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

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

Follows the import procedure in README.md.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,108 @@
---
kind: CASE
slug: capability-constant-outlives-its-condition
title: 능력 상수가 프로파일 조건보다 오래 살아서 중복 제거 없는 목적지가 가드를 통과한다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:capability-constant-outlives-its-condition
evidenceCapturedOn: 2026-09-01
body: case-capability-constant-outlives-its-condition.body.md
assets:
- key: capability-constant-outlives-its-condition
file: ../../../final/evidence/rendered/capability-constant-outlives-its-condition.svg
evidence:
- ../../../final/evidence/raw/capability-constant-outlives-its-condition.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-nats-experimental.md#L111 이다.
---
# 능력 상수가 프로파일 조건보다 오래 살아서 중복 제거 없는 목적지가 가드를 통과한다
어댑터가 중복 제거 지원을 상수로 참이라 답한다. 실제 중복 제거 식별자는 프로파일에 창이 있을 때만 실린다. 창은 선택 사항이고 아무도 요구하지 않는다. 그리고 이 플래그는 부재가 예외를 만드는 유일한 능력이다.
## 관계
- **같은 자동 설정 안에서 검증기 하나만 감싸이지 않아 트랜잭션 조건이 검사되지 않는다**
같은 통독에서 나온 짝이다.
- **지원 매트릭스가 코드와 반대를 말한다**
같은 능력 표의 반대 방향 사례다.
- **조용히 약해진 보증은 사고 전까지 동작하는 것과 구분되지 않는다**
능력 레코드의 자바독이 적은 근거다.
## 문제
능력 레코드의 자바독이 계약을 선언한다.
프로파일이 여기 없는 것을 요구하면 플랫폼이 크게 실패한다는 것이다. 조용히 저하되지 않는다는 것이고, 그 이유는 조용히 약해진 보증이 사고가 나기 전까지 동작하는 보증과 구분되지 않기 때문이라는 것이다.
이 어댑터가 그 계약을 어떻게 답하는지 확인했다.
## 결론
상수로 답한다.
능력 값이 정적 최종 필드이고 프로파일을 보지 않는다. 그중 중복 제거 발행이 참이다.
그런데 중복 제거 식별자는 프로파일에 창이 있을 때만 만들어진다. 창이 비어 있으면 식별자가 없고, 헤더가 실리지 않고, 서버는 중복을 제거하지 않는다.
창은 선택 사항이다. 프로파일 생성자도 시작 검증기도 창을 요구하지 않는다.
이 플래그가 특별한 이유가 있다.
능력 열둘 중 부재가 예외를 만드는 유일한 것이다. 나머지는 읽히지 않거나 분기에만 쓰인다.
그러므로 창 없는 목적지가 그 가드를 통과한다. 가드는 어댑터가 참이라 답했으니 통과시킨다.
그리고 어댑터 자신이 그 조건을 알고 있다.
클래스 자바독이 시간 초과 발행을 모호로 다루는 이유를 적으면서, 그 모호를 재시도해도 안전하게 만드는 것은 중복 제거 창이며 프로파일이 그것을 켰을 때라고 적는다.
프로파일이 그것을 켰을 때라는 조건이 정확히 능력이 담지 않은 것이다.
창이 없는 목적지에서 모호를 재시도하면 스트림에 같은 메시지가 두 번 들어간다.
## 검증 환경
확인 방식 : 능력 상수와 식별자 생성 경로 대조, 프로파일 생성자와 검증기의 요구 확인
소스 수정 : x
## 재현 조건
원문은 document-detail 의 analysis/messaging/messaging-nats-experimental.md 에 있다.
1. 어댑터의 능력 상수를 읽고 각 성분의 의미를 레코드 문서에서 확인한다.
2. 중복 제거 식별자를 만드는 메서드를 읽는다.
3. 프로파일의 창 필드가 선택 사항인지 확인한다.
4. 생성자와 검증기가 창을 요구하는지 확인한다.
5. 그 플래그의 부재가 어디서 예외를 만드는지 확인한다.
## 본문
<!-- body:start -->
어댑터가 `deduplicatedPublish=true` 를 상수로 답한다. 그런데 중복 제거 식별자는 프로파일에 창이 있을 때만 만들어지고, 창은 선택 사항이며 생성자도 검증기도 요구하지 않는다.
## 중복 제거 식별자가 만들어지는 조건
:::evidence key="capability-constant-outlives-its-condition" alt="분석 문서 analysis/messaging/messaging-nats-experimental.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-nats-experimental.md 발췌 — 15줄" zoom="true"
:::
## 창이 없으면 서버가 중복을 제거하지 않는다
`Nats-Msg-Id` 가 실리지 않기 때문이다.
## 하필 그 플래그다
이 플래그는 능력 열둘 중 부재가 예외를 만드는 유일한 것이라, 창 없는 목적지가 그 가드를 통과한 뒤 모호 재발행에서 스트림에 같은 메시지를 두 번 넣는다.
## 어댑터 자신의 javadoc 이 조건을 안다
재시도를 안전하게 만드는 것은 창이며 "프로파일이 그것을 켰을 때" 라고 적는다.
## 확인하지 못한 것
창 없는 프로파일로 모호 재발행을 실행해 중복 저장을 재현하지 않았다. 이 리프는 실험 등급이고 배선 경로가 없다.
<!-- body:end -->
@@ -0,0 +1,74 @@
---
kind: CASE
slug: grpc-spring-boot-starter-f02
title: 자동 설정이 transport 를 읽지 않고 전송을 하드코딩한다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-spring-boot-starter-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-spring-boot-starter-f02
file: ../../../final/evidence/rendered/grpc-spring-boot-starter-f02.svg
evidence:
- ../../../final/evidence/raw/grpc-spring-boot-starter-f02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-spring-boot-starter.md#L221 이다.
module: grpc-spring-boot-starter
priority: P3
---
# 자동 설정이 transport 를 읽지 않고 전송을 하드코딩한다
GrpcPlatformProperties.transport 는 GrpcServerTransport 열거형이고 기본값이 NETTY_SHADED 다. 그 값을 자동 설정이 보지 않으므로 다른 값을 설정해도 만들어지는 프로파일은 같다.
## 문제
GrpcPlatformProperties.transport 는 GrpcServerTransport 열거형이고 기본값이 NETTY_SHADED 다.
그 값을 자동 설정이 보지 않으므로 다른 값을 설정해도 만들어지는 프로파일은 같다.
## 결론
지금은 무해에 가깝다 — 기본값이 하드코딩된 것과 같고, 다른 값은 §17.1 때문에 거부되지도 않지만 반영되지도 않는다.
그러나 설정 키가 존재하고 문서화되어 있으므로 운영자는 그것이 전송을 고른다고 읽는다.
수정은 프로파일 팩토리를 transport 로 분기시키거나, 그 키를 검증 전용임을 자바독에 명시하는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcPlatformProperties 참조 19건 검색과 자동 설정이 transport 값을 읽는지 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-spring-boot-starter.md#L221 에 있다.
## 본문
<!-- body:start -->
`GrpcPlatformProperties.transport``GrpcServerTransport` 열거형이고 기본값이 `NETTY_SHADED` 다. 그 값을 자동 설정이 보지 않으므로 다른 값을 설정해도 만들어지는 프로파일은 같다.
## GrpcPlatformProperties 참조 위치
:::evidence key="grpc-spring-boot-starter-f02" alt="코드베이스에서 GrpcPlatformProperties 를 검색한 출력 19줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcPlatformProperties 코드베이스 검색 — 19줄 · exit 0" zoom="true"
:::
## 지금은 무해에 가깝다
기본값이 하드코딩된 것과 같고, 다른 값은 §17.1 때문에 거부되지도 않지만 반영되지도 않는다.
## 그래도 운영자는 그것이 전송을 고른다고 읽는다
설정 키가 존재하고 문서화되어 있다. 수정은 프로파일 팩토리를 `transport` 로 분기시키거나, 그 키를 검증 전용임을 자바독에 명시하는 것이다.
## 확인하지 못한 것
다른 transport 값을 설정해 만들어지는 프로파일이 같은지 실행으로 확인하지 않았다. 자동 설정이 그 값을 읽지 않는다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,83 @@
---
kind: CASE
slug: messaging-admin-runtime-f02
title: 토폴로지 검증 스택이 두 벌이고 판정이 어긋난다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-admin-runtime-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-f02
file: ../../../final/evidence/rendered/messaging-admin-runtime-f02.svg
- key: messaging-admin-runtime-f02-diagram
file: ../../../final/assets/diagrams/messaging-admin-runtime-f02.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-f02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L910 이다.
module: messaging-admin-runtime
priority: P2
---
# 토폴로지 검증 스택이 두 벌이고 판정이 어긋난다
Stack A 는 파티션 스케일업을 ADVISORY 로 두어 기동을 허용하고 그 근거를 명시한다. Stack B 는 같은 상황을 차이로 보고 기동을 거부한다.
## 문제
Stack A 는 파티션 스케일업을 ADVISORY 로 두어 기동을 허용하고 그 근거를 명시한다.
Stack B 는 같은 상황을 차이로 보고 기동을 거부한다.
## 결론
둘 다 프로덕션 호출부가 0건이라 지금은 충돌하지 않지만, analysis/messaging/messaging-admin-api.md §17 첫 항목대로 토폴로지 검증을 기동에 배선하는 순간 어느 스택을 배선하느냐가 스케일업한 배포의 기동 여부를 가른다.
Stack A 가 남아야 할 것으로 보인다 — severity 구분, physicalName 검사, 근거 주석이 있고 테스트도 13건으로 더 두껍다.
Stack B(TopologyValidationRuntime, TopologyReader, ObservedTopology, 그리고 그것만 쓰는 TopologyManifest.differencesFrom)를 제거하는 편이 낫다.
같은 코드 문자열 TOPOLOGY_MISMATCH 를 두 예외 타입이 쓰는 것도 정리 대상이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : TopologyValidationRuntime 참조 13건 검색과 두 스택이 같은 상황에 내리는 판정 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-admin-runtime.md#L910 에 있다.
## 본문
<!-- body:start -->
Stack A 는 파티션 스케일업을 ADVISORY 로 두어 기동을 허용하고 그 근거를 명시한다. Stack B 는 같은 상황을 차이로 보고 기동을 거부한다.
## 두 스택이 갈리는 지점
:::evidence key="messaging-admin-runtime-f02-diagram" alt="스택 A 쪽에 파티션 스케일업과 ADVISORY 기동 허용이 놓이고 스택 B 쪽에 파티션 스케일업과 차이 기동 거부가 놓인다" caption="두 스택이 갈리는 지점" zoom="false"
:::
## TopologyValidationRuntime 참조 위치
:::evidence key="messaging-admin-runtime-f02" alt="코드베이스에서 TopologyValidationRuntime 를 검색한 출력 13줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="TopologyValidationRuntime 코드베이스 검색 — 13줄 · exit 0" zoom="true"
:::
## 배선하는 순간 기동 여부가 갈린다
둘 다 프로덕션 호출부가 0건이라 지금은 충돌하지 않지만, `analysis/messaging/messaging-admin-api.md` §17 첫 항목대로 토폴로지 검증을 기동에 배선하는 순간 **어느 스택을 배선하느냐가 스케일업한 배포의 기동 여부를 가른다**.
## 남아야 할 쪽
Stack A 로 보인다 — severity 구분, `physicalName` 검사, 근거 주석이 있고 테스트도 13건으로 더 두껍다. Stack B(`TopologyValidationRuntime`, `TopologyReader`, `ObservedTopology`, 그리고 그것만 쓰는 `TopologyManifest.differencesFrom`)를 제거하는 편이 낫다. 같은 코드 문자열 `TOPOLOGY_MISMATCH` 를 두 예외 타입이 쓰는 것도 정리 대상이다.
## 확인하지 못한 것
두 스택을 실제 브로커 토폴로지에 걸어 판정 차이를 관측하지 않았다. BrokerTopologyInspector 의 구현이 저장소에 없다.
<!-- body:end -->
@@ -0,0 +1,68 @@
---
kind: CASE
slug: messaging-admin-runtime-f10
title: 실패한 리드라이브 항목의 사유가 어디에도 남지 않는다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-admin-runtime-f10
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-f10
file: ../../../final/evidence/rendered/messaging-admin-runtime-f10.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-f10.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L961 이다.
module: messaging-admin-runtime
priority: P3
---
# 실패한 리드라이브 항목의 사유가 어디에도 남지 않는다
attempt(...) 는 예외와 미확인을 모두 false 로 접는다(RedriveService:142-156). 감사 이벤트는 failed 개수만 담는다(:135).
## 문제
attempt(...) 는 예외와 미확인을 모두 false 로 접는다(RedriveService:142-156).
감사 이벤트는 failed 개수만 담는다(:135).
## 결론
사건 복구 중에 "왜 이 메시지들이 안 갔는가" 를 물을 수 있어야 하는데 답이 없다.
RedriveReport 에 실패 사유별 집계(코드 → 개수) 정도만 추가해도 크게 달라진다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : RedriveReport 참조 10건 검색과 실패 항목이 접히는 지점 및 감사 이벤트가 담는 값 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-admin-runtime.md#L961 에 있다.
## 본문
<!-- body:start -->
`attempt(...)` 는 예외와 미확인을 모두 `false` 로 접는다(`RedriveService:142-156`). 감사 이벤트는 `failed` 개수만 담는다(`:135`).
## RedriveReport 참조 위치
:::evidence key="messaging-admin-runtime-f10" alt="코드베이스에서 RedriveReport 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RedriveReport 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## 사건 복구 중에 물을 수 있어야 하는 질문
"왜 이 메시지들이 안 갔는가" 인데 답이 없다. `RedriveReport` 에 실패 사유별 집계(코드 → 개수) 정도만 추가해도 크게 달라진다.
## 확인하지 못한 것
사건 복구 중에 실패 사유를 되묻는 상황을 재현하지 않았다. attempt 가 예외와 미확인을 같은 값으로 접는다는 코드로 판정했다.
<!-- body:end -->
@@ -0,0 +1,79 @@
---
kind: CASE
slug: messaging-core-api-f02
title: 배치 metadata를 만들고 넘길 곳이 없다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-core-api-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-core-api-f02
file: ../../../final/evidence/rendered/messaging-core-api-f02.svg
- key: messaging-core-api-f02-diagram
file: ../../../final/assets/diagrams/messaging-core-api-f02.svg
evidence:
- ../../../final/evidence/raw/messaging-core-api-f02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-core-api.md#L844 이다.
module: messaging-core-api
priority: P2
---
# 배치 metadata를 만들고 넘길 곳이 없다
KafkaBatchConsumerRegistrar:104와 RabbitBatchConsumerRegistrar:139가 BatchDeliveryMetadata를 만들고, 두 javadoc 다 "the batch-wide metadata handed to the handler"라고 적는다. new BatchMessageDelivery는 저장소 전체에서 0건이고 BatchMessageHandler 참조도 0건이다.
## 문제
KafkaBatchConsumerRegistrar:104와 RabbitBatchConsumerRegistrar:139가 BatchDeliveryMetadata를 만들고, 두 javadoc 다 "the batch-wide metadata handed to the handler"라고 적는다.
new BatchMessageDelivery는 저장소 전체에서 0건이고 BatchMessageHandler 참조도 0건이다.
## 결론
두 registrar는 브로커별로 다른 정확한 계산을 한다 — Kafka는 파티션 단위 커밋이라 settlableAsBatch=true, Rabbit은 multiple-ack이 in-flight까지 정산하므로 false.
이 판단이 계산되어 어디에도 전달되지 않는다.
javadoc은 존재하지 않는 수신자를 가리킨다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : BatchDeliveryMetadata 참조 10건 검색과 그것을 받을 타입의 생성 지점 수 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-core-api.md#L844 에 있다.
## 본문
<!-- body:start -->
`KafkaBatchConsumerRegistrar:104``RabbitBatchConsumerRegistrar:139``BatchDeliveryMetadata`를 만들고, 두 javadoc 다 "the batch-wide metadata handed to the handler"라고 적는다.
## 만들고 넘길 곳이 없는 값
:::evidence key="messaging-core-api-f02-diagram" alt="registrar 의 계산만 이 값이 도달하는 범위 안에 놓이고 BatchMessageHandler 가 바깥에 빗금으로 놓인다" caption="만들고 넘길 곳이 없는 값" zoom="false"
:::
`new BatchMessageDelivery`는 저장소 전체에서 0건이고 `BatchMessageHandler` 참조도 0건이다.
## BatchDeliveryMetadata 참조 위치
:::evidence key="messaging-core-api-f02" alt="코드베이스에서 BatchDeliveryMetadata 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="BatchDeliveryMetadata 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## 브로커별로 다른 정확한 계산이 버려진다
Kafka는 파티션 단위 커밋이라 `settlableAsBatch=true`, Rabbit은 multiple-ack이 in-flight까지 정산하므로 `false`. 이 판단이 계산되어 어디에도 전달되지 않고, javadoc은 존재하지 않는 수신자를 가리킨다.
## 확인하지 못한 것
저장소 밖 소비자가 이 타입을 구현하는지 확인할 방법이 이 저장소 안에 없다. 이 판단이 그 미지수에 걸려 있다.
<!-- body:end -->
@@ -0,0 +1,84 @@
---
kind: CASE
slug: messaging-inbox-jdbc-postgresql-f03
title: SQL 실패가 재시도 불가로 분류된다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-inbox-jdbc-postgresql-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-inbox-jdbc-postgresql-f03
file: ../../../final/evidence/rendered/messaging-inbox-jdbc-postgresql-f03.svg
- key: messaging-inbox-jdbc-postgresql-f03-diagram
file: ../../../final/assets/diagrams/messaging-inbox-jdbc-postgresql-f03.svg
evidence:
- ../../../final/evidence/raw/messaging-inbox-jdbc-postgresql-f03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-inbox-jdbc-postgresql.md#L666 이다.
module: messaging-inbox-jdbc-postgresql
priority: P2
---
# SQL 실패가 재시도 불가로 분류된다
INBOX_RESERVE_FAILED·INBOX_QUERY_FAILED·INBOX_PURGE_FAILED 셋 다 MessagingConfigurationException이고, 그 예외의 카테고리는 CONFIGURATION, retryable = false다. SQLException의 원인 대부분은 구성 오류가 아니라 일시적 인프라다 — 연결 끊김, 데드락, 락 타임아웃, 커넥션 풀 고갈.
## 관계
- **컬럼 폭은 애플리케이션 검증과 짝을 이룬다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
INBOX_RESERVE_FAILED·INBOX_QUERY_FAILED·INBOX_PURGE_FAILED 셋 다 MessagingConfigurationException이고, 그 예외의 카테고리는 CONFIGURATION, retryable = false다.
SQLException의 원인 대부분은 구성 오류가 아니라 일시적 인프라다 — 연결 끊김, 데드락, 락 타임아웃, 커넥션 풀 고갈.
## 결론
FailureCategory는 "the stable classification a retry engine, DLQ router, and dashboard all agree on"이고 retryable = false는 재시도 엔진이 즉시 파킹한다는 뜻이다.
같은 leaf의 INBOX_ACTION_FAILED는 TRANSIENT_INFRASTRUCTURE/retryable = true로 정확히 분류된다 — 같은 파일 안에서 기준이 갈린다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 세 코드가 쓰는 예외 타입과 그 예외의 카테고리·재시도 가능 여부 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-inbox-jdbc-postgresql.md#L666 에 있다.
## 본문
<!-- body:start -->
`INBOX_RESERVE_FAILED`·`INBOX_QUERY_FAILED`·`INBOX_PURGE_FAILED` 셋 다 `MessagingConfigurationException`이고, 그 예외의 카테고리는 `CONFIGURATION`, `retryable = false`다.
## 같은 파일 안에서 갈리는 분류
:::evidence key="messaging-inbox-jdbc-postgresql-f03-diagram" alt="SQLException 에서 세 코드는 CONFIGURATION 이고 INBOX_ACTION_FAILED 는 TRANSIENT 인 두 갈래가 나온다" caption="같은 파일 안에서 갈리는 분류" zoom="false"
:::
`SQLException`의 원인 대부분은 구성 오류가 아니라 **일시적 인프라**다 — 연결 끊김, 데드락, 락 타임아웃, 커넥션 풀 고갈.
## 세 코드가 공유하는 예외 타입
:::evidence key="messaging-inbox-jdbc-postgresql-f03" alt="분석 문서 analysis/messaging/messaging-inbox-jdbc-postgresql.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-inbox-jdbc-postgresql.md 발췌 — 15줄" zoom="true"
:::
## 세 소비자의 합의가 깨진다
`FailureCategory`는 "the stable classification a retry engine, DLQ router, and dashboard all agree on"이고 `retryable = false`는 재시도 엔진이 즉시 파킹한다는 뜻이다. 같은 leaf의 `INBOX_ACTION_FAILED``TRANSIENT_INFRASTRUCTURE`/`retryable = true`로 정확히 분류된다 — 같은 파일 안에서 기준이 갈린다.
## 확인하지 못한 것
일시적 인프라 오류를 실제로 일으켜 메시지가 파킹되는 것을 관측하지 않았다. 예외 타입과 카테고리의 대조로 판정했다.
<!-- body:end -->
@@ -0,0 +1,97 @@
---
kind: CASE
slug: messaging-kafka-f02
title: 천장에 닿아 일시정지된 파티션을 재개하는 경로가 없다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-kafka-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-f02
file: ../../../final/evidence/rendered/messaging-kafka-f02.svg
- key: messaging-kafka-f02-diagram
file: ../../../final/assets/diagrams/messaging-kafka-f02.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-f02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka.md#L265 이다.
module: messaging-kafka
priority: P2
---
# 천장에 닿아 일시정지된 파티션을 재개하는 경로가 없다
pollOnce 의 파티션 루프는 세 경우에 그 파티션을 멈춘다. 이 세 경로 중 어느 것도 retries.pauseUntil(...) 을 부르지 않는다.
## 문제
pollOnce 의 파티션 루프는 세 경우에 그 파티션을 멈춘다.
이 세 경로 중 어느 것도 retries.pauseUntil(...) 을 부르지 않는다.
## 결론
그런데 폴 루프가 파티션을 재개하는 곳은 하나뿐이다.
retries 에 항목을 넣는 곳은 QueuedSettlement.enqueueRequeue 하나이고, 그것은 핸들러 실패·타임아웃·명시적 requeue 경로다.
천장·배수·풀 거부 경로는 등록하지 않는다.
따라서 천장 때문에 멈춘 파티션은 폴 루프가 스스로 재개하지 않는다.
재개할 수 있는 것은 외부에서 부른 resume(scope) 이나 재조정뿐이다.
maxInFlightPerOrderingUnit 의 기본값은 1 이다(DestinationSettings.Consumer).
한 폴이 같은 파티션의 레코드를 둘 이상 돌려주는 순간 두 번째에서 tryAcquire 가 거짓이 되고, 그 파티션이 멈춘다.
그 뒤 작업자가 끝나 coordinator.release 로 슬롯이 비어도 consumer 는 여전히 일시정지 상태다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : QueuedSettlement 참조 3건 검색과 파티션을 멈추는 세 경로의 재개 등록 여부 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-kafka.md#L265 에 있다.
## 본문
<!-- body:start -->
`pollOnce` 의 파티션 루프는 세 경우에 그 파티션을 멈춘다 — 천장(`tryAcquire` 실패), 배수 시작(`tryBeginWork` 실패), 풀 거부(`dispatch` 실패). 이 세 경로 중 어느 것도 `retries.pauseUntil(...)` 을 부르지 않는다.
## 재개가 끊긴 자리
:::evidence key="messaging-kafka-f02-diagram" alt="핸들러 실패와 타임아웃과 requeue 만 재개 목록에 등록되는 경로 안에 놓이고 천장 도달과 배수 및 풀 거부가 바깥에 빗금으로 놓인다" caption="재개가 끊긴 자리" zoom="false"
:::
폴 루프가 파티션을 재개하는 곳은 `applyDueResumes` 하나이고 그것은 `retries.dueForResume(now)` 만 본다. `retries` 에 항목을 넣는 곳은 `QueuedSettlement.enqueueRequeue` 하나이고, 그것은 핸들러 실패·타임아웃·명시적 requeue 경로다.
## QueuedSettlement 참조 위치
:::evidence key="messaging-kafka-f02" alt="코드베이스에서 QueuedSettlement 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="QueuedSettlement 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 기본값이 이 경로를 흔하게 만든다
`maxInFlightPerOrderingUnit` 의 기본값은 1 이다(`DestinationSettings.Consumer`). 한 폴이 같은 파티션의 레코드를 둘 이상 돌려주는 순간 두 번째에서 `tryAcquire` 가 거짓이 되고 그 파티션이 멈춘다. 그 뒤 작업자가 끝나 `coordinator.release` 로 슬롯이 비어도 `consumer` 는 여전히 일시정지 상태다.
## 두 pause 가 구분되어 쓰인다
`applySettlements``PAUSE_AND_SEEK``coordinator.pause(...)``consumer.pause(...)` 를 둘 다 부르고, 천장 경로는 `consumer` 쪽만 부른다. 그래서 조정자는 그 파티션을 멈춘 것으로 알지 못하고, `tryAcquire` 는 계속 참을 답하는데 브로커에서 레코드가 오지 않는다.
## 수정
천장 경로가 `retries.pauseUntil(partition, seekBackTo, Duration.ZERO, now)` 를 등록하면 다음 주기의 `applyDueResumes` 가 즉시 재개한다 — 지연이 0 이므로 `dueForResume` 이 곧바로 돌려준다. 배수 경로는 재개하지 않는 것이 맞고, 풀 거부 경로는 천장과 같다. 소비 경로가 조립되지 않으므로(§12.1) P2.
## 확인하지 못한 것
실행으로 재현하지 않았다. resume 호출처가 둘뿐이고 천장 경로가 재개 목록에 아무것도 등록하지 않는다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,97 @@
---
kind: CASE
slug: messaging-kafka-f03
title: 오염된 재시도 헤더가 격리되지 않고 무한 pause-and-seek 을 만든다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-kafka-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-f03
file: ../../../final/evidence/rendered/messaging-kafka-f03.svg
- key: messaging-kafka-f03-diagram
file: ../../../final/assets/diagrams/messaging-kafka-f03.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-f03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka.md#L301 이다.
module: messaging-kafka
priority: P2
---
# 오염된 재시도 헤더가 격리되지 않고 무한 pause-and-seek 을 만든다
KafkaRetryMetadataMapper.attemptOf 는 읽을 수 없는 msg.retry.attempt 에 대해 fail-closed 를 택하고, 그 이유를 정확하게 적는다. 메시지가 "quarantined" 라고 말한다.
## 문제
KafkaRetryMetadataMapper.attemptOf 는 읽을 수 없는 msg.retry.attempt 에 대해 fail-closed 를 택하고, 그 이유를 정확하게 적는다.
메시지가 "quarantined" 라고 말한다.
## 결론
소비자는 그렇게 하지 않는다.
격리 경로는 디코딩 실패에만 걸려 있다.
attemptOf 는 디코딩이 끝난 뒤 두 번째 블록에서 던지고, MessagingConfigurationException 은 MessagingException 을 통해 RuntimeException 이므로 두 번째 catch 가 잡는다.
결과는 requeueAfterFailure() → PAUSE_AND_SEEK → 같은 오프셋 재읽기 → 같은 헤더 → 같은 예외다.
즉 fail-closed 가 막으려던 것(재시도 예산 무력화)보다 나쁜 것을 만든다 — 그 파티션이 영구히 그 레코드에서 멈춘다.
그리고 javadoc 이 지적한 대로 이 헤더는 호출자가 쓸 수 있는 값이므로, 숫자가 아닌 값 하나로 파티션 하나를 정지시킬 수 있다.
ReservedHeaderForgeryTest.aMalformedRetryAttemptIsQuarantined 는 attemptOf 가 던지는 것만 단언한다.
이름은 "quarantined" 인데 격리를 확인하지 않는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : KafkaRetryMetadataMapper 참조 9건 검색과 던지는 지점이 놓인 try·catch 범위 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-kafka.md#L301 에 있다.
## 본문
<!-- body:start -->
`KafkaRetryMetadataMapper.attemptOf` 는 읽을 수 없는 `msg.retry.attempt` 에 대해 fail-closed 를 택하고, 메시지가 "quarantined" 라고 말한다. 소비자는 그렇게 하지 않는다 — 격리 경로는 **디코딩 실패에만** 걸려 있다.
## 격리 대신 도는 루프
:::evidence key="messaging-kafka-f03-diagram" alt="숫자 아닌 헤더가 attemptOf 던짐과 두 번째 catch 를 지나 같은 오프셋 재읽기로 이어진다" caption="격리 대신 도는 루프" zoom="false"
:::
`attemptOf` 는 디코딩이 끝난 뒤 두 번째 블록에서 던지고, `MessagingConfigurationException``MessagingException` 을 통해 `RuntimeException` 이므로 두 번째 `catch` 가 잡는다. 결과는 `requeueAfterFailure()``PAUSE_AND_SEEK` → 같은 오프셋 재읽기 → 같은 헤더 → 같은 예외다.
## KafkaRetryMetadataMapper 참조 위치
:::evidence key="messaging-kafka-f03" alt="코드베이스에서 KafkaRetryMetadataMapper 를 검색한 출력 9줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="KafkaRetryMetadataMapper 코드베이스 검색 — 9줄 · exit 0" zoom="true"
:::
## fail-closed 가 막으려던 것보다 나쁜 것을 만든다
그 파티션이 영구히 그 레코드에서 멈춘다. 그리고 javadoc 이 지적한 대로 이 헤더는 호출자가 쓸 수 있는 값이므로, 숫자가 아닌 값 하나로 파티션 하나를 정지시킬 수 있다.
## 테스트가 이름과 다른 것을 본다
`ReservedHeaderForgeryTest.aMalformedRetryAttemptIsQuarantined``attemptOf` 가 던지는 것만 단언한다. 이름은 "quarantined" 인데 격리를 확인하지 않는다.
## 수정
`attemptOf` 호출을 디코딩과 같은 블록으로 옮겨 격리 경로에 태우거나, 두 번째 `catch` 가 예외 종류를 나누게 한다 — `MessagingConfigurationException` 은 재시도로 회복되지 않는 종류이므로 격리 대상이고, 핸들러 실패는 재시도 대상이다.
## 확인하지 못한 것
실행으로 재현하지 않았다. attemptOf 가 dispatch 의 두 번째 try 안에 있고 그 catch 가 재요청이라는 것, 그리고 그 예외가 RuntimeException 을 상속한다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,91 @@
---
kind: CASE
slug: messaging-kafka-f04
title: 시계를 주입받는 클래스가 한 곳에서만 벽시계를 읽는다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-kafka-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-f04
file: ../../../final/evidence/rendered/messaging-kafka-f04.svg
- key: messaging-kafka-f04-diagram
file: ../../../final/assets/diagrams/messaging-kafka-f04.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-f04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka.md#L342 이다.
module: messaging-kafka
priority: P3
---
# 시계를 주입받는 클래스가 한 곳에서만 벽시계를 읽는다
KafkaConsumerRegistrar 의 설계 성질이 javadoc 에 적혀 있다. 주기마다 Instant now 를 받아 applyDueResumes(now) 로 넘긴다.
## 문제
KafkaConsumerRegistrar 의 설계 성질이 javadoc 에 적혀 있다.
주기마다 Instant now 를 받아 applyDueResumes(now) 로 넘긴다.
## 결론
그런데 그 짝인 등록 쪽은 이렇다.
이 리프에서 Instant.now() 를 읽는 유일한 자리다.
그리고 그 호출은 작업자 스레드에서 일어나므로 폴 스레드의 now 와 다른 순간이다.
결과는 두 가지다.
지연 재시도(requeue(Duration))의 재개 시점을 고정 시계로 시험할 수 없고, 시험이 Duration.ZERO 밖의 지연을 다루지 못한다 — 실제로 어떤 시험도 다루지 않는다.
수정은 생성자에 Supplier<Instant> 를 하나 더 받는 것이다.
같은 저장소의 MessagingShutdownLifecycle 이 정확히 그 형태로 두 생성자를 둔다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : KafkaConsumerRegistrar 참조 5건 검색과 주입된 시계가 덮는 범위 대비 벽시계 호출 지점 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-kafka.md#L342 에 있다.
## 본문
<!-- body:start -->
`KafkaConsumerRegistrar` 의 설계 성질이 javadoc 에 적혀 있다 — 주기마다 `Instant now` 를 받아 `applyDueResumes(now)` 로 넘긴다.
## 시계가 닿지 않는 한 곳
:::evidence key="messaging-kafka-f04-diagram" alt="폴 주기의 now 만 주입된 시계가 덮는 범위 안에 놓이고 등록 쪽 Instant.now 가 바깥에 빗금으로 놓인다" caption="시계가 닿지 않는 한 곳" zoom="false"
:::
그 짝인 등록 쪽이 이 리프에서 `Instant.now()` 를 읽는 유일한 자리다. 그리고 그 호출은 작업자 스레드에서 일어나므로 폴 스레드의 `now` 와 다른 순간이다.
## KafkaConsumerRegistrar 참조 위치
:::evidence key="messaging-kafka-f04" alt="코드베이스에서 KafkaConsumerRegistrar 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="KafkaConsumerRegistrar 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## 결과 둘
지연 재시도(`requeue(Duration)`)의 재개 시점을 고정 시계로 시험할 수 없고, 시험이 `Duration.ZERO` 밖의 지연을 다루지 못한다 — 실제로 어떤 시험도 다루지 않는다.
## 수정
생성자에 `Supplier<Instant>` 를 하나 더 받는 것이다. 같은 저장소의 `MessagingShutdownLifecycle` 이 정확히 그 형태로 두 생성자를 둔다.
## 확인하지 못한 것
시계를 고정해 두 경로의 시각 차이를 관측하지 않았다. 호출 지점의 코드 대조로 판정했다.
<!-- body:end -->
@@ -0,0 +1,82 @@
---
kind: CASE
slug: messaging-kafka-f05
title: 결함으로 판정된 메서드가 남아 있고, 실브로커 증명이 그것 위에서 돈다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-kafka-f05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-f05
file: ../../../final/evidence/rendered/messaging-kafka-f05.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-f05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka.md#L362 이다.
module: messaging-kafka
priority: P3
---
# 결함으로 판정된 메서드가 남아 있고, 실브로커 증명이 그것 위에서 돈다
KafkaTransactionalPublisher 에 같은 일을 하는 메서드가 둘 있다. inTransaction 의 javadoc 이 둘째를 결함으로 지목한다.
## 문제
KafkaTransactionalPublisher 에 같은 일을 하는 메서드가 둘 있다.
inTransaction 의 javadoc 이 둘째를 결함으로 지목한다.
## 결론
sendInTransaction 은 public 이고 production 호출자가 없다.
호출하는 것은 시험 다섯 자리뿐이다 — 그리고 그 다섯이 실브로커 트랜잭션 증명 전부다(KafkaTransactionIT·KafkaTransactionFencingIT·KafkaReadCommittedIT).
고쳐진 inTransaction 을 시험하는 것은 KafkaTransactionOrderingTest 하나이고 MockProducer 다.
즉 실브로커에서 커밋·중단·펜싱이 증명된 것은 옛 모양이고, 새 모양은 목 위에서만 증명됐다.
기능적 차이는 크지 않다(body 가 비어 있으면 두 메서드는 같은 호출열을 만든다).
그래도 두 가지가 남는다 — 결함으로 판정된 순서를 만드는 public 진입점이 여전히 열려 있다는 것, 그리고 실브로커 증거가 production 경로가 아닌 것 위에 있다는 것.
수정은 ITs 를 inTransaction(..., () -> null) 로 옮기고 sendInTransaction 을 지우는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : KafkaTransactionalPublisher 참조 26건 검색과 두 메서드 중 실브로커 증명이 쓰는 쪽 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-kafka.md#L362 에 있다.
## 본문
<!-- body:start -->
`KafkaTransactionalPublisher` 에 같은 일을 하는 메서드가 둘 있고, `inTransaction` 의 javadoc 이 둘째를 결함으로 지목한다. `sendInTransaction` 은 public 이고 production 호출자가 없다.
## KafkaTransactionalPublisher 참조 위치
:::evidence key="messaging-kafka-f05" alt="코드베이스에서 KafkaTransactionalPublisher 를 검색한 출력 26줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="KafkaTransactionalPublisher 코드베이스 검색 — 26줄 · exit 0" zoom="true"
:::
## 실브로커 증명이 옛 모양 위에서 돈다
호출하는 것은 시험 다섯 자리뿐이고, 그 다섯이 실브로커 트랜잭션 증명 전부다(`KafkaTransactionIT`·`KafkaTransactionFencingIT`·`KafkaReadCommittedIT`). 고쳐진 `inTransaction` 을 시험하는 것은 `KafkaTransactionOrderingTest` 하나이고 `MockProducer` 다.
## 남는 두 가지
기능적 차이는 크지 않다 — `body` 가 비어 있으면 두 메서드는 같은 호출열을 만든다. 그래도 결함으로 판정된 순서를 만드는 public 진입점이 여전히 열려 있고, 실브로커 증거가 production 경로가 아닌 것 위에 있다. 수정은 ITs 를 `inTransaction(..., () -> null)` 로 옮기고 `sendInTransaction` 을 지우는 것이다.
## 확인하지 못한 것
Toxiproxy 인증 레인을 직접 돌리지 않았다. 코드와 그 레인이 기록하는 증거 형식만 읽었다.
<!-- body:end -->
@@ -0,0 +1,98 @@
---
kind: CASE
slug: messaging-kafka-share-experimental-f01
title: "등록"이 아무것도 등록하지 않고 성공을 반환한다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-kafka-share-experimental-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-share-experimental-f01
file: ../../../final/evidence/rendered/messaging-kafka-share-experimental-f01.svg
- key: messaging-kafka-share-experimental-f01-diagram
file: ../../../final/assets/diagrams/messaging-kafka-share-experimental-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-share-experimental-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka-share-experimental.md#L467 이다.
module: messaging-kafka-share-experimental
priority: P2
---
# "등록"이 아무것도 등록하지 않고 성공을 반환한다
KafkaShareGroupRegistrar.register(profile, spec)이 spec을 Objects.requireNonNull로만 처리하고 버린다. ShareRegistration은 profile과 AtomicBoolean 둘만 갖는다.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다**
같은 분석 리프에서 끌어낸 규칙이다.
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
KafkaShareGroupRegistrar.register(profile, spec)이 spec을 Objects.requireNonNull로만 처리하고 버린다.
ShareRegistration은 profile과 AtomicBoolean 둘만 갖는다.
## 결론
Kafka 소비자가 만들어지지 않고(import org.apache.kafka 0건), spec.sink가 저장되지 않으므로 어떤 전달도 일어나지 않는다.
반환된 registration은 isActive() == true를 보고한다.
같은 클래스의 javadoc이 pause를 조용히 무시하는 것을 거절한 이유로 "would let a retry policy that depends on pausing appear to work while doing nothing"을 든다.
register 자체가 정확히 그 형태다 — 성공을 반환하고 아무것도 하지 않으며 isActive()가 true다.
오늘 호출자가 없으므로 사고는 아니지만, 이 leaf를 배선하는 사람이 가장 먼저 부를 메서드다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : register 본문에서 spec 이 보관되는 필드 유무와 sink 호출 지점 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-kafka-share-experimental.md#L467 에 있다.
## 본문
<!-- body:start -->
`KafkaShareGroupRegistrar.register(profile, spec)``spec``Objects.requireNonNull`로만 처리하고 버린다.
## 등록이 버리는 것
:::evidence key="messaging-kafka-share-experimental-f01-diagram" alt="profile 과 AtomicBoolean 만 register 가 보관하는 것 안에 놓이고 spec 과 sink 가 바깥에 빗금으로 놓인다" caption="등록이 버리는 것" zoom="false"
:::
`ShareRegistration``profile``AtomicBoolean` 둘만 갖는다. Kafka 소비자가 만들어지지 않고(`import org.apache.kafka` 0건), `spec.sink`가 저장되지 않으므로 어떤 전달도 일어나지 않는다.
## register 가 spec 에 하는 일
:::evidence key="messaging-kafka-share-experimental-f01" alt="분석 문서 analysis/messaging/messaging-kafka-share-experimental.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-kafka-share-experimental.md 발췌 — 15줄" zoom="true"
:::
## 같은 클래스가 이 형태를 거절한 적이 있다
반환된 registration은 `isActive() == true`를 보고한다. 같은 클래스의 javadoc이 pause를 조용히 무시하는 것을 거절한 이유로 "would let a retry policy that depends on pausing appear to work while doing nothing"을 든다. `register` 자체가 정확히 그 형태다 — 성공을 반환하고 아무것도 하지 않으며 `isActive()`가 true다.
## 오늘 호출자는 없다
사고는 아니지만, 이 leaf를 배선하는 사람이 가장 먼저 부를 메서드다.
## 확인하지 못한 것
이 리프를 완성할 계획이 있는지 확인할 수 없었다. 커밋이 대량 커밋뿐이고 기록이 없다.
<!-- body:end -->
@@ -0,0 +1,72 @@
---
kind: CASE
slug: messaging-nats-experimental-f02
title: 닫힌 전송의 거절이 영구 업무 실패로 분류된다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-nats-experimental-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-nats-experimental-f02
file: ../../../final/evidence/rendered/messaging-nats-experimental-f02.svg
evidence:
- ../../../final/evidence/raw/messaging-nats-experimental-f02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-nats-experimental.md#L209 이다.
module: messaging-nats-experimental
priority: P3
---
# 닫힌 전송의 거절이 영구 업무 실패로 분류된다
rejectedLocally 가 FailureCategory.PERMANENT_BUSINESS 를 고정으로 쓰고, 두 호출자 중 하나가 NATS_TRANSPORT_CLOSED 다. 자매 어댑터(Pulsar)와 같은 형태이고 같은 판단이다 — 종료 중이라는 것은 이 세대의 사정이지 업무의 영구 실패가 아니다.
## 문제
rejectedLocally 가 FailureCategory.PERMANENT_BUSINESS 를 고정으로 쓰고, 두 호출자 중 하나가 NATS_TRANSPORT_CLOSED 다.
자매 어댑터(Pulsar)와 같은 형태이고 같은 판단이다 — 종료 중이라는 것은 이 세대의 사정이지 업무의 영구 실패가 아니다.
## 결론
같은 파일의 classify 는 범주를 신중히 나눈다.
두 리프가 같은 형태를 공유하므로 수정도 함께 하는 편이 낫다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : rejectedLocally 가 붙이는 범주와 두 호출자의 실패 성격 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-nats-experimental.md#L209 에 있다.
## 본문
<!-- body:start -->
`rejectedLocally``FailureCategory.PERMANENT_BUSINESS` 를 고정으로 쓰고, 두 호출자 중 하나가 `NATS_TRANSPORT_CLOSED` 다.
## rejectedLocally 가 붙이는 범주
:::evidence key="messaging-nats-experimental-f02" alt="분석 문서 analysis/messaging/messaging-nats-experimental.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-nats-experimental.md 발췌 — 15줄" zoom="true"
:::
## 자매 어댑터와 같은 형태이고 같은 판단이다
Pulsar 쪽도 그렇다 — 종료 중이라는 것은 이 세대의 사정이지 업무의 영구 실패가 아니다.
## 같은 파일의 classify 는 범주를 신중히 나눈다
두 리프가 같은 형태를 공유하므로 수정도 함께 하는 편이 낫다.
## 확인하지 못한 것
종료 중 거절을 실제로 발생시켜 재시도 정책의 차이를 관측하지 않았다. 같은 파일의 분류기가 범주를 나누는 것과의 대조로 판정했다.
<!-- body:end -->
@@ -0,0 +1,97 @@
---
kind: CASE
slug: messaging-nats-experimental-f03
title: NatsJetStreamProfileValidator 를 부르는 곳이 javadoc 링크 하나뿐이다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-nats-experimental-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-nats-experimental-f03
file: ../../../final/evidence/rendered/messaging-nats-experimental-f03.svg
- key: messaging-nats-experimental-f03-diagram
file: ../../../final/assets/diagrams/messaging-nats-experimental-f03.svg
evidence:
- ../../../final/evidence/raw/messaging-nats-experimental-f03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-nats-experimental.md#L217 이다.
module: messaging-nats-experimental
priority: P2
---
# NatsJetStreamProfileValidator 를 부르는 곳이 javadoc 링크 하나뿐이다
75줄짜리 검증기가 이 어댑터의 시작 시점 판단 넷을 들고 있다 — 실험 스위치가 꺼져 있으면 거부, 최소 한 번 배달에 코어 NATS 거부, 순서 있는 소비자와 경쟁 작업자 동시 사용 거부, 키 순서 목적지 거부. 저장소 전역에서 이 클래스 이름이 나오는 곳은 두 줄뿐이다.
## 문제
75줄짜리 검증기가 이 어댑터의 시작 시점 판단 넷을 들고 있다 — 실험 스위치가 꺼져 있으면 거부, 최소 한 번 배달에 코어 NATS 거부, 순서 있는 소비자와 경쟁 작업자 동시 사용 거부, 키 순서 목적지 거부.
저장소 전역에서 이 클래스 이름이 나오는 곳은 두 줄뿐이다.
## 결론
하나는 선언이고 하나는 javadoc 링크다.
코드 호출자 0, 테스트 0.
validate 는 jetStreamEnabled · orderedConsumer · competingWorkers · enabled 를 전부 인자로 받는다.
즉 스스로 아무것도 관찰하지 않고, 호출자가 이미 알고 있는 사실을 넘겨 줘야만 판단한다.
그런 호출자가 없으니 이 판단들은 한 번도 실행된 적이 없다.
전송의 클래스 javadoc 이 "그래서 검증기가 시작 시 그 조합을 거부한다"고 단언한다.
읽는 사람에게 이 어댑터는 코어 NATS 오설정으로부터 보호되는 것처럼 보이는데, 실제로는 아무 게이트도 걸려 있지 않다.
실험 등급이라 지금 당장 사고가 나지는 않지만, 이 어댑터를 실전에 붙이는 사람이 가장 먼저 신뢰할 문장이 지금 사실이 아니다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : NatsJetStreamProfileValidator 참조 2건 검색으로 선언과 javadoc 링크 외의 호출 유무 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-nats-experimental.md#L217 에 있다.
## 본문
<!-- body:start -->
75줄짜리 검증기가 이 어댑터의 시작 시점 판단 넷을 들고 있다 — 실험 스위치가 꺼져 있으면 거부, 최소 한 번 배달에 코어 NATS 거부, 순서 있는 소비자와 경쟁 작업자 동시 사용 거부, 키 순서 목적지 거부.
## 검증기를 가리키는 두 줄
:::evidence key="messaging-nats-experimental-f03-diagram" alt="선언과 javadoc 링크만 검증기를 가리키는 것 안에 놓이고 코드 호출자와 테스트가 바깥에 빗금으로 놓인다" caption="검증기를 가리키는 두 줄" zoom="false"
:::
저장소 전역에서 이 클래스 이름이 나오는 곳은 두 줄뿐이다 — 하나는 선언이고 하나는 **javadoc 링크**다. 코드 호출자 0, 테스트 0.
## NatsJetStreamProfileValidator 참조 위치
:::evidence key="messaging-nats-experimental-f03" alt="코드베이스에서 NatsJetStreamProfileValidator 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="NatsJetStreamProfileValidator 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 스스로 아무것도 관찰하지 않는다
`validate``jetStreamEnabled` · `orderedConsumer` · `competingWorkers` · `enabled` 를 전부 인자로 받는다 — 호출자가 이미 알고 있는 사실을 넘겨 줘야만 판단한다. 그런 호출자가 없으니 이 판단들은 한 번도 실행된 적이 없다.
## 읽는 사람에게는 보호되는 것처럼 보인다
전송의 클래스 javadoc 이 "그래서 검증기가 시작 시 그 조합을 거부한다"고 단언한다. 실험 등급이라 지금 당장 사고가 나지는 않지만, 이 어댑터를 실전에 붙이는 사람이 가장 먼저 신뢰할 문장이 지금 사실이 아니다.
## 같은 형태를 여러 번 봤고 이쪽이 더 나쁘다
`GrpcRawApiImportRule` · `GrpcApplicationBoundaryRules` · `GrpcNettyParityContract` 등은 최소한 테스트가 리터럴을 먹여 판단 자체는 실행해 본다. 여기서는 그것조차 없다. 수정은 어댑터 조립 지점에서 `validate` 를 부르거나, 그럴 지점이 아직 없다면 프로파일 생성 시점에 걸리도록 옮기는 것이다. 어느 쪽도 못 하겠다면 전송 javadoc 의 "refuses ... at startup" 을 사실에 맞게 고친다.
## 확인하지 못한 것
리플렉션이나 문자열 기반 조립으로 이 검증기를 부르는 형태는 배제하지 못했다. 클래스 이름과 패키지 이름 두 가지 grep 으로만 확인했다.
<!-- body:end -->
@@ -0,0 +1,90 @@
---
kind: CASE
slug: messaging-policy-f01
title: 재시도 엔진과 DLQ 조정자가 bean으로 만들어지고 주입되는 곳이 없다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-policy-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-f01
file: ../../../final/evidence/rendered/messaging-policy-f01.svg
- key: messaging-policy-f01-diagram
file: ../../../final/assets/diagrams/messaging-policy-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L781 이다.
module: messaging-policy
priority: P2
---
# 재시도 엔진과 DLQ 조정자가 bean으로 만들어지고 주입되는 곳이 없다
MessagingCoreAutoConfiguration이 RetryDecisionEngine(:167)과 DeadLetterOrchestrator(:179)를 @Bean @ConditionalOnMissingBean으로 만든다. 두 타입을 받는 production 코드는 각각 KafkaRetryExecutor와 KafkaDeadLetterPublisher/RabbitDeadLetterPublisher뿐이고, 셋 다 저장소 어디에서도 생성되지 않는다.
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
MessagingCoreAutoConfiguration이 RetryDecisionEngine(:167)과 DeadLetterOrchestrator(:179)를 @Bean @ConditionalOnMissingBean으로 만든다.
두 타입을 받는 production 코드는 각각 KafkaRetryExecutor와 KafkaDeadLetterPublisher/RabbitDeadLetterPublisher뿐이고, 셋 다 저장소 어디에서도 생성되지 않는다.
## 결론
같은 설정의 51개 bean 중 두 타입을 인자로 받는 @Bean 메서드가 없다.
컨텍스트에 두 bean이 앉아 있고 MessagingAutoConfigurationTest류의 hasSingleBean 검사는 통과한다 — 즉 bean 존재 검사가 배선을 증명하지 않는다.
그리고 이 leaf가 가장 공들인 두 축(6개 재시도 모드·8단 판단 순서·full jitter·capability 인식, DLQ 발행-후-정산 불변식·예약 헤더 6개)이 실행되지 않는다.
42개 테스트 중 16개가 이 두 축을 검증한다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : MessagingCoreAutoConfiguration 참조 8건 검색과 두 빈을 인자로 받는 Bean 메서드 유무 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-policy.md#L781 에 있다.
## 본문
<!-- body:start -->
`MessagingCoreAutoConfiguration``RetryDecisionEngine`(\:167)과 `DeadLetterOrchestrator`(\:179)를 `@Bean @ConditionalOnMissingBean`으로 만든다.
## 만들어지고 쓰이지 않는 빈
:::evidence key="messaging-policy-f01-diagram" alt="RetryDecisionEngine 과 DeadLetterOrchestrator 가 컨텍스트에 있는 것 안에 놓이고 두 빈을 받는 Bean 메서드가 바깥에 빗금으로 놓인다" caption="만들어지고 쓰이지 않는 빈" zoom="false"
:::
두 타입을 받는 production 코드는 각각 `KafkaRetryExecutor``KafkaDeadLetterPublisher`/`RabbitDeadLetterPublisher`뿐이고, **셋 다 저장소 어디에서도 생성되지 않는다.** 같은 설정의 51개 bean 중 두 타입을 인자로 받는 `@Bean` 메서드가 없다.
## MessagingCoreAutoConfiguration 참조 위치
:::evidence key="messaging-policy-f01" alt="코드베이스에서 MessagingCoreAutoConfiguration 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingCoreAutoConfiguration 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## bean 존재 검사가 배선을 증명하지 않는다
컨텍스트에 두 bean이 앉아 있고 `MessagingAutoConfigurationTest`류의 `hasSingleBean` 검사는 통과한다. 그리고 이 leaf가 가장 공들인 두 축(6개 재시도 모드·8단 판단 순서·full jitter·capability 인식, DLQ 발행-후-정산 불변식·예약 헤더 6개)이 실행되지 않는다. 42개 테스트 중 16개가 이 두 축을 검증한다.
## 확인하지 못한 것
소비 경로를 배선할 계획이 있는지 확인할 수 없었다. 저장소 안에 답이 없다.
<!-- body:end -->
@@ -0,0 +1,90 @@
---
kind: CASE
slug: messaging-policy-f03
title: 재시도와 DLQ 각각에 두 개의 구현이 있고 정본이 표시되지 않았다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-policy-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-f03
file: ../../../final/evidence/rendered/messaging-policy-f03.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-f03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L799 이다.
module: messaging-policy
priority: P3
---
# 재시도와 DLQ 각각에 두 개의 구현이 있고 정본이 표시되지 않았다
재시도: DefaultRetryDecisionEngine(6모드·백오프·순서 인식) vs DefaultDeliveryProcessor(고정 지연·시도 횟수 미확인). DLQ: DeadLetterOrchestrator(예약 헤더 6개 부착) vs DefaultDeliveryProcessor.DeadLetterPublisher(헤더 없음).
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
재시도: DefaultRetryDecisionEngine(6모드·백오프·순서 인식) vs DefaultDeliveryProcessor(고정 지연·시도 횟수 미확인).
DLQ: DeadLetterOrchestrator(예약 헤더 6개 부착) vs DefaultDeliveryProcessor.DeadLetterPublisher(헤더 없음).
## 결론
둘 다 조립되지 않았다.
오늘 경쟁하지 않지만, 소비 경로를 배선하는 사람이 둘 중 하나를 고르게 되고 코드가 어느 쪽이 정본인지 말하지 않는다.
두 javadoc이 각각 자기가 플랫폼 규칙의 구현이라고 서술한다.
그리고 선택 결과가 다르다 — DefaultDeliveryProcessor 경로로 DLQ된 메시지에는 실패 카테고리·코드·원본 목적지·시도 횟수가 붙지 않는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : DefaultRetryDecisionEngine 참조 6건 검색과 두 구현의 javadoc·분기 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-policy.md#L799 에 있다.
## 본문
<!-- body:start -->
재시도와 DLQ 각각에 구현이 둘이다.
| 축 | 한쪽 | 다른쪽 |
|---|---|---|
| 재시도 | `DefaultRetryDecisionEngine`(6모드·백오프·순서 인식) | `DefaultDeliveryProcessor`(고정 지연·시도 횟수 미확인) |
| DLQ | `DeadLetterOrchestrator`(예약 헤더 6개 부착) | `DefaultDeliveryProcessor.DeadLetterPublisher`(헤더 없음) |
## DefaultRetryDecisionEngine 참조 위치
:::evidence key="messaging-policy-f03" alt="코드베이스에서 DefaultRetryDecisionEngine 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultRetryDecisionEngine 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## 둘 다 조립되지 않았다
오늘 경쟁하지 않지만, 소비 경로를 배선하는 사람이 둘 중 하나를 고르게 되고 코드가 어느 쪽이 정본인지 말하지 않는다. 두 javadoc이 각각 자기가 플랫폼 규칙의 구현이라고 서술한다.
## 선택 결과가 다르다
`DefaultDeliveryProcessor` 경로로 DLQ된 메시지에는 실패 카테고리·코드·원본 목적지·시도 횟수가 붙지 않는다.
## 확인하지 못한 것
두 재시도 구현 중 어느 쪽이 정본인지 확인할 수 없었다. 소비 경로 배선 계획이라는 같은 미지수에 걸린다.
<!-- body:end -->
@@ -0,0 +1,77 @@
---
kind: CASE
slug: messaging-policy-f04
title: DLQ 메타데이터의 두 시각이 항상 같다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-policy-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-f04
file: ../../../final/evidence/rendered/messaging-policy-f04.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-f04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L808 이다.
module: messaging-policy
priority: P3
---
# DLQ 메타데이터의 두 시각이 항상 같다
DeadLetterMetadata가 firstFailureAt과 lastFailureAt을 별도 필드로 선언하는데, 유일한 생산 지점인 DeadLetterOrchestrator:89-97이 둘 다 delivery.metadata().receivedAt()으로 채운다. 두 헤더(msg.first-failure-at, msg.last-failure-at)가 DLQ 메시지에 붙는데 항상 같은 값이다.
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
DeadLetterMetadata가 firstFailureAt과 lastFailureAt을 별도 필드로 선언하는데, 유일한 생산 지점인 DeadLetterOrchestrator:89-97이 둘 다 delivery.metadata().receivedAt()으로 채운다.
두 헤더(msg.first-failure-at, msg.last-failure-at)가 DLQ 메시지에 붙는데 항상 같은 값이다.
## 결론
운영자가 "이 메시지가 얼마나 오래 실패해 왔는가"를 헤더에서 알 수 없다.
ReservedHeaders가 두 이름을 따로 정의한 목적이 실현되지 않는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : DeadLetterMetadata 참조 5건 검색과 유일한 생산 지점이 두 필드에 넣는 값 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-policy.md#L808 에 있다.
## 본문
<!-- body:start -->
`DeadLetterMetadata``firstFailureAt``lastFailureAt`을 별도 필드로 선언하는데, 유일한 생산 지점인 `DeadLetterOrchestrator:89-97`이 둘 다 `delivery.metadata().receivedAt()`으로 채운다.
## DeadLetterMetadata 참조 위치
:::evidence key="messaging-policy-f04" alt="코드베이스에서 DeadLetterMetadata 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DeadLetterMetadata 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## 두 헤더가 항상 같은 값이다
`msg.first-failure-at`, `msg.last-failure-at` 가 DLQ 메시지에 붙는다. 운영자가 "이 메시지가 얼마나 오래 실패해 왔는가"를 헤더에서 알 수 없다 — `ReservedHeaders`가 두 이름을 따로 정의한 목적이 실현되지 않는다.
## 확인하지 못한 것
DLQ 메시지의 두 헤더가 실제 배포에서 같은 값을 갖는 것을 관측하지 않았다. 생산 지점 한 곳의 코드로 판정했다.
<!-- body:end -->
@@ -0,0 +1,86 @@
---
kind: CASE
slug: messaging-pulsar-experimental-f01
title: 닫힌 전송의 거절이 영구 업무 실패로 분류된다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-pulsar-experimental-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-pulsar-experimental-f01
file: ../../../final/evidence/rendered/messaging-pulsar-experimental-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-pulsar-experimental-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-pulsar-experimental.md#L196 이다.
module: messaging-pulsar-experimental
priority: P3
---
# 닫힌 전송의 거절이 영구 업무 실패로 분류된다
두 호출자가 이 메서드를 쓴다. 첫째는 영구 업무 실패가 맞다.
## 문제
두 호출자가 이 메서드를 쓴다.
첫째는 영구 업무 실패가 맞다.
## 결론
둘째는 아니다.
종료 중이라는 것은 이 세대의 사정이고, 다음 세대나 다른 인스턴스에서는 같은 메시지가 발행된다.
같은 파일의 classify 가 분류를 신중히 나눈다 — 사전 거절은 CONFIGURATION, 모호는 TRANSIENT_INFRASTRUCTURE.
닫힘만 그 규율 밖에 있다.
전송되지 않았다는 증거(notTransmitted)는 옳다.
어긋난 것은 범주뿐이다.
수정은 닫힘에 TRANSIENT_INFRASTRUCTURE 를 주거나, 두 호출자가 범주를 인자로 받게 하는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 로컬 거절 헬퍼가 붙이는 범주와 두 호출자의 실패 성격 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-pulsar-experimental.md#L196 에 있다.
## 본문
<!-- body:start -->
두 호출자가 로컬 거절 헬퍼를 쓴다. 첫째(적재물 상한 초과)는 영구 업무 실패가 맞다. 둘째(전송 종료 중)는 아니다.
## 헬퍼를 부르는 두 호출자
:::evidence key="messaging-pulsar-experimental-f01" alt="분석 문서 analysis/messaging/messaging-pulsar-experimental.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-pulsar-experimental.md 발췌 — 15줄" zoom="true"
:::
## 종료 중은 이 세대의 사정이다
다음 세대나 다른 인스턴스에서는 같은 메시지가 발행된다.
## 같은 파일의 classify 는 분류를 신중히 나눈다
사전 거절은 `CONFIGURATION`, 모호는 `TRANSIENT_INFRASTRUCTURE`. 닫힘만 그 규율 밖에 있다.
## 증거는 옳고 범주만 어긋났다
전송되지 않았다는 증거(`notTransmitted`)는 옳다. 수정은 닫힘에 `TRANSIENT_INFRASTRUCTURE` 를 주거나, 두 호출자가 범주를 인자로 받게 하는 것이다.
## 확인하지 못한 것
실제 Pulsar 브로커를 띄우지 않았다. 이 리프가 클라이언트 브리지를 싣지 않으므로 그럴 대상도 없다.
<!-- body:end -->
@@ -0,0 +1,88 @@
---
kind: CASE
slug: messaging-rabbit-f01
title: 확인 등급이 요구에서 파생되고, 그 요구를 뒷받침하는 강제는 목적지 종류 하나에만 걸린다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-rabbit-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-rabbit-f01
file: ../../../final/evidence/rendered/messaging-rabbit-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-rabbit-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-rabbit.md#L210 이다.
module: messaging-rabbit
priority: P3
---
# 확인 등급이 요구에서 파생되고, 그 요구를 뒷받침하는 강제는 목적지 종류 하나에만 걸린다
증거의 등급이 브로커가 무엇을 했는지가 아니라 프로파일이 무엇을 요구했는지 에서 나온다. 대부분의 경우 이 파생은 성립한다.
## 문제
증거의 등급이 브로커가 무엇을 했는지가 아니라 프로파일이 무엇을 요구했는지 에서 나온다.
대부분의 경우 이 파생은 성립한다.
## 결론
두 강제가 그것을 받쳐 준다.
RabbitHeaderMapper.toProperties 가 배달 모드를 무조건 PERSISTENT 로 둔다.
RabbitMQ 는 지속 메시지를 디스크에 쓴 뒤에 확인한다.
RabbitProfileValidator.validateDestination 이 내구 작업 큐에 쿼럼 큐를 요구한다.
쿼럼 큐의 확인은 다수 복제 뒤에 온다.
빈틈은 둘째 강제의 범위다.
작업 큐가 아닌 목적지에는 쿼럼 요구가 없다.
교환기로 발행하는 목적지가 REPLICATION_OR_PERSISTENCE_ACK 를 요구하면, 그 교환기에 바인딩된 큐가 고전 큐여도 어댑터는 그 등급을 보고한다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : RabbitHeaderMapper 참조 13건 검색과 확인 등급이 파생되는 입력 및 강제가 걸린 목적지 종류 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-rabbit.md#L210 에 있다.
## 본문
<!-- body:start -->
증거의 등급이 브로커가 무엇을 했는지가 아니라 프로파일이 무엇을 **요구했는지** 에서 나온다.
## RabbitHeaderMapper 참조 위치
:::evidence key="messaging-rabbit-f01" alt="코드베이스에서 RabbitHeaderMapper 를 검색한 출력 13줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RabbitHeaderMapper 코드베이스 검색 — 13줄 · exit 0" zoom="true"
:::
## 두 강제가 그 파생을 받쳐 준다
`RabbitHeaderMapper.toProperties` 가 배달 모드를 무조건 `PERSISTENT` 로 둔다 — RabbitMQ 는 지속 메시지를 디스크에 쓴 뒤에 확인한다. 그리고 `RabbitProfileValidator.validateDestination` 이 내구 작업 큐에 쿼럼 큐를 요구한다 — 쿼럼 큐의 확인은 다수 복제 뒤에 온다.
## 빈틈은 둘째 강제의 범위다
작업 큐가 아닌 목적지에는 쿼럼 요구가 없다. 교환기로 발행하는 목적지가 `REPLICATION_OR_PERSISTENCE_ACK` 를 요구하면, 그 교환기에 바인딩된 큐가 고전 큐여도 어댑터는 그 등급을 보고한다. 지속 모드 덕분에 디스크 기록은 보장되지만 복제는 보장되지 않는다.
## 이 저장소의 규율과 어긋난다
증거가 관측에서 나와야 한다는 것이다 — `MessagingCapabilities` 의 javadoc 이 "a silently weakened guarantee is indistinguishable from a working one until the incident" 라고 적는다. 수정은 쿼럼 요구를 목적지 종류가 아니라 **요구된 확인 등급** 에 걸거나, 작업 큐가 아닌 목적지에서는 등급을 `BROKER_ACK` 로 낮추는 것이다.
## 확인하지 못한 것
실제 브로커로 반환-먼저-확인 순서를 재현하지 않았다. 그 레인은 컨테이너가 필요하다.
<!-- body:end -->
@@ -0,0 +1,80 @@
---
kind: CASE
slug: messaging-rabbit-f03
title: SCRAM 자격을 RabbitMQ 의 데모 기구로 조용히 매핑한다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-rabbit-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-rabbit-f03
file: ../../../final/evidence/rendered/messaging-rabbit-f03.svg
evidence:
- ../../../final/evidence/raw/messaging-rabbit-f03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-rabbit.md#L274 이다.
module: messaging-rabbit
priority: P3
---
# SCRAM 자격을 RabbitMQ 의 데모 기구로 조용히 매핑한다
RABBIT-CR-DEMO 는 RabbitMQ 의 시연용 challenge-response 인증 기구(rabbit_auth_mechanism_cr_demo)의 이름이고 기본 활성이 아니다. RabbitMQ 는 SCRAM-SHA 를 구현하지 않으므로 SaslScram 에 대응하는 AMQP 기구가 없다는 것 자체는 사실이다.
## 문제
RABBIT-CR-DEMO 는 RabbitMQ 의 시연용 challenge-response 인증 기구(rabbit_auth_mechanism_cr_demo)의 이름이고 기본 활성이 아니다.
RabbitMQ 는 SCRAM-SHA 를 구현하지 않으므로 SaslScram 에 대응하는 AMQP 기구가 없다는 것 자체는 사실이다.
## 결론
문제는 그 사실을 다루는 방식이 같은 파일 안에서 일관되지 않다는 것이다.
그리고 이웃 어댑터의 같은 클래스가 정확히 이 상황에 대한 규범을 적어 두었다.
SaslScram 에도 그 규범이 적용되어야 한다.
플러그인이 없는 브로커에서는 handshake 가 알아보기 어려운 오류로 실패하고, 있는 브로커에서는 시연용 기구로 인증한다.
수정은 Nkey 와 같이 거부하거나, PLAIN 으로 매핑하고 그 이유를 주석으로 남기는 것이다.
어느 쪽이든 지금처럼 말없이 데모 기구를 고르는 것보다 낫다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : SaslScram 참조 18건 검색과 매핑되는 기구 이름의 RabbitMQ 기본 활성 여부 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-rabbit.md#L274 에 있다.
## 본문
<!-- body:start -->
`RABBIT-CR-DEMO` 는 RabbitMQ 의 시연용 challenge-response 인증 기구(`rabbit_auth_mechanism_cr_demo`)의 이름이고 기본 활성이 아니다.
## SaslScram 참조 위치
:::evidence key="messaging-rabbit-f03" alt="코드베이스에서 SaslScram 를 검색한 출력 18줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SaslScram 코드베이스 검색 — 18줄 · exit 0" zoom="true"
:::
## 대응 기구가 없다는 것 자체는 사실이다
RabbitMQ 는 SCRAM-SHA 를 구현하지 않는다. 문제는 그 사실을 다루는 방식이 같은 파일 안에서 일관되지 않다는 것이다 — 이웃 어댑터의 같은 클래스가 정확히 이 상황에 대한 규범을 적어 두었고, `SaslScram` 에도 그 규범이 적용되어야 한다.
## 두 브로커 형상에서 결과가 다르다
플러그인이 없는 브로커에서는 handshake 가 알아보기 어려운 오류로 실패하고, 있는 브로커에서는 시연용 기구로 인증한다. 수정은 `Nkey` 와 같이 거부하거나, `PLAIN` 으로 매핑하고 그 이유를 주석으로 남기는 것이다.
## 확인하지 못한 것
이 기구를 실제 브로커에 붙여 보지 않았다. 이름과 RabbitMQ 의 기본 활성 상태로 판정했다.
<!-- body:end -->
@@ -0,0 +1,80 @@
---
kind: CASE
slug: messaging-rabbit-f05
title: pause 의 의미가 SPI 하나 뒤에서 두 브로커에 다르게 구현된다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-rabbit-f05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-rabbit-f05
file: ../../../final/evidence/rendered/messaging-rabbit-f05.svg
evidence:
- ../../../final/evidence/raw/messaging-rabbit-f05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-rabbit.md#L335 이다.
module: messaging-rabbit
priority: P3
---
# pause 의 의미가 SPI 하나 뒤에서 두 브로커에 다르게 구현된다
호출자가 이 단계를 기다리고 나면 "일시정지되었다" 고 읽는다. 실제로 일어난 것은 onMessage 가 이후 배달에 false 를 답하기 시작한 것뿐이고, 리스너 컨테이너는 계속 배달을 밀며 그 배달들은 미확인 상태로 재배달된다.
## 문제
호출자가 이 단계를 기다리고 나면 "일시정지되었다" 고 읽는다.
실제로 일어난 것은 onMessage 가 이후 배달에 false 를 답하기 시작한 것뿐이고, 리스너 컨테이너는 계속 배달을 밀며 그 배달들은 미확인 상태로 재배달된다.
## 결론
즉 정지가 아니라 거부-재배달 루프다.
Kafka 쪽은 같은 SPI 를 정반대로 구현하고 그 이유를 적는다.
AMQP 에는 대응하는 수단이 있다 — basicCancel 로 소비자를 취소하거나 컨테이너를 멈추는 것.
지금 구현이 그것을 하지 않는 이유는 어디에도 없다.
전용 시험(aPausedQueueRefusesDeliveriesSoTheBrokerRedeliversThem)의 이름이 이미 실제 동작을 정확히 말한다.
그러므로 수정은 둘 중 하나다 — 컨테이너를 실제로 멈추거나, SPI 의 javadoc 에 "브로커에 따라 정지가 거부-재배달일 수 있다" 를 명시하는 것.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 두 브로커의 pause 구현 본문 대조와 리스너 컨테이너의 배달 지속 여부 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-rabbit.md#L335 에 있다.
## 본문
<!-- body:start -->
호출자가 pause 단계를 기다리고 나면 "일시정지되었다" 고 읽는다. 실제로 일어난 것은 `onMessage` 가 이후 배달에 `false` 를 답하기 시작한 것뿐이고, 리스너 컨테이너는 계속 배달을 밀며 그 배달들은 미확인 상태로 재배달된다 — 정지가 아니라 거부-재배달 루프다.
## pause 이후 실제로 일어나는 것
:::evidence key="messaging-rabbit-f05" alt="분석 문서 analysis/messaging/messaging-rabbit.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-rabbit.md 발췌 — 15줄" zoom="true"
:::
## Kafka 쪽은 같은 SPI 를 정반대로 구현한다
그리고 그 이유를 적는다. AMQP 에는 대응하는 수단이 있다 — `basicCancel` 로 소비자를 취소하거나 컨테이너를 멈추는 것. 지금 구현이 그것을 하지 않는 이유는 어디에도 없다.
## 시험 이름이 이미 실제 동작을 말한다
`aPausedQueueRefusesDeliveriesSoTheBrokerRedeliversThem`. 수정은 둘 중 하나다 — 컨테이너를 실제로 멈추거나, SPI 의 javadoc 에 "브로커에 따라 정지가 거부-재배달일 수 있다" 를 명시하는 것.
## 확인하지 못한 것
실제 브로커로 일시정지 이후의 재배달을 관측하지 않았다. 두 구현의 코드 대조로 판정했다.
<!-- body:end -->
@@ -0,0 +1,74 @@
---
kind: CASE
slug: messaging-testkit-f03
title: Faults 내부클래스 57줄이 3개 모듈에 바이트 단위로 복제되어 있다
topic: transport-and-provider-semantics
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-testkit-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-testkit-f03
file: ../../../final/evidence/rendered/messaging-testkit-f03.svg
evidence:
- ../../../final/evidence/raw/messaging-testkit-f03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-testkit.md#L960 이다.
module: messaging-testkit
priority: P3
---
# Faults 내부클래스 57줄이 3개 모듈에 바이트 단위로 복제되어 있다
sha256 이 세 곳 모두 3028b459… 로 동일하다(EVD-299). 총 171줄.
## 문제
sha256 이 세 곳 모두 3028b459… 로 동일하다(EVD-299).
총 171줄.
## 결론
messaging-testkit/src/main 에 DefaultFaultController (또는 RecordingFaultController) 하나를 두고 세 하니스가 그것을 필드로 갖게 하면 된다.
MessagingAdapterHarness.faults() 의 반환 타입은 FaultController 그대로이므로 외부 API 변경이 없다.
이 복제가 위험한 이유는 P2 와 겹친다: rejectPublish 의 전송 증거를 고치려면 지금은 세 파일을 고쳐야 하고, 세 파일이 어긋나면 어댑터마다 다른 결함 의미를 갖게 된다 — 이 리프가 존재하는 이유 자체를 무너뜨린다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : Faults 참조 12건 검색과 세 복제본의 sha256 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-testkit.md#L960 에 있다.
## 본문
<!-- body:start -->
`sha256` 이 세 곳 모두 `3028b459…` 로 동일하다(`EVD-299`). 총 171줄.
## Faults 참조 위치
:::evidence key="messaging-testkit-f03" alt="코드베이스에서 Faults 를 검색한 출력 12줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="Faults 코드베이스 검색 — 12줄 · exit 0" zoom="true"
:::
## 통합 방법
`messaging-testkit/src/main``DefaultFaultController`(또는 `RecordingFaultController`) 하나를 두고 세 하니스가 그것을 필드로 갖게 하면 된다. `MessagingAdapterHarness.faults()` 의 반환 타입은 `FaultController` 그대로이므로 외부 API 변경이 없다.
## 이 복제가 위험한 이유는 P2 와 겹친다
`rejectPublish` 의 전송 증거를 고치려면 지금은 세 파일을 고쳐야 하고, 세 파일이 어긋나면 어댑터마다 다른 결함 의미를 갖게 된다 — 이 리프가 존재하는 이유 자체를 무너뜨린다.
## 확인하지 못한 것
없음 — 세 사본의 해시가 같다는 것을 직접 확인했다.
<!-- body:end -->