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:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
+253
@@ -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 -->
|
||||
+223
@@ -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 -->
|
||||
+134
@@ -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 -->
|
||||
+127
@@ -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 -->
|
||||
+133
@@ -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 -->
|
||||
+160
@@ -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 -->
|
||||
+130
@@ -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 -->
|
||||
+143
@@ -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 -->
|
||||
+122
@@ -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 -->
|
||||
+121
@@ -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 -->
|
||||
+122
@@ -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 -->
|
||||
+124
@@ -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 -->
|
||||
+114
@@ -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 -->
|
||||
+128
@@ -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 -->
|
||||
+100
@@ -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 -->
|
||||
+110
@@ -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 -->
|
||||
+135
@@ -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 -->
|
||||
Reference in New Issue
Block a user