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,581 @@
# messaging-claim-check 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-claim-check`
> SSOT owner: `messaging-claim-check`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-claim-check`
- canonical state `analysisFile`: `analysis/messaging/messaging-claim-check.md`
- source path: `src/messaging/messaging-claim-check`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-reliability-api"]`
- registry `runtime_memberships`: **`["app-bootstrap"]`**
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 6 |
| production LOC | 418 |
| 패키지 | 1 (`dev.caskeleton.messaging.claimcheck`) |
| test 파일 | 3 |
| test 메서드(실행 확인) | 22 |
| 외부(비프로젝트) 의존성 | **0** |
여섯 타입:
| 타입 | 종류 | 역할 | leaf 밖 참조 |
|---|---|---|---:|
| `ClaimCheckStore` | interface | payload 저장·조회·삭제 port | **0** |
| `ClaimCheckPolicy` | record | 문턱과 보존 규칙 | **0** |
| `ClaimCheckPublisher` | class | 발행 측 오프로드 결정 | **0** |
| `ClaimCheckResolver` | class | 소비 측 조회 + 검증 | **0** |
| `ClaimCheckIntegrityGuard` | class | digest·크기·만료 검사 | **0** |
| `ClaimCheckIntegrityException` | exception | digest 불일치 | **0** |
**여섯 전부 leaf 밖 참조가 0이다.**
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |
| `src/test/java/**` (3) | 3 | `FULL_READ` | 테스트명·fake 구현 확인 |
| `build.gradle` | 1 | `FULL_READ` | 6줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
**Claim Check 패턴** — 브로커 한계를 넘는 payload를 객체 저장소에 두고 메시지는 참조만 나른다.
`messaging-reliability-api``ClaimCheckReference`(storageKey·sizeBytes·sha256·expiresAt)를 값 타입으로 쓰고, 이 leaf가 그것을 만들고 검증하는 동작을 소유한다.
경계 진술이 두 클래스에 있다.
```java
// ClaimCheckIntegrityGuard.java:14-17
* <p>A claim check turns one message into two systems that can drift. The payload store has its own
* retention, its own replication, and its own access control, and none of them are coordinated with
* the broker's. So a consumer that fetches bytes and decodes them without checking is trusting
* something the message never proved.
```
```java
// ClaimCheckResolver.java:11-15
* <p>Verification is not optional and cannot be skipped by a caller. An object store key is a
* string, and a message carrying the wrong one through a bug, a replay against a rotated bucket,
* or a deliberate tamper fetches bytes that decode perfectly into the wrong object. The digest is
* the only thing standing between that and a handler acting on someone else's data.
```
**"decode perfectly into the wrong object"**가 이 leaf의 위협 모델이다 — 실패가 아니라 잘못된 성공.
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api), `messaging-reliability-api`(api).
나가는 것: `messaging-spring-boot-starter``allowed_dependencies`에 포함된다.
**배선: 없다.** `ClaimCheckStore`의 production 구현이 0이고(유일한 구현은 테스트의 `FakeStore`), `ClaimCheckPublisher`·`ClaimCheckResolver`·`ClaimCheckPolicy` 생성이 leaf 밖에서 0건이다.
그런데 **`runtime_memberships``["app-bootstrap"]`이다.** starter closure를 통해 배포 아티팩트에 실린다.
`messaging-cloudevents`와 같은 조합이다 — **싣고 쓰지 않는다**(`analysis/messaging/messaging-cloudevents.md` §12.1).
---
## 3. 패키지/컴포넌트 지도
```
발행 측
ClaimCheckPublisher(store, policy)
└── offload(byte[]) → Offloaded(payload, Optional<ClaimCheckReference>)
├── policy.shouldOffload(len) == false → Offloaded(payload.clone(), empty)
└── true → store.put(payload, retention) → Offloaded(new byte[0], reference)
소비 측
ClaimCheckResolver(store)
└── resolve(inline, Optional<reference>, now)
├── reference 없음 → inline.clone()
├── reference.isExpired(now) → CLAIM_CHECK_EXPIRED
├── store.get(reference) == null → CLAIM_CHECK_NOT_FOUND
└── guard.verify(...) → 검증된 바이트
└── *_MISMATCH → ClaimCheckIntegrityException으로 승격
정책
ClaimCheckPolicy(thresholdBytes, retention, brokerRetention, maxRedeliveryWindow)
└── 생성자가 retention >= brokerRetention + maxRedeliveryWindow를 강제
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `ClaimCheckPolicy` — 보존이 생성자 불변식이다
```java
Duration required = brokerRetention.plus(maxRedeliveryWindow);
if (retention.compareTo(required) < 0) {
throw new MessagingConfigurationException("CLAIM_CHECK_RETENTION_TOO_SHORT", ...);
}
```
javadoc이 이유를 적는다.
```java
// :10-14
* <p>The retention rule is the one that matters. A claim check object deleted while its message is
* still deliverable turns a large message into an undeliverable one the consumer fetches, gets
* nothing, and the message dead-letters for a reason that has nothing to do with the message. So
* retention must exceed the broker's own retention plus the full retry and dead-letter window, and
* the constructor refuses a configuration where it does not.
```
**이것이 `messaging-reliability-api`의 `InboxRepository.purgeProcessedBefore` javadoc이 요구하고 강제하지 않는 것과 같은 형태의 규칙인데, 이쪽은 생성자가 강제한다.** 같은 저장소에서 같은 종류의 시간 관계 규칙을 한 곳은 강제하고 한 곳은 문서로만 둔다 — 그 leaf §17이 소유한다.
문턱과 목적지 payload 상한을 분리한 이유도 명시돼 있다.
```java
// :16-18
* <p>The threshold is separate from the destination's payload limit. Offloading starts well below
* the limit, because the limit is where the broker refuses the message and the threshold is where
* carrying it inline stops being a good idea.
```
`DEFAULT_THRESHOLD_BYTES = 262,144` = 1 MiB의 1/4이고 javadoc이 그렇게 부른다.
`defaults()`가 브로커 1일 보존 + 1일 재시도 경로에 대해 3일 보존을 준다 — 요구치(2일)보다 1일 여유.
### 4.2 `ClaimCheckPublisher` — 순서와 미삭제
```java
// :9-17
* <p>The object is written <em>before</em> the message is published, and that order is the whole
* design. Publishing first would let a consumer receive a reference to an object that does not
* exist yet a race that is rare in a test and routine under load, because the broker hop is
* faster than the object store write.
*
* <p>Nothing here deletes on failure. If the publish is rejected the object is left behind, and the
* retention sweep reclaims it; deleting eagerly would delete the object out from under a publish
* that turned out to be ambiguous rather than rejected.
```
두 번째가 `messaging-core-api`의 3상태와 직접 연결된다 — `REJECTED``AMBIGUOUS`를 구분할 수 없는 시점에 삭제하면 모호한 발행의 payload를 지운다.
오프로드된 메시지는 payload를 **아예 갖지 않는다**.
```java
// The published message carries no payload bytes at all, only the reference. Carrying both
// would double the transfer for no benefit and let the two disagree.
return new Offloaded(new byte[0], Optional.of(reference));
```
`Offloaded` record가 양방향 방어 복사를 한다(생성자 `payload.clone()`, 접근자 `payload.clone()`) — `EncodedMessage`(schema-api)·`OutboxRecord`(reliability-api)와 같은 패턴이다.
**`ClaimCheckStore.delete`가 이 leaf에서 호출되지 않는다.** 인터페이스에 선언돼 있고 publisher가 의도적으로 안 부른다("Nothing here deletes on failure"). 보존 sweep이 부를 것을 전제하는데 그 sweep이 이 leaf에 없다.
### 4.3 `ClaimCheckIntegrityGuard` — 세 검사, 전부 fail-closed
| 순서 | 검사 | 코드 |
|---:|---|---|
| 1 | `reference.isExpired(now)` | `CLAIM_CHECK_EXPIRED` |
| 2 | `payload.length != reference.sizeBytes()` | `CLAIM_CHECK_SIZE_MISMATCH` |
| 3 | `sha256(payload) != reference.sha256()` | `CLAIM_CHECK_DIGEST_MISMATCH` |
```java
// :19-22
* <p>Both checks fail closed. An expired reference is reported before the fetch, because a
* not-found from the store is ambiguous between "reaped" and "never written". A digest mismatch is
* reported as validation rather than deserialization, because the bytes are not corrupt JSON they
* are the wrong bytes.
```
크기 검사가 digest보다 먼저인 것이 합리적이다 — 크기 불일치는 SHA-256 계산 없이 즉시 판정된다.
`sha256(byte[])``HexFormat.of().formatHex(...)`**소문자** hex를 만든다. `ClaimCheckReference`의 정규식이 `[a-f0-9]{64}`이므로 두 쪽이 맞는다.
`verify`가 검증된 payload의 **복사본**을 반환한다.
### 4.4 `ClaimCheckResolver` — 만료를 fetch 전에 본다
```java
if (claimCheck.isExpired(now)) {
// Checked before fetching. A store that still returns the object past its retention would
// otherwise hide a misconfiguration until the day the sweep caught up.
throw new MessageValidationException("CLAIM_CHECK_EXPIRED", ...);
}
```
**저장소가 아직 반환하더라도 거절한다.** 보존 sweep이 늦게 도는 저장소에서 잘못된 설정이 숨는 것을 막는다.
`fetch``null``CLAIM_CHECK_NOT_FOUND`로 번역하고 메시지가 두 원인을 나열한다 — "it was either reaped early or never written".
**예외 승격이 코드 접미사로 판정된다.**
```java
} catch (MessageValidationException validation) {
// A size or digest mismatch is a poison message, not a validation failure to be retried:
// fetching the same key again returns the same wrong bytes.
if (validation.failure().code().endsWith("_MISMATCH")) {
throw new ClaimCheckIntegrityException(
validation.failure().code(), validation.failure().sanitizedMessage());
}
throw validation;
}
```
`endsWith("_MISMATCH")`**문자열 접미사로 분기한다.** guard가 코드 이름을 바꾸거나 `_MISMATCH`로 끝나는 다른 코드를 추가하면 분류가 조용히 달라진다. §17.
### 4.5 `ClaimCheckIntegrityException` — 카테고리가 `POISON_MESSAGE`
```java
// :12-18
* <p>Not retryable. A digest mismatch means the object at that key is not the object the producer
* wrote the key was reused, the object was overwritten, or something truncated it and fetching
* it again returns the same wrong bytes. Retrying would only delay the dead-letter.
*
* <p>Deliberately distinct from "the object is gone". An expired claim check is an operational
* problem with a known cause and a known fix; a digest mismatch means something wrote data nobody
* expected, and the two must not be diagnosed as one.
```
`FailureCategory.POISON_MESSAGE`, `retryable = false`. `messaging-core-api``FailureDescriptor.defaultRetryable``POISON_MESSAGE`를 false로 두는 것과 일치한다.
**이 예외가 `MessagingException`을 확장하는 저장소 내 두 곳 중 하나다**(다른 하나는 core-api 자신의 23개). `analysis/messaging/messaging-core-api.md` §12.1(b)가 그 사실을 관측했다.
---
## 5. 주요 실행 경로
**발행:** `publisher.offload(encodedPayload)` → 문턱 이하면 인라인 → 초과면 `store.put``Offloaded(빈 바이트, reference)`
**소비:** `resolver.resolve(inline, reference, now)` → reference 없으면 인라인 → 만료 확인 → `store.get` → null이면 NOT_FOUND → `guard.verify`(만료·크기·digest) → `_MISMATCH``ClaimCheckIntegrityException`
두 경로 모두 production에서 호출되지 않는다(§12.1).
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 카테고리 | retryable | 조건 |
|---|---|---|:---:|---|
| `CLAIM_CHECK_RETENTION_TOO_SHORT` | `MessagingConfigurationException` | `CONFIGURATION` | false | 정책 생성 시 |
| `CLAIM_CHECK_EXPIRED` | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 만료 |
| `CLAIM_CHECK_NOT_FOUND` | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 객체 없음 |
| `CLAIM_CHECK_SIZE_MISMATCH` | `ClaimCheckIntegrityException` | **`POISON_MESSAGE`** | false | 크기 불일치 |
| `CLAIM_CHECK_DIGEST_MISMATCH` | `ClaimCheckIntegrityException` | **`POISON_MESSAGE`** | false | digest 불일치 |
**분류가 두 단계로 정확하다.** 만료·부재는 운영 문제(`PERMANENT_BUSINESS`), 크기·digest 불일치는 오염(`POISON_MESSAGE`). 두 예외 클래스와 두 카테고리가 그 구분을 담는다.
`ClaimCheckIntegrityGuard.sha256``NoSuchAlgorithmException``IllegalStateException("Java runtime does not provide SHA-256")`으로 감싼다 — 복구 불가능한 환경 문제이므로 메시지 실패가 아니다.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
`ClaimCheckPublisher`·`ClaimCheckResolver`는 final 필드만 갖는 불변 객체다. `ClaimCheckIntegrityGuard`는 상태가 없고 `ClaimCheckResolver`가 인스턴스를 필드로 하나 만든다.
`MessageDigest.getInstance("SHA-256")`**호출마다** 새 인스턴스를 만든다 — `MessageDigest`는 스레드 안전하지 않으므로 이것이 옳다. 재사용했다면 동시 호출이 서로의 상태를 오염시킨다.
`ClaimCheckStore` 구현의 스레드 안전성 요구는 인터페이스 javadoc에 없다.
수명주기 참여 없음.
---
## 8. 설정·기능 플래그·환경 차이
| 상수/기본값 | 값 |
|---|---|
| `ClaimCheckPolicy.DEFAULT_THRESHOLD_BYTES` | 262,144 (1 MiB의 1/4) |
| `ClaimCheckPolicy.defaults()` | 문턱 256 KiB, 보존 3일, 브로커 보존 1일, 재전달 창 1일 |
설정 파일 없음. 모든 값이 생성자 인자다.
---
## 9. 퍼시스턴스/외부 시스템 세부
`ClaimCheckStore`가 객체 저장소를 가리키는 port다. **구현이 없다** — production에도, 다른 messaging leaf에도.
저장소의 `adapter/outbound/objectstorage` leaf가 후보 구현처이지만 두 leaf가 연결되지 않는다(`messaging-claim-check``allowed_dependencies`에 없고, 반대 방향도 없다).
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-claim-check:test`. **BUILD SUCCESSFUL, 22 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `ClaimCheckIntegrityGuardTest` | 6 | 만료·크기·digest 세 검사 | 실제 저장소 |
| `ClaimCheckResolverTest` | 8 | 인라인 통과, 만료 사전 거절, NOT_FOUND, `_MISMATCH` 승격 | **production 호출 여부** |
| `ClaimCheckRetentionValidatorTest` | 8 | 보존 불변식과 문턱 판정 | — |
`ClaimCheckStore`의 유일한 구현이 `ClaimCheckResolverTest:22``FakeStore`다. 즉 **이 leaf의 테스트가 자기 port의 유일한 구현을 제공한다.**
`ClaimCheckPublisher`를 겨냥한 테스트 클래스가 **없다.** 오프로드 결정·객체 선기록 순서·`Offloaded`의 방어 복사가 이 레인에서 검증되지 않는다. 세 테스트 클래스 이름에 publisher가 없다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-reliability-api"]` |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| vendor `api` 규칙 | 벤더 의존성 0 |
| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |
| ArchUnit | 전용 규칙 없음 |
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt`.
### 12.1 Public surface reachability
**여섯 타입 전부 leaf 밖 참조 0이다.**
| 타입 | leaf 밖 |
|---|---:|
| `ClaimCheckStore` | 0 |
| `ClaimCheckPolicy` | 0 |
| `ClaimCheckPublisher` | 0 |
| `ClaimCheckResolver` | 0 |
| `ClaimCheckIntegrityGuard` | 0 |
| `ClaimCheckIntegrityException` | 0 |
`ClaimCheckStore` 구현은 테스트 fake 하나뿐이고, 세 클래스의 생성이 leaf 밖에서 0건이다.
**그런데 이 leaf는 배포 아티팩트에 실린다.**
```
messaging-claim-check runtime_memberships=['app-bootstrap']
messaging-spring-boot-starter runtime_memberships=['app-bootstrap']
starter deps include claim-check: True
```
`messaging-cloudevents`와 같은 조합이다. 형제 비교:
| leaf | 소비자 | membership | 정합 |
|---|:---:|---|---|
| `messaging-schema-avro` | 0 | `[]` | o |
| `messaging-schema-protobuf` | 0 | `[]` | o |
| `messaging-kafka-share-experimental` | 0 | `[]` | o |
| **`messaging-cloudevents`** | **0** | **`["app-bootstrap"]`** | **x** |
| **`messaging-claim-check`** | **0** | **`["app-bootstrap"]`** | **x** |
**"싣고 쓰지 않는" leaf가 둘이다.** 오늘 실행되는 코드가 없으므로 사고는 아니다.
**한 가지 정황이 이 leaf를 다르게 만든다.** `messaging-policy``PayloadPolicy``claimCheckThresholdBytes` 필드를 갖고, `DestinationProfileValidator`가 그 값을 검사한다(`:49`). 즉 **목적지 프로파일은 claim check를 상정하고 있는데 그 상정을 실현하는 코드가 배선되지 않았다.** payload가 문턱을 넘어도 오프로드되지 않고, `PayloadLimitGuard`가 상한 초과로 거절한다 — `MessageTooLargeException("PAYLOAD_LIMIT_EXCEEDED", "... use claim check")`. **에러 메시지가 존재하지 않는 경로를 권한다.**
### 12.2 Conditional sibling comparison
Spring 주석 0개, bean 없음. starter가 이 leaf의 타입으로 만드는 bean도 없다.
`messaging-reliability-api`의 세 port 중 둘(`OutboxRepository`, `InboxRepository`)은 구현 leaf와 starter bean을 갖고 `ClaimCheckStore`는 둘 다 없다 — 같은 계열의 port 셋 중 하나만 미완이다.
### 12.3 Duplicate mechanism sweep
**(a) claim check 문턱이 두 곳에 있고 서로를 모른다**
| 위치 | 필드 | 검사 |
|---|---|---|
| `messaging-policy` `PayloadPolicy` | `claimCheckThresholdBytes` | `DestinationProfileValidator:49``<= maxBytes` 확인 |
| 이 leaf `ClaimCheckPolicy` | `thresholdBytes` | 생성자가 `>= 1` 확인 |
**두 값을 대조하는 코드가 없다.** 목적지 프로파일이 문턱 512 KiB를 선언하고 `ClaimCheckPolicy`가 256 KiB를 쓰면 둘 다 유효한 구성이고 실제 동작은 후자를 따른다. 오늘은 후자가 배선되지 않아 전자만 존재하므로 충돌하지 않는다.
**(b) 보존/시간 관계 규칙이 두 곳에 있고 강제 강도가 다르다**
| 규칙 | 위치 | 강제 |
|---|---|---|
| claim check 보존 ≥ 브로커 보존 + 재전달 창 | `ClaimCheckPolicy` 생성자 | **강제됨** |
| inbox 보존 > 브로커 최대 재전달 창 | `InboxRepository` javadoc | **문서만** |
같은 종류의 규칙(“보존이 재전달 창보다 길어야 한다”)을 한 leaf는 생성자로 막고 다른 leaf는 문서로만 둔다. `analysis/messaging/messaging-reliability-api.md` §17이 후자를 소유한다.
**(c) digest 계산이 저장소에 여럿 있는가**
`MessageDigest.getInstance("SHA-256")`을 쓰는 곳이 저장소에 여럿 있다(objectstorage, fileserver 등). 그러나 책임이 다르고(무결성 검증 vs 콘텐츠 주소화) runtime eligibility가 겹치지 않는다. 중복 경쟁 아님.
**(d) `_MISMATCH` 접미사 분기**
`ClaimCheckResolver.verify``validation.failure().code().endsWith("_MISMATCH")`로 예외를 승격한다. `ClaimCheckIntegrityGuard`의 코드 셋 중 둘이 그 접미사를 갖고 하나(`CLAIM_CHECK_EXPIRED`)가 갖지 않는다. **문자열 규약이 두 클래스 사이의 계약이 되어 있고 그것이 어디에도 선언되지 않았다.** §17.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `ClaimCheckPolicy` javadoc: 문턱이 "a quarter of the portable payload limit" | 262,144 = 1,048,576 / 4 | **일치** |
| `ClaimCheckPublisher` javadoc: 실패 시 삭제하지 않고 보존 sweep이 회수 | 이 leaf에 sweep 없음 | **미실현** |
| `ClaimCheckIntegrityGuard` javadoc: 두 검사가 fail closed | 세 검사 전부 예외 | **일치**(검사가 셋인데 javadoc은 "Both") |
| `PayloadLimitGuard` 에러 메시지: "use claim check" | claim check 경로 미배선 | **불일치** |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
세 번째가 작은 표현 drift다 — javadoc이 "Both checks fail closed"라고 하는데 `verify`는 만료·크기·digest 셋을 검사한다. 크기 검사가 나중에 추가된 것으로 보인다.
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
이 leaf의 javadoc은 이전 결함을 서술하지 않는다 — 대신 **막으려는 사고**를 서술한다.
| 위치 | 막으려는 것 |
|---|---|
| `ClaimCheckPublisher` | 발행 후 저장 순서 → 존재하지 않는 객체의 참조를 소비자가 받음. "rare in a test and routine under load" |
| `ClaimCheckPublisher` | 실패 시 즉시 삭제 → 모호한 발행의 payload를 지움 |
| `ClaimCheckResolver` | 검증 없는 fetch → 잘못된 키가 완벽히 디코딩되는 다른 객체를 반환 |
| `ClaimCheckResolver` | fetch 후 만료 확인 → sweep이 늦은 저장소에서 오설정이 숨음 |
| `ClaimCheckPolicy` | 짧은 보존 → 메시지와 무관한 이유로 dead-letter |
| `ClaimCheckIntegrityException` | 만료와 불일치를 한 진단으로 합침 |
**"rare in a test and routine under load"**가 이 저장소 전반의 주제다 — `messaging-observability`의 카디널리티, `messaging-security`의 회전 경합, `messaging-transport-spi`의 자원 누수가 같은 형태다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` §A·§B | 여섯 타입 참조 0, `ClaimCheckStore` 구현이 테스트 fake뿐, membership과 starter 의존, 두 문턱과 검사 위치 | 정적 검색 |
| EVD-291 | command | `./gradlew :messaging:messaging-claim-check:test --rerun-tasks` | BUILD SUCCESSFUL, 22 / 0 / 0 | 저장소가 fake. publisher 미검증 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 두 시스템이 drift한다는 위협 모델 — `ClaimCheckIntegrityGuard` javadoc
- 검증이 선택 불가인 이유 — `ClaimCheckResolver` javadoc
- 저장이 발행보다 먼저인 이유 — `ClaimCheckPublisher` javadoc
- 실패 시 삭제하지 않는 이유 — 같은 javadoc
- payload와 참조를 함께 나르지 않는 이유 — 인라인 주석
- 만료를 fetch 전에 보는 이유 — `resolve` 인라인 주석
- 보존 규칙과 그것을 생성자가 강제하는 이유 — `ClaimCheckPolicy` javadoc
- 문턱과 목적지 상한이 다른 이유 — 같은 javadoc
- digest 불일치가 재시도 불가인 이유, 만료와 구분하는 이유 — `ClaimCheckIntegrityException` javadoc
- `_MISMATCH` 승격이 poison message인 이유 — `verify` 인라인 주석
**추론**
- 배선되지 않은 것이 미완인지 확장점인지 → **미상**. `ClaimCheckStore` 구현이 없다는 관측만 있다.
- `_MISMATCH` 접미사 규약이 의도인지 → **미상**. 선언된 곳이 없다.
- javadoc의 "Both checks"가 세 검사가 되기 전 표현인지 → **추론**.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 6개 타입 418줄 전문의 계약
- 22개 테스트가 통과하고 무엇을 단언하는지, 그리고 `ClaimCheckPublisher`가 미검증이라는 것
- 여섯 타입 전부 leaf 밖 참조 0이고 `ClaimCheckStore` 구현이 테스트 fake뿐이라는 것
- `runtime_memberships``["app-bootstrap"]`이라 배포 아티팩트에 실린다는 것
- `PayloadLimitGuard`의 에러 메시지가 배선되지 않은 경로를 권한다는 것
- 문턱이 두 곳에 있고 대조되지 않는다는 것
**확인하지 못한 것**
- **`ClaimCheckStore`를 구현할 계획이 있는지.** `adapter/outbound/objectstorage`가 후보이지만 두 leaf가 registry에서 연결되지 않는다.
- 보존 sweep을 누가 도는지 — `ClaimCheckStore.delete`의 호출자가 없다.
- 실제 객체 저장소에서 `store.get`이 만료 후에도 반환하는지 — `resolve`의 사전 만료 검사가 그 경우를 상정한다.
- 두 문턱이 실제 배포에서 어긋나는지 — 한쪽이 배선되지 않아 관측 불가.
---
## 17. 손볼 것
### P2 — 배포 아티팩트가 싣지만 아무도 부르지 않고, 다른 곳의 에러 메시지가 이 경로를 권한다
- **사실.** 여섯 타입 전부 leaf 밖 참조 0, `ClaimCheckStore` 구현이 테스트 fake뿐, 조립 0건. 그런데 `runtime_memberships``["app-bootstrap"]`이고 starter의 `allowed_dependencies`에 포함된다. 그리고 `messaging-policy``PayloadLimitGuard`가 상한 초과 payload를 거절하며 `"payload of %d bytes exceeds the %d byte limit for %s; use claim check"`라고 안내한다.
- **근거.** `evidence/raw/290` §A. `PayloadLimitGuard.java:46-49`.
- **왜 문제인가.** 운영자가 상한 초과 오류를 보고 안내대로 claim check를 켜려 해도 켤 것이 없다 — 저장소 구현도, bean도, 오프로드를 부르는 발행 경로도 없다. 그리고 `DestinationProfile``claimCheckThresholdBytes`를 선언하고 검증까지 하므로 **설정 표면은 존재한다.** 설정할 수 있고 아무 효과가 없는 값이다.
- **확인 방법.** `evidence/raw/290` §A 재실행. `git grep -n 'use claim check' -- src`.
- **후보.** (a) `ClaimCheckStore` 구현(objectstorage 어댑터 경유)과 발행 경로 배선. (b) 배선 전까지 membership을 `[]`로 되돌리고 `PayloadLimitGuard` 메시지에서 안내를 뺀다. (c) 미완임을 `support-matrix.md`에 표시한다.
- **다음 단계.** **CASE 후보.** `messaging-cloudevents` §17의 "싣고 쓰지 않는다"와 같은 계열이지만, 여기서는 **다른 컴포넌트가 이 경로를 권한다**는 점이 추가된다.
### P3 — claim check 문턱이 두 곳에서 독립적으로 정해진다
- **사실.** `messaging-policy``PayloadPolicy.claimCheckThresholdBytes`(목적지별, `DestinationProfileValidator:49`가 검사)와 이 leaf의 `ClaimCheckPolicy.thresholdBytes`(전역). 두 값을 대조하는 코드가 없다.
- **근거.** `evidence/raw/290` §B.
- **왜 문제인가.** 배선되면 실제 동작은 후자를 따르고 전자는 선언만 남는다. 목적지별로 다른 문턱을 두려던 설계가 전역 정책 하나에 덮인다.
- **확인 방법.** 두 필드와 검증기 확인.
- **후보.** `ClaimCheckPublisher`가 목적지 프로파일의 값을 읽거나, `PayloadPolicy`에서 그 필드를 제거한다.
- **다음 단계.** **REFERENCE 후보**(같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다).
### P3 — 예외 승격이 에러 코드 문자열 접미사에 의존한다
- **사실.** `ClaimCheckResolver.verify``validation.failure().code().endsWith("_MISMATCH")``ClaimCheckIntegrityException` 승격을 결정한다. `ClaimCheckIntegrityGuard`의 세 코드 중 둘이 그 접미사를 갖는다.
- **근거.** `ClaimCheckResolver.java:84`.
- **왜 문제인가.** 두 클래스 사이의 계약이 **문자열 명명 규약**이고 어디에도 선언되지 않았다. guard가 코드를 바꾸면(예: `CLAIM_CHECK_DIGEST_INVALID`) 승격이 조용히 멈추고 poison message가 `PERMANENT_BUSINESS`로 분류된다 — 재시도 정책이 달라진다.
- **확인 방법.** `git grep -n '_MISMATCH' -- src/messaging/messaging-claim-check`
- **후보.** guard가 두 종류의 예외를 직접 던지거나, 코드 집합을 상수로 선언하고 그것과 비교한다.
- **다음 단계.** **CASE 후보 + REFERENCE 후보**(타입 사이의 계약을 문자열 명명 규약으로 표현하지 않는다).
### P3 — `ClaimCheckPublisher`가 이 leaf의 테스트에 등장하지 않는다
- **사실.** 세 테스트 클래스가 guard·resolver·policy를 겨냥한다. publisher 전용 테스트가 없다.
- **근거.** `find src/test -name '*Test.java'` → 셋.
- **왜 문제인가.** publisher가 소유한 결정 셋이 미검증이다 — 오프로드 판정(`shouldOffload`), 오프로드 시 payload를 비우는 것, `Offloaded`의 양방향 방어 복사. 특히 "저장이 발행보다 먼저"라는 순서는 publisher의 계약인데 그것을 확인하는 테스트가 없다.
- **확인 방법.** 세 테스트 클래스 이름 확인.
- **후보.** `ClaimCheckPublisherTest`를 추가한다.
- **다음 단계.** **REFERENCE 후보**(leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다).
### P3 — 보존 sweep이 없다
- **사실.** `ClaimCheckStore.delete`가 선언돼 있고 이 leaf에서 호출되지 않는다. `ClaimCheckPublisher` javadoc이 "the retention sweep reclaims it"이라고 그 존재를 전제한다.
- **근거.** `git grep -n 'delete(' -- src/messaging/messaging-claim-check` → 인터페이스 선언만.
- **왜 문제인가.** 실패한 발행이 남긴 객체를 회수할 주체가 없다. 저장소 자체의 lifecycle 정책(예: S3 object expiration)이 대신할 수 있으나 `ClaimCheckPolicy.retention`이 그것과 연결되지 않는다.
- **확인 방법.** `delete` 호출자 검색.
- **후보.** sweep 작업을 만들거나, 저장소 lifecycle에 위임함을 javadoc에 명시한다.
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 `ClaimCheckStore` 구현 계획에 걸린다.
### 확인된 설계(문제 아님)
- 보존 규칙(보존 ≥ 브로커 보존 + 재전달 창)을 생성자가 강제하는 것
- 문턱과 목적지 상한을 분리하고 그 이유를 적은 것
- 객체를 발행보다 먼저 저장하는 순서
- 실패 시 삭제하지 않아 모호한 발행의 payload를 지키는 것
- 오프로드 시 payload를 아예 비워 둘이 어긋날 여지를 없앤 것
- 만료를 fetch 전에 확인해 저장소의 늦은 sweep이 오설정을 숨기지 않게 하는 것
- 크기 검사를 digest보다 먼저 두는 것
- 만료·부재와 크기·digest 불일치를 다른 카테고리로 분류하는 것
- `MessageDigest`를 호출마다 새로 만드는 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MCC-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 2개, memberships `["app-bootstrap"]` | 선언 |
| MCC-002 | build | `messaging-claim-check/build.gradle` | same | 벤더 의존성 0 | — |
| MCC-003 | code | `.../claimcheck/ClaimCheckPolicy.java` | same | §4.1 보존 불변식과 문턱 | — |
| MCC-004 | code | `.../claimcheck/ClaimCheckPublisher.java` | same | §4.2 순서·미삭제·빈 payload | 전용 테스트 없음 |
| MCC-005 | code | `.../claimcheck/ClaimCheckIntegrityGuard.java` | same | §4.3 세 검사 | — |
| MCC-006 | code | `.../claimcheck/ClaimCheckResolver.java` | same | §4.4 사전 만료 확인, 접미사 승격 | 접미사 의존(§17) |
| MCC-007 | code | `.../claimcheck/{ClaimCheckStore,ClaimCheckIntegrityException}.java` | same | port 계약, POISON_MESSAGE 분류 | 구현 없음 |
| MCC-008 | test | 3 클래스 / 22 테스트 | same | §10 표 | fake 저장소. publisher 미검증 |
| MCC-009 | cross-leaf code | `messaging-policy/.../PayloadLimitGuard.java:46-49` | same | "use claim check" 안내 | 해당 leaf SSOT가 소유 |
| MCC-010 | cross-leaf code | `messaging-policy/.../PayloadPolicy.java:14`, `DestinationProfileValidator.java:49` | same | 두 번째 문턱과 그 검증 | 해당 leaf SSOT가 소유 |
| EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` | same | §12.1·§12.3 | 정적 검색 |
| EVD-291 | command | `./gradlew :messaging:messaging-claim-check:test --rerun-tasks` | same | 22 / 0 / 0 | — |
@@ -0,0 +1,594 @@
# messaging-cloudevents 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-cloudevents`
> SSOT owner: `messaging-cloudevents`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-cloudevents`
- canonical state `analysisFile`: `analysis/messaging/messaging-cloudevents.md`
- source path: `src/messaging/messaging-cloudevents`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]`
- registry `runtime_memberships`: **`["app-bootstrap"]`**
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 3 |
| production LOC | 228 |
| 패키지 | 1 (`dev.caskeleton.messaging.cloudevents`) |
| test 파일 | 1 |
| test 메서드(실행 확인) | 7 |
| 외부 의존성 | 2 (`cloudevents-api:4.0.1` **api**, `cloudevents-core:4.0.1` implementation) |
세 타입: `CloudEventMapper`(인터페이스), `DefaultCloudEventMapper`(구현), `CloudEventExtensions`(확장 속성 이름 4개).
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `.../cloudevents/CloudEventMapper.java` | 1 | `FULL_READ` | 33줄 전문 |
| `.../cloudevents/DefaultCloudEventMapper.java` | 1 | `FULL_READ` | 171줄 전문 |
| `.../cloudevents/CloudEventExtensions.java` | 1 | `FULL_READ` | 24줄 전문 |
| `src/test/java/**` | 1 | `FULL_READ` | 162줄 전문 |
| `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 |
| `gradle.lockfile` | 1 | `FULL_READ` | cloudevents 좌표 2건 확인 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
플랫폼 봉투와 CloudEvents 1.0.2 사이의 양방향 매퍼.
적용 범위를 인터페이스 javadoc이 한정한다.
```java
// CloudEventMapper.java:11-13
* <p>Offered for domain and integration events only. Commands and work items are not forced through
* CloudEvents: they are internal contracts where the interoperability the specification buys does
* not pay for the attributes it requires.
```
의존성 선언에 이 저장소에서 가장 자세한 근거 주석이 붙어 있다.
```groovy
// api, because CloudEventMapper's public signatures return io.cloudevents.CloudEvent.
//
// Declared `implementation`, the type appeared in this module's public API while the
// dependency was hidden from consumers: an adopter calling the documented method could not
// name its return type without adding CloudEvents to their own build, and Gradle gave them no
// hint why. A type in a public signature is part of the artifact's contract.
api 'io.cloudevents:cloudevents-api:4.0.1'
implementation 'io.cloudevents:cloudevents-core:4.0.1'
```
**둘의 scope가 다른 것이 정확하다.** `cloudevents-api`(`CloudEvent`, `CloudEventData`)는 public 시그니처에 나오므로 `api`, `cloudevents-core`(`CloudEventBuilder`, `BytesCloudEventData`)는 구현 안에서만 쓰이므로 `implementation`이다. `src/messaging/CLAUDE.md:40-43`의 게이트가 잡는 구분을 두 좌표로 나눠 지켰다.
**이 leaf의 위치가 형제들과 다르다.** `runtime_memberships``["app-bootstrap"]`이다 — 배포 아티팩트가 싣는다. 그런데 소비자가 하나도 없다(§12.1). Avro·Protobuf는 "싣지도 않고 쓰지도 않는다"로 정합하지만, 이 leaf는 **싣고 쓰지 않는다.**
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `cloudevents-api:4.0.1`(api), `cloudevents-core:4.0.1`(implementation).
나가는 것: `messaging-spring-boot-starter``allowed_dependencies`에 포함된다. 그래서 `app-bootstrap` → starter → 이 leaf 경로로 런타임 classpath에 오른다.
**그러나 어떤 코드도 이 leaf의 타입을 부르지 않는다.** starter의 어느 `@Bean``CloudEventMapper`를 만들지 않고, 어느 클래스도 import하지 않는다(§12.1).
bean 없음(Spring 주석 0개).
---
## 3. 패키지/컴포넌트 지도
```
CloudEventMapper (interface)
├── toCloudEvent(MessageEnvelope<?>, URI) → CloudEvent
└── fromCloudEvent(CloudEvent) → MessageEnvelope<EncodedMessage>
DefaultCloudEventMapper (구현)
├── toCloudEvent : occurredAt 필수, payload는 이미 인코딩된 것만
├── fromCloudEvent : time 필수, schemaversion 확장 필수
├── stringExtension / intExtension
└── producerFrom(URI) : 마지막 세그먼트를 producer id로
CloudEventExtensions (상수 4개)
correlationid · causationid · schemaversion · tenantcontext
```
`CloudEventExtensions`의 javadoc이 이름이 봉투 필드명과 다른 이유를 적는다 — "CloudEvents requires extension names to be lowercase alphanumeric".
---
## 4. 계약·불변식·상태 모델
### 4.1 매핑 표
**봉투 → CloudEvent**
| 봉투 | CloudEvent | 비고 |
|---|---|---|
| `messageId.value()` | `id` (String) | UUID 문자열 |
| — | `source` | 호출자가 인자로 준다 |
| `messageType.value()` | `type` | |
| `occurredAt` | `time` | **필수** — 없으면 거절 |
| `contentType.value()` | `datacontenttype` | |
| `schemaVersion.value()` | 확장 `schemaversion` | 문자열로 |
| `correlationId` | 확장 `correlationid` | 있을 때만 |
| `causationId` | 확장 `causationid` | 있을 때만 |
| `tenantContext.tenantId()` | 확장 `tenantcontext` | 있을 때만 |
| `payload``schemaReference.schemaUri` | `dataschema` | 있을 때만 |
| `payload` | `data` | `EncodedMessage` 또는 `byte[]`만 |
**CloudEvent → 봉투**
| CloudEvent | 봉투 | 비고 |
|---|---|---|
| `id` | `messageId` | `UUID.fromString``MessageId`(**UUIDv7 강제**) |
| `type` | `messageType` | |
| `time` | `producedAt` **및** `occurredAt` | 같은 값이 둘에 들어간다 |
| `source` | `producer` | 마지막 세그먼트만 |
| 확장 `schemaversion` | `schemaVersion` | **필수** |
| 확장 `correlationid` | `correlationId` | |
| 확장 `causationid` | `causationId` | `UUID.fromString``MessageId` |
| 확장 `tenantcontext` | `tenantContext` | |
| `datacontenttype` (없으면 `application/json`) | `contentType` | |
| `data` (없으면 `new byte[0]`) | `EncodedMessage` | |
| — | `partitionKey`, `orderingKey` | 항상 empty |
| — | `traceContext` | 항상 `TraceContext.none()` |
| — | `headers` | 항상 `MessageHeaders.empty()` |
### 4.2 두 가지 명시적 매핑 결정
```java
// DefaultCloudEventMapper.java:31-35
* <p>Two mapping decisions are deliberate. An event without {@code occurredAt} is rejected rather
* than defaulted to the production instant, because {@code time} is read downstream as when the
* fact happened, not when the platform got around to serialising it. And an event with no data maps
* to an envelope with empty bytes, never to a Kafka null value: a tombstone deletes a key, and
* inventing one from an absent CloudEvent payload would turn an empty notification into a deletion.
```
두 번째는 `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로 강제한 이유가 여기서 실제 매핑 규칙으로 나타난다.
### 4.3 `producerFrom`: 무한 URI를 유한 이름으로
```java
// :158-163
* <p>The last path or scheme-specific segment is used so that a long URI does not become an
* unbounded producer name, which would leak straight into metric tags.
private static String producerFrom(URI source) {
String text = source.toString();
int separator = Math.max(text.lastIndexOf('/'), text.lastIndexOf(':'));
String candidate =
separator >= 0 && separator + 1 < text.length() ? text.substring(separator + 1) : text;
return candidate.isBlank() ? "unknown" : candidate;
}
```
`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`.
### 4.4 `time`이 두 필드로 복제된다
```java
Instant occurredAt = time.toInstant();
return new MessageEnvelope<>(
..., occurredAt, // producedAt
Optional.of(occurredAt), // occurredAt
...);
```
CloudEvents에는 `time` 하나뿐이므로 봉투의 두 시각(플랫폼이 봉투를 만든 때 / 사실이 일어난 때)을 구분할 수 없다. 같은 값을 넣는 것은 합리적 선택이지만 **정보 손실이 기록되지 않았다** — 왕복 후 `producedAt`은 원래 값이 아니다. 테스트의 왕복 검증(`roundTripsBackToAnEnvelopeWithoutInventingATombstone`)이 `messageId`·`messageType`·`schemaVersion`·`correlationId`·`tenantContext`·`payload`만 비교하고 `producedAt`은 비교하지 않는다. fixture에서 `producedAt``09:15:01Z`, `occurredAt``09:15:00Z`**일부러 다르게** 설정돼 있으므로, 비교했다면 실패했을 것이다.
### 4.5 왕복에서 소실되는 것
`fromCloudEvent`가 항상 비우는 필드가 다섯이다.
| 필드 | 결과 |
|---|---|
| `partitionKey` | `Optional.empty()` |
| `orderingKey` | `Optional.empty()` |
| `traceContext` | `TraceContext.none()` |
| `headers` | `MessageHeaders.empty()` |
| `producedAt` | `occurredAt`으로 덮임 |
**`traceContext`의 소실이 가장 무겁다.** `messaging-core-api``TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 적는다 — "Keeping them on the envelope rather than only in headers means a trace survives an Outbox round trip through the database, where broker headers do not exist yet." CloudEvents 왕복은 그 보존을 깨뜨린다. CloudEvents는 분산 추적 확장(`traceparent`를 distributed-tracing extension으로)을 정의하는데 이 매퍼는 그것을 읽지도 쓰지도 않는다.
`toCloudEvent``traceContext`·`headers`·`partitionKey`·`orderingKey`를 쓰지 않는다. 즉 소실은 양방향이다.
### 4.6 `id`의 UUIDv7 강제 — 이 leaf에서 가장 중요한 계약
```java
new MessageId(UUID.fromString(event.getId()))
```
CloudEvents 1.0.2는 `id`**"Type: String; Constraints: REQUIRED, MUST be a non-empty string"**으로 정의한다. UUID 형식 요구가 없다.
`MessageId`(messaging-core-api)는 UUID이면서 **version 7 · variant 2**를 요구한다.
두 계약이 만나는 지점의 실제 동작을 런타임 probe로 확인했다(`evidence/raw/273-cloudevents-inbound-id-probe.txt`).
```
--- spec-conformant opaque string id
id = A234-1234-1234
result = REJECTED
thrown = java.lang.IllegalArgumentException
message = Invalid UUID string: A234-1234-1234
is a MessagingException (carries FailureDescriptor) = false
--- UUIDv4 id
id = 9c1f1f2e-6a1a-4d3b-8f0e-2b0d5b2f6c11
result = REJECTED
thrown = java.lang.IllegalArgumentException
message = a message identity is UUIDv7 (time-ordered); this is version 4
is a MessagingException (carries FailureDescriptor) = false
--- UUIDv7 id (what this platform mints)
result = ACCEPTED
```
`A234-1234-1234`는 CloudEvents 명세 자신의 예시가 쓰는 id다.
**의도는 문서화돼 있다.** 테스트에 주석이 있다.
```java
// CloudEventMappingTest.java:90-91
// A v7 id: MessageId enforces the version it documents, so a v4 arriving from a foreign
// producer is refused here exactly as it would be on the wire.
```
즉 "외부 producer의 v4를 거절한다"는 것은 알고 내린 결정이다. 그러나 두 가지가 그 결정과 별개다.
1. **비UUID id는 명세 위반이 아니다.** v4 거절은 정책 선택이지만, `A234-1234-1234` 거절은 CloudEvents 상호운용성 자체를 포기하는 것이다. 그리고 그 경우는 어디에도 언급되지 않았다.
2. **실패가 플랫폼 어휘 밖이다.** 이 매퍼의 다른 모든 검증 실패는 `MessageValidationException`(→ `FailureDescriptor`, `PERMANENT_BUSINESS`, 안정 코드)이다. id 실패만 raw `IllegalArgumentException`이다. 분류도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드 집계가 이 실패를 못 본다.
§17에서 다룬다.
### 4.7 `schemaversion` 확장이 필수다
```java
private static int intExtension(CloudEvent event, String name) {
return stringExtension(event, name)
.map(value -> { try { return Integer.valueOf(value); }
catch (NumberFormatException e) {
throw new MessageValidationException("CLOUDEVENT_SCHEMA_VERSION_INVALID", ...); } })
.orElseThrow(() -> new MessageValidationException("CLOUDEVENT_SCHEMA_VERSION_REQUIRED",
"schemaversion extension is required by this profile"));
}
```
에러 메시지가 "**by this profile**"이라고 적어 이것이 명세 요구가 아니라 이 프로파일의 요구임을 밝힌다. 좋은 표현이다 — `id`의 UUIDv7 요구에는 그런 표시가 없다.
이 확장을 쓰지 않는 외부 producer의 CloudEvent는 전부 거절된다. `id`와 합치면 **이 매퍼가 받아들이는 CloudEvent는 사실상 이 플랫폼이 만든 것뿐이다.**
### 4.8 `toCloudEvent`의 payload 계약
```java
if (envelope.payload() instanceof EncodedMessage encoded) { ... }
else if (envelope.payload() instanceof byte[] bytes) { builder.withData(BytesCloudEventData.wrap(bytes.clone())); }
else { throw new MessageValidationException("CLOUDEVENT_PAYLOAD_NOT_ENCODED", ...); }
```
이미 인코딩된 것만 받는다 — 매퍼가 codec 역할을 하지 않는다. `byte[]` 분기에서 `clone()`하는 것도 `EncodedMessage.bytes()`가 이미 복사본을 주는 것과 대칭이다.
---
## 5. 주요 실행 경로
**나가는 방향:** `occurredAt` 확인(없으면 거절) → `CloudEventBuilder.v1()`에 id·source·type·time·datacontenttype·schemaversion → 선택 확장 셋 → payload 종류 판정 → `dataschema`(있을 때) → `build()`
**들어오는 방향:** `time` 확인(없으면 거절) → `datacontenttype`(기본 `application/json`) → `data`(없으면 빈 배열) → `MessageId`·`MessageType`·`SchemaVersion`·`ProducerId`·확장 셋 → `MessageEnvelope` 조립
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 방향 | 조건 |
|---|---|---|---|
| `CLOUDEVENT_TIME_REQUIRED` | `MessageValidationException` | 양방향 | `occurredAt` 없음 / `time` 없음 |
| `CLOUDEVENT_PAYLOAD_NOT_ENCODED` | `MessageValidationException` | 나가는 | payload가 `EncodedMessage``byte[]`도 아님 |
| `CLOUDEVENT_SCHEMA_VERSION_REQUIRED` | `MessageValidationException` | 들어오는 | 확장 없음 |
| `CLOUDEVENT_SCHEMA_VERSION_INVALID` | `MessageValidationException` | 들어오는 | 확장이 정수가 아님 |
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `id`가 UUID가 아니거나 v7이 아님 |
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `causationid`가 UUID가 아니거나 v7이 아님 |
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `type``MessageType` 제약 위반(240바이트·제어문자) |
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `correlationid`가 160바이트 초과 |
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `tenantcontext`가 슬러그 패턴 위반 |
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | 유도된 producer 이름이 120바이트 초과 또는 제어문자 |
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `datacontenttype`이 미디어 타입 문법 위반 |
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `schemaversion`이 0 이하 |
**분류된 실패 넷, 분류되지 않은 실패 여덟.** 매퍼가 직접 던지는 것은 전부 `MessageValidationException`이지만, 값 객체 생성자에 위임한 검증은 전부 raw `IllegalArgumentException`이다. `fromCloudEvent`는 **외부에서 온 데이터**를 다루는 유일한 진입점인데, 그 진입점의 실패 대부분이 플랫폼 실패 어휘 밖이다.
`messaging-core-api``FailureDescriptor` 설계 전체가 "예외 클래스로 분기하지 말고 선언된 분류로 판단하라"였다. 이 경로는 그 분류를 만들지 않는다.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
`DefaultCloudEventMapper`**상태가 없다** — 필드가 `SPEC_CONTENT_TYPE_FALLBACK` 상수 하나뿐이고 모든 메서드가 인자만 쓴다. 스레드 안전하다. 다만 그 사실이 javadoc에 적혀 있지 않다.
`CloudEventExtensions`는 상수 홀더이고 private 생성자를 갖는다.
`CloudEventBuilder`는 호출마다 새로 만들어진다.
---
## 8. 설정·기능 플래그·환경 차이
설정 없음.
| 상수 | 값 | 위치 |
|---|---|---|
| `SPEC_CONTENT_TYPE_FALLBACK` | `"application/json"` | `DefaultCloudEventMapper.java:39` (private) |
| `CloudEventExtensions.CORRELATION_ID` | `"correlationid"` | public |
| `CloudEventExtensions.CAUSATION_ID` | `"causationid"` | public |
| `CloudEventExtensions.SCHEMA_VERSION` | `"schemaversion"` | public |
| `CloudEventExtensions.TENANT_CONTEXT` | `"tenantcontext"` | public |
CloudEvents 버전은 `4.0.1`로 고정(lockfile 확인). CloudEvents **명세** 버전은 `CloudEventBuilder.v1()`이 고정한다 — javadoc은 1.0.2를 명시한다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-cloudevents:test`. **BUILD SUCCESSFUL, 7 tests, 0 skipped, 0 failures**.
| 테스트 | 증명하는 것 |
|---|---|
| `mapsLogicalIdentityAndExtensions` | id·type·schemaversion·source·datacontenttype |
| `mapsCorrelationAndTenantAsExtensions` | 두 확장 |
| `mapsOccurredAtToEventTime` | `occurredAt``time` |
| `rejectsAnEventEnvelopeWithoutOccurredAt` | 나가는 방향의 `time` 필수 |
| `roundTripsBackToAnEnvelopeWithoutInventingATombstone` | 왕복 시 6개 필드 보존 |
| `aCloudEventWithNoDataBecomesAnEmptyPayloadNotANullValue` | 빈 data → 빈 바이트(tombstone 아님) |
| `rejectsAnUnencodedPayload` | 인코딩되지 않은 payload 거절 |
**이 레인의 결정적 한계: 모든 입력이 이 플랫폼이 만든 것이다.**
`fromCloudEvent`를 부르는 두 테스트 중 하나는 `mapper.toCloudEvent(original, SOURCE)`의 출력을 되돌리고, 다른 하나는 `MessageId.newId()`로 v7 id를 만들어 CloudEvent를 조립한다. 후자에는 주석이 붙어 있다 — "A v7 id: MessageId enforces the version it documents".
**외부 producer가 만든 CloudEvent를 이 매퍼에 넣는 경로가 한 번도 테스트되지 않았다.** 이 leaf의 존재 이유가 상호운용성인데, 상호운용 방향이 검증 공백이다. §4.6의 probe가 그 공백을 실제로 실행해 본 결과다.
**왕복 검증의 선택적 비교.** `roundTripsBackToAnEnvelopeWithoutInventingATombstone``producedAt`·`traceContext`·`headers`·`partitionKey`·`orderingKey`를 비교하지 않는다. fixture는 `producedAt`(`09:15:01Z`)과 `occurredAt`(`09:15:00Z`)을 다르게 두었으므로, 비교했다면 실패했을 것이다. 테스트 이름이 "roundTrips"인데 실제로는 6개 필드의 부분 보존을 확인한다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` — 편입이 강제됨 |
| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | `cloudevents-api`는 public 시그니처에 등장 → `api`. `cloudevents-core`는 구현 전용 → `implementation`. **통과** |
| ArchUnit | 전용 규칙 없음 |
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/272-schema-family-reachability.txt`, `evidence/raw/273-cloudevents-inbound-id-probe.txt`.
### 12.1 Public surface reachability
| 타입 | leaf 밖 참조 | 판정 |
|---|---:|---|
| `CloudEventMapper` | **0** | 소비자 없음 |
| `DefaultCloudEventMapper` | **0** | 소비자 없음 |
| `CloudEventExtensions` | **0** | 소비자 없음 |
세 타입 모두 `git grep` exit 1.
**형제와 다른 조합이다.**
| leaf | 소비자 | starter codec 등록 | `runtime_memberships` | 정합 |
|---|:---:|:---:|---|---|
| `messaging-schema-json` | 1 | o | `["app-bootstrap"]` | o |
| `messaging-schema-avro` | 0 | x | `[]` | o |
| `messaging-schema-protobuf` | 0 | x | `[]` | o |
| **`messaging-cloudevents`** | **0** | 해당 없음 | **`["app-bootstrap"]`** | **x** |
Avro·Protobuf는 "싣지 않고 쓰지 않는다"로 정합한다. 이 leaf는 **싣고 쓰지 않는다.** `messaging-spring-boot-starter``allowed_dependencies`에 들어 있어 배포 아티팩트가 `cloudevents-api``cloudevents-core` 두 jar를 함께 싣는다.
지금 그것이 사고는 아니다 — 아무도 부르지 않으므로 코드가 실행되지 않는다. 비용은 아티팩트 크기와, "이 의존성이 왜 여기 있지?"를 나중에 조사할 사람의 시간이다.
### 12.2 Conditional sibling comparison
Spring 주석 0개, bean 없음.
**조립 비대칭은 starter 쪽에서 관측된다.** `MessagingCoreAutoConfiguration``JacksonMessageCodec`으로 codec registry를 만드는 `@Bean`을 갖는데, `CloudEventMapper`를 만드는 `@Bean`은 없다. 두 leaf 모두 starter의 의존 목록에 있고 한쪽만 배선된다. 상세는 `messaging-spring-boot-starter` leaf SSOT가 소유한다.
### 12.3 Duplicate mechanism sweep
**(a) 다른 CloudEvents 구현이 있는가 — 없다**
`git grep -l 'io.cloudevents' -- src`가 이 leaf 밖에서 맞추는 것이 없다. 저장소에 CloudEvents를 다루는 코드는 이 세 파일뿐이다.
**(b) 봉투 ↔ 외부 표현 매핑이 다른 곳에도 있는가 — 있다, 그러나 책임이 다르다**
`messaging-kafka``KafkaHeaderMapper`/`KafkaDeliveryMapper`, `messaging-rabbit``RabbitDeliveryMapper`가 봉투를 브로커 표현으로 옮긴다. 그러나 그들은 **transport 매핑**이고 이것은 **interchange 포맷 매핑**이다. runtime eligibility가 겹치지 않는다(브로커 매퍼는 항상 실행되고 이것은 명시 호출이 필요하다).
다만 겹치는 관심사가 하나 있다 — `traceContext`. 브로커 매퍼들은 `traceparent`/`tracestate`/`baggage`를 예약 헤더로 실어 나르고(`ReservedHeaders`가 세 이름을 갖는다), 이 매퍼는 그것을 버린다(§4.5). 같은 봉투 필드를 두 경로가 다르게 취급한다.
**(c) UUID 파싱** — `UUID.fromString`을 통한 외부 문자열 → 식별자 변환이 이 leaf에서 두 곳(id, causationid)에 있고 둘 다 방어가 없다. 저장소의 다른 곳에서는 대체로 값 객체가 그 방어를 갖는다.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| build.gradle 주석: `cloudevents-api`가 public 시그니처에 등장 | `CloudEventMapper`의 두 메서드가 `CloudEvent`를 반환/수취 | **일치** |
| build.gradle 주석: `cloudevents-core`는 구현 전용 | `CloudEventBuilder`·`BytesCloudEventData``DefaultCloudEventMapper` 안에서만 | **일치** |
| 클래스 javadoc: "CloudEvents 1.0.2 compatible profile" | `id` 제약이 명세보다 엄격(§4.6). `schemaversion` 확장 필수 | **부분 불일치** — 아래 참조 |
| `CloudEventMapper` javadoc: domain/integration event 전용 | 코드에 그 구분을 강제하는 것 없음 | **미강제** — 정책 진술이고 게이트가 없다 |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치** — family drift의 사례(`messaging-core-api` §12.4) |
**"compatible profile"의 정확한 의미.** 명세는 `id`를 임의의 비어 있지 않은 문자열로 정의하고, 이 프로파일은 UUIDv7만 받는다. **나가는 방향은 명세를 만족한다**(UUID 문자열은 유효한 id다). **들어오는 방향은 명세 준수 이벤트의 부분집합만 받는다.** javadoc의 "compatible"이 어느 방향을 말하는지 밝히지 않는다. `schemaversion` 에러 메시지는 "required by this profile"이라고 정확히 적는 반면 `id` 제약에는 그런 표시가 없다 — 같은 파일 안에서 표현의 정밀도가 다르다.
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
build.gradle 주석이 이전 결함 하나를 보존한다.
> Declared `implementation`, the type appeared in this module's public API while the dependency was hidden from consumers: an adopter calling the documented method could not name its return type without adding CloudEvents to their own build, and Gradle gave them no hint why. A type in a public signature is part of the artifact's contract.
이것이 `src/messaging/CLAUDE.md:40-43`의 게이트를 만든 사례군에 속한다 — "source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 `build.gradle``api`로 선언했는지 대조". 이 leaf는 그 게이트를 두 좌표로 나눠 통과한 모범 사례다.
코드 주석이 남긴 두 매핑 결정(§4.2)도 실패 이력의 성격을 갖는다 — "defaulted to the production instant"와 "inventing a tombstone"은 하지 않기로 한 것들이다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §D, §E | 세 타입의 소비자 0, membership `["app-bootstrap"]` | 정적 검색 |
| **EVD-273** | **runtime probe** | `evidence/raw/273-cloudevents-inbound-id-probe.txt` | 명세 예시 id·UUIDv4·UUIDv7 세 경우의 실제 결과와 예외 타입, `MessagingException` 여부 | 저장소 소스를 수정하지 않은 별도 probe. 세 id 형태만 확인 |
| EVD-278 | command | `./gradlew :messaging:messaging-cloudevents:test --rerun-tasks` | BUILD SUCCESSFUL, 7 / 0 / 0 | 외부 producer 입력 없음 |
EVD-273의 실행 방법: `:messaging:messaging-cloudevents` test runtimeClasspath에 대해 `/tmp/CeProbe.java`를 컴파일·실행. 저장소 파일은 읽기만 했다.
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- domain/integration event 전용인 이유 — `CloudEventMapper` javadoc
- `occurredAt` 없는 이벤트를 거절하는 이유 — `DefaultCloudEventMapper` javadoc
- 빈 data를 tombstone으로 만들지 않는 이유 — 같은 javadoc
- producer 이름을 마지막 세그먼트로 자르는 이유 — `producerFrom` javadoc
- 확장 이름이 봉투 필드명과 다른 이유 — `CloudEventExtensions` javadoc
- `cloudevents-api``api`여야 하는 이유 — build.gradle 주석
- v4 id를 거절하는 것이 의도라는 것 — 테스트 주석(`CloudEventMappingTest.java:90-91`)
**추론**
- 비UUID id 거절이 의도인지 → **미상**. 테스트 주석은 v4만 언급하고 비UUID는 언급하지 않는다. 두 경우는 다른 판단이다.
- `traceContext`·`headers`를 버리는 것이 의도인지 → **미상**. 어디에도 언급이 없다.
- `producedAt``occurredAt`으로 덮는 것이 의도인지 → **추론**. CloudEvents에 `time`이 하나뿐이라는 제약에서 나온 것으로 보이지만 주석이 없다.
- membership이 있고 소비자가 없는 이유 → **미상**.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 세 타입 228줄 전문의 매핑 계약, 양방향 필드 대응표
- 7개 테스트가 통과하고 무엇을 단언하는지, 그리고 무엇을 비교하지 않는지
- 소비자 0인데 `runtime_memberships``["app-bootstrap"]`이라는 비정합
- **명세 예시 id와 UUIDv4가 분류되지 않은 `IllegalArgumentException`으로 거절된다는 것 — 런타임 probe로 실행 확인**
- 왕복에서 다섯 필드가 소실된다는 것
- `api`/`implementation` 분리가 정확하다는 것
**확인하지 못한 것**
- 실제 외부 CloudEvents producer(예: Knative, Azure Event Grid)의 id 형식 분포. 명세가 제약하지 않으므로 UUID가 아닐 가능성이 높지만 측정하지 않았다.
- 이 leaf가 starter 의존 목록에 들어간 시점과 이유. 커밋이 4개뿐이고 전부 대량 커밋이다.
- `dataschema`가 실제로 쓰이는지 — `EncodedMessage.schemaReference().schemaUri()`가 채워지는 경로가 이 저장소에 없다(세 codec 모두 `SchemaReference.of(subject, version)`로 URI 없이 만든다). 즉 `dataschema`는 현재 항상 비어 있다.
- CloudEvents distributed-tracing extension을 쓸 계획이 있는지.
---
## 17. 손볼 것
### P2 — 상호운용을 위한 매퍼가 명세 준수 이벤트를 분류되지 않은 예외로 거절한다
- **사실.** `fromCloudEvent``new MessageId(UUID.fromString(event.getId()))`로 id를 파싱한다. CloudEvents 1.0.2는 `id`를 비어 있지 않은 문자열로만 제약한다. 런타임 probe 결과: 명세 예시 id `A234-1234-1234``java.lang.IllegalArgumentException: Invalid UUID string`, UUIDv4 → `java.lang.IllegalArgumentException: a message identity is UUIDv7`. **둘 다 `MessagingException`이 아니다.**
- **근거.** `evidence/raw/273-cloudevents-inbound-id-probe.txt` (실행 확인). `DefaultCloudEventMapper.java:116`.
- **왜 문제인가.** 두 층이다.
- **(1) 범위.** v4 거절은 의도이고 테스트 주석이 그렇게 적는다. 그러나 **비UUID 거절은 어디에도 언급되지 않았고** 그것은 다른 판단이다 — v4 거절은 "우리 정책", 비UUID 거절은 "CloudEvents 상호운용 포기"다. 이 leaf의 존재 이유가 상호운용인데 명세 예시조차 받지 못한다.
- **(2) 실패 어휘.** 같은 메서드의 다른 검증 실패 넷은 전부 `MessageValidationException`이고 안정 코드(`CLOUDEVENT_TIME_REQUIRED` 등)를 갖는다. id 실패만 raw `IllegalArgumentException`이라 `FailureDescriptor`가 없다 — 카테고리도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드가 이 실패를 분류하지 못한다. 같은 문제가 `causationid`·`type`·`correlationid`·`tenantcontext`·`producer`·`datacontenttype`·`schemaversion` 값 범위에도 있다(§6의 "코드 없음" 여덟 행).
- **확인 방법.** `evidence/raw/273`의 probe 재실행. 또는 `MessageId` 생성자와 `UUID.fromString`의 계약 대조.
- **후보.** (a) `fromCloudEvent`의 값 객체 생성을 전부 감싸 `MessageValidationException`으로 번역하고 각각 안정 코드를 준다. (b) 비UUID id에 대해 결정한다 — 거절하되 명시적으로 하거나, `id`를 그대로 보존하는 필드를 두거나, 결정론적 UUIDv5/v7으로 유도한다. (c) javadoc의 "compatible profile"이 나가는 방향만 뜻함을 밝힌다.
- **다음 단계.** **CASE 후보.** 재현이 실행 evidence로 확정됐고 결론이 leaf 경계 안에서 닫힌다. (b)의 선택은 별도 **DECISION 후보**이며 지금은 근거가 없으므로 `NEEDS_DECISION`이다.
### P2 — 배포 아티팩트가 싣지만 아무도 부르지 않는다
- **사실.** 세 타입의 leaf 밖 참조가 0인데 `runtime_memberships``["app-bootstrap"]`이다. `messaging-spring-boot-starter`의 의존 목록에 있어 `cloudevents-api`·`cloudevents-core` 두 jar가 런타임 classpath에 오른다. starter에 `CloudEventMapper`를 만드는 `@Bean`이 없다.
- **근거.** `evidence/raw/272` §D·§E. `MessagingCoreAutoConfiguration` 전수(`CloudEvent` 참조 0).
- **왜 문제인가.** 형제 Avro·Protobuf는 소비자 0과 membership `[]`이 일치하는 정합적 incubating 상태다. 이 leaf만 어긋난다. 오늘 실행되는 코드가 없으므로 사고는 아니지만, 아티팩트 크기와 "이 의존성이 왜 있지"의 조사 비용이 남는다. 그리고 `support-matrix.md:23`이 "모든 messaging leaf가 unwired"라고 적고 있어 문서에서도 이 사실을 알 수 없다.
- **확인 방법.** `git grep -l -w CloudEventMapper -- src ':!src/messaging/messaging-cloudevents'` → exit 1. registry의 membership 확인.
- **후보.** (a) starter에서 `@ConditionalOnClass`/`@ConditionalOnProperty`로 mapper bean을 배선한다. (b) starter 의존에서 빼고 membership을 `[]`로 되돌려 Avro·Protobuf와 같은 상태로 만든다.
- **다음 단계.** **CASE 후보.** "장치는 있고 회로가 닫히지 않았다"의 변형 — 여기서는 회로가 닫히지 않았는데 **부품은 배송됐다.**
### P3 — 왕복이 다섯 필드를 버리고, 테스트가 그 필드를 비교하지 않는다
- **사실.** `fromCloudEvent``partitionKey`·`orderingKey`를 empty로, `traceContext``none()`으로, `headers``empty()`로 두고, `producedAt``occurredAt` 값으로 덮는다. 왕복 테스트는 6개 필드만 비교하고 이 다섯은 비교하지 않는다. fixture의 `producedAt`(`09:15:01Z`)과 `occurredAt`(`09:15:00Z`)이 다르므로 비교했다면 실패했을 것이다.
- **근거.** `DefaultCloudEventMapper.java:115-131`, `CloudEventMappingTest.java:72-84, 143-161`.
- **왜 문제인가.** `traceContext` 소실이 가장 무겁다. `messaging-core-api``TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 "a trace survives an Outbox round trip through the database, where broker headers do not exist yet"이라고 적는다. CloudEvents 왕복이 그 보존을 깨뜨리고, CloudEvents 자신이 정의하는 distributed-tracing extension을 쓰지 않는다. 그리고 테스트 이름이 `roundTrips…`인데 실제로는 부분 보존 확인이다.
- **확인 방법.** 왕복 테스트에 `producedAt`·`traceContext` 비교를 추가하면 실패한다.
- **후보.** (a) 소실 필드를 javadoc에 명시한다. (b) `traceparent`/`tracestate`/`baggage`를 CloudEvents distributed-tracing extension으로 왕복시킨다. (c) 테스트 이름을 실제 보장에 맞춘다.
- **다음 단계.** **REFERENCE 후보**(왕복이라 부르는 테스트는 무엇을 보존하지 않는지도 적는다).
### P3 — `dataschema`가 채워질 경로가 없다
- **사실.** `toCloudEvent``encoded.schemaReference().flatMap(SchemaReference::schemaUri).ifPresent(builder::withDataSchema)``dataschema`를 채운다. 그런데 세 codec(JSON·Avro·Protobuf) 모두 `SchemaReference.of(subject, version)`로 만들고, 그 factory는 `schemaUri``Optional.empty()`로 둔다.
- **근거.** `DefaultCloudEventMapper.java:81-86`, `SchemaReference.java:36-38`, 세 codec의 `encode`.
- **왜 문제인가.** `dataschema`는 CloudEvents 소비자가 페이로드를 해석하는 데 쓰는 표준 속성이다. 항상 비어 있으므로 이 프로파일이 만드는 CloudEvent는 스키마 위치를 알리지 않는다. `schemaversion` 확장이 그 자리를 대신하지만 그것은 비표준 확장이다.
- **확인 방법.** `git grep -n 'new SchemaReference(' -- 'src/messaging/**/*.java'` — 3인자 생성자를 부르는 production 코드가 있는지 확인.
- **후보.** schema registry URI를 갖는 배포에서 `SchemaReference`의 3인자 생성자를 쓰게 하거나, `dataschema` 분기가 현재 도달 불가임을 주석으로 남긴다.
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 "이 저장소가 외부 schema registry를 쓸 것인가"에 걸리고, 그 질문은 `messaging-schema-api``SchemaRegistry` port가 구현 0인 것과 같은 뿌리다.
### P3 — `CloudEventMapper` javadoc의 범위 제한이 강제되지 않는다
- **사실.** "Offered for domain and integration events only. Commands and work items are not forced through CloudEvents." 코드에 `DestinationKind`를 보는 분기가 없다.
- **근거.** `CloudEventMapper.java:11-13`, `DefaultCloudEventMapper` 전문.
- **왜 문제인가.** 소비자가 0이므로 지금은 무해하다. 배선되면 `ASYNC_COMMAND`·`WORK_QUEUE` 봉투도 이 매퍼를 통과한다.
- **확인 방법.** `git grep -n 'DestinationKind' -- 'src/messaging/messaging-cloudevents/**'` → 매치 없음.
- **후보.** 진술을 유지하되 "호출자 책임"임을 명시하거나, `toCloudEvent``DestinationKind`를 받아 검사한다.
- **다음 단계.** **REFERENCE 후보**(문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다).
### 확인된 설계(문제 아님)
- `occurredAt` 없는 이벤트를 production 시각으로 기본값 처리하지 않고 거절하는 것
- 빈 data를 tombstone(Kafka null value)으로 만들지 않는 것 — `MessageEnvelope`의 non-null payload 계약과 정확히 짝을 이룸
- producer 이름을 마지막 세그먼트로 잘라 메트릭 카디널리티를 막는 것
- `cloudevents-api``api`로, `cloudevents-core``implementation`으로 나눈 것과 그 근거 주석
- `schemaversion` 에러 메시지가 "by this profile"이라고 밝히는 것
- `byte[]` payload를 `clone()`해서 넘기는 것
- 매퍼가 상태를 갖지 않는 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MCE-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, `runtime_memberships: ["app-bootstrap"]` | 선언 |
| MCE-002 | build | `messaging-cloudevents/build.gradle` | same | `api`/`implementation` 분리와 그 근거 | — |
| MCE-003 | build | `messaging-cloudevents/gradle.lockfile:33-34` | same | cloudevents 4.0.1 두 좌표 | — |
| MCE-004 | code | `.../cloudevents/CloudEventMapper.java` 전문 | same | 계약과 적용 범위 진술 | 범위 미강제(§17) |
| MCE-005 | code | `.../cloudevents/DefaultCloudEventMapper.java` 전문 | same | §4 전체 매핑표와 두 명시적 결정 | — |
| MCE-006 | code | `.../cloudevents/CloudEventExtensions.java` | same | 확장 이름 4개와 명명 이유 | — |
| MCE-007 | test | `CloudEventMappingTest` (7) | same | §10 표 | 외부 producer 입력 없음. 왕복이 5개 필드 미비교 |
| MCE-008 | cross-leaf code | `messaging-core-api/.../MessageId.java:20-32` | same | UUIDv7 강제의 출처 | 해당 leaf SSOT가 소유 |
| MCE-009 | cross-leaf code | `messaging-core-api/.../TraceContext.java:11-13` | same | 봉투가 trace를 갖는 이유(§17 왕복 소실) | 해당 leaf SSOT가 소유 |
| MCE-010 | cross-leaf code | `messaging-schema-api/.../SchemaReference.java:36-38` | same | `of`가 URI를 비움 → `dataschema` 도달 불가 | 해당 leaf SSOT가 소유 |
| MCE-011 | external spec | CloudEvents 1.0.2, `id` 속성 정의 | — | `id`는 비어 있지 않은 String이며 형식 제약 없음 | 외부 표준. 저장소 밖 지식으로 명시 분리 |
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | 세 타입 소비자 0, membership | 정적 검색 |
| EVD-273 | runtime probe | `evidence/raw/273-cloudevents-inbound-id-probe.txt` | same | 세 id 형태의 실제 결과와 예외 타입 | 세 형태만. 저장소 소스 미수정 |
| EVD-278 | command | `./gradlew :messaging:messaging-cloudevents:test --rerun-tasks` | same | 7 / 0 / 0 | — |
@@ -0,0 +1,924 @@
# messaging-core-api 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-core-api`
> SSOT owner: `messaging-core-api`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
> **성격.** 정책 문서가 아니라 읽기 기록이다. 이 leaf가 무엇을 선언했고, 그 선언 중 무엇이 실제로 소비되며, 무엇이 소비되지 않는지를 source anchor와 함께 적는다. cycle 1의 family 문서(`analysis/19-messaging-platform.md`)는 25개 leaf를 하나의 문서로 다뤘고 새 계약에서 secondary evidence로 강등됐다. 이 문서가 `messaging-core-api`의 canonical SSOT다.
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-core-api`
- canonical state `analysisFile`: `analysis/messaging/messaging-core-api.md`
- source path: `src/messaging/messaging-core-api`
- leaf-owned subdocuments: 없음
- related family/integration documents: `analysis/19-messaging-platform.md` (secondary)
- registry `allowed_dependencies`: `[]` — 이 저장소에서 의존성이 하나도 없는 두 leaf 중 하나(다른 하나는 `grpc-core-api`)
- registry `runtime_memberships`: `["app-bootstrap"]`
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 85 |
| production LOC | 3,948 |
| 패키지 | 7 |
| test 파일 | 8 |
| test 메서드(실행 확인) | 79 |
| build/config 파일 | `build.gradle` 1, `gradle.lockfile` 1 |
| migration | 0 |
| 외부 의존성 | **0** |
패키지 7개와 그 안의 타입 수:
| 패키지 | 타입 | 성격 |
|---|---:|---|
| `api` (root) | 12 | 봉투와 그 안의 값 객체 |
| `api.header` | 5 | 헤더 이름·값·맵·예약 네임스페이스 |
| `api.destination` | 7 | 논리 목적지와 capability |
| `api.publish` | 17 | 발행 요청·결과·증거 |
| `api.delivery` | 13 | 수신·핸들러 결과 |
| `api.settlement` | 5 | 수동 정산 |
| `api.error` | 26 | 실패 분류와 예외 계층 |
| 합계 | **85** | |
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**/api/*.java` (root 12) | 12 | `FULL_READ` | 전 파일 본문 확인 |
| `src/main/java/**/api/header/*.java` | 5 | `FULL_READ` | 전 파일 본문 확인 |
| `src/main/java/**/api/destination/*.java` | 7 | `FULL_READ` | 전 파일 본문 확인 |
| `src/main/java/**/api/publish/*.java` | 17 | `FULL_READ` | 전 파일 본문 확인 |
| `src/main/java/**/api/delivery/*.java` | 13 | `FULL_READ` | 전 파일 본문 확인 |
| `src/main/java/**/api/settlement/*.java` | 5 | `FULL_READ` | 전 파일 본문 확인 |
| `api/error/FailureCategory·FailureDescriptor·MessagingException` | 3 | `FULL_READ` | 전 파일 본문 확인 |
| `api/error/Message*Exception` 나머지 | 23 | `STRUCTURAL_ONLY` | 전부 동일 형태 — 3개 생성자, 고정 `CATEGORY` 상수, `retryable` 리터럴. 시그니처·카테고리·retryable 값을 전수 대조했고 그 외 본문이 없다 |
| `src/test/java/**` | 8 | `FULL_READ` | 전 파일 본문 확인 |
| `build.gradle` | 1 | `FULL_READ` | 4줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일; 선언 의존성 0을 build.gradle에서 이미 확인 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물. source가 아니다 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
이 leaf는 **브로커 중립 공개 계약**을 소유한다. 여기에는 구현이 거의 없다 — 85개 타입 중 인터페이스 11개, enum 12개, record 46개, 유틸리티 final class 5개, 예외 26개이고, 실행 가능한 로직은 `UuidV7.next()`, `WireSafeText.require`, `MessageHeaders.validateAndCopy`, 그리고 record 생성자의 검증뿐이다.
**무엇이 아닌가**가 이 leaf에서는 무엇인가만큼 중요하고, 코드가 그것을 직접 말한다.
`build.gradle` 전문:
```groovy
apply plugin: 'java-library'
dependencies {
}
```
`src/main/java` 전체에서 `java.*`와 자기 패키지 밖 import는 **0개**다(`evidence/raw/269` §F). Spring도, Kafka·AMQP 클라이언트도, Reactor도 없다. 이것은 우연이 아니라 원래 계획이 명시한 제약이고(`docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md:13` — "`messaging-core-api`에는 Spring Kafka, Spring AMQP, Pulsar, NATS, Spring `Message<?>`, Reactor 의존성을 넣지 않는다"), 현재 소스에서 재측정해도 참이다.
경계는 세 방향으로 그어져 있다.
**브로커 쪽으로.** `MessageDestination`은 논리 이름·카탈로그 타입·payload 클래스만 갖고 topic/exchange/queue/subject를 갖지 않는다(`destination/MessageDestination.java:9-11`). `DestinationName`의 패턴 `[a-z0-9][a-z0-9.-]{0,159}``:``/`와 공백을 배제해서 `topic://orders` 같은 물리 주소를 논리 이름으로 밀어 넣는 것을 생성자에서 막는다(`destination/DestinationName.java:16`). 주석이 이유를 적는다 — "otherwise the physical mapping owned by the destination profile could be bypassed from application code."
**프로그래밍 모델 쪽으로.** 핵심 계약은 `CompletionStage`다. blocking facade(`BlockingMessagePublisher`)는 인터페이스만 여기 두고 구현을 다른 모듈로 밀어냈으며, Reactor facade는 아예 없다(`publish/MessagePublisher.java:10-11`).
**애플리케이션 쪽으로.** 이 경계는 이 leaf가 아니라 ArchUnit이 긋는다. `CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`(`CleanArchitectureTest.java:229-240`)은 `..application..` 패키지가 `dev.caskeleton.messaging..`에 의존하는 것을 금지한다. 이유가 규칙 본문에 적혀 있다:
> the application owns its publish port and outbox model; a bridge adapter translates, and the two outbox status models mean opposite things under the same names
이 규칙은 §12의 reachability 결과를 읽을 때 반드시 같이 봐야 한다. 이 leaf의 공개 타입 중 다수가 `..application..`에서 참조 0인 것은 **금지되어 있기 때문**이지 잊혀서가 아니다.
---
## 2. 의존성과 런타임 배선
### 2.1 source 의존성
들어오는 것: 없음. registry `allowed_dependencies: []`이고 `build.gradle`에 선언이 없다.
나가는 것(이 leaf를 의존하는 messaging leaf, registry 기준): `messaging-schema-api`, `messaging-schema-json`, `messaging-schema-avro`, `messaging-schema-protobuf`, `messaging-cloudevents`, `messaging-policy`, `messaging-transport-spi`, `messaging-runtime-core`, `messaging-observability`, `messaging-security`, `messaging-kafka`, `messaging-kafka-share-experimental`, `messaging-rabbit`, `messaging-reliability-api`, `messaging-outbox-jdbc-postgresql`, `messaging-inbox-jdbc-postgresql`, `messaging-claim-check`, `messaging-admin-api`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-cloud-stream-bridge`, `messaging-spring-boot-starter`, `messaging-testkit` — messaging family의 나머지 **24개 전부**.
### 2.2 런타임 배선
`runtime_memberships: ["app-bootstrap"]`이고, 그 편입은 직접 선언이 아니라 **전이(transitive)**로 일어난다. `src/app-bootstrap/build.gradle:87`이 선언하는 것은 하나다:
```groovy
implementation project(':messaging:messaging-spring-boot-starter')
```
starter의 `allowed_dependencies`가 17개 leaf를 끌고 오고 그 closure에 `messaging-core-api`가 있다. 즉 **배포 아티팩트가 이 leaf를 싣는다.** 실행 여부는 별개이고 master switch `app.messaging.enabled`(기본 `false`)가 결정한다(`src/messaging/CLAUDE.md:56-57`).
이 leaf 자체는 bean을 하나도 만들지 않는다. Spring stereotype·`@Bean`·`@Conditional`·`@Profile` 주석이 leaf 전체에 0개다(`evidence/raw/269` §F, `git grep` exit=1). 따라서 §12.2의 conditional sibling 비교는 이 leaf에 **적용 대상이 없다** — 비교할 sibling bean이 존재하지 않는다.
---
## 3. 패키지/컴포넌트 지도
### 3.1 `api` — 봉투와 값 객체 (12)
`MessageEnvelope<T>`가 중심이고 나머지 11개가 그 필드 타입이다.
```
MessageEnvelope<T>
├── MessageId UUIDv7만 허용
├── MessageType 카탈로그 이름, 240 UTF-8 bytes
├── SchemaVersion 1 이상
├── producedAt Instant
├── occurredAt Optional<Instant>
├── ProducerId 서비스 이름, 120 bytes
├── CorrelationId 워크플로 상관값, 160 bytes
├── CausationId → MessageId
├── ContentType media type, 160자
├── partitionKey Optional<String>, 1024 bytes
├── orderingKey Optional<String>, 1024 bytes
├── TenantContext [a-z0-9][a-z0-9._-]{0,63}
├── TraceContext W3C traceparent/tracestate/baggage
├── MessageHeaders ≤64개, ≤32,768 bytes
└── payload T, non-null
```
부속: `UuidV7`(생성기), `WireSafeText`(검증 유틸).
봉투는 불변이고 네 가지 파생 메서드가 있다 — `withPayload`, `withContentType`, `withTenant`, `withHeaders`. 넷 다 `messageId`를 복사한다. `withPayload`의 javadoc이 그 이유를 적는다: "Encoding, decoding, Claim Check offloading, and DLQ forwarding all need this, and every one of them must keep `messageId()` intact — which is exactly what this method guarantees by construction"(`MessageEnvelope.java:80-82`).
### 3.2 `api.header` — 헤더 (5)
`HeaderName`, `HeaderValue`, `MessageHeaders`, `ReservedHeaders`, `CanonicalEnvelopeHeaders`.
`ReservedHeaders`는 23개 이름 상수와 `msg.` **prefix 전체**를 소유한다. `CanonicalEnvelopeHeaders`는 그 예약 네임스페이스를 둘로 쪼갠다 — 봉투 필드가 이미 갖고 있는 15개(`ENVELOPE_FIELDS`)와, 봉투에 대응 필드가 없어서 헤더로만 이동할 수 있는 나머지 8개(`REDRIVE_ID`, `REDRIVE_COUNT`, `RETRY_ATTEMPT`, `FIRST_FAILURE_AT`, `LAST_FAILURE_AT`, `FAILURE_CATEGORY`, `FAILURE_CODE`, `ORIGIN_DESTINATION`).
### 3.3 `api.destination` — 목적지 (7)
`MessageDestination<T>`, `DestinationName`, `DestinationKind`(7), `MessagingCapabilities`(boolean 12), `DestinationCapabilities`, `ConfirmationRequirement`(3), `CapabilityRegistry`.
### 3.4 `api.publish` — 발행 (17)
퍼블리셔 4종(`MessagePublisher`, `BlockingMessagePublisher`, `BatchMessagePublisher`, `DelayedMessagePublisher`), 요청 3종, 결과 5종, 증거 3종, enum 3종(`PublishCompletion`, `ConfirmationLevel`, `RoutingOutcome`, `TransmissionEvidence` — 4종), `BrokerPosition`.
### 3.5 `api.delivery` — 수신 (13)
`MessageDelivery<T>`, `DeliveryMetadata`, `DeliveryContext`, `MessageHandler<T>`, `BatchMessageDelivery<T>`, `BatchDeliveryMetadata`, `BatchMessageHandler<T>`, `HandleResult`(sealed, 4 변형), `PauseResumeController`, enum 4종.
### 3.6 `api.settlement` — 수동 정산 (5)
`ManualMessageHandler<T>`, `SettlementController`, `SettlementResult`, `SettlementEvidence`, `SettlementCompletion`.
### 3.7 `api.error` — 실패 (26)
`FailureCategory`(10), `FailureDescriptor`, `MessagingException`(abstract) + 구체 예외 23종.
---
## 4. 계약·불변식·상태 모델
이 leaf의 실질은 여기 있다. **표현할 수 없는 상태를 생성자에서 거절하는 것**이 설계의 축이다.
### 4.1 발행 결과: 3상태와 12개 금지 조합
`PublishCompletion`은 boolean이 아니라 3상태다.
| 값 | 의미 | 호출자가 할 수 있는 것 |
|---|---|---|
| `CONFIRMED` | 요구 수준으로 브로커가 수락 | 완료 |
| `REJECTED` | 확실히 저장되지 않음 | 이 시도를 버려도 안전 |
| `AMBIGUOUS` | 브로커가 갖고 있을 수도 있음 | **같은 `messageId`로만** 재발행 |
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`).
`PublishResult` 생성자(`publish/PublishResult.java:39-101`)가 거절하는 조합 12가지:
| # | 거절 조건 | 이유(코드/주석 기준) |
|---:|---|---|
| 1 | `attempts < 1` | 첫 시도가 1 |
| 2 | `elapsed < 0` | — |
| 3 | `CONFIRMED` + `!brokerAccepted` | 확인은 브로커 수락을 전제 |
| 4 | `CONFIRMED` + `confirmationLevel == NONE` | 확인 수준 없는 확인은 확인이 아님 |
| 5 | `CONFIRMED` + `UNROUTABLE` | 라우팅 실패를 성공으로 읽히게 함 |
| 6 | `AMBIGUOUS` + `confirmationLevel != NONE` | 모호한데 확인을 주장 |
| 7 | `AMBIGUOUS` + `brokerAccepted` | 같은 이유 |
| 8 | `AMBIGUOUS` + `NOT_TRANSMITTED` | 나가지 않은 것은 모호가 아니라 거절 |
| 9 | `!CONFIRMED` + `failure.isEmpty()` | 실패 서술 없는 실패 |
| 10 | `CONFIRMED` + `failure.isPresent()` | 성공에 실패 서술 |
| 11 | `REJECTED` + `brokerAccepted` | **"한 주문이 둘이 되는 조합"** |
| 12 | `CONFIRMED` + `UNKNOWN` routing | 확인해 준 응답이 라우팅도 말한다 |
| 13 | `AMBIGUOUS` + `ROUTED` | 라우팅을 보고한 브로커는 답한 것 |
| 14 | `position.isPresent()` + `NOT_TRANSMITTED` | 나가지 않은 메시지의 좌표는 남의 것 |
11번과 14번에는 코드 주석이 직접 달려 있다.
```java
if (completion == PublishCompletion.REJECTED && evidence.brokerAccepted()) {
// A broker that acknowledged the message did not reject it. Left representable, this is the
// combination that turns a delivered message into one the caller re-publishes as if it had
// never been sent.
throw new IllegalArgumentException("rejected publish cannot claim broker acceptance");
}
```
record가 public이고 모든 adapter가 이것을 만들기 때문에 호출부를 믿지 않고 여기서 검증한다는 것도 javadoc에 적혀 있다(`PublishResult.java:18-20`).
### 4.2 증거는 결론보다 먼저 기록된다
`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`)인 것이 그 순서를 가능하게 한다.
### 4.3 정산: 같은 3상태 규율
`SettlementResult`(`settlement/SettlementResult.java:23-36`)도 같은 형태다.
- `SETTLED`인데 `!brokerConfirmed` → 거절
- `SETTLED`인데 `redeliveryPossible` → 거절
- `!SETTLED`인데 `failure.isEmpty()` → 거절
`SettlementEvidence``brokerConfirmed && !transmitted`를 거절한다. javadoc: "Treating an unconfirmed acknowledgement as settled is the classic route to a message that looks processed in logs and is processed again minutes later."
### 4.4 없는 것으로 말하는 계약
세 enum이 **일부러 비어 있는 자리**를 갖는다.
| enum | 없는 값 | 코드가 적은 이유 |
|---|---|---|
| `DeliveryGuarantee` | `EXACTLY_ONCE` | "No broker delivers exactly-once across an external side effect... Naming a guarantee the platform cannot honour would push that responsibility out of sight, so the enum stops where the evidence stops." |
| `OrderingScope` | `GLOBAL` | "Ordering is a property of a partition, a key mapping, or a single consumer — never of a whole destination." |
| `PublishOptions` | 자유형 hint map | "One existed for a native surface that does not read it... an escape hatch around destination policy that never opened." |
이 셋은 테스트로 붙들려 있다 — `CoreValueTypesTest.guaranteeEnumsDoNotAdvertiseUnsupportedSemantics``values()``EXACTLY_ONCE``GLOBAL`이 없음을 단언한다(`CoreValueTypesTest.java:25-29`). 이름이 다시 추가되면 테스트가 깨진다.
### 4.5 wire 안전성: 한 곳에 모은 규칙
`WireSafeText`(`WireSafeText.java`)가 두 가지를 한다.
```java
public static void requireNoControls(String value, String what) {
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (character < 0x20 || character == 0x7F) { throw ... }
}
}
```
- **바이트로 센다.** javadoc: "A `char` count bounds nothing on a wire: a 240-character string is up to 960 UTF-8 bytes."
- **제어문자를 정제하지 않고 거절한다.** "Silently stripping a CR turns a caller's two-line value into a one-line value that no longer means what they wrote, and the caller never learns."
- **탭도 거절한다.** HTTP 필드 값에서는 합법이지만 "a header carried over a line-folding binding and the same header carried over a length-prefixed one disagree about whether a tab ends the value."
호출자: `CorrelationId`(160), `MessageType`(240), `ProducerId`(120), `HeaderValue`(4096), `MessageEnvelope`의 partitionKey/orderingKey(1024), `TraceContext.baggage`.
`HeaderName``WireSafeText`를 쓰지 않고 자체 정규식 `[a-zA-Z0-9!#$%&'*+._|~-]+`(HTTP token)을 쓴다. 더 엄격하다 — 공백·콜론·비ASCII를 전부 배제한다. 그리고 trim하지 않고 **선행/후행 공백을 거절**한다. 주석이 이유를 적는다:
```java
if (!value.equals(value.strip())) {
// Trimming would mean `Authorization ` and `Authorization` are the same name to the
// denylist and different names on the wire, which is precisely how the check was bypassed.
```
### 4.6 자격증명 헤더 차단: 정확 일치 → 세그먼트 매칭
`MessageHeaders.carriesACredential`(`header/MessageHeaders.java:142-160`)은 두 단계다.
1. `SECRET_NAMES` 9개 정확 일치(`authorization`, `cookie`, `access_token`, …)
2. `SECRET_SEGMENTS` 10개를 `[._\-]+`로 쪼갠 **세그먼트** 단위로 검사, 그리고 **인접 세그먼트를 붙여서** 한 번 더 검사
```java
// Adjacent segments are also tested joined, because the same word is written both ways:
// `api_key` is one segment to a reader and two to a splitter, and `x-api-key` is two of
// three. Joining only neighbouring pairs is what keeps `routing-key` accepted.
```
두 방향 다 테스트가 있다. `x-api-key`·`auth-token`·`db_password`·`request.signature`·`Cookie`는 거절되고(`WireBoundaryRejectionTest.java:166-175`), `tokenizer-version`·`secretariat-id`는 통과한다(`:177-188`). 부분문자열 매칭이었으면 후자가 오탐이 된다.
거절 메시지는 **이름만** 담고 값은 절대 담지 않는다. 주석: "an error message is written to a log that is exactly as readable as the broker storage this check exists to keep the value out of."
### 4.7 예약 네임스페이스: 이름 목록 → prefix 소유
`ReservedHeaders.isReserved`(`header/ReservedHeaders.java:135-141`)는 23개 이름 집합 **또는** `msg.` prefix로 판정한다.
```java
// The check used to be exact membership of NAMES, so `msg.anything` that this
// release has not defined was an ordinary application header — until a later release defined it,
// at which point every application already writing it silently started overwriting envelope
// metadata. Owning the prefix means a new platform header is a compatible change.
```
테스트가 이 성질을 직접 붙든다 — `ReservedHeaders.isReserved("msg.not-defined-in-this-release")``true`이고, 애플리케이션이 `msg.not-defined-yet`을 쓰면 거절되며, platform factory는 여전히 쓸 수 있다(`WireBoundaryRejectionTest.java:190-207`).
### 4.8 `MessageHeaders`의 두 factory
| factory | 예약 이름 | 자격증명 이름 | 호출자 |
|---|---|---|---|
| `application(Map)` | 거절 | 거절 | 업무 코드 |
| `platform(Map)` | **허용** | 거절 | wire에서 봉투를 복원하는 adapter |
자격증명은 양쪽 다 거절이다. javadoc: "a credential that reaches a header ends up in broker storage, DLQ dumps, and operator tooling, and no downstream redaction can undo that."
### 4.9 `MessageId`: 타입 이름과 실제 검증의 정렬
```java
if (value.version() != VERSION_7) {
throw new IllegalArgumentException(
"a message identity is UUIDv7 (time-ordered); this is version " + value.version());
}
if (value.variant() != 2) {
throw new IllegalArgumentException("a message identity must use the RFC 4122 variant");
}
```
주석이 왜 이 검증이 생겼는지 적는다: "The type says UUIDv7 and the constructor accepted any UUID, including v4 and the nil UUID. Version 7 is what makes the identity time-ordered, which is what the outbox index and every 'oldest first' claim depend on; a v4 stored in the same column silently defeats both."
테스트가 그 문장을 그대로 단언한다 — `new MessageId(UUID.randomUUID())`는 거절되고 이유 문자열에 `UUIDv7`이 포함된다(`WireSafeValueObjectTest.java:73-80`, `as("a v4 in the same column defeats every 'oldest first' claim the outbox makes")`).
> **주의.** 이것은 **이 leaf의** `MessageId`에만 해당한다. 저장소의 다른 UUIDv7 구현들은 별개이고 §12.3에서 다룬다.
### 4.10 `UuidV7`: 밀리초 내 단조성
`UuidV7.advance`(`UuidV7.java:54-61`)는 48비트 타임스탬프와 12비트 카운터를 하나의 `AtomicLong`에 packing하고 `updateAndGet`으로 CAS 루프를 돈다.
```java
private static long advance(long previous) {
long now = System.currentTimeMillis();
long previousTimestamp = previous >>> COUNTER_BITS;
if (now > previousTimestamp) {
return now << COUNTER_BITS;
}
return previous + 1;
}
```
RFC 9562의 `rand_a` 12비트를 난수가 아니라 **밀리초 내 단조 카운터**로 쓴다. 시계가 뒤로 가도 `previous + 1`이므로 중복이나 역행이 나오지 않고 "미래에서 빌려올" 뿐이다. 카운터가 넘치면 타임스탬프 필드로 자연히 carry된다.
이 성질은 `CoreValueTypesTest.newMessageIdIsVersionSevenAndTimeOrdered`가 두 연속 호출의 `compareTo`가 음수임을 단언해서 붙든다. 다만 **단일 스레드 2회 호출**이므로 경합 하 단조성은 이 테스트가 증명하지 않는다(§16 참조).
### 4.11 `TraceContext`: 표준을 실제로 검사한다
세 값이 전부 `Optional<String>`이고 non-null 검사만 있던 시절의 기록이 javadoc에 남아 있다 — "which made this record a general-purpose string carrier wearing the name of a standard."
현재 검사:
| 필드 | 규칙 |
|---|---|
| `traceparent` | `[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}` 정확 일치, `ff` 버전 거절, all-zero trace id 거절, all-zero span id 거절 |
| `tracestate` | ≤512 bytes, ≤32 list member, 각 member가 `key=value` 또는 `tenant@vendor=value` 문법, 빈 member는 허용(전방호환) |
| `baggage` | ≤8,192 bytes, ≤64 member, 제어문자 없음, 각 member `key=value` |
| 조합 | `tracestate`가 있는데 `traceparent`가 없으면 거절 |
대문자 hex를 접는 대신 **거절**하는 이유도 적혀 있다: "the standard defines the field as lowercase, and a receiver comparing trace IDs as strings — which collectors do — would treat the two cases as two different traces."
`tracestate` 단독 거절 이유: "vendor state belonging to no trace. Propagating it hands the next hop a key it will attribute to whatever trace that hop starts."
7개 무효 traceparent가 파라미터 테스트로 전부 커버된다(`WireBoundaryRejectionTest.java:71-90`).
### 4.12 실패 분류와 기본 재시도 정책
`FailureCategory` 10개, `FailureDescriptor.defaultRetryable`(`error/FailureDescriptor.java:67-79`)이 그 중 3개만 재시도 가능으로 본다.
| retryable = true | retryable = false |
|---|---|
| `TRANSIENT_INFRASTRUCTURE` | `PERMANENT_BUSINESS`, `POISON_MESSAGE`, `DESERIALIZATION`, `AUTHENTICATION`, `AUTHORIZATION`, **`AMBIGUOUS`**, `CONFIGURATION` |
| `THROTTLED` | |
| `PROCESSING_TRANSIENT` | |
`AMBIGUOUS`가 false인 것은 모순이 아니라 설계다. 모호한 발행은 **자동** 재시도 대상이 아니고, 호출자가 같은 `messageId`로 재발행할지를 결정한다(`MessagePublishAmbiguousException` javadoc).
`FailureDescriptor`는 DLQ까지 이동하므로 payload·스택트레이스·자격증명·실제 메시지 키를 담지 않고, `sanitizedMessage`는 512자에서 **잘린다**(거절이 아니라 절단). javadoc: "Stack traces belong in secure log storage; a DLQ is read by more people than the log is."
### 4.13 `HandleResult`: sealed 4변형
`Success` / `Retry(FailureDescriptor)` / `DeadLetter(FailureDescriptor)` / `Reject(FailureDescriptor)`. 어떤 변형도 브로커 ack 핸들을 갖지 않는다. javadoc: "The handler states an intent; the platform performs the settlement."
`ConsumerContractTest.handleResultPermitsExactlyTheFourDeclaredOutcomes``getPermittedSubclasses()`로 이 집합을 고정한다.
### 4.14 배치는 트랜잭션이 아니다
`BatchPublishResult`는 항목별 결과를 제출 인덱스와 함께 보존하고 배치 수준 boolean으로 접지 않는다. `BatchPublishOptions`에는 **retry 설정이 없다**. javadoc: "retrying the batch would resubmit entries that already confirmed."
`BatchDeliveryMetadata.isSafeForOrderedDestination()``orderingUnit.isPresent()`다 — 두 파티션에서 끌어온 배치는 순서 보장 목적지에 넘길 수 없다.
---
## 5. 주요 실행 경로
이 leaf에는 실행 경로가 거의 없다. 실제로 코드가 도는 지점은 넷이다.
1. **봉투 생성**`new MessageEnvelope<>(...)` → 14개 non-null 검사 + partitionKey/orderingKey wire 검사
2. **헤더 생성**`MessageHeaders.application/platform(Map)` → 개수(≤64) → 이름별 예약/자격증명/중복 검사 → 총 바이트(≤32,768)
3. **식별자 생성**`MessageId.newId()``UuidV7.next()``AtomicLong.updateAndGet(advance)`
4. **결과 조립**`new PublishResult(...)` / `new SettlementResult(...)` → 조합 검증
나머지는 전부 인터페이스 선언이고, 구현은 `messaging-runtime-core`·`messaging-kafka`·`messaging-rabbit` 등 다른 leaf가 소유한다.
---
## 6. 실패 경로와 복구/번역
### 6.1 계층
`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."
### 6.2 23개 예외의 카테고리·재시도 전수표
| 예외 | category | retryable | leaf 밖 참조 |
|---|---|:---:|:---:|
| `MessageAuthenticationException` | `AUTHENTICATION` | false | **0** |
| `MessageAuthorizationException` | `AUTHORIZATION` | false | 16 |
| `MessageBackpressureException` | `TRANSIENT_INFRASTRUCTURE` | true | 4 |
| `MessageBrokerUnavailableException` | `TRANSIENT_INFRASTRUCTURE` | true | **0** |
| `MessageConsumerException` | `PROCESSING_TRANSIENT` | true | **0** |
| `MessageDeadLetterException` | `TRANSIENT_INFRASTRUCTURE` | true | **0** |
| `MessageHandlerTimeoutException` | `PROCESSING_TRANSIENT` | true | **0** |
| `MessageHeaderRejectedException` | `PERMANENT_BUSINESS` | false | **0** |
| `MessagePublishAmbiguousException` | `AMBIGUOUS` | false | **0** |
| `MessagePublishRejectedException` | `PERMANENT_BUSINESS` | false | **0** |
| `MessagePublishTimeoutException` | `AMBIGUOUS` | false | 2 |
| `MessageRedriveException` | `TRANSIENT_INFRASTRUCTURE` | true | **0** |
| `MessageRetryExhaustedException` | `PERMANENT_BUSINESS` | false | **0** |
| `MessageRoutingException` | `PERMANENT_BUSINESS` | false | **0** |
| `MessageSchemaIncompatibleException` | `DESERIALIZATION` | false | 4 |
| `MessageSerializationException` | `DESERIALIZATION` | false | 8 |
| `MessageSettlementException` | `TRANSIENT_INFRASTRUCTURE` | true | 1 |
| `MessageSettlementUnknownException` | `AMBIGUOUS` | false | **0** |
| `MessageTooLargeException` | `PERMANENT_BUSINESS` | false | 17 |
| `MessageTopologyException` | `CONFIGURATION` | false | 2 |
| `MessageValidationException` | `PERMANENT_BUSINESS` | false | 16 |
| `MessagingCapabilityUnavailableException` | `CONFIGURATION` | false | 13 |
| `MessagingConfigurationException` | `CONFIGURATION` | false | 59 |
**23개 중 12개가 leaf 밖에서 한 번도 참조되지 않는다**(`evidence/raw/269` §B, 12개 전부 `git grep` exit=1). §12.1에서 다룬다.
### 6.3 조용한 성능 저하를 막는 설계
`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."
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 개념이 이 leaf에는 두 가지 형태로만 등장하고 둘 다 **선언**이다.
- `MessagingCapabilities.brokerTransaction` — 브로커가 트랜잭션 스코프를 제공하는가
- `ProcessingGuarantee.BROKER_TRANSACTIONAL` — "Atomicity holds only inside the transaction scope the broker itself defines"
- `ExternalSideEffectGuarantee.INBOX_TRANSACTIONAL` — "An Inbox row and the side effect commit inside the same database transaction"
DB 트랜잭션은 이 leaf가 만지지 않는다.
동시성 지점은 **하나**다: `UuidV7.STATE`(`AtomicLong`). `updateAndGet`이 CAS 루프이므로 다중 스레드에서도 각 호출이 서로 다른 packed state를 얻는다. `RANDOM`(`SecureRandom`)은 thread-safe다.
`MessageHeaders`는 생성 시 `LinkedHashMap`에 복사하고 `Collections.unmodifiableMap`으로 감싸 반환하므로 공유 안전하다. 다만 `find(String)``values.entrySet().stream()` 선형 탐색이다 — 최대 64개이므로 실용상 문제는 아니지만 hot path에서 반복 호출되면 O(n)이다.
수명주기 개념은 `DeliveryContext.shutdownRequested`뿐이고, javadoc이 목적을 적는다 — "during a graceful drain the platform stops creating new retry attempts, and a long-running handler that can wind down early shortens the drain instead of being cancelled at the deadline." **이 필드는 production에서 도달 불가능하다**(§12.1).
---
## 8. 설정·기능 플래그·환경 차이
이 leaf에는 설정이 **없다**. properties·yaml·환경변수·시스템 프로퍼티를 읽는 코드가 0이다. 모든 값은 컴파일 타임 상수다.
경계값 전수:
| 상수 | 값 | 위치 |
|---|---:|---|
| `ContentType.MAX_LENGTH` | 160자 | `ContentType.java:12` |
| `CorrelationId.MAX_BYTES` | 160 | `CorrelationId.java:16` |
| `MessageType.MAX_BYTES` | 240 | `MessageType.java:13` |
| `ProducerId.MAX_BYTES` | 120 | `ProducerId.java:13` |
| `MessageEnvelope.MAX_KEY_BYTES` | 1,024 | `MessageEnvelope.java:75` |
| `HeaderName.MAX_BYTES` | 128 | `HeaderName.java:16` |
| `HeaderValue.MAX_BYTES` | 4,096 | `HeaderValue.java:18` |
| `MessageHeaders.MAX_COUNT` | 64 | `MessageHeaders.java:22` |
| `MessageHeaders.MAX_TOTAL_BYTES` | 32,768 | `MessageHeaders.java:23` |
| `TenantContext` 패턴 | `[a-z0-9][a-z0-9._-]{0,63}` | `TenantContext.java:16` |
| `DestinationName` 패턴 | `[a-z0-9][a-z0-9.-]{0,159}` | `DestinationName.java:16` |
| `TraceContext.MAX_TRACESTATE_BYTES` | 512 | `TraceContext.java:53` |
| `TraceContext.MAX_TRACESTATE_MEMBERS` | 32 | `TraceContext.java:51` |
| `TraceContext.MAX_BAGGAGE_BYTES` | 8,192 | `TraceContext.java:56` |
| `TraceContext.MAX_BAGGAGE_MEMBERS` | 64 | `TraceContext.java:58` |
| `FailureDescriptor.MAX_MESSAGE_LENGTH` | 512자(절단) | `FailureDescriptor.java:26` |
| `FailureDescriptor.MAX_CODE_LENGTH` | 120자(거절) | `FailureDescriptor.java:27` |
| `PublishOptions.DEFAULT_TIMEOUT` | 5초 | `PublishOptions.java:25` |
| `BatchPublishOptions.DEFAULT_TIMEOUT` | 30초 | `BatchPublishOptions.java:21` |
| `BatchPublishOptions.DEFAULT_MAX_BATCH_SIZE` | 500 | `BatchPublishOptions.java:22` |
`PublishOptions.defaults()`가 요구하는 확인 수준은 `REPLICATION_OR_PERSISTENCE_ACK`다 — 기본값이 가장 강한 보장이고, 약하게 쓰려면 명시해야 한다.
단위가 섞인 곳이 하나 있다. `ContentType`**문자** 160, 다른 문자열 값 객체는 **바이트**다. `ContentType`은 미디어 타입 정규식이 ASCII만 허용하므로 실질 차이가 없지만, 이 leaf에서 유일하게 `WireSafeText`를 쓰지 않는 문자열 값이다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. 이 leaf는 DB·브로커·파일시스템·네트워크를 만지지 않는다. `SecureRandom`(엔트로피)과 `System.currentTimeMillis()`(시계)가 유일한 외부 접촉이고 둘 다 `UuidV7` 안에 있다.
---
## 10. 테스트 레인과 실제 증명 범위
레인은 하나다: `./gradlew :messaging:messaging-core-api:test`. 실행 결과 **BUILD SUCCESSFUL**, 79 tests, 0 skipped, 0 failures (`--rerun-tasks`, revision `21234e38`).
| 테스트 클래스 | 수 | 무엇을 실제로 증명하는가 | 무엇을 증명하지 않는가 |
|---|---:|---|---|
| `CoreValueTypesTest` | 7 | 값 객체 거절 조건, `MessageId` v7/variant 2, 연속 2회 시간순, `EXACTLY_ONCE`/`GLOBAL` 부재 | 경합 하 `UuidV7` 단조성 |
| `MessageEnvelopeTest` | 11 | 예약/비밀 헤더 거절(대소문자 무관), platform factory의 예약 쓰기 허용, 개수·바이트·이름·값 상한, `withPayload`의 identity 보존 | 실제 브로커가 이 값을 받아들이는지 |
| `WireSafeValueObjectTest` | 7 | 헤더 이름 CRLF·NUL·콜론·후행공백 거절, 메시지 타입 개행 거절, 바이트 경계, v4 거절 | — |
| `WireBoundaryRejectionTest` | 27 | 제어문자 6종 파라미터화, 바이트 경계, traceparent 무효 7종, tracestate/baggage 경계, 자격증명 이름 5종 거절 + 오탐 2종 통과, `msg.` prefix 소유 | 실제 collector/브로커 동작 |
| `ConsumerContractTest` | 8 | `HandleResult` 4변형 고정, attempt 1 규칙, redelivered 모순 거절, `SETTLED` 불변식, `DeliveryContext.isExpired` 경계 | production이 `DeliveryContext`를 만드는지 |
| `DestinationCapabilityTest` | 5 | 논리 이름에 브로커 주소 불가, 대문자 거절, `MessagingCapabilities.none()`, `DestinationKind` 7종 고정 | capability 선언이 실제 브로커와 맞는지 |
| `PublishResultTest` | 13 | §4.1의 금지 조합 중 9가지를 직접 단언 | 실제 adapter가 이 조합을 만들지 않는지 |
| `ModuleSmokeTest` | 1 | 패키지 이름 | 사실상 아무것도 |
**이 레인이 증명하는 것의 성격.** 전부 `new`로 값을 만들고 예외를 기대하는 순수 단위 테스트다. 브로커도, Spring 컨텍스트도, 네트워크도 없다. 그래서 "계약이 자기 자신과 모순되지 않는다"는 증명되고, "adapter가 이 계약을 지킨다"는 증명되지 않는다. 후자는 `messaging-kafka`·`messaging-rabbit`의 contract harness가 소유하고 이 leaf 밖이다.
`ConsumerContractTest.deliveryContextReportsHandlerDeadlineExpiry`가 특히 그렇다 — 경계 동작은 정확히 검증되지만, §12.1이 보이듯 production 코드는 `DeliveryContext`를 만들지 않으므로 그 검증이 실행 경로를 보호하고 있지는 않다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 위치 | 이 leaf에 대해 실제로 무엇을 막는가 | 실패 지점 |
|---|---|---|---|
| registry fail-closed | `src/config/architecture/modules.json` + `ca.architecture-registry.settings.gradle` | 등록되지 않은 leaf는 settings에 포함되지 않음 | Gradle configuration |
| `verifyCleanArchitectureDependencies` | `src/build.gradle` | 실제 project 의존 edge를 `allowed_dependencies: []`와 대조 — 이 leaf에 의존성을 하나라도 추가하면 실패 | Gradle task |
| `verifyRuntimeModuleMembership` | `src/build.gradle` | 코드만 추가해서 런타임에 들어가는 것을 막음. registry를 먼저 고쳐야 함 | Gradle task |
| `APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM` | `CleanArchitectureTest.java:229` | `..application..``dev.caskeleton.messaging..`을 참조하는 것을 금지 | ArchUnit |
| checkstyle / spotbugs | convention plugin | `build/reports/{checkstyle,spotbugs}` 생성 확인 | Gradle |
**이 leaf에 직접 걸리는 messaging 전용 ArchUnit 규칙은 없다.** `MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS`(`CleanArchitectureTest.java:2068`)는 `..adapter.outbound.messaging..`을 대상으로 하고 이 leaf(`dev.caskeleton.messaging.api`)가 아니다.
`src/build.gradle:65-110``messagingVerificationSkeletons`(9개 `verifyMessaging*` task)는 전부 `app-bootstrap/build/messaging-evidence/**/manifest.json`을 요구하는 fail-closed 자격 게이트이고, 이 leaf의 산출물을 요구하지 않는다.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/269-messaging-core-api-reachability.txt`, `evidence/raw/270-messaging-runtime-membership-doc-drift.txt`.
검색 명령(전부 revision `21234e38`에서 실행):
```bash
git grep -n -w '<PublicType>' -- src ':!src/messaging/messaging-core-api'
```
`git grep`은 무매치에서 exit 1을 반환하므로, 아래의 "0"은 전부 exit 1로 확인한 값이다.
### 12.1 Public surface reachability
85개 타입 중 leaf 밖 참조가 0인 것은 **21개**다. 성격이 다른 세 묶음으로 나뉜다.
**(a) 내부 헬퍼 — 문제 없음 (1)**
`WireSafeText`. 이 leaf의 값 객체들이 내부적으로만 부른다. public인 것은 패키지가 나뉘어 있어서다.
**(b) 소비자 없는 예외 어휘 (12)**
`MessageAuthenticationException`, `MessageBrokerUnavailableException`, `MessageConsumerException`, `MessageDeadLetterException`, `MessageHandlerTimeoutException`, `MessageHeaderRejectedException`, `MessagePublishAmbiguousException`, `MessagePublishRejectedException`, `MessageRedriveException`, `MessageRetryExhaustedException`, `MessageRoutingException`, `MessageSettlementUnknownException`.
기반 타입 `MessagingException`은 살아 있다 — leaf 밖 3곳이 쓴다:
- `DefaultMessagingAdminService.java:223``instanceof`로 분류
- `ClaimCheckIntegrityException.java:20``extends`
- `PublishResults.java:37``instanceof`로 sanitized descriptor 추출
**계층은 쓰이고 잎은 쓰이지 않는다.** 특히 `MessagePublishAmbiguousException`은 이 설계 전체의 중심 개념(`AMBIGUOUS`)에 이름을 준 타입인데 아무도 던지지 않는다. adapter들은 예외 대신 `PublishResult`를 반환하는 경로를 쓰고(§6.2에서 `MessagingConfigurationException` 59회, `MessageTooLargeException` 17회처럼 실제로 쓰이는 것들은 대부분 **설정/검증** 계열이다), 발행·정산의 실패는 결과 record로 흐른다.
**(c) 소비자 없는 consumer-side 계약 (8)**
| 타입 | 선언된 역할 | leaf 밖 참조 |
|---|---|:---:|
| `MessageHandler<T>` | "The M1 typed handler implemented by ordinary business code" | 0 |
| `BatchMessageHandler<T>` | "The M2 batch consume entry point" | 0 |
| `BatchMessageDelivery<T>` | 배치 핸들러에 넘겨지는 배치 | 0 |
| `ManualMessageHandler<T>` | "The M2 handler that settles its own deliveries" | 0 |
| `PauseResumeController` | "The M2 consumer flow-control entry point" | 0 |
| `ProcessingGuarantee` | 중복 처리 무력화 방식 | 0 |
| `DelayedMessagePublisher` | "The M2 scheduled-delivery entry point" | 0 |
| `CapabilityRegistry` | 목적지별 capability 해석 | 0 |
이 중 `MessageHandler<T>`가 가장 무겁다. **선언된 핸들러 계약과 실제로 배선된 핸들러 계약이 다르다.**
`messaging-core-api`가 선언하는 것:
```java
// delivery/MessageHandler.java:14-22
public interface MessageHandler<T> {
CompletionStage<HandleResult> handle(MessageDelivery<T> delivery);
}
```
`MessageDelivery<T>``MessageEnvelope<T>` + `DeliveryMetadata` + `DeliveryContext`를 묶는다.
핸들러 결과를 정산으로 바꾸는 **유일한** 지점(`messaging-runtime-core.DefaultDeliveryProcessor`)이 실제로 받는 것:
```java
// DefaultDeliveryProcessor.java:40, 47
private final Function<MessageEnvelope<EncodedMessage>, HandleResult> handler;
```
세 가지가 다르다.
1. **동기다.** `CompletionStage`가 아니라 `Function`이므로 핸들러가 비동기일 수 없다.
2. **`MessageDelivery`가 없다.** 봉투만 받는다. 따라서 `DeliveryMetadata.deliveryAttempt`(몇 번째 시도인가)와 `redelivered`가 핸들러에 도달하지 않는다.
3. **`DeliveryContext`가 없다.** `handlerDeadline`·`isExpired(now)`·`shutdownRequested`가 도달하지 않는다.
세 번째는 독립적으로도 확인된다. `DeliveryContext`의 leaf 밖 참조 4건은 **전부 테스트 파일**이다 — `KafkaContractHarness.java:7,216``DeadLetterOrchestratorTest.java:12,226`. production 소스에서 `DeliveryContext`를 만드는 코드는 저장소에 없다. `DeliveryContext`의 javadoc이 설명하는 graceful drain 협력("a long-running handler that can wind down early shortens the drain")은 현재 배선으로는 일어날 수 없다.
한편 `MessageDelivery``DeliveryMetadata`는 production에서 **쓰인다** — 다만 핸들러에 넘기기 위해서가 아니라 DLQ·retry 경로에서 쓰인다:
- `MessageDelivery`: `KafkaDeadLetterPublisher:44`, `KafkaRetryExecutor:72`, `KafkaRetryTopicPublisher:59`, `RabbitDeadLetterPublisher:67`, `DeadLetterOrchestrator:69`, `TransactionalInboxHandler:49`
- `DeliveryMetadata`: `KafkaDeliveryMapper:105`, `RabbitDeliveryMapper:107`, `policy/RetryContext:23`, `transport-spi/TransportDelivery:21`
그리고 핸들러 계약은 저장소에 **셋**이 있다:
| 인터페이스 | 소유 leaf | 시그니처 | 구현체 |
|---|---|---|---|
| `MessageHandler<T>` | `messaging-core-api` | `CompletionStage<HandleResult> handle(MessageDelivery<T>)` | **없음** |
| `IdempotentMessageHandler<T>` | `messaging-reliability-api` | `CompletionStage<HandleResult> handleOnce(String, MessageDelivery<T>, TransactionalMessageAction<T>)` | `TransactionalInboxHandler` |
| (익명) `Function<MessageEnvelope<EncodedMessage>, HandleResult>` | `messaging-runtime-core` | 동기, 봉투만 | 생성자 인자 |
**(d) 배치 경로: 만들어진 metadata를 받을 곳이 없다**
`BatchDeliveryMetadata`는 leaf 밖 참조가 **있다**(0이 아니다). 두 registrar가 만든다:
- `KafkaBatchConsumerRegistrar.java:104``metadataFor(partition, slice, now)`
- `RabbitBatchConsumerRegistrar.java:139``release(now)`
그리고 둘 다 자기 브로커 전용 record에 담는다(`PartitionBatch`, `AmqpBatch`). 두 record의 javadoc이 같은 문장을 쓴다:
```
* @param metadata the batch-wide metadata handed to the handler
```
그런데 `new BatchMessageDelivery`는 저장소 전체에서 **0건**이고(`git grep` exit=1), `BatchMessageHandler`를 구현하거나 참조하는 코드도 0건이다. 즉 두 registrar는 배치 metadata를 정확히 계산해서(Kafka는 파티션 단위라 `settlableAsBatch=true`, Rabbit은 multiple-ack이 in-flight까지 정산하므로 `false`) 브로커별 record에 넣고, **javadoc이 말하는 handler로의 전달은 존재하지 않는다.**
**(e) 한계**
`git grep` 기반 정적 검색이므로 다음을 덮지 못한다: 리플렉션 조회, `ServiceLoader`, 애노테이션 프로세서 생성 코드, 문자열로 조립한 클래스 이름, 이 저장소 밖의 소비자. 다만 이 leaf에는 애노테이션이 0개이고 `META-INF/services`도 없으며(`find` 결과 resources 디렉터리 자체가 없다 — Gradle이 `processResources NO-SOURCE`를 보고한다), 이 저장소는 라이브러리 배포 저장소가 아니라 템플릿이므로 "저장소 밖 소비자"가 유일하게 남는 가능성이다. §17에서 그 갈래를 다룬다.
### 12.2 Conditional sibling comparison
**적용 대상 없음.** 이 leaf에는 Spring stereotype·`@Bean`·`@Conditional`·`@Profile`이 0개이고(`git grep` exit=1), bean을 하나도 만들지 않는다. 비교할 sibling이 존재하지 않는다.
이 leaf의 활성화 비대칭은 다른 축에서 일어난다 — registry `runtime_memberships`. §12.4 참조.
### 12.3 Duplicate mechanism sweep
**(a) UUIDv7 생성기**
저장소에 UUIDv7을 다루는 production 구현이 여럿이다.
| 위치 | 성격 |
|---|---|
| `messaging-core-api/.../api/UuidV7.java` | 이 leaf. `AtomicLong` packing, 밀리초 내 단조 카운터 |
| `adapter/outbound/notification/.../dispatch/UuidV7Generator.java` | notification 플랫폼 전용 |
| `adapter/outbound/persistence-jpa/src/testkit/.../id/UuidV7Generator.java` | testkit source set |
| `adapter/outbound/persistence-mongo/.../mapping/DomainDocumentId.java` | Mongo 문서 id |
| `application-core/.../notification/platform/api/NotificationId.java` 외 | 애플리케이션 식별자 |
| `sample-portfolio/.../identifier/Uuid*Factory.java` (3종) | 샘플 |
`messaging-core-api.UuidV7`의 leaf 밖 참조는 **1건**이다(`messaging-testkit`의 JMH 벤치마크). 즉 messaging 밖에서는 아무도 이 구현을 쓰지 않고 각자 만들었다.
이것이 자동으로 결함은 아니다 — 모듈 경계가 의존을 금지하는 구조(`APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`)에서는 중복이 **의도된 비용**일 수 있다. 다만 §4.10의 밀리초 내 단조성 같은 성질이 구현마다 같은지는 이 leaf가 답할 수 없고, family 밖이므로 cross-scope가 소유한다.
**(b) wire-safe 텍스트 검증**
`WireSafeText`의 leaf 밖 참조는 **0**이다. 그런데 제어문자·인코딩 경계를 각자 검사하는 곳이 저장소에 최소 15개 있다 — `web/conditional/EntityTag`, `web/http/WebUriPolicy`, `cache-redis/.../codec/RedisEnvelope`, `fileserver/FileserverControlRecordCodec`, `mongo/changestream/MongoChangeEventIdentity`, `application-core/cache/CacheRefreshOwnerToken`, `application-core/objectstorage/model/ObjectMediaType`, `grpc-core-api/core/GrpcIdentifiers`, `shared-contract/ratelimit/EdgeRateLimitSubject` 등.
`WireSafeText`의 javadoc은 그 존재 이유를 "Each copy of this check that lived in its own record was one more place for the rule to drift"라고 적는데, 그 통합은 **이 leaf 안에서만** 일어났다. 저장소 수준에서는 여전히 각자 검사한다. 다시 말해 규칙은 옳게 진술됐고 적용 범위가 leaf 경계에서 멈춘다.
**(c) 헤더 네임스페이스**
`msg.` 리터럴을 이 leaf 밖에서 쓰는 production 코드는 **1곳**뿐이다 — `messaging-observability/.../MessagingRedactor.java:24``"msg.id"`를 문자열 리터럴로 갖는다. 나머지 매치는 Kafka 테스트다. 상수(`ReservedHeaders.MESSAGE_ID`)가 있는데 리터럴을 쓴 것이므로, 상수가 바뀌면 redactor가 조용히 어긋난다. 작지만 실재하는 drift 표면이다.
### 12.4 Documentation / measured-count drift
**확인된 drift 1건.** 원시 증거 `evidence/raw/270-messaging-runtime-membership-doc-drift.txt`.
`docs/messaging/support-matrix.md:23-24`가 이렇게 말한다:
> 또한 registry의 messaging leaf는 모두 `runtime_memberships`가 비어 있다. 이는 **build-only / incubating** — 어느 composition root에도 편입되지 않았다는 뜻이며…
현재 revision에서 registry를 다시 세면:
```
messaging leaves : 25
runtime_memberships empty : 7
runtime_memberships wired : 18
```
`messaging-core-api` 자신이 wired 18개에 포함된다. 즉 이 문장은 **이 leaf에 대해 직접 틀렸다.**
같은 문단의 마지막 문장은 "자세한 규칙은 `src/messaging/CLAUDE.md`가 소유한다"고 가리키는데, 그 파일은 이미 정정을 기록해 두었다(`src/messaging/CLAUDE.md:46-59`):
> **이 절은 한동안 사실이 아닌 채로 남아 있었다.** "registry의 모든 messaging leaf는 `runtime_memberships`가 비어 있고 따라서 build-only"라고 쓰여 있었는데, 다섯 어댑터 remediation이 `messaging-spring-boot-starter`를 `app-bootstrap` 의존성으로 넣으면서 그 closure 전체가 런타임 classpath에 올라갔다. 정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다.
그래서 이것은 단순한 오래된 문서가 아니다. **같은 저장소의 두 문서가 같은 revision에서 서로 모순되고, 틀린 쪽이 옳은 쪽을 권위로 지목하고 있다.** 그리고 틀린 쪽이 운영자가 읽는 지원 매트릭스다. `CLAUDE.md`가 도달한 결론("세는 순간 다시 drift한다")이 정확히 support-matrix에는 적용되지 않았다.
영향 방향이 중요하다 — 문서는 실제보다 **약하게** 진술한다. "아무것도 배선되지 않았다"고 읽은 운영자는 배포 아티팩트가 이 leaf들을 싣고 있고 `app.messaging.enabled` 하나로 켜진다는 사실을 모른다. 과대 진술보다는 낫지만, 사고 시 조사 범위를 좁히는 방향의 오류다.
**나머지 문서 주장은 재측정에서 일치했다.**
- `docs/superpowers/plans/…:13` "`messaging-core-api`에는 Spring/broker/Reactor 의존성을 넣지 않는다" → 참(import 0개, `build.gradle` 빈 dependencies)
- `docs/messaging/experimental-policy.md:44` "`messaging-core-api`의 타입을 바꾸지 않는다" → 정책 문장이며 이번 revision에서 위반 근거를 찾지 못함
**측정하지 않은 것.** `docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md`의 경로(`modules/messaging/…`)와 패키지(`io.backend.skeleton.messaging.api`)는 현재 소스(`src/messaging/…`, `dev.caskeleton.messaging.api`)와 다르다. 다만 이것은 계획 문서이고 실행 후 이름이 바뀐 것으로 보이므로 "drift"로 분류하지 않고 §13의 역사로 기록한다.
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
이 leaf를 건드린 커밋은 4개다.
```
a24ece9c feat: web, websocket 어댑터 추가 구현
01372634 refactor: 각 어댑터터별 리펙토링 진행
2f5d2fc2 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가
d646c2f1 feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가
```
최초 커밋 메시지는 **24개 leaf**라고 적었고 현재 registry의 messaging leaf는 **25개**다. 이후 커밋에서 하나가 늘었다는 뜻이며, 커밋 메시지는 그 시점의 사실이므로 drift로 분류하지 않는다.
**코드 주석이 보존한 실패 이력**이 이 leaf의 가장 밀도 높은 사료다. 아래는 전부 "예전에는 이랬고 그래서 무엇이 깨졌다"를 현재 코드가 직접 적어 둔 것이다.
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `WireSafeText` 클래스 javadoc | 각 값 객체가 Java `char`로만 길이 검사 | 240자 = 최대 960바이트. 바이트를 세는 브로커가 발행 시점에 거절 |
| `HeaderName.TOKEN` 주석 | "not blank, at most 128 bytes" | CR/LF/NUL/콜론이 통과 → 헤더 인젝션, 이름 절단, 레코드 분할 |
| `HeaderName` 공백 검사 주석 | `strip()`으로 trim | `Authorization `이 denylist에는 같은 이름, wire에는 다른 이름 → 우회 |
| `HeaderValue` javadoc | 길이 상한만 | 값 안의 CRLF가 line-oriented 바인딩에서 헤더를 끝내고 새 헤더 시작 |
| `CorrelationId` javadoc | 160 **문자** 상한 | 640바이트 값이 흐름 중간에 거절됨 — 다른 identity로 재전송할 수 없는 메시지에서 |
| `MessageEnvelope` 생성자 주석 | partitionKey/orderingKey 무제한 | orderingKey는 여러 바인딩이 wire에 싣는다 → 헤더와 같은 인젝션 표면 |
| `MessageId` 생성자 주석 | 아무 UUID나 허용 | v4가 같은 컬럼에 들어가 outbox의 "oldest first"를 무력화 |
| `TraceContext` javadoc | non-null 검사만 | 파싱 불가 `traceparent`를 collector가 **드롭** → 조사 중인 바로 그 hop에서 trace 소실 |
| `ReservedHeaders.PLATFORM_PREFIX` 주석 | `NAMES` 정확 일치 | 다음 릴리스가 `msg.x`를 정의하는 순간 기존 애플리케이션이 봉투 메타데이터를 덮어씀 |
| `ReservedHeaders.TENANT` javadoc | 헤더 이름 자체가 없었음 | 소비된 메시지가 전부 빈 tenant로 재구성됨 — 하위 authorization/파티셔닝이 읽는 필드 |
| `MessageHeaders.SECRET_SEGMENTS` 주석 | 정확 이름 매칭만 | `x-api-key`·`auth-token`·`db_password`가 전부 통과 |
| `PublishOptions` javadoc | 자유형 hint map 존재 | 읽는 쪽이 없어서 런타임 거절만 유발하는 escape hatch |
| `CanonicalEnvelopeHeaders` javadoc | 예약 네임스페이스를 통째로 "위조 가능한 내용"으로 취급 | `msg.retry-attempt`까지 드롭 → attempt 카운터가 1로 재시작, retry 예산이 아무것도 제한하지 못함 |
| `DefaultDeliveryProcessor` javadoc (다른 leaf, 이 계약 관련) | `HandleResult`를 정산에 연결하는 곳이 없었음 | 각 브로커 adapter가 retry/dead-letter의 뜻을 각자 결정 |
이 목록 자체가 이 leaf의 성격을 말한다 — **13개 이상의 wire 경계 결함을 한 번에 정리한 흔적**이고, 대부분이 "검사가 없었다"가 아니라 "검사가 잘못된 단위(문자 vs 바이트, 정확일치 vs 세그먼트, 이름목록 vs prefix)로 되어 있었다"이다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-269 | command | `evidence/raw/269-messaging-core-api-reachability.txt` | 21개 타입의 leaf 밖 참조 0(exit=1), `DeliveryContext`의 test-only 성격, 세 핸들러 계약, `new BatchMessageDelivery` 0건, leaf의 무의존성 | `git grep` 정적 검색. 리플렉션·서비스로더·저장소 밖 소비자 미포함 |
| EVD-270 | command | `evidence/raw/270-messaging-runtime-membership-doc-drift.txt` | support-matrix.md:23의 주장과 registry 재측정(25/7/18), `CLAUDE.md`의 정정 기록, starter 조립 edge | 한 시점 registry snapshot |
| EVD-271 | command | `:messaging:messaging-core-api:test --rerun-tasks` | BUILD SUCCESSFUL, 79 tests / 0 skipped / 0 failures | 순수 단위 테스트 레인. 브로커·Spring 없음 |
`evidence/raw/`에는 primary output만 둔다. 위 해석은 전부 이 문서가 소유한다.
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적(코드 주석·javadoc·테스트 이름·설계 문서가 직접 말함)**
- `EXACTLY_ONCE`·`GLOBAL` 부재 — `DeliveryGuarantee`/`OrderingScope` javadoc + `CoreValueTypesTest`
- 3상태 발행 결과 — `PublishCompletion` javadoc
- 증거가 결론보다 먼저 — `PublishEvidence` javadoc
- 정제 대신 거절 — `WireSafeText` javadoc
- `msg.` prefix 소유 — `ReservedHeaders.PLATFORM_PREFIX` 주석
- 세그먼트 매칭 + 인접 결합 — `MessageHeaders.carriesACredential` 주석
- 예약 네임스페이스 2분할 — `CanonicalEnvelopeHeaders` javadoc
- `messageId` 보존이 `withPayload`의 목적 — `MessageEnvelope.withPayload` javadoc
- `rand_a`를 카운터로 — `UuidV7` javadoc
- 애플리케이션이 이 플랫폼을 참조하지 않는 이유 — `CleanArchitectureTest:229` `.because(...)`
- `runtime_memberships`의 현재 의미 — `src/messaging/CLAUDE.md:46-70`
**추론(이 문서의 판단이며 코드가 직접 말하지 않음)**
- `MessageHandler<T>`가 미사용인 것은 `DefaultDeliveryProcessor`가 다른 시그니처를 택했기 때문이다 → **추론**. 두 사실(선언 존재, 다른 시그니처 사용)은 관측이고, 인과는 추론이다. 커밋 메시지나 ADR에서 이 선택의 근거를 찾지 못했다.
- 12개 예외가 미사용인 것은 adapter들이 예외 대신 결과 record 경로를 택했기 때문이다 → **추론**. `MessagingConfigurationException`(59회)처럼 실제 쓰이는 것들이 설정/검증 계열에 몰려 있다는 관측에서 나온 설명이다.
- 저장소 밖 소비자가 있을 가능성 → **가설**. 확인 수단이 이 저장소 안에 없다.
**관측했으나 원인을 모름**
- `MessagingRedactor.java:24`가 상수 대신 `"msg.id"` 리터럴을 쓰는 이유
- `ContentType`만 바이트가 아니라 문자로 상한을 두는 이유
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- production 85파일 전부의 계약·불변식·경계값 (§4, §8)
- 79개 테스트가 실제로 통과하고 무엇을 단언하는지 (§10)
- 21개 타입의 leaf 밖 참조 0 — 재현 가능한 명령과 exit code로 (§12.1)
- 선언된 핸들러 계약과 배선된 핸들러 계약의 불일치 (§12.1)
- 배치 metadata를 만드는 두 지점과, 그것을 받을 `BatchMessageDelivery`가 0건이라는 사실 (§12.1)
- `support-matrix.md:23`의 주장이 현재 registry와 어긋난다는 것 (§12.4)
- 이 leaf가 외부 의존성 0이라는 것 (§1, §12.2)
- 코드 주석이 보존한 13건 이상의 이전 결함 이력 (§13)
**확인하지 못한 것**
- **경합 하 `UuidV7` 단조성.** `updateAndGet`의 CAS 성질에서 추론되지만 다중 스레드 테스트가 없다. `CoreValueTypesTest`는 단일 스레드 2회 호출만 본다.
- **저장소 밖 소비자.** 이 템플릿을 가져다 쓰는 파생 프로젝트가 `MessageHandler`·`CapabilityRegistry` 등을 구현하는지 확인할 방법이 이 저장소 안에 없다. §12.1(c)와 §17의 판단이 이 미지수에 걸려 있다.
- **실제 브로커가 이 경계값을 받아들이는지.** 128바이트 헤더 이름, 32,768바이트 헤더 총량, 1,024바이트 ordering key가 Kafka·RabbitMQ에서 실제로 통과하는지는 이 leaf의 레인이 증명하지 않는다. `messaging-kafka`/`messaging-rabbit`의 컨테이너 레인이 소유하고, 그 레인들은 이번 분석에서 실행하지 않았다.
- **`MessagingRedactor`의 리터럴이 실제로 어긋난 적이 있는지.** 현재는 `ReservedHeaders.MESSAGE_ID`와 값이 같다.
---
## 17. 손볼 것
### P2 — 선언된 핸들러 계약이 배선된 것과 다르다
- **사실.** `MessageHandler<T>`(`delivery/MessageHandler.java:14`)의 저장소 전체 참조가 0이다. 핸들러 결과를 정산으로 바꾸는 유일한 지점 `DefaultDeliveryProcessor``Function<MessageEnvelope<EncodedMessage>, HandleResult>`를 받는다.
- **근거.** `evidence/raw/269` §A, §D.
- **왜 문제인가.** `MessageDelivery`가 빠지면서 `deliveryAttempt`·`redelivered`·`handlerDeadline`·`shutdownRequested`가 핸들러에 도달할 수 없다. `DeliveryContext`의 javadoc이 설명하는 graceful drain 협력은 현재 배선으로는 성립하지 않는다. 그리고 새 소비자를 붙이는 사람은 공개 API에서 `MessageHandler`를 먼저 보게 되는데, 그것을 구현해도 아무 데도 꽂히지 않는다.
- **확인 방법.** `git grep -n -w MessageHandler -- src ':!src/messaging/messaging-core-api'` → exit 1. `DefaultDeliveryProcessor.java:40,47` 확인.
- **후보.** (a) `DefaultDeliveryProcessor``MessageHandler<T>`를 받도록 시그니처를 맞춘다 — `MessageDelivery`를 조립해야 하므로 `DeliveryContext` 생성 책임을 runtime에 준다. (b) `MessageHandler`·`DeliveryContext`를 이 leaf에서 제거하고 실제 계약만 남긴다. (c) 파생 프로젝트가 구현하는 확장점이라면 그 사실을 javadoc과 `support-matrix.md`에 명시한다.
- **다음 단계.** 세 선택지는 "저장소 밖 소비자가 있는가"라는 미지수에 걸린다(§16). 그 답을 먼저 정해야 한다 → **OPEN QUESTION 후보.** 답이 정해지면 CASE 승격 가능.
### P2 — 배치 metadata를 만들고 넘길 곳이 없다
- **사실.** `KafkaBatchConsumerRegistrar:104``RabbitBatchConsumerRegistrar:139``BatchDeliveryMetadata`를 만들고, 두 javadoc 다 "the batch-wide metadata handed to the handler"라고 적는다. `new BatchMessageDelivery`는 저장소 전체에서 0건이고 `BatchMessageHandler` 참조도 0건이다.
- **근거.** `evidence/raw/269` §E (`git grep 'new BatchMessageDelivery'` exit=1).
- **왜 문제인가.** 두 registrar는 브로커별로 다른 정확한 계산을 한다 — Kafka는 파티션 단위 커밋이라 `settlableAsBatch=true`, Rabbit은 multiple-ack이 in-flight까지 정산하므로 `false`. 이 판단이 계산되어 어디에도 전달되지 않는다. javadoc은 존재하지 않는 수신자를 가리킨다.
- **확인 방법.** `git grep -n 'new BatchMessageDelivery' -- 'src/**/*.java'` → exit 1.
- **후보.** 배치 경로를 완성하거나(handler 인터페이스를 registrar에 연결), 미완성임을 javadoc과 `support-matrix.md`에 표시하거나, `BatchMessageHandler`/`BatchMessageDelivery`를 제거한다.
- **다음 단계.** **CASE 후보.** 재현이 정적 검색으로 끝나고 결론이 경계 안에서 닫힌다.
### P2 — 운영자용 지원 매트릭스가 런타임 편입을 반대로 적는다
- **사실.** `docs/messaging/support-matrix.md:23`이 "registry의 messaging leaf는 모두 `runtime_memberships`가 비어 있다 … 어느 composition root에도 편입되지 않았다"고 적는다. 현재 registry는 25개 중 **18개**가 `["app-bootstrap"]`이고 `messaging-core-api`가 그 안에 있다.
- **근거.** `evidence/raw/270`.
- **왜 문제인가.** 같은 문단이 권위로 지목하는 `src/messaging/CLAUDE.md:46-59`는 이미 정정을 기록했고 "정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다"는 결론까지 적었다. 그 결론이 support-matrix에는 적용되지 않았다. 배포 아티팩트가 실제로 이 leaf들을 싣고 `app.messaging.enabled` 하나로 켜진다는 사실을 운영자가 문서에서 알 수 없다.
- **확인 방법.** `evidence/raw/270`의 python 블록 재실행.
- **후보.** support-matrix의 해당 문장을 삭제하고 `CLAUDE.md`로 위임하거나(문장이 이미 그렇게 하고 있다), registry에서 파생하는 생성 문서로 바꾼다.
- **다음 단계.** **CASE 후보 + REFERENCE 후보**("숫자는 세지 말고 소유자에게 위임하거나 게이트로 붙든다"). 두 문서가 같은 revision에서 모순되고 틀린 쪽이 옳은 쪽을 가리킨다는 형태 자체가 재사용 가능한 기준이다.
### P3 — 12개 예외가 선언만 되어 있다
- **사실.** 23개 구체 예외 중 12개가 leaf 밖 참조 0이다(§6.2 표).
- **근거.** `evidence/raw/269` §B.
- **왜 문제인가.** 지금 당장 깨지는 것은 없다. 다만 `MessagePublishAmbiguousException`처럼 설계의 중심 개념에 이름을 준 타입이 던져지지 않으면, 그 개념이 실제로 어떤 경로로 표현되는지(결과 record)를 읽는 사람이 스스로 알아내야 한다. 그리고 `src/messaging/CLAUDE.md:44` — "새 public 타입은 그 모듈의 계약이다. 삭제·시그니처 변경은 breaking change로 취급한다" — 때문에 나중에 정리하는 비용이 계속 커진다.
- **확인 방법.** `evidence/raw/269` §B 재실행.
- **후보.** adapter들이 결과 record 대신 예외를 던져야 하는 지점을 정하거나, 미사용 예외를 제거하거나, "이것은 파생 프로젝트용 어휘"임을 명시한다.
- **다음 단계.** P2 첫 항목과 같은 미지수(저장소 밖 소비자)를 공유한다 → 그 OPEN QUESTION에 **MERGED** 후보.
### P3 — `MessagingRedactor`가 상수 대신 문자열 리터럴을 쓴다
- **사실.** `messaging-observability/.../MessagingRedactor.java:24``"msg.id"`를 리터럴로 갖는다. `ReservedHeaders.MESSAGE_ID` 상수가 있다.
- **근거.** `git grep '"msg\.'` — production 매치는 이 한 곳뿐.
- **왜 문제인가.** 상수가 바뀌면 redaction이 조용히 대상을 잃는다. 컴파일러가 잡지 않는다.
- **확인 방법.** `git grep -n '"msg\.' -- 'src/**/*.java' | grep -v messaging-core-api`
- **후보.** 리터럴을 `ReservedHeaders.MESSAGE_ID`로 교체.
- **다음 단계.** `messaging-observability` leaf SSOT가 소유한다. 여기서는 교차 참조만 남긴다.
### P3 — `WireSafeText`의 규칙이 leaf 경계에서 멈춘다
- **사실.** `WireSafeText`의 leaf 밖 참조 0. 제어문자·인코딩 경계를 각자 검사하는 곳이 저장소에 최소 15개.
- **근거.** §12.3(b).
- **왜 문제인가.** javadoc이 "Each copy of this check ... was one more place for the rule to drift"라고 적었고 그 통합을 leaf 안에서만 했다. 저장소 수준에서는 같은 drift가 그대로 남아 있다.
- **확인 방법.** `git grep -l -E 'requireNoControls|control character|0x7F' -- 'src/**/*.java'`
- **후보.** 규칙을 공유 위치(`shared-contract`)로 올리거나, leaf 경계를 이유로 중복을 명시적으로 수용한다고 적는다.
- **다음 단계.** 저장소 전역 판단이므로 **cross-scope 소유.** 여기서는 관측만 기록한다.
### 확인된 설계(문제 아님)
- 외부 의존성 0 — 계획 문서의 제약이 현재 소스에서 성립
- `EXACTLY_ONCE`/`GLOBAL` 부재가 테스트로 고정됨
- `PublishResult`의 14개 금지 조합 중 9개가 테스트로 커버됨
- 자격증명 세그먼트 매칭의 양방향(거절/오탐 회피) 테스트 존재
- `msg.` prefix 소유가 테스트로 고정됨
- W3C traceparent 무효 7종이 파라미터 테스트로 커버됨
---
## Source anchors
| id | kind | path / command | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MCA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | leaf id, `allowed_dependencies: []`, `runtime_memberships: ["app-bootstrap"]` | 선언이며 런타임 실행 자체는 아님 |
| MCA-002 | build | `src/messaging/messaging-core-api/build.gradle` | same | 선언 의존성 0 | convention plugin의 test 의존성은 별개 |
| MCA-003 | code | `src/main/java/**/api/*.java` (12) | same | 봉투와 값 객체 불변식, 바이트 경계, UUIDv7 검증 | — |
| MCA-004 | code | `src/main/java/**/api/header/*.java` (5) | same | 헤더 문법, 예약 prefix 소유, 자격증명 세그먼트 매칭, 두 factory 분리 | 실제 브로커 수용 여부는 미포함 |
| MCA-005 | code | `src/main/java/**/api/publish/*.java` (17) | same | 3상태 완료, 14개 금지 조합, 증거 우선 순서 | adapter가 이를 지키는지는 별개 |
| MCA-006 | code | `src/main/java/**/api/delivery/*.java` (13) | same | `HandleResult` sealed 4변형, attempt 1 규칙, 선언된 핸들러 계약 | 배선 여부는 §12가 답함 |
| MCA-007 | code | `src/main/java/**/api/settlement/*.java` (5) | same | 정산 3상태와 불변식 | — |
| MCA-008 | code | `src/main/java/**/api/error/*.java` (26) | same | 10개 카테고리, 기본 retryable 정책, 23개 예외의 카테고리 전수 | — |
| MCA-009 | code | `src/main/java/**/api/destination/*.java` (7) | same | 논리 목적지, 12개 capability boolean | capability 선언이 실제 브로커와 맞는지는 별개 |
| MCA-010 | test | `src/test/java/**` (8 클래스 / 79 테스트) | same | §10 표의 단언 | 순수 단위. 브로커·Spring 없음 |
| MCA-011 | architecture test | `src/app-bootstrap/.../CleanArchitectureTest.java:229-240` | same | `..application..``dev.caskeleton.messaging..` 금지와 그 이유 | 정적 분석. 헬퍼/AOP 우회는 별도 |
| MCA-012 | assembly | `src/app-bootstrap/build.gradle:87` | same | starter를 통한 전이 편입 경로 | 실행 활성화는 `app.messaging.enabled`가 결정 |
| MCA-013 | module policy | `src/messaging/CLAUDE.md:44, 46-70` | same | public 타입 = 계약, runtime membership의 현재 의미와 정정 기록 | 정책 문서 |
| MCA-014 | doc | `docs/messaging/support-matrix.md:18-27` | same | 운영자용 등급표와 런타임 편입 주장 | 23행이 registry와 어긋남(§12.4) |
| MCA-015 | design doc | `docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md:7,13` | same | 무의존성 제약의 원래 근거 | 계획 문서. 경로/패키지는 이후 변경됨 |
| MCA-016 | cross-leaf code | `messaging-runtime-core/.../DefaultDeliveryProcessor.java:22-99` | same | 핸들러 결과 → 정산의 유일한 지점과 그 시그니처 | 해당 leaf SSOT가 소유 |
| MCA-017 | cross-leaf code | `messaging-kafka/.../KafkaBatchConsumerRegistrar.java:102-124`, `messaging-rabbit/.../RabbitBatchConsumerRegistrar.java:133-160` | same | 배치 metadata 생성 지점과 "handed to the handler" javadoc | 해당 leaf SSOT가 소유 |
| MCA-018 | cross-leaf code | `messaging-reliability-api/.../IdempotentMessageHandler.java:21-32` | same | 세 번째 핸들러 계약의 존재 | 해당 leaf SSOT가 소유 |
| EVD-269 | command | `evidence/raw/269-messaging-core-api-reachability.txt` | same | §12.1·§12.2 전부, exit code 포함 | 정적 `git grep`. 리플렉션/서비스로더/저장소 밖 미포함 |
| EVD-270 | command | `evidence/raw/270-messaging-runtime-membership-doc-drift.txt` | same | §12.4의 drift, registry 재측정 25/7/18 | 한 시점 snapshot |
| EVD-271 | command | `./gradlew :messaging:messaging-core-api:test --rerun-tasks` | same | BUILD SUCCESSFUL, 79 / 0 skipped / 0 failures | 순수 단위 레인 |
@@ -0,0 +1,734 @@
# messaging-inbox-jdbc-postgresql 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-inbox-jdbc-postgresql`
> SSOT owner: `messaging-inbox-jdbc-postgresql`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-inbox-jdbc-postgresql`
- canonical state `analysisFile`: `analysis/messaging/messaging-inbox-jdbc-postgresql.md`
- source path: `src/messaging/messaging-inbox-jdbc-postgresql`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-reliability-api"]`
- registry `runtime_memberships`: `["app-bootstrap"]`
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 6 |
| production LOC | 542 |
| 패키지 | 1 (`dev.caskeleton.messaging.inbox`) |
| migration | 1 (`V2__messaging_inbox.sql`) |
| test 파일 | 4 |
| test 메서드(실행 확인) | **25** |
| 외부 의존성 | `spring-jdbc`, `spring-tx`(implementation) · testcontainers·postgresql·messaging-testkit(test) |
여섯 타입:
| 타입 | 역할 | leaf 밖 참조 |
|---|---|---:|
| `JdbcInboxRepository` | `InboxRepository` 구현 | 0 |
| `IdempotentConsumer` | 예약+부작용을 한 트랜잭션에 | 1 |
| `TransactionalInboxHandler` | `IdempotentMessageHandler` 구현 | 1 |
| `InboxCleanupJob` | 보존 스윕 | 1 |
| `InboxRetentionPolicy` | 보존 규칙 | 1 |
| `InboxOutcome` | 처리/중복 결과 | 0 |
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |
| `src/main/resources/db/migration/messaging/V2__messaging_inbox.sql` | 1 | `FULL_READ` | 18줄 전문 |
| `src/test/java/**` (4) | 4 | `FULL_READ` | fake 구현·테스트명·단언 확인 |
| `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
`messaging-reliability-api``InboxRepository`·`IdempotentMessageHandler` 포트를 PostgreSQL로 구현한다. 이름이 기술을 드러낸다 — `docs/messaging/support-matrix.md`가 그 개명 이유를 적는다(MSG-023).
**메커니즘 전체가 하나의 SQL 문장에 있다.**
```sql
INSERT INTO messaging_inbox (message_id, consumer_id, processed_at)
VALUES (?, ?, ?)
ON CONFLICT (message_id, consumer_id) DO NOTHING
```
```java
// JdbcInboxRepository.java:20-23
* <p>Reservation is an {@code INSERT ... ON CONFLICT DO NOTHING} whose affected-row count is the
* answer: one means first delivery, zero means already processed. The composite primary key does
* the work, so there is no read-then-write race two concurrent deliveries of the same message
* cannot both see "not processed" and both proceed.
```
migration이 같은 사실을 반대편에서 적는다.
```sql
-- The composite primary key is the deduplication mechanism: reserving a message is an INSERT that
-- either succeeds or violates the key, inside the same transaction as the handler's side effect.
-- Two independent consumers of the same event each get their own row, so one cannot suppress the
-- other.
```
`build.gradle` 주석이 테스트 전략을 명시한다.
```groovy
// Live-database certification. The reliability patterns are claims about transaction
// boundaries and uniqueness constraints, and only a real database can settle them.
testImplementation 'org.testcontainers:testcontainers-postgresql'
```
**그리고 실제로 실행된다**`InboxPostgresIT` 6개가 기본 `test` 태스크에서 통과한다(§10).
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api), `messaging-reliability-api`(api), `spring-jdbc`·`spring-tx`(implementation).
나가는 것: `messaging-spring-boot-starter`.
**배선됨.** starter의 `MessagingReliabilityAutoConfiguration`이 셋을 만든다.
| bean | 이 leaf의 타입 |
|---|---|
| `InboxRetentionPolicy` | o |
| `InboxCleanupJob` | o |
| `TransactionalInboxHandler<Object>` | o (`IdempotentConsumer`를 받음) |
`JdbcInboxRepository`는 그 목록에 없다 — `InboxRepository` bean을 누가 만드는지는 starter leaf가 답한다.
Spring 타입을 두 곳에서 쓴다 — `DataSourceUtils``TransactionSynchronizationManager`. 둘 다 `implementation` scope이고 public 시그니처에 나오지 않으므로 vendor `api` 규칙에 맞는다.
---
## 3. 패키지/컴포넌트 지도
```
TransactionalInboxHandler<T> (IdempotentMessageHandler<T> 구현)
└── handleOnce(consumerName, delivery, action)
└── IdempotentConsumer.runOnce(messageId, consumerId, now, sideEffect)
└── TransactionRunner.inTransaction(...) ← 호출자가 제공
├── InboxRepository.reserve(...) == false → InboxOutcome.duplicate()
└── true → sideEffect.get() → InboxOutcome.processed(...)
JdbcInboxRepository (InboxRepository 구현)
├── reserve(MessageId, String, Instant) ← requireActiveTransaction 3검사 후 위임
├── reserve(Connection, ...) ← package-private, 실제 INSERT
├── isProcessed(...) ← 자기 커넥션
├── purgeProcessedBefore(Instant, int) ← LIMIT + FOR UPDATE SKIP LOCKED. 호출자 0 (§12.1)
└── purgeProcessedBefore(Instant) ← 무제한 DELETE. 이것이 불린다
InboxCleanupJob(inbox, policy, maxBatches)
├── 생성자가 policy.validate()
└── runOnce(now) → maxBatches회 루프, 매회 무제한 purge
InboxRetentionPolicy(retention, maximumRedeliveryWindow)
├── REQUIRED_SAFETY_FACTOR = 2.0
└── validate() → retention >= window * 2 아니면 INBOX_RETENTION_TOO_SHORT
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `requireActiveTransaction` — 세 겹 검사
이 leaf에서 가장 중요한 안전 장치이고 이전 결함이 javadoc에 있다.
```java
// JdbcInboxRepository.java:53-57
* <p>Package-private. It used to be public and was the only path that actually joined the
* caller's transaction, while the interface method the one {@code IdempotentConsumer} calls
* opened a raw connection that auto-commits. A reservation that commits on its own while the
* business side effect rolls back is a message that will never be redelivered and whose work
* never happened.
```
**두 개의 오버로드가 있었고 호출되는 쪽이 틀린 쪽이었다.** 현재는 interface 메서드가 세 가지를 확인한다.
| 검사 | 실패 시 메시지의 핵심 |
|---|---|
| `isActualTransactionActive()` | "a reservation that commits alone marks a message processed whose work may still roll back" |
| `!isCurrentTransactionReadOnly()` | "the current one is read-only" |
| `hasResource(dataSource)` | "it is bound to another, so the reservation and the side effect would commit independently" |
세 번째가 특히 정교하다 — **트랜잭션이 활성이어도 다른 DataSource에 묶여 있으면 거절한다.** 멀티 데이터소스 배포에서 실제로 발생하는 형태이고, 그 경우 예약과 부작용이 서로 다른 트랜잭션에 들어간다.
세 검사 전부 같은 코드 `INBOX_TRANSACTION_REQUIRED`를 쓴다 — 메시지만 다르다.
```java
// requireActiveTransaction javadoc:96-99
* <p>The reservation and the side effect it guards have to commit or roll back together. Running
* the reservation on its own connection breaks that on the rollback path only which is the path
* nobody exercises before production, and the one where the message is lost for good.
```
**"the path nobody exercises before production"**가 이 leaf의 테스트 전략을 설명한다 — `InboxPostgresIT.aRolledBackTransactionLeavesNoReservationAndNoSideEffect`가 정확히 그 경로를 실 DB에서 돈다.
### 4.2 `IdempotentConsumer` — 트랜잭션을 열지 않는다
```java
// :12-15
* <p>The reservation and the side effect must share one transaction. This class does not open that
* transaction itself the caller supplies a runner that does because the boundary belongs to the
* application's data access layer, and a nested or separate transaction here would silently break
* the guarantee while still looking correct.
```
`TransactionRunner`가 함수형 인터페이스이고 `<T> T inTransaction(Supplier<T> work)` 하나다. 즉 이 leaf는 Spring `@Transactional`에 의존하지 않고 **경계 제공을 호출자에게 위임**한다. `JdbcInboxRepository.requireActiveTransaction`이 그 위임이 지켜졌는지를 런타임에 확인한다 — **위임과 검증이 짝을 이룬다.**
중복이 정상 결과라는 것도 명시돼 있다 — "A duplicate is not an error. It is the expected consequence of at-least-once delivery, so the skip path is a normal outcome rather than an exception."
### 4.3 `TransactionalInboxHandler` — 세 가지를 할 수 없다
```java
// :20-23
* <p>Reservation and effect commit together, in the runner's single transaction. Everything else
* about this class follows from that: it cannot settle the message (settlement is not
* transactional), it cannot publish (the publish would survive a rollback), and it cannot catch and
* swallow the action's exception (the rollback is how the reservation is undone).
```
세 금지가 `messaging-reliability-api``TransactionalMessageAction` javadoc이 구현자에게 요구한 것과 대칭이다 — 그쪽은 action에게, 이쪽은 handler에게.
예외 처리가 그 세 번째를 지킨다.
```java
try {
action.apply(delivery);
} catch (Exception failure) {
// Wrapped, not swallowed: the transaction runner has to see a throw to roll the
// reservation back along with the effect.
throw new ActionFailedException(failure);
}
```
`ActionFailedException`이 private `RuntimeException`이고, 바깥에서 잡아 `HandleResult.Retry`로 번역한다. **checked exception을 트랜잭션 runner를 통과시키기 위한 캐리어**다.
중복은 성공으로 보고한다.
```java
private static HandleResult duplicateIsSuccess() {
// The effect already ran in an earlier delivery. Settling is correct; redelivering is not.
return HandleResult.success();
}
```
실패는 `TRANSIENT_INFRASTRUCTURE` + `retryable = true` + `exceptionType`에 원인 클래스 단순명 — `FailureDescriptor``Optional<String> exceptionType`을 실제로 채우는 저장소 내 드문 지점이다.
### 4.4 `InboxRetentionPolicy` — 곱셈 안전계수
```java
// :11-18
* <p>Retention must exceed the broker's maximum redelivery window. That is not a tuning preference:
* a row pruned while the broker can still redeliver its message turns the inbox into a no-op for
* exactly that message, and the side effect runs a second time. The failure is silent, rare, and
* only happens under the conditions that already made the day bad.
*
* <p>The safety margin is multiplicative rather than additive so that it scales with the window
* itself. A stream whose redelivery window is measured in days needs more slack than one measured
* in minutes, for the same reason: the estimate of that window is proportionally less certain.
```
`REQUIRED_SAFETY_FACTOR = 2.0`, `DEFAULT_RETENTION = 7일`.
**`messaging-reliability-api``InboxRepository.purgeProcessedBefore` javadoc이 요구하고 강제하지 않은 규칙을 이 leaf가 강제한다.** 그 leaf §17이 "미강제"로 기록한 것이 여기서 `validate()`가 된다 — 다만 `validate()``InboxCleanupJob` 생성자만 부른다. 즉 **cleanup job을 만들지 않는 배포에서는 여전히 검사되지 않는다.**
`required()``Math.round(window.toMillis() * 2.0)`이다. 곱셈 이유가 적혀 있고, `theRequiredRetentionScalesWithTheWindow` 테스트가 2일 창 → 4일 요구를 확인한다.
### 4.5 `InboxCleanupJob` — 선언과 구현이 어긋난다
javadoc이 두 가지를 약속한다.
```java
// :10-16
* <p>Deletes in bounded batches. A single unbounded {@code DELETE} over a table that has been
* accumulating for weeks holds locks long enough to block the very reservations the inbox exists to
* serve, so the cleanup would cause the outage it is meant to prevent.
*
* <p>The policy is validated before the first deletion. Running a cleanup under a retention that is
* shorter than the redelivery window would actively create the duplicate-processing bug, so the job
* refuses to start rather than dutifully deleting the rows.
```
**두 번째는 지켜진다** — 생성자가 `policy.validate()`를 부르고 테스트가 확인한다.
**첫 번째는 지켜지지 않는다.**
```java
public static final int DEFAULT_BATCH_SIZE = 1_000; // ← 선언되고 어디서도 쓰이지 않음
...
for (int batch = 0; batch < maxBatches; batch++) {
int deleted = inbox.purgeProcessedBefore(cutoff); // ← 무제한 overload
...
}
```
`InboxRepository`에는 두 오버로드가 있다.
| 오버로드 | 구현 |
|---|---|
| `purgeProcessedBefore(Instant, int)` | `WITH expired AS (SELECT … LIMIT ? FOR UPDATE SKIP LOCKED) DELETE …` |
| `purgeProcessedBefore(Instant)` | `DELETE FROM messaging_inbox WHERE processed_at < ?` |
job은 후자를 부른다. 첫 호출이 컷오프 이전 **전부**를 한 문장으로 지우고, 두 번째 호출이 0을 반환해 루프가 끊긴다. `maxBatches`는 사실상 의미가 없고 `DEFAULT_BATCH_SIZE`는 죽은 상수다.
**javadoc이 "cleanup would cause the outage it is meant to prevent"라고 서술한 바로 그 동작을 한다.** §12.1·§17.
### 4.6 `InboxOutcome` — 두 상태
`(boolean processed, Optional<T> result)`. `processed(value)``duplicate()` 두 factory.
`TransactionalInboxHandler``T = InboxResult`로 쓰고 항상 `InboxResult.APPLIED`를 넣는다 — §12.3.
### 4.7 migration
```sql
CREATE TABLE messaging_inbox
(
message_id UUID NOT NULL,
consumer_id VARCHAR(160) NOT NULL,
processed_at TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_messaging_inbox PRIMARY KEY (message_id, consumer_id)
);
CREATE INDEX ix_messaging_inbox_processed_at ON messaging_inbox (processed_at);
```
`message_id``UUID` 타입이다 — `MessageId`가 UUIDv7만 허용하므로(`messaging-core-api` §4.9) 컬럼 타입이 그 제약과 맞는다.
`consumer_id VARCHAR(160)``IdempotentConsumer`가 공백만 거절하고 길이를 보지 않는다. **160자를 넘는 consumerId는 DB가 거절한다.** 애플리케이션 층에 대응 검증이 없다. §17.
인덱스 주석이 보존 규칙을 다시 적는다.
---
## 5. 주요 실행 경로
**수신 처리:** `handleOnce(name, delivery, action)``consumer.runOnce(messageId, name, now, () -> { action.apply(delivery); return APPLIED; })` → runner가 트랜잭션 열기 → `repository.reserve(...)` → 세 검사 → `INSERT … ON CONFLICT DO NOTHING` → 1행이면 부작용 실행, 0행이면 `duplicate()` → 커밋 → `HandleResult.success()`
**실패:** action 예외 → `ActionFailedException` → runner가 롤백(예약도 함께) → `HandleResult.Retry("INBOX_ACTION_FAILED")`
**보존:** `cleanupJob.runOnce(now)``policy.cutoff(now)` → 무제한 DELETE 1회 → 두 번째 호출 0 → 종료
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 조건 |
|---|---|---|
| `INBOX_TRANSACTION_REQUIRED` | `MessagingConfigurationException` | 트랜잭션 없음/읽기전용/다른 DataSource |
| `INBOX_RESERVE_FAILED` | `MessagingConfigurationException` | 예약 SQL 실패 |
| `INBOX_QUERY_FAILED` | `MessagingConfigurationException` | 조회 SQL 실패 |
| `INBOX_PURGE_FAILED` | `MessagingConfigurationException` | 스윕 SQL 실패 |
| `INBOX_RETENTION_TOO_SHORT` | `MessagingConfigurationException` | 보존 < 창 × 2 |
| `INBOX_ACTION_FAILED` | `HandleResult.Retry`(예외 아님) | action 실패 |
**SQL 실패 셋이 전부 `MessagingConfigurationException`이다.** 그 예외의 카테고리는 `CONFIGURATION`이고 `retryable = false`다. 그런데 `SQLException`의 원인은 대부분 **일시적 인프라 문제**(연결 끊김, 데드락, 타임아웃)다. 즉 재시도 가능한 실패가 재시도 불가로 분류된다. §17.
`INBOX_ACTION_FAILED``TRANSIENT_INFRASTRUCTURE`/`retryable = true`이고 예외가 아니라 `HandleResult`로 흐른다 — 분류가 정확하다.
---
## 7. 트랜잭션·동시성·수명주기
**이 leaf의 주제 자체가 트랜잭션이다.**
| 지점 | 메커니즘 |
|---|---|
| 중복 제거 | 복합 PK + `ON CONFLICT DO NOTHING`의 영향 행 수 |
| 예약·부작용 원자성 | 호출자의 `TransactionRunner` + `requireActiveTransaction` 3검사 |
| 커넥션 참여 | `DataSourceUtils.getConnection/releaseConnection` — Spring 트랜잭션 동기화 커넥션을 얻는다 |
| 스윕 격리 | bounded overload가 `FOR UPDATE SKIP LOCKED`**호출되지 않음** |
`DataSourceUtils.getConnection`은 활성 트랜잭션에 묶인 커넥션이 있으면 그것을 주고, 없으면 새로 연다. 그래서 `requireActiveTransaction`**먼저** 도는 것이 필수다 — 없으면 새 커넥션이 열리고 자동 커밋된다. 그것이 §4.1의 이전 결함이다.
`isProcessed`와 두 `purge*``dataSource.getConnection()`을 직접 쓴다 — 트랜잭션에 참여하지 않는다. javadoc이 그것을 명시한다("The no-argument overload is provided only for retention sweeps and read-only queries").
동시성 원시 요소는 DB에 있다. Java 쪽에 락이나 원자 변수가 없다.
수명주기 참여 없음 — `InboxCleanupJob`을 스케줄링하는 것은 starter다.
---
## 8. 설정·기능 플래그·환경 차이
| 상수 | 값 | 사용 |
|---|---:|---|
| `InboxCleanupJob.DEFAULT_BATCH_SIZE` | 1,000 | **없음** |
| `InboxRetentionPolicy.REQUIRED_SAFETY_FACTOR` | 2.0 | `required()` |
| `InboxRetentionPolicy.DEFAULT_RETENTION` | 7일 | starter가 참조할 수 있음 |
| `consumer_id` 컬럼 폭 | 160자 | migration |
설정 파일 없음. `maxBatches`와 두 `Duration`이 생성자 인자다.
---
## 9. 퍼시스턴스/외부 시스템 세부
**PostgreSQL 전용이다.** 세 SQL이 벤더 기능을 쓴다.
| 구문 | 용도 |
|---|---|
| `ON CONFLICT (…) DO NOTHING` | 예약. PostgreSQL 고유 |
| `FOR UPDATE SKIP LOCKED` | bounded 스윕. PostgreSQL 9.5+ |
| `WITH … DELETE … USING` | bounded 스윕. CTE + USING |
| `TIMESTAMPTZ` | 컬럼 타입 |
leaf 이름이 그 사실을 드러낸다.
`statement.setObject(1, messageId.value())``java.util.UUID`를 그대로 넘긴다 — PostgreSQL JDBC 드라이버가 `UUID``uuid` 매핑을 지원한다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-inbox-jdbc-postgresql:test`. **BUILD SUCCESSFUL, 25 tests, 0 skipped, 0 failures.**
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `InboxPostgresIT` | **6** | **실 PostgreSQL**에서: 첫 예약 성공/둘째 실패, 두 소비자 각각 1회, 조회 가시성, 재전달이 부작용을 두 번 실행하지 않음, **롤백이 예약도 부작용도 남기지 않음**, 보존 삭제 | bounded 스윕(무제한 overload를 부른다) |
| `JdbcInboxTransactionRequirementTest` | 4 | 트랜잭션 없음/읽기전용/다른 DataSource 거절이 **커넥션 요청 전에** 일어남, 코드가 검색 가능 | — |
| `IdempotentConsumerTest` | 6 | 첫 실행/재전달 스킵/두 소비자/한 트랜잭션 공유/조회 가시성/보존 삭제 | in-memory fake |
| `InboxOperationsTest` | 9 | 보존 규칙 4개, cleanup 루프 2개, 소비자별 1회, 재전달 억제, `InboxResult` 세 값의 `isSafeToSettle` | **bounded 배치**(§10.2) |
### 10.1 컨테이너 레인이 실제로 돈다
`InboxPostgresIT``@Testcontainers`이고 **기본 `test` 태스크에서 6개가 통과했다.** 이 저장소의 다른 컨테이너 레인 중 일부는 별도 태스크에 격리돼 있는데 이것은 아니다.
`aRolledBackTransactionLeavesNoReservationAndNoSideEffect`가 §4.1이 말한 "the path nobody exercises before production"을 실 DB에서 검증한다. `build.gradle` 주석의 주장("only a real database can settle them")이 실현된 지점이다.
### 10.2 `cleanupDeletesInBoundedBatches`가 증명하지 않는 것
테스트 이름이 속성을 주장한다. 실제 단언은 이렇다.
```java
@Test
void cleanupDeletesInBoundedBatches() {
InMemoryInbox inbox = new InMemoryInbox(List.of(1000, 500));
int removed = new InboxCleanupJob(inbox, policy(7일, 1일), 10).runOnce(NOW);
assertThat(removed).isEqualTo(1500);
assertThat(inbox.cutoffs).hasSize(3);
}
```
`InMemoryInbox`는 **대본을 읽는 fake**다.
```java
@Override
public int purgeProcessedBefore(Instant processedBefore) {
cutoffs.add(processedBefore);
return pass < deletions.size() ? deletions.get(pass++) : 0;
}
@Override
public int purgeProcessedBefore(Instant processedBefore, int limit) {
return Math.min(purgeProcessedBefore(processedBefore), limit);
}
```
무제한 메서드가 미리 준 목록(`1000, 500`)을 순서대로 반환하고 이후 0을 준다. **아무것도 삭제하지 않고 아무것도 제한하지 않는다.**
그래서 이 테스트가 통과로 증명하는 것은 "job이 0을 받을 때까지 루프를 돈다"이고, **"삭제가 배치로 제한된다"는 아니다.** 1000과 500은 배치처럼 보이는 숫자일 뿐이다.
bounded overload(`purgeProcessedBefore(Instant, int)`)는 fake에도 구현돼 있지만 **job이 부르지 않으므로 실행되지 않는다.**
`cleanupHonoursTheBatchCeilingSoItCannotRunForever`는 다른 성질(루프 상한)을 정확히 검증한다 — `maxBatches=2`에 6개 대본을 주고 호출이 2회임을 확인한다.
### 10.3 `anAlreadyAppliedMessageIsSafeToSettleButAClaimedOneIsNot`
```java
assertThat(InboxResult.APPLIED.isSafeToSettle()).isTrue();
assertThat(InboxResult.ALREADY_APPLIED.isSafeToSettle()).isTrue();
assertThat(InboxResult.CLAIMED_ELSEWHERE.isSafeToSettle()).isFalse();
```
**enum 상수의 boolean 필드를 단언한다.** 동작이 아니라 선언이다 — `messaging-transport-spi``MessagingLifecycleTest`가 enum 선언 순서를 단언하는 것(그쪽 §10.2)과 같은 형태다. 그리고 §12.3이 보이듯 `CLAIMED_ELSEWHERE`는 production에서 생성되지 않는다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-reliability-api"]` |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| vendor `api` 규칙 | Spring 타입이 public 시그니처에 없음 → `implementation`. **통과** |
| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |
| Flyway migration | `V2__messaging_inbox.sql` — 네이밍이 `messaging` 네임스페이스 |
| ArchUnit | 전용 규칙 없음 |
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/294-bounded-purge-never-called.txt`.
### 12.1 Public surface reachability
| 타입 | leaf 밖 | 판정 |
|---|---:|---|
| `IdempotentConsumer` | 1 | starter |
| `InboxCleanupJob` | 1 | starter |
| `InboxRetentionPolicy` | 1 | starter |
| `TransactionalInboxHandler` | 1 | starter |
| `JdbcInboxRepository` | **0** | — |
| `InboxOutcome` | **0** | 내부 반환 타입 |
`JdbcInboxRepository`의 0이 주목된다 — starter가 `InboxRepository` bean을 만들지 않는다(§2). `InboxCleanupJob`·`TransactionalInboxHandler` bean이 `InboxRepository`/`IdempotentConsumer`를 인자로 받으므로 **누군가 그 bean을 공급해야 하고, 이 leaf의 구현이 그 후보인데 연결이 없다.** 그 판정은 starter leaf가 소유한다.
**메서드 수준 도달성: bounded 스윕이 호출되지 않는다**
`InboxRepository``OutboxRepository` 둘 다 `purge*Before(Instant, int)` 오버로드를 선언하고, 두 JDBC 구현이 실제로 `LIMIT`를 쓰는 SQL로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 전부 **선언·구현·테스트 fake override**이고 **호출 지점이 하나도 없다**.
```
2 port declarations + 2 production implementations + 5 test fake overrides = 9
None of them is a call site.
```
두 cleanup job이 무제한 오버로드를 부른다.
```java
// InboxCleanupJob.java:56
int deleted = inbox.purgeProcessedBefore(cutoff);
// OutboxCleanupJob.java:50
int deleted = outbox.purgePublishedBefore(cutoff);
```
**`OutboxRepository`의 bounded 오버로드 javadoc이 그 상황을 정확히 예고한다.**
> The unbounded version deletes everything before the cutoff in one statement. On a table that has been accumulating published rows since the last sweep that is a single long transaction holding locks and generating WAL in proportion to the backlog, which shows up as the relay and the business writes stalling behind retention. **The cleanup jobs describe themselves as bounded by batch size; this is the parameter that makes that true.**
그 파라미터를 부르는 코드가 없다. 두 cleanup job은 여전히 "bounded by batch size"라고 자기를 서술한다.
`InboxCleanupJob.DEFAULT_BATCH_SIZE = 1_000`은 저장소 전체에서 **자기 선언 한 줄**만 등장한다.
### 12.2 Conditional sibling comparison
Spring 주석 0개. starter의 세 bean이 이 leaf 타입을 만든다.
**형제 비교가 결정적이다.** `messaging-outbox-jdbc-postgresql`이 같은 구조를 갖는다.
| | inbox | outbox |
|---|---|---|
| bounded purge 구현 | o (`LIMIT` + `SKIP LOCKED`) | o |
| cleanup job이 부르는 것 | 무제한 | 무제한 |
| batch size 상수 | `DEFAULT_BATCH_SIZE`(미사용) | (outbox leaf가 답함) |
**두 leaf가 같은 결함을 갖는다.** 우연이 아니라 같은 리팩터가 두 곳에 같은 형태로 적용되고 호출부 갱신이 빠진 것으로 보인다 — 추론이며 커밋 근거는 없다.
### 12.3 Duplicate mechanism sweep
**(a) `InboxResult`의 세 값 중 하나만 생성된다**
`TransactionalInboxHandler:70``InboxResult.APPLIED`를 반환하는 것이 production의 유일한 생성 지점이다. `ALREADY_APPLIED`·`CLAIMED_ELSEWHERE``InboxOperationsTest`의 단언에만 등장한다.
**구조적 이유가 있다.** `InboxRepository.reserve``boolean`을 반환하므로 세 갈래를 표현할 수 없다. `messaging-reliability-api``InboxResult` javadoc이 세 값이 필요한 이유를 이렇게 적는다.
> Three outcomes, not two. Collapsing `ALREADY_APPLIED` and `CLAIMED_ELSEWHERE` into a single "duplicate" would settle a message whose effect is still only half-written by another instance: if that instance then rolls back, the effect is lost and the broker will never redeliver, because this instance already acknowledged it.
**포트의 반환 타입이 그 구분을 표현 불가능하게 만든다.** `reserve`가 false를 주면 `IdempotentConsumer``duplicate()`를 만들고 `TransactionalInboxHandler``HandleResult.success()`를 반환한다 — 즉 **정산한다.** javadoc이 정산하면 안 된다고 한 경우와 해도 되는 경우가 같은 false로 들어온다.
**이 leaf에서 그 구분이 실제로 필요한지는 PostgreSQL의 `ON CONFLICT DO NOTHING` 동시성 동작에 달려 있고, 그것을 확인하지 않았다.** 미커밋 충돌 행이 있을 때 `DO NOTHING`이 대기하는지 즉시 0을 반환하는지에 따라 `CLAIMED_ELSEWHERE` 상황이 발생 가능한지가 갈린다. §16·§17.
**(b) 보존 규칙이 세 곳에 있다**
| 위치 | 형태 | 강제 |
|---|---|---|
| `InboxRepository.purgeProcessedBefore` javadoc | "Retention must outlive the broker's maximum redelivery window" | 없음 |
| 이 leaf `InboxRetentionPolicy.validate()` | `retention >= window × 2.0` | **강제**(단 `InboxCleanupJob` 생성 시에만) |
| `messaging-claim-check` `ClaimCheckPolicy` 생성자 | `retention >= brokerRetention + maxRedeliveryWindow` | **강제**(항상) |
세 곳이 같은 종류의 시간 관계를 다루고 **강제 시점과 공식이 다르다** — 곱셈(×2.0) vs 덧셈(brokerRetention + window). 두 leaf가 서로를 참조하지 않는다.
**(c) 커넥션 획득 방식이 둘**
| 메서드 | 방식 | 트랜잭션 참여 |
|---|---|---|
| `reserve(...)` | `DataSourceUtils.getConnection` | o |
| `isProcessed`, `purge*` | `dataSource.getConnection()` | x |
의도된 구분이고 javadoc이 명시한다. 중복 아님.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `InboxCleanupJob` javadoc: "Deletes in bounded batches" | 무제한 오버로드 호출, `DEFAULT_BATCH_SIZE` 미사용 | **불일치** |
| 같은 javadoc: 정책을 첫 삭제 전에 검증 | 생성자가 `policy.validate()` | **일치** |
| `JdbcInboxRepository` javadoc: 예약이 `ON CONFLICT DO NOTHING`의 영향 행 수 | SQL 확인 | **일치** |
| 같은 javadoc: 무인자 오버로드는 "only for retention sweeps and read-only queries" | 그 스윕이 무인자를 부르므로 문장은 맞다. 다만 그 스윕이 bounded여야 한다는 다른 javadoc과 충돌 | **부분 불일치** |
| `OutboxRepository` javadoc: "this is the parameter that makes that true" | 그 파라미터 호출자 0 | **불일치** |
| migration 주석: 보존 창이 재전달 지연보다 길어야 함 | `InboxRetentionPolicy`가 강제 | **일치** |
| `build.gradle` 주석: 실 DB 인증 | `InboxPostgresIT` 6개 통과 | **일치** |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `JdbcInboxRepository.reserve(Connection,…)` javadoc | 그 메서드가 public이고, interface 메서드는 **raw 커넥션을 열어 자동 커밋** | 부작용이 롤백돼도 예약은 커밋됨 → **메시지는 처리됨으로 남고 작업은 일어나지 않았으며 재전달이 거부됨** |
| `JdbcInboxTransactionRequirementTest` javadoc | 같은 결함을 테스트 쪽에서 서술 | "the message counts as processed, the work never happened, and redelivery is refused because the inbox row is already there" |
**한 결함이 두 파일에 기록돼 있고, 그중 하나가 그것을 막는 테스트다.** 그리고 그 테스트가 "hermetic: the refusal has to happen before any connection is requested, and the data source below fails the test by being asked for one"이라고 자기 설계를 적는다 — **DataSource가 요청받으면 테스트가 실패하도록** 만들어 검사 순서까지 고정한다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-294 | command | `evidence/raw/294-bounded-purge-never-called.txt` | 두 포트의 bounded 오버로드 선언과 이유, 두 구현의 SQL, 시그니처 9회 등장이 전부 비호출, 두 cleanup job의 실제 호출, `DEFAULT_BATCH_SIZE` 단일 등장, 무제한 구현의 SQL, 테스트 fake의 대본, 컨테이너 레인도 무제한 호출 | 정적 검색 |
| EVD-295 | command | `./gradlew :messaging:messaging-inbox-jdbc-postgresql:test --rerun-tasks` | BUILD SUCCESSFUL, 25 / 0 / 0. **`InboxPostgresIT` 6개 포함** | Testcontainers 환경 의존 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 복합 PK가 중복 제거 메커니즘인 이유 — 클래스 javadoc + migration 주석
- 예약이 호출자 트랜잭션에 참여해야 하는 이유와 이전 결함 — `reserve(Connection,…)` javadoc
- 세 검사가 커넥션 요청 전에 일어나야 하는 이유 — `requireActiveTransaction` javadoc + 테스트 javadoc
- 트랜잭션 경계를 호출자에게 위임하는 이유 — `IdempotentConsumer` javadoc
- 중복이 오류가 아닌 이유 — 같은 javadoc + `duplicateIsSuccess` 주석
- 예외를 감싸되 삼키지 않는 이유 — 인라인 주석
- 안전계수가 곱셈인 이유 — `InboxRetentionPolicy` javadoc
- 정책을 첫 삭제 전에 검증하는 이유 — `InboxCleanupJob` javadoc
- 실 DB 인증이 필요한 이유 — `build.gradle` 주석
**추론**
- 두 cleanup job이 같은 형태로 무제한 오버로드를 부르는 것은 bounded 오버로드가 나중에 추가되고 호출부가 갱신되지 않았기 때문이다 → **추론**. 두 곳의 동일한 형태는 관측이고 인과는 추론이다.
- `CLAIMED_ELSEWHERE`가 생성되지 않는 것은 포트가 `boolean`을 반환하기 때문이다 → **관측에 가까운 추론**. 반환 타입은 관측이다.
- `consumer_id` 길이 검증이 없는 것이 의도인지 → **미상**.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 6개 타입 542줄과 migration 전문
- 25개 테스트가 통과하고 **컨테이너 레인 6개가 실 PostgreSQL에서 돈다**는 것
- bounded purge 오버로드가 두 포트·두 구현에 있고 **호출 지점이 0**이라는 것
- 두 cleanup job이 무제한 오버로드를 부르고 `DEFAULT_BATCH_SIZE`가 죽은 상수라는 것
- `cleanupDeletesInBoundedBatches`가 대본 fake 위에서 통과한다는 것
- `InboxResult` 세 값 중 하나만 production에서 생성된다는 것과 그 구조적 이유
- 세 겹 트랜잭션 검사와 그것이 막는 이전 결함
**확인하지 못한 것**
- **PostgreSQL의 `ON CONFLICT DO NOTHING`이 미커밋 충돌 행에 대해 대기하는지 즉시 0을 반환하는지.** `CLAIMED_ELSEWHERE` 상황의 발생 가능성이 여기에 달려 있고, 이 저장소의 테스트가 그것을 재현하지 않는다.
- `InboxRepository` bean을 누가 만드는지 — starter leaf가 소유한다.
- `consumer_id`가 160자를 넘는 배포가 있는지.
- 무제한 DELETE가 실제 규모의 테이블에서 얼마나 오래 락을 잡는지 — 측정하지 않았다.
- `InboxCleanupJob`을 스케줄링하는 주기 — starter가 소유한다.
---
## 17. 손볼 것
### P1 — bounded purge가 구현돼 있고 호출되지 않아, cleanup이 스스로 막겠다고 한 장애를 일으킨다
- **사실.** `InboxRepository`·`OutboxRepository` 둘 다 `purge*Before(Instant, int)` 오버로드를 선언하고, `JdbcInboxRepository:141`·`JdbcOutboxRepository:486``LIMIT` + `FOR UPDATE SKIP LOCKED`로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 **선언 2 + 구현 2 + 테스트 fake override 5**이고 **호출 지점이 0**이다. `InboxCleanupJob:56``OutboxCleanupJob:50`이 무제한 오버로드를 부른다. `InboxCleanupJob.DEFAULT_BATCH_SIZE = 1_000`은 자기 선언 한 줄만 존재한다.
- **근거.** `evidence/raw/294` §C·§D·§E.
- **왜 문제인가.** `InboxCleanupJob`의 javadoc이 스스로 적는다 — *"A single unbounded DELETE over a table that has been accumulating for weeks holds locks long enough to block the very reservations the inbox exists to serve, so the cleanup would cause the outage it is meant to prevent."* 실행되는 코드가 정확히 그 문장이 서술하는 동작이다. `OutboxRepository`의 bounded 오버로드 javadoc은 한 발 더 나간다 — *"The cleanup jobs describe themselves as bounded by batch size; **this is the parameter that makes that true**."* 그 파라미터를 아무도 넘기지 않는다. 그리고 두 leaf가 **동일한 형태로** 그렇다.
- **왜 P1인가.** 두 leaf 다 `runtime_memberships: ["app-bootstrap"]`이고 두 cleanup job이 starter에서 bean으로 만들어진다(`MessagingReliabilityAutoConfiguration``inboxCleanupJob`·`outboxCleanupJob`). 즉 **출하 구성에서 실행되는 경로**이며, 백로그가 쌓인 뒤 첫 스윕에서 발현한다. 다른 미배선 발견들과 성격이 다르다.
- **확인 방법.** `evidence/raw/294` 재실행. 또는 `git grep -n -E 'purge(Processed|Published)Before\s*\([^)]*,' -- 'src/**/*.java'`로 호출 지점이 없음을 확인.
- **후보.** 두 job이 bounded 오버로드에 배치 크기를 넘기게 한다 — `InboxCleanupJob`은 이미 `DEFAULT_BATCH_SIZE`를 갖고 있다.
- **다음 단계.** **CASE 후보.** 정적 재현이 완결되고, "장치는 있고 회로가 닫히지 않았다"의 변형 중 **닫히지 않은 회로가 실행 경로 위에 있는** 유일한 사례다. `messaging-outbox-jdbc-postgresql` leaf와 공동 소유.
### P2 — 속성을 이름으로 주장하는 테스트가 그 속성을 보일 수 없는 fake 위에서 통과한다
- **사실.** `InboxOperationsTest.cleanupDeletesInBoundedBatches``InMemoryInbox(List.of(1000, 500))`에 대해 `removed == 1500``cutoffs.hasSize(3)`을 단언한다. 그 fake의 무제한 메서드는 미리 준 목록을 순서대로 반환하는 **대본**이고 아무것도 삭제하거나 제한하지 않는다. bounded 오버로드는 fake에도 있지만 job이 부르지 않아 실행되지 않는다.
- **근거.** `evidence/raw/294` §G.
- **왜 문제인가.** 이 테스트가 통과로 증명하는 것은 "0을 받을 때까지 루프를 돈다"이고 이름이 주장하는 "배치로 제한된다"가 아니다. 1000·500은 배치처럼 보이는 숫자다. **P1이 이 테스트를 통과한 채로 존재할 수 있었던 이유**다. 그리고 컨테이너 레인(`InboxPostgresIT.retentionRemovesOldRows`)도 무제한 오버로드를 한 행에 대해 부르므로 실 DB에서도 드러나지 않는다.
- **확인 방법.** `evidence/raw/294` §G·§H.
- **후보.** fake의 무제한 메서드가 실제로 컬렉션에서 삭제하게 하고, bounded 메서드가 `limit`를 존중하게 한다. 그러면 테스트가 P1을 잡는다.
- **다음 단계.** **CASE 후보 + REFERENCE 후보.** `messaging-transport-spi` §10.2(enum 순서를 단언하는 종료 테스트)와 같은 계열이고, "이름이 주장하는 속성을 fake가 표현할 수 있는지 먼저 확인한다"가 재사용 가능한 기준이다.
### P2 — SQL 실패가 재시도 불가로 분류된다
- **사실.** `INBOX_RESERVE_FAILED`·`INBOX_QUERY_FAILED`·`INBOX_PURGE_FAILED` 셋 다 `MessagingConfigurationException`이고, 그 예외의 카테고리는 `CONFIGURATION`, `retryable = false`다.
- **근거.** `JdbcInboxRepository.java:77-80, 134-137, 165-168, 179-182`. `MessagingConfigurationException.java``CATEGORY` 상수.
- **왜 문제인가.** `SQLException`의 원인 대부분은 구성 오류가 아니라 **일시적 인프라**다 — 연결 끊김, 데드락, 락 타임아웃, 커넥션 풀 고갈. `FailureCategory`는 "the stable classification a retry engine, DLQ router, and dashboard all agree on"이고 `retryable = false`는 재시도 엔진이 즉시 파킹한다는 뜻이다. 같은 leaf의 `INBOX_ACTION_FAILED``TRANSIENT_INFRASTRUCTURE`/`retryable = true`로 정확히 분류된다 — 같은 파일 안에서 기준이 갈린다.
- **확인 방법.** 네 catch 블록과 `MessagingConfigurationException`의 카테고리 대조.
- **후보.** SQL 실패를 `MessageBrokerUnavailableException`류(또는 `TRANSIENT_INFRASTRUCTURE` 카테고리를 갖는 예외)로 바꾸고, 진짜 구성 오류(테이블 없음 등)만 `CONFIGURATION`으로 남긴다.
- **다음 단계.** **CASE 후보.** 재시도 정책이 실제로 갈리는 지점이다.
### P3 — 세 갈래 판정이 포트의 `boolean`에서 두 갈래로 접힌다
- **사실.** `InboxResult`가 세 값과 `isSafeToSettle()`을 갖는데 production은 `APPLIED`만 만든다. `InboxRepository.reserve``boolean`을 반환하므로 `ALREADY_APPLIED``CLAIMED_ELSEWHERE`가 같은 `false`로 들어온다. `TransactionalInboxHandler`는 그 경우 `HandleResult.success()`를 반환한다 — 정산한다.
- **근거.** `evidence/raw/294` 범위 밖이나 §12.3(a)의 검색 결과. `InboxResult` javadoc.
- **왜 문제인가.** `InboxResult` javadoc이 세 값이 필요한 이유로 정확히 그 정산을 든다 — "would settle a message whose effect is still only half-written by another instance". **다만 그 상황이 PostgreSQL에서 실제로 발생 가능한지 확인하지 않았다**(§16). `ON CONFLICT DO NOTHING`이 미커밋 충돌에 대해 대기한다면 `CLAIMED_ELSEWHERE`는 도달 불가능한 상태이고 enum이 과설계인 것이며, 즉시 0을 반환한다면 이것은 실제 결함이다.
- **확인 방법.** 두 커넥션에서 같은 (message, consumer)를 예약하고 한쪽을 커밋하지 않은 채 다른 쪽의 `executeUpdate()` 반환을 관측한다 — `InboxPostgresIT`에 추가 가능하다.
- **후보.** 먼저 확인한다. 발생 가능하면 포트 반환 타입을 `InboxResult`로 바꾼다.
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 확인하지 않은 DB 동작에 걸린다.
### P3 — `consumer_id` 길이 제약이 애플리케이션 층에 없다
- **사실.** migration이 `consumer_id VARCHAR(160)`이다. `IdempotentConsumer`·`TransactionalInboxHandler`·`JdbcInboxRepository`가 공백만 거절하고 길이를 보지 않는다.
- **근거.** `V2__messaging_inbox.sql:10`, 세 클래스의 검증.
- **왜 문제인가.** 긴 consumerId가 DB에서 `SQLException`으로 실패하고, §17의 다른 항목대로 그것이 `INBOX_RESERVE_FAILED`/`CONFIGURATION`/`retryable=false`가 된다 — 즉 **설정 실수가 메시지 파킹으로 나타난다.** `messaging-core-api`의 값 객체들이 바이트 상한을 생성자에서 강제하는 것(그쪽 §4.5)과 대비된다.
- **확인 방법.** 161자 consumerId로 `reserve` 호출.
- **후보.** consumerId를 값 객체로 만들거나 길이 검증을 추가한다.
- **다음 단계.** **REFERENCE 후보**(컬럼 폭은 애플리케이션 검증과 짝을 이룬다).
### P3 — 보존 규칙이 세 곳에 있고 공식이 다르다
- **사실.** `InboxRepository` javadoc(강제 없음), 이 leaf `InboxRetentionPolicy`(`× 2.0`, `InboxCleanupJob` 생성 시에만), `messaging-claim-check` `ClaimCheckPolicy`(`brokerRetention + maxRedeliveryWindow`, 항상).
- **근거.** 세 위치.
- **왜 문제인가.** 같은 종류의 시간 관계를 곱셈과 덧셈으로 다르게 표현하고, 강제 시점도 다르다. 그리고 이 leaf의 `validate()`**cleanup job을 만들 때만** 불린다 — cleanup을 배선하지 않은 배포는 보존 검사를 받지 않는다.
- **확인 방법.** 세 위치의 공식 대조.
- **후보.** 공식을 하나로 정하고 정책 생성자에서 강제한다(claim-check처럼).
- **다음 단계.** **REFERENCE 후보**(같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다).
### 확인된 설계(문제 아님)
- 복합 PK + `ON CONFLICT DO NOTHING`의 영향 행 수를 판정으로 쓰는 것
- 트랜잭션 경계를 호출자에게 위임하고 그 위임이 지켜졌는지 런타임에 세 겹으로 확인하는 것
- 세 검사가 커넥션 요청 **전에** 일어나고, 그것을 DataSource가 요청받으면 실패하는 테스트로 고정한 것
- 다른 DataSource에 묶인 트랜잭션을 거절하는 것
- action 예외를 감싸되 삼키지 않아 롤백이 예약까지 되돌리게 하는 것
- 중복을 성공으로 보고해 완료된 작업을 DLQ로 보내지 않는 것
- 안전계수를 곱셈으로 둔 것과 그 이유
- 정책을 첫 삭제 전에 검증하는 것
- 실 PostgreSQL 컨테이너 레인이 기본 test 태스크에서 도는 것과, 롤백 경로를 그 레인이 검증하는 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MIJ-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 2개, memberships `["app-bootstrap"]` | 선언 |
| MIJ-002 | build | `messaging-inbox-jdbc-postgresql/build.gradle` | same | 실 DB 인증 의도 | — |
| MIJ-003 | code | `.../inbox/JdbcInboxRepository.java` 전문 | same | §4.1 세 검사, 두 오버로드의 SQL | 무제한만 호출됨 |
| MIJ-004 | code | `.../inbox/IdempotentConsumer.java` | same | §4.2 트랜잭션 위임 | — |
| MIJ-005 | code | `.../inbox/TransactionalInboxHandler.java` | same | §4.3 세 금지와 예외 캐리어 | `APPLIED`만 생성 |
| MIJ-006 | code | `.../inbox/InboxRetentionPolicy.java` | same | §4.4 곱셈 안전계수 | `validate()` 호출 시점 제한 |
| MIJ-007 | code | `.../inbox/InboxCleanupJob.java` | same | §4.5 선언과 구현의 불일치 | — |
| MIJ-008 | migration | `.../db/migration/messaging/V2__messaging_inbox.sql` | same | 복합 PK, 인덱스, 컬럼 폭 | — |
| MIJ-009 | test | `InboxPostgresIT` (6) | same | 실 PostgreSQL 롤백·중복·보존 | bounded 스윕 미검증 |
| MIJ-010 | test | `JdbcInboxTransactionRequirementTest` (4) | same | 세 거절이 커넥션 전에 | — |
| MIJ-011 | test | `IdempotentConsumerTest` (6), `InboxOperationsTest` (9) | same | §10 표 | fake가 대본(§10.2) |
| MIJ-012 | cross-leaf code | `messaging-reliability-api/.../InboxRepository.java:36-52`, `OutboxRepository.java:132-151` | same | 두 오버로드 선언과 bounded의 존재 이유 | 해당 leaf SSOT가 소유 |
| MIJ-013 | cross-leaf code | `messaging-outbox-jdbc-postgresql/.../OutboxCleanupJob.java:50`, `JdbcOutboxRepository.java:486` | same | 같은 결함이 형제 leaf에도 | 해당 leaf SSOT가 소유 |
| EVD-294 | command | `evidence/raw/294-bounded-purge-never-called.txt` | same | §12.1 전부 | 정적 검색 |
| EVD-295 | command | `./gradlew :messaging:messaging-inbox-jdbc-postgresql:test --rerun-tasks` | same | 25 / 0 / 0, 컨테이너 6개 포함 | Testcontainers 환경 의존 |
@@ -0,0 +1,546 @@
# messaging-kafka-share-experimental 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-kafka-share-experimental`
> SSOT owner: `messaging-kafka-share-experimental`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-kafka-share-experimental`
- canonical state `analysisFile`: `analysis/messaging/messaging-kafka-share-experimental.md`
- source path: `src/messaging/messaging-kafka-share-experimental`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-policy", "messaging-transport-spi", "messaging-kafka"]`
- registry `runtime_memberships`: **`[]`** — build-only / incubating
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | **4** |
| production LOC | **190** — messaging family에서 가장 작다 |
| 패키지 | 1 (`dev.caskeleton.messaging.kafka.share`) |
| test 파일 | 1 |
| test 메서드(실행 확인) | 6 |
| 선언된 외부 의존성 | 1 (`org.apache.kafka:kafka-clients`, `implementation`) |
| **실제 사용된 외부 의존성** | **0**(§12.4) |
네 타입:
| 타입 | 종류 | LOC | leaf 밖 참조 |
|---|---|---:|---:|
| `KafkaShareGroupRegistrar` | class | 88 | **0** |
| `KafkaShareProfileValidator` | class | 42 | **0** |
| `KafkaShareProfile` | record | 33 | **0** |
| `KafkaShareWorkQueueCapability` | class | 27 | **0** |
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 확인 |
| `src/test/java/**` (1) | 1 | `FULL_READ` | 6개 테스트 확인 |
| `build.gradle` | 1 | `FULL_READ` | 10줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
Kafka **Share Group**(KIP-932, 경쟁 소비자 work queue)을 실험적 어댑터로 감싼다. `runtime_memberships: []`이고 이름 자체가 `-experimental`이다.
이 leaf의 실질은 **거절**이다. 190줄 중 실제 동작을 하는 코드는 거의 없고, 세 가지를 거절한다.
| 거절 | 코드 | 이유 |
|---|---|---|
| 비활성 상태의 사용 | `KAFKA_SHARE_DISABLED` | experimental이 기본 켜지지 않게 |
| 순서 보장 목적지 | `IllegalArgumentException` | share group이 순서를 줄 수 없음 |
| pause/resume | `KAFKA_SHARE_NO_PAUSE`/`_NO_RESUME` | 일시정지할 파티션 할당이 없음 |
핵심 진술이 validator javadoc에 있다.
```java
// KafkaShareProfileValidator.java:10-17
* <p>A share group hands individual records to competing consumers and acknowledges them
* individually. That is a work queue, and it is fundamentally incompatible with partition ordering:
* two consumers in the same share group can process records from one partition concurrently and
* finish in either order. Configuring an ordered destination on a share group would therefore
* advertise a guarantee the broker is not providing, so it is refused rather than degraded.
*
* <p>The adapter is also off unless explicitly enabled, so an Experimental capability cannot drift
* into a Stable deployment by default.
```
두 번째 문단이 이 저장소의 experimental 정책을 한 문장으로 담는다 — **기본 꺼짐이 drift 방지 수단이다.**
---
## 2. 의존성과 런타임 배선
들어오는 것(project): `messaging-core-api`, `messaging-policy`, `messaging-transport-spi`, `messaging-kafka` — 넷 다 `api`.
들어오는 것(vendor): `org.apache.kafka:kafka-clients`(`implementation`) — **어떤 소스도 import하지 않는다**(§12.4).
나가는 것: **없다.** 어떤 leaf의 `allowed_dependencies`에도 이 leaf가 없고 starter 목록에도 없다.
런타임 배선: 없음. `runtime_memberships: []`. bean 없음(Spring 주석 0개).
**소비자 없음·membership 없음·조립 없음의 삼중 정합**이다 — `messaging-schema-avro`·`messaging-schema-protobuf`와 같은 형태이고, incubating leaf의 올바른 상태다.
**`messaging-policy``messaging-kafka` 의존이 실제로 쓰이는가.**
| 의존 | 사용 |
|---|---|
| `messaging-core-api` | `OrderingScope`, `DestinationName`, `MessagingCapabilities`, `MessagingCapabilityUnavailableException`**사용** |
| `messaging-transport-spi` | `TransportConsumerRegistration`, `TransportConsumerSpec`**사용** |
| `messaging-policy` | 어떤 타입도 import하지 않음 — **미사용** |
| `messaging-kafka` | 어떤 타입도 import하지 않음 — **미사용** |
네 project 의존 중 둘, 벤더 의존 하나가 미사용이다. §17.
---
## 3. 패키지/컴포넌트 지도
```
KafkaShareProfile (record)
destination · shareGroup · orderingScope · enabled · maxDeliveryCount
KafkaShareProfileValidator.validate(profile)
├── !enabled → MessagingCapabilityUnavailableException(KAFKA_SHARE_DISABLED)
└── orderingScope != NONE → IllegalArgumentException
KafkaShareGroupRegistrar.register(profile, spec)
└── ShareRegistration implements TransportConsumerRegistration
├── pause(scope) → failedFuture(KAFKA_SHARE_NO_PAUSE)
├── resume(scope) → failedFuture(KAFKA_SHARE_NO_RESUME)
├── isActive() → true until close()
└── close() → active = false
KafkaShareWorkQueueCapability.capabilities() → MessagingCapabilities(12 booleans)
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `KafkaShareProfile`
다섯 필드. 생성자가 `shareGroup` 공백과 `maxDeliveryCount < 1`을 거절한다.
`maxDeliveryCount`가 javadoc에서 "how many times a record may be re-acquired before it is released"라고 정의된다 — Share Group의 재획득 한계다. **이 필드를 읽는 코드가 이 leaf에 없다.** validator도 registrar도 쓰지 않는다.
### 4.2 `KafkaShareProfileValidator` — 두 거절
```java
if (!profile.enabled()) {
throw new MessagingCapabilityUnavailableException(
"KAFKA_SHARE_DISABLED",
"the Kafka Share Group adapter is experimental and disabled unless "
+ "backend.messaging.experimental.kafka-share=true");
}
if (profile.orderingScope() != OrderingScope.NONE) {
throw new IllegalArgumentException(
"a Kafka share group cannot provide ordered delivery: " + profile.destination().value());
}
```
**두 거절의 예외 타입이 다르다.** 첫째는 `MessagingCapabilityUnavailableException`(카테고리 `CONFIGURATION`, 안정 코드 있음), 둘째는 `IllegalArgumentException`(코드 없음). 둘 다 설정 오류인데 하나만 플랫폼 실패 어휘를 쓴다. §17.
에러 메시지가 **프로퍼티 키를 직접 적는다**`backend.messaging.experimental.kafka-share=true`. 그 키를 읽는 코드가 이 저장소에 없다(§12.4).
### 4.3 `KafkaShareGroupRegistrar` — spec을 받고 쓰지 않는다
```java
public TransportConsumerRegistration register(
KafkaShareProfile profile, TransportConsumerSpec spec) {
Objects.requireNonNull(spec, "spec must not be null");
validator.validate(profile);
return new ShareRegistration(profile);
}
```
`spec`**null 검사만 받는다.** `ShareRegistration``profile``AtomicBoolean active` 둘만 갖는다.
`TransportConsumerSpec``(DestinationProfile profile, Function<TransportDelivery, CompletionStage<Void>> sink)`이고, `sink`가 플랫폼이 전달마다 부르는 콜백이다(`messaging-transport-spi` §4.5). 그 sink가 저장되지 않으므로 **어떤 메시지도 전달되지 않는다.**
Kafka 소비자도 만들어지지 않는다 — `kafka-clients`를 import하는 코드가 없다.
`register(...)`**아무것도 등록하지 않고** `isActive() == true`인 객체를 반환한다. §17.
### 4.4 `ShareRegistration` — pause/resume은 실패 stage
```java
@Override
public CompletionStage<Void> pause(String scope) {
return CompletableFuture.failedFuture(
new MessagingCapabilityUnavailableException("KAFKA_SHARE_NO_PAUSE", ...));
}
```
registrar javadoc이 이유를 적는다.
```java
// :12-15
* <p>Pause and resume are refused rather than silently ignored. A share group has no partition
* assignment to pause, so accepting the call would let a retry policy that depends on pausing
* appear to work while doing nothing.
```
**예외를 던지지 않고 실패한 `CompletionStage`를 반환한다**`TransportConsumerRegistration.pause`의 반환 타입이 `CompletionStage<Void>`이므로 비동기 계약을 지킨다. `messaging-runtime-core``DefaultDeliveryProcessor.OneShotSettlement`가 이중 정산을 `failedFuture`로 보고하는 것과 같은 규율이다.
이 거절이 `messaging-policy``RetryMode.PAUSE_PARTITION`과 맞물린다 — 그 모드를 share group 목적지에 설정하면 `DefaultRetryDecisionEngine``PauseAndRetry`를 고르고 이 registration이 그것을 거절한다. **두 leaf가 같은 사실을 양쪽에서 안다.**
`close()``active`를 false로 바꾸는 것 외에 아무것도 하지 않는다 — 해제할 자원이 없기 때문이다.
### 4.5 `KafkaShareWorkQueueCapability` — 12개 boolean
```java
return new MessagingCapabilities(
true, true, true, false, false, false, false, false, false, false, false, false);
```
`MessagingCapabilities`의 필드 순서에 대입하면:
| # | capability | 값 |
|---:|---|:---:|
| 1 | `brokerAcknowledgement` | **true** |
| 2 | `replicationOrPersistenceEvidence` | **true** |
| 3 | `perMessageSettlement` | **true** |
| 4 | `batchSettlement` | false |
| 5 | `orderedStream` | false |
| 6 | `keyedOrdering` | false |
| 7 | `replay` | false |
| 8 | `delayedDelivery` | false |
| 9 | `brokerTransaction` | false |
| 10 | `deduplicatedPublish` | false |
| 11 | `nativeDeadLetter` | false |
| 12 | `topologyManagement` | false |
javadoc이 요약한다 — "Per-record settlement, yes. Ordering, replay, and transactions, no — a share group gives up exactly those to gain competing-consumer throughput."
**세 true가 정확히 3·1·2번**이고 javadoc이 "per-record settlement"만 언급한다. 1·2번(브로커 ack, 복제 증거)은 언급되지 않는다.
선언 목적도 적혀 있다 — "Declared as a capability rather than assumed, so that the shared validators refuse an ordered or replayed destination on this adapter before a message is ever produced." 즉 `messaging-policy`의 검증기와 `DefaultRetryDecisionEngine`이 이 값을 읽을 것을 전제한다. **그 전달 경로가 없다**(§12.1).
---
## 5. 주요 실행 경로
**등록:** `registrar.register(profile, spec)``validator.validate(profile)` → 통과하면 `ShareRegistration(profile)` 반환 → **이후 아무 일도 일어나지 않는다**
**pause:** `registration.pause(scope)` → 즉시 실패 stage
이 leaf에 메시지가 흐르는 경로가 없다.
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 카테고리 | 조건 |
|---|---|---|---|
| `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`의 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."
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
`ShareRegistration.active``AtomicBoolean`이다. `close()``set(false)`이고 CAS가 아니므로 두 번 닫아도 무해하다(멱등).
`KafkaShareProfileValidator`·`KafkaShareWorkQueueCapability`는 상태가 없다. `KafkaShareGroupRegistrar`는 validator 참조 하나만 갖는다.
수명주기 참여 없음 — `TransportConsumerRegistration``AutoCloseable`이지만 이 구현은 닫을 자원을 갖지 않는다.
---
## 8. 설정·기능 플래그·환경 차이
| 항목 | 값 |
|---|---|
| 프로퍼티 키(에러 메시지에만 등장) | `backend.messaging.experimental.kafka-share` |
| `enabled` | `KafkaShareProfile`의 필드 — 호출자가 채운다 |
**그 프로퍼티를 읽는 코드가 저장소에 없다.** `enabled``KafkaShareProfile` 생성자 인자이고 그 profile을 만드는 production 코드도 없다. 즉 키는 문서로만 존재한다. §17.
상수 없음.
---
## 9. 퍼시스턴스/외부 시스템 세부
**없다.** Kafka Share Group을 감싼다고 선언하지만 Kafka 클라이언트를 사용하지 않는다.
`build.gradle``implementation 'org.apache.kafka:kafka-clients'`를 선언하고 `import org.apache.kafka`가 소스에 0건이다(§12.4).
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-kafka-share-experimental:test`. **BUILD SUCCESSFUL, 6 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `KafkaShareProfileValidatorTest` | 6 | 두 거절 조건과 통과 조건 | **registrar·capability가 검증되지 않음** |
**네 타입 중 하나만 테스트된다.**
- `KafkaShareGroupRegistrar` — 테스트 없음. `register`가 spec을 무시하는 것, pause/resume이 실패 stage를 반환하는 것, `close``isActive`를 바꾸는 것이 전부 미검증
- `KafkaShareWorkQueueCapability` — 테스트 없음. 12개 boolean 중 어느 것도 단언되지 않음
- `KafkaShareProfile` — 생성자 거절 둘이 validator 테스트를 통해 간접적으로만
`messaging-schema-avro`가 3개 테스트 클래스로 2개 production 타입을 덮는 것과 대비된다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | 네 project 의존 — **미사용 둘을 포함해 통과한다**(허용 목록은 상한이지 하한이 아니다) |
| `verifyRuntimeModuleMembership` | `[]` — 런타임 편입 없음이 강제됨 |
| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | public 시그니처에 Kafka 타입이 없으므로 `implementation`이 맞다. **다만 아예 쓰이지 않는다**(§12.4) |
| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |
| ArchUnit | 전용 규칙 없음 |
첫 행이 이 leaf의 §17 항목 중 하나다 — `allowed_dependencies`**실제 사용을 요구하지 않는다.**
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` §C·§D·§E.
### 12.1 Public surface reachability
**네 타입 전부 leaf 밖 참조 0이다.**
```
KafkaShareGroupRegistrar 0
KafkaShareProfile 0
KafkaShareProfileValidator 0
KafkaShareWorkQueueCapability 0
```
`runtime_memberships: []`, starter 미포함, 조립 0건 — **삼중 정합**이다. `messaging-schema-avro`·`messaging-schema-protobuf`와 같은 상태이고, incubating leaf가 이래야 하는 형태다.
`messaging-claim-check`·`messaging-cloudevents`와 대비된다 — 그 둘은 소비자 0인데 membership이 있다.
**`KafkaShareWorkQueueCapability`의 0이 다른 의미를 갖는다.** 이 클래스의 javadoc은 "so that the shared validators refuse an ordered or replayed destination on this adapter before a message is ever produced"라고 한다. 즉 **공유 검증기가 이 값을 읽을 것을 전제한다.** `messaging-policy``RetryContext.capabilities``DefaultRetryDecisionEngine`이 그 소비자인데, 그것에 이 값을 넘기는 경로가 없다. `MessagingTransport.capabilities(DestinationName)`가 그 경로여야 하는데 이 leaf는 `MessagingTransport`를 구현하지 않는다.
### 12.2 Conditional sibling comparison
Spring 주석 0개, bean 없음.
**`MessagingTransport` 구현 sibling과의 비교가 유의미하다.**
| 어댑터 leaf | `MessagingTransport` 구현 | membership |
|---|:---:|---|
| `messaging-kafka` | o (`KafkaMessagingTransport`) | `["app-bootstrap"]` |
| `messaging-rabbit` | o (`RabbitMessagingTransport`) | `["app-bootstrap"]` |
| `messaging-pulsar-experimental` | o (`PulsarMessagingTransport`) | `[]` |
| `messaging-nats-experimental` | o (`NatsJetStreamTransport`) | `[]` |
| **`messaging-kafka-share-experimental`** | **x** | `[]` |
**네 형제 어댑터가 전부 SPI를 구현하고 이 leaf만 구현하지 않는다.** 두 experimental 형제(pulsar, nats)도 구현한다. 그래서 이 leaf는 "experimental이라서 미완"이 아니라 **형제와 다른 형태**다 — `TransportConsumerRegistration`만 부분 구현하고 `MessagingTransport`는 건드리지 않는다.
결과: capability 선언(§12.1)도, 발행 경로도, 소비 경로도 플랫폼에 연결될 지점이 없다.
### 12.3 Duplicate mechanism sweep
**(a) 순서 거절이 두 곳에 있다**
| 위치 | 검사 |
|---|---|
| 이 leaf `KafkaShareProfileValidator` | `orderingScope != NONE` → 거절 |
| `messaging-policy` `DestinationProfileValidator:78-82` | `orderingScope == DESTINATION && consumer.concurrency > 1` → 거절 |
| `messaging-policy` `DestinationProfileValidator:83-87` | `isOrdered() && maxInFlightPerOrderingUnit > 1` → 거절 |
세 검사가 같은 관심사(순서와 동시성의 양립 불가)를 다룬다. 이 leaf의 것이 가장 강하다 — **순서 자체를 금지**한다. policy 쪽은 순서를 허용하되 동시성을 1로 묶는다.
두 정책이 만나는 지점이 없다 — 이 leaf가 `DestinationProfile`을 받지 않고 자기 `KafkaShareProfile`을 쓴다. 즉 **목적지 프로파일 하나가 두 검증기를 통과하는 경로가 없다.** 중복이 아니라 **연결되지 않은 두 모델**이다.
**(b) `enabled` 플래그 패턴**
experimental leaf 셋(`kafka-share`, `pulsar`, `nats`) 중 이 leaf만 `enabled`를 profile 필드로 갖는다. 나머지 둘의 활성화 방식은 각 leaf SSOT가 답한다.
**(c) `MessagingCapabilities` 선언이 어댑터마다**
각 어댑터가 자기 capability 집합을 선언한다. 이 leaf는 정적 메서드 하나, `messaging-kafka``KafkaMessagingTransport.CAPABILITIES` 상수. 형태가 다르지만 중복 경쟁은 아니다 — 각자 자기 브로커를 서술한다.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `build.gradle`: `kafka-clients` 의존 | `import org.apache.kafka` **0건** | **미사용 의존** |
| registry: `messaging-policy`·`messaging-kafka` 의존 | 두 패키지에서 import 0건 | **미사용 의존** |
| `KafkaShareProfileValidator` 에러 메시지: `backend.messaging.experimental.kafka-share=true` | 그 키를 읽는 코드 0건 | **미실현** |
| `KafkaShareWorkQueueCapability` javadoc: "the shared validators refuse … before a message is ever produced" | capability를 검증기로 넘기는 경로 없음 | **미실현** |
| `KafkaShareGroupRegistrar` javadoc: "Registers a share group consumer" | 소비자를 만들지 않음 | **불일치** |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 실제로 `[]` | **이 leaf에 한해 참** |
다섯 번째가 이 leaf의 가장 무거운 drift다 — 클래스 이름과 메서드 이름이 하지 않는 일을 서술한다.
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
이 leaf의 javadoc에 **이전 결함 서술이 없다.** 다른 messaging leaf 대부분이 "X used to …" 형태의 기록을 갖는 것과 대비된다.
대신 **막으려는 것**을 셋 적는다.
| 위치 | 막으려는 것 |
|---|---|
| `KafkaShareProfileValidator` | 순서 목적지를 share group에 설정 → 브로커가 주지 않는 보장을 광고 |
| 같은 곳 | experimental이 기본 켜져 Stable 배포로 drift |
| `KafkaShareGroupRegistrar` | pause를 조용히 무시 → pause에 의존하는 retry 정책이 동작하는 것처럼 보이며 아무것도 하지 않음 |
세 번째가 이 leaf에서 가장 성숙한 판단이다 — **거절이 무시보다 낫다**는 원칙이고, `messaging-core-api``MessagingCapabilityUnavailableException` javadoc과 같은 계열이다.
역설적으로 **그 원칙이 `register(...)`에는 적용되지 않았다** — spec을 받아 무시하고 성공을 반환한다(§17).
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` §C·§D·§E | 네 타입 참조 0, `kafka-clients` 선언과 import 0(exit=1), `register`가 spec을 무시하는 코드와 `ShareRegistration` 필드 | 정적 검색 |
| EVD-293 | command | `./gradlew :messaging:messaging-kafka-share-experimental:test --rerun-tasks` | BUILD SUCCESSFUL, 6 / 0 / 0 | validator만 검증 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- share group이 순서와 양립 불가인 이유 — `KafkaShareProfileValidator` javadoc
- experimental이 기본 꺼짐인 이유 — 같은 javadoc
- pause/resume을 무시하지 않고 거절하는 이유 — `KafkaShareGroupRegistrar` javadoc
- capability를 선언으로 두는 이유 — `KafkaShareWorkQueueCapability` javadoc
- share group이 포기한 것(순서·replay·트랜잭션)과 얻은 것(경쟁 소비자 처리량) — 같은 javadoc
**추론**
- `register`가 spec을 쓰지 않는 것이 미완인지 의도인지 → **미상**. 다른 형제 어댑터는 전부 실제 소비자를 만든다.
- `kafka-clients`·`messaging-policy`·`messaging-kafka` 의존이 선언만 된 이유 → **추론**. 완성된 구현을 상정하고 미리 선언한 것으로 보인다.
- `maxDeliveryCount`를 읽는 코드가 없는 이유 → **미상**. 같은 추론이 적용된다.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 4개 타입 190줄 전문
- 6개 테스트가 통과하고 validator만 덮는다는 것
- 네 타입 전부 참조 0이고 membership `[]`과 정합한다는 것
- `kafka-clients` 의존 선언과 import 0건
- `messaging-policy`·`messaging-kafka` 의존이 사용되지 않는다는 것
- `register(...)``TransportConsumerSpec`을 null 검사만 하고 버린다는 것
- 형제 어댑터 넷이 전부 `MessagingTransport`를 구현하고 이 leaf만 하지 않는다는 것
**확인하지 못한 것**
- 이 leaf를 완성할 계획이 있는지 — 커밋이 대량 커밋뿐이고 기록이 없다.
- Kafka Share Group(KIP-932)이 이 저장소가 고정한 Kafka 버전에서 사용 가능한지 — `kafka-clients` 버전이 lockfile에 있으나 확인하지 않았다.
- `backend.messaging.experimental.kafka-share` 키가 어딘가 문서화돼 있는지 — `docs/messaging/experimental-policy.md`가 후보다.
- `maxDeliveryCount`가 어떤 값을 갖도록 의도됐는지.
---
## 17. 손볼 것
### P2 — "등록"이 아무것도 등록하지 않고 성공을 반환한다
- **사실.** `KafkaShareGroupRegistrar.register(profile, spec)``spec``Objects.requireNonNull`로만 처리하고 버린다. `ShareRegistration``profile``AtomicBoolean` 둘만 갖는다. Kafka 소비자가 만들어지지 않고(`import org.apache.kafka` 0건), `spec.sink`가 저장되지 않으므로 어떤 전달도 일어나지 않는다. 반환된 registration은 `isActive() == true`를 보고한다.
- **근거.** `evidence/raw/290` §D·§E.
- **왜 문제인가.** 같은 클래스의 javadoc이 pause를 조용히 무시하는 것을 거절한 이유로 "would let a retry policy that depends on pausing appear to work while doing nothing"을 든다. `register` 자체가 정확히 그 형태다 — 성공을 반환하고 아무것도 하지 않으며 `isActive()`가 true다. 오늘 호출자가 없으므로 사고는 아니지만, 이 leaf를 배선하는 사람이 가장 먼저 부를 메서드다.
- **확인 방법.** `evidence/raw/290` §E 재실행.
- **후보.** (a) 실제 share group 소비자를 만든다. (b) 미구현임을 명시하고 `MessagingCapabilityUnavailableException`으로 거절한다 — 이 leaf 자신의 원칙과 일관된다. (c) `register`를 제거하고 validator와 capability만 남긴다.
- **다음 단계.** **CASE 후보.** "무시보다 거절"을 명시한 클래스가 자기 주 메서드에서는 무시한다는 형태가 그 자체로 가치가 있다.
### P3 — 선언된 의존 셋이 사용되지 않는다
- **사실.** `build.gradle``org.apache.kafka:kafka-clients`를 선언하고 `import org.apache.kafka`가 0건. registry가 `messaging-policy`·`messaging-kafka` 의존을 허용하고 두 패키지의 import가 0건.
- **근거.** `evidence/raw/290` §D. import 전수.
- **왜 문제인가.** `verifyCleanArchitectureDependencies``allowed_dependencies`를 **상한**으로 검사하므로 미사용 의존을 잡지 못한다. 결과: 이 leaf의 build closure가 실제 필요보다 넓고, `messaging-kafka`(34파일)와 그 전이 의존이 딸려 온다. 그리고 의존 선언이 "이 leaf가 Kafka를 쓴다"는 인상을 준다.
- **확인 방법.** `grep -rn 'import org.apache.kafka\|import dev.caskeleton.messaging.policy\|import dev.caskeleton.messaging.kafka\.' src/messaging/messaging-kafka-share-experimental/src`
- **후보.** 구현 전까지 미사용 의존을 제거하거나, 미완 상태임을 build.gradle 주석에 적는다.
- **다음 단계.** **REFERENCE 후보**(허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다).
### P3 — 형제 어댑터 넷이 구현하는 SPI를 이 leaf만 구현하지 않는다
- **사실.** `KafkaMessagingTransport`·`RabbitMessagingTransport`·`PulsarMessagingTransport`·`NatsJetStreamTransport`가 전부 `MessagingTransport`를 구현한다. 이 leaf는 `TransportConsumerRegistration`만 부분 구현한다.
- **근거.** `evidence/raw/280` §D(transport-spi probe)와 이 leaf의 소스.
- **왜 문제인가.** `KafkaShareWorkQueueCapability`가 존재하는 이유("shared validators refuse … before a message is ever produced")가 실현되려면 `MessagingTransport.capabilities(DestinationName)`를 통해 값이 전달돼야 한다. 그 인터페이스를 구현하지 않으므로 capability는 아무도 읽지 않는 상수다. 두 experimental 형제(pulsar, nats)는 구현하므로 "experimental이라서"가 이유가 되지 않는다.
- **확인 방법.** `git grep -n 'implements MessagingTransport' -- 'src/messaging/**/*.java'`
- **후보.** `MessagingTransport`를 구현하거나, capability를 어떻게 전달할지 정한다.
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 §17 첫 항목("완성할 것인가")에 걸린다.
### P3 — 두 거절이 다른 예외 계층을 쓴다
- **사실.** `!enabled``MessagingCapabilityUnavailableException`(안정 코드 `KAFKA_SHARE_DISABLED`), `orderingScope != NONE``IllegalArgumentException`(코드 없음).
- **근거.** `KafkaShareProfileValidator.java:31-40`.
- **왜 문제인가.** 둘 다 설정 오류이고 둘 다 시작 시점에 잡힌다. 한쪽만 `FailureDescriptor`를 갖는다. `messaging-security``MessageSecurityValidator`(전부 `IllegalArgumentException`)와 `BrokerTlsPolicy`(전부 `MessagingConfigurationException`)가 갈라진 것과 같은 형태다.
- **확인 방법.** 두 throw 문 대조.
- **후보.** 둘 다 `MessagingConfigurationException`으로 통일하고 안정 코드를 준다.
- **다음 단계.** `messaging-security` §17의 같은 항목과 함께 **REFERENCE 후보**(구성 오류는 한 예외 타입과 안정 코드로 보고한다).
### P3 — 네 타입 중 하나만 테스트된다
- **사실.** `KafkaShareProfileValidatorTest`만 존재한다. registrar·capability에 테스트가 없다.
- **근거.** `find src/test -name '*Test.java'` → 하나.
- **왜 문제인가.** `register`가 spec을 버리는 것(§17 첫 항목)이 테스트가 있었다면 드러났을 형태다 — sink가 호출되는지 확인하는 테스트가 실패했을 것이다. capability 12개 boolean도 미검증이라 순서를 true로 바꿔도 아무것도 깨지지 않는다.
- **확인 방법.** 테스트 클래스 목록.
- **후보.** registrar와 capability에 테스트를 추가한다.
- **다음 단계.** **REFERENCE 후보**(leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다) — `messaging-claim-check` §17과 같은 기준.
### P3 — 활성화 프로퍼티 키가 에러 메시지에만 존재한다
- **사실.** `backend.messaging.experimental.kafka-share=true``KAFKA_SHARE_DISABLED` 메시지에 적혀 있다. 그 키를 읽는 코드가 저장소에 없다.
- **근거.** `git grep -n 'kafka-share' -- src` → 이 leaf의 문자열 하나.
- **왜 문제인가.** 운영자가 메시지를 보고 그 프로퍼티를 설정해도 효과가 없다. `enabled``KafkaShareProfile` 생성자 인자이고 그 profile을 만드는 production 코드가 없다.
- **확인 방법.** 키 문자열 검색.
- **후보.** 배선될 때 프로퍼티 바인딩을 함께 만들거나, 메시지에서 키를 빼고 "이 profile의 `enabled`를 설정하라"로 바꾼다.
- **다음 단계.** **REFERENCE 후보**(에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다) — `messaging-claim-check` §17의 "use claim check"와 같은 형태.
### 확인된 설계(문제 아님)
- 순서 목적지를 degrade하지 않고 거절하는 것과 그 이유
- experimental을 기본 꺼짐으로 두는 것
- pause/resume을 조용히 무시하지 않고 실패 stage로 거절하는 것
- capability를 가정이 아니라 선언으로 두는 것
- `close()`가 멱등인 것
- 소비자 0·membership `[]`·조립 0의 삼중 정합
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MKS-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 4개, `runtime_memberships: []` | 선언 |
| MKS-002 | build | `messaging-kafka-share-experimental/build.gradle` | same | `kafka-clients` 선언 | 사용되지 않음(§12.4) |
| MKS-003 | code | `.../kafka/share/KafkaShareProfileValidator.java` | same | §4.2 두 거절과 experimental 정책 | 예외 계층 불일치(§17) |
| MKS-004 | code | `.../kafka/share/KafkaShareGroupRegistrar.java` | same | §4.3 spec 무시, §4.4 pause 거절 | 테스트 없음 |
| MKS-005 | code | `.../kafka/share/KafkaShareProfile.java` | same | 다섯 필드와 두 거절 | `maxDeliveryCount` 미사용 |
| MKS-006 | code | `.../kafka/share/KafkaShareWorkQueueCapability.java` | same | 12 boolean과 선언 목적 | 전달 경로 없음 |
| MKS-007 | test | `KafkaShareProfileValidatorTest` (6) | same | 두 거절과 통과 | 네 타입 중 하나만 |
| MKS-008 | cross-leaf code | 4개 `*MessagingTransport.java` | same | 형제 넷이 SPI 구현 | 각 leaf SSOT가 소유 |
| MKS-009 | cross-leaf code | `messaging-policy/.../DestinationProfileValidator.java:78-87` | same | 연결되지 않은 두 순서 정책 | 해당 leaf SSOT가 소유 |
| EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` §C·§D·§E | same | §12.1·§12.4 | 정적 검색 |
| EVD-293 | command | `./gradlew :messaging:messaging-kafka-share-experimental:test --rerun-tasks` | same | 6 / 0 / 0 | validator만 |
@@ -0,0 +1,443 @@
# messaging-kafka 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 34파일 3,427줄 + `src/test` 24파일 4,087줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-kafka`
> SSOT owner: `messaging-kafka`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `runtime_memberships`: **`["app-bootstrap"]`** — 출하
- 등급: Stable · 이 가족에서 실제로 선택 가능한 유일한 브로커(§12.1)
| 파일 | LOC | 조립되나 |
|---|---:|---|
| `KafkaConsumerRegistrar` | 621 | **아니오** — 테스트만 |
| `KafkaMessagingTransport` | 215 | 예(발행 전용 생성자) |
| `KafkaTransactionalPublisher` | 178 | 아니오 |
| `KafkaSecurityConfigurer` | 173 | 빈으로만 — 호출처 없음 |
| `KafkaHeaderMapper` | 169 | 예(전송 경유) |
| `KafkaDeliveryMapper` | 167 | 아니오 |
| `KafkaBatchConsumerRegistrar` | 146 | **아니오** — 저장소 전체에 참조 0 |
| `PartitionWorkCoordinator` | 128 | 아니오 |
| `KafkaRetryExecutor` | 126 | 아니오 |
| `ContiguousPartitionOffsetTracker` | 119 | 아니오 |
| `KafkaPublishFailureClassifier` | 116 | 예(전송 경유) |
| `KafkaPublishMapper` | 111 | 예(전송 경유) |
| `KafkaRetryMetadataMapper` | 93 | 아니오 |
| `KafkaPartitionRetryScheduler` | 90 | 아니오 |
| `KafkaReplayCapability` | 82 | 아니오 |
| `KafkaRetryTopicPublisher` | 75 | 아니오 |
| `SpringKafkaTransactionalProcessor` | 68 | 아니오 |
| `KafkaOffsetResetExecutor` | 63 | 아니오 |
| `KafkaProfileValidator` | 60 | 예(시작 검증) |
| `KafkaReplayPlanner` | 58 | 아니오 |
| `KafkaBrokerProfile` | 56 | 예(설정 컴파일) |
| `KafkaSettlementQueue` · `KafkaTransactionProfileValidator` | 52 · 52 | 아니오 / 빈만(§17.2) |
| `KafkaSettlementCommand` | 50 | 아니오 |
| `KafkaDeadLetterPublisher` | 49 | 아니오 |
| `PartitionOffsetTracker` | 48 | 아니오 |
| `KafkaTopologyInspector` | 45 | 아니오 |
| `KafkaRetryOutcome` | 40 | 아니오 |
| `KafkaPosition` | 38 | 예(발행 결과) |
| `KafkaReplayPlan` | 36 | 아니오 |
| `KafkaQuarantinePublisher` | 31 | 기본 구현만 |
| `KafkaTransactionalProcessor` | 29 | 아니오 |
| `KafkaTransactionalDelivery` · `KafkaTransactionalOutput` | 22 · 21 | 아니오 |
main 총 **34파일 / 3,427줄**.
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 34 | `FULL_READ` | 3,427줄. 위 표가 전부 |
| `test/java/**` | 24 | `FULL_READ` | 4,087줄. 인증 레인·Toxiproxy 레인 포함 |
| `build.gradle` | 1 | `FULL_READ` | 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
`UNCLASSIFIED` 0.
> 이 표는 2026-09-01 재통독에서 다시 세었다. 이전 판은 `main/java/**` 를 **29** 로 적었다. 실제는 34 이고, 빠져 있던 다섯 안에 §17.4 의 `KafkaRetryMetadataMapper` 가 있었다. "조립되나" 열도 이번에 추가했다 — 이 리프의 판정 등급이 전부 그 열에 달려 있다.
---
## 1. 소비자 런타임 — 스레드 규율이 설계다
> "Every call into `Consumer` — poll, pause, resume, seek, commit — happens on the poll thread and
> nowhere else. `KafkaConsumer` is documented as not thread-safe, and a worker that committed
> directly would corrupt the client's internal state under concurrency in ways that surface much
> later as skipped offsets. Workers therefore enqueue a `KafkaSettlementCommand` and the poll thread
> applies it at the top of the next cycle."
공개 API 인 `pause`/`resume` 도 제어 큐를 통해 폴 스레드로 넘어가고, 반환된 단계는 **다음 폴 주기** 에 완료된다.
> "so a caller that awaits it knows the consumer is paused rather than merely asked to pause… there
> is no safe way to touch the consumer from another thread, so 'paused' cannot be true until the loop
> says so."
`close()` 만 예외이고 그 예외에 근거가 붙어 있다 — 이후 폴 루프가 멈추므로 큐에 넣으면 영원히 배수되지 않는다.
이 규율은 실제로 지켜진다. 작업자 람다가 만지는 것은 `settlements`·`coordinator`·`shutdown`·`retries` 뿐이고 `consumer` 는 한 번도 없다. 통독으로 확인했다.
## 2. 커밋은 연속 워터마크로만 전진한다
> "A Kafka offset commit is a watermark, not a set: committing offset 13 declares that everything
> below it is done. With concurrent handlers, offsets finish out of order — 10 and 12 may complete
> while 11 is still running — and committing 13 at that moment would silently discard 11."
그 대가도 적혀 있다 — 느린 메시지 하나가 그 파티션의 워터마크를 붙든다. 그것이 옳은 교환이라는 근거는 대안이 메시지를 잃는다는 것이고, 지연은 소비자 랙으로 보인다는 것이다.
그리고 등록만 되고 제출되지 않은 오프셋을 되돌리는 경로가 있다.
> "A delivered offset with no worker behind it holds the contiguous watermark back forever: nothing
> will ever complete it, so the partition stops committing while continuing to consume."
## 3. 이미 고쳐진 결함 네 개가 코드에 주석으로 남아 있다
이 리프의 서술 방식이다 — 고친 자리마다 이전 상태를 적어 둔다.
**커밋 순서.** 지역 맵과 트래커를 `commitSync` **뒤에** 갱신한다. 이전 순서는 실패한 커밋 뒤에 브로커가 받은 적 없는 오프셋을 커밋된 것으로 믿게 했고, 잘린 트래커가 그것을 다시 만들 수 없어 다음 커밋이 간극을 건너뛰었다.
**재조정 에폭.** 파티션 회수 시 에폭을 **먼저** 지운다.
> "Any settlement still in flight for these partitions now carries a number no live assignment has,
> so applySettlements refuses it instead of moving a watermark this consumer no longer owns."
**전역 break 제거.** 한 파티션이 천장에 닿았을 때 배치 전체를 버리던 형태를 파티션별 처리로 바꿨다.
> "which abandoned every record the same poll had returned for *other* partitions… a processing gap
> that nothing reported."
**정착의 단일 종결.** `acknowledge`/`requeue`/`discard` 가 하나의 CAS 를 두고 경쟁한다 — 핸들러가 둘 다 말하면 폴 스레드가 두 번째를 믿던 형태를 막는다.
거부된 정착 수는 조용히 세지 않고 `staleSettlements()` 로 노출한다. 0 이 아니면 핸들러가 자기 할당보다 오래 살고 있다는 뜻이고, 운영자가 행동할 수 있는 신호다.
## 4. 배압은 버퍼가 아니라 일시정지로 준다
> "A partition at its in-flight ceiling stops being fetched, so unprocessed records stay in the
> broker instead of in the heap."
파티션 단위로만 멈추고, 재개 지점은 그 파티션의 가장 이른 미제출 오프셋이다. 재개 자체가 없는 것이 §17.3 이다.
## 5. 발행 실패 분류
> "The split is between failures that prove the record was not stored and failures that prove
> nothing… The default is deliberately ambiguous rather than rejected. Guessing 'rejected' on an
> unknown error is what turns one lost confirmation into two orders."
일곱 예외 타입만 단정적 거부이고, 그중 셋(`AuthenticationException`·`AuthorizationException`·`SerializationException`)은 각각 전용 범주로 간다.
## 6. 트랜잭션 조건
`KafkaTransactionProfileValidator` 가 넷을 요구한다 — 트랜잭션 식별자 접두, 멱등 생산자, `acks=all`, 수동 오프셋 커밋. 그리고 다섯째가 핵심이다.
> "A destination that declares `INBOX_TRANSACTIONAL` is telling the platform its side effect lives in
> a database, and a Kafka transaction cannot span that. Allowing both to be configured together would
> let a team read 'transactional' twice and conclude the whole path is atomic when the two halves can
> still diverge."
## 10. 테스트 레인
24파일 4,087줄. 세 층이다.
| 층 | 파일 | 무엇을 붙드나 |
|---|---|---|
| 결정적 | `KafkaConsumerRegistrarTest`(440), `KafkaTransactionOrderingTest`(192), `KafkaProfileValidatorTest`(167), `KafkaEnvelopeRoundTripTest`(154), `ReservedHeaderForgeryTest`(123), `KafkaHeaderMapperTest`(118), `ContiguousPartitionOffsetTrackerTest`(94), `KafkaReplayPlannerTest`(88), `PartitionWorkCoordinatorTest`(77), `KafkaProducerContractTest`(24) | `MockConsumer`·`MockProducer` 로 폴 주기·트랜잭션 호출 순서·헤더 왕복·워터마크 산술 |
| 실브로커 IT | `KafkaBrokerIT`(292), `KafkaConsumerSettlementIT`(218), `KafkaAmbiguityChaosIT`(173), `KafkaTransactionIT`(168), `KafkaReadCommittedIT`(155), `KafkaTransactionFencingIT`(150), `KafkaTopologyValidationIT`(144), `KafkaContainerSmokeTest`(66) | Testcontainers `apache/kafka:4.1.0` |
| 인증 레인 | `KafkaBrokerCertificationIT`(466) | Toxiproxy 로 소켓 단위 결함 주입 |
인증 레인의 판단이 이 가족에서 가장 강하다.
> "No `@EnabledIf` on Docker, deliberately… a certification lane that skips reports success for a
> broker nobody started, which is the exact failure the evidence exists to rule out."
그리고 커버하지 못하는 시나리오를 숨기지 않는다 — `connection-refused` 는 Kafka 생산자가 연결 성립 전에 레코드를 버퍼링하므로 전송에 대해 아무것도 증명하지 못하는 배달 마감으로만 나타난다. 그래서 그것을 `knownGaps` 로 남긴다.
`KafkaReadCommittedIT.abortATransactionCarrying` 의 주석도 같은 종류다 — `flush()` 가 없으면 abort 가 클라이언트 측에서 레코드를 버리므로 빈 토픽에 대해 시험이 무의미하게 통과한다.
## 12. negative-space probes
**12.1 도달성 — 이 리프의 절반이 조립되지 않는다.** 스타터는 이렇게 만든다.
```java
return new dev.caskeleton.messaging.kafka.KafkaMessagingTransport("kafka", 1L, producer);
```
인자 셋짜리 생성자다. 그 생성자는 소비자 팩토리를 이렇게 채운다.
```java
spec -> { throw new MessagingCapabilityUnavailableException(
"KAFKA_CONSUMER_NOT_CONFIGURED", "this Kafka transport was created without a consumer factory"); }
```
그리고 저장소 전체에서 `new KafkaConsumerRegistrar`**테스트 5곳에만** 있다. 소비 경로 전체 — 폴 루프(621), 정착 큐, 오프셋 트래커, 재시도 스케줄러, 파티션 조정자, 배달 매퍼, 재시도 실행기 — 가 배포에 조립되지 않는다.
조립되는 것은 발행 경로다. 전송·발행 매퍼·헤더 매퍼·실패 분류기·`KafkaPosition`, 그리고 시작 검증기 하나.
이 사실이 §17.3·§17.4·§17.5 의 등급을 한 칸 낮춘다. 오늘의 사고가 아니라 소비를 배선하는 날의 사고다.
**12.2 참조가 0인 production 파일.** `KafkaBatchConsumerRegistrar` 146줄은 저장소 전체에서 자기 파일 밖의 참조가 없다 — production 도 테스트도 아니다. 배치 소비의 규칙(파티션을 넘지 않는 배치, `DESTINATION` 순서와의 비양립)을 정확하게 서술하고 아무도 부르지 않는다.
**12.3 대조군 — 능력 선언 방식.** 세 어댑터가 모두 능력을 상수로 둔다. Pulsar 는 전송과 검증기가 서로 다른 값을 답하고, NATS 는 프로파일과 무관하게 중복 제거를 참으로 둔다. Kafka 는 §17.2 의 형태다.
**12.4 테스트가 볼 수 없는 것.** 소비 경로의 세 결함이 전부 같은 이유로 시험에서 벗어난다.
| 결함 | 가리는 형태 |
|---|---|
| §17.3 천장 일시정지 후 재개 없음 | 결정적 시험은 천장에 닿은 **그 주기**까지만 단언한다(`anOrderedDestinationDispatchesOneRecordAtATime`). 실브로커 IT 는 전부 핸들러 풀이 `Runnable::run`(인라인)이라 천장에 닿지 않고, 전부 레코드 1건만 발행한다 |
| §17.4 재시도 헤더 오염 | `ReservedHeaderForgeryTest.aMalformedRetryAttemptIsQuarantined``attemptOf`**던짐만** 단언한다. 소비자가 그 던짐을 어떻게 다루는지는 어떤 시험도 보지 않는다 |
| §17.5 벽시계 | `pollOnce(Instant)` 는 시계를 주입받는데 재시도 등록만 `Instant.now()` 를 읽는다. 지연 재개를 결정적으로 시험할 수 없다 |
**12.5 고쳐진 메서드와 증명된 메서드가 다르다.** §17.6.
## 16. 확인하지 못한 것
- 실제 브로커로 재조정 중 정착 거부를 재현하지 않았다. 인증 레인이 그 자리이고 컨테이너가 필요하다.
- §17.3 을 실행으로 재현하지 않았다. `consumer.resume(...)` 호출처가 둘(`applyDueResumes`·공개 `resume(scope)`)뿐이고 천장 경로가 `retries` 에 아무것도 등록하지 않는다는 것으로 판정했다.
- §17.4 를 실행으로 재현하지 않았다. `attemptOf``dispatch` 의 두 번째 `try` 안에 있고 그 `catch``requeueAfterFailure()` 라는 것, `MessagingConfigurationException``RuntimeException` 을 상속한다는 것으로 판정했다.
- Toxiproxy 인증 레인을 직접 돌리지 않았다. 코드와 그 레인이 기록하는 증거 형식만 읽었다.
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
## 17. 손볼 것
### 17.1 P1 — 지원 문서가 `deduplicatedPublish` 를 지원으로 적고, 코드는 거짓이며, 그 차이가 정확히 코드가 경고한 피해다
```java
private static final MessagingCapabilities CAPABILITIES =
new MessagingCapabilities(true, true, true, true, true, true, true, false, true, false, false, true);
// ^^^^^ deduplicatedPublish
```
코드의 판정이 옳고 그 근거가 javadoc 에 있다.
> "Producer idempotence deduplicates *sequence retries within one producer session*: the producer id
> is reassigned on restart, so the same logical message published again after a crash is a new
> sequence and the broker stores it twice."
`docs/messaging/support-matrix.md:55` 의 능력 표는 이 칸을 `O` 로 적는다.
그 차이가 무거운 이유는 이 플랫폼에서 이 플래그가 특별하기 때문이다. 능력 열둘 중 **부재가 예외를 만드는 유일한 플래그**다.
```java
// DefaultMessagePublisher:249-252
if (options.deduplication().isPresent()
&& !transport.capabilities(profile.name()).capabilities().deduplicatedPublish()) {
throw new ("PUBLISH_DEDUPLICATION_UNSUPPORTED", );
```
그래서 표를 읽고 중복 제거를 전제한 목적지를 설계한 팀은 실행 시점에 능력 예외를 만난다. 반대로 표를 읽고 "중복 제거가 있으니 모호를 그냥 재시도해도 된다" 고 결론지으면, 실제로는 중복이 저장된다.
`MessagingCapabilities` 의 클래스 javadoc 이 그 피해를 미리 적는다 — "a silently weakened guarantee is indistinguishable from a working one until the incident."
수정은 문서 쪽이다. 코드가 이미 옳다.
### 17.2 P2 — 브로커 트랜잭션을 무조건 참으로 선언하고, 그 조건을 검사하는 검증기는 시작 시 돌지 않는다
능력 상수의 아홉 번째가 `brokerTransaction = true` 다. 프로파일과 무관한 상수다.
그런데 Kafka 트랜잭션이 실제로 성립하려면 `KafkaTransactionProfileValidator` 가 요구하는 넷이 모두 참이어야 한다 — 트랜잭션 식별자 접두, 멱등 생산자, `acks=all`, 수동 커밋.
그 검증기는 스타터가 빈으로 만들지만 `StartupProfileValidation` 으로 감싸지 않는다.
```java
// KafkaMessagingAutoConfiguration
@Bean public KafkaProfileValidator kafkaProfileValidator() { }
@Bean public StartupProfileValidation<KafkaBrokerProfile> kafkaProfileStartupValidation() { } // ← 감싼다
@Bean public KafkaTransactionProfileValidator kafkaTransactionProfileValidator() { } // ← 감싸지 않는다
```
즉 두 겹이 함께 비어 있다. 능력은 조건과 무관하게 참을 답하고, 조건을 검사할 검증기는 발행되기만 하고 주입되지 않는다.
`StartupProfileValidation` 의 javadoc 이 서술한 이전 결함이 정확히 그 형태다 — "the context published a validator per broker and validated nothing."
수정은 두 갈래를 함께 한다.
- 스타터에서 `kafkaProfileStartupValidation` 형태를 복사해 트랜잭션 검증기를 감싼다(스타터 SSOT §17.2 와 같은 수정).
- 능력을 프로파일에서 파생시킨다 — `enableIdempotence && "all".equals(acks) && 접두 존재`.
두 번째가 없으면 검증기가 돌더라도 능력 조회는 여전히 프로파일과 무관하게 답한다.
### 17.3 P2 — 천장에 닿아 일시정지된 파티션을 재개하는 경로가 없다
`pollOnce` 의 파티션 루프는 세 경우에 그 파티션을 멈춘다.
```java
if (!coordinator.tryAcquire(partition)) { seekBackTo = record.offset(); continue; } // 천장
if (!shutdown.tryBeginWork()) { continue; } // 배수 시작
if (!dispatch(record, partition, now, epoch)) { continue; } // 풀 거부
if (seekBackTo >= 0) {
consumer.pause(Set.of(partition));
consumer.seek(partition, seekBackTo);
}
```
이 세 경로 중 어느 것도 `retries.pauseUntil(...)` 을 부르지 않는다. 그런데 폴 루프가 파티션을 재개하는 곳은 하나뿐이다.
```java
private void applyDueResumes(Instant now) {
Map<TopicPartition, Long> due = retries.dueForResume(now); // ← retries 에 등록된 것만
due.forEach((partition, seekTo) -> { consumer.seek(...); coordinator.resume(...); consumer.resume(...); });
}
```
`retries` 에 항목을 넣는 곳은 `QueuedSettlement.enqueueRequeue` 하나이고, 그것은 핸들러 실패·타임아웃·명시적 requeue 경로다. 천장·배수·풀 거부 경로는 등록하지 않는다.
따라서 천장 때문에 멈춘 파티션은 **폴 루프가 스스로 재개하지 않는다.** 재개할 수 있는 것은 외부에서 부른 `resume(scope)` 이나 재조정뿐이다.
**도달 조건이 좁지 않다.** `maxInFlightPerOrderingUnit` 의 기본값은 1 이다(`DestinationSettings.Consumer`). 한 폴이 같은 파티션의 레코드를 둘 이상 돌려주는 순간 두 번째에서 `tryAcquire` 가 거짓이 되고, 그 파티션이 멈춘다. 그 뒤 작업자가 끝나 `coordinator.release` 로 슬롯이 비어도 `consumer` 는 여전히 일시정지 상태다.
**대조.** 같은 파일이 `coordinator.pause(...)``consumer.pause(...)` 를 구분해서 쓴다 — `applySettlements``PAUSE_AND_SEEK` 는 둘 다 부르고, 천장 경로는 `consumer` 쪽만 부른다. 그래서 조정자는 그 파티션을 멈춘 것으로 알지 못하고, 결과적으로 `tryAcquire` 는 계속 참을 답하는데 브로커에서 레코드가 오지 않는다.
**수정.** 천장 경로가 `retries.pauseUntil(partition, seekBackTo, Duration.ZERO, now)` 를 등록하면 다음 주기의 `applyDueResumes` 가 즉시 재개한다. 지연이 0 이므로 `dueForResume` 이 곧바로 돌려준다. 배수 경로는 재개하지 않는 것이 맞고, 풀 거부 경로는 천장과 같다.
**등급.** 소비 경로가 조립되지 않으므로(§12.1) P2. 배선하는 순간 P1 이다 — 파티션이 조용히 멈추고, 커밋 워터마크도 함께 멈추므로 소비자 랙만 늘어난다.
### 17.4 P2 — 오염된 재시도 헤더가 격리되지 않고 무한 pause-and-seek 을 만든다
`KafkaRetryMetadataMapper.attemptOf` 는 읽을 수 없는 `msg.retry.attempt` 에 대해 fail-closed 를 택하고, 그 이유를 정확하게 적는다.
```java
} catch (NumberFormatException malformed) {
// Returning 1 for an unreadable header restarts the retry budget on every redelivery… and the
// header is caller-influenced, which makes "unreadable" a way to defeat the cap rather than an
// accident.
throw new MessagingConfigurationException("RETRY_ATTEMPT_MALFORMED",
"the retry attempt header is not a positive integer; the message is quarantined rather"
+ " than restarting its retry budget");
}
```
메시지가 "quarantined" 라고 말한다. 소비자는 그렇게 하지 않는다.
```java
try {
envelope = deliveryMapper.toEnvelope(record);
} catch (RuntimeException undecodable) {
if (quarantine.quarantine(record, undecodable)) { settlement.acknowledgeAfterQuarantine(); }
else { settlement.requeueAfterFailure(); }
return; // ← 격리 경로는 여기까지다
}
try {
int attempt = retryMetadataMapper.attemptOf(envelope); // ← 던지는 자리는 여기다
} catch (ExecutionException | RuntimeException failure) {
settlement.requeueAfterFailure(); // ← pause-and-seek
}
```
격리 경로는 **디코딩 실패에만** 걸려 있다. `attemptOf` 는 디코딩이 끝난 뒤 두 번째 블록에서 던지고, `MessagingConfigurationException``MessagingException` 을 통해 `RuntimeException` 이므로 두 번째 `catch` 가 잡는다. 결과는 `requeueAfterFailure()``PAUSE_AND_SEEK` → 같은 오프셋 재읽기 → 같은 헤더 → 같은 예외다.
즉 fail-closed 가 막으려던 것(재시도 예산 무력화)보다 나쁜 것을 만든다 — 그 파티션이 영구히 그 레코드에서 멈춘다. 그리고 javadoc 이 지적한 대로 이 헤더는 호출자가 쓸 수 있는 값이므로, 숫자가 아닌 값 하나로 파티션 하나를 정지시킬 수 있다.
**테스트가 보지 못하는 이유.** `ReservedHeaderForgeryTest.aMalformedRetryAttemptIsQuarantined``attemptOf` 가 던지는 것만 단언한다. 이름은 "quarantined" 인데 격리를 확인하지 않는다.
**수정.** `attemptOf` 호출을 디코딩과 같은 블록으로 옮겨 격리 경로에 태우거나, 두 번째 `catch` 가 예외 종류를 나누게 한다 — `MessagingConfigurationException` 은 재시도로 회복되지 않는 종류이므로 격리 대상이고, 핸들러 실패는 재시도 대상이다.
### 17.5 P3 — 시계를 주입받는 클래스가 한 곳에서만 벽시계를 읽는다
`KafkaConsumerRegistrar` 의 설계 성질이 javadoc 에 적혀 있다.
> "`pollOnce(Instant)` is one full cycle and is public so the whole loop — commit ordering, pause,
> seek, rebalance — is testable against `MockConsumer` without threads or sleeps."
주기마다 `Instant now` 를 받아 `applyDueResumes(now)` 로 넘긴다. 그런데 그 짝인 등록 쪽은 이렇다.
```java
private SettlementResult enqueueRequeue(Duration delay) {
retries.pauseUntil(partition, offset, delay, Instant.now()); // ← 주입된 시계가 아니다
```
이 리프에서 `Instant.now()` 를 읽는 유일한 자리다. 그리고 그 호출은 작업자 스레드에서 일어나므로 폴 스레드의 `now` 와 다른 순간이다.
결과는 두 가지다. 지연 재시도(`requeue(Duration)`)의 재개 시점을 고정 시계로 시험할 수 없고, 시험이 `Duration.ZERO` 밖의 지연을 다루지 못한다 — 실제로 어떤 시험도 다루지 않는다.
수정은 생성자에 `Supplier<Instant>` 를 하나 더 받는 것이다. 같은 저장소의 `MessagingShutdownLifecycle` 이 정확히 그 형태로 두 생성자를 둔다.
### 17.6 P3 — 결함으로 판정된 메서드가 남아 있고, 실브로커 증명이 그것 위에서 돈다
`KafkaTransactionalPublisher` 에 같은 일을 하는 메서드가 둘 있다.
```java
public <T> T inTransaction(delivery, profiles, inputOffsets, Supplier<T> body) // begin → body → send → commit
public void sendInTransaction(delivery, profiles, inputOffsets) // begin → send → commit (body 없음)
```
`inTransaction` 의 javadoc 이 둘째를 결함으로 지목한다.
> "The processor used to run the handler and only afterwards hand the delivery here — so
> `beginTransaction` happened *after* the handler had already finished… a handler that succeeded and
> a commit that then failed left the handler's work applied with its input offsets unsent."
`sendInTransaction` 은 public 이고 production 호출자가 없다. 호출하는 것은 시험 다섯 자리뿐이다 — 그리고 그 다섯이 실브로커 트랜잭션 증명 전부다(`KafkaTransactionIT`·`KafkaTransactionFencingIT`·`KafkaReadCommittedIT`).
고쳐진 `inTransaction` 을 시험하는 것은 `KafkaTransactionOrderingTest` 하나이고 `MockProducer` 다. 즉 실브로커에서 커밋·중단·펜싱이 증명된 것은 옛 모양이고, 새 모양은 목 위에서만 증명됐다.
기능적 차이는 크지 않다(`body` 가 비어 있으면 두 메서드는 같은 호출열을 만든다). 그래도 두 가지가 남는다 — 결함으로 판정된 순서를 만드는 public 진입점이 여전히 열려 있다는 것, 그리고 실브로커 증거가 production 경로가 아닌 것 위에 있다는 것.
수정은 ITs 를 `inTransaction(..., () -> null)` 로 옮기고 `sendInTransaction` 을 지우는 것이다.
### 확인된 설계(문제 아님)
- **모든 소비자 호출을 폴 스레드로 모으고, 그 이유를 클라이언트의 문서화된 비스레드안전성에서 끌어온 것.** 통독으로 실제 준수를 확인했다.
- **공개 제어 API 도 큐를 지나게 하고, 반환 단계가 다음 주기에 완료된다는 것을 정직하게 서술한 것.**
- **`close()` 만 큐를 지나지 않게 하고 그 예외에 근거를 붙인 것.**
- **연속 워터마크로만 커밋하고, 그 대가를 소비자 랙으로 받아들인 것.**
- **제출되지 않은 배달 등록을 되돌리는 경로.**
- **커밋 뒤에 지역 상태를 갱신하도록 순서를 고치고, 이전 순서가 만든 결함을 주석에 남긴 것.**
- **재조정에서 에폭을 먼저 지워 늦은 정착을 거부하는 것, 그리고 할당에서도 에폭을 파생시켜 수동 할당을 덮은 것.**
- **거부된 정착 수를 지표로 노출한 것.**
- **정착을 단일 종결(CAS)로 만든 것.**
- **파티션별 처리로 바꿔 전역 break 이 만들던 처리 간극을 없앤 것.**
- **알 수 없는 발행 실패의 기본값을 모호로 둔 것.**
- **격리 기본 구현이 `false` 를 답해 커밋을 막는 것** — 쓸 곳이 없으면 오프셋을 넘기지 않는다.
- **Kafka 트랜잭션이 데이터베이스 부수효과를 덮지 못한다는 것을 검증기가 거부로 표현한 것.**
- **재생 기본값을 격리된 임시 그룹으로 두고, 운영 그룹 재생에 승인을 요구한 것.**
- **오프셋 재설정의 승인 술어를 생성자 인자로 둔 것** — 승인 출처 없이 조립된 런타임은 물리적으로 재설정할 수 없다.
- **JAAS 값 이스케이프 순서(역슬래시 먼저)와 제어문자 거부.**
- **OAuth 를 절반만 설정하는 대신 거부한 것.**
- **예약 헤더 위조 거부를 "envelope 필드를 재진술하는 이름" 으로만 좁힌 것** — 재시도·사후처리 재발행이 그 가드에 걸리지 않는다.
- **인증 레인이 Docker 조건부 skip 을 쓰지 않는 것, 그리고 커버 못 하는 시나리오를 `knownGaps` 로 남긴 것.**
- **`pollOnce` 를 공개해 전체 주기를 스레드 없이 검증 가능하게 만든 것.**
---
## Source anchors
```
src/messaging/messaging-kafka/build.gradle
main/java/…/kafka/KafkaConsumerRegistrar.java:1-621 (§17.3 pollOnce:216-257 · dispatch:264-333 · applyDueResumes:375-383)
main/java/…/kafka/KafkaMessagingTransport.java:1-215 (능력 상수 62-64)
main/java/…/kafka/KafkaTransactionalPublisher.java:1-178 (§17.6 inTransaction:96-119 · sendInTransaction:144-178)
main/java/…/kafka/KafkaSecurityConfigurer.java:1-173
main/java/…/kafka/KafkaHeaderMapper.java:1-169
main/java/…/kafka/KafkaDeliveryMapper.java:1-167
main/java/…/kafka/KafkaBatchConsumerRegistrar.java:1-146 (§12.2 참조 0)
main/java/…/kafka/PartitionWorkCoordinator.java:1-128
main/java/…/kafka/KafkaRetryExecutor.java:1-126
main/java/…/kafka/ContiguousPartitionOffsetTracker.java:1-119
main/java/…/kafka/KafkaPublishFailureClassifier.java:1-116
main/java/…/kafka/KafkaPublishMapper.java:1-111
main/java/…/kafka/KafkaRetryMetadataMapper.java:1-93 (§17.4 attemptOf:725-748)
main/java/…/kafka/KafkaPartitionRetryScheduler.java:1-90
main/java/…/kafka/KafkaReplayCapability.java:1-82
main/java/…/kafka/KafkaRetryTopicPublisher.java:1-75
main/java/…/kafka/SpringKafkaTransactionalProcessor.java:1-68
main/java/…/kafka/KafkaOffsetResetExecutor.java:1-63
main/java/…/kafka/KafkaProfileValidator.java:1-60
main/java/…/kafka/KafkaReplayPlanner.java:1-58
main/java/…/kafka/KafkaBrokerProfile.java:1-56
main/java/…/kafka/{KafkaSettlementQueue:1-52, KafkaTransactionProfileValidator:1-52, KafkaSettlementCommand:1-50,
KafkaDeadLetterPublisher:1-49, PartitionOffsetTracker:1-48, KafkaTopologyInspector:1-45,
KafkaRetryOutcome:1-40, KafkaPosition:1-38, KafkaReplayPlan:1-36, KafkaQuarantinePublisher:1-31,
KafkaTransactionalProcessor:1-29, KafkaTransactionalDelivery:1-22, KafkaTransactionalOutput:1-21}
test/java/…/kafka/ 24파일 4,087줄 (KafkaBrokerCertificationIT:466 · KafkaConsumerRegistrarTest:440 · KafkaContractHarness:370 …)
messaging-spring-boot-starter/…/KafkaMessagingAutoConfiguration.java:100-125 (§12.1 발행 전용 조립 · §17.2)
messaging-runtime-core/…/DefaultMessagePublisher.java:249-252 (§17.1 능력 부재가 예외를 만드는 유일한 자리)
docs/messaging/support-matrix.md:55 (§17.1 능력 표 대조)
```
@@ -0,0 +1,291 @@
# messaging-nats-experimental 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 7파일 755줄 축자 통독 완료. test 2파일 460줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-nats-experimental`
> SSOT owner: `messaging-nats-experimental`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- 선언 의존: messaging 계열 project 7 + vendor `jnats:2.26.2`
- `runtime_memberships`: **`[]`** — build-only · 등급 EXPERIMENTAL
| 파일 | LOC |
|---|---:|
| `NatsJetStreamTransport` | 295 |
| `NatsJetStreamProfile` | 103 |
| `NatsMaxDeliverParkingWorkflow` | 85 |
| `NatsJetStreamProfileValidator` · `NatsStreamPosition` | 75 · 75 |
| `NatsPreSendRejection` | 65 |
| `NatsAckMode` | 57 |
| **main 합계** | **755** |
| `NatsAdapterContractTest` · `NatsMaxDeliverParkingTest` | 337 · 123 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 7 | `FULL_READ` | 755줄 전 본문 |
| `test/java/**` | 2 | `FULL_READ` | 460줄 전 본문 · 테스트 28개 |
| `build.gradle` | 1 | `FULL_READ` | 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 이 어댑터의 판단 셋
**JetStream 만 쓴다.**
> "A core publish returns as soon as the bytes are written to the socket, with no persistence and no
> acknowledgement, so an adapter using it would report success for messages that were never stored —
> the failure is total and silent."
거부 코드는 `NatsJetStreamProfileValidator.validate` 에 있다 — 최소 한 번 배달 목적지에 코어 NATS 는 안 된다. 다만 그 검증기를 호출하는 곳이 저장소에 하나도 없다(§17.3). 이 절이 서술하는 것은 판단이 코드로 적혀 있다는 사실이지, 그 판단이 실행 경로에 걸려 있다는 사실이 아니다.
**확인은 지속 증거다.** 발행 승인이 메시지가 안착한 스트림과 순번을 이름 짓는다. 소켓에 바이트를 쓴 영수증이 아니다.
**기본 실패는 모호다.** 사전 거절 타입만 확실히 전송되지 않음으로 다루고 나머지는 전부 모호다.
> "a caller that reads `REJECTED` may republish under a new identity and duplicate a message the
> server already stored."
## 2. 죽은 편지가 없는 브로커에서 죽은 편지를 만든다
`NatsMaxDeliverParkingWorkflow` javadoc:
> "JetStream has no dead-letter queue. When a message hits `maxDeliver` the server terminates it: no
> redelivery, no routing, no record beyond an advisory. Every other broker in this platform parks a
> poison message somewhere an operator can find it, and this workflow is what makes NATS behave the
> same way."
핵심은 시점이다.
> "The parking therefore happens on the delivery **before** the limit, not on the limit itself.
> Acting at `maxDeliver` would mean acting on the delivery JetStream is about to discard, so any
> failure in the dead-letter publish would lose the message outright."
그래서 프로파일이 `maxDeliver < 2` 를 거부한다 — 플랫폼이 주차할 여유 배달이 최소 하나 있어야 한다.
그리고 정착은 죽은 편지 발행이 확인된 뒤에만 허용된다.
> "Terminating first would discard the message on a broker that cannot redeliver it, which is the
> one irreversible mistake available here."
세 번째 결과 `ALREADY_TERMINATED` 는 살아 있는 소비자 아래에서 프로파일이 바뀐 경우에만 도달한다. 회복할 것이 없고, 재배달로 오인되지 않도록 결과로 남긴다.
## 3. 능력 선언
```java
CAPABILITIES = (true, true, true, true, true, false, true, false, false, true, false, true);
```
`nativeDeadLetter=false` 의 근거가 클래스 javadoc 과 검증기 javadoc 양쪽에 있다 — 없는 큐를 찾아 나서게 만들지 않는다.
`keyedOrdering=false` 도 검증기가 강제한다 — 키 순서를 요구하는 목적지를 거부한다.
`deduplicatedPublish=true` 는 §17.1 이 다룬다.
## 4. 프로파일이 스스로 거부하는 것
`NatsJetStreamProfile` 은 record 이고, 압축 생성자가 이 어댑터의 불변식을 전부 들고 있다. 검증기가 호출되지 않는 지금, **실제로 실행되는 유일한 게이트가 여기다.**
```java
if (!ackMode.supportsAtLeastOnce()) throw ; // NONE · ALL 거부
if (ackWait.isNegative() || ackWait.isZero()) throw ;
if (maxDeliver < 2) throw ; // "headroom"
if (deduplicationWindow.isPresent() && isZero()) throw ; // 설정했으면 양수
```
`ackMode` 거부 사유는 `NatsAckMode` 자신이 문장으로 들고 있고(`rejectionReason()`), 프로파일이 그 문장을 예외 메시지에 그대로 싣는다. `NONE` 은 "forgotten", `ALL` 은 "still in flight" — 테스트가 그 두 낱말로 각각 걸어 잠근다.
`PARKING_HEADROOM = 1` 상수와 `parkAtDelivery() = maxDeliver - PARKING_HEADROOM` 가 §2 의 시점 선택을 숫자로 못 박는다. `NatsMaxDeliverParkingWorkflow.parkingThreshold()` 는 이 값을 그대로 위임한다 — 임계값의 정의가 한 곳에만 있다.
**주의할 비대칭.** 편의 팩토리 `durable(subject, stream, durableName)` 는 중복 제거 창을 `Optional.of(2분)` 으로 채운다. 즉 팩토리를 거친 프로파일은 §17.1 의 구멍에 빠지지 않는다. 그러나 팩토리에도 호출자가 없고(저장소 전역 grep 0건), 정규 생성자는 빈 창을 정상값으로 받는다. 기본 경로가 안전하다는 사실이 그 구멍을 닫아 주지 않는다.
## 10. 테스트 레인
두 테스트 460줄 · 28개.
`NatsAdapterContractTest` 17개 — 지속 증거(`REPLICATION_OR_PERSISTENCE_ACK`), 위치 반환, 시간 초과의 모호 판정, 사전 거절만이 `NOT_TRANSMITTED` 라는 것, 감싸인 미지 실패의 모호 판정, 호출자 마감의 유효성, 중복 제거 식별자 유무, 초과 페이로드 거절, 능력 두 개, 닫힌 전송, 재배달 인식, 순번 하한, 실패 범주.
전송은 `(subject, deduplicationId, request) -> CompletionStage<NatsStreamPosition>` 람다로 주입된다. 실제 JetStream 클라이언트는 이 리프에 없고, 테스트가 성공·실패·영영 안 끝남을 직접 만든다.
두 테스트가 회귀를 이름으로 기록한다 — `aFailureNamedLikeAKnownOneIsStillAmbiguous` 는 "예외 클래스 이름이 분류자였던" 과거를, `aPublishThatNeverCompletesIsBoundedByTheCallersTimeout` 은 "호출자 마감이 아예 무시되던" 과거를 주석에 남긴다. 셋째 회귀 기록은 어셈블이 비어 있다(§17.4).
`NatsMaxDeliverParkingTest` 11개 — 한계 직전 주차, 한계 자체도 주차, 한계 초과의 `ALREADY_TERMINATED`, 확인 뒤 정착, `maxDeliver=1` 거부, 배달 수 하한, 임계값, `ackMode` 세 값.
`NatsJetStreamProfileValidator` 를 세우는 테스트는 없다.
## 12. negative-space probes
**12.1 도달성.** `dev.caskeleton.messaging.nats` 를 import 하는 코드가 리프 밖에 없다. 리프 밖에서 이 모듈이 등장하는 곳은 세 군데인데 전부 **이름 문자열**이다 — `config/architecture/modules.json` 의 등록, `messaging-testkit/CompatibilityMatrix``("messaging-nats-experimental", List.of("2.14"), Tier.EXPERIMENTAL, false, false)` 항목, 그리고 그 표를 문서와 대조하는 `MessagingDocumentationContractTest`. 즉 등급표가 이 어댑터를 알고 있을 뿐, 어떤 실행 경로도 이 클래스들에 닿지 않는다. build-only · experimental 표기 그대로다.
**12.2 대조군 — 자매 실험 어댑터.** `messaging-pulsar-experimental` 과 구조가 같다 — 주입되는 전송 연산, 타입 있는 사전 거절, 기본 모호, 실험 등급 게이트. 차이는 능력 선언의 출처다. Pulsar 는 전송과 검증기가 서로 다른 값을 답하고(그쪽 §17.1), NATS 는 두 곳이 같은 값을 답한다.
다만 그 일치는 공유가 아니라 **복사**다. `NatsJetStreamTransport.CAPABILITIES` 상수와 `NatsJetStreamProfileValidator.capabilities()` 가 열두 개 불리언 리터럴을 각자 손으로 적어 두었고, 둘을 묶는 것은 아무것도 없다. 오늘 같은 값인 것이 내일도 같으리라는 보장은 코드에 없다 — Pulsar 가 이미 그 갈라짐의 실물이다.
이쪽의 문제는 따로 있다. 그 값이 프로파일에서 파생되지 않는다는 것이다(§17.1).
**12.4 드리프트.** 실험 등급 표기와 코드가 일치한다.
## 16. 확인하지 못한 것
- 실제 JetStream 서버를 띄우지 않았다. 클라이언트 브리지를 싣지 않는 리프다.
- 중복 제거 창이 없는 프로파일로 모호 재발행을 재현하지 않았다. 능력 상수와 `deduplicationId` 구현으로 판정했다.
- 검증기를 부르는 조립 지점이 다른 형태(설정 클래스 · 스타터)로 어딘가에 있을 가능성은 클래스 이름 · 패키지 이름 두 가지 grep 으로만 배제했다. 리플렉션이나 문자열 기반 조립이라면 잡히지 않는다.
- 테스트를 실행하지 않았다. §17.4 의 "항상 통과"는 어셈블 의미론으로 판정한 것이다.
## 17. 손볼 것
### 17.1 P2 — `deduplicatedPublish` 를 무조건 참으로 선언하는데 실제 중복 제거는 프로파일에 창이 있을 때만 일어난다
능력은 상수다.
```java
private static final MessagingCapabilities CAPABILITIES =
new MessagingCapabilities(true, true, true, true, true, false, true, false, false, true, false, true);
// ^^^^ deduplicatedPublish
```
검증기의 `capabilities()` 도 같은 값을 돌려준다.
그런데 중복 제거 식별자는 프로파일에 창이 있을 때만 만들어진다.
```java
private Optional<String> deduplicationId(TransportPublishRequest request) {
return profile.deduplicationWindow().map(window -> request.envelope().messageId().value().toString());
}
```
`NatsJetStreamProfile.deduplicationWindow``Optional<Duration>` 이고, 비어 있는 것이 정상 상태다 — 프로파일 생성자도 검증기도 창을 요구하지 않는다. 창이 없으면 `Nats-Msg-Id` 가 실리지 않고 서버는 중복을 제거하지 않는다.
즉 능력 선언이 프로파일과 무관하게 참이다.
**왜 이 플래그인가.** 이 저장소에서 능력 열두 개 중 부재가 예외를 만드는 유일한 것이 `deduplicatedPublish` 다(`DefaultMessagePublisher:250`). 나머지는 읽히지 않거나 분기에 쓰인다. 그러므로 이 플래그의 과대 선언은 다른 어느 플래그의 과대 선언보다 직접적이다 — 창 없는 목적지가 그 가드를 통과한다.
**그리고 어댑터 자신이 그 조건을 알고 있다.** 클래스 javadoc:
> "A publish that times out is `AMBIGUOUS`: JetStream may have stored it and lost only the
> acknowledgement, and **the deduplication window is what makes retrying it safe when the profile
> enables one.**"
"when the profile enables one" 이 정확히 능력이 담지 않은 조건이다. 창이 없는 목적지에서 모호를 재시도하면 스트림에 같은 메시지가 두 번 들어간다.
`MessagingCapabilities` 의 클래스 javadoc 이 이 상황을 미리 서술한다 — "a silently weakened guarantee is indistinguishable from a working one until the incident."
**테스트가 두 쪽을 동시에 못 박는다.** `NatsAdapterContractTest` 안에서, 같은 빈 창 프로파일(`confirming(Optional.empty())`)에 대해:
```java
void theAdapterAdvertisesDeduplicatedPublish() {
assertThat(confirming(Optional.empty()).capabilities().capabilities()
.deduplicatedPublish()).isTrue(); // 능력은 참이라고 한다
}
void noDeduplicationWindowSendsNoDeduplicationId() {
confirming(Optional.empty()).publish(request(64));
assertThat(capturedDeduplicationIds).singleElement()
.satisfies(id -> assertThat(id).isEmpty()); // 선에는 아무것도 안 실린다
}
```
둘 다 통과한다. 모순이 우연히 남은 것이 아니라 **테스트로 고정되어** 있다는 뜻이고, 수정할 때 함께 고쳐야 할 지점이 어디인지도 이 두 개가 알려 준다.
**팩토리는 이 구멍을 메우지 않는다.** `NatsJetStreamProfile.durable(...)` 는 창을 2분으로 채워 주지만 호출자가 없고, 정규 생성자는 빈 창을 정상값으로 받는다(§4).
**수정.** 능력을 프로파일에서 파생시킨다.
```java
new MessagingCapabilities(, profile.deduplicationWindow().isPresent(), )
```
또는 검증기가 최소 한 번 배달 목적지에 중복 제거 창을 요구한다. 후자는 코어 NATS 거부와 같은 형태의 시작 시점 거부다.
### 17.2 P3 — 닫힌 전송의 거절이 영구 업무 실패로 분류된다
`rejectedLocally``FailureCategory.PERMANENT_BUSINESS` 를 고정으로 쓰고, 두 호출자 중 하나가 `NATS_TRANSPORT_CLOSED` 다.
자매 어댑터(Pulsar)와 같은 형태이고 같은 판단이다 — 종료 중이라는 것은 이 세대의 사정이지 업무의 영구 실패가 아니다. 같은 파일의 `classify` 는 범주를 신중히 나눈다.
두 리프가 같은 형태를 공유하므로 수정도 함께 하는 편이 낫다.
### 17.3 P2 — `NatsJetStreamProfileValidator` 를 호출하는 곳이 저장소에 없다. javadoc 링크 하나가 유일한 흔적이다
75줄짜리 검증기가 이 어댑터의 시작 시점 판단 넷을 들고 있다 — 실험 스위치가 꺼져 있으면 거부, 최소 한 번 배달에 코어 NATS 거부, 순서 있는 소비자와 경쟁 작업자 동시 사용 거부, 키 순서 목적지 거부.
저장소 전역에서 이 클래스 이름이 나오는 곳은 두 줄뿐이다.
```
NatsJetStreamTransport.java:35: * is why {@link NatsJetStreamProfileValidator} refuses the combination at startup.
NatsJetStreamProfileValidator.java:21: public final class NatsJetStreamProfileValidator {
```
하나는 선언이고 하나는 **javadoc 링크**다. 코드 호출자 0, 테스트 0.
`validate``jetStreamEnabled` · `orderedConsumer` · `competingWorkers` · `enabled` 를 전부 인자로 받는다. 즉 스스로 아무것도 관찰하지 않고, 호출자가 이미 알고 있는 사실을 넘겨 줘야만 판단한다. 그런 호출자가 없으니 이 판단들은 한 번도 실행된 적이 없다.
**왜 P2 인가.** 전송의 클래스 javadoc 이 "그래서 검증기가 시작 시 그 조합을 거부한다"고 단언한다. 읽는 사람에게 이 어댑터는 코어 NATS 오설정으로부터 보호되는 것처럼 보이는데, 실제로는 아무 게이트도 걸려 있지 않다. 실험 등급이라 지금 당장 사고가 나지는 않지만, 이 어댑터를 실전에 붙이는 사람이 가장 먼저 신뢰할 문장이 지금 사실이 아니다.
같은 형태를 이 저장소에서 여러 번 봤다 — 채점기는 있는데 그 채점기에 값을 넣어 주는 생산자가 없는 구조(`GrpcRawApiImportRule` · `GrpcApplicationBoundaryRules` · `GrpcNettyParityContract` 등). 이쪽이 더 나쁜 쪽인 이유는 그 리프들에서는 최소한 테스트가 리터럴을 먹여 판단 자체는 실행해 보는데, 여기서는 그것조차 없다는 점이다.
**수정.** 어댑터 조립 지점에서 `validate` 를 부르거나, 그럴 지점이 아직 없다면 최소한 프로파일 생성 시점에 걸리도록 옮긴다(§4 의 압축 생성자가 이미 실행되는 유일한 게이트다). 어느 쪽도 못 하겠다면 전송 javadoc 의 "refuses ... at startup" 을 사실에 맞게 고친다.
### 17.4 P3 — 경과 시간 회귀를 막으려는 어셈블이 항상 참이다
```java
@Test
void theReportedElapsedTimeIsMeasuredRatherThanZero() {
PublishResult result = await(failingWith(new TimeoutException("no ack")).publish(request(64)));
assertThat(result.elapsed())
.as("every outcome reported Duration.ZERO, so latency evidence was fiction")
.isGreaterThanOrEqualTo(Duration.ZERO);
}
```
`as(...)` 가 막으려는 회귀는 "모든 결과가 `Duration.ZERO` 를 보고하던 것"이다. 그런데 어셈블은 `>= Duration.ZERO` 다. `Duration.ZERO` 는 이 조건을 통과한다. 경과 시간은 시작 시점에서 잰 값이라 음수가 될 수 없으므로, 이 어셈블은 **구현이 무엇을 하든 통과한다.**
이름과 `as` 메시지가 정확히 짚은 회귀를, 어셈블만 못 잡는다. 그래서 이 테스트는 회귀 방지가 아니라 회귀 방지의 표시다.
**수정.** `isGreaterThan(Duration.ZERO)` 로 바꾼다. 시간 분해능이 불안하면 전송 람다에 관측 가능한 지연을 넣고 그 하한과 비교한다 — 같은 클래스의 `aPublishThatNeverCompletesIsBoundedByTheCallersTimeout` 가 이미 50밀리초 마감으로 그 방식을 쓴다.
### 확인된 설계(문제 아님)
- **코어 NATS 를 최소 한 번 배달에 쓰지 못하게 시작 시 거부한 것과 그 근거.**
- **확인을 지속 증거로 기록한 것** — 스트림과 순번을 이름 짓는 승인이다.
- **알 수 없는 실패의 기본값을 모호로 둔 것.**
- **한계 직전 배달에서 주차하는 것과 그 시점 선택의 근거.**
- **`maxDeliver < 2` 를 거부해 주차 여유를 강제한 것.**
- **죽은 편지 발행이 확인된 뒤에만 원본을 정착시키는 것.**
- **`ALREADY_TERMINATED` 를 별도 결과로 남겨 재배달과 구분한 것.**
- **`nativeDeadLetter=false` 를 선언하고 그 이유를 두 곳에 적은 것.**
- **순서 있는 소비자와 경쟁 작업자의 배타성을 검증기가 강제한 것.**
- **중복 제거 식별자로 논리 메시지 식별자를 쓰는 것** — 시도마다 새 식별자를 만들면 창이 필요한 상황에서 쓸모가 없어진다.
- **예외 클래스 이름으로 실패를 분류하던 것을 걷어내고 타입으로 옮긴 것** — 테스트가 그 회귀를 주석으로 남겨 두었다.
- **주차 임계값의 정의를 프로파일 한 곳에만 둔 것** — 워크플로는 `parkAtDelivery()` 를 위임만 한다.
- **`NatsStreamPosition` 이 스트림 순번과 소비자 순번을 따로 들고 있는 것** — 재배달 인식이 둘의 차이에서 나오고, 재생은 스트림 순번으로만 되돌아간다.
---
## Source anchors
```
src/messaging/messaging-nats-experimental/build.gradle
main/java/…/nats/NatsJetStreamTransport.java:1-295
main/java/…/nats/NatsJetStreamProfile.java:1-103
main/java/…/nats/NatsMaxDeliverParkingWorkflow.java:1-85
main/java/…/nats/NatsJetStreamProfileValidator.java:1-75
main/java/…/nats/NatsStreamPosition.java:1-75
main/java/…/nats/NatsPreSendRejection.java:1-65
main/java/…/nats/NatsAckMode.java:1-57
test/java/…/nats/NatsAdapterContractTest.java:1-337
test/java/…/nats/NatsMaxDeliverParkingTest.java:1-123
src/messaging/messaging-core-api/…/destination/MessagingCapabilities.java (성분 의미)
src/messaging/messaging-testkit/…/CompatibilityMatrix.java:107-109 (등급표의 이름 항목)
src/config/architecture/modules.json (등록)
```
@@ -0,0 +1,775 @@
# messaging-observability 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-observability`
> SSOT owner: `messaging-observability`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-observability`
- canonical state `analysisFile`: `analysis/messaging/messaging-observability.md`
- source path: `src/messaging/messaging-observability`
- registry `allowed_dependencies`: `["messaging-core-api"]`
- registry `runtime_memberships`: `["app-bootstrap"]`
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 9 |
| production LOC | 838 |
| 패키지 | 1 (`dev.caskeleton.messaging.observation`) |
| test 파일 | 6 |
| test 메서드(실행 확인) | 42 |
| 외부(비프로젝트) 의존성 | 1 (`io.micrometer:micrometer-core`, **`api`**) |
아홉 타입을 세 축으로 나누면:
| 축 | 타입 | leaf 밖 소비자 |
|---|---|---|
| **관측 seam** | `MessagingObservation`(interface) · `MessagingMetrics`(Micrometer 구현) · `MessagingTags`(record) · `DefaultMessagingObservationConvention` | seam 2 · 구현 **0** · tags 2 · convention **0** |
| **경계** | `CardinalityGuard` · `MessagingRedactor` | 1 · 1 |
| **추적·감사** | `MessagingTracer` · `MessagingAuditSink` · `MessagingAuditEvent` | **0** · **0** · 2 |
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (9) | 9 | `FULL_READ` | 전 파일 본문 확인 |
| `src/test/java/**` (6) | 6 | `FULL_READ` | 클래스 javadoc·단언·테스트명 전수 확인 |
| `build.gradle` | 1 | `FULL_READ` | 주석 포함 9줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
이 leaf는 **"메시징이 무엇을 밖으로 내보내도 되는가"**를 소유한다. 메트릭·추적·감사 셋이 여기 있고, 셋 다 같은 제약 아래 있다 — **경계가 알려진 값만 나간다.**
Micrometer를 `api`로 선언한 이유가 build.gradle에 있다.
```groovy
// api: MessagingMetrics' public constructor takes a MeterRegistry, so wiring it requires
// naming the type.
api 'io.micrometer:micrometer-core'
```
`src/messaging/CLAUDE.md:40-43`의 vendor `api` 게이트를 통과한다. 다만 `MessagingObservation` 인터페이스 자체는 Micrometer를 모른다 — 벤더는 `MessagingMetrics` 한 클래스에만 나타난다. 즉 **seam은 중립이고 구현만 벤더에 묶인다.**
의존이 `messaging-core-api` 하나뿐인 것도 의도적이다. `MessagingTracer``TraceContext`·`MessageHeaders`를 쓰고 `DefaultMessagingObservationConvention``PublishCompletion`·`FailureCategory`를 쓴다. policy나 transport는 필요 없다.
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api), `micrometer-core`(api).
나가는 것: `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`.
**출하 조립은 두 개뿐이다.**
| bean | 라인 | 소비 |
|---|---:|---|
| `MessagingRedactor` | `MessagingCoreAutoConfiguration:253` | **없음** |
| `CardinalityGuard` | `:264` | **없음** |
두 클래스는 `MessagingMetrics`의 생성자 인자다. 그런데 `MessagingMetrics` bean이 없다(§12.1). 즉 **재료 둘만 bean으로 있고 그것을 조립하는 것이 없다.**
`MessagingTracer`·`MessagingAuditSink`·`DefaultMessagingObservationConvention`은 bean도 없고 소비자도 없다.
---
## 3. 패키지/컴포넌트 지도
```
seam
MessagingObservation (5 메서드: publish · delivery · settlement · backlog · diagnostics)
↑ 구현
MessagingMetrics ──┬── CardinalityGuard (차원당 200값 상한)
├── MessagingRedactor (키 denylist 27개)
└── MeterRegistry (Micrometer)
어휘
MessagingTags (record, 6차원 고정)
↑ 생성
DefaultMessagingObservationConvention (publish/consume/settlement/deadLetter)
추적
MessagingTracer (inject / extract / shouldLinkRatherThanContinue)
감사
MessagingAuditSink (interface + InMemory) ── MessagingAuditEvent (record)
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `MessagingTags` — 닫힌 6차원
```java
// :8-14
* <p>It is a fixed record rather than an open map on purpose. 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.
```
여섯 차원: `broker`, `destinationProfile`, `operation`, `outcome`, `failureCategory`, `retryStage`. 없는 값은 `NONE = "none"`이다 — null도 빈 문자열도 아니고 명시적 sentinel이다.
**두 factory의 차이가 §12.1의 핵심이 된다.**
| factory | failureCategory | retryStage |
|---|---|---|
| `new MessagingTags(6개 인자)` | 호출자가 지정 | 호출자가 지정 |
| `MessagingTags.of(4개 인자)` | **`NONE` 고정** | **`NONE` 고정** |
`asMap()``LinkedHashMap`으로 순서를 고정하고 `Map.copyOf`로 불변화한다.
### 4.2 `DefaultMessagingObservationConvention` — 태그 값이 공개 계약이다
```java
// :12-18
* <p>Centralised because the tag values are a public contract: dashboards, alert rules, and SLOs
* are written against these exact strings, so an adapter inventing its own spelling of "rejected"
* silently breaks every alert that was watching for it. The conversion lives here, once, rather
* than at each call site.
*
* <p>Only bounded inputs are accepted. Every parameter is an enum or a configured name, which is
* what lets {@link CardinalityGuard} bound the resulting series.
```
네 메서드와 네 상수(`PUBLISH`, `CONSUME`, `SETTLE`, `DEAD_LETTER`). `publish(...)``PublishCompletion``Optional<FailureCategory>`를 받아 **enum에서 문자열을 파생**한다 — 호출자가 철자를 정하지 않는다.
이 클래스는 소비자가 0이다(§12.1).
### 4.3 `CardinalityGuard` — 실패가 점진적이지 않다
```java
// :9-17
* <p>Cardinality failures are not gradual. A tag that accidentally carries a message id looks fine
* in a test with ten messages and takes down the metrics backend in production, and by then the
* series already exist. The guard bounds each dimension at registration time and refuses the value
* that would cross the limit, so the damage is one rejected tag rather than a monitoring outage.
*
* <p>It fails loudly rather than silently substituting a placeholder, because a metric that quietly
* collapses distinct values is worse than one that is missing: it looks correct.
```
기본 상한 200/차원.
**두 개의 이전 결함이 코드에 남아 있다.**
```java
// admit(String, String):57-59
// Size-then-add was not atomic: N threads could each read size == limit - 1 and each add, so
// the configured limit was an average rather than a bound. A guard that can be exceeded under
// load is no guard — load is when it matters.
synchronized (values) { ... }
```
먼저 lock 없이 `values.contains(value)`로 빠른 경로를 두고, 새 값일 때만 `synchronized`로 들어가 다시 확인한다 — double-checked 패턴이다. 테스트가 경합을 직접 재현한다(`MessagingSecretLeakTest.concurrentAdmissionNeverExceedsTheLimit`).
```java
// admit(MessagingTags):81-85
// Preflight every dimension before committing any of them.
//
// The loop used to admit each dimension as it went, so a tag set rejected on its last
// dimension had already permanently added the earlier ones — spending the budget of a bounded
// dimension on a series that was never emitted.
```
`wouldAdmit`으로 전수 사전 확인 후 `admit`으로 커밋한다. **사전 확인과 커밋 사이에 lock이 없으므로** 두 스레드가 동시에 통과할 수 있고, 그 경우 두 번째 `admit`이 false를 반환해 `admitted &= ...`가 false가 된다 — 상한은 지켜지고 결과만 거절이 된다. 안전한 방향이다.
### 4.4 `MessagingRedactor` — allowlist가 아니라 denylist인 이유
```java
// :11-15
* <p>This is a denylist of keys that must never leave the process, not an allowlist, because
* diagnostic maps are assembled ad hoc at call sites and an allowlist would quietly drop the useful
* half. Two categories are removed. Secrets, for the obvious reason. And per-message identity
* message ids, keys, offsets, delivery tags because those are what turn a bounded metric into one
* series per message, and a support log into a re-identification surface.
```
27개 키. **두 범주**를 섞어 담는다.
| 범주 | 키 |
|---|---|
| 자격증명 (11) | `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `access_token`, `refresh_token`, `api_key`, `apikey`, `password`, `client_secret`, `credential`, `secret`, `token` |
| 메시지별 신원 (10) | `messageid`, `msg.id`, `correlationid`, `causationid`, `partitionkey`, `orderingkey`, `key`, `offset`, `deliverytag`, `sequence` |
| 본문·진단 (5) | `payload`, `body`, `data`, `exceptionmessage`, `stacktrace` |
두 메서드가 다른 목적을 갖는다.
| 메서드 | 동작 | 언제 |
|---|---|---|
| `sanitize` | 거부 키를 **제거** | 값이 나가면 안 되고 키의 존재도 의미 없을 때 |
| `mask` | 값을 `[redacted]`**대체** | "Useful where the presence of a field is itself the diagnostic signal" |
`isDenied`가 소문자 정규화 후 정확 일치다. **`messaging-core-api``MessageHeaders.carriesACredential`은 세그먼트 매칭 + 인접 결합**(그쪽 §4.6)인데 이쪽은 정확 일치다 — 같은 저장소에서 같은 문제를 두 강도로 푼다(§12.3).
`msg.id`가 목록에 리터럴로 들어 있다. `ReservedHeaders.MESSAGE_ID` 상수가 있는데 참조하지 않는다 — `analysis/messaging/messaging-core-api.md` §12.3(c)가 이 사실을 관측했다.
### 4.5 `MessagingMetrics` — 순서가 계약이다
```java
// :19-27
* <p>Every tag set passes the {@link CardinalityGuard} before a meter is created. That ordering is
* the whole point: a meter registry never forgets a series, so a single tag carrying a message id
* permanently inflates the backend. Refused tag sets are counted under a fixed {@code
* messaging.tags.rejected} counter, which makes the rejection visible without creating the series
* that caused it.
*
* <p>Logical messages and physical attempts are separate meters. One message redelivered four times
* is one publish and five attempts; a single counter would make a redelivery storm read as traffic
* growth and hide the incident.
```
여섯 미터:
| 상수 | 이름 | 종류 |
|---|---|---|
| `PUBLISH_TIMER` | `messaging.publish` | Timer (histogram) |
| `DELIVERY_TIMER` | `messaging.delivery` | Timer (histogram) |
| `MESSAGE_COUNTER` | `messaging.messages` | Counter — **첫 시도만** |
| `SETTLEMENT_COUNTER` | `messaging.settlements` | Counter |
| `BACKLOG_GAUGE` | `messaging.backlog` | Gauge |
| `REJECTED_TAGS_COUNTER` | `messaging.tags.rejected` | Gauge (`LongAdder`) |
```java
// recordDelivery:87-89
// Only the first attempt counts as a logical message; later attempts are the same
// message arriving again, and counting them would inflate throughput during a storm.
if (attempt == 1) { registry.counter(MESSAGE_COUNTER, micrometerTags).increment(); }
```
**거절 카운터가 gauge인 것이 중요하다.** 거절된 태그 세트는 미터를 만들지 않으므로 그 사실을 기록할 유일한 방법이 고정 이름의 별도 미터다. 그것마저 태그를 붙이면 같은 문제가 생긴다.
**`recordDiagnostics`가 가장 긴 주석을 갖는다.**
```java
// :122-132
// The value never becomes a tag.
//
// It used to: every diagnostic key and value was attached to a counter, behind a guard that
// only bounded the base dimensions. One unique message id, exception message or URL per
// request created one meter series per request — permanently, in the backend and in this
// process's heap — and the redactor only masks keys it recognises, so free-form text carried
// whatever it carried.
//
// What stays is the shape: which diagnostic keys occurred, counted against the bounded base
// dimensions. The values belong in a structured log or a trace event, where they are bounded
// by retention rather than by cardinality.
```
현재 구현은 **키만** 태그로 만들고(`Tag.of("diagnostic", key)`), 그 키도 `guard.admit("diagnostic", key)`를 통과해야 한다. 값은 어디에도 가지 않는다.
redaction 순서도 명시돼 있다 — "Redact before anything else touches the values. Diagnostics are the one place where a caller can pass arbitrary keys."
`backlogs` 맵이 `computeIfAbsent`로 gauge를 한 번만 등록하고 `AtomicLong`을 재사용한다 — Micrometer gauge는 재등록해도 첫 참조를 유지하므로 필요한 패턴이다.
### 4.6 `MessagingTracer` — 브로커 홉을 건너는 추적
```java
// :12-22
* <p>Messaging breaks in-process trace propagation: the publish and the consume happen in different
* processes, often minutes apart, so the only way the two spans meet is if the context travels in
* the message headers. W3C {@code traceparent}/{@code tracestate} are used rather than a private
* format so that a non-Java consumer, or a broker-side tool, can still join the trace.
*
* <p>The consume side is deliberately a <em>link</em> rather than a child span in the general case.
* A batch consume can draw messages from many unrelated traces, and forcing them into one parent
* would invent a causal relationship that does not exist. Retry and dead-letter hops keep the
* original trace so a message's whole journey stays one story.
```
`inject``MessageHeaders.platform(values)`를 쓴다 — 예약 이름을 쓸 수 있는 factory다.
```java
// inject:38-40
* <p>Written as platform headers, not application headers, so that an application cannot
* overwrite them and silently sever the trace.
```
`messaging-core-api`의 두 factory 분리(그쪽 §4.8)를 실제로 쓰는 **두 번째** production 지점이다(첫 번째는 `messaging-policy``DeadLetterEnvelopeFactory`).
`shouldLinkRatherThanContinue(batchSize)``batchSize > 1`이다 — 단일 전달은 계속, 배치는 링크. 테스트가 두 경우를 각각 확인한다.
`inject``traceparent`가 비면 **헤더를 건드리지 않고 그대로 반환**한다. 활성 추적이 없을 때 빈 헤더를 만들지 않는다.
### 4.7 감사 — 메트릭과 분리된 이유
```java
// MessagingAuditSink.java:10-13
* <p>Separate from metrics and from application logs. An audit trail answers "who authorised this
* destructive operation", which is a different retention, access, and integrity requirement from
* "how slow was publish yesterday"; mixing them means either the audit gets dropped with the
* metrics or the metrics inherit the audit's retention cost.
```
`MessagingAuditEvent`가 여섯 필드를 요구하고 넷은 빈 문자열을 거절한다 — `operation`, `subject`, `destination`, `approvalTicket`. **승인 티켓이 필수**인 것이 설계다.
```java
// MessagingAuditEvent.java:10-13
* <p>Audit covers the operations that change state an application cannot: replay, redrive, offset
* reset, purge, and delete. The subject is the operator identity and the details are passed through
* {@link MessagingRedactor}, so an audit trail proves who did what without becoming a second copy
* of the payload.
```
`MessagingAuditSink.inMemory()``CopyOnWriteArrayList` 기반 구현을 준다 — "for tests and for a deployment that has no external audit store yet".
**javadoc이 "details are passed through `MessagingRedactor`"라고 하지만 `MessagingAuditEvent` 생성자는 redactor를 부르지 않는다.** `Map.copyOf`만 한다. 즉 redaction은 호출자 책임이고 타입이 강제하지 않는다 — §17.
---
## 5. 주요 실행 경로
**메트릭:** 호출자가 `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`
---
## 6. 실패 경로와 복구/번역
이 leaf는 `MessagingException`을 하나도 던지지 않는다. 실패를 **값으로 표현**한다.
| 상황 | 결과 |
|---|---|
| 태그 세트가 상한 초과 | 미터를 만들지 않고 `messaging.tags.rejected` 증가 |
| 진단 키가 상한 초과 | 그 키만 건너뜀 |
| 진단 키가 denylist | `sanitize`가 제거 |
| `traceparent` 없음 | `TraceContext.none()` |
`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.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음. 이 leaf는 messaging family에서 `messaging-transport-spi` 다음으로 동시성이 조밀하다.
| 지점 | 도구 | 보호 |
|---|---|---|
| `CardinalityGuard.observed` | `ConcurrentHashMap` + `ConcurrentHashMap.newKeySet()` | 차원별 값 집합 |
| `CardinalityGuard.admit` | 빠른 경로 `contains` + `synchronized(values)` 재확인 | 상한이 평균이 아니라 경계 |
| `MessagingMetrics.backlogs` | `ConcurrentHashMap` + `computeIfAbsent` | gauge 한 번만 등록 |
| `MessagingMetrics.rejectedTagSets` | `LongAdder` | 경합 하 카운트 |
| `MessagingAuditSink.InMemory.events` | `CopyOnWriteArrayList` | 읽기 우세 |
`MessagingRedactor`·`MessagingTracer`·`DefaultMessagingObservationConvention`은 상태가 없다. `MessagingTags`는 불변 record다.
**`synchronized(values)``Set` 인스턴스를 락으로 쓴다.** 그 `Set``ConcurrentHashMap.newKeySet()`이고 외부에 노출되지 않으므로(`observed` 맵이 private) 외부 락 경합은 없다. 차원별로 락이 분리되는 효과도 있다.
수명주기 참여 없음.
---
## 8. 설정·기능 플래그·환경 차이
| 상수 | 값 | 위치 |
|---|---:|---|
| `CardinalityGuard.DEFAULT_LIMIT` | 200 | `:21` (private) |
| `MessagingTags.NONE` | `"none"` | public |
| 미터 이름 6개 | `messaging.*` | `MessagingMetrics` public 상수 |
| 연산 이름 4개 | `publish`/`consume`/`settle`/`deadLetter` | `DefaultMessagingObservationConvention` public 상수 |
| W3C 헤더 3개 | `traceparent`/`tracestate`/`baggage` | `MessagingTracer` public 상수 |
| denylist | 27개 키 | `MessagingRedactor` private |
starter가 `CardinalityGuard`를 기본 생성자로 만든다(`:264-265`) — 상한 200이 설정 불가다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. `MeterRegistry`가 유일한 외부 접점이고 인터페이스로 주입된다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-observability:test`. **BUILD SUCCESSFUL, 42 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `MessagingMetricCardinalityTest` | 8 | 상한 도달 시 미터 미생성, 거절 카운트, 첫 시도만 message counter | 실제 backend 동작 |
| `MessagingRedactorTest` | 6 | denylist 동작, `sanitize`/`mask` 차이 | — |
| `MessagingSecretLeakTest` | 9 | 금지 헤더 전수, 대소문자 무관, 메시지별 신원 제거, payload/예외 제거, **비밀이 meter registry에 도달하지 않음**, 서로 다른 진단 값이 새 series를 만들지 않음, **경합 하 상한 유지**, mask의 존재 신호 유지, 감사 이벤트가 payload를 안 담음 | — |
| `MessagingTraceLinkTest` | 8 | 브로커 홉 왕복, tracestate/baggage 보존, platform 헤더로 기록, 기존 헤더 보존, 추적 없음 처리, 단일=계속/배치=링크 | 실제 collector |
| `SecretLeakStaticScanTest` | 6 | **messaging 소스 트리 전체를 정적 스캔** — 콘솔 출력 없음, 민감 식별자 문자열 연결 없음, 스캐너 자체 동작 3건 | 런타임 유출 |
| `SecretLeakScannerCharacterizationTest` | 5 | 스캐너 분류기의 현재 판정을 고정 | — |
### 10.1 정적 스캔 테스트
이 저장소에서 드문 형태다 — **테스트가 소스 트리를 읽는다.**
```java
// SecretLeakStaticScanTest.java:16-21
* <p>A runtime redactor only protects the values that pass through it. A {@code toString()} that
* concatenates a credential, or a log line that interpolates a payload, bypasses it entirely and is
* invisible to every unit test the leak only shows up in a production log, after the fact. A
* static scan is the cheapest way to make that class of mistake fail in CI instead.
```
분류기가 네 단계로 오탐을 줄인다 — 문자열 리터럴 제거, `+` 주변 피연산자 추출, 안전한 파생(`.length`/`.size`/`getSimpleName`…) 제외, 산술(`+ 1`) 제외, 서술형 접미사(`Id`/`Name`/`Count`…) 제외.
`theScanActuallyReachesTheSourceTree`라는 테스트가 있다 — **스캔이 실제로 파일을 읽었는지 확인한다.** 경로 탐색이 실패해 0개 파일을 스캔하고 통과하는 것을 막는다. 이 저장소가 반복하는 주제(게이트가 아무것도 검사하지 않는 것을 막기)의 좋은 예다.
### 10.2 특성화 테스트의 자기 서술
`SecretLeakScannerCharacterizationTest`의 javadoc이 자기 존재 이유와 **제거 조건**을 적는다.
```java
// :12-27
* Records exactly what {@link SecretLeakStaticScanTest}'s line classifier does today, so the fix
* that removes its two false positives can be checked against the detection power it must keep.
*
* <p>The classifier below is a verbatim copy of the one under test. A characterization test that
* called the real method would be the better design, and Wave 2 makes that possible by extracting
* the classifier; until then a copy is the only way to assert on the decision procedure at all,
* because every part of it is private and static. The copy is deleted in the same change that
* proves the extracted classifier agrees with it.
*
* <p>Two cases here were the offenders that failed the full {@code test} run at HEAD, and naming
* them as characterization turned "the build is red" into "the scanner cannot see a method call's
* suffix, and cannot see that {@code + 1} is arithmetic".
```
**분류기가 두 파일에 복제돼 있고, 그 복제를 지울 조건("Wave 2")이 명시돼 있으며, 그 Wave 2는 아직 일어나지 않았다.** §12.3.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api"]` |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| vendor `api` 규칙 | Micrometer가 `MessagingMetrics` 생성자에 등장 → `api`. **통과** |
| **`SecretLeakStaticScanTest`** | messaging 소스 트리 전체에 대해 콘솔 출력·민감 문자열 연결을 금지. `:messaging-observability:test`로 실행 |
| ArchUnit | 전용 규칙 없음 |
네 번째가 특이하다 — **한 leaf의 테스트가 family 전체 소스를 검사한다.** 스캔 루트가 `messaging-core-api` 디렉터리를 찾아 올라가는 방식이므로 messaging 전체가 대상이다. 즉 이 leaf의 테스트 레인이 family 수준 게이트를 겸한다.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/285-observability-tag-vocabulary-bypass.txt`.
### 12.1 Public surface reachability
> **방법 주의.** 단어 검색은 `CardinalityGuard`에서 **오탐 9건**을 냈다. 저장소에 같은 이름의 클래스가 둘 있다. 아래는 import로 확인한 값이다.
```
src/application-core/.../notification/platform/observation/CardinalityGuard.java:24 ← 다른 클래스
src/messaging/messaging-observability/.../observation/CardinalityGuard.java:19 ← 이 leaf
```
import 기준으로 이 leaf의 `CardinalityGuard`를 쓰는 파일은 **한 개**다(`MessagingCoreAutoConfiguration:6`). 나머지 다섯은 notification 쪽 동명 클래스를 import한다. `messaging-schema-api``SchemaRegistry`와 같은 함정이다(그쪽 §12.1).
교정 후 표:
| 타입 | leaf 밖 소비자 | 판정 |
|---|---:|---|
| `MessagingObservation` | 2 (`DefaultMessagePublisher` + 그 테스트) | 사용됨 |
| `MessagingTags` | 2 (같음) | 사용됨 |
| `MessagingAuditEvent` | 2 production (`RedriveService`, `ReplayService`) + 1 test | 사용됨 |
| `MessagingRedactor` | 1 (starter bean) | bean만 |
| `CardinalityGuard` | 1 (starter bean) | bean만 |
| **`MessagingMetrics`** | **0** | 구현이 조립되지 않음 |
| **`MessagingTracer`** | **0** | |
| **`MessagingAuditSink`** | **0** | |
| **`DefaultMessagingObservationConvention`** | **0** | |
**(a) 관측 구현이 조립되지 않는다**
`MessagingMetrics``MessagingObservation`의 유일한 구현이고 저장소 전체에서 자기 테스트에서만 생성된다. starter는 그 **두 생성자 인자**(`MessagingRedactor:253`, `CardinalityGuard:264`)를 bean으로 만들고 그 둘을 합칠 bean은 만들지 않는다.
그리고 `DefaultMessagePublisher`는 6인자 생성자로 조립되어 `NO_OBSERVATION`을 쓴다. 상세는 `analysis/messaging/messaging-runtime-core.md` §12.1(b)가 소유한다. 이 leaf 쪽 사실은 **구현·재료·seam이 다 있는데 조립만 없다**는 것이다.
**(b) 태그 어휘가 존재하고 유일한 호출부가 우회한다**
`DefaultMessagingObservationConvention`은 "the tag values are a public contract … an adapter inventing its own spelling of 'rejected' silently breaks every alert"를 이유로 만들어졌고, 소비자가 0이다.
유일한 production 호출부가 이렇게 쓴다.
```java
// DefaultMessagePublisher.observe:260-269
observation.recordPublish(
MessagingTags.of(
profile.broker(),
profile.name().value(),
"publish", // ← 리터럴
result.completion().name().toLowerCase(Locale.ROOT)), // ← 직접 파생
elapsedSince(startedAt));
```
두 가지가 어긋난다.
1. `"publish"`가 리터럴이다. `DefaultMessagingObservationConvention.PUBLISH` 상수가 같은 값으로 존재한다.
2. **4인자 `MessagingTags.of(...)`를 쓰므로 `failureCategory`가 항상 `NONE`이다.** convention의 `publish(broker, dest, completion, Optional<FailureCategory>)`는 정확히 그 값을 채우려고 있다.
결과: 메트릭이 배선되더라도 **실패한 발행의 실패 분류가 기록되지 않는다.** `MessagingTags`가 6차원을 선언하고 실제로 채워지는 것은 4차원이다. `retryStage`도 마찬가지이지만 그쪽은 소비 경로가 없으므로 채울 주체 자체가 없다.
**(c) 추적과 감사 sink는 소비자가 없다**
`MessagingTracer`는 브로커 홉을 건너는 추적의 유일한 수단인데 참조가 0이다. 어댑터(`messaging-kafka`, `messaging-rabbit`)가 헤더를 매핑하지만 `MessagingTracer`를 쓰지 않는다 — 각 leaf SSOT가 무엇을 대신 하는지 답해야 한다.
`MessagingAuditSink`는 인터페이스 참조가 0이다. 그런데 `MessagingAuditEvent``messaging-admin-runtime`**production에서 쓴다**(`RedriveService:126`, `ReplayService:73`). 즉 이벤트 타입은 쓰고 sink 인터페이스는 안 쓴다 — §12.3(c).
**한계.** 정적 검색이다. 파생 프로젝트가 `MessagingObservation` 구현을 제공할 수 있으나, `DefaultMessagePublisher`의 6인자 조립을 대체하려면 publisher bean 전체를 바꿔야 한다(`@ConditionalOnMissingBean(MessagePublisher.class)`).
### 12.2 Conditional sibling comparison
이 leaf에 bean은 없다. starter 쪽 sibling 셋을 비교하면 비대칭이 드러난다.
| starter가 만드는 것 | 조건 | 이 leaf 소속 | 주입처 |
|---|---|---|---|
| `MessagingRedactor` (`:253`) | `@ConditionalOnMissingBean` | o | **0** |
| `CardinalityGuard` (`:264`) | `@ConditionalOnMissingBean` | o | **0** |
| `MessagingMetrics` | — | o | **만들지 않음** |
**두 재료는 만들고 그것을 쓰는 것은 만들지 않는다.** 조건은 동일하고 결과가 다르다. `messaging-policy``RetryDecisionEngine`/`DeadLetterOrchestrator`(그쪽 §12.2)와 같은 형태이되, 여기서는 **만들어진 것조차 주입처가 없다** — 더 이른 단계에서 끊겼다.
### 12.3 Duplicate mechanism sweep
**(a) 자격증명 판정이 두 강도로 존재한다**
| 위치 | 방식 | 예 |
|---|---|---|
| `messaging-core-api` `MessageHeaders.carriesACredential` | 정확 일치 9개 + **세그먼트 매칭 10개 + 인접 결합** | `x-api-key` 거절, `tokenizer-version` 통과 |
| 이 leaf `MessagingRedactor.isDenied` | **정확 일치 27개만** | `x-api-key` **통과**(목록에 없음) |
`MessagingRedactor`의 denylist에 `api_key``apikey`는 있지만 `x-api-key`는 없다. core-api가 세그먼트 매칭으로 잡는 형태를 이쪽은 놓친다. 두 곳이 다른 표면을 보호하므로(헤더 vs 진단 맵) 같은 규칙일 필요는 없지만, **더 약한 쪽이 더 자유로운 입력을 받는다** — 진단 맵은 "the one place where a caller can pass arbitrary keys"라고 이 leaf 자신이 적는다. §17.
**(b) 정적 스캐너 분류기가 두 파일에 복제돼 있다**
`SecretLeakStaticScanTest`의 private static 분류기(5개 `Pattern` + 판정 로직)가 `SecretLeakScannerCharacterizationTest`에 **글자 그대로 복사**돼 있다. 후자의 javadoc이 그 사실과 제거 조건을 명시한다 — "The copy is deleted in the same change that proves the extracted classifier agrees with it." 그 change("Wave 2")는 일어나지 않았다.
의도된 임시 중복이고 조건이 문서화돼 있으므로 결함으로 분류하지 않는다. 다만 두 복사본이 갈라지면 특성화 테스트가 실제 스캐너와 다른 것을 고정하게 된다.
**(c) 감사 sink 인터페이스가 사용처에서 다시 선언된다**
```java
// messaging-admin-runtime/RedriveService.java:208
void record(dev.caskeleton.messaging.observation.MessagingAuditEvent event);
```
`MessagingAuditSink.record(MessagingAuditEvent)`와 같은 시그니처다. `messaging-admin-runtime``allowed_dependencies``messaging-observability`**포함돼 있으므로** 인터페이스를 쓸 수 있는데 쓰지 않는다.
결과: `MessagingAuditSink.inMemory()`가 제공하는 구현을 admin-runtime이 쓸 수 없고, 두 인터페이스가 구조적으로 호환되지만 타입 수준에서는 무관하다.
**(d) 관측 seam이 family 밖에도 있다**
notification 플랫폼이 자기 `CardinalityGuard`·`NotificationObservationConvention`·`MicrometerNotificationMetrics`를 갖는다. 같은 문제(카디널리티 경계 + 태그 어휘 + Micrometer 바인딩)를 두 family가 각자 푼다. 책임 경계가 다르므로 중복 경쟁은 아니지만, `CardinalityGuard`라는 **이름이 겹쳐** reachability 판정에 오탐을 만들었다(§12.1). 저장소 전역 판단이므로 cross-scope가 소유한다.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `DefaultMessagingObservationConvention` javadoc: "The conversion lives here, once, rather than at each call site" | 소비자 0, 유일한 호출부가 리터럴 사용 | **불일치** |
| `MessagingAuditEvent` javadoc: "the details are passed through `MessagingRedactor`" | 생성자가 redactor를 부르지 않음 | **미강제** — 호출자 책임 |
| `MessagingMetrics` javadoc: 태그가 guard를 먼저 통과 | `admitted(tags)`가 모든 record 메서드의 첫 단계 | **일치** |
| `MessagingRedactor` javadoc: denylist인 이유 | 27키 정확 일치 | **일치** |
| `MessagingTracer` javadoc: platform 헤더로 기록 | `MessageHeaders.platform` 사용 | **일치** |
| build.gradle 주석: Micrometer가 public 생성자에 등장 | `MessagingMetrics(MeterRegistry, …)` | **일치** |
| `SecretLeakScannerCharacterizationTest` javadoc: Wave 2에서 복사본 제거 | 복사본 존재 | **미실현** |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `CardinalityGuard.admit` 주석 | size-then-add가 비원자적 | N개 스레드가 각각 `size == limit-1`을 읽고 각각 추가 → 설정된 상한이 경계가 아니라 **평균**. "A guard that can be exceeded under load is no guard — load is when it matters." |
| `CardinalityGuard.admit(tags)` 주석 | 차원을 순회하며 즉시 커밋 | 마지막 차원에서 거절된 태그 세트가 앞 차원의 예산을 **영구히** 소비 — 방출된 적 없는 series에 |
| `MessagingMetrics.recordDiagnostics` 주석 | 진단 키와 **값**을 전부 카운터 태그로 | 요청당 고유 message id/예외 메시지/URL 하나가 요청당 미터 series 하나를 영구 생성. redactor는 아는 키만 마스킹하므로 자유형 텍스트는 그대로 |
| `SecretLeakScannerCharacterizationTest` javadoc | 스캐너가 메서드 호출 접미사와 `+ 1` 산술을 구분 못 함 | 전체 `test` 실행이 red |
세 번째가 가장 무겁다 — **경계가 있었는데 기본 차원만 보호했고 진단 값은 그 밖이었다.** 현재는 값이 태그가 되지 않고 키만 별도 guard 차원(`"diagnostic"`)을 통과한다.
첫 두 개는 같은 주제의 두 형태다 — **경계는 예산을 정확히 소비할 때만 경계다.** `messaging-policy`의 슬롯 누수 방지, `messaging-transport-spi``endWork` clamp와 같은 계열이고 각 leaf §13이 소유한다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-285 | command | `evidence/raw/285-observability-tag-vocabulary-bypass.txt` | 9타입 단어검색 원본값, `CardinalityGuard` 동명 클래스 둘과 import별 실제 소유자, 소비자 0인 네 타입, convention의 `publish()``MessagingTags.of()`와 유일한 호출부 나란히, 감사 sink 재선언과 admin-runtime의 허용 의존 | 정적 검색. 파생 프로젝트 미포함 |
| EVD-286 | command | `./gradlew :messaging:messaging-observability:test --rerun-tasks` | BUILD SUCCESSFUL, 42 / 0 / 0 | `SimpleMeterRegistry` 사용. 실제 backend 없음 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 태그를 닫힌 record로 두는 이유와 무엇을 뺐는지 — `MessagingTags` javadoc
- 태그 값이 공개 계약인 이유 — `DefaultMessagingObservationConvention` javadoc
- 카디널리티 실패가 점진적이지 않은 이유, 조용한 대체보다 시끄러운 거절이 나은 이유 — `CardinalityGuard` javadoc
- 두 개의 이전 경합/예산 결함 — 두 인라인 주석
- denylist를 고른 이유와 두 범주 — `MessagingRedactor` javadoc
- guard가 미터 생성보다 먼저인 이유 — `MessagingMetrics` javadoc
- 논리 메시지와 물리 시도를 분리한 이유 — 같은 javadoc + `MessagingObservation` javadoc
- 진단 값이 태그가 되지 않는 이유 — `recordDiagnostics` 주석
- 메시징이 in-process 추적을 끊는 이유, W3C를 쓰는 이유 — `MessagingTracer` javadoc
- 배치가 링크인 이유 — 같은 javadoc
- 추적 헤더를 platform 헤더로 쓰는 이유 — `inject` javadoc
- 감사를 메트릭·로그와 분리한 이유 — `MessagingAuditSink` javadoc
- 정적 스캔이 필요한 이유 — `SecretLeakStaticScanTest` javadoc
- 특성화 테스트의 복사본이 임시인 이유와 제거 조건 — 그 javadoc
- Micrometer를 `api`로 선언한 이유 — build.gradle 주석
**추론**
- `MessagingMetrics` bean이 없는 것이 미완인지 → **미상**. 두 재료가 bean으로 있다는 점이 미완을 시사한다.
- `DefaultMessagePublisher`가 convention을 쓰지 않는 것이 의도인지 → **미상**.
- 어댑터가 `MessagingTracer` 대신 무엇을 쓰는지 → **미확인**(각 어댑터 leaf 소유).
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 9개 타입 838줄 전문의 계약
- 42개 테스트가 통과하고 무엇을 단언하는지, 정적 스캔 테스트가 무엇을 검사하는지
- `CardinalityGuard`가 동명의 다른 클래스와 혼동된다는 것과 import 기준 실제 소비자가 1개라는 것
- `MessagingMetrics`·`MessagingTracer`·`MessagingAuditSink`·`DefaultMessagingObservationConvention` 넷이 소비자 0이라는 것
- 태그 어휘가 존재하고 유일한 호출부가 리터럴과 4인자 factory로 우회하며, 그 결과 `failureCategory`가 항상 `none`이 된다는 것
- starter가 `MessagingMetrics`의 두 재료만 bean으로 만든다는 것
- `MessagingAuditEvent`는 admin-runtime이 쓰고 `MessagingAuditSink`는 재선언된다는 것
**확인하지 못한 것**
- **`MessagingMetrics` bean이 없는 것이 미완인지 확장점인지.** 저장소 안에 답이 없다.
- 어댑터들이 추적 헤더를 어떻게 다루는지 — `MessagingTracer`를 쓰지 않는 것은 확인했고 무엇을 대신 하는지는 각 leaf가 답한다.
- `SecretLeakStaticScanTest`의 스캔 루트가 어떤 디렉터리 집합을 실제로 덮는지 — 코드상 `messaging-core-api`를 찾아 올라가지만 실행 시 파일 수를 남기지 않았다.
- `extract`가 손상된 `traceparent`를 만났을 때의 실제 빈도.
- `CardinalityGuard` 상한 200이 실제 배포에서 충분한지.
---
## 17. 손볼 것
### P2 — 태그 어휘가 존재하고 유일한 호출부가 우회해, 실패 분류가 기록되지 않는다
- **사실.** `DefaultMessagingObservationConvention`은 소비자가 0이다. 유일한 production 호출부(`DefaultMessagePublisher.observe:260-269`)가 `"publish"` 리터럴과 **4인자** `MessagingTags.of(...)`를 쓴다. 그 factory는 `failureCategory``retryStage``NONE`으로 고정한다. convention의 `publish(broker, dest, completion, Optional<FailureCategory>)`는 정확히 `failureCategory`를 채우려고 존재한다.
- **근거.** `evidence/raw/285` §C·§D.
- **왜 문제인가.** convention javadoc이 "an adapter inventing its own spelling of 'rejected' silently breaks every alert that was watching for it"를 이유로 중앙화를 선언했고, 첫 호출부가 그것을 지나쳤다. 그리고 결과가 철자 문제에 그치지 않는다 — **`MessagingTags`가 선언한 6차원 중 4개만 채워진다.** 메트릭이 배선되더라도(§다음 항목) 실패한 발행이 `failureCategory=none`으로 기록되어, "왜 실패했는가"를 메트릭에서 나눌 수 없다. `PublishResult.failure()``FailureDescriptor`가 이미 있으므로 값은 손에 있다.
- **확인 방법.** `evidence/raw/285` §D 재실행. 또는 `git grep -n -w DefaultMessagingObservationConvention -- src`.
- **후보.** `observe(...)`가 convention의 `publish(profile.broker(), profile.name().value(), result.completion(), result.failure().map(FailureDescriptor::category))`를 호출하게 바꾼다.
- **다음 단계.** **CASE 후보.** 그리고 "중앙 어휘는 첫 호출부가 쓸 때만 어휘다"가 **REFERENCE 후보**다.
### P2 — 관측 구현이 조립되지 않고, 그 재료 둘만 bean으로 존재한다
- **사실.** `MessagingMetrics``MessagingObservation`의 유일한 구현이고 저장소 전체에서 자기 테스트에서만 생성된다. starter는 그 생성자 인자 둘(`MessagingRedactor:253`, `CardinalityGuard:264`)을 bean으로 만들고 `MessagingMetrics` bean은 만들지 않는다. `DefaultMessagePublisher``NO_OBSERVATION`을 쓰는 6인자 생성자로 조립된다.
- **근거.** `evidence/raw/285` §A·§C. `evidence/raw/283` §D(runtime-core 쪽 증거).
- **왜 문제인가.** 재료·구현·seam·호출부가 전부 있고 조립 한 줄이 없다. 그리고 두 재료 bean은 주입처가 0이므로 컨텍스트에 앉아만 있다 — bean 존재 검사는 통과한다.
- **확인 방법.** `git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?MessagingMetrics\s*\(' -- src` → 테스트만.
- **다음 단계.** `analysis/messaging/messaging-runtime-core.md` §17 첫 항목과 **동일 사건**이다. 그 leaf가 CASE를 소유하고 여기서는 이 leaf 쪽 사실(재료만 bean, 구현 미조립)을 기여한다.
### P3 — 브로커 홉 추적기가 소비자를 갖지 않는다
- **사실.** `MessagingTracer`의 leaf 밖 참조 0. 이 클래스가 존재하는 이유는 "the only way the two spans meet is if the context travels in the message headers"다.
- **근거.** `evidence/raw/285` §C.
- **왜 문제인가.** `messaging-core-api``TraceContext`가 봉투 필드로 있고(그쪽 §4.11), 어댑터가 헤더를 매핑한다. 그런데 `traceparent`/`tracestate`/`baggage`를 헤더로 옮기는 **명시된 수단**을 아무도 쓰지 않는다. 어댑터가 각자 하고 있다면 `MessageHeaders.platform` 사용 여부와 빈 추적 처리가 어댑터마다 다를 수 있다.
- **확인 방법.** `git grep -n -w MessagingTracer -- src` → 이 leaf만. 어댑터의 헤더 매퍼가 세 이름을 어떻게 다루는지 확인 필요.
- **후보.** 어댑터가 `MessagingTracer`를 쓰게 하거나, 어댑터가 대신 하고 있음을 확인하고 이 클래스를 정리한다.
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 `messaging-kafka`·`messaging-rabbit` leaf의 사실에 걸린다.
### P3 — 감사 sink 인터페이스가 사용처에서 다시 선언된다
- **사실.** `MessagingAuditSink.record(MessagingAuditEvent)`와 같은 시그니처를 `RedriveService:208`이 자기 중첩 인터페이스로 선언한다. `messaging-admin-runtime``messaging-observability`에 의존할 수 있다(registry 확인).
- **근거.** `evidence/raw/285` §E.
- **왜 문제인가.** `MessagingAuditSink.inMemory()`가 제공하는 구현을 admin-runtime이 쓸 수 없다. 그리고 감사 sink의 계약(분리된 보존·접근·무결성 요구)이 문서화된 곳과 실제로 구현되는 곳이 다르다.
- **확인 방법.** 두 시그니처 대조.
- **후보.** `RedriveService``MessagingAuditSink`를 받게 한다.
- **다음 단계.** **CASE 후보.** `messaging-admin-runtime` leaf SSOT와 공동 소유.
### P3 — 자격증명 판정이 core-api보다 약하다
- **사실.** `MessagingRedactor.isDenied`는 27키 **정확 일치**다. `messaging-core-api``MessageHeaders.carriesACredential`은 세그먼트 매칭 + 인접 결합으로 `x-api-key`·`auth-token`·`db_password`를 잡는다. redactor의 denylist에 `api_key`·`apikey`는 있으나 `x-api-key`는 없다.
- **근거.** 두 구현 대조. `MessagingRedactor.java:21-50`, `MessageHeaders.java:142-160`.
- **왜 문제인가.** 두 표면이 다르지만 **더 자유로운 입력을 받는 쪽이 더 약하다.** 이 leaf 자신이 진단 맵을 "the one place where a caller can pass arbitrary keys"라고 부른다. 그리고 `recordDiagnostics`가 redaction을 첫 단계로 두는 이유가 바로 그것이다.
- **확인 방법.** `redactor.isDenied("x-api-key")`가 false임을 확인.
- **후보.** core-api의 세그먼트 매칭을 공유하거나 이쪽 denylist를 같은 방식으로 바꾼다.
- **다음 단계.** **CASE 후보 + REFERENCE 후보**(같은 규칙을 두 강도로 구현하면 자유로운 입력 쪽을 강한 것으로 맞춘다).
### P3 — 감사 이벤트가 redaction을 강제하지 않는다
- **사실.** `MessagingAuditEvent` javadoc이 "the details are passed through `MessagingRedactor`"라고 하지만 생성자는 `Map.copyOf`만 한다.
- **근거.** `MessagingAuditEvent.java:30-38`.
- **왜 문제인가.** 감사 기록은 "often retained far longer than the source topic"이고 운영자가 읽는다. redaction이 호출자 책임이면 새 호출부가 그것을 잊을 수 있다. `messaging-core-api``FailureDescriptor`가 512자 절단을 생성자에서 하는 것과 대비된다.
- **확인 방법.** 생성자 본문 확인. `RedriveService:126`·`ReplayService:73`이 redactor를 부르는지 확인.
- **후보.** 생성자가 `MessagingRedactor.sanitize`를 적용하거나, javadoc을 "호출자가 통과시켜야 한다"로 고친다.
- **다음 단계.** **REFERENCE 후보**(타입이 문서화한 불변식은 타입이 강제한다).
### P3 — `extract`가 손상된 추적 헤더에 분류되지 않은 예외를 던진다
- **사실.** `MessagingTracer.extract``new TraceContext(...)`를 부르고, 그 생성자는 W3C 문법·바이트 상한·all-zero를 검사해 `IllegalArgumentException`을 던진다. `extract`는 잡지 않는다.
- **근거.** `MessagingTracer.java:67-75`, `TraceContext.java:60-72`.
- **왜 문제인가.** 다른 시스템이 보낸 메시지의 헤더는 신뢰할 수 없는 입력이다. 손상된 `traceparent` 하나가 `MessagingException`이 아닌 예외로 소비 경로를 끊는다 — 추적이 없어야 할 자리에서 메시지 처리가 실패한다. `messaging-cloudevents`의 id 파싱과 같은 형태다(그쪽 §17).
- **확인 방법.** `tracer.extract`에 잘못된 `traceparent` 헤더를 넣어 확인.
- **후보.** `extract`가 검증 실패를 `TraceContext.none()`으로 강등한다 — 추적 손실이 메시지 손실보다 낫다.
- **다음 단계.** **CASE 후보.** 다만 `MessagingTracer` 소비자가 0이므로 오늘의 사고는 아니다.
### 확인된 설계(문제 아님)
- 태그를 닫힌 6차원 record로 두고 message id·partition key·tenant·offset을 명시적으로 배제한 것
- guard가 미터 생성보다 먼저이고, 거절을 고정 이름 미터로만 기록하는 것
- 논리 메시지 카운터를 첫 시도에만 증가시키는 것
- 진단의 **값**을 태그로 만들지 않고 키만 별도 차원으로 세는 것, redaction을 첫 단계로 두는 것
- 경합 하에서도 상한이 경계로 유지되는 double-checked 구조와, 그것을 재현하는 테스트
- 태그 세트를 사전 확인 후 커밋해 거절된 세트가 예산을 안 먹게 하는 것
- 추적을 platform 헤더로 써서 애플리케이션이 덮지 못하게 하는 것
- 배치 소비를 부모가 아니라 링크로 두는 것
- 감사를 메트릭·로그와 분리하고 승인 티켓을 필수로 둔 것
- 소스 트리를 정적 스캔하는 테스트와, 그 스캔이 실제로 파일을 읽었는지 확인하는 테스트
- Micrometer를 `api`로 선언하고 seam은 벤더 중립으로 유지한 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MOB-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 1개, memberships `["app-bootstrap"]` | 선언 |
| MOB-002 | build | `messaging-observability/build.gradle` | same | Micrometer `api`와 그 이유 | — |
| MOB-003 | code | `.../observation/MessagingTags.java` | same | 닫힌 6차원, 두 factory의 차이 | — |
| MOB-004 | code | `.../observation/DefaultMessagingObservationConvention.java` | same | 태그 어휘 중앙화 의도 | 소비자 0(§12.1b) |
| MOB-005 | code | `.../observation/CardinalityGuard.java` | same | 상한 강제와 두 이전 결함 | 동명 클래스 존재(§12.1) |
| MOB-006 | code | `.../observation/MessagingRedactor.java` | same | 27키 denylist, `sanitize`/`mask` | core-api보다 약함(§17) |
| MOB-007 | code | `.../observation/MessagingMetrics.java` | same | 여섯 미터, guard 우선 순서, 진단 값 배제 | 조립되지 않음 |
| MOB-008 | code | `.../observation/MessagingObservation.java` | same | seam 5메서드 | — |
| MOB-009 | code | `.../observation/MessagingTracer.java` | same | W3C 왕복, platform 헤더, 배치 링크 | 소비자 0 |
| MOB-010 | code | `.../observation/{MessagingAuditSink,MessagingAuditEvent}.java` | same | 감사 분리와 필수 필드 | sink 소비자 0, redaction 미강제 |
| MOB-011 | test | `MessagingSecretLeakTest` (9) | same | 비밀·신원이 meter registry에 도달 못 함, 경합 하 상한 | `SimpleMeterRegistry` |
| MOB-012 | test | `MessagingMetricCardinalityTest` (8) | same | 상한 동작과 거절 카운트 | — |
| MOB-013 | test | `MessagingTraceLinkTest` (8) | same | 추적 왕복과 링크 판정 | 실제 collector 없음 |
| MOB-014 | test | `MessagingRedactorTest` (6) | same | denylist 동작 | — |
| MOB-015 | test | `SecretLeakStaticScanTest` (6) | same | messaging 소스 전체 정적 스캔 + 스캔 도달 확인 | 런타임 유출 미포함 |
| MOB-016 | test | `SecretLeakScannerCharacterizationTest` (5) | same | 분류기 판정 고정 | 분류기 복사본(§12.3b) |
| MOB-017 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:253,264` | same | 두 재료 bean, `MessagingMetrics` 부재 | 해당 leaf SSOT가 소유 |
| MOB-018 | cross-leaf code | `messaging-runtime-core/.../DefaultMessagePublisher.java:258-269` | same | 유일한 관측 호출부와 그 우회 | 해당 leaf SSOT가 소유 |
| MOB-019 | cross-leaf code | `messaging-admin-runtime/.../RedriveService.java:126,208`, `ReplayService.java:73` | same | 이벤트 사용, sink 재선언 | 해당 leaf SSOT가 소유 |
| EVD-285 | command | `evidence/raw/285-observability-tag-vocabulary-bypass.txt` | same | §12.1·§12.3(c) | 정적 검색 |
| EVD-286 | command | `./gradlew :messaging:messaging-observability:test --rerun-tasks` | same | 42 / 0 / 0 | `SimpleMeterRegistry` |
@@ -0,0 +1,880 @@
# messaging-policy 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-policy`
> SSOT owner: `messaging-policy`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-policy`
- canonical state `analysisFile`: `analysis/messaging/messaging-policy.md`
- source path: `src/messaging/messaging-policy`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]`
- registry `runtime_memberships`: `["app-bootstrap"]`
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 26 |
| production LOC | 1,738 |
| 패키지 | 1 (`dev.caskeleton.messaging.policy`) |
| test 파일 | 4 |
| test 메서드(실행 확인) | 42 |
| 외부(비프로젝트) 의존성 | **0** |
26개 타입을 관심사로 나누면 다섯이다.
| 축 | 타입 |
|---|---|
| **목적지 정의** (8) | `DestinationProfile` · `PhysicalDestination` · `SchemaPolicy` · `ProducerPolicy` · `ConsumerPolicy` · `PayloadPolicy` · `DeadLetterPolicy` · `CapabilityTier` |
| **시작 검증** (1) | `DestinationProfileValidator` |
| **발행 관문** (3) | `MessagingAdmissionController` · `PayloadLimitGuard` · `InFlightLimiter` |
| **재시도 판단** (8) | `RetryPolicy` · `RetryMode` · `OrderingImpact` · `RetryContext` · `RetryDecision` · `RetryDecisionEngine` · `DefaultRetryDecisionEngine` · `BackoffCalculator` |
| **DLQ 조정** (6) | `DeadLetterOrchestrator` · `DeadLetterEnvelopeFactory` · `DeadLetterMetadata` · `DeadLetterResult` · `SourceSettlement` · `FailureDescriptorDefaults`(package-private) |
**다섯 축의 배선 상태가 서로 다르다.** 목적지 정의·시작 검증·발행 관문은 출하 컨텍스트에서 실제로 실행되고, 재시도 판단과 DLQ 조정은 bean으로 생성되지만 주입되는 곳이 없다(§12.1).
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (26) | 26 | `FULL_READ` | 전 파일 본문 확인 |
| `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 및 단언 확인 |
| `build.gradle` | 1 | `FULL_READ` | 6줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
이 leaf는 **"이 목적지는 무엇을 약속하는가"**를 소유한다. 브로커를 만지지 않고 벤더 의존성이 0이며, 대신 브로커 어댑터가 따라야 할 판단을 미리 계산한다.
경계 규칙 하나가 leaf 전체를 관통한다: **모순은 부팅 실패여야 한다.**
```java
// DestinationProfileValidator.java:20-24
* <p>Every rule here exists because the alternative is a production surprise. A profile that asks
* for ordered delivery and configures a reordering retry does not fail on the happy path; it fails
* the first time a message is retried, months later, in a way that looks like a data bug rather
* than a configuration one. Making the contradiction a boot failure moves that discovery to the
* deploy that introduced it.
```
두 번째 경계는 **물리 주소의 격리**다.
```java
// PhysicalDestination.java:9-11
* <p>Held here and nowhere else. Once a topic name reaches application code the logical destination
* stops being a boundary, and swapping the broker under a service becomes a code change instead of
* a configuration change.
```
`messaging-core-api``DestinationName``:``/`를 정규식으로 막고(그쪽 §4), 이 leaf가 물리 주소를 독점한다. 두 leaf가 같은 경계를 양쪽에서 지킨다.
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api). 둘 다 `api`인 이유는 `DestinationProfile``DeliveryGuarantee`·`OrderingScope`·`DestinationKind`·`DestinationName`(core-api)와 `SchemaCompatibility`(schema-api)를 필드로 갖기 때문이다.
나가는 것: `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`.
**실제 배선 지점 넷**(전부 `messaging-spring-boot-starter/MessagingCoreAutoConfiguration`):
| 지점 | 라인 | 상태 |
|---|---:|---|
| `new DestinationProfileValidator().validateAll(registered)` | 134 | **실행됨** — 시작 시 전체 registry 검증 |
| `DestinationProfileValidator` bean | 145146 | 생성 |
| `MessagingAdmissionController` bean | 407417 | 생성 + `DefaultMessagePublisher`·`MessagingEndpoint`·`MessagingShutdownLifecycle`이 주입받음 |
| `RetryDecisionEngine` bean | 167169 | 생성, **주입처 없음**(§12.1) |
| `DeadLetterOrchestrator` bean | 179181 | 생성, **주입처 없음**(§12.1) |
이 leaf 자체는 Spring 주석을 갖지 않는다 — bean 정의는 전부 starter 쪽에 있다.
---
## 3. 패키지/컴포넌트 지도
```
[목적지 정의]
DestinationProfile ─┬─ PhysicalDestination (topic/exchange/routingKey/queue/subject/stream)
├─ SchemaPolicy (codec, compatibility, 닫힌 messageTypes)
├─ ProducerPolicy (confirmation, timeout, mandatoryRouting, idempotent)
├─ ConsumerPolicy (group, concurrency, maxInFlightPerUnit, prefetch, timeout, manual)
├─ RetryPolicy (mode, maxAttempts, backoff, orderingImpact, 카테고리 오버라이드)
├─ DeadLetterPolicy (enabled, destination, maxRedriveCount)
├─ PayloadPolicy (maxBytes, claimCheckThreshold)
└─ CapabilityTier (M1/M2/M3)
[시작 검증] DestinationProfileValidator
├─ validate(profile) : 프로파일 내부 모순 15가지
└─ validateAll(profiles) : 중복 이름 + retry/DLQ 그래프 사이클
[발행 관문] MessagingAdmissionController
├─ PayloadLimitGuard ── PayloadPolicy
└─ InFlightLimiter (Semaphore, fair)
[재시도 판단] RetryContext ─→ RetryDecisionEngine ─→ RetryDecision (sealed 5)
DefaultRetryDecisionEngine ── BackoffCalculator
[DLQ 조정] DeadLetterOrchestrator ─┬─ DeadLetterEnvelopeFactory ── DeadLetterMetadata
└─ SourceSettlement → DeadLetterResult
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `DestinationProfileValidator.validate` — 15가지 모순 거절
프로파일 하나에 대해 순서대로 검사한다.
| # | 거절 조건 | 왜 |
|---:|---|---|
| 1 | `retry.orderingImpact == PRESERVE && retry.reorders()` | 정책이 자기 자신과 모순 |
| 2 | `isOrdered() && retry.orderingImpact == ALLOW_REORDER` | 순서 목적지가 재정렬 재시도를 허용 |
| 3 | `payload.maxBytes > 8,388,608` | 절대 상한 초과 |
| 4 | `claimCheckThreshold > payload.maxBytes` | 오프로드 문턱이 상한보다 큼 |
| 5 | DLQ가 자기 자신을 가리킴 | 무한 루프 |
| 6 | retry 목적지가 자기 자신을 가리킴 | 무한 루프 |
| 7 | `orderingScope == KEY && !keyResolverConfigured` | 키 기반 순서인데 키 추출기 없음 |
| 8 | `tier == M1 && consumer.manualSettlement` | M1이 수동 정산을 쓰면 정산 순서가 앱으로 새 나감 |
| 9 | `AT_LEAST_ONCE && producer.confirmation == NONE` | 확인 없는 at-least-once는 보장이 아님 |
| 10 | `production && topologyAutoCreate` | 운영에서 앱이 토폴로지를 만듦 |
| 11 | `orderingScope == DESTINATION && consumer.concurrency > 1` | 목적지 전체 순서는 동시성 1을 요구 |
| 12 | `isOrdered() && maxInFlightPerOrderingUnit > 1` | 순서 단위 안 동시 처리 |
| 13 | `physical.isEmpty()` | 물리 주소 없음 |
| 14 | `retry.mode == NONE && maxAttempts > 1` | 모드와 횟수 모순 |
| 15 | `retry.mode == RETRY_DESTINATION && retryDestination.isEmpty()` | 목적지 없는 재시도 목적지 모드 |
| 16 | `maxAttempts > 1 && mode != NONE && !deadLetter.enabled` | 재시도하는데 소진 후 갈 곳 없음 |
11번과 12번이 짝이다 — 전자는 목적지 수준 동시성, 후자는 순서 단위 안 동시성. 둘 다 있어야 "순서 보장"이 실제로 성립한다.
### 4.2 `validateAll` — 두 종류의 간선을 하나의 그래프로
이 leaf에서 가장 정교한 판단이다.
```java
// :131-136
// One graph carrying both edge kinds, not two walks.
//
// Walking retry and dead-letter separately misses a cycle that alternates between them: A's
// retry points at B and B's dead letter points back at A. Neither single-edge walk revisits a
// node, both pass, and a poison message loops between the two destinations forever. The label
// is kept per edge so the reported path still says which kind each hop was.
```
`Edge` enum이 `RETRY``DEAD_LETTER` 둘을 갖고, `walk`가 두 간선을 동시에 따라간다.
**`onPath`가 전역 방문 집합이 아니라 현재 경로다.**
```java
// :164-169
* <p>{@code onPath} is the current walk rather than everything ever seen, so a diamond two
* destinations that both forward to a third is not mistaken for a loop.
walk(nextProfile, byName, new LinkedHashSet<>(onPath), branch);
```
각 분기마다 `new LinkedHashSet<>(onPath)`로 복사하므로 형제 분기가 서로의 방문 기록을 오염시키지 않는다. 다이아몬드(A→C, B→C)는 사이클이 아니고, 그것을 사이클로 판정하면 정상 구성이 부팅에 실패한다.
테스트가 두 경우를 각각 붙든다 — `aMixedEdgeCycleIsRejected`(retry/DLQ 교대 사이클 거절)와 `aSharedDeadLetterIsNotACycle`(다이아몬드 허용).
미등록 목적지도 여기서 잡힌다 — `anUnregisteredRetryDestinationIsRejected`.
**비용 주의.** 매 분기마다 `onPath``path`를 복사하므로 시간·공간이 경로 수에 지수적이다. 목적지 수가 수십 개인 정상 구성에서는 문제가 없지만, 이 성질이 어디에도 기록되지 않았다 — §17의 P3.
### 4.3 `MessagingAdmissionController` — 순서가 계약이다
```java
// :13-16
* <p>Order matters and is fixed here rather than left to each adapter: the payload limit is checked
* <em>before</em> a permit is taken. An oversized message can never succeed, so letting it occupy a
* scarce in-flight permit while it is being rejected would let a stream of bad messages starve the
* good ones.
```
`admit`의 실제 순서:
1. `payloadGuard.checkPayload` → 초과면 `MessageTooLargeException`
2. `acceptingNewWork` 확인 → 종료 중이면 `MessageBackpressureException("SHUTTING_DOWN")`
3. `reserve(destination)` — 목적지별 CAS 루프 → 초과면 `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED`
4. `limiter.tryAcquire()` — 프로세스 전역 semaphore, 유한 대기 → 실패면 목적지 슬롯 **반납 후** `IN_FLIGHT_LIMIT_EXCEEDED`
**두 개의 천장이 있는 이유**도 명시돼 있다.
```java
// :23-26
* <p>Two ceilings, because one is not enough. The per-destination ceiling stops a single slow
* downstream from consuming every permit in the process, and the process-wide ceiling stops the sum
* of well-behaved destinations from exhausting memory without it, adding a destination silently
* raises what the process can be holding at once.
```
**거절이 모호하지 않은 것이 설계의 핵심**이다 — "Both refusals happen before transmission, so neither is ambiguous — the caller may resubmit under the same message id without risking a duplicate." `messaging-core-api`의 3상태 발행 결과와 직접 연결된다.
**세 가지 누수 방지**가 코드에 있다.
```java
} catch (InterruptedException interrupted) {
// The destination slot was taken a moment ago and no publish will use it, so it goes back
// here: a slot leaked per interruption shrinks the destination's ceiling until it is zero.
release(destination);
```
```java
public void complete(String destination) {
if (!release(destination)) {
// A completion for a destination that holds nothing: either it names the wrong destination or
// it is a second completion for the same publish. Returning the process permit anyway frees
// one nobody took, and the process-wide ceiling then reads below what is really in flight and
// admits more work than the process can carry.
return;
}
limiter.release();
}
```
```java
// release():195-197
// Drop the entry at zero, atomically, so the map does not accumulate one counter per
// destination ever published to for the life of the process.
perDestination.computeIfPresent(destination, (key, value) -> value.get() == 0 ? null : value);
```
세 번째는 장기 실행 누수 방지다 — 목적지 이름이 동적이면(예: 테넌트별) 맵이 무한히 자란다.
`InFlightLimiter`가 **fair semaphore**를 쓰는 이유도 적혀 있다 — "an unfair semaphore lets a late arrival barge ahead of a caller that has already been waiting, which turns a bounded wait into an unbounded one for the unlucky."
`release()``availablePermits() < limit`를 확인하고 반납한다 — "an unbalanced release would raise the ceiling silently and the limiter would stop limiting anything."
### 4.4 `DefaultRetryDecisionEngine` — 고정된 판단 순서
```java
// :10-15
* <p>The order is fixed and evaluated top to bottom. Retryability is checked before the attempt
* budget so that a deserialization failure is parked on its first delivery instead of being
* replayed three more times against a payload that cannot change. The ordering-preserving strategy
* is checked before the re-publishing one so that an ordered destination can never fall through to
* a strategy that reorders it, even if both are technically configured.
```
실제 순서:
| # | 조건 | 결정 |
|---:|---|---|
| 1 | `!isRetryable(...)` | `park(context)` — DLQ가 있으면 `DeadLetter`, `AT_MOST_ONCE`이고 DLQ 없으면 `Reject`, 그 외 `DeadLetter` |
| 2 | `attempt >= maxAttempts` | `DeadLetter` |
| 3 | `orderingImpact == PRESERVE && isOrdered() && capabilities.orderedStream()` | `PauseAndRetry(delay)` |
| 4 | `mode == PAUSE_PARTITION` | `PauseAndRetry(delay)` |
| 5 | `mode == RETRY_DESTINATION && ALLOW_REORDER && retryDestination.isPresent()` | `PublishToRetryDestination` |
| 6 | `mode == INLINE \|\| BLOCKING` | `RetryInline(delay)` |
| 7 | `mode == BROKER_DELAYED && capabilities.delayedDelivery()` | `PublishToRetryDestination` |
| 8 | (그 외) | `DeadLetter` |
**capability가 입력이다.**
```java
// RetryContext.java:11-13
* <p>Capabilities are an input rather than an assumption: the same policy resolves to
* pause-and-retry on a partitioned Kafka topic and to a retry destination on a queue that cannot
* pause, and the engine must not pick a strategy the adapter cannot actually carry out.
```
3번과 7번이 그것을 쓴다 — `orderedStream()`이 false면 pause 전략이 선택되지 않고, `delayedDelivery()`가 false면 `BROKER_DELAYED`가 8번으로 떨어져 DLQ가 된다. **조용한 성능 저하 대신 명시적 파킹**이다.
`isRetryable`의 3단 판정:
```java
if (policy.nonRetryableCategories().contains(category)) return false; // 명시적 제외 최우선
if (policy.retryableCategories().contains(category)) return true; // 명시적 허용
return descriptorRetryable && FailureDescriptorDefaults.retryable(category); // 둘 다 만족해야
```
마지막 줄이 **AND**다 — descriptor가 retryable이라 해도 카테고리 기본값이 false면 재시도하지 않는다. `RetryPolicy` 생성자가 두 집합의 교집합을 거절하므로(§4.5) 1·2번이 동시에 참일 수 없다.
`FailureDescriptorDefaults`는 package-private 위임자다 — "kept in one place so policy and engine cannot disagree". 실제로는 `FailureDescriptor.defaultRetryable`(core-api)를 그대로 부른다. 한 줄 짜리 간접층이지만 정책 쪽에서 기본값을 바꿔야 할 때 바꿀 지점을 명시한다.
### 4.5 `RetryPolicy` — 기본값이 "재시도 없음"
```java
// :13-15
* <p>Automatic retry is opt-in. The default for an ordinary destination is zero attempts, because a
* retry that reorders a stream, multiplies a non-idempotent side effect, or hammers a throttled
* downstream is worse than a visible failure.
```
`none()``mode=NONE, maxAttempts=1, delays=ZERO, multiplier=1.0, jitter=false, orderingImpact=PRESERVE, 두 집합 비어 있음`이다.
생성자 검증 여섯:
- `maxAttempts >= 1` (첫 전달 포함)
- 두 지연 음수 아님
- `maxDelay >= initialDelay`
- `multiplier >= 1.0`
- 두 카테고리 집합을 `Set.copyOf`로 복사
- **두 집합의 교집합 거절** — "a failure category cannot be both retryable and non-retryable"
`reorders()``RETRY_DESTINATION || BROKER_DELAYED`다 — 이 둘만 메시지를 원래 순서 단위 밖으로 옮긴다. `RetryMode` javadoc이 같은 사실을 반대편에서 적는다.
### 4.6 `BackoffCalculator` — full jitter
```java
// :11-14
* <p>The delay is {@code min(maxDelay, initialDelay * multiplier^(attempt-1))}. Full jitter then
* picks uniformly from {@code [0, delay]} rather than shaving a small percentage off. That matters
* when a downstream recovers: without jitter every consumer that failed in the same second retries
* in the same second, and the recovery is immediately undone by the retry storm.
```
`randomFraction``DoubleSupplier`로 주입 가능해서 테스트가 결정론적이다. 테스트가 두 각도를 본다 — `backoffGrowsExponentiallyAndIsCappedByMaxDelay``fullJitterSpreadsRetriesAcrossTheWholeWindow`.
`capped <= 0`이면 `Duration.ZERO`를 반환하므로 `initialDelay=0`인 정책에서 곱셈이 무의미해지는 경우를 방어한다.
### 4.7 `DeadLetterOrchestrator` — 하나의 불변식
```java
// :21-29
* <p>This ordering is the single invariant that stops dead lettering from becoming data loss. If
* the source were acknowledged first, a failed dead letter publish would leave no copy of the
* message anywhere: the broker has released it and the dead letter destination never received it.
* So the source stays unsettled on anything other than a confirmed publish, including an ambiguous
* one, and the message is redelivered instead of disappearing.
*
* <p>An ambiguous dead letter publish therefore produces a duplicate rather than a loss. That is
* the intended trade: the dead letter destination is read by humans who can spot a duplicate, and
* it is the only side of the trade that is recoverable.
```
구현이 그 문장 그대로다.
```java
.thenCompose(result -> {
if (result.completion() != PublishCompletion.CONFIRMED) {
return CompletableFuture.completedFuture(new DeadLetterResult(result, false));
}
return settleAfterConfirmation(result, settlement);
});
```
`CONFIRMED`가 아니면 — `REJECTED``AMBIGUOUS`든 — 원본을 정산하지 않는다. `messaging-core-api`의 3상태가 여기서 실제 분기가 된다.
`SourceSettlement`이 콜백으로 주입되는 이유도 적혀 있다 — "so that the ordering constraint … lives in one place instead of being re-implemented by every adapter."
### 4.8 `DeadLetterEnvelopeFactory` — 예약 헤더 6개, payload 불변
```java
// :16-21
* <p>The payload and the logical {@code messageId} are carried through untouched. That is what
* makes a redrive a genuine replay rather than a new message: an Inbox downstream still recognises
* it, and an operator can correlate the dead letter with the original publish.
*
* <p>Failure context is written into reserved headers, never into the payload, so redriving does
* not require unwrapping a platform-specific structure.
```
쓰는 헤더: `FAILURE_CATEGORY`, `FAILURE_CODE`, `ORIGIN_DESTINATION`, `RETRY_ATTEMPT`, `FIRST_FAILURE_AT`, `LAST_FAILURE_AT`. 전부 `ReservedHeaders`의 상수를 쓴다(리터럴 아님).
`MessageHeaders.platform(headers)`를 쓴다 — 예약 이름을 쓸 수 있는 factory다(`messaging-core-api` §4.8). 이것이 core-api의 두 factory 분리가 실제로 필요한 이유를 보여주는 유일한 production 사용처다.
여섯 헤더 중 `RETRY_ATTEMPT`·`FIRST_FAILURE_AT`·`LAST_FAILURE_AT`·`FAILURE_CATEGORY`·`FAILURE_CODE`·`ORIGIN_DESTINATION`은 전부 `CanonicalEnvelopeHeaders`가 "platform bookkeeping"으로 분류한 8개에 속한다 — 봉투 필드가 없어서 헤더로만 이동할 수 있는 것들이다. 두 leaf의 분류가 정확히 맞물린다.
### 4.9 `DeadLetterMetadata` — 일부러 작다
```java
// :11-13
* <p>Deliberately small. A dead letter destination is read by operators, exported to tickets, and
* often retained far longer than the source topic, so it holds a category, a code, and timing not
* a stack trace, not the exception message, and not the original headers.
```
`messaging-core-api``FailureDescriptor` javadoc("a DLQ is read by more people than the log is")과 같은 판단을 다른 층에서 반복한다.
**한 가지 관측.** `DeadLetterOrchestrator``DeadLetterMetadata`를 만들 때 `firstFailureAt``lastFailureAt`**같은 값**(`delivery.metadata().receivedAt()`)을 넣는다.
```java
Instant failedAt = delivery.metadata().receivedAt();
DeadLetterMetadata metadata = new DeadLetterMetadata(..., failedAt, failedAt);
```
즉 두 필드가 구분되어 선언됐지만 현재 유일한 생산 경로에서는 항상 같다. 첫 실패 시각을 이전 시도에서 이어받는 코드가 없다 — §17의 P3.
---
## 5. 주요 실행 경로
**시작:** `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)
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 위치 | 조건 |
|---|---|---|---|
| `PAYLOAD_LIMIT_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 목적지 상한 초과 |
| `BATCH_COUNT_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 배치 항목 수 초과 |
| `BATCH_BYTES_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 배치 총 바이트 초과 |
| `SHUTTING_DOWN` | `MessageBackpressureException` | `MessagingAdmissionController` | 종료 중 |
| `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED` | `MessageBackpressureException` | 같음 | 목적지 천장 |
| `IN_FLIGHT_LIMIT_EXCEEDED` | `MessageBackpressureException` | 같음 | 프로세스 천장 |
| `ADMISSION_INTERRUPTED` | `MessageBackpressureException` | 같음 | 대기 중 인터럽트 |
| `DEAD_LETTER_NOT_CONFIGURED` | `MessagingConfigurationException` | `DeadLetterOrchestrator` | DLQ 미설정 목적지를 DLQ하려 함 |
**배치 상한이 두 축인 이유**가 적혀 있다.
```java
// PayloadLimitGuard.java:16-18
* <p>Batches are limited by count <em>and</em> bytes. A count limit alone lets a handful of large
* messages exceed the broker's frame; a byte limit alone lets a huge number of tiny messages exceed
* its request timeout.
```
`checkBatch`가 각 항목에 대해 `checkPayload`도 부르므로 **개별 상한 · 개수 상한 · 총합 상한** 셋이 함께 적용된다.
프로파일 검증 실패는 `IllegalArgumentException`이다 — `MessagingException` 계층 밖이다. 시작 시점의 구성 오류이지 메시지 실패가 아니므로 일관적이다. 다만 `MessagingConfigurationException`("Raised at startup wherever possible")이 존재하는데 쓰이지 않는다 — §17의 P3.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
동시성 지점은 `MessagingAdmissionController``InFlightLimiter` 둘이다.
| 지점 | 도구 | 보호 |
|---|---|---|
| `perDestination` 맵 | `ConcurrentHashMap` + `computeIfAbsent` | 목적지 카운터 생성 |
| 목적지 카운터 증가 | `AtomicInteger` CAS 루프 | 천장 초과 방지 |
| 목적지 카운터 감소 | `getAndUpdate` + 0 clamp | 음수 방지 |
| 맵 항목 제거 | `computeIfPresent` (원자) | 0일 때만 제거, 누수 방지 |
| `acceptingNewWork` | `volatile boolean` | 종료 플래그 가시성 |
| permit | `Semaphore(limit, true)`**fair** | 유한 대기 보장 |
| permit 반납 | `availablePermits() < limit` 확인 | 천장 상승 방지 |
`reserve`의 CAS 루프는 `AtomicInteger.updateAndGet`으로 쓸 수 있었지만 조건부 실패(`return false`)가 필요해서 직접 루프를 돈다.
`release`에 **미세한 경합**이 있다. `getAndUpdate`로 감소한 뒤 `computeIfPresent`로 0인 항목을 제거하는데, 그 사이에 다른 스레드가 `computeIfAbsent`로 같은 키를 만들고 증가시킬 수 있다. 그러면 `computeIfPresent`의 람다가 `value.get() == 0`을 보지 못해 제거하지 않는다 — 안전한 방향의 경합이다(누수가 아니라 제거 실패). 반대 순서였다면 살아 있는 카운터를 지울 수 있었다.
`DefaultRetryDecisionEngine`·`BackoffCalculator`·`DeadLetterOrchestrator`·`DeadLetterEnvelopeFactory`·`DestinationProfileValidator`는 전부 상태가 없거나 불변이다. `BackoffCalculator`의 기본 생성자가 `ThreadLocalRandom`을 쓰므로 스레드 안전하다.
수명주기 참여는 `stopAcceptingNewWork()` 하나이고, `MessagingShutdownLifecycle`(starter)이 종료 1단계에서 부른다(`messaging-transport-spi` §12.1 참조).
---
## 8. 설정·기능 플래그·환경 차이
설정 파일 없음. 상수와 기본값:
| 상수/기본값 | 값 | 위치 |
|---|---:|---|
| `PayloadPolicy.DEFAULT_MAX_BYTES` | 1,048,576 | `PayloadPolicy.java:17` (public) |
| `PayloadPolicy.HARD_MAX_BYTES` | 8,388,608 | `:20` (public) |
| `ProducerPolicy.defaults()` | `REPLICATION_OR_PERSISTENCE_ACK`, 5초, mandatoryRouting, idempotent | `:34-37` |
| `ConsumerPolicy.defaults(group)` | concurrency 1, maxInFlightPerUnit 1, prefetch 16, timeout 30초, manual false | `:52-54` |
| `RetryPolicy.none()` | mode NONE, 1회, 지연 0, PRESERVE | `:115-125` |
| `DeadLetterPolicy.disabled()` / `.to(dest)` | maxRedrive 0 / 1 | `:32-44` |
**모든 기본값이 보수적이다** — 재시도 없음, 동시성 1, 순서 보존, 확인 최대, DLQ 비활성. 켜는 것이 명시적 선택이다.
`PayloadPolicy.HARD_MAX_BYTES = 8 MiB`의 근거도 적혀 있다 — "Raising a broker's frame limit to carry large payloads trades a bounded, testable failure for an unbounded one: it degrades broker memory, replication latency, and consumer recovery all at once."
`PayloadPolicy.DEFAULT_MAX_BYTES`는 이 저장소에서 1 MiB 상한을 선언하는 다섯 곳 중 하나이고 **정책 축의 자연스러운 주인**이다. 그런데 starter는 이것 대신 `JacksonMessageCodec.DEFAULT_MAX_BYTES`를 참조한다 — `analysis/messaging/messaging-schema-json.md` §17이 소유한다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. 브로커·DB·파일시스템을 만지지 않는다. `ThreadLocalRandom`(jitter)과 `Semaphore`가 유일한 런타임 자원이다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-policy:test`. **BUILD SUCCESSFUL, 42 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `DestinationProfileValidatorTest` | 13 | 순서/페이로드/DLQ 자기참조/키 리졸버/M1 수동정산/확인/토폴로지/DLQ 필요, **retry↔DLQ 교대 사이클 거절**, **다이아몬드 허용**, 미등록 목적지 거절 | 실제 부팅에서 이 검증이 호출되는지(→ starter가 부른다, §2) |
| `MessagingAdmissionControllerTest` | 13 | permit 점유/반납, 초과 시 큐잉 대신 거절, backpressure가 retryable, 초과 payload가 permit을 안 먹음, 종료 시 기존 permit 유지, 불균형 반납이 천장을 못 올림, 한 목적지가 전부 못 먹음, 거절이 슬롯을 안 남김, 완료가 둘 다 반납, 미지 목적지 완료가 permit을 안 품, 이중 완료, 배치 두 축, 대기 후 승인 | 실제 부하에서의 공정성 |
| `RetryDecisionEngineTest` | 10 | 역직렬화 실패 즉시 파킹, 인증/구성 실패 미재시도, 순서 Kafka는 pause, 소진은 DLQ, 비순서 재시도목적지 재발행, blocking은 inline, **지수 증가와 상한**, **full jitter 분포**, 프로파일 오버라이드, at-most-once DLQ 없으면 discard | **이 엔진이 production에서 호출되는지** |
| `DeadLetterOrchestratorTest` | 6 | 확인 후에만 원본 정산, 모호하면 미정산, 거절되면 미정산, 헤더 부착 | **이 orchestrator가 production에서 호출되는지** |
**두 축의 증명 성격이 다르다.** 검증기와 관문은 배선까지 확인되지만(§2), 재시도 엔진과 DLQ 조정자는 로직만 증명되고 배선은 §12.1이 부정한다. 테스트가 통과한다는 것이 그 코드가 실행된다는 뜻이 아닌 전형적인 예다.
`MessagingAdmissionControllerTest``as(...)` 문구들이 특히 구체적이다 — "a slot leaked per refusal shrinks the destination's ceiling until it is zero", "a permit nobody took cannot be given back; doing so makes the ceiling fiction". 각 테스트가 어떤 이전 결함을 붙들고 있는지 이름 자체가 말한다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| vendor `api` 규칙 | 벤더 의존성 0 |
| **부팅 검증** | `MessagingCoreAutoConfiguration:134``validateAll`을 호출 — 이 leaf의 규칙이 실제로 부팅을 막는 유일한 지점 |
| ArchUnit | 전용 규칙 없음 |
§4.1의 15가지 규칙은 **ArchUnit이 아니라 런타임 시작 시점**에 강제된다. `verifyCleanArchitectureDependencies`가 빌드 타임에 도는 것과 대비된다. 잘못된 프로파일은 컴파일되고, 부팅에서 막힌다.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/281-messaging-policy-retry-engine-unwired.txt`.
> **방법 주의.** 이 절의 조립 판정은 `new ([a-zA-Z0-9_.]+\.)?<Type>\s*\(` 패턴으로 재확인한 것이다. 처음에 `new <Type>(`로만 검색해 **오탐**을 냈다 — 이 저장소는 `new dev.caskeleton.messaging.runtime.TransportMessagingRuntime(`처럼 정규화된 이름으로 생성하는 곳이 있고, 그 패턴은 그것을 놓친다. 아래 결과는 전부 수정된 패턴의 것이다.
### 12.1 Public surface reachability
leaf 밖 참조가 0인 것은 둘이고 성격이 다르다.
| 타입 | leaf 밖 | 판정 |
|---|---:|---|
| `DeadLetterEnvelopeFactory` | 0 | **내부 협력자**`DeadLetterOrchestrator`가 쓴다. 문제 아님 |
| `DeadLetterMetadata` | 0 | 같음 |
나머지 24개는 전부 외부 참조가 있다. `DestinationProfile` 43파일, `RetryDecision` 23, `RetryContext` 18, `SchemaPolicy` 17, `PayloadPolicy` 15, `PhysicalDestination` 13.
**참조 수는 이 leaf에서 오해를 낳는다.** 참조가 있어도 실행되지 않을 수 있고, 여기가 정확히 그렇다.
**(a) `RetryDecisionEngine` bean은 만들어지고 아무 데도 주입되지 않는다**
```java
// MessagingCoreAutoConfiguration.java:165-169
@Bean
@ConditionalOnMissingBean
public RetryDecisionEngine retryDecisionEngine() {
return new DefaultRetryDecisionEngine(new BackoffCalculator());
}
```
이 타입을 받는 코드는 저장소 전체에서 **하나**다 — `KafkaRetryExecutor`의 필드와 생성자 인자(`KafkaRetryExecutor.java:32,46`).
그리고 `KafkaRetryExecutor`**한 번도 생성되지 않는다.**
```
## D. is each of those dependents ever constructed?
KafkaRetryExecutor NEVER CONSTRUCTED
```
즉 5개 `@Bean` 설정 클래스가 만드는 51개 bean 중 어느 것도 `RetryDecisionEngine`을 인자로 받지 않는다. bean은 매 시작마다 생성되고 컨텍스트에 앉아 있다.
**(b) `DeadLetterOrchestrator` bean도 같다**
```java
// :177-181
@Bean
@ConditionalOnMissingBean
public DeadLetterOrchestrator deadLetterOrchestrator(MessagePublisher publisher) {
return new DeadLetterOrchestrator(publisher);
}
```
이 타입을 받는 production 코드는 둘 — `KafkaDeadLetterPublisher`(:29)와 `RabbitDeadLetterPublisher`(:47). 둘 다 **NEVER CONSTRUCTED**.
**(c) 왜 그런가 — 소비 경로 전체에 production 조립이 없다**
```
## F. control: the consume path is constructed only in tests
KafkaConsumerRegistrar src/main=0 src/test=4
RabbitConsumerRegistrar src/main=0 src/test=1
KafkaBatchConsumerRegistrar src/main=0 src/test=0
RabbitBatchConsumerRegistrar src/main=0 src/test=1
DefaultDeliveryProcessor src/main=0 src/test=1
```
대조군으로 발행 경로를 같은 패턴으로 확인하면 전부 production에서 생성된다.
```
## E. control: the publish path IS constructed in production
DefaultMessagePublisher MessagingCoreAutoConfiguration.java:446
TransportMessagingRuntime MessagingCoreAutoConfiguration.java:476
DefaultRetryDecisionEngine MessagingCoreAutoConfiguration.java:168
DeadLetterOrchestrator MessagingCoreAutoConfiguration.java:180
```
**즉 출하 컨텍스트는 발행할 수 있고 소비할 수 없다.** 재시도와 DLQ는 소비 경로에만 존재하는 개념이므로, 이 leaf의 두 축이 배선되지 않은 것은 그 결과다.
이 사실은 `analysis/messaging/messaging-core-api.md` §12.1이 관측한 것 — `MessageHandler<T>`의 저장소 참조 0 — 에 조립 쪽 설명을 준다. 핸들러를 받을 소비자 런타임이 조립되지 않으므로 핸들러 계약에 소비자가 없다.
**(d) `RetryDecision`을 실제로 실행하는 코드는 하나뿐이다**
```
## G. every file that acts on a RetryDecision variant
messaging-kafka/.../KafkaRetryExecutor.java (생성되지 않음)
messaging-policy/.../DefaultRetryDecisionEngine.java (생산자)
messaging-policy/.../RetryDecision.java (선언)
messaging-policy/.../RetryDecisionEngineTest.java (테스트)
```
`messaging-rabbit`은 production 코드에서 `RetryDecision`·`RetryDecisionEngine`·`BackoffCalculator`·`RetryPolicy`를 전혀 참조하지 않는다(테스트 fixture 한 곳 제외). Rabbit에는 `RabbitRetryQueueTopology`가 있는데 그것은 **토폴로지 서술**(TTL 큐 + DLX)이고 `RetryDecision`을 소비하지 않는다. Pulsar·NATS도 0이다.
즉 브로커 중립 재시도 엔진의 실행자가 저장소에 **한 브로커 분량**만 있고, 그마저 조립되지 않았다.
**(e) 배선된 축은 확실히 배선됐다**
- `DestinationProfileValidator``MessagingCoreAutoConfiguration:134`에서 `validateAll(registered)` 호출. 부팅을 실제로 막는다.
- `MessagingAdmissionController``DefaultMessagePublisher`(발행 관문)·`MessagingEndpoint`(관측)·`MessagingShutdownLifecycle`(종료 1단계) 셋이 주입받는다.
- `PayloadLimitGuard`·`InFlightLimiter`·`PayloadPolicy` → admission controller 안에서 실행된다.
**한계.** 정적 `git grep`이다. 리플렉션·`ObjectProvider` 지연 조회·`@Autowired` 필드 주입은 덮지 못한다. 다만 이 저장소의 messaging 자동설정은 전부 생성자 주입 `@Bean` 메서드이고(51개 전수 확인), `ObjectProvider``MessageContracts``MessagingTransport` 두 곳에만 쓰인다.
### 12.2 Conditional sibling comparison
이 leaf에는 bean이 없다. 그러나 **starter 쪽 sibling 비교가 결정적이다.**
`MessagingCoreAutoConfiguration`의 27개 `@Bean` 중 이 leaf의 타입을 만드는 것은 셋이고, 조건이 전부 같다(`@ConditionalOnMissingBean`).
| bean | 조건 | 주입처 |
|---|---|---|
| `DestinationProfileValidator` | `@ConditionalOnMissingBean` | (직접 호출도 있음, :134) |
| `MessagingAdmissionController` | `@ConditionalOnMissingBean` | **3곳** |
| `RetryDecisionEngine` | `@ConditionalOnMissingBean` | **0곳** |
| `DeadLetterOrchestrator` | `@ConditionalOnMissingBean` | **0곳** |
**조건은 같고 결과가 다르다.** 활성화 비대칭이 아니라 **소비 비대칭**이다 — 넷 다 똑같이 만들어지고 둘만 쓰인다. `@ConditionalOnMissingBean`은 "이미 있으면 만들지 마라"를 뜻할 뿐 "쓰이는지"를 말하지 않는다.
### 12.3 Duplicate mechanism sweep
**(a) 재시도 메커니즘이 둘이고, 정교한 쪽이 배선되지 않았다**
| | `messaging-policy` | `messaging-runtime-core` |
|---|---|---|
| 구현 | `DefaultRetryDecisionEngine` | `DefaultDeliveryProcessor` |
| 입력 | `RetryContext`(프로파일 + 전달 메타 + 실패 + capability) | `HandleResult` |
| 재시도 판단 | 6개 모드, 8단 우선순위 | `Retry` → 무조건 requeue |
| 지연 | `BackoffCalculator` — 지수 + full jitter + 상한 | 생성자로 받은 **고정 `retryDelay`** |
| 시도 횟수 | `attempt >= maxAttempts` 확인 | **확인하지 않음** |
| 순서 인식 | `orderingImpact`·`isOrdered()`·`capabilities` | 없음 |
| DLQ | 5개 결정 중 하나 | `DeadLetter` → 발행 후 확인되면 ack |
| **production 조립** | **없음** | **없음**(테스트만) |
둘 다 조립되지 않았으므로 오늘 경쟁하지 않는다. 그러나 소비 경로를 배선하려는 사람은 **두 개의 서로 다른 재시도 의미론** 중 하나를 골라야 하고, 어느 쪽이 정본인지 코드가 말하지 않는다. `DefaultDeliveryProcessor`의 javadoc은 자기가 "the platform decides when and in what order the settlement happens"를 실현한다고 말하고, `DefaultRetryDecisionEngine`의 javadoc은 자기 순서가 "fixed and evaluated top to bottom"이라고 말한다.
**(b) DLQ 경로가 둘**
| | `messaging-policy` | `messaging-runtime-core` |
|---|---|---|
| 구현 | `DeadLetterOrchestrator` | `DefaultDeliveryProcessor``DeadLetterPublisher` 함수형 인터페이스 |
| 순서 보장 | 확인 후 정산 (명시) | 확인 후 ack, 미확인이면 requeue (명시) |
| 헤더 | 6개 예약 헤더 부착 | **부착하지 않음** |
| 결과 | `DeadLetterResult(publishResult, sourceSettled)` | `SettlementResult` |
같은 불변식(확인 전 정산 금지)을 두 곳이 각자 구현한다. 그리고 **한쪽만 실패 컨텍스트를 헤더에 남긴다**`DefaultDeliveryProcessor` 경로로 DLQ된 메시지는 왜 거기 있는지 알 수 없다.
**(c) 1 MiB 상한** — `PayloadPolicy.DEFAULT_MAX_BYTES`가 이 저장소 다섯 곳 중 정책 축의 주인인데 starter가 참조하지 않는다. `analysis/messaging/messaging-schema-json.md` §17이 소유한다.
**(d) 프로파일 검증기가 브로커별로 또 있다**
`RabbitProfileValidator`, `KafkaProfileValidator`, `KafkaTransactionProfileValidator`가 각 어댑터 leaf에 있고 starter가 bean으로 만든다. 이들은 **브로커 고유 제약**(exchange/queue 조합, 트랜잭션 설정)을 보므로 `DestinationProfileValidator`의 브로커 중립 규칙과 책임이 다르다. 중복이 아니라 계층이다. 다만 호출 순서가 어디에도 명시되지 않았다 — 중립 검증이 먼저인지 브로커 검증이 먼저인지는 starter leaf가 답한다.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `DestinationProfileValidator` javadoc: 모순은 부팅 실패 | `:134`에서 `validateAll` 호출 확인 | **일치** |
| `MessagingAdmissionController` javadoc: "The single gate every publish passes" | `DefaultMessagePublisher`가 주입받아 호출 | **일치** |
| `PhysicalDestination` javadoc: 물리 주소를 여기서만 보관 | leaf 밖 13파일이 참조하나 전부 `PhysicalDestination` 타입 경유 | **일치** |
| `RetryPolicy` javadoc: 자동 재시도는 opt-in | `none()``maxAttempts=1, mode=NONE` | **일치** |
| `InFlightLimiter` javadoc: "Section 40.3 of the design specifies…" | 그 설계 문서를 이 저장소에서 찾지 못함 | **미확인** — 아래 참조 |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
**`InFlightLimiter`의 "Section 40.3"이 가리키는 문서를 찾지 못했다.** `docs/messaging/` 아래 10개 파일과 `docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md`에 절 번호 40.3이 없다. 저장소 밖 설계 문서이거나 이전 버전의 흔적이다. 인용된 문구("bounded wait, then `MessageBackpressureException`")는 코드와 일치하므로 내용 drift는 아니고, **참조가 해소되지 않는다**는 것이 관측이다.
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
이 leaf의 주석은 이전 결함보다 **왜 이 형태여야 하는가**를 더 많이 적는다. 그중 이전 상태를 직접 서술하는 것은 셋이다.
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `validateAll` 주석 | retry 그래프와 DLQ 그래프를 따로 순회 | A의 retry가 B를, B의 DLQ가 A를 가리키는 교대 사이클을 둘 다 통과시킴 → poison 메시지가 두 목적지 사이를 영원히 순환 |
| `admit``InterruptedException` 주석 | 인터럽트 시 목적지 슬롯 미반납 | 인터럽트마다 슬롯이 새서 목적지 천장이 0까지 줄어듦 |
| `complete` 주석 | 미보유 목적지에도 프로세스 permit 반납 | 아무도 안 가져간 permit을 돌려줘 전역 천장이 실제 in-flight보다 낮게 읽힘 → 감당 못 할 만큼 승인 |
| `release` 주석 | 0인 카운터를 맵에 잔류 | 발행한 적 있는 모든 목적지의 카운터가 프로세스 수명 동안 누적 |
| `InFlightLimiter.release` 주석 | 불균형 반납 허용 | 천장이 조용히 올라가 limiter가 아무것도 제한하지 않음 |
세 번째와 다섯 번째가 같은 형태다 — **반납이 획득보다 많으면 제한이 사라진다.** `messaging-transport-spi``GracefulShutdownCoordinator.endWork` clamp와 `DefaultMessagingRuntimeRegistry`의 "정확히 한 번 close"도 같은 계열이고, 그 leaf §13이 소유한다. 저장소 전체에서 반복되는 주제다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-281 | command | `evidence/raw/281-messaging-policy-retry-engine-unwired.txt` | 26개 타입 참조 수, 두 bean의 선언, 그 두 타입을 받는 코드 전수, 해당 dependent가 NEVER CONSTRUCTED, 발행 경로 대조군, 소비 경로 src/main=0, `RetryDecision` 실행자 목록, 호출되는 시작 게이트 | 정적 `git grep`. 리플렉션·지연 조회 미포함. **정규화된 생성자 이름을 포함하는 패턴으로 재실행한 결과** |
| EVD-282 | command | `./gradlew :messaging:messaging-policy:test --rerun-tasks` | BUILD SUCCESSFUL, 42 / 0 / 0 | 순수 단위. 브로커·Spring 컨텍스트 없음 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 모순을 부팅 실패로 옮기는 이유 — `DestinationProfileValidator` javadoc
- 두 간선을 한 그래프로 순회하는 이유와 다이아몬드 오탐 방지 — `validateAll`/`walk` 주석
- payload 검사가 permit 획득보다 먼저인 이유 — `MessagingAdmissionController` javadoc
- 천장이 둘인 이유 — 같은 javadoc
- 거절이 모호하지 않은 이유 — 같은 javadoc
- 세 가지 누수 방지 각각의 이유 — 세 개의 인라인 주석
- fair semaphore와 불균형 반납 방지 — `InFlightLimiter` 주석
- 재시도 판단 순서가 고정된 이유 — `DefaultRetryDecisionEngine` javadoc
- capability가 입력인 이유 — `RetryContext` javadoc
- 자동 재시도가 opt-in인 이유 — `RetryPolicy` javadoc
- full jitter를 쓰는 이유 — `BackoffCalculator` javadoc
- DLQ 발행 후 정산 순서와 그 trade — `DeadLetterOrchestrator` javadoc
- DLQ 메타데이터를 작게 두는 이유 — `DeadLetterMetadata` javadoc
- 물리 주소를 이 leaf에 가두는 이유 — `PhysicalDestination` javadoc
- Pulsar 구독명·NATS 스트림이 주소의 일부인 이유 — 두 factory javadoc
**추론**
- 재시도 엔진과 DLQ 조정자가 미배선인 것은 소비 경로 전체에 조립이 없기 때문이다 → **추론**. 조립 부재는 관측이고 인과는 추론이다. 커밋 메시지나 ADR에 소비 경로를 나중으로 미룬 기록이 없다.
- `firstFailureAt``lastFailureAt`을 같은 값으로 채우는 것이 임시인지 → **미상**.
- 브로커별 검증기와 중립 검증기의 호출 순서 → **미상**(starter leaf가 소유).
**관측했으나 원인을 모름**
- `InFlightLimiter` javadoc이 인용하는 "Section 40.3"의 출처
- `MessagingConfigurationException`이 존재하는데 프로파일 검증이 `IllegalArgumentException`을 쓰는 이유
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 26개 타입 1,738줄 전문의 계약과 불변식
- 42개 테스트가 통과하고 무엇을 단언하는지
- 다섯 축 중 셋(목적지 정의·시작 검증·발행 관문)이 출하 컨텍스트에서 실제로 실행된다는 것과 그 정확한 배선 지점
- 두 축(재시도 판단·DLQ 조정)이 bean으로 생성되고 주입처가 0이라는 것 — 그리고 그 이유가 소비 경로 전체의 조립 부재라는 것
- `RetryDecision`을 실행하는 코드가 저장소에 하나뿐이며 그것이 생성되지 않는다는 것
- 재시도와 DLQ 각각에 대해 두 개의 서로 다른 구현이 존재한다는 것
**확인하지 못한 것**
- **소비 경로를 배선할 계획이 있는지.** 저장소 안에 답이 없다. 두 재시도 구현 중 어느 쪽이 정본인지도 이 미지수에 걸린다.
- 실제 부팅에서 `validateAll`이 어떤 프로파일 집합을 받는지 — `ValidatedDestinationRegistry`가 무엇을 채우는지는 starter leaf가 소유한다.
- `walk`의 지수적 복사 비용이 실제 구성에서 문제가 되는 규모. 목적지 수가 큰 배포를 관측하지 못했다.
- `InFlightLimiter`의 fair semaphore가 실제 부하에서 주는 처리량 손실.
- "Section 40.3"이 가리키는 문서.
---
## 17. 손볼 것
### P2 — 재시도 엔진과 DLQ 조정자가 bean으로 만들어지고 주입되는 곳이 없다
- **사실.** `MessagingCoreAutoConfiguration``RetryDecisionEngine`(:167)과 `DeadLetterOrchestrator`(:179)를 `@Bean @ConditionalOnMissingBean`으로 만든다. 두 타입을 받는 production 코드는 각각 `KafkaRetryExecutor``KafkaDeadLetterPublisher`/`RabbitDeadLetterPublisher`뿐이고, **셋 다 저장소 어디에서도 생성되지 않는다.** 같은 설정의 51개 bean 중 두 타입을 인자로 받는 `@Bean` 메서드가 없다.
- **근거.** `evidence/raw/281` §B·§C·§D.
- **왜 문제인가.** 컨텍스트에 두 bean이 앉아 있고 `MessagingAutoConfigurationTest`류의 `hasSingleBean` 검사는 통과한다 — 즉 **bean 존재 검사가 배선을 증명하지 않는다.** 그리고 이 leaf가 가장 공들인 두 축(6개 재시도 모드·8단 판단 순서·full jitter·capability 인식, DLQ 발행-후-정산 불변식·예약 헤더 6개)이 실행되지 않는다. 42개 테스트 중 16개가 이 두 축을 검증한다.
- **확인 방법.** `git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?KafkaRetryExecutor\s*\(' -- src` → 매치 없음. `evidence/raw/281` §D 재실행.
- **후보.** (a) 소비 경로를 조립한다(§17 다음 항목과 같은 작업). (b) 배선되기 전까지 두 bean을 만들지 않는다 — `@ConditionalOnBean`으로 실제 소비자에 매단다. (c) 미완임을 `support-matrix.md`에 표시한다.
- **다음 단계.** **CASE 후보.** 재현이 정적이고 결론이 닫힌다. "bean이 있다"와 "배선됐다"의 구분이 그대로 **REFERENCE 후보**이기도 하다.
### P2 — 출하 컨텍스트가 발행은 하고 소비는 하지 못한다
- **사실.** `KafkaConsumerRegistrar`·`RabbitConsumerRegistrar`·`KafkaBatchConsumerRegistrar`·`RabbitBatchConsumerRegistrar`·`DefaultDeliveryProcessor`·`KafkaRetryExecutor`·`KafkaDeadLetterPublisher`·`RabbitDeadLetterPublisher`가 전부 `src/main` 생성 0이다. 대조군인 발행 경로(`DefaultMessagePublisher`·`TransportMessagingRuntime`)는 `MessagingCoreAutoConfiguration:446,476`에서 생성된다.
- **근거.** `evidence/raw/281` §E·§F.
- **왜 문제인가.** `messaging-policy`의 두 축이 미배선인 근본 원인이고, `analysis/messaging/messaging-core-api.md` §12.1이 관측한 `MessageHandler<T>` 참조 0의 조립 쪽 설명이다. 그리고 `docs/messaging/support-matrix.md`의 브로커 등급표가 소비 측 보장(순서·정산·재시도)을 서술하는데, 그 보장을 수행할 코드가 조립되지 않는다.
- **확인 방법.** `evidence/raw/281` §F 재실행.
- **후보.** 소비자 등록을 자동설정에 추가하거나, 소비 경로가 파생 프로젝트의 조립 책임임을 문서화한다.
- **다음 단계.** **이 leaf가 아니라 cross-scope 또는 `messaging-spring-boot-starter` leaf가 소유해야 한다.** 여기서는 관측과 교차 참조만 남긴다. **OPEN QUESTION 후보**(소비 경로 조립이 미완인가, 의도적 확장점인가).
### P3 — 재시도와 DLQ 각각에 두 개의 구현이 있고 정본이 표시되지 않았다
- **사실.** 재시도: `DefaultRetryDecisionEngine`(6모드·백오프·순서 인식) vs `DefaultDeliveryProcessor`(고정 지연·시도 횟수 미확인). DLQ: `DeadLetterOrchestrator`(예약 헤더 6개 부착) vs `DefaultDeliveryProcessor.DeadLetterPublisher`(헤더 없음). 둘 다 조립되지 않았다.
- **근거.** §12.3(a)(b). `DefaultDeliveryProcessor.java:38-99`.
- **왜 문제인가.** 오늘 경쟁하지 않지만, 소비 경로를 배선하는 사람이 둘 중 하나를 고르게 되고 코드가 어느 쪽이 정본인지 말하지 않는다. 두 javadoc이 각각 자기가 플랫폼 규칙의 구현이라고 서술한다. 그리고 선택 결과가 다르다 — `DefaultDeliveryProcessor` 경로로 DLQ된 메시지에는 실패 카테고리·코드·원본 목적지·시도 횟수가 붙지 않는다.
- **확인 방법.** 두 클래스의 javadoc과 분기 대조.
- **후보.** `DefaultDeliveryProcessor``RetryDecisionEngine``DeadLetterOrchestrator`를 위임받도록 합치거나, 한쪽을 제거한다.
- **다음 단계.** **CASE 후보**(같은 책임의 두 구현이 서로를 모른다). `messaging-runtime-core` leaf SSOT와 공동 소유.
### P3 — DLQ 메타데이터의 두 시각이 항상 같다
- **사실.** `DeadLetterMetadata``firstFailureAt``lastFailureAt`을 별도 필드로 선언하는데, 유일한 생산 지점인 `DeadLetterOrchestrator:89-97`이 둘 다 `delivery.metadata().receivedAt()`으로 채운다.
- **근거.** 해당 라인.
- **왜 문제인가.** 두 헤더(`msg.first-failure-at`, `msg.last-failure-at`)가 DLQ 메시지에 붙는데 항상 같은 값이다. 운영자가 "이 메시지가 얼마나 오래 실패해 왔는가"를 헤더에서 알 수 없다. `ReservedHeaders`가 두 이름을 따로 정의한 목적이 실현되지 않는다.
- **확인 방법.** `DeadLetterOrchestrator.java:89` 확인.
- **후보.** 이전 시도의 `msg.first-failure-at` 헤더가 있으면 그것을 이어받는다.
- **다음 단계.** **CASE 후보.** 단, §17 첫 항목대로 이 코드는 실행되지 않으므로 오늘의 사고가 아니다.
### P3 — 사이클 검사가 경로마다 집합을 복사한다
- **사실.** `walk`가 각 분기마다 `new LinkedHashSet<>(onPath)``new ArrayList<>(path)`를 만든다. 비용이 경로 수에 비례하고, 경로 수는 분기 계수에 지수적이다.
- **근거.** `DestinationProfileValidator.java:196-198`.
- **왜 문제인가.** 정상 구성(목적지 수십 개, 목적지당 간선 0–2개)에서는 무해하다. 다만 이 성질이 어디에도 기록되지 않았고, `validateAll`은 **부팅 경로**다. 목적지가 수백 개인 배포에서 부팅이 느려지면 원인을 찾기 어렵다.
- **확인 방법.** 코드 검토. 목적지 수를 늘려가며 `validateAll` 시간을 측정.
- **후보.** 방문 상태를 색칠(white/gray/black)로 바꾸면 복사 없이 O(V+E)가 된다.
- **다음 단계.** **REFERENCE 후보**(부팅 경로의 알고리즘 복잡도는 문서화한다).
### P3 — 프로파일 검증 실패가 플랫폼 예외 계층 밖이다
- **사실.** `DestinationProfileValidator`의 16개 거절이 전부 `IllegalArgumentException`이다. `MessagingConfigurationException`이 존재하고 그 javadoc이 "Raised at startup wherever possible"이라고 적는다.
- **근거.** `DestinationProfileValidator` 전문, `MessagingConfigurationException` javadoc.
- **왜 문제인가.** 부팅 실패이므로 실무 영향은 낮다. 다만 `FailureDescriptor`가 없어 코드·카테고리가 붙지 않고, 같은 leaf의 `DeadLetterOrchestrator``MessagingConfigurationException("DEAD_LETTER_NOT_CONFIGURED")`을 쓴다 — 같은 leaf 안에서 구성 오류를 두 방식으로 보고한다.
- **확인 방법.** 두 클래스의 throw 문 대조.
- **후보.** 검증 실패를 `MessagingConfigurationException`으로 통일하고 규칙별 안정 코드를 준다.
- **다음 단계.** **REFERENCE 후보**(구성 오류는 한 예외 타입과 안정 코드로 보고한다).
### P3 — javadoc이 해소되지 않는 설계 문서를 인용한다
- **사실.** `InFlightLimiter` javadoc이 "Section 40.3 of the design specifies 'bounded wait, then `MessageBackpressureException`'"이라고 적는다. 그 절 번호를 가진 문서를 이 저장소에서 찾지 못했다.
- **근거.** `InFlightLimiter.java:11-13`. `docs/messaging/*.md` 10개와 계획 문서에 절 40.3 없음.
- **왜 문제인가.** 인용된 내용은 코드와 일치하므로 내용 drift는 아니다. 다만 근거를 확인하려는 사람이 도달할 수 없다.
- **확인 방법.** `git grep -n '40\.3' -- docs`
- **후보.** 참조를 실제 문서로 바꾸거나 인용만 남기고 절 번호를 뺀다.
- **다음 단계.** **REFERENCE 후보**(저장소 밖 문서를 절 번호로 인용하지 않는다).
### 확인된 설계(문제 아님)
- 모순을 부팅 실패로 옮기는 16가지 규칙과, 그것이 실제로 시작 시 호출된다는 것
- retry와 DLQ 간선을 하나의 그래프로 순회하고 다이아몬드를 오탐하지 않는 것
- payload 검사를 permit 획득보다 먼저 두는 것
- 두 개의 천장과 세 가지 슬롯 누수 방지
- fair semaphore와 불균형 반납 차단
- capability를 재시도 판단의 입력으로 두어 수행 불가능한 전략을 고르지 않는 것
- 모든 기본값이 보수적인 것(재시도 없음·동시성 1·순서 보존·확인 최대)
- DLQ 발행이 확인되기 전에는 원본을 정산하지 않는 것과 그 trade를 명시한 것
- DLQ 헤더에 `ReservedHeaders` 상수를 쓰고 `MessageHeaders.platform`을 쓰는 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MPO-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 2개, memberships `["app-bootstrap"]` | 선언 |
| MPO-002 | build | `messaging-policy/build.gradle` | same | 벤더 의존성 0 | — |
| MPO-003 | code | `.../policy/DestinationProfileValidator.java` 전문 | same | §4.1 16규칙, §4.2 이중 간선 그래프 | 복잡도 미문서화(§17) |
| MPO-004 | code | `.../policy/MessagingAdmissionController.java` 전문 | same | §4.3 순서·두 천장·세 누수 방지 | — |
| MPO-005 | code | `.../policy/InFlightLimiter.java` | same | fair semaphore, 불균형 반납 차단 | "Section 40.3" 미해소 |
| MPO-006 | code | `.../policy/DefaultRetryDecisionEngine.java` | same | §4.4 8단 판단 순서, capability 입력 | production 호출 없음(§12.1) |
| MPO-007 | code | `.../policy/{RetryPolicy,RetryMode,RetryDecision,RetryContext,BackoffCalculator,OrderingImpact}.java` | same | 재시도 어휘 전체 | — |
| MPO-008 | code | `.../policy/DeadLetterOrchestrator.java` | same | §4.7 발행-후-정산 불변식 | production 호출 없음(§12.1) |
| MPO-009 | code | `.../policy/{DeadLetterEnvelopeFactory,DeadLetterMetadata,DeadLetterPolicy,DeadLetterResult,SourceSettlement}.java` | same | DLQ 봉투와 메타데이터 | 두 시각이 항상 같음(§17) |
| MPO-010 | code | `.../policy/{DestinationProfile,PhysicalDestination,SchemaPolicy,ProducerPolicy,ConsumerPolicy,PayloadPolicy,CapabilityTier}.java` | same | 목적지 정의 8타입과 기본값 | — |
| MPO-011 | test | `DestinationProfileValidatorTest` (13) | same | 규칙별 거절, 교대 사이클, 다이아몬드 | — |
| MPO-012 | test | `MessagingAdmissionControllerTest` (13) | same | 관문 동작 전수 | 실부하 아님 |
| MPO-013 | test | `RetryDecisionEngineTest` (10) | same | 판단 순서와 백오프/지터 | 배선 미증명 |
| MPO-014 | test | `DeadLetterOrchestratorTest` (6) | same | 정산 순서 불변식 | 배선 미증명 |
| MPO-015 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:134,145,167,179,407,446,476` | same | 배선된 것과 만들어지기만 한 것 | 해당 leaf SSOT가 소유 |
| MPO-016 | cross-leaf code | `messaging-kafka/.../KafkaRetryExecutor.java` | same | `RetryDecision`의 유일한 실행자 | 생성되지 않음 |
| MPO-017 | cross-leaf code | `messaging-runtime-core/.../DefaultDeliveryProcessor.java` | same | 경쟁하는 재시도/DLQ 구현 | 해당 leaf SSOT가 소유 |
| EVD-281 | command | `evidence/raw/281-messaging-policy-retry-engine-unwired.txt` | same | §12.1 전부 | 정적 검색. 정규화 생성자 패턴 사용 |
| EVD-282 | command | `./gradlew :messaging:messaging-policy:test --rerun-tasks` | same | 42 / 0 / 0 | 순수 단위 |
@@ -0,0 +1,296 @@
# messaging-pulsar-experimental 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 8파일 663줄, test 2파일 414줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-pulsar-experimental`
> SSOT owner: `messaging-pulsar-experimental`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- 선언 의존: messaging 계열 project 7 + vendor `pulsar-client:4.0.3`
- `runtime_memberships`: **`[]`** — build-only · 등급 EXPERIMENTAL
| 파일 | LOC |
|---|---:|
| `PulsarMessagingTransport` | 275 |
| `PulsarProfile` | 80 |
| `PulsarProfileValidator` | 66 |
| `PulsarPreSendRejection` | 65 |
| `PulsarSubscriptionMode` | 62 |
| `PulsarTransactionCapability` · `PulsarMessagePosition` | 49 · 49 |
| `PulsarSubscriptionType` | 17 |
| **main 합계** | **663** |
| `PulsarAdapterContractTest` · `PulsarSubscriptionGuardTest` | 289 · 125 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 8 | `FULL_READ` | 663줄 전 본문 |
| `test/java/**` | 2 | `FULL_READ` | 414줄 전 본문 · 테스트 27개 |
| `build.gradle` | 1 | `FULL_READ` | 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 이 어댑터가 무엇이고 무엇이 아닌가
> "This is an Experimental contract seam, not a Stable adapter. It exercises the transport SPI
> against a send operation the application supplies; it does not ship a Pulsar client bridge,
> producer lifecycle, or reconnection."
전송은 `PulsarSendOperation` 함수형 인터페이스로 주입된다 — 브로커 없이 검증 가능하게 만든 격리다.
## 2. 실패 분류 — 타입 있는 신호만 본다
```java
if (cause instanceof PulsarPreSendRejection rejection) REJECTED (CONFIGURATION)
boolean timedOut = cause instanceof TimeoutException;
나머지 전부 AMBIGUOUS (TRANSIENT_INFRASTRUCTURE)
```
javadoc 이 이전 구현과 그 결함을 적는다.
> "Classification used to read the exception's class simple name: `"Timeout"` meant ambiguous,
> anything else meant rejected. A class name is not part of Pulsar's contract — it changes between
> client versions — and defaulting the unknown case to `REJECTED` tells the caller nothing was
> transmitted, which is how the same entry is published to the bookies twice."
기본값이 모호로 바뀐 것이 핵심이다. 알 수 없는 실패에서 안전한 방향은 모호다.
확인된 성공은 복제 증거로 기록된다 — 전송 미래가 설정된 수의 저장 노드에 기록된 뒤에야 해소되므로 영수증이 아니라 복제 증거다.
## 3. 호출자의 마감을 존중한다
```java
send.send(profile.topic(), request).toCompletableFuture()
.orTimeout(request.options().timeout().toMillis(), MILLISECONDS)
```
주석이 이유를 적는다 — 멈춘 전송이 호출자가 요청한 마감이 아니라 SDK 기본값만큼 호출자를 붙들고 있었다.
## 4. 구독 형태가 보장을 결정한다
`PulsarSubscriptionMode` 가 구독 종류와 확인 방식을 함께 묶고 두 조합을 생성자에서 거부한다.
> "A `Key_Shared` subscription with cumulative acknowledgement is not keyed ordering with a faster
> ack — cumulative ack over interleaved keys acknowledges messages from keys the consumer has not
> finished, so the combination silently loses the property the subscription type was chosen for."
그리고 검증기가 목적지의 순서 범위와 구독 종류의 합의를 요구한다. 목적지 전체 순서는 아예 거부한다.
## 5. 트랜잭션은 주석이 아니라 클래스로 거절한다
> "Pulsar has transactions. The platform does not offer them, and the distinction matters enough to
> be a class rather than a comment: an operator reading the capability matrix needs to know the
> answer is 'not proven here', not 'the broker cannot do it'."
그리고 거절을 던지지 않고 값으로 돌려준다 — 호출부에서 `throw` 가 보이게 하기 위해서다.
## 10. 테스트 레인
두 테스트 414줄 · 27개.
`PulsarAdapterContractTest` 14개 — 복제 증거로서의 확인, 위치 반환, 시간 초과의 모호, 타입 있는 사전 거절만이 `NOT_TRANSMITTED`, 미인식 실패의 모호, 감싸인 실패의 모호, 호출자 마감, 적재물 상한, 닫힘, `register` 인자 검사, 능력 세 개.
`PulsarSubscriptionGuardTest` 13개 — 누적 확인 조합 거부 둘, 순서 범위 둘, 영 지연 거부, 확인 시간 초과 하한, 기본 프로파일이 확인 시간 초과를 끄는 것, 트랜잭션 미승격 둘, 위치 렌더링 셋, 그리고 §17.3 이 다루는 마지막 하나.
전송은 `(topic, request) -> CompletionStage<PulsarMessagePosition>` 람다로 주입된다. 성공·실패·영영 안 끝남을 테스트가 직접 만든다.
**레인에 없는 것 둘.** `orderedStream()` 을 확인하는 단언이 하나도 없다 — §17.1 의 어긋남이 살아남은 자리다. 그리고 `register(spec)` 를 실제 spec 으로 부르는 테스트가 없어서, 기본 소비자 팩토리가 던지는 `PULSAR_CONSUMER_NOT_CONFIGURED` 는 한 번도 실행되지 않는다(§17.3).
## 12. negative-space probes
**12.1 도달성.** build-only · experimental. `PulsarMessagingTransport` 는 자기 테스트에서만 만들어진다.
리프 밖에서 `dev.caskeleton.messaging.pulsar` 가 등장하는 곳은 전부 **이름 문자열**이다 — `config/architecture/modules.json`, `messaging-testkit/CompatibilityMatrix`, 그리고 그것을 읽는 두 테스트. 그중 `CrossBrokerContractSuite:110-113` 이 이 어댑터의 상태를 명시적으로 못 박는다.
```java
assertThat(matrix.isComplete("messaging-pulsar-experimental")) ;
assertThat(matrix.gapsFor("messaging-pulsar-experimental")).isNotEmpty();
```
즉 플랫폼의 호환성 표가 이 어댑터를 "빈칸이 있는 상태"로 기록하고 있고, 그것을 테스트가 지킨다. 등급 표기와 실제 상태가 어긋나면 저 테스트가 깨진다.
**12.2 `PulsarProfileValidator` 는 선언 말고 아무 데도 없다.**
```
$ grep -rn PulsarProfileValidator --include=*.java src/
src/…/pulsar/PulsarProfileValidator.java:20: public final class PulsarProfileValidator {
```
한 줄. 자기 선언뿐이다 — 리프 밖 참조가 없는 정도가 아니라 **리프 안 참조도, 테스트도 없다.** 그래서 §4 가 서술하는 "검증기가 목적지의 순서 범위와 구독 종류의 합의를 요구한다"는 판단은 코드로 적혀 있을 뿐 한 번도 실행된 적이 없다.
자매 어댑터(NATS)의 검증기도 같은 상태다(그쪽 §17.3). 다만 그쪽은 전송 javadoc 이 `{@link}` 로 가리키기라도 하는데, 이쪽은 그것조차 없다.
**12.3 `cumulativeAcknowledgement = true` 를 만들 수 있는 조합이 없다.**
```java
if (cumulativeAcknowledgement && subscriptionType == KEY_SHARED) throw ;
if (cumulativeAcknowledgement && subscriptionType == SHARED) throw ;
```
`PulsarSubscriptionType` 의 값은 그 둘뿐이다. 그러므로 이 record 의 두 번째 성분은 `false` 만 가질 수 있다.
의도의 흔적은 남아 있다 — `PulsarSubscriptionType` javadoc 이 `Exclusive``Failover` 를 "의도적으로 뺐다"고 적는데, Pulsar 에서 누적 확인이 정당한 것이 정확히 그 두 종류다. 즉 종류를 둘로 줄인 결정이 이 성분을 죽였다.
§4 는 이 짝지음을 "두 값이 함께 보장을 결정한다"고 서술한다. 지금 코드에서는 한 값이 다른 값을 언제나 결정한다. 두 거부 메시지가 서로 다른 이유를 대므로 문서로서는 살아 있고, 그래서 §17 이 아니라 여기에 적는다.
**12.4 드리프트.** 실험 등급 표기가 코드와 문서에서 일치한다. `PulsarTransactionCapability.PROMOTED = false` 와 두 능력 상수의 `brokerTransaction=false` 도 일치한다.
## 16. 확인하지 못한 것
- 실제 Pulsar 브로커를 띄우지 않았다. 이 리프가 클라이언트 브리지를 싣지 않으므로 그럴 대상도 없다.
- §17.1 의 두 능력 답이 실제 재시도 선택을 어떻게 가르는지 실행으로 재현하지 않았다.
- 테스트를 실행하지 않았다. 27개 전부 본문으로만 확인했다.
- §17.3 의 두 테스트가 실제로 무엇을 통과시키는지 디버거로 확인하지 않았다. `register` 의 첫 줄 널 검사와 `assertThatThrownBy` 가 단언하는 예외 타입으로 판정했다.
## 17. 손볼 것
### 17.1 P2 — 같은 어댑터의 능력을 두 곳이 다르게 답하고, 런타임이 쓰는 쪽이 record 의 문서화된 의미와 어긋난다
전송이 답하는 값:
```java
SHARED_CAPABILITIES = (true, true, true, true, false, false, true, true, false, false, true, true);
KEY_SHARED_CAPABILITIES = (true, true, true, true, false, true, true, true, false, false, true, true);
```
검증기가 답하는 값:
```java
public MessagingCapabilities capabilities(PulsarSubscriptionType subscriptionType) {
boolean keyed = subscriptionType == PulsarSubscriptionType.KEY_SHARED;
return new MessagingCapabilities(true, true, true, true, keyed, keyed, true, true, false, false, true, true);
}
```
다섯 번째 성분이 갈린다.
| Key_Shared 에서 | `orderedStream` | `keyedOrdering` |
|---|---|---|
| `PulsarMessagingTransport.capabilities(...)` | **false** | true |
| `PulsarProfileValidator.capabilities(...)` | **true** | true |
`MessagingCapabilities` 의 성분 문서가 판정 기준이다.
```
@param orderedStream the destination preserves order inside an ordering unit
@param keyedOrdering order is preserved per key
```
Key_Shared 의 순서 단위는 키다. 그 단위 안에서 순서가 보존되므로 검증기 쪽이 문서화된 의미와 맞고, 전송 쪽은 `keyedOrdering=true` 이면서 `orderedStream=false` 라 자기 안에서 모순이다.
그리고 어긋난 쪽이 런타임이 읽는 쪽이다. `capabilities(DestinationName)` 이 SPI 메서드이고, `orderedStream` 은 이 저장소에서 production 코드가 실제로 읽는 세 능력 중 하나다 — `DefaultRetryDecisionEngine` 이 그 값이 있으면 순서 보존 재시도를 고른다.
결과적으로 Key_Shared 목적지가 키 단위 순서를 약속하면서 순서 보존 재시도를 받지 못한다.
**등급.** 리프가 미배선이라 오늘의 사고는 아니다. 두 답 중 하나를 고르는 것이 먼저이고, 그 다음이 한 곳에서만 답하게 만드는 것이다. 검증기의 `capabilities` 는 리프 밖 소비자가 없으므로 전송이 그것을 부르게 하는 쪽이 자연스럽다.
### 17.2 P3 — 닫힌 전송의 거절이 영구 업무 실패로 분류된다
```java
private static TransportPublishResult rejectedLocally(String code, String message) {
return new TransportPublishResult(new PublishResult(
PublishCompletion.REJECTED, PublishEvidence.notTransmitted(), RoutingOutcome.NOT_APPLICABLE,
Optional.empty(), 1, Duration.ZERO,
Optional.of(FailureDescriptor.of(FailureCategory.PERMANENT_BUSINESS, code, message))));
}
```
두 호출자가 이 메서드를 쓴다.
```
PAYLOAD_TOO_LARGE — 적재물이 상한을 넘음
PULSAR_TRANSPORT_CLOSED — "the transport is shutting down"
```
첫째는 영구 업무 실패가 맞다. 둘째는 아니다. 종료 중이라는 것은 이 세대의 사정이고, 다음 세대나 다른 인스턴스에서는 같은 메시지가 발행된다.
같은 파일의 `classify` 가 분류를 신중히 나눈다 — 사전 거절은 `CONFIGURATION`, 모호는 `TRANSIENT_INFRASTRUCTURE`. 닫힘만 그 규율 밖에 있다.
전송되지 않았다는 증거(`notTransmitted`)는 옳다. 어긋난 것은 범주뿐이다.
수정은 닫힘에 `TRANSIENT_INFRASTRUCTURE` 를 주거나, 두 호출자가 범주를 인자로 받게 하는 것이다.
### 17.3 P3 — 이름이 검사하지 않는 것을 검사한다고 말하는 테스트 둘
**하나.**
```java
@Test
void theValidatorAcceptsAKeyedProfileOnKeyShared() {
assertThatCode(() -> new PulsarProfile(, PulsarSubscriptionMode.keyShared(), ))
.doesNotThrowAnyException();
}
```
본문에 `PulsarProfileValidator` 가 없다. 만들지도, 부르지도 않는다. 확인하는 것은 `PulsarProfile` 생성자가 키 공유 모드를 거부하지 않는다는 사실뿐이다.
이 리프에서 검증기를 언급하는 유일한 테스트 이름이 이것이고(§12.2), 그래서 이름만 읽으면 검증기에 커버리지가 있다고 읽힌다.
**둘.**
```java
@Test
void aTransportWithoutAConsumerFactoryRefusesToRegisterRatherThanReturningNothing() {
assertThatThrownBy(() -> confirming().register(null)).isInstanceOf(NullPointerException.class);
}
```
이름이 말하는 것은 "소비자 팩토리 없이 만든 전송이 등록을 거절한다"이다. 그 거절은 4-인자 생성자가 심어 두는 기본 팩토리에 있다.
```java
spec -> { throw new MessagingCapabilityUnavailableException(
"PULSAR_CONSUMER_NOT_CONFIGURED", "this Pulsar transport was created without a consumer factory"); }
```
그런데 테스트는 `register(null)` 을 부른다. `register` 첫 줄의 `Objects.requireNonNull(spec, …)` 에서 `NullPointerException` 이 나고, 팩토리까지 가지 않는다. 단언하는 예외 타입도 `NullPointerException` 이지 `MessagingCapabilityUnavailableException` 이 아니다.
결과적으로 `PULSAR_CONSUMER_NOT_CONFIGURED` 는 이 저장소에서 한 번도 실행되지 않는 코드다.
**왜 P3 인가.** 어느 쪽도 잘못된 동작을 통과시키지 않는다 — 두 테스트가 확인하는 것은 사실이다. 문제는 커버리지 지도가 틀렸다는 것이고, 그래서 §12.2 의 "검증기에 호출자가 없다"가 지금까지 눈에 띄지 않았다.
**수정.** 첫째는 `new PulsarProfileValidator().validate(profile, KEY_SHARED, true)` 를 부르고, 키 순서 목적지를 `SHARED` 로 넘겼을 때 거부되는 짝 테스트를 붙인다. 둘째는 유효한 `TransportConsumerSpec` 을 넘겨 `MessagingCapabilityUnavailableException` 과 그 코드를 단언한다. 두 수정 모두 새 production 코드를 요구하지 않는다.
### 확인된 설계(문제 아님)
- **알 수 없는 실패의 기본값을 모호로 둔 것과, 이전 구현의 결함을 javadoc 에 남긴 것.**
- **클래스 이름이 아니라 타입 있는 신호로 분류하는 것** — 클래스 이름은 클라이언트 판본 사이에서 바뀐다.
- **확인을 복제 증거로 기록한 것** — 영수증과 구분한다.
- **호출자의 마감을 `orTimeout` 으로 존중하는 것.**
- **구독 종류와 확인 방식을 한 record 로 묶고 두 조합을 생성자에서 거부한 것.**
- **트랜잭션 미승격을 클래스로 표현하고, 거절을 던지지 않고 값으로 돌려주는 것.**
- **전송 연산을 함수형 인터페이스로 분리해 브로커 없이 검증 가능하게 만든 것.**
- **확인 시간 초과를 기본에서 끄고 그 이유를 적은 것** — "an ack timeout redelivers messages from handlers that are merely slow." 테스트가 기본값이 비어 있음을 지킨다.
- **음수 확인 재배달 지연이 곧 백오프라는 것을 밝히고 0 을 거부한 것** — 0 은 실패하는 핸들러를 브로커 대상 스핀 루프로 바꾼다.
- **확인 시간 초과 하한을 Pulsar 자신의 하한(10초)으로 둔 것** — 브로커가 어차피 거부할 값을 시작 시점에 거부한다.
- **메시지 위치를 불투명 문자열이 아니라 네 조각으로 분해해 들고 있는 것** — 배치 메시지는 id 를 공유하므로 `batchIndex` 가 개별 메시지를 주소 지정 가능하게 만드는 유일한 조각이다.
- **`Exclusive` · `Failover` 구독을 노출하지 않은 것과 그 근거** — 목적지 프로파일이 이미 소유한 토폴로지 결정을 두 곳에서 설정하게 만들지 않는다. (그 결정의 부작용은 §12.3.)
---
## Source anchors
```
src/messaging/messaging-pulsar-experimental/build.gradle
main/java/…/pulsar/PulsarMessagingTransport.java:1-275
main/java/…/pulsar/PulsarProfileValidator.java:1-66
main/java/…/pulsar/PulsarSubscriptionMode.java:1-62
main/java/…/pulsar/PulsarTransactionCapability.java:1-49
main/java/…/pulsar/PulsarProfile.java:1-80
main/java/…/pulsar/PulsarPreSendRejection.java:1-65
main/java/…/pulsar/PulsarMessagePosition.java:1-49
main/java/…/pulsar/PulsarSubscriptionType.java:1-17
test/java/…/pulsar/PulsarAdapterContractTest.java:1-289
test/java/…/pulsar/PulsarSubscriptionGuardTest.java:1-125
src/messaging/messaging-testkit/…/CrossBrokerContractSuite.java:110-113 (호환성 표의 미완 기록)
src/messaging/messaging-core-api/…/destination/MessagingCapabilities.java:11-36 (성분 의미)
src/messaging/messaging-policy/…/DefaultRetryDecisionEngine.java (orderedStream 소비)
```
@@ -0,0 +1,404 @@
# messaging-rabbit 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 20파일 2,443줄 + `src/test` 10파일 1,727줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-rabbit`
> SSOT owner: `messaging-rabbit`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `runtime_memberships`: **`["app-bootstrap"]`** — 클래스패스에 올라간다
- 도달성: **없다.** 제공자 선택이 `rabbit` 을 이름으로 거부한다(§12.1)
| 파일 | LOC | 참조 |
|---|---:|---|
| `RabbitConfirmCoordinator` | 279 | 전송 + 테스트 |
| `RabbitMessagingTransport` | 246 | 테스트만 |
| `RabbitConsumerRegistrar` | 238 | 테스트만 |
| `RabbitDeliveryMapper` | 195 | 소비자 + 테스트 |
| `RabbitHeaderMapper` | 180 | 매퍼 둘 + 테스트 |
| `RabbitSecurityConfigurer` | 179 | 스타터 빈만 — 호출처 없음 |
| `RabbitBatchConsumerRegistrar` | 165 | 테스트만 |
| `RabbitTopologyProfile` | 137 | 네이티브 DLQ 능력 + 테스트 |
| `RabbitDeadLetterPublisher` | 130 | **자기 파일 밖 참조 0** |
| `RabbitPublishFailureClassifier` | 117 | 전송 + 테스트 |
| `RabbitSettlementController` | 84 | 소비자 + 테스트 |
| `RabbitProfileValidator` | 83 | 스타터 시작 검증 |
| `RabbitPublishMapper` | 72 | 전송 |
| `RabbitNativeDeadLetterCapability` | 70 | DLQ 발행자 + 테스트 |
| `RabbitRetryQueueTopology` | 57 | 테스트만 |
| `RabbitSettlementOperations` | 50 | 인터페이스 — 구현은 테스트 셋뿐 |
| `RabbitBrokerProfile` | 50 | 설정 컴파일 |
| `RabbitChannelPublisher` | 42 | 인터페이스 — **production 구현 0** |
| `RabbitPublishReference` | 37 | 좌표 |
| `RabbitRequestReply` | 32 | **자기 파일 밖 참조 0** |
main 총 **20파일 / 2,443줄**.
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 20 | `FULL_READ` | 2,443줄. 위 표가 전부 |
| `test/java/**` | 10 | `FULL_READ` | 1,727줄 |
| `build.gradle` | 1 | `FULL_READ` | 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
`UNCLASSIFIED` 0.
> "참조" 열은 2026-09-01 재통독에서 저장소 전체 grep 으로 채웠다. 이전 판의 Source anchors 는 절반을 괄호 하나로 묶어 두었고, 그 괄호 안에 §17.2·§17.3·§17.4 가 있었다.
---
## 1. 이 어댑터의 중심 — 확인과 반환은 다른 질문에 답한다
> "AMQP delivers a return *before* the confirm for an unroutable message, so a naive adapter that
> completes on the confirm reports success for a message the broker threw away. The coordinator
> therefore keeps each publish pending until the confirm arrives, and remembers whether a return was
> seen first."
네 결과가 나온다 — 확인+미반환은 `CONFIRMED`, 확인+반환은 `UNROUTABLE``REJECTED`, 부정 확인은 `REJECTED`, 확인 미도착은 `AMBIGUOUS`.
이 리프에서 가장 중요한 판단이고, 실브로커 시험(`RabbitBrokerIT.anUnroutablePublishIsRejectedEvenThoughTheExchangeConfirmedIt`)이 그것을 붙든다. 그리고 그 시험이 production 에 없는 조각을 스스로 채워 넣는다(§17.2).
## 2. 자료구조 선택이 결함 수정이다
```java
private final ConcurrentSkipListMap<Long, PendingPublish> pending = new ConcurrentSkipListMap<>();
```
> "Ordered because a Rabbit confirm carries a `multiple` flag meaning 'everything up to and including
> this tag'. Resolving one sequence per confirm — which is what a hash map forces — leaves every
> earlier publish pending forever: the caller's stage never completes and the entry is never removed,
> so the map grows for the life of the connection."
`confirmed(sequence, multiple=true, …)``headMap(sequence, true)` 로 범위를 해소한다. 전용 시험이 있다.
## 3. 부정 확인의 증거를 전송됨으로 기록한다
```java
// A NACK is the broker's answer to a frame it received. Recording it as never sent contradicts the
// very evidence that produced it, and a caller reading the evidence would conclude the message can
// be re-sent freely.
```
`REJECTED` + `TRANSMITTED` + `ConfirmationLevel.NONE`. 완결 상태와 전송 증거를 분리해서 다루는 곳이 이 가족에서 여기와 Kafka 뿐이다.
## 4. 소비·정착·죽은 편지의 세 규율
**좁은 catch.** `RabbitConsumerRegistrar.onMessage` 가 디코딩만 감싸는 안쪽 `try` 를 따로 둔다.
> "One catch around decode, the handler and the settlement meant a business failure or an ACK that
> could not be written was recorded as an undecodable payload and discarded — a message that should
> have been retried, deleted instead."
**정착하지 않은 핸들러.** 완료했는데 정착하지 않으면 대신 ack 하지 않고 requeue 한다 — "acknowledging on its behalf would silently drop it".
**네이티브 죽은 편지.** `RabbitNativeDeadLetterCapability` 가 두 조건을 모두 요구한다.
> "If the dead-letter exchange is unroutable — nobody bound a queue to it, or the binding was removed
> — the broker discards the message silently and the reject still succeeds."
## 5. 자격증명은 연결 시도마다 해석된다
> "RabbitMQ client connections are long-lived and reconnect on their own, so a factory holding a
> credential from startup will happily reconnect with a revoked one for as long as the process runs —
> the reconnect is exactly the moment a rotated credential should take effect."
`AmqpCredentials` 가 record 가 아니라 class 인 이유도 적혀 있다 — 비밀을 지우려면 가변이어야 하고, record 가 `char[]` 를 동등성에 쓰면 같은 자재를 가진 둘이 서로 다르다고 판정된다.
## 6. 시작 검증
`RabbitProfileValidator` 가 여덟을 요구한다 — Stable 에 확인·반환·mandatory, 소비자 auto-ack 금지, prefetch ≥ 1, 확인 마감 양수, 운영에 TLS·인증. 그리고 목적지 검증이 둘 더 — 작업 큐에 쿼럼 큐, 교환기나 큐 중 하나.
Kafka 쪽과 달리 이 검증기는 스타터에서 `StartupProfileValidation` 으로 **감싸여 있다**. 다만 그 자동 설정 자체가 도달하지 않는다(§12.1).
## 10. 테스트 레인
10파일 1,727줄.
| 파일 | 줄 | 무엇을 붙드나 |
|---|---:|---|
| `RabbitRuntimeTest` | 309 | 전송 4경로 + 소비자 7경로(일시정지·배수·미디코딩·핸들러 실패·미정착·close) |
| `RabbitContractHarness` | 304 | 공유 어댑터 계약을 production 조정자·정착 제어기 위에서 |
| `RabbitBrokerIT` | 232 | 실브로커 `rabbitmq:4.3-management` — 반환-먼저-확인 |
| `RabbitTopologyAndBatchTest` | 218 | 쿼럼 요구·DLX 논리·실패 분류·배치 누적 |
| `RabbitProfileValidatorTest` | 184 | 검증기 여덟 규칙 |
| `RabbitConfirmCoordinatorTest` | 167 | 상태 기계 12경로(다중 확인·채널 종료 포함) |
| `RabbitEnvelopeRoundTripTest` | 152 | 헤더 왕복·위조 거부 |
| `RabbitSettlementControllerTest` | 105 | 일회 종결·지연 재시도 큐 인자 |
| `RabbitAdapterContractTest` · `RabbitFixtureProfiles` | 24 · 32 | 계약 실행·픽스처 |
`RabbitAdapterContractTest` 의 javadoc 이 이 레인의 요점을 적는다.
> "Two brokers with completely different machinery — offsets and commits versus delivery tags and
> confirms — answering the same seven questions the same way is what makes the logical destination
> abstraction real rather than aspirational."
## 12. negative-space probes
**12.1 도달성 — 리프 전체가 production 호출자를 갖지 않는다.**
전송을 만들려면 `RabbitChannelPublisher` 구현이 필요하다. 저장소 전체에서 그 인터페이스의 구현은 **테스트의 익명 클래스 둘**(`RabbitRuntimeTest:49`, `RabbitBrokerIT:709`)뿐이다. 따라서 `new RabbitMessagingTransport(...)` 도 테스트에만 있고, `RabbitConsumerRegistrar`·`RabbitBatchConsumerRegistrar` 도 마찬가지다.
그리고 그 사실이 플랫폼 쪽에 이름으로 기록되어 있다.
```java
// MessagingProviderSelection
static final Map<String, String> BROKERS_WITHOUT_A_TRANSPORT = Map.of(
"rabbit",
"the Rabbit adapter ships its validators and security configuration but no MessagingTransport: "
+ "its native channel publisher is not implemented, so a publish has nothing to travel on");
```
`app.messaging.broker=rabbit` 은 시작 오류이고, 전용 시험이 그 메시지를 단언한다. 그래서 `RabbitMessagingAutoConfiguration` 98줄도 도달하지 않는다.
이 리프의 품질과 도달성이 정반대다. 코드는 이 가족에서 가장 정교한 축이고 — 반환-먼저-확인 상태 기계, `multiple` 범위 해소, 정착 일회성, 네이티브 DLQ 의 조건부 신뢰 — 실행 경로는 없다.
**12.2 리프 자체 기준으로도 죽은 둘.**
| 파일 | LOC | 상태 |
|---|---:|---|
| `RabbitDeadLetterPublisher` | 130 | 자기 파일 밖 참조 0 — production 도 테스트도 부르지 않는다 |
| `RabbitRequestReply` | 32 | 인터페이스. 구현 0, 테스트 0, 호출 0 |
`RabbitDeadLetterPublisher` 가 담고 있는 것이 §4 의 세 번째 규율 — 네이티브 경로와 플랫폼 발행 중 어느 쪽을 쓸지 한 곳에서 결정한다는 판단 — 인데, 그 결정을 내리는 코드를 아무도 부르지 않는다. 그 판단의 근거가 되는 `RabbitNativeDeadLetterCapability` 는 테스트가 있다. 즉 **판단의 재료는 시험되고 판단 자체는 시험되지 않는다.**
`RabbitRequestReply` 는 M2 능력의 인터페이스 선언이다. javadoc 이 왜 제한적으로 제공하는지를 적는데("a synchronous call wearing an asynchronous costume"), 제공되는 것이 없다.
**12.3 대조군 — 재시도 헤더 오염의 처리가 두 어댑터에서 갈린다.** 두 어댑터의 `attemptOf` 는 같은 fail-closed 결정을 같은 문구로 적는다.
```java
// "the message is quarantined rather than restarting its retry budget"
throw new MessagingConfigurationException("RETRY_ATTEMPT_MALFORMED", );
```
그런데 소비자가 그 던짐을 받는 위치가 다르다.
| 어댑터 | `attemptOf` 호출 위치 | 결과 |
|---|---|---|
| Rabbit | `toMetadata` 안 → 디코딩 실패 `catch` 안쪽 | `operations.discard(tag, …)` — DLX 가 있으면 죽은 편지로 |
| Kafka | 디코딩 `catch` **바깥**의 두 번째 블록 | `requeueAfterFailure()` → 무한 pause-and-seek(messaging-kafka §17.4) |
같은 판단, 반대 결과다. Rabbit 쪽이 javadoc 이 약속한 것에 가깝다.
**12.4 대조군 — `pause` 의 뜻이 SPI 하나 뒤에서 두 가지다.**
| 어댑터 | 반환 시점 | 의미 |
|---|---|---|
| Kafka | 다음 폴 주기 | `consumer.pause()` — 브로커에서 더 가져오지 않는다 |
| Rabbit | 즉시 완료 | `onMessage``false` 를 답한다 — 리스너 컨테이너가 계속 밀고, 미확인으로 재배달된다 |
Kafka 쪽 javadoc 은 왜 즉시 완료하지 않는지를 명시한다("'paused' cannot be true until the loop says so"). Rabbit 쪽에는 그 대비 서술이 없다. §17.4.
**12.5 드리프트.** 검증기가 강제하는 항목과 어댑터가 실제로 보내는 플래그(`mandatory`)가 일치한다.
## 16. 확인하지 못한 것
- 실제 브로커로 반환-먼저-확인 순서를 재현하지 않았다. `RabbitBrokerIT` 가 그 레인이고 컨테이너가 필요하다.
- 지연 재시도 큐 토폴로지를 실제로 선언해 보지 않았다.
- §17.2 를 실행으로 재현하지 않았다. `RabbitHeaderMapper.toProperties` 전문에 순번 헤더가 없다는 것과, `RabbitBrokerIT` 가 자기 publish 람다에서 `x-seq` 를 붙인다는 것으로 판정했다.
- `RABBIT-CR-DEMO`(§17.3)를 실제 브로커에 붙여 보지 않았다. 이름과 RabbitMQ 의 기본 활성 상태로 판정했다.
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
## 17. 손볼 것
### 17.1 P3 — 확인 등급이 요구에서 파생되고, 그 요구를 뒷받침하는 강제는 목적지 종류 하나에만 걸린다
```java
ConfirmationLevel level =
requirement == ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK
? ConfirmationLevel.REPLICATION_OR_PERSISTENCE_ACK
: ConfirmationLevel.BROKER_ACK;
```
증거의 등급이 브로커가 무엇을 했는지가 아니라 프로파일이 무엇을 **요구했는지** 에서 나온다.
대부분의 경우 이 파생은 성립한다. 두 강제가 그것을 받쳐 준다.
- `RabbitHeaderMapper.toProperties` 가 배달 모드를 무조건 `PERSISTENT` 로 둔다. RabbitMQ 는 지속 메시지를 디스크에 쓴 뒤에 확인한다.
- `RabbitProfileValidator.validateDestination` 이 내구 작업 큐에 쿼럼 큐를 요구한다. 쿼럼 큐의 확인은 다수 복제 뒤에 온다.
빈틈은 둘째 강제의 범위다.
```java
if (destination.kind() == DestinationKind.WORK_QUEUE && !broker.quorumQueues()) { throw ; }
```
작업 큐가 아닌 목적지에는 쿼럼 요구가 없다. 교환기로 발행하는 목적지가 `REPLICATION_OR_PERSISTENCE_ACK` 를 요구하면, 그 교환기에 바인딩된 큐가 고전 큐여도 어댑터는 그 등급을 보고한다. 지속 모드 덕분에 디스크 기록은 보장되지만 복제는 보장되지 않는다.
이 저장소의 규율은 증거가 관측에서 나와야 한다는 것이다 — `MessagingCapabilities` 의 javadoc 이 "a silently weakened guarantee is indistinguishable from a working one until the incident" 라고 적는다.
수정은 쿼럼 요구를 목적지 종류가 아니라 **요구된 확인 등급** 에 걸거나, 작업 큐가 아닌 목적지에서는 등급을 `BROKER_ACK` 로 낮추는 것이다.
### 17.2 P2 — 반환을 순번에 맞추는 조각이 production 에 없고, 시험이 그 자리를 스스로 메운다
이 어댑터의 핵심 보장(§1)은 반환과 확인을 **같은 발행** 에 묶는 데 달려 있다. 묶는 열쇠는 순번이다.
```java
public void returned(long sequence) { } // RabbitConfirmCoordinator
public void onReturn(long sequence) { } // RabbitMessagingTransport
```
그런데 AMQP 의 `basic.return` 콜백은 순번을 주지 않는다. 교환기·라우팅 키·속성·본문만 온다. 그래서 발행자가 순번을 메시지에 실어 보내고 반환에서 되읽어야 한다.
`RabbitHeaderMapper.toProperties` 전문에 그런 헤더가 없다. 쓰는 것은 `msg.*` 예약 헤더들과 AMQP 의 `messageId`·`correlationId`·`timestamp`·`deliveryMode` 뿐이다.
그 조각이 존재하는 곳은 시험 하나다.
```java
// RabbitBrokerIT
channel.addReturnListener(returned ->
transport.onReturn(Long.parseLong(returned.getProperties().getHeaders().get("x-seq").toString())));
private static Map<String, Object> withSequence(MessageProperties source, long sequence) {
headers.put("x-seq", Long.toString(sequence)); // ← 시험이 직접 붙인다
}
```
그 메서드의 javadoc 이 문제를 정확히 서술한다.
> "A returned message arrives without its publish sequence number, so the adapter has to carry one
> itself to correlate the return with the pending publish."
"the adapter has to" 인데 어댑터는 하지 않는다. `RabbitChannelPublisher` 의 javadoc 은 **등록 경합**(확인이 `basicPublish` 반환보다 먼저 올 수 있다)만 설명하고 이 상관 문제는 언급하지 않는다.
결과는 이렇다. 언젠가 `RabbitChannelPublisher` 를 구현하는 사람은 이 헤더 규약을 다시 발명해야 하고, 발명하지 않으면 `onReturn` 이 호출되지 않아 unroutable 발행이 **`CONFIRMED` 로 보고된다** — 이 어댑터가 존재하는 이유로 든 바로 그 실패다.
수정은 순번 헤더를 `RabbitHeaderMapper``RabbitPublishMapper` 로 올려 production 계약으로 만들고, 그 이름을 `RabbitChannelPublisher` javadoc 에 적는 것이다. 지금은 그 규약이 시험 파일 20줄에만 있다.
### 17.3 P3 — SCRAM 자격을 RabbitMQ 의 데모 기구로 조용히 매핑한다
```java
case BrokerCredentialProfile.SaslScram scram -> {
CredentialRuntime resolved = credentials.resolve(scram.credentialId(), now);
yield new AmqpCredentials("RABBIT-CR-DEMO", scram.credentialId(), resolved.material(), profile.tlsEnabled());
}
```
`RABBIT-CR-DEMO` 는 RabbitMQ 의 시연용 challenge-response 인증 기구(`rabbit_auth_mechanism_cr_demo`)의 이름이고 기본 활성이 아니다. RabbitMQ 는 SCRAM-SHA 를 구현하지 않으므로 `SaslScram` 에 대응하는 AMQP 기구가 없다는 것 자체는 사실이다.
문제는 그 사실을 다루는 방식이 같은 파일 안에서 일관되지 않다는 것이다.
```java
case BrokerCredentialProfile.Nkey ignored ->
throw new IllegalArgumentException("NKey credentials are a NATS concept, not an AMQP one"); // ← 거부
case BrokerCredentialProfile.OAuth2 oauth -> {
// RabbitMQ's OAuth 2 plugin takes the token in the password field of a PLAIN exchange.
yield new AmqpCredentials("PLAIN", ); // ← 주석으로 근거
}
case BrokerCredentialProfile.SaslScram scram -> yield new AmqpCredentials("RABBIT-CR-DEMO", ); // ← 둘 다 없다
```
그리고 이웃 어댑터의 같은 클래스가 정확히 이 상황에 대한 규범을 적어 두었다.
> "Refused rather than half-configured. Setting the mechanism name without a callback handler
> produces a client that authenticates with nothing and fails at connect time, which is later and
> harder to attribute than failing here." — `KafkaSecurityConfigurer`
`SaslScram` 에도 그 규범이 적용되어야 한다. 플러그인이 없는 브로커에서는 handshake 가 알아보기 어려운 오류로 실패하고, 있는 브로커에서는 시연용 기구로 인증한다.
수정은 `Nkey` 와 같이 거부하거나, `PLAIN` 으로 매핑하고 그 이유를 주석으로 남기는 것이다. 어느 쪽이든 지금처럼 말없이 데모 기구를 고르는 것보다 낫다.
### 17.4 P3 — 능력 상수의 `delayedDelivery` 가 무조건 참이고, 그 지연을 제공할 토폴로지는 조립되지 않는다
```java
private static final MessagingCapabilities CAPABILITIES =
new MessagingCapabilities(true, true, true, false, false, false, false, true, false, false, true, true);
// ^^^^ delayedDelivery
```
이 플래그는 읽힌다.
```java
// DefaultRetryDecisionEngine:64
if (policy.mode() == RetryMode.BROKER_DELAYED && context.capabilities().delayedDelivery()) { }
```
그런데 지연을 실제로 만드는 것은 `RabbitRetryQueueTopology` 이고, 그 클래스는 자기 파일과 시험 하나 밖에서 참조되지 않는다. 어떤 production 코드도 그 큐를 선언하지 않는다.
그리고 그 클래스의 javadoc 이 이 지연의 성질을 정확히 적는다.
> "TTL expiry is evaluated at the head of the queue, so mixed delays in one retry queue do not expire
> independently."
즉 제공되는 것은 "메시지별 지연" 이 아니라 "재시도 큐 하나당 TTL 하나" 다. 능력 모델에는 그 구분을 표현하는 자리가 없고, 상수는 프로파일과 무관하게 참을 답한다.
Kafka 는 같은 칸을 `false` 로 둔다. 그래서 이 플래그의 두 값이 "지연 있음/없음" 이 아니라 "지연을 흉내낼 토폴로지를 선언할 수 있음/없음" 을 뜻하게 된다.
수정은 능력을 전송 상수가 아니라 목적지의 재시도 큐 선언에서 파생시키는 것이다. 이 리프가 조립되지 않는 동안에는 P3 이고, `RabbitChannelPublisher` 구현이 생기는 날 함께 봐야 한다.
### 17.5 P3 — `pause` 의 의미가 SPI 하나 뒤에서 두 브로커에 다르게 구현된다
```java
@Override public CompletionStage<Void> pause(String scope) {
pausedScopes.add(scope == null ? "" : scope);
return CompletableFuture.completedFuture(null); // ← 즉시 완료
}
```
호출자가 이 단계를 기다리고 나면 "일시정지되었다" 고 읽는다. 실제로 일어난 것은 `onMessage` 가 이후 배달에 `false` 를 답하기 시작한 것뿐이고, 리스너 컨테이너는 계속 배달을 밀며 그 배달들은 미확인 상태로 재배달된다. 즉 정지가 아니라 거부-재배달 루프다.
Kafka 쪽은 같은 SPI 를 정반대로 구현하고 그 이유를 적는다.
> "The returned stage completes after the poll loop has actually applied the change, so a caller that
> awaits it knows the consumer is paused rather than merely asked to pause… there is no safe way to
> touch the consumer from another thread, so 'paused' cannot be true until the loop says so."
AMQP 에는 대응하는 수단이 있다 — `basicCancel` 로 소비자를 취소하거나 컨테이너를 멈추는 것. 지금 구현이 그것을 하지 않는 이유는 어디에도 없다.
전용 시험(`aPausedQueueRefusesDeliveriesSoTheBrokerRedeliversThem`)의 이름이 이미 실제 동작을 정확히 말한다. 그러므로 수정은 둘 중 하나다 — 컨테이너를 실제로 멈추거나, SPI 의 javadoc 에 "브로커에 따라 정지가 거부-재배달일 수 있다" 를 명시하는 것.
### 확인된 설계(문제 아님)
- **확인과 반환을 두 질문으로 나누고, 반환-먼저 순서를 상태 기계로 다룬 것.**
- **정렬된 맵을 골라 `multiple` 확인의 범위 해소를 가능하게 한 것과, 해시 맵이 만들었을 누수를 javadoc 에 남긴 것.**
- **부정 확인의 증거를 전송됨으로 기록한 것과 그 근거.**
- **채널 종료를 모호로 완결시킨 것** — 보류로 남기면 호출자가 매달린다.
- **순번 예약을 발행과 분리한 것** — 확인이 `basicPublish` 반환을 앞지를 수 있다.
- **동기 발행 실패를 던지지 않고 분류기를 거쳐 스테이지로 돌려주는 것.**
- **적재물 크기와 종료 상태를 채널 앞에서 검사해 미전송 증거로 실패시키는 것.**
- **정착의 일회성과 재사용된 배달 태그의 위험을 명시한 것.**
- **디코딩만 감싸는 좁은 `catch`** — 넓은 catch 가 재시도 가능한 실패를 삭제로 바꾸던 형태를 고쳤다.
- **정착하지 않은 핸들러를 대신 ack 하지 않고 requeue 하는 것.**
- **요구 재큐 대신 지연 재시도 큐를 쓴 것과, TTL 이 큐 머리에서 평가된다는 한계를 javadoc 에 남긴 것.**
- **배수 중 진행 배달을 끝내게 한 것.**
- **네이티브 죽은 편지를 검증된 곳에서만 쓰고 나머지는 공유 조율자에 위임한 것, 그리고 네이티브 경로의 증거를 `BROKER_ACK` 로만 주장한 것.**
- **자격증명을 연결 시도마다 해석하고 짧은 수명 객체로 넘긴 것, `AmqpCredentials` 를 record 가 아니라 class 로 둔 것과 그 근거.**
- **내구 작업 큐에 쿼럼 큐를 요구한 것과 그 근거.**
- **배치 누적에 나이 경계를 필수로 만든 것** — 조용한 큐가 마지막 메시지를 미확인으로 붙들지 않게.
- **prefetch 가 배치 크기보다 작으면 교착이라는 것을 거부로 표현한 것.**
- **배치를 `settlableAsBatch=false` 로 보고한 것** — AMQP multiple-ack 은 진행 중인 작업까지 정착시킨다.
---
## Source anchors
```
src/messaging/messaging-rabbit/build.gradle
main/java/…/rabbit/RabbitConfirmCoordinator.java:1-279 (§17.2 returned:74-79)
main/java/…/rabbit/RabbitMessagingTransport.java:1-246 (능력 상수 41-43 · §17.4)
main/java/…/rabbit/RabbitConsumerRegistrar.java:1-238 (§17.5 pause:694-697)
main/java/…/rabbit/RabbitDeliveryMapper.java:1-195 (§12.3 attemptOf:133-153)
main/java/…/rabbit/RabbitHeaderMapper.java:1-180 (§17.1 배달 모드 235 · §17.2 순번 헤더 부재)
main/java/…/rabbit/RabbitSecurityConfigurer.java:1-179 (§17.3 switch 438-463)
main/java/…/rabbit/RabbitBatchConsumerRegistrar.java:1-165
main/java/…/rabbit/RabbitTopologyProfile.java:1-137
main/java/…/rabbit/RabbitDeadLetterPublisher.java:1-130 (§12.2 참조 0)
main/java/…/rabbit/RabbitPublishFailureClassifier.java:1-117
main/java/…/rabbit/RabbitSettlementController.java:1-84
main/java/…/rabbit/RabbitProfileValidator.java:1-83 (§17.1 validateDestination:543-555)
main/java/…/rabbit/RabbitPublishMapper.java:1-72
main/java/…/rabbit/RabbitNativeDeadLetterCapability.java:1-70
main/java/…/rabbit/RabbitRetryQueueTopology.java:1-57 (§17.4)
main/java/…/rabbit/{RabbitSettlementOperations:1-50, RabbitBrokerProfile:1-50, RabbitChannelPublisher:1-42,
RabbitPublishReference:1-37, RabbitRequestReply:1-32}
test/java/…/rabbit/ 10파일 1,727줄 (RabbitRuntimeTest:309 · RabbitContractHarness:304 · RabbitBrokerIT:232 …)
test/java/…/rabbit/RabbitBrokerIT.java:726-733, 786-813 (§17.2 시험이 메우는 x-seq 규약)
messaging-spring-boot-starter/…/MessagingProviderSelection.java:64-69 (§12.1 rabbit 거부)
messaging-policy/…/DefaultRetryDecisionEngine.java:64 (§17.4 delayedDelivery 소비처)
```
@@ -0,0 +1,793 @@
# messaging-reliability-api 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-reliability-api`
> SSOT owner: `messaging-reliability-api`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-reliability-api`
- canonical state `analysisFile`: `analysis/messaging/messaging-reliability-api.md`
- source path: `src/messaging/messaging-reliability-api`
- registry `allowed_dependencies`: `["messaging-core-api"]`
- registry `runtime_memberships`: `["app-bootstrap"]`
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 13 |
| production LOC | 817 |
| 패키지 | 1 (`dev.caskeleton.messaging.reliability`) |
| **test 파일** | **0 — `src/test` 디렉터리가 없다** |
| 외부(비프로젝트) 의존성 | **0** |
13개 타입:
| 축 | 타입 | leaf 밖 참조 |
|---|---|---:|
| **Outbox** | `OutboxRepository` · `OutboxRecord` · `OutboxCanonicalMetadata` · `OutboxStatus` · `OutboxLease` · `OutboxTransitionResult` | 7 · 13 · 8 · 7 · 6 · 6 |
| **Inbox** | `InboxRepository` · `InboxRecord` · `InboxResult` · `IdempotentMessageHandler` · `TransactionalMessageAction` | 6 · **0** · 2 · 1 · 1 |
| **기타** | `ClaimCheckReference` · `ReliableMessagePublisher` | 6 · **0** |
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (13) | 13 | `FULL_READ` | 전 파일 본문 확인 |
| `src/test/**` | 0 | — | **존재하지 않음**(§10) |
| `build.gradle` | 1 | `FULL_READ` | 5줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
이 leaf는 **effectively-once 처리의 계약**을 소유한다. 구현이 없다 — 13개 중 인터페이스 5개, record 5개, enum 3개이고 실행 가능한 로직은 record 생성자 검증과 `isExpired`/`expiredAt` 술어 정도다. 벤더 의존성 0, 저장소 기술 중립이다.
세 개의 독립적인 메커니즘을 담는다.
**Outbox** — dual-write 문제의 답.
```java
// ReliableMessagePublisher.java:14-15
* <p>This is the answer to the dual-write problem. Writing to the database and publishing to the
* broker in the same method cannot be made atomic; writing both to the database can.
```
**Inbox** — 소비 측 중복 제거.
```java
// InboxRepository.java:9-13
* <p>{@link #reserve} must run inside the same database transaction as the handler's side effect.
* That is the entire mechanism: the uniqueness constraint on the inbox row and the business write
* commit together, so a redelivered message either finds the row already present and skips, or
* writes both. Reserving in a separate transaction reintroduces exactly the gap the Inbox exists to
* close.
```
**Claim Check** — 브로커 밖 payload 참조.
그리고 셋의 관계를 `OutboxRecord`가 명시한다.
```java
// OutboxRecord.java:21-24
* <p>What the outbox does not do is remove duplicates. A relay that cannot confirm a publish will
* retry it, and the same message may reach the broker twice. Effectively-once processing comes from
* this row carrying a stable {@code messageId} and the consumer having an Inbox not from the
* outbox alone.
```
**Outbox 하나로는 부족하다는 것을 타입의 javadoc이 직접 말한다.** 이 저장소에서 반복되는 "보장을 과대 진술하지 않는다"의 예다.
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api) 하나.
나가는 것: `messaging-outbox-jdbc-postgresql`, `messaging-inbox-jdbc-postgresql`, `messaging-claim-check`, `messaging-spring-boot-starter`.
**구현 leaf가 셋 있고 전부 배선된다.**
| 포트 | 구현 | 조립 |
|---|---|---|
| `OutboxRepository` | `messaging-outbox-jdbc-postgresql/JdbcOutboxRepository` | starter `MessagingReliabilityAutoConfiguration` |
| `InboxRepository` | `messaging-inbox-jdbc-postgresql/JdbcInboxRepository` | 같음 |
| `IdempotentMessageHandler` | `messaging-inbox-jdbc-postgresql/TransactionalInboxHandler` | `transactionalInboxHandler` bean |
| `ReliableMessagePublisher` | **없음** | — |
`ReliableMessagePublisher`는 구현도 소비자도 0이다(§12.1). Outbox에 행을 쓰는 애플리케이션 측 진입점인데, 그 진입점이 없다.
이 leaf 자체는 Spring 주석을 갖지 않는다.
---
## 3. 패키지/컴포넌트 지도
```
Outbox
ReliableMessagePublisher.addToOutbox(dest, envelope) ← 구현 0
↓ (쓰기)
OutboxRecord ─┬─ messageId / destination / type / version / contentType / payload / headers
├─ OutboxCanonicalMetadata (provenance 10필드)
└─ status / attempts / leaseExpiresAt / lastFailureCode
↓ (릴레이)
OutboxRepository ─┬─ append
├─ [구세대] leaseBatch → List<OutboxRecord>
│ markPublished/markAmbiguous/markFailed/releaseLease(MessageId) → void
└─ [신세대] claimBatch → List<OutboxLease>
markPublished/markAmbiguous/markExhausted/markFailed/releaseLease(OutboxLease)
→ OutboxTransitionResult {APPLIED, STALE_LEASE}
OutboxStatus {PENDING, IN_FLIGHT, PUBLISHED, AMBIGUOUS, FAILED, EXHAUSTED}
Inbox
IdempotentMessageHandler.handleOnce(consumerName, delivery, TransactionalMessageAction)
InboxRepository.reserve(messageId, consumerId, now) → boolean
InboxRecord (messageId + consumerId + processedAt) ← 참조 0
InboxResult {APPLIED, ALREADY_APPLIED, CLAIMED_ELSEWHERE}
Claim Check
ClaimCheckReference (storageKey, sizeBytes, sha256, expiresAt)
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `OutboxLease` — fencing token
이 leaf에서 가장 중요한 안전 장치이고, 이전 결함이 javadoc에 통째로 있다.
```java
// OutboxLease.java:8-16
* <p>The port used to take a {@code MessageId} for every terminal transition, so a write said which
* row to change and nothing about which claim it belonged to. A relay that stalled past its lease
* could still record {@code AMBIGUOUS} over the {@code PUBLISHED} another relay had already
* written, and the row became claimable again one message, published twice, by a system whose
* whole purpose is to publish it once.
*
* <p>The token is the part that makes staleness detectable. It increases on every claim, so a
* superseded relay holds a number the row no longer has and its update matches zero rows.
```
`token < 1`을 거절하는 이유도 적혀 있다 — `"a claim's token starts at 1; 0 is the value of a row nobody has claimed"`.
`expiredAt(now)``!now.isBefore(expiresAt)`다.
### 4.2 `OutboxTransitionResult` — void가 삼킨 것
```java
// :5-9
* <p>The transitions returned {@code void}, so an update that matched zero rows was
* indistinguishable from one that matched one. That is precisely the stale-lease case: the relay
* believes it recorded the outcome, the row still says something else, and nothing anywhere counts
* the disagreement.
```
두 값이고 `STALE_LEASE`의 javadoc이 운영 의미까지 적는다.
```java
* <p>Another relay claimed it after the lease expired. Not an error to throw the message is
* being handled by somebody else but never a success either: it is the signal that this
* worker's publish attempt may have produced a duplicate, and it belongs on a metric.
```
**"belongs on a metric"** — 그 메트릭이 존재하는지는 outbox leaf가 답한다.
### 4.3 `OutboxStatus` — 여섯 상태와 두 개의 구분
`PENDING``IN_FLIGHT``PUBLISHED` / `AMBIGUOUS` / `FAILED` / `EXHAUSTED`.
**두 쌍의 구분이 각각 이유를 갖는다.**
`AMBIGUOUS` vs `FAILED`:
```java
// :6-9
* <p>{@link #AMBIGUOUS} is a distinct state rather than a flavour of failure. A record whose
* publish timed out may already be on the broker; retrying it is correct, but only under the same
* logical message id, and an operator looking at the table needs to be able to tell those rows
* apart from ones that definitely never landed.
```
`EXHAUSTED` vs `FAILED`:
```java
// :31-34
* <p>Distinct from {@link #FAILED}, which means the broker refused the message: this one means
* nobody ever got an answer. Collapsing the two loses the difference between "this message is
* invalid" and "the broker was unreachable for an hour", and those need different operator
* actions the first a fix, the second a redrive.
```
`OutboxRepository.markExhausted`의 javadoc이 같은 말을 반복한다 — "The first needs a fix, the second a redrive."
**`FAILED`의 의미가 애플리케이션 쪽 동명 enum과 반대다.** `CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM``.because(...)`가 그것을 ArchUnit 규칙의 근거로 든다 — "its `OutboxStatus.FAILED` means the opposite of the legacy `OutboxEventStatus.FAILED`, so the two models cannot be mixed by name without inverting retryable and terminal." 즉 **이 enum의 의미가 저장소 규칙 하나의 존재 이유다.**
### 4.4 `InboxResult` — 두 개가 아니라 세 개
```java
// :6-9
* <p>Three outcomes, not two. Collapsing {@link #ALREADY_APPLIED} and {@link #CLAIMED_ELSEWHERE}
* into a single "duplicate" would settle a message whose effect is still only half-written by
* another instance: if that instance then rolls back, the effect is lost and the broker will never
* redeliver, because this instance already acknowledged it.
```
`safeToSettle` 플래그가 상수에 붙어 있다.
| 값 | safeToSettle | 뜻 |
|---|:---:|---|
| `APPLIED` | true | 이 트랜잭션에서 효과 실행 |
| `ALREADY_APPLIED` | true | 커밋된 예약 존재 — 이미 실행됨 |
| `CLAIMED_ELSEWHERE` | **false** | 다른 인스턴스가 **미커밋** 예약 보유 |
세 번째의 javadoc이 결론을 적는다 — "Do *not* settle. The other transaction may still roll back, and this delivery is the only remaining copy that could re-apply the effect."
**세 값 모두 필요한 이유가 명확하고, `isSafeToSettle()`이 그 판단을 하나로 모은다.**
### 4.5 `InboxRepository` — 키가 (message, consumer)다
```java
// InboxRecord.java:9-12
* <p>Keyed by message id <em>and</em> consumer id, because two independent consumers of the same
* event must each process it once deduplicating on the message alone would let the first consumer
* suppress the second.
```
`IdempotentMessageHandler`의 javadoc이 같은 이유를 API 형태로 반복한다 — `consumerName`이 파라미터인 이유.
`purgeProcessedBefore`의 javadoc이 보존 기간 규칙을 적는다.
```java
* <p>Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery
* arrives after its inbox row was pruned and is processed a second time.
```
**이 규칙을 강제하는 코드가 없다.** 보존 기간과 브로커 재전달 창을 비교하는 검증이 이 leaf에도, `messaging-policy`의 프로파일 검증기에도 없다. §17.
### 4.6 `TransactionalMessageAction` — 트랜잭션 경계의 소유권
```java
// :8-16
* <p>Sharing one transaction is the entire mechanism. If the effect committed separately from the
* "I have handled this message" marker, a crash between the two would either replay the effect or
* suppress a message that was never handled and which of those you get would depend on the order
* the two commits happened to be written in.
*
* <p>Implementations must not settle the message, publish, or start their own transaction. The
* runtime owns the transaction boundary precisely so that the action cannot accidentally commit
* half of it.
```
세 금지("settle하지 마라, publish하지 마라, 자기 트랜잭션을 시작하지 마라")가 **문서로만 표현된다.** 함수형 인터페이스이므로 타입이 강제할 수 없다. §17.
### 4.7 `OutboxCanonicalMetadata` — 컬럼이어야 하는 이유
이 leaf에서 가장 긴 javadoc이고, 이전 결함과 설계 대안을 함께 적는다.
```java
// :14-28
* <p>They used to live nowhere. A row held identity, type, version, content type, payload and an
* arbitrary header map, so producer, tenant, correlation, causation, trace and schema were either
* invented when the envelope was rebuilt {@code Optional.empty()} for every one of them or
* smuggled through the header map under reserved names the platform was supposed to own.
*
* <p>Both routes fail in the same direction. A relay cannot filter, route or diagnose by tenant
* without decoding the payload, so the operational question "which tenant is backed up" has no
* answer; and a message that crossed the outbox arrived at its consumer with a different tenant,
* trace and correlation than the one that was published, which makes the publish path direct,
* polling or CDC part of the message's meaning.
*
* <p>Columns rather than a blob, because the point is that the database can answer questions about
* them. A versioned envelope encoding would round-trip just as faithfully and would still leave the
* relay unable to select rows for one tenant.
```
**세 번째 문단이 고려된 대안을 명시적으로 기각한다** — 버전 있는 봉투 인코딩이 왕복 충실도는 같지만 테넌트별 조회를 못 한다는 것. 이 저장소에서 대안을 이름 붙여 기각한 드문 예다.
불변식 하나: `schemaUri.isPresent() && schemaSubject.isEmpty()`를 거절한다 — "a reader would have a URI and no way to know what it is a schema for".
`traceContext``Optional`이 아니고 `TraceContext.none()`이라는 자체 빈 형태를 갖는다. javadoc이 그 이유를 적는다 — 컬럼이 생기기 전에 쓰인 행과, 진짜로 correlation이 없는 행을 구분할 필요가 없다는 것("the reader's behaviour is the same: carry what is there and invent nothing").
### 4.8 `OutboxRecord` — 두 반쪽의 소유자가 다르다
```java
// :26-29
* <p>{@link OutboxCanonicalMetadata} is a separate component rather than more fields here because
* the two halves answer to different owners. Identity, payload, status, attempts and lease are the
* relay's bookkeeping; the metadata is the message's own provenance, and it is the half that has to
* survive the round trip through the database unchanged.
```
`payload`가 양방향 방어 복사(`payload.clone()` 생성 시와 접근 시), `headers``Map.copyOf``messaging-schema-api``EncodedMessage`(그쪽 §4.3)와 같은 패턴이다.
`withStatus``messageId`를 파라미터로 받지 않는다 — "The message id is never a parameter, so no state transition can change it." 타입이 불변식을 강제하는 예다.
`equals`/`hashCode`**다섯 필드 중 넷만** 본다 — `messageId`, `status`, `attempts`, `payload`. `destination`·`metadata`·`createdAt`·`leaseExpiresAt`·`lastFailureCode`는 비교하지 않는다. record 기본 동작을 의도적으로 좁혔는데 **그 이유가 어디에도 적혀 있지 않다.** §17.
`toString`이 payload를 담지 않는다.
### 4.9 `ClaimCheckReference` — digest가 선택이 아니다
```java
// :10-16
* <p>The digest is part of the reference, not an optional extra. A claim check splits a message
* into two systems with independent retention and replication, so a consumer that fetches the
* payload has to be able to prove it got the bytes the producer stored otherwise a truncated or
* replaced object is indistinguishable from a valid one.
*
* <p>The expiry is carried for the same reason: a claim check whose payload has been reaped is a
* dead message, and detecting that at fetch time is better than a mysterious not-found.
```
`sha256``[a-f0-9]{64}` 정확 일치다 — 대문자 hex를 거절한다. `messaging-core-api``TraceContext`가 대문자 traceparent를 거절하는 것(그쪽 §4.11)과 같은 규율이지만, 여기서는 그 이유가 적혀 있지 않다.
`expiresAt``Optional`이 아니다 — 모든 claim check가 만료를 갖는다.
---
## 5. 주요 실행 경로
**Outbox 쓰기:** 애플리케이션 트랜잭션 안에서 `ReliableMessagePublisher.addToOutbox(...)``OutboxRepository.append(record)`**진입점 구현이 없다**(§12.1)
**Outbox 릴레이:** `claimBatch(owner, size, lease, now, maxAttempts)``List<OutboxLease>` → 각 lease에 대해 발행 → 결과에 따라 `markPublished`/`markAmbiguous`/`markExhausted`/`markFailed`(lease 기반) → `APPLIED`면 정상, `STALE_LEASE`면 다른 릴레이가 가져감
**Inbox:** `handleOnce(consumerName, delivery, action)` → 한 트랜잭션 안에서 `reserve(messageId, consumerId, now)` → true면 `action.apply(delivery)` → 커밋
---
## 6. 실패 경로와 복구/번역
**이 leaf는 `MessagingException`을 하나도 던지지 않는다.** 실패를 상태와 반환값으로 표현한다.
| 표현 | 값 |
|---|---|
| 릴레이 전이 결과 | `OutboxTransitionResult.{APPLIED, STALE_LEASE}` |
| Outbox 행 상태 | `OutboxStatus` 6개 |
| Inbox 판정 | `InboxResult` 3개 + `isSafeToSettle()` |
| claim check 만료 | `ClaimCheckReference.isExpired(now)` |
| lease 만료 | `OutboxLease.expiredAt(now)` |
`IllegalArgumentException`을 던지는 곳은 record 생성자 여섯이다 — 전부 호출자의 프로그래밍 오류다.
`TransactionalMessageAction.apply``throws Exception`이다 — javadoc: "rolling back both it and the inbox reservation". 즉 예외가 롤백 신호이고, 그 처리는 구현 leaf가 소유한다.
---
## 7. 트랜잭션·동시성·수명주기
**이 leaf 전체가 트랜잭션 계약이다.** 그런데 코드에는 트랜잭션이 없다 — 전부 javadoc이 요구하는 규약이다.
| 계약 | 표현 위치 | 강제 |
|---|---|---|
| `OutboxRepository.append`가 호출자 트랜잭션 안 | 인터페이스 javadoc | **없음** |
| 나머지 메서드는 릴레이 자기 트랜잭션 | 같은 javadoc | 없음 |
| `InboxRepository.reserve`가 핸들러 부작용과 같은 트랜잭션 | 인터페이스 javadoc | 없음 |
| `TransactionalMessageAction`이 자기 트랜잭션을 시작하지 않음 | javadoc | 없음 |
| `ReliableMessagePublisher.addToOutbox``void`인 것 | javadoc | **타입이 강제** |
마지막 하나만 타입이 강제한다.
```java
// ReliableMessagePublisher.java:9-12
* <p>The return type is {@code void}, and that is the contract. There is no publish outcome to
* report yet: the row is written inside the caller's transaction, so if the transaction rolls back
* the message never existed, and if it commits the relay will publish it later. Handing back a
* {@code PublishResult} here would be a lie about work that has not happened.
```
동시성 원시 요소는 하나 — **fencing token**. 그것이 `OutboxLease.token`이고 검사는 구현의 SQL `WHERE`에 있다(§12.1).
모든 record가 불변이다. 상태를 가진 클래스가 하나도 없다.
수명주기 참여 없음.
---
## 8. 설정·기능 플래그·환경 차이
설정 없음. 상수도 없다 — `ClaimCheckReference.SHA256` 정규식 하나가 private이다.
`OutboxRepository`의 두 `purge*` 메서드가 `limit` 파라미터를 갖는 것이 유일한 튜닝 지점이고, 그 이유가 javadoc에 있다.
```java
// :143-147
* <p>The unbounded version deletes everything before the cutoff in one statement. On a table that
* has been accumulating published rows since the last sweep that is a single long transaction
* holding locks and generating WAL in proportion to the backlog, which shows up as the relay and
* the business writes stalling behind retention. The cleanup jobs describe themselves as bounded
* by batch size; this is the parameter that makes that true.
```
`InboxRepository`도 같은 쌍을 갖는다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다 — 포트만 정의한다. 다만 **포트가 저장소 기술을 전제한다.**
- `InboxRepository.reserve`의 메커니즘이 "the uniqueness constraint on the inbox row"다 — 유니크 제약이 있는 저장소를 전제
- `OutboxRepository.claimBatch`의 의미가 "a record claimed by one relay is invisible to the others"다 — 행 잠금 또는 그에 준하는 것을 전제
- `OutboxTransitionResult.STALE_LEASE`가 "its update matches zero rows"에서 나온다 — 조건부 UPDATE의 영향 행 수를 셀 수 있는 저장소를 전제
세 전제 모두 javadoc에 있고 인터페이스 이름에는 없다. 구현 leaf 이름(`*-jdbc-postgresql`)이 실제 선택을 드러낸다.
---
## 10. 테스트 레인과 실제 증명 범위
**이 leaf에는 테스트가 없다.** `src/test` 디렉터리 자체가 존재하지 않는다 — `src` 아래에 `main`만 있다.
13개 타입 중 record 생성자 검증이 있는 것이 여섯(`ClaimCheckReference`, `InboxRecord`, `OutboxCanonicalMetadata`, `OutboxLease`, `OutboxRecord`, `OutboxTransitionResult`는 enum), 술어가 있는 것이 셋(`isExpired`, `expiredAt`, `isSafeToSettle`)이다. 그중 어느 것도 이 leaf의 레인에서 검증되지 않는다.
**검증은 전부 구현 leaf에서 일어난다.**
| 검증 위치 | 무엇을 |
|---|---|
| `messaging-outbox-jdbc-postgresql` 테스트 4개 | `OutboxRepository` 구현, 릴레이 |
| `messaging-inbox-jdbc-postgresql` 테스트 4개 | `InboxRepository` 구현, 멱등 핸들러 |
| `messaging-claim-check` 테스트 3개 | claim check |
| starter `MessagingOutboxRelayLifecycleTest` | 릴레이 수명주기 |
그 결과 이 leaf의 **계약 불변식**(예: `OutboxCanonicalMetadata``schemaUri` 없이 `schemaSubject` 금지, `OutboxLease``token >= 1`, `InboxResult.isSafeToSettle`의 세 값)은 구현이 우연히 그 경로를 지나갈 때만 실행된다.
**그리고 §12.1(c)가 보이듯, 실제 PostgreSQL 컨테이너 테스트는 production이 쓰지 않는 API 세대를 검증한다.**
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api"]` |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| vendor `api` 규칙 | 벤더 의존성 0 |
| **`APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`** | `..application..`이 이 leaf를 포함한 `dev.caskeleton.messaging..`을 참조하는 것을 금지. **규칙의 근거가 이 leaf의 `OutboxStatus.FAILED` 의미다** |
| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |
| ArchUnit 전용 규칙 | 없음 |
네 번째가 특이하다 — ArchUnit 규칙 하나가 **이 leaf의 enum 상수 의미**를 근거로 든다. 즉 이 leaf의 어휘가 저장소 경계 규칙의 일부다.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/289-reliability-api-two-generations.txt`.
### 12.1 Public surface reachability
| 타입 | leaf 밖 파일 | 판정 |
|---|---:|---|
| `OutboxRecord` | 13 | 활발 |
| `OutboxCanonicalMetadata` | 8 | 활발 |
| `OutboxRepository` | 7 | 구현 1 + 릴레이 + 테스트 |
| `OutboxStatus` | 7 | 활발 |
| `OutboxLease` | 6 | 활발 |
| `OutboxTransitionResult` | 6 | 활발 |
| `InboxRepository` | 6 | 구현 1 + 테스트 |
| `ClaimCheckReference` | 6 | 활발 |
| `InboxResult` | 2 | |
| `IdempotentMessageHandler` | 1 | `TransactionalInboxHandler` |
| `TransactionalMessageAction` | 1 | 같음 |
| **`InboxRecord`** | **0** | |
| **`ReliableMessagePublisher`** | **0** | |
**(a) Outbox 쓰기 진입점에 구현이 없다**
`ReliableMessagePublisher`는 애플리케이션이 outbox에 행을 넣는 유일한 선언된 방법이다. 구현이 0이고 참조도 0이다.
`OutboxRepository.append`는 존재하지만 그것은 저장소 포트다 — javadoc이 "must be callable inside the caller's business transaction"이라고 하므로 애플리케이션이 직접 부를 수도 있다. 그러나 `ReliableMessagePublisher`가 존재하는 이유는 애플리케이션이 저장소 포트를 직접 만지지 않게 하는 것이고, 그 층이 비어 있다.
**그리고 애플리케이션은 이 leaf를 참조할 수 없다**`APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`이 금지한다. 즉 `ReliableMessagePublisher`를 애플리케이션이 쓰려면 브리지 어댑터가 필요하고, 그 어댑터가 없다. `messaging-spring-cloud-stream-bridge`가 후보 이름이지만 그 leaf는 `runtime_memberships: []`다.
**(b) `InboxRecord`가 쓰이지 않는다**
`InboxRepository`의 어느 메서드도 `InboxRecord`를 주고받지 않는다 — `reserve``boolean`, `isProcessed``boolean`, `purge*``int`다. record는 "One row of the consumer inbox"를 서술하지만 그 행을 반환하는 API가 없다.
같은 leaf의 `OutboxRecord`는 정반대다 — `leaseBatch`/`find`가 반환하고 13개 파일이 쓴다. 두 record의 역할이 비대칭이다.
**(c) 컨테이너 테스트가 production이 쓰지 않는 API 세대를 검증한다**
`OutboxRepository`는 같은 다섯 전이에 대해 **두 세대**를 갖는다.
| 전이 | 구세대 (MessageId) | 신세대 (OutboxLease) |
|---|---|---|
| 배치 획득 | `leaseBatch(size, lease, now)``List<OutboxRecord>` | `claimBatch(owner, size, lease, now[, maxAttempts])``List<OutboxLease>` |
| 발행 확정 | `markPublished(MessageId, Instant)``void` | `markPublished(OutboxLease, Instant)``OutboxTransitionResult` |
| 모호 | `markAmbiguous(MessageId, String, Instant)``void` | `markAmbiguous(OutboxLease, ...)``OutboxTransitionResult` |
| 실패 | `markFailed(MessageId, String, Instant)``void` | `markFailed(OutboxLease, ...)``OutboxTransitionResult` |
| 반납 | `releaseLease(MessageId)``void` | `releaseLease(OutboxLease)``OutboxTransitionResult` |
| 소진 | — | `markExhausted(OutboxLease, String, Instant)` |
**production 릴레이는 신세대만 쓴다.**
```
OutboxRelay.java:158 repository.claimBatch(owner, batchSize, leaseDuration, now, scheduler.maxAttempts())
OutboxRelay.java:171 repository.markPublished(lease, now) == OutboxTransitionResult.APPLIED
OutboxRelay.java:189 repository.markExhausted(lease, reason, now)
OutboxRelay.java:192 repository.markAmbiguous(...)
OutboxRelay.java:205 repository.markFailed(...)
```
**실제 PostgreSQL 컨테이너 테스트는 구세대만 쓴다.**
```
OutboxPostgresIT.java:92,111,112,121,124,133,148,161 repository.leaseBatch(...)
OutboxPostgresIT.java:135 repository.markAmbiguous(record.messageId(), "CONFIRM_TIMEOUT", NOW)
OutboxPostgresIT.java:150,200 repository.markPublished(record.messageId(), NOW)
OutboxPostgresIT.java:163 repository.markFailed(record.messageId(), "INVALID_TOPIC", NOW)
```
**fencing token 경로가 실제 데이터베이스에 대해 한 번도 실행되지 않는다.** 그 경로의 정확성은 구현의 SQL `WHERE ... AND token = ?`이 영향 행 수를 정확히 세는지에 달려 있는데, 그것을 검증할 수 있는 유일한 레인이 다른 세대를 쓴다. 나머지 검증은 `InMemoryOutboxRepository`(`OutboxRelayTest:223`)와 `RecordingRepository`(`OutboxOperationsTest:23`) — 둘 다 SQL이 없는 fake다.
`OutboxLease` javadoc이 fencing token을 만든 이유로 든 사고("one message, published twice")가 정확히 그 SQL이 막는 것이다.
**이 판정의 소유권.** API 형태(두 세대 공존, `@Deprecated` 부재)는 이 leaf가 소유하고, **테스트 커버리지 판정은 `messaging-outbox-jdbc-postgresql` leaf가 소유한다.** 여기서는 관측과 교차 참조를 남긴다.
**(d) 구세대가 prose로만 deprecated다**
```java
// OutboxRepository.java:41-43
* <p>The token is what a terminal write is checked against. {@link #leaseBatch} returns records
* without one, so its callers cannot prove a write belongs to their claim; it remains for
* inspection paths and is deprecated for the relay's use.
```
`@Deprecated` 애노테이션이 **이 leaf 전체에 하나도 없다**(`git grep '@Deprecated' -- src/messaging/messaging-reliability-api` exit 1).
결과: 새 구현자가 17개 메서드를 전부 구현해야 하고, 그중 다섯은 fencing이 없는 형태다. 컴파일러가 경고하지 않으므로 새 호출자가 구세대를 고를 수 있고, 실제로 컨테이너 테스트가 그렇게 했다.
**(e) bounded purge 오버로드가 두 포트에 선언·구현돼 있고 호출 지점이 0이다**
> 이 항목은 `messaging-inbox-jdbc-postgresql` 분석 중에 확인됐다. 이 문서의 초판은 §17의 "확인된 설계"에 "purge에 `limit` 파라미터를 둔 것"을 넣었는데, 그것은 파라미터의 **존재**만 본 판정이었다. 호출 여부를 재측정해 정정한다.
`InboxRepository.purgeProcessedBefore(Instant, int)``OutboxRepository.purgePublishedBefore(Instant, int)`가 선언돼 있고 두 JDBC 구현이 `LIMIT`(inbox는 `FOR UPDATE SKIP LOCKED`까지)로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 **선언 2 + 구현 2 + 테스트 fake override 5**이고 **호출 지점이 하나도 없다**. 두 cleanup job이 무제한 오버로드를 부른다 — `InboxCleanupJob:56`, `OutboxCleanupJob:50`.
`OutboxRepository:140-151`의 javadoc이 그 상황을 예고한다.
> The unbounded version deletes everything before the cutoff in one statement. … which shows up as the relay and the business writes stalling behind retention. The cleanup jobs describe themselves as bounded by batch size; **this is the parameter that makes that true.**
그 파라미터를 아무도 넘기지 않는다. 판정은 `analysis/messaging/messaging-inbox-jdbc-postgresql.md` §17(P1)이 소유하고, 이 문서는 **포트가 두 오버로드를 나란히 노출했다는 것**을 기여한다 — (a)의 두 세대 전이와 같은 형태다.
### 12.2 Conditional sibling comparison
이 leaf에 bean은 없다. **구현 leaf 셋의 sibling 비교가 유의미하다.**
| 포트 | 구현 leaf | membership | starter bean |
|---|---|---|---|
| `OutboxRepository` | `messaging-outbox-jdbc-postgresql` | `["app-bootstrap"]` | `MessagingReliabilityAutoConfiguration` |
| `InboxRepository` | `messaging-inbox-jdbc-postgresql` | `["app-bootstrap"]` | 같음 |
| `IdempotentMessageHandler` | `messaging-inbox-jdbc-postgresql` | 같음 | `transactionalInboxHandler` bean |
| `ReliableMessagePublisher` | **없음** | — | — |
네 포트 중 셋이 구현·편입·조립을 모두 갖고 하나가 셋 다 없다. 비대칭이 명확하다.
### 12.3 Duplicate mechanism sweep
**(a) 같은 전이의 두 세대** — §12.1(c). 한 인터페이스 안의 중복이라는 점에서 이 저장소의 다른 중복(두 클래스, 두 leaf)과 형태가 다르다.
**(b) outbox 개념이 저장소에 둘 있다**
| | 이 leaf | `application-core` |
|---|---|---|
| 상태 enum | `OutboxStatus` | `OutboxEventStatus` |
| `FAILED`의 뜻 | 브로커가 확정적으로 거절 — **재시도 안 함** | (반대 의미, ArchUnit javadoc이 명시) |
| 행 타입 | `OutboxRecord` | `NewOutboxEvent` 등 |
| 사용처 | messaging family | application + persistence-jpa |
**의도된 분리다.** ArchUnit 규칙이 둘을 섞지 못하게 하고, 그 규칙의 `.because(...)`가 이유를 적는다 — "the two outbox status models mean opposite things under the same names". 중복 경쟁이 아니라 **명시적으로 격리된 두 모델**이다.
다만 그 결과 `ReliableMessagePublisher`가 쓰일 자리가 없다(§12.1a) — 애플리케이션은 자기 outbox 모델을 쓰고, 이 leaf의 진입점은 브리지 없이는 도달 불가다.
**(c) 이름 충돌 주의**
`markPublished`·`markFailed`·`releaseLease`라는 메서드 이름이 저장소의 **완전히 다른 인터페이스** 여러 곳에 있다 — `persistence-jpa``OutboxStoreAdapter`·`JpaCleanupQueue`·`JpaUploadSessionStore`, `cache-redis``RedisIdempotencyStoreAdapter`, `notification``JpaProviderEventLedger`. 단어 검색으로 이 leaf의 사용처를 세면 오탐이 대량 발생한다. §12.1(c)의 측정은 `src/messaging/**`로 범위를 좁혀 얻은 것이다.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `OutboxRepository:43`: `leaseBatch`가 "deprecated for the relay's use" | `@Deprecated` 0건, 컨테이너 테스트가 사용 | **미강제** |
| `OutboxRecord` javadoc: outbox만으로는 중복 제거 안 됨 | `InboxRepository`가 별도 존재 | **일치** |
| `InboxRepository.purge*` javadoc: 보존이 브로커 재전달 창보다 길어야 함 | 그 비교를 하는 코드 없음 | **미강제** |
| `TransactionalMessageAction` javadoc: 구현이 settle/publish/트랜잭션 시작 금지 | 타입이 강제하지 않음 | **미강제** |
| `ReliableMessagePublisher` javadoc: dual-write의 답 | 구현 0 | **미실현** |
| `OutboxTransitionResult.STALE_LEASE` javadoc: "it belongs on a metric" | 이 leaf에 메트릭 없음. outbox leaf가 답함 | **미확인** |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
이 leaf의 javadoc은 **세 개의 서로 다른 결함**을 보존한다.
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `OutboxLease` javadoc | 모든 terminal 전이가 `MessageId`만 받음 | lease를 넘긴 릴레이가 다른 릴레이의 `PUBLISHED` 위에 `AMBIGUOUS`를 기록 → 행이 다시 claim 가능해짐 → **한 메시지가 두 번 발행됨, 한 번만 발행하는 것이 목적인 시스템에서** |
| `OutboxTransitionResult` javadoc | 전이가 `void` 반환 | 0행 매치와 1행 매치가 구별 불가 → 릴레이는 기록했다고 믿고 행은 다른 상태이며 **그 불일치를 아무도 세지 않음** |
| `OutboxCanonicalMetadata` javadoc | provenance가 어디에도 없음 | 봉투 재구성 시 producer·tenant·correlation·causation·trace·schema가 전부 `Optional.empty()`가 되거나 헤더 맵에 예약 이름으로 밀반입 → **outbox를 지난 메시지가 다른 tenant·trace·correlation으로 도착**, 즉 발행 경로가 메시지의 의미의 일부가 됨 |
| `OutboxRepository.purgePublishedBefore` javadoc | 무제한 삭제 | 백로그에 비례하는 단일 긴 트랜잭션이 락과 WAL을 생성 → **릴레이와 업무 쓰기가 보존 작업 뒤에서 멈춤** |
첫 둘이 같은 사건의 두 측면이다 — fencing token(감지 수단)과 반환값(감지 결과의 전달 수단). 둘 다 있어야 stale lease가 관측된다.
세 번째의 마지막 문장이 이 저장소에서 가장 날카로운 진술 중 하나다 — **"which makes the publish path — direct, polling or CDC — part of the message's meaning."** 전달 경로가 메시지 내용을 바꾸면 그것은 더 이상 전달이 아니다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-294 | command | `evidence/raw/294-bounded-purge-never-called.txt` | bounded 오버로드의 호출 지점 0, 두 cleanup job의 실제 호출 | 정적 검색. `messaging-inbox-jdbc-postgresql`이 판정 소유 |
| EVD-289 | command | `evidence/raw/289-reliability-api-two-generations.txt` | `src/test` 부재, 13타입 정규화 참조 수, 소비자 0인 둘, 네 포트의 구현자, `OutboxRepository`의 두 세대 시그니처 전수, `@Deprecated` 0건, production 릴레이와 컨테이너 테스트가 쓰는 세대, ArchUnit 규칙의 근거 문구 | 정적 검색. 이 leaf에 실행할 테스트 레인이 없음 |
**이 leaf에는 test lane evidence가 없다**`src/test`가 존재하지 않으므로 `:messaging:messaging-reliability-api:test`는 실행할 소스가 없다.
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- outbox만으로 중복이 제거되지 않는 이유 — `OutboxRecord` javadoc
- fencing token이 필요한 이유와 이전 이중 발행 — `OutboxLease` javadoc
- 전이가 결과를 반환해야 하는 이유 — `OutboxTransitionResult` javadoc
- `AMBIGUOUS`가 실패의 한 종류가 아닌 이유, `EXHAUSTED``FAILED`와 다른 이유 — `OutboxStatus` javadoc
- provenance가 컬럼이어야 하는 이유와 기각된 대안(버전 봉투 인코딩) — `OutboxCanonicalMetadata` javadoc
- 두 반쪽의 소유자가 다른 이유 — `OutboxRecord` javadoc
- inbox 키가 (message, consumer)인 이유 — `InboxRecord`·`IdempotentMessageHandler` javadoc
- `InboxResult`가 셋인 이유 — 그 javadoc
- 예약이 부작용과 같은 트랜잭션이어야 하는 이유 — `InboxRepository`·`TransactionalMessageAction` javadoc
- `addToOutbox``void`인 이유 — `ReliableMessagePublisher` javadoc
- claim check digest와 만료가 필수인 이유 — `ClaimCheckReference` javadoc
- purge에 `limit`이 필요한 이유 — `OutboxRepository` javadoc
- inbox 보존이 재전달 창보다 길어야 하는 이유 — `InboxRepository` javadoc
**추론**
- `ReliableMessagePublisher` 구현이 없는 것은 애플리케이션이 자기 outbox 모델을 쓰고 브리지가 없기 때문이다 → **추론**. ArchUnit 금지와 두 모델의 공존은 관측이고 인과는 추론이다.
- `OutboxRecord.equals`가 다섯 필드만 보는 이유 → **미상**.
- `sha256`이 소문자만 받는 이유 → **미상**(다른 곳의 같은 규율에서 유추 가능하나 여기엔 없음).
- 구세대를 남긴 이유 → **부분 명시**("remains for inspection paths"). 제거 시점은 미상.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 13개 타입 817줄 전문의 계약과 불변식
- 이 leaf에 테스트가 하나도 없다는 것(`src/test` 부재)
- `ReliableMessagePublisher``InboxRecord`의 참조 0
- `OutboxRepository`가 같은 다섯 전이의 두 세대를 갖고 `@Deprecated`가 하나도 없다는 것
- production 릴레이가 신세대만, PostgreSQL 컨테이너 테스트가 구세대만 쓴다는 것
- 세 개의 이전 결함(fencing 부재, void 반환, provenance 부재)과 각각의 실패 형태
- `OutboxStatus.FAILED`의 의미가 저장소 ArchUnit 규칙의 근거라는 것
**확인하지 못한 것**
- **fencing token SQL이 실제 PostgreSQL에서 정확한지.** 그것을 검증할 레인이 다른 세대를 쓴다. `messaging-outbox-jdbc-postgresql` leaf가 이 판정을 소유한다.
- `STALE_LEASE`가 실제로 메트릭으로 나가는지 — 같은 leaf가 답한다.
- inbox 보존 기간이 실제 배포에서 브로커 재전달 창보다 긴지 — 비교하는 코드가 없다.
- `ReliableMessagePublisher`를 구현할 계획이 있는지, 아니면 애플리케이션 outbox 모델이 정본인지.
- `OutboxRecord.equals`의 좁은 비교가 어떤 코드에 의존되는지 — 컬렉션 연산에서 의미가 달라질 수 있다.
---
## 17. 손볼 것
### P2 — 한 인터페이스가 같은 전이의 두 세대를 갖고, 안전하지 않은 쪽에 `@Deprecated`가 없다
- **사실.** `OutboxRepository`가 다섯 전이 각각에 대해 `MessageId` 기반(반환 `void`)과 `OutboxLease` 기반(반환 `OutboxTransitionResult`) 두 형태를 선언한다. javadoc이 전자를 "deprecated for the relay's use"라고 부르지만 `@Deprecated` 애노테이션이 이 leaf 전체에 **0건**이다.
- **근거.** `evidence/raw/289` §E·§F.
- **왜 문제인가.** 전자에는 fencing이 없다 — `OutboxLease` javadoc이 그 부재가 만든 이중 발행 사고를 기록한다. 컴파일러가 경고하지 않으므로 새 호출자가 그것을 고를 수 있고, **실제로 PostgreSQL 컨테이너 테스트가 그렇게 했다**(§12.1c). 그리고 새 구현자는 17개 메서드를 전부 구현해야 하며 그중 다섯은 안전하지 않은 형태다.
- **확인 방법.** `git grep -n '@Deprecated' -- src/messaging/messaging-reliability-api` → 없음. `evidence/raw/289` §E.
- **후보.** (a) 구세대 다섯에 `@Deprecated`를 붙인다. (b) 검사 경로가 정말 필요하면 별도 인터페이스(`OutboxInspection`)로 분리한다. (c) 구세대를 제거하고 호출자를 옮긴다.
- **다음 단계.** **CASE 후보 + REFERENCE 후보.** "prose deprecation은 컴파일러가 읽지 않는다"가 재사용 가능한 기준이다.
### P2 — fencing token 경로가 실제 데이터베이스에 대해 실행되지 않는다
- **사실.** `OutboxRelay``claimBatch`/lease 기반 전이만 쓴다. `OutboxPostgresIT``leaseBatch`/`MessageId` 기반 전이만 쓴다. 신세대를 쓰는 다른 테스트는 `InMemoryOutboxRepository``RecordingRepository` — SQL이 없는 fake다.
- **근거.** `evidence/raw/289` §G.
- **왜 문제인가.** fencing의 정확성은 구현의 조건부 UPDATE가 영향 행 수를 정확히 세는지에 달려 있다. `OutboxTransitionResult.STALE_LEASE`는 "its update matches zero rows"에서 나오고, 그것은 SQL의 성질이지 Java의 성질이 아니다. in-memory fake는 그 SQL을 실행하지 않는다. 즉 **이중 발행을 막는 장치가 그것을 검증할 수 있는 유일한 환경에서 실행되지 않는다.**
- **확인 방법.** `evidence/raw/289` §G 재실행. `OutboxPostgresIT`에서 `claimBatch` 검색 → 없음.
- **후보.** 컨테이너 테스트를 신세대로 옮기고, stale lease 시나리오(두 릴레이, 만료 후 재claim)를 실제 DB에서 재현한다.
- **다음 단계.** **판정은 `messaging-outbox-jdbc-postgresql` leaf가 소유한다.** 여기서는 API 형태가 그 혼동을 가능하게 했다는 관측을 기여한다. **CASE 후보**(그 leaf).
### P2 — dual-write의 답이라고 선언한 진입점에 구현이 없다
- **사실.** `ReliableMessagePublisher`가 구현 0, 참조 0이다. javadoc은 "This is the answer to the dual-write problem"이라고 한다.
- **근거.** `evidence/raw/289` §B·§C·§D.
- **왜 문제인가.** `OutboxRepository.append`가 있으므로 outbox에 행을 넣을 방법이 없는 것은 아니다. 그러나 그 포트는 저장소 계약이고, `ReliableMessagePublisher`는 애플리케이션이 저장소를 직접 만지지 않게 하려고 존재한다. 그리고 **애플리케이션은 ArchUnit 규칙 때문에 이 leaf를 참조할 수 없으므로** 브리지 어댑터가 필요한데 그것이 없다. 즉 이 leaf의 Outbox 절반은 "릴레이가 읽는 쪽"만 배선돼 있고 "애플리케이션이 쓰는 쪽"이 비어 있다.
- **확인 방법.** `git grep -n -E 'implements .*ReliableMessagePublisher' -- src` → 없음.
- **후보.** (a) 브리지 어댑터를 만든다. (b) 애플리케이션 outbox 모델이 정본이면 이 인터페이스를 제거하거나 "파생 프로젝트가 구현하는 확장점"임을 명시한다.
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 "두 outbox 모델 중 어느 쪽이 정본인가"에 걸리고, 그 질문은 `application-core`와 cross-scope가 함께 답한다.
### P3 — 이 leaf에 테스트가 없다
- **사실.** `src/test` 디렉터리가 존재하지 않는다. 13개 타입의 record 생성자 검증 여섯과 술어 셋이 이 leaf의 레인에서 실행되지 않는다.
- **근거.** `evidence/raw/289` §A.
- **왜 문제인가.** 계약 불변식 중 일부는 구현이 우연히 지나가지 않으면 실행되지 않는다 — 예: `OutboxCanonicalMetadata``schemaUri` 있고 `schemaSubject` 없는 조합을 거절하는 것, `OutboxLease``token < 1`을 거절하는 것, `InboxResult.isSafeToSettle`의 세 값. 형제 leaf들은 전부 자기 테스트를 갖는다(`messaging-core-api` 79개, `messaging-policy` 42개 등).
- **확인 방법.** `ls src/messaging/messaging-reliability-api/src``main`만.
- **후보.** record 불변식과 세 술어를 겨냥한 단위 테스트를 추가한다.
- **다음 단계.** **REFERENCE 후보**(계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다).
### P3 — inbox 보존 규칙이 문서로만 있다
- **사실.** `InboxRepository.purgeProcessedBefore` javadoc이 "Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery arrives after its inbox row was pruned and is processed a second time"라고 한다. 그 비교를 하는 코드가 이 leaf에도 `messaging-policy`의 프로파일 검증기에도 없다.
- **근거.** 해당 javadoc. `DestinationProfileValidator` 16규칙 전수(재전달 창 관련 없음).
- **왜 문제인가.** 위반의 결과가 **부작용의 이중 실행**이다 — Inbox가 존재하는 이유 그 자체가 무효화된다. 그리고 위반이 조용하다: 짧은 보존은 정상 동작처럼 보이고 늦은 재전달이 올 때만 드러난다.
- **확인 방법.** `git grep -n -i 'redelivery window\|retention' -- 'src/messaging/**/*.java'`
- **후보.** 보존 설정과 브로커 재전달 창을 시작 시 비교하는 검증을 `messaging-policy`나 starter에 추가한다.
- **다음 단계.** **CASE 후보 + REFERENCE 후보**(두 시간 상수가 순서 관계를 가지면 그 관계를 시작 시 검사한다).
### P3 — 트랜잭션 계약 셋이 타입으로 강제되지 않는다
- **사실.** `OutboxRepository.append`가 호출자 트랜잭션 안, `InboxRepository.reserve`가 부작용과 같은 트랜잭션, `TransactionalMessageAction`이 자기 트랜잭션을 시작하지 않을 것 — 셋 다 javadoc 요구다.
- **근거.** 세 javadoc.
- **왜 문제인가.** `ReliableMessagePublisher``void` 반환으로 계약의 일부를 타입에 담았다("Handing back a `PublishResult` here would be a lie"). 나머지 셋에는 그런 장치가 없고, 위반의 결과가 조용하다 — `InboxRepository.reserve`를 별도 트랜잭션에서 부르면 "exactly the gap the Inbox exists to close"가 다시 열린다.
- **확인 방법.** 세 javadoc과 구현의 `@Transactional` 배치 대조 — 구현 leaf가 소유한다.
- **후보.** 구현 leaf가 트랜잭션 참여를 검증하는 테스트를 두거나, ArchUnit으로 `append`/`reserve` 호출부의 트랜잭션 컨텍스트를 검사한다.
- **다음 단계.** **REFERENCE 후보**(호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다).
### P3 — `OutboxRecord.equals`가 다섯 필드만 비교하고 이유가 없다
- **사실.** `equals`/`hashCode``messageId`·`status`·`attempts`·`payload` 넷만 본다. `destination`·`metadata`·`createdAt`·`leaseExpiresAt`·`lastFailureCode`는 무시한다.
- **근거.** `OutboxRecord.java:114-126`.
- **왜 문제인가.** record 기본 동작을 좁힌 것이고, 배열 필드 때문에 재정의가 필요한 것까지는 명확하다(`messaging-schema-api``EncodedMessage`도 같다). 그러나 `EncodedMessage`는 **모든 필드**를 비교하고 이쪽은 아니다. 같은 `messageId`·`status`·`attempts`·`payload`를 가진 두 행이 다른 목적지·다른 provenance를 가져도 같다고 판정된다. 컬렉션 연산이나 테스트 단언에서 의미가 달라진다.
- **확인 방법.** 두 record의 `equals` 대조.
- **후보.** 전 필드 비교로 바꾸거나 좁힌 이유를 javadoc에 적는다.
- **다음 단계.** **REFERENCE 후보**(record의 `equals`를 좁히면 이유를 적는다).
### P3 — 포트가 bounded/unbounded purge 두 오버로드를 나란히 노출하고, 호출자가 무제한 쪽을 고른다
- **사실.** `InboxRepository``OutboxRepository`가 각각 `purge*Before(Instant)``purge*Before(Instant, int)`를 선언한다. 후자에 호출 지점이 0이고 두 cleanup job이 전자를 부른다.
- **근거.** `evidence/raw/294-bounded-purge-never-called.txt`.
- **왜 문제인가.** §12.1(a)의 두 세대 전이와 같은 형태다 — **한 인터페이스가 안전한 형태와 그렇지 않은 형태를 나란히 두고, `@Deprecated`도 이름 차이도 없으며, 호출자가 짧은 쪽을 골랐다.** 두 경우 모두 포트의 형태가 오용을 가능하게 했다.
- **확인 방법.** `git grep -n -E 'purge(Processed|Published)Before\s*\([^)]*,' -- 'src/**/*.java'`
- **다음 단계.** 판정은 `analysis/messaging/messaging-inbox-jdbc-postgresql.md` §17(P1)이 소유한다. 여기서는 포트 형태의 기여만 남긴다. §12.1(a)와 **같은 CASE로 묶을 후보**다.
### 확인된 설계(문제 아님)
- outbox만으로 중복이 제거되지 않는다는 것을 타입 javadoc이 직접 말하는 것
- fencing token과 전이 결과 반환값이 함께 있어야 stale lease가 관측된다는 설계
- `AMBIGUOUS`/`FAILED`/`EXHAUSTED` 세 상태의 구분과 각각의 운영 행동 차이
- `InboxResult`가 셋이고 `isSafeToSettle()`이 그 판단을 모으는 것
- inbox 키가 (message, consumer)인 것
- provenance를 컬럼으로 두고 대안(버전 봉투 인코딩)을 명시적으로 기각한 것
- `withStatus``messageId`를 파라미터로 받지 않아 전이가 신원을 바꿀 수 없는 것
- `addToOutbox``void` 반환이 계약인 것
- claim check의 digest와 만료가 필수인 것
- 두 outbox 모델을 ArchUnit으로 격리한 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MRA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 1개, memberships `["app-bootstrap"]` | 선언 |
| MRA-002 | build | `messaging-reliability-api/build.gradle` | same | 벤더 의존성 0 | — |
| MRA-003 | code | `.../reliability/OutboxRepository.java` 전문 | same | 두 세대 17메서드, purge limit 이유 | `@Deprecated` 없음 |
| MRA-004 | code | `.../reliability/OutboxLease.java` | same | fencing token과 이중 발행 이력 | — |
| MRA-005 | code | `.../reliability/OutboxTransitionResult.java` | same | void 반환이 삼킨 것 | — |
| MRA-006 | code | `.../reliability/OutboxStatus.java` | same | 여섯 상태와 두 구분의 이유 | — |
| MRA-007 | code | `.../reliability/OutboxCanonicalMetadata.java` | same | provenance 결함 이력, 기각된 대안 | — |
| MRA-008 | code | `.../reliability/OutboxRecord.java` | same | 두 반쪽 분리, 방어 복사, 좁은 equals | equals 이유 없음(§17) |
| MRA-009 | code | `.../reliability/{InboxRepository,InboxRecord,InboxResult}.java` | same | 트랜잭션 계약, (message,consumer) 키, 세 판정 | `InboxRecord` 참조 0 |
| MRA-010 | code | `.../reliability/{IdempotentMessageHandler,TransactionalMessageAction}.java` | same | 멱등 핸들러 계약과 세 금지 | 금지 미강제 |
| MRA-011 | code | `.../reliability/{ReliableMessagePublisher,ClaimCheckReference}.java` | same | dual-write 답, digest 필수 | publisher 구현 0 |
| MRA-012 | cross-leaf code | `messaging-outbox-jdbc-postgresql/.../OutboxRelay.java:158-205` | same | production이 신세대만 사용 | 해당 leaf SSOT가 소유 |
| MRA-013 | cross-leaf test | `messaging-outbox-jdbc-postgresql/.../OutboxPostgresIT.java:92-200` | same | 컨테이너 테스트가 구세대만 사용 | 해당 leaf SSOT가 소유 |
| MRA-014 | architecture test | `src/app-bootstrap/.../CleanArchitectureTest.java:229-240` | same | `OutboxStatus.FAILED` 의미가 규칙의 근거 | 정적 분석 |
| EVD-289 | command | `evidence/raw/289-reliability-api-two-generations.txt` | same | §12.1 전부, `src/test` 부재 | 정적 검색. 이 leaf에 테스트 레인 없음 |
@@ -0,0 +1,807 @@
# messaging-runtime-core 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-runtime-core`
> SSOT owner: `messaging-runtime-core`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-runtime-core`
- canonical state `analysisFile`: `analysis/messaging/messaging-runtime-core.md`
- source path: `src/messaging/messaging-runtime-core`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api", "messaging-policy", "messaging-transport-spi", "messaging-security", "messaging-observability"]` — messaging family에서 두 번째로 많은 의존
- registry `runtime_memberships`: `["app-bootstrap"]`
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | **6** |
| production LOC | 787 |
| 패키지 | 1 (`dev.caskeleton.messaging.runtime`) |
| test 파일 | 4 (테스트 3 + fixture 1) |
| test 메서드(실행 확인) | 21 |
| 외부(비프로젝트) 의존성 | **0** |
여섯 클래스:
| 클래스 | LOC | 역할 | 출하 조립 |
|---|---:|---|---|
| `DefaultMessagePublisher` | 366 | **유일한 발행 경로** | o (`:446`) |
| `DefaultDeliveryProcessor` | 155 | 핸들러 결과 → 정산 | **x** |
| `RegisteredMessageCodecs` | 89 | content type → codec | o (`:363`) |
| `DestinationProfileRegistry` | 62 | 논리 이름 → 프로파일 | o (`:377`) |
| `TransportMessagingRuntime` | 67 | transport를 세대로 포장 | o (`:476`) |
| `DeclaredDestinationAccess` | 48 | 기본 접근 정책 | o |
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |
| `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 및 단언 확인 |
| `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
**이 leaf는 조립 결함 하나를 고치기 위해 만들어졌다.** 여섯 파일 중 다섯의 javadoc이 "X was an interface with no implementation" 형태로 시작한다. `build.gradle`이 그 사정을 파일 맨 위에 적는다.
```groovy
// The central publish and delivery orchestration.
//
// MessagePublisher was an interface with no implementation anywhere in the new platform: the
// brokers implemented MessagingTransport, the core auto-configuration built dead-letter and facade
// beans on top of a publisher bean that nothing supplied, and admission, security, runtime leases
// and observation existed as beans that no publish path ever called. A starter that filled the gap
// with an application-supplied fake would pass a context test while running none of them.
```
이 진단의 마지막 문장이 핵심이다 — **컨텍스트 테스트를 통과하면서 아무것도 실행하지 않는 조립**이 가능했다는 것. 이 저장소가 반복해서 만나는 형태다.
six 파일이 메운 구멍:
| 인터페이스(소유 leaf) | 구현이 없었음 | 이 leaf가 채운 것 |
|---|---|---|
| `MessagePublisher` (core-api) | 어디에도 없음 | `DefaultMessagePublisher` |
| `MessageCodecRegistry` (schema-api) | 어디에도 없음 | `RegisteredMessageCodecs` |
| `MessagingRuntime` (transport-spi) | 어디에도 없음 | `TransportMessagingRuntime` |
| (없음) 논리이름→프로파일 해석 | 아무도 하지 않음 | `DestinationProfileRegistry` |
| `DestinationAccessPolicy` 기본값 (security) | `denyAll()`뿐 | `DeclaredDestinationAccess` |
| `HandleResult` → 정산 (core-api) | 어댑터가 각자 결정 | `DefaultDeliveryProcessor` |
여섯 중 다섯은 배선됐고 마지막 하나(`DefaultDeliveryProcessor`)는 배선되지 않았다(§12.1).
---
## 2. 의존성과 런타임 배선
들어오는 것: 여섯 project 의존, 전부 `api`. `DefaultMessagePublisher` 한 클래스가 그중 다섯을 생성자로 받으므로 `api`가 맞다.
나가는 것: `messaging-spring-boot-starter`만.
**배선 지점 다섯**(전부 `MessagingCoreAutoConfiguration`):
| 라인 | 무엇 |
|---:|---|
| 363 | `RegisteredMessageCodecs.of(JacksonMessageCodec.of(...))` |
| 377 | `DestinationProfileRegistry.of(destinations.all())` |
| 446 | `new DefaultMessagePublisher(destinations, access, codecs, admission, runtimes, transport)` |
| 476 | `new TransportMessagingRuntime(selected.brokerName(), 1L, selected)``InitializingBean` 안 |
| — | `DeclaredDestinationAccess.of(...)`로 접근 정책 bean |
446의 인자가 **여섯 개**라는 것이 §12.1의 관측 지점이다.
---
## 3. 패키지/컴포넌트 지도
```
발행 (조립됨)
DefaultMessagePublisher
├── DestinationProfileRegistry 논리 이름 → DestinationProfile
├── DestinationAccessPolicy ← DeclaredDestinationAccess.of(profiles)
├── MessageCodecRegistry ← RegisteredMessageCodecs
├── MessagingAdmissionController (policy)
├── MessagingRuntimeRegistry (transport-spi) → TransportMessagingRuntime
├── MessagingTransport (transport-spi) → Kafka/Rabbit/…
└── MessagingObservation ← NO_OBSERVATION (§12.1)
소비 (조립 안 됨)
DefaultDeliveryProcessor
├── Function<MessageEnvelope<EncodedMessage>, HandleResult>
├── DeadLetterPublisher (내부 함수형 인터페이스)
└── OneShotSettlement → TransportSettlement
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `DefaultMessagePublisher` — 순서가 계약이다
```java
// :40-49
* <p>The order below is fixed, not composed from a map of interceptors. Each stage's position is a
* decision:
*
* <ul>
* <li>destination and access first, so an unauthorized publish never encodes a payload;
* <li>encoding before admission, because the admission bound is on bytes and the byte count is
* not known until the payload is encoded;
* <li>the runtime lease last before the send, so a rotation cannot swap the transport underneath
* a message that has already been counted against the in-flight limit.
* </ul>
```
실제 순서 여덟 단계:
| # | 단계 | 실패 시 |
|---:|---|---|
| 1 | `destinations.require(name)` | `DESTINATION_NOT_REGISTERED``REJECTED` |
| 2 | `requireSupportedOptions(profile, options)` | `PUBLISH_DEDUPLICATION_UNSUPPORTED``REJECTED` |
| 3 | `access.mayPublish(name)` | `PUBLISH_FORBIDDEN``REJECTED` (**인코딩 전**) |
| 4 | `encode(message)` | `PUBLISH_PREPARATION_FAILED``REJECTED` |
| 5 | 남은 예산 확인 | `PUBLISH_DEADLINE_EXCEEDED``REJECTED` |
| 6 | `admission.admit(name, bytes)` | 예외 전파(`MessageTooLargeException`/`MessageBackpressureException`) |
| 7 | `runtimes.acquire(broker)` | `PUBLISH_RUNTIME_UNAVAILABLE``REJECTED` |
| 8 | `transport.publish(...)` + 마감 | 타임아웃 → `AMBIGUOUS` / 그 외 예외 → `AMBIGUOUS` |
**17은 전부 `REJECTED`, 8만 `AMBIGUOUS`다.** 그 경계가 정확히 "바이트가 프로세스를 떠났는가"다.
```java
} catch (RuntimeException beforeTheWire) {
// Nothing left this process, so the outcome is definite. Reporting it as ambiguous would send
// the caller into reconciliation for a message no broker ever saw.
return rejected("PUBLISH_PREPARATION_FAILED", sanitized(beforeTheWire), startedAt);
}
```
`messaging-core-api`의 3상태(§4.1)가 여기서 실제 분기가 된다. 그리고 `rejected(...)`가 만드는 `PublishResult``PublishEvidence.notTransmitted()`를 쓰므로 `PublishResult` 생성자의 14가지 금지 조합 검증을 자연히 통과한다.
**3번이 4번보다 먼저인 이유**가 인라인 주석에 있다.
```java
// Before encoding: an unauthorized publish must not serialise the payload, because the
// encoded bytes are what a claim-check or a log would then be holding.
```
### 4.2 예산은 호출 시점부터 센다
```java
// :131-138
* <p>Measured from the call, not from the send. {@code PublishOptions.timeout()} is documented as
* the publish operation's deadline, so a slow destination lookup or a large encode spends the
* same budget the broker wait does; timing only the transport call would let the total exceed the
* deadline by however long preparation took.
```
`remainingBudget``timeout - elapsedSince(startedAt)`이고, 0 이하면 전송 전에 `REJECTED`로 끝낸다 — "Sending anyway would start a message the caller has already stopped waiting for."
### 4.3 마감을 복사본에 건다
```java
// :143-154
* <p>The bound is applied to a copy so that expiry never completes the transport's own stage: the
* adapter still owns its in-flight publish and its own bookkeeping. The permit and the runtime
* lease are released when the copy completes, which is deliberate holding them until a stalled
* broker answers is how a rotation waits forever on a generation nobody is using.
private static CompletableFuture<TransportPublishResult> withDeadline(
CompletionStage<TransportPublishResult> inFlight, Duration remaining) {
return inFlight.toCompletableFuture().copy()
.orTimeout(remaining.toMillis(), TimeUnit.MILLISECONDS);
}
```
`.copy()`가 핵심이다. `orTimeout`을 원본에 걸면 만료가 어댑터의 stage를 완료시켜 어댑터의 자기 정리가 깨진다. 복사본에 걸면 만료는 이쪽 경로만 끝내고 어댑터는 자기 in-flight를 계속 소유한다.
그 대가도 명시돼 있다 — permit과 lease는 **복사본이 완료될 때** 반납되므로, 브로커가 나중에 응답해도 이미 반납된 상태다. 그것이 의도다("holding them until a stalled broker answers is how a rotation waits forever").
### 4.4 획득한 것은 모든 경로에서 정확히 한 번 반납된다
```java
// :51-53
* <p>Everything acquired is released exactly once, on every path success, failure, exception and
* cancellation. A permit or lease that leaks on the failure path is a limiter that shrinks by one
* per failure until it stops accepting anything.
```
두 경로가 있다.
```java
.handle((result, failure) -> {
// One release per acquisition, whatever happened.
held.close();
admission.complete(destination.name().value());
...
});
```
```java
} catch (RuntimeException beforeTheSend) {
if (lease != null) { lease.close(); }
admission.complete(destination.name().value());
return rejected("PUBLISH_RUNTIME_UNAVAILABLE", ...);
}
```
`handle``whenComplete`와 달리 실패를 삼키고 값을 반환하므로 두 경우가 한 블록에서 처리된다. `lease.close()``MessagingRuntimeLease` 계약상 멱등이고(`transport-spi` §4.1), `admission.complete`도 미보유 목적지에 대해 무해하다(`messaging-policy` §4.3).
**한 가지 비대칭.** 6번(`admit`)이 예외를 던지면 그 예외가 그대로 호출자에게 전파된다 — `try` 블록 밖이다. 다른 모든 실패는 `PublishResult`로 정규화되는데 admission 실패만 예외다. `MessageTooLargeException`·`MessageBackpressureException``MessagingException`이므로 호출자가 `FailureDescriptor`를 얻을 수 있지만, 반환 타입이 `CompletionStage<PublishResult>`인 메서드가 **동기적으로 throw**한다. §17.
### 4.5 `requireSupportedOptions` — 조용한 no-op을 막는다
```java
// :65-72
* <p>The transports accept {@code request.options()} and read nothing from it, so an option this
* destination cannot honour has to be refused here or it is honoured nowhere. A caller asking for
* broker-side deduplication got a publish with no deduplication and no error, and then skipped
* the idempotency it would otherwise have written which is exactly the case {@code
* PublishDeduplication}'s own javadoc says must be a startup failure rather than a silent no-op.
```
`messaging-core-api``PublishDeduplication` javadoc("Requesting this on a broker without the `deduplicatedPublish` capability is a startup failure, not a silent no-op")이 여기서 실제 검사가 된다. 다만 **startup이 아니라 publish 시점**이다 — javadoc이 요구한 시점과 실제 시점이 다르다. §17.
그리고 "The transports accept `request.options()` and read nothing from it"은 이 leaf가 관측한 어댑터 쪽 사실이다. 어댑터 leaf SSOT들이 그것을 확인해야 한다.
### 4.6 `encode` — 폴백이 기본 codec이다
```java
private <T> MessageEnvelope<EncodedMessage> encode(MessageEnvelope<T> message) {
MessageCodec codec = codecs.find(message.contentType()).orElseGet(codecs::defaultCodec);
...
}
```
봉투의 content type에 맞는 codec이 없으면 기본 codec으로 인코딩한다. **content type을 무시하는 폴백**이다 — 봉투가 `application/avro`를 선언해도 registry에 Avro codec이 없으면 JSON으로 인코딩되고, `EncodedMessage`의 content type은 codec이 정하므로(`ContentType.JSON`) 봉투 선언과 실제 인코딩이 갈라진다. 그리고 출하 registry에는 JSON 하나뿐이다(`analysis/messaging/messaging-schema-json.md` §2). §17.
`RegisteredMessageCodecs.defaultCodec()`이 raw bytes일 수 없다는 것은 그 클래스가 생성자에서 강제한다(§4.8).
### 4.7 `DestinationProfileRegistry` — 폴백 없는 조회
```java
// :13-18
* <p>Nothing resolved a logical destination to a profile before this: the brokers took an
* already-resolved {@code DestinationProfile} and the publisher that would have produced one did
* not exist. A registry rather than a lookup with a fallback, because a destination nobody declared
* has no physical name, no ordering guarantee and no payload bound publishing to it would mean
* inventing all three at the call site.
```
`require`가 미등록 목적지에 `MessagingConfigurationException("DESTINATION_NOT_REGISTERED")`을 던지고 메시지가 세 가지 부재를 나열한다. `empty()` factory도 있다 — "every publish is refused until a destination is declared".
### 4.8 `RegisteredMessageCodecs` — 기본 codec은 명시 선택
```java
// :18-27
* <p>The default codec is a deliberate choice rather than "the first one registered". Selecting one
* by iteration order means the encoding a message is written with depends on how the map was
* populated, which is a wire-format decision made by accident. The registry takes it explicitly and
* refuses to be constructed without it.
*
* <p>The raw-bytes codec is never eligible as the default that is the contract's own rule, and
* the reason is that raw bytes silently disable schema validation for every destination that forgot
* to declare an encoding.
```
두 가지를 생성자에서 거절한다.
```java
if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) { throw ... }
...
MessageCodec existing = into.putIfAbsent(codec.contentType(), codec);
if (existing != null && existing != codec) {
// Two codecs for one content type is not a preference to resolve at runtime: whichever wins
// decides how bytes on the wire are read by a consumer that was compiled against the other.
throw new IllegalArgumentException("two codecs claim content type " + ...);
}
```
**클래스가 아니라 content type으로 raw-bytes를 거절**하는 것이 `messaging-schema-api`의 규칙보다 넓다 — 그 leaf §12.2가 소유한다.
### 4.9 `TransportMessagingRuntime` — 얇은 포장
`MessagingRuntime` 구현으로 `brokerName`·`generation`·`transport` 셋을 들고 `close()`가 CAS로 멱등이다.
```java
// close():61-62
// Idempotent: the registry closes a drained generation, and a context shutdown may close it
// again. Closing a transport twice is not an error worth propagating into shutdown.
```
`DefaultMessagingRuntimeRegistry`(transport-spi)도 자체 `closed` CAS를 갖는다 — **두 층이 각각 멱등**이다. 중복 방어이지만 `transport-spi``Generation.forceClose()`가 이미 한 번만 부르므로 이쪽 CAS는 컨텍스트 종료 경로를 위한 것이다.
**generation이 항상 `1L`이다.** starter의 유일한 설치 지점(`:476`)이 리터럴 `1L`을 넘긴다. `MessagingRuntime.generation()` javadoc은 "increasing with each replacement"라고 하고, `TransportMessagingRuntime` javadoc은 "the credential generation a rotation increments"라고 한다. 회전 코드가 없으므로 항상 1이다. §17.
### 4.10 `DeclaredDestinationAccess` — 기본값의 세 번째 선택지
```java
// :13-32
* <p>{@link DestinationAccessPolicy} is three sets of destination names and has a {@code denyAll()}
* factory. Neither is a usable default on its own:
*
* <ul>
* <li><b>Deny everything</b> and the platform assembles, starts, and refuses every publish
* <li><b>Allow everything</b> and the check is decoration.
* </ul>
*
* <p>So the default is neither: <b>a deployment may publish to the destinations it declared.</b>
* a message to a destination nobody declared is not an access-control edge case, it is a typo or
* a module reaching past its own contract.
*
* <p>Consume and administer stay empty. A publisher's default has no business granting either, and
* a deployment that needs them replaces this bean which is the point of it being a bean.
```
**publish만 허용하고 consume·administer는 빈 집합**이다. 이것이 §12.1의 소비 경로 미조립과 정합적이다 — 기본 접근 정책이 소비를 허용하지 않는다.
### 4.11 `DefaultDeliveryProcessor` — 두 규칙 (미조립)
```java
// :27-36
* <li><strong>One terminal call.</strong> A delivery is acknowledged, requeued or discarded once.
* A second call is a programming error acknowledging after a requeue tells the broker the
* message is done while a copy is already in flight.
* <li><strong>Dead-letter before acknowledgement.</strong> The source is acknowledged only after
* the dead-letter publish is confirmed.
```
`OneShotSettlement``AtomicBoolean` CAS로 한 번을 강제하고, 두 번째 호출은 `CompletableFuture.failedFuture(IllegalStateException)`을 반환한다 — 예외를 던지지 않고 stage로 보고한다.
핸들러 예외 처리에 이전 결함이 기록돼 있다.
```java
} catch (RuntimeException handlerFailed) {
// A handler that threw is a retry, not a discard. Treating an exception as "this message is
// undeliverable" is how a transient bug in one consumer silently drops a day of traffic —
// and it is exactly what the Rabbit consumer did by folding handler exceptions into its
// deserialization-failure path.
return settlement.requeue(retryDelay);
}
```
`result == null`도 requeue다. 그런데 그것을 서술하는 `missingResult()` 정적 메서드가 있고 **아무도 부르지 않는다**`HANDLER_RETURNED_NOTHING` 코드가 만들어지지만 어떤 경로도 그 descriptor를 사용하지 않는다. §17.
DLQ 분기의 두 주석이 trade를 명시한다.
```java
? settlement.acknowledge() // Confirmed: the message exists somewhere else, so removing it here is safe.
: settlement.requeue(retryDelay); // Not confirmed — rejected or ambiguous. Requeueing risks a
// duplicate; acknowledging loses the message outright, and a
// duplicate is the recoverable half of that choice.
```
`messaging-policy``DeadLetterOrchestrator`가 같은 불변식을 다른 형태로 구현한다(§12.3).
---
## 5. 주요 실행 경로
**발행(조립됨):** §4.1의 8단계.
**소비(미조립):** `TransportDelivery``handler.apply(envelope)``HandleResult` 4분기 → `OneShotSettlement`로 정확히 한 번 정산.
**세대 설치(조립됨):** `InitializingBean``transport.getIfAvailable()` → null이면 조용히 반환(이유가 주석에 있음) → `new TransportMessagingRuntime(brokerName, 1L, transport)``runtimes.install(...)`.
---
## 6. 실패 경로와 복구/번역
`DefaultMessagePublisher`가 만드는 결과:
| 코드 | completion | category | 언제 |
|---|---|---|---|
| `PUBLISH_FORBIDDEN` | `REJECTED` | `CONFIGURATION` | 접근 정책 거부 |
| `PUBLISH_PREPARATION_FAILED` | `REJECTED` | `CONFIGURATION` | 해석·인코딩 중 예외 |
| `PUBLISH_DEADLINE_EXCEEDED` | `REJECTED` | `CONFIGURATION` | 전송 전 예산 소진 |
| `PUBLISH_RUNTIME_UNAVAILABLE` | `REJECTED` | `CONFIGURATION` | lease 획득 실패 |
| `PUBLISH_DEADLINE_EXCEEDED` | `AMBIGUOUS` | `AMBIGUOUS` | 전송 후 마감 |
| `PUBLISH_OUTCOME_UNKNOWN` | `AMBIGUOUS` | `AMBIGUOUS` | 전송 후 그 외 실패 |
같은 코드 `PUBLISH_DEADLINE_EXCEEDED`**두 completion에 쓰인다.** 전송 전이면 `REJECTED`, 후면 `AMBIGUOUS`다. 코드만 보는 대시보드는 두 경우를 구분할 수 없다 — completion을 함께 봐야 한다. §17.
`sanitized(Throwable)`가 메시지가 아니라 **타입 이름만** 남긴다.
```java
// :175-180
* <p>A driver message can carry a routing key, a payload fragment or a connection string, and a
* {@code FailureDescriptor} is designed to be logged and exported.
return cause.getClass().getSimpleName();
```
`messaging-core-api``FailureDescriptor` javadoc("no payload, no stack trace, no credential")과 같은 관심사다.
`isDeadline``sanitized` 둘 다 `CompletionException`을 한 겹 벗긴다 — 비동기 경로에서 원인이 감싸지기 때문이다.
`DefaultDeliveryProcessor`는 예외를 던지지 않는다. 이중 정산만 `failedFuture`로 보고한다.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
| 지점 | 도구 | 보호 |
|---|---|---|
| `OneShotSettlement.settled` | `AtomicBoolean` CAS | 정확히 한 번 정산 |
| `TransportMessagingRuntime.closed` | `AtomicBoolean` CAS | 정확히 한 번 transport close |
| `RegisteredMessageCodecs.byContentType` | `Map.copyOf` | 불변 |
| `DestinationProfileRegistry.profiles` | `Map.copyOf` | 불변 |
| `withDeadline``.copy()` | `CompletableFuture` | 어댑터 stage와 이쪽 경로 분리 |
`DefaultMessagePublisher` 자체는 불변이고 상태를 갖지 않는다 — 필드 여덟이 전부 final 협력자다. `lease`만 메서드 지역 변수이고 `handle` 람다가 `held`라는 effectively-final 복사본으로 캡처한다.
수명주기 참여는 `TransportMessagingRuntime.close()`뿐이고, 그것을 부르는 것은 registry(회전 시)와 컨텍스트 종료 두 경로다.
---
## 8. 설정·기능 플래그·환경 차이
설정 없음. 이 leaf의 모든 값은 생성자 인자다.
**주입 가능한 두 지점**이 테스트 가능성을 만든다.
| 인자 | 기본 | 목적 |
|---|---|---|
| `LongSupplier nanoTime` | `System::nanoTime` | 경과 시간을 sleep 없이 테스트 |
| `MessagingObservation observation` | `NO_OBSERVATION` | 관측 주입 |
두 번째의 기본값이 §12.1의 발견 지점이다.
`TransportMessagingRuntime``generation`은 생성자 인자이고 유일한 호출자가 `1L`을 넘긴다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. 브로커 접촉은 `MessagingTransport` 인터페이스 뒤에 있다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-runtime-core:test`. **BUILD SUCCESSFUL, 21 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `DefaultMessagePublisherTest` | 10 | 8단계 순서, 각 실패의 completion·code, 마감 전후 구분, permit/lease 반납, 관측 호출 | 실제 브로커. **출하 조립이 관측을 넘기는지** |
| `DefaultDeliveryProcessorTest` | 7 | `HandleResult` 4분기 → 정산, 핸들러 예외 → requeue, DLQ 확인 후 ack / 미확인 시 requeue, 이중 정산 거절 | **production에서 호출되는지**(§12.1) |
| `RegisteredMessageCodecsTest` | 4 | raw-bytes 기본 거절, content type 충돌 거절, 조회 | — |
`DefaultMessagePublisherTest:271`이 익명 `MessagingObservation`을 만들어 관측 호출을 확인한다. 즉 **테스트는 8인자 생성자를 쓰고 출하는 6인자를 쓴다.** 테스트가 검증하는 경로와 출하되는 경로가 이 인자 하나만큼 다르다.
`RecordingTransport`(`:426`)가 `MessagingTransport`를 구현해 전송을 대체한다. 그래서 이 레인은 "발행 오케스트레이션이 옳다"를 증명하고 "어댑터가 계약을 지킨다"는 증명하지 않는다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | 여섯 project 의존 |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| vendor `api` 규칙 | 벤더 의존성 0 |
| ArchUnit | 전용 규칙 없음 |
`MessagingStarterOffContractTest`(starter leaf)가 이 leaf의 조립 이력을 문자열로 언급한다 — "DeadLetterOrchestrator had nothing to depend on. DefaultMessagePublisher …". 그 테스트가 무엇을 실제로 강제하는지는 starter leaf SSOT가 소유한다.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/283-runtime-core-observation-noop.txt`.
### 12.1 Public surface reachability
| 타입 | leaf 밖 파일 | 출하 조립 |
|---|---:|---|
| `DefaultMessagePublisher` | 2 | **o**`MessagingCoreAutoConfiguration:446` |
| `TransportMessagingRuntime` | 1 | **o**`:476` |
| `RegisteredMessageCodecs` | 1 | **o**`:363` |
| `DestinationProfileRegistry` | 1 | **o**`:377` |
| `DeclaredDestinationAccess` | 1 | **o** |
| `DefaultDeliveryProcessor` | **0** | **x**`src/main` 생성 0, `src/test` 1 |
**(a) 소비 경로의 유일한 오케스트레이터가 조립되지 않는다**
`DefaultDeliveryProcessor`는 leaf 밖 참조가 0이고 `src/main`에서 생성되지 않는다. 이것이 `analysis/messaging/messaging-policy.md` §12.1이 관측한 "소비 경로 전체 미조립"의 중심이다 — 어댑터의 consumer registrar들도, 재시도 실행자도, DLQ 발행자도 전부 조립되지 않는다.
이 클래스의 javadoc은 자기가 **고친** 문제를 서술한다 — "Each broker adapter decided for itself what a retry or a dead-letter meant, so '_the platform decides when and in what order the settlement happens_' … described a decision nobody made in one place." 그 결정을 한 곳에 모았고, 그 한 곳이 배선되지 않았다.
**(b) 관측이 구현·호출부·인자를 모두 갖추고도 no-op이다**
네 조각이 있다.
| 조각 | 상태 |
|---|---|
| `MessagingObservation` 인터페이스 (observability) | 존재 |
| `MessagingMetrics implements MessagingObservation` | 존재 |
| `DefaultMessagePublisher.observe(...)` 호출부 | 존재, 모든 발행 결과를 기록 |
| 8인자 생성자 (관측 주입) | 존재 |
| **출하 조립** | **6인자 생성자 → `NO_OBSERVATION`** |
| **`MessagingMetrics` bean** | **없음** |
```java
// MessagingCoreAutoConfiguration.java:446-447
return new dev.caskeleton.messaging.runtime.DefaultMessagePublisher(
destinations, access, codecs, admission, runtimes, transport);
```
그리고 `MessagingMetrics`는 저장소 전체에서 **자기 테스트에서만** 생성된다(`MessagingMetricCardinalityTest`, `MessagingSecretLeakTest`).
starter는 `MessagingMetrics`**두 협력자를 bean으로 만든다**`MessagingRedactor`(:253)와 `CardinalityGuard`(:264). `MessagingMetrics`의 생성자는 `(registry, CardinalityGuard, MessagingRedactor)`를 받는다(테스트가 그렇게 호출한다). 즉 **재료 둘은 배선됐고 그것을 조립하는 bean이 없다.**
이 클래스의 javadoc이 그 상황을 예언한다.
```java
// DefaultMessagePublisher.java:74-78
* <p>{@code MessagingObservation} existed as a bean and no publish path called it, so the
* platform's own metrics described nothing. It is a constructor argument rather than an optional
* decorator because an unobserved publish path is how "the dashboards were empty during the
* incident" happens.
```
**이전 상태:** bean은 있고 호출하는 경로가 없었다.
**현재 상태:** 호출하는 경로는 있고 bean이 없다.
두 상태의 관측 결과는 같다 — 메트릭이 비어 있다. 고침이 간극을 닫은 것이 아니라 **반대편으로 옮겼다.** 그리고 "constructor argument rather than an optional decorator"라는 선택이 그것을 막지 못했다 — 인자를 기본값으로 채우는 짧은 생성자가 함께 존재하기 때문이다.
**(c) 배선된 것은 확실히 배선됐다**
발행 경로 다섯이 전부 `src/main`에서 생성된다(§2 표). 대조군으로서 이 사실이 (a)와 (b)의 판정을 뒷받침한다 — 검색 방법이 조립을 놓치는 것이 아니라 실제로 조립되지 않은 것이다.
**한계.** 정적 검색이다. `ObjectProvider` 지연 조회는 `MessageContracts``MessagingTransport` 두 곳에만 쓰이고 둘 다 확인했다. 파생 프로젝트가 `MessagingObservation` bean을 제공하면 `@ConditionalOnMissingBean(MessagePublisher.class)` 때문에 publisher bean 자체를 대체해야 한다 — 관측만 끼워 넣을 수는 없다.
### 12.2 Conditional sibling comparison
이 leaf에 bean은 없다. starter 쪽 sibling 비교가 유의미하다.
`MessagingCoreAutoConfiguration`이 이 leaf의 타입을 만드는 지점 다섯의 조건:
| 대상 | 조건 |
|---|---|
| `RegisteredMessageCodecs` | `@ConditionalOnMissingBean(MessageCodecRegistry.class)` |
| `DestinationProfileRegistry` | `@ConditionalOnMissingBean` |
| `DefaultMessagePublisher` | `@ConditionalOnMissingBean(MessagePublisher.class)` |
| `TransportMessagingRuntime` | 조건 없음 — `InitializingBean` 안, `transport.getIfAvailable()` null 검사 |
| `DeclaredDestinationAccess` | `@ConditionalOnMissingBean` |
**네 번째만 조건 대신 런타임 null 검사를 쓴다.** 그 이유가 주석에 있다.
```java
// Not a silent skip of a check: MessagingProviderSelection is what guarantees a transport
// when a broker is selected, and it refuses startup by name when one is not. This
// configuration is also loadable on its own — an adopter composing the policy primitives
// without a transport — and demanding one here would refuse that.
```
즉 "transport 없이도 로드 가능해야 한다"가 명시적 요구이고, 그 요구가 `@ConditionalOnBean` 대신 런타임 분기를 쓰게 했다. 부재 시 조용히 반환하지만 그것이 조용한 스킵이 아님을 주석이 다른 게이트(`MessagingProviderSelection`)로 설명한다. 그 게이트의 실제 동작은 starter leaf SSOT가 확인해야 한다.
### 12.3 Duplicate mechanism sweep
**(a) DLQ 순서 불변식이 두 곳에 구현돼 있다**
| | `messaging-policy` `DeadLetterOrchestrator` | 이 leaf `DefaultDeliveryProcessor` |
|---|---|---|
| 불변식 | 확인 후에만 원본 정산 | 확인 후에만 ack |
| 미확인 시 | 정산하지 않음(`sourceSettled=false`) | **requeue** |
| 헤더 | 예약 헤더 6개 부착 | 없음 |
| 발행 주체 | `MessagePublisher` | `DeadLetterPublisher` 함수형 인터페이스 |
**미확인 시 동작이 다르다.** policy 쪽은 "정산하지 않는다"(브로커가 알아서 재전달), 이쪽은 "명시적으로 requeue한다". 둘 다 메시지를 잃지 않지만 `requeue(delay)`는 지연을 지정하고 무정산은 브로커의 기본 재전달 타이밍을 따른다.
둘 다 조립되지 않았으므로 오늘 충돌하지 않는다. `analysis/messaging/messaging-policy.md` §12.3(b)가 같은 사건을 반대편에서 기록한다.
**(b) 재시도 지연이 두 출처**
`DefaultDeliveryProcessor``retryDelay`는 **생성자 인자 하나**다. 시도 횟수를 세지 않고 백오프도 없다. `messaging-policy``BackoffCalculator`(지수 + full jitter + 상한)와 대비된다. 같은 leaf 문서 §12.3(a)가 소유한다.
**(c) 멱등 종료가 두 층**
`TransportMessagingRuntime.close()``DefaultMessagingRuntimeRegistry.Generation.forceClose()`(transport-spi) 둘 다 CAS로 한 번을 보장한다. 중복이지만 **의도된 중복**이다 — 이쪽 주석이 "the registry closes a drained generation, and a context shutdown may close it again"이라고 두 경로를 명시한다. 결함 아님.
**(d) content type 폴백**
`encode``codecs.find(contentType).orElseGet(codecs::defaultCodec)`으로 폴백한다. `RegisteredMessageCodecs.find`는 미등록이면 `Optional.empty()`를 주고, `defaultCodec()`은 JSON이다. 즉 **선언된 content type과 실제 인코딩이 갈라질 수 있는 유일한 지점**이고, 그 갈라짐이 조용하다. §17.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| build.gradle 주석: `MessagePublisher`에 구현이 없었다 | 현재 이 leaf가 구현하고 `:446`에서 조립 | **해소됨** |
| `TransportMessagingRuntime` javadoc: registry가 비어 있어 모든 발행이 실패했다 | 현재 `:476`이 설치 | **해소됨** |
| `DefaultMessagePublisher` javadoc: 관측 bean이 있고 호출 경로가 없었다 | 현재 호출 경로가 있고 bean이 없다 | **반전됨**(§12.1b) |
| `DefaultDeliveryProcessor` javadoc: 어댑터가 각자 결정했다 | 한 곳에 모았으나 조립되지 않음 | **부분 해소** |
| `MessagingRuntime.generation()` javadoc: "increasing with each replacement" | 유일한 설치가 리터럴 `1L` | **미실현** |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
세 번째와 다섯 번째가 이 leaf의 §17 항목이 된다.
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
이 leaf는 **통째로 하나의 수정**이다. MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다(`DeclaredDestinationAccess`, `TransportMessagingRuntime`, `MessagingCoreAutoConfiguration:461`).
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `build.gradle` 주석 | `MessagePublisher` 구현 없음 | 자동설정이 없는 bean 위에 DLQ·facade bean을 쌓음. admission·security·lease·observation이 bean으로 존재하되 어떤 발행도 부르지 않음 |
| `TransportMessagingRuntime` javadoc | `MessagingRuntime` 구현 없음 | registry가 빈 채로 만들어져 모든 발행이 `PUBLISH_RUNTIME_UNAVAILABLE` — 목적지 해석·접근 확인·인코딩을 **전부 마친 뒤에** |
| `DestinationProfileRegistry` javadoc | 논리 이름→프로파일 해석 없음 | 어댑터는 해석된 프로파일을 받는데 그것을 만들 publisher가 없었음 |
| `DefaultDeliveryProcessor` javadoc | `HandleResult`→정산 연결 없음 | 각 어댑터가 retry/dead-letter의 뜻을 각자 결정 |
| `DefaultDeliveryProcessor` 핸들러 예외 주석 | Rabbit consumer가 핸들러 예외를 역직렬화 실패 경로로 접음 | 한 consumer의 일시적 버그가 하루치 트래픽을 조용히 버림 |
| `requireSupportedOptions` javadoc | transport가 `options`를 읽지 않음 | 중복 억제를 요청한 호출자가 억제도 오류도 못 받고, 그래서 쓸 idempotency를 건너뜀 |
| `withDeadline` javadoc | transport가 마감을 무시 | 확인이 오지 않는 Rabbit publish에 마감이 없어 호출자 스레드가 완료 불가능한 stage에 묶임 |
`build.gradle` 주석의 마지막 문장이 이 leaf 전체의 교훈이다 — "A starter that filled the gap with an application-supplied fake would pass a context test while running none of them."
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-283 | command | `evidence/raw/283-runtime-core-observation-noop.txt` | 여섯 타입 참조 수, 발행 경로 조립 지점, `DefaultDeliveryProcessor` src/main=0, 관측 4조각과 끊긴 한 지점, `MessagingMetrics`가 테스트에서만 생성됨, starter가 만드는 관측 bean 둘 | 정적 검색. 파생 프로젝트의 대체 조립 미포함 |
| EVD-284 | command | `./gradlew :messaging:messaging-runtime-core:test --rerun-tasks` | BUILD SUCCESSFUL, 21 / 0 / 0 | 브로커 대체(`RecordingTransport`) |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 이 leaf가 존재하는 이유와 이전 결함 — `build.gradle` 주석
- 발행 8단계의 순서가 고정된 이유와 각 위치의 근거 — `DefaultMessagePublisher` javadoc
- 접근 확인이 인코딩보다 먼저인 이유 — 인라인 주석
- 전송 전 실패가 `REJECTED`인 이유 — 인라인 주석
- 예산을 호출 시점부터 세는 이유 — `remainingBudget` javadoc
- 마감을 복사본에 거는 이유와 그 대가 — `withDeadline` javadoc
- 모든 경로에서 정확히 한 번 반납하는 이유 — 클래스 javadoc + 인라인 주석
- 지원하지 않는 옵션을 거절하는 이유 — `requireSupportedOptions` javadoc
- 기본 codec을 명시 인자로 받는 이유, raw-bytes 금지 이유 — `RegisteredMessageCodecs` javadoc
- 폴백 없는 목적지 조회 이유 — `DestinationProfileRegistry` javadoc
- 기본 접근 정책이 deny도 allow도 아닌 이유 — `DeclaredDestinationAccess` javadoc
- 핸들러 예외가 retry인 이유 — 인라인 주석
- DLQ 미확인 시 requeue를 고른 이유 — 인라인 주석
- transport 부재를 조용히 넘기는 것이 조용한 스킵이 아닌 이유 — `InitializingBean` 안 주석
- 관측을 생성자 인자로 둔 이유 — `observation` 필드 javadoc
**추론**
- 출하 조립이 6인자 생성자를 쓰는 것이 의도인지 → **추론이 아니라 미상.** 어디에도 근거가 없고, 8인자 생성자와 `MessagingMetrics`가 둘 다 존재한다는 점이 미완을 시사한다.
- `generation`이 항상 1인 것은 회전 코드가 없기 때문이다 → **추론**. 회전 코드 부재는 관측이다.
- `DefaultDeliveryProcessor` 미조립이 미완인지 확장점인지 → **미상**.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 6개 클래스 787줄 전문의 계약과 순서 결정
- 21개 테스트가 통과하고 무엇을 단언하는지
- 다섯 클래스가 출하 컨텍스트에서 조립되고 정확히 어느 라인인지
- `DefaultDeliveryProcessor``src/main`에서 생성되지 않는다는 것
- 관측의 네 조각 중 마지막 하나(bean)가 없고, 출하가 no-op 생성자를 쓴다는 것
- `MessagingMetrics`가 자기 테스트에서만 생성되고, 그 협력자 둘은 bean으로 존재한다는 것
- `generation`이 유일한 설치 지점에서 리터럴 `1L`이라는 것
**확인하지 못한 것**
- **6인자 생성자 선택이 의도인지.** 커밋이 대량 커밋 4개뿐이고 이 선택을 설명하는 기록이 없다.
- `MessagingProviderSelection`이 실제로 transport 부재를 이름으로 거절하는지 — starter leaf가 소유한다.
- 어댑터들이 `request.options()`를 정말 읽지 않는지 — 이 leaf의 javadoc이 그렇게 주장하고, 각 어댑터 leaf가 확인해야 한다.
- 실제 브로커에서 `withDeadline``.copy()` 전략이 어댑터 정리와 어떻게 상호작용하는지. 컨테이너 레인이 있으나 실행하지 않았다.
- 파생 프로젝트가 publisher bean 전체를 대체해 관측을 넣는지.
---
## 17. 손볼 것
### P2 — 관측이 구현·호출부·주입 자리를 모두 갖추고도 출하에서 no-op이다
- **사실.** `DefaultMessagePublisher`가 모든 발행 결과를 `observation.recordPublish(...)`로 기록하고, 관측을 "constructor argument rather than an optional decorator"로 받는다. `MessagingMetrics``MessagingObservation`을 구현한다. 그런데 출하 조립(`MessagingCoreAutoConfiguration:446`)은 **6인자 생성자**를 써서 `NO_OBSERVATION`을 넣고, `MessagingMetrics`는 저장소 전체에서 자기 테스트에서만 생성된다. starter는 `MessagingMetrics`의 협력자 둘(`MessagingRedactor:253`, `CardinalityGuard:264`)을 bean으로 만든다.
- **근거.** `evidence/raw/283` §D.
- **왜 문제인가.** 이 필드의 javadoc이 정확히 이 상황을 막으려고 쓰였다 — "an unobserved publish path is how 'the dashboards were empty during the incident' happens". 그리고 같은 javadoc이 **이전 결함**을 "bean은 있고 호출 경로가 없었다"로 기록한다. 지금은 반대다 — 호출 경로가 있고 bean이 없다. 관측 결과는 같다. **고침이 간극을 닫은 게 아니라 반대편으로 옮겼다.** "decorator가 아니라 생성자 인자"라는 선택도 막지 못했는데, 인자를 기본값으로 채우는 짧은 생성자가 함께 있기 때문이다.
- **확인 방법.** `evidence/raw/283` §D 재실행. 또는 `:446`의 인자 수와 `:138-146` 생성자 시그니처 대조.
- **후보.** (a) `MessagingMetrics` bean을 만들고 publisher가 8인자 생성자를 쓰게 한다. (b) 6인자 생성자를 제거해 관측을 명시 인자로 강제한다. (c) 관측이 배선되지 않았음을 `support-matrix.md`에 표시한다.
- **다음 단계.** **CASE 후보.** 재현이 정적이고, "장치는 있고 회로가 닫히지 않았다"의 변형 중 **회로가 반대편에서 끊긴** 사례라 독립적으로 가치가 있다. 그리고 "생성자 기본값이 있는 필수 협력자는 필수가 아니다"가 **REFERENCE 후보**다.
### P2 — 소비 오케스트레이터가 조립되지 않는다
- **사실.** `DefaultDeliveryProcessor`는 leaf 밖 참조 0, `src/main` 생성 0, `src/test` 생성 1이다.
- **근거.** `evidence/raw/283` §A·§C.
- **왜 문제인가.** 이 클래스가 고친 문제("각 어댑터가 retry/dead-letter의 뜻을 각자 결정")가 배선 없이는 그대로 남는다. 그리고 `DeclaredDestinationAccess`가 consume 권한을 빈 집합으로 두는 것과 정합적이다 — 기본 구성은 소비를 상정하지 않는다.
- **확인 방법.** `git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?DefaultDeliveryProcessor\s*\(' -- src`
- **다음 단계.** `analysis/messaging/messaging-policy.md` §17의 "출하 컨텍스트가 발행은 하고 소비는 하지 못한다"와 **동일 사건**이다. 소유는 cross-scope 또는 starter leaf. 여기서는 교차 참조만 남긴다.
### P3 — 선언된 content type과 실제 인코딩이 조용히 갈라질 수 있다
- **사실.** `encode``codecs.find(message.contentType()).orElseGet(codecs::defaultCodec)`으로 폴백한다. 출하 registry에는 JSON codec 하나만 등록된다. 봉투가 `application/avro`를 선언해도 JSON으로 인코딩되고, `EncodedMessage`의 content type은 codec이 정하므로 `application/json`이 된다.
- **근거.** `DefaultMessagePublisher.java:97-102`, `RegisteredMessageCodecs.find`, `MessagingCoreAutoConfiguration:363`(varargs 비어 있음).
- **왜 문제인가.** 실패하지 않고 **다른 포맷으로 성공**한다. 소비 측이 봉투의 원래 선언을 믿고 디코더를 고르면 어긋난다. `DestinationProfile.schema().codec()`이 목적지의 codec을 선언하는데 그 값과 대조하는 코드가 이 경로에 없다.
- **확인 방법.** 등록되지 않은 content type의 봉투를 발행해 `EncodedMessage.contentType()`을 확인.
- **후보.** 미등록 content type을 `MessagingConfigurationException`으로 거절하거나, `profile.schema().codec()`과 대조한다.
- **다음 단계.** **CASE 후보.** 조용한 성공이라는 형태가 `messaging-core-api`의 "조용한 성능 저하 금지" 설계와 정면으로 어긋난다.
### P3 — 같은 실패 코드가 두 completion에 쓰인다
- **사실.** `PUBLISH_DEADLINE_EXCEEDED`가 전송 전이면 `REJECTED`(`:16-21`), 전송 후면 `AMBIGUOUS`(`:42-47`)로 붙는다.
- **근거.** 두 위치.
- **왜 문제인가.** 두 경우의 운영자 행동이 정반대다 — 전자는 버려도 안전, 후자는 같은 `messageId`로만 재발행. `FailureDescriptor.code`가 "stable, machine-readable code"이고 대시보드가 그것으로 집계하는데, 이 코드는 completion을 함께 보지 않으면 판단을 뒤집는다.
- **확인 방법.** `git grep -n 'PUBLISH_DEADLINE_EXCEEDED' -- src/messaging/messaging-runtime-core`
- **후보.** 전송 전을 `PUBLISH_DEADLINE_BEFORE_SEND`처럼 분리한다.
- **다음 단계.** **REFERENCE 후보**(안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다).
### P3 — admission 실패만 예외로 전파된다
- **사실.** 8단계 중 admission(`:24`)만 `try` 블록 밖이고, `MessageTooLargeException`·`MessageBackpressureException`이 그대로 던져진다. 나머지 실패는 전부 `CompletionStage<PublishResult>`로 정규화된다.
- **근거.** `DefaultMessagePublisher.java:198`(admit 호출 위치)과 그 앞뒤 try 블록 범위.
- **왜 문제인가.** 반환 타입이 `CompletionStage`인 메서드가 동기적으로 throw한다. `.publish(...).exceptionally(...)`로만 처리하는 호출자는 이 두 예외를 놓친다. 두 예외 다 `MessagingException`이라 `FailureDescriptor`는 있지만 전달 방식이 다른 실패들과 다르다.
- **확인 방법.** 상한 초과 payload로 `publish`를 호출하고 반환 stage가 아니라 호출 자체가 던지는지 확인.
- **후보.** admission을 `try` 안으로 넣어 `rejected(...)`로 정규화하거나, javadoc에 동기 throw를 명시한다.
- **다음 단계.** **REFERENCE 후보**(`CompletionStage`를 반환하는 메서드는 동기적으로 던지지 않는다).
### P3 — `generation`이 항상 1이다
- **사실.** 유일한 설치 지점(`MessagingCoreAutoConfiguration:476`)이 리터럴 `1L`을 넘긴다. `MessagingRuntime.generation()` javadoc은 "increasing with each replacement", `TransportMessagingRuntime` javadoc은 "the credential generation a rotation increments"라고 한다.
- **근거.** `:476`, 두 javadoc.
- **왜 문제인가.** 오늘 회전 코드가 없으므로 무해하다. 다만 `DefaultMessagingRuntimeRegistry`의 세대 드레인 로직(transport-spi §4.2)이 세대 구분을 전제하고, 진단에서 generation을 읽는 사람은 항상 1을 본다. 회전을 붙일 때 이 리터럴이 잊히면 두 세대가 같은 번호를 갖는다.
- **확인 방법.** `git grep -n 'TransportMessagingRuntime(' -- src/main`
- **후보.** 자격증명 회전 카운터에서 값을 가져오거나, 회전이 없음을 주석으로 남긴다.
- **다음 단계.** **REFERENCE 후보**(증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다).
### P3 — `missingResult()`가 아무 데도 쓰이지 않는다
- **사실.** `DefaultDeliveryProcessor.missingResult()`(package-private static)가 `HANDLER_RETURNED_NOTHING` descriptor를 만든다. `result == null` 분기는 그것을 쓰지 않고 바로 `settlement.requeue(retryDelay)`를 부른다.
- **근거.** `DefaultDeliveryProcessor.java:77-79`, `:146-154`.
- **왜 문제인가.** 핸들러가 null을 반환한 경우와 `HandleResult.Retry`를 반환한 경우가 정산 수준에서 구분되지 않는다. 전자는 프로그래밍 오류이고 후자는 정상 흐름인데 같은 requeue가 된다. descriptor는 만들어졌으나 흐르지 않는다.
- **확인 방법.** `git grep -n 'missingResult' -- src`
- **후보.** null 분기에서 descriptor를 관측이나 로그로 흘리거나, 메서드를 제거한다.
- **다음 단계.** **REFERENCE 후보**(만들어 두고 흘리지 않는 진단값은 진단이 아니다).
### 확인된 설계(문제 아님)
- 발행 8단계의 고정 순서와 각 위치의 명시된 근거
- 전송 전/후 경계가 `REJECTED`/`AMBIGUOUS`를 가르는 것
- 예산을 호출 시점부터 세는 것
- 마감을 복사본에 걸어 어댑터의 stage를 완료시키지 않는 것과, permit/lease를 그 시점에 반납한다는 명시적 trade
- 성공·실패·예외 모든 경로에서 lease와 permit을 정확히 한 번 반납하는 것
- 지원하지 않는 발행 옵션을 조용히 무시하지 않고 거절하는 것
- 기본 codec을 명시 인자로 받고 raw-bytes를 content type 기준으로 거절하는 것
- 폴백 없는 목적지 조회
- 기본 접근 정책이 "선언한 목적지에만 발행"인 것과 consume·administer를 비워 두는 것
- 정확히 한 번 정산(CAS)과 핸들러 예외를 retry로 취급하는 것
- 실패 서술에 예외 메시지가 아니라 타입 이름만 남기는 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MRC-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 6개, memberships `["app-bootstrap"]` | 선언 |
| MRC-002 | build | `messaging-runtime-core/build.gradle` | same | 이 leaf가 존재하는 이유(MSG-INT-003 진단) | — |
| MRC-003 | code | `.../runtime/DefaultMessagePublisher.java` 전문 | same | §4.14.6, §6 | 브로커 대체 테스트만 |
| MRC-004 | code | `.../runtime/DefaultDeliveryProcessor.java` 전문 | same | §4.11 두 규칙, 핸들러 예외 이력 | 조립되지 않음(§12.1a) |
| MRC-005 | code | `.../runtime/RegisteredMessageCodecs.java` | same | §4.8 기본 codec 규칙과 충돌 거절 | — |
| MRC-006 | code | `.../runtime/DestinationProfileRegistry.java` | same | §4.7 폴백 없는 조회 | — |
| MRC-007 | code | `.../runtime/TransportMessagingRuntime.java` | same | §4.9 멱등 종료, generation 인자 | 항상 1(§17) |
| MRC-008 | code | `.../runtime/DeclaredDestinationAccess.java` | same | §4.10 기본 접근 정책의 세 번째 선택지 | — |
| MRC-009 | test | `DefaultMessagePublisherTest` (10) | same | 8단계와 실패 정규화, 관측 호출 | **8인자 생성자 사용** |
| MRC-010 | test | `DefaultDeliveryProcessorTest` (7) | same | 4분기 정산, 이중 정산 거절 | 배선 미증명 |
| MRC-011 | test | `RegisteredMessageCodecsTest` (4) | same | 기본 codec 규칙 | — |
| MRC-012 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:363,377,446,461-478` | same | 다섯 조립 지점과 6인자 생성자 선택 | 해당 leaf SSOT가 소유 |
| MRC-013 | cross-leaf code | `messaging-observability/.../MessagingMetrics.java:29` | same | `MessagingObservation`의 유일한 구현 | 테스트에서만 생성 |
| MRC-014 | cross-leaf code | `messaging-policy/.../DeadLetterOrchestrator.java` | same | 경쟁하는 DLQ 구현 | 해당 leaf SSOT가 소유 |
| EVD-283 | command | `evidence/raw/283-runtime-core-observation-noop.txt` | same | §12.1 전부 | 정적 검색 |
| EVD-284 | command | `./gradlew :messaging:messaging-runtime-core:test --rerun-tasks` | same | 21 / 0 / 0 | `RecordingTransport` 대체 |
@@ -0,0 +1,547 @@
# messaging-schema-api 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-schema-api`
> SSOT owner: `messaging-schema-api`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
> **성격.** 읽기 기록이다. 이 leaf가 선언한 codec/schema 계약과, 그 중 무엇이 실제로 호출되는지를 source anchor와 함께 적는다.
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-schema-api`
- canonical state `analysisFile`: `analysis/messaging/messaging-schema-api.md`
- source path: `src/messaging/messaging-schema-api`
- leaf-owned subdocuments: 없음
- registry `allowed_dependencies`: `["messaging-core-api"]`
- registry `runtime_memberships`: `["app-bootstrap"]`
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 10 |
| production LOC | 630 |
| 패키지 | 1 (`dev.caskeleton.messaging.schema`) |
| test 파일 | 3 |
| test 메서드(실행 확인) | 19 |
| 외부(비프로젝트) 의존성 | **0** |
10개 타입의 성격:
| 타입 | 종류 | 역할 |
|---|---|---|
| `MessageCodec` | interface | 한 wire 포맷의 인코딩/디코딩 |
| `MessageCodecRegistry` | interface | content type → codec, 그리고 기본 codec |
| `SchemaRegistry` | interface | subject/version → schema, 그리고 compatibility mode |
| `MessageContractKey` | record | `(MessageType, SchemaVersion)` — registry 키 |
| `SchemaReference` | record | subject + version + 선택적 URI |
| `EncodedMessage` | record | 바이트 + content type + schema reference |
| `SchemaCompatibility` | enum(7) | 진화 모드 |
| `SchemaCompatibilityValidator` | class | 포맷 독립 진화 규칙 |
| `BoundedByteSink` | class | 한도 초과 바이트를 **쓰기 시점에** 거절하는 OutputStream |
| `RawBytesMessageCodec` | class | 스키마 없는 M2 escape hatch |
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (10) | 10 | `FULL_READ` | 전 파일 본문 확인 |
| `src/test/java/**` (3) | 3 | `FULL_READ` | 전 파일 본문 확인 |
| `build.gradle` | 1 | `FULL_READ` | 5줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
이 leaf는 **"바이트를 어떻게 만들고 읽는가"의 계약**을 소유한다. 실제 포맷 구현은 갖지 않는다 — 단 하나의 예외가 `RawBytesMessageCodec`이고, 그것은 포맷이 아니라 포맷의 부재를 구현한다.
경계 규칙 하나가 모든 곳에 반복된다: **codec은 닫힌 registry에 대해서만 동작한다.**
```java
// MessageCodec.java:10-12
* <p>Implementations operate against a closed message-type registry. Accepting an unregistered type
* would let a producer introduce a wire contract nothing has reviewed, which is the same class of
* problem that makes Java serialization unsupported here.
```
`build.gradle``api project(':messaging:messaging-core-api')` 하나뿐이고 vendor 의존성이 없다. 포맷별 vendor(`jackson`, `avro`, `protobuf`)는 각자 leaf가 갖는다.
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api 노출).
나가는 것: `messaging-schema-json`, `messaging-schema-avro`, `messaging-schema-protobuf`, `messaging-cloudevents`, `messaging-policy`, `messaging-transport-spi`, `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`, `messaging-testkit`.
런타임 편입은 `messaging-core-api`와 같은 경로다 — `app-bootstrap``messaging-spring-boot-starter`를 선언하고 그 closure가 이 leaf를 끌어온다.
이 leaf는 bean을 만들지 않는다. Spring 주석 0개.
---
## 3. 패키지/컴포넌트 지도
패키지 하나에 10개 타입이 평평하게 있다. 관심사로 나누면 셋이다.
```
codec 축 MessageCodec ── MessageCodecRegistry
└── RawBytesMessageCodec (유일한 구현)
식별 축 MessageContractKey (type, version)
SchemaReference (subject, version, uri?)
EncodedMessage (bytes, contentType, schemaReference?)
진화 축 SchemaRegistry ── SchemaCompatibility(7)
└── SchemaCompatibilityValidator
경계 축 BoundedByteSink
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `MessageContractKey`: 버전을 키에 넣는 이유
이 leaf에서 가장 밀도 높은 javadoc이다.
```java
// MessageContractKey.java:10-17
* <p>Keying on the message type alone is what let an unregistered version decode. The version
* travels in the envelope and in {@link SchemaReference}, so a consumer receiving {@code
* order.created v999} would look up {@code order.created}, find the v1 class or parser, decode
* against it, and then keep the v999 label on the result. Nothing failed, and every downstream
* compatibility gate and audit record then described a version that was never registered.
```
핵심은 "Nothing failed"다. 타입만으로 키를 잡으면 실패가 발생하지 않고 **잘못된 성공**이 발생한다. 그리고 그 결과에는 등록된 적 없는 버전 라벨이 붙어 하위 감사 기록까지 오염된다.
이 결정은 세 codec에 전부 반영돼 있다 — `JacksonMessageCodec.requireRegistered`, `AvroMessageCodec.schemaFor`, `ProtobufMessageCodec.requireRegistered`가 모두 "타입은 아는데 버전을 모른다"와 "타입 자체를 모른다"를 **다른 에러 코드**로 구분한다(`SCHEMA_VERSION_NOT_REGISTERED` vs `UNKNOWN_MESSAGE_TYPE`). 그 구분이 있어야 운영자가 "등록을 빠뜨렸다"와 "오타다"를 나눌 수 있다.
### 4.2 `BoundedByteSink`: 보고 임계값 → 할당 경계
```java
// BoundedByteSink.java:11-15
* <p>Every codec here used to serialize into an unbounded buffer and compare {@code bytes.length}
* to the configured maximum afterwards. That makes the maximum a reporting threshold rather than an
* allocation bound: a payload whose graph expands to hundreds of megabytes exhausts the heap while
* being written, and the check that would have rejected it never runs. Under a broker consumer that
* is a process-wide outage caused by one message.
```
세 가지 설계 결정이 붙어 있다.
1. **버퍼를 한도로 미리 잡지 않는다.** `new ByteArrayOutputStream(Math.min(maxBytes, 8_192))` — 주석: "a 1 GiB bound must not pre-allocate 1 GiB."
2. **codec의 에러 코드를 그대로 던진다.** `errorCode`가 생성자 인자다. 그래서 Avro는 `AVRO_PAYLOAD_TOO_LARGE`, JSON은 `PAYLOAD_TOO_LARGE`가 나온다. 테스트가 이 성질을 직접 단언한다(`BoundedByteSinkTest.java:69-77`, `as("the sink reports the codec's own code, not a generic one")`).
3. **`requireFits(size)`는 예산을 소비하지 않는다.** Protobuf는 직렬화 크기를 미리 알므로 첫 바이트 전에 거절할 수 있다. 그리고 그 뒤의 쓰기도 여전히 경계 안이다 — 주석: "this is a cheaper refusal, not a replacement for the bound."
`refuseIfBeyondLimit``size > maxBytes - written`으로 비교하는 것도 의도적이다. `written + size > maxBytes`였다면 `int` 오버플로가 가능하다.
테스트가 실제 시나리오를 재현한다 — 10 MiB를 1 KiB씩 제공하고, `written()`이 한도(64) 이하로 유지되며 `toByteArray()`가 비어 있음을 확인한다(`BoundedByteSinkTest.java:34-53`).
### 4.3 `EncodedMessage`: 양방향 방어 복사
```java
public EncodedMessage {
...
bytes = bytes.clone(); // 생성 시
}
@Override
public byte[] bytes() {
return bytes.clone(); // 접근 시
}
```
javadoc이 이유를 적는다 — "These bytes travel through retry, DLQ, and redrive paths where a shared mutable array would let one stage corrupt another's copy of the same logical message."
`equals`/`hashCode``Arrays.equals`/`Arrays.hashCode`로 재정의된다(record 기본은 배열 참조 비교라 항상 불일치). `toString`은 바이트를 찍지 않고 크기만 찍는다 — payload가 로그에 새지 않는다.
`size()`가 복사 없이 길이를 반환하는 별도 메서드로 있는 것도 의도적이다. `bytes().length`는 전체 복사를 유발한다.
### 4.4 `SchemaCompatibility`: 7개 모드와 transitive의 의미
```java
// SchemaCompatibility.java:6-8
* <p>Transitive modes check every historical version, not just the immediate predecessor. That
* matters for integration events, where a consumer may be several releases behind and a chain of
* individually-compatible changes can still be collectively breaking.
```
`NONE_EXPERIMENTAL`은 "M2 raw bytes에만 허용"이라고 enum 상수 javadoc이 적는다.
### 4.5 `SchemaRegistry`: 포트이고, 순서가 계약이다
```java
// SchemaRegistry.java:16-17
* <p>{@link #history} returns oldest first. Transitive compatibility checks read the whole list, so
* an ordering mistake here silently converts a transitive check into a pairwise one.
```
이것은 문서화된 함정이다. `history`가 newest-first로 구현되면 `versionsToCheck``reversed()`한 뒤 `history.get(0)`을 취하므로 **가장 오래된 버전 하나**만 비교하게 된다 — transitive가 pairwise로 조용히 축소되는 것이 아니라 아예 엉뚱한 버전을 비교한다.
`latest(subject)`가 default 메서드로 `versions.get(versions.size() - 1)`인 것도 같은 순서 계약에 의존한다. 테스트가 이 성질을 직접 단언한다(`SchemaCompatibilityValidatorTest.theLatestVersionIsTheNewestNotTheFirstListed`).
port로 둔 이유도 적혀 있다 — "A hosted registry, a classpath directory of schema files, and a static in-process map are all legitimate sources … Binding to a vendor client here would make the rules untestable without that vendor running."
### 4.6 `SchemaCompatibilityValidator`: 포맷 독립 규칙
두 가지를 한다.
**(a) 비교할 버전 목록**
```java
public List<SchemaVersion> versionsToCheck(String subject) {
SchemaCompatibility mode = registry.compatibilityOf(subject);
if (mode == SchemaCompatibility.NONE_EXPERIMENTAL) return List.of();
List<SchemaVersion> history = registry.history(subject).reversed();
if (history.isEmpty()) return List.of();
return isTransitive(mode) ? history : List.of(history.get(0));
}
```
**(b) production 목적지 게이트**
```java
public void requireProductionMode(String subject, String destination) {
if (registry.compatibilityOf(subject) == SchemaCompatibility.NONE_EXPERIMENTAL) {
throw new MessageSchemaIncompatibleException(
"UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION", ...);
}
}
```
javadoc이 이유를 적는다 — "A mode that checks nothing is useful while a message type is being designed and actively dangerous once a retained log exists, because the log outlives every consumer that could still read it."
그리고 **분리 자체의 이유**를 명시한다:
```java
// SchemaCompatibilityValidator.java:12-14
* <p>Split from the per-format gates on purpose. Whether v3 must be checked against v1 as well as
* v2 is a property of the compatibility mode, not of Avro or Protobuf, and duplicating that
* reasoning in each codec is how the two formats drift apart.
```
§12.1과 §12.3이 이 문장을 다시 다룬다.
### 4.7 `RawBytesMessageCodec`: 부재를 구현한다
```java
// RawBytesMessageCodec.java:12-16
* <p>It still enforces the byte limit, and it is deliberately excluded from default codec
* selection: schema-free publishing has to be an explicit, auditable choice per destination, never
* something a destination falls back to because its codec was misconfigured.
```
`encode``byte[]`가 아닌 payload를 `MessageSerializationException("RAW_BYTES_PAYLOAD_REQUIRED")`로 거절하고, `decode``byte[].class`가 아닌 대상을 `RAW_BYTES_TARGET_REQUIRED`로 거절한다. `decode``encoded.clone()`을 반환한다 — 호출자가 원본을 건드릴 수 없다.
`DEFAULT_MAX_BYTES = 1_048_576`(1 MiB)은 세 Stable codec이 공유하는 값이다.
**주의:** 이 codec은 `BoundedByteSink`를 쓰지 않는다. 이미 `byte[]`를 받으므로 스트리밍 경계가 의미 없고, `bytes.length > maxBytes` 비교로 충분하다. 다른 codec에서는 그 비교가 §4.2가 지적하는 "보고 임계값"이지만 여기서는 할당이 이미 끝난 입력이라 성격이 다르다.
---
## 5. 주요 실행 경로
세 개다.
1. **경계 있는 인코딩** — codec이 `BoundedByteSink.of(maxBytes, code)`를 만들고 → 포맷 라이브러리가 sink에 쓰고 → 한도를 넘는 write에서 `MessageTooLargeException` → 아니면 `sink.toByteArray()``EncodedMessage` 조립
2. **계약 조회**`new MessageContractKey(type, version)` → registry lookup → 미스면 "타입 미등록" vs "버전 미등록" 구분
3. **진화 검사**`registry.compatibilityOf(subject)``versionsToCheck` → (포맷별 게이트가 실제 비교)
3번은 이 저장소에서 실행되지 않는다(§12.1).
---
## 6. 실패 경로와 복구/번역
이 leaf가 던지는 예외는 셋이고 전부 `messaging-core-api` 소유다.
| 예외 | 코드 | 조건 |
|---|---|---|
| `MessageTooLargeException` | codec별(`PAYLOAD_TOO_LARGE`, `AVRO_PAYLOAD_TOO_LARGE`, …) | sink 한도 초과 |
| `MessageTooLargeException` | `RAW_BYTES_TOO_LARGE` | raw codec 한도 초과 |
| `MessageSerializationException` | `RAW_BYTES_PAYLOAD_REQUIRED` / `RAW_BYTES_TARGET_REQUIRED` | 타입 불일치 |
| `MessageSchemaIncompatibleException` | `UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION` | `NONE_EXPERIMENTAL`이 production 목적지에 |
`IllegalArgumentException`도 던진다 — `BoundedByteSink` 생성자의 `maxBytes < 1`, `requireFits`의 음수, `SchemaReference`의 빈 subject. 이들은 **호출자의 프로그래밍 오류**이고 메시지 실패가 아니므로 `MessagingException` 계층 밖인 것이 일관적이다.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
동시성: `BoundedByteSink`**의도적으로 thread-safe가 아니다.** javadoc이 명시한다 — "Not thread-safe, and not meant to be: an instance belongs to a single encode call." 실제로 codec들이 매 `encode` 호출마다 새로 만든다.
`EncodedMessage`, `MessageContractKey`, `SchemaReference`는 불변이다. `SchemaCompatibilityValidator`는 registry 참조만 갖고 상태가 없다.
`MessageCodecRegistry`/`SchemaRegistry` 구현의 스레드 안전성은 이 leaf가 규정하지 않는다 — port javadoc에 그에 대한 요구가 없다. 이것은 §17의 P3 항목이다.
---
## 8. 설정·기능 플래그·환경 차이
설정 없음. 상수 하나:
| 상수 | 값 | 위치 |
|---|---:|---|
| `RawBytesMessageCodec.DEFAULT_MAX_BYTES` | 1,048,576 | `RawBytesMessageCodec.java:21` |
`BoundedByteSink`의 초기 버퍼 상한 8,192는 private다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. `SchemaRegistry`가 외부 registry를 가리킬 수 있는 port지만, 이 leaf에는 구현이 없다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-schema-api:test`. **BUILD SUCCESSFUL, 19 tests, 0 skipped, 0 failures** (`--rerun-tasks`, revision `21234e38`).
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `BoundedByteSinkTest` | 4 | 한도 포함/초과 경계, 10 MiB 스트림이 한도에서 멈춤, pre-flight가 예산을 안 먹음, codec 에러 코드 전달 | 실제 codec들이 이 sink를 쓰는지(각 codec leaf가 소유) |
| `RawBytesMessageCodecTest` | 6 | round trip, content type, 비-byte[] 거절 양방향, 한도, `EncodedMessage` 방어 복사 | — |
| `SchemaCompatibilityValidatorTest` | 9 | pairwise vs transitive 목록, `NONE_EXPERIMENTAL` 빈 목록, 빈 history, production 게이트 양방향, `checksBackward`/`checksForward` 조합, `latest`가 newest | **production 코드가 이 validator를 호출하는지** |
마지막 칸이 핵심이다. `SchemaCompatibilityValidatorTest`는 9개 단언으로 규칙을 정확히 고정하지만, §12.1이 보이듯 그 규칙을 실행 경로에서 부르는 코드가 없다. 테스트는 **규칙이 옳다**를 증명하고 **규칙이 적용된다**를 증명하지 않는다.
테스트가 쓰는 `FixedRegistry``SchemaRegistry`의 유일한 구현이다(production 구현 0개, §12.1).
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| registry fail-closed | 등록됨 |
| `verifyCleanArchitectureDependencies` | `allowed_dependencies: ["messaging-core-api"]`와 실제 project edge 대조 |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| `src/messaging/CLAUDE.md`의 vendor `api` 규칙 | 이 leaf는 vendor 의존성이 없으므로 대상 없음 |
| ArchUnit | 이 leaf 전용 규칙 없음 |
`src/messaging/CLAUDE.md:40-43`이 기술하는 게이트 — "source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 `build.gradle``api`로 선언했는지 대조" — 는 이 leaf에서 확인할 것이 없다. 형제 leaf(`schema-avro`, `schema-protobuf`, `cloudevents`)는 이 규칙 때문에 vendor를 `api`로 선언했고 build.gradle 주석이 그 이유를 적는다.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/272-schema-family-reachability.txt`.
### 12.1 Public surface reachability
leaf 밖 참조를 파일 수로 세면:
| 타입 | leaf 밖 파일 수 | 판정 |
|---|---:|---|
| `EncodedMessage` | 50 | 널리 쓰임 — 사실상 이 leaf의 주력 수출품 |
| `SchemaCompatibility` | 15 | 세 codec leaf + policy가 씀 |
| `MessageContractKey` | 7 | 세 codec leaf가 씀 |
| `MessageCodec` | 7 | 세 codec + runtime-core |
| `MessageCodecRegistry` | 5 | runtime-core가 구현 |
| `SchemaReference` | 4 | codec들이 만듦 |
| `BoundedByteSink` | 3 | JSON·Avro·Protobuf codec |
| `RawBytesMessageCodec` | **0** | 자기 테스트만 |
| `SchemaCompatibilityValidator` | **0** | 자기 테스트만 |
| `SchemaRegistry` | **0** | 아래 참조 |
**`SchemaRegistry`의 "0"은 확인이 필요했다.** 단순 이름 검색은 2개 파일을 맞췄지만 둘 다 다른 타입이다:
```
src/adapter/outbound/messaging/.../LocalJsonSchemaRegistry.java:6: import com.networknt.schema.SchemaRegistry;
src/adapter/outbound/notification/.../JsonSchemaVariableValidator.java:5: import com.networknt.schema.SchemaRegistry;
```
`import dev.caskeleton.messaging.schema.SchemaRegistry` 검색은 exit 1이다. 즉 **이 플랫폼의 `SchemaRegistry` port를 import하는 파일이 저장소에 하나도 없다.** 이름 충돌이 우연히 검색을 오염시킨 사례이고, `-w` 단어 매칭만으로 reachability를 판정하면 안 되는 이유이기도 하다.
**`SchemaCompatibilityValidator`의 "0"이 이 leaf에서 가장 무거운 사실이다.** 검색 결과 전체가 자기 선언과 자기 테스트다. 다시 말해:
- 어떤 버전들을 비교해야 하는가 → 아무도 묻지 않는다
- `NONE_EXPERIMENTAL`이 production 목적지를 뒷받침할 수 있는가 → 아무도 묻지 않는다
`requireProductionMode`는 "retained log outlives every consumer"라는 이유로 만들어졌고, 그 게이트가 호출되는 지점이 없다.
`RawBytesMessageCodec`의 "0"은 성격이 다르다. 이 클래스가 없어도 그 **규칙**은 살아 있다 — §12.2 참조.
### 12.2 Conditional sibling comparison
Spring 주석 0개이므로 bean 활성화 비대칭은 없다.
대신 이 leaf에는 **다른 형태의 sibling 비대칭**이 있고 결과가 좋다. `RawBytesMessageCodec`의 javadoc이 "deliberately excluded from default codec selection"이라고 선언하는 규칙을, 실제로 강제하는 코드는 다른 leaf에 있다:
```java
// messaging-runtime-core/RegisteredMessageCodecs.java:52-56
if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) {
throw new IllegalArgumentException(
"the raw bytes codec must not be the default: every destination that has not declared an "
+ "encoding would silently skip schema validation");
}
```
**클래스가 아니라 content type으로 판정한다.** 그래서 `RawBytesMessageCodec`을 아무도 쓰지 않아도, 그리고 누가 `ContentType.OCTET_STREAM`을 내놓는 다른 codec을 새로 만들어도 규칙이 유지된다. 선언된 규칙과 강제하는 코드가 다른 leaf에 있으면서 **강제 쪽이 더 넓은** 드문 경우다. 결함이 아니라 확인된 설계로 기록한다.
### 12.3 Duplicate mechanism sweep
**`SchemaCompatibilityValidator`가 막으려던 중복이 실제로 존재한다.**
`AvroCompatibilityGate`(다른 leaf)가 같은 판단을 private static으로 다시 구현했다.
| 판단 | schema-api (`SchemaCompatibilityValidator`) | schema-avro (`AvroCompatibilityGate`) |
|---|---|---|
| transitive인가 | `mode == BACKWARD_TRANSITIVE \|\| FORWARD_TRANSITIVE \|\| FULL_TRANSITIVE` (:107-112) | **같은 식을 그대로** (:49-53) |
| 후방 검사하나 | `mode == BACKWARD \|\| BACKWARD_TRANSITIVE \|\| FULL \|\| FULL_TRANSITIVE`**허용목록** (:79-85) | `mode != FORWARD && mode != FORWARD_TRANSITIVE`**거부목록** (:55-57) |
| 전방 검사하나 | `mode == FORWARD \|\| FORWARD_TRANSITIVE \|\| FULL \|\| FULL_TRANSITIVE`**허용목록** (:93-99) | `mode != BACKWARD && mode != BACKWARD_TRANSITIVE`**거부목록** (:59-61) |
`isTransitive`는 글자까지 동일한 복사본이다. 방향 판정 둘은 **형태가 반대**다.
현재 enum 7개 값에 대해 두 구현의 결과를 대조하면 일치한다. `NONE_EXPERIMENTAL`만 다른데(validator는 둘 다 false, gate는 둘 다 true) `AvroCompatibilityGate.check:34`가 그 모드에서 먼저 return하므로 가려진다.
**문제는 오늘의 불일치가 아니라 형태다.** 허용목록은 새 모드가 추가되면 "검사 안 함"으로 기본값이 잡히고, 거부목록은 "양방향 검사"로 잡힌다. `SchemaCompatibility`에 값이 하나 추가되는 순간 두 구현은 **반대 방향으로** 갈라진다. javadoc이 예고한 "how the two formats drift apart"가 바로 이 형태이고, 그것을 막으려고 만든 클래스는 §12.1에서 보듯 호출되지 않는다.
`isTransitive``SchemaCompatibilityValidator`에서 **public static**이다. Avro 게이트가 그것을 부를 수 있었고 부르지 않았다.
### 12.4 Documentation / measured-count drift
이 leaf를 직접 이름으로 언급하는 문서 주장을 재측정했다.
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| 계획 문서: codec은 닫힌 registry에 대해 동작 | `MessageCodec` javadoc + 세 구현의 `requireRegistered`/`schemaFor` | **일치** |
| `RawBytesMessageCodec` javadoc: 기본 codec 선택에서 제외됨 | `RegisteredMessageCodecs.of` 생성자 검사 | **일치**(더 넓게 강제) |
| `SchemaRegistry` javadoc: history는 oldest-first | 유일한 구현이 테스트 fixture이고 그 계약을 지킴 | 일치하나 production 구현 없음 |
§12.4의 family 전체 drift(`support-matrix.md:23`의 runtime membership 주장)는 `analysis/messaging/messaging-core-api.md` §12.4가 소유한다. 이 leaf도 그 18개 wired 목록에 포함된다.
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
코드 주석이 보존한 이전 결함:
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `BoundedByteSink` javadoc | 각 codec이 무제한 버퍼에 직렬화 후 길이 비교 | 한도가 **보고 임계값**일 뿐 할당 경계가 아님 → 팽창하는 payload 하나가 consumer 프로세스를 죽임 |
| `MessageContractKey` javadoc | 타입만으로 registry 키 | v999가 v1 클래스로 디코딩되고 v999 라벨을 유지 → 하위 게이트·감사 기록이 등록된 적 없는 버전을 서술 |
두 사례 다 형태가 같다 — **검사가 없었던 게 아니라 검사의 위치/키가 틀렸다.** `messaging-core-api` §13의 "문자 vs 바이트, 정확일치 vs 세그먼트" 목록과 같은 계열이다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | `SchemaCompatibilityValidator` 호출자 전무, allowlist/denylist 두 형태 나란히, `SchemaRegistry` port import 0(exit=1)과 이름 충돌, codec별 소비자 | 정적 `git grep` |
| EVD-274 | command | `./gradlew :messaging:messaging-schema-api:test --rerun-tasks` | BUILD SUCCESSFUL, 19 / 0 / 0 | 순수 단위 레인 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 버전을 registry 키에 넣는 이유 — `MessageContractKey` javadoc
- 할당 경계 vs 보고 임계값 — `BoundedByteSink` javadoc
- codec 에러 코드를 sink에 넘기는 이유 — `BoundedByteSink` javadoc + 테스트 `as(...)`
- 포맷 독립 규칙을 분리한 이유 — `SchemaCompatibilityValidator` javadoc
- `NONE_EXPERIMENTAL`을 production에서 막는 이유 — 같은 javadoc
- `SchemaRegistry`를 port로 둔 이유, history 순서가 계약인 이유 — `SchemaRegistry` javadoc
- raw codec을 기본에서 제외하는 이유 — `RawBytesMessageCodec` javadoc + `RegisteredMessageCodecs` javadoc
- `EncodedMessage` 양방향 복사 이유 — `EncodedMessage` javadoc
**추론**
- `SchemaCompatibilityValidator`가 미호출인 것은 이 저장소에 schema registry를 실제로 운영하는 배포가 없기 때문이다 → **추론**. `SchemaRegistry` production 구현이 0인 것은 관측이고, 인과는 추론이다.
- Avro 게이트가 자기 복사본을 쓴 이유 → **미상**. 커밋 메시지에 근거가 없다.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 10개 타입 전부의 계약과 불변식
- 19개 테스트가 통과하고 무엇을 단언하는지
- `SchemaCompatibilityValidator`·`RawBytesMessageCodec`·`SchemaRegistry`의 leaf 밖 참조 0 (`SchemaRegistry`는 이름 충돌을 배제한 뒤)
- Avro 게이트의 중복 구현과 두 형태의 차이
- raw-bytes 기본 금지 규칙이 content type 기준으로 더 넓게 강제된다는 것
**확인하지 못한 것**
- `SchemaCompatibility` enum이 실제로 확장될 계획이 있는지. §12.3의 위험은 그때 실현된다.
- port 구현의 스레드 안전성 요구. javadoc에 없고 이 저장소에 production 구현이 없어 관측할 대상이 없다.
- `BoundedByteSink`의 경계가 실제 Jackson/Avro/Protobuf 인코더에서 기대대로 동작하는지 — 각 codec leaf의 테스트가 소유하고 이 문서 범위 밖이다.
---
## 17. 손볼 것
### P2 — 포맷 독립 진화 규칙이 호출되지 않고, 그것이 막으려던 중복이 실제로 생겼다
- **사실.** `SchemaCompatibilityValidator`의 저장소 전체 참조가 자기 선언과 자기 테스트뿐이다. 동시에 `AvroCompatibilityGate``isTransitive`를 글자 그대로 복사했고 방향 판정 둘은 허용목록/거부목록으로 형태가 반대다.
- **근거.** `evidence/raw/272` §A, §B.
- **왜 문제인가.** 오늘은 7개 모드 전부에서 두 구현의 결과가 같다(`NONE_EXPERIMENTAL`은 gate의 early return이 가린다). 그러나 enum에 값이 하나 추가되면 허용목록은 "검사 안 함", 거부목록은 "양방향 검사"로 **반대 방향** 기본값을 갖는다. 그리고 `requireProductionMode` — 검사 없는 스키마가 보존 로그를 뒷받침하는 것을 막는 게이트 — 는 호출되는 곳이 없다.
- **확인 방법.** `git grep -n -E 'requireProductionMode|versionsToCheck|SchemaCompatibilityValidator' -- 'src/**/*.java'`
- **후보.** (a) Avro 게이트가 `SchemaCompatibilityValidator`의 public static을 부르게 한다. (b) validator를 CI 게이트에 배선한다. (c) 둘 다 쓰지 않을 거라면 validator를 제거하고 규칙 소유권을 게이트로 옮긴다.
- **다음 단계.** **CASE 후보 + REFERENCE 후보**. "중복을 막으려고 만든 추상이 호출되지 않으면 중복은 그대로 생긴다"는 형태가 재사용 가능하다. 그리고 "허용목록과 거부목록은 enum이 자라는 순간 반대로 갈라진다"도 별도 기준이다.
### P3 — port 구현의 스레드 안전성 요구가 문서화되어 있지 않다
- **사실.** `SchemaRegistry``MessageCodecRegistry` javadoc에 동시성 요구가 없다. `BoundedByteSink`만 "not thread-safe"를 명시한다.
- **근거.** 세 타입의 javadoc 전문.
- **왜 문제인가.** `MessageCodecRegistry`의 유일한 구현 `RegisteredMessageCodecs``Map.copyOf`로 불변이라 안전하지만, 그것은 구현의 성질이지 계약이 아니다. 외부 registry를 감싸는 `SchemaRegistry` 구현은 브로커 소비자 스레드들에서 동시에 호출된다.
- **확인 방법.** 세 인터페이스의 javadoc 확인.
- **후보.** port javadoc에 "구현은 스레드 안전해야 한다"를 명시.
- **다음 단계.** **REFERENCE 후보**(port 계약은 동시성 요구를 적는다).
### P3 — `SchemaRegistry`라는 이름이 저장소에서 두 가지를 가리킨다
- **사실.** `dev.caskeleton.messaging.schema.SchemaRegistry`(이 leaf의 port)와 `com.networknt.schema.SchemaRegistry`(JSON Schema 라이브러리)가 공존하고, 후자만 실제로 import된다.
- **근거.** `evidence/raw/272` §C.
- **왜 문제인가.** 지금 깨지는 것은 없다. 다만 reachability 판정에서 실제로 오탐을 만들었다 — 단어 검색이 2건을 맞췄고 둘 다 다른 타입이었다. 사람이 같은 실수를 한다.
- **확인 방법.** `git grep -n 'import .*\.SchemaRegistry;' -- src`
- **후보.** 이름 변경 없이 두는 것이 합리적일 수 있다. 기록만 남긴다.
- **다음 단계.** **REFERENCE 후보**(도달성 판정은 단어가 아니라 import로 확인한다).
### 확인된 설계(문제 아님)
- `BoundedByteSink`가 codec의 에러 코드를 전달하고, pre-flight가 예산을 소비하지 않는 것 — 테스트가 양쪽을 고정
- `EncodedMessage`의 양방향 방어 복사와 payload를 찍지 않는 `toString`
- 버전을 registry 키에 포함하고 "타입 미등록"과 "버전 미등록"을 다른 코드로 구분하는 것
- raw-bytes 기본 금지가 클래스가 아니라 content type으로 강제되는 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MSA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps `["messaging-core-api"]`, memberships `["app-bootstrap"]` | 선언 |
| MSA-002 | build | `messaging-schema-api/build.gradle` | same | vendor 의존성 0 | — |
| MSA-003 | code | `.../schema/MessageContractKey.java` | same | 버전 키 결정과 그 이유 | — |
| MSA-004 | code | `.../schema/BoundedByteSink.java` | same | 할당 경계, 에러 코드 전달, pre-flight | 실제 인코더 동작은 각 codec leaf |
| MSA-005 | code | `.../schema/EncodedMessage.java` | same | 양방향 복사, 배열 equals, 안전한 toString | — |
| MSA-006 | code | `.../schema/SchemaCompatibilityValidator.java` | same | 포맷 독립 규칙과 분리 이유 | 호출자 없음(§12.1) |
| MSA-007 | code | `.../schema/SchemaRegistry.java` | same | port 계약, history oldest-first | production 구현 없음 |
| MSA-008 | code | `.../schema/RawBytesMessageCodec.java` | same | escape hatch 계약 | 외부 사용 0 |
| MSA-009 | code | `.../schema/{MessageCodec,MessageCodecRegistry,SchemaReference,SchemaCompatibility}.java` | same | codec/식별/모드 계약 | — |
| MSA-010 | test | `src/test/java/**` (3 클래스 / 19 테스트) | same | §10 표 | 순수 단위 |
| MSA-011 | cross-leaf code | `messaging-runtime-core/.../RegisteredMessageCodecs.java:29-77` | same | raw-bytes 기본 금지의 실제 강제 지점, 중복 content type 거절 | 해당 leaf SSOT가 소유 |
| MSA-012 | cross-leaf code | `messaging-schema-avro/.../AvroCompatibilityGate.java:34-61` | same | 중복 구현과 두 형태의 차이 | 해당 leaf SSOT가 소유 |
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | §12.1·§12.3 전부 | 정적 검색 |
| EVD-274 | command | `./gradlew :messaging:messaging-schema-api:test --rerun-tasks` | same | 19 / 0 skipped / 0 failures | 순수 단위 |
@@ -0,0 +1,597 @@
# messaging-schema-avro 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-schema-avro`
> SSOT owner: `messaging-schema-avro`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-schema-avro`
- canonical state `analysisFile`: `analysis/messaging/messaging-schema-avro.md`
- source path: `src/messaging/messaging-schema-avro`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]`
- registry `runtime_memberships`: **`[]`** — build-only / incubating
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 2 |
| production LOC | 345 |
| 패키지 | 1 (`dev.caskeleton.messaging.schema.avro`) |
| test 파일 | 3 |
| test 메서드(실행 확인) | 16 |
| test resource | `/schemas/order.created/v1.avsc` |
| 외부 의존성 | 1 (`org.apache.avro:avro:1.12.0`, **`api`**) |
두 클래스: `AvroMessageCodec`(런타임 인코딩/디코딩), `AvroCompatibilityGate`(CI용 진화 검사).
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `.../avro/AvroMessageCodec.java` | 1 | `FULL_READ` | 272줄 전문 |
| `.../avro/AvroCompatibilityGate.java` | 1 | `FULL_READ` | 73줄 전문 |
| `src/test/java/**` | 3 | `FULL_READ` | 전문 |
| `src/test/resources/schemas/order.created/v1.avsc` | 1 | `STRUCTURAL_ONLY` | fixture 스키마; 필드 구성만 확인 |
| `build.gradle` | 1 | `FULL_READ` | 주석 포함 11줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
선택적(optional) Avro codec. Stable이 아니고 registry membership이 비어 있다 — **build-only / incubating**이며, `docs/messaging/support-matrix.md`의 등급과는 다른 축이다.
Avro를 `api`로 선언한 이유가 build.gradle 주석에 있다.
```groovy
// api: AvroMessageCodec's constructors take a registry of org.apache.avro.Schema and
// AvroCompatibilityGate.check takes and compares them. A consumer cannot build that
// registry without naming the type, so hiding the dependency only stops them compiling.
api 'org.apache.avro:avro:1.12.0'
```
`src/messaging/CLAUDE.md:40-43`이 기술하는 게이트 — public/protected 시그니처에 나오는 vendor 라이브러리가 `api`로 선언됐는지 대조 — 를 이 leaf가 통과한다. 형제 `messaging-schema-json`은 Jackson 타입이 시그니처에 없으므로 `implementation`이고, 그 판정 차이가 규칙이 실제로 작동한다는 증거다.
**클래스 둘의 실행 시점이 다르다.**
| 클래스 | 언제 도는가 | 근거 |
|---|---|---|
| `AvroMessageCodec` | 런타임(메시지마다) | `MessageCodec` 구현 |
| `AvroCompatibilityGate` | **CI** | 클래스 javadoc: "Run in CI rather than at runtime" |
게이트의 javadoc이 그 이유를 적는다 — "By the time a producer has published one incompatible record, the damage is durable: the record sits in a retained log that every current and future consumer must be able to read."
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `avro:1.12.0`(api).
나가는 것: **없다.** 어떤 leaf의 `allowed_dependencies`에도 `messaging-schema-avro`가 없다. `messaging-spring-boot-starter`의 17개 의존 목록에도 없다.
런타임 배선: 없음. `runtime_memberships: []`이므로 배포 아티팩트에 실리지 않는다. bean도 없다(Spring 주석 0개).
**소비자 없음과 membership 없음이 일치한다.** 이것이 정합적인 incubating 상태다 — `messaging-cloudevents`와 대비된다(그쪽은 membership이 있고 소비자가 없다).
---
## 3. 패키지/컴포넌트 지도
```
AvroMessageCodec (MessageCodec 구현)
├── encode(type, version, GenericRecord) → EncodedMessage
├── decode(type, version, byte[], Class) → GenericRecord (writer == reader)
├── decodeEvolved(type, writerV, readerV, byte[]) → GenericRecord (writer != reader)
├── schemaFor(type, version) → 등록 조회, 2단 에러
├── boundedReader(writer, reader) → newArray 오버라이드
└── flatten(nested registry) → (type, version) 평탄화 + 깊은 복사
AvroCompatibilityGate (CI)
└── check(candidate, history, mode)
├── isTransitive / readsBackward / readsForward (private, 자체 구현)
└── requireCompatible → org.apache.avro.SchemaCompatibility
```
---
## 4. 계약·불변식·상태 모델
### 4.1 Avro 바이너리에는 스키마가 없다 — 그래서 registry가 계약이다
```java
// AvroMessageCodec.java:33-37
* <p>Decoding uses an explicit writer schema and reader schema pair. Avro binary carries no schema
* of its own, so decoding with the wrong schema does not fail it produces plausible garbage. The
* registry is what makes the writer schema knowable, and passing both schemas to the reader is what
* makes evolution work: Avro resolves added, removed, and defaulted fields only when it can see
* both sides.
```
"does not fail — it produces plausible garbage"가 이 leaf의 모든 방어의 전제다. JSON이나 Protobuf와 달리 Avro는 잘못된 스키마로 디코딩해도 예외를 던지지 않는 경우가 있다.
single-object encoding에 헤더를 붙이지 않는 것도 명시적 결정이다 — "The framing that would carry a schema fingerprint belongs to the transport headers, where the platform already carries schema identity for every format, rather than being duplicated inside the Avro payload for this one format."
### 4.2 `flatten`: 얕은 복사가 만든 구멍
생성자가 받는 것은 중첩 맵 `Map<MessageType, Map<SchemaVersion, Schema>>`이고, `Map.copyOf`**바깥 레벨만** 복사한다.
```java
// AvroMessageCodec.java:78-82
* <p>{@code Map.copyOf} on the outer map is a shallow copy: every inner {@code Map<SchemaVersion,
* Schema>} stayed the caller's own object, so a caller that kept a reference could add, replace,
* or remove a schema version after construction and the codec would silently start encoding
* against it. Flattening to {@code (type, version)} keys copies both levels and makes the version
* part of the identity the lookup uses rather than a second hop.
```
이 결함이 위험한 이유는 §4.1과 곱해진다 — 스키마가 바뀌어도 디코딩이 실패하지 않고 그럴듯한 쓰레기를 낸다.
`AvroRegistryBoundsTest.mutatingTheCallersMapAfterConstructionChangesNothing`이 세 가지를 한 번에 확인한다: 생성 후 추가한 버전은 미등록, 생성 후 추가한 타입도 미등록, 원래 등록한 스키마는 그대로.
평탄화가 `MessageContractKey`(schema-api)를 키로 쓰므로 §4.5의 2단 에러 구분도 자연히 따라온다.
### 4.3 인코딩: direct encoder를 쓰는 이유
```java
// AvroMessageCodec.java:122-124
// A direct encoder, not the buffering one: the buffering encoder holds bytes back until flush,
// which would let a large record allocate freely before the sink ever sees a write. Direct
// encoding makes the bound apply to the record as it is written.
BinaryEncoder encoder = EncoderFactory.get().directBinaryEncoder(sink, null);
```
`BoundedByteSink`(schema-api)의 경계가 실제로 작동하려면 인코더가 증분적으로 써야 한다. `EncoderFactory.get().binaryEncoder(...)`는 버퍼링하므로 sink가 첫 write를 보기 전에 큰 레코드가 이미 할당된다. 즉 **schema-api의 방어가 이 한 줄에 의존한다.**
인코딩 전 검사 둘:
- payload가 `GenericRecord`인가 → `AVRO_PAYLOAD_NOT_A_RECORD`
- `schema.equals(record.getSchema())`인가 → `AVRO_SCHEMA_MISMATCH`
두 번째는 테스트가 이유를 적는다 — `as("encoding v2 data under the v1 version would produce bytes nothing can decode")`.
### 4.4 `boundedReader`: 다섯 바이트 공격
이 leaf에서 가장 깊은 방어다.
```java
// AvroMessageCodec.java:222-235
* <p>Avro writes an array as a declared element count followed by the elements. The count is a
* variable-length integer, so five bytes can claim four hundred million elements, and the generic
* reader allocates the backing array from that claim before reading a single element. Bounding
* the input length does not help: the whole hostile payload is five bytes, well under any limit,
* and the failure is an {@code OutOfMemoryError} rather than an exception the codec could report
* on a consumer thread that is the process, not the message.
*
* <p>The ceiling is the byte limit itself. Every element costs at least one byte on the wire even
* when it is empty, so a payload of at most {@code maxBytes} bytes cannot honestly contain more
* than {@code maxBytes} elements, and any larger claim is a lie the reader should refuse rather
* than reserve memory for.
```
구현은 익명 서브클래스의 `newArray` 오버라이드다.
```java
return new GenericDatumReader<>(writerSchema, readerSchema) {
@Override
protected Object newArray(Object old, int size, Schema schema) {
if (size > maxElements) {
throw new MessageTooLargeException("AVRO_COLLECTION_TOO_LARGE", ...);
}
return super.newArray(old, size, schema);
}
};
```
**상한 선택의 논리가 정확하다.** 원소 하나가 wire에서 최소 1바이트를 쓰므로, `maxBytes` 바이트짜리 payload가 정직하게 담을 수 있는 원소는 `maxBytes`개를 넘을 수 없다. 별도 튜닝 상수를 만들지 않고 이미 있는 경계에서 파생시켰다.
`AvroHostileInputTest`가 이 공격을 손으로 만든 zigzag varint로 재현한다.
```java
// AvroHostileInputTest.java:118-123
* <p>Hand-written rather than taken from an encoder because the point is to write a count with no
* elements behind it, which no encoder will do.
```
그리고 공격의 크기를 직접 단언한다 — `assertThat(hostile).as("the whole attack is five bytes, so no byte limit stands between it and the allocation").hasSizeLessThan(16)`.
테스트 클래스 javadoc이 **왜 corpus가 좁은지**까지 적는다.
```java
// AvroHostileInputTest.java:30-33
* <p>Strings, byte arrays and maps were already safe: Avro validates those lengths against the
* bytes actually remaining. Arrays were the one shape that allocated on trust, which is why the
* corpus below is narrow rather than exhaustive it pins the case that failed, and the two cases
* that must keep working around it.
```
이것은 "좁은 테스트"를 정당화한 드문 예다 — 다른 형태는 라이브러리가 이미 방어하므로 재확인이 아니라 잡음이 된다.
### 4.5 `schemaFor`: 2단 에러
`AVRO_TYPE_NOT_REGISTERED`(타입 미등록)와 `AVRO_VERSION_NOT_REGISTERED`(버전 미등록)를 구분한다. JSON codec의 `UNKNOWN_MESSAGE_TYPE`/`SCHEMA_VERSION_NOT_REGISTERED`와 같은 형태이지만 **코드 문자열이 다르다.** 두 codec이 같은 판단을 다른 어휘로 보고한다 — §12.3.
### 4.6 `decodeEvolved`: 나중에 붙은 경계
```java
// AvroMessageCodec.java:199-201
// The same bound the ordinary decode applies. It was missing here, so the evolution path — the
// one a consumer takes for every message written by a newer producer — accepted input of any
// size.
requireWithinLimit(encoded.length);
```
테스트가 두 각도에서 붙든다 — `AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound`(`as("decodeEvolved accepted input of any size")`)와 `AvroHostileInputTest.theEvolutionDecodeAppliesTheSameCollectionBound`(`as("a consumer reading a newer producer takes this path for every message")`).
`decodeEvolved`**가장 흔한 경로인데 가장 늦게 보호됐다.** 진화 경로는 producer가 앞서 나간 순간부터 모든 메시지가 지나는 길이다.
### 4.7 `AvroCompatibilityGate`
```java
public void check(Schema candidate, List<Schema> history, SchemaCompatibility mode) {
if (mode == SchemaCompatibility.NONE_EXPERIMENTAL || history.isEmpty()) return;
List<Schema> checked = isTransitive(mode) ? history : history.subList(0, 1);
for (Schema previous : checked) {
if (readsBackward(mode)) requireCompatible(candidate, previous, "backward");
if (readsForward(mode)) requireCompatible(previous, candidate, "forward");
}
}
```
`history`는 **newest first**를 요구한다(javadoc `@param history the previously registered schemas, newest first`). 이것은 `messaging-schema-api``SchemaRegistry.history`가 **oldest first**를 계약으로 삼는 것과 반대다. 두 계약을 잇는 코드가 없으므로 오늘은 충돌하지 않지만, 잇는 순간 `reversed()`를 빠뜨리면 조용히 잘못된 버전을 비교한다. `SchemaCompatibilityValidator.versionsToCheck`가 정확히 그 `reversed()`를 수행하고, 그 클래스는 호출되지 않는다(§12.3).
에러 코드는 방향에서 파생된다 — `"AVRO_" + direction.toUpperCase(Locale.ROOT) + "_INCOMPATIBLE"``AVRO_BACKWARD_INCOMPATIBLE` / `AVRO_FORWARD_INCOMPATIBLE`.
---
## 5. 주요 실행 경로
**encode:** `schemaFor``GenericRecord` 확인 → 스키마 동일성 확인 → `BoundedByteSink` + direct encoder → `writer.write` + `flush``EncodedMessage(bytes, AVRO, SchemaReference)`
**decode(동일 버전):** `requireWithinLimit``schemaFor` → 대상 타입이 `GenericRecord` 계열인지 → `boundedReader(writer, writer)``reader.read`
**decodeEvolved:** `requireWithinLimit``schemaFor(writer)` + `schemaFor(reader)``boundedReader(writer, reader)``reader.read`
**CI 게이트:** `check(candidate, history, mode)` → 모드에 따라 비교 대상 선정 → 방향별 `checkReaderWriterCompatibility`
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 조건 |
|---|---|---|
| `AVRO_TYPE_NOT_REGISTERED` | `MessageValidationException` | 타입 미등록 |
| `AVRO_VERSION_NOT_REGISTERED` | `MessageValidationException` | 버전 미등록 |
| `AVRO_PAYLOAD_NOT_A_RECORD` | `MessageValidationException` | encode/decode 대상이 `GenericRecord`가 아님 |
| `AVRO_SCHEMA_MISMATCH` | `MessageValidationException` | payload 스키마 ≠ 등록 스키마 |
| `AVRO_PAYLOAD_TOO_LARGE` | `MessageTooLargeException` | 인코딩 중 또는 디코딩 입력 상한 초과 |
| `AVRO_COLLECTION_TOO_LARGE` | `MessageTooLargeException` | 배열 원소 수 주장 > `maxBytes` |
| `AVRO_ENCODE_FAILED` | `MessageSerializationException` | 그 외 인코딩 실패 |
| `AVRO_DECODE_FAILED` | `MessageSerializationException` | 그 외 디코딩 실패 |
| `AVRO_EVOLUTION_FAILED` | `MessageSerializationException` | 진화 해석 실패 |
| `AVRO_BACKWARD_INCOMPATIBLE` / `AVRO_FORWARD_INCOMPATIBLE` | `MessageSchemaIncompatibleException` | CI 게이트 |
**예외 재던지기 패턴이 세 곳에 반복된다.**
```java
} catch (IOException | RuntimeException failure) {
if (failure instanceof MessageTooLargeException tooLarge) {
throw tooLarge;
}
throw new MessageSerializationException("AVRO_*_FAILED", ..., failure);
}
```
`BoundedByteSink`가 던지는 `MessageTooLargeException``RuntimeException`이므로 catch에 걸린다. 그것을 그대로 통과시키지 않으면 크기 실패가 인코딩 실패로 접힌다 — JSON codec의 `unwrapTooLarge`와 같은 문제를 다른 방식(원인 사슬 탐색이 아니라 즉시 `instanceof`)으로 푼다. §12.3.
`AvroHostileInputTest.aCountBeyondIntRangeFailsWhileReadingRatherThanWhileReserving`가 흥미로운 경계를 잡는다 — 2³²을 주장하면 int로 잘려 무해한 값이 되고, 그 다음 읽기가 입력 부족으로 실패해 `MessageSerializationException`이 된다. 즉 `newArray` 방어를 우회하는 값이 존재하지만 그 우회는 할당이 아니라 읽기 실패로 끝난다.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
`AvroMessageCodec`은 불변이다 — `schemas``Map.copyOf`된 평탄 맵, `maxBytes`는 int. `BoundedByteSink`·`BinaryEncoder`·`DatumReader`·`BinaryDecoder`는 전부 호출마다 새로 만들어진다.
`EncoderFactory.get()`/`DecoderFactory.get()`은 Avro의 싱글턴 팩토리이고 스레드 안전하다. 다만 `binaryDecoder(encoded, null)`의 두 번째 인자가 재사용 decoder 자리인데 항상 `null`을 넘긴다 — 재사용하지 않으므로 공유 상태가 없다. 성능을 버리고 안전을 택한 형태다.
`AvroCompatibilityGate`는 상태가 없다.
---
## 8. 설정·기능 플래그·환경 차이
설정 없음.
| 상수 | 값 | 가시성 |
|---|---:|---|
| `AvroMessageCodec.DEFAULT_MAX_BYTES` | 1,048,576 | **private** |
private이므로 §12.3의 "1 MiB가 다섯 곳에 복사됨" 문제에서 이 leaf는 외부에 값을 노출하지 않는다. 대신 공유 상수를 읽지도 않는다.
Avro 버전은 `1.12.0`으로 build.gradle에 고정돼 있다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. 외부 schema registry를 쓰지 않는다 — 스키마는 생성자 인자로 받는다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-schema-avro:test`. **BUILD SUCCESSFUL, 16 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `AvroCompatibilityTest` | 8 | round trip, schema reference, v1→v2 default를 통한 진화, defaulted 필드 추가는 backward 호환, default 없는 추가는 거절, payload 스키마 불일치 사전 거절, 미등록 버전/타입 거절 | transitive 모드 실제 동작(테스트가 `BACKWARD`만 씀) |
| `AvroHostileInputTest` | 4 | 4억 원소 주장이 할당 전에 거절됨, 진화 경로도 같은 방어, int 범위 초과는 읽기 실패로 끝남, 정직한 배열은 정상 | 문자열·맵·바이트 배열(라이브러리가 이미 방어한다고 javadoc이 명시) |
| `AvroRegistryBoundsTest` | 4 | 생성 후 맵 변경이 무효, 인코딩 중 거절(`refused at byte`), 진화 경로 상한, 정확히 상한인 payload 허용 | — |
**증명 공백 하나.** `AvroCompatibilityGate`의 transitive 모드가 테스트되지 않는다. 8개 중 게이트를 부르는 것은 둘이고 둘 다 `SchemaCompatibility.BACKWARD`(pairwise)다. `isTransitive`가 true인 경로 — `history` 전체를 순회하는 분기 — 는 실행되지 않는다. 그 분기는 §12.3이 지적하는 중복 구현의 핵심이기도 하다.
세 테스트 클래스 중 둘이 클래스 javadoc으로 **이전 결함을 서술한다**(`AvroHostileInputTest`, `AvroRegistryBoundsTest`). 이 저장소의 일관된 습관이다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` |
| `verifyRuntimeModuleMembership` | `[]` — 런타임 편입 없음이 강제됨 |
| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | Avro가 public 시그니처에 등장 → `api` 선언 필요. **통과** |
| ArchUnit | 전용 규칙 없음 |
`AvroCompatibilityGate`가 "Run in CI"라고 선언하지만, **이 저장소의 CI에서 그것을 실행하는 task가 없다.** `src/build.gradle`의 9개 `verifyMessaging*` task는 전부 `app-bootstrap/build/messaging-evidence/**/manifest.json`을 요구하는 자격 게이트이고 스키마 진화 검사를 부르지 않는다. §12.1.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/272-schema-family-reachability.txt`.
### 12.1 Public surface reachability
| 타입 | leaf 밖 참조 | 판정 |
|---|---:|---|
| `AvroMessageCodec` | **0** | 소비자 없음 |
| `AvroCompatibilityGate` | **0** | 소비자 없음 |
`git grep -l -w AvroMessageCodec -- src ':!src/messaging/messaging-schema-avro'` exit 1, `AvroCompatibilityGate`도 동일.
**두 클래스의 "0"은 성격이 다르다.**
`AvroMessageCodec`의 0은 정합적이다 — `runtime_memberships: []`이고 starter의 codec registry에도 등록되지 않는다(`RegisteredMessageCodecs.of(JacksonMessageCodec.of(...))`, varargs 비어 있음). 소비자 없음과 배포 없음이 일치한다.
`AvroCompatibilityGate`의 0은 다르다. 이 클래스는 **런타임이 아니라 CI에서 도는 것을 전제로 설계됐다.** javadoc이 그렇게 선언한다. 그런데 그것을 부르는 CI task가 없다. 즉 "런타임에 안 쓰이는 건 당연하다"가 이 클래스에는 적용되지 않는다 — 이 클래스는 애초에 런타임 소비자를 가질 계획이 없었고, 계획된 소비자(CI)도 없다.
이 구분이 중요한 이유: 배포 게이트가 생겨 `messaging-schema-avro`가 런타임에 편입되면 `AvroMessageCodec`은 자연히 배선되지만 `AvroCompatibilityGate`는 여전히 아무 데도 붙지 않는다. 두 문제는 함께 풀리지 않는다.
**한계.** 이 저장소는 템플릿이고, 파생 프로젝트가 `AvroCompatibilityGate`를 자기 CI에서 부를 수 있다. 그것을 확인할 수단이 저장소 안에 없다.
### 12.2 Conditional sibling comparison
Spring 주석 0개. bean 없음. 비교 대상 없음.
**codec sibling 비교는 가능하고 결과가 유의미하다.**
| codec | `MessageCodec` 구현 | starter 등록 | membership | 정합성 |
|---|:---:|:---:|---|---|
| `JacksonMessageCodec` | o | o | `["app-bootstrap"]` | 일치 |
| `AvroMessageCodec` | o | x | `[]` | **일치** |
| `ProtobufMessageCodec` | o | x | `[]` | 일치 |
| `RawBytesMessageCodec` | o | x | `["app-bootstrap"]`(schema-api 소속) | 불일치 |
Avro는 세 축이 전부 "없음"으로 정렬돼 있다. incubating leaf가 이래야 하는 형태다.
### 12.3 Duplicate mechanism sweep
**(a) 진화 판단 중복 — 확인됨**
`AvroCompatibilityGate`의 private `isTransitive`/`readsBackward`/`readsForward``messaging-schema-api``SchemaCompatibilityValidator`의 public static `isTransitive`/`checksBackward`/`checksForward`와 같은 판단을 다시 구현한다.
| 판단 | schema-api | 이 leaf |
|---|---|---|
| `isTransitive` | public static, 허용목록 | private static, **글자까지 동일한 복사본** |
| 후방 검사 | `checksBackward`, 허용목록 | `readsBackward`, **거부목록** |
| 전방 검사 | `checksForward`, 허용목록 | `readsForward`, **거부목록** |
현재 enum 7개 값에서 두 구현의 결과는 같다(`NONE_EXPERIMENTAL``check:34`의 early return이 가린다). 형태가 반대이므로 enum이 자라면 갈라진다 — 허용목록은 새 모드를 "검사 안 함"으로, 거부목록은 "양방향 검사"로 기본 처리한다.
schema-api의 javadoc이 이 중복을 정확히 예고했다 — "duplicating that reasoning in each codec is how the two formats drift apart". 그리고 그것을 막을 클래스는 호출되지 않는다. 상세는 `analysis/messaging/messaging-schema-api.md` §12.3이 소유한다.
**(b) history 순서 계약이 반대다**
| 위치 | 요구 |
|---|---|
| `SchemaRegistry.history` (schema-api) | **oldest first** |
| `AvroCompatibilityGate.check``history` 파라미터 | **newest first** |
둘을 잇는 코드가 없어 오늘은 충돌하지 않는다. 잇는 순간 `reversed()`를 빠뜨리면 `history.subList(0, 1)`이 가장 오래된 스키마를 "직전 버전"으로 비교한다. 실패하지 않고 **엉뚱한 비교를 통과시킬 수 있다.**
**(c) 크기 예외 통과 패턴이 codec마다 다르다**
| codec | 방식 |
|---|---|
| `JacksonMessageCodec` | `unwrapTooLarge` — 원인 사슬을 끝까지 훑음 |
| `AvroMessageCodec` | `catch` 안에서 즉시 `instanceof` (3곳 반복) |
| `ProtobufMessageCodec` | 해당 없음 — `requireFits`로 사전 거절 |
같은 문제(`BoundedByteSink``MessageTooLargeException`이 포맷 라이브러리 예외에 삼켜지는 것)를 세 가지로 푼다. Jackson은 예외를 감싸므로 사슬 탐색이 필요하고, Avro는 감싸지 않으므로 즉시 검사로 충분하다 — 즉 차이가 라이브러리 동작에서 나온 정당한 것이다. 다만 그 이유가 어디에도 적혀 있지 않다.
**(d) 에러 코드 어휘가 codec마다 다르다**
같은 판단에 다른 문자열:
| 판단 | JSON | Avro | Protobuf |
|---|---|---|---|
| 타입 미등록 | `UNKNOWN_MESSAGE_TYPE` | `AVRO_TYPE_NOT_REGISTERED` | `UNKNOWN_MESSAGE_TYPE` |
| 버전 미등록 | `SCHEMA_VERSION_NOT_REGISTERED` | `AVRO_VERSION_NOT_REGISTERED` | `SCHEMA_VERSION_NOT_REGISTERED` |
| 타입 불일치 | `PAYLOAD_TYPE_MISMATCH` | `AVRO_PAYLOAD_NOT_A_RECORD` / `AVRO_SCHEMA_MISMATCH` | `PAYLOAD_TYPE_MISMATCH` |
JSON과 Protobuf는 어휘를 공유하고 Avro만 접두사를 붙인다. 대시보드가 코드로 집계하면 Avro만 별도 계열이 된다.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `AvroCompatibilityGate` javadoc: "Run in CI rather than at runtime" | 저장소 CI에 호출 지점 없음 | **미실현** — 진술이 틀린 게 아니라 계획이 실행되지 않음 |
| build.gradle 주석: Avro가 public 시그니처에 등장하므로 `api` | 두 클래스의 public 시그니처에 `org.apache.avro.Schema` 등장 확인 | **일치** |
| `docs/messaging/support-matrix.md`: Avro가 Stable이 아님 | membership `[]`, starter 미등록 | **일치** |
| `docs/messaging/support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 실제로 `[]`**이 leaf에 한해서는 맞다** | family 전체로는 틀림(`messaging-core-api` §12.4) |
마지막 행이 흥미롭다. 잘못된 일반화가 우연히 이 leaf에서는 참이 된다. 그래서 이 문서만 읽으면 drift를 발견할 수 없다 — family 수준에서 세야 보인다.
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
테스트 클래스 javadoc이 세 결함을 보존한다.
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `AvroRegistryBoundsTest` javadoc | 중첩 맵에 `Map.copyOf`(얕은 복사) | 호출자가 생성 후 스키마 교체 가능 → Avro는 실패하지 않고 그럴듯한 쓰레기를 만듦 |
| `AvroRegistryBoundsTest` javadoc | `decodeEvolved`에 크기 검사 없음 | producer가 앞서 나간 뒤 **모든 메시지**가 지나는 경로가 무제한 입력을 수용 |
| `AvroHostileInputTest` javadoc | 배열 원소 수 주장을 신뢰하고 할당 | 5바이트로 4억 원소 배열 → `OutOfMemoryError`, codec이 분류할 수 없는 실패, consumer 스레드에서 프로세스 사망 |
| `AvroMessageCodec.decodeEvolved` 주석 | 같은 내용 | — |
세 번째가 형태상 가장 흥미롭다 — **바이트 상한이라는 올바른 도구가 잘못된 공격에 적용되어 있었다.** 테스트 javadoc이 그것을 한 문장으로 적는다: "The byte limit is the wrong instrument for this attack and was the only one in place."
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §B, §D, §E | 두 형태의 진화 판단 나란히, codec별 소비자 0, membership `[]` | 정적 검색 |
| EVD-276 | command | `./gradlew :messaging:messaging-schema-avro:test --rerun-tasks` | BUILD SUCCESSFUL, 16 / 0 / 0 | 실제 Avro 브로커 없음 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- Avro 바이너리에 스키마가 없어 registry가 계약이 되는 이유 — 클래스 javadoc
- single-object encoding에 헤더를 안 붙이는 이유 — 클래스 javadoc
- 얕은 복사가 만든 구멍과 평탄화로 고친 이유 — `flatten` javadoc
- direct encoder를 쓰는 이유 — encode 주석
- 배열 원소 상한을 `maxBytes`로 잡은 논리 — `boundedReader` javadoc
- 적대적 입력 corpus가 좁은 이유 — `AvroHostileInputTest` javadoc
- 게이트가 CI용인 이유 — `AvroCompatibilityGate` javadoc
- Avro를 `api`로 선언한 이유 — build.gradle 주석
**추론**
- 크기 예외 통과 방식이 JSON과 다른 것은 Jackson이 예외를 감싸고 Avro는 감싸지 않기 때문이다 → **추론**. 두 코드의 형태는 관측이고 인과는 추론이다.
- 에러 코드에 `AVRO_` 접두사를 붙인 것이 의도인지 → **미상**.
- 게이트가 `newest first`를 요구하는 것과 port가 `oldest first`인 것 중 어느 쪽이 나중인지 → **미상**. 커밋이 4개뿐이고 둘 다 같은 커밋에 들어왔다.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 두 클래스 345줄 전문의 계약과 방어
- 16개 테스트가 통과하고 무엇을 단언하는지
- 소비자 0과 membership `[]`이 정합적이라는 것
- 진화 판단이 schema-api와 중복이고 형태가 반대라는 것
- `history` 순서 계약이 schema-api와 반대라는 것
- CI 실행을 전제한 게이트를 부르는 CI task가 없다는 것
**확인하지 못한 것**
- **transitive 모드의 실제 동작.** 테스트가 `BACKWARD`만 쓴다. `history` 전체 순회 분기가 실행된 적이 없다.
- 파생 프로젝트가 `AvroCompatibilityGate`를 자기 CI에서 부르는지. 저장소 안에 확인 수단이 없다.
- 실제 Avro 스키마 진화 사례에서 `checkReaderWriterCompatibility`의 판정이 이 게이트의 방향 매핑과 맞는지 — 테스트는 defaulted 필드 추가/미추가 두 경우만 본다.
- `decodeEvolved`가 실제 다중 버전 배포에서 어떤 빈도로 쓰이는지. 소비자가 없어 관측할 수 없다.
---
## 17. 손볼 것
### P2 — CI에서 돈다고 선언한 게이트를 부르는 CI가 없다
- **사실.** `AvroCompatibilityGate` javadoc이 "Run in CI rather than at runtime"이라고 선언한다. 저장소 전체에서 이 클래스 참조는 자기 선언과 자기 테스트뿐이고, `src/build.gradle`의 9개 `verifyMessaging*` task 중 스키마 진화를 검사하는 것이 없다.
- **근거.** `evidence/raw/272` §D. `src/build.gradle:65-110`.
- **왜 문제인가.** 게이트의 존재 이유가 "한 번 발행되면 보존 로그에 영구히 남는다"인데, 그 보호가 어느 파이프라인에도 붙어 있지 않다. `AvroMessageCodec`의 미사용과 달리 이것은 membership으로 설명되지 않는다 — 런타임 편입 여부와 무관하게 CI 게이트는 붙었어야 한다.
- **확인 방법.** `git grep -n -w AvroCompatibilityGate -- src` · `git grep -n 'verifyMessaging' -- src/build.gradle`
- **후보.** (a) 스키마 디렉터리를 읽어 게이트를 돌리는 Gradle task를 만든다. (b) 파생 프로젝트가 붙이는 확장점이라면 javadoc이 그렇게 말하도록 고친다.
- **다음 단계.** **CASE 후보.** "장치는 있고 회로가 닫히지 않았다"의 전형이고, 재현이 정적 검색으로 끝난다.
### P2 — 진화 판단이 두 곳에 있고 형태가 반대다
- **사실.** `isTransitive``SchemaCompatibilityValidator`(public static)와 이 leaf(private static)에 글자까지 같은 복사본이 있다. 방향 판정은 전자가 허용목록, 후자가 거부목록이다.
- **근거.** `evidence/raw/272` §B에 두 형태가 나란히 출력된다.
- **왜 문제인가.** 오늘 7개 모드에서 결과는 같지만 형태가 반대이므로 `SchemaCompatibility`에 값이 추가되는 순간 갈라진다 — 허용목록은 "검사 안 함", 거부목록은 "양방향 검사". 그리고 이 중복은 schema-api의 javadoc이 명시적으로 막으려던 것이다.
- **확인 방법.** `evidence/raw/272` §B 재실행.
- **후보.** `AvroCompatibilityGate``SchemaCompatibilityValidator`의 public static을 부르게 한다. 세 메서드 다 이미 public static이다.
- **다음 단계.** `messaging-schema-api` §17의 같은 항목과 **동일 사건**이다. 그 leaf가 소유하고 여기서는 교차 참조만 남긴다.
### P3 — `history` 순서 계약이 port와 게이트에서 반대다
- **사실.** `SchemaRegistry.history` javadoc은 oldest first, `AvroCompatibilityGate.check``@param history`는 newest first.
- **근거.** 두 javadoc.
- **왜 문제인가.** 둘을 잇는 코드가 없어 지금은 무해하다. 이으면서 `reversed()`를 빠뜨리면 pairwise 모드가 **가장 오래된** 스키마를 직전 버전으로 비교한다. 실패하지 않고 통과할 수 있는 오류다. port javadoc이 이미 같은 위험을 경고한다 — "an ordering mistake here silently converts a transitive check into a pairwise one."
- **확인 방법.** 두 javadoc 대조.
- **후보.** 게이트도 oldest-first를 받게 통일하고 내부에서 뒤집는다.
- **다음 단계.** **REFERENCE 후보**(컬렉션 순서가 계약이면 양쪽에서 같은 방향으로 적는다).
### P3 — transitive 분기가 테스트되지 않는다
- **사실.** `AvroCompatibilityTest`의 게이트 호출 2건이 모두 `SchemaCompatibility.BACKWARD`다. `isTransitive`가 true인 경로가 실행되지 않는다.
- **근거.** `AvroCompatibilityTest.java:134-150`.
- **왜 문제인가.** transitive 모드는 "여러 릴리스 뒤처진 consumer"를 위한 것이고 그것이 이 게이트의 존재 이유 중 절반이다. 그리고 그 분기가 §12.3의 중복 구현이 갈라질 지점이다.
- **확인 방법.** 두 테스트의 모드 인자 확인.
- **후보.** v1·v2·v3 세 스키마로 `BACKWARD_TRANSITIVE` 케이스를 추가한다.
- **다음 단계.** **REFERENCE 후보**(모드 enum을 분기 조건으로 쓰면 각 분기에 테스트를 둔다).
### P3 — 에러 코드 어휘가 형제 codec과 갈라진다
- **사실.** 같은 판단에 JSON/Protobuf는 `UNKNOWN_MESSAGE_TYPE`·`SCHEMA_VERSION_NOT_REGISTERED`, Avro는 `AVRO_TYPE_NOT_REGISTERED`·`AVRO_VERSION_NOT_REGISTERED`를 쓴다.
- **근거.** 세 codec의 `requireRegistered`/`schemaFor`.
- **왜 문제인가.** `FailureDescriptor.code`는 "stable, machine-readable code"이고 대시보드·재시도 정책이 이것으로 집계한다. 같은 판단이 두 어휘로 나뉘면 Avro만 별도 계열이 된다.
- **확인 방법.** `git grep -n 'NOT_REGISTERED' -- 'src/messaging/**/*.java'`
- **후보.** 공통 코드를 쓰고 포맷은 `sanitizedMessage`로 구분한다.
- **다음 단계.** **REFERENCE 후보**(안정 코드는 판단 단위로 정하고 구현 단위로 정하지 않는다).
### 확인된 설계(문제 아님)
- 중첩 registry를 `(type, version)`으로 평탄화해 양쪽 레벨을 복사하는 것
- direct encoder 선택 — `BoundedByteSink`의 경계가 실제로 작동하기 위한 전제
- 배열 원소 상한을 별도 튜닝 값이 아니라 `maxBytes`에서 파생시킨 것
- `decodeEvolved`에 같은 상한을 적용한 것과, 그것을 두 각도에서 붙드는 테스트
- 적대적 입력 corpus를 좁게 두고 그 이유를 적은 것
- Avro를 `api`로 선언한 것(형제 JSON과 반대 판정이고, 그것이 맞다)
- 소비자 0과 membership `[]`이 정합적인 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MSV-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, `runtime_memberships: []` | 선언 |
| MSV-002 | build | `messaging-schema-avro/build.gradle` | same | Avro `api` 선언과 그 이유, 버전 1.12.0 | — |
| MSV-003 | code | `.../avro/AvroMessageCodec.java` 전문 | same | §4.14.6 | — |
| MSV-004 | code | `.../avro/AvroCompatibilityGate.java` 전문 | same | §4.7, §12.3(a) | — |
| MSV-005 | test | `AvroCompatibilityTest` (8) | same | round trip·진화·게이트 pairwise | transitive 미검증 |
| MSV-006 | test | `AvroHostileInputTest` (4) | same | 5바이트 4억 원소 공격과 방어, 진화 경로 동일 방어 | 문자열·맵은 범위 밖(javadoc이 이유를 적음) |
| MSV-007 | test | `AvroRegistryBoundsTest` (4) | same | 생성 후 맵 변경 무효, 인코딩 중 거절, 진화 경로 상한 | — |
| MSV-008 | cross-leaf code | `messaging-schema-api/.../SchemaCompatibilityValidator.java:79-112` | same | 중복의 다른 쪽 | 해당 leaf SSOT가 소유 |
| MSV-009 | cross-leaf code | `messaging-schema-api/.../SchemaRegistry.java:16-17` | same | oldest-first 계약 | 해당 leaf SSOT가 소유 |
| MSV-010 | cross-leaf code | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:360-366` | same | codec registry에 Avro 미등록 | 해당 leaf SSOT가 소유 |
| MSV-011 | build policy | `src/build.gradle:65-110`, `src/messaging/CLAUDE.md:40-43` | same | `verifyMessaging*` 9개가 스키마 진화를 부르지 않음, vendor `api` 규칙 | — |
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | §12.1·§12.3 | 정적 검색 |
| EVD-276 | command | `./gradlew :messaging:messaging-schema-avro:test --rerun-tasks` | same | 16 / 0 / 0 | 실제 브로커 없음 |
@@ -0,0 +1,511 @@
# messaging-schema-json 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-schema-json`
> SSOT owner: `messaging-schema-json`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-schema-json`
- canonical state `analysisFile`: `analysis/messaging/messaging-schema-json.md`
- source path: `src/messaging/messaging-schema-json`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]`
- registry `runtime_memberships`: `["app-bootstrap"]`
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | **1** |
| production LOC | 226 |
| 패키지 | 1 (`dev.caskeleton.messaging.schema.json`) |
| test 파일 | 3 |
| test 메서드(실행 확인) | 18 |
| 외부 의존성 | 1 (`tools.jackson.core:jackson-databind`, `implementation`) |
이 leaf는 클래스 하나다: `JacksonMessageCodec`. **그리고 messaging 플랫폼에서 production 소비자를 가진 유일한 codec이다**(§12.1).
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `.../json/JacksonMessageCodec.java` | 1 | `FULL_READ` | 226줄 전문 |
| `src/test/java/**` | 3 | `FULL_READ` | 전문 |
| `build.gradle` | 1 | `FULL_READ` | 8줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
Stable JSON codec 하나. `MessageCodec`(schema-api)을 구현하고 Jackson 3(`tools.jackson.*` 네임스페이스)을 쓴다.
javadoc이 "기본 codec으로 노출해도 안전한 이유" 셋을 명시한다.
```java
// JacksonMessageCodec.java:29-37
* <p>Three things make this safe to expose as the default. The message-type registry is closed, so
* a payload class only becomes reachable when someone registered it. The parser is constrained on
* depth, document length, and duplicate keys, so a hostile document cannot exhaust the consumer
* before the handler ever runs. And the encoded size is checked against the destination limit here
* rather than at the broker, so an oversized payload fails locally with {@code NOT_TRANSMITTED}
* evidence instead of ambiguously mid-flight.
*
* <p>Polymorphic default typing is never enabled. It is the mechanism behind most JSON
* deserialization gadget chains, and no legitimate message contract needs it.
```
세 번째가 `messaging-core-api`의 3상태 발행 결과와 직접 연결된다 — 크기 초과를 브로커가 아니라 여기서 잡으면 `NOT_TRANSMITTED` 증거가 붙은 `REJECTED`가 되고, 브로커에서 잡히면 `AMBIGUOUS`가 된다. 전자는 버려도 안전하고 후자는 아니다.
Jackson 의존성은 `implementation`이다 — public 시그니처에 Jackson 타입이 없기 때문이다. 형제 leaf(`schema-avro`, `schema-protobuf`, `cloudevents`)는 vendor 타입이 public 시그니처에 나오므로 `api`로 선언했고 build.gradle에 그 이유를 주석으로 적었다. `src/messaging/CLAUDE.md:40-43`의 게이트가 이 구분을 강제한다.
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `jackson-databind`(implementation).
나가는 것: `messaging-spring-boot-starter`(registry `allowed_dependencies`에 포함).
**실제 배선 지점이 하나 있다** — 이 플랫폼에서 유일하게 조립되는 codec이다.
```java
// messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:360-366
@ConditionalOnMissingBean(dev.caskeleton.messaging.schema.MessageCodecRegistry.class)
public dev.caskeleton.messaging.runtime.RegisteredMessageCodecs messagingCodecs(
ObjectProvider<MessageContracts> contracts) {
return dev.caskeleton.messaging.runtime.RegisteredMessageCodecs.of(
dev.caskeleton.messaging.schema.json.JacksonMessageCodec.of(
contracts.getIfAvailable(MessageContracts::none).byKey()));
}
```
`RegisteredMessageCodecs.of(defaultCodec, codecs...)`의 varargs 자리가 비어 있다. 즉 **출하 구성의 codec registry에는 JSON 하나만 들어간다.** Avro·Protobuf·raw bytes는 등록되지 않는다.
두 번째 배선 지점은 상수 참조다.
```java
// 같은 파일 :410-413
new dev.caskeleton.messaging.policy.PayloadPolicy(
JacksonMessageCodec.DEFAULT_MAX_BYTES,
JacksonMessageCodec.DEFAULT_MAX_BYTES / 2),
```
payload 정책의 상한이 **JSON codec의 상수에서 파생된다.** 포맷 중립이어야 할 admission 정책이 한 포맷의 클래스 상수를 참조한다 — §17에서 다룬다.
`contracts.getIfAvailable(MessageContracts::none)`이 기본값이므로, 애플리케이션이 `MessageContracts` bean을 내놓지 않으면 **빈 registry**로 codec이 만들어진다. 그 codec은 모든 `encode`/`decode``UNKNOWN_MESSAGE_TYPE`으로 거절한다.
---
## 3. 패키지/컴포넌트 지도
클래스 하나, 공개 표면 6개.
| 멤버 | 종류 | 용도 |
|---|---|---|
| `DEFAULT_MAX_BYTES` = 1,048,576 | public 상수 | starter의 payload 정책이 참조 |
| `MAX_NESTING_DEPTH` = 100 | public 상수 | 파서 깊이 상한 |
| `of(Map)` | factory | 기본 1 MiB |
| `of(Map, int)` | factory | 명시 상한 |
| `testingDefault(MessageType, Class)` | factory | 단일 계약, v1 |
| `testingDefault(MessageType, SchemaVersion, Class)` | factory | 단일 계약, 명시 버전 |
private 상수 둘: `MAX_STRING_CHARACTERS` = 5,000,000, `MAX_NUMBER_DIGITS` = 1,000.
---
## 4. 계약·불변식·상태 모델
### 4.1 파서 강화 — `strictMapper`
```java
// JacksonMessageCodec.java:207-225
JsonFactory factory =
JsonFactory.builder()
.streamReadConstraints(
StreamReadConstraints.builder()
.maxNestingDepth(MAX_NESTING_DEPTH) // 100
.maxDocumentLength(maxBytes) // = codec 상한
.maxNumberLength(MAX_NUMBER_DIGITS) // 1,000
.maxStringLength(MAX_STRING_CHARACTERS) // 5,000,000
.maxNameLength(MAX_STRING_CHARACTERS)
.build())
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
.build();
return JsonMapper.builder(factory)
.enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
```
여섯 가지 방어가 한 곳에 있다.
| 설정 | 막는 것 |
|---|---|
| `maxNestingDepth(100)` | 중첩 폭탄으로 파서 스택 소진 |
| `maxDocumentLength(maxBytes)` | 문서 길이 — codec 상한과 동일 |
| `maxNumberLength(1000)` | 초대형 `BigDecimal` 파싱 비용 |
| `maxStringLength`/`maxNameLength` | 단일 토큰 메모리 |
| `STRICT_DUPLICATE_DETECTION` + `FAIL_ON_READING_DUP_TREE_KEY` | 중복 키 — 파서마다 "먼저/나중 승리"가 달라 파싱 차이 공격이 됨 |
| `FAIL_ON_TRAILING_TOKENS` | 문서 뒤 추가 JSON — 두 번째 문서를 조용히 무시하는 것 |
| `FAIL_ON_UNKNOWN_PROPERTIES` | 미등록 필드 |
그리고 **polymorphic default typing을 켜지 않는다.** javadoc이 그것이 대부분의 JSON gadget chain의 기반이라고 적는다.
`maxDocumentLength``maxBytes`와 같다는 점이 중요하다 — 인코딩 상한과 디코딩 파서 상한이 하나의 값에서 나온다. 따로 두면 둘이 갈라진다.
### 4.2 인코딩 — 스트리밍 경계
```java
BoundedByteSink sink = BoundedByteSink.of(maxBytes, "PAYLOAD_TOO_LARGE");
try {
mapper.writeValue(sink, payload);
} catch (JacksonException exception) {
throw unwrapTooLarge(exception);
}
```
주석이 이유를 적는다 — "Jackson writes incrementally, so a payload whose serialized form is far larger than the limit stops at the limit instead of after the whole graph has been rendered into a buffer nobody bounded."
`unwrapTooLarge`가 필요한 이유도 명시돼 있다.
```java
// :190-196
* <p>Jackson wraps stream failures, so the size refusal would otherwise reach the caller as
* {@code JSON_ENCODE_FAILED} indistinguishable from a payload the mapper genuinely could not
* render, and the two need different operator responses.
```
`for (Throwable cause = exception; cause != null; cause = cause.getCause())` — 원인 사슬을 끝까지 훑어 `MessageTooLargeException`을 찾는다. 못 찾으면 `MessageSerializationException("JSON_ENCODE_FAILED")`.
### 4.3 registry 조회 — 세 갈래 결과
```java
private Class<?> requireRegistered(MessageType type, SchemaVersion version) {
MessageContractKey key = new MessageContractKey(type, version);
Class<?> registered = registry.get(key);
if (registered != null) return registered;
boolean typeIsKnown = registry.keySet().stream().anyMatch(known -> known.type().equals(type));
if (typeIsKnown) {
// Deliberately not falling back to another version's class: decoding v999 bytes with the v1
// class is exactly the silent type confusion the version-keyed registry exists to stop.
throw new MessageValidationException("SCHEMA_VERSION_NOT_REGISTERED", ...);
}
throw new MessageValidationException("UNKNOWN_MESSAGE_TYPE", ...);
}
```
`SCHEMA_VERSION_NOT_REGISTERED` 메시지에는 `registeredVersions(type)`가 정렬되어 포함된다. 테스트가 그 내용을 직접 단언한다 — `hasMessageContaining("order.created v999").hasMessageContaining("[1, 2]")`(`JsonContractRegistryTest.java:58-61`). 운영자가 "1과 2는 있고 999는 없다"를 에러 메시지만으로 알 수 있다.
### 4.4 인코딩·디코딩의 타입 검사 비대칭
| 방향 | 검사 |
|---|---|
| `encode` | `registered.isInstance(payload)`**하위 타입 허용** |
| `decode` | `registered.equals(payloadType)`**정확 일치 요구** |
비대칭이 합리적이다. 인코딩에서 `OrderCreated`의 하위 타입을 넘기면 Jackson이 등록된 형태로 직렬화한다. 디코딩에서 하위 타입을 허용하면 등록된 계약과 다른 클래스로 역직렬화되므로 정확 일치여야 한다. 다만 이 비대칭은 주석으로 설명되지 않았다 — §15의 추론 항목이다.
### 4.5 디코딩의 이중 상한
```java
if (encoded.length > maxBytes) { throw new MessageTooLargeException("PAYLOAD_TOO_LARGE", ...); }
...
return mapper.readValue(encoded, payloadType);
```
명시 검사 하나(`encoded.length`)와 파서 내부 검사 하나(`maxDocumentLength`)가 겹친다. 중복이지만 둘의 실패 형태가 다르다 — 전자는 `MessageTooLargeException`, 후자는 `JacksonException``MessageSerializationException`. 명시 검사가 있어야 크기 초과가 크기 초과로 보고된다.
### 4.6 `EncodedMessage`에 붙는 schema reference
```java
return new EncodedMessage(
sink.toByteArray(), ContentType.JSON, Optional.of(SchemaReference.of(type.value(), version)));
```
subject가 message type 값이고 URI는 없다. 즉 이 codec은 외부 schema registry를 쓰지 않고 "타입 이름 + 버전"을 스키마 신원으로 삼는다. 테스트가 확인한다(`JacksonMessageCodecTest.encodedMessageCarriesTheSchemaReference`).
---
## 5. 주요 실행 경로
**encode:** `requireRegistered(type, version)` → payload가 등록 타입의 인스턴스인지 → `BoundedByteSink` 생성 → `mapper.writeValue(sink, payload)` → 실패 시 `unwrapTooLarge``EncodedMessage(bytes, JSON, SchemaReference)`
**decode:** `requireRegistered(type, version)` → 요청 클래스가 등록 클래스와 정확히 같은지 → `encoded.length` 상한 → `mapper.readValue``JacksonException`이면 `JSON_DECODE_FAILED`
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 조건 | retryable |
|---|---|---|:---:|
| `UNKNOWN_MESSAGE_TYPE` | `MessageValidationException` | 타입 자체 미등록 | false |
| `SCHEMA_VERSION_NOT_REGISTERED` | `MessageValidationException` | 타입은 알고 버전 미등록 | false |
| `PAYLOAD_TYPE_MISMATCH` | `MessageValidationException` | encode: 인스턴스 아님 / decode: 클래스 불일치 | false |
| `PAYLOAD_TOO_LARGE` | `MessageTooLargeException` | 인코딩 중 한도 초과 또는 디코딩 입력 초과 | false |
| `JSON_ENCODE_FAILED` | `MessageSerializationException` | 그 외 Jackson 인코딩 실패 | false |
| `JSON_DECODE_FAILED` | `MessageSerializationException` | 파싱 실패(깊이·중복키·trailing·미지 필드 포함) | false |
전부 `retryable = false`다 — `PERMANENT_BUSINESS``DESERIALIZATION` 카테고리다. 같은 바이트를 다시 디코딩해도 같은 결과이므로 일관적이다.
**진단 손실 하나.** 파서 강화가 잡는 여섯 가지(깊이, 중복 키, trailing token, 미지 필드, 문서 길이, 토큰 길이)가 전부 하나의 코드 `JSON_DECODE_FAILED`로 접힌다. 운영자는 "JSON 디코딩 실패"만 보고 원인 여섯 갈래를 구분할 수 없다. 원인 예외가 `cause`로 붙지만 `FailureDescriptor``exceptionType``Optional.empty()`로 둔다(`MessageSerializationException`의 3인자 생성자 경로). §17 참조.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
동시성: `JacksonMessageCodec`은 불변이다 — `registry``Map.copyOf`, `maxBytes`는 int, `mapper`는 빌드 후 재구성되지 않는 Jackson `ObjectMapper`(스레드 안전). `BoundedByteSink`는 매 `encode`마다 새로 만들어지므로 공유되지 않는다.
`PlatformOverheadPerformanceTest.aRoundTripDoesNotAllocateAGrowingRetainedSet`이 codec이 메시지별 상태를 보유하지 않음을 간접 확인한다(메시지당 유지 메모리 64바이트 미만).
---
## 8. 설정·기능 플래그·환경 차이
설정 파일 없음. 상수:
| 상수 | 값 | 가시성 |
|---|---:|---|
| `DEFAULT_MAX_BYTES` | 1,048,576 | public — starter가 참조 |
| `MAX_NESTING_DEPTH` | 100 | public |
| `MAX_STRING_CHARACTERS` | 5,000,000 | private |
| `MAX_NUMBER_DIGITS` | 1,000 | private |
`maxBytes`는 생성자 인자로 재정의 가능하고 파서의 `maxDocumentLength`가 그 값을 따라간다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-schema-json:test`. **BUILD SUCCESSFUL, 18 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `JacksonMessageCodecTest` | 9 | round trip, 1 MiB 초과 거절, 미등록 타입, payload 타입 불일치, trailing token, 미지 필드, 중복 키, 깊이 200 거절, schema reference | 등록 registry가 실제 배포에서 채워지는지 |
| `JsonContractRegistryTest` | 6 | 버전별 클래스 분리, v999 거절 + 등록 버전 목록 노출, 클래스/버전 짝 검사, 타입 미등록과 버전 미등록 구분, 20 MiB payload가 1,024 상한에서 멈춤, 정확히 상한인 payload 허용 | — |
| `PlatformOverheadPerformanceTest` | 3 | 봉투 생성 < 20µs/건, JSON 인코딩 < 50µs/건, round trip 유지 메모리 < 64 B/건 | 실제 처리량. 의도적으로 브로커 없음 |
성능 테스트의 자기 규정이 명확하다.
```java
// PlatformOverheadPerformanceTest.java:22-30
* <p>This measures what the platform adds identity, validation, encoding and nothing else.
* There is no broker in the loop, deliberately: broker throughput is a property of the deployment
* and varies by an order of magnitude between a laptop and a cluster, so asserting on it produces a
* test that fails for reasons nobody can act on.
*
* <p>The budgets are generous on purpose. The regression worth catching here is structural an
* accidental per-message reflection call, a defensive copy that became a deep copy, a validator
* that started compiling a regex per invocation and those cost orders of magnitude, not
* percentages. A tight budget would instead catch a busy CI agent.
```
이것은 성능 테스트가 무엇을 잡으려는지 명시한 드문 예다 — 퍼센트가 아니라 자릿수 회귀. 다만 `aRoundTripDoesNotAllocateAGrowingRetainedSet``System.gc()``totalMemory() - freeMemory()`에 의존하므로 JVM이 GC 힌트를 무시하면 잡음이 낀다. 64 B/건이라는 여유가 그것을 흡수한다.
`JsonContractRegistryTest`의 클래스 javadoc이 이 codec에서 만난 두 결함을 기록한다 — 타입만으로 키를 잡았던 것과, 완성된 배열에 크기 제한을 적용했던 것.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | Jackson이 public 시그니처에 없으므로 `implementation`이 맞음 — 형제 leaf와 반대 판정 |
| ArchUnit | 전용 규칙 없음 |
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/272-schema-family-reachability.txt`.
### 12.1 Public surface reachability
`JacksonMessageCodec`의 leaf 밖 참조는 **1개 파일**이다 — `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java`.
이 하나가 messaging codec 전체에서 유일한 production 소비다. 형제 비교:
| codec | 소비자 | registry membership |
|---|---|---|
| `JacksonMessageCodec` | `MessagingCoreAutoConfiguration` | `["app-bootstrap"]` |
| `AvroMessageCodec` | **없음** | `[]` |
| `ProtobufMessageCodec` | **없음** | `[]` |
| `RawBytesMessageCodec` | **없음** | (schema-api 소속, `["app-bootstrap"]`) |
| `DefaultCloudEventMapper` | **없음** | `["app-bootstrap"]` |
Avro·Protobuf는 소비자 없음과 membership 없음이 **일치한다** — 정합적인 incubating 상태다. `RawBytesMessageCodec`과 CloudEvents는 어긋난다(각 leaf 문서 참조).
### 12.2 Conditional sibling comparison
이 leaf에는 bean이 없다. 그러나 이 leaf가 조립되는 지점의 조건은 확인했다.
```java
@ConditionalOnMissingBean(dev.caskeleton.messaging.schema.MessageCodecRegistry.class)
```
즉 애플리케이션이 자기 `MessageCodecRegistry`를 내놓으면 JSON codec 조립이 통째로 대체된다. 그 경우 `PayloadPolicy`가 참조하는 `JacksonMessageCodec.DEFAULT_MAX_BYTES`**그대로 남는다** — 정책 상한만 JSON codec의 값을 유지한다. §17 참조.
### 12.3 Duplicate mechanism sweep
JSON 인코딩/디코딩을 하는 다른 지점이 저장소에 여럿 있다(web adapter의 응답 직렬화, redis codec, fileserver 저널, mongo cursor 등). 그러나 그들은 **다른 책임**(HTTP 응답, 캐시 봉투, 로컬 저널)이고 messaging 계약을 구현하지 않는다. runtime eligibility가 겹치지 않으므로 중복 경쟁으로 분류하지 않는다.
같은 `messaging` family 안에서 `MessageCodec`을 구현하는 것은 넷이고(JSON·Avro·Protobuf·raw) content type이 서로 달라 `RegisteredMessageCodecs.register`가 충돌을 거절한다. 책임 분리가 명확하다.
**한 가지 실질 중복이 있다.** 1 MiB payload 상한이 messaging family의 production 코드 **다섯 곳**에서 독립적으로 선언된다.
| 위치 | 가시성 | 값 |
|---|---|---:|
| `messaging-policy/PayloadPolicy.DEFAULT_MAX_BYTES:17` | **public** | 1,048,576 |
| `messaging-schema-api/RawBytesMessageCodec.DEFAULT_MAX_BYTES:21` | public | 1,048,576 |
| `messaging-schema-json/JacksonMessageCodec.DEFAULT_MAX_BYTES:42` | public | 1,048,576 |
| `messaging-schema-avro/AvroMessageCodec.DEFAULT_MAX_BYTES:46` | private | 1,048,576 |
| `messaging-schema-protobuf/ProtobufMessageCodec.DEFAULT_MAX_BYTES:35` | private | 1,048,576 |
테스트에도 네 곳(`ClaimCheckRetentionValidatorTest:47`, `DestinationProfileValidatorTest:225`, `RabbitContractHarness:40`, `InMemoryMessagingHarness:31`)이 같은 리터럴을 갖는다.
`schema-api``RawBytesMessageCodec` javadoc은 이 값을 "The default encoded byte limit **shared with** the Stable codecs"라고 부르는데, 실제로는 공유되지 않고 복사돼 있다. 그리고 **정책 쪽에 이미 주인이 있다**`messaging-policy``PayloadPolicy.DEFAULT_MAX_BYTES`가 public 상수로 존재한다. 그런데 starter는 그것을 쓰지 않고 `JacksonMessageCodec.DEFAULT_MAX_BYTES`를 참조한다(§2). 같은 값의 후보가 둘 있고 배선이 덜 적절한 쪽을 골랐다.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `RawBytesMessageCodec` javadoc: 1 MiB가 "Stable codec들과 공유되는" 기본 상한 | 네 codec에 각자 리터럴 존재, 공유 상수 없음 | **표현 drift** — 값은 일치, "shared"는 사실이 아님 |
| `JacksonMessageCodec` javadoc: polymorphic default typing 미사용 | `strictMapper``activateDefaultTyping` 호출 없음 | **일치** |
| `docs/messaging/support-matrix.md`의 JSON Stable 등급 | 이 leaf가 유일하게 조립되는 codec인 것과 정합 | **일치** |
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
`JsonContractRegistryTest` 클래스 javadoc이 이 codec에서 만난 두 결함을 남겼다.
```java
// JsonContractRegistryTest.java:20-24
* <p>Two defects met in this codec. The registry was keyed on message type alone, so a message
* labelled v999 was decoded with the v1 class and kept its v999 label the compatibility gate and
* the audit record then both described a contract that was never registered. And the size limit was
* applied to the finished byte array, which reports an oversized payload rather than preventing
* one.
```
두 결함 다 `messaging-schema-api`가 소유하는 타입(`MessageContractKey`, `BoundedByteSink`)으로 고쳐졌다. 즉 **이 leaf에서 발견된 문제가 상위 leaf의 타입을 만들어냈다.**
`MessagingCoreAutoConfiguration:420-427`의 주석은 이 codec이 아니라 publisher 조립 결함(MSG-INT-003)을 기록하는데, 같은 configuration 안에 있으므로 조립 이력의 맥락으로 참조할 가치가 있다 — "no configuration produced one … the starter did not depend on that leaf."
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §D, §E | codec별 소비자와 registry membership | 정적 검색 |
| EVD-275 | command | `./gradlew :messaging:messaging-schema-json:test --rerun-tasks` | BUILD SUCCESSFUL, 18 / 0 / 0 | 브로커 없음 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 기본 codec으로 안전한 이유 셋 — 클래스 javadoc
- polymorphic default typing 금지 — 클래스 javadoc
- 크기 초과를 로컬에서 잡아야 `NOT_TRANSMITTED`가 된다 — 클래스 javadoc
- `unwrapTooLarge`가 필요한 이유 — 메서드 javadoc
- 다른 버전 클래스로 폴백하지 않는 이유 — `requireRegistered` 주석
- 성능 예산이 느슨한 이유 — `PlatformOverheadPerformanceTest` javadoc
- 이 codec에서 만난 두 결함 — `JsonContractRegistryTest` javadoc
**추론**
- encode는 `isInstance`, decode는 `equals`로 비대칭인 이유 → **추론**. 방향별 안전성으로 설명되지만 주석이 없다.
- 파서 실패 여섯 갈래가 한 코드로 접힌 것이 의도인지 → **미상**.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 226줄 전문의 계약과 파서 강화 설정 전수
- 18개 테스트가 통과하고 무엇을 단언하는지
- 이 codec이 유일하게 조립되는 codec이라는 것과 그 조립 코드의 정확한 형태
- payload 정책 상한이 이 codec의 public 상수에서 파생된다는 것
- 1 MiB 상한이 네 codec에 복사돼 있다는 것
**확인하지 못한 것**
- 실제 배포에서 `MessageContracts` bean이 채워지는지. 채워지지 않으면 codec은 모든 메시지를 `UNKNOWN_MESSAGE_TYPE`으로 거절한다. 이 저장소에 `MessageContracts` production 구현이 있는지는 starter leaf가 소유한다.
- Jackson 3의 `StreamReadConstraints`가 이 값들에서 실제로 어떻게 실패하는지 — 테스트는 깊이 200과 중복 키만 확인했고 `maxNumberLength`·`maxStringLength`는 검증하지 않았다.
- 성능 예산이 실제 CI 하드웨어에서 얼마나 여유 있는지 — 이번 실행은 통과했으나 측정값을 남기지 않았다.
---
## 17. 손볼 것
### P2 — 포맷 중립 payload 정책이, 자기 상수를 두고 JSON codec의 상수를 참조한다
- **사실.** `MessagingCoreAutoConfiguration:410-413``new PayloadPolicy(JacksonMessageCodec.DEFAULT_MAX_BYTES, JacksonMessageCodec.DEFAULT_MAX_BYTES / 2)`를 만든다. 그런데 `PayloadPolicy` 자신이 같은 값의 public 상수 `PayloadPolicy.DEFAULT_MAX_BYTES`(`messaging-policy/PayloadPolicy.java:17`)를 갖고 있다.
- **근거.** 두 라인, 그리고 `git grep -n '1_048_576' -- 'src/messaging/**/*.java'`의 production 5건.
- **왜 문제인가.** `MessagingAdmissionController`는 목적지의 codec이 무엇이든 지나는 관문이다. 그 상한이 **한 포맷 클래스**의 상수에서 나오면 두 가지가 깨진다. (1) `@ConditionalOnMissingBean`이 허용하는 대로 애플리케이션이 자기 `MessageCodecRegistry`를 내놓아 JSON codec을 대체해도, 정책은 여전히 JSON codec의 값을 읽는다. (2) 다섯 곳의 리터럴 중 하나만 바뀌면 조용히 갈라지고, `RawBytesMessageCodec` javadoc이 이미 "shared with the Stable codecs"라고 사실과 다르게 부르고 있다. 정책 소유자가 이미 존재하는데 배선이 그것을 지나쳤다.
- **확인 방법.** `git grep -n '1_048_576' -- 'src/messaging/**/*.java'` · `grep -n 'DEFAULT_MAX_BYTES' src/messaging/messaging-policy/src/main/java/dev/caskeleton/messaging/policy/PayloadPolicy.java`
- **후보.** starter가 `PayloadPolicy.DEFAULT_MAX_BYTES`를 참조하게 바꾸고, 네 codec의 기본값도 그 상수(또는 설정 프로퍼티)에서 파생시킨다.
- **다음 단계.** **CASE 후보.** 조립 지점이 한 줄이고 재현이 정적이며, "값은 맞는데 출처가 틀렸다"는 형태가 명확하다.
### P3 — 파서 방어 여섯 갈래가 하나의 실패 코드로 접힌다
- **사실.** 깊이 초과·중복 키·trailing token·미지 필드·문서 길이·토큰 길이가 전부 `JSON_DECODE_FAILED`가 된다.
- **근거.** `decode``catch (JacksonException)` 단일 분기(`JacksonMessageCodec.java:155-158`).
- **왜 문제인가.** 여섯 중 셋(중복 키, trailing token, 깊이)은 **적대적 입력의 신호**이고 나머지는 계약 불일치다. DLQ에 쌓인 메시지를 보는 운영자가 그 둘을 구분할 수 없다. `FailureDescriptor.exceptionType`도 비어 있다.
- **확인 방법.** `JacksonMessageCodecTest`의 네 케이스가 전부 같은 예외 타입을 기대하는 것으로 확인 가능.
- **후보.** `JacksonException` 하위 타입별로 코드를 나누거나, 최소한 `exceptionType`에 원인 클래스 단순명을 채운다.
- **다음 단계.** **REFERENCE 후보**(실패 코드는 운영자의 다음 행동이 갈리는 지점마다 나눈다).
### P3 — 빈 registry로 조립되면 모든 메시지가 거절된다
- **사실.** `contracts.getIfAvailable(MessageContracts::none)`이 기본값이므로 `MessageContracts` bean이 없으면 빈 registry로 codec이 만들어진다.
- **근거.** `MessagingCoreAutoConfiguration:362-365`.
- **왜 문제인가.** 그 codec은 시작에 성공하고 첫 publish에서 `UNKNOWN_MESSAGE_TYPE`으로 실패한다. `messaging-core-api` 계열의 다른 leaf에서 관측된 것과 같은 형태다 — "시작은 하고 첫 쓰기에서 실패한다."
- **확인 방법.** `MessageContracts` production 구현의 존재 여부를 starter leaf에서 확인해야 한다.
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 이 leaf 밖(`messaging-spring-boot-starter`)의 사실에 걸린다. 그 leaf SSOT가 답을 갖는다.
### 확인된 설계(문제 아님)
- 파서 상한 여섯 가지와 polymorphic typing 금지
- `maxDocumentLength`가 codec 상한과 같은 값에서 나오는 것
- `unwrapTooLarge`가 원인 사슬을 훑어 크기 실패를 크기 실패로 보고하는 것
- 미등록 버전 에러가 등록된 버전 목록을 포함하는 것
- Jackson을 `implementation`으로 선언한 것(형제 leaf와 반대이고, 그것이 맞다)
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MSJ-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, memberships `["app-bootstrap"]` | 선언 |
| MSJ-002 | build | `messaging-schema-json/build.gradle` | same | Jackson이 `implementation` | — |
| MSJ-003 | code | `.../json/JacksonMessageCodec.java` 전문 | same | §4 전체 | — |
| MSJ-004 | test | `JacksonMessageCodecTest` (9) | same | 파서 방어와 registry 거절 | 브로커 없음 |
| MSJ-005 | test | `JsonContractRegistryTest` (6) | same | 버전 키 동작, 20 MiB가 1 KiB 상한에서 멈춤 | — |
| MSJ-006 | test | `PlatformOverheadPerformanceTest` (3) | same | 구조적 회귀 예산 | 처리량 아님. `System.gc()` 의존 |
| MSJ-007 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:358-366, 408-417` | same | 유일한 codec 조립 지점, varargs 비어 있음, payload 정책의 상수 출처 | 해당 leaf SSOT가 소유 |
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | codec별 소비자와 membership | 정적 검색 |
| EVD-275 | command | `./gradlew :messaging:messaging-schema-json:test --rerun-tasks` | same | 18 / 0 / 0 | — |
@@ -0,0 +1,589 @@
# messaging-schema-protobuf 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-schema-protobuf`
> SSOT owner: `messaging-schema-protobuf`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-schema-protobuf`
- canonical state `analysisFile`: `analysis/messaging/messaging-schema-protobuf.md`
- source path: `src/messaging/messaging-schema-protobuf`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]`
- registry `runtime_memberships`: **`[]`** — build-only / incubating
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 2 |
| production LOC | 199 |
| 패키지 | 1 (`dev.caskeleton.messaging.schema.protobuf`) |
| test 파일 | 1 |
| test 메서드(실행 확인) | 12 |
| test 리소스 | `src/test/proto/order_created_v1.proto` (**컴파일되지 않음**) |
| 외부 의존성 | 1 (`com.google.protobuf:protobuf-java:4.29.3`, **`api`**) |
두 타입: `ProtobufMessageCodec`(codec), `ProtobufMessageContract`(record — 클래스와 parser의 검증된 짝).
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `.../protobuf/ProtobufMessageCodec.java` | 1 | `FULL_READ` | 146줄 전문 |
| `.../protobuf/ProtobufMessageContract.java` | 1 | `FULL_READ` | 53줄 전문 |
| `src/test/java/**` | 1 | `FULL_READ` | 255줄 전문 |
| `src/test/proto/order_created_v1.proto` | 1 | `FULL_READ` | 23줄 전문. 어느 빌드도 컴파일하지 않음(§12.4) |
| `build.gradle` | 1 | `FULL_READ` | 주석 포함 11줄 |
| `gradle.lockfile` | 1 | `FULL_READ` | protobuf 좌표 2건 확인 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
선택적 Protobuf codec. `runtime_memberships: []`이고 starter의 codec registry에도 등록되지 않는다 — build-only / incubating.
protobuf를 `api`로 선언한 이유가 build.gradle 주석에 있다.
```groovy
// api: ProtobufMessageContract is a public record over com.google.protobuf.Message and
// Parser, and registering a contract is the first thing a consumer of this codec does.
api 'com.google.protobuf:protobuf-java:4.29.3'
```
`src/messaging/CLAUDE.md:40-43`의 vendor `api` 게이트를 통과한다 — `ProtobufMessageContract(Class<? extends Message>, Parser<? extends Message>)`가 public record이므로 소비자가 그 타입을 이름 부르지 않고는 계약을 등록할 수 없다.
**이 leaf의 핵심 문제 인식**은 클래스 javadoc이 한 문장으로 적는다.
```java
// ProtobufMessageCodec.java:25-27
* <p>Bound to a closed registry of generated parsers. Protobuf's own wire format will happily
* decode almost any bytes into almost any message, so without the registry a type confusion is
* silent the consumer gets a populated object built from the wrong schema rather than an error.
```
`messaging-schema-avro`의 "does not fail — it produces plausible garbage"와 같은 성질이다. **JSON은 틀린 스키마로 디코딩하면 대개 실패하고, Avro와 Protobuf는 실패하지 않는다.** 그래서 두 leaf 모두 registry를 계약의 중심에 둔다.
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `protobuf-java:4.29.3`(api).
나가는 것: **없다.** 어떤 leaf의 `allowed_dependencies`에도 없고 starter 목록에도 없다.
런타임 배선: 없음. bean 없음(Spring 주석 0개).
lockfile이 확인하는 실제 해석:
```
com.google.protobuf:protobuf-java:4.29.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
```
컴파일/런타임은 4.29.3, annotation processor 경로만 4.33.2다. §12.4에서 저장소 전체의 protobuf 버전 지형을 다룬다.
---
## 3. 패키지/컴포넌트 지도
```
ProtobufMessageContract (record)
├── payloadType : Class<? extends Message>
├── parser : Parser<? extends Message>
└── compact 생성자가 빈 입력을 파싱해 짝을 증명
ProtobufMessageCodec (MessageCodec 구현)
├── encode(type, version, Message) → requireFits + writeTo(sink)
├── decode(type, version, byte[], Class) → parser.parseFrom
├── requireRegistered(type, version) → 2단 에러
└── registeredVersions(type) → 에러 메시지용 정렬 목록
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `ProtobufMessageContract`: 생성 시점에 짝을 증명한다
이 leaf에서 가장 밀도 높은 결정이다.
```java
// ProtobufMessageContract.java:10-20
* <p>They used to live in two parallel maps. Nothing checked that the two agreed, so a registry
* that paired {@code OrderCreated.class} with {@code OrderCancelled}'s parser was accepted at
* construction and produced a {@code ClassCastException} at decode time on a broker thread, for
* one message type, in production. Worse, a type present in one map and absent from the other made
* {@code parsers.get(type)} return null and the decode fail with a {@code NullPointerException}
* rather than the registry error the operator needed to read.
*
* <p>Binding them in one value makes the mismatch impossible to express, and the constructor proves
* the pairing by parsing empty input: the parser's default instance must be an instance of the
* declared class.
```
증명 방법이 영리하다.
```java
public ProtobufMessageContract {
Message defaultInstance;
try {
defaultInstance = parser.parseFrom(new byte[0]);
} catch (Exception failure) {
throw new MessagingConfigurationException("PROTOBUF_CONTRACT_UNUSABLE", ..., failure);
}
if (!payloadType.isInstance(defaultInstance)) {
throw new MessagingConfigurationException("PROTOBUF_CONTRACT_MISMATCH", ...);
}
}
```
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")`.
### 4.2 인코딩: 크기를 미리 알 수 있다
```java
BoundedByteSink sink = BoundedByteSink.of(maxBytes, "PAYLOAD_TOO_LARGE");
sink.requireFits(message.getSerializedSize());
try {
message.writeTo(sink);
}
```
주석이 이유를 적는다.
```java
// :77-79
// Protobuf knows its serialized size exactly before writing a byte, so the limit is checked
// against that estimate first and enforced again by the sink. `toByteArray` allocated the whole
// encoding before anything could object.
```
**세 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."
### 4.3 인코딩 타입 검사: 이중 조건
```java
if (!(payload instanceof Message message) || !contract.payloadType().isInstance(payload)) {
throw new MessageValidationException("PAYLOAD_TYPE_MISMATCH", ...);
}
```
`Message`인지와 등록된 클래스의 인스턴스인지를 함께 본다. 후자만으로 충분해 보이지만 전자가 `writeTo`를 부를 수 있음을 보장한다.
### 4.4 디코딩: 정확 일치와 상한
```java
if (!contract.payloadType().equals(payloadType)) { throw ... PAYLOAD_TYPE_MISMATCH ... }
if (encoded.length > maxBytes) { throw ... PAYLOAD_TOO_LARGE ... }
return payloadType.cast(contract.parser().parseFrom(encoded));
```
JSON codec과 같은 비대칭이다 — encode는 `isInstance`(하위 타입 허용), decode는 `equals`(정확 일치).
### 4.5 `requireRegistered`: 2단 에러, JSON과 같은 어휘
```java
// :123-127
if (typeIsKnown) {
// Protobuf will happily decode almost any bytes with almost any parser, so falling back to
// another version's parser does not fail — it returns a populated object built from a schema
// nobody registered for this version.
throw new MessageValidationException("SCHEMA_VERSION_NOT_REGISTERED", ...);
}
throw new MessageValidationException("UNKNOWN_MESSAGE_TYPE", ...);
```
코드 문자열이 `JacksonMessageCodec`과 동일하다(`SCHEMA_VERSION_NOT_REGISTERED`, `UNKNOWN_MESSAGE_TYPE`). `AvroMessageCodec``AVRO_` 접두사를 붙여 어휘가 갈라진다 — `analysis/messaging/messaging-schema-avro.md` §12.3(d)가 소유한다.
에러 메시지에 `registeredVersions(type)`가 정렬되어 포함되는 것도 JSON과 같다.
### 4.6 unknown field 보존
```java
// :29-31
* <p>Unknown fields are preserved by the generated types, which is what makes forward compatibility
* work: an old consumer round-tripping a message written by a newer producer does not silently drop
* the fields it does not understand.
```
이것은 이 codec이 하는 일이 아니라 **protobuf-java 생성 타입의 성질**이다. 테스트가 그 성질을 직접 확인한다 — `aNewWriterIsStillReadableByAnOldReader``asV1.getUnknownFields().hasField(5)`를 단언하고 `as("the unrecognised field is retained, not dropped, so a round trip does not lose it")`라고 적는다.
---
## 5. 주요 실행 경로
**계약 등록:** `new ProtobufMessageContract(class, parser)` → 빈 입력 파싱 → 클래스 일치 확인 → 실패 시 `MessagingConfigurationException`
**encode:** `requireRegistered``Message`이고 등록 클래스인지 → `requireFits(getSerializedSize())``writeTo(sink)``EncodedMessage(bytes, PROTOBUF, SchemaReference)`
**decode:** `requireRegistered` → 요청 클래스 정확 일치 → `encoded.length` 상한 → `parser.parseFrom`
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 카테고리 | 조건 |
|---|---|---|---|
| `PROTOBUF_CONTRACT_UNUSABLE` | `MessagingConfigurationException` | `CONFIGURATION` | parser가 빈 입력을 파싱하지 못함 |
| `PROTOBUF_CONTRACT_MISMATCH` | `MessagingConfigurationException` | `CONFIGURATION` | parser 산출 클래스 ≠ 선언 클래스 |
| `UNKNOWN_MESSAGE_TYPE` | `MessageValidationException` | `PERMANENT_BUSINESS` | 타입 미등록 |
| `SCHEMA_VERSION_NOT_REGISTERED` | `MessageValidationException` | `PERMANENT_BUSINESS` | 버전 미등록 |
| `PAYLOAD_TYPE_MISMATCH` | `MessageValidationException` | `PERMANENT_BUSINESS` | 타입 불일치(양방향) |
| `PAYLOAD_TOO_LARGE` | `MessageTooLargeException` | `PERMANENT_BUSINESS` | 크기 초과 |
| `PROTOBUF_ENCODE_FAILED` | `MessageSerializationException` | `DESERIALIZATION` | `IOException` |
| `PROTOBUF_DECODE_FAILED` | `MessageSerializationException` | `DESERIALIZATION` | `InvalidProtocolBufferException` |
**Avro와 다른 점 하나.** Avro는 `catch (IOException | RuntimeException)` 안에서 `MessageTooLargeException``instanceof`로 통과시킨다. Protobuf는 `catch (IOException failure)`만 잡으므로 sink가 던지는 `MessageTooLargeException`(`RuntimeException`)이 그대로 전파된다. 별도 통과 로직이 필요 없다 — protobuf-java가 예외를 감싸지 않기 때문이다. 세 codec이 같은 문제를 세 가지로 푸는데(JSON은 원인 사슬 탐색, Avro는 즉시 `instanceof`, Protobuf는 아무것도 안 함) 각각 라이브러리 동작에 맞는 최소 해법이다. 다만 그 이유가 코드에 적혀 있지 않다.
**계약 위반은 `CONFIGURATION`이고 메시지 실패가 아니다.** `ProtobufMessageContract` 생성 실패는 registry를 조립하는 시점, 즉 시작 시점에 난다. `MessagingConfigurationException` javadoc이 그 의도를 적는다 — "Raised at startup wherever possible."
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
`ProtobufMessageCodec`은 불변이다 — `contracts``Map.copyOf`, `maxBytes`는 int. `ProtobufMessageContract`는 record이고 `Class`/`Parser` 둘 다 protobuf-java에서 스레드 안전하다.
`BoundedByteSink`는 매 encode마다 새로 만들어진다.
`Map.copyOf`가 여기서는 **얕은 복사 문제가 없다**`Map<MessageContractKey, ProtobufMessageContract>`가 이미 평탄한 한 레벨이다. `AvroMessageCodec`이 중첩 맵을 받아 `flatten`이 필요했던 것과 대비된다(§`messaging-schema-avro` §4.2). 두 codec이 같은 registry 개념을 다른 형태로 받았고, 평탄한 쪽이 결함을 만들지 않았다.
---
## 8. 설정·기능 플래그·환경 차이
설정 없음.
| 상수 | 값 | 가시성 |
|---|---:|---|
| `ProtobufMessageCodec.DEFAULT_MAX_BYTES` | 1,048,576 | **private** |
protobuf-java 버전은 `4.29.3`으로 build.gradle에 직접 고정돼 있다 — §12.4.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. 외부 schema registry를 쓰지 않는다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-schema-protobuf:test`. **BUILD SUCCESSFUL, 12 tests, 0 skipped, 0 failures**.
테스트 하나가 모든 것을 덮는다: `ProtobufCompatibilityTest`.
| 테스트 | 증명하는 것 |
|---|---|
| `aRoundTripPreservesEveryField` | 인코딩/디코딩 왕복, content type |
| `renamingAFieldKeepsItsValueBecauseTheTagNumberIsTheContract` | 태그 4의 이름을 `currency``currency_code`로 바꿔도 값 보존 |
| `anAddedFieldDecodesAsItsDefaultForAnOldWriter` | v1이 쓴 바이트를 v2로 읽으면 새 필드가 기본값 `""` |
| `aNewWriterIsStillReadableByAnOldReader` | v2가 쓴 것을 v1로 읽어도 태그 4 보존, 태그 5는 unknown field로 유지 |
| `reusingATagNumberCorruptsTheReadWhichIsWhyTagsAreNeverRecycled` | 태그 4를 string→int64로 재사용하면 값이 `0L`로 소실 |
| `anUnregisteredTypeIsRejectedRatherThanGuessed` | 타입 미등록 거절 |
| `anUnregisteredVersionIsRejectedRatherThanDecodedWithAnotherVersionsParser` | v2 요청이 v1 parser로 폴백하지 않음, 메시지에 `order.created v2` 포함 |
| `aParserThatDoesNotProduceTheDeclaredClassIsRejectedAtConstruction` | 짝 검증 |
| `anOversizedPayloadIsRefusedBeforeItIsSerialized` | 16바이트 상한에서 `refused at byte` |
| `aPayloadAtExactlyTheLimitIsAccepted` | 정확히 상한인 payload 허용 |
| `aLengthPrefixNoPayloadOfThisSizeCouldHonourIsADecodeFailure` | 4억 바이트를 주장하는 6바이트 메시지가 할당이 아니라 디코딩 실패로 끝남 |
| `theEncodedMessageCarriesItsSchemaReference` | schema reference의 버전 |
**테스트 설계의 핵심 결정**이 클래스 javadoc에 있다.
```java
// ProtobufCompatibilityTest.java:30-33
* <p>Descriptors are built at runtime rather than generated by protoc. The properties under test
* that a reader keyed on tag numbers survives a rename, that an added field decodes as its default,
* and that reusing a tag corrupts the read are properties of the wire format, so proving them
* without a code-generation step keeps the test honest and the build free of a protoc toolchain.
```
`DescriptorProto`/`FileDescriptor`/`DynamicMessage`로 런타임에 스키마를 만든다. 그래서 이 leaf의 빌드에 protoc 툴체인이 없다.
**`aLengthPrefixNoPayloadOfThisSizeCouldHonourIsADecodeFailure`가 Avro와의 대비를 만든다.** 같은 형태의 공격(작은 바이트로 큰 길이를 주장)이 Avro에서는 `newArray` 오버라이드가 필요했고 Protobuf에서는 라이브러리가 알아서 막는다.
```java
// 테스트 주석 :238-240
// Tag 1, wire type 2 (length-delimited), then a varint claiming four hundred million bytes
// follow. The whole message is six bytes, so it passes the size limit; what must not happen is
// the parser reserving the claimed length before discovering there is nothing behind it.
```
결과가 `MessageSerializationException`이다 — 즉 protobuf-java는 길이 주장을 신뢰해 미리 할당하지 않는다. Avro의 `GenericDatumReader.newArray`는 신뢰한다. **같은 공격에 두 라이브러리의 기본 방어가 다르고, 이 저장소는 그 차이를 각 leaf에서 다르게 처리했다.**
**증명 공백.** `ProtobufMessageCodec.decode`의 상한 검사(`encoded.length > maxBytes`)를 직접 겨냥한 테스트가 없다. 인코딩 상한은 두 테스트가 덮지만 디코딩 상한은 덮이지 않는다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` |
| `verifyRuntimeModuleMembership` | `[]` |
| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | protobuf가 public record 시그니처에 등장 → `api` 필요. **통과** |
| Gradle dependency locking | `gradle.lockfile`이 4.29.3/4.33.2를 고정 |
| ArchUnit | 전용 규칙 없음 |
| protoc 툴체인 | **없음** — 의도적(§10) |
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/272-schema-family-reachability.txt`.
### 12.1 Public surface reachability
| 타입 | leaf 밖 참조 | 판정 |
|---|---:|---|
| `ProtobufMessageCodec` | **0** | 소비자 없음 |
| `ProtobufMessageContract` | **0** | 소비자 없음 |
`git grep -l -w ProtobufMessageCodec -- src ':!src/messaging/messaging-schema-protobuf'` exit 1.
**정합적이다.** `runtime_memberships: []`, starter 미등록, 소비자 0 — 세 축이 모두 "없음"이다. `messaging-schema-avro`와 같은 형태이고, 이것이 incubating leaf의 올바른 상태다.
**한계.** 이 저장소는 템플릿이므로 파생 프로젝트가 이 codec을 쓸 수 있다. 그것을 확인할 수단이 저장소 안에 없다. 다만 이 leaf는 그 경우를 위해 준비돼 있다 — vendor를 `api`로 노출했고, 계약 등록이 첫 단계임을 build.gradle 주석이 명시한다.
### 12.2 Conditional sibling comparison
Spring 주석 0개. bean 없음.
codec sibling 비교는 `analysis/messaging/messaging-schema-avro.md` §12.2의 표가 소유한다. 이 leaf는 Avro와 같은 행(구현 o / starter 등록 x / membership `[]` / 정합)이다.
### 12.3 Duplicate mechanism sweep
**(a) registry 조회 로직이 세 codec에 복제돼 있다**
`requireRegistered`(JSON), `schemaFor`(Avro), `requireRegistered`(Protobuf)가 같은 구조다.
```
key = (type, version)
if 등록됨 → 반환
typeIsKnown = 키들 중 type이 같은 것이 있는가
if typeIsKnown → "버전 미등록" + 등록 버전 목록
else → "타입 미등록"
```
JSON과 Protobuf는 `registeredVersions(type)` 헬퍼까지 사실상 동일하다(스트림 필터 → 버전 추출 → 정렬 → 리스트). Avro는 등록 버전 목록을 메시지에 넣지 않는다.
이 중복은 `messaging-schema-api`가 흡수할 수 있었다 — `MessageContractKey`가 이미 그 leaf에 있고, "타입은 알고 버전을 모른다"는 판단은 키의 성질이지 포맷의 성질이 아니다. `SchemaCompatibilityValidator`가 진화 규칙에 대해 정확히 그 일을 하려 했던 것과 같은 구조이고, 그쪽은 호출되지 않았다(`analysis/messaging/messaging-schema-api.md` §12.1).
**(b) 크기 예외 통과 방식이 세 codec에 셋**
| codec | 방식 | 필요한 이유 |
|---|---|---|
| JSON | `unwrapTooLarge` 원인 사슬 탐색 | Jackson이 스트림 예외를 감쌈 |
| Avro | `catch` 안 즉시 `instanceof`(3곳) | Avro가 감싸지 않지만 `IOException`과 함께 잡힘 |
| Protobuf | **없음** | `catch (IOException)`만 잡으므로 그대로 전파 |
셋 다 라이브러리 동작에 맞는 최소 해법이고 결과는 같다. 중복 경쟁이 아니라 **불가피한 분기**로 분류한다. 다만 세 코드 어디에도 "왜 우리는 다른가"가 적혀 있지 않아, 넷째 codec을 추가하는 사람이 어느 형태를 골라야 하는지 알 수 없다.
**(c) 1 MiB 상한** — `analysis/messaging/messaging-schema-json.md` §12.3이 소유한다. 이 leaf의 `DEFAULT_MAX_BYTES`는 private이므로 외부에 값을 노출하지 않는다.
### 12.4 Documentation / measured-count drift
**(a) `.proto` fixture를 컴파일하는 빌드가 없다**
`src/test/proto/order_created_v1.proto`가 존재하고 v1 계약을 서술한다.
```proto
message OrderCreated {
string order_id = 1;
string customer_id = 2;
int64 total_minor_units = 3;
string currency = 4;
// v2 adds `channel = 5`. ...
}
```
테스트는 이것을 읽지 않는다. `DescriptorProto`로 손수 만든 `V1_DESCRIPTOR`가 같은 네 필드를 같은 태그로 선언하고, `V2_DESCRIPTOR`가 태그 4를 `currency_code`로 개명하고 태그 5 `channel`을 추가한다.
**오늘은 둘이 일치한다.** 필드 이름·태그·타입을 전수 대조했고 `.proto`의 주석이 예고하는 v2 변경도 테스트의 `V2_DESCRIPTOR`와 맞는다. 그러나 일치를 강제하는 것이 아무것도 없다 — protoc 툴체인이 없고, 테스트가 파일을 읽지 않으며, 게이트도 없다. 테스트 javadoc이 `.proto`를 "the fixture documents"라고 부르는데, 문서와 테스트가 각자 진실을 갖고 있다.
이 판단은 신중해야 한다. protoc를 뺀 것은 명시적 설계 결정이고 그 이유(테스트를 정직하게, 빌드를 가볍게)가 적혀 있다. 문제는 protoc의 부재가 아니라 **`.proto`가 남아 있으면서 아무도 검증하지 않는다는 것**이다.
**(b) protobuf-java 버전이 저장소에 셋 있다**
| 위치 | 버전 | 성격 |
|---|---|---|
| `src/build.gradle:180` `ext.protobufVersion` | **3.25.5** | 주석이 "the single SSOT"라 부름 |
| `messaging-schema-protobuf/build.gradle:9` | **4.29.3** | 이 leaf가 직접 고정 |
| `adapter/inbound/websocket/build.gradle:44,46` | **4.33.2** | compileOnly / testImplementation |
| 다수 lockfile의 `annotationProcessor` 경로 | 4.33.2 | 전이 |
`src/build.gradle:174-180`의 주석을 정확히 읽어야 한다.
```
// Inbound gRPC adapter (adapter:inbound:grpc) — the Spring Boot BOM does NOT manage io.grpc:* or
// protobuf versions, and this repo has no version catalog. Pin them here as the single SSOT so the
// grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as
// platforms at MODULE scope (not the shared dependencyManagement block below) — keeping the
// strict-locking blast radius to the grpc module alone.
```
**"single SSOT"의 범위가 문장 안에서 grpc 모듈로 한정된다** — "keeping the strict-locking blast radius to the grpc module alone". 따라서 이 leaf가 4.29.3을 쓰는 것은 그 SSOT를 위반한 것이 아니다. 정확한 사실은 이렇다: **저장소에 protobuf 버전 정책이 전역으로 존재하지 않고, 세 곳이 독립적으로 고정한다.** 그리고 "single SSOT"라는 표현이 전역 정책의 존재를 시사하는 반면 실제 범위는 한 모듈이다.
오늘 이것이 사고가 아닌 이유: 이 leaf의 `runtime_memberships``[]`이므로 4.29.3이 4.33.2·3.25.5와 같은 classpath에 오르지 않는다. **채택 시점의 부채이지 지금의 결함이 아니다.** 이 leaf를 런타임에 편입시키면 그때 버전 충돌 판정이 필요해진다.
**(c) 일치하는 주장들**
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| build.gradle 주석: protobuf가 public 시그니처에 등장하므로 `api` | `ProtobufMessageContract`가 public record over `Message`/`Parser` | **일치** |
| 클래스 javadoc: unknown field가 보존됨 | 테스트가 `getUnknownFields().hasField(5)` 확인 | **일치** |
| `support-matrix.md`: Protobuf가 Stable 아님 | membership `[]`, starter 미등록 | **일치** |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 실제로 `[]` | 이 leaf에 한해 참(family 전체로는 틀림) |
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
`ProtobufMessageContract` javadoc이 두 결함을 보존한다.
| 이전 상태 | 그것이 만든 실패 |
|---|---|
| 클래스와 parser를 **두 개의 병렬 맵**에 보관, 일치 검사 없음 | `OrderCreated.class`와 `OrderCancelled`의 parser 짝이 생성 시 통과 → 디코딩 시점의 `ClassCastException`, **브로커 스레드에서, 한 메시지 타입에 대해, production에서** |
| 한쪽 맵에만 존재하는 타입 | `parsers.get(type)`이 null → `NullPointerException`. 운영자가 읽어야 할 registry 에러 대신 NPE |
두 번째가 특히 이 저장소의 반복 주제다 — **실패의 종류가 바뀌면 운영자가 읽을 정보가 사라진다.** `messaging-core-api``FailureDescriptor` 설계, `MessageContractKey`의 2단 에러, JSON codec의 `unwrapTooLarge`가 전부 같은 관심사다.
`.proto` 파일의 주석도 설계 이유를 남긴다 — "Field numbers are the contract, not the field names ... Tags are never reused, and removed fields are reserved so that a later edit cannot take the number back." 이 규칙 셋 중 둘(개명 안전, 태그 재사용 위험)이 테스트로 증명되고 하나(reserved)는 증명되지 않는다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §D, §E | 두 타입의 소비자 0, membership `[]` | 정적 검색 |
| EVD-277 | command | `./gradlew :messaging:messaging-schema-protobuf:test --rerun-tasks` | BUILD SUCCESSFUL, 12 / 0 / 0 | protoc 없음. 런타임 descriptor |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 닫힌 registry가 없으면 타입 혼동이 조용하다 — 클래스 javadoc
- 두 병렬 맵이 만든 두 결함과 짝 증명 방식 — `ProtobufMessageContract` javadoc
- 크기를 미리 알 수 있어 사전 거절한다 — encode 주석
- 다른 버전 parser로 폴백하지 않는 이유 — `requireRegistered` 주석
- unknown field 보존이 forward compatibility의 기반 — 클래스 javadoc
- descriptor를 런타임에 만드는 이유(protoc 툴체인 회피) — 테스트 javadoc
- 태그 번호가 계약인 이유 — `.proto` 주석
- protobuf를 `api`로 선언한 이유 — build.gradle 주석
- `ext.protobufVersion`의 범위가 grpc 모듈로 한정된 이유 — `src/build.gradle:174-178`
**추론**
- 크기 예외 통과 로직이 없는 것은 protobuf-java가 예외를 감싸지 않기 때문이다 → **추론**. 코드 형태는 관측, 인과는 추론.
- 4.29.3을 고른 이유 → **미상**. 주석도 커밋 메시지도 없다.
- `.proto`를 남겨 둔 이유 → **미상**. 문서용으로 보이지만 명시되지 않았다.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 두 타입 199줄 전문의 계약
- 12개 테스트가 통과하고 무엇을 단언하는지
- 소비자 0 / starter 미등록 / membership `[]`의 삼중 정합
- `.proto` fixture와 테스트 descriptor가 오늘 일치한다는 것(전수 대조)과 그것을 강제하는 것이 없다는 것
- 저장소에 protobuf 버전이 셋 있고 "single SSOT"의 범위가 한 모듈이라는 것
- 길이 주장 공격에 대해 protobuf-java가 Avro와 달리 사전 할당하지 않는다는 것(테스트로 확인)
**확인하지 못한 것**
- **디코딩 상한을 겨냥한 테스트가 없다.** `encoded.length > maxBytes` 분기가 실행된 적이 없다.
- `.proto` 주석이 말하는 `reserved` 규칙 — 테스트가 없다.
- 파생 프로젝트가 이 codec을 쓰는지.
- 4.29.3과 3.25.5·4.33.2가 한 classpath에 올랐을 때 무슨 일이 생기는지. 오늘은 그 조합이 존재하지 않는다.
- 실제 protoc 생성 타입(`GeneratedMessage` 서브클래스)에서 `ProtobufMessageContract`의 빈 입력 파싱 증명이 동작하는지 — 테스트는 `DynamicMessage`만 쓴다.
---
## 17. 손볼 것
### P3 — `.proto` fixture와 테스트 descriptor의 일치를 아무도 강제하지 않는다
- **사실.** `src/test/proto/order_created_v1.proto`가 v1 계약을 서술하고, 테스트는 그 파일을 읽지 않고 `DescriptorProto`로 같은 스키마를 손수 만든다. 오늘 둘은 일치한다(필드 4개, 태그 1–4, 타입 전수 대조).
- **근거.** `.proto` 전문 vs `ProtobufCompatibilityTest.java:41-66`.
- **왜 문제인가.** protoc를 뺀 것은 명시적 설계 결정이고 이유가 적혀 있다. 문제는 `.proto`가 남아 있으면서 검증되지 않는다는 것이다. 테스트 javadoc이 그것을 "the fixture documents"라 부르므로, 읽는 사람은 그 파일이 테스트의 근거라고 믿는다. 한쪽만 수정되면 조용히 갈라진다.
- **확인 방법.** 두 파일의 필드/태그/타입 대조. `find src/messaging/messaging-schema-protobuf -name '*.proto'`
- **후보.** (a) `.proto`를 읽어 descriptor를 만드는 테스트 헬퍼를 쓴다(protoc 없이 `protobuf-java`의 파서로는 불가하므로 실제로는 어렵다). (b) `.proto`를 삭제하고 규칙 주석을 테스트로 옮긴다. (c) `.proto`에 "이 파일은 문서이며 테스트는 descriptor를 손수 만든다"를 명시한다.
- **다음 단계.** **REFERENCE 후보**(검증되지 않는 스키마 파일은 문서임을 파일 안에 적는다).
### P3 — 디코딩 상한 분기가 테스트되지 않는다
- **사실.** `decode``if (encoded.length > maxBytes)` 분기를 겨냥한 테스트가 없다. 인코딩 상한은 두 테스트가 덮는다.
- **근거.** `ProtobufMessageCodec.java:104-108`, `ProtobufCompatibilityTest` 12개 전수.
- **왜 문제인가.** 디코딩은 **신뢰할 수 없는 입력**을 받는 쪽이다. 브로커에서 온 바이트에 대한 방어가 자기 코드가 만든 바이트에 대한 방어보다 덜 검증됐다. 형제 leaf는 반대다 — `AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound`가 정확히 이 각도를 덮는다.
- **확인 방법.** 12개 테스트 중 `decode`에 큰 입력을 주는 것이 없음.
- **후보.** `maxBytes`보다 큰 `byte[]``decode`를 부르는 테스트 추가.
- **다음 단계.** **REFERENCE 후보**(신뢰할 수 없는 입력 쪽 경계를 먼저 테스트한다).
### P3 — protobuf-java 버전이 저장소에 셋이고 전역 정책이 없다
- **사실.** `ext.protobufVersion = 3.25.5`(grpc 모듈 범위로 한정), 이 leaf `4.29.3`, websocket `4.33.2`. lockfile들이 세 값을 모두 고정한다.
- **근거.** `src/build.gradle:174-180` · `messaging-schema-protobuf/build.gradle:9` · `adapter/inbound/websocket/build.gradle:44,46` · 각 `gradle.lockfile`.
- **왜 문제인가.** 오늘은 사고가 아니다 — 이 leaf의 `runtime_memberships``[]`이라 세 버전이 한 classpath를 공유하지 않는다. **채택 시점의 부채다.** 이 leaf를 런타임에 편입시키는 순간 버전 판정이 필요해지고, 그때 참조할 전역 정책이 없다. 그리고 `src/build.gradle`의 "the single SSOT"라는 표현이 전역 정책의 존재를 시사하는데 실제 범위는 그 문장 안에서 grpc 모듈로 한정된다.
- **확인 방법.** `git grep -n 'protobuf-java\|protobufVersion' -- src --include='*.gradle'`
- **후보.** (a) 편입 전까지 현 상태 유지하되 `src/messaging/CLAUDE.md`에 "편입 시 버전 정합을 먼저 판정한다"를 적는다. (b) `ext.protobufVersion`의 범위를 넓히고 주석의 "single SSOT" 표현을 실제 범위에 맞춘다.
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 "이 leaf를 런타임에 편입할 것인가"에 걸린다. 저장소 안에 답이 없다.
### P3 — registry 조회 로직이 세 codec에 복제돼 있다
- **사실.** `requireRegistered`(JSON/Protobuf)와 `schemaFor`(Avro)가 같은 3단 판단을 각자 구현한다. JSON과 Protobuf는 `registeredVersions` 헬퍼까지 사실상 동일하다.
- **근거.** 세 codec의 해당 메서드.
- **왜 문제인가.** 판단은 `MessageContractKey`의 성질이지 포맷의 성질이 아니다. 그리고 실제로 갈라졌다 — Avro만 `AVRO_` 접두 코드를 쓰고 등록 버전 목록을 메시지에 넣지 않는다. `messaging-schema-api`가 흡수할 수 있는 형태다.
- **확인 방법.** 세 메서드 대조.
- **후보.** `messaging-schema-api``ContractLookup`류 헬퍼를 두고 세 codec이 부른다.
- **다음 단계.** `messaging-schema-api` §17의 "포맷 독립 규칙" 항목과 같은 계열이다. 그 leaf가 소유하고 여기서는 교차 참조만 남긴다.
### 확인된 설계(문제 아님)
- 클래스와 parser를 한 값에 묶고 빈 입력 파싱으로 짝을 증명하는 것
- 직렬화 크기를 미리 알아 사전 거절하고, sink 경계를 여전히 통과시키는 이중 방어
- 다른 버전 parser로 폴백하지 않고 등록 버전 목록을 에러에 넣는 것
- descriptor를 런타임에 만들어 protoc 툴체인 없이 wire 성질을 증명하는 것
- 소비자 0 / starter 미등록 / membership `[]`의 삼중 정합
- 크기 예외 통과 로직이 없는 것(protobuf-java가 감싸지 않으므로 불필요)
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MSP-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, `runtime_memberships: []` | 선언 |
| MSP-002 | build | `messaging-schema-protobuf/build.gradle` | same | protobuf `api` 선언과 이유, 버전 4.29.3 | — |
| MSP-003 | build | `messaging-schema-protobuf/gradle.lockfile:26-27` | same | 4.29.3(compile/runtime), 4.33.2(annotationProcessor) | 이 leaf 범위 |
| MSP-004 | code | `.../protobuf/ProtobufMessageContract.java` 전문 | same | §4.1 짝 증명과 두 이전 결함 | `DynamicMessage`로만 검증됨 |
| MSP-005 | code | `.../protobuf/ProtobufMessageCodec.java` 전문 | same | §4.24.6 | — |
| MSP-006 | test | `ProtobufCompatibilityTest` (12) | same | §10 표 전부 | protoc 없음. decode 상한 미검증 |
| MSP-007 | fixture | `src/test/proto/order_created_v1.proto` | same | 태그 규칙 서술 | 컴파일되지 않음(§12.4a) |
| MSP-008 | build policy | `src/build.gradle:174-180` | same | `ext.protobufVersion = 3.25.5`와 그 범위가 grpc 모듈로 한정됨 | — |
| MSP-009 | cross-leaf build | `adapter/inbound/websocket/build.gradle:44,46` | same | 세 번째 protobuf 버전 4.33.2 | 해당 leaf SSOT가 소유 |
| MSP-010 | cross-leaf code | `messaging-schema-api/.../BoundedByteSink.java:66-80` | same | `requireFits`가 이 codec을 위해 존재 | 해당 leaf SSOT가 소유 |
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | §12.1 | 정적 검색 |
| EVD-277 | command | `./gradlew :messaging:messaging-schema-protobuf:test --rerun-tasks` | same | 12 / 0 / 0 | — |
@@ -0,0 +1,741 @@
# messaging-security 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-security`
> SSOT owner: `messaging-security`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-security`
- canonical state `analysisFile`: `analysis/messaging/messaging-security.md`
- source path: `src/messaging/messaging-security`
- registry `allowed_dependencies`: `["messaging-core-api"]`
- registry `runtime_memberships`: `["app-bootstrap"]`
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 12 |
| production LOC | 954 |
| 패키지 | 1 (`dev.caskeleton.messaging.security`) |
| test 파일 | 3 |
| test 메서드(실행 확인) | 24 |
| 외부(비프로젝트) 의존성 | **0** |
12개 타입을 세 축으로:
| 축 | 타입 | leaf 밖 소비 파일 |
|---|---|---:|
| **자격증명 수명주기** | `CredentialProvider` · `CredentialRuntime` · `CredentialRuntimeRegistry` · `CredentialRotationPlan` · `CredentialIds`(package-private) | 4 · 2 · 6 · **0** · 0 |
| **연결 posture** | `BrokerSecurityProfile` · `BrokerCredentialProfile` · `BrokerTlsPolicy` · `MessageSecurityValidator` | 8 · 5 · 6 · 1 |
| **권한** | `DestinationAccessPolicy` · `DestinationAccessValidator` · `BrokerAclManifest` | 7 · **0** · **0** |
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (12) | 12 | `FULL_READ` | 전 파일 본문 확인 |
| `src/test/java/**` (3) | 3 | `FULL_READ` | 테스트명·단언 전수 확인 |
| `build.gradle` | 1 | `FULL_READ` | 5줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
이 leaf는 **"브로커에 연결하기 전에 무엇이 참이어야 하는가"**를 소유한다. 벤더 의존성이 0이고 브로커를 만지지 않는다 — 어댑터의 security configurer가 이 leaf의 타입을 받아 실제 클라이언트 설정을 만든다.
세 가지 원칙이 코드 전반에 반복된다.
**(a) 비밀은 참조로만 다룬다.**
```java
// BrokerCredentialProfile.java:5-8
* <p>No variant carries a secret. The platform stores an identifier and resolves the material
* through a {@link CredentialProvider} at connect time, so a rotation is a provider concern and a
* heap dump or configuration print never yields a usable credential.
```
`BrokerCredentialProfile`의 다섯 변형 전부가 `credentialId` 하나만 갖는다 — `SaslScram`, `OAuth2`, `MutualTls`, `UsernamePassword`, `Nkey`. sealed interface이므로 여섯 번째를 만들려면 이 파일을 고쳐야 한다.
**(b) 타입이 통제의 일부다.**
```java
// CredentialRuntime.java:13-15
* <p>Holds the material in a {@code char[]} that {@link #clear()} overwrites. A {@code String}
* cannot be erased it stays in the constant pool and in every heap dump taken until the next GC
* decides otherwise so the type of the field is itself part of the control.
```
`CredentialProvider.resolve``char[]`을 반환하고 `CredentialRuntime`이 그것을 참조로 보관하며 `clear()``Arrays.fill(material, '\0')` 후 빈 배열로 교체한다.
**(c) 역할 분리가 강제된다.** `BrokerSecurityProfile`이 producer·consumer·admin 세 자격증명을 **별도 필드**로 갖는다.
```java
// BrokerSecurityProfile.java:9-11
* <p>Producer, consumer, and admin credentials are separate fields rather than one connection
* credential. That separation is what makes "an application cannot purge a topic" enforceable: the
* runtime never holds admin material, so a compromised handler has nothing to escalate with.
```
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api) 하나.
나가는 것: `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`.
**이 leaf는 messaging family에서 배선이 가장 잘 된 축에 속한다.** 어댑터 두 곳이 직접 소비한다.
| 소비자 | 무엇을 쓰는가 |
|---|---|
| `messaging-kafka/KafkaSecurityConfigurer` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry`, `CredentialProvider` |
| `messaging-rabbit/RabbitSecurityConfigurer` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry` |
| `messaging-runtime-core/DefaultMessagePublisher` | `DestinationAccessPolicy` |
| `messaging-runtime-core/DeclaredDestinationAccess` | `DestinationAccessPolicy` |
| starter `MessagingCoreAutoConfiguration` | `MessageSecurityValidator`·`BrokerTlsPolicy`·`CredentialRuntimeRegistry` bean |
| starter `MessagingCredentialRequirementValidator` | `CredentialProvider` |
| starter `Kafka/RabbitMessagingAutoConfiguration` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry` |
이 leaf 자체는 Spring 주석을 갖지 않는다.
---
## 3. 패키지/컴포넌트 지도
```
자격증명 수명주기
CredentialProvider (port)
↓ resolve(id) → char[] / expiresAt(id) → Optional<Instant>
CredentialRuntimeRegistry ──compute(single-flight)──> CredentialRuntime
│ ├── material() → clone
│ ├── isDueForRotation(now)
│ ├── isExpired(now)
└── dueForRotation / expired / clearAll └── clear() → 덮어쓰기
CredentialRotationPlan ← 같은 술어를 다시 구현, 소비자 0 (§12.3)
연결 posture
BrokerSecurityProfile ─┬─ BrokerCredentialProfile (sealed, 5변형) ── CredentialIds
└─ DestinationAccessPolicy
BrokerTlsPolicy.validate(profile, protocols) ← 어댑터가 호출
MessageSecurityValidator.validate(profile) ← starter bean, 검사 범위가 겹침 (§12.3)
권한
DestinationAccessPolicy (publishable / consumable / administrable)
DestinationAccessValidator ← 소비자 0 (§12.1)
BrokerAclManifest ← 소비자 0 (§12.1)
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `CredentialRuntimeRegistry.resolve` — key별 single-flight
이 leaf에서 가장 조밀한 동시성 코드이고, 이전 결함이 주석에 통째로 남아 있다.
```java
// :62-70
// Single-flight, keyed by credential id.
//
// get → fetch → put → clear had no synchronization at all. Two callers rotating the same
// credential both read the same old runtime and both fetched a replacement: one replacement
// was lost from the map without ever being cleared — a secret left in memory that nothing owns
// — and the caller that lost the race could clear material the winner was still using.
//
// compute holds the bin lock for this key, so exactly one fetch publishes and the previous
// generation is retired by that same caller.
```
**두 개의 서로 다른 결함이 한 경합에서 나왔다.**
1. 진 쪽의 교체본이 맵에서 사라지고 `clear()`도 안 됨 → **소유자 없는 비밀이 힙에 남음**
2. 진 쪽이 이긴 쪽이 쓰고 있는 material을 `clear()`할 수 있음 → **사용 중인 자격증명이 지워짐**
현재 구현:
```java
CredentialRuntime current = resolved.get(credentialId);
if (current != null && !current.isDueForRotation(now)) {
return current; // 락 없는 빠른 경로
}
return resolved.compute(credentialId, (key, existing) -> {
if (existing != null && !existing.isDueForRotation(now)) {
return existing; // 대기 중 다른 스레드가 회전함
}
CredentialRuntime replacement = fetch(key, now);
if (existing != null) {
existing.clear(); // 설치 후에만, 그리고 교체한 스레드만
}
return replacement;
});
```
`ConcurrentHashMap.compute`가 해당 bin의 락을 잡으므로 fetch가 정확히 한 번 일어난다. 그리고 **`clear()``replacement` 생성 후에 온다** — 주석이 그 순서의 이유를 적는다: "no reader sees a window with no usable credential — and only by the thread that replaced it, so the material a concurrent reader holds is never wiped underneath it."
**대가.** `compute`의 람다 안에서 `provider.resolve(...)`가 호출된다. 즉 **외부 I/O가 맵 bin 락을 잡은 채로 일어난다.** 같은 credential id를 요청하는 다른 스레드는 그 동안 막히고, `ConcurrentHashMap` 문서는 compute 람다 안에서 같은 맵을 갱신하지 말라고 요구한다(여기서는 지켜진다). 다른 키는 다른 bin이면 막히지 않지만 해시 충돌 시 같은 bin이면 막힌다. §17.
### 4.2 `CredentialRuntime` — material의 세 가지 통제
| 통제 | 구현 |
|---|---|
| 저장 | `char[]`, `String` 아님 |
| 반환 | `material.clone()` — "A copy, so a caller that clears its own array cannot blind every other holder" |
| 소거 | `Arrays.fill(material, '\0')``material = new char[0]` |
| 소거 후 접근 | `IllegalStateException("credential X has already been cleared")` |
| 표현 | `toString()`이 id와 expiry만 — material 없음 |
소거 판정이 `material.length == 0`이다. 생성자가 빈 배열을 거절하므로(`"credential material must not be empty"`) 길이 0은 소거된 상태를 뜻한다 — 별도 플래그 없이 같은 필드로 상태를 표현한다.
**`material` 필드가 `volatile`이 아니다.** `clear()`가 다른 스레드에서 호출되면 `material()`이 옛 참조를 볼 수 있다. 실제 경로에서는 `compute` 안에서만 `clear()`가 불리고 그 전에 `replacement`가 맵에 들어가므로 위험이 낮지만, `clearAll()`은 락 없이 순회한다. §17.
### 4.3 회전 시점 — 만료가 아니라 만료 이전
```java
// CredentialRuntime.java:17-18
* <p>Rotation is driven from the expiry, ahead of it. Waiting for the broker to start refusing
* connections turns a scheduled, invisible rotation into an outage.
```
`DEFAULT_ROTATION_LEAD = 30분`. `isDueForRotation(now)``!now.isBefore(expiry.minus(rotationLead))`다 — 만료 30분 전부터 참이고 만료 후에도 참이다.
`expiresAt`이 비어 있으면 **둘 다 false**다 — 만료를 모르는 자격증명은 회전 대상도 만료 대상도 아니다. `orElse(false)`가 그 선택을 명시한다.
### 4.4 `BrokerTlsPolicy` — 허용목록과 두 단계 실패
```java
// :15-17
* <p>Disabling hostname verification is treated as a separate, worse failure than disabling TLS.
* Plaintext is at least obviously insecure, whereas TLS without hostname verification looks
* encrypted in every dashboard while accepting any certificate a man in the middle presents.
```
네 가지 거절:
| 코드 | 조건 |
|---|---|
| `TLS_REQUIRED` | TLS 꺼짐 && (production \|\| 평문 비허용) |
| `HOSTNAME_VERIFICATION_REQUIRED` | TLS 켜짐 && hostname 검증 꺼짐 |
| `TLS_PROTOCOL_NOT_ACCEPTED` | 프로토콜이 `{TLSv1.2, TLSv1.3}` 밖 |
| `TLS_PROTOCOL_UNSPECIFIED` | TLS 켜짐인데 프로토콜 목록이 비어 있음 |
**허용목록을 고른 이유가 적혀 있다.**
```java
// :72-78
// An allowlist, not a denylist.
//
// The denylist named the old versions somebody thought of, so `SSL`, `TLSv0.9`, `PLAINTEXT`
// and any typo passed — and a protocol string the JVM does not recognise is negotiated as
// whatever the JVM defaults to, which is the outcome this policy exists to prevent. Naming the
// two acceptable versions means an unknown string fails here rather than at connect time on a
// production broker.
```
`messaging-schema-api``SchemaCompatibilityValidator`가 허용목록이고 `AvroCompatibilityGate`가 거부목록인 것(그쪽 §12.3)과 같은 축의 판단이며, 여기서는 허용목록을 고른 이유가 명시돼 있다.
**네 번째 검사에 순서 문제가 있다.** `TLS_PROTOCOL_UNSPECIFIED``TLS_PROTOCOL_NOT_ACCEPTED` **뒤에** 있는데, 빈 목록은 `filter`를 통과하는 요소가 없으므로 `unsupported`가 비어 있어 앞 검사를 지나간다. 결과적으로 빈 목록은 네 번째에서 잡힌다 — 동작은 맞다. 다만 읽는 순서와 논리 순서가 다르다.
### 4.5 `MessageSecurityValidator` — 시작 시 네 가지
```java
// :9-12
* <p>These checks are boot failures rather than warnings. An unencrypted production broker
* connection or a shared producer/admin credential is not a degraded mode the platform can run in
* safely; both are the kind of misconfiguration that stays invisible until it is exploited.
```
| # | 거절 조건 |
|---:|---|
| 1 | production && TLS 꺼짐 |
| 2 | production && hostname 검증 꺼짐 |
| 3 | producer와 consumer가 같은 credential id |
| 4 | admin이 producer/consumer와 같은 credential id |
| 5 | production && admin 존재 |
3·4번을 `LinkedHashSet.add`의 반환값으로 구현한다 — 추가에 실패하면 중복이다. 간결하고 정확하다.
5번이 (c) 원칙을 강제하는 지점이다 — **운영 런타임은 admin 자격증명을 아예 갖지 못한다.**
1·2번이 `BrokerTlsPolicy`와 겹친다(§12.3).
### 4.6 `BrokerAclManifest` — 초과가 발견이다
```java
// :113-118
* <p>Excess is the finding, not the shortfall: a missing grant fails loudly on first use, while
* an undeclared extra one sits unnoticed until it is abused.
```
`undeclared(observed)`가 관측 선언, `missing(observed)`가 선언 − 관측이다. 두 방향을 모두 계산하지만 javadoc이 어느 쪽이 발견인지 정한다.
`Operation` enum이 파괴적 여부를 상수에 담는다 — `ALTER`, `DELETE`, `PURGE``destructive=true`.
```java
// :17-20
* <p>Destructive permissions are named separately from ordinary ones. {@code DELETE_TOPIC} and
* {@code PURGE} are not "write, but more"; they destroy data an application can never restore, so
* an application runtime declaring one is rejected outright.
```
`requireApplicationRuntime()`이 파괴적 grant가 하나라도 있으면 `MessagingConfigurationException("APPLICATION_HOLDS_DESTRUCTIVE_GRANT")`을 던진다.
**이 클래스 전체가 소비자 0이다**(§12.1).
`undeclared`/`missing``Set<Grant>`를 받는데, `Grant`는 record이므로 equals가 세 필드 전부를 비교한다. 즉 `pattern`이 문자열 정확 일치여야 한다 — 와일드카드 패턴(`orders.*`)을 브로커가 다르게 표현하면 오탐이 난다. javadoc에 언급 없음.
### 4.7 `CredentialIds` — 참조 자리에 비밀을 붙여넣는 사고
```java
// :9-13
* <p>The bounded slug pattern is not cosmetic. Credential ids reach log lines and metric tags, so
* an unbounded id is a cardinality problem, and an id that looks like a secret is a leak. The
* heuristic check rejects the most common accident: pasting the secret itself where the reference
* belongs.
```
패턴 `[a-z0-9][a-z0-9._-]{1,63}` — 최소 2자, 최대 64자.
휴리스틱 접두사 다섯: `bearer `, `basic `, `sk-`, `-----begin`, `eyj`. 각각 HTTP Authorization, OpenAI 키, PEM 블록, base64 JWT 헤더(`{"``eyJ`)를 노린다.
**패턴이 이미 대부분을 막는다.** `[a-z0-9._-]`만 허용하므로 공백이 있는 `bearer `·`basic `는 패턴에서 이미 거절되고, `-----begin`은 첫 글자가 `-`라 거절된다. 실제로 휴리스틱만이 잡는 것은 `sk-``eyj`뿐이다. 중복 방어이고 해롭지 않다.
### 4.8 `DestinationAccessPolicy` — 세 역할, 세 집합
`publishable`/`consumable`/`administrable` 셋이 전부 `Set.copyOf`로 불변화된다. `denyAll()`이 세 빈 집합이다.
```java
// :10-12
* <p>The platform checks this before the broker does. Relying only on broker ACLs means an
* accidental publish surfaces as a generic authorization error at runtime, in the adapter, with no
* record of which application module attempted it.
```
`DestinationAccessValidator`가 세 `require*` 메서드로 그 검사를 예외로 바꾼다 — 그리고 소비자가 0이다(§12.1).
---
## 5. 주요 실행 경로
**자격증명 해석:** 어댑터의 security configurer → `registry.resolve(credentialId, now)` → 캐시 유효하면 반환 → 아니면 `compute` 안에서 `provider.resolve` + `provider.expiresAt` → 새 `CredentialRuntime` 설치 → 옛 것 `clear()`
**시작 검증(1):** starter가 `MessageSecurityValidator` bean 생성 → `validate(profile)` 호출 지점은 starter가 소유
**시작 검증(2):** 어댑터 configurer가 `BrokerTlsPolicy.validate(profile, enabledProtocols)` 호출
**발행 권한:** `DefaultMessagePublisher``access.mayPublish(name)` → false면 `PublishResult(REJECTED, PUBLISH_FORBIDDEN)`
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 위치 |
|---|---|---|
| `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`는 안정 코드가 붙은 `MessagingConfigurationException`을 쓰고, `MessageSecurityValidator`는 코드 없는 `IllegalArgumentException`을 쓴다. 둘이 같은 두 검사(TLS·hostname)를 공유하는데도 그렇다 — §12.3, §17.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
| 지점 | 도구 | 보호 |
|---|---|---|
| `CredentialRuntimeRegistry.resolved` | `ConcurrentHashMap` | 맵 자체 |
| `resolve` | `compute`(bin 락) | key별 single-flight, fetch 정확히 한 번 |
| `CredentialRuntime.material` | **동기화 없음** | (§17) |
레코드 여섯(`BrokerSecurityProfile`, `BrokerCredentialProfile` 5변형, `DestinationAccessPolicy`, `BrokerAclManifest`, `CredentialRotationPlan`)은 전부 불변이다. `BrokerTlsPolicy`·`MessageSecurityValidator`·`DestinationAccessValidator`는 상태가 없거나 불변 참조만 갖는다.
수명주기 참여는 `clearAll()`뿐이고 "for shutdown"이라고 javadoc이 적는다. **그것을 부르는 코드가 저장소에 없다** — 종료 시 자격증명이 소거되지 않는다. §17.
---
## 8. 설정·기능 플래그·환경 차이
| 상수/기본값 | 값 | 위치 |
|---|---|---|
| `CredentialRuntime.DEFAULT_ROTATION_LEAD` | 30분 | public |
| `BrokerTlsPolicy.MINIMUM_PROTOCOL` | `"TLSv1.2"` | public |
| `BrokerTlsPolicy.ACCEPTED_PROTOCOLS` | `{TLSv1.2, TLSv1.3}` | private |
| `BrokerTlsPolicy()` 기본 | `allowPlaintextOutsideProduction = true` | — |
| `CredentialIds.VALID` | `[a-z0-9][a-z0-9._-]{1,63}` | private |
`production` 플래그가 세 클래스의 분기 조건이다 — `BrokerTlsPolicy`, `MessageSecurityValidator`, 그리고 `BrokerSecurityProfile`의 필드. 그 값을 정하는 곳은 이 leaf 밖이다.
**`MINIMUM_PROTOCOL`이 public이고 아무도 쓰지 않는다.** `ACCEPTED_PROTOCOLS`가 private이므로 외부에서 허용 집합을 알려면 `isAcceptable(String)`을 부르거나 이 상수를 보는데, 상수는 최소값만 알려준다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. `CredentialProvider`가 외부 비밀 저장소를 가리킬 수 있는 port이고 이 leaf에 구현이 없다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-security:test`. **BUILD SUCCESSFUL, 24 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `CredentialRuntimeRegistryTest` | 13 | 해석·캐시·회전·소거·경합 하 single-flight | 실제 비밀 저장소 |
| `MessageSecurityValidatorTest` | 7 | 다섯 거절 조건 | 실제 부팅에서 호출되는지(→ starter가 bean 생성) |
| `CredentialRotationContractTest` | 4 | 회전 시점 술어 | **`CredentialRotationPlan`이 쓰이는지** |
**커버리지 공백 셋.**
- `BrokerTlsPolicy`를 겨냥한 테스트 클래스가 **없다.** 네 거절 조건과 허용목록 판정이 이 leaf의 테스트로 검증되지 않는다. 어댑터 쪽 `KafkaSecurityConfigurerTest`가 간접적으로 지나갈 수 있으나 그것은 다른 leaf의 레인이고 다른 것을 목표로 한다.
- `BrokerAclManifest`를 겨냥한 테스트가 **없다.** `undeclared`/`missing`/`requireApplicationRuntime` 셋 다 미검증이다.
- `DestinationAccessValidator`·`DestinationAccessPolicy`를 겨냥한 테스트가 **없다.**
**12개 타입 중 5개가 이 leaf의 테스트에 등장하지 않는다.** 그리고 그중 셋은 §12.1의 소비자 0 목록과 겹친다 — 쓰이지도 않고 테스트되지도 않는다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api"]` |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| vendor `api` 규칙 | 벤더 의존성 0 |
| `SecretLeakStaticScanTest`(observability leaf) | **이 leaf의 소스도 스캔 대상** — 콘솔 출력·민감 식별자 문자열 연결 금지 |
| ArchUnit | 전용 규칙 없음 |
네 번째가 이 leaf에 실질적이다. `CredentialRuntime.toString()`이 material을 빼고 id와 expiry만 담는 것, `MessagingRedactor`가 credential 키를 지우는 것과 함께 **세 층의 방어**를 이룬다 — 타입(`char[]`), 표현(`toString`), 정적 스캔.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/287-messaging-security-duplicate-checks.txt`.
> **방법.** 정규화된 이름(`import dev.caskeleton.messaging.security.<Type>;` 또는 `dev.caskeleton.messaging.security.<Type>`)으로 측정했다. `messaging-observability`의 `CardinalityGuard`처럼 동명 클래스가 있는 경우를 배제하기 위해서다.
### 12.1 Public surface reachability
| 타입 | leaf 밖 파일 | 판정 |
|---|---:|---|
| `BrokerSecurityProfile` | 8 | 활발 |
| `DestinationAccessPolicy` | 7 | 활발 |
| `BrokerTlsPolicy` | 6 | 어댑터 둘 + starter 셋 |
| `CredentialRuntimeRegistry` | 6 | 같음 |
| `BrokerCredentialProfile` | 5 | 활발 |
| `CredentialProvider` | 4 | 활발 |
| `CredentialRuntime` | 2 | |
| `MessageSecurityValidator` | 1 | starter bean |
| **`DestinationAccessValidator`** | **0** | |
| **`BrokerAclManifest`** | **0** | |
| **`CredentialRotationPlan`** | **0** | |
| `CredentialIds` | 0 | package-private — 구조상 내부. 결함 아님 |
**(a) 접근 검증기가 쓰이지 않고, 같은 검사가 다른 형태로 인라인돼 있다**
`DestinationAccessValidator.requirePublish`는 예외를 던진다.
```java
if (!policy.mayPublish(destination)) {
throw new MessageAuthorizationException(
"DESTINATION_PUBLISH_DENIED",
"the producer credential may not publish to " + destination.value());
}
```
발행 경로는 정책을 직접 묻고 결과를 반환한다.
```java
// DefaultMessagePublisher.java:170-176
if (!access.mayPublish(destination.name())) {
return rejected(
"PUBLISH_FORBIDDEN",
"this application may not publish to '" + destination.name().value() + '\'',
startedAt);
}
```
**같은 판단, 다른 코드, 다른 실패 형태.** `DESTINATION_PUBLISH_DENIED`(`AUTHORIZATION` 카테고리, 예외) vs `PUBLISH_FORBIDDEN`(`CONFIGURATION` 카테고리, `PublishResult`). 대시보드가 권한 거부를 세려면 두 어휘를 모두 알아야 하는데, 실제로 발생하는 것은 후자뿐이다. 그리고 **`FailureCategory`가 다르다** — 권한 거부가 `AUTHORIZATION`이 아니라 `CONFIGURATION`으로 기록된다.
`requireConsume`/`requireAdminister`도 소비자가 없다 — 소비 경로가 조립되지 않고(`analysis/messaging/messaging-policy.md` §17) admin 경로는 `messaging-admin-runtime`이 자체 검사를 할 수 있다.
**(b) ACL 매니페스트 전체가 미사용이다**
`BrokerAclManifest`는 선언·비교·거절 셋을 모두 갖춘 메커니즘이다 — `requireApplicationRuntime()`이 파괴적 grant를 가진 애플리케이션을 거절하고, `undeclared(observed)`가 브로커가 실제로 준 초과 권한을 찾는다. javadoc이 그 목적을 "The manifest is what the platform checks itself against at startup"이라고 적는다.
그 startup 검사를 하는 코드가 없다. 그리고 `observed` 집합을 만들려면 브로커에서 ACL을 읽어야 하는데, 그 읽기를 하는 코드도 없다 — `messaging-admin-api``BrokerTopologyInspector`가 후보이지만 이 leaf와 연결되지 않는다. 즉 **미사용의 이유가 단순한 배선 누락이 아니라 관측 소스의 부재**일 수 있다. 그것은 admin leaf가 답한다.
**(c) 회전 계획 record가 미사용이고 그 술어가 다른 곳에 복제돼 있다** — §12.3.
### 12.2 Conditional sibling comparison
이 leaf에 bean은 없다. starter 쪽 sibling 셋의 조건은 동일(`@ConditionalOnMissingBean`)하고 소비가 다르다.
| bean | 주입처 |
|---|---|
| `BrokerTlsPolicy` | `KafkaMessagingAutoConfiguration`, `RabbitMessagingAutoConfiguration` |
| `CredentialRuntimeRegistry` | 같음 |
| `MessageSecurityValidator` | **없음** — bean만 존재 |
세 번째가 `messaging-policy``RetryDecisionEngine`(그쪽 §12.1)과 같은 형태다. 다만 차이가 있다 — `MessageSecurityValidator`**직접 호출** 지점이 있을 수 있다(starter가 bean을 만들면서 같은 파일에서 부를 수 있다). 그 확인은 starter leaf가 소유한다.
### 12.3 Duplicate mechanism sweep
**(a) TLS posture 검사가 두 클래스에 있고 엄격도가 다르다**
| | `MessageSecurityValidator` | `BrokerTlsPolicy` |
|---|---|---|
| TLS 필수 | `production && !tlsEnabled` | `!tlsEnabled && (production \|\| !allowPlaintextOutsideProduction)` |
| hostname 검증 | **`production && !hostnameVerification`** | **`tlsEnabled && !hostnameVerification`** |
| 프로토콜 버전 | 없음 | 허용목록 + 빈 목록 거절 |
| 예외 | `IllegalArgumentException` | `MessagingConfigurationException` |
| 안정 코드 | 없음 | 4개 |
| 호출자 | starter bean(주입처 없음) | 어댑터 둘 |
**hostname 검증의 조건이 다르다.** `MessageSecurityValidator`는 production에서만 요구하고, `BrokerTlsPolicy`**TLS가 켜져 있으면 언제나** 요구한다. 즉 비운영에서 TLS를 켜고 hostname 검증을 끈 구성은 후자가 거절하고 전자는 통과시킨다. 후자가 더 엄격하고, 후자가 실제로 호출되는 쪽이다.
두 클래스가 같은 `BrokerSecurityProfile`을 받는다. 어느 쪽이 정본인지 코드가 말하지 않는다.
**(b) 회전 술어가 두 번 구현돼 있다**
```java
// CredentialRotationPlan.isDue(now) — 소비자 0
return expiresAt.map(expiry -> !now.isBefore(expiry.minus(rotateBefore))).orElse(false);
// CredentialRuntime.isDueForRotation(now) — 사용됨
return expiresAt.map(expiry -> !now.isBefore(expiry.minus(rotationLead))).orElse(false);
```
`isExpired`도 같다.
```java
// CredentialRotationPlan.isExpired(now)
return expiresAt.map(expiry -> !now.isBefore(expiry)).orElse(false);
// CredentialRuntime.isExpired(now)
return expiresAt.map(expiry -> !now.isBefore(expiry)).orElse(false);
```
**글자까지 동일하다.** 필드 이름만 `rotateBefore` vs `rotationLead`로 다르다. `CredentialRotationPlan`은 material을 갖지 않는 순수 계획 record이고 `CredentialRuntime`은 material을 갖는 런타임 상태다 — 관심사 분리로는 말이 되지만, 술어가 복제된 채로 한쪽만 쓰인다.
`CredentialRotationContractTest``CredentialRotationPlan`을 테스트한다. 즉 **쓰이지 않는 쪽이 테스트되고 쓰이는 쪽의 같은 술어는 그 테스트가 덮지 않는다.** (`CredentialRuntimeRegistryTest`가 간접적으로 덮는다.)
**(c) 권한 검사 두 형태** — §12.1(a).
**(d) 자격증명 참조 검증이 다른 family에도 있는가**
`git grep`으로 credential id 패턴 검증을 저장소 전역에서 찾으면 이 leaf의 `CredentialIds`가 유일하다. notification·grpc family는 자기 자격증명 모델을 갖지만 messaging의 것을 쓰지 않는다 — 경계가 분명하므로 중복 경쟁이 아니다.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `BrokerCredentialProfile` javadoc: 어떤 변형도 비밀을 담지 않음 | 다섯 record 전부 `credentialId` 하나 | **일치** |
| `CredentialRuntime` javadoc: `char[]`로 보관하고 `clear()`가 덮어씀 | 확인 | **일치** |
| `BrokerAclManifest` javadoc: "what the platform checks itself against at startup" | 호출자 0 | **불일치** |
| `DestinationAccessPolicy` javadoc: "The platform checks this before the broker does" | `mayPublish`가 발행 경로에서 호출됨 | **일치**(다만 validator 경유 아님) |
| `CredentialRuntimeRegistry.clearAll` javadoc: "for shutdown" | 호출자 0 | **불일치** |
| `MessageSecurityValidator` javadoc: "boot failures rather than warnings" | starter가 bean 생성. 호출 지점은 starter가 소유 | **미확인** |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `CredentialRuntimeRegistry.resolve` 주석 | `get → fetch → put → clear`, 동기화 없음 | 두 스레드가 같은 자격증명을 회전 → **진 쪽 교체본이 맵에서 사라지고 소거도 안 됨(소유자 없는 비밀이 힙에 잔류)**, 그리고 **진 쪽이 이긴 쪽이 사용 중인 material을 소거** |
| `BrokerTlsPolicy` 프로토콜 검사 주석 | 거부목록 | `SSL`·`TLSv0.9`·`PLAINTEXT`·오타가 전부 통과 → JVM이 인식 못 하는 문자열은 **JVM 기본값으로 협상**, 즉 이 정책이 막으려던 결과 |
두 번째가 `messaging-schema-api` §12.3의 허용목록/거부목록 축과 같은 주제이고, 여기서는 **거부목록이 실제로 뚫린 기록**이 남아 있다.
첫 번째는 이 저장소가 반복하는 "정확히 한 번" 주제의 보안 판본이다 — `messaging-transport-spi`의 세대 close, `messaging-policy`의 permit 반납과 같은 계열이며, 여기서는 실패의 결과가 **비밀 잔류**다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-287 | command | `evidence/raw/287-messaging-security-duplicate-checks.txt` | 12타입 정규화 이름 기준 참조 수, 소비자 0인 넷, 접근 검사 두 형태 나란히, TLS 검사 두 클래스의 조건 차이, 회전 술어 두 복사본, 실제 소비자 목록 | 정적 검색. 리플렉션·파생 프로젝트 미포함 |
| EVD-288 | command | `./gradlew :messaging:messaging-security:test --rerun-tasks` | BUILD SUCCESSFUL, 24 / 0 / 0 | `BrokerTlsPolicy`·`BrokerAclManifest`·접근 정책 미검증 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 어떤 변형도 비밀을 담지 않는 이유 — `BrokerCredentialProfile` javadoc
- `char[]`이 타입 수준 통제인 이유 — `CredentialRuntime` javadoc
- material을 복사해 반환하는 이유 — `material()` javadoc
- 만료 이전에 회전하는 이유 — `CredentialRuntime`·`CredentialRotationPlan` javadoc
- single-flight가 필요한 이유와 두 개의 이전 결함 — `resolve` 주석
- 소거 순서(설치 후, 교체한 스레드가) 이유 — 같은 주석
- hostname 검증 부재가 평문보다 나쁜 이유 — `BrokerTlsPolicy` javadoc
- 허용목록을 고른 이유와 거부목록이 뚫린 기록 — 같은 파일 주석
- 보안 검사가 경고가 아니라 부팅 실패인 이유 — `MessageSecurityValidator` javadoc
- 세 자격증명을 분리하는 이유 — `BrokerSecurityProfile` javadoc
- 초과 권한이 발견인 이유 — `BrokerAclManifest` javadoc
- 파괴적 연산을 따로 이름 붙인 이유 — 같은 javadoc
- credential id를 슬러그로 제한하는 이유 — `CredentialIds` javadoc
- 플랫폼이 브로커보다 먼저 검사하는 이유 — `DestinationAccessPolicy`·`DestinationAccessValidator` javadoc
**추론**
- `DestinationAccessValidator`가 미사용인 것은 발행 경로가 예외 대신 `PublishResult`를 반환하기로 했기 때문이다 → **추론**. 두 형태의 존재는 관측이고 인과는 추론이다.
- `BrokerAclManifest`가 미사용인 것은 브로커에서 ACL을 읽는 코드가 없기 때문이다 → **추론**. 읽기 코드 부재는 관측이다.
- `CredentialRotationPlan`이 미사용인 것이 `CredentialRuntime`으로 흡수된 결과인지 → **미상**.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 12개 타입 954줄 전문의 계약
- 24개 테스트가 통과하고 무엇을 단언하는지, 그리고 5개 타입이 테스트에 등장하지 않는다는 것
- 정규화 이름 기준 참조 수와, 소비자 0인 셋(+package-private 하나)
- 어댑터 둘이 `BrokerTlsPolicy`·`CredentialRuntimeRegistry`를 실제로 쓴다는 것
- 같은 판단이 두 형태로 존재하는 세 쌍(접근 검사, TLS posture, 회전 술어)과 그중 TLS는 **엄격도가 실제로 다르다**는 것
- `clearAll()`의 호출자가 없다는 것
**확인하지 못한 것**
- **`MessageSecurityValidator.validate`가 실제로 호출되는지.** starter가 bean을 만들고, 같은 파일에서 직접 호출할 가능성이 있다. starter leaf가 답한다.
- 브로커에서 ACL을 읽는 경로가 존재하는지 — `messaging-admin-api``BrokerTopologyInspector`가 후보다.
- `compute` 안에서 `provider.resolve`가 실제 저장소를 호출할 때의 지연. 구현이 없어 관측할 수 없다.
- `CredentialRuntime.material` 필드의 가시성 문제가 실제로 발생하는지 — 현재 경로에서는 창이 좁다.
- `BrokerAclManifest.Grant``pattern` 정확 일치가 실제 브로커 표현과 맞는지.
---
## 17. 손볼 것
### P2 — 같은 TLS posture를 두 클래스가 다른 엄격도로 검사한다
- **사실.** `MessageSecurityValidator`는 hostname 검증을 `production && !hostnameVerification`일 때만 요구하고, `BrokerTlsPolicy``tlsEnabled && !hostnameVerification`일 때 요구한다. 전자는 코드 없는 `IllegalArgumentException`, 후자는 안정 코드가 붙은 `MessagingConfigurationException`을 던진다. 둘 다 같은 `BrokerSecurityProfile`을 받고, 후자만 어댑터에서 실제로 호출된다.
- **근거.** `evidence/raw/287` §D.
- **왜 문제인가.** 비운영에서 TLS를 켜고 hostname 검증을 끈 구성을 두 검사가 다르게 판정한다. 그리고 이 leaf 자신의 javadoc이 그 구성을 "looks encrypted in every dashboard while accepting any certificate a man in the middle presents"라고 부른다 — 즉 더 느슨한 쪽이 그 위험을 통과시킨다. 실패 형태도 달라서 운영자가 두 어휘를 알아야 한다.
- **확인 방법.** `evidence/raw/287` §D 재실행. 또는 두 `validate` 메서드 대조.
- **후보.** (a) `MessageSecurityValidator``BrokerTlsPolicy`에 위임한다. (b) 두 클래스의 책임을 나눈다 — TLS는 후자, 자격증명 분리는 전자.
- **다음 단계.** **CASE 후보 + REFERENCE 후보.** "같은 불변식을 두 곳에서 검사하면 느슨한 쪽이 통과 경로가 된다"가 재사용 가능한 기준이다.
### P2 — 권한 거부가 `AUTHORIZATION`이 아니라 `CONFIGURATION`으로 기록된다
- **사실.** `DestinationAccessValidator.requirePublish``MessageAuthorizationException("DESTINATION_PUBLISH_DENIED")`을 던지고 그 카테고리는 `AUTHORIZATION`이다. 소비자가 0이다. 실제 발행 경로는 `access.mayPublish`를 직접 묻고 `rejected("PUBLISH_FORBIDDEN", ...)`을 반환하는데, `rejected(...)``FailureCategory.CONFIGURATION`을 붙인다.
- **근거.** `evidence/raw/287` §C. `DefaultMessagePublisher.java:104-116`(`rejected`의 카테고리).
- **왜 문제인가.** `FailureCategory`는 "stable classification a retry engine, DLQ router, and dashboard all agree on"이다(`messaging-core-api` §4.12). 권한 거부가 구성 오류로 분류되면 보안 대시보드가 그것을 보지 못하고, 구성 오류 알림이 권한 거부로 오염된다. 그리고 `AUTHORIZATION` 카테고리를 쓰는 유일한 코드가 미사용 클래스에 있다.
- **확인 방법.** `MessageAuthorizationException``CATEGORY` 상수와 `DefaultMessagePublisher.rejected`의 카테고리 대조.
- **후보.** 발행 경로가 권한 거부에 `AUTHORIZATION` 카테고리를 붙이거나, `DestinationAccessValidator`를 쓰고 예외를 `PublishResult`로 번역한다.
- **다음 단계.** **CASE 후보.** `messaging-runtime-core` leaf와 공동 소유.
### P3 — ACL 매니페스트 전체가 쓰이지 않는다
- **사실.** `BrokerAclManifest`의 세 메서드(`requireApplicationRuntime`, `undeclared`, `missing`)와 두 enum이 소비자 0이다. javadoc은 "The manifest is what the platform checks itself against at startup"이라고 한다.
- **근거.** `evidence/raw/287` §A·§B.
- **왜 문제인가.** "애플리케이션 런타임은 파괴적 권한을 갖지 않는다"는 이 leaf의 핵심 원칙 중 하나이고, `MessageSecurityValidator`가 admin **자격증명**의 부재만 검사한다. 브로커가 producer 자격증명에 `DELETE`를 준 경우는 아무도 보지 않는다.
- **확인 방법.** `git grep -l 'BrokerAclManifest' -- src ':!src/messaging/messaging-security'` → 없음.
- **후보.** startup 검사에 배선하거나, 브로커 ACL 읽기가 없으면 그 사실을 javadoc에 적는다.
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 "브로커 ACL을 읽는 경로가 있는가"에 걸리고, 그것은 `messaging-admin-api`가 답한다.
### P3 — 종료 시 자격증명 소거가 호출되지 않는다
- **사실.** `CredentialRuntimeRegistry.clearAll()`의 javadoc이 "Clears every held credential, for shutdown"이라고 하고, 호출자가 저장소에 없다.
- **근거.** `git grep -n 'clearAll' -- src`.
- **왜 문제인가.** 이 leaf 전체가 "비밀이 힙에 남지 않게 한다"를 목적으로 하고(`char[]`, `clear()`, 회전 시 즉시 소거), 종료 경로에서 그 마지막 단계가 빠져 있다. 프로세스가 끝나면 힙도 사라지지만, 종료가 느리거나 힙 덤프가 뜨는 경우가 정확히 이 통제가 노리는 상황이다.
- **확인 방법.** `git grep -n 'clearAll' -- src` → 선언과 테스트만.
- **후보.** `MessagingShutdownLifecycle`이나 `DisposableBean`에 연결한다.
- **다음 단계.** **CASE 후보.** `messaging-transport-spi` §12.1의 8단계 종료 계약과 같은 맥락이다.
### P3 — 회전 술어가 두 번 구현돼 있고, 쓰이지 않는 쪽이 테스트된다
- **사실.** `CredentialRotationPlan.isDue`/`isExpired``CredentialRuntime.isDueForRotation`/`isExpired`가 글자까지 같다. 전자는 소비자 0이고 전용 테스트(`CredentialRotationContractTest`, 4개)가 있다.
- **근거.** `evidence/raw/287` §E.
- **왜 문제인가.** 테스트가 고정하는 것과 실행되는 것이 다른 객체다. 한쪽만 고치면 다른 쪽은 조용히 다른 시점에 회전한다.
- **확인 방법.** 두 메서드 본문 대조.
- **후보.** `CredentialRuntime``CredentialRotationPlan`을 필드로 갖고 위임하거나, 계획 record를 제거한다.
- **다음 단계.** **REFERENCE 후보**(같은 술어가 두 타입에 있으면 하나가 다른 하나를 부른다).
### P3 — 자격증명 해석이 맵 bin 락 안에서 외부 I/O를 한다
- **사실.** `resolve``resolved.compute(credentialId, (key, existing) -> { ... provider.resolve(key) ... })` 형태다. `CredentialProvider.resolve`는 외부 비밀 저장소를 호출할 수 있는 port다.
- **근거.** `CredentialRuntimeRegistry.java:71-86`.
- **왜 문제인가.** single-flight를 얻은 대가다 — 같은 credential id를 요청하는 다른 스레드는 저장소 왕복 동안 막힌다. 그것이 의도이고 옳다. 다만 **`ConcurrentHashMap`의 bin은 키가 공유하므로** 해시가 충돌하는 다른 credential id도 함께 막힌다. 그리고 저장소가 느려지면 그 지연이 발행 경로로 전파된다 — 타임아웃이 없다.
- **확인 방법.** `provider.resolve` 호출 위치가 람다 안임을 확인.
- **후보.** 현 구조를 유지하되 `CredentialProvider` javadoc에 "구현은 유한 시간 안에 반환해야 한다"를 명시한다.
- **다음 단계.** **REFERENCE 후보**(맵 갱신 함수 안에서 I/O를 하면 그 지연이 락 범위가 된다).
### P3 — 다섯 타입이 이 leaf의 테스트에 등장하지 않는다
- **사실.** `BrokerTlsPolicy`·`BrokerAclManifest`·`DestinationAccessPolicy`·`DestinationAccessValidator`·`BrokerCredentialProfile`을 겨냥한 테스트가 없다.
- **근거.** 세 테스트 클래스 전수.
- **왜 문제인가.** `BrokerTlsPolicy`**실제로 배선된** 클래스다 — 어댑터 둘이 호출한다. 네 거절 조건과 허용목록 판정이 이 leaf의 레인에서 검증되지 않는다. 어댑터 테스트가 간접적으로 지나가더라도 그것은 다른 목표를 가진 레인이다.
- **확인 방법.** `find src/test -name '*Test.java'` → 셋.
- **후보.** `BrokerTlsPolicy`의 네 거절 조건과 허용/거부 경계를 겨냥한 테스트를 추가한다.
- **다음 단계.** **REFERENCE 후보**(배선된 게이트는 자기 leaf 레인에서 검증한다).
### P3 — `CredentialRuntime.material`이 동기화되지 않는다
- **사실.** `private char[] material``volatile`이 아니고 `clear()`가 그것을 교체한다. `clearAll()`은 락 없이 순회한다.
- **근거.** `CredentialRuntime.java:29,129-132`, `CredentialRuntimeRegistry.java:129-132`.
- **왜 문제인가.** 정상 경로(`compute` 안 소거)에서는 `ConcurrentHashMap`이 happens-before를 준다. `clearAll()` 경로에는 그 보장이 없다 — 다른 스레드가 소거된 배열의 옛 참조를 보고 이미 지워진 material을 읽을 수 있다(0으로 채워진 값). 실질 위험은 낮고 방향도 안전(비밀 유출이 아니라 잘못된 값)하다.
- **확인 방법.** 필드 선언 확인.
- **후보.** `material``volatile`로 하거나 `clearAll()``compute` 기반으로 바꾼다.
- **다음 단계.** **REFERENCE 후보**(가변 필드로 상태 전이를 표현하면 가시성을 함께 정한다).
### 확인된 설계(문제 아님)
- 어떤 자격증명 프로파일 변형도 비밀을 담지 않고 참조만 갖는 것, 그리고 sealed로 닫은 것
- material을 `char[]`로 보관하고 반환 시 복사하며 소거 시 덮어쓰는 세 통제
- `toString()`이 material을 담지 않는 것
- key별 single-flight와 "설치 후 소거, 교체한 스레드만" 순서
- 만료가 아니라 만료 이전에 회전하는 것, 만료를 모르면 회전 대상이 아닌 것
- TLS 프로토콜을 허용목록으로 판정한 것과 그 이유가 실패 이력으로 남은 것
- hostname 검증 부재를 평문보다 나쁜 실패로 분류한 것
- producer·consumer·admin 자격증명 분리와 운영에서 admin 금지
- credential id 슬러그 제한과 비밀-모양 접두사 휴리스틱
- 파괴적 연산을 enum 상수에 표시한 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MSC-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 1개, memberships `["app-bootstrap"]` | 선언 |
| MSC-002 | build | `messaging-security/build.gradle` | same | 벤더 의존성 0 | — |
| MSC-003 | code | `.../security/CredentialRuntime.java` 전문 | same | §4.2 세 통제, 회전 술어 | `material` 미동기화(§17) |
| MSC-004 | code | `.../security/CredentialRuntimeRegistry.java` 전문 | same | §4.1 single-flight와 두 이전 결함 | `clearAll` 호출자 없음 |
| MSC-005 | code | `.../security/BrokerTlsPolicy.java` 전문 | same | §4.4 네 거절과 허용목록 이력 | 전용 테스트 없음 |
| MSC-006 | code | `.../security/MessageSecurityValidator.java` | same | §4.5 다섯 거절 | TLS 검사가 §4.4와 겹침 |
| MSC-007 | code | `.../security/BrokerAclManifest.java` | same | §4.6 초과=발견, 파괴적 연산 분리 | 소비자 0 |
| MSC-008 | code | `.../security/{DestinationAccessPolicy,DestinationAccessValidator}.java` | same | §4.8 세 역할, 검증기의 세 코드 | 검증기 소비자 0 |
| MSC-009 | code | `.../security/{BrokerSecurityProfile,BrokerCredentialProfile,CredentialIds,CredentialProvider,CredentialRotationPlan}.java` | same | 역할 분리, sealed 5변형, id 검증, port | 계획 record 소비자 0 |
| MSC-010 | test | `CredentialRuntimeRegistryTest` (13) | same | 해석·회전·소거·경합 | 실제 저장소 없음 |
| MSC-011 | test | `MessageSecurityValidatorTest` (7) | same | 다섯 거절 조건 | — |
| MSC-012 | test | `CredentialRotationContractTest` (4) | same | 회전 시점 술어 | **미사용 타입을 테스트** |
| MSC-013 | cross-leaf code | `messaging-kafka/.../KafkaSecurityConfigurer.java`, `messaging-rabbit/.../RabbitSecurityConfigurer.java` | same | `BrokerTlsPolicy`·`CredentialRuntimeRegistry`의 실제 소비 | 각 leaf SSOT가 소유 |
| MSC-014 | cross-leaf code | `messaging-runtime-core/.../DefaultMessagePublisher.java:170-176` | same | 인라인 권한 검사와 그 코드·카테고리 | 해당 leaf SSOT가 소유 |
| MSC-015 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java` | same | 세 bean 생성 | 해당 leaf SSOT가 소유 |
| EVD-287 | command | `evidence/raw/287-messaging-security-duplicate-checks.txt` | same | §12.1·§12.3 전부 | 정적 검색 |
| EVD-288 | command | `./gradlew :messaging:messaging-security:test --rerun-tasks` | same | 24 / 0 / 0 | 5개 타입 미검증 |
@@ -0,0 +1,456 @@
# messaging-spring-boot-starter 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 28파일 3,528줄 + `src/test` 10파일 2,349줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-spring-boot-starter`
> SSOT owner: `messaging-spring-boot-starter`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `runtime_memberships`: **`["app-bootstrap"]`** — 출하. 이 리프가 messaging 폐포 전체를 실행 클래스패스에 올린다
- 자동 설정 등록: `MessagingPlatformRootAutoConfiguration` 하나
- 다만 `app-bootstrap``application.yml` 어디에도 `app.messaging.enabled` 가 없다. 클래스패스에는 있고 꺼져 있다
| 파일 | LOC | 역할 |
|---|---:|---|
| `MessagingCoreAutoConfiguration` | 480 | 정책·전송·관측 빈 26개 + 발행자 + 런타임 설치 |
| `MessagingConfigurationCompiler` | 331 | 문서화된 설정 → 플랫폼 프로파일 |
| `MessagingSettings` | 301 | `app.messaging` 바인딩 + 중첩 4클래스 |
| `DefaultBatchMessagePublisher` | 254 | 배치 팬아웃 + 마감 |
| `MessagingConfigurationKeyValidator` | 227 | 바인딩되지 않는 키 거부 |
| `MessagingReliabilityAutoConfiguration` | 185 | 발신함·수신함 운영 빈 |
| `DestinationSettings` | 177 | 목적지 한 항목(중첩 record 7) |
| `KafkaMessagingAutoConfiguration` | 170 | Kafka 검증기·보안 설정기·생산자·전송 |
| `MessagingProviderSelection` | 150 | 닫힌 레지스트리에서 전송 하나 선택 |
| `MessagingShutdownLifecycle` | 124 | 승인 차단 → 배수 |
| `RabbitMessagingAutoConfiguration` | 98 | 검증기·분류기·보안 설정기 (전송 없음) |
| `MessagingCredentialRequirementValidator` | 92 | 운영 프로파일에 자격 출처 요구 |
| `MessagingAdminAutoConfiguration` | 86 | 관리 평면(별도 스위치) |
| `MessagingPrefixMigrationValidator` | 83 | 죽은 접두 거부 |
| `MessagingEndpoint` | 78 | 읽기 전용 actuator |
| `PublishResults` | 71 | 예외 → 결과 변환 |
| `BrokerSettings` | 69 | 브로커 한 항목(두 가족 한 record) |
| `MessagingOutboxRelayLifecycle` | 69 | 중계 구동 |
| `MessagingAdminDurabilityValidator` | 68 | 비내구 저널 위 운영 프로파일 거부 |
| `DefaultBlockingMessagePublisher` | 65 | 블로킹 파사드 |
| `ValidatedDestinationRegistry` | 59 | 검증 통과 목적지 |
| `CompiledMessagingConfiguration` | 53 | 컴파일 결과 4묶음 |
| `BrokerSecuritySettings` | 50 | 보안 한 항목(비밀 없음) |
| `StartupProfileValidation` | 46 | 검증기를 실제로 부르는 어댑터 |
| `DefaultReactiveMessagePublisher` | 39 | Reactor 파사드 |
| `MessagingPlatformRootAutoConfiguration` | 36 | 마스터 조건 소유 |
| `MessageContracts` | 35 | 메시지 계약 홀더 |
| `ReactiveMessagePublisher` | 32 | Reactor 인터페이스 |
main 총 **28파일 / 3,528줄**.
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 28 | `FULL_READ` | 3,528줄. 위 표가 전부 |
| `main/resources/META-INF/spring/*.imports` | 1 | `FULL_READ` | 1줄 |
| `test/java/**` | 10 | `FULL_READ` | 2,349줄 |
| `build.gradle` | 1 | `FULL_READ` | 66줄 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
`UNCLASSIFIED` 0.
> 이 표는 2026-09-01 재통독에서 다시 세었다. 이전 판은 큰 파일 아홉만 적고 "나머지 18파일 — " 로 닫았다. 그 "나머지" 안에 §17.4 가 있었다.
---
## 1. 하나의 뿌리가 조건을 소유한다
```java
@AutoConfiguration
@ConditionalOnProperty(prefix = MessagingSettings.PREFIX, name = "enabled", havingValue = "true")
@EnableConfigurationProperties(MessagingSettings.class)
@Import({MessagingCoreAutoConfiguration.class, MessagingProviderSelection.class,
MessagingReliabilityAutoConfiguration.class, MessagingAdminAutoConfiguration.class})
public class MessagingPlatformRootAutoConfiguration {}
```
javadoc 이 이전 상태와 수정을 적는다.
> "The starter registered five auto-configurations directly, and not one carried a messaging master
> condition — putting the starter on the classpath assembled the platform… one root owning the
> condition, importing children that carry none, so a bean added to any child next month is gated
> without anyone remembering to repeat a condition."
그리고 제공자 선택의 이전 상태도 적는다.
> "Kafka and Rabbit were each conditioned on their client class being present, so an application that
> happened to have both libraries — a transitive dependency is enough — assembled both providers and
> published through whichever bean won. Selection now reads `app.messaging.broker` against a closed
> registry, and a value outside it is a startup error rather than a context with no provider at all."
꺼진 상태의 계약도 명시된다 — 빈도, 클라이언트도, 스레드도, 결속된 상세 이름공간도 없다. `MessagingStarterOffContractTest` 가 그것을 빈 이름과 **살아 있는 스레드** 로 붙든다.
## 2. 선택은 닫힌 레지스트리이고, 등록과 조립은 다르다
`MessagingProviderSelection` 에 지도가 셋이다.
```java
REGISTERED_BROKERS = {kafka: org.apache.kafka.clients.producer.Producer,
rabbit: com.rabbitmq.client.Channel}
PROVIDER_CONFIGURATIONS = {kafka: KafkaMessagingAutoConfiguration,
rabbit: RabbitMessagingAutoConfiguration}
BROKERS_WITHOUT_A_TRANSPORT = {rabbit: "…ships its validators and security configuration but no
MessagingTransport…"}
```
셋째 지도가 이 클래스의 판단이다. 등록되어 있다는 것과 조립할 수 있다는 것을 분리했고, 그 이유를 적었다 — Rabbit 을 고르면 핵심 설정 깊은 곳에서 `MessagingTransport` 빈이 없다는 오류가 나는데, 그것은 운영자에게 빈이 없다고만 말하지 고른 전송이 완성되지 않았다고는 말하지 않는다.
결과로 오늘 조립 가능한 전송은 `kafka` 하나다. `RabbitMessagingAutoConfiguration` 98줄은 선택 단계에서 거부되므로 **어떤 경로로도 도달하지 않는다**(§12.3).
## 3. 설정이 프로파일이 된다
`MessagingConfigurationCompiler` 가 닫는 것은 기능이 아니라 바인더의 부재다.
> "`docs/messaging/configuration-reference.md` described destination, broker and security sections;
> the only thing that bound was four flags… So a deployment that followed the documentation
> configured nothing, and nothing said so — which is the worst of the three possible outcomes, the
> other two being 'it works' and 'it refuses to start'."
컴파일과 검증을 나눈 이유도 적혀 있다. 컴파일은 객체 모델이 표현할 수 없는 것만 본다 — 목적지의 브로커가 존재하는지, 사후 처리 목적지가 선언되었는지, 보안 항목이 실재하는 브로커를 지키는지. 프로파일이 자체로 정합한지는 `DestinationProfileValidator` 의 질문이고 레지스트리 전체에 대해 던져진다. 그래서 설정으로 만든 프로파일과 빈으로 선언한 프로파일이 **같은 규칙**을 받는다.
그리고 모든 거부가 키를 부른다. 타입을 부르는 오류는 운영자가 고칠 줄을 알려 주지 않기 때문이다.
## 4. 시작 프로파일 검증
`StartupProfileValidation` 이 이 가족에서 이미 한 번 고쳐진 결함을 기록한다.
> "The Kafka, RabbitMQ and security validators were all beans and none of them was injected
> anywhere: the context published a validator per broker and validated nothing."
수정의 두 판단이 적혀 있다 — `afterPropertiesSet` 으로 돌려 컨텍스트 구성 중에 실패하게 한 것, 그리고 프로파일을 `Supplier` 로 받아 애플리케이션 선언 빈과 설정에서 컴파일된 프로파일 **두 출처** 를 모두 보게 한 것.
> "a validator that saw only one of the two would leave the other half of a deployment's
> configuration unchecked. Which half went unchecked would depend on how the deployment happened to
> be written, which is the worst possible rule."
## 5. 신뢰성 배선의 원칙
> "Every bean here is conditional on the application having supplied the corresponding repository.
> The platform cannot provide those: they write inside the application's own transaction, against the
> application's own datasource, and a default implementation would silently write to the wrong place
> — or to nowhere at all, which is worse because the outbox would look healthy while nothing was ever
> staged."
정리 작업과 중계의 처리가 갈리고 그 이유도 적혀 있다.
> "The cleanup jobs are beans but no scheduler is registered for them. Scheduling is the
> application's decision: a service running several replicas usually wants one of them to run
> cleanup, and auto-registering a fixed-rate task would have every replica delete the same rows."
> "The relay is the opposite case and is driven here. Its claims are fenced by owner and token under
> `SKIP LOCKED`, so every replica running one is safe, while nobody running one is a table that fills
> up behind a business transaction that reported success."
## 6. 종료 순서가 두 수명 주기의 phase 로 표현된다
```java
MessagingOutboxRelayLifecycle.getPhase() = Integer.MAX_VALUE
MessagingShutdownLifecycle.getPhase() = Integer.MAX_VALUE - 1024
```
`SmartLifecycle` 은 내림차순으로 멈추므로 중계가 먼저, 승인 차단과 배수가 다음이다. 두 클래스의 javadoc 이 서로를 근거로 든다 — 중계가 발행 중일 때 승인을 닫으면 그 회차의 행이 모호해지고, 그 모호함이야말로 배수가 없애려는 것이다. 그리고 브로커 연결을 쥔 빈(`@Bean(destroyMethod = "close")` 인 생산자)은 `Lifecycle` 이 아니므로 컨텍스트가 `destroyBeans()` 에 도달할 때, 즉 두 수명 주기가 모두 끝난 뒤에 닫힌다. 순서가 맞는다.
## 10. 테스트 레인
10파일 2,349줄.
| 파일 | 줄 | 무엇을 붙드나 |
|---|---:|---|
| `MessagingAutoConfigurationTest` | 546 | 빈 조립·바인딩·모순 프로파일 거부·접두 이관·저널 내구성·자격 요구 |
| `MessagingConfigurationBindingTest` | 320 | **문서를 실행한다**`docs/messaging/configuration-reference.md` 의 YAML 블록을 꺼내 컨텍스트를 띄운다. 그리고 거부 9종 |
| `BatchPublisherTest` | 316 | 인덱스별 결과·동기 실패·마감·지연된 거부 |
| `MessagingStarterOffContractTest` | 254 | 꺼짐=빈 0·스레드 0, 선택 계약, Rabbit 거부 |
| `MessagingLiveRoundTripQualificationTest` | 217 | Testcontainers Kafka 4.1.0 에 실제로 바이트를 보내고 읽어 온다 |
| `MessagingOutboxRelayLifecycleTest` | 203 | 컨텍스트가 중계를 실제로 돌리는지 |
| `BlockingFacadeTest` · `ReactiveFacadeTest` | 148 · 134 | 마감·모호 처리 / 차가운 `Mono` |
| `MessagingEndpointTest` | 133 | 보고 내용·쓰기 연산 0 |
| `MessagingShutdownLifecycleTest` | 78 | 승인 차단이 배수보다 먼저 |
두 테스트가 이 리프의 검증 태도를 규정한다.
**문서를 실행한다.** `MessagingConfigurationBindingTest.documented()` 가 마크다운에서 ```` ```yaml ```` 블록을 뽑아 `YamlPropertySourceLoader` 로 올린다. 문서를 고쳐 바인더가 감당 못 하면 여기서 깨지고, 바인더를 고쳐 문서가 없는 모양을 서술하게 되어도 깨진다.
**가짜가 결함을 가리는 것을 막는다.** `selectingRabbitIsRefused` 의 주석이 자기 이전 판을 기록한다.
> "This test used to run under `withAPublisher()` and assert the context started. The fake
> MessagePublisher tripped @ConditionalOnMissingBean and removed the very bean whose missing
> dependency is the defect — so a configuration that cannot start in any deployment passed as
> 'assembles Rabbit and not Kafka'."
그리고 그 교훈을 지키는 가드 테스트(`aFakePublisherDoesNotHideAnUnassemblableTransport`)를 따로 둔다.
## 12. negative-space probes
**12.1 도달성.** 이 리프는 `app-bootstrap` 에 출하되고 자동 설정이 등록된다. 그런데 `app-bootstrap` 의 `application.yml`·`application-{local,dev,prod}.yml` 어디에도 `app.messaging.enabled` 가 없다. 클래스패스에 있고 꺼져 있다. 그래서 이 리프의 판정은 전부 "속성 하나를 켜는 날" 의 것이다 — 그리고 그 속성을 켜는 것이 곧 이 스타터를 채택하는 행위다.
**12.2 대조군 — 검증기를 부르는가.** `grpc-spring-boot-starter` 는 시작 검증기를 만들어 놓고 부르지 않는다. 이쪽은 `StartupProfileValidation` 으로 실제로 부른다 — 다만 셋 중 하나가 빠져 있다(§17.2).
**12.3 도달하지 않는 설정 클래스.** `RabbitMessagingAutoConfiguration` 98줄은 `PROVIDER_CONFIGURATIONS` 에 등록되어 있지만 `selectedBroker` 가 `rabbit` 을 먼저 거부하므로 `Selector.selectImports` 가 이 클래스 이름을 돌려주는 경로가 없다. 죽은 코드이되 **의도된** 죽은 코드다 — 전송이 생기는 날 `BROKERS_WITHOUT_A_TRANSPORT` 에서 항목이 빠지면 살아난다. 그 의도가 지도 이름과 javadoc 에 적혀 있다.
**12.4 드리프트.** 등록 파일이 뿌리 하나만 담고, 그 뿌리가 넷을 가져온다. 서술과 일치한다.
**12.5 설정처럼 보이지만 상수인 것.** `MessagingConfigurationCompiler.credential(...)` 의 넷째 매개변수 `Supplier<Boolean> required` 는 호출처 셋 모두 `() -> true` 다(§17.4).
**12.6 두 설정 경로의 비대칭.** 이 리프는 "빈으로 선언한 프로파일과 설정으로 만든 프로파일이 같은 규칙을 받아야 한다" 를 반복해서 근거로 든다. 그런데 `DestinationSettings.Retry` 에는 `retryableCategories`·`nonRetryableCategories` 에 대응하는 키가 없다(§17.5).
**12.7 보안 설정기를 부르는 곳이 없다.** 저장소 전체에서 `KafkaSecurityConfigurer` 를 언급하는 production 코드는 이 리프의 빈 선언 한 줄뿐이다. 나머지는 자기 자신과 자기 테스트다(§17.1).
## 16. 확인하지 못한 것
- 애플리케이션이 저장소 빈을 공급한 상태로 컨텍스트를 세우지 않았다. 저장소에 그런 애플리케이션이 없다.
- `@ConditionalOnBean` 의 평가 순서를 실제 컨텍스트로 재현하지 않았다(§17.3). 스프링의 문서화된 제약으로 판정했다.
- §17.1 을 TLS·SASL 을 요구하는 실제 브로커에 붙여 재현하지 않았다. 조립되는 생산자 설정 맵의 성분 전부(`bootstrap.servers`·직렬화기 둘·`acks`·`enable.idempotence`)와 `KafkaSecurityConfigurer.configure` 가 만드는 성분 다섯(`security.protocol`·`ssl.enabled.protocols`·`ssl.endpoint.identification.algorithm`·`sasl.mechanism`·`sasl.jaas.config`)이 교집합 0 이라는 것으로 판정했다.
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
## 17. 손볼 것
### 17.1 P1 — 운영 배포에 TLS 와 인증을 **선언하라고 요구한 뒤**, 그 둘이 없는 생산자를 만든다
두 사실을 나란히 놓으면 보인다.
**검증기가 요구한다.** `KafkaProfileValidator`:
```java
if (profile.production() && !profile.tlsEnabled()) {
throw new IllegalArgumentException("a production Kafka connection requires TLS: " + profile.broker());
}
if (profile.production() && !profile.authenticationEnabled()) {
throw new IllegalArgumentException("a production Kafka connection requires broker authentication: " + profile.broker());
}
```
그리고 이 리프의 `kafkaProfileStartupValidation` 이 그것을 설정에서 컴파일된 프로파일에도 실제로 돌린다. 전용 테스트가 있다 — `aProductionKafkaBrokerWithoutTransportSecurityFailsStartup`.
**조립되는 생산자에는 그 둘이 없다.** `KafkaMessagingAutoConfiguration.messagingKafkaProducer`:
```java
Map<String, Object> config = new HashMap<>();
config.put(BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
config.put(KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
config.put(VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
config.put(ACKS_CONFIG, "all");
config.put(ENABLE_IDEMPOTENCE_CONFIG, true);
return new KafkaProducer<>(config);
```
다섯 항목이 전부다. `security.protocol` 이 없으므로 Kafka 클라이언트의 기본값 `PLAINTEXT` 로 접속한다.
**그 둘을 만드는 코드는 있고, 아무도 부르지 않는다.** `KafkaSecurityConfigurer.configure(...)` 가 정확히 다섯을 만든다.
```java
properties.put(SECURITY_PROTOCOL, securityProtocol(profile, credential)); // SASL_SSL | SSL | SASL_PLAINTEXT | PLAINTEXT
if (profile.tlsEnabled()) {
properties.put(ENABLED_PROTOCOLS, String.join(",", enabledProtocols));
properties.put(ENDPOINT_IDENTIFICATION, "https");
}
… properties.put(SASL_MECHANISM, "SCRAM-SHA-512");
properties.put(SASL_JAAS_CONFIG, scramJaas(scram.credentialId(), resolved));
```
이 클래스를 언급하는 production 코드는 저장소 전체에서 이 리프의 빈 선언 한 줄뿐이다. 나머지 참조는 자기 자신과 `KafkaSecurityConfigurerTest` 다.
**그래서 배포가 겪는 것.**
1. `app.messaging.brokers.k.production=true` 를 쓴다.
2. 검증기가 `tls-enabled=true` 와 `authentication-enabled=true` 를 요구한다.
3. 운영자가 둘을 켜고, `app.messaging.security.k` 에 SASL 자격 식별자를 적고, `CredentialProvider` 빈을 공급한다. 시작이 통과한다.
4. 만들어진 생산자는 평문·무인증으로 접속한다.
세 검증(`KafkaProfileValidator`·`MessagingCredentialRequirementValidator`·`BrokerTlsPolicy`)이 전부 통과하고, 통과의 대상이 실제 연결이 아니다. 보안을 요구하지 않는 브로커에는 인증 없이 붙고, 요구하는 브로커에는 첫 발행에서 실패한다 — 어느 쪽도 "선언한 대로 접속했다" 가 아니다.
**테스트가 이것을 볼 수 없는 이유.** 조립을 확인하는 두 테스트(`selectingKafkaAssemblesOnlyKafka`·`aSelectedTransportAssemblesAPublisher`)는 빈의 존재만 단언한다. 유일한 실 브로커 시험 `MessagingLiveRoundTripQualificationTest` 는 보안 없는 `KafkaContainer` 에 `production=false` 프로파일로 붙는다. 즉 이 플랫폼이 실제로 증명한 왕복은 평문 왕복 하나다.
**수정.** `messagingKafkaProducer` 가 `KafkaSecurityConfigurer` 와 선택된 브로커의 `BrokerSecurityProfile` 을 받아 `config.putAll(configurer.configure(profile, profile.producerCredential(), protocols, now))` 를 하는 것이다. 자격 회전이 목적이라면 생산자 하나를 고정 설정으로 만드는 형태 자체를 다시 봐야 한다 — `KafkaSecurityConfigurer` 의 javadoc 이 그 이유를 이미 적어 두었다.
> "a client configured from a value read once at startup holds that value until the process
> restarts, so the rotation the credential store performs never reaches the broker connection."
지금 조립되는 생산자가 정확히 그 형태이고, 심지어 한 번 읽지도 않는다.
### 17.2 P2 — 같은 자동 설정 안에서 검증기 하나만 감싸이지 않는다
`KafkaMessagingAutoConfiguration` 은 검증기 셋을 만든다.
```java
@Bean public KafkaProfileValidator kafkaProfileValidator() { … }
@Bean public StartupProfileValidation<KafkaBrokerProfile> kafkaProfileStartupValidation(…) { … } // ← 감싼다
@Bean public KafkaTransactionProfileValidator kafkaTransactionProfileValidator() { … }
@Bean public KafkaPublishFailureClassifier kafkaPublishFailureClassifier() { … }
```
`KafkaTransactionProfileValidator` 에는 대응하는 `StartupProfileValidation` 이 없다. 즉 컨텍스트가 그 검증기를 발행하고 아무도 주입하지 않는다 — `StartupProfileValidation` 의 javadoc 이 서술한 이전 상태와 정확히 같은 형태다.
`RabbitMessagingAutoConfiguration` 은 검증기 하나이고 그것을 감싼다. 그러므로 이 가족에서 감싸이지 않은 검증기는 이 하나다.
트랜잭션 프로파일 검증이 무엇을 막는지는 그 클래스가 안다 — 비트랜잭션 생산자 위의 정확히 한 번 주장 같은 조합이다. 그 검증이 지금 돌지 않는다.
수정은 한 블록이다. 같은 파일의 `kafkaProfileStartupValidation` 형태를 복사해 세 번째 검증기를 감싼다.
### 17.3 P2 — 출고되는 신뢰성 체인 전체가 아무도 공급하지 않는 빈 뒤에 있고, 그 사슬이 자기 클래스 안을 가리킨다
```java
@Bean @ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class}) public OutboxRelay outboxRelay(…)
@Bean @ConditionalOnBean(OutboxRelay.class) public OutboxRelayWorker outboxRelayWorker(…)
@Bean @ConditionalOnBean(OutboxRelayWorker.class) public MessagingOutboxRelayLifecycle outboxRelayLifecycle(…)
@Bean @ConditionalOnBean(OutboxRepository.class) public OutboxCleanupJob outboxCleanupJob(…)
@Bean @ConditionalOnBean(InboxRepository.class) public InboxCleanupJob inboxCleanupJob(…)
@Bean @ConditionalOnBean(IdempotentConsumer.class) public TransactionalInboxHandler<Object> transactionalInboxHandler(…)
```
**공급자가 없다.** 여섯 빈 전부가 애플리케이션이 공급해야 하는 타입에 걸려 있다. 조건 자체는 옳고 근거도 정확하다 — 플랫폼이 기본 구현을 주면 조용히 엉뚱한 곳에, 또는 아무 데도 쓰지 않게 된다. 문제는 저장소 안에 그 타입을 공급하는 코드가 없다는 것이다. `messaging-outbox-jdbc-postgresql` 의 `JdbcOutboxRepository` 는 스프링 스테레오타입도 `@Bean` 선언도 없고, `new JdbcOutboxRepository` 가 main 에 0 건이다. 그래서 이 스타터를 켠 배포는 발행 경로는 얻고 발신함 경로는 얻지 못하며, 그 사실이 시작 시점에 어떤 신호도 내지 않는다.
**사슬이 자기 클래스 안을 가리킨다.** 둘째와 셋째가 같은 설정 클래스 안에서 방금 선언된 빈의 존재를 조건으로 삼는다. 스프링은 `@ConditionalOnBean` 을 자동 설정 클래스에서만, 그리고 등록 순서에 의존하는 방식으로만 신뢰할 수 있다고 문서화한다. 지금은 첫 조건이 이미 거짓이라 결과가 드러나지 않는다. 발신함을 배선하는 순간 이 사슬이 실제로 평가된다.
같은 가족의 다른 결정과 대비된다. 관리 평면은 스위치가 켜졌을 때 만들어지지 **않는** 타입의 부재를 javadoc 에 명시한다(`DestructiveMessagingAdmin` 하나). 이쪽은 여섯이 조용히 빠진다.
수정은 둘이다. 발신함을 요구하는 설정에서 저장소 빈이 없으면 시작을 거부하는 검증(이 가족의 `StartupProfileValidation` 형태), 그리고 중계·작업자·수명을 하나의 `@Bean` 으로 합치거나 조건을 전부 최초 두 타입으로 표현하는 것.
### 17.4 P3 — 죽은 매개변수 하나가 유일한 비기본값에서 NPE 를 낳는다
```java
private static BrokerCredentialProfile credential(
String broker, String role, BrokerSecuritySettings.Credential credential, Supplier<Boolean> required) {
if (credential == null && Boolean.TRUE.equals(required.get())) {
throw configurationError(key("security", broker, role), "a configured broker needs a %s credential; …");
}
String type = credential.type() == null ? "" : credential.type().toUpperCase(Locale.ROOT);
```
호출처가 셋이고 전부 `() -> true` 다.
```java
credential(name, "producer", security.producer(), () -> true),
credential(name, "consumer", security.consumer(), () -> true),
Optional.ofNullable(security.admin()).map(admin -> credential(name, "admin", admin, () -> true))
```
그래서 이 매개변수는 값을 하나만 갖는다. 그리고 그것이 죽어 있다는 것보다 나쁜 성질이 있다 — 이 매개변수가 존재하는 이유("이 역할은 선택적이다")대로 `() -> false` 를 넘기면 `credential == null` 인 경로가 가드를 지나 다음 줄의 `credential.type()` 에서 NPE 로 죽는다. 즉 이 매개변수의 유일한 비기본값이 의도한 동작이 아니라 널 역참조다.
수정은 매개변수를 지우고 널 검사를 무조건으로 만드는 것이다. 선택적 역할이 필요해지는 날에는 `Optional` 을 돌려주는 별도 메서드가 그 자리다 — `admin` 이 이미 호출처에서 그렇게 다뤄진다.
### 17.5 P3 — 설정 경로의 재시도가 예외 분류를 표현할 수 없다
`RetryPolicy` 는 성분 열이고 그중 둘이 분류 집합이다.
```java
Set<FailureCategory> retryableCategories, // "categories added to the retryable set"
Set<FailureCategory> nonRetryableCategories, // "categories removed from the retryable set"
```
`DestinationSettings.Retry` 에는 이 둘에 대응하는 키가 없고, 컴파일러가 상수로 채운다.
```java
return new RetryPolicy(retry.mode(), retry.maxAttempts(), retry.initialDelay(), retry.maxDelay(),
retry.multiplier(), retry.jitter(), retry.orderingImpact(),
Set.of(), Set.of(), // ← 설정으로 표현할 수 없다
Optional.ofNullable(blankToNull(retry.destination())).map(DestinationName::new));
```
빈 집합은 "기본 분류 그대로" 라는 중립값이므로 오동작은 아니다. 문제는 비대칭이다. `DestinationProfile` 을 자바로 선언한 배포는 두 집합을 조정할 수 있고, 문서대로 YAML 로 설정한 배포는 할 수 없다. 이 리프가 반복해서 근거로 든 규칙이 정확히 그 비대칭을 금지한다.
> "Which half went unchecked would depend on how the deployment happened to be written, which is the
> worst possible rule."
수정은 `Retry` 에 두 키를 더하는 것이다. `FailureCategory` 는 열거이므로 relaxed binding 이 그대로 처리한다.
### 17.6 P3 — 배치 발행자가 `CompletionStage` 를 돌려주면서 동기 예외를 던진다
```java
public CompletionStage<BatchPublishResult> publish(List<PublishRequest<?>> requests, BatchPublishOptions options) {
if (requests.size() > options.maxBatchSize()) {
throw new MessageTooLargeException("BATCH_COUNT_EXCEEDED", …); // ← 스테이지가 아니라 던진다
}
```
같은 클래스가 자기 의존 대상에 대해서는 정확히 이 형태를 방어한다.
```java
} catch (RuntimeException synchronousFailure) {
// A publisher that validates eagerly throws instead of returning a failed stage. Converting
// it here keeps the "one result per index" contract that the caller resubmits from.
```
즉 "게으르게 검증하고 실패한 스테이지를 돌려준다" 가 이 클래스가 아는 계약인데, 자기 호출자에게는 그것을 지키지 않는다. 비동기 파이프라인으로 배치를 부르는 코드는 `.exceptionally(...)` 로 잡히지 않는 예외를 만난다.
등급이 P3 인 이유는 이것이 프로그래밍 오류(배치 크기 초과)이고 결과가 손실이 아니라 예외 형태의 불일치이기 때문이다. 전용 테스트(`aBatchLargerThanItsLimitIsRefusedBeforeAnythingIsPublished`)가 `assertThatThrownBy` 로 현재 동작을 고정하고 있으므로, 고치려면 그 테스트도 함께 바꾼다.
### 확인된 설계(문제 아님)
- **하나의 뿌리가 마스터 조건을 소유하고 자식은 조건을 갖지 않는 것.**
- **제공자 선택을 클래스패스 사고가 아니라 닫힌 레지스트리의 속성으로 만든 것.**
- **등록과 조립 가능을 분리하고, 조립 못 하는 전송을 선택 단계에서 이유와 함께 거부한 것.**
- **꺼진 상태의 계약을 빈 이름과 살아 있는 스레드로 붙든 것** — 빈 목록만으로는 "꺼짐" 이 증명되지 않는다.
- **시작 검증을 `afterPropertiesSet` 으로 돌린 것과 그 이유.**
- **프로파일을 두 출처에서 모으는 `Supplier` 를 쓴 것과 그 근거.**
- **모든 설정 거부가 타입이 아니라 키를 부르는 것.**
- **바인딩되지 않는 키를 record 성분에서 파생해 거부한 것** — 목록을 손으로 적으면 쓰는 날에만 맞는다.
- **환경변수를 키 검증에서 제외하고 그 이유를 적은 것** — 밑줄 경계를 되돌릴 방법이 없고, 추측은 정상 배포를 거부한다.
- **설정 참조 문서를 실행 가능한 진술로 만든 것.**
- **가짜 발행자가 조립 불가를 가린 사례를 테스트 주석에 남기고 가드 테스트를 붙인 것.**
- **저장소 기본 구현을 제공하지 않기로 한 판단과 그 근거.**
- **정리 작업은 스케줄러를 등록하지 않고 중계는 구동하는 비대칭과 각각의 이유.**
- **두 수명 주기의 phase 로 종료 순서를 표현한 것과 서로를 근거로 든 javadoc.**
- **배수 예산을 임차 기간으로 둔 것** — 그보다 오래 기다려도 증명되는 것이 없다.
- **`MessageContracts` 를 맨 `Map` 빈이 아니라 홀더로 만든 것** — 스프링에서 `Map` 은 중립적인 주입 타입이 아니다.
- **메시지 계약 기본값을 빈 것으로 두어 fail-closed 로 만든 것.**
- **actuator 끝점을 읽기 전용으로 두고 그것을 리플렉션으로 붙든 것.**
- **JAAS 값 이스케이프와 제어문자 거부**(`KafkaSecurityConfigurer`) — 지금은 아무도 부르지 않지만 코드 자체는 옳다.
---
## Source anchors
```
src/messaging/messaging-spring-boot-starter/build.gradle:1-66
main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:1
main/java/…/autoconfigure/MessagingCoreAutoConfiguration.java:1-480
main/java/…/autoconfigure/MessagingConfigurationCompiler.java:1-331
main/java/…/autoconfigure/MessagingSettings.java:1-301
main/java/…/autoconfigure/DefaultBatchMessagePublisher.java:1-254
main/java/…/autoconfigure/MessagingConfigurationKeyValidator.java:1-227
main/java/…/autoconfigure/MessagingReliabilityAutoConfiguration.java:1-185
main/java/…/autoconfigure/DestinationSettings.java:1-177
main/java/…/autoconfigure/KafkaMessagingAutoConfiguration.java:1-170
main/java/…/autoconfigure/MessagingProviderSelection.java:1-150
main/java/…/autoconfigure/MessagingShutdownLifecycle.java:1-124
main/java/…/autoconfigure/RabbitMessagingAutoConfiguration.java:1-98
main/java/…/autoconfigure/MessagingCredentialRequirementValidator.java:1-92
main/java/…/autoconfigure/MessagingAdminAutoConfiguration.java:1-86
main/java/…/autoconfigure/MessagingPrefixMigrationValidator.java:1-83
main/java/…/autoconfigure/MessagingEndpoint.java:1-78
main/java/…/autoconfigure/PublishResults.java:1-71
main/java/…/autoconfigure/BrokerSettings.java:1-69
main/java/…/autoconfigure/MessagingOutboxRelayLifecycle.java:1-69
main/java/…/autoconfigure/MessagingAdminDurabilityValidator.java:1-68
main/java/…/autoconfigure/DefaultBlockingMessagePublisher.java:1-65
main/java/…/autoconfigure/ValidatedDestinationRegistry.java:1-59
main/java/…/autoconfigure/CompiledMessagingConfiguration.java:1-53
main/java/…/autoconfigure/BrokerSecuritySettings.java:1-50
main/java/…/autoconfigure/StartupProfileValidation.java:1-46
main/java/…/autoconfigure/DefaultReactiveMessagePublisher.java:1-39
main/java/…/autoconfigure/MessagingPlatformRootAutoConfiguration.java:1-36
main/java/…/autoconfigure/MessageContracts.java:1-35
main/java/…/autoconfigure/ReactiveMessagePublisher.java:1-32
test/java/…/autoconfigure/{MessagingAutoConfigurationTest:546, MessagingConfigurationBindingTest:320,
BatchPublisherTest:316, MessagingStarterOffContractTest:254, MessagingLiveRoundTripQualificationTest:217,
MessagingOutboxRelayLifecycleTest:203, BlockingFacadeTest:148, ReactiveFacadeTest:134,
MessagingEndpointTest:133, MessagingShutdownLifecycleTest:78}
messaging-kafka/…/KafkaSecurityConfigurer.java:1-173 (§17.1 — 부르는 곳 없음)
messaging-kafka/…/KafkaProfileValidator.java:47-56 (§17.1 — 운영 TLS·인증 요구)
messaging-kafka/…/KafkaMessagingTransport.java:1-215 (§17.1 — 생산자를 감싸기만 한다)
messaging-policy/…/RetryPolicy.java:28-37 (§17.5)
app-bootstrap/src/main/resources/application*.yml (§12.1 — app.messaging.enabled 부재)
messaging-outbox-jdbc-postgresql/…/JdbcOutboxRepository.java (§17.3 — 공급자 부재)
```
@@ -0,0 +1,635 @@
# messaging-spring-cloud-stream-bridge 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-spring-cloud-stream-bridge`
> SSOT owner: `messaging-spring-cloud-stream-bridge`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-spring-cloud-stream-bridge`
- canonical state `analysisFile`: `analysis/messaging/messaging-spring-cloud-stream-bridge.md`
- source path: `src/messaging/messaging-spring-cloud-stream-bridge`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-policy", "messaging-transport-spi"]`
- registry `runtime_memberships`: **`[]`** — build-only
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 6 |
| production LOC | 507 |
| 패키지 | 1 (`dev.caskeleton.messaging.streambridge`) |
| test 파일 | 2 |
| test 메서드(실행 확인) | **20** |
| 선언된 의존 | project 3 + vendor 1 |
| **실제 import되는 의존** | **project 2** (§12.4) |
여섯 타입:
| 타입 | 종류 | 역할 | leaf 밖 참조 |
|---|---|---|---:|
| `MessagingBindingBridge` | interface | 논리 목적지 ↔ Stream 바인딩 | 0 |
| `SpringCloudStreamPublisherBridge` | class | 발행 측 + 위 인터페이스 구현 | 0 |
| `SpringCloudStreamConsumerBridge` | class | 수신 측 | 0 |
| `StreamBridgePolicyGuard` | class | 목적지가 브리지 대상인가 | 0 |
| `BindingProfileValidator` | class | 바인딩 구성이 일관적인가 | 0 |
| `BindingCapabilityReport` | record | 무엇을 보장하지 **않는가** | 0 |
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |
| `src/test/java/**` (2) | 2 | `FULL_READ` | 20개 테스트명·단언 확인 |
| `build.gradle` | 1 | `FULL_READ` | 9줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
Spring Cloud Stream 바인딩을 이미 쓰는 서비스가 같은 논리 목적지에 닿게 하는 **상호운용 seam**이다.
```java
// MessagingBindingBridge.java:8-15
* <p>The bridge is an interoperability seam, not a second messaging API. Its whole reason to exist
* is that a service already has Stream bindings and needs to reach the same destinations without a
* rewrite.
*
* <p>Binder semantics are never promoted to platform guarantees. Stream's binder has its own retry,
* its own dead-letter, and its own acknowledgement mode, and they look enough like the platform's
* to be mistaken for them so a destination that actually relies on the platform's versions is
* refused by {@link StreamBridgePolicyGuard} rather than served with the binder's.
```
**"look enough like the platform's to be mistaken for them"**이 이 leaf 전체의 위협 모델이다. 브리지는 기능을 추가하지 않고 **차이를 드러낸다.**
세 층으로 그것을 한다.
| 층 | 무엇을 |
|---|---|
| `StreamBridgePolicyGuard` | 플랫폼 보장에 의존하는 목적지를 아예 거절 |
| `BindingProfileValidator` | 바인더 확장 속성이 프로파일 결정을 덮는 것을 거절 |
| `BindingCapabilityReport` | 남은 차이를 **문장으로** 기록 |
세 번째가 특이하다 — 거절할 수 없는 차이를 문서화 가능한 값으로 만든다.
---
## 2. 의존성과 런타임 배선
**선언된 것과 쓰이는 것이 다르다.**
| 선언 | scope | 실제 import |
|---|---|---|
| `messaging-core-api` | api | **o**`DestinationName`, `MessagingConfigurationException`, publish 6타입 |
| `messaging-policy` | api | **o**`DestinationProfile`, `RetryMode` |
| `messaging-transport-spi` | api | **x** |
| `org.springframework:spring-context` | implementation | **x** |
`grep -rn 'import dev.caskeleton.messaging.transport\|import org.springframework'` → exit 1.
**Spring Cloud Stream 브리지가 Spring을 import하지 않는다.** 바인더 접촉면 전체가 두 함수형 인터페이스로 추상화돼 있다 — `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`와 같은 상태다.
---
## 3. 패키지/컴포넌트 지도
```
게이트 (2단)
StreamBridgePolicyGuard.validate(profile, enabled)
├── !enabled → STREAM_BRIDGE_DISABLED
├── isOrdered() → STREAM_BRIDGE_ORDERING_UNSUPPORTED
├── retry != NONE → STREAM_BRIDGE_RETRY_UNSUPPORTED
└── deadLetter on → STREAM_BRIDGE_DLQ_UNSUPPORTED
↓ (통과 후)
BindingProfileValidator.validate(profile, bindingName, extendedProperties, enabled)
├── guard.validate(...) ← 위임
├── 바인딩 이름 패턴 → INVALID_BINDING_NAME
├── 충돌 확장 속성 8개 → BINDING_OVERRIDES_PLATFORM_POLICY
├── profile.production() → BRIDGE_ON_PRODUCTION_DESTINATION
└── → BindingCapabilityReport.bridged(...) ← 네 보장 전부 false
발행
SpringCloudStreamPublisherBridge(ChannelSend) implements MessagingBindingBridge
├── bindPublisher / bindConsumer ← 두 맵
└── publish(dest, payload, headers)
├── 바인딩 없음 → NO_OUTPUT_BINDING
├── send == true → AMBIGUOUS (STREAM_BRIDGE_NO_BROKER_EVIDENCE)
└── send == false → REJECTED (STREAM_BRIDGE_SEND_REFUSED)
수신
SpringCloudStreamConsumerBridge ← MessagingBindingBridge를 구현하지 않음
├── register(dest, binding, BridgedHandler)
└── dispatch(binding, payload, headers)
├── 미등록 → NO_BRIDGED_HANDLER
└── handler.handle(...) ← 예외를 잡지 않음
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `StreamBridgePolicyGuard` — 의존하는 순간 거절
```java
// :10-17
* <p>The bridge exists for interoperability with existing Spring Cloud Stream bindings, and its
* risk is specific: Stream owns its own binder configuration, so a binding can quietly acquire its
* own serializer, its own error handling, and its own acknowledgement mode none of which the
* destination profile knows about.
*
* <p>So the bridge is only permitted where the platform's guarantees are not the thing being relied
* on: a destination that declares an ordering scope, a retry policy, or a dead letter destination
* must go through the native adapter, where those are actually enforced.
```
**세 거절이 `DestinationProfile`의 세 필드를 직접 본다.**
| 조건 | 코드 |
|---|---|
| `profile.isOrdered()``orderingScope != NONE` | `STREAM_BRIDGE_ORDERING_UNSUPPORTED` |
| `profile.retry().mode() != RetryMode.NONE` | `STREAM_BRIDGE_RETRY_UNSUPPORTED` |
| `profile.deadLetter().enabled()` | `STREAM_BRIDGE_DLQ_UNSUPPORTED` |
**`messaging-policy`가 정의한 세 보장 각각에 대해 "이것을 선언했으면 브리지를 쓸 수 없다"**를 강제한다. 세 코드 전부 `MessagingConfigurationException`이고 안정 코드를 갖는다 — `messaging-kafka-share-experimental`이 두 거절에 다른 예외 타입을 쓴 것(그쪽 §17)과 대비된다.
`!enabled`도 같은 예외 타입이다 — 일관적이다.
### 4.2 `BindingProfileValidator` — 확장 속성을 병합하지 않는다
```java
// :16-20
* <p>The binder's extended properties are the sharp edge. Stream lets a binding override the
* serializer, the acknowledgement mode, and the concurrency, and each of those silently replaces
* something the destination profile already decided. Rather than merging the two which produces a
* configuration nobody can read a conflicting extended property is rejected and the operator is
* told which side to remove.
```
거절 목록 8개:
| 속성 | 무엇을 덮는가 |
|---|---|
| `autoBindDlq`, `republishToDlq` | DLQ 정책 |
| `maxAttempts`, `backOffInitialInterval` | 재시도 정책 |
| `autoCommitOffset`, `ackMode` | 정산 |
| `useNativeEncoding`, `contentType` | codec |
에러 메시지가 **두 선택지를 명시한다** — "remove it from the binding or move the destination to the native adapter". 무엇을 하라고만 하지 않고 어느 쪽을 포기할지를 준다.
**production 목적지는 무조건 거절한다.**
```java
if (profile.production()) {
throw new MessagingConfigurationException(
"BRIDGE_ON_PRODUCTION_DESTINATION",
"destination %s is marked production; the bridge does not carry the platform's publish "
+ "evidence, retry, or confirmed dead lettering");
}
```
guard의 세 조건을 통과한 목적지(순서 없음·재시도 없음·DLQ 없음)라도 production이면 막는다. **네 번째 게이트**다.
바인딩 이름 패턴 `[a-zA-Z][a-zA-Z0-9-]{0,63}` — 언더스코어와 점을 배제한다.
### 4.3 `BindingCapabilityReport` — 부재를 값으로
```java
// :8-14
* <p>An explicit report rather than silence. The binder does provide retry and dead-lettering of
* its own, so a binding looks like it has them; what it does not have is the platform's versions
* bounded attempts under the destination's retry policy, and a dead-letter publish confirmed before
* the source is settled. An operator comparing a bridged binding to a native one needs that
* difference written down, because nothing at runtime will show it.
```
**"nothing at runtime will show it"**이 이 record가 존재하는 이유다.
네 boolean과 두 factory:
| factory | 네 값 |
|---|---|
| `bridged(destination, bindingName)` | 전부 `false` |
| `nativeAdapter(destination, bindingName)` (`BindingProfileValidator`의 static) | 전부 `true` |
`gaps()`가 각 `false`마다 **문장 하나**를 만든다.
| 결여 | 문장 |
|---|---|
| publish evidence | "the binder reports a send, not a broker confirmation, so an ambiguous publish is indistinguishable from a confirmed one" |
| retry | "the binder's own retry runs instead of the destination's retry policy, with its own attempt budget and backoff" |
| dead letter | "the binder settles the source without waiting for the dead-letter publish to confirm, so a dead-letter outage loses the message" |
| ordering | "the binder's concurrency settings decide ordering, not the profile" |
**각 문장이 결과까지 적는다** — "indistinguishable", "loses the message". 상태 플래그가 아니라 운영자가 읽는 진술이다.
`isFullyGuaranteed()``gaps().isEmpty()`다 — 매 호출마다 네 문장을 다시 만든다. 성능 문제는 아니지만 순수 조회가 문자열을 할당한다.
### 4.4 `SpringCloudStreamPublisherBridge` — 가장 정직한 결과
```java
// :20-27
* <p>The result is deliberately {@code AMBIGUOUS} rather than {@code CONFIRMED}. A Stream {@code
* send} returns a boolean from the message channel it says the binder accepted the message, not
* that a broker did. Reporting that as confirmed would put the platform's strongest word on the
* binder's weakest evidence, and a caller reading {@code CONFIRMED} would stop worrying about a
* message that may never have left the process.
*
* <p>A caller that needs real publish evidence has to use the native adapter. That is the honest
* trade the bridge exists to make visible.
```
`accepted == true`일 때의 결과:
```java
PublishCompletion.AMBIGUOUS,
new PublishEvidence(true, TransmissionEvidence.MAY_HAVE_BEEN_TRANSMITTED, false, ConfirmationLevel.NONE),
RoutingOutcome.UNKNOWN,
...
FailureDescriptor.of(FailureCategory.AMBIGUOUS, "STREAM_BRIDGE_NO_BROKER_EVIDENCE", ...)
```
**`messaging-core-api``PublishResult` 14개 금지 조합을 전부 통과하도록 정확히 구성돼 있다** — `AMBIGUOUS``confirmationLevel == NONE`, `brokerAccepted == false`, `transmission != NOT_TRANSMITTED`, `routingOutcome != ROUTED`, `failure.isPresent()`를 요구하고 다섯 다 만족한다.
`accepted == false``REJECTED` + `notTransmitted()` + `TRANSIENT_INFRASTRUCTURE` — 채널이 거부했으므로 아무것도 나가지 않았고, 일시적 문제일 수 있으므로 재시도 가능하다.
**두 결과가 core-api의 3상태를 정확히 쓴다.** 이 저장소에서 `AMBIGUOUS`를 의도적으로 생성하는 몇 안 되는 지점이다.
`Duration.ZERO`를 elapsed로 넣는다 — 측정하지 않는다. `PublishResult`가 음수만 거절하므로 통과한다.
### 4.5 `SpringCloudStreamConsumerBridge` — 정산하지 않는다
```java
// :12-18
* <p>Settlement stays with the binder. The bridge cannot acknowledge, retry, or dead-letter a
* message itself, because Stream's binder already owns the acknowledgement for that binding and two
* things settling one message is worse than either doing it alone.
*
* <p>What the bridge does own is the translation and the honesty about it: a handler failure is
* rethrown so the binder's error channel sees it, rather than being converted into a platform
* {@code HandleResult} that nothing downstream would act on.
```
`dispatch`가 핸들러 예외를 잡지 않는다.
```java
// Not caught. The binder's error channel is what retries and dead-letters this binding, and
// swallowing the failure here would acknowledge a message nothing handled.
handler.handle(destination, payload, headers);
```
**`HandleResult`를 만들지 않는 것이 결정이다.** javadoc이 "nothing downstream would act on"이라고 적는데, 이것은 `messaging-runtime-core``DefaultDeliveryProcessor`가 조립되지 않았다는 사실과 정합한다(`analysis/messaging/messaging-runtime-core.md` §12.1a) — 이 leaf가 그 사실을 알고 쓰였다.
`ConcurrentHashMap`(handlers, destinations)이 바인딩 이름을 키로 한다. **두 맵이 함께 갱신되지만 원자적이지 않다**`register``handlers.put``destinations.put`을 한다. 그 사이에 `dispatch`가 들어오면 handler는 있고 destination은 없어 `NO_BRIDGED_HANDLER`가 난다. 안전한 방향이다(잘못된 목적지로 전달하지 않는다). §17.
### 4.6 `MessagingBindingBridge` — 구현이 한쪽뿐
인터페이스가 `bindPublisher``bindConsumer` 둘을 선언한다. **`SpringCloudStreamPublisherBridge`가 둘 다 구현하고, `SpringCloudStreamConsumerBridge`는 이 인터페이스를 구현하지 않는다.**
결과: `bindConsumer`가 publisher 쪽 `inputBindings` 맵에 기록되고, 실제 수신 등록(`register`)은 consumer 쪽에서 따로 일어난다. 두 클래스가 같은 바인딩에 대해 각자 상태를 갖는다. §17.
---
## 5. 주요 실행 경로
**검증:** `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(...)` (예외 그대로 전파)
---
## 6. 실패 경로와 복구/번역
| 코드 | 예외 | 위치 |
|---|---|---|
| `STREAM_BRIDGE_DISABLED` | `MessagingConfigurationException` | guard |
| `STREAM_BRIDGE_ORDERING_UNSUPPORTED` | 같음 | guard |
| `STREAM_BRIDGE_RETRY_UNSUPPORTED` | 같음 | guard |
| `STREAM_BRIDGE_DLQ_UNSUPPORTED` | 같음 | guard |
| `INVALID_BINDING_NAME` | 같음 | validator |
| `BINDING_OVERRIDES_PLATFORM_POLICY` | 같음 | validator |
| `BRIDGE_ON_PRODUCTION_DESTINATION` | 같음 | validator |
| `NO_OUTPUT_BINDING` | 같음 | publisher bridge |
| `NO_BRIDGED_HANDLER` | 같음 | consumer bridge |
| `STREAM_BRIDGE_NO_BROKER_EVIDENCE` | (예외 아님) `PublishResult` `AMBIGUOUS` | publisher bridge |
| `STREAM_BRIDGE_SEND_REFUSED` | (예외 아님) `PublishResult` `REJECTED` | publisher bridge |
**아홉 개의 구성 실패가 전부 `MessagingConfigurationException` + 안정 코드다.** 이 저장소 messaging family에서 예외 어휘가 가장 일관된 leaf다 — `messaging-security`(두 계층 혼용)·`messaging-kafka-share-experimental`(두 계층 혼용)·`messaging-policy`(검증기가 `IllegalArgumentException`)와 대비된다.
발행 결과 둘은 예외가 아니라 값이다 — `messaging-core-api`의 설계를 그대로 따른다.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
| 지점 | 도구 |
|---|---|
| `SpringCloudStreamPublisherBridge.outputBindings`/`inputBindings` | `ConcurrentHashMap` |
| `SpringCloudStreamConsumerBridge.handlers`/`destinations` | `ConcurrentHashMap` |
각 맵은 스레드 안전하지만 **두 맵의 갱신이 원자적이지 않다**(§4.5). 정산이나 자원 해제가 없으므로 다른 동시성 지점은 없다.
`StreamBridgePolicyGuard`·`BindingProfileValidator`는 상태가 없다(`BindingProfileValidator`가 guard 인스턴스를 필드로 하나 갖지만 그것도 무상태).
수명주기 참여 없음 — `close()``stop()`이 없다. 등록된 핸들러를 해제하는 방법이 없다. §17.
---
## 8. 설정·기능 플래그·환경 차이
| 항목 | 값 |
|---|---|
| 프로퍼티 키(에러 메시지에만) | `backend.messaging.bridge.spring-cloud-stream` |
| 바인딩 이름 패턴 | `[a-zA-Z][a-zA-Z0-9-]{0,63}` |
| 충돌 확장 속성 | 8개 |
**그 프로퍼티를 읽는 코드가 저장소에 없다.** `enabled``validate(...)`의 인자다. `messaging-kafka-share-experimental``backend.messaging.experimental.kafka-share`와 같은 형태다(그쪽 §17).
상수 없음 — 두 패턴과 한 집합이 전부 private.
---
## 9. 퍼시스턴스/외부 시스템 세부
**없다.** Spring Cloud Stream 자체를 만지지 않는다 — 바인더 접촉면이 두 함수형 인터페이스(`ChannelSend`, `BridgedHandler`)로 추상화돼 있고 구현은 이 leaf 밖의 책임이다.
그래서 이 leaf는 **바인더 없이 전부 테스트 가능하다** — 20개 테스트가 실제로 그렇게 한다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-spring-cloud-stream-bridge:test`. **BUILD SUCCESSFUL, 20 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 무엇을 증명하는가 |
|---|---:|---|
| `BindingProfileValidatorTest` | 10 | 허용 목적지, 비활성 거절, 순서/DLQ/production 거절, 충돌 속성 거절, 무해한 속성 통과, 이름 거절, **브리지 리포트가 네 결여를 전부 보고**, native 리포트는 결여 없음 |
| `BridgePublishEvidenceTest` | 10 | accepted → `AMBIGUOUS`, transmission unknown, descriptor가 결여를 이름, refused → `REJECTED`, 미바인딩 목적지 거절, payload 도달, 양방향 조회, **핸들러 실패가 바인더 error channel로 재던져짐**, 미등록 바인딩 거절, 핸들러가 바인딩된 목적지를 받음 |
**여섯 타입 전부가 테스트에 등장한다.** 이 leaf는 messaging family에서 **타입 대비 테스트 커버리지가 가장 고른** 축이다 — `messaging-kafka-share-experimental`(4타입 중 1개만)·`messaging-claim-check`(publisher 미검증)·`messaging-security`(12 중 5개 미검증)와 대비된다.
`aHarmlessBinderPropertyIsAllowedThrough`가 특히 중요하다 — 거절 목록이 **과잉 차단하지 않는다**는 반대 방향 확인이다. `messaging-core-api`의 자격증명 세그먼트 매칭 테스트(`aNameThatMerelyContainsTheLettersIsAccepted`)와 같은 규율이다.
**증명하지 않는 것:** 실제 Spring Cloud Stream 바인더와의 통합. `ChannelSend`·`BridgedHandler`가 fake이므로 바인더가 실제로 이 계약대로 동작하는지는 이 레인 밖이다. 그리고 그 통합 코드 자체가 이 저장소에 없다(§12.1).
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | 세 project 의존 — **미사용 하나를 포함해 통과**(허용 목록은 상한) |
| `verifyRuntimeModuleMembership` | `[]` |
| vendor `api` 규칙 | Spring 타입이 public 시그니처에 없음 → `implementation`이 맞다. **다만 아예 쓰이지 않는다** |
| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |
| ArchUnit | 전용 규칙 없음 |
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/296-stream-bridge-unconsumed-and-unused-deps.txt`.
### 12.1 Public surface reachability
**여섯 타입 전부 leaf 밖 참조 0이다.**
`runtime_memberships: []`, starter 미포함, 조립 0건 — **삼중 정합**이다. incubating leaf가 이래야 하는 형태이고, `messaging-claim-check`·`messaging-cloudevents`가 어긋난 것과 대비된다.
**다만 이 leaf는 미완의 성격이 다르다.** 바인더 접촉면이 두 함수형 인터페이스로 추상화돼 있고 그 구현이 없다 — 즉 **Spring Cloud Stream과 실제로 연결하는 코드가 존재하지 않는다.** 이 leaf는 "브리지의 정책과 정직성"을 완성했고 "브리지 자체"는 없다.
그 사실이 `spring-context` 의존과 맞물린다(§12.4).
### 12.2 Conditional sibling comparison
Spring 주석 0개, bean 없음.
**`MessagingTransport` 구현 sibling과의 비교:**
| leaf | 브로커 접촉 | membership |
|---|---|---|
| `messaging-kafka`·`messaging-rabbit` | `MessagingTransport` 구현 | `["app-bootstrap"]` |
| `messaging-pulsar-experimental`·`messaging-nats-experimental` | `MessagingTransport` 구현 | `[]` |
| `messaging-kafka-share-experimental` | 부분 구현(`TransportConsumerRegistration`) | `[]` |
| **이 leaf** | **구현 없음 — 자체 인터페이스** | `[]` |
이 leaf는 `MessagingTransport`를 구현하지 **않는** 것이 의도다. 브리지는 transport가 아니라 **다른 프레임워크로의 seam**이고, 그래서 `MessagingBindingBridge`라는 자기 인터페이스를 갖는다. `messaging-transport-spi` 의존이 선언만 되고 쓰이지 않는 것이 그 판단과 정합한다 — 처음에 transport로 만들려다 방향을 바꾼 흔적으로 보인다(**추론**).
### 12.3 Duplicate mechanism sweep
**(a) 활성화 플래그 패턴이 세 leaf에 있다**
| leaf | 키 | 전달 방식 |
|---|---|---|
| 이 leaf | `backend.messaging.bridge.spring-cloud-stream` | `validate(..., boolean enabled)` |
| `messaging-kafka-share-experimental` | `backend.messaging.experimental.kafka-share` | `KafkaShareProfile.enabled` 필드 |
| (pulsar·nats) | — | 각 leaf SSOT가 답함 |
두 키 모두 **에러 메시지에만 존재**하고 읽는 코드가 없다. 같은 형태의 미완이다.
**(b) capability 보고가 두 형태**
| 위치 | 형태 |
|---|---|
| `messaging-core-api` `MessagingCapabilities` | boolean 12개, 브로커가 **할 수 있는 것** |
| 이 leaf `BindingCapabilityReport` | boolean 4개 + 문장, 브리지가 **하지 않는 것** |
**방향이 반대다.** 전자는 능력 선언이고 후자는 결여 진술이다. 그리고 후자만 사람이 읽는 문장을 만든다. 중복이 아니라 서로 다른 질문에 답한다 — 다만 `BindingCapabilityReport`의 네 boolean이 `MessagingCapabilities`의 어느 필드와도 대응하지 않아, 두 모델을 잇는 코드가 생기면 매핑을 새로 정해야 한다.
**(c) 순서·재시도·DLQ 거절이 여러 곳에**
| 위치 | 무엇을 거절 |
|---|---|
| `messaging-policy` `DestinationProfileValidator` | 프로파일 **내부** 모순(순서 + 재정렬 재시도 등) |
| `messaging-kafka-share-experimental` `KafkaShareProfileValidator` | 순서 목적지를 share group에 |
| 이 leaf `StreamBridgePolicyGuard` | 순서·재시도·DLQ를 **선언한** 목적지를 브리지에 |
셋이 다른 질문에 답한다 — 내부 일관성 / 어댑터 능력 / seam 적격성. 중복 아니다. 다만 셋 다 `DestinationProfile`의 같은 필드를 읽고 **서로를 참조하지 않는다.**
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| build.gradle: `messaging-transport-spi` 의존 | import 0건 | **미사용 의존** |
| build.gradle: `spring-context` 의존 | `org.springframework` import 0건 | **미사용 의존** |
| `MessagingBindingBridge` javadoc: "an interoperability seam" | 바인더 연결 코드 없음 | **미실현** |
| `StreamBridgePolicyGuard` 에러 메시지: `backend.messaging.bridge.spring-cloud-stream=true` | 그 키를 읽는 코드 0건 | **미실현** |
| `BindingCapabilityReport` javadoc: 운영자가 native와 비교할 수 있어야 함 | `nativeAdapter(...)` 호출자가 테스트뿐 | **부분 미실현** |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 실제로 `[]` | **이 leaf에 한해 참** |
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
이 leaf의 javadoc에 **이전 결함 서술이 없다.** 대신 막으려는 것을 다섯 적는다.
| 위치 | 막으려는 것 |
|---|---|
| `MessagingBindingBridge` | 바인더 의미론이 플랫폼 보장으로 승격되는 것 |
| `StreamBridgePolicyGuard` | 바인딩이 자기 serializer·error handling·ack mode를 조용히 획득하는 것 |
| `BindingProfileValidator` | 확장 속성과 프로파일을 병합해 "아무도 읽을 수 없는 구성"을 만드는 것 |
| `BindingCapabilityReport` | 차이를 침묵으로 두는 것 — "nothing at runtime will show it" |
| `SpringCloudStreamPublisherBridge` | 바인더의 가장 약한 증거에 플랫폼의 가장 강한 단어를 붙이는 것 |
| `SpringCloudStreamConsumerBridge` | 두 주체가 한 메시지를 정산하는 것 |
**여섯 파일 중 여섯이 "하지 않는 것"을 서술한다.** 이 leaf는 기능이 아니라 **경계**로 구성돼 있다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-296 | command | `evidence/raw/296-stream-bridge-unconsumed-and-unused-deps.txt` | 여섯 타입 참조 0, membership `[]`, 선언 의존 4개와 실제 import 목록, transport-spi·spring-context import 0(exit=1), 인터페이스 구현이 publisher뿐, `nativeAdapter` 호출자가 테스트뿐 | 정적 검색 |
| EVD-297 | command | `./gradlew :messaging:messaging-spring-cloud-stream-bridge:test --rerun-tasks` | BUILD SUCCESSFUL, 20 / 0 / 0 | 바인더 없이 fake로 검증 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 브리지가 두 번째 messaging API가 아닌 이유 — `MessagingBindingBridge` javadoc
- 바인더 의미론을 승격하지 않는 이유 — 같은 javadoc
- 플랫폼 보장에 의존하는 목적지를 거절하는 이유 — `StreamBridgePolicyGuard` javadoc
- 확장 속성을 병합하지 않고 거절하는 이유 — `BindingProfileValidator` javadoc
- 결여를 명시적 리포트로 만드는 이유 — `BindingCapabilityReport` javadoc
- `AMBIGUOUS`가 유일하게 정직한 답인 이유 — `SpringCloudStreamPublisherBridge` javadoc
- 정산이 바인더에 남는 이유, 예외를 재던지는 이유 — `SpringCloudStreamConsumerBridge` javadoc
- `ChannelSend`를 분리한 이유("testable without a binder") — 그 인터페이스 javadoc
**추론**
- `messaging-transport-spi` 의존이 선언만 된 것은 처음에 transport로 만들려다 방향을 바꿨기 때문이다 → **추론**. 의존 선언과 미사용은 관측이고 인과는 추론이다.
- `spring-context` 의존이 선언만 된 것은 바인더 통합 코드를 상정했기 때문이다 → **추론**.
- `SpringCloudStreamConsumerBridge``MessagingBindingBridge`를 구현하지 않는 것이 의도인지 → **미상**.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 6개 타입 507줄 전문
- 20개 테스트가 통과하고 **여섯 타입 전부를 덮는다**는 것
- 여섯 타입 전부 참조 0이고 membership `[]`과 정합한다는 것
- `messaging-transport-spi``spring-context`가 선언되고 import 0건이라는 것
- 바인더 접촉면이 두 함수형 인터페이스로 추상화돼 있고 그 구현이 저장소에 없다는 것
- 아홉 구성 실패가 전부 같은 예외 타입과 안정 코드를 쓴다는 것
-`PublishResult`가 core-api의 14개 금지 조합을 정확히 만족한다는 것
**확인하지 못한 것**
- 실제 Spring Cloud Stream 바인더가 `ChannelSend`의 boolean 계약대로 동작하는지 — 바인더가 저장소에 없다.
- `backend.messaging.bridge.spring-cloud-stream` 키가 어딘가 문서화돼 있는지.
- `SpringCloudStreamConsumerBridge`에 해제 경로가 필요한지 — 바인더 수명주기를 모른다.
- 이 leaf를 완성할 계획이 있는지.
---
## 17. 손볼 것
### P3 — 선언된 의존 둘이 사용되지 않는다
- **사실.** registry가 `messaging-transport-spi`를 허용하고 `build.gradle``spring-context`를 선언한다. main 소스의 비-JDK import 9개는 전부 `messaging-core-api``messaging-policy`에서 온다. `import dev.caskeleton.messaging.transport` · `import org.springframework` 검색이 exit 1이다.
- **근거.** `evidence/raw/296` §B.
- **왜 문제인가.** `verifyCleanArchitectureDependencies`가 허용 목록을 **상한**으로 검사하므로 잡히지 않는다. 그리고 `spring-context` 선언이 "이 leaf가 Spring과 통합돼 있다"는 인상을 주는데 실제로는 Spring 타입을 한 번도 이름 부르지 않는다 — 바인더 접촉면 전체가 자체 함수형 인터페이스다.
- **확인 방법.** `evidence/raw/296` §B 재실행.
- **후보.** 두 의존을 제거하거나, 완성 시 필요함을 build.gradle 주석에 적는다.
- **다음 단계.** `messaging-kafka-share-experimental` §17의 같은 항목과 **동일 형태**다. 두 incubating leaf가 같은 방식으로 미사용 의존을 선언한다 → **REFERENCE 후보**(허용 의존 목록은 상한이므로 미사용을 잡지 않는다).
### P3 — 브리지의 바인더 쪽 절반이 없다
- **사실.** `ChannelSend`·`BridgedHandler` 두 함수형 인터페이스가 바인더 접촉면이고, 그 구현이 저장소에 없다. `MessagingBindingBridge` javadoc은 "a service already has Stream bindings and needs to reach the same destinations without a rewrite"를 존재 이유로 든다.
- **근거.** `evidence/raw/296` §A·§B.
- **왜 문제인가.** 정책·검증·정직성 세 층이 완성돼 있고 그것들을 실제 바인딩에 연결하는 코드가 없다. `runtime_memberships: []`와 정합하므로 오늘의 결함은 아니지만, 이 leaf의 이름이 약속하는 것("spring-cloud-stream-bridge")이 절반만 존재한다.
- **확인 방법.** `git grep -n 'ChannelSend\|BridgedHandler' -- src` → 이 leaf와 그 테스트만.
- **후보.** 바인더 어댑터를 만들거나, 두 인터페이스가 파생 프로젝트의 구현점임을 javadoc에 명시한다.
- **다음 단계.** **OPEN QUESTION 후보.** `messaging-kafka-share-experimental` §17 첫 항목과 같은 질문("완성할 것인가")이다.
### P3 — 인터페이스를 publisher만 구현하고 두 클래스가 같은 바인딩에 각자 상태를 갖는다
- **사실.** `MessagingBindingBridge``bindPublisher`·`bindConsumer` 둘을 선언한다. `SpringCloudStreamPublisherBridge`가 둘 다 구현하고 `inputBindings` 맵에 기록한다. `SpringCloudStreamConsumerBridge`는 이 인터페이스를 구현하지 않고 자기 `handlers`·`destinations` 맵에 기록한다.
- **근거.** `evidence/raw/296` §C.
- **왜 문제인가.** 한 바인딩에 대해 두 객체가 각자 등록을 갖고 서로를 모른다. `bindConsumer`를 부르고 `register`를 부르지 않으면 publisher 쪽은 바인딩이 있다고 보고하고 실제 전달은 `NO_BRIDGED_HANDLER`로 실패한다. `consumerBinding(dest)`가 그 불일치를 드러내지 않는다.
- **확인 방법.** 두 클래스의 필드와 인터페이스 구현 확인.
- **후보.** consumer bridge가 `MessagingBindingBridge`를 구현하고 publisher가 `bindConsumer`를 위임하거나, 인터페이스를 발행·수신으로 나눈다.
- **다음 단계.** **REFERENCE 후보**(한 개념의 등록 상태를 두 객체가 나눠 갖지 않는다).
### P3 — 두 맵 갱신이 원자적이지 않다
- **사실.** `SpringCloudStreamConsumerBridge.register``handlers.put(...)``destinations.put(...)`을 한다. 같은 형태가 publisher의 두 맵에도 있다(다만 각각 독립 키).
- **근거.** `SpringCloudStreamConsumerBridge.java:38-39`.
- **왜 문제인가.** 그 사이에 `dispatch`가 들어오면 `destination == null`이 되어 `NO_BRIDGED_HANDLER`가 난다. **안전한 방향**이다 — 잘못된 목적지로 전달하지 않는다. 다만 에러 코드가 "핸들러가 없다"인데 실제로는 핸들러가 있고 목적지가 아직 없다.
- **확인 방법.** 두 `put` 사이의 창.
- **후보.** 한 record로 묶어 한 번에 put한다.
- **다음 단계.** **REFERENCE 후보**(함께 읽히는 두 맵은 한 값으로 묶는다).
### P3 — 등록 해제 경로가 없다
- **사실.** `SpringCloudStreamConsumerBridge``unregister``close`가 없다. `SpringCloudStreamPublisherBridge`도 마찬가지다.
- **근거.** 두 클래스의 public 메서드 전수.
- **왜 문제인가.** 바인딩이 재구성되거나 컨텍스트가 종료될 때 맵이 비워지지 않는다. 오늘은 조립되지 않아 무해하다. `messaging-transport-spi``TransportConsumerRegistration``AutoCloseable`인 것과 대비된다.
- **확인 방법.** public 메서드 목록.
- **후보.** `unregister(bindingName)` 또는 `AutoCloseable` 구현.
- **다음 단계.** **REFERENCE 후보**(등록을 받는 컴포넌트는 해제도 제공한다).
### P3 — 활성화 프로퍼티 키가 에러 메시지에만 존재한다
- **사실.** `backend.messaging.bridge.spring-cloud-stream=true``STREAM_BRIDGE_DISABLED` 메시지에 적혀 있다. 그 키를 읽는 코드가 없다.
- **근거.** `git grep -n 'spring-cloud-stream=true' -- src` → 이 leaf의 문자열 하나.
- **왜 문제인가.** `messaging-kafka-share-experimental`·`messaging-claim-check`와 같은 형태다 — 메시지가 지시하는 설정에 대응 코드가 없다.
- **다음 단계.** 그 두 leaf의 같은 항목과 함께 **REFERENCE 후보**(에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다).
### 확인된 설계(문제 아님)
- 플랫폼 보장에 의존하는 목적지를 브리지에서 아예 거절하는 4단 게이트
- 확장 속성을 병합하지 않고 거절하며 어느 쪽을 지울지 알려 주는 것
- 무해한 확장 속성은 통과시키고 그것을 테스트로 고정한 것
- 결여를 boolean이 아니라 **결과가 적힌 문장**으로 만드는 것
- 바인더의 boolean send를 `AMBIGUOUS`로 보고하고 그 이유를 적은 것
-`PublishResult`가 core-api의 금지 조합을 정확히 만족하는 것
- 정산을 바인더에 남기고 핸들러 예외를 재던지는 것
- 바인더 접촉면을 함수형 인터페이스로 분리해 바인더 없이 전부 테스트 가능하게 한 것
- 아홉 구성 실패가 한 예외 타입과 안정 코드를 쓰는 것
- 소비자 0 · membership `[]` · 조립 0의 삼중 정합
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MSB-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 3개, `runtime_memberships: []` | 선언 |
| MSB-002 | build | `messaging-spring-cloud-stream-bridge/build.gradle` | same | 네 의존 선언 | 둘은 미사용(§12.4) |
| MSB-003 | code | `.../streambridge/StreamBridgePolicyGuard.java` | same | §4.1 네 거절 | — |
| MSB-004 | code | `.../streambridge/BindingProfileValidator.java` | same | §4.2 8속성 거절, production 거절 | — |
| MSB-005 | code | `.../streambridge/BindingCapabilityReport.java` | same | §4.3 결여를 문장으로 | `nativeAdapter` 호출자 테스트뿐 |
| MSB-006 | code | `.../streambridge/SpringCloudStreamPublisherBridge.java` | same | §4.4 AMBIGUOUS 결정과 두 결과 | — |
| MSB-007 | code | `.../streambridge/SpringCloudStreamConsumerBridge.java` | same | §4.5 정산 미소유, 예외 재던짐 | 두 맵 비원자(§17) |
| MSB-008 | code | `.../streambridge/MessagingBindingBridge.java` | same | seam 선언과 위협 모델 | 구현이 publisher뿐 |
| MSB-009 | test | `BindingProfileValidatorTest` (10), `BridgePublishEvidenceTest` (10) | same | §10 표, 여섯 타입 전부 | 실제 바인더 없음 |
| MSB-010 | cross-leaf code | `messaging-core-api/.../PublishResult.java:39-101` | same | 두 결과가 만족하는 금지 조합 | 해당 leaf SSOT가 소유 |
| MSB-011 | cross-leaf code | `messaging-policy/.../DestinationProfile.java`, `RetryMode.java` | same | 게이트가 읽는 세 필드 | 해당 leaf SSOT가 소유 |
| EVD-296 | command | `evidence/raw/296-stream-bridge-unconsumed-and-unused-deps.txt` | same | §12.1·§12.4 | 정적 검색 |
| EVD-297 | command | `./gradlew :messaging:messaging-spring-cloud-stream-bridge:test --rerun-tasks` | same | 20 / 0 / 0 | fake 바인더 |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,716 @@
# messaging-transport-spi 완전 해부
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/messaging/messaging-transport-spi`
> SSOT owner: `messaging-transport-spi`
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id: `messaging-transport-spi`
- canonical state `analysisFile`: `analysis/messaging/messaging-transport-spi.md`
- source path: `src/messaging/messaging-transport-spi`
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api", "messaging-policy"]`
- registry `runtime_memberships`: `["app-bootstrap"]`
### 숫자
| 항목 | 수 |
|---|---:|
| production Java 파일 | 13 |
| production LOC | 776 |
| 패키지 | 1 (`dev.caskeleton.messaging.transport`) |
| test 파일 | 4 |
| test 메서드(실행 확인) | 24 |
| 외부(비프로젝트) 의존성 | **0** |
13개 타입:
| 타입 | 종류 | 역할 |
|---|---|---|
| `MessagingTransport` | interface | **브로커 어댑터가 구현하는 SPI** |
| `TransportPublishRequest` | record | 이미 인코딩된 발행 요청 |
| `TransportPublishResult` | record | `PublishResult` 래퍼 |
| `TransportConsumerSpec` | record | 프로파일 + 콜백 |
| `TransportConsumerRegistration` | interface | 살아 있는 구독 |
| `TransportDelivery` | record | 아직 인코딩된 수신 |
| `TransportSettlement` | interface | 어댑터 측 정산 핸들 |
| `MessagingRuntime` | interface | 한 세대의 연결·자격증명·토폴로지 |
| `MessagingRuntimeLease` | interface | 세대 참조 대여 |
| `MessagingRuntimeRegistry` | interface | 브로커별 현재 세대 |
| `DefaultMessagingRuntimeRegistry` | class | 참조 계수 + 원자 교체 구현 |
| `GracefulShutdownCoordinator` | class | 드레인 조정자 |
| `MessagingLifecycle` | interface | **8단계 종료 순서 계약 — 구현체 없음(§12.1)** |
### Coverage ledger
| scope/file group | count | disposition | reason |
|---|---:|---|---|
| `src/main/java/**` (13) | 13 | `FULL_READ` | 전 파일 본문 확인 |
| `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 확인 |
| `build.gradle` | 1 | `FULL_READ` | 7줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
브로커 어댑터가 구현할 **SPI**와, 그 어댑터들의 **수명주기·세대 관리**를 소유한다. 벤더 의존성이 0이다.
가장 중요한 경계 규칙이 `MessagingTransport`의 javadoc에 있다.
```java
// MessagingTransport.java:10-12
* <p>No method returns a native client object. Handing back a raw producer or channel would let an
* application bypass destination policy, payload limits, and the settlement ordering in one call,
* and the resulting code would silently stop working the moment the broker changed.
```
13개 타입 중 어느 것도 브로커 네이티브 타입을 시그니처에 노출하지 않는다. `BrokerPosition`(core-api)이 `Map<String,String> diagnosticAttributes()`로 좌표를 문자열로만 내보내는 것과 같은 규율이다.
두 번째 경계는 **인코딩 위치**다.
```java
// TransportDelivery.java:11-13
* <p>Decoding happens above the transport so that a payload the consumer cannot parse is classified
* as a schema failure by the platform, and parked, rather than being turned into an
* adapter-specific exception each broker reports differently.
```
`TransportPublishRequest`도 대칭이다 — "The payload arrives already encoded and the profile arrives already validated, so an adapter never chooses a codec or a limit for itself. That is what keeps two adapters from disagreeing about what 'the same message' means."
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `messaging-policy`(api). 셋 다 `api`인 이유는 세 leaf의 타입이 이 leaf의 public 시그니처에 직접 등장하기 때문이다 — `TransportPublishRequest``DestinationProfile`(policy)·`MessageEnvelope`(core-api)·`EncodedMessage`(schema-api)를 필드로 갖는다.
나가는 것: `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을 만들지 않는다.
---
## 3. 패키지/컴포넌트 지도
세 축이 한 패키지에 있다.
```
[SPI] MessagingTransport
├── publish(TransportPublishRequest) → TransportPublishResult
├── register(TransportConsumerSpec) → TransportConsumerRegistration
├── capabilities(DestinationName) → DestinationCapabilities
└── brokerName / generation / close
↑ 구현: Kafka · Rabbit · Pulsar · NATS (4)
[세대] MessagingRuntime ── MessagingRuntimeLease ── MessagingRuntimeRegistry
DefaultMessagingRuntimeRegistry (구현)
[종료] GracefulShutdownCoordinator (사용됨: 11개 파일)
MessagingLifecycle.ShutdownPhase(8) (구현 없음, 소비자 0)
```
세 축이 **다른 정도로 살아 있다.** SPI는 4개 어댑터가 구현하고, 세대 관리는 구현이 하나 있고, 종료 계약은 절반만 실현됐다(§12.1).
---
## 4. 계약·불변식·상태 모델
### 4.1 세대 모델: 회전은 변경이 아니라 교체다
```java
// MessagingRuntime.java:5-8
* <p>Credential rotation and topology reload replace a whole generation rather than mutating a live
* one. In-flight publishes keep the generation they started on, which is what makes a rotation
* invisible to callers instead of a burst of authentication failures.
```
세 타입이 그 모델을 이룬다.
| 타입 | 불변식 |
|---|---|
| `MessagingRuntime` | 불변. `close()`**멱등이어야 한다**(javadoc이 명시) |
| `MessagingRuntimeLease` | 참조를 pin. `close()`**멱등이어야 한다** |
| `MessagingRuntimeRegistry` | 브로커당 현재 세대 하나 |
### 4.2 `DefaultMessagingRuntimeRegistry`: 참조 계수와 원자 교체
이 leaf의 유일한 실질 구현이고 동시성 설계가 조밀하다.
**설치(교체)**
```java
Generation retired = current.put(runtime.brokerName(), new Generation(runtime));
if (retired == null) return;
retired.retire(now);
if (!retired.closeIfIdle()) {
synchronized (draining) { draining.add(retired); }
}
```
`ConcurrentHashMap.put`이 원자적이므로 호출자는 옛 세대 또는 새 세대만 본다 — javadoc: "never a half-rebuilt connection pool".
**대여**
```java
Generation generation = current.computeIfPresent(brokerName, (key, value) -> {
value.leases.incrementAndGet();
return value;
});
```
`computeIfPresent`의 리맵 함수가 **버킷 잠금 안에서** 실행되므로, 조회와 증가가 원자적이다. `get` 후 증가였다면 그 사이에 `install`이 세대를 교체해 이미 은퇴한 세대의 계수를 올릴 수 있다.
**해제**
```java
void release() {
if (leases.decrementAndGet() == 0) { closeIfIdle(); }
}
boolean closeIfIdle() {
if (retired.get() && leases.get() == 0) { return forceClose(); }
return false;
}
boolean forceClose() {
if (closed.compareAndSet(false, true)) { runtime.close(); return true; }
return false;
}
```
`closed`가 CAS로 보호되므로 **정확히 한 번만** `runtime.close()`가 불린다. 테스트가 그것을 직접 단언한다(`aRetiredGenerationIsClosedExactlyOnce`, `as("a second close on a real connection pool throws from a shutdown hook")`).
`Lease.close()`도 자체 `AtomicBoolean released`로 멱등이다 — 두 층의 멱등성이다.
**세대별 은퇴 시각**
```java
// Generation.retiredAt javadoc:180-183
* <p>Each generation carries its own. The deadline check took one {@code retiredAt} from the
* caller and applied it to every draining generation, so a rotation during a drain either
* force-closed a generation that had just retired or gave an old one a fresh deadline
* depending on which timestamp the caller happened to pass.
```
이전 결함의 기록이다. 하나의 타임스탬프를 전체 목록에 적용하면 회전이 겹칠 때 판정이 호출자가 우연히 넘긴 값에 좌우된다.
**닫힌 세대의 목록 제거**
```java
// closeExpiredDraining:112-113
// Anything already closed leaves the list too: it is not draining, and leaving it there is
// what made drainingCount report work that had finished.
draining.removeIf(Generation::isClosed);
```
`drainingCount()`가 관측 지표이므로, 이미 닫힌 세대가 목록에 남으면 지표가 영원히 0으로 안 떨어진다.
**`close()`가 현재 세대까지 닫는다**
```java
// close() javadoc:131-134
* <p>Nothing closed the current generation. The registry only ever closed what a rotation had
* retired, so a process that shut down without rotating left its broker connections to the JVM's
* exit which drops unflushed producer batches and leaves consumer sessions to time out on the
* broker instead of leaving the group.
```
이것도 이전 결함이다. 회전 없이 종료하는 프로세스(=대부분의 프로세스)가 연결을 정리하지 않았다.
**동시성 미세 결함 하나.** `close()``draining``synchronized`로 비우지만 `current``List.copyOf(current.keySet())` 후 하나씩 `remove`한다. 그 사이에 `install`이 새 세대를 넣으면 그 세대는 닫히지 않는다. 종료 중 설치는 정상 시나리오가 아니므로 실질 위험은 낮다 — §17의 P3.
### 4.3 `GracefulShutdownCoordinator`: 세 단계와 그 이유
```java
// GracefulShutdownCoordinator.java:12-22
* <p>Shutdown has three phases, in order: stop accepting new work, let what is running finish, then
* close. Skipping the middle phase is what produces the classic shutdown bug a handler is
* interrupted between its side effect and its settlement, so the message is redelivered and the
* effect happens twice.
*
* <p>The deadline exists because draining cannot be unbounded: a stuck handler would otherwise hold
* the process open forever. Work still running at the deadline is abandoned <em>unsettled</em>, so
* the broker redelivers it rather than the platform pretending it completed.
*
* <p>No retry attempt is created once draining begins. Starting a fresh attempt during shutdown
* guarantees it will be abandoned at the deadline.
```
`tryBeginWork`가 **이중 검사**다.
```java
public boolean tryBeginWork() {
if (draining.get()) return false;
inFlight.incrementAndGet();
if (draining.get()) { inFlight.decrementAndGet(); return false; }
return true;
}
```
증가 후 다시 확인해서, 증가와 `beginDrain` 사이의 경합에서 계수를 되돌린다. 이 패턴이 없으면 드레인 시작 직후 시작된 작업이 계수에 남아 `isDrained`가 영원히 false가 된다.
`endWork`가 0에서 clamp한다.
```java
// :65-67
* <p>Clamped at zero. A double release used to drive the count negative, and a negative in-flight
* count reports the drain as complete while work is still running which is exactly when the
* process shuts down underneath it.
public void endWork() {
inFlight.updateAndGet(current -> current > 0 ? current - 1 : current);
}
```
`isDrained(now)`가 세 갈래다 — 드레인 전이면 false, 계수 0이면 true, 아니면 마감 경과 여부. `abandonedWorkAtDeadline`이 "마감으로 끝났는가"를 별도로 답해서, 완주한 드레인과 포기한 드레인을 구분할 수 있다.
### 4.4 `MessagingLifecycle`: 8단계 순서 계약
```java
// MessagingLifecycle.java:8-15
* <p>The order in {@link ShutdownPhase} is the contract, not an implementation detail. Closing
* connections before settlements have been transmitted loses the settlements, and pausing consumers
* after draining lets fresh deliveries arrive into a runtime that is already shutting down. Each
* adapter implements the phases; none of them chooses the order.
*
* <p>Implementations are driven by the Spring lifecycle rather than a JVM shutdown hook alone. A
* shutdown hook runs after the context has already begun disposing beans, so a handler mid-drain
* can find its datasource closed underneath it.
```
여덟 단계:
| # | 단계 | 뜻 |
|---:|---|---|
| 1 | `STOP_PUBLISH_ADMISSION` | 새 발행 거부 |
| 2 | `STOP_NEW_HANDLERS` | 새 핸들러 시작 거부 |
| 3 | `PAUSE_CONSUMERS` | 브로커에 전달 중단 요청 |
| 4 | `DRAIN_HANDLERS` | 실행 중 핸들러 완료 대기 |
| 5 | `FLUSH_SETTLEMENTS` | 그 핸들러들이 만든 정산 전송 |
| 6 | `AWAIT_PRODUCER_CONFIRMS` | 미확인 발행이 모호로 남지 않게 |
| 7 | `RELEASE_OUTBOX_LEASES` | 다른 relay가 즉시 claim 가능하게 |
| 8 | `CLOSE_CONNECTIONS` | 연결·채널 종료 |
`shutdown(Duration)`이 마감 시점에 실행 중이던 단계를 반환한다 — 완주하면 `CLOSE_CONNECTIONS`.
**이 인터페이스를 구현하는 것이 저장소에 없다.** §12.1.
### 4.5 `TransportConsumerRegistration`: 순서 단위별 pause
```java
// :8-10
* <p>Pause and resume operate on an ordering unit rather than the whole consumer, because that is
* what makes {@code PAUSE_PARTITION} retry possible: one stuck key must not stall every other
* partition on the same connection.
```
`scope`가 빈 문자열이면 전체다. `core-api``PauseResumeController``"*"`를 전체로 쓴다 — 두 인터페이스가 같은 개념에 **다른 sentinel**을 쓴다. `PauseResumeController`는 소비자가 0이므로(`analysis/messaging/messaging-core-api.md` §12.1) 오늘 충돌하지 않지만, 그것을 배선하려는 사람이 두 규약을 이어야 한다.
### 4.6 `TransportSettlement`: 애플리케이션에 노출되지 않는다
```java
// :10-11
* <p>Deliberately not exposed to application code. Handlers state an intent; the platform decides
* when and in what order the settlement happens, and this is the seam it uses to do that.
```
`acknowledge` / `requeue(delay)` / `discard` 셋이고, `core-api``SettlementController`(`ack`/`retry`/`deadLetter`/`reject`)와 **이름도 개수도 다르다.** 전자는 어댑터 측 원시 연산, 후자는 M2 수동 정산 API다. `deadLetter`가 전자에 없는 것이 핵심이다 — DLQ 발행은 플랫폼(`DefaultDeliveryProcessor`)이 하고 어댑터는 `acknowledge`만 받는다.
---
## 5. 주요 실행 경로
**발행:** 상위(`DefaultMessagePublisher`)가 `TransportPublishRequest`를 만들어 `MessagingTransport.publish` → 어댑터가 `TransportPublishResult(PublishResult)` 반환
**수신:** 상위가 `TransportConsumerSpec(profile, sink)``register` → 어댑터가 메시지마다 `sink.apply(TransportDelivery)` → 상위가 `TransportSettlement`으로 정산
**회전:**`MessagingRuntime` 생성 → `registry.install(runtime, now)` → 옛 세대 `retire` → lease가 0이면 즉시 close, 아니면 `draining`에 적재 → 스케줄러가 `closeExpiredDraining(now)` 호출
**종료:** (실제 경로) `MessagingShutdownLifecycle.stop()``admission.stopAcceptingNewWork()``drain.beginDrain(now)` → 50 ms 폴링으로 `isDrained` 대기 → 마감 도달 시 중단
---
## 6. 실패 경로와 복구/번역
이 leaf가 직접 던지는 예외는 **하나**다.
| 코드 | 예외 | 조건 |
|---|---|---|
| `RUNTIME_NOT_INSTALLED` | `MessagingConfigurationException` | `acquire(brokerName)`인데 그 브로커의 세대가 없음 |
나머지는 `IllegalArgumentException`(생성자 인자 검증)과 `NullPointerException`(`Objects.requireNonNull`)이다. 이 leaf가 다루는 실패의 대부분은 **예외가 아니라 상태**다 — 드레인 마감 초과는 `abandonedWorkAtDeadline(now)`가 true를 반환하는 것이고, 세대 강제 종료는 `closeExpiredDraining`의 반환 계수다.
**포기가 조용하지 않다는 것이 설계다.** 마감에 도달한 작업은 정산되지 않은 채 버려지고, 브로커가 재전달한다. `GracefulShutdownCoordinator` javadoc: "rather than the platform pretending it completed."
---
## 7. 트랜잭션·동시성·수명주기
이 leaf는 messaging family에서 **동시성 밀도가 가장 높다.**
| 지점 | 도구 | 보호하는 것 |
|---|---|---|
| `current` 맵 | `ConcurrentHashMap` | 세대 교체의 원자성 |
| lease 증가 | `computeIfPresent` 리맵 | 조회-증가 사이의 교체 |
| `leases` | `AtomicInteger` | 참조 계수 |
| `retired`, `closed` | `AtomicBoolean` + CAS | 정확히 한 번 close |
| `Lease.released` | `AtomicBoolean` + CAS | 이중 close 방지 |
| `retiredAt` | `volatile Instant` | 세대별 마감 가시성 |
| `draining` 리스트 | `synchronized` 블록 | `ArrayList` 보호 |
| `inFlight` | `AtomicInteger` + 이중 검사 + clamp | 드레인 계수 |
| `draining`(coordinator) | `AtomicBoolean` CAS | 드레인 시작 한 번 |
| `drainStartedAt` | `volatile Instant` | 마감 가시성 |
**주목할 비대칭:** `DefaultMessagingRuntimeRegistry``current`는 lock-free(`ConcurrentHashMap`)로, `draining``synchronized ArrayList`로 다룬다. `draining`은 회전 때만 접근하므로 경합이 없다 — 합리적 선택이지만 주석이 없다.
수명주기는 §4.4의 8단계가 **선언**이고 §12.1이 실현 상태를 다룬다.
---
## 8. 설정·기능 플래그·환경 차이
설정 없음.
| 상수 | 값 | 위치 |
|---|---|---|
| `DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE` | 30초 | `:28` (private) |
| `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE` | 30초 | `:40` (public, 인터페이스 상수) |
**같은 값이 두 곳에 있다.** 그리고 `MessagingShutdownLifecycle`(starter)은 셋 중 어느 것도 참조하지 않고 생성자 인자로 받는다. 세 번째 값이 프로퍼티에서 올 수 있다는 뜻이다 — 그 배선은 starter leaf가 소유한다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. 이 leaf는 브로커를 만지지 않는다 — 만지는 방법의 **모양**만 정의한다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-transport-spi:test`. **BUILD SUCCESSFUL, 24 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `MessagingRuntimeRegistryTest` | 9 | 세대 설치·대여·은퇴·드레인 계수 | 실제 브로커 연결 |
| `ResourceLeakGateTest` | 4 | 20세대 연속 회전 후 현재 세대만 열림, 누수 lease가 마감에 강제 종료, 막힌 작업도 드레인 종료, 은퇴 세대가 **정확히 한 번** close | 며칠 단위 실행 |
| `GracefulShutdownTest` | 5 | 드레인이 새 작업만 막고 실행 중은 완료, 재시도 금지, 마감 경계(29초 false / 30초 true), 유휴 코디네이터, 이중 `endWork` clamp | — |
| `MessagingLifecycleTest` | 6 | **enum 선언 순서와 상수 값** | **아무 종료 동작도 증명하지 않는다** |
### 10.1 `ResourceLeakGateTest`의 자기 규정
```java
// :12-18
* <p>Every resource the platform holds is bounded by something that must eventually release it: a
* runtime generation by its last lease, an in-flight slot by its handler finishing, a drain by its
* deadline. Each of those has a failure mode that is invisible in a short test and fatal over days
* a retired generation whose credential never gets revoked, a partition that never accepts work
* again, a shutdown that never completes.
```
세 자원과 각각의 해제 조건을 명시하고, "짧은 테스트에서 안 보이고 며칠이면 치명적"이라는 실패 성격까지 적는다. 20세대 회전 루프가 그 형태를 압축한 것이다.
### 10.2 `MessagingLifecycleTest`가 실제로 단언하는 것
여섯 테스트 중 다섯이 이 형태다.
```java
List<ShutdownPhase> order = List.of(ShutdownPhase.values());
assertThat(order.indexOf(ShutdownPhase.DRAIN_HANDLERS))
.as("flushing before the handlers finish would lose the settlements they produce")
.isLessThan(order.indexOf(ShutdownPhase.FLUSH_SETTLEMENTS));
```
`ShutdownPhase.values()`는 **소스에 상수가 적힌 순서**를 반환한다. 이 단언이 검증하는 것은 "누군가 enum 상수를 이 순서로 타이핑했다"이다. 여섯 번째는 상수 값 비교(`DEFAULT_DRAIN_DEADLINE == 30초`)다.
`as(...)` 문구들은 실제 시스템 동작을 서술한다 — "flushing before the handlers finish would lose the settlements", "a confirm that arrives after close cannot be observed". 그러나 그 동작을 수행하는 코드가 없다(§12.1). 테스트 이름(`handlersDrainBeforeTheirSettlementsAreFlushed`)과 실제 단언(enum 인덱스 비교) 사이의 거리가 이 레인에서 가장 큰 항목이다.
이 여섯 테스트는 **enum 상수 순서를 바꾸면 실패한다.** 그리고 순서를 바꿔도 시스템 동작은 바뀌지 않는다 — 아무도 그 순서를 읽지 않기 때문이다. 게이트가 지키는 것과 게이트가 지킨다고 이름 붙인 것이 다르다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | 세 project 의존 |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| vendor `api` 규칙 | 벤더 의존성 0이므로 대상 없음. 세 project 의존은 전부 `api`이고 시그니처에 실제로 등장 |
| ArchUnit | 전용 규칙 없음 |
| `MessagingLifecycle` 구현 강제 | **없음** — 인터페이스는 컴파일 타임 강제를 만들지 않는다 |
마지막 행이 §12.1의 구조적 이유다. `MessagingTransport`는 어댑터가 구현하지 않으면 `TransportMessagingRuntime`이 컴파일되지 않는다. `MessagingLifecycle`은 아무도 받지 않으므로 구현하지 않아도 아무것도 깨지지 않는다.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/280-transport-spi-lifecycle-unimplemented.txt`.
### 12.1 Public surface reachability
| 타입 | leaf 밖 파일 수 | 판정 |
|---|---:|---|
| `TransportPublishRequest` | 18 | 활발 |
| `TransportConsumerSpec` | 14 | 활발 |
| `MessagingTransport` | 12 | 4개 어댑터가 구현 |
| `GracefulShutdownCoordinator` | 11 | 활발 |
| `TransportPublishResult` | 10 | 활발 |
| `TransportDelivery` | 9 | 활발 |
| `TransportConsumerRegistration` | 8 | 활발 |
| `TransportSettlement` | 4 | 활발 |
| `MessagingRuntime` | 3 | `TransportMessagingRuntime`이 구현 |
| `MessagingRuntimeRegistry` | 3 | |
| `MessagingRuntimeLease` | 2 | |
| `DefaultMessagingRuntimeRegistry` | 2 | |
| **`MessagingLifecycle`** | **0** | **구현 없음, 소비자 없음** |
**이 leaf는 messaging family에서 가장 잘 쓰이는 leaf 중 하나다.** 13개 중 12개가 실제 소비자를 갖는다. 그래서 나머지 하나가 두드러진다.
**`MessagingLifecycle`의 세 겹 부재**
1. `git grep -E 'implements .*MessagingLifecycle'` → exit 1. **구현체 없음.**
2. `git grep -w ShutdownPhase -- src ':!src/messaging/messaging-transport-spi'` → exit 1. **8단계 enum의 외부 소비자 없음.**
3. `MessagingLifecycle`의 저장소 전체 언급이 자기 선언과 자기 테스트 두 줄뿐.
한편 `MessagingTransport`는 넷이 구현한다 — `KafkaMessagingTransport`, `RabbitMessagingTransport`, `PulsarMessagingTransport`, `NatsJetStreamTransport`. **네 어댑터 중 어느 것도 `MessagingLifecycle`을 구현하지 않는다.** javadoc이 "Each adapter implements the phases"라고 적은 그 어댑터들이다.
**실제 종료 경로는 존재하고 다른 타입으로 되어 있다.**
`messaging-spring-boot-starter``MessagingShutdownLifecycle implements SmartLifecycle`이 종료를 수행한다.
```java
public void stop() {
if (!running.compareAndSet(true, false)) return;
admission.stopAcceptingNewWork(); // ≈ phase 1
Instant startedAt = clock.get();
drain.beginDrain(startedAt); // ≈ phase 2
Instant deadline = startedAt.plus(drainDeadline);
while (!drain.isDrained(clock.get()) && clock.get().isBefore(deadline)) { ... } // ≈ phase 4
}
```
선언된 8단계와 대조:
| # | 선언 단계 | 실제 수행 |
|---:|---|---|
| 1 | `STOP_PUBLISH_ADMISSION` | **수행**`admission.stopAcceptingNewWork()` |
| 2 | `STOP_NEW_HANDLERS` | **수행**`beginDrain` 이후 `tryBeginWork()`가 false |
| 3 | `PAUSE_CONSUMERS` | 명시적 호출 없음. 어댑터의 registrar가 자체 처리 |
| 4 | `DRAIN_HANDLERS` | **수행** — 폴링 루프 |
| 5 | `FLUSH_SETTLEMENTS` | 명시적 단계 없음 |
| 6 | `AWAIT_PRODUCER_CONFIRMS` | 명시적 단계 없음 |
| 7 | `RELEASE_OUTBOX_LEASES` | 명시적 단계 없음 — `getPhase()` javadoc이 outbox relay와의 상대 순서만 언급 |
| 8 | `CLOSE_CONNECTIONS` | Spring bean 소멸에 위임 — `getPhase()``Integer.MAX_VALUE - 1024`로 transport보다 먼저 멈춤 |
**8단계 중 셋이 명시적으로 수행되고, 하나는 Spring 단계 순서에 위임되며, 넷은 명시적 단계가 없다.** 그리고 순서를 결정하는 것은 `ShutdownPhase` enum이 아니라 Spring의 `getPhase()` 정수다.
`MessagingShutdownLifecycle`의 javadoc이 자기 순서를 스스로 설명한다 — "The order is admission first, drain second. Reversed, the drain waits for a count that new work keeps topping up." 두 단계에 대해서만 순서를 논한다.
**한계.** `PAUSE_CONSUMERS`·`FLUSH_SETTLEMENTS`·`AWAIT_PRODUCER_CONFIRMS`가 어댑터 내부에서 다른 이름으로 수행될 수 있다. `KafkaConsumerRegistrar``RabbitConsumerRegistrar``GracefulShutdownCoordinator`를 쓰므로 그 leaf들이 답을 갖는다. 이 문서는 **`ShutdownPhase`가 그 순서를 결정하지 않는다**만 주장한다.
### 12.2 Conditional sibling comparison
Spring 주석 0개, bean 없음.
**`MessagingTransport` 구현 sibling 넷의 비대칭이 관측된다.**
| 어댑터 | `MessagingTransport` | registry membership |
|---|:---:|---|
| `KafkaMessagingTransport` | o | `["app-bootstrap"]` |
| `RabbitMessagingTransport` | o | `["app-bootstrap"]` |
| `PulsarMessagingTransport` | o | `[]` |
| `NatsJetStreamTransport` | o | `[]` |
넷 다 같은 SPI를 구현하고 둘만 편입된다 — `docs/messaging/support-matrix.md`의 experimental 구분과 정합한다. 각 어댑터의 조건부 활성화는 해당 leaf SSOT가 소유한다.
### 12.3 Duplicate mechanism sweep
**(a) 드레인 마감 30초가 세 곳에 있다**
| 위치 | 가시성 |
|---|---|
| `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE` | public 인터페이스 상수 |
| `DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE` | private |
| `MessagingShutdownLifecycle`(starter) | 생성자 인자 |
public 상수가 있는데 같은 leaf의 다른 클래스가 자기 private 복사본을 쓴다. `MessagingLifecycleTest`의 여섯 번째 테스트가 public 쪽만 고정한다 — private 쪽이 바뀌어도 통과한다.
**(b) 정산 인터페이스가 둘**
| 인터페이스 | leaf | 연산 |
|---|---|---|
| `TransportSettlement` | 이 leaf | `acknowledge` / `requeue(delay)` / `discard` |
| `SettlementController` | `messaging-core-api` | `ack` / `retry(delay)` / `deadLetter(failure)` / `reject(failure)` |
책임이 다르다 — 전자는 어댑터 원시 연산, 후자는 M2 수동 정산 API이고 `deadLetter`가 추가돼 있다. 중복이 아니라 계층이다. 다만 `SettlementController`는 소비자가 0이므로(`messaging-core-api` §12.1) 오늘 계층의 위쪽이 비어 있다.
**(c) pause scope sentinel이 둘**
| 인터페이스 | 전체를 뜻하는 값 |
|---|---|
| `TransportConsumerRegistration.pause(String scope)` | **빈 문자열** |
| `PauseResumeController.pause(dest, String scope)` (core-api) | **`"*"`** |
두 javadoc이 각각 명시한다. 이으려면 변환이 필요하고, 그 변환 코드는 없다(`PauseResumeController` 소비자 0).
**(d) 드레인 조정 로직**
`GracefulShutdownCoordinator`가 유일하다. 저장소의 다른 곳에서 in-flight 계수 + 마감 패턴을 다시 만든 곳은 messaging family 안에 없다. 다른 family(grpc의 admission controller 등)와의 비교는 cross-scope가 소유한다.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `MessagingLifecycle` javadoc: "Each adapter implements the phases" | 4개 어댑터 중 0개 구현 | **불일치** |
| `MessagingLifecycle` javadoc: "The order in ShutdownPhase is the contract" | 그 순서를 읽는 코드 0 | **불일치** |
| `MessagingTransport` javadoc: 네이티브 클라이언트 미반환 | 13개 타입 시그니처 전수 확인 | **일치** |
| `TransportDelivery` javadoc: 디코딩이 transport 위에서 | `TransportDelivery.envelope``MessageEnvelope<EncodedMessage>` | **일치** |
| `TransportSettlement` javadoc: 애플리케이션에 미노출 | 이 leaf가 `..application..`에서 참조 0(ArchUnit이 금지) | **일치** |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift, `messaging-core-api` §12.4가 소유) |
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
코드 주석이 네 결함을 보존한다. 전부 **장기 실행에서만 드러나는** 종류다.
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `Generation.retiredAt` javadoc | 호출자가 넘긴 하나의 `retiredAt`을 전체 draining 목록에 적용 | 드레인 중 회전이 겹치면, 방금 은퇴한 세대를 강제 종료하거나 오래된 세대에 새 마감을 주거나 — 호출자가 우연히 넘긴 타임스탬프에 좌우 |
| `closeExpiredDraining` 주석 | 이미 닫힌 세대가 목록에 잔류 | `drainingCount()`가 끝난 작업을 영원히 보고 |
| `close()` javadoc | 회전이 은퇴시킨 것만 닫음 | 회전 없이 종료한 프로세스가 브로커 연결을 JVM 종료에 맡김 → 미전송 producer 배치 소실, consumer 세션이 그룹을 떠나지 않고 브로커에서 타임아웃 |
| `endWork` javadoc | clamp 없음 | 이중 해제가 계수를 음수로 → 작업이 도는 중에 드레인 완료로 보고 |
네 번째와 `LeakTrackingRuntime.closeCount()` javadoc("Closing twice is as much a defect as never closing")이 같은 주제를 반대편에서 말한다 — **해제는 정확히 한 번이어야 하고, 0번도 2번도 결함이다.**
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-280 | command | `evidence/raw/280-transport-spi-lifecycle-unimplemented.txt` | 13개 타입 참조 수, `MessagingLifecycle` 구현 0(exit=1)·`ShutdownPhase` 외부 소비자 0(exit=1), 순서 테스트가 실제로 단언하는 것, 배선된 종료 경로와 그 4개 호출 | 정적 `git grep`. 어댑터 내부의 pause/flush 수행 여부는 각 leaf가 답함 |
| EVD-279 | command | `./gradlew :messaging:messaging-transport-spi:test --rerun-tasks` | BUILD SUCCESSFUL, 24 / 0 / 0 | 실제 브로커 없음 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- 네이티브 클라이언트를 반환하지 않는 이유 — `MessagingTransport` javadoc
- 디코딩이 transport 위에서 일어나는 이유 — `TransportDelivery` javadoc
- 어댑터가 codec/limit을 고르지 않는 이유 — `TransportPublishRequest` javadoc
- 회전이 세대 교체인 이유 — `MessagingRuntime` javadoc
- lease가 세대를 pin하는 이유 — `MessagingRuntimeLease` javadoc
- 드레인 마감이 필요한 이유, 재시도 금지 이유 — `GracefulShutdownCoordinator` javadoc
- 종료 3단계 중 중간 단계를 건너뛰면 생기는 일 — 같은 javadoc
- 순서 단위별 pause가 필요한 이유 — `TransportConsumerRegistration` javadoc
- `TransportSettlement`을 애플리케이션에 노출하지 않는 이유 — 그 javadoc
- 네 개의 이전 결함 — §13
**추론**
- `MessagingLifecycle`이 미구현인 것은 `MessagingShutdownLifecycle`이 Spring `SmartLifecycle`로 같은 일을 다르게 하기로 했기 때문이다 → **추론**. 두 타입의 존재와 후자의 배선은 관측이고, 전자를 버린 결정은 어디에도 기록되지 않았다.
- `current`는 lock-free, `draining``synchronized`인 이유 → **추론**(경합 빈도 차이). 주석 없음.
- pause sentinel이 둘인 이유 → **미상**.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 13개 타입 776줄 전문의 계약과 불변식
- 24개 테스트가 통과하고 무엇을 단언하는지, 그리고 `MessagingLifecycleTest`가 enum 선언 순서만 단언한다는 것
- `MessagingLifecycle` 구현 0, `ShutdownPhase` 외부 소비자 0 (둘 다 exit 1로 확인)
- 실제 배선된 종료 경로(`MessagingShutdownLifecycle`)가 8단계 중 셋을 명시적으로 수행하고 하나를 Spring 단계에 위임한다는 것
- 참조 계수·CAS·이중 검사·clamp의 동시성 설계와 그것을 만든 네 개의 이전 결함
**확인하지 못한 것**
- **어댑터가 `PAUSE_CONSUMERS`·`FLUSH_SETTLEMENTS`·`AWAIT_PRODUCER_CONFIRMS`를 다른 이름으로 수행하는지.** `KafkaConsumerRegistrar`·`RabbitConsumerRegistrar``GracefulShutdownCoordinator`를 쓰는 것은 확인했으나 그 내부는 각 leaf가 소유한다.
- 실제 종료에서 이 순서가 지켜지는지. 컨테이너 레인(`KafkaBrokerIT`, `KafkaConsumerSettlementIT`)이 있으나 이번 분석에서 실행하지 않았다.
- `MessagingLifecycle`을 남겨 둔 것이 의도인지, 미완인지.
- `close()``install()`이 동시에 일어나는 경우의 실제 빈도. 코드상 창은 존재한다(§4.2).
---
## 17. 손볼 것
### P2 — 8단계 종료 순서 계약을 구현하는 것이 없고, 그것을 검증한다는 테스트는 enum 선언 순서만 본다
- **사실.** `MessagingLifecycle`은 8단계 종료 순서를 선언하고 javadoc이 "The order in ShutdownPhase is the contract, not an implementation detail. … Each adapter implements the phases; none of them chooses the order"라고 적는다. 저장소에 구현체가 없고(`git grep -E 'implements .*MessagingLifecycle'` exit 1), `ShutdownPhase`의 외부 소비자도 없다(exit 1). `MessagingTransport`를 구현하는 네 어댑터 중 어느 것도 이 인터페이스를 구현하지 않는다. `MessagingLifecycleTest`의 다섯 순서 테스트는 전부 `List.of(ShutdownPhase.values()).indexOf(A) < indexOf(B)` 형태로, **소스에 상수가 적힌 순서**를 단언한다.
- **근거.** `evidence/raw/280-transport-spi-lifecycle-unimplemented.txt` §B·§C.
- **왜 문제인가.** 세 겹이다.
- 실제 종료는 `MessagingShutdownLifecycle`(starter)이 하고, 8단계 중 **셋만 명시적으로 수행**한다(admission 정지 · 새 핸들러 정지 · 드레인). 나머지는 Spring `getPhase()` 정수와 bean 소멸 순서에 위임되거나 명시 단계가 없다. 순서를 결정하는 것은 `ShutdownPhase`가 아니다.
- 테스트 이름과 `as(...)` 문구가 시스템 동작을 서술한다("flushing before the handlers finish would lose the settlements they produce"). 통과하는 것은 그 동작이 아니라 타이핑 순서다. **이 여섯 테스트는 enum 상수를 재배열하면 실패하고, 재배열해도 시스템은 바뀌지 않는다** — 게이트가 지키는 것과 이름이 어긋난다.
- 인터페이스는 컴파일 강제를 만들지 않는다. `MessagingTransport`는 구현 안 하면 빌드가 깨지고, 이것은 아무것도 깨지지 않는다.
- **확인 방법.** `evidence/raw/280` 재실행. 또는 `git grep -n -w MessagingLifecycle -- src` → 두 줄(자기 선언, 자기 테스트).
- **후보.** (a) 네 어댑터가 `MessagingLifecycle`을 구현하고 `MessagingShutdownLifecycle``shutdown(deadline)`을 호출하게 한다. (b) 인터페이스를 제거하고 순서 규칙을 `MessagingShutdownLifecycle`과 각 registrar의 계약으로 옮긴다. (c) 인터페이스를 "미실현 설계"로 표시하고 테스트가 enum 순서만 본다는 것을 이름과 javadoc에 반영한다.
- **다음 단계.** **CASE 후보 + REFERENCE 후보.** Case는 "선언된 순서 계약과 실제 종료 경로의 불일치"이고, Reference는 "enum 선언 순서를 단언하는 테스트는 그 순서를 읽는 코드가 있을 때만 게이트다"이다.
### P3 — 드레인 마감 30초가 세 곳에서 독립적으로 결정된다
- **사실.** `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE`(public), `DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE`(private), `MessagingShutdownLifecycle`의 생성자 인자.
- **근거.** 세 위치.
- **왜 문제인가.** public 상수가 같은 leaf 안에 있는데 다른 클래스가 자기 private 복사본을 쓴다. `MessagingLifecycleTest.theDefaultDrainDeadlineMatchesTheDesign`이 public 쪽만 고정하므로 private 쪽이 바뀌어도 통과한다. 그리고 §17 첫 항목대로 public 상수가 있는 인터페이스는 구현체가 없다 — 즉 살아 있는 값(private)이 죽은 인터페이스의 값(public)을 참조하지 않는다.
- **확인 방법.** `git grep -n 'DEFAULT_DRAIN_DEADLINE' -- 'src/messaging/**/*.java'`
- **후보.** registry가 `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE`를 참조하거나, 값의 주인을 한 곳으로 정한다.
- **다음 단계.** 첫 항목과 같은 사건의 일부다 → 그 CASE에 **MERGED** 후보.
### P3 — 종료 중 `install`이 닫히지 않는 창
- **사실.** `close()``draining``synchronized`로 비우고, `current``List.copyOf(current.keySet())` 후 개별 `remove`한다. 그 사이 `install`이 새 세대를 넣으면 그 세대는 닫히지 않는다.
- **근거.** `DefaultMessagingRuntimeRegistry.java:141-156`.
- **왜 문제인가.** 종료 중 회전은 정상 시나리오가 아니므로 실질 위험이 낮다. 다만 이 클래스의 다른 모든 경로가 "정확히 한 번 close"를 CAS로 보장하는 것과 대비되고, 남는 것은 닫히지 않은 브로커 연결이다 — §13의 세 번째 결함과 같은 결과다.
- **확인 방법.** 코드 검토. 테스트로 재현하려면 `close()``install`을 끼워 넣어야 한다.
- **후보.** `close()`에 종료 플래그를 두고 `install`이 그 이후에는 즉시 `runtime.close()`하도록 한다.
- **다음 단계.** **REFERENCE 후보**(멱등 종료를 보장하는 컴포넌트는 종료 이후의 등록도 정의한다).
### P3 — pause scope sentinel이 두 인터페이스에서 다르다
- **사실.** `TransportConsumerRegistration.pause`는 빈 문자열이 전체, `PauseResumeController.pause`(core-api)는 `"*"`가 전체.
- **근거.** 두 javadoc.
- **왜 문제인가.** `PauseResumeController`가 소비자 0이므로 오늘 충돌하지 않는다. 그것을 배선하려는 사람이 변환을 넣어야 하고, 빠뜨리면 `"*"`가 이름이 `"*"`인 파티션을 가리키게 된다 — 실패하지 않고 아무것도 일시정지하지 않는다.
- **확인 방법.** 두 javadoc 대조.
- **후보.** sentinel을 통일하거나 `Optional<String>`으로 바꾼다.
- **다음 단계.** **REFERENCE 후보**(같은 개념의 sentinel은 계층을 넘어 하나로 정한다).
### 확인된 설계(문제 아님)
- 네이티브 클라이언트를 반환하지 않는 SPI 경계
- 인코딩/디코딩을 transport 밖에 두어 실패 분류를 플랫폼이 소유하는 것
- 세대 교체 + 참조 계수 + CAS로 "정확히 한 번 close"를 보장하는 것과, 그것을 20세대 회전으로 확인하는 테스트
- `computeIfPresent`로 조회-증가를 원자화한 것
- `tryBeginWork`의 이중 검사와 `endWork`의 clamp
- 마감 도달 작업을 **정산하지 않고** 버려 브로커가 재전달하게 하는 것
- 세대별 `retiredAt`과 닫힌 세대의 목록 제거
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MTS-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 3개, memberships `["app-bootstrap"]` | 선언 |
| MTS-002 | build | `messaging-transport-spi/build.gradle` | same | 벤더 의존성 0, 세 project 의존이 전부 `api` | — |
| MTS-003 | code | `.../transport/MessagingTransport.java` | same | SPI 경계와 네이티브 미노출 | — |
| MTS-004 | code | `.../transport/DefaultMessagingRuntimeRegistry.java` 전문 | same | §4.2 동시성 설계 전부와 세 개의 이전 결함 | 종료 중 install 창(§17) |
| MTS-005 | code | `.../transport/GracefulShutdownCoordinator.java` 전문 | same | §4.3 드레인 계약과 clamp 결함 이력 | — |
| MTS-006 | code | `.../transport/MessagingLifecycle.java` | same | 8단계 선언과 "order is the contract" 진술 | 구현 없음(§12.1) |
| MTS-007 | code | `.../transport/Transport*.java` (6) | same | 발행·수신·정산 계약 | — |
| MTS-008 | test | `MessagingRuntimeRegistryTest` (9) | same | 세대 관리 | 실제 브로커 없음 |
| MTS-009 | test | `ResourceLeakGateTest` (4) | same | 20세대 회전, 누수 lease 강제 종료, 정확히 한 번 close | 며칠 단위 아님 |
| MTS-010 | test | `GracefulShutdownTest` (5) | same | 드레인 경계 29/30초, 이중 endWork clamp | — |
| MTS-011 | test | `MessagingLifecycleTest` (6) | same | **enum 선언 순서와 상수 값만** | 종료 동작 미증명(§10.2) |
| MTS-012 | cross-leaf code | `messaging-spring-boot-starter/.../MessagingShutdownLifecycle.java` 전문 | same | 실제 배선된 종료 경로와 그것이 수행하는 3단계, `getPhase()` 위임 | 해당 leaf SSOT가 소유 |
| MTS-013 | cross-leaf code | 4개 `*MessagingTransport.java` | same | SPI 구현 넷, `MessagingLifecycle` 구현 0 | 각 leaf SSOT가 소유 |
| EVD-280 | command | `evidence/raw/280-transport-spi-lifecycle-unimplemented.txt` | same | §12.1 전부, exit code 포함 | 정적 검색 |
| EVD-279 | command | `./gradlew :messaging:messaging-transport-spi:test --rerun-tasks` | same | 24 / 0 / 0 | 브로커 없음 |