docs(keycloak-session-store): import the session-storage lab as a new project

The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-inbound-graphql-c03
title: 미배선 인터셉터는 누락이 아니라 중복이다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-graphql-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-graphql-c03
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c03.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-graphql-c03.txt
source:
- 원본 분석 절은 analysis/16-adapter-inbound-graphql.md#L336 이다.
module: adapter-inbound-graphql
---
# 미배선 인터셉터는 누락이 아니라 중복이다
`GraphQlOperationNameInterceptor.apply(...)`(미배선)와 `runtime/GraphQlOperationSelectionHandler`(autoconf=2, 배선됨)가 같은 일을 하는데 배선된 쪽이 더 많이 한다.
## 본문
<!-- body:start -->
`GraphQlOperationNameInterceptor.apply(...)`(미배선)와 `runtime/GraphQlOperationSelectionHandler`(autoconf=2, 배선됨)가 같은 일을 한다. **배선된 쪽이 더 많이 한다**: 익명 연산 거부 · 다중 연산 시 `operationName` 요구 · 연산 정체성 정규화가 전부 배선된 경로에 있다.
## 정규화가 거부가 아니라 익명으로 떨어지는 이유
규칙에 근거가 붙어 있다 — "any name that cannot survive normalisation becomes the anonymous identity rather than being rejected — **a naming convention is not a reason to refuse an otherwise valid request**."
## GraphQlOperationNamePolicy 참조 위치
:::evidence key="adapter-inbound-graphql-c03" alt="코드베이스에서 GraphQlOperationNamePolicy 를 검색한 출력 16줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlOperationNamePolicy 코드베이스 검색 — 16줄 · exit 0" zoom="true"
:::
## 정책 객체까지 함께 미배선이다
따라서 미배선 인터셉터는 **누락이 아니라 중복**이다. 다만 그것이 쓰는 `GraphQlOperationNamePolicy`(85줄, 참조자 = 인터셉터와 자기 자신뿐)도 함께 미배선이고, 배선된 핸들러는 다른 정책 객체를 쓴다. §12.2.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: adapter-inbound-graphql-c06
title: 매니페스트 조회를 설계했는데 단일 빈이 대신 주입된다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-graphql-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-graphql-c06
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c06.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-graphql-c06.txt
source:
- 원본 분석 절은 analysis/16-adapter-inbound-graphql.md#L471 이다.
module: adapter-inbound-graphql
---
# 매니페스트 조회를 설계했는데 단일 빈이 대신 주입된다
설계는 `GraphQlClientPolicyManifest`에서 프로파일을 해석하는 것을 말하지만, 자동설정은 `GraphQlClientPolicy.defaults(...)` 단일 빈을 만들어 여덟 개 빈에 주입한다. 매니페스트는 만들어지지 않는다.
## 본문
<!-- body:start -->
설계는 매니페스트 조회를 말한다.
> `GraphQlClientPolicyManifest` — "The design keeps benchmarked limits in an environment manifest rather than in application code, so **this is the one place a profile is resolved from**. An unknown profile is a startup or request failure rather than a silent fallback to a permissive default."
자동설정은 단일 빈을 만든다.
```java
// GraphQlPlatformAutoConfiguration:302-303
@Bean public GraphQlClientPolicy graphQlClientPolicy(GraphQlPlatformSettings properties) {
return GraphQlClientPolicy.defaults(...);
}
```
그리고 그 하나가 여덟 개 빈(`:123` · `:237` · `:380` · `:389` · `:397` · `:453` · `:463` …)에 주입된다. 매니페스트는 만들어지지 않는다. §16.2.
## GraphQlPlatformWebInterceptor 참조 위치
:::evidence key="adapter-inbound-graphql-c06" alt="코드베이스에서 GraphQlPlatformWebInterceptor 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlPlatformWebInterceptor 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## 프로파일은 여전히 신뢰된 경로에서 온다
**프로파일 자체는 신뢰된 경로에서 온다**`GraphQlAuthenticationContextFactory:59``principal.clientProfile()`을 쓰고(검증된 principal), 미인증 호출자에는 `GraphQlPlatformWebInterceptor``anonymousProfile`이 붙는다. 즉 `GraphQlClientProfileResolver`가 막으려는 노출(호출자가 자기 프로파일을 지정)은 배선된 경로에서도 발생하지 않는다. 그 타입은 중복이다.
<!-- body:end -->
@@ -0,0 +1,61 @@
---
kind: CONCEPT
slug: adapter-inbound-graphql-c09
title: 선언한 전송 프로파일과 실제 응답을 만드는 쪽이 다르다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-graphql-c09
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-graphql-c09
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c09.svg
- key: adapter-inbound-graphql-c09-diagram
file: ../../../final/assets/diagrams/adapter-inbound-graphql-c09.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-graphql-c09.txt
source:
- 원본 분석 절은 analysis/16-adapter-inbound-graphql.md#L660 이다.
module: adapter-inbound-graphql
---
# 선언한 전송 프로파일과 실제 응답을 만드는 쪽이 다르다
`http` 패키지 19개 파일 중 자동설정이 값으로 소비하는 둘을 빼면 나머지는 실행되지 않는다. 실제로 응답을 만드는 것은 Spring GraphQL이다.
## 본문
<!-- body:start -->
`http` 패키지 19개 파일 중 자동설정이 값으로 소비하는 둘(`GraphQlHttpProfile` autoconf=2, `GraphQlJsonStructurePolicy` autoconf=4)을 빼면, 나머지는 실행되지 않는다.
## 응답을 실제로 만드는 쪽
:::evidence key="adapter-inbound-graphql-c09-diagram" alt="Spring GraphQL 경계 안에 상태 코드 규칙과 미디어 타입 협상이 들어 있고 http 패키지가 경계 밖에 빗금 상자로 놓인 구조" caption="응답을 실제로 만드는 쪽" zoom="false"
:::
GraphQL-over-HTTP에서 상태 코드 규칙은 미디어 타입에 달려 있다 — `application/json`은 실행 오류에도 200을, `application/graphql-response+json`은 실제 상태를 쓴다. 그 규칙을 `GraphQlHttpStatusMapper``GraphQlAcceptHeader`가 담고 있고, 실제로 응답을 만드는 것은 Spring GraphQL이다.
## GraphQlHttpProfile 참조 위치
:::evidence key="adapter-inbound-graphql-c09" alt="코드베이스에서 GraphQlHttpProfile 를 검색한 출력 30줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlHttpProfile 코드베이스 검색 — 30줄 · exit 0" zoom="true"
:::
## 노출이 아니라 통제권의 문제다
Spring GraphQL 자신이 GraphQL-over-HTTP 스펙을 구현하므로 동작은 합리적이다. 잃는 것은 (a) 이 플랫폼이 선언한 프로파일(`V1`)이 실제 동작과 일치한다는 보장, (b) 사전 파싱 한계 중 봉투 검증기에만 있는 부분, (c) "새 결과 종류가 임의 상태를 갖고 한 호출 지점에 생기는 것"을 막겠다는 단일 팩토리의 목적.
## 운영자가 문서대로 클라이언트를 쓸 때
운영자가 `GraphQlPlatformConfigurationReport`(§8.1을 고쳐 발행하게 된 뒤)에서 `httpProfile=V1`을 읽고 그 프로파일 문서대로 클라이언트를 작성한다. 실제 응답 상태와 미디어 타입은 Spring GraphQL이 정하며, 두 문서가 다른 지점에서 클라이언트가 깨진다.
## 두 갈래 권고
(a) 프레임워크 전송을 정본으로 인정하고 `http` 패키지에서 전송 기계를 제거한 뒤 `GraphQlHttpProfile`을 프레임워크 동작의 서술로 좁힌다. (b) `WebGraphQlInterceptor`(`GraphQlPlatformWebInterceptor`가 이미 그 자리에 있다)에서 봉투 검증과 응답 정책을 적용해 프로파일을 실제로 강제한다. 지금은 선언과 실행이 분리돼 있다.
## 무엇이 미배선인가
`GraphQlAcceptHeader`(151), `GraphQlRequestEnvelopeValidator`(152), `GraphQlHttpResponseFactory`(84), `GraphQlMediaTypes`(100), 그리고 봉투·결과·확장 정책 타입 470줄이 실행되지 않는다.
<!-- body:end -->
@@ -0,0 +1,54 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c02
title: 실패를 보여 준 적 없는 경계 테스트는 잘못된 디렉터리를 스캔한 것과 구별되지 않는다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c02
file: ../../../final/evidence/rendered/adapter-inbound-web-c02.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c02.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L140 이다.
module: adapter-inbound-web
---
# 실패를 보여 준 적 없는 경계 테스트는 잘못된 디렉터리를 스캔한 것과 구별되지 않는다
`WebModuleBoundaryTest`가 다섯 개의 긍정 규칙마다 부정 픽스처를 붙이고, 스캔이 아무것도 못 찾으면 통과가 아니라 실패하도록 만들었다.
## 본문
<!-- body:start -->
`WebModuleBoundaryTest`가 다섯 개의 긍정 규칙과 **네 개의 부정 픽스처**를 갖는다.
| 규칙 | 부정 픽스처 |
|---|---|
| 모든 프로덕션 패키지가 선언된 모듈 정체성을 가진다 | `ROOT.undeclared` 패키지를 만들어 거부되는지 확인 |
| 선언된 모든 모듈이 트리에 존재한다 | — |
| 모든 교차 모듈 import가 선언된 edge다 | `conditional → ratelimit` 위반을 만들어 확인 |
| CORE 모듈은 프레임워크 자유다 | `cursor``@Component`를 import하게 만들어 확인 |
| 스캔이 아무것도 못 찾으면 통과가 아니라 실패다 | 빈 디렉터리로 `IllegalStateException` 확인 |
## 부정 픽스처가 있는 이유
"A boundary test that has never been shown to fail is indistinguishable from one that scans the wrong directory." 그리고 프로덕션 스캔에 `fileCount() > 100` 하한과 `packages()`에 특정 패키지 두 개가 있어야 한다는 확인이 함께 붙는다.
## WebModuleBoundaryTest 참조 위치
:::evidence key="adapter-inbound-web-c02" alt="코드베이스에서 WebModuleBoundaryTest 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="WebModuleBoundaryTest 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 탐지 정규식에 Jackson 두 버전이 함께 있는 이유
"this repository runs on Spring 7, whose message converters take Jackson 3 — so a CORE module could have imported a mapper without this detector noticing, which is **a hole in exactly the check that is supposed to have none**."
이것은 이 저장소에서 확인한 경계 강제 중 가장 강하다. notification의 `EndpointGuardCallSiteTest`(호출처 목록이 가드 javadoc과 달랐던)와 달리, 여기서는 목록 자체가 스캔으로 생성된다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c12
title: compileOnly로 막았지만 컨버터를 등록하는 코드도 없다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c12
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c12
file: ../../../final/evidence/rendered/adapter-inbound-web-c12.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c12.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L986 이다.
module: adapter-inbound-web
---
# compileOnly로 막았지만 컨버터를 등록하는 코드도 없다
`build.gradle`이 XML·CBOR 백엔드를 `compileOnly`로 두는 이유는 명확하고 의도된 설계다. 그런데 배포가 그 백엔드를 추가하더라도 메시지 컨버터를 등록하는 코드가 없다.
## 본문
<!-- body:start -->
`build.gradle`이 두 백엔드를 `compileOnly`로 두고 그 이유를 길게 적는다(§2) — `implementation`이었을 때 "silently began parsing `application/xml` request bodies... an XXE surface nobody chose"였기 때문이다. 의도된 설계다.
## WebXmlMapperFactory 참조 위치
:::evidence key="adapter-inbound-web-c12" alt="코드베이스에서 WebXmlMapperFactory 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="WebXmlMapperFactory 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 백엔드를 추가해도 등록하는 코드가 없다
그 잭슨 백엔드를 배포가 추가하더라도 메시지 컨버터를 등록하는 코드가 없다. `WebXmlMapperFactory`·`WebCborMapperFactory`·`RepresentationNegotiationPolicy`를 참조하는 파일은 자기 패키지와 테스트뿐이고, 두 자동설정(MVC 12빈 · WebFlux 11빈)에도 없다. `WebRepresentation.available()`이 "absent backend를 문장으로 바꾼다"는 장치는 그 문장을 낼 호출자가 없다.
<!-- body:end -->
@@ -0,0 +1,50 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c13
title: 나중에 도는 필터가 클라이언트 값으로 덮어쓴다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c13
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c13
file: ../../../final/evidence/rendered/adapter-inbound-web-c13.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c13.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L1071 이다.
module: adapter-inbound-web
---
# 나중에 도는 필터가 클라이언트 값으로 덮어쓴다
두 필터 모두 서블릿 배포에서 등록되고 둘 다 `X-Request-Id` 응답 헤더를 쓰는데, 신뢰 정책이 반대다.
## 본문
<!-- body:start -->
두 필터 모두 서블릿 배포에서 등록되고 둘 다 `X-Request-Id` 응답 헤더를 쓰는데, 신뢰 정책이 반대다.
```java
// mvc/filter/WebMvcRequestIdFilter.java:105-112 (기본 trustInboundRequestId = false)
private WebRequestId resolveRequestId(HttpServletRequest request) {
if (!trustInboundRequestId) {
return new WebRequestId(UUID.randomUUID().toString()); // 클라이언트 값을 보지 않는다
}
return sanitized(request.getHeader(REQUEST_ID_HEADER)) ...
}
```
## WebMvcRequestIdFilter 참조 위치
:::evidence key="adapter-inbound-web-c13" alt="코드베이스에서 WebMvcRequestIdFilter 를 검색한 출력 27줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="WebMvcRequestIdFilter 코드베이스 검색 — 27줄 · exit 0" zoom="true"
:::
## 순서가 만드는 결과
순서상 `WebMvcRequestIdFilter`(`HIGHEST_PRECEDENCE + 10`)가 먼저 돌아 새 UUID를 헤더에 쓰고, `RequestLoggingFilter`(`LOWEST_PRECEDENCE`)가 나중에 돌아 **클라이언트가 보낸 값으로 덮어쓴다**. MDC의 `request_id`와 접근 로그도 클라이언트 값이다. §32.1.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: adapter-inbound-websocket-c01
title: 세 설정 접두사 중 하나에 소비자가 없다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-websocket-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-websocket-c01
file: ../../../final/evidence/rendered/adapter-inbound-websocket-c01.svg
- key: adapter-inbound-websocket-c01-diagram
file: ../../../final/assets/diagrams/adapter-inbound-websocket-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-websocket-c01.txt
source:
- 원본 분석 절은 analysis/17-adapter-inbound-websocket.md#L24 이다.
module: adapter-inbound-websocket
---
# 세 설정 접두사 중 하나에 소비자가 없다
`META-INF` 자동설정 리소스가 없고 조립은 전적으로 컴포넌트 스캔에 달려 있는데, 스캔이 잡을 수 있는 Spring 애노테이션을 가진 파일이 169개 중 7개다. 그리고 세 설정 접두사 중 하나에는 그것을 읽어 조립하는 `@Configuration`이 없다.
## 본문
<!-- body:start -->
여섯 소스셋(inbound-web과 같은 형태)이고 `META-INF` 자동설정 리소스가 **없다**. 조립은 전적으로 컴포넌트 스캔에 달려 있으며, 컴포지션 루트는 이 leaf를 스캔에서 제외하지 **않는다**(graphql과 반대). 그런데 스캔이 잡을 수 있는 Spring 애노테이션을 가진 파일이 **169개 중 7개**다.
```text
config/WebSocketPlatformSettings.java @ConfigurationProperties(prefix = "backend.websocket")
stomp/WebSocketProperties.java @ConfigurationProperties(prefix = "ca-skeleton.websocket")
stomp/WebSocketConfig.java @Configuration + @ConditionalOnProperty("ca-skeleton.websocket.enabled")
advanced/sockjs/SockJsConfiguration.java @Configuration + prefix "app.websocket-platform.advanced.sockjs"
advanced/stomp/StompConfiguration.java @Configuration + prefix "app.websocket-platform.advanced.stomp"
advanced/stomp/StompDefaultsConfiguration.java @Configuration + prefix "app.websocket-platform.advanced.stomp"
advanced/stomp/rabbit/RabbitBrokerRelayConfiguration.java prefix "app.websocket-platform.advanced.stomp.relay"
```
## 설정 접두사와 소비자
:::evidence key="adapter-inbound-websocket-c01-diagram" alt="설정 접두사에서 두 Configuration 으로만 화살표가 가고, backend.websocket 은 읽는 Configuration 없음 이라고 이름 붙은 별도 영역 안에 화살표 없이 놓인다" caption="설정 접두사와 소비자" zoom="false"
:::
세 번째가 이 모듈의 핵심 사실이다. `backend.websocket` 네임스페이스가 규정하는 "플랫폼"이 main 169 파일 중 약 90개를 차지하고, 그것을 조립하는 `@Configuration`이 하나도 없다.
## 분석 원문의 접두사 표
:::evidence key="adapter-inbound-websocket-c01" alt="분석 문서 analysis/17-adapter-inbound-websocket.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/17-adapter-inbound-websocket.md 발췌 — 15줄" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c01
title: 꺼져 있을 때 아무것도 기여하지 않는다는 말이 문자 그대로다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c01
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c01.svg
- key: adapter-outbound-cache-redis-c01-diagram
file: ../../../final/assets/diagrams/adapter-outbound-cache-redis-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c01.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L85 이다.
module: adapter-outbound-cache-redis
---
# 꺼져 있을 때 아무것도 기여하지 않는다는 말이 문자 그대로다
`RedisSdkSettings`가 애플리케이션 전역 `@ConfigurationPropertiesScan`이 아니라 `RedisSdkAutoConfiguration``@Bean`으로만 존재한다. 그래서 스위치가 꺼져 있으면 속성이 묶이지도 않는다.
## 본문
<!-- body:start -->
`RedisSdkAutoConfiguration`의 javadoc이 규칙을 적는다.
> "`app.redis.enabled` is the whole switch. While it is false this class contributes nothing, and because `RedisSdkSettings` is registered here rather than by the application-wide `@ConfigurationPropertiesScan`, **'contributes nothing' is literal**: the properties are not bound, the cross-field rules are not run, no credential is resolved, and no policy resource, TLS material, client, connection or thread is created."
## 조립이 고정된 순서
:::evidence key="adapter-outbound-cache-redis-c01-diagram" alt="설정 바인딩과 교차 필드 검증과 클라이언트 생성이 왼쪽에서 오른쪽으로 이어지고 화살표에 bound settings 와 검증 통과가 붙은 구조" caption="조립이 고정된 순서" zoom="false"
:::
`RedisSdkSettings``@ConfigurationPropertiesScan` 대상이 아니라 이 클래스의 `@Bean` + `@ConfigurationProperties`로만 존재한다. 그래서 Redis를 쓰지 않는 배포는 Redis 설정을 들고 다니지 않고, **켠 적 없는 잘못된 Redis 설정 때문에 벌을 받지도 않는다**. test가 그 넷을 이름으로 고정한다 — `absentSwitchRegistersNothing`, `disabledRegistersNothing`, `disabledIgnoresMalformedRedisConfiguration`, `disabledNeverAsksForASecretOrAConnection`.
## RedisSdkAutoConfiguration 참조 위치
:::evidence key="adapter-outbound-cache-redis-c01" alt="코드베이스에서 RedisSdkAutoConfiguration 를 검색한 출력 30줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RedisSdkAutoConfiguration 코드베이스 검색 — 30줄 · exit 0" zoom="true"
:::
## 검증이 bean factory 메서드 안에 있는 이유
순서도 bind → validate → build로 고정된다. 검증이 `@PostConstruct`나 리스너가 아니라 **bean factory 메서드 안**에 있어서, 설정 오류가 "그 bean을 만들지 못했다"는 실패로 보고되고 그 아래 어떤 것도 검증되지 않은 settings를 잡을 수 없다. 그리고 `redisSdkSettingsValidation`이 별도 bean인 이유도 적혀 있다 — Spring은 factory 메서드가 **반환한 뒤에** binder를 돌리므로 검증이 `redisSdkSettings()` 안에 있을 수 없다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c02
title: 기본값이 가리키는 리소스가 없고 그것이 시작 실패로 잡힌다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c02
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c02.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c02.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L107 이다.
module: adapter-outbound-cache-redis
---
# 기본값이 가리키는 리소스가 없고 그것이 시작 실패로 잡힌다
`RedisSdkSettings.Raw.policyResource` 기본값 `classpath:redis-sdk/raw-command-allowlist.yml`은 저장소에 없는 파일이다. 결함이 아니라 이미 잡혀 있는 함정이다.
## 본문
<!-- body:start -->
`RedisSdkSettings.Raw.policyResource` 기본값은 `classpath:redis-sdk/raw-command-allowlist.yml`인데, 저장소에 그 파일은 **없다**(`159-...` §8.3b, `git ls-files` 매치 0. 이 leaf의 main resource는 `AutoConfiguration.imports``redis-sdk/redis-command-policy.yml` 둘뿐).
## 결함이 아니라 이미 잡혀 있는 함정이다
`requireRawPolicyResource`가 그 사실과 과거 증상을 함께 적는다.
> "`validate()` only checks that the setting is non-blank, and the default points at … a resource this module does not ship. So enabling the raw gateway passed configuration validation and then **failed at the first raw command, from inside a request, against a live connection.** The allowlist is the entire authorisation model for that gateway; not being able to read it is a startup failure."
test `enabledRejectsAMissingRawAllowlistResource``enabledAcceptsAReadableRawAllowlistResource`가 양쪽을 고정한다.
## 분석 원문의 확인 절차
:::evidence key="adapter-outbound-cache-redis-c02" alt="분석 문서 analysis/10-adapter-outbound-cache-redis.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/10-adapter-outbound-cache-redis.md 발췌 — 15줄" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-fileserver-c01
title: 적재는 import filter가 아니라 명시적 component scan이다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-fileserver-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-fileserver-c01
file: ../../../final/evidence/rendered/adapter-outbound-fileserver-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-fileserver-c01.txt
source:
- 원본 분석 절은 analysis/08-adapter-outbound-fileserver.md#L127 이다.
module: adapter-outbound-fileserver
---
# 적재는 import filter가 아니라 명시적 component scan이다
이 leaf에는 `AutoConfiguration.imports`가 없다. 실제 적재는 `CaSkeletonApplication`의 명시적 `@ComponentScan`이 하고, bean 생성만 `@ConditionalOnProperty`로 막힌다.
## 본문
<!-- body:start -->
이 leaf에는 `META-INF/spring/…AutoConfiguration.imports`**없다**(`142-...` §8.1). `FileExportConfig`/`FileserverR2Config`를 leaf 밖에서 이름으로 참조하는 production 코드도 없고, 유일한 외부 참조는 app-bootstrap의 test(`OptionalAdapterBeanGatingTest`)다.
## FileExportConfig 참조 위치
:::evidence key="adapter-outbound-fileserver-c01" alt="코드베이스에서 FileExportConfig 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="FileExportConfig 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
## 실제 적재 경로
`CaSkeletonApplication`의 명시적 `@ComponentScan``dev.caskeleton.adapter.outbound.fileserver`를 목록에 올려서 이루어진다(`:75`). 즉 CLAUDE.md의 "never activates unexpectedly when merely present on the classpath"는 **classpath 존재만으로 bean이 생기지 않는다**는 뜻으로는 정확하지만, 기전은 import filter가 아니라 "@Configuration은 스캔되고 bean 생성만 `@ConditionalOnProperty`로 막힌다"이다.
## 기전이 다른 것을 기록해 두는 이유
fail-closed는 성립한다 — 기록해 두는 이유는 mongo leaf의 4중 opt-in(§sub-scope 01, 06번 문서)과 기전이 다르기 때문이다.
<!-- body:end -->
@@ -0,0 +1,63 @@
---
kind: CONCEPT
slug: adapter-outbound-notification-c01
title: 이름 없던 상태에 이름을 붙인 세 자리
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-notification-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-notification-c01
file: ../../../final/evidence/rendered/adapter-outbound-notification-c01.svg
- key: adapter-outbound-notification-c01-diagram
file: ../../../final/assets/diagrams/adapter-outbound-notification-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-notification-c01.txt
source:
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L84 이다.
module: adapter-outbound-notification
---
# 이름 없던 상태에 이름을 붙인 세 자리
세 클래스가 각각 이전에는 구분되지 않던 두 상황을 구분한다 — 공급자가 없는 플랫폼, 알 수 없는 공급자 타입, 그리고 능력별로 필요한 비밀 키.
## 본문
<!-- body:start -->
세 클래스가 각각 이전에는 **구분되지 않던 두 상황**을 구분한다.
## 이름 없던 상태에 이름 붙이기
:::evidence key="adapter-outbound-notification-c01-diagram" alt="이전과 지금 두 열에 세 상태가 같은 높이로 놓이고 이전 쪽 세 상자만 빗금으로 표시된 구조" caption="이름 없던 상태에 이름 붙이기" zoom="false"
:::
**`NotificationPlatformMode`** — 공급자가 하나도 조립되지 않은 플랫폼이 공급자가 있는 플랫폼과 똑같이 보였다.
> "A platform with no assembled provider used to look identical to one with providers: the same beans, the same scheduler, the same readiness. **Requests were accepted durably and then sat in the queue with no eligible route.** Naming the state makes it a decision an operator takes rather than a situation they discover."
`INGEST_ONLY`**명시적으로 선택해야** 하고("A deployment that reaches zero providers by accident is a misconfiguration, and the whole point of this enum is that the two are told apart"), `NotificationProviderAssembly:183`이 그것을 강제한다 — 경로가 비었는데 모드가 `INGEST_ONLY`가 아니면 조립을 거부하고 메시지로 그 모드를 안내한다.
## NotificationPlatformMode 참조 위치
:::evidence key="adapter-outbound-notification-c01" alt="코드베이스에서 NotificationPlatformMode 를 검색한 출력 23줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="NotificationPlatformMode 코드베이스 검색 — 23줄 · exit 0" zoom="true"
:::
app-bootstrap 쪽에서도 `NotificationPlatformWorkerConfig`가 "everything that starts a thread, and therefore everything `INGEST_ONLY` must not have"를 그 모드로 가른다.
## 자유 문자열이던 타입이 닫힌 enum이 됐다
**`ProviderType`** — 설정이 타입을 자유 문자열로 날랐고 "the only thing that read it was a" 비교였다. 지금은 닫힌 enum이라 알 수 없는 타입이 startup 바인딩 실패가 되고 채널도 타입에서 유도된다.
## 여덟 키를 항상 요구하던 것이 잘못된 방향이었던 이유
**`NotificationSecretRequirements`** — 이전에는 여덟 개 키를 **항상** 요구했다.
> "That is **fail-closed in the wrong direction**: it made every deployment provision and rotate keys for capabilities it had switched off — a Web Push signing key for a platform with no Web Push profile… and **a key that exists but is never used is a key nobody notices leaking.** It also made the eight look equally load-bearing."
지금은 네 개(`CONTACT_ENCRYPTION`·`CONTACT_LOOKUP_HMAC`·`PAYLOAD_ENCRYPTION`·`PROVIDER_REQUEST_LOOKUP_HMAC`)가 모든 모드에 필요하고 — **수용 경로**에 있으므로 `INGEST_ONLY`에서도 필요하다 — 나머지 넷은 능력을 따라간다. 약해지면 안 되는 방향은 명시된다: "a capability that is switched *on* and whose key is missing still refuses the boot, because the alternative is discovering it on a user's notification."
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-notification-c02
title: 같은 종료 절차를 쓰지만 대상이 다르다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-notification-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-notification-c02
file: ../../../final/evidence/rendered/adapter-outbound-notification-c02.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-notification-c02.txt
source:
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L306 이다.
module: adapter-outbound-notification
---
# 같은 종료 절차를 쓰지만 대상이 다르다
`NotificationSchedulerWorker.close``NotificationBackgroundWorkers.close` 둘 다 취소 → shutdown → awaitTermination(grace) → shutdownNow를 수행한다. 형태는 같지만 대상이 다르다.
## 본문
<!-- body:start -->
`NotificationSchedulerWorker.close``NotificationBackgroundWorkers.close` 둘 다 "취소 → shutdown → awaitTermination(grace) → shutdownNow"를 수행한다. 형태는 같지만 대상이 다르다(폴링 스레드 + virtual-thread executor 대 단일 데몬 scheduler). 중복 아님.
## NotificationSchedulerWorker 참조 위치
:::evidence key="adapter-outbound-notification-c02" alt="코드베이스에서 NotificationSchedulerWorker 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="NotificationSchedulerWorker 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
## 인터럽트가 닿지 않는 한 지점
스케줄러의 `close()`는 폴링 스레드를 `interrupt()`하지만(`:173`), `runOnce``globalConcurrency.acquireUninterruptibly()`(`:90`)는 인터럽트에 반응하지 않는다. 주석(`:169`)은 "인터럽트가 poll-interval sleep을 깬다"고만 말하고 그 점은 정확하다.
## 그래도 결함으로 보지 않은 이유
세마포어는 in-flight 작업이 `finally`에서 반납하므로 결국 풀리고, 최악의 경우 `join(shutdownGrace)`가 만료된 뒤 종료가 계속된다. 결함 아님.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c56
title: 항상 설치되는 스캔이 opt-in package를 끌고 오지 않는다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c56
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c56
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c56.svg
- key: adapter-outbound-persistence-jpa-c56-diagram
file: ../../../final/assets/diagrams/adapter-outbound-persistence-jpa-c56.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c56.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L4143 이다.
module: adapter-outbound-persistence-jpa
---
# 항상 설치되는 스캔이 opt-in package를 끌고 오지 않는다
`PersistenceJpaConfig`는 persistence root를 통째로 스캔하지 않고 20개 package를 열거한다. opt-in 두 개는 각자의 조건부 configuration이 자기 package만 스캔한다.
## 본문
<!-- body:start -->
`PersistenceJpaConfig`는 persistence root를 통째로 스캔하지 않고 20개 package를 열거한다. 빠진 것은 `config`, `h2`(JPA stereotype 없음)와 opt-in 두 개(`notification`, `fileserver`)다. 두 opt-in은 각자의 `@ConditionalOnProperty` configuration이 자기 package만 스캔한다.
## 두 스캔을 가르는 선
:::evidence key="adapter-outbound-persistence-jpa-c56-diagram" alt="PersistenceJpaConfig 경계 안에 열거된 스무 개 package 가 들어 있고 notification 과 fileserver 가 경계 밖 점선 상자로 놓인 구조" caption="두 스캔을 가르는 선" zoom="false"
:::
이 배치의 이유는 javadoc과 `PersistenceEntityScanCoverageTest`에 기록돼 있다 — 과거에 root를 스캔해서 capability를 끈 배포가 `ddl-auto=validate`에서 `notification_request` / `fs_cleanup_item`을 요구하며 부팅에 실패했다.
## PersistenceJpaConfig 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c56" alt="코드베이스에서 PersistenceJpaConfig 를 검색한 출력 18줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="PersistenceJpaConfig 코드베이스 검색 — 18줄 · exit 0" zoom="true"
:::
## 측정 결과 경계가 유지되고 있다
`@Entity` 25개 중 opt-in package(`notification` 13, `fileserver` 6) 밖의 4개는 `idempotency_record`, `outbox_event`, `live_event_log`, `durable_operation`이고, 이 네 테이블은 모두 default location `db/migration/postgresql`(V1/V3/V11/V12)이 만든다. `postgresql` package는 scan 대상이지만 그 안의 candidate adapter들(`inbox`, `outbox` v2, `idempotency` v2)은 `@Entity`가 아니라 native SQL 기반이라 persistence unit에 들어오지 않는다.
## 커버리지 테스트가 한쪽만 막는다
opt-in configuration 두 개에 대해서는 `@EntityScan` 목록과 `@EnableJpaRepositories` 목록이 **정확히 같은지** `containsExactly`로 검사한다("entities without repositories is half a scan, and fails at the first query"). 그런데 always-install `PersistenceJpaConfig`에 대해서는 `@EntityScan` 목록만 읽어 디스크와 대조하고, 두 목록의 일치는 검사하지 않는다. 현재 두 목록은 20개로 동일하다.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-mongo-c01
title: 네 겹이 같은 스위치를 읽고 각각 다른 실패를 막는다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-mongo-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-mongo-c01
file: ../../../final/evidence/rendered/adapter-outbound-persistence-mongo-c01.svg
- key: adapter-outbound-persistence-mongo-c01-diagram
file: ../../../final/assets/diagrams/adapter-outbound-persistence-mongo-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-mongo-c01.txt
source:
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L98 이다.
module: adapter-outbound-persistence-mongo
---
# 네 겹이 같은 스위치를 읽고 각각 다른 실패를 막는다
opt-in이 네 겹이고 전부 `ca-skeleton.persistence-mongo.enabled=true`를 읽는다. 중복이 아니라 계층별 차단이다 — filter는 Boot의 후보군을, 나머지 셋은 자기 bean 그래프를 담당한다.
## 본문
<!-- body:start -->
네 겹 모두 `ca-skeleton.persistence-mongo.enabled=true`라는 같은 조건을 읽는다(`evidence/raw/123-...` §8.2). 이것은 중복이 아니라 계층별 차단이다: filter는 Boot의 후보군, 나머지 셋은 자기 bean 그래프를 담당한다.
## 옵트인을 이루는 네 겹
:::evidence key="adapter-outbound-persistence-mongo-c01-diagram" alt="import filter 와 root 자동설정과 infrastructure 설정과 platform 자동설정이 위에서 아래로 쌓이고 같은 스위치 화살표가 아래로 그려진 구조" caption="옵트인을 이루는 네 겹" zoom="false"
:::
Mongo starter는 classpath만으로 auto-configuration 후보를 등록하고 project condition은 후보 선정 **뒤에** 평가되므로, 후보 단계에서 9개 Boot Mongo auto-configuration을 빼지 않으면 평범한 `@EnableAutoConfiguration` 앱이 client와 template을 만든다. `MongoPersistenceConfig``@ImportAutoConfiguration`**명시적** import라 `spring.autoconfigure.exclude`의 영향을 받지 않는다.
## MongoPersistenceConfigTest 참조 위치
:::evidence key="adapter-outbound-persistence-mongo-c01" alt="코드베이스에서 MongoPersistenceConfigTest 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MongoPersistenceConfigTest 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
`MongoPersistenceConfigTest`가 실제 `@EnableAutoConfiguration` context로 default/false에서 `MongoClient`·`MongoTemplate` 부재를, `enabled=true` + mock client에서 `MongoTemplate` 단일 bean을 확인한다.
## 남은 미연결의 성격이 다른 이유
`MongoPlatformAutoConfiguration`(443줄)은 이 leaf에서 가장 밀도가 높은 파일이고, 거의 모든 `@Bean`의 javadoc이 **과거에 "shipped했지만 아무 configuration도 만들지 않던" 경로**를 기록한다 — atomic/bulk template, reactive 실행 경로 일체, change-stream source와 consumer, startup validator, client generation registry, health indicator. 이 leaf는 그 미연결들을 한 번 훑어 고친 이력을 갖고 있고, 그 사실이 이 sub-scope의 판단 기준을 바꾼다: 남아 있는 미연결은 "아직 안 한 것"이 아니라 "훑고도 남은 것"이다.
## 조건이 곧 탈출구가 되지 않게 하는 장치
`mongoPlatformStartupCheck``MongoTopologyProbe` bean이 있을 때만 돌지만, 그 조건이 곧 탈출구가 되는 것을 막기 위해 `mongoTopologyProbeRequirement`**probe 조건 없이** 등록되어 "platform profile이 있는데 probe가 없으면" 실패시킨다. javadoc이 그 이유를 한 줄로 적는다 — "a requirement that only applies when the thing it requires is present is not a requirement".
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-mongo-c08
title: phase를 code table 위에 두는 순서까지 논증돼 있다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-mongo-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-mongo-c08
file: ../../../final/evidence/rendered/adapter-outbound-persistence-mongo-c08.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-mongo-c08.txt
source:
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L1142 이다.
module: adapter-outbound-persistence-mongo
---
# phase를 code table 위에 두는 순서까지 논증돼 있다
`MongoFailureClassifier``MongoFailureTranslator`는 auto-configuration의 실제 bean이고 imperative·reactive 두 executor가 모두 그것을 통해 번역한다. 규칙 사슬은 label → phase → 적용 가능성 → code table → fail closed다.
## 본문
<!-- body:start -->
`MongoFailureClassifier``MongoFailureTranslator`는 auto-configuration의 실제 bean이고(`MongoPlatformAutoConfiguration:85·92`), imperative·reactive 두 executor가 모두 그것을 통해 번역한다. 규칙 사슬도 순서까지 논증돼 있다 — **label → phase → 적용 가능성 → code table → fail closed**.
> Phase sits above the code table because a failure that never reached a server is safe to repeat whatever code accompanies it, and a commit failure is unsafe to replay whatever code accompanies it — **both were decided by the code table before, and the code table knows neither.**
## MongoFailureClassifier 참조 위치
:::evidence key="adapter-outbound-persistence-mongo-c08" alt="코드베이스에서 MongoFailureClassifier 를 검색한 출력 31줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MongoFailureClassifier 코드베이스 검색 — 31줄 · exit 0" zoom="true"
:::
## 고쳐진 결함 네 가지
- **번역기가 phase를 버렸다.** `DefaultMongoFailureTranslator`가 operationType을 들고도 context-free overload를 불러서, "FIND의 응답 유실 = 재현 가능한 읽기 / UPDATE의 같은 유실 = 결과 불명 쓰기"라는 구분이 **transaction이 아닌 모든 경로에서** 버려졌다 — 즉 모든 평범한 연산에서. 실패한 읽기가 ambiguous write로 보고됐다.
- **server-selection이 terminal이었다.** label도 code도 없는 실패가 `UNCLASSIFIED`로 떨어져 재시도 불가로 처리됐다 — 재시도가 명백히 안전한 유일한 경우인데.
- **Spring 래핑이 분류를 통째로 건너뛰었다.** `MongoFailureExtractor`가 그 수리다. cause 사슬을 깊이 16까지, `IdentityHashMap`으로 순환 안전하게 탐색한다("a cycle is about the same object appearing twice").
- **message는 절대 읽지 않는다.** `MongoDriverFailureView`가 driver 예외를 label·code·boolean 둘로 좁히는 지점이고, 그 이후 어느 계층도 나머지에 닿을 수 없다 — "no later layer can reach the rest, because no later layer is ever handed it".
## 생성자가 조합을 좁히는 지점
`MongoFailureClassification`의 생성자가 `COMMIT_ONLY``TRANSACTION_COMMIT_UNKNOWN`에만 허용하는 것도 §15의 불변식과 맞물린다.
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: adapter-outbound-support-c01
title: 허용된 의존과 실제 의존이 다르다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-support-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-support-c01
file: ../../../final/evidence/rendered/adapter-outbound-support-c01.svg
- key: adapter-outbound-support-c01-diagram
file: ../../../final/assets/diagrams/adapter-outbound-support-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-support-c01.txt
source:
- 원본 분석 절은 analysis/04-adapter-outbound-support.md#L52 이다.
module: adapter-outbound-support
---
# 허용된 의존과 실제 의존이 다르다
`adapter-outbound-support`의 프로덕션 표면은 타입 셋뿐이다. 레지스트리가 허용하는 project dependency는 셋이지만 실제 project dependency는 0개다.
## 본문
<!-- body:start -->
`adapter-outbound-support`는 application port를 구현하는 하나의 기술 adapter라기보다 **여러 outbound adapter가 공유할 수 있는 기술적 보조 seam**이다.
## 이 리프의 프로덕션 표면
:::evidence key="adapter-outbound-support-c01-diagram" alt="리프 경계 안에 상관 식별자 조회와 fail-open 로거와 기본 bean 설정 세 상자가 나란히 들어 있는 구조" caption="이 리프의 프로덕션 표면" zoom="false"
:::
`OutboundCorrelation`은 SLF4J MDC에서 `correlation_id`를 조회하고 값이 없거나 blank면 `"unknown"`을 반환한다. `FailOpenDependencyLogger`는 optional/fail-open outbound 호출의 success/failure observation을 공통 포맷으로 기록한다 — success는 DEBUG, failure는 WARN이다. `OutboundSupportConfig``FailOpenDependencyLogger` default bean을 제공하고, `@ConditionalOnMissingBean`으로 fork/application이 같은 타입을 override할 수 있게 한다.
## OutboundCorrelation 참조 위치
:::evidence key="adapter-outbound-support-c01" alt="코드베이스에서 OutboundCorrelation 를 검색한 출력 15줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboundCorrelation 코드베이스 검색 — 15줄 · exit 0" zoom="true"
:::
## 허용된 의존과 실제 의존
`src/config/architecture/modules.json`은 support leaf가 `domain-core`·`application-core`·`shared-contract`를 project dependency로 **허용**한다. 그러나 현재 `build.gradle`과 fresh `compileClasspath` 결과를 보면 실제 project dependency는 **0개**이고, 실제 compile dependency는 `spring-boot-autoconfigure` 4.0.8과 `slf4j-api` 2.0.18뿐이다.
즉 registry의 `allowed_dependencies`는 가능한 최대 경계를 나타내고, 현재 source graph가 그 edge를 모두 사용한다는 뜻이 아니다. support는 현 snapshot에서 domain/application/shared 타입과도 결합하지 않는다.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: grpc-advanced-bootstrap-c02
title: 세 조건을 하나의 boolean으로 접지 않는다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-advanced-bootstrap-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-bootstrap-c02
file: ../../../final/evidence/rendered/grpc-advanced-bootstrap-c02.svg
- key: grpc-advanced-bootstrap-c02-diagram
file: ../../../final/assets/diagrams/grpc-advanced-bootstrap-c02.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-bootstrap-c02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-bootstrap.md#L76 이다.
module: grpc-advanced-bootstrap
---
# 세 조건을 하나의 boolean으로 접지 않는다
게이트가 깃발 설정 여부, 등급의 시작 가능 여부, 운영 별도 승인을 순서대로 따로 본다. 접으면 처방이 서로 다른 세 상황이 같은 메시지를 받는다.
## 본문
<!-- body:start -->
게이트가 세 조건을 순서대로 본다.
```java
if (!flags.flagSet(capability)) "its feature flag is not set"
if (!grade.startable()) "it is graded WATCH, which cannot start"
if (production && requiresApproval && !approved) "production needs a separate approval"
```
> "Collapsing them into one boolean produces a 'not enabled' message for three situations with three different remedies."
## 게이트가 보는 세 조건
:::evidence key="grpc-advanced-bootstrap-c02-diagram" alt="깃발 설정 여부와 등급의 시작 가능 여부와 운영 별도 승인이 왼쪽에서 오른쪽으로 이어지는 구조" caption="게이트가 보는 세 조건" zoom="false"
:::
## 이 기록이 다루는 파일 범위
:::evidence key="grpc-advanced-bootstrap-c02" alt="코드베이스에서 파일 목록을 만든 출력 9줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 9줄 · exit 0" zoom="true"
:::
## 같은 불변식의 런타임 확인은 실행되지 않는다
`requireStableStarterIsClean` 이 같은 불변식을 런타임에서도 확인한다 — 팻 자, 셰이드 산출물, 테스트 하네스처럼 다른 방식으로 조립된 런타임을 위해서다. **다만 그 메서드를 부르는 런타임이 없다**(§12.1). 지금 그 검사를 실행하는 것은 이 리프의 자기 테스트뿐이다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: grpc-observability-c02
title: 네 타입을 쓰지만 그 관측을 만드는 코드가 없다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-observability-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-observability-c02
file: ../../../final/evidence/rendered/grpc-observability-c02.svg
evidence:
- ../../../final/evidence/raw/grpc-observability-c02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-observability.md#L59 이다.
module: grpc-observability
---
# 네 타입을 쓰지만 그 관측을 만드는 코드가 없다
`grpc-core-api`에서 쓰는 타입은 `GrpcMethodName`, `GrpcStatusCode`, `RpcType`, `GrpcCompletionOutcome` 넷이고 전부 `GrpcRpcObservation`의 record 성분이다. 그런데 그 관측을 만드는 production 코드가 없다.
## 본문
<!-- body:start -->
`grpc-core-api` 에서 쓰는 타입은 넷이다 — `GrpcMethodName`, `GrpcStatusCode`, `RpcType`, `GrpcCompletionOutcome`. 네 타입 모두 `GrpcRpcObservation` 의 record 성분이다. `GrpcStreamObservation``GrpcMethodName` 하나만 쓴다.
## GrpcMethodName 참조 위치
:::evidence key="grpc-observability-c02" alt="코드베이스에서 GrpcMethodName 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcMethodName 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
## 관측을 만드는 production 코드가 없다
배선 없음(`EVD-325`). `runtime_memberships` 가 비어 있고, 저장소 어디에서도 `new GrpcObservationConvention(...)` 을 만드는 production 코드가 없다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-admin-runtime-c02
title: 선언한 여섯 의존 중 셋이 import 0건이다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-runtime-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-c02
file: ../../../final/evidence/rendered/messaging-admin-runtime-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L78 이다.
module: messaging-admin-runtime
---
# 선언한 여섯 의존 중 셋이 import 0건이다
`build.gradle`이 여섯 project를 `api`로 선언하는데 실측 import는 셋이 0건이다. 지금까지 본 리프 중 가장 많다.
## 본문
<!-- body:start -->
`build.gradle`이 여섯 project를 `api`로 선언하는데 실측 import(`EVD-308`)는 셋이 0건이다. 지금까지 본 리프 중 가장 많다.
| 선언 | 패키지 | import | 판정 |
|---|---|---:|---|
| `messaging-admin-api` | `…messaging.admin` | 45 | O |
| `messaging-core-api` | `…messaging.api` | 6 | O |
| `messaging-observability` | `…messaging.observation` | 1 | O |
| `messaging-policy` | `…messaging.policy` | **0** | X |
| `messaging-transport-spi` | `…messaging.transport` | **0** | X |
| `messaging-security` | `…messaging.security` | **0** | X |
## MessagingAdminService 참조 위치
:::evidence key="messaging-admin-runtime-c02" alt="코드베이스에서 MessagingAdminService 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingAdminService 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 빈이 되는 것은 둘뿐이다
배선은 starter 한 곳뿐이고, 이 리프에서 빈이 되는 것은 **둘**이다(`EVD-307`). `MessagingAdminService`, `ReplayService`, `RedriveService`, `DestructiveMessagingAdmin` — 넷 다 빈이 없다. starter 는 그중 하나에 대해서만 이유를 밝힌다. 나머지 셋의 부재에 대한 설명은 어디에도 없다.
<!-- body:end -->
@@ -0,0 +1,70 @@
---
kind: CONCEPT
slug: messaging-kafka-share-experimental-c01
title: 이 리프의 실질은 세 가지 거절이다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-kafka-share-experimental-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-share-experimental-c01
file: ../../../final/evidence/rendered/messaging-kafka-share-experimental-c01.svg
- key: messaging-kafka-share-experimental-c01-diagram
file: ../../../final/assets/diagrams/messaging-kafka-share-experimental-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-share-experimental-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka-share-experimental.md#L54 이다.
module: messaging-kafka-share-experimental
---
# 이 리프의 실질은 세 가지 거절이다
Kafka Share Group(KIP-932, 경쟁 소비자 work queue)을 실험적 어댑터로 감싼다. 190줄 중 실제 동작을 하는 코드는 거의 없고 세 가지를 거절한다.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다**
같은 분석 리프에서 끌어낸 규칙이다.
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
Kafka **Share Group**(KIP-932, 경쟁 소비자 work queue)을 실험적 어댑터로 감싼다. `runtime_memberships: []`이고 이름 자체가 `-experimental`이다. 이 leaf의 실질은 **거절**이다 — 190줄 중 실제 동작을 하는 코드는 거의 없고, 세 가지를 거절한다.
| 거절 | 코드 | 이유 |
|---|---|---|
| 비활성 상태의 사용 | `KAFKA_SHARE_DISABLED` | experimental이 기본 켜지지 않게 |
| 순서 보장 목적지 | `IllegalArgumentException` | share group이 순서를 줄 수 없음 |
| pause/resume | `KAFKA_SHARE_NO_PAUSE`/`_NO_RESUME` | 일시정지할 파티션 할당이 없음 |
## 이 리프가 실제로 하는 일
:::evidence key="messaging-kafka-share-experimental-c01-diagram" alt="리프 경계 안에 프로파일 검증기와 능력 선언이 들어 있고 소비자 구성이 경계 밖 빗금 상자로 놓인 구조" caption="이 리프가 실제로 하는 일" zoom="false"
:::
## 순서를 낮춰 주지 않고 거절하는 이유
핵심 진술이 validator javadoc에 있다.
> "A share group hands individual records to competing consumers and acknowledges them individually. That is a work queue, and it is fundamentally incompatible with partition ordering: two consumers in the same share group can process records from one partition concurrently and finish in either order. Configuring an ordered destination on a share group would therefore advertise a guarantee the broker is not providing, so it is refused rather than degraded."
## 기본 꺼짐이 drift 방지 수단이다
두 번째 문단이 이 저장소의 experimental 정책을 한 문장으로 담는다 — "The adapter is also off unless explicitly enabled, so an Experimental capability cannot drift into a Stable deployment by default."
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-kafka-share-experimental-c01" alt="코드베이스에서 파일 목록을 만든 출력 4줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 4줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: messaging-kafka-share-experimental-c02
title: 소비자 없음과 membership 없음과 조립 없음이 서로 맞는다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-kafka-share-experimental-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-share-experimental-c02
file: ../../../final/evidence/rendered/messaging-kafka-share-experimental-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-share-experimental-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka-share-experimental.md#L84 이다.
module: messaging-kafka-share-experimental
---
# 소비자 없음과 membership 없음과 조립 없음이 서로 맞는다
나가는 의존이 하나도 없고 `runtime_memberships`가 비어 있으며 Spring 주석이 0개다. incubating leaf의 올바른 상태다.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다**
같은 분석 리프에서 끌어낸 규칙이다.
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
들어오는 것(project): `messaging-core-api`, `messaging-policy`, `messaging-transport-spi`, `messaging-kafka` — 넷 다 `api`. 들어오는 것(vendor): `org.apache.kafka:kafka-clients`(`implementation`) — **어떤 소스도 import하지 않는다**(§12.4). 나가는 것: **없다.** 어떤 leaf의 `allowed_dependencies`에도 이 leaf가 없고 starter 목록에도 없다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-kafka-share-experimental-c02" alt="코드베이스에서 파일 목록을 만든 출력 4줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 4줄 · exit 0" zoom="true"
:::
## 세 가지 부재가 서로 맞는다
런타임 배선: 없음. `runtime_memberships: []`. bean 없음(Spring 주석 0개). **소비자 없음·membership 없음·조립 없음의 삼중 정합**이다 — `messaging-schema-avro`·`messaging-schema-protobuf`와 같은 형태이고, incubating leaf의 올바른 상태다.
## 선언했지만 쓰이지 않는 의존
네 project 의존 중 둘, 벤더 의존 하나가 미사용이다. §17.
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: messaging-nats-experimental-c02
title: 검증기가 불리지 않는 지금 실제로 도는 게이트는 생성자다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-nats-experimental-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-nats-experimental-c02
file: ../../../final/evidence/rendered/messaging-nats-experimental-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-nats-experimental-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-nats-experimental.md#L94 이다.
module: messaging-nats-experimental
---
# 검증기가 불리지 않는 지금 실제로 도는 게이트는 생성자다
`NatsJetStreamProfile`은 record이고 압축 생성자가 이 어댑터의 불변식을 전부 들고 있다. 검증기가 호출되지 않는 지금 실제로 실행되는 유일한 게이트가 거기다.
## 본문
<!-- body:start -->
`NatsJetStreamProfile` 은 record 이고, 압축 생성자가 이 어댑터의 불변식을 전부 들고 있다. 검증기가 호출되지 않는 지금, **실제로 실행되는 유일한 게이트가 여기다.**
## 거부 사유를 enum이 문장으로 들고 있다
`ackMode` 거부 사유는 `NatsAckMode` 자신이 문장으로 들고 있고(`rejectionReason()`), 프로파일이 그 문장을 예외 메시지에 그대로 싣는다. `NONE` 은 "forgotten", `ALL` 은 "still in flight" — 테스트가 그 두 낱말로 각각 걸어 잠근다.
## NatsJetStreamProfile 참조 위치
:::evidence key="messaging-nats-experimental-c02" alt="코드베이스에서 NatsJetStreamProfile 를 검색한 출력 17줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="NatsJetStreamProfile 코드베이스 검색 — 17줄 · exit 0" zoom="true"
:::
## 임계값의 정의가 한 곳에만 있다
`PARKING_HEADROOM = 1` 상수와 `parkAtDelivery() = maxDeliver - PARKING_HEADROOM` 가 §2 의 시점 선택을 숫자로 못 박는다. `NatsMaxDeliverParkingWorkflow.parkingThreshold()` 는 이 값을 그대로 위임한다 — 임계값의 정의가 한 곳에만 있다.
## 팩토리가 안전하다는 사실이 구멍을 닫지 않는다
**주의할 비대칭.** 편의 팩토리 `durable(subject, stream, durableName)` 는 중복 제거 창을 `Optional.of(2분)` 으로 채운다. 즉 팩토리를 거친 프로파일은 §17.1 의 구멍에 빠지지 않는다. 그러나 팩토리에도 호출자가 없고(저장소 전역 grep 0건), 정규 생성자는 빈 창을 정상값으로 받는다.
<!-- body:end -->
@@ -0,0 +1,59 @@
---
kind: CONCEPT
slug: messaging-runtime-core-c07
title: 이 리프는 통째로 하나의 수정이다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-runtime-core-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-runtime-core-c07
file: ../../../final/evidence/rendered/messaging-runtime-core-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-runtime-core-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-runtime-core.md#L631 이다.
module: messaging-runtime-core
---
# 이 리프는 통째로 하나의 수정이다
MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다. 이 리프가 채운 것은 기능이 아니라 조립의 빈칸이다.
## 관계
- **만들어 두고 흘리지 않는 진단값은 진단이 아니다**
같은 분석 리프에서 끌어낸 규칙이다.
- **증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다**
같은 분석 리프에서 끌어낸 규칙이다.
- **`CompletionStage`를 반환하는 메서드는 동기적으로 던지지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
이 leaf는 **통째로 하나의 수정**이다. MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다(`DeclaredDestinationAccess`, `TransportMessagingRuntime`, `MessagingCoreAutoConfiguration:461`).
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `build.gradle` 주석 | `MessagePublisher` 구현 없음 | 자동설정이 없는 bean 위에 DLQ·facade bean을 쌓음 |
| `TransportMessagingRuntime` javadoc | `MessagingRuntime` 구현 없음 | registry가 빈 채로 만들어져 모든 발행이 `PUBLISH_RUNTIME_UNAVAILABLE` — 목적지 해석·접근 확인·인코딩을 **전부 마친 뒤에** |
| `DestinationProfileRegistry` javadoc | 논리 이름→프로파일 해석 없음 | 어댑터는 해석된 프로파일을 받는데 그것을 만들 publisher가 없었음 |
| `DefaultDeliveryProcessor` javadoc | `HandleResult`→정산 연결 없음 | 각 어댑터가 retry/dead-letter의 뜻을 각자 결정 |
| `withDeadline` javadoc | transport가 마감을 무시 | 확인이 오지 않는 Rabbit publish에 마감이 없어 호출자 스레드가 완료 불가능한 stage에 묶임 |
## DeclaredDestinationAccess 참조 위치
:::evidence key="messaging-runtime-core-c07" alt="코드베이스에서 DeclaredDestinationAccess 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DeclaredDestinationAccess 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 가짜로 빈칸을 채우면 안 되는 이유
`build.gradle` 주석의 마지막 문장이 이 leaf 전체의 교훈이다 — "A starter that filled the gap with an application-supplied fake would pass a context test while running none of them."
<!-- body:end -->
@@ -0,0 +1,62 @@
---
kind: CONCEPT
slug: messaging-security-c02
title: 이 리프는 messaging family에서 배선이 가장 잘 된 축이다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-security-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-security-c02
file: ../../../final/evidence/rendered/messaging-security-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-security-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-security.md#L91 이다.
module: messaging-security
---
# 이 리프는 messaging family에서 배선이 가장 잘 된 축이다
들어오는 것은 `messaging-core-api` 하나이고 나가는 것은 일곱이다. 어댑터 두 곳이 직접 소비하고, 이 리프 자체는 Spring 주석을 갖지 않는다.
## 관계
- **배선된 게이트는 자기 leaf 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 술어가 두 타입에 있으면 하나가 다른 하나를 부른다**
같은 분석 리프에서 끌어낸 규칙이다.
- **가변 필드로 상태 전이를 표현하면 가시성을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **맵 갱신 함수 안에서 I/O를 하면 그 지연이 락 범위가 된다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
들어오는 것: `messaging-core-api`(api) 하나. 나가는 것: `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`.
## 실제로 소비하는 곳
| 소비자 | 무엇을 쓰는가 |
|---|---|
| `messaging-kafka/KafkaSecurityConfigurer` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry`, `CredentialProvider` |
| `messaging-rabbit/RabbitSecurityConfigurer` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry` |
| `messaging-runtime-core/DefaultMessagePublisher` | `DestinationAccessPolicy` |
| `messaging-runtime-core/DeclaredDestinationAccess` | `DestinationAccessPolicy` |
| starter `MessagingCoreAutoConfiguration` | `MessageSecurityValidator`·`BrokerTlsPolicy`·`CredentialRuntimeRegistry` bean |
| starter `MessagingCredentialRequirementValidator` | `CredentialProvider` |
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-security-c02" alt="코드베이스에서 파일 목록을 만든 출력 12줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 12줄 · exit 0" zoom="true"
:::
## 리프 자체는 프레임워크를 모른다
이 leaf 자체는 Spring 주석을 갖지 않는다. 배선은 전부 소비자 쪽에서 이루어진다.
<!-- body:end -->
@@ -0,0 +1,63 @@
---
kind: CONCEPT
slug: messaging-spring-boot-starter-c01
title: 등록되어 있다는 것과 조립할 수 있다는 것을 분리했다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-spring-boot-starter-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-spring-boot-starter-c01
file: ../../../final/evidence/rendered/messaging-spring-boot-starter-c01.svg
- key: messaging-spring-boot-starter-c01-diagram
file: ../../../final/assets/diagrams/messaging-spring-boot-starter-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-spring-boot-starter-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-boot-starter.md#L94 이다.
module: messaging-spring-boot-starter
---
# 등록되어 있다는 것과 조립할 수 있다는 것을 분리했다
`MessagingProviderSelection`에 지도가 셋이고 셋째 지도가 이 클래스의 판단이다. 오늘 조립 가능한 전송은 `kafka` 하나다.
## 관계
- **검증기는 발행이 아니라 주입이 강제다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`MessagingProviderSelection` 에 지도가 셋이다.
```java
REGISTERED_BROKERS = {kafka: org.apache.kafka.clients.producer.Producer,
rabbit: com.rabbitmq.client.Channel}
PROVIDER_CONFIGURATIONS = {kafka: KafkaMessagingAutoConfiguration,
rabbit: RabbitMessagingAutoConfiguration}
BROKERS_WITHOUT_A_TRANSPORT = {rabbit: "…ships its validators and security configuration but no
MessagingTransport…"}
```
## 등록과 조립의 분리
:::evidence key="messaging-spring-boot-starter-c01-diagram" alt="선택 레지스트리에서 kafka 와 rabbit 으로 화살표가 나가고 rabbit 상자만 빗금으로 표시된 구조" caption="등록과 조립의 분리" zoom="false"
:::
셋째 지도가 이 클래스의 판단이다. 등록되어 있다는 것과 조립할 수 있다는 것을 분리했고, 그 이유를 적었다 — Rabbit 을 고르면 핵심 설정 깊은 곳에서 `MessagingTransport` 빈이 없다는 오류가 나는데, 그것은 운영자에게 빈이 없다고만 말하지 고른 전송이 완성되지 않았다고는 말하지 않는다.
## MessagingProviderSelection 참조 위치
:::evidence key="messaging-spring-boot-starter-c01" alt="코드베이스에서 MessagingProviderSelection 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingProviderSelection 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## 오늘 조립 가능한 전송은 하나다
결과로 오늘 조립 가능한 전송은 `kafka` 하나다. `RabbitMessagingAutoConfiguration` 98줄은 선택 단계에서 거부되므로 **어떤 경로로도 도달하지 않는다**(§12.3).
<!-- body:end -->
@@ -0,0 +1,65 @@
---
kind: CONCEPT
slug: messaging-spring-cloud-stream-bridge-c01
title: 플랫폼 보장과 헷갈릴 만큼 닮은 것이 이 리프의 위협 모델이다
topic: composition-and-lifecycle-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-spring-cloud-stream-bridge-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-spring-cloud-stream-bridge-c01
file: ../../../final/evidence/rendered/messaging-spring-cloud-stream-bridge-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-spring-cloud-stream-bridge-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-cloud-stream-bridge.md#L56 이다.
module: messaging-spring-cloud-stream-bridge
---
# 플랫폼 보장과 헷갈릴 만큼 닮은 것이 이 리프의 위협 모델이다
Spring Cloud Stream 바인딩을 이미 쓰는 서비스가 같은 논리 목적지에 닿게 하는 상호운용 seam이다. 바인더 의미론을 플랫폼 보장으로 승격하지 않는다.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **등록을 받는 컴포넌트는 해제도 제공한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **함께 읽히는 두 맵은 한 값으로 묶는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **한 개념의 등록 상태를 두 객체가 나눠 갖지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
Spring Cloud Stream 바인딩을 이미 쓰는 서비스가 같은 논리 목적지에 닿게 하는 **상호운용 seam**이다.
> "The bridge is an interoperability seam, not a second messaging API… Binder semantics are never promoted to platform guarantees. Stream's binder has its own retry, its own dead-letter, and its own acknowledgement mode, and they look enough like the platform's to be mistaken for them — so a destination that actually relies on the platform's versions is refused by `StreamBridgePolicyGuard` rather than served with the binder's."
**"look enough like the platform's to be mistaken for them"**이 이 leaf 전체의 위협 모델이다.
## 차이를 드러내는 세 층
브리지는 기능을 추가하지 않고 **차이를 드러낸다.**
| 층 | 무엇을 |
|---|---|
| `StreamBridgePolicyGuard` | 플랫폼 보장에 의존하는 목적지를 아예 거절 |
| `BindingProfileValidator` | 바인더 확장 속성이 프로파일 결정을 덮는 것을 거절 |
| `BindingCapabilityReport` | 남은 차이를 **문장으로** 기록 |
세 번째가 특이하다 — 거절할 수 없는 차이를 문서화 가능한 값으로 만든다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-spring-cloud-stream-bridge-c01" alt="코드베이스에서 파일 목록을 만든 출력 6줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 6줄 · exit 0" zoom="true"
:::
<!-- body:end -->