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,40 @@
---
kind: CONCEPT
slug: adapter-inbound-graphql-c08
title: 보고되는 프로파일이 규정하는 동작을 수행하는 코드가 없다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-graphql-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-graphql-c08
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c08.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-graphql-c08.txt
source:
- 원본 분석 절은 analysis/16-adapter-inbound-graphql.md#L612 이다.
module: adapter-inbound-graphql
---
# 보고되는 프로파일이 규정하는 동작을 수행하는 코드가 없다
`GraphQlPlatformConfigurationReport``GraphQlHttpProfile.V1.name()`을 배포 상태의 일부로 보고하는데, 그 프로파일이 규정하는 전송 동작을 수행하는 코드가 미배선이다.
## 본문
<!-- body:start -->
`GraphQlPlatformConfigurationReport`(§8.1)가 `GraphQlHttpProfile.V1.name()`을 배포 상태의 일부로 보고한다. `GraphQlHttpProfile`은 autoconf=2로 참조되지만, 그 프로파일이 규정하는 전송 동작(상태 매핑 · Accept 협상 · 응답 형태)을 수행하는 코드는 미배선이다(§19.1).
## GraphQlPlatformConfigurationReport 참조 위치
:::evidence key="adapter-inbound-graphql-c08" alt="코드베이스에서 GraphQlPlatformConfigurationReport 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlPlatformConfigurationReport 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 보고서를 발행할 엔드포인트도 등록되지 않는다
§8.1.
<!-- body:end -->
@@ -0,0 +1,50 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c06
title: 모호한 실행을 재시도 가능으로 표시할 수 없다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c06
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c06.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c06.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L332 이다.
module: adapter-outbound-cache-redis
---
# 모호한 실행을 재시도 가능으로 표시할 수 없다
`RedisFailureMetadata`의 불변식 하나가 이 SDK의 재시도 규칙 전체다 — 모호한 실행은 재시도 가능일 수 없다.
## 본문
<!-- body:start -->
`RedisFailureMetadata`는 "Low-cardinality, payload-free description"이고, 불변식 하나가 이 SDK의 재시도 규칙 전체다.
```java
if (retryable && ambiguousExecution) {
throw new IllegalArgumentException("an ambiguous execution must never be marked retryable");
}
```
## RedisFailureMetadata 참조 위치
:::evidence key="adapter-outbound-cache-redis-c06" alt="코드베이스에서 RedisFailureMetadata 를 검색한 출력 30줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RedisFailureMetadata 코드베이스 검색 — 30줄 · exit 0" zoom="true"
:::
## 두 팩토리가 그 규칙을 실제 상황에 적용한다
`notSent(...)``retryable = readOperation`으로 유도한다 — 서버에 닿지 않은 읽기는 재시도해도 안전하다. `storedDataCorruption(...)`**일부러 `notSent`가 아니고**, javadoc이 그 이유를 적는다 — 같은 바이트를 다시 디코딩하면 같은 실패가 나오므로 값이 틀린 것이지 시도가 틀린 것이 아니다. 그 팩토리는 실제로 쓰인다 — `JsonEnvelopeFraming:202`, `VersionedJsonCodec:103` 두 곳이 디코딩 실패에서 호출한다.
## 메시지가 담지 않는 것
예외 계층은 12종이고 전부 `RedisOperationException`을 상속한다. 메시지는 reason + `command=` 계열 + `mode=` + `ambiguous=`만 조립하고, javadoc이 경계를 적는다 — "keys, fields, members, values, arguments, and authentication material never appear."
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-fileserver-c05
title: 길이 프레이밍이 구분자를 없애고, 잘린 토큰의 충돌을 컴파일 시점에 잡는다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-fileserver-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-fileserver-c05
file: ../../../final/evidence/rendered/adapter-outbound-fileserver-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-fileserver-c05.txt
source:
- 원본 분석 절은 analysis/08-adapter-outbound-fileserver.md#L300 이다.
module: adapter-outbound-fileserver
---
# 길이 프레이밍이 구분자를 없애고, 잘린 토큰의 충돌을 컴파일 시점에 잡는다
다이제스트는 값마다 길이를 앞세워 경계를 고정하고, 그 다이제스트를 잘라 만든 route token은 컴파일 시점에 충돌 검사를 받는다.
## 본문
<!-- body:start -->
`FilePublicationCanonicalDigests.digestOrderedValues`는 값 개수를 먼저 넣고, 값마다 **길이(4바이트) + 엄격 UTF-8 바이트**를 넣는다. 구분자를 쓰지 않으므로 값 안에 어떤 문자가 있어도 경계가 흐려지지 않는다. `FilePublishRequestFingerprint`도 같은 방식이다.
## FilePublicationCanonicalDigests 참조 위치
:::evidence key="adapter-outbound-fileserver-c05" alt="코드베이스에서 FilePublicationCanonicalDigests 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="FilePublicationCanonicalDigests 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## 잘린 토큰이 만들 수 있는 유일한 문제
`routeToken`은 정책 다이제스트의 앞 31자에 `r`을 붙인 것이라 **잘린 값**이다. 그래서 `FileserverBindingCompiler.deriveUniqueRouteTokens`가 컴파일 시점에 토큰 충돌을 검사하고, 충돌하면 두 destination 이름을 모두 담아 거부한다. 잘림이 만들 수 있는 유일한 문제를 그 자리에서 닫는다.
## 값이 아니라 관계를 다시 계산해 대조한다
컴파일 후에도 `compiled.forEach`로 각 destination의 토큰이 레지스트리와 같은지 다시 확인한다. `CompiledFileDestination`의 compact 생성자는 넘겨받은 `effectivePolicyDigest`를 **다시 계산해 대조**하고, `routeToken`이 그 다이제스트에서 유도됐는지, `formatPolicyDigest`가 정본과 같은지도 확인한다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-fileserver-c07
title: 인식하지 못한 실패는 변경 연산이면 ambiguous로 떨어진다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-fileserver-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-fileserver-c07
file: ../../../final/evidence/rendered/adapter-outbound-fileserver-c07.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-fileserver-c07.txt
source:
- 원본 분석 절은 analysis/08-adapter-outbound-fileserver.md#L517 이다.
module: adapter-outbound-fileserver
---
# 인식하지 못한 실패는 변경 연산이면 ambiguous로 떨어진다
`AmbiguousFilesystemOperationDetector`의 기본값이 보수적이라, 메시지 텍스트 매칭이 빗나가도 안전한 방향으로 떨어진다.
## 본문
<!-- body:start -->
`AmbiguousFilesystemOperationDetector``IOException`을 네 결과로 나눈다(`NOT_SENT` / `DEFINITELY_REJECTED` / `AMBIGUOUS_COMPLETION` / `RECONCILIATION_REQUIRED`). 기본값이 보수적이다 — 인식하지 못한 실패는 **변경 연산이면 ambiguous**다.
## AmbiguousFilesystemOperationDetector 참조 위치
:::evidence key="adapter-outbound-fileserver-c07" alt="코드베이스에서 AmbiguousFilesystemOperationDetector 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AmbiguousFilesystemOperationDetector 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
## 두 오분류의 값이 다르다
javadoc이 비대칭을 적는다: "the cost of a wrong 'safe to retry' is a corrupted object, while the cost of a wrong 'ambiguous' is one reconciliation entry." `mutating` 인자로 순수 읽기는 결코 ambiguous가 되지 않게 하고, stale handle은 변경 연산일 때 `RECONCILIATION_REQUIRED`로 격상한다 — 에러만으로는 결과를 알 수 없으므로 물리 증거를 다시 읽어야 한다.
## 분류가 메시지 문구에 걸려 있다
`isStaleHandle`·`isLostResponse``FilesystemFailureClassifier.isOutOfSpace`가 **메시지 텍스트 매칭**에 의존한다("stale file handle", "estale", "timed out", "No space left on device", "Disk quota exceeded"). 후자에는 주석이 붙어 있다 — "The JDK has no dedicated exception for this, so the reason text is the only available signal." 로케일이나 JDK 판본에 따라 문구가 달라지면 분류가 기본값으로 떨어지는데, 기본값이 보수적(변경 연산 → ambiguous)이므로 안전한 방향이다. 기록만 한다.
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: adapter-outbound-messaging-c04
title: 브로커가 받아들인 발행을 로거 실패가 실패로 만들지 못한다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-messaging-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-messaging-c04
file: ../../../final/evidence/rendered/adapter-outbound-messaging-c04.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-messaging-c04.txt
source:
- 원본 분석 절은 analysis/12-adapter-outbound-messaging.md#L284 이다.
module: adapter-outbound-messaging
---
# 브로커가 받아들인 발행을 로거 실패가 실패로 만들지 못한다
두 발행 포트의 실패 정책이 정반대이고, 발행과 관측이 분리된 이유가 수정 이력으로 남아 있다.
## 본문
<!-- body:start -->
두 발행 포트의 실패 정책이 정반대다.
| 포트 | 정책 | 근거 |
|---|---|---|
| `MessagePublisher``OutboundMessagePublisher` | **fail-open** | "a broker outage must never turn a core use case into a 5xx (durable delivery is delegated to the outbox/retry path)" |
| `OutboxMessagePublishPort``OutboxMessagePublishAdapter` | **fail-closed** | 실패가 그대로 전파되어 relay가 FAILED/DEAD 전이를 몰 수 있게 한다 |
## OutboundMessagePublisher 참조 위치
:::evidence key="adapter-outbound-messaging-c04" alt="코드베이스에서 OutboundMessagePublisher 를 검색한 출력 13줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboundMessagePublisher 코드베이스 검색 — 13줄 · exit 0" zoom="true"
:::
## 같은 try 블록을 쓰던 시절
`OutboundMessagePublisher.publish`에 이 저장소에서 반복해 본 종류의 수정 이력이 있다.
> "The send and the observation are separate steps because they used to share a try block: **a logger that threw after a successful send was caught by the same catch and reported as a publish failure.** The broker had accepted the message; the only thing that failed was the record of it, and the two must not be confusable."
## 진단은 권위를 갖지 않는다
`observeQuietly`가 진단 예외를 흡수하며 "Diagnostics are non-authoritative. **An appender that is out of disk must not change what the caller believes about the broker.**" 비활성 sentinel 둘은 조용한 no-op이 아니라 `AdapterDisabledException`을 던지고, 서로 다른 클래스로 분리된 이유가 bean 조회 모호성이다(§2).
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: adapter-outbound-notification-c03
title: provider가 요청한 지연은 계산값보다 길 때만 채택되고 max에서 잘린다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-notification-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-notification-c03
file: ../../../final/evidence/rendered/adapter-outbound-notification-c03.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-notification-c03.txt
source:
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L582 이다.
module: adapter-outbound-notification
---
# provider가 요청한 지연은 계산값보다 길 때만 채택되고 max에서 잘린다
`Retry-After` 힌트가 종단까지 도달하는 것을 확인했고, 그 힌트가 배달 기한을 늘릴 수 있는 경로는 없다.
## 본문
<!-- body:start -->
`ProviderResults.retryAfter`가 파싱한 값이 종단까지 도달하는지 추적했다. 도달한다 — `NotificationDispatchService.java:381``ProviderFailure::retryAfter`로 넘기고, `RetryBackoff.delay`(`:43-45`)가 소비한다.
```java
Duration computed = Duration.ofMillis(Math.max(jittered, base.toMillis()));
Duration chosen = retryAfter.filter(hint -> hint.compareTo(computed) > 0).orElse(computed);
return chosen.compareTo(max) > 0 ? max : chosen;
```
힌트는 계산값보다 **길 때만** 채택되고, 그 뒤 설정된 `max`(기본 5분)로 **상한이 걸린다**.
## ProviderResults 참조 위치
:::evidence key="adapter-outbound-notification-c03" alt="코드베이스에서 ProviderResults 를 검색한 출력 25줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ProviderResults 코드베이스 검색 — 25줄 · exit 0" zoom="true"
:::
## javadoc의 주장이 코드와 일치한다
"A provider-supplied `Retry-After` always wins over the computed value, but never over the configured maximum: a provider asking for an hour must not silently extend a delivery deadline." 악의적 provider가 큰 `Retry-After` 값으로 배달을 수십 년 뒤로 미루는 경로는 **없다**. §17.1의 `AccessContext`와 대조되는, 회로가 닫힌 사례다.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c04
title: offset을 표현할 수 없는 타입과 count 질의 없는 hasNext
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c04
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c04.svg
- key: adapter-outbound-persistence-jpa-c04-diagram
file: ../../../final/assets/diagrams/adapter-outbound-persistence-jpa-c04.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c04.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L302 이다.
module: adapter-outbound-persistence-jpa
---
# offset을 표현할 수 없는 타입과 count 질의 없는 hasNext
질의 API가 offset과 page number를 타입으로 표현하지 않고, `fetchSize()`가 size + 1을 반환해 별도 count 질의 없이 `hasNext`를 정한다.
## 본문
<!-- body:start -->
offset/page number를 아예 표현하지 않으므로 keyset API를 사용하는 consumer가 실수로 large offset pagination으로 회귀하기 어렵다.
## 질의 API가 표현하지 않는 것
:::evidence key="adapter-outbound-persistence-jpa-c04-diagram" alt="정렬 키와 size 더하기 1과 서명된 커서가 질의 API 안에 놓이고 offset과 total count 질의가 바깥에 빗금으로 놓인다" caption="질의 API가 표현하지 않는 것" zoom="false"
:::
## count 질의 없이 hasNext를 정한다
`fetchSize()`는 요청 size + 1을 반환한다. 즉 별도 count query 없이 한 row를 더 읽어 `hasNext`를 판단하는 계약이다. hasNext=true이면 nextCursor 필수, terminal slice이면 nextCursor 금지, items는 defensive copy. page number/total count가 없다는 것은 API omission이 아니라 의도된 성능 정책이다 — "keyset을 쓰면서 매번 count(*)도 수행"하는 모순을 contract shape에서 제거한다.
## QueryName 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c04" alt="코드베이스에서 QueryName 를 검색한 출력 21줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="QueryName 코드베이스 검색 — 21줄 · exit 0" zoom="true"
:::
## raw SQL이 metric identity가 될 수 없다
`QueryName`도 bounded registry key다. `QueryObservation.start(QueryName)``QueryScope` 구조에서 `QueryScope.failure` 문서가 "throwable message를 log하지 말 것"을 직접 계약한다. Micrometer implementation이 이를 실제로 지키는지는 observation sub-scope에서 확인한다.
## backend가 없어도 흐름이 갈라지지 않는다
`NoopQueryObservation`은 backend가 없을 때도 caller control flow가 갈라지지 않게 singleton no-op scope를 제공한다. app-bootstrap `JpaObservabilityAutoConfiguration`에서 actual fallback consumer가 존재한다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c31
title: grep refs=0은 finding의 시작점이지 결론이 아니다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c31
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c31
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c31.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c31.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L2151 이다.
module: adapter-outbound-persistence-jpa
---
# grep refs=0은 finding의 시작점이지 결론이 아니다
production consumer가 확인되지 않은 열 개의 optimization helper를 곧바로 dead code로 읽으면 안 되는 이유를, 이 저장소의 기존 기록과 integration test가 갈라 준다.
## 본문
<!-- body:start -->
negative-space search에서 다음 implementation roots는 repository production consumer가 확인되지 않았다 — `HibernateJpaBatchExecutor`, `JpaBatchProfileRegistry`, `HibernateBulkDmlExecutor`, `HibernateStatelessSessionRunner`, `FetchPlanApplier`, `JpaKeysetQuerySupport`, `JpaRepositoryFragmentSupport`, `JpaStreamExecutor`, `SpecificationPolicy`, `QuerydslJpaSupport`.
## HibernateJpaBatchExecutor 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c31" alt="코드베이스에서 HibernateJpaBatchExecutor 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="HibernateJpaBatchExecutor 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## "dead code가 대량 존재한다"로 읽으면 안 되는 이유
이 repository의 기존 study/review 문서도 이미 JPA platform helper가 **구현/qualification되어 있지만 sample production path가 대부분 채택하지 않은 상태**라고 기록한다. 또한 batch/bulk/stateless helper는 real PostgreSQL integration tests에서 직접 실행된다. 따라서 현재 판단은 capability별로 나눈다 — 이들은 library capability로 유지할 수 있다.
## 같은 refs=0이라도 성질이 다른 것
`NamedStatementInspector`처럼 global Hibernate hook이 필요한 기능은 "아무 use case가 안 쓴다"와 다르다 — feature를 사용하려면 composition이 먼저 존재해야 한다. transaction scope의 `TransactionProfileRegistry`처럼 history를 통해 실제 residue로 판정해야 하는 것도 있다.
<!-- body:end -->
@@ -0,0 +1,41 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c32
title: 두 export 목록에 postgresql과 h2만큼의 차이가 있다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c32
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c32
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c32.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c32.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L2217 이다.
module: adapter-outbound-persistence-jpa
---
# 두 export 목록에 postgresql과 h2만큼의 차이가 있다
app-bootstrap consumer rule이 leaf의 export 목록과 별개의 `EXPORTED` set을 다시 정의한다.
## 본문
<!-- body:start -->
`CleanArchitectureTest.BOOTSTRAP_USES_ONLY_THE_PERSISTENCE_EXPORT_SURFACE`는 또 다른 `EXPORTED` set을 정의한다. 여기에는 root composition이 vendor entry point를 import해야 하므로 두 항목이 추가돼 있다.
- postgresql
- h2
즉 두 목록은 이미 동일하지 않다.
## 부트스트랩 쪽 EXPORTED 집합
:::evidence key="adapter-outbound-persistence-jpa-c32" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 15줄" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c33
title: leaf list 자체는 outside consumer를 검사하지 않는다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c33
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c33
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c33.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c33.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L2230 이다.
module: adapter-outbound-persistence-jpa
---
# leaf list 자체는 outside consumer를 검사하지 않는다
leaf의 export test와 consumer restriction이 서로 다른 데이터를 읽으므로, 둘 다 통과한다는 것이 둘이 drift하지 않는다는 증명은 아니다.
## 본문
<!-- body:start -->
`JpaModuleBoundaryTest`의 local export test는 export package가 실제 존재하는지, 새 top-level package가 governance 대상인지를 보지만 repository의 outside consumer import를 직접 스캔하지 않는다. 실제 consumer restriction은 app-bootstrap의 별도 ArchUnit rule이 담당한다.
## JpaModuleBoundaryTest 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c33" alt="코드베이스에서 JpaModuleBoundaryTest 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="JpaModuleBoundaryTest 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 둘 다 통과한다는 것이 증명하지 않는 것
fresh architecture tests는 모두 통과했다. 이것은 현재 import graph가 각자의 rule을 만족한다는 뜻이지 **A와 B가 서로 drift하지 않는다는 증명은 아니다.**
## 권장 방향
우선순위는 **P2/P3 architecture-governance hardening**이고, 권장 방향은 exported package registry를 한 곳으로 옮겨 leaf package DAG와 consumer ArchUnit rule이 같은 데이터를 읽게 하는 것이다.
<!-- body:end -->
@@ -0,0 +1,67 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-mongo-c07
title: 투영 먼저 checkpoint 나중, 그리고 3-state claim
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-mongo-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-mongo-c07
file: ../../../final/evidence/rendered/adapter-outbound-persistence-mongo-c07.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-mongo-c07.txt
source:
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L1015 이다.
module: adapter-outbound-persistence-mongo
---
# 투영 먼저 checkpoint 나중, 그리고 3-state claim
이 leaf에서 유일하게 조립까지 된 대형 서브시스템이고, 그 설계의 규칙 대부분이 과거 결함을 이름으로 적어 두고 있다.
## 본문
<!-- body:start -->
앞선 sub-scope들과 다르다. `MongoPlatformAutoConfiguration`이 두 개의 bean을 실제로 만든다.
- `mongoChangeStreamSource`(209행) — `SpringReactiveChangeStreamSource`, 무조건.
- `reactiveMongoChangeStreamConsumer`(235행) — fork만 공급할 수 있는 5종(`MongoChangeStreamSubscription`, `MongoResumeCheckpointStore`, `MongoResumeTokenCodec`, `MongoChangeProjector`, `MongoChangeDeduplicationStore`)에 `@ConditionalOnBean`.
## MongoPlatformAutoConfiguration 참조 위치
:::evidence key="adapter-outbound-persistence-mongo-c07" alt="코드베이스에서 MongoPlatformAutoConfiguration 를 검색한 출력 14줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MongoPlatformAutoConfiguration 코드베이스 검색 — 14줄 · exit 0" zoom="true"
:::
## fork가 다섯을 채우면 완성된 소비자가 돈다
pipeline·runner·recovery policy·invalidate recovery는 auto-configuration이 직접 `new`한다. 이 사실이 아래 §67의 심각도를 결정한다.
## 순서가 계약이다
`MongoChangeStreamRunner`는 투영 먼저, checkpoint 나중이다 — "Checkpointing first would mean a crash between the two loses the event permanently, with no trace." 그래서 중복을 택하고 중복을 제거한다.
## 읽고-쓰기가 동시성에서 살아남지 못했다
과거 `alreadyProjected` + `markProjected`는 동시에 `false`를 읽은 두 subscriber가 둘 다 투영했다 — "the deduplication that exists precisely because redelivery is guaranteed did not survive concurrency". 지금은 `CLAIMED`/`ALREADY_COMPLETED`/`BUSY`의 원자적 전이다.
## 빈 완료는 프로토콜 위반이다
`Mono<Boolean>`이 empty로 완료되면 `flatMap`을 그냥 통과해 "투영도 checkpoint도 없이 아무도 문제를 보고하지 않는" 상태가 됐다. 이제 `switchIfEmpty(Mono.error(...))`로 잡는다.
## 구분자를 0x1F로 고른 이유
identity는 SHA-256이고 구분자는 ASCII unit separator(0x1F)다 — namespace/clusterTime/operationType에 나타날 수 없으므로 필드 재배열로 다른 이벤트의 identity를 위조할 수 없다. 한 transaction이 같은 문서를 두 번 고치면 앞 네 필드가 모두 같아지므로 `txnNumber`+`lsid` discriminator를 추가로 넣는다 — 없으면 두 번째가 첫 번째의 재전달로 **버려진다**.
## 로그에 찍히면 안 되는 것
`MongoResumeCheckpoint.toString()`은 길이만 보고한다. token은 clusterTime과 documentKey를 인코딩하므로 로그에 찍는 순간 production write의 모양과 타이밍이 샌다.
## 기본 구현을 일부러 두지 않았다
`MongoResumeTokenCodec`에는 기본 구현이 없다 — "a built-in that merely encoded would be worse than none: it would satisfy the type and none of the reason for it." `HISTORY_LOST`는 자동 복구하지 않는다 — "resuming from now… the projection then looks healthy and is quietly wrong, which is worse than a stopped consumer somebody has to look at." `MongoClusterTime`은 숫자로 비교한다 — 텍스트 비교는 `1700000000.10``1700000000.9`보다 앞에 놓는데, 그것은 바쁜 1초가 정확히 만드는 경우다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: application-core-c06
title: 계약에 Kafka topic도 WebSocket도 나오지 않는다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:application-core-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: application-core-c06
file: ../../../final/evidence/rendered/application-core-c06.svg
evidence:
- ../../../final/evidence/raw/application-core-c06.txt
source:
- 원본 분석 절은 analysis/03-application-core.md#L174 이다.
module: application-core
---
# 계약에 Kafka topic도 WebSocket도 나오지 않는다
messaging·realtime 계약이 semantic 정보만 담고 provider/transport 어휘를 밖으로 밀어내며, qualification은 evidence provenance property까지 요구한다.
## 관계
- **legacy storage/notification compatibility surface의 제거 조건 추적**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
messaging application contract catalog는 contract id, logical destination, schema resource, ordering, payload/envelope bounds, sensitivity, retry/requeue horizon 등 semantic 정보만 가진다. Kafka topic/provider runtime type은 public contract에 없다. validated integration event는 partition key, schema/content hash, catalog/binding revision 같은 immutable evidence를 보존한다.
## 이 기록이 다루는 범위
:::evidence key="application-core-c06" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
## qualification이 테스트 이름만으로는 통과하지 않는다
strict `messagingApplicationContractQualificationTest`는 normal test source set의 세 required class를 no-skip 조건으로 실행한다. 처음 digest property 없이 실행했을 때 `prepareMessagingContractEvidence`가 fail-closed로 거부했다. current source/archive, current application-core JAR, exact profile file의 SHA-256을 공급한 재실행에서는 **15 tests, 0 skipped, BUILD SUCCESSFUL**이었다. 즉 qualification은 evidence provenance property까지 요구한다.
## realtime 계약이 accepted와 delivered를 구분한다
durable fanout과 ephemeral fanout을 분리한다. durable은 accepted와 delivered를 동일시하지 않고 stream+position dedupe/replay를 모델링한다. stale cursor는 resnapshot 요구로 분리된다. presence는 non-authoritative이며 TTL/heartbeat failure 시 empty로 degrade할 뿐 security 판단에 사용하지 않는다. logical channel은 WebSocket/STOMP 같은 transport 명칭을 소유하지 않는다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: application-core-c07
title: forRemoval이 붙었는데 production consumer가 남아 있다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:application-core-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: application-core-c07
file: ../../../final/evidence/rendered/application-core-c07.svg
evidence:
- ../../../final/evidence/raw/application-core-c07.txt
source:
- 원본 분석 절은 analysis/03-application-core.md#L182 이다.
module: application-core
---
# forRemoval이 붙었는데 production consumer가 남아 있다
raw key/whole-byte legacy 계약과 provider-neutral semantic 계약이 같은 leaf에 공존하고, 제거 시점이 날짜가 아니라 실제 usage로 문서화돼 있다.
## 관계
- **legacy storage/notification compatibility surface의 제거 조건 추적**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`application.storage.ObjectStoragePort`는 raw object key/whole-byte 방식의 legacy contract이며 `forRemoval` 표시가 있지만 실제 production consumer가 남아 있다. sample poster upload, adapter/config, characterization test에서 사용되므로 dead code로 분류할 수 없다.
## FilesystemCsvExportAdapter 참조 위치
:::evidence key="application-core-c07" alt="코드베이스에서 FilesystemCsvExportAdapter 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="FilesystemCsvExportAdapter 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## 제거 시점을 날짜로 적지 않았다
제거 시점은 날짜가 아니라 실제 migration/zero usage로 판단하도록 문서화돼 있다. `fileexport` 역시 raw filesystem path를 반환하는 opt-in legacy capability이며 `FilesystemCsvExportAdapter`/configuration을 통해 조건부 활성화된다.
## receipt surface에 raw Path가 없는 쪽
반대로 `filepublication`은 logical destination, operation/reference/version, schema, row streaming/checkpoint, durability semantic을 provider-neutral 계약으로 만든다. CSV formula injection(`=`, `+`, `-`, `@`, tab, CR)을 reject하는 정책이 테스트로 고정돼 있고, raw Path/SFTP/fileserver 타입이 receipt surface에 나오지 않는다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: application-core-c11
title: adapter까지 있고 호출자가 없는 교정 경로
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:application-core-c11
evidenceCapturedOn: 2026-09-01
assets:
- key: application-core-c11
file: ../../../final/evidence/rendered/application-core-c11.svg
evidence:
- ../../../final/evidence/raw/application-core-c11.txt
source:
- 원본 분석 절은 analysis/03-application-core.md#L270 이다.
module: application-core
---
# adapter까지 있고 호출자가 없는 교정 경로
package-level reachability는 모두 확인됐고 legacy surface도 dead가 아니지만, notification admin의 원자적 `claim()`은 구현까지 있고 application service consumer가 없다.
## 관계
- **legacy storage/notification compatibility surface의 제거 조건 추적**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
static production reference scan에서 주요 application package는 모두 외부 production consumer를 확인했다. 이 count는 "모든 type이 각각 호출된다"는 의미가 아니라 package-level runtime/repository reachability의 evidence다. 세부 파일은 `evidence/raw/013-application-core-reachability.txt`에 보존했다.
## NotificationPort 참조 위치
:::evidence key="application-core-c11" alt="코드베이스에서 NotificationPort 를 검색한 출력 17줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="NotificationPort 코드베이스 검색 — 17줄 · exit 0" zoom="true"
:::
## legacy surface를 무조건 dead로 분류하지 않았다
`application.storage.ObjectStoragePort`, root notification `NotificationPort`, `NotificationVariablesCodecPort`, old idempotency-related exception 등은 adapter/config/characterization path에서 실제 reference가 남아 있다. 현재 상태는 dead code가 아니라 migration/compatibility surface다.
## 이번 scope에서 가장 중요한 reachability finding
반대로 notification admin atomic `claim()`은 adapter 구현까지 존재하지만 application service consumer가 없는 **unwired corrective path**로 판정했다.
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: domain-core-c02
title: bean은 없고 컴파일 시점 참조만 있다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:domain-core-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: domain-core-c02
file: ../../../final/evidence/rendered/domain-core-c02.svg
evidence:
- ../../../final/evidence/raw/domain-core-c02.txt
source:
- 원본 분석 절은 analysis/01-domain-core.md#L146 이다.
module: domain-core
---
# bean은 없고 컴파일 시점 참조만 있다
`domain-core`는 Spring bean도 entry point도 없지만 두 runtime composition에 membership이 있고, 주요 public abstraction이 각각 실제 소비자를 갖는다.
## 본문
<!-- body:start -->
`domain-core` 자체에는 Spring bean/configuration/entry point가 없다. Registry상 `app-bootstrap`, `sample-portfolio` 두 runtime composition에 membership이 있고, concrete consumers가 compile-time type/annotation으로 이 module을 참조한다.
## ResourceId 참조 위치
:::evidence key="domain-core-c02" alt="코드베이스에서 ResourceId 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ResourceId 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 어떤 추상을 누가 참조하나
- `ResourceId` — application-core messaging contract 및 sample IDs
- `IdFactory` — sample factory/use-case/identifier adapter
- `AggregateRoot` — sample aggregate
- `DomainEvent` — sample events와 websocket broadcaster qualification
- `ValueObject` — sample IDs/value objects
## 두 방향 모두 근거가 없다
major public abstraction이 완전히 dead/unwired인 상태는 아니다. 반대로 `domain-core`가 runtime service를 직접 수행한다는 근거도 없다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: grpc-admin-c01
title: 건강 레지스트리 — 낙관에서 시작하지 않는다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-admin-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-admin-c01
file: ../../../final/evidence/rendered/grpc-admin-c01.svg
- key: grpc-admin-c01-diagram
file: ../../../final/assets/diagrams/grpc-admin-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-admin-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-admin.md#L50 이다.
module: grpc-admin
---
# 건강 레지스트리 — 낙관에서 시작하지 않는다
모든 등록 서비스가 UNKNOWN 에서 시작한다. 그리고 배수 중에는 markServing·markNotServing 이 무시된다.
## 본문
<!-- body:start -->
모든 등록 서비스가 `UNKNOWN` 에서 시작한다.
> "A registry that starts optimistic reports ready during startup, receives traffic before the first dependency check has run, and fails the requests that arrive in that window — the window being exactly the moment a rollout is shifting traffic onto the instance."
## 배수 중에는 표시가 무시된다
`markServing`·`markNotServing` 이 무시된다.
> "a service that reports itself healthy after the drain has started would be routed traffic the instance has already promised not to take."
## 전역 상태 판정 우선순위
:::evidence key="grpc-admin-c01-diagram" alt="임계 의존 불건강과 하나라도 NOT_SERVING 과 하나라도 SERVING 과 그 밖이 위에서 아래로 쌓여 있고 오른쪽에 판정 순서 화살표가 있다" caption="전역 상태 판정 우선순위" zoom="false"
:::
임계 의존이 하나라도 불건강하면 `NOT_SERVING`, 아니면 하나라도 `NOT_SERVING` 이면 `NOT_SERVING`, 하나라도 `SERVING` 이면 `SERVING`, 그 밖에는 `UNKNOWN` 이다.
## 이 기록이 다루는 범위
:::evidence key="grpc-admin-c01" alt="코드베이스에서 파일 목록을 만든 출력 13줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 13줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,54 @@
---
kind: CONCEPT
slug: grpc-admin-c02
title: 배수 순서
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-admin-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-admin-c02
file: ../../../final/evidence/rendered/grpc-admin-c02.svg
evidence:
- ../../../final/evidence/raw/grpc-admin-c02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-admin.md#L65 이다.
module: grpc-admin
---
# 배수 순서
beginDrain 이 앞의 둘을 한 번에 수행하고, 그 전에 rejectNewAdmission 을 부르면 던진다. 조정자는 잠들지 않는다.
## 본문
<!-- body:start -->
배수 순서가 여섯 단계로 고정돼 있다.
```text
READINESS_FALSE → HEALTH_DRAINING → REJECT_NEW_ADMISSION → DRAIN_UNARY → SIGNAL_STREAMS → FORCE_CANCEL
```
## beginDrain 이 앞의 둘을 한 번에 한다
그 전에 `rejectNewAdmission` 을 부르면 던진다.
> "refusing calls before readiness has flipped produces errors for traffic that routing is still sending"
## 이 기록이 다루는 범위
:::evidence key="grpc-admin-c02" alt="코드베이스에서 파일 목록을 만든 출력 13줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 13줄 · exit 0" zoom="true"
:::
## 조정자는 잠들지 않는다
> "It is given the current moment and the counts, and returns whether the phase is done; the waiting belongs to the caller, which is what makes every branch of this testable without a clock."
## 예산은 누적이다
스트림 신호 완료 판정이 `unaryDrainBudget + streamSignalBudget` 을 기준으로 한다.
<!-- body:end -->
@@ -0,0 +1,50 @@
---
kind: CONCEPT
slug: grpc-advanced-resilience-c02
title: xDS 시작 가드
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-advanced-resilience-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-resilience-c02
file: ../../../final/evidence/rendered/grpc-advanced-resilience-c02.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-resilience-c02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-resilience.md#L75 이다.
module: grpc-advanced-resilience
---
# xDS 시작 가드
두 거절이 있고 javadoc 이 둘째를 더 중요하다고 적는다. 시작 차단 사유는 둘 — 능력이 사용 가능하지 않음, 그리고 애플리케이션이 재시도 정책을 함께 정의함.
## 본문
<!-- body:start -->
두 거절이 있고 javadoc 이 둘째를 더 중요하다고 적는다.
> "xDS working in a deployment is not the same claim as the platform supporting it: it brings a control plane, its outage modes, its own security boundary and its own version skew, and the Stable support statement covers DNS and static targets. A support matrix that quietly widens is a support matrix nobody can rely on."
## 시작을 막는 두 사유
능력이 사용 가능하지 않음, 그리고 애플리케이션이 재시도 정책을 함께 정의함이다.
> "with xDS the control plane owns it, and defining it in both places makes the winner depend on resolution order"
## 이 기록이 다루는 범위
:::evidence key="grpc-advanced-resilience-c02" alt="코드베이스에서 파일 목록을 만든 출력 16줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 16줄 · exit 0" zoom="true"
:::
## 부트스트랩 대조가 보는 세 가지
`xds_servers` 선언, 통제 평면 채널의 TLS, 프로파일의 자원 이름공간이다.
> "a client whose bootstrap names a namespace the deployment did not configure subscribes successfully and receives another team's routing. Nothing errors — the control plane answers, the resources parse, and traffic goes somewhere nobody chose."
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: messaging-admin-api-c01
title: 권한을 불리언이 아니라 타입으로 만든다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-api-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-api-c01
file: ../../../final/evidence/rendered/messaging-admin-api-c01.svg
- key: messaging-admin-api-c01-diagram
file: ../../../final/assets/diagrams/messaging-admin-api-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-api-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-api.md#L57 이다.
module: messaging-admin-api
---
# 권한을 불리언이 아니라 타입으로 만든다
되돌릴 수 없는 작업을 사람의 승인에 묶는 타입 집합이다. 실행 코드는 하나도 없다 — 브로커를 만지는 것도, 메시지를 옮기는 것도 전부 messaging-admin-runtime 과 어댑터가 한다.
## 본문
<!-- body:start -->
**되돌릴 수 없는 작업을 사람의 승인에 묶는 타입 집합**이다. 실행 코드는 하나도 없다 — 브로커를 만지는 것도, 메시지를 옮기는 것도 전부 `messaging-admin-runtime` 과 어댑터가 한다.
## 이 리프가 소유한 어휘
:::evidence key="messaging-admin-api-c01-diagram" alt="계획 타입과 승인 타입과 검증 타입이 messaging-admin-api 안에 놓이고 브로커 접촉과 저장소 스프링이 바깥에 빗금으로 놓인다" caption="이 리프가 소유한 어휘" zoom="false"
:::
이 리프가 정의하는 것은 "무엇이 승인이고, 승인이 무엇을 인가하며, 인가되지 않은 것이 왜 컴파일되지 않는가" 다. 설계의 축은 하나다 — **권한을 불리언이 아니라 타입으로 만든다.**
## ReplayPlan 참조 위치
:::evidence key="messaging-admin-api-c01" alt="코드베이스에서 ReplayPlan 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ReplayPlan 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## 같은 기법이 한 층 더 쌓인다
`ReplayPlan``ApprovedReplayPlan` → 실행. 각 화살표가 타입 경계이고, 각 경계에서 검사가 **생성자 안에** 있어 우회 경로가 없다.
## 이 리프가 모르는 것
브로커를 모른다 — `DestinationTopology` 는 브로커가 보고한 값을 담는 record 일 뿐 조회하지 않는다. 저장소를 모른다 — `AdminOperationJournal` 은 인터페이스다. 스프링도 모른다.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: messaging-admin-runtime-c01
title: 부품은 정교하고 부품을 잇는 층은 실행된 적이 없다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-runtime-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-c01
file: ../../../final/evidence/rendered/messaging-admin-runtime-c01.svg
- key: messaging-admin-runtime-c01-diagram
file: ../../../final/assets/diagrams/messaging-admin-runtime-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L62 이다.
module: messaging-admin-runtime
---
# 부품은 정교하고 부품을 잇는 층은 실행된 적이 없다
messaging-admin-api 가 정의한 타입들을 실제로 실행하는 계층이다. 계획을 세우고, 저널에 자리를 잡고, 옮기고, 결과를 보고한다.
## 본문
<!-- body:start -->
`messaging-admin-api` 가 정의한 타입들을 **실제로 실행하는 계층**이다. 계획을 세우고, 저널에 자리를 잡고, 옮기고, 결과를 보고한다.
## 실행 계층이 만지는 것
:::evidence key="messaging-admin-runtime-c01-diagram" alt="오케스트레이션과 실행 서비스와 토폴로지 저널이 messaging-admin-runtime 안에 놓이고 브로커 클라이언트와 스프링 배선이 바깥에 빗금으로 놓인다" caption="실행 계층이 만지는 것" zoom="false"
:::
1. **오케스트레이션**`MessagingAdminService` / `DefaultMessagingAdminService`. 계획·승인·저널·실행을 잇는다.
2. **실행**`ReplayService`, `RedriveService`. 각각 하나의 작업을 수행하며, 브로커 접촉은 SPI(`ReplayExecutor`, `RedriveSource`, `RedrivePublisher`)로 밀어낸다.
3. **토폴로지·저널**`CompositeTopologyValidator`+`TopologyValidator`, `TopologyValidationRuntime`, `InMemoryAdminOperationJournal`.
## MessagingAdminService 참조 위치
:::evidence key="messaging-admin-runtime-c01" alt="코드베이스에서 MessagingAdminService 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingAdminService 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 경계 밖
브로커 클라이언트가 없다. Kafka·Rabbit 어느 것도 import 하지 않고, 모든 브로커 접촉이 함수형 인터페이스 뒤에 있다. Spring 도 없다 — 배선은 전부 starter 몫이다.
## 읽고 나서 남는 인상이 갈린다
**개별 부품은 대단히 정교하다** — 저널의 펜싱 프로토콜, 리드라이브 루프의 per-item 경계, 토폴로지 severity 판정은 각각 실패 사례를 겪고 나온 코드로 보이며 그 근거가 주석에 있다. 반면 **부품을 잇는 층은 실행된 적이 없다** — §12.1 에서 보듯 `DefaultMessagingAdminService` 는 프로덕션에서도 테스트에서도 인스턴스화되지 않는다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: messaging-admin-runtime-c04
title: 파괴적 작업에는 실행 경로가 없다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-runtime-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-c04
file: ../../../final/evidence/rendered/messaging-admin-runtime-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L440 이다.
module: messaging-admin-runtime
---
# 파괴적 작업에는 실행 경로가 없다
경로 A — 리드라이브 (설계상 의도된 흐름) 경로 B — 토폴로지 검증 Stack A 는 validateTopology() 로 진입해 보고서를 돌려준다. 그 보고서로 requireAcceptable() 을 부르는 코드는 없다.
## 본문
<!-- body:start -->
**경로 A — 리드라이브** 가 설계상 의도된 흐름이다.
## 경로 B — 토폴로지 검증
Stack A 는 `validateTopology()` 로 진입해 보고서를 돌려준다. 그 보고서로 `requireAcceptable()` 을 부르는 코드는 없다. Stack B 는 `validate(...)` 안에서 직접 던진다. 둘 다 프로덕션 진입점이 없다(`EVD-307`).
## DestructiveMessagingAdmin 참조 위치
:::evidence key="messaging-admin-runtime-c04" alt="코드베이스에서 DestructiveMessagingAdmin 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DestructiveMessagingAdmin 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 경로 C — 파괴적 작업
없다. `DestructiveMessagingAdmin` 구현체가 0건이므로 `PURGE`·`OFFSET_RESET`·`DELETE_DESTINATION` 은 이 저장소에 실행 경로가 없다.
<!-- body:end -->
@@ -0,0 +1,60 @@
---
kind: CONCEPT
slug: messaging-cloudevents-c04
title: 왕복 검증이 producedAt 을 비교하지 않는다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-cloudevents-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-cloudevents-c04
file: ../../../final/evidence/rendered/messaging-cloudevents-c04.svg
- key: messaging-cloudevents-c04-diagram
file: ../../../final/assets/diagrams/messaging-cloudevents-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-cloudevents-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-cloudevents.md#L113 이다.
module: messaging-cloudevents
---
# 왕복 검증이 producedAt 을 비교하지 않는다
봉투 → CloudEvent CloudEvent → 봉투 두 번째는 messaging-core-api의 MessageEnvelope javadoc과 정확히 짝을 이룬다 — "A null Kafka value is a tombstone, which is a distinct broker-native operation with different retention semantics." 봉투가 payload를 non-null로 강제한 이유가 여기서 실제 매핑 규칙으로 나타난다. ProducerId가 "deployment-independent service name, not a host, pod, or connection identity, so that it stays a bounded value safe for metric tags"라고 선언한 것과 같은 관심사다.
## 관계
- **왕복이라 부르는 테스트는 무엇을 보존하지 않는지도 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
매핑 방향이 둘이고, 두 번째는 `messaging-core-api``MessageEnvelope` javadoc과 정확히 짝을 이룬다 — "A null Kafka value is a tombstone, which is a distinct broker-native operation with different retention semantics." 봉투가 payload를 non-null로 강제한 이유가 여기서 실제 매핑 규칙으로 나타난다.
## producerFrom 의 방어가 완전하지 않다
`ProducerId`가 "deployment-independent service name, not a host, pod, or connection identity, so that it stays a bounded value safe for metric tags"라고 선언한 것과 같은 관심사다. **다만 이 방어는 완전하지 않다** — 마지막 세그먼트가 여전히 120 UTF-8 바이트를 넘거나 제어문자를 담을 수 있고, 그 경우 `ProducerId` 생성자가 `IllegalArgumentException`을 던진다(§4.5). `urn:service:order-api``order-api`(테스트가 쓰는 형태), `https://a.example/very/long/path/x``x`.
## MessageEnvelope 참조 위치
:::evidence key="messaging-cloudevents-c04" alt="코드베이스에서 MessageEnvelope 를 검색한 출력 18줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessageEnvelope 코드베이스 검색 — 18줄 · exit 0" zoom="true"
:::
## 봉투가 왕복에서 잃는 것
:::evidence key="messaging-cloudevents-c04-diagram" alt="원래 봉투에 occurredAt 과 producedAt 이 있고 왕복 후 봉투에서 producedAt 만 빗금으로 놓인다" caption="봉투가 왕복에서 잃는 것" zoom="false"
:::
CloudEvents에는 `time` 하나뿐이므로 봉투의 두 시각(플랫폼이 봉투를 만든 때 / 사실이 일어난 때)을 구분할 수 없다. 같은 값을 넣는 것은 합리적 선택이지만 **정보 손실이 기록되지 않았다** — 왕복 후 `producedAt`은 원래 값이 아니다.
## 테스트가 비교하지 않는 필드
왕복 검증(`roundTripsBackToAnEnvelopeWithoutInventingATombstone`)이 `messageId`·`messageType`·`schemaVersion`·`correlationId`·`tenantContext`·`payload`만 비교하고 `producedAt`은 비교하지 않는다. fixture에서 `producedAt``09:15:01Z`, `occurredAt``09:15:00Z`**일부러 다르게** 설정돼 있으므로, 비교했다면 실패했을 것이다.
<!-- body:end -->
@@ -0,0 +1,57 @@
---
kind: CONCEPT
slug: messaging-core-api-c04
title: 거절과 결과 모름을 하나로 합치면 중복 주문이 생긴다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-core-api-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-core-api-c04
file: ../../../final/evidence/rendered/messaging-core-api-c04.svg
- key: messaging-core-api-c04-diagram
file: ../../../final/assets/diagrams/messaging-core-api-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-core-api-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-core-api.md#L180 이다.
module: messaging-core-api
---
# 거절과 결과 모름을 하나로 합치면 중복 주문이 생긴다
이 leaf의 실질은 여기 있다. 표현할 수 없는 상태를 생성자에서 거절하는 것이 설계의 축이다.
## 본문
<!-- body:start -->
이 leaf의 실질은 여기 있다. **표현할 수 없는 상태를 생성자에서 거절하는 것**이 설계의 축이다.
## 발행 결과 세 상태
:::evidence key="messaging-core-api-c04-diagram" alt="발행 시도에서 CONFIRMED 와 REJECTED 와 AMBIGUOUS 세 갈래가 나온다" caption="발행 결과 세 상태" zoom="false"
:::
`PublishCompletion`은 boolean이 아니라 3상태다. enum javadoc이 왜 셋인지 적는다 — "Collapsing 'the broker refused this' and 'we never learned what the broker did' into one failure is what produces duplicate orders"(`publish/PublishCompletion.java:6-8`). `AMBIGUOUS`인 호출자는 **같은 `messageId`로만** 재발행할 수 있다.
## PublishCompletion 참조 위치
:::evidence key="messaging-core-api-c04" alt="코드베이스에서 PublishCompletion 를 검색한 출력 29줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="PublishCompletion 코드베이스 검색 — 29줄 · exit 0" zoom="true"
:::
## 생성자가 거절하는 열두 조합
`PublishResult` 생성자(`publish/PublishResult.java:39-101`)가 12가지를 거절한다. 11번과 14번에는 코드 주석이 직접 달려 있다. record가 public이고 모든 adapter가 이것을 만들기 때문에 호출부를 믿지 않고 여기서 검증한다는 것도 javadoc에 적혀 있다(`PublishResult.java:18-20`).
## 증거가 결론보다 먼저 기록된다
`PublishEvidence`(`publish/PublishEvidence.java`)는 `queuedLocally`, `transmission`, `brokerAccepted`, `confirmationLevel` 넷을 갖고, javadoc이 순서를 못 박는다 — "Evidence is recorded before a completion is chosen, not derived from it. That ordering is what lets an operator answer 'could the broker be holding this message?' from a stored result." `TransmissionEvidence`가 3상태(`NOT_TRANSMITTED` / `MAY_HAVE_BEEN_TRANSMITTED` / `TRANSMITTED`)인 것이 그 순서를 가능하게 한다.
## 정산 쪽도 같은 형태다
`SettlementResult`(`settlement/SettlementResult.java:23-36`)는 `SETTLED`인데 `!brokerConfirmed`이면 거절하고, `SETTLED`인데 `redeliveryPossible`이면 거절한다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: messaging-core-api-c06
title: 예외 클래스로 분기하지 않아도 되게 만든 기반 타입
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-core-api-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-core-api-c06
file: ../../../final/evidence/rendered/messaging-core-api-c06.svg
evidence:
- ../../../final/evidence/raw/messaging-core-api-c06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-core-api.md#L419 이다.
module: messaging-core-api
---
# 예외 클래스로 분기하지 않아도 되게 만든 기반 타입
MessagingException(abstract) → 23개 구체 예외. 기반 타입이 FailureDescriptor를 갖고 category()·retryable()를 위임한다.
## 본문
<!-- body:start -->
`MessagingException`(abstract) → 23개 구체 예외. 기반 타입이 `FailureDescriptor`를 갖고 `category()`·`retryable()`를 위임한다. javadoc이 목적을 적는다 — "a caller catching the base type can still classify and route the failure without matching on exception classes."
## MessagingException 참조 위치
:::evidence key="messaging-core-api-c06" alt="코드베이스에서 MessagingException 를 검색한 출력 36줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingException 코드베이스 검색 — 36줄 · exit 0" zoom="true"
:::
## 23개 중 12개가 leaf 밖에서 참조되지 않는다
`evidence/raw/269` §B — 12개 전부 `git grep` exit=1. §12.1에서 다룬다.
## 조용한 강등을 막는 문장들
`MessagingCapabilityUnavailableException` javadoc — "Downgrading replication evidence to a bare ack, or ordered delivery to unordered, produces a system that looks healthy right up to the moment the guarantee actually mattered." `MessageBackpressureException` javadoc — "Blocking the caller until a slot frees turns producer-side saturation into thread exhaustion in the calling application, which is a far worse failure than a fast rejection." 그리고 "Nothing was transmitted when this is thrown, so the message has no ambiguity."
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: messaging-kafka-c01
title: 소비자 런타임 — 스레드 규율이 설계다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-kafka-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-c01
file: ../../../final/evidence/rendered/messaging-kafka-c01.svg
- key: messaging-kafka-c01-diagram
file: ../../../final/assets/diagrams/messaging-kafka-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka.md#L69 이다.
module: messaging-kafka
---
# 소비자 런타임 — 스레드 규율이 설계다
공개 API 인 pause/resume 도 제어 큐를 통해 폴 스레드로 넘어가고, 반환된 단계는 다음 폴 주기 에 완료된다. close() 만 예외이고 그 예외에 근거가 붙어 있다 — 이후 폴 루프가 멈추므로 큐에 넣으면 영원히 배수되지 않는다.
## 본문
<!-- body:start -->
공개 API 인 `pause`/`resume` 도 제어 큐를 통해 폴 스레드로 넘어가고, 반환된 단계는 **다음 폴 주기** 에 완료된다.
## 폴 스레드가 소유한 것
:::evidence key="messaging-kafka-c01-diagram" alt="consumer 객체와 제어 큐 배수가 폴 스레드 안에 놓이고 작업자 람다가 바깥에 빗금으로 놓인다" caption="폴 스레드가 소유한 것" zoom="false"
:::
## close 만 예외인 이유
이후 폴 루프가 멈추므로 큐에 넣으면 영원히 배수되지 않는다.
## 이 기록이 다루는 범위
:::evidence key="messaging-kafka-c01" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
## 규율이 실제로 지켜진다
작업자 람다가 만지는 것은 `settlements`·`coordinator`·`shutdown`·`retries` 뿐이고 `consumer` 는 한 번도 없다. 통독으로 확인했다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: messaging-kafka-share-experimental-c04
title: 등록이 통과한 뒤에 아무 일도 일어나지 않는다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-kafka-share-experimental-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-share-experimental-c04
file: ../../../final/evidence/rendered/messaging-kafka-share-experimental-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-share-experimental-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka-share-experimental.md#L234 이다.
module: messaging-kafka-share-experimental
---
# 등록이 통과한 뒤에 아무 일도 일어나지 않는다
등록: registrar.register(profile, spec) → validator.validate(profile) → 통과하면 ShareRegistration(profile) 반환 → 이후 아무 일도 일어나지 않는다 pause: registration.pause(scope) → 즉시 실패 stage 이 leaf에 메시지가 흐르는 경로가 없다.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다**
같은 분석 리프에서 끌어낸 규칙이다.
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**등록:** `registrar.register(profile, spec)``validator.validate(profile)` → 통과하면 `ShareRegistration(profile)` 반환 → **이후 아무 일도 일어나지 않는다**.
**pause:** `registration.pause(scope)` → 즉시 실패 stage.
## 이 기록이 다루는 범위
:::evidence key="messaging-kafka-share-experimental-c04" alt="코드베이스에서 파일 목록을 만든 출력 4줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 4줄 · exit 0" zoom="true"
:::
## 메시지가 흐르는 경로가 없다
이 leaf에 메시지가 흐르는 경로가 없다.
<!-- body:end -->
@@ -0,0 +1,59 @@
---
kind: CONCEPT
slug: messaging-kafka-share-experimental-c05
title: 조용히 강등하지 않고 던진다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-kafka-share-experimental-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-share-experimental-c05
file: ../../../final/evidence/rendered/messaging-kafka-share-experimental-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-share-experimental-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka-share-experimental.md#L244 이다.
module: messaging-kafka-share-experimental
---
# 조용히 강등하지 않고 던진다
MessagingCapabilityUnavailableException의 javadoc이 이 leaf의 태도와 정확히 일치한다 — "Thrown instead of quietly degrading. Downgrading … ordered delivery to unordered, produces a system that looks healthy right up to the moment the guarantee actually mattered."
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다**
같은 분석 리프에서 끌어낸 규칙이다.
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
실패가 다섯 가지다.
| 코드 | 예외 | 카테고리 | 조건 |
|---|---|---|---|
| `KAFKA_SHARE_DISABLED` | `MessagingCapabilityUnavailableException` | `CONFIGURATION` | `enabled == false` |
| (코드 없음) | `IllegalArgumentException` | — | `orderingScope != NONE` |
| `KAFKA_SHARE_NO_PAUSE` | `MessagingCapabilityUnavailableException` | `CONFIGURATION` | `pause(...)` |
| `KAFKA_SHARE_NO_RESUME` | `MessagingCapabilityUnavailableException` | `CONFIGURATION` | `resume(...)` |
| (코드 없음) | `IllegalArgumentException` | — | `shareGroup` 공백, `maxDeliveryCount < 1` |
## MessagingCapabilityUnavailableException 참조 위치
:::evidence key="messaging-kafka-share-experimental-c05" alt="코드베이스에서 MessagingCapabilityUnavailableException 를 검색한 출력 37줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingCapabilityUnavailableException 코드베이스 검색 — 37줄 · exit 0" zoom="true"
:::
## javadoc 이 이 leaf 의 태도와 일치한다
"Thrown instead of quietly degrading. Downgrading … ordered delivery to unordered, produces a system that looks healthy right up to the moment the guarantee actually mattered."
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: messaging-nats-experimental-c01
title: 없는 큐를 찾아 나서게 만들지 않는다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-nats-experimental-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-nats-experimental-c01
file: ../../../final/evidence/rendered/messaging-nats-experimental-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-nats-experimental-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-nats-experimental.md#L82 이다.
module: messaging-nats-experimental
---
# 없는 큐를 찾아 나서게 만들지 않는다
nativeDeadLetter=false 의 근거가 클래스 javadoc 과 검증기 javadoc 양쪽에 있다 — 없는 큐를 찾아 나서게 만들지 않는다. keyedOrdering=false 도 검증기가 강제한다 — 키 순서를 요구하는 목적지를 거부한다.
## 본문
<!-- body:start -->
능력 상수는 다음과 같다.
```java
CAPABILITIES = (true, true, true, true, true, false, true, false, false, true, false, true);
```
## nativeDeadLetter 가 false 인 근거
클래스 javadoc 과 검증기 javadoc 양쪽에 있다 — 없는 큐를 찾아 나서게 만들지 않는다.
## 이 기록이 다루는 범위
:::evidence key="messaging-nats-experimental-c01" alt="코드베이스에서 파일 목록을 만든 출력 7줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 7줄 · exit 0" zoom="true"
:::
## keyedOrdering 도 검증기가 강제한다
키 순서를 요구하는 목적지를 거부한다. `deduplicatedPublish=true` 는 §17.1 이 다룬다.
<!-- body:end -->
@@ -0,0 +1,72 @@
---
kind: CONCEPT
slug: messaging-observability-c03
title: 차원이 닫혀 있고, 사전 확인과 커밋 사이에 lock이 없다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-observability-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-observability-c03
file: ../../../final/evidence/rendered/messaging-observability-c03.svg
- key: messaging-observability-c03-diagram
file: ../../../final/assets/diagrams/messaging-observability-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-observability-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-observability.md#L113 이다.
module: messaging-observability
---
# 차원이 닫혀 있고, 사전 확인과 커밋 사이에 lock이 없다
여섯 차원: broker, destinationProfile, operation, outcome, failureCategory, retryStage. 없는 값은 NONE = "none"이다 — null도 빈 문자열도 아니고 명시적 sentinel이다.
## 관계
- **타입이 문서화한 불변식은 타입이 강제한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`MessagingTags`는 열린 map이 아니라 고정 record다 — "Every field here is bounded by configuration or by an enum, so the cardinality of the metric is known before it is ever scraped. Message ids, partition keys, tenant ids, and offsets are all deliberately absent: each of them is unbounded at runtime and would multiply every series by the message volume."
## 태그에서 일부러 뺀 것
:::evidence key="messaging-observability-c03-diagram" alt="여섯 고정 차원과 없는 값은 none 이 MessagingTags 안에 놓이고 message id 와 partition key, tenant id 와 offset 이 바깥에 빗금으로 놓인다" caption="태그에서 일부러 뺀 것" zoom="false"
:::
여섯 차원은 `broker`, `destinationProfile`, `operation`, `outcome`, `failureCategory`, `retryStage`다. 없는 값은 `NONE = "none"`이다 — null도 빈 문자열도 아니고 명시적 sentinel이다.
## 두 factory의 차이
`new MessagingTags(6개 인자)``failureCategory``retryStage`를 호출자가 지정하고, `MessagingTags.of(4개 인자)`는 둘 다 `NONE` 고정이다. 이 차이가 §12.1의 핵심이 된다.
## LinkedHashMap 참조 위치
:::evidence key="messaging-observability-c03" alt="코드베이스에서 LinkedHashMap 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="LinkedHashMap 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
`asMap()``LinkedHashMap`으로 순서를 고정하고 `Map.copyOf`로 불변화한다.
## 호출자가 철자를 정하지 않는다
`MessagingObservation`은 네 메서드와 네 상수(`PUBLISH`, `CONSUME`, `SETTLE`, `DEAD_LETTER`)를 갖는다. `publish(...)``PublishCompletion``Optional<FailureCategory>`를 받아 **enum에서 문자열을 파생**한다. 이 클래스는 소비자가 0이다(§12.1).
## 이전 결함 둘이 코드에 남아 있다
기본 상한은 200/차원이다. 먼저 lock 없이 `values.contains(value)`로 빠른 경로를 두고, 새 값일 때만 `synchronized`로 들어가 다시 확인한다 — double-checked 패턴이다. 테스트가 경합을 직접 재현한다(`MessagingSecretLeakTest.concurrentAdmissionNeverExceedsTheLimit`).
## 사전 확인과 커밋 사이에 lock이 없다
`wouldAdmit`으로 전수 사전 확인 후 `admit`으로 커밋한다. 그 사이에 lock이 없으므로 두 스레드가 동시에 통과할 수 있고, 그 경우 두 번째 `admit`이 false를 반환해 `admitted &= ...`가 false가 된다 — 상한은 지켜지고 결과만 거절이 된다. 안전한 방향이다.
## 같은 문제를 두 강도로 푼다
거부 목록은 27개 키이고 **두 범주**를 섞어 담는다. `isDenied`가 소문자 정규화 후 정확 일치다. **`messaging-core-api``MessageHeaders.carriesACredential`은 세그먼트 매칭 + 인접 결합**(그쪽 §4.6)인데 이쪽은 정확 일치다(§12.3). `msg.id`가 목록에 리터럴로 들어 있다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: messaging-observability-c04
title: 거절된 태그 집합도 세어서 남긴다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-observability-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-observability-c04
file: ../../../final/evidence/rendered/messaging-observability-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-observability-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-observability.md#L334 이다.
module: messaging-observability
---
# 거절된 태그 집합도 세어서 남긴다
메트릭: 호출자가 MessagingTags를 만들어 MessagingObservation의 다섯 메서드 중 하나를 호출 → MessagingMetrics.admitted(tags) → guard.admit(tags) → 통과하면 Micrometer Tags로 변환 후 미터 기록, 거절되면 rejectedTagSets.increment() 추적(발행): tracer.inject(context, headers) → traceparent 없으면 그대로 반환 → 있으면 세 헤더를 platform factory로 추가 추적(수신): tracer.extract(headers) → traceparent 없으면 TraceContext.none() → 있으면 세 값으로 TraceContext 재구성(core-api의 W3C 검증을 통과해야 함) 감사: 호출자가 MessagingAuditEvent를 만들어 sink에 record
## 관계
- **타입이 문서화한 불변식은 타입이 강제한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**메트릭:** 호출자가 `MessagingTags`를 만들어 `MessagingObservation`의 다섯 메서드 중 하나를 호출 → `MessagingMetrics.admitted(tags)``guard.admit(tags)` → 통과하면 Micrometer `Tags`로 변환 후 미터 기록, 거절되면 `rejectedTagSets.increment()`.
## MessagingTags 참조 위치
:::evidence key="messaging-observability-c04" alt="코드베이스에서 MessagingTags 를 검색한 출력 37줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingTags 코드베이스 검색 — 37줄 · exit 0" zoom="true"
:::
## 추적 — 발행 쪽
`tracer.inject(context, headers)``traceparent` 없으면 그대로 반환 → 있으면 세 헤더를 `platform` factory로 추가.
## 추적 — 수신 쪽
`tracer.extract(headers)``traceparent` 없으면 `TraceContext.none()` → 있으면 세 값으로 `TraceContext` 재구성(**core-api의 W3C 검증을 통과해야 함**).
## 감사
호출자가 `MessagingAuditEvent`를 만들어 sink에 `record` 한다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-observability-c05
title: 손상된 traceparent 는 이 leaf 의 실패 어휘 밖에서 터진다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-observability-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-observability-c05
file: ../../../final/evidence/rendered/messaging-observability-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-observability-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-observability.md#L346 이다.
module: messaging-observability
---
# 손상된 traceparent 는 이 leaf 의 실패 어휘 밖에서 터진다
이 leaf는 MessagingException을 하나도 던지지 않는다. 실패를 값으로 표현한다.
## 관계
- **타입이 문서화한 불변식은 타입이 강제한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
이 leaf는 `MessagingException`을 하나도 던지지 않는다. 실패를 **값으로 표현**한다.
## MessagingException 참조 위치
:::evidence key="messaging-observability-c05" alt="코드베이스에서 MessagingException 를 검색한 출력 36줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingException 코드베이스 검색 — 36줄 · exit 0" zoom="true"
:::
## 던지는 곳은 전부 호출자의 프로그래밍 오류다
`IllegalArgumentException`을 던지는 곳은 넷 — `CardinalityGuard` 생성자(`limitPerDimension < 1`), `MessagingMetrics.recordDelivery`(`attempt < 1`), `MessagingTracer.shouldLinkRatherThanContinue`(`batchSize < 1`), `MessagingAuditEvent` 생성자(빈 필드).
## extract 가 W3C 검증에 걸릴 수 있다
`new TraceContext(traceparent, tracestate, baggage)`가 core-api의 정규식·바이트 상한·all-zero 검사를 돌리므로(그쪽 §4.11), 다른 시스템이 보낸 손상된 `traceparent``IllegalArgumentException`이 된다. 그 예외는 `MessagingException`이 아니고 `extract`는 그것을 잡지 않는다. `messaging-cloudevents`의 id 파싱과 같은 형태다(`analysis/messaging/messaging-cloudevents.md` §17). §17.
<!-- body:end -->
@@ -0,0 +1,60 @@
---
kind: CONCEPT
slug: messaging-outbox-jdbc-postgresql-c03
title: 확률을 낮추는 것과 데이터 제약으로 만드는 것은 다르다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-outbox-jdbc-postgresql-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-outbox-jdbc-postgresql-c03
file: ../../../final/evidence/rendered/messaging-outbox-jdbc-postgresql-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-outbox-jdbc-postgresql-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-outbox-jdbc-postgresql.md#L177 이다.
module: messaging-outbox-jdbc-postgresql
---
# 확률을 낮추는 것과 데이터 제약으로 만드는 것은 다르다
V1 — message_id 를 대리키가 아니라 기본키로 삼는다. 인덱스도 근거가 있다.
## 본문
<!-- body:start -->
마이그레이션 네 개가 이 리프의 이력을 담고 있다.
## V1 — message_id 가 기본키인 이유
대리키가 아니라 기본키다 — 릴레이가 모든 재시도에서 보존해야 하는 논리적 정체이고, 그것을 키로 만들면 어떤 경로도 같은 행을 새 id로 발행할 수 없다. 인덱스도 근거가 있다 — 부분 인덱스인 이유("PUBLISHED rows accumulate until the retention job removes them"), `IN_FLIGHT` 를 포함하는 이유("A relay that dies mid-publish leaves rows in that state ... omitting them here would strand those messages").
## V2 — 펜싱 토큰
주석이 시나리오를 그대로 적는다 — relay A 가 청구하고 브로커를 부르는 사이 lease 가 만료되고, relay B 가 재청구해 발행하고 PUBLISHED 를 기록한다. "확률을 낮추는 것과 데이터 제약으로 만드는 것은 다르다" — 이 리프에서 가장 좋은 한 줄이다. `EXHAUSTED` 상태 추가와 `next_attempt_at` 인덱스도 여기서 들어온다.
## 이 기록이 다루는 범위
:::evidence key="messaging-outbox-jdbc-postgresql-c03" alt="코드베이스에서 파일 목록을 만든 출력 13줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 13줄 · exit 0" zoom="true"
:::
## V3 — admin 저널
복합 기본키 `(approval_ticket, plan_digest)` 의 근거가 `messaging-admin-api` 의 것과 동일하게 적혀 있다.
## V4 — 정경 메타데이터 12컬럼
왜 봉투 blob 이 아니라 컬럼인지가 명확하다. 그리고 밀반입 문제를 명시한다 — "smuggled through the header map under the reserved `msg.*` names ... a row whose header map contains `msg.id` overwrites another message's identity on the wire". DB 레벨 제약을 Java 와 이중으로 거는 이유도 적혀 있다. 마지막으로 **생성 컬럼**이 두 릴레이의 합의를 하나로 만든다.
## 이 수정이 properties 파일에는 도달하지 않았다
§12.4(a).
## append 가 보는 세 가지
`:228-245` — 활성 트랜잭션이 있는가 / 읽기 전용이 아닌가 / **이 DataSource 에 바인딩되어 있는가**. 세 번째가 특히 좋다 — 다른 DataSource 의 트랜잭션 안에서 append 하면 둘이 독립적으로 커밋된다. `append(Connection, OutboxRecord)` 가 package-private 으로 내려간 이력도 적혀 있다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: messaging-policy-c02
title: bean 정의는 전부 starter 쪽에 있다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-policy-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-c02
file: ../../../final/evidence/rendered/messaging-policy-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L84 이다.
module: messaging-policy
---
# bean 정의는 전부 starter 쪽에 있다
들어오는 것: messaging-core-api(api), messaging-schema-api(api). 둘 다 api인 이유는 DestinationProfile이 DeliveryGuarantee·OrderingScope·DestinationKind·DestinationName(core-api)와 SchemaCompatibility(schema-api)를 필드로 갖기 때문이다.
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
들어오는 것은 `messaging-core-api`(api)와 `messaging-schema-api`(api)다. 둘 다 `api`인 이유는 `DestinationProfile``DeliveryGuarantee`·`OrderingScope`·`DestinationKind`·`DestinationName`(core-api)와 `SchemaCompatibility`(schema-api)를 필드로 갖기 때문이다.
## 이 기록이 다루는 범위
:::evidence key="messaging-policy-c02" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
## 나가는 쪽
`messaging-transport-spi`, `messaging-runtime-core`, `messaging-kafka`, `messaging-kafka-share-experimental`, `messaging-rabbit`, `messaging-outbox-jdbc-postgresql`, `messaging-admin-api`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-cloud-stream-bridge`, `messaging-spring-boot-starter`, `messaging-testkit`.
## 실제 배선 지점 넷이 전부 starter 안에 있다
전부 `messaging-spring-boot-starter/MessagingCoreAutoConfiguration`이다. 이 leaf 자체는 Spring 주석을 갖지 않는다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: messaging-policy-c04
title: 재시도 판단과 DLQ 경로는 출하 컨텍스트에서 호출되지 않는다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-policy-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-c04
file: ../../../final/evidence/rendered/messaging-policy-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L411 이다.
module: messaging-policy
---
# 재시도 판단과 DLQ 경로는 출하 컨텍스트에서 호출되지 않는다
시작: MessagingCoreAutoConfiguration:134 → validateAll(registered) → 프로파일별 15검사 + 중복 이름 + 사이클 그래프 → 실패 시 IllegalArgumentException으로 부팅 중단 발행: DefaultMessagePublisher → admission.admit(destination, bytes) → 크기 → 종료 여부 → 목적지 슬롯 → 프로세스 permit → (발행) → admission.complete(destination) 재시도 판단: RetryContext(profile, deliveryMetadata, failure, capabilities, ...) → engine.decide(...) → RetryDecision 5종 중 하나 — 이 경로는 출하 컨텍스트에서 호출되지 않는다(§12.1) DLQ: orchestrator.deadLetter(profile, delivery, failure, settlement) → 헤더 6개 추가 → 발행 → CONFIRMED면 원본 정산 — 이 경로도 호출되지 않는다(§12.1)
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**시작:** `MessagingCoreAutoConfiguration:134``validateAll(registered)` → 프로파일별 15검사 + 중복 이름 + 사이클 그래프 → 실패 시 `IllegalArgumentException`으로 부팅 중단.
**발행:** `DefaultMessagePublisher``admission.admit(destination, bytes)` → 크기 → 종료 여부 → 목적지 슬롯 → 프로세스 permit → (발행) → `admission.complete(destination)`.
## 이 기록이 다루는 범위
:::evidence key="messaging-policy-c04" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
## 호출되지 않는 두 경로
**재시도 판단:** `RetryContext(profile, deliveryMetadata, failure, capabilities, ...)``engine.decide(...)``RetryDecision` 5종 중 하나 — 이 경로는 출하 컨텍스트에서 호출되지 않는다(§12.1).
**DLQ:** `orchestrator.deadLetter(profile, delivery, failure, settlement)` → 헤더 6개 추가 → 발행 → CONFIRMED면 원본 정산 — 이 경로도 호출되지 않는다(§12.1).
<!-- body:end -->
@@ -0,0 +1,52 @@
---
kind: CONCEPT
slug: messaging-policy-c07
title: 발행 경로는 프로덕션에서 실제로 조립된다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-policy-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-c07
file: ../../../final/evidence/rendered/messaging-policy-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L603 이다.
module: messaging-policy
---
# 발행 경로는 프로덕션에서 실제로 조립된다
DefaultMessagePublisher MessagingCoreAutoConfiguration.java:446 TransportMessagingRuntime MessagingCoreAutoConfiguration.java:476 DefaultRetryDecisionEngine MessagingCoreAutoConfiguration.java:168 DeadLetterOrchestrator MessagingCoreAutoConfiguration.java:180
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
네 타입이 프로덕션 auto-configuration 안에서 생성된다.
| 타입 | 생성 지점 |
|---|---|
| `DefaultMessagePublisher` | `MessagingCoreAutoConfiguration.java:446` |
| `TransportMessagingRuntime` | `MessagingCoreAutoConfiguration.java:476` |
| `DefaultRetryDecisionEngine` | `MessagingCoreAutoConfiguration.java:168` |
| `DeadLetterOrchestrator` | `MessagingCoreAutoConfiguration.java:180` |
## 이 기록이 다루는 범위
:::evidence key="messaging-policy-c07" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,52 @@
---
kind: CONCEPT
slug: messaging-policy-c08
title: RetryDecision 변형에 반응하는 파일 넷 중 셋이 선언과 테스트다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-policy-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-c08
file: ../../../final/evidence/rendered/messaging-policy-c08.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-c08.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L617 이다.
module: messaging-policy
---
# RetryDecision 변형에 반응하는 파일 넷 중 셋이 선언과 테스트다
messaging-kafka/.../KafkaRetryExecutor.java (생성되지 않음) messaging-policy/.../DefaultRetryDecisionEngine.java (생산자) messaging-policy/.../RetryDecision.java (선언) messaging-policy/.../RetryDecisionEngineTest.java (테스트)
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`RetryDecision` 변형에 실제로 작용하는 파일은 넷이다.
| 파일 | 역할 |
|---|---|
| `messaging-kafka/.../KafkaRetryExecutor.java` | 생성되지 않음 |
| `messaging-policy/.../DefaultRetryDecisionEngine.java` | 생산자 |
| `messaging-policy/.../RetryDecision.java` | 선언 |
| `messaging-policy/.../RetryDecisionEngineTest.java` | 테스트 |
## 이 기록이 다루는 범위
:::evidence key="messaging-policy-c08" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-rabbit-c01
title: 소비·정착·죽은 편지의 세 규율
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-rabbit-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-rabbit-c01
file: ../../../final/evidence/rendered/messaging-rabbit-c01.svg
- key: messaging-rabbit-c01-diagram
file: ../../../final/assets/diagrams/messaging-rabbit-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-rabbit-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-rabbit.md#L91 이다.
module: messaging-rabbit
---
# 소비·정착·죽은 편지의 세 규율
좁은 catch. RabbitConsumerRegistrar.onMessage 가 디코딩만 감싸는 안쪽 try 를 따로 둔다.
## 본문
<!-- body:start -->
**좁은 catch.** `RabbitConsumerRegistrar.onMessage` 가 디코딩만 감싸는 안쪽 `try` 를 따로 둔다.
## 핸들러가 끝난 뒤의 두 갈래
:::evidence key="messaging-rabbit-c01-diagram" alt="핸들러 완료에서 정착함 ack 와 정착 안 함 requeue 두 갈래가 나온다" caption="핸들러가 끝난 뒤의 두 갈래" zoom="false"
:::
**정착하지 않은 핸들러.** 완료했는데 정착하지 않으면 대신 ack 하지 않고 requeue 한다 — "acknowledging on its behalf would silently drop it".
## RabbitConsumerRegistrar 참조 위치
:::evidence key="messaging-rabbit-c01" alt="코드베이스에서 RabbitConsumerRegistrar 를 검색한 출력 13줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RabbitConsumerRegistrar 코드베이스 검색 — 13줄 · exit 0" zoom="true"
:::
## 네이티브 죽은 편지
`RabbitNativeDeadLetterCapability` 가 두 조건을 모두 요구한다.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: messaging-runtime-core-c01
title: 컨텍스트 테스트를 통과하면서 아무것도 실행하지 않는 조립
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-runtime-core-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-runtime-core-c01
file: ../../../final/evidence/rendered/messaging-runtime-core-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-runtime-core-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-runtime-core.md#L55 이다.
module: messaging-runtime-core
---
# 컨텍스트 테스트를 통과하면서 아무것도 실행하지 않는 조립
이 leaf는 조립 결함 하나를 고치기 위해 만들어졌다. 여섯 파일 중 다섯의 javadoc이 "X was an interface with no implementation" 형태로 시작한다.
## 관계
- **만들어 두고 흘리지 않는 진단값은 진단이 아니다**
같은 분석 리프에서 끌어낸 규칙이다.
- **증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다**
같은 분석 리프에서 끌어낸 규칙이다.
- **`CompletionStage`를 반환하는 메서드는 동기적으로 던지지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**이 leaf는 조립 결함 하나를 고치기 위해 만들어졌다.** 여섯 파일 중 다섯의 javadoc이 "X was an interface with no implementation" 형태로 시작한다. `build.gradle`이 그 사정을 파일 맨 위에 적는다.
## DefaultDeliveryProcessor 참조 위치
:::evidence key="messaging-runtime-core-c01" alt="코드베이스에서 DefaultDeliveryProcessor 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultDeliveryProcessor 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
## 진단의 마지막 문장
**컨텍스트 테스트를 통과하면서 아무것도 실행하지 않는 조립**이 가능했다는 것. 이 저장소가 반복해서 만나는 형태다.
## 여섯 중 하나는 아직 배선되지 않았다
여섯 파일이 구멍을 메웠고, 그중 다섯은 배선됐다. 마지막 하나(`DefaultDeliveryProcessor`)는 배선되지 않았다(§12.1).
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: messaging-runtime-core-c02
title: 인자가 여섯 개라는 것이 관측 지점이다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-runtime-core-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-runtime-core-c02
file: ../../../final/evidence/rendered/messaging-runtime-core-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-runtime-core-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-runtime-core.md#L86 이다.
module: messaging-runtime-core
---
# 인자가 여섯 개라는 것이 관측 지점이다
들어오는 것: 여섯 project 의존, 전부 api. DefaultMessagePublisher 한 클래스가 그중 다섯을 생성자로 받으므로 api가 맞다.
## 관계
- **만들어 두고 흘리지 않는 진단값은 진단이 아니다**
같은 분석 리프에서 끌어낸 규칙이다.
- **증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다**
같은 분석 리프에서 끌어낸 규칙이다.
- **`CompletionStage`를 반환하는 메서드는 동기적으로 던지지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
들어오는 것은 여섯 project 의존이고 전부 `api`다. `DefaultMessagePublisher` 한 클래스가 그중 다섯을 생성자로 받으므로 `api`가 맞다.
## DefaultMessagePublisher 참조 위치
:::evidence key="messaging-runtime-core-c02" alt="코드베이스에서 DefaultMessagePublisher 를 검색한 출력 22줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultMessagePublisher 코드베이스 검색 — 22줄 · exit 0" zoom="true"
:::
## 나가는 쪽과 배선 지점
나가는 것은 `messaging-spring-boot-starter`뿐이다. 배선 지점은 다섯이고 전부 `MessagingCoreAutoConfiguration`에 있다. 446의 인자가 **여섯 개**라는 것이 §12.1의 관측 지점이다.
<!-- body:end -->
@@ -0,0 +1,74 @@
---
kind: CONCEPT
slug: messaging-runtime-core-c03
title: 바이트가 프로세스를 떠났는가가 REJECTED와 AMBIGUOUS를 가른다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-runtime-core-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-runtime-core-c03
file: ../../../final/evidence/rendered/messaging-runtime-core-c03.svg
- key: messaging-runtime-core-c03-diagram
file: ../../../final/assets/diagrams/messaging-runtime-core-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-runtime-core-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-runtime-core.md#L128 이다.
module: messaging-runtime-core
---
# 바이트가 프로세스를 떠났는가가 REJECTED와 AMBIGUOUS를 가른다
실제 순서 여덟 단계: 1–7은 전부 REJECTED, 8만 AMBIGUOUS다. 그 경계가 정확히 "바이트가 프로세스를 떠났는가"다.
## 관계
- **만들어 두고 흘리지 않는 진단값은 진단이 아니다**
같은 분석 리프에서 끌어낸 규칙이다.
- **증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다**
같은 분석 리프에서 끌어낸 규칙이다.
- **`CompletionStage`를 반환하는 메서드는 동기적으로 던지지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
순서가 인터셉터 map에서 조립되지 않고 고정돼 있으며, javadoc이 각 단계의 위치를 결정으로 적는다 — 목적지와 접근이 먼저라 인가되지 않은 발행이 payload를 인코딩하지 않고, 인코딩이 admission보다 먼저인 것은 admission 경계가 바이트에 걸려 있어 인코딩 전에는 바이트 수를 모르기 때문이며, 런타임 lease가 전송 직전 마지막인 것은 이미 in-flight 한도에 계상된 메시지 밑에서 rotation이 transport를 바꾸지 못하게 하기 위해서다.
## 발행 단계가 고정된 순서
:::evidence key="messaging-runtime-core-c03-diagram" alt="목적지와 접근에서 인코딩으로 이름 확인이 건너가고 인코딩에서 admission 으로 바이트 수가 건너가고 admission 에서 브로커 전송으로 permit 이 건너간다" caption="발행 단계가 고정된 순서" zoom="false"
:::
## 여덟 단계 중 여덟만 AMBIGUOUS다
**17은 전부 `REJECTED`, 8만 `AMBIGUOUS`다.** 그 경계가 정확히 "바이트가 프로세스를 떠났는가"다. `messaging-core-api`의 3상태(§4.1)가 여기서 실제 분기가 된다. 그리고 `rejected(...)`가 만드는 `PublishResult``PublishEvidence.notTransmitted()`를 쓰므로 `PublishResult` 생성자의 14가지 금지 조합 검증을 자연히 통과한다.
## PublishResult 참조 위치
:::evidence key="messaging-runtime-core-c03" alt="코드베이스에서 PublishResult 를 검색한 출력 25줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="PublishResult 코드베이스 검색 — 25줄 · exit 0" zoom="true"
:::
## 이미 기다리기를 그만둔 메시지를 보내지 않는다
`remainingBudget``timeout - elapsedSince(startedAt)`이고, 0 이하면 전송 전에 `REJECTED`로 끝낸다 — "Sending anyway would start a message the caller has already stopped waiting for."
## copy 에 타임아웃을 거는 이유
`orTimeout`을 원본에 걸면 만료가 어댑터의 stage를 완료시켜 어댑터의 자기 정리가 깨진다. 복사본에 걸면 만료는 이쪽 경로만 끝내고 어댑터는 자기 in-flight를 계속 소유한다. 그 대가도 명시돼 있다 — permit과 lease는 **복사본이 완료될 때** 반납되므로, 브로커가 나중에 응답해도 이미 반납된 상태다. 그것이 의도다("holding them until a stalled broker answers is how a rotation waits forever").
## 두 경우를 한 블록에서 처리한다
`handle``whenComplete`와 달리 실패를 삼키고 값을 반환한다. `lease.close()``MessagingRuntimeLease` 계약상 멱등이고(`transport-spi` §4.1), `admission.complete`도 미보유 목적지에 대해 무해하다(`messaging-policy` §4.3).
## 한 가지 비대칭
6번(`admit`)이 예외를 던지면 그 예외가 그대로 호출자에게 전파된다 — `try` 블록 밖이다. 다른 모든 실패는 `PublishResult`로 정규화되는데 admission 실패만 예외다.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: messaging-runtime-core-c05
title: 같은 코드가 두 completion에 쓰인다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-runtime-core-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-runtime-core-c05
file: ../../../final/evidence/rendered/messaging-runtime-core-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-runtime-core-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-runtime-core.md#L397 이다.
module: messaging-runtime-core
---
# 같은 코드가 두 completion에 쓰인다
DefaultMessagePublisher가 만드는 결과: 같은 코드 PUBLISH_DEADLINE_EXCEEDED가 두 completion에 쓰인다. 전송 전이면 REJECTED, 후면 AMBIGUOUS다.
## 관계
- **만들어 두고 흘리지 않는 진단값은 진단이 아니다**
같은 분석 리프에서 끌어낸 규칙이다.
- **증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다**
같은 분석 리프에서 끌어낸 규칙이다.
- **`CompletionStage`를 반환하는 메서드는 동기적으로 던지지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`DefaultMessagePublisher`가 만드는 결과에서 같은 코드 `PUBLISH_DEADLINE_EXCEEDED`**두 completion에 쓰인다.** 전송 전이면 `REJECTED`, 후면 `AMBIGUOUS`다. 코드만 보는 대시보드는 두 경우를 구분할 수 없다 — completion을 함께 봐야 한다. §17.
## DefaultMessagePublisher 참조 위치
:::evidence key="messaging-runtime-core-c05" alt="코드베이스에서 DefaultMessagePublisher 를 검색한 출력 22줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultMessagePublisher 코드베이스 검색 — 22줄 · exit 0" zoom="true"
:::
## sanitized 가 타입 이름만 남긴다
`sanitized(Throwable)`가 메시지가 아니라 **타입 이름만** 남긴다. `messaging-core-api``FailureDescriptor` javadoc("no payload, no stack trace, no credential")과 같은 관심사다. `isDeadline``sanitized` 둘 다 `CompletionException`을 한 겹 벗긴다 — 비동기 경로에서 원인이 감싸지기 때문이다.
## 처리기 쪽은 던지지 않는다
`DefaultDeliveryProcessor`는 예외를 던지지 않는다. 이중 정산만 `failedFuture`로 보고한다.
<!-- body:end -->
@@ -0,0 +1,54 @@
---
kind: CONCEPT
slug: messaging-schema-json-c01
title: 크기 초과를 codec에서 잡으면 버려도 안전한 실패가 된다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-schema-json-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-schema-json-c01
file: ../../../final/evidence/rendered/messaging-schema-json-c01.svg
- key: messaging-schema-json-c01-diagram
file: ../../../final/assets/diagrams/messaging-schema-json-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-schema-json-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-schema-json.md#L46 이다.
module: messaging-schema-json
---
# 크기 초과를 codec에서 잡으면 버려도 안전한 실패가 된다
Stable JSON codec 하나. MessageCodec(schema-api)을 구현하고 Jackson 3(tools.jackson.* 네임스페이스)을 쓴다.
## 관계
- **실패 코드는 운영자의 다음 행동이 갈리는 지점마다 나눈다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
Stable JSON codec 하나. `MessageCodec`(schema-api)을 구현하고 Jackson 3(`tools.jackson.*` 네임스페이스)을 쓴다.
## 크기 초과를 어디서 잡나
:::evidence key="messaging-schema-json-c01-diagram" alt="크기 초과에서 codec 이 잡으면 REJECTED 이고 브로커가 잡으면 AMBIGUOUS 인 두 갈래가 나온다" caption="크기 초과를 어디서 잡나" zoom="false"
:::
javadoc이 "기본 codec으로 노출해도 안전한 이유" 셋을 명시한다. 세 번째가 `messaging-core-api`의 3상태 발행 결과와 직접 연결된다 — 크기 초과를 브로커가 아니라 여기서 잡으면 `NOT_TRANSMITTED` 증거가 붙은 `REJECTED`가 되고, 브로커에서 잡히면 `AMBIGUOUS`가 된다. 전자는 버려도 안전하고 후자는 아니다.
## MessageCodec 참조 위치
:::evidence key="messaging-schema-json-c01" alt="코드베이스에서 MessageCodec 를 검색한 출력 40줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessageCodec 코드베이스 검색 — 40줄 · exit 0" zoom="true"
:::
## 이 leaf만 implementation인 이유
Jackson 의존성은 `implementation`이다 — public 시그니처에 Jackson 타입이 없기 때문이다. 형제 leaf(`schema-avro`, `schema-protobuf`, `cloudevents`)는 vendor 타입이 public 시그니처에 나오므로 `api`로 선언했고 build.gradle에 그 이유를 주석으로 적었다. `src/messaging/CLAUDE.md:40-43`의 게이트가 이 구분을 강제한다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-schema-json-c05
title: 여섯 갈래 원인이 하나의 코드로 접힌다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-schema-json-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-schema-json-c05
file: ../../../final/evidence/rendered/messaging-schema-json-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-schema-json-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-schema-json.md#L246 이다.
module: messaging-schema-json
---
# 여섯 갈래 원인이 하나의 코드로 접힌다
전부 retryable = false다 — PERMANENT_BUSINESS와 DESERIALIZATION 카테고리다. 같은 바이트를 다시 디코딩해도 같은 결과이므로 일관적이다.
## 관계
- **실패 코드는 운영자의 다음 행동이 갈리는 지점마다 나눈다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
실패는 전부 `retryable = false`다 — `PERMANENT_BUSINESS``DESERIALIZATION` 카테고리다. 같은 바이트를 다시 디코딩해도 같은 결과이므로 일관적이다.
## 이 기록이 다루는 범위
:::evidence key="messaging-schema-json-c05" alt="코드베이스에서 파일 목록을 만든 출력 1줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 1줄 · exit 0" zoom="true"
:::
## 진단 손실 하나
파서 강화가 잡는 여섯 가지(깊이, 중복 키, trailing token, 미지 필드, 문서 길이, 토큰 길이)가 전부 하나의 코드 `JSON_DECODE_FAILED`로 접힌다. 운영자는 "JSON 디코딩 실패"만 보고 원인 여섯 갈래를 구분할 수 없다.
## 원인은 붙지만 분류에는 남지 않는다
원인 예외가 `cause`로 붙지만 `FailureDescriptor``exceptionType``Optional.empty()`로 둔다(`MessageSerializationException`의 3인자 생성자 경로). §17 참조.
<!-- body:end -->
@@ -0,0 +1,64 @@
---
kind: CONCEPT
slug: messaging-schema-protobuf-c03
title: 어긋난 짝을 표현할 수 없게 만든 값 하나
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-schema-protobuf-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-schema-protobuf-c03
file: ../../../final/evidence/rendered/messaging-schema-protobuf-c03.svg
- key: messaging-schema-protobuf-c03-diagram
file: ../../../final/assets/diagrams/messaging-schema-protobuf-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-schema-protobuf-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-schema-protobuf.md#L112 이다.
module: messaging-schema-protobuf
---
# 어긋난 짝을 표현할 수 없게 만든 값 하나
이 leaf에서 가장 밀도 높은 결정이다. 증명 방법이 영리하다.
## 관계
- **검증되지 않는 스키마 파일은 문서임을 파일 안에 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **신뢰할 수 없는 입력 쪽 경계를 먼저 테스트한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
이 leaf에서 가장 밀도 높은 결정이다. 예전에는 클래스와 parser가 두 개의 평행한 맵에 살았고 둘이 일치하는지 아무도 확인하지 않아, `OrderCreated.class``OrderCancelled`의 parser를 짝지은 registry가 생성 시점에 받아들여진 뒤 디코딩 시점에 브로커 스레드에서 `ClassCastException`을 냈다. 둘을 한 값에 묶으면 어긋남을 표현할 수 없다.
## 짝을 생성 시점에 증명하는 방법
:::evidence key="messaging-schema-protobuf-c03-diagram" alt="빈 바이트 파싱에서 default instance 로 proto3 유효가 건너가고 default instance 에서 선언 클래스 대조로 산출 타입이 건너간다" caption="짝을 생성 시점에 증명하는 방법" zoom="false"
:::
증명 방법이 영리하다. proto3에서 모든 필드가 wire상 optional이므로 **빈 바이트는 항상 유효한 메시지**다. 그것을 파싱하면 default instance가 나오고 그 클래스가 곧 parser의 산출 타입이다. 별도 리플렉션 없이 짝을 확인한다.
## 에러 메시지가 실패 지점을 명시한다
"a mismatched pairing fails at decode time on a broker thread, not here" — 즉 **여기서 실패하는 것이 목적**임을 메시지가 스스로 말한다. 두 코드가 다르다 — `PROTOBUF_CONTRACT_UNUSABLE`(파싱 자체 실패)과 `PROTOBUF_CONTRACT_MISMATCH`(파싱은 되는데 타입이 다름). 카테고리는 둘 다 `CONFIGURATION`이다. 테스트가 이 성질을 붙든다 — `aParserThatDoesNotProduceTheDeclaredClassIsRejectedAtConstruction`, `as("the mismatch used to surface as a ClassCastException on a broker thread")`.
## BoundedByteSink 참조 위치
:::evidence key="messaging-schema-protobuf-c03" alt="코드베이스에서 BoundedByteSink 를 검색한 출력 15줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="BoundedByteSink 코드베이스 검색 — 15줄 · exit 0" zoom="true"
:::
## 세 codec 중 유일하게 사전 거절이 가능하다
`BoundedByteSink.requireFits`가 이 leaf를 위해 존재하고, schema-api의 javadoc이 그것을 명시한다 — "Protobuf knows its serialized size exactly, so the whole encode can be refused before the first byte is written." 그리고 사전 검사가 사후 경계를 대체하지 않는다 — `writeTo(sink)`가 여전히 sink를 통과하므로 이중 방어다. schema-api javadoc: "this is a cheaper refusal, not a replacement for the bound."
## 두 검사를 함께 보는 이유
`Message`인지와 등록된 클래스의 인스턴스인지를 함께 본다. 후자만으로 충분해 보이지만 전자가 `writeTo`를 부를 수 있음을 보장한다. JSON codec과 같은 비대칭이다.
<!-- body:end -->
@@ -0,0 +1,65 @@
---
kind: CONCEPT
slug: messaging-security-c05
title: 같은 두 검사를 두 예외 계층이 나눠 갖는다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-security-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-security-c05
file: ../../../final/evidence/rendered/messaging-security-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-security-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-security.md#L339 이다.
module: messaging-security
---
# 같은 두 검사를 두 예외 계층이 나눠 갖는다
보안 판정이 두 예외 계층으로 나뉜다. BrokerTlsPolicy는 안정 코드가 붙은 MessagingConfigurationException을 쓰고, MessageSecurityValidator는 코드 없는 IllegalArgumentException을 쓴다.
## 관계
- **배선된 게이트는 자기 leaf 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 술어가 두 타입에 있으면 하나가 다른 하나를 부른다**
같은 분석 리프에서 끌어낸 규칙이다.
- **가변 필드로 상태 전이를 표현하면 가시성을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **맵 갱신 함수 안에서 I/O를 하면 그 지연이 락 범위가 된다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
실패가 두 계층으로 나뉜다.
| 코드 | 예외 | 위치 |
|---|---|---|
| `TLS_REQUIRED` | `MessagingConfigurationException` | `BrokerTlsPolicy` |
| `HOSTNAME_VERIFICATION_REQUIRED` | `MessagingConfigurationException` | 같음 |
| `TLS_PROTOCOL_NOT_ACCEPTED` | `MessagingConfigurationException` | 같음 |
| `TLS_PROTOCOL_UNSPECIFIED` | `MessagingConfigurationException` | 같음 |
| `APPLICATION_HOLDS_DESTRUCTIVE_GRANT` | `MessagingConfigurationException` | `BrokerAclManifest`(미사용) |
| `DESTINATION_PUBLISH_DENIED` | `MessageAuthorizationException` | `DestinationAccessValidator`(미사용) |
| `DESTINATION_CONSUME_DENIED` | `MessageAuthorizationException` | 같음(미사용) |
| `DESTINATION_ADMIN_DENIED` | `MessageAuthorizationException` | 같음(미사용) |
| (코드 없음) | `IllegalArgumentException` × 5 | `MessageSecurityValidator` |
| (코드 없음) | `IllegalArgumentException` | `CredentialIds`, 각 생성자 |
| (코드 없음) | `IllegalStateException` | `CredentialRuntime.material()` 소거 후 |
## BrokerTlsPolicy 참조 위치
:::evidence key="messaging-security-c05" alt="코드베이스에서 BrokerTlsPolicy 를 검색한 출력 28줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="BrokerTlsPolicy 코드베이스 검색 — 28줄 · exit 0" zoom="true"
:::
## 나뉘는 지점
`BrokerTlsPolicy`는 안정 코드가 붙은 `MessagingConfigurationException`을 쓰고, `MessageSecurityValidator`는 코드 없는 `IllegalArgumentException`을 쓴다. 둘이 같은 두 검사(TLS·hostname)를 공유하는데도 그렇다 — §12.3, §17.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-spring-boot-starter-c02
title: 모든 거부가 타입이 아니라 키를 부른다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-spring-boot-starter-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-spring-boot-starter-c02
file: ../../../final/evidence/rendered/messaging-spring-boot-starter-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-spring-boot-starter-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-boot-starter.md#L111 이다.
module: messaging-spring-boot-starter
---
# 모든 거부가 타입이 아니라 키를 부른다
MessagingConfigurationCompiler 가 닫는 것은 기능이 아니라 바인더의 부재다. 컴파일과 검증을 나눈 이유도 적혀 있다.
## 관계
- **검증기는 발행이 아니라 주입이 강제다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`MessagingConfigurationCompiler` 가 닫는 것은 기능이 아니라 바인더의 부재다. 컴파일과 검증을 나눈 이유도 적혀 있다.
## MessagingConfigurationCompiler 참조 위치
:::evidence key="messaging-spring-boot-starter-c02" alt="코드베이스에서 MessagingConfigurationCompiler 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingConfigurationCompiler 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 컴파일이 보는 것
객체 모델이 표현할 수 없는 것만 본다 — 목적지의 브로커가 존재하는지, 사후 처리 목적지가 선언되었는지, 보안 항목이 실재하는 브로커를 지키는지. 프로파일이 자체로 정합한지는 `DestinationProfileValidator` 의 질문이고 레지스트리 전체에 대해 던져진다. 그래서 설정으로 만든 프로파일과 빈으로 선언한 프로파일이 **같은 규칙**을 받는다.
## 거부가 키를 부르는 이유
타입을 부르는 오류는 운영자가 고칠 줄을 알려 주지 않기 때문이다.
<!-- body:end -->
@@ -0,0 +1,54 @@
---
kind: CONCEPT
slug: messaging-spring-boot-starter-c03
title: 종료 순서가 두 수명 주기의 phase 로 표현된다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-spring-boot-starter-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-spring-boot-starter-c03
file: ../../../final/evidence/rendered/messaging-spring-boot-starter-c03.svg
- key: messaging-spring-boot-starter-c03-diagram
file: ../../../final/assets/diagrams/messaging-spring-boot-starter-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-spring-boot-starter-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-boot-starter.md#L155 이다.
module: messaging-spring-boot-starter
---
# 종료 순서가 두 수명 주기의 phase 로 표현된다
SmartLifecycle 은 내림차순으로 멈추므로 중계가 먼저, 승인 차단과 배수가 다음이다. 두 클래스의 javadoc 이 서로를 근거로 든다 — 중계가 발행 중일 때 승인을 닫으면 그 회차의 행이 모호해지고, 그 모호함이야말로 배수가 없애려는 것이다.
## 관계
- **검증기는 발행이 아니라 주입이 강제다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`SmartLifecycle` 은 내림차순으로 멈추므로 중계가 먼저, 승인 차단과 배수가 다음이다.
## 두 수명 주기로 나뉜 종료
:::evidence key="messaging-spring-boot-starter-c03-diagram" alt="중계 정지에서 승인 차단과 배수로 내림차순 phase 가 건너가고 승인 차단과 배수에서 빈 소멸로 두 수명 주기 종료가 건너간다" caption="두 수명 주기로 나뉜 종료" zoom="false"
:::
두 클래스의 javadoc 이 서로를 근거로 든다 — 중계가 발행 중일 때 승인을 닫으면 그 회차의 행이 모호해지고, 그 모호함이야말로 배수가 없애려는 것이다.
## SmartLifecycle 참조 위치
:::evidence key="messaging-spring-boot-starter-c03" alt="코드베이스에서 SmartLifecycle 를 검색한 출력 14줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SmartLifecycle 코드베이스 검색 — 14줄 · exit 0" zoom="true"
:::
## 브로커 연결을 쥔 빈이 마지막이다
`@Bean(destroyMethod = "close")` 인 생산자는 `Lifecycle` 이 아니므로 컨텍스트가 `destroyBeans()` 에 도달할 때, 즉 두 수명 주기가 모두 끝난 뒤에 닫힌다. 순서가 맞는다.
<!-- body:end -->
@@ -0,0 +1,57 @@
---
kind: CONCEPT
slug: messaging-spring-cloud-stream-bridge-c02
title: Spring Cloud Stream 브리지가 Spring을 import하지 않는다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-spring-cloud-stream-bridge-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-spring-cloud-stream-bridge-c02
file: ../../../final/evidence/rendered/messaging-spring-cloud-stream-bridge-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-spring-cloud-stream-bridge-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-cloud-stream-bridge.md#L86 이다.
module: messaging-spring-cloud-stream-bridge
---
# Spring Cloud Stream 브리지가 Spring을 import하지 않는다
선언된 것과 쓰이는 것이 다르다. grep -rn 'import dev.caskeleton.messaging.transport\|import org.springframework' → exit 1.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **등록을 받는 컴포넌트는 해제도 제공한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **함께 읽히는 두 맵은 한 값으로 묶는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **한 개념의 등록 상태를 두 객체가 나눠 갖지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**선언된 것과 쓰이는 것이 다르다.** `grep -rn 'import dev.caskeleton.messaging.transport\|import org.springframework'` → exit 1.
## SpringCloudStreamPublisherBridge 참조 위치
:::evidence key="messaging-spring-cloud-stream-bridge-c02" alt="코드베이스에서 SpringCloudStreamPublisherBridge 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SpringCloudStreamPublisherBridge 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## 바인더 접촉면이 두 함수형 인터페이스뿐이다
`SpringCloudStreamPublisherBridge.ChannelSend``SpringCloudStreamConsumerBridge.BridgedHandler`. javadoc이 그 목적을 적는다 — "isolated so the bridge is testable without a binder". 즉 **`spring-context` 의존은 실제 통합 코드가 있어야 필요했을 것**인데 그 코드가 없다. §12.4.
## 삼중 정합
나가는 것이 없다 — 어떤 leaf의 `allowed_dependencies`에도 이 leaf가 없고 starter 목록에도 없다. 런타임 배선 없음, `runtime_memberships: []`, bean 없음. **소비자 0 · membership `[]` · 조립 0의 삼중 정합**`messaging-kafka-share-experimental`·`messaging-schema-avro`와 같은 상태다.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: messaging-spring-cloud-stream-bridge-c04
title: send가 true를 반환해도 AMBIGUOUS다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-spring-cloud-stream-bridge-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-spring-cloud-stream-bridge-c04
file: ../../../final/evidence/rendered/messaging-spring-cloud-stream-bridge-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-spring-cloud-stream-bridge-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-cloud-stream-bridge.md#L309 이다.
module: messaging-spring-cloud-stream-bridge
---
# send가 true를 반환해도 AMBIGUOUS다
검증: validator.validate(profile, bindingName, extendedProperties, enabled) → guard 4검사 → 이름 → 속성 8개 → production → BindingCapabilityReport.bridged(...) 발행: bridge.bindPublisher(dest, binding) → bridge.publish(dest, payload, headers) → send.send(...) → true면 AMBIGUOUS, false면 REJECTED 수신: consumerBridge.register(dest, binding, handler) → 바인더가 dispatch(binding, payload, headers) → handler.handle(...) (예외 그대로 전파)
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **등록을 받는 컴포넌트는 해제도 제공한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **함께 읽히는 두 맵은 한 값으로 묶는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **한 개념의 등록 상태를 두 객체가 나눠 갖지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
세 경로가 있다.
**검증:** `validator.validate(profile, bindingName, extendedProperties, enabled)` → guard 4검사 → 이름 → 속성 8개 → production → `BindingCapabilityReport.bridged(...)`
**발행:** `bridge.bindPublisher(dest, binding)``bridge.publish(dest, payload, headers)``send.send(...)` → true면 `AMBIGUOUS`, false면 `REJECTED`
**수신:** `consumerBridge.register(dest, binding, handler)` → 바인더가 `dispatch(binding, payload, headers)``handler.handle(...)` (예외 그대로 전파)
## 이 기록이 다루는 범위
:::evidence key="messaging-spring-cloud-stream-bridge-c04" alt="코드베이스에서 파일 목록을 만든 출력 6줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 6줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: messaging-spring-cloud-stream-bridge-c05
title: messaging family에서 예외 어휘가 가장 일관된 leaf
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-spring-cloud-stream-bridge-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-spring-cloud-stream-bridge-c05
file: ../../../final/evidence/rendered/messaging-spring-cloud-stream-bridge-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-spring-cloud-stream-bridge-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-cloud-stream-bridge.md#L319 이다.
module: messaging-spring-cloud-stream-bridge
---
# messaging family에서 예외 어휘가 가장 일관된 leaf
아홉 개의 구성 실패가 전부 MessagingConfigurationException + 안정 코드다. 이 저장소 messaging family에서 예외 어휘가 가장 일관된 leaf다 — messaging-security(두 계층 혼용)·messaging-kafka-share-experimental(두 계층 혼용)·messaging-policy(검증기가 IllegalArgumentException)와 대비된다.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **등록을 받는 컴포넌트는 해제도 제공한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **함께 읽히는 두 맵은 한 값으로 묶는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **한 개념의 등록 상태를 두 객체가 나눠 갖지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**아홉 개의 구성 실패가 전부 `MessagingConfigurationException` + 안정 코드다.** `messaging-security`(두 계층 혼용)·`messaging-kafka-share-experimental`(두 계층 혼용)·`messaging-policy`(검증기가 `IllegalArgumentException`)와 대비된다.
## MessagingConfigurationException 참조 위치
:::evidence key="messaging-spring-cloud-stream-bridge-c05" alt="코드베이스에서 MessagingConfigurationException 를 검색한 출력 24줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingConfigurationException 코드베이스 검색 — 24줄 · exit 0" zoom="true"
:::
## 발행 결과는 예외가 아니라 값이다
`messaging-core-api`의 설계를 그대로 따른다.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: messaging-testkit-c01
title: 지원한다는 단어의 정의를 코드로 못 박는 곳
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-testkit-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-testkit-c01
file: ../../../final/evidence/rendered/messaging-testkit-c01.svg
- key: messaging-testkit-c01-diagram
file: ../../../final/assets/diagrams/messaging-testkit-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-testkit-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-testkit.md#L68 이다.
module: messaging-testkit
---
# 지원한다는 단어의 정의를 코드로 못 박는 곳
이 리프는 "지원한다(supported)"라는 단어의 정의를 코드로 못 박는 곳이다. 플랫폼의 다른 어떤 리프도 "Kafka 는 Stable 이다" 를 주장하지 않는다.
## 본문
<!-- body:start -->
이 리프는 **"지원한다(supported)"라는 단어의 정의를 코드로 못 박는 곳**이다. 플랫폼의 다른 어떤 리프도 "Kafka 는 Stable 이다" 를 주장하지 않는다. 그 주장은 여기에만 있고, 여기서만 검증된다.
## 이 리프가 소유한 세 층
:::evidence key="messaging-testkit-c01-diagram" alt="공유 계약과 결함 시나리오 증거와 지원 등급이 messaging-testkit 안에 놓이고 어댑터 구현과 어댑터 실행이 바깥에 빗금으로 놓인다" caption="이 리프가 소유한 세 층" zoom="false"
:::
1. **공유 계약** (`MessagingAdapterContract` + `MessagingAdapterHarness` + `ContractMessage`/`ContractAssertions`/`ObservedDelivery`/`HandleOutcome`/`FaultController`) — 브로커가 무엇이든 똑같이 답해야 하는 7가지 행동.
2. **결함 시나리오와 그 증거** (`NetworkFaultScenario` + `BrokerCertificationEvidence` + `CertifiedEvidence` + `BrokerFailureMatrix`) — 어떤 장애를 실제로 돌려 봤는가.
3. **지원 등급** (`CompatibilityMatrix`) — 위 두 층의 결과로 어댑터가 얻는 등급.
## MessagingAdapterContract 참조 위치
:::evidence key="messaging-testkit-c01" alt="코드베이스에서 MessagingAdapterContract 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingAdapterContract 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## 구현하지도 실행하지도 않는다
하니스 구현은 각 어댑터 리프의 `src/test` 에 있다(`KafkaContractHarness`, `RabbitContractHarness`). 이 리프가 가진 유일한 하니스는 `InMemoryMessagingHarness` 이며 `src/test` 에 있고, 그 javadoc 이 스스로 선을 긋는다.
## 접근 제어자로도 강제되는 문장
`final` + package-private + `private` 생성자 + 정적 팩토리. "프로덕션 어댑터가 되어서는 안 된다" 는 문장이 접근 제어자로도 강제되어 있다. `src/main` 이 아니라 `src/test` 에 둔 것도 같은 결정이다 — 다른 리프의 test 클래스패스에 올라가는 것은 `src/main` 뿐이므로, 이 하니스는 물리적으로 이 리프 밖으로 나갈 수 없다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: messaging-testkit-c02
title: 상속하는 쪽이 컴파일되려면 전부 전이되어야 한다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-testkit-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-testkit-c02
file: ../../../final/evidence/rendered/messaging-testkit-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-testkit-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-testkit.md#L99 이다.
module: messaging-testkit
---
# 상속하는 쪽이 컴파일되려면 전부 전이되어야 한다
여섯 개가 전부 api 다. implementation 이 하나도 없다.
## 본문
<!-- body:start -->
여섯 개가 전부 `api` 다. `implementation` 이 하나도 없다.
## MessagingAdapterContract 참조 위치
:::evidence key="messaging-testkit-c02" alt="코드베이스에서 MessagingAdapterContract 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingAdapterContract 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## 이 리프에서는 그것이 옳은 선택이다
`MessagingAdapterContract``@Test`**자기 시그니처에** 달고 있고(`MessagingAdapterContract.java:28`), `ContractAssertions` 는 AssertJ 를 반환 타입 없이 쓰지만 상속받는 쪽이 같은 AssertJ 를 봐야 하며, `MessagingAdapterHarness.publish``PublishResult`(core-api)를, `ContractMessage``EncodedMessage`(schema-api)를 **공개 시그니처에** 노출한다.
## 빈 membership 의 뜻이 다른 리프들과 정반대다
`runtime_memberships: []` 이지만 §12.1 의 판정은 다른 `[]` 리프들과 정반대다. 추가로 `messaging-kafka/build.gradle:91` 이 이 리프의 **리소스 파일 경로를 문자열로 참조**한다(§4.3).
<!-- body:end -->
@@ -0,0 +1,47 @@
---
kind: CONCEPT
slug: messaging-transport-spi-c02
title: 세 leaf의 타입이 public 시그니처에 직접 등장한다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-transport-spi-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-transport-spi-c02
file: ../../../final/evidence/rendered/messaging-transport-spi-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-transport-spi-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-transport-spi.md#L90 이다.
module: messaging-transport-spi
---
# 세 leaf의 타입이 public 시그니처에 직접 등장한다
들어오는 것: messaging-core-api(api), messaging-schema-api(api), messaging-policy(api). 셋 다 api인 이유는 세 leaf의 타입이 이 leaf의 public 시그니처에 직접 등장하기 때문이다 — TransportPublishRequest가 DestinationProfile(policy)·MessageEnvelope(core-api)·EncodedMessage(schema-api)를 필드로 갖는다.
## 관계
- **멱등 종료를 보장하는 컴포넌트는 종료 이후의 등록도 정의한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 개념의 sentinel은 계층을 넘어 하나로 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
들어오는 것은 `messaging-core-api`(api), `messaging-schema-api`(api), `messaging-policy`(api)다. 셋 다 `api`인 이유는 세 leaf의 타입이 이 leaf의 public 시그니처에 직접 등장하기 때문이다 — `TransportPublishRequest``DestinationProfile`(policy)·`MessageEnvelope`(core-api)·`EncodedMessage`(schema-api)를 필드로 갖는다.
## TransportPublishRequest 참조 위치
:::evidence key="messaging-transport-spi-c02" alt="코드베이스에서 TransportPublishRequest 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="TransportPublishRequest 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 나가는 쪽
`messaging-runtime-core`, `messaging-kafka`, `messaging-kafka-share-experimental`, `messaging-rabbit`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-admin-runtime`, `messaging-spring-cloud-stream-bridge`, `messaging-spring-boot-starter`, `messaging-testkit`. 런타임 편입은 starter closure를 통해서다. 이 leaf 자체는 bean을 만들지 않는다.
<!-- body:end -->
@@ -0,0 +1,57 @@
---
kind: CONCEPT
slug: shared-contract-c01
title: factory는 검증하고 raw 생성자는 검증하지 않는다
topic: delivery-and-settlement-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:shared-contract-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: shared-contract-c01
file: ../../../final/evidence/rendered/shared-contract-c01.svg
- key: shared-contract-c01-diagram
file: ../../../final/assets/diagrams/shared-contract-c01.svg
evidence:
- ../../../final/evidence/raw/shared-contract-c01.txt
source:
- 원본 분석 절은 analysis/02-shared-contract.md#L51 이다.
module: shared-contract
---
# factory는 검증하고 raw 생성자는 검증하지 않는다
ApiErrorCode는 code/category/httpStatus/retryable의 최소 표면을 제공하고 OperationalError가 registry mirror 역할을 한다. Category는 VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL의 10개 값으로 고정되어 있으며 테스트가 정확한 vocabulary를 pin 한다.
## 본문
<!-- body:start -->
`ApiErrorCode`는 code/category/httpStatus/retryable의 최소 표면을 제공하고 `OperationalError`가 registry mirror 역할을 한다. `Category`는 VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL의 10개 값으로 고정되어 있으며 테스트가 정확한 vocabulary를 pin 한다.
## ApiErrorCode 참조 위치
:::evidence key="shared-contract-c01" alt="코드베이스에서 ApiErrorCode 를 검색한 출력 16줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ApiErrorCode 코드베이스 검색 — 16줄 · exit 0" zoom="true"
:::
## retryable 이 category 에서 자동으로 나오지 않는다
`OperationalErrorTest`는 단순 enum 존재보다 category × retryable 의미를 강하게 검증한다. deterministic VALIDATION/AUTHZ/NOT_FOUND는 retryable=false이고, transient INTERNAL은 기본적으로 retryable=true이되 deploy-time/configuration/terminal 상태인 일부 code는 명시적 예외로 false다. `AUTH_KID_UNKNOWN`은 key rotation 중 JWKS refresh 가능성을 이유로 AUTH 중 유일한 retryable case로 pin 되어 있다. upstream 4xx 전체를 permanent/non-retryable로 분류하면서 408/429의 의미 차이가 남는다는 점은 source comment와 README가 이미 known edge로 기록한다.
## 어떤 예외가 코드를 나르는가
`DependencyFailureException``PersistenceFailureException``ApiErrorCarrier`를 통해 transport adapter에 stable error code를 전달하면서 raw cause/diagnostic message를 server-side 정보로 남긴다. `AdapterDisabledException`은 carrier를 구현하지 않고 별도 mapping 대상이다.
## 검증이 걸리는 자리
:::evidence key="shared-contract-c01-diagram" alt="factory 경로에 배타성 검증과 문서의 정상 shape 가 놓이고 raw 생성자에 배타성 미검증과 invalid shape 가능이 빗금으로 놓인다" caption="검증이 걸리는 자리" zoom="false"
:::
`Envelope`, `BulkEnvelope`, `ResponseMeta`, `PageMeta`, `Operation`은 framework-neutral record/factory로 API shape를 전달한다. `Envelope.ok/failure`, `BulkEnvelope.allOk/partial`, `Operation.pending/succeeded/failed` factory는 문서의 정상 shape를 생성하고 테스트도 이 factory path를 검증한다. 그러나 canonical record constructor 자체는 success/data/error의 배타성, operation status와 result/error의 조합, pagination 범위 등을 검증하지 않는다.
## 그래서 이 규칙의 성격이 다르다
rate-limit value object처럼 intrinsic constructor invariant가 아니라 factory/adapter usage contract다. 현재 source와 test가 일치하므로 즉시 결함으로 분류하지 않지만, raw constructor가 외부 module에 public인 만큼 invalid shape 생성 가능성은 P1 hardening 후보로 남는다.
<!-- body:end -->