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,253 @@
---
kind: CASE
slug: a12-f001-check-verifyjsonschemaruntimegraph
title: 매 PR 을 막는 게이트가 초록일 수 없다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a12-f001-check-verifyjsonschemaruntimegraph
evidenceCapturedOn: 2026-09-02
body: case-a12-f001-check-verifyjsonschemaruntimegraph.body.md
assets:
- key: a12-f001-check-verifyjsonschemaruntimegraph
file: ../../../final/evidence/rendered/a12-f001-check-verifyjsonschemaruntimegraph.svg
- key: a12-f001-check-verifyjsonschemaruntimegraph-run
file: ../../../final/evidence/rendered/a12-f001-check-verifyjsonschemaruntimegraph-run.svg
evidence:
- ../../../final/evidence/raw/a12-f001-check-verifyjsonschemaruntimegraph.txt
- ../../../final/evidence/raw/a12-f001-check-verifyjsonschemaruntimegraph-run.txt
source:
- 원본 분석 절은 analysis/12-adapter-outbound-messaging.md#L82 이다. 등급은 P2 이다. 이 리프의 중심 규율을 강제하는 태스크가 검사 단계에 붙어 있다는 지형, 실행하면 실패한다는 관측, 필수 좌표에 정확한 패치 판본이 하드코딩되어 있고 잠긴 좌표는 다르다는 대조, BOM 이 올라가면서 두 좌표가 어긋났다는 원인 지목, 금지 조건 쪽은 여전히 옳다는 확인, 그리고 수정 방향 둘이 그 절에 있다.
- 이 기록이 더한 것은 넷이다. 하드코딩은 처음부터 틀린 것이 아니었고, 목록을 쓴 커밋 시점에는 잠금 파일과 일치했다. 883 파일을 건드린 커밋이 잠금 파일만 올리고 이 빌드 파일을 지나갔다. 이 태스크는 상위 자격 태스크 둘의 선행이기도 하지만 그 둘은 어떤 워크플로도 부르지 않고, 매 PR 마다 실제로 도는 것은 품질 게이트 잡의 검사 명령이며 그 잡은 릴리스 게이트의 필수 선행이다. 그리고 잠금 파일이 움직이기 여드레 전 같은 검사가 성공한 기록이 저장소에 남아 있다.
- 그래서 등급을 P1 로 올린다. 원본이 판단한 시점의 관찰은 게이트가 통과할 수 없다는 것까지였고, 그 게이트가 차단하는 범위와 그 상태가 이어진 기간은 이 기록에서 확인했다.
---
# 매 PR 을 막는 게이트가 초록일 수 없다
이 리프의 중심 규율을 강제하는 태스크가 검사 단계에 붙어 있고, CI 의 품질 게이트 잡이 매 PR 마다 그 검사를 부른다. 실행하면 종료 코드 1 이다. 필수 좌표에 패치 판본이 하드코딩되어 있는데, 그 판본은 태스크를 쓸 당시에는 맞았고 그 뒤 잠금 파일만 올라갔다.
## 관계
- **release gate가 실제로 차단하는 것은 hermetic test 3개이고, mongo용 CI workflow는 없다**
릴리스 게이트가 실제로 차단하는 대상을 센 문서다.
- **durable-operation 게이트는 켤 수 없고 켜면 부팅이 실패한다**
두 게이트 모두 조건이 성립할 수 없어 통과 상태가 될 수 없다.
- **없다고 적은 라이브러리로 옆 파일이 봉투를 만든다**
같은 리프에서 같은 커밋이 만든 사례다.
## 문제
이 리프의 중심 규율은 JSON 검증 런타임을 닫는 것이다.
이 규율을 지키는 태스크는 검사 단계에만 붙어 있는 것이 아니라 CI 가 매 PR 마다 부르는 검사 안에 들어 있다.
그래서 실행했다.
## 결론
실패한다. 필수로 잠긴 모듈 하나가 없다는 이유로 종료 코드 1 을 낸다.
태스크가 요구하는 좌표는 셋이고 전부 판본이 3.0.2 다. 잠금 파일이 고정한 것은 검증기 라이브러리만 3.0.2 이고 직렬화 계열 셋은 3.1.5 다.
하드코딩이 처음부터 틀렸던 것은 아니다. 이 목록을 쓴 커밋 시점의 잠금 파일에는 검증기와 직렬화 계열이 모두 3.0.2 로 잡혀 있었다.
그 뒤 883 파일을 건드린 커밋이 잠금 파일의 직렬화 계열 세 좌표를 3.1.5 로 올렸다. 태스크가 이름으로 요구하는 둘이 그 안에 있다. 그 커밋은 같은 판본을 적어 둔 빌드 파일을 건드리지 않았다.
닿는 범위가 검사 단계 하나가 아니다. 상위 자격 태스크 둘도 이 태스크를 선행으로 걸지만, 그 둘을 부르는 워크플로가 없다. 실제로 매 PR 과 main 푸시마다 이 태스크를 돌리는 것은 품질 게이트 잡의 검사 명령이고, 그 잡은 릴리스 게이트의 필수 선행이다.
여드레 전에는 초록이었다. 저장소에 남은 실행 기록에 같은 검사 명령이 이 태스크를 돌고 성공으로 끝난 로그가 있다.
금지 조건 쪽은 걸리는 것이 0 이다. 다만 그 0 은 이 검사가 잡아낸 결과라기보다 설정 블록이 형식 계열 세 모듈을 미리 빼 둔 결과다. 그 위에 구세대 이름공간이 통째로 빠진 것도 아니다. 애너테이션 아티팩트가 남아 있고, 태스크는 그것을 금지 목록에서 의도적으로 뺀다.
판정을 P2 에서 올린다.
원본은 게이트 전체가 통과할 수 없다는 관찰로 P2 를 매겼다. 그 게이트가 매 PR 을 막는 필수 잡 안에 있고, 그 상태가 특정 날짜부터 이어지고 있으며, 그 전에 초록이던 기록이 저장소에 남아 있다. 상시 빨간 차단 게이트는 P1 이다.
수정은 필수 좌표에서 판본을 떼고 그룹과 이름만 확인하거나, 잠금 파일에서 판본을 읽어 비교하는 것이다. 닫힘 조건은 무엇이 없는가이지 어느 패치인가가 아니다.
## 검증 환경
Gradle : 9.0.0
확인 방식 : 태스크 실행, 검사 단계 예행 실행, 잠금 파일과 커밋 이력 조회
소스 수정 : x
비고 : 해결이 STRICT 의존성 잠금에 고정되어 있어 오프라인 여부가 판본을 바꾸지 않는다
## 재현 조건
1. 검증 태스크를 단독으로 실행하고 종료 코드와 메시지를 본다.
2. 검사 단계를 예행 실행해 그 태스크에 닿는지 확인한다.
3. 태스크가 요구하는 좌표 셋을 빌드 파일에서 읽는다.
4. 잠금 파일이 고정한 같은 그룹의 좌표를 뽑는다.
5. 이 목록을 쓴 커밋을 찾아 그 시점의 잠금 파일 값을 확인한다.
6. 잠금 파일의 판본을 올린 커밋을 찾아, 그 커밋의 잠금 파일 차이와 이 빌드 파일 포함 여부를 본다.
7. 이 태스크 이름이 나오는 곳을 확장자 제한 없이 세고, 검사 단계를 부르는 워크플로와 상위 자격 태스크를 부르는 워크플로를 각각 센다.
8. 그 전에 이 태스크가 초록이던 실행 기록이 저장소에 있는지 찾는다.
## 본문
<!-- body:start -->
이 리프의 규율을 지키는 태스크 하나가 검사 단계에 붙어 있고, CI 의 품질 게이트 잡은 그 검사를 매 PR 마다 부른다.
## 태스크가 하는 일
:::evidence key="a12-f001-check-verifyjsonschemaruntimegraph" alt="검증 태스크의 정의 전체와 그 앞의 설정 제외 블록을 줄 번호와 함께, 이 태스크 이름이 저장소에서 나오는 곳 전부, CI 가 검사 단계를 부르는 줄과 릴리스 게이트의 필수 선행 목록, 상위 자격 태스크를 부르는 워크플로 수, 그리고 금지 목록에서 의도적으로 빠진 구세대 이름공간 아티팩트를 출력한 터미널 기록." caption="설정 블록이 형식 계열 세 모듈을 미리 빼고, 태스크는 그 뒤 필수 좌표 셋을 판본까지 확인한다 · CI 는 매 PR 과 main 푸시마다 검사 단계를 부르고 그 잡은 릴리스 게이트의 필수 선행이다 · 상위 자격 태스크를 부르는 워크플로는 0 · 구세대 이름공간의 애너테이션 아티팩트는 그래프에 남아 있다 — 71줄 · exit 0" zoom="true"
:::
```groovy
50: [
51: 'com.networknt:json-schema-validator:3.0.2',
52: 'tools.jackson.core:jackson-core:3.0.2',
53: 'tools.jackson.core:jackson-databind:3.0.2'
54: ].each { String required ->
55: if (!modules.contains(required)) {
56: throw new GradleException(
57: "Messaging JSON runtime is missing required locked module ${required}")
58: }
59: }
60: // Jackson 3 intentionally retains the 2.x-namespace annotations artifact. It is not a
61: // Jackson 2 databind/runtime engine and is part of the official Jackson 3 BOM graph.
62: }
63:}
64:
65:tasks.named('check') {
66: dependsOn tasks.named('verifyJsonSchemaRuntimeGraph')
67:}
```
## 돌려 보면
:::evidence key="a12-f001-check-verifyjsonschemaruntimegraph-run" alt="검증 태스크를 그대로 실행한 결과와 종료 코드, 검사 단계 예행 실행이 그 태스크에 닿는지, 태스크가 요구하는 좌표와 잠금 파일이 고정한 좌표, 이 목록을 쓴 커밋 시점의 잠금 값, 잠금 파일의 판본을 올린 커밋과 그 커밋의 잠금 파일 차이 및 이 빌드 파일 포함 여부, 그리고 그 전에 이 태스크가 초록이던 실행 기록을 출력한 터미널 기록." caption="태스크는 필수 모듈 하나가 없다며 종료 코드 1 로 끝나고, 검사 단계 예행 실행이 그 태스크에 닿는다 · 요구 판본은 셋 다 3.0.2 인데 잠금 파일의 직렬화 계열 셋은 3.1.5 · 883 파일 커밋이 그 셋을 올리면서 빌드 파일은 건드리지 않았다 · 여드레 전 실행 기록에는 같은 검사가 성공으로 끝나 있다 — 49줄 · exit 0" zoom="true"
:::
```text
[check 에 붙은 태스크를 그대로 실행]
Execution failed for task ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph'.
> Messaging JSON runtime is missing required locked module tools.jackson.core:jackson-core:3.0.2
EXIT=1
```
요구 판본 셋 중 둘이 어긋난다.
```text
[태스크가 요구하는 좌표]
com.networknt:json-schema-validator:3.0.2
tools.jackson.core:jackson-core:3.0.2
tools.jackson.core:jackson-databind:3.0.2
[잠금 파일이 고정한 좌표]
com.networknt:json-schema-validator:3.0.2
tools.jackson.core:jackson-core:3.1.5
tools.jackson.core:jackson-databind:3.1.5
tools.jackson:jackson-bom:3.1.5
```
검증기 라이브러리만 맞는다. 직렬화 계열은 BOM 을 포함해 셋이 3.1.5 로 올라가 있다.
## 처음부터 틀렸던 것은 아니다
```text
[태스크를 쓴 시점의 잠금 파일]
e5af2912 2026-07-31 feat: add messaging R2 polling producer
com.networknt:json-schema-validator:3.0.2
tools.jackson.core:jackson-core:3.0.2
tools.jackson.core:jackson-databind:3.0.2
tools.jackson:jackson-bom:3.0.2
```
이 목록을 쓴 커밋 시점에는 넷이 모두 3.0.2 였다.
```text
[잠금 파일을 움직인 커밋]
a24ece9c 2026-08-28 feat: web, websocket 어댑터 추가 구현
-com.fasterxml.jackson.core:jackson-annotations:2.20
+com.fasterxml.jackson.core:jackson-annotations:2.21
-tools.jackson.core:jackson-core:3.0.2
-tools.jackson.core:jackson-databind:3.0.2
-tools.jackson:jackson-bom:3.0.2
+tools.jackson.core:jackson-core:3.1.5
+tools.jackson.core:jackson-databind:3.1.5
+tools.jackson:jackson-bom:3.1.5
그 커밋이 건드린 파일 수 : 883
그중 messaging/build.gradle : 0 건
```
883 파일을 건드린 커밋이 BOM 을 3.1.5 로 올리면서 그것이 끌고 오는 두 좌표를 함께 올렸고, 같은 판본을 적어 둔 이 빌드 파일은 지나갔다.
## 어디까지 막는가
```text
src/build.gradle:850: dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph'
src/build.gradle:892: dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph'
src/adapter/outbound/messaging/README.md:139:Gradle dependency lock과 `verifyJsonSchemaRuntimeGraph`가 담당한다. 이 검증은 business schema
src/adapter/outbound/messaging/build.gradle:28:tasks.register('verifyJsonSchemaRuntimeGraph') {
src/adapter/outbound/messaging/build.gradle:66: dependsOn tasks.named('verifyJsonSchemaRuntimeGraph')
src/adapter/outbound/messaging/CLAUDE.md:55: `CodeSource` is a regular JAR. Strict dependency locks and `verifyJsonSchemaRuntimeGraph` own the
```
850 과 892 가 상위 자격 태스크 둘이다. 그런데 그 둘을 부르는 워크플로가 없다.
```text
verifyMessagingJsonSchemaV1 / verifyMessagingContracts 가 .github 아래 나오는 줄 : 0
```
실제로 도는 것은 66 쪽이다.
```text
50: run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --warning-mode=fail --no-daemon --stacktrace
release-gate:
needs:
- quality-gates
- sample-off
```
품질 게이트 잡이 매 PR 과 main 푸시마다 검사 단계를 부르고, 릴리스 게이트가 그 잡을 필수 선행으로 건다. 예행 실행이 그 경로를 보여 준다.
```text
[CI 가 부르는 check 가 이 태스크에 닿는가]
:adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph SKIPPED
:adapter:outbound:messaging:check SKIPPED
```
## 여드레 전에는 초록이었다
```text
[그 전에 초록이던 기록]
run-at: 2026-08-20T01:53:16Z
775:> Task :adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph
899:BUILD SUCCESSFUL in 5m 42s
901:exit=0
```
저장소에 커밋된 실행 기록이다. 잠금 파일이 움직이기 여드레 전, 같은 검사 명령이 이 태스크를 돌고 성공으로 끝났다.
## 금지 조건 쪽
걸리는 것은 0 이다. 다만 그 0 을 만드는 것의 상당 부분은 검사가 아니라 그 앞의 제외 블록이다.
```groovy
22:configurations.configureEach {
23: exclude group: 'tools.jackson.dataformat', module: 'jackson-dataformat-yaml'
24: exclude group: 'org.yaml', module: 'snakeyaml'
25: exclude group: 'org.snakeyaml', module: 'snakeyaml-engine'
26:}
```
금지 조건의 네 갈래 중 둘이 여기서 미리 제거된 모듈을 찾는다.
구세대 이름공간이 그래프에서 사라진 것도 아니다.
```text
8:com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
```
60~61 줄 주석이 이 아티팩트를 금지 목록에서 뺀 이유를 적는다. 닫힌 그래프 안에 구세대 이름공간 하나가 설계대로 남아 있다.
## 확인하지 못한 것
판본을 떼는 수정을 적용해 태스크가 초록이 되는지 확인하지 않았다. 문서 작업 범위에서 소스를 고치지 않는다.
두 커밋 사이에 같은 두 파일을 건드린 커밋이 둘 더 있다. 그 둘은 필수 목록과 직렬화 계열 잠금 줄을 손대지 않았다.
<!-- body:end -->
@@ -0,0 +1,223 @@
---
kind: CASE
slug: a12-f002-jackson-databind
title: 근거를 없앤 커밋이 README 를 열고 그 줄만 두었다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a12-f002-jackson-databind
body: case-a12-f002-jackson-databind.body.md
evidenceCapturedOn: 2026-09-02
assets:
- key: a12-f002-jackson-databind
file: ../../../final/evidence/rendered/a12-f002-jackson-databind.svg
- key: a12-f002-jackson-databind-history
file: ../../../final/evidence/rendered/a12-f002-jackson-databind-history.svg
evidence:
- ../../../final/evidence/raw/a12-f002-jackson-databind.txt
- ../../../final/evidence/raw/a12-f002-jackson-databind-history.txt
source:
- 원본 분석 절은 analysis/12-adapter-outbound-messaging.md#L118 이다. 등급은 P3 이다. README 문장 인용, 잠금 파일에서 그 좌표가 컴파일과 실행 양쪽에 있다는 대조, 같은 리프의 검증 태스크가 그 모듈을 필수로 요구한다는 지적, 구세대 이름공간 쪽은 실제로 금지되어 있으므로 서술이 그 이름공간을 뜻했다면 맞지만 문장이 한정하지 않는다는 판단, 코드 결함이 아니라 근거로 적힌 사실이 무너진 것이라는 결론이 그 절에 있다.
- 이 기록이 더한 것은 넷이다. 같은 주장이 클래스 자바독에도 있어 고칠 자리가 둘이라는 것. 잠금 파일이 아니라 이 리프의 빌드 선언이 그 좌표를 끌어온다는 것. 구성을 바꾼 커밋이 2026-07-31 이고, 그 커밋이 같은 자리에서 README 에 절 셋을 붙이면서 이 줄만 두고 갔으며 그 뒤로 이 파일을 연 커밋이 없다는 것. 그리고 그 라이브러리로 봉투를 만드는 옆 파일이 있지만 그것을 부르는 어댑터를 조립하는 곳은 없다는 것이다.
---
# 근거를 없앤 커밋이 README 를 열고 그 줄만 두었다
README 가 손수 짠 직렬화의 근거로 결속 라이브러리가 클래스패스에 없다는 사실을 들고, 그 클래스의 자바독이 같은 문장을 다시 적는다. 그 좌표를 배포되는 클래스패스로 올린 커밋이 같은 자리에서 README 에 절 셋을 붙이면서 이 줄만 두고 갔다.
## 관계
- **매 PR 을 막는 게이트가 초록일 수 없다**
같은 리프의 같은 잠금 좌표에서 갈라진 사례다. 저쪽은 판본이 올라간 커밋, 이쪽은 구성이 바뀐 커밋이다.
- **README가 "노출된 setting도 bean도 없다"고 적은 능력에 production bean 여덟이 있다**
두 사례 모두 README 가 적은 것과 실제 코드가 다르다.
- **문서가 지목하는 조정 레코드를 쓰는 코드가 없다**
문서가 가리킨 대상이 코드에 없다는 점이 같다.
## 문제
봉투 직렬화를 손으로 짠 이유가 README 에 적혀 있다.
이 모듈은 결속 라이브러리를 클래스패스에 두지 않아 뼈대를 가볍게 유지하며, 그래서 봉투 직렬화는 의존성 없는 손수 짠 JSON 이라는 것이다.
## 결론
손수 짠 클래스는 그 서술과 어긋나지 않는다. 바깥에서 끌어오는 타입이 없다.
다만 그 클래스의 자바독이 README 와 같은 문장을 다시 적는다. 모듈이 그 라이브러리를 클래스패스 밖에 둔다는 것이다. 고칠 자리가 하나가 아니라 둘이다.
잠금 파일만의 문제도 아니다. 이 리프의 빌드 파일이 스프링 JSON 스타터와 스키마 검증기를 직접 선언한다. 그 좌표가 컴파일과 실행 클래스패스에 올라 있는 것은 그 선언의 결과다.
같은 빌드 파일의 검증 태스크는 그 좌표가 런타임 그래프에 있어야 한다고 요구한다. 문서가 부재를 근거로 삼는 동안 게이트는 존재를 조건으로 건다.
문장이 쓰일 때는 맞았다. 초기 커밋 시점에는 그 좌표가 배포되는 클래스패스 어디에도 없었다.
바꾼 것은 2026-07-31 의 메시징 R2 커밋이다. 그 커밋이 빌드 파일에 JSON 스택 둘을 선언하면서 좌표를 컴파일과 실행으로 올렸다. 같은 커밋이 README 를 열어 절 셋을 새로 붙였고, 근거가 무너진 그 문장만 그대로 두었다. 그 뒤로 이 README 를 건드린 커밋은 없다.
실시간 팬아웃 봉투는 석 주 뒤에 들어왔다. 883 파일을 건드린 커밋이 이 리프에서 만진 것은 잠금 파일과 팬아웃 어댑터와 그 봉투 클래스 셋이고, 잠금 파일에서 바뀐 것은 판본뿐이다.
문장이 근거로 삼은 것은 모듈 전체의 부재이고, 그것이 깨진 뒤 옆 파일이 봉투 JSON 조립을 그 라이브러리로 처리한다.
다만 그 봉투를 부르는 어댑터를 조립하는 곳이 저장소 어디에도 없다. 선언은 그대로 있고 그것을 부르는 실행 경로만 없다.
구세대 이름공간의 결속과 코어를 세우는 것은 검증 태스크 안의 단언 한 줄이고, 구성 단위 제외 목록이 빼는 것은 형식 계열뿐이다. 애너테이션 쪽 2.21 은 아직 두 클래스패스에 다 남아 있다. 서술이 그 이름공간을 뜻했다면 맞지만, 문장에 이름공간이 없다.
판정은 P3 다. 코드 결함은 아니다.
README 를 읽고 이 리프의 의존성 정책을 판단하는 사람은 지금 없는 사실을 근거로 삼게 된다.
## 검증 환경
Gradle : 9.0.0
확인 방식 : README 와 자바독 대조, 빌드 선언과 잠금 파일 확인, 리프 main 의 참조 전수 확인, 커밋 이력 조회
소스 수정 : x
## 재현 조건
1. README 의 직렬화 근거 문장과 손수 짠 클래스의 자바독을 나란히 읽는다.
2. 빌드 파일에서 그 라이브러리를 끌어오는 선언을 찾는다.
3. 같은 빌드 파일의 검증 태스크가 요구하는 좌표를 읽는다.
4. 리프 main 에서 그 라이브러리를 쓰는 곳을 임포트와 인라인 표기까지 전수로 찾는다.
5. 그중 봉투를 만드는 쪽의 본문을 읽고, 그 봉투와 어댑터를 자기 파일 밖에서 부르는 곳을 센다.
6. 잠금 파일에서 관련 좌표를 확인한다.
7. 그 좌표의 구성이 바뀐 이력을 커밋별로 뽑는다.
8. README 를 건드린 커밋 전부를 나열하고, 구성을 바꾼 커밋이 README 에 무엇을 했는지 그 앞뒤 36 행으로 확인한다.
9. 봉투 클래스가 들어온 커밋과 그 커밋이 이 리프에서 건드린 파일을 확인한다.
## 본문
<!-- body:start -->
봉투 직렬화를 손으로 짠 이유가 README 에 적혀 있고, 그 클래스의 자바독도 같은 말을 한다.
## 같은 주장을 하는 두 자리
:::evidence key="a12-f002-jackson-databind" alt="README 의 근거 문단과 손수 짠 클래스의 자바독이 같은 주장을 하는 것, 빌드 파일이 그 라이브러리를 끌어오는 선언과 같은 파일의 검증 태스크가 요구하는 좌표, 리프 main 에서 그 라이브러리를 쓰는 곳을 임포트와 인라인 표기까지 전수로 찾은 목록, 그중 봉투를 만드는 쪽의 본문, 그 봉투와 어댑터를 자기 파일 밖에서 부르는 곳의 수, 그리고 잠금 파일의 관련 좌표를 출력한 터미널 기록." caption="README 와 클래스 자바독이 같은 문장을 적는다 · 빌드 파일이 스프링 JSON 스타터와 스키마 검증기를 선언하고, 같은 파일의 검증 태스크는 그 좌표를 필수로 든다 · 리프 main 의 두 파일이 그 라이브러리를 쓰고 한쪽은 봉투를 만든다 · 그 봉투를 부르는 어댑터를 조립하는 곳은 0 — 44줄 · exit 0" zoom="true"
:::
```text
## OutboxEnvelopeJson — 손수 짠 JSON
이 모듈은 `jackson-databind` 를 classpath 에 두지 않아(스켈레톤을 가볍게 유지) outbox envelope
직렬화는 의존성 없는 손수 짠 JSON 이다.
```
```java
/**
* Hand-rolled, dependency-free JSON serialiser for the outbox envelope (no Jackson the module
* deliberately keeps {@code jackson-databind} off its classpath).
```
손수 짠 코드 자체는 서술대로다. 임포트가 도메인 타입 하나뿐이다. 고쳐야 할 것은 그 코드가 아니라 두 자리에 적힌 근거다.
## 빌드 파일이 그것을 끌어온다
```text
8: implementation 'org.springframework.boot:spring-boot-starter-json'
9: implementation('com.networknt:json-schema-validator:3.0.2') {
```
잠금 파일이 스스로 그렇게 된 것이 아니다. 이 리프가 직접 선언한다. 그리고 같은 파일 아래쪽의 검증 태스크가 그 좌표를 요구한다.
```groovy
'com.networknt:json-schema-validator:3.0.2',
'tools.jackson.core:jackson-core:3.0.2',
'tools.jackson.core:jackson-databind:3.0.2'
```
README 가 없다고 적은 모듈을 이 리프의 게이트가 필수로 든다.
## 문장이 무너진 자리
:::evidence key="a12-f002-jackson-databind-history" alt="이 좌표의 구성이 바뀐 이력을 커밋별로 뽑은 목록, README 를 건드린 커밋 전부, 구성을 바꾼 커밋이 README 에 절 셋을 붙였다는 것과 그 커밋 앞뒤의 36 행, 그리고 봉투 클래스가 들어온 커밋과 그 커밋이 이 리프에서 건드린 파일을 출력한 터미널 기록." caption="초기 커밋에서는 시험 클래스패스에만 있었고, 2026-07-31 커밋이 컴파일과 실행으로 올렸다 · 그 커밋이 README 에 절 셋을 붙였는데 36 행은 앞뒤가 같다 · 그 뒤 README 를 건드린 커밋은 없다 · 봉투 클래스는 석 주 뒤 커밋이 들여왔고 그 커밋의 잠금 변경은 판본뿐 — 29줄 · exit 0" zoom="true"
:::
```text
# 이 좌표의 구성이 바뀐 이력
a24ece9c 2026-08-28 feat: web, websocket 어댑터 추가 구현
-tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+tools.jackson.core:jackson-databind:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
5f10b791 2026-08-11 chore: record pre-existing uncommitted repository state
e5af2912 2026-07-31 feat: add messaging R2 polling producer
-tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
+tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
821fe00c 2026-07-24 init: 클린 아키텍처 백엔드
+tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
```
구성을 바꾼 것은 2026-07-31 커밋이다. 8월 커밋이 한 일은 판본을 올린 것뿐이다.
문장의 classpath 를 배포되는 쪽으로 읽으면 초기 커밋 때는 성립했다. 이름공간을 두고는 같은 호의를 주지 않는다는 것이 아래의 문제다.
## 그 커밋이 README 에 한 일
```text
# 구성을 바꾼 커밋이 README 에 한 일
README 를 건드렸는가 : 1
그 커밋이 README 에 새로 붙인 절 : 3 개
그 커밋 앞뒤의 36 행
before 이 모듈은 `jackson-databind` 를 classpath 에 두지 않아(스켈레톤을 가볍게 유지) outbox envelope
after 이 모듈은 `jackson-databind` 를 classpath 에 두지 않아(스켈레톤을 가볍게 유지) outbox envelope
```
같은 커밋이 이 README 를 열어 절 셋을 새로 붙였다. 방금 자기가 무너뜨린 그 한 줄만 그대로 두었다.
```text
# README 를 건드린 커밋 전부
e5af2912 2026-07-31 feat: add messaging R2 polling producer
b3add016 2026-07-28 feat: redis, fileserver, httpclient 런타임 시점 구현 추가
821fe00c 2026-07-24 init: 클린 아키텍처 백엔드
```
그 뒤로 이 파일을 연 커밋은 없다.
## 같은 리프에서 그 라이브러리를 쓰는 곳
```text
realtime/RealtimeFanoutEnvelopeJson.java:5:import tools.jackson.databind.ObjectMapper;
realtime/RealtimeFanoutEnvelopeJson.java:6:import tools.jackson.databind.json.JsonMapper;
realtime/RealtimeFanoutEnvelopeJson.java:7:import tools.jackson.databind.node.ObjectNode;
realtime/RealtimeFanoutEnvelopeJson.java:71: private static String text(tools.jackson.databind.JsonNode root, String field) {
envelope/LocalJsonSchemaRegistry.java:33:import tools.jackson.databind.DeserializationFeature;
envelope/LocalJsonSchemaRegistry.java:34:import tools.jackson.databind.JsonNode;
envelope/LocalJsonSchemaRegistry.java:35:import tools.jackson.databind.ObjectMapper;
envelope/LocalJsonSchemaRegistry.java:36:import tools.jackson.databind.json.JsonMapper;
```
README 가 근거로 든 것은 모듈 차원의 부재다. 그 부재가 깨진 자리에서 같은 리프의 옆 파일이 같은 종류의 일을 그 라이브러리로 한다.
```java
24: private static final String VERSION = "1";
25: private static final ObjectMapper MAPPER = JsonMapper.builder().build();
30: public static String toJson(DurableFanoutRecord record) {
31: Objects.requireNonNull(record, "record must not be null");
32: ObjectNode root = MAPPER.createObjectNode();
```
봉투 JSON 을 조립하는 일이다.
## 다만 그 경로는 아직 돌지 않는다
```text
RealtimeFanoutEnvelopeJson 을 자기 파일 밖에서 부르는 곳 : 1
MessagingDurableFanoutAdapter 를 자기 파일 밖에서 부르는 곳 : 0
```
부르는 하나는 팬아웃 어댑터이고, 그 어댑터를 조립하는 곳은 없다. 클래스패스 사실은 그대로이고, 도는 경로만 아직 없다.
## 이름공간을 한정하지 않는다
구세대 이름공간의 결속과 코어를 막는 것은 빌드 파일 검증 태스크의 단언이다. 구성 단위의 제외 목록은 형식 계열만 뺀다.
```text
8:com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
```
같은 이름공간의 애너테이션은 지금도 컴파일과 실행에 있다. 서술이 결속과 코어만 뜻했다면 맞지만, 문장에 이름공간이 없다.
## 확인하지 못한 것
실시간 팬아웃 봉투가 손수 짠 쪽과 같은 규율을 따라야 하는지는 판단하지 않았다. 여기서 확인한 것은 문장이 서술하는 사실 관계까지다.
<!-- body:end -->
@@ -0,0 +1,134 @@
---
kind: CASE
slug: a19-f003-messaging-reliability-api
title: messaging-reliability-api는 main 13파일 · 817 LOC에 테스트가 0개다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a19-f003-messaging-reliability-api
evidenceCapturedOn: 2026-09-04
body: case-a19-f003-messaging-reliability-api.body.md
assets:
- key: a19-f003-messaging-reliability-api
file: ../../../final/evidence/rendered/a19-f003-messaging-reliability-api.svg
evidence:
- ../../../final/evidence/raw/a19-f003-messaging-reliability-api.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L325 이다.
---
# messaging-reliability-api는 main 13파일 · 817 LOC에 테스트가 0개다
네 리프 중 `messaging-reliability-api``src` 아래에 `main` 디렉터리만 있다. 담긴 것은 outbox 와 inbox 계약 타입 열셋인데, 그중 넷은 어느 시험도 이름을 대지 않는다.
## 관계
- **outbox 가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다**
이 리프가 선언한 `OutboxRepository` 를 구현하는 것은 저쪽이 다루는 스택이고, 출하되는 outbox 는 `application-core` 의 별도 포트를 쓴다.
- **소비자가 없는 fixture 셋**
저쪽은 세 타입을 참조하는 소스가 test 와 testFixtures 양쪽에서 0 이고, 여기는 리프에 test 소스 세트 자체가 없다.
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
이 리프에 `test` 디렉터리가 없어서, 레코드 생성자가 거는 검증과 열거형이 든 술어가 이 리프의 레인에서는 한 번도 실행되지 않는다.
## 문제
이 하위 범위에 리프가 넷 있다.
각 리프에 어떤 소스 세트가 있고 파일이 몇인지, 그리고 시험이 없는 리프의 타입을 무엇이 참조하는지 셌다.
## 결론
테스트 디렉터리가 있는 리프가 셋, 없는 리프가 하나다.
형제 셋은 각각 test 파일을 8, 4, 4 개 갖는다. messaging-reliability-api 만 0 이고, 그 리프에는 test 디렉터리 자체가 없다.
제목의 817 은 원시 줄 수다. 그중 415 줄이 자바독이고 남는 코드가 323 줄이다.
열세 타입은 레코드 다섯, 인터페이스 다섯, 열거형 셋이다. 큰 것은 OutboxCanonicalMetadata(158줄)와 OutboxRepository(152줄)와 OutboxRecord(140줄)다.
상태 전이를 담는다고 알려진 두 열거형은 메서드가 0 개다. OutboxStatus 와 OutboxTransitionResult 는 전이의 어휘를 정의할 뿐이고, 규칙은 OutboxRepository 의 자바독 계약과 그것을 강제하는 JDBC 구현에 있다. 규칙을 값으로 든 것은 InboxResult 뿐이다.
참조는 다른 리프에 있다. 자바독과 문자열 리터럴을 걷어낸 뒤 세면 OutboxStatus 쪽이 여섯 파일, OutboxTransitionResult 쪽이 네 파일이다.
그렇다고 열세 타입이 모두 시험된다고 읽으면 안 된다. 넷은 test 참조가 0 이고, InboxRecord 와 ReliableMessagePublisher 는 자기 파일 밖 main 참조도 0 이다.
판정은 P3 이고 원문과 같다.
## 검증 환경
확인 방식 : 리프별 소스 세트와 파일·줄 수 계수, 817 줄의 분해, 열세 타입의 선언 형태와 메서드 수, 전이 규칙이 적힌 자리 추적, 자바독을 걷어낸 뒤의 참조 재계수
소스 수정 : x
## 재현 조건
1. 이 하위 범위의 리프 넷을 나열하고 각각의 src 아래 디렉터리를 읽는다.
2. 리프마다 main 과 test 파일 수, 그리고 main 원시 줄 수를 센다.
3. 그 줄 수를 공백과 주석과 코드로 나눈다.
4. 테스트 디렉터리가 없는 리프의 타입을 전부 나열하고 선언 형태와 줄 수를 확인한다.
5. 두 열거형의 메서드 수를 세고, 전이 규칙이 실제로 선언된 자리를 찾는다.
6. 같은 리프의 다른 열거형이 값에 규칙을 실었는지 확인한다.
7. 원문이 지목한 구현 리프 이름이 실재하는지 확인하고, 같은 문서의 표가 적은 실제 이름과 계수를 읽는다.
8. 두 열거형의 test 참조를 셀 때 자바독과 문자열 리터럴을 먼저 걷어낸다.
9. 열세 타입 각각에 대해 test 참조와 자기 파일 밖 main 참조를 센다.
## 본문
<!-- body:start -->
`messaging` 플랫폼의 첫 하위 범위에 리프가 넷 있다. 셋은 자기 `test` 디렉터리를 갖고 하나는 갖지 않는다.
## 네 리프의 소스 세트와 817 줄의 내역
:::evidence key="a19-f003-messaging-reliability-api" alt="저장소 루트에서 돌린 정적 검색 출력 70줄. 네 리프의 main·test 파일 수와 main 줄 수와 소스 세트가 먼저 나오는데 messaging-reliability-api 만 소스 세트가 main 하나다. 이어서 그 817 줄이 공백 79 · 주석 415 · 코드 323 으로 갈리고, 열세 타입이 선언 형태와 줄 수로 나열된다. 그다음 InboxResult 만 isSafeToSettle 이라는 메서드를 갖고 OutboxStatus 와 OutboxTransitionResult 의 메서드 수가 0 이며, 전이 규칙이 OutboxRepository 의 자바독에 적혀 있다는 것이 보인다. 원문 §3.6 이 지목한 두 리프 이름은 src 디렉터리가 없고 같은 문서의 표가 적은 실제 이름 셋의 계수가 이어진다. jpa 쌍은 modules.json 등재 0 건 git 추적 파일 0 개다. 끝으로 주석과 문자열을 제거하고 단어 경계로 다시 센 두 열거형의 test 참조가 파일별 횟수와 소속 리프까지 나오고, 열세 타입 중 test 참조가 0 인 넷이 보인다." caption="네 리프의 소스 세트 · 817 줄의 내역 · 열세 타입과 메서드 수 · 실제 구현 리프 이름 · 재계수한 참조와 참조 0 인 넷 — 70줄 · exit 0" zoom="true"
:::
`messaging-core-api` 는 main 85 에 test 8, `messaging-transport-spi` 는 13 에 4, `messaging-runtime-core` 는 6 에 4 다. `messaging-reliability-api` 만 main 13 에 test 0 이고, 그 리프의 `src` 아래에는 `main` 디렉터리 하나만 있다.
main 줄 수 817 은 원시 줄 수다. 갈라 보면 공백 79, 주석 415, 코드 323 이다. 절반이 자바독이라 제목의 `817 LOC` 를 구현 규모로 읽으면 두 배 넘게 과장된다.
## 열세 타입이 무엇인가
레코드가 다섯이다. `OutboxCanonicalMetadata`(158줄), `OutboxRecord`(140줄), `ClaimCheckReference`(50줄), `OutboxLease`(42줄), `InboxRecord`(27줄).
인터페이스가 다섯이다. `OutboxRepository`(152줄), `InboxRepository`(53줄), `IdempotentMessageHandler`(33줄), `TransactionalMessageAction`(29줄), `ReliableMessagePublisher`(27줄).
열거형이 셋이다. `InboxResult`(45줄), `OutboxStatus`(37줄), `OutboxTransitionResult`(24줄).
## 두 열거형은 규칙이 아니라 어휘다
`OutboxStatus``OutboxTransitionResult` 는 메서드가 0 개다. 상수와 자바독뿐이고 전이를 판정하는 코드가 없다.
규칙이 선언된 자리는 `OutboxRepository` 의 자바독이다. `:70` 이 확정된 발행 기록을 이 임차가 아직 그 행을 소유할 때만 한다고 적고, `:72` 가 다른 릴레이가 가져갔으면 `STALE_LEASE` 를 돌려준다고 적으며, `:87` 이 모호한 결과에 같은 조건을 건다. 강제하는 것은 JDBC 구현의 SQL 이다.
이 리프에서 실행 가능한 규칙을 가진 열거형은 `InboxResult` 하나다. `:42``isSafeToSettle()``CLAIMED_ELSEWHERE` 에서 정산하면 효과가 사라진다는 것을 값으로 들고 있다.
## 계약 타입은 다른 리프의 시험이 참조한다
주석과 문자열을 제거하고 단어 경계로 다시 세면 `OutboxStatus` 를 참조하는 test 는 여섯 파일이고 전부 `messaging-outbox-jdbc-postgresql` 안에 있다. 가장 많이 쓰는 것은 `OutboxRelayTest`(18회)와 `OutboxPostgresIT`(8회)다.
`OutboxTransitionResult` 는 네 파일이다. `OutboxRelayTest`(19회), `OutboxOperationsTest`(11회), `MessagingOutboxRelayLifecycleTest`(7회), `OutboxPostgresIT`(4회)이고 마지막 하나만 다른 리프다.
다만 열세 중 넷은 test 참조가 0 이다. `IdempotentMessageHandler`, `InboxRecord`, `ReliableMessagePublisher`, `TransactionalMessageAction` 이고, 그중 `InboxRecord``ReliableMessagePublisher` 는 자기 파일 밖 main 참조도 0 이다.
그래서 이 리프의 계약이 전부 시험된다는 뜻은 아니다. 시험되는 타입은 다른 리프에서 시험되고, 시험되지 않는 타입이 넷 있다.
## 원문과 갈리는 자리
원문 §3.6 은 구현 쪽 확인을 다음 범위로 넘기면서 `messaging-outbox-jdbc``messaging-inbox-jdbc` 를 지목했다. 그 이름의 `src` 디렉터리는 없다.
다만 이것은 미확인이 아니라 축약 표기다. 같은 문서의 리프 표가 `messaging-outbox-jdbc-postgresql`, `messaging-inbox-jdbc-postgresql`, `messaging-claim-check` 를 실제 이름으로 싣고 있다. 세 리프 모두 자기 시험을 갖는다 — 각각 main 13 에 test 8, main 6 에 test 4, main 6 에 test 3 이다. 원문의 표는 리소스를 포함한 다른 기준으로 세어 수치가 다르다.
원문이 `OutboxTransitionResult``OutboxStatus` 를 "상태 전이 규칙을 담을 수 있는 타입" 이라고 조심스럽게 적은 것도 그대로 옳다. 둘 다 메서드가 없어서 담을 수 있을 뿐 담고 있지는 않다.
## 확인하지 못한 것
두 열거형에 전이 로직이 없다는 것은 메서드 계수와 본문 읽기로 판정했다. `OutboxRepository` 의 자바독 계약을 JDBC 구현이 실제로 어떻게 강제하는지는 SQL 을 열어 보지 않았다.
참조 계수는 파일 수와 등장 횟수다. 어느 시험이 무엇을 단언하는지까지는 들어가지 않았다.
두 열거형에 전이 로직이 없다는 것은 메서드 계수와 본문 읽기로 판정했다. `OutboxRepository` 의 자바독 계약을 JDBC 구현이 실제로 어떻게 강제하는지는 SQL 을 열어 보지 않았다.
`messaging-outbox-jpa``messaging-inbox-jpa` 디렉터리는 `modules.json` 등재가 0 건이고 git 이 추적하는 파일도 0 개다. 모듈이 아니라 빌드 산출물이 남은 자리로 보고 계수에서 뺐다.
<!-- body:end -->
@@ -0,0 +1,127 @@
---
kind: CASE
slug: a19-f006-messaging-cloudevents
title: messaging-cloudevents는 출하 leaf이고 starter의 의존이며 소비자가 없다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a19-f006-messaging-cloudevents
evidenceCapturedOn: 2026-09-04
body: case-a19-f006-messaging-cloudevents.body.md
assets:
- key: a19-f006-messaging-cloudevents
file: ../../../final/evidence/rendered/a19-f006-messaging-cloudevents.svg
evidence:
- ../../../final/evidence/raw/a19-f006-messaging-cloudevents.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L431 이다.
---
# messaging-cloudevents는 출하 leaf이고 starter의 의존이며 소비자가 없다
`modules.json` 이 이 리프의 런타임 소속을 `app-bootstrap` 으로 적고 `messaging-spring-boot-starter/build.gradle:26``implementation` 으로 물어 출하 산출물에 들어간다. 그런데 `new DefaultCloudEventMapper``CloudEventMappingTest:30` 한 줄뿐이고, `CloudEventMapper``CloudEventExtensions` 를 참조하는 main 파일은 이 리프의 세 파일 자신이다. main 자바 소스 전체에서 `CloudEvent` 를 언급하는 파일도 그 셋뿐이다.
## 관계
- **outbox 가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다**
저쪽은 조건이 참이 될 수 없어 사슬 전체가 조립되지 않고, 여기는 조립하는 코드가 아예 없다. 둘 다 main 파일은 남아 있는데 그것을 실행하는 경로가 없다.
- **브로커 ACL 매니페스트의 자기 점검이 존재하지 않는다**
두 기록 모두 출하 리프의 타입이 main 코드에서 한 번도 참조되지 않는다. 저쪽은 자바독이 약속한 시작 시 대조가 실행되지 않고, 여기는 CloudEvents 헤더 매핑을 부르는 경로가 없다.
- **runtime_memberships를 먼저 읽고 심각도를 정한다**
이 리프의 런타임 소속이 `app-bootstrap` 이라 빌드 전용 리프에 주는 미조립 면제가 적용되지 않고, 그래서 세 main 파일의 미조립이 기록할 사건이 된다.
## 문제
이 리프는 CloudEvents 상호운용 규격을 담는다.
런타임 소속이 app-bootstrap 이라 세 main 파일이 출하 산출물에 들어가므로, 그 세 파일을 부르는 main 코드가 있는지 셌다.
## 결론
없다.
DefaultCloudEventMapper(171줄)를 생성하는 코드는 CloudEventMappingTest:30 하나다. 계약 타입 둘도 리프 밖 main 에서는 나오지 않고, 파일 이름 규칙 없이 CloudEvent 를 main 전수 검색해도 결과가 같다. 서비스 로더가 읽을 등록 파일도 저장소에 없다.
출하는 선언만이 아니라 해소된 결과로도 확인된다. 런타임 프로젝트 클로저에 이 리프가 있고 app-bootstrap/gradle.lockfile 이 io.cloudevents 두 아티팩트를 productionRuntimeClasspath 로 등재한다.
빌드 파일이 스스로 모순을 적어 둔 자리도 있다. messaging-spring-boot-starter/build.gradle:24 의 주석은 이 그룹을 자동설정이 배선하고 채택자가 이름을 대지 않는 것으로 적는데, 이 리프는 배선하는 자동설정이 없고 implementation 이라 채택자가 이름을 댈 수도 없다.
위험은 안 쓰이는 코드가 있다는 것이 아니다. 설계 스펙은 이 프로파일을 제공한다고 적고 한 절을 그 매핑에 할애했는데, 실행으로 옮기는 코드가 어느 경로에도 없다.
한 방향에는 부르는 코드가 있다. 봉투 필드 사칭을 판정하는 메서드를 두 브로커 헤더 매퍼가 쓰는데, 그중 조립되는 것은 Kafka 발행 쪽뿐이다. 반대 방향을 맡는 코드는 이 리프 안에 갇혀 있다.
판정은 P2 이고 원문과 같다. 다만 지원 매트릭스와 설정 참조 문서에는 CloudEvents 언급이 0 건이라, 약속이 적힌 곳은 설계 스펙까지다.
## 검증 환경
확인 방식 : 리프 파일과 줄 수 확인, 런타임 소속과 빌드 선언과 해소된 클로저와 잠금 파일 대조, new DefaultCloudEventMapper 와 두 계약 타입의 main 참조 전수, CloudEvent 문자열의 main 전수 검색과 설정 클래스 계수, META-INF/services 계수, 원문이 적은 28 의 출처 대조, CanonicalEnvelopeHeaders 호출 네 줄의 성격과 그 위 transport 의 생성 지점 확인
소스 수정 : x
## 재현 조건
1. 이 리프의 소스 파일을 소스 세트별로 나열하고 줄 수를 센다.
2. modules.json 의 런타임 소속과 스타터 빌드 선언, 그리고 그 선언 위의 주석을 읽는다.
3. 해소된 런타임 프로젝트 클로저와 gradle.lockfile 에서 이 리프와 서드파티 아티팩트를 찾는다.
4. new DefaultCloudEventMapper 를 저장소 전체에서 센다.
5. 두 계약 타입을 참조하는 main 파일을 나열한다.
6. CloudEvent 를 main 자바 전수로 검색해 파일 이름 규칙에 기대지 않은 결과를 얻고, 설정 클래스와 서비스 로더 등록 파일도 함께 센다.
7. 원문이 28 로 적은 수가 어느 디렉터리의 파일 수인지 확인하고 그중 *AutoConfiguration 을 센다.
8. CanonicalEnvelopeHeaders 를 부르는 main 네 줄이 값을 싣는 코드인지 판정 코드인지 읽고, 실제로 헤더를 싣는 줄을 따로 찾는다.
9. 그 네 줄 위의 transport 두 개를 만드는 main 코드를 각각 센다.
10. 설계 스펙과 운영자용 문서에서 CloudEvents 언급을 각각 센다.
## 본문
<!-- body:start -->
`messaging-cloudevents` 는 파일 넷짜리 리프다. main 셋이 `DefaultCloudEventMapper`(171줄), `CloudEventMapper`(33줄), `CloudEventExtensions`(24줄)이고, test 하나가 `CloudEventMappingTest`(162줄)다.
## 출하 산출물에 들어간다
:::evidence key="a19-f006-messaging-cloudevents" alt="저장소 루트에서 돌린 정적 검색 출력 75줄. 리프의 파일 넷이 소스 세트와 줄 수로 먼저 나오고, 출하를 말하는 네 근거가 이어진다 — modules.json 의 런타임 소속, 스타터 build.gradle 의 주석과 implementation 선언, 해소된 런타임 프로젝트 클로저에 이 리프 이름이 있다는 것, 그리고 gradle.lockfile 에 io.cloudevents 두 아티팩트가 productionRuntimeClasspath 로 등재된 줄이다. 그다음 new DefaultCloudEventMapper 가 자기 시험 한 줄뿐이라는 것과 계약 타입을 참조하는 main 파일이 리프의 셋이라는 것, main 자바 소스 전체에서 CloudEvent 를 언급하는 파일도 그 셋뿐이라는 것, 설정 클래스 계수와 META-INF/services 파일 수 0 이 나온다. 원문이 28 로 적은 수가 스타터 autoconfigure 패키지의 파일 수이고 그중 이름이 AutoConfiguration 인 것은 여섯이라는 대조가 이어진다. 끝으로 CanonicalEnvelopeHeaders 를 부르는 네 줄이 값을 싣는 곳이 아니라 거절과 건너뛰기 판정이라는 것과 실제로 헤더를 싣는 ReservedHeaders 줄, 그리고 두 매퍼 위의 transport 중 Kafka 쪽만 main 에서 만들어진다는 것, 설계 스펙이 CloudEvents 프로파일을 약속한 두 줄과 지원 매트릭스·설정 참조에는 언급이 0 건이라는 것이 보인다." caption="리프 파일 넷 · 출하 근거 넷 · 생성 지점은 시험 하나 · CloudEvent 를 아는 main 파일 셋 · 28 의 출처 · 반대편 네 줄의 성격과 배선 · 약속한 문서와 없는 문서 — 75줄 · exit 0" zoom="true"
:::
`modules.json` 이 이 리프의 `runtime_memberships``["app-bootstrap"]` 으로 적고, `messaging-spring-boot-starter/build.gradle:26``implementation` 으로 문다. 선언만이 아니다. 해소된 런타임 프로젝트 클로저 파일에 이 리프 이름이 있고, `app-bootstrap/gradle.lockfile``io.cloudevents:cloudevents-api:4.0.1``cloudevents-core:4.0.1``productionRuntimeClasspath` 로 등재한다.
`implementation` 선언 바로 위 `:24` 의 주석이 이 그룹의 성격을 적는다 — 자동설정이 배선하고 채택자의 소스에서 이름을 대는 일이 없다는 것이다. 이 리프에 대해서는 앞뒤가 다 어긋난다. 배선하는 자동설정이 없고, `implementation` 이라 채택자의 컴파일 클래스패스에 타입이 오르지 않아 이름을 댈 수도 없다.
## 부르는 코드가 없다
`new DefaultCloudEventMapper` 는 저장소 전체에서 `CloudEventMappingTest:30` 한 줄이다. `CloudEventMapper``CloudEventExtensions` 를 참조하는 main 파일은 이 리프의 셋 자신이다.
파일 이름 규칙에 기대지 않고 `CloudEvent` 라는 문자열을 main 자바 소스 전체에서 찾아도 나오는 파일은 같은 셋뿐이다. 이름이 `*AutoConfiguration.java` 인 36 개와 `AutoConfiguration.imports` 등재 14 개는 그 전체 집합의 부분집합이므로, 자동설정에 없다는 것은 그 계수와 무관하게 성립한다. `META-INF/services` 파일은 저장소에 하나도 없다.
## 그래서 CloudEvents 헤더를 기대하는 소비자와 맺어지는 계약이 없다
이 리프가 담은 것은 유선 상호운용 규격이다. 설계 스펙이 `:35` 의 역량 표에서 CloudEvents 를 도메인·통합 이벤트에 선택 가능한 1.0.2 호환 프로파일로 제공한다고 적고, `:756` 부터 한 절을 그 프로파일에 쓴다.
그 약속을 받아 실행하는 코드가 어느 경로에도 없다. 위험은 안 쓰이는 코드가 산출물에 들어간다는 것이 아니라, 외부 소비자가 CloudEvents 헤더로 메시지를 받을 것으로 기대할 때 그 기대와 맺어지는 계약이 어느 실행 경로에서도 성립하지 않는다는 것이다.
## 매핑의 한쪽 방향에는 호출자가 있다
`CanonicalEnvelopeHeaders.restatesEnvelopeField``KafkaHeaderMapper:90`·`:141``RabbitHeaderMapper:102`·`:158` 이 부른다. 다만 이 넷은 값을 싣는 코드가 아니다. `:90` 은 사용자 헤더가 봉투 필드를 사칭하면 `RESERVED_HEADER_FORGED` 로 던지고, `:141` 은 되읽을 때 건너뛴다. 실제로 헤더를 싣는 것은 `KafkaHeaderMapper:36` 부터의 `put(headers, ReservedHeaders.MESSAGE_ID, …)` 계열이다.
그 넷 중 조립되는 경로에 있는 것도 하나다. `KafkaPublishMapper:34``KafkaHeaderMapper` 를 만들고, 그 위의 `KafkaMessagingTransport``KafkaMessagingAutoConfiguration:168` 이 만든다. `RabbitMessagingTransport` 를 만드는 main 코드는 0 건이라 Rabbit 쪽 두 줄은 조립되지 않은 클래스 안에 있다.
정리하면 정규 봉투 헤더 판정은 Kafka 발행 경로에서 실제로 지나가고, 그 헤더를 `CloudEventExtensions` 로 옮기는 코드는 `DefaultCloudEventMapper` 안에만 있으며 그것을 부르는 main 코드가 없다.
## 원문과 갈리는 자리
원문은 자동설정을 28 개 클래스로 적었다. 28 은 스타터의 `autoconfigure` 패키지 파일 수다. 그중 이름이 `*AutoConfiguration` 인 것은 여섯이고 나머지는 설정 값 타입과 검증기와 발행자다. 근거가 없는 수가 아니라 그 28 개를 "자동설정 클래스" 라고 부른 이름이 부정확하다.
원문은 `CanonicalEnvelopeHeaders``CloudEventExtensions` 사이에 매핑이 존재한다고 적었다. 두 클래스는 서로를 참조하지 않는다. `DefaultCloudEventMapper` 가 양쪽 개념을 각각 다루는 것이지 두 타입이 이어져 있지는 않다.
판정과 등급은 원문과 같다.
## 확인하지 못한 것
헤더를 실은 메시지를 실제로 흘려보내 보지는 않았다. 부르는 경로가 없다는 데서 멈췄다.
리플렉션이나 서비스 로더로 이 매퍼를 가져가는 경로는 `META-INF/services` 파일이 0 개라는 것까지만 확인했고, 클래스 이름 문자열로 불러 쓰는 경로는 따로 세지 않았다. 판정 근거는 이름 기반 정적 검색이다.
리플렉션이나 서비스 로더로 이 매퍼를 가져가는 경로는 `META-INF/services` 파일이 0 개라는 것까지만 확인했고, 클래스 이름 문자열로 불러 쓰는 경로는 따로 세지 않았다. 판정 근거는 이름 기반 정적 검색이다.
지원 매트릭스와 설정 참조 문서에 CloudEvents 언급이 0 건이라, 실제 외부 소비자가 이 약속을 보고 붙었는지는 확인할 방법이 없었다.
<!-- body:end -->
@@ -0,0 +1,133 @@
---
kind: CASE
slug: a19-f008-acl
title: 브로커 ACL 매니페스트의 자기 점검이 존재하지 않는다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a19-f008-acl
evidenceCapturedOn: 2026-09-04
body: case-a19-f008-acl.body.md
assets:
- key: a19-f008-acl
file: ../../../final/evidence/rendered/a19-f008-acl.svg
evidence:
- ../../../final/evidence/raw/a19-f008-acl.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L495 이다.
---
# 브로커 ACL 매니페스트의 자기 점검이 존재하지 않는다
`messaging-security``BrokerAclManifest` 자바독은 기동 대조와 파괴적 권한 거부를 적어 두었다. 두 검사 모두 이 record 안에 메서드로 있고 시험도 단언한다. 그 메서드를 부르는 main 코드가 `BrokerAclManifest.java` 밖에 없다.
## 관계
- **ACL 매니페스트 전체가 쓰이지 않는다**
저쪽은 이 타입의 소비자가 없다는 미해결 질문이고, 여기서는 그중 자바독이 적어 둔 두 검사가 어느 main 경로에서도 불리지 않는 것을 확인했다.
- **클래스 javadoc 이 강제되지 않는 규칙을 강제된다고 말한다**
저쪽은 규칙이 코드로 없고 여기는 코드로 있는데 부르는 자리가 없다. 자바독만 읽는 사람에게 보이는 결과는 같다.
- **타입이 문서화한 불변식은 타입이 강제한다**
이 record 는 그 규칙을 지킨 형태다. 거부가 조건부라 생성자가 아니라 가드 메서드로 두었고, 남은 문제는 그 가드를 부르는 자리다.
## 문제
브로커 권한 매니페스트가 출하 리프에 record 로 있다.
자바독이 적어 둔 두 검사가 어디까지 존재하고 어디서 불리는지 확인했다.
## 결론
두 검사 모두 이 record 안에 있다. 부르는 main 코드가 없다.
파괴적 권한 거부는 requireApplicationRuntime()(:103)이 한다. 선언된 파괴 권한이 비어 있지 않으면 APPLICATION_HOLDS_DESTRUCTIVE_GRANT 로 던진다. 기동 대조에 필요한 뺄셈은 undeclared(:122) 와 missing(:135) 에 양방향으로 있다.
CredentialRuntimeRegistryTest 한 파일이 이 타입을 17 줄에서 쓰는데, 그 시험은 파괴적 grant 를 넣은 매니페스트가 requireApplicationRuntime() 에서 예외를 내는 것과 undeclared() 가 선언에 없는 grant 를 돌려주는 것을 단언한다. 두 메서드를 부르는 자리가 그 시험뿐이다.
StartupProfileValidation 으로 감싸인 대상 셋에도 이 타입은 들어 있지 않다. 감싸인 것은 KafkaBrokerProfile 과 RabbitBrokerProfile 과 BrokerSecurityProfile 셋이다. 마지막 것은 이 매니페스트와 같은 패키지인데 매니페스트만 빠져 있다. 브로커에 권한을 질의하는 이름 여덟도 0 파일인데, Kafka AdminClient 자체가 main 에 없어서 그 0 이 말해 주는 범위는 좁다.
간결 생성자가 파괴 여부를 보지 않는 것은 결함이 아니다. 자바독이 거부한다고 적은 것은 애플리케이션 런타임이 파괴적 권한을 선언한 경우이고, 운영 평면의 principal 은 그것을 정당하게 선언한다. 조건을 아는 쪽이 부르는 가드가 맞고 그 가드가 이미 있다.
원본 분석의 등급은 P2 이고 이 기록은 새로 매기지 않는다. 자바독이 단정한 것이 코드로 없다는 P2 의 지렛대는 이 리비전에서 성립하지 않는다. 낮출 근거도 있는데, 검사 대상이 런타임에 만들어지지 않고 파괴 연산 인터페이스에도 main 구현이 없다는 것이다.
## 검증 환경
확인 방식 : 자바독 원문 확인, 대응 메서드 넷의 본문과 그것을 단언하는 시험 확인, 자기 파일 안팎을 나눈 호출자 계수, 이 타입의 생성·주입 지점 계수, Operation 열거값의 파괴 표시 확인, 기동 검증으로 감싸인 프로파일 전수, 브로커 ACL 질의 API 이름 여덟 검색과 AdminClient 의 소스 세트 분포, 파괴 연산 인터페이스의 구현 계수
소스 수정 : x
## 재현 조건
1. BrokerAclManifest 의 클래스 자바독에서 적어 둔 두 검사를 읽는다.
2. 그 두 검사에 대응하는 메서드가 있는지 파일 끝까지 읽는다.
3. 그 메서드들을 부르는 자리를 자기 파일 안과 밖으로 나눠 세고 소스 세트도 가른다.
4. 그 동작을 단언하는 시험의 이름과 단언 줄을 읽는다.
5. 이 타입의 생성 지점과 주입 지점을 main 에서 찾는다.
6. 간결 생성자가 검사하는 것을 나열하고, 거부가 무조건인지 조건부인지 자바독에서 확인한다.
7. Operation 열거값의 파괴 표시를 읽고 자바독이 적어 둔 이름과 대조한다.
8. StartupProfileValidation 으로 감싸인 대상을 전부 찾고 각각 무엇을 감싸는지 읽는다.
9. 브로커 권한 질의 API 이름을 검색하고, 그 이름들이 속한 클라이언트가 main 에 있는지도 함께 센다.
10. 파괴 연산을 담은 인터페이스의 main 구현을 센다.
## 본문
<!-- body:start -->
`BrokerAclManifest``messaging-security` 의 141줄짜리 record 다. 클래스 자바독에는 플랫폼이 기동 시점에 자신을 이 매니페스트와 대조한다고 적혀 있고, 선언한 것보다 많은 권한을 들고 있는 런타임은 finding 이라고 적혀 있다. 파괴적 권한을 선언한 애플리케이션 런타임은 그대로 거부된다고도 적혀 있다.
## 자바독이 적어 둔 두 검사
:::evidence key="a19-f008-acl" alt="저장소 루트에서 돌린 정적 검색 출력 125줄. BrokerAclManifest 의 줄 수와 자바독의 두 단정이 원문 그대로 먼저 나오고, 이어서 그 두 단정에 대응하는 메서드 넷의 본문이 실린다 — destructiveGrants, 파괴적 권한이 있으면 APPLICATION_HOLDS_DESTRUCTIVE_GRANT 로 던지는 requireApplicationRuntime, 그리고 관측된 권한과 선언을 양방향으로 비교하는 undeclared 와 missing 이다. 간결 생성자는 널 검사와 공백 검사와 리스트 복사 셋만 한다. 그 메서드들을 부르는 자리 전부가 나오는데 BrokerAclManifest.java 밖의 main 코드는 0 건이고, 이 타입을 만들거나 받는 main 코드도 0 건이며, 자기 파일 밖에서 이름이 나오는 파일은 시험 하나다. 그 시험이 파괴적 grant 에 예외가 나는 것과 선언에 없는 grant 를 골라내는 것을 각각 단언한다. 이어서 Operation 열거값 일곱과 파괴 표시 셋, 기동 검증으로 감싸인 프로파일 셋, DestructiveMessagingAdmin 을 구현하는 main 클래스 0, 브로커 권한 질의 API 이름 여덟이 모두 0 파일이고 Kafka AdminClient 자체가 test 에만 있다는 것이 보인다." caption="자바독의 두 검사 · 대응 메서드 넷의 본문 · 파일 밖 main 호출자 0 과 시험의 단언 · 기동 검증 대상 셋 · 파괴 연산 구현 0 · ACL 질의 0 과 그 0 의 의미 — 125줄 · exit 0" zoom="true"
:::
두 번째 단정에 대응하는 것은 `requireApplicationRuntime()`(`:103`)이다. `destructiveGrants()` 로 선언된 파괴적 권한을 모아 비어 있지 않으면 `MessagingConfigurationException``APPLICATION_HOLDS_DESTRUCTIVE_GRANT` 코드로 던진다. 자바독 `:99` 에는 애플리케이션 런타임에 파괴적 권한을 주는 매니페스트를 거부한다고 적혀 있다.
첫 번째 단정에 대응하는 것은 `undeclared(Set<Grant>)`(`:122`)와 `missing(Set<Grant>)`(`:135`)다. 관측된 권한과 선언을 양방향으로 뺀다. `:116`\~`:117` 에는 초과가 finding 이고 부족이 아니라고 적혀 있다 — 빠진 권한은 첫 사용에서 시끄럽게 실패하지만 선언되지 않은 여분은 남용될 때까지 눈에 띄지 않기 때문이다.
시험도 그 둘을 단언한다. `CredentialRuntimeRegistryTest:167``anApplicationRuntimeMayNotHoldADestructiveGrant``:181` 에서 `requireApplicationRuntime` 에 예외가 나는 것을, `:187``aGrantTheBrokerHoldsButNobodyDeclaredIsTheFinding``:200` 에서 `undeclared` 가 선언에 없는 grant 를 돌려주는 것을 확인한다.
## 부르는 프로덕션 코드가 없다
`BrokerAclManifest.java` 밖에서 이 네 메서드를 부르는 main 코드는 0 건이다. 이 타입을 생성하거나 파라미터로 받는 main 코드도 0 건이고, 자기 파일 밖에서 이름이 나오는 파일은 그 시험 하나다.
기동 시점 검증에도 이 타입이 없다. `StartupProfileValidation` 이 감싸는 것은 `KafkaMessagingAutoConfiguration:59` 의 Kafka 브로커 프로파일, `RabbitMessagingAutoConfiguration:55` 의 Rabbit 브로커 프로파일, `MessagingCoreAutoConfiguration:221``compiled.security()` 셋이다. 세 번째는 같은 `messaging.security` 계열인데 매니페스트는 그 목록에 없다.
브로커에 권한을 질의하는 코드도 없다. `describeAcls`·`AclBinding`·`Authorizer` 를 포함한 이름 여덟이 저장소 전체에서 0 파일이다. 다만 그 0 의 의미는 좁다 — Kafka `AdminClient` 자체가 main 에 없고 test 5 파일에만 있으므로, 애초에 맞을 수 있는 코드가 main 에 없었다.
## 간결 생성자에 넣을 검사는 아니다
`:81`\~`:87` 의 간결 생성자는 `grants` 널 검사와 `principal` 공백 검사와 리스트 복사만 한다. 파괴 여부를 보지 않는다.
여기에 검사를 넣는 것이 수정 방향은 아니다. 자바독이 거부한다고 적은 것은 파괴적 권한 일반이 아니라 **애플리케이션 런타임**이 그것을 선언한 경우다. 운영 평면의 principal 은 `DELETE``PURGE` 를 정당하게 선언한다. 생성자에서 막으면 그 매니페스트를 이 타입으로 표현할 수 없다. 조건을 아는 쪽이 부르는 명시적 가드가 맞는 형태이고, 그 형태가 이미 `:103` 에 있다.
## 원문과 갈리는 자리
원문은 두 단정이 모두 실행되는 코드가 아니라고 적었고, 파괴적 권한을 선언한 매니페스트를 막는 코드가 record 자신에도 없다고 했다. 이 자리가 원문이 틀린 자리다. `requireApplicationRuntime()` 이 그 거부를 구현하고 시험이 그것을 고정한다. `undeclared``missing` 도 기동 대조에 필요한 비교를 갖고 있다. 없는 것은 구현이 아니라 그것을 부르는 자리다.
원문은 간결 생성자가 `principal``pattern` 공백을 검사한다고 적었다. `pattern` 공백 검사는 중첩된 `Grant` 의 생성자에 있다.
원문은 기동 검증으로 감싼 것을 브로커 프로파일 둘로 적었다. 셋이고, 세 번째가 이 매니페스트와 같은 패키지의 보안 프로파일이다.
원문이 인용한 자바독은 파괴적 권한으로 `DELETE_TOPIC``PURGE` 를 적는다. `Operation``DELETE_TOPIC` 이라는 값은 없고 `DELETE` 가 있으며, 파괴로 표시된 값은 `ALTER`·`DELETE`·`PURGE` 셋이다.
원문이 적은 "테스트 1건" 은 파일 수로는 맞다. 그 한 파일 안에서 이 타입이 나오는 줄은 17 이다.
## 등급에 대해
원본 분석의 등급은 P2 다. 이 리비전에서 그 등급을 떠받치던 근거 하나는 성립하지 않는다. 자바독이 단정한 것이 코드로 존재하지 않는다는 것이 P2 의 지렛대였는데, 코드는 있고 부르는 자리만 없다.
낮출 근거도 함께 있다. 프로덕션에서 이 매니페스트를 만드는 코드가 0 이라 검사할 대상이 런타임에 없고, 파괴 연산을 담은 `DestructiveMessagingAdmin` 은 main 구현이 0 이라 매니페스트가 거짓이어도 애플리케이션이 파괴 연산을 부를 수 없다.
이 기록은 등급을 새로 매기지 않는다. 상류가 P2 로 둔 근거와 여기서 확인한 반대 근거를 함께 남긴다.
## 확인하지 못한 것
그런 매니페스트를 실제로 만들어 아무 곳에서도 걸리지 않는 것을 실행으로 보이지는 않았다. `requireApplicationRuntime()` 을 부르는 main 코드가 없다는 데까지다.
브로커에 붙어 권한을 조회하지 않았다. 그런 조회를 하는 코드가 없다는 것까지 확인했다.
채택자가 `DestructiveMessagingAdmin` 을 구현해 넣는 배포는 보지 않았다. 이 저장소 main 에 구현이 0 이라는 것까지 확인했다.
채택자가 `DestructiveMessagingAdmin` 을 구현해 넣는 배포는 보지 않았다. 이 저장소 main 에 구현이 0 이라는 것까지 확인했다.
<!-- body:end -->
@@ -0,0 +1,160 @@
---
kind: CASE
slug: a19-f014-kafka-msg
title: Kafka 스택이 둘이고, 브로커 이름만 주면 기동이 실패한다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a19-f014-kafka-msg
evidenceCapturedOn: 2026-09-04
body: case-a19-f014-kafka-msg.body.md
assets:
- key: a19-f014-kafka-msg
file: ../../../final/evidence/rendered/a19-f014-kafka-msg.svg
- key: a19-f014-kafka-msg-probe
file: ../../../final/evidence/rendered/a19-f014-kafka-msg-probe.svg
evidence:
- ../../../final/evidence/raw/a19-f014-kafka-msg.txt
- ../../../final/evidence/raw/a19-f014-kafka-msg-probe.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L735 이다.
---
# Kafka 스택이 둘이고, 브로커 이름만 주면 기동이 실패한다
한 아티팩트가 Kafka `Producer` 빈을 둘 발행한다. 원문은 마스터 스위치가 꺼진 채 브로커 이름만 주면 `KafkaSenderConfig` 쪽만 올라온다고 적었는데, 컴포지션 루트를 그 조합으로 띄우면 `kafkaSeamProducer` 에서 컨텍스트가 죽는다.
## 관계
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
이름이 겹치는 둘을 만났을 때 조립되는 쪽을 가리는 절차다. 여기서는 조건 애너테이션만으로는 갈리지 않아 컨텍스트를 띄워 갈랐다.
- **마스터 스위치는 루트 하나가 소유하고 자식 설정은 조건을 갖지 않는다**
그 결정은 능력 하나를 켜고 끄는 권한을 루트 한 곳에 둔다. 두 스택이 그 루트를 각각 다른 경로로 지나므로 조건만 읽으면 한쪽이 스위치 밖에 있는 것처럼 보인다.
- **조건부 빈의 평가 시점 — 파싱 시점과 등록 시점**
조건 애너테이션만 나란히 읽으면 `KafkaSenderConfig` 는 조건이 하나로 보인다. 그 빈이 파라미터로 받는 설정 타입을 누가 등록하는지까지 봐야 실제로 조립되는 조합이 나온다.
## 문제
한 아티팩트가 Kafka 생산자를 만드는 자리를 둘 갖고 있고, 둘의 조건이 달라 보인다.
마스터 스위치가 그중 어디까지 막는지 확인했다.
## 결론
생산자 빈은 둘이다. KafkaSenderConfig:63 이 Producer<String, String> 을, KafkaMessagingAutoConfiguration:130 이 Producer<byte[], byte[]> 를 만든다. modules.json 상 두 모듈은 서로를 의존하지 않는다.
조건은 달라 보인다. 앞쪽은 클래스에 app.messaging.broker 값 조건 하나만 달고, 뒤쪽은 MessagingPlatformRootAutoConfiguration:28 의 마스터 스위치를 지난 뒤 MessagingProviderSelection:48 이 같은 값으로 고른다.
그런데 앞쪽도 스위치 뒤에 있다. kafkaSeamProducer 가 받는 KafkaAdapterSettings 는 KafkaAdapterConfig:19 만 등록하고, 그 클래스를 수입하는 main 코드는 MessagingBridgeRootAutoConfiguration:23 하나이며 그 루트가 :21 에서 마스터 스위치를 요구한다. 다른 경로도 없다 — CaSkeletonApplication 의 스캔 제외 정규식이 그 패키지를 잘라 내고 @ConfigurationPropertiesScan 목록에도 없다.
ShippedCompositionHarness 로 컴포지션 루트를 세 조합으로 띄웠다. 스위치를 끈 채 브로커 이름만 주면 kafkaSeamProducer 가 KafkaAdapterSettings 를 못 찾아 기동이 실패한다. 스위치까지 켜면 그 생산자가 만들어진다. :62 의 빈 조건은 정의 등록 시점을 보는 것이라 이 클래스는 물러나지 않는다.
브로커 이름만 주고도 통과하는 시험 둘이 있는데 둘 다 슬라이스다. MessagingConfigTest:41 과 OptionalAdapterBeanGatingTest:68 이 KafkaAdapterConfig 를 손으로 넣고, 어느 쪽도 CaSkeletonApplication 이나 KafkaSenderConfig 를 참조하지 않는다.
CapabilityDependencyValidator:70 은 스위치가 켜졌는데 브로커가 빈 경우만 잡는다. 런북은 :28~:30 에서 이 키의 성격을 선택자로 못박는다. 출하 설정은 두 키를 늘 짝으로 주므로 이 조합에 닿지 않는다.
판정은 P2 이고 원문과 같다. 다만 받치는 근거가 하나 교체된다. 문서보다 좁은 기본 보호 범위 대신, 검증기를 통과하는 조합이 컨텍스트를 죽인다는 사실이 들어온다.
## 검증 환경
확인 방식 : 두 @Bean 선언과 각각의 조건 애너테이션 확인, 설정 타입의 등록 지점과 그것을 수입하는 자리 전수, 컴포넌트 스캔 제외 정규식과 프로퍼티 스캔 목록 확인, ShippedCompositionHarness 로 컴포지션 루트를 세 조합으로 기동, 브로커 값을 쓰는 시험 파일별 스위치 지정 횟수 계수와 그 시험들의 러너 구성 확인, 능력 의존 검증기의 조건 확인, 런북 서술과 출하 설정 파일의 두 키 확인, modules.json 의 의존 방향 확인
소스 수정 : x
## 재현 조건
1. Kafka Producer 를 만드는 @Bean 을 main 에서 모두 찾아 시그니처와 빈 이름을 적는다.
2. 각 빈에 걸린 조건 애너테이션을 클래스 단위와 메서드 단위로 나눠 읽는다.
3. 앞쪽 빈이 파라미터로 받는 설정 타입을 등록하는 자리를 main 에서 전수로 찾는다.
4. 그 등록 클래스를 @Import 하거나 자동설정으로 올리는 자리를 찾고 각각의 조건을 읽는다.
5. 컴포넌트 스캔 제외 정규식과 @ConfigurationPropertiesScan 목록이 그 패키지를 덮는지 본다.
6. ShippedCompositionHarness 로 컴포지션 루트를 세 조합으로 띄우고 실패한 빈과 없는 타입을 적는다.
7. 브로커 값을 쓰는 시험 파일마다 스위치를 몇 줄 주는지 세고, 0 줄인 파일의 러너 구성을 읽는다.
8. 능력 의존 검증기가 어떤 조합을 위반으로 모으는지 조건을 읽는다.
9. 런북과 출하 설정 파일이 두 키를 어떻게 다루는지 확인한다.
## 본문
<!-- body:start -->
`app-bootstrap` 하나에 Kafka `Producer` 를 만드는 `@Bean` 이 둘 있다. `MSG-015` 가 그 상태를 미해결로 들고 있다(`src/messaging/CLAUDE.md:70`, `:82`).
## 두 생산자 빈과 각각의 조건
:::evidence key="a19-f014-kafka-msg" alt="저장소 루트에서 돌린 정적 검색 출력 140줄. 두 Producer 빈의 선언 줄과 각각의 조건이 나온다 — KafkaSenderConfig 는 클래스에 app.messaging.broker=kafka 조건 하나를 달고 Producer<String,String> 을 만들고, KafkaMessagingAutoConfiguration 은 Producer<byte[],byte[]> 를 만들며 MessagingPlatformRootAutoConfiguration 의 app.messaging.enabled=true 와 MessagingProviderSelection 의 제공자 선택을 거쳐 닿는다. 이어서 KafkaAdapterSettings 를 등록하는 유일한 자리와 그 자리를 @Import 하는 유일한 루트, 그 루트의 조건이 나오고, CaSkeletonApplication 의 컴포넌트 스캔 제외 정규식과 @ConfigurationPropertiesScan 목록이 adapter.outbound.messaging 을 덮지 않는 것이 보인다. app.messaging.broker=kafka 를 쓰는 시험 파일마다 app.messaging.enabled 를 몇 줄 주는지 세어 보이고, 그중 0 줄인 두 파일이 KafkaAdapterConfig 를 손으로 등록하며 CaSkeletonApplication 도 KafkaSenderConfig 도 참조하지 않는 것이 나온다. 마지막으로 이 조합을 거르지 않는 CapabilityDependencyValidator 의 조건, 이 키를 선택자라고 설명하는 런북, 두 키를 항상 짝으로 주는 출하 설정, 그리고 KafkaSenderConfig 의 빈 조건 애너테이션이 실린다." caption="두 Producer 빈과 조건 · 설정 빈의 유일한 등록·수입 경로 · 스캔 제외 정규식 · broker 만 준 시험이 통과하는 이유 · 이 조합을 거르지 않는 검증기와 그것을 권하는 런북 · 출하 설정의 짝 — 140줄 · exit 0" zoom="true"
:::
`KafkaSenderConfig:63``Producer<String, String> kafkaSeamProducer` 를 만든다. `:61` 이 빈 이름을 `kafkaSeamProducer` 로 고정하고 `:62` 가 같은 이름의 빈이 없을 때만 만든다는 조건을 단다. 클래스에는 `:38``@ConditionalOnProperty(name = "app.messaging.broker", havingValue = "kafka")` 하나가 붙어 있다.
`KafkaMessagingAutoConfiguration:130``Producer<byte[], byte[]> messagingKafkaProducer` 를 만든다. 여기까지 오는 길은 `MessagingPlatformRootAutoConfiguration:28``app.messaging.enabled=true` 를 지나고, `MessagingProviderSelection:48``kafka` 라는 값에 이 자동설정을 물린다.
`modules.json``adapter-outbound-messaging` 의 의존에 `messaging-*` 이 없고, `messaging-spring-boot-starter` 의 의존에 adapter 가 없다. 두 스택은 서로를 참조하지 않는다.
## KafkaSenderConfig 도 app.messaging.enabled=true 를 지나야 조립된다
조건 애너테이션이 하나뿐이라고 해서 그 하나만으로 조립된다는 뜻은 아니다.
`kafkaSeamProducer` 가 파라미터로 받는 `KafkaAdapterSettings` 를 등록하는 자리는 `KafkaAdapterConfig:19``@EnableConfigurationProperties` 하나뿐이다. 그 클래스를 `@Import` 하는 main 코드는 `MessagingBridgeRootAutoConfiguration:23` 하나이고, 그 루트는 `:21` 에서 `app.messaging.enabled=true` 를 요구한다.
다른 경로로 들어올 수도 없다. `CaSkeletonApplication:100`\~`:105``AUTO_CONFIGURED_PACKAGES` 정규식이 `dev\.caskeleton\.adapter\.outbound\.messaging\..*` 를 컴포넌트 스캔에서 잘라 내고(`:55`\~`:56`), `@ConfigurationPropertiesScan` 목록에도 그 패키지가 없다.
`KafkaSenderConfig` 자신은 `dev.caskeleton.bootstrap.messaging` 패키지에 있고 그 이름은 제외 정규식에 없다. 스캔으로 들어온다.
## 세 조합을 실제로 기동한 결과
:::evidence key="a19-f014-kafka-msg-probe" alt="출하 컴포지션 루트를 세 조합으로 기동한 프로브 출력 43줄. 세 조합 모두 ShippedCompositionHarness 의 requiredOperatorInputs 와 allOffArguments 를 받고 조합마다 인자를 덮어썼다. C1 은 마스터 스위치가 꺼지고 브로커 이름이 없는 조합인데 jpaSharedEM_entityManagerFactory 에서 실패한다. C2 는 브로커 이름만 kafka 로 덮어쓴 조합인데 실패한 빈이 kafkaSeamProducer 로 바뀌고 없는 것이 KafkaAdapterSettings 라고 나온다. C3 는 마스터 스위치까지 켠 조합인데 KafkaProducer 가 실제로 만들어져 bootstrap.servers 와 두 직렬화기와 security.protocol 이 찍히고, 그 뒤 C1 과 같은 JPA 빈에서 끝난다. 세 조합을 통틀어 만들어진 KafkaProducer 는 1 개다. 마지막 세 줄은 이 프로브의 클래스패스에 시험 출력이 함께 있어 JPA 저장소 스캔이 켜진다는 것과, C2 의 실패는 그보다 먼저 난다는 것을 밝힌다." caption="컴포지션 루트 세 조합 기동 — 브로커 이름만 더하면 실패 지점이 kafkaSeamProducer 로 바뀐다 · 스위치를 켜면 그 생산자가 만들어진다 · 관측된 KafkaProducer 1 개 · 프로브 클래스패스가 남긴 JPA 실패 명시 — 43줄 · exit 0" zoom="true"
:::
`ShippedCompositionHarness.shippedComposition("local")``requiredOperatorInputs()``allOffArguments()` 를 주고 세 조합으로 돌렸다. `allOffArguments()``--app.messaging.enabled=false` 를 포함한다.
브로커 이름을 주지 않은 C1 에서는 messaging 쪽 빈이 만들어지지 않는다. 브로커 이름만 `kafka` 로 덮어쓴 C2 에서는 실패한 빈이 `kafkaSeamProducer` 이고, 없다고 보고된 것이 `KafkaAdapterSettings` 다. 마스터 스위치까지 켠 C3 에서는 그 생산자가 실제로 만들어져 `bootstrap.servers = [localhost:9092]`, 두 직렬화기가 `StringSerializer`, `security.protocol = PLAINTEXT` 로 찍힌다.
`:62``@ConditionalOnMissingBean(name = "kafkaSeamProducer")` 은 빈 정의를 등록할지 정하는 조건이다. 그 시점에 같은 이름의 빈이 없으므로 정의는 등록되고, 실패는 정의를 실체로 만들 때 파라미터를 못 찾아서 난다. `KafkaSenderConfig` 는 물러나지 않는다.
이 프로브의 클래스패스에는 `app-bootstrap` 의 시험 출력이 함께 있어 Spring Data JPA 저장소 스캔이 켜진다. 그래서 C1 과 C3 는 끝에서 `entityManagerFactory` 부재로 죽는다. C2 의 실패는 그보다 먼저 난다.
## 슬라이스 시험이 보여 주는 것과 다른 것
`app.messaging.broker=kafka` 를 쓰는 시험 파일 넷 중 둘은 `app.messaging.enabled` 를 한 줄도 주지 않는다. `MessagingConfigTest``OptionalAdapterBeanGatingTest` 다.
그 둘은 `ApplicationContextRunner` 에 설정 클래스를 골라 넣는 슬라이스다. `MessagingConfigTest:41``OptionalAdapterBeanGatingTest:68``KafkaAdapterConfig` 를 직접 등록한다. 마스터 스위치 뒤에 있는 것을 손으로 넣으므로 스위치를 지날 필요가 없다.
두 파일 모두 `CaSkeletonApplication` 을 참조하지 않고 `KafkaSenderConfig` 도 참조하지 않는다. 컴포넌트 스캔이 없으니 실패하는 빈 자체가 그 슬라이스에 없다.
`MessagingConfigTest` 의 broker 전용 시험 둘은 이름이 `selectedKafkaBrokerWithoutProjectSenderFailsStartupCharacterization`(`:39`)과 `selectedBrokerIdMismatchFailsStartupCharacterization`(`:54`)이다. 선택자만 준 조합을 기동 실패로 특성화한다.
## 이 조합을 거르는 검증기가 없다
`CapabilityDependencyValidator:70``app.messaging.enabled=true` 인데 브로커가 비어 있으면 위반으로 모은다. 반대 조합은 조건에 없다.
`docs/runbooks/outbox-publish-failed.md:28`\~`:30``APP_MESSAGING_BROKER` 가 활성화 스위치가 아니라 선택자이고 messaging 을 끄는 것은 `APP_MESSAGING_ENABLED=false` 라고 적는다. 그 설명대로 스위치를 끈 채 선택자만 남기면 C2 가 된다.
출하 설정은 그 조합에 닿지 않는다. `compose-profile-contracts.json` 의 세 자리(`:170`, `:202`, `:604`)가 두 키를 항상 짝으로 주고, `.env.example:229``.env.local.example:21` 은 브로커를 공백으로 둔다.
## 원문과 갈리는 자리
원문은 `app.messaging.enabled=false` 인 기본 상태에서 브로커 이름만 주면 `KafkaSenderConfig` 쪽 스택만 올라온다고 적었고, 그래서 마스터 스위치가 두 스택 중 하나만 막는다고 했다.
C2 가 그 반대를 보인다. 그 조합에서 올라오는 것은 없고 컨텍스트가 `kafkaSeamProducer` 에서 죽는다. 마스터 스위치는 두 스택을 모두 막는다. 원문이 정정 대상으로 지목한 `src/messaging/CLAUDE.md:82` 의 서술 — 지금 안전한 이유가 설계가 아니라 `app.messaging.enabled=false` 라는 기본값이라는 것 — 은 그대로 성립한다.
원문이 인용한 `KafkaSenderConfig:23`\~`:27` 의 자바독은 정확하다. 다만 그 자바독이 없다고 적은 빈은 `KafkaSender` 이고, C2 에서 없는 것은 `KafkaAdapterSettings` 다. 층이 다르다.
## 등급에 대해
원본 분석의 등급은 P2 이고 이 기록은 그대로 둔다. 다만 등급을 받치던 근거 하나가 바뀐다.
기본값이 지켜 주는 범위가 문서보다 좁다는 것은 성립하지 않는다. 남는 근거는 한 아티팩트에 서로를 참조하지 않는 Kafka 스택이 둘이라는 것이고, 이것은 정상 운영 조합에서 빈 이름이 달라 기동이 성공하므로 알려 주는 신호가 없다.
그 자리에 들어오는 근거가 하나 늘었다. 검증기가 통과시키고 런북의 설명이 이끄는 조합이 기동을 죽인다. 출하 설정이 그 조합에 닿지 않아 P2 를 넘기지는 않는다.
## 확인하지 못한 것
두 생산자가 한 컨텍스트에 함께 등록된 것을 보지 못했다. 스위치를 켠 조합에서 `kafkaSeamProducer` 하나가 만들어진 데까지 갔다.
두 생산자가 같은 클러스터에 붙는지 확인하지 않았다. 각자 어느 설정에서 주소를 읽는지까지 봤다.
프로브의 클래스패스는 출하 클래스패스가 아니다. `app-bootstrap` 시험 출력이 함께 있어 JPA 저장소 스캔이 켜지고, C1 과 C3 는 그 때문에 끝에서 죽는다.
프로브의 클래스패스는 출하 클래스패스가 아니다. `app-bootstrap` 시험 출력이 함께 있어 JPA 저장소 스캔이 켜지고, C1 과 C3 는 그 때문에 끝에서 죽는다.
<!-- body:end -->
@@ -0,0 +1,130 @@
---
kind: CASE
slug: a19-f015-compatibilitymatrix-extension
title: CompatibilityMatrix 의 EXTENSION 등급을 쓰는 항목이 없다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a19-f015-compatibilitymatrix-extension
evidenceCapturedOn: 2026-09-04
body: case-a19-f015-compatibilitymatrix-extension.body.md
assets:
- key: a19-f015-compatibilitymatrix-extension
file: ../../../final/evidence/rendered/a19-f015-compatibilitymatrix-extension.svg
evidence:
- ../../../final/evidence/raw/a19-f015-compatibilitymatrix-extension.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L801 이다.
---
# CompatibilityMatrix 의 EXTENSION 등급을 쓰는 항목이 없다
`messaging-testkit``CompatibilityMatrix` 가 어댑터 지원 등급을 `Tier` 셋으로 두는데 `ENTRIES` 다섯 중 `EXTENSION` 을 쓰는 항목이 0 이다. 그 등급 설명에 해당하는 `messaging-spring-cloud-stream-bridge` 는 브로커 버전을 인증하지 않아 `Entry` 생성자를 통과할 수 없다.
## 관계
- **build-only 등급이 90개 파일의 미조립을 오늘의 사고에서 면제한다**
그 기록은 빌드에만 참여하는 모듈이 조립 검사에서 빠지는 것을 다룬다. 이 leaf 도 `runtime_memberships` 가 비어 있는데, 여기서 어긋난 것은 조립이 아니라 등급 표기다.
- **기계가 읽는 능력 매니페스트와 사람이 읽는 등급표가 커서 서명에 대해 다르게 답한다**
둘 다 코드가 든 표와 운영자가 읽는 문서 표가 같은 대상에 대해 서로 다른 값을 적고 있는 경우다.
- **지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다**
그 결정은 증거가 있을 때만 등급을 올린다고 정한다. 여기서는 그 규칙 이전에, 등급 값 하나가 어떤 항목에도 붙지 못한 채 열거형에만 남아 있다.
## 문제
지원 등급이 messaging-testkit 의 CompatibilityMatrix 안에 표로 들어 있다.
세 등급 중 하나가 어느 항목에도 쓰이지 않는데, 그 등급 설명에 맞는 모듈이 저장소에 있다.
## 결론
Tier 값 셋 중 EXTENSION 을 쓰는 항목이 ENTRIES 에 없다. 다섯 항목은 messaging-kafka 하나가 STABLE 이고 나머지 넷이 EXPERIMENTAL 이다.
그 등급 설명이 말하는 어댑터 SPI 전용·지원 집합 밖에 해당하는 모듈은 messaging-spring-cloud-stream-bridge 다. main 6 파일 507 줄인데 표에 이름이 없다.
행이 되지 못하는 기계적 이유가 있다. Entry 의 간결 생성자는 브로커 버전 목록이 비면 거부한다. 이 모듈은 브로커가 아니라 Spring Cloud Stream 바인더 위의 이음매라 인증할 브로커 버전이 없다.
운영자용 docs/messaging/support-matrix.md 는 등급을 한 표로 관리하지 않는다. 앞쪽 표에서 Extension 이 붙은 행은 :37 하나뿐이다. 거기 적힌 어댑터 이름을, 코드 쪽 시험은 표에 없는 이름의 예시로 쓴다. 기능 등급 표(:66)의 :78 에는 이 bridge 가 있고 등급 칸이 Optional 인데, 그 낱말은 Tier 에 없다.
이 타입을 참조하는 main 코드는 자기 파일 하나뿐이다. 미등록 이름을 거부한다는 보장이 걸리는 범위는 시험과 계약 스위트다.
판정은 P3 이고 원문과 같다. 다만 원문은 이 bridge 가 운영자 문서에도 없다고 적었는데 :78 에 행이 있다.
## 검증 환경
확인 방식 : Tier 열거값과 각 자바독 확인, ENTRIES 항목 수와 등급별 계수, bridge leaf 의 main 파일 수·줄 수와 modules.json 의 runtime_memberships 확인, Entry 간결 생성자의 거부 조건 확인, of 의 거부 줄과 그것을 고정하는 시험 확인, 그 시험이 넘기는 이름 검색, CompatibilityMatrix 를 쓰는 main 코드 계수, 운영자 문서의 등급 표 전수와 각 표의 해당 행 확인, bridge 자바독과 정책 가드의 조건·예외 코드 확인
소스 수정 : x
## 재현 조건
1. CompatibilityMatrix.Tier 의 값 셋과 각 값의 자바독을 읽는다.
2. ENTRIES 의 항목을 세고 각 항목의 Tier 를 모은다.
3. 등급별 사용 횟수를 집계해 쓰이지 않는 값을 찾는다.
4. 그 값의 설명에 해당하는 모듈을 저장소에서 찾고 파일 수와 줄 수를 잰다.
5. modules.json 에서 그 모듈의 runtime_memberships 를 읽는다.
6. Entry 의 간결 생성자가 무엇을 거부하는지 읽고, 그 모듈이 그 조건을 만족할 수 있는지 본다.
7. of 가 등록되지 않은 이름을 어떻게 처리하는지와 그것을 고정하는 시험, 그 시험이 넘기는 이름을 확인한다.
8. CompatibilityMatrix 를 자기 파일 밖에서 쓰는 코드를 소스 세트별로 센다.
9. 운영자 문서에서 등급 표를 전부 찾고, 각 표에서 그 등급 값과 그 모듈 이름이 나오는 행을 읽는다.
## 본문
<!-- body:start -->
`messaging-testkit``CompatibilityMatrix` 는 어댑터별 지원 등급을 코드 안의 표로 들고 있다. `Tier` 열거값은 셋이고, `ENTRIES` 는 다섯 항목이다.
## Tier 셋과 ENTRIES 다섯
:::evidence key="a19-f015-compatibilitymatrix-extension" alt="저장소 루트에서 돌린 정적 검색 출력 73줄. CompatibilityMatrix.java 의 Tier 열거값 셋이 각각의 자바독과 함께 나오고, ENTRIES 다섯 항목의 어댑터 이름과 Tier 가 이어지며 STABLE 1 · EXPERIMENTAL 4 · EXTENSION 0 으로 집계된다. messaging-spring-cloud-stream-bridge 는 main 6 파일 507 줄에 modules.json 의 runtime_memberships 가 빈 리스트이고 ENTRIES 에 이름이 0 건이다. Entry 의 간결 생성자가 어댑터 이름 공백과 브로커 버전 목록 비어 있음을 각각 IllegalArgumentException 으로 거부하는 본문이 실리고, of 가 등록되지 않은 이름을 거부하는 줄과 그것을 고정하는 시험이 나오는데 그 시험이 미등록 이름의 예로 messaging-artemis 를 넘긴다. 같은 이름이 다른 시험의 픽스처와 계약 스위트에도 나오고, CompatibilityMatrix 를 자기 파일 밖에서 쓰는 main 코드는 0 건이다. 운영자 문서에는 등급 표가 브로커 등급과 기능 등급 둘로 있고 앞쪽의 유일한 Extension 행이 Artemis/JMS, 뒤쪽 78번 줄이 Spring Cloud Stream bridge 를 Optional 로 적는다. 마지막으로 bridge 자신의 자바독과 StreamBridgePolicyGuard 의 조건 넷이 각각의 예외 코드와 함께 나온다." caption="Tier 셋과 ENTRIES 의 등급 분포 · 표 밖의 bridge leaf · Entry 가 요구하는 브로커 버전 · 미등록 이름 거부와 그 시험이 쓰는 이름 · 자기 파일 밖 main 사용 0 · 운영자 문서의 등급 표 둘 · 가드의 조건 넷과 예외 코드 — 73줄 · exit 0" zoom="true"
:::
`:23`\~`:32``Tier``STABLE`, `EXPERIMENTAL`, `EXTENSION` 셋이다. `:30``EXTENSION` 에는 어댑터 SPI 전용이고 지원 집합 밖이라는 설명이 붙어 있다.
`ENTRIES` 는 다섯이다. `messaging-kafka``STABLE`(`:85`), 나머지 넷 — `messaging-rabbit`(`:92`), `messaging-kafka-share-experimental`(`:97`), `messaging-pulsar-experimental`(`:104`), `messaging-nats-experimental`(`:109`) — 이 `EXPERIMENTAL` 이다. `Tier.EXTENSION` 을 쓰는 항목은 0 이다.
## ENTRIES 밖에 있는 messaging-spring-cloud-stream-bridge
`messaging-spring-cloud-stream-bridge` 는 main 6 파일 507 줄이고 `ENTRIES` 에 이름이 없다.
`MessagingBindingBridge:8` 의 자바독에는 상호운용 이음매이지 두 번째 메시징 API 가 아니라고 적혀 있다. `StreamBridgePolicyGuard.validate` 는 네 자리에서 거부한다 — `:30` 은 스위치가 꺼져 있으면 `STREAM_BRIDGE_DISABLED`, `:36` 은 순서 범위를 선언한 목적지를 `STREAM_BRIDGE_ORDERING_UNSUPPORTED`, `:41` 은 재시도 모드가 `NONE` 이 아니면 `STREAM_BRIDGE_RETRY_UNSUPPORTED`, `:46` 은 데드레터가 켜져 있으면 `STREAM_BRIDGE_DLQ_UNSUPPORTED` 로 던진다. 뒤 셋의 메시지는 그런 목적지가 네이티브 어댑터로 가야 한다고 적는다.
`EXTENSION` 자바독은 어댑터 SPI 전용이고 지원 집합 밖이라고 적고, 이 leaf 의 자바독은 두 번째 메시징 API 가 아니라고 적는다. 둘 다 지원 집합 밖에 두는 서술인데 이 leaf 는 `ENTRIES` 에 없다.
## Entry 생성자가 브로커 버전을 요구한다
`Entry` 의 간결 생성자(`:66`\~`:74`)는 어댑터 이름이 공백이면 거부하고(`:67`), 브로커 버전 목록이 비어 있으면 어댑터는 적어도 하나의 브로커 버전을 인증해야 한다는 메시지로 `IllegalArgumentException` 을 던진다(`:70`\~`:71`).
이 leaf 는 브로커가 아니라 Spring Cloud Stream 바인더 위의 이음매다. 인증할 브로커 버전이 없으므로 지금 형태로는 `ENTRIES` 의 행이 될 수 없다. `Tier.EXTENSION` 이 비어 있는 것은 아무도 채우지 않아서만은 아니고, 그 등급 설명에 맞는 대상이 이 record 의 요구를 통과하지 못하기 때문이기도 하다.
## 미등록 이름을 of 에 넘겼을 때
`of`(`:126`)는 `ENTRIES` 에 없는 이름을 받으면 `:129` 에서 그 이름이 호환성 표에 없다는 메시지로 던진다. `CompatibilityMatrixTest:83``anUnknownAdapterIsNotSilentlyTreatedAsSupported` 가 그 동작을 고정하는데, 그 시험이 `of` 에 넘기는 미등록 이름이 `"messaging-artemis"` 다(`:84`).
## 운영자 문서의 두 등급 표
`docs/messaging/support-matrix.md` 에는 등급 표가 둘이다. `:29``## 브로커 등급``:66``## 기능 등급` 이다.
앞쪽 표에서 `Extension` 이 붙은 행은 하나뿐이고 `:37``Artemis/JMS` 다. 인증 기준은 범위 밖, Stable 기능은 adapter SPI만이라고 적혀 있다 — `Tier.EXTENSION` 자바독과 같은 내용이다. 그런데 코드의 시험은 같은 `messaging-artemis` 를 등록되지 않은 이름의 예로 쓴다.
뒤쪽 표 `:78` 에 이 leaf 가 있다. 등급 칸의 값은 `Optional` 이고, 그것은 `Tier` 에 없는 낱말이다.
## 이 표를 읽는 main 코드가 없다
`CompatibilityMatrix` 를 자기 파일 밖에서 쓰는 main 코드는 0 건이다. `of` 의 거부도 `hasLiveBrokerCertification`(`:60`)도 시험과 계약 스위트 안에서만 불린다. 등록되지 않은 이름을 조용히 지원으로 두지 않는다는 보장은 시험 경로에 대한 것이지 런타임에 대한 것이 아니다.
## 원문과 갈리는 자리
원문은 이 leaf 가 코드의 표에도 운영자 문서의 표에도 없다고 적었다. 문서 쪽은 그렇지 않다. `:78` 에 행이 있고 등급 칸이 `Optional` 로 채워져 있다. 원문이 본 것은 `:29` 의 브로커 등급 표이고, 이 leaf 는 `:66` 의 기능 등급 표에 있다.
원문은 `Tier.EXTENSION` 이 쓰이지 않는 것을 미조립의 한 사례로 묶었다. `runtime_memberships` 가 비어 있는 것은 맞지만, 이 record 에 대해서는 `Entry` 의 브로커 버전 요구가 별도의 이유로 작용한다.
## 확인하지 못한 것
JMS 계열 어댑터가 다른 모듈명으로 존재할 가능성은 좁히지 않았다. `src/messaging` 아래에 해당 디렉터리가 없다는 데까지다.
`EXTENSION` 항목을 실제로 추가해 생성자에서 거부되는 것을 실행으로 보이지 않았다. 거부 조건을 코드로 읽은 데까지다.
두 등급 표의 어휘가 다른 것이 의도인지 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,143 @@
---
kind: CASE
slug: a19-f020-messaging-admin-api
title: messaging-admin-api 의 시험 한 파일이 아홉을 담고 만료까지 단언한다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a19-f020-messaging-admin-api
evidenceCapturedOn: 2026-09-04
body: case-a19-f020-messaging-admin-api.body.md
assets:
- key: a19-f020-messaging-admin-api
file: ../../../final/evidence/rendered/a19-f020-messaging-admin-api.svg
evidence:
- ../../../final/evidence/raw/a19-f020-messaging-admin-api.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L997 이다.
---
# messaging-admin-api 의 시험 한 파일이 아홉을 담고 만료까지 단언한다
`messaging-admin-api` 는 main 25 파일 1,613 줄인데 test 는 `DestructiveOperationGuardTest` 한 파일 147 줄이다. 그 한 파일이 담은 `@Test` 는 아홉이고, 만료된 승인에 대한 거부까지 그 안에 있다.
## 관계
- **messaging-reliability-api는 main 13파일 · 817 LOC에 테스트가 0개다**
두 기록 모두 같은 가족의 leaf 에서 main 대비 test 파일 수를 세는 데서 출발한다. 그 leaf 는 test 소스 세트 자체가 없어 계수가 그대로 결론이 되지만, 여기서는 시험 파일 하나를 열어 아홉이 무엇을 단언하는지 봐야 했다.
- **브로커 ACL 매니페스트의 자기 점검이 존재하지 않는다**
저 기록은 `BrokerAclManifest` 의 검사 메서드가 코드에 있는데 부르는 main 코드가 없는 경우다. 여기는 검증자를 만드는 코드가 시험 네 파일에만 있는데, 왜 그래야 하는지가 그 타입의 자바독에 적혀 있다.
- **admin 스위치가 가드를 켜고 서비스는 켜지 않는다**
저 기록은 관리 스위치를 켜도 서비스 빈이 생기지 않는 것을 빈 목록에서 확인했다. 여기서는 같은 사슬의 가드가 `@Bean` 으로 존재하고 검증자만 test 에서 만들어진다는 것을 소스 세트로 확인했다.
## 문제
관리 평면의 계약 타입이 messaging-admin-api 에 스물다섯 개 있고 그 leaf 의 시험 파일은 하나다.
그 하나가 무엇을 덮고 나머지 검증이 어디 있는지 확인했다.
## 결론
원문이 적은 계수는 맞다.
DestructiveOperationGuardTest 가 담은 @Test 는 아홉이다. 승인 요구와 만료 거부와 승인 통과가 각각 :63, :73, :102 에 있는데, 셋 다 단언이 걸리는 대상은 DestructiveOperationGuard 가 내는 예외 코드와 메시지다. 아홉 전부가 승인 객체를 한 헬퍼에서 얻는데, 그 헬퍼의 자바독은 아무나 생성할 수 있던 record 를 서명 없이는 얻을 수 없는 타입으로 바꾼 것이 목적이라고 적는다. 재구동의 자기 대상 금지(:113), 배치 상한(:119), TopologyManifest 의 차이 보고(:128, :140)도 같은 파일이다.
실제 시험은 대부분 messaging-admin-runtime 에 있다. 그 leaf 의 시험 6 파일 1,051 줄이 시험 51 개를 담고, 그중 ApprovalForgeryTest 열 개가 승인 위조 경로를 덮는다.
HmacApprovalVerifier 를 생성하는 시험 파일은 두 leaf 에 넷이고 생성 지점은 다섯 줄이다. 그 타입을 만드는 main 코드는 0 건이다.
그것이 공백은 아니다. HmacApprovalVerifier:50~:53 의 자바독이 그 부재를 설계로 못박는다 — 키 보관자가 곧 발급자이므로 실행 런타임에 두어서는 안 된다는 것이다. 같은 사슬의 DestructiveOperationGuard 는 MessagingAdminAutoConfiguration:37 이 @Bean 으로 만들며 false 를 넘기는데, 그 인자가 같은 경계를 값으로 적은 것이다.
PlanDigest, ApprovalGrant, TopologyManifest 는 그 이름을 건 시험 파일이 모두 0 개다. 세 타입을 참조하는 test 파일은 각각 6, 4, 4 인데 그 시험들은 이름이 가리키는 다른 대상을 단언한다.
판정은 P3 이고 원문과 같다. 원문이 미확인으로 남긴 예 둘 가운데 ApprovalGrant 의 만료는 DestructiveOperationGuardTest:73 과 ApprovalForgeryTest:158 이 단언하고, PlanDigest 의 정규화만 미확인으로 남는다.
## 검증 환경
확인 방식 : messaging-admin-api 와 messaging-admin-runtime 의 소스 세트 목록과 파일 수·줄 수 계수, 계약 leaf 시험 파일의 @Test 수와 메서드 이름 전수, 소비자 leaf 시험 파일별 @Test 수 계수와 ApprovalForgeryTest 메서드 전수, 계약 leaf 의 main 타입 전수, ApprovalVerifier 의 main 구현과 HmacApprovalVerifier 생성 지점을 leaf·소스 세트별로 분리, 그 타입의 자바독과 MessagingAdminAutoConfiguration 의 가드 빈 확인, 계약 타입 여섯의 main·test 참조 파일 수와 그 이름을 건 시험 파일 검색, PlanDigest 의 test 참조 전수
소스 수정 : x
## 재현 조건
1. messaging-admin-api 와 messaging-admin-runtime 의 소스 세트를 나열하고 각각의 파일 수와 줄 수를 센다.
2. 계약 leaf 의 시험 파일을 열고 @Test 수와 메서드 이름을 줄 순서대로 나열한다.
3. 각 이름이 무엇을 단언하는지 읽고 계약 타입과 짝짓는다.
4. 소비자 leaf 의 시험 파일마다 @Test 수를 세고, 승인 위조 시험의 메서드를 전부 읽는다.
5. 계약 leaf 의 main 타입을 전부 나열한다.
6. ApprovalVerifier 의 main 구현을 찾고, HmacApprovalVerifier 를 생성하는 자리를 leaf 와 소스 세트로 갈라 센다.
7. 그 타입의 자바독에서 main 에 두지 않는 이유가 적혀 있는지 읽는다.
8. 같은 사슬의 다른 타입이 자동 설정에서 빈으로 만들어지는지 확인하고 인자를 읽는다.
9. 계약 타입 몇 개의 main·test 참조 파일 수를 세고, 그 이름을 건 시험 파일을 *Test·*Tests·*IT 로 넓혀 찾는다.
10. 만료를 단언하는 자리를 검색하고, PlanDigest 가 test 에서 어떻게 쓰이는지 전수로 읽는다.
## 본문
<!-- body:start -->
`messaging-admin-api` 는 관리 평면의 계약 타입을 담는 leaf 다. main 25 파일 1,613 줄에 test 는 `DestructiveOperationGuardTest` 한 파일 147 줄이다. 그 비율만 보면 승인 사슬이 검증되지 않은 것처럼 읽힌다.
## DestructiveOperationGuardTest 한 파일이 담은 시험 아홉
:::evidence key="a19-f020-messaging-admin-api" alt="저장소 루트에서 돌린 정적 검색 출력 95줄. messaging-admin-api 와 messaging-admin-runtime 이 각각 main test 두 소스 세트만 갖고 25/1613·1/147, 12/1253·6/1051 이라는 계수가 먼저 나온다. 계약 leaf 의 유일한 시험 파일이 @Test 아홉을 담고 그 메서드 이름과 줄 번호가 줄 순서대로 나열되며, 이어서 그 시험들이 승인 객체를 얻는 통로인 헬퍼의 자바독과 verify 호출 줄, 각 단언이 기다리는 문자열, 그리고 가드가 그 문자열을 내는 자리가 나온다. 소비자 leaf 시험 여섯의 @Test 개수가 이어지고 그중 승인 위조 시험 열 개의 메서드 이름이 모두 나온다. 계약 leaf 의 main 타입 스물다섯이 이름으로 실리고, ApprovalVerifier 의 main 구현이 하나이며 HmacApprovalVerifier 생성 지점이 다섯 곳 파일 네 개로 전부 test 소스 세트라는 것이 leaf 이름과 함께 나온다. 이어서 그 검증자를 실행 런타임에 두지 않는 이유를 적은 자바독 네 줄과, 같은 사슬의 가드를 @Bean 으로 만드는 자동 설정 일곱 줄이 원문 그대로 실린다. 마지막으로 계약 타입 여섯의 main·test 참조 파일 수와 그 이름을 건 시험 파일이 모두 0 개라는 것, 만료를 고정하는 두 자리, 그리고 PlanDigest 가 test 에서 쓰이는 열두 줄이 나온다. 긴 문자열 리터럴은 가려져 있다." caption="두 leaf 의 소스 세트와 계수 · 계약 leaf 시험 아홉의 이름과 단언 대상 · 승인 객체를 얻는 헬퍼 · 소비자 시험 여섯과 위조 시험 열 · main 타입 스물다섯 · 검증자 생성 지점 다섯 곳 전부 test · 그것이 경계라고 적은 자바독과 main 빈으로 있는 가드 · 타입별 참조와 전용 시험 0 · 만료를 고정하는 두 자리 — 123줄 · exit 0" zoom="true"
:::
파일 이름은 `DestructiveOperationGuard` 만 가리키는데 `@Test` 는 아홉이다.
이름에 승인이 들어간 셋이 승인 판정을 단언한다. `anAdminRuntimeStillNeedsAnApproval`(`:63`)이 승인 없는 관리 런타임을 거부하는 것을, `anExpiredApprovalDoesNotAuthorise`(`:73`)가 만료된 승인이 권한을 주지 않는 것을, `anApprovedAdminOperationIsAuthorised`(`:102`)가 승인된 연산이 통과하는 것을 단언한다.
셋의 단언이 떨어지는 곳은 모두 `DestructiveOperationGuard` 다. `:69``"approval"` 을, `:87``"validity window"` 를 기다리는데 그 문자열은 `DestructiveOperationGuard:66``APPROVAL_REQUIRED``:70``APPROVAL_EXPIRED` 메시지에서 온다. `:109` 는 예외가 없는 것만 본다.
셋이 검증자를 지나는 정도는 서로 다르다. `:63``Optional.empty()` 를 넘기므로 승인 객체를 만들지 않는다. `:102` 는 정적 필드 `VALID` 를 쓰는데 그것은 클래스 로딩 때 한 번 만들어진다. `:73` 만 자기 시험 안에서 만료 구간을 지정해 승인 객체를 새로 만든다.
승인 객체를 만드는 통로는 `verified(...)` 헬퍼 하나이고 `:47` 에서 `ISSUER.verify(grant, ISSUER.sign(grant), digest, from)` 을 부른다. 그 헬퍼의 자바독(`:30`\~`:32`)은 가드가 예전에는 아무나 생성할 수 있는 `AdminApproval` 을 그대로 받았고, 지금은 모든 경우가 실제 서명을 지나야 승인 객체를 얻는다고 적는다. 타입을 바꾼 목적이 그것이라는 것이다.
나머지 여섯은 줄 순서대로 이렇다. `anApplicationRuntimeCannotRedrive`(`:51`), `aDryRunIsAlwaysPermitted`(`:91`), `aRedriveCannotTargetItsOwnSource`(`:113`), `aRedriveBatchIsBoundedSoOneOperationCannotFloodTheSource`(`:119`), `aTopologyManifestReportsEveryDifference`(`:128`), `aMatchingTopologyReportsNoDifferences`(`:140`) 다. 뒤의 둘은 `TopologyManifest` 가 차이를 전부 보고하는 경우와 일치할 때 하나도 보고하지 않는 경우를 짝으로 단언한다.
## messaging-admin-runtime 의 시험 6 파일이 담은 51 개
소비자 leaf 는 `messaging-admin-runtime` 하나이고 main 12 파일 1,253 줄에 시험 6 파일 1,051 줄이다. `@Test` 수는 `TopologyValidatorTest` 13, `ApprovedPlanExecutionTest` 11, `ApprovalForgeryTest` 10, `AdminOperationJournalTest` 8, `RedriveResumptionTest` 5, `TopologyValidationRuntimeTest` 4 로 합계 51 이다.
`ApprovalForgeryTest` 열 개가 승인 사슬의 위조 경로를 덮는다. 검증자 밖에서 `VerifiedApproval` 을 만들 수 없다는 것(`:51`), 변조된 grant 가 검증되지 않는 것(`:69`), 다른 발급자의 서명이 검증되지 않는 것(`:84`), 한 계획의 승인이 다른 계획을 실행하지 못하는 것(`:96`), 만료된 grant 가 검증되지 않는 것(`:158`), 승인자와 운영자가 달라야 한다는 것(`:172`)이 각각 단언된다.
## HmacApprovalVerifier 를 main 에 두지 않는 것은 경계다
`ApprovalVerifier` 의 main 구현은 `HmacApprovalVerifier` 하나다. 그것을 생성하는 지점은 다섯 곳이고 파일은 넷이다. 하나는 `messaging-admin-api``DestructiveOperationGuardTest:21` 이고 나머지 셋은 `messaging-admin-runtime``ApprovalForgeryTest:42`·`:46`, `ApprovedPlanExecutionTest:37`, `RedriveResumptionTest:45` 다. main 에는 생성하는 코드가 없다.
그 부재가 검증 공백은 아니다. `HmacApprovalVerifier:50`\~`:53` 은 이 키를 쥔 쪽이 곧 발급자이며 그것이 연산을 실행하는 런타임에 있어서는 안 된다고 적는다.
같은 사슬의 다른 끝은 main 빈으로 있다. `MessagingAdminAutoConfiguration:35`\~`:41``@Bean @ConditionalOnMissingBean` 으로 `DestructiveOperationGuard` 를 만들면서 `false` 를 넘기고, 주석은 애플리케이션 런타임이 관리 자격을 갖지 않으므로 가드가 그런 연산을 거부한다고, 운영자 도구가 이 빈을 `true` 로 덮어쓴다고 적는다. 생성자에 넘기는 `false` 가 자바독이 말한 경계를 그대로 인코딩한 값이다.
## PlanDigest·ApprovalGrant·TopologyManifest 를 이름으로 건 시험 파일이 0 개다
계약 타입 여섯의 참조를 셌다. `PlanDigest` main 10 · test 6, `VerifiedApproval` main 7 · test 4, `TopologyManifest` main 5 · test 4, `ApprovalGrant` main 3 · test 4, `ApprovedRedrivePlan` main 2 · test 2, `ApprovedReplayPlan` main 2 · test 1 이다.
여섯 모두 그 이름을 건 시험 파일이 0 개다. `*Test.java` 뿐 아니라 이 저장소가 쓰는 `*IT.java``*Tests.java` 까지 넓혀 세도 0 이다.
만료는 두 자리가 고정한다. `DestructiveOperationGuardTest:73` 이 가드 쪽에서, `ApprovalForgeryTest:158``anExpiredGrantDoesNotVerify` 가 검증자 쪽에서 단언한다.
`PlanDigest` 는 다르다. test 에서 나오는 열두 줄이 전부 `PlanDigest.ofCanonical(...)` 로 다이제스트를 만들거나 파라미터로 받는 자리다. 정규화 자체를 단언하는 줄은 없다.
## 원문과 갈리는 자리
원문은 계약 leaf 의 test 를 `1 (DestructiveOperationGuardTest)` 로만 적었다. 파일 이름은 그렇지만 담긴 아홉 중 셋이 승인 판정을, 둘이 `TopologyManifest` 를 단언한다.
원문은 계약 자체의 경계 조건이 별도로 고정돼 있는지 확인되지 않는다고 적으면서 `ApprovalGrant` 의 만료를 예로 들었다. 그 예는 `DestructiveOperationGuardTest:73``ApprovalForgeryTest:158` 이 고정한다. `PlanDigest` 의 정규화만 남는다.
원문 §8.2 는 main 참조가 0 인 다섯 중 넷에는 이유가 적혀 있지 않다고 하면서 `HmacApprovalVerifier` 를 그 넷에 넣었다. 이 타입에는 이유가 자기 자바독에 적혀 있다.
## 계약 타입 목록에 대해
원문은 계약 타입을 열 개 들고 `등` 으로 닫았다. 이 leaf 의 main 타입은 스물다섯이고, 그 열에 없는 `AdminOperationLease``AdminOperationState``DestinationTopology` 도 같은 스물다섯에 들어 있다.
## 확인하지 못한 것
시험을 한 번도 돌리지 않았다. 세고 읽는 데 그쳤다.
아홉을 셋과 여섯으로 나눈 기준은 메서드 이름과 단언 대상이다. 실행 경로를 계측해 가른 것이 아니다.
다른 이름을 단 시험이 `PlanDigest` 의 정규화를 고정하고 있을 가능성은 test 참조 열두 줄까지 읽고 좁혔다.
두 leaf 의 시험을 실행하지 않았다. 파일과 `@Test` 를 세고 메서드 이름과 단언 대상을 읽은 데까지다.
<!-- body:end -->
@@ -0,0 +1,122 @@
---
kind: CASE
slug: a19-f022-messagingpublicsurfacecontracttest
title: 공개 표면 계약 시험이 가족 밖 app-bootstrap 에 있다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a19-f022-messagingpublicsurfacecontracttest
evidenceCapturedOn: 2026-09-04
body: case-a19-f022-messagingpublicsurfacecontracttest.body.md
assets:
- key: a19-f022-messagingpublicsurfacecontracttest
file: ../../../final/evidence/rendered/a19-f022-messagingpublicsurfacecontracttest.svg
evidence:
- ../../../final/evidence/raw/a19-f022-messagingpublicsurfacecontracttest.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L1114 이다.
---
# 공개 표면 계약 시험이 가족 밖 app-bootstrap 에 있다
지침이 이 규칙을 붙든다고 지목한 `MessagingPublicSurfaceContractTest` 가 가족 트리가 아니라 `src/app-bootstrap/src/test` 에 있다. 원문은 이것을 자동 검증이 없는 사례로 읽었는데, 경로 필터가 없는 `ci-quality-gates.yml` 이 모든 `pull_request` 에서 `./gradlew check` 를 돌리고 그 안에 이 시험이 들어 있다.
## 관계
- **아무도 돌리지 않는 레인의 게이트는 마지막으로 돌린 사람이 본 것을 보고한다**
원문이 이 사례를 그 규칙으로 읽었다. 그런데 이 시험은 경로 필터 없는 워크플로에 실려 PR 마다 새로 도므로, 인용된 문장이 겨냥한 상태가 아니다.
- **문서 계약 테스트의 단언 경계 밖에 발견된 드리프트 세 건이 전부 있었다**
둘 다 문서가 어떤 규칙을 시험이 지킨다고 적었는데, 그 시험이 실제로 붙드는 범위가 문서를 읽은 사람이 기대하는 범위보다 좁다. 저 기록은 단언 여덟 개 밖의 드리프트이고, 여기는 가족 태스크가 고르지 않는 소스 트리다.
## 문제
가족 지침은 :39 에서 이 규칙을 붙드는 것이 문서가 아니라 MessagingPublicSurfaceContractTest 라고 적는다.
그 시험이 어느 트리에 있고 어떤 경로로 실행되는지 확인했다.
## 결론
MessagingPublicSurfaceContractTest 가 놓인 트리는 src/app-bootstrap/src/test 이고 패키지는 dev.caskeleton.bootstrap.contract.messaging 이다. src/messaging 아래 24 개 트리에는 그 이름이 없다.
담긴 시험은 셋이고 @Tag 는 0 개다. 하나는 api 선언 누락을, 하나는 검사가 헛도는 경우를, 하나는 broker SDK 두 개의 컴파일 클래스패스 유출을 각각 잡는다.
가족 태스크는 이 클래스를 고르지 않는다. :messaging:messaging-kafka:test 에 이름을 넘기면 시험을 찾지 못하고 빌드가 실패한다.
가족 경로에서 도는 messaging-certification.yml 도 이 시험을 돌리지 않는다. 그 워크플로의 gradle 호출은 Kafka 인증 증거 검증 태스크 하나뿐이다.
그러나 원문이 읽은 것과 달리 자동 경로가 있다. ci-quality-gates.yml 은 paths 필터 없이 모든 pull_request 에서 돌고 :50 에서 ./gradlew check 를 돌린다. check 가 모든 레인을 덮지는 않는다는 경고가 그 아래 주석에 있어 이 프로젝트만 따로 확인했다. dry-run 그래프에 :app-bootstrap:test 가 있고, 그 태스크로 실행하면 test/ 아래에 tests=3 failures=0 이 남는다.
판정은 P3 이고 원문과 같다. 다만 근거가 다르다. 원문은 이 계약을 자동으로 검증하는 경로가 없다는 것을 들었는데, 검증은 매 PR 에서 돈다. 남는 것은 가족 태스크에 이 시험이 없어 로컬에서 위반을 보지 못한다는 것이다.
## 검증 환경
Gradle : 9.0.0
확인 방식 : 지침 문장 원문 확인, 시험 클래스의 소스 트리·패키지·태그 확인과 단언 메서드 전수, 가족 test 트리 계수, 가족 경로 워크플로의 트리거 전부와 실행 명령 확인, 경로 필터 없는 워크플로의 트리거·job 조건·실행 명령과 그 아래 주석 확인, :app-bootstrap:check 의 태스크 그래프 dry-run, 루트 test 규약의 제외 태그 확인, 그 시험을 이름으로 지정한 실제 실행과 결과 XML 계수, 가족 태스크에 같은 이름을 넘긴 실행
소스 수정 : x
## 재현 조건
1. 가족 지침에서 공개 표면 규칙을 붙든다고 적은 문장과 그 시험이 무엇을 대조하는지 문장 끝까지 읽는다.
2. 그 클래스를 찾아 소스 트리와 패키지를 확인하고, 가족 트리에 동명 파일이 있는지 센다.
3. 그 파일의 @Tag@Disabled 를 세고 단언 메서드를 전부 읽는다.
4. 가족 경로에서 도는 워크플로의 트리거를 전부 읽고 실행 명령을 확인한다.
5. 경로 필터가 없는 워크플로를 찾고 job 조건과 실행 명령을 읽는다.
6. 그 명령 주변 주석에서 check 의 포괄 범위에 대한 경고가 있는지 본다.
7. check 를 dry-run 해 그 프로젝트의 test 가 그래프에 있는지 확인한다.
8. 루트 규약이 test 에 거는 제외 태그를 읽는다.
9. 그 시험을 이름으로 지정해 실제로 돌리고 결과 XML 의 디렉터리와 계수를 읽는다.
10. 같은 이름을 가족 태스크에 넘겨 돌린다.
## 본문
<!-- body:start -->
`src/messaging/CLAUDE.md:39` 는 의존성 노출 규칙을 문서가 아니라 `MessagingPublicSurfaceContractTest` 가 붙들고 있다고 적는다. 그 클래스는 `src/messaging` 아래에 없다.
## 지침이 지목한 시험과 그 트리
:::evidence key="a19-f022-messagingpublicsurfacecontracttest" alt="저장소 루트에서 돌린 정적 검색과 gradle 실행을 합친 출력 76줄. src/messaging/CLAUDE.md 의 36번부터 44번 줄이 원문 그대로 실려 규칙과 그 시험이 무엇을 대조하는지가 문장 끝까지 보인다. 이어서 그 클래스가 app-bootstrap 의 test 트리에 있고 src/messaging 아래 동명 파일이 0 개이며 가족 test 트리가 24 개라는 것, 그 파일의 @Test 가 3 이고 @Tag@Disabled 가 0 이라는 것과 세 메서드 이름이 나온다. messaging-certification 워크플로의 트리거 세 가지가 경로와 cron 과 수동 실행까지 모두 나오고 그것이 돌리는 gradle 명령이 Kafka 인증 증거 검증이라는 것이 보인다. ci-quality-gates 는 on 블록 1번부터 9번 줄까지 실려 paths 필터가 0 건이라는 계수가 붙고, job 정의와 check 를 돌리는 step, 그리고 그 바로 아래 check 가 graphqlStableTest 에 의존하지 않는다고 적은 주석 다섯 줄이 함께 나온다. 마지막으로 app-bootstrap 의 check 를 dry-run 한 결과에 test 가 들어 있는 것, 루트 규약이 거는 유일한 필터, 그 시험을 이름으로 골라 실제로 돌린 결과가 결과 디렉터리 test 에 시험 3 개 실패 0 으로 나온 것, 같은 이름을 가족 태스크로 고르면 시험을 찾지 못하고 빌드가 실패하는 것이 나온다." caption="지침 원문 · 시험 클래스의 트리와 태그와 세 단언 · 가족 워크플로의 트리거 전부와 실행 명령 · 경로 필터 0 인 워크플로와 check 가 모든 시험을 덮지 않는다는 주석 · check 그래프에 있는 test · 이름으로 고른 실행 결과 3개 · 가족 태스크는 찾지 못함 — 76줄 · exit 0" zoom="true"
:::
`CLAUDE.md:39`\~`:43` 은 이 시험이 leaf 의 production source 에서 public·protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf 의 `build.gradle``api` 로 선언했는지 대조하고, starter 의 `api` closure 에 broker client 둘이 들어오지 않는 것도 함께 본다고 적는다.
클래스는 `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/` 에 있다. 가족 test 트리는 24 개인데 그중 어디에도 같은 이름의 파일이 없다.
`@Test` 는 셋이다. `vendorTypesInPublicSignaturesAreDeclaredApi`(`:73`)가 공개 시그니처의 vendor 타입이 `api` 로 선언됐는지 보고, `theScanIsNotVacuous`(`:94`)가 그 검사가 아무것도 못 찾은 채로 통과하는 경우를 막고, `neitherBrokerSdkReachesAnAdoptersCompileClasspath`(`:114`)가 두 broker SDK 가 채택자의 컴파일 클래스패스에 닿지 않는 것을 본다.
## 가족만 바꾸고 가족 태스크만 돌릴 때
`:messaging:*:test` 에 이 클래스 이름을 넘겨 봤다. `No tests found for given includes` 로 빌드가 실패한다.
가족 경로에서 도는 워크플로는 `messaging-certification.yml` 이다. `:16`\~`:19``src/messaging/**` 와 자기 워크플로 파일에서 돌게 하고 `:20`\~`:22` 가 주 1회 cron 과 수동 실행을 더한다. `:52` 가 돌리는 것은 `:messaging:messaging-kafka:verifyMessagingCertificationEvidence` 다. Kafka 인증 증거이지 이 계약이 아니다.
## ci-quality-gates.yml 은 경로 필터 없이 매 PR 에서 check 를 돌린다
`:3`\~`:9``on` 블록은 `pull_request``main` 푸시와 수동 실행이고 `paths` 가 0 건이다. `:20``quality-gates` job 에 조건이 없고, `:49`\~`:50``working-directory: src` 에서 `./gradlew check verifyPublicPathSnapshot verifyDependencyLocks` 를 돌린다.
`check` 가 모든 시험 태스크를 덮는다고 가정할 수는 없다. 바로 아래 `:51`\~`:55` 주석이 `check``graphqlStableTest` 에 의존하지 않으며 그래서 그 레인의 가드가 CI 에서 아무것도 지키지 못했다고 적는다. 그래서 이 프로젝트에 대해 직접 확인했다.
`:app-bootstrap:check` 의 태스크 그래프에 `:app-bootstrap:test` 가 있다. 루트 규약(`src/build.gradle:549`\~`:553`)이 `test` 에 거는 필터는 `excludeTags 'quarantine'` 하나이고 이 클래스에는 `@Tag` 가 0 개다.
그리고 이름으로 골라 실제로 돌렸다. `BUILD SUCCESSFUL` 이고 결과가 `test/` 디렉터리에 `tests=3 failures=0 skipped=0` 으로 남는다. 기본 `test` 가 이 시험을 고른다.
## 원문과 갈리는 자리
원문은 결과를 둘로 적었다. 가족 태스크만 돌리면 검증되지 않는다는 것과 가족 경로 워크플로가 Kafka 인증 레인이라는 것이다. 둘 다 맞다.
원문은 그 둘에서 이 사례가 모듈 18 §4.1c 의 문장 — 아무도 지역에서 돌리지 않는 레인의 붉은 게이트는 마지막으로 돌린 사람이 본 것을 보고한다 — 과 같은 형태라고 읽었다. 그 문장은 여기에 붙지 않는다. `ci-quality-gates.yml` 이 경로 필터 없이 매 PR 에서 `check` 를 돌리고, 그 안에 이 시험이 들어 있다. 이 게이트가 보고하는 것은 마지막으로 돌린 사람이 본 것이 아니라 그 PR 에서 새로 돈 결과다.
`api`·`implementation` 분리가 이 가족의 정책이고 위반이 이 가족의 `build.gradle` 에서 난다는 서술은 맞다. 다른 것은 검증의 유무가 아니라 그 검증이 실행되는 시점이다. 가족 태스크에는 이 시험이 없고 `ci-quality-gates.yml``check` 에만 있다.
## 확인하지 못한 것
가족 leaf 에 `api` 누락을 심어 `:messaging:*:test` 가 초록으로 끝나는 것을 재현하지 않았다. 그 태스크가 이 클래스 이름을 찾지 못한다는 것까지 실행으로 확인했다.
GitHub 러너에서 `./gradlew check` 를 돌리지 않았다. 워크플로가 그 명령을 돌린다는 것, `check` 의 그래프에 `:app-bootstrap:test` 가 있다는 것, 그 태스크가 이 시험을 고른다는 것을 각각 확인해 이었다.
워크플로 스물여덟 개의 트리거를 전수로 조사하지 않았다. 이 계약과 관련된 둘을 읽었다.
워크플로 스물여덟 개의 트리거를 전수로 조사하지 않았다. 이 계약과 관련된 둘을 읽었다.
<!-- body:end -->
@@ -0,0 +1,121 @@
---
kind: CASE
slug: analysis-finding-a19-f001
title: capability 12개 중 main 코드가 읽는 것은 3개, 거부하는 것은 1개
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a19-f001
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a19-f001.body.md
assets:
- key: analysis-finding-a19-f001
file: ../../../final/evidence/rendered/analysis-finding-a19-f001.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a19-f001.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L231 이다.
---
# capability 12개 중 main 코드가 읽는 것은 3개, 거부하는 것은 1개
능력 레코드의 자바독이 없는 능력을 요구하면 플랫폼이 크게 실패한다고 선언한다. 열두 깃발 중 아홉을 주 코드가 읽지 않고, 읽는 셋 중 둘은 거부가 아니라 분기다. 거부하는 것은 하나뿐이다.
## 관계
- **선언된 Advanced 능력 11개 중 9개는 켜는 방법이 없다**
같은 형태의 능력 선언과 실제 어긋남이다.
- **조용히 약해진 보증은 사고 전까지 동작하는 것과 구분되지 않는다**
자바독이 적은 근거다.
- **분기와 거부는 다른 답이다**
이 사례가 그 규칙의 형태다.
## 문제
능력 레코드의 클래스 자바독이 계약을 선언한다.
프로파일이 여기 없는 것을 요구하면 플랫폼이 크게 실패한다는 것이다. 가능하면 시작 시점에, 아니면 능력 예외로 실패하지 조용히 저하되지 않는다는 것이다.
그 근거도 적는다. 조용히 약해진 보증은 사고가 나기 전까지 동작하는 보증과 구분되지 않기 때문이라는 것이다.
열두 깃발 전체에 대해 주 코드와 테스트 참조를 셌다.
## 결론
읽는 것은 셋이다.
순서 있는 흐름은 재시도 결정 엔진에서 읽고, 있으면 순서 보존 재시도를 고른다.
지연 배달도 같은 엔진에서 읽고, 있으면 브로커 지연 방식을 쓴다.
중복 제거 발행은 발행자에서 읽고, 없으면 예외를 던진다.
나머지 아홉은 모든 브로커 어댑터가 선언하지만 주 코드 어디서도 읽지 않는다.
브로커 확인과 복제 또는 지속 증거와 메시지 단위 정산과 배치 정산, 키 기반 순서와 재생과 브로커 트랜잭션과 기본 죽은 편지와 위상 관리다.
읽는 셋 중 둘은 거부가 아니라 분기다.
없으면 재시도 엔진이 조용히 다른 방식을 고른다. 자바독이 조용한 저하라고 부른 그 동작이다.
거부하는 것은 중복 제거 발행 하나뿐이다.
계수에는 주의할 점이 하나 있다. 순서 있는 흐름의 주 참조 다섯 건 중 넷은 프레임워크의 동명 메서드로 잡힌 오탐이다. 실제 깃발 참조는 한 건이다.
판정은 P2 다.
## 검증 환경
확인 방식 : 깃발별 참조 계수와 동명 오탐 제거
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/252 계열에 있다.
1. 능력 레코드의 클래스 자바독을 읽는다.
2. 열두 깃발 이름을 나열한다.
3. 각 이름의 주 참조와 테스트 참조를 센다.
4. 동명 메서드로 잡힌 오탐을 제거한다.
5. 읽는 지점에서 무엇을 하는지 확인한다.
## 본문
<!-- body:start -->
`MessagingCapabilities`의 클래스 javadoc이 이 record의 계약을 선언한다.
> "When a profile asks for something absent here **the platform fails loudly** — at startup where possible, otherwise with a capability exception — rather than quietly degrading, because **a silently weakened guarantee is indistinguishable from a working one until the incident.**"
## MessagingCapabilities 참조 위치
:::evidence key="analysis-finding-a19-f001" alt="코드베이스에서 MessagingCapabilities 를 검색한 출력 13줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingCapabilities 코드베이스 검색 — 13줄 · exit 0" zoom="true"
:::
## 12개 플래그의 참조를 전수로 셌다
| 플래그 | main | test | main에서 하는 일 |
|---|---|---|---|
| `brokerAcknowledgement` | 0 | 1 | — |
| `replicationOrPersistenceEvidence` | 0 | 0 | — |
| `perMessageSettlement` | 0 | 1 | — |
| `batchSettlement` | 0 | 0 | — |
| `orderedStream` | **1** | 1 | `DefaultRetryDecisionEngine:49` — 있으면 순서보존 재시도 선택 |
| `keyedOrdering` | 0 | 3 | — |
| `replay` | 0 | 2 | — |
| `delayedDelivery` | **1** | 0 | `DefaultRetryDecisionEngine:64` — 있으면 BROKER_DELAYED 사용 |
| `brokerTransaction` | 0 | 3 | — |
| `deduplicatedPublish` | **1** | 1 | `DefaultMessagePublisher:250`**없으면 예외** |
| `nativeDeadLetter` | 0 | 1 | — |
| `topologyManagement` | 0 | 0 | — |
## 읽는 것은 셋, 거부하는 것은 하나
javadoc이 선언한 "fails loudly"가 실제로 성립하는 플래그는 `deduplicatedPublish` 하나다. P2.
## 확인하지 못한 것
능력이 없는 브로커로 프로파일을 구성해 각 깃발의 동작 차이를 재현하지 않았다. 참조 계수상 아홉은 차이가 없다.
<!-- body:end -->
@@ -0,0 +1,122 @@
---
kind: CASE
slug: analysis-finding-a19-f002
title: 8개 profile validator 중 조립에서 실행되는 것은 3개
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a19-f002
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a19-f002.body.md
assets:
- key: analysis-finding-a19-f002
file: ../../../final/evidence/rendered/analysis-finding-a19-f002.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a19-f002.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L288 이다.
---
# 8개 profile validator 중 조립에서 실행되는 것은 3개
시작 검증 도우미의 자바독이 이미 한 번 고쳐진 같은 결함을 서술한다. 검증기들이 전부 빈이었는데 아무 데도 주입되지 않아 아무것도 검증하지 않았다는 것이다. 그 수정이 적용된 것은 둘이고, 주 소스의 검증기 여덟 중 실행되는 것은 셋이다.
## 관계
- **capability 12개 중 main 코드가 읽는 것은 3개이고 거부하는 것은 1개다**
같은 가족의 같은 형태다.
- **브로커 권한 매니페스트의 자기 점검이 존재하지 않는다**
같은 계열의 시작 검증 부재다.
- **같은 결함이 이미 한 번 고쳐졌는데 나머지에는 적용되지 않았다**
이 사례가 그 규칙의 형태다.
## 문제
시작 검증 도우미의 자바독이 이미 고쳐진 결함을 서술한다.
브로커 두 종과 보안 검증기가 전부 빈이었는데 아무 데도 주입되지 않았다는 것이다. 컨텍스트가 브로커마다 검증기를 발행하고 아무것도 검증하지 않았다는 것이다.
브로커가 줄 수 없는 보증을 약속하는 프로파일이 깨끗하게 부팅하고 그것에 의존하는 첫 메시지에서 실패한다는 것이다.
수정 방식도 정확하다.
초기화 콜백으로 돌려서 컨텍스트가 아직 만들어지는 중에 실패하고 원인이 된 프로파일 빈이 스택에 이름으로 남게 한다.
그리고 프로파일을 공급자로 받는다. 애플리케이션이 선언한 빈과 설정에서 컴파일된 프로파일 두 출처를 모두 보기 위해서다.
## 결론
그 수정이 적용된 것은 둘이다.
주 소스에 존재하는 프로파일 검증기 여덟 전체의 도달성을 확인했다.
목적지 검증기는 핵심 자동 설정이 검증 메서드를 직접 부른다. 실행된다.
브로커 두 종의 검증기는 각자의 자동 설정이 시작 검증 도우미로 감싼다. 실행된다.
브로커 트랜잭션 검증기는 출하 리프에 있고 자동 설정이 빈으로 선언만 한다. 주입처가 없다. 실행되지 않는다.
나머지는 빌드 전용 리프이거나 조립 지점이 없다.
즉 여덟 중 셋만 실행된다.
그리고 실행되지 않는 것 중 하나는 출하 리프의 것이다.
판정은 P2 다.
## 검증 환경
Spring Boot : 4.0.8
확인 방식 : 검증기별 조립 지점과 주입처 확인
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/252 계열에 있다.
1. 시작 검증 도우미의 자바독을 읽는다.
2. 주 소스의 프로파일 검증기를 모두 나열한다.
3. 각각의 리프와 출하 여부를 확인한다.
4. 각각의 조립 지점을 찾는다.
5. 빈 선언만 있고 주입처가 없는 것을 가려낸다.
## 본문
<!-- body:start -->
`StartupProfileValidation`의 javadoc이 **이미 한 번 고쳐진 같은 결함**을 서술한다.
> "The Kafka, RabbitMQ and security validators were all beans and **none of them was injected anywhere**: the context published a validator per broker and **validated nothing**. A profile that promises a guarantee its broker cannot give — an exactly-once claim on a non-transactional producer, a quorum ack on a single replica, a plaintext credential on a production listener — then boots cleanly and fails on the first message that depends on it."
## StartupProfileValidation 참조 위치
:::evidence key="analysis-finding-a19-f002" alt="코드베이스에서 StartupProfileValidation 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="StartupProfileValidation 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 수정 방식도 정확하다
`InitializingBean.afterPropertiesSet`으로 돌려서 "컨텍스트가 아직 만들어지는 중에 실패하고 원인이 된 프로파일 bean이 스택에 이름으로 남게" 한다. 그리고 프로파일을 `ObjectProvider`가 아니라 `Supplier`로 받는다 — 애플리케이션이 선언한 bean과 `app.messaging`에서 컴파일된 프로파일 두 출처를 모두 보기 위해서다.
## 여덟 validator의 도달성
| validator | leaf | 출하? | 조립 지점 | 실행되는가 |
|---|---|---|---|---|
| `DestinationProfileValidator` | policy | 출하 | `MessagingCoreAutoConfiguration:134` | **✔** |
| `KafkaProfileValidator` | kafka | 출하 | `KafkaMessagingAutoConfiguration:58``StartupProfileValidation` | **✔** |
| `RabbitProfileValidator` | rabbit | 출하 | `RabbitMessagingAutoConfiguration:54``StartupProfileValidation` | **✔** |
| `KafkaTransactionProfileValidator` | kafka | **출하** | `KafkaMessagingAutoConfiguration:74`@Bean 선언만, 주입처 없음 | **✘** |
| `KafkaShareProfileValidator` | kafka-share | build-only | registrar가 보유, 테스트에서만 생성 | ✘ (등급 일치) |
| `NatsJetStreamProfileValidator` | nats | build-only | 참조가 javadoc 문장 하나 | ✘ (등급 일치) |
| `PulsarProfileValidator` | pulsar | build-only | **참조 0건** — 테스트조차 없다 | ✘ (등급 일치) |
| `BindingProfileValidator` | scs-bridge | build-only | 테스트에서만 생성 | ✘ (등급 일치) |
## build-only 넷은 등급과 일치한다
어떤 런타임에도 오르지 않으므로 조립 지점이 없는 것이 등급과 일치한다 — 오늘의 사고가 아니라 채택 시점의 부채다. 다만 `PulsarProfileValidator`**테스트조차 없어서** 다른 셋과도 다르다. P2.
## 확인하지 못한 것
잘못된 프로파일로 띄워 검증되지 않는 것을 재현하지 않았다. 주입처 부재상 그 결과가 나온다.
<!-- body:end -->
@@ -0,0 +1,124 @@
---
kind: CASE
slug: analysis-finding-a19-f004
title: 스키마 호환성 검증기는 출하 leaf에 있고, main 코드에서 호출되지 않는다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a19-f004
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a19-f004.body.md
assets:
- key: analysis-finding-a19-f004
file: ../../../final/evidence/rendered/analysis-finding-a19-f004.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a19-f004.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L390 이다.
---
# 스키마 호환성 검증기는 출하 leaf에 있고, main 코드에서 호출되지 않는다
검증기의 주 참조가 0 건이다. 그것이 거부하도록 만들어진 검사 없음 모드는 설정으로 켤 수 있고, 그 설정을 막는 다른 규칙도 없다. 게다가 검증기가 읽어야 할 이력의 출처 자체가 프로덕션에 없다.
## 관계
- **호환성 게이트를 가진 두 포맷은 build-only이고 출하되는 유일한 코덱에는 게이트가 없다**
같은 공백의 다른 면이다.
- **8개 profile validator 중 조립에서 실행되는 것은 3개다**
같은 가족의 같은 형태다.
- **검사하지 않는 모드는 설계 중에는 유용하고 보존 로그가 생긴 뒤에는 위험하다**
자바독이 적은 근거다.
## 문제
스키마 호환성 검증기가 출하 리프에 있다.
주 참조를 셌다.
## 결론
0 건이다. 테스트 하나뿐이다.
이 클래스가 하는 일은 둘이다.
하나는 호환성 모드에 따라 비교해야 할 후보 판본 목록을 돌려주는 것이다. 이행 모드면 전체 이력이고 짝 모드면 직전 하나다.
다른 하나는 검사 없음 실험 모드를 운영 목적지에서 거부하는 것이다.
두 번째의 근거가 클래스 자바독에 있다.
아무것도 검사하지 않는 모드는 메시지 타입을 설계하는 동안에는 유용하고 보존 로그가 생기면 능동적으로 위험하다는 것이다. 로그가 그것을 아직 읽을 수 있는 모든 소비자보다 오래 살기 때문이라는 것이다.
그리고 그 모드는 설정으로 켤 수 있다.
목적지 설정의 기본값이 안전한 값이지만, 설정으로 검사 없음 실험 모드를 쓰면 그대로 통과한다.
목적지 프로파일 검증기의 열여섯 규칙에 스키마 항목이 없고, 거부 메서드는 호출되지 않는다.
부수적으로 스키마 저장소에는 주 구현이 하나도 없다.
유일한 구현은 검증기 테스트 안의 고정 저장소다.
즉 검증기가 읽어야 할 스키마 이력의 출처 자체가 프로덕션에 존재하지 않는다. 호출하려 해도 넘길 저장소가 없다.
같은 가족에서 코덱 저장소가 겪었고 등록 목록 타입으로 해결된 것과 같은 모양이고, 이쪽은 아직 해결되지 않았다.
실패 시나리오는 이렇다.
운영자가 한 목적지에 검사 없음 모드를 설정한다. 설계 중이라는 정당한 이유다.
그 설정이 그대로 프로덕션으로 나간다.
보존 로그에 이전 판본으로 쓴 메시지가 남고, 이후 판본이 필드를 삭제하면 그 로그를 읽는 소비자가 깨진다.
판정은 P2 다.
## 검증 환경
확인 방식 : 참조 계수와 설정 경로 확인, 저장소 구현 검색
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/252 계열에 있다.
1. 검증기의 주 참조를 센다.
2. 두 메서드가 무엇을 하는지 읽는다.
3. 클래스 자바독의 거부 근거를 읽는다.
4. 목적지 설정에서 호환성 모드의 기본값과 설정 가능성을 확인한다.
5. 목적지 프로파일 검증기의 규칙에 스키마 항목이 있는지 확인한다.
6. 스키마 저장소의 주 구현을 검색한다.
## 본문
<!-- body:start -->
`SchemaCompatibilityValidator`(`messaging-schema-api`, **출하**)의 main 참조는 **0건**이다. 테스트 1개뿐.
## SchemaCompatibilityValidator 참조 위치
:::evidence key="analysis-finding-a19-f004" alt="코드베이스에서 SchemaCompatibilityValidator 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SchemaCompatibilityValidator 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 이 클래스가 하는 일 둘
1. `versionsToCheck(subject)` — 호환성 모드에 따라 후보 스키마를 비교해야 할 버전 목록(transitive면 전체 이력, pairwise면 직전 하나)을 돌려준다.
2. `requireProductionMode(subject, destination)``NONE_EXPERIMENTAL`을 production destination에서 **거부**한다.
두 번째의 근거가 클래스 javadoc에 있다 — "`NONE_EXPERIMENTAL` is refused for production destinations. A mode that checks nothing is useful while a message type is being designed and **actively dangerous once a retained log exists, because the log outlives every consumer that could still read it.**"
## 그리고 그 모드는 설정으로 켤 수 있다
`DestinationSettings.Schema``@DefaultValue("BACKWARD_TRANSITIVE")`가 안전한 값이지만, `app.messaging.destinations.<name>.schema.compatibility=NONE_EXPERIMENTAL`을 쓰면 그대로 통과한다 — `DestinationProfileValidator`의 16개 규칙에 스키마 항목이 없고(§3.5), `requireProductionMode`는 호출되지 않는다.
## 넘길 registry 자체가 없다
`SchemaRegistry`에는 main 구현이 하나도 없다. 유일한 구현은 `SchemaCompatibilityValidatorTest``FixedRegistry`다. §3.5의 `MessageCodecRegistry`가 겪었고 `RegisteredMessageCodecs`로 해결된 것과 같은 모양이며, 이쪽은 아직 해결되지 않았다. P2.
## 확인하지 못한 것
검사 없음 모드를 설정하고 판본을 진화시켜 소비자가 깨지는 것을 재현하지 않았다. 호출 부재상 그 결과가 나온다.
<!-- body:end -->
@@ -0,0 +1,114 @@
---
kind: CASE
slug: analysis-finding-a19-f005
title: 호환성 게이트를 가진 두 포맷은 build-only이고, 출하되는 유일한 코덱에는 게이트가 없다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a19-f005
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a19-f005.body.md
assets:
- key: analysis-finding-a19-f005
file: ../../../final/evidence/rendered/analysis-finding-a19-f005.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a19-f005.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L415 이다.
---
# 호환성 게이트를 가진 두 포맷은 build-only이고, 출하되는 유일한 코덱에는 게이트가 없다
스키마 진화 검사가 존재하는 두 포맷은 어떤 런타임에도 오르지 않는다. 실제로 유선에 바이트를 쓰는 유일한 코덱에는 포맷 수준의 게이트가 없다. 위험 방향이 뒤집혀 있다.
## 관계
- **스키마 호환성 검증기는 출하 leaf에 있고 main 코드에서 호출되지 않는다**
이 공백을 메울 자리인데 그것도 호출되지 않는다.
- **상호운용 규격 leaf는 출하되고 starter의 의존이며 소비자가 없다**
같은 계수에서 드러난 다른 사례다.
- **결함은 미조립 자체가 아니라 출하되는 쪽에 대응하는 게이트가 없다는 비대칭이다**
이 사례가 그 규칙의 형태다.
## 문제
이 가족에 코덱이 다섯 있다.
각 코덱의 출하 여부와 조립 지점과 호환성 게이트를 대조했다.
## 결론
위험 방향이 뒤집혀 있다.
JSON 코덱은 출하되고 핵심 자동 설정이 코덱 목록에 조립한다. 포맷 수준의 호환성 게이트가 없다.
두 이진 포맷 코덱은 빌드 전용이고 조립 지점이 없다. 각각 호환성 게이트나 전용 테스트를 갖는다. 다만 테스트에서만 실행된다.
원시 바이트 코덱은 리프에 출하되지만 조립 지점이 없다. 기본 코덱 금지 대상이다.
상호운용 사상기는 출하되는데 조립 지점이 없다.
즉 스키마 진화 검사가 존재하는 두 포맷은 어떤 런타임에도 오르지 않고, 실제로 유선에 바이트를 쓰는 유일한 코덱에는 포맷 수준의 게이트가 없다.
포맷 독립 검증기가 그 공백을 메울 자리인데 그것도 호출되지 않는다.
JSON 의 진화 위험이 이진 포맷보다 작은 것은 사실이지만 0 은 아니다.
필드 삭제와 타입 변경과 열거값 제거는 역직렬화 실패로 나타난다.
그리고 스키마 정책이 목적지마다 호환성 모드를 선언하게 되어 있다. 선언은 있고 집행이 없는 상태다.
빌드 전용 두 리프의 미조립 자체는 등급과 일치하므로 결함이 아니다.
결함은 출하되는 쪽에 대응하는 게이트가 없다는 비대칭이다.
판정은 P2 다.
## 검증 환경
확인 방식 : 코덱별 출하 여부와 조립 지점, 게이트 대조
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/252 계열에 있다.
1. 이 가족의 코덱 목록을 만든다.
2. 각 코덱의 리프와 런타임 구성원을 확인한다.
3. 각 코덱의 조립 지점을 찾는다.
4. 각 포맷의 호환성 게이트를 확인한다.
5. 스키마 정책이 무엇을 선언하게 하는지 확인한다.
## 본문
<!-- body:start -->
코덱별로 출하 여부와 게이트가 갈린다.
| 코덱 | 출하? | 조립 지점 | 호환성 게이트 |
|---|---|---|---|
| `JacksonMessageCodec` (JSON) | **출하** | `MessagingCoreAutoConfiguration:366` `messagingCodecs` ✔ | **없음** |
| `AvroMessageCodec` | build-only | 없음 | `AvroCompatibilityGate` (테스트에서만 실행) |
| `ProtobufMessageCodec` | build-only | 없음 | `ProtobufCompatibilityTest`만 |
| `RawBytesMessageCodec` | 출하(leaf) | 없음 — 기본 코덱 금지 대상 | n/a |
| `DefaultCloudEventMapper` | **출하** | **없음** | n/a |
## SchemaCompatibilityValidator 참조 위치
:::evidence key="analysis-finding-a19-f005" alt="코드베이스에서 SchemaCompatibilityValidator 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SchemaCompatibilityValidator 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 위험 방향이 뒤집혀 있다
스키마 진화 검사가 존재하는 두 포맷(Avro·Protobuf)은 어떤 런타임에도 오르지 않고, 실제로 wire에 바이트를 쓰는 유일한 코덱(JSON)에는 포맷 수준의 호환성 게이트가 없다. §4.3의 포맷 독립 검증기가 그 공백을 메울 자리인데 그것도 호출되지 않는다.
## JSON의 진화 위험은 작지만 0이 아니다
필드 삭제, 타입 변경, enum 값 제거는 Jackson에서 런타임 역직렬화 실패로 나타난다. 그리고 `SchemaPolicy`가 destination마다 `compatibility` 모드를 **선언하게** 되어 있으므로, 선언은 있고 집행이 없는 상태다. build-only 두 leaf의 미조립 자체는 등급과 일치하므로 결함이 아니다 — 결함은 **출하되는 쪽에 대응하는 게이트가 없다는 비대칭**이다. P2.
## 확인하지 못한 것
JSON 코덱으로 필드를 삭제한 판본을 흘려 역직렬화 실패를 재현하지 않았다.
<!-- body:end -->
@@ -0,0 +1,128 @@
---
kind: CASE
slug: analysis-finding-a19-f009
title: 접근 검사가 두 갈래로 존재하고, 조립된 쪽이 진단이 약한 쪽이다 (§8.3)
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a19-f009
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a19-f009.body.md
assets:
- key: analysis-finding-a19-f009
file: ../../../final/evidence/rendered/analysis-finding-a19-f009.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a19-f009.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L511 이다.
---
# 접근 검사가 두 갈래로 존재하고, 조립된 쪽이 진단이 약한 쪽이다 (§8.3)
같은 권한 검사가 두 형태로 있다. 조립된 쪽은 거부를 설정 분류로 돌려주고, 조립되지 않은 쪽은 전용 인가 예외를 던진다. 검사 자체는 조립된 쪽에서 수행되므로 보안 구멍은 아니다.
## 관계
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
이 사례가 그 규칙의 형태다.
- **브로커 권한 매니페스트의 자기 점검이 존재하지 않는다**
같은 가족의 다른 권한 사례다.
- **권한 거부와 설정 실수가 같은 버킷에 들어간다**
기록하는 이유다.
## 문제
같은 권한 검사가 두 형태로 있다.
어느 쪽이 조립되었고 두 쪽의 차이가 무엇인지 확인했다.
## 결론
조립된 쪽은 발행자가 접근 정책을 직접 부르는 형태다.
발행이 허용되지 않으면 발행 금지 코드와 메시지를 담은 거부 결과를 돌려준다. 실패 분류가 설정이고 재시도 불가다.
조립되지 않은 쪽은 전용 검증기다. 주 참조도 테스트 참조도 0 이다.
발행이 허용되지 않으면 전용 인가 예외를 던진다. 코드와 함께 자격증명이 그 목적지에 발행할 수 없다는 메시지를 담는다.
그 클래스의 자바독이 존재 이유를 적는다.
브로커의 권한 검사보다 먼저 돌고, 일으키는 실패가 논리 목적지와 역할을 이름으로 부른다는 것이다.
브로커 권한 거부는 애플리케이션 문맥이 없는 연결 수준 오류로 도착하며, 그래서 어느 모듈이 어디로 발행하려 했는가가 로그 한 줄이 아니라 조사가 된다는 것이다.
두 경로의 차이는 분류다.
조립된 쪽은 인가 거부를 설정으로 분류한다. 조립되지 않은 쪽은 전용 인가 예외를 던진다.
핵심 계약의 예외 스물여섯 종에 그 인가 예외가 명시적으로 있는데, 실제 발행 경로는 그 타입을 쓰지 않는다.
권한 거부가 설정으로 집계되면 설정 실수와 권한 침해 시도가 같은 갈래에 들어간다.
실제 검사 자체는 조립된 쪽에서 수행되므로 보안 구멍은 아니다.
분류와 진단의 문제이고, 중복 장치 중 조립되지 않은 쪽이 더 정확한 분류를 갖고 있다.
판정은 P3 다.
## 검증 환경
확인 방식 : 두 경로의 구현과 참조 계수 대조
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/252 계열에 있다.
1. 발행자에서 권한 검사를 부르는 지점을 읽는다.
2. 거부 결과의 분류와 재시도 여부를 확인한다.
3. 전용 검증기의 구현과 자바독을 읽는다.
4. 그 검증기의 참조를 센다.
5. 핵심 계약의 예외 목록에 인가 예외가 있는지 확인한다.
## 본문
<!-- body:start -->
같은 권한 검사가 두 형태로 있다.
## 조립된 쪽
`DefaultMessagePublisher``DestinationAccessPolicy`를 직접 호출한다.
```java
if (!access.mayPublish(destination.name())) {
return rejected("PUBLISH_FORBIDDEN",
"this application may not publish to '" + destination.name().value() + '\'', startedAt);
}
```
`FailureCategory.CONFIGURATION` · `retryable=false``PublishResult`를 돌려준다.
## DefaultMessagePublisher 참조 위치
:::evidence key="analysis-finding-a19-f009" alt="코드베이스에서 DefaultMessagePublisher 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultMessagePublisher 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## 조립되지 않은 쪽
`DestinationAccessValidator`(main 참조 0건, 테스트 0건)가 전용 예외를 던진다.
```java
public void requirePublish(DestinationName destination) {
if (!policy.mayPublish(destination)) {
throw new MessageAuthorizationException("DESTINATION_PUBLISH_DENIED",
"the producer credential may not publish to " + destination.value());
}
}
```
조립된 쪽이 진단이 약한 쪽이다. P3.
## 확인하지 못한 것
권한 거부를 발생시켜 집계 갈래를 확인하지 않았다. 분류 상수상 그 결과가 나온다.
<!-- body:end -->
@@ -0,0 +1,100 @@
---
kind: CASE
slug: analysis-finding-a19-f013
title: 가족 권위 문서가 이미 정정한 build-only 문장이 지원 매트릭스에 남아 있다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a19-f013
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a19-f013.body.md
assets:
- key: analysis-finding-a19-f013
file: ../../../final/evidence/rendered/analysis-finding-a19-f013.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a19-f013.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L682 이다.
---
# 가족 권위 문서가 이미 정정한 build-only 문장이 지원 매트릭스에 남아 있다
같은 저장소의 두 문서가 정반대를 말한다. 한쪽은 자기가 틀렸었다는 사실과 그 원인까지 적어 두었고, 다른 쪽은 고쳐지지 않았다. 고쳐지지 않은 쪽이 운영자용 문서다.
## 관계
- **문서 계약 test가 존재하고 그 커버리지 경계가 드리프트 위치를 정확히 예측한다**
이 표류가 그 커버리지 밖에 있다.
- **README readiness 표가 있는 것을 없다고 적는다**
같은 방향의 문서 표류다.
- **산문에서 세는 순간 다시 drift한다**
가족 문서가 이미 적어 둔 근거다.
## 문제
이 가족의 운영자용 지원 매트릭스가 런타임 구성원에 대한 문장을 담는다.
레지스트리의 이 가족 리프는 모두 런타임 구성원이 비어 있으며, 따라서 빌드 전용이고 어느 조립 루트에도 편입되지 않았다는 것이다.
같은 저장소의 가족 지침 문서와 대조했다.
## 결론
가족 지침 문서가 정반대를 말한다.
그리고 자기가 틀렸었다는 사실까지 적는다.
이 절은 한동안 사실이 아닌 채로 남아 있었다는 것이다. 모든 리프가 빌드 전용이라고 쓰여 있었는데, 다섯 어댑터 보정이 스타터를 부트스트랩 의존성으로 넣으면서 그 폐포 전체가 실행 클래스패스에 올라갔다는 것이다.
그리고 원인까지 적는다. 정확한 목록은 레지스트리가 소유하므로 여기서 세지 않는다는 것이다. 세는 순간 다시 표류하기 때문이라는 것이다.
레지스트리 실측은 출하 열여덟에 빌드 전용 일곱이다.
같은 저장소의 두 문서가 정반대를 말하고, 한쪽은 자기가 틀렸었다는 사실과 그 원인을 적어 두었으면서 다른 쪽은 고쳐지지 않았다.
그리고 고쳐지지 않은 쪽이 운영자용 문서다.
이 표류의 실질적 무게는 이 가족 분석 전체의 심각도 판정 축과 같다.
지원 매트릭스만 읽은 운영자는 이 가족이 아무것도 출하하지 않는다고 결론 내린다.
실제로는 두 브로커 어댑터와 스타터와 보안과 관측과 상호운용 규격, 발신함과 수신함과 청구 확인, 그리고 관리 평면이 전부 부트스트랩 산출물에 실려 있다.
판정은 P2 다.
## 검증 환경
확인 방식 : 두 문서 대조와 레지스트리 실측
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/252 계열에 있다.
1. 지원 매트릭스의 런타임 구성원 문장을 읽는다.
2. 가족 지침 문서의 같은 주제 절을 읽는다.
3. 레지스트리에서 이 가족 리프의 런타임 구성원을 센다.
4. 출하와 빌드 전용을 가른다.
5. 어느 문서가 운영자용인지 확인한다.
## 본문
<!-- body:start -->
지원 매트릭스가 "모든 messaging leaf는 build-only"라고 적고, 가족 권위 문서는 그 문장이 틀렸다고 이미 기록했다.
## 두 문서가 같은 문장에 대해 하는 말
:::evidence key="analysis-finding-a19-f013" alt="분석 문서 analysis/19-messaging-platform.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/19-messaging-platform.md 발췌 — 15줄" zoom="true"
:::
## 매트릭스만 읽으면 반대 결론에 도달한다
지원 매트릭스만 읽은 운영자는 messaging이 아무것도 출하하지 않는다고 결론 내리는데, 실제로는 `messaging-kafka`·`messaging-rabbit`·`messaging-spring-boot-starter`·`messaging-security`·`messaging-observability`·`messaging-cloudevents`·outbox/inbox/claim-check·admin plane이 전부 `app-bootstrap` 아티팩트에 실려 있다. 이 드리프트의 실질적 무게는 이 문서 전체의 심각도 판정 축과 같다(§1.1).
## 확인하지 못한 것
지원 매트릭스가 언제부터 어긋났는지 이력에서 확인하지 않았다. 가족 문서가 원인을 적어 두었다.
<!-- body:end -->
@@ -0,0 +1,110 @@
---
kind: CASE
slug: analysis-finding-a19-f018
title: claim-check는 starter에 배선 코드가 한 줄도 없다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a19-f018
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a19-f018.body.md
assets:
- key: analysis-finding-a19-f018
file: ../../../final/evidence/rendered/analysis-finding-a19-f018.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a19-f018.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L926 이다.
---
# claim-check는 starter에 배선 코드가 한 줄도 없다
출하 리프의 두 진입 타입이 주 참조 0 건이고 신뢰성 자동 설정에 그 이름이 아예 나오지 않는다. 프로파일은 임계값을 선언할 수 있고 검증도 받는데 그것을 수행하는 코드가 조립되지 않는다.
## 관계
- **상호운용 규격 leaf는 출하되고 starter의 의존이며 소비자가 없다**
같은 가족의 같은 형태다.
- **두 개의 outbox 중 하나만 조립되어 있다**
같은 계열의 조립 문제다.
- **조용한 잘못된 성공이 아니라 시끄러운 실패다**
판정을 낮춘 이유다.
## 문제
청구 확인 리프가 출하된다. 주 파일 여섯에 사백여 줄이다.
바깥에서 들어오는 경로가 있는지 확인했다.
## 결론
없다.
발행자와 해석기의 주 참조가 0 건이고, 신뢰성 자동 설정에 이 이름의 문자열이 등장하지 않는다.
무결성 가드와 정책과 저장소는 리프 내부에서 서로를 참조한다. 그러므로 리프는 내부적으로 일관되다.
다만 바깥에서 들어오는 경로가 없다.
목적지 프로파일 검증기는 이 기능을 알고 있다.
임계값이 최대 크기보다 크면 거부한다.
즉 프로파일은 임계값을 선언할 수 있고 검증도 받는데, 그 임계값을 넘는 적재물에 대해 이 기능을 수행하는 코드가 조립되지 않는다.
임계값은 설정 가능하고 효과는 없다.
같은 가족의 발신함 사례보다 낮은 등급으로 두는 이유가 있다.
이 기능은 부재 시 동작이 명확하다.
적재물이 그대로 전송되고, 크기 한도에 걸리면 전용 예외로 명시적으로 실패한다.
조용한 잘못된 성공이 아니라 시끄러운 실패다.
판정은 P3 다.
## 검증 환경
확인 방식 : 참조 계수와 자동 설정 문자열 검색
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/252 계열에 있다.
1. 리프의 주 파일과 줄 수를 센다.
2. 두 진입 타입의 주 참조를 센다.
3. 신뢰성 자동 설정에서 이 이름을 검색한다.
4. 리프 내부의 상호 참조를 확인한다.
5. 목적지 프로파일 검증기의 관련 규칙을 확인한다.
## 본문
<!-- body:start -->
`messaging-claim-check`(6 main, 418 LOC, **출하**)의 `ClaimCheckPublisher`·`ClaimCheckResolver`는 main 참조 0건이고, `MessagingReliabilityAutoConfiguration``ClaimCheck` 문자열이 등장하지 않는다.
## ClaimCheckPublisher 참조 위치
:::evidence key="analysis-finding-a19-f018" alt="코드베이스에서 ClaimCheckPublisher 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ClaimCheckPublisher 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## leaf는 내부적으로 일관되고 바깥 경로가 없다
`ClaimCheckIntegrityGuard`·`ClaimCheckPolicy`·`ClaimCheckStore`는 leaf 내부에서 서로를 참조한다.
## 임계값은 설정 가능하고 효과는 없다
`DestinationProfileValidator`는 claim check를 알고 있다 — `profile.payload().claimCheckThresholdBytes() > profile.payload().maxBytes()`를 거부한다. 즉 프로파일은 claim check 임계값을 선언할 수 있고 검증도 받지만, 그 임계값을 넘는 payload에 대해 claim check를 수행하는 코드가 조립되지 않는다.
## P3으로 두는 이유
claim check는 outbox와 달리 **부재 시 동작이 명확**하다 — payload가 그대로 전송되고, 크기 한도(`BoundedByteSink`, §4.1)에 걸리면 `MessageTooLargeException`으로 명시적으로 실패한다. 조용한 잘못된 성공이 아니라 시끄러운 실패다.
## 확인하지 못한 것
임계값을 넘는 적재물을 보내 그대로 전송되는 것을 재현하지 않았다. 조립 부재상 그 결과가 나온다.
<!-- body:end -->
@@ -0,0 +1,135 @@
---
kind: CASE
slug: analysis-finding-a19-f019
title: admin 스위치가 가드를 켜고 서비스는 켜지 않는다
topic: messaging-and-outbox
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a19-f019
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a19-f019.body.md
assets:
- key: analysis-finding-a19-f019
file: ../../../final/evidence/rendered/analysis-finding-a19-f019.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a19-f019.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md#L975 이다.
---
# admin 스위치가 가드를 켜고 서비스는 켜지 않는다
관리 스위치를 켜면 빈 넷이 만들어진다. 전부 가드와 기록과 검증기다. 그중 관리 서비스 인터페이스의 유일한 구현과 승인 검증자의 유일한 구현은 만들어지지 않는다. 부재가 문서화된 것은 하나뿐이다.
## 관계
- **관리 계약 leaf는 main 25파일 1613줄에 테스트 파일이 1개다**
같은 하위 범위의 다른 사례다.
- **멱등 실행 계층과 durable-operation 표면이 픽스처에서만 조립된다**
다른 가족의 같은 형태다.
- **켰다고 생각한 기능이 없다는 것을 사고 한가운데에서 발견한다**
이 사례가 그 규칙의 형태다.
## 문제
관리 스위치를 켜면 무엇이 만들어지는지 확인했다.
## 결론
빈 넷이다.
파괴적 연산 가드와 관리 연산 기록, 관리 내구성 검증기, 그리고 위상 조사기가 있을 때 합성 위상 검증기다.
만들지 않는 것이 다섯이다.
파괴적 관리자는 주 참조가 둘이고 부재가 자바독에 명시되어 있다.
기본 관리 서비스는 주 참조가 0 이고 부재 이유가 없다.
해시 기반 승인 검증자도 주 참조가 0 이고 테스트가 넷이다. 부재 이유가 없다.
위상 검증 실행체도 0 이고 이유가 없다.
재구동 서비스와 재생 서비스도 각각 주 참조가 있고 이유가 없다.
기본 관리 서비스는 관리 서비스 인터페이스의 유일한 구현이다.
즉 관리 평면을 켜도 관리 서비스가 없다.
해시 기반 승인 검증자는 승인 검증자의 유일한 구현이고, 테스트 넷이 그것을 검증한다. 위조 테스트도 포함된다.
승인된 재구동 계획과 재생 계획과 검증된 승인과 계획 요약으로 이루어진 승인 사슬 전체가 검증자 없이는 시작될 수 없다.
부재의 등급이 넷 다 다르지 않은데 문서화는 하나만 됐다.
파괴적 관리자의 부재에는 명확한 이유가 있다. 이 실행체는 관리 자격증명을 갖지 않는다는 것이다.
나머지 넷에는 이유가 적혀 있지 않고, 그중 둘은 파괴적이지 않은 관리 동작에 필요한 것이다. 재구동과 재생의 승인과 실행이다.
실패 시나리오는 이렇다.
운영 절차서에 따라 사고 대응 중 재구동을 실행하려 한다.
관리 스위치를 켠다. 부팅은 성공하고 가드와 기록과 내구성 검증기가 올라온다.
그런데 관리 서비스 빈이 없으므로 재구동을 호출할 대상이 없다.
사고 한가운데에서 켰다고 생각한 기능이 없다는 것을 발견한다.
내구성 검증기의 자바독이 경계한 상황과 정확히 같은 시점이다. 그 간극은 그 연산을 돌리게 만든 사고 도중에만 드러나며 그것이 발견하기에 가장 나쁜 순간이라는 것이다.
판정은 P2 다.
## 검증 환경
Spring Boot : 4.0.8
확인 방식 : 자동 설정 빈 목록과 타입별 참조 계수
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/252 계열에 있다.
1. 관리 자동 설정의 조건과 빈 목록을 읽는다.
2. 관리 평면에 필요한 타입 목록을 만든다.
3. 각 타입의 주 참조를 센다.
4. 각 타입의 부재가 문서화되었는지 확인한다.
5. 관리 서비스 인터페이스의 구현을 센다.
## 본문
<!-- body:start -->
`app.messaging.admin.enabled=true`가 만드는 bean은 넷이다 — `DestructiveOperationGuard`, `AdminOperationJournal`, `MessagingAdminDurabilityValidator`, (`BrokerTopologyInspector`가 있을 때) `CompositeTopologyValidator`.
## DestructiveOperationGuard 참조 위치
:::evidence key="analysis-finding-a19-f019" alt="코드베이스에서 DestructiveOperationGuard 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DestructiveOperationGuard 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## 만들지 않는 것
| 타입 | leaf | main 참조 | 부재가 문서화됐는가 |
|---|---|---|---|
| `DestructiveMessagingAdmin` | admin-runtime | 2 | **예** — javadoc이 명시 |
| `DefaultMessagingAdminService` | admin-runtime | **0** | 아니오 |
| `HmacApprovalVerifier` | admin-api | **0** (test 4) | 아니오 |
| `TopologyValidationRuntime` | admin-runtime | **0** | 아니오 |
| `RedriveService` / `ReplayService` | admin-runtime | 2 / 1 | 아니오 |
`DefaultMessagingAdminService``MessagingAdminService`(인터페이스, main 참조 2)의 유일한 구현이다. 즉 admin plane을 켜도 admin 서비스가 없다. `HmacApprovalVerifier``ApprovalVerifier`의 유일한 구현이고, `ApprovedRedrivePlan`/`ApprovedReplayPlan`/`VerifiedApproval`/`PlanDigest`(main 참조 10)로 이루어진 승인 사슬 전체가 **검증자 없이는 시작될 수 없다.**
## 부재의 등급이 다르지 않은데 문서화는 하나만 됐다
`DestructiveMessagingAdmin`의 부재에는 명확한 이유가 있다("이 런타임은 admin 자격 증명을 갖지 않는다"). 나머지 넷에는 이유가 적혀 있지 않고, 그중 둘은 파괴적이지 않은 admin 동작(redrive/replay의 승인·실행)에 필요한 것이다.
## 실패 시나리오
운영 절차서(`docs/messaging/retry-dlq-redrive.md`)에 따라 사고 대응 중 redrive를 실행하려 한다. `app.messaging.admin.enabled=true`로 켠다. 부팅은 성공하고 가드·journal·durability 검증기가 올라온다. 그런데 `MessagingAdminService` bean이 없으므로 redrive를 호출할 대상이 없다 — `MessagingAdminDurabilityValidator`의 javadoc이 경계한 상황("the gap only shows up during the incident the operation was run to resolve, which is **the worst possible moment to discover it**")과 정확히 같은 시점이다. P2.
## 확인하지 못한 것
관리 스위치를 켜고 재구동을 호출해 대상이 없는 것을 재현하지 않았다. 빈 목록상 그 결과가 나온다.
<!-- body:end -->