Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-claim-check.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 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>
2026-09-04 22:51:59 +09:00

582 lines
32 KiB
Markdown

# 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 | — |