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

32 KiB

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-apiClaimCheckReference(storageKey·sizeBytes·sha256·expiresAt)를 값 타입으로 쓰고, 이 leaf가 그것을 만들고 검증하는 동작을 소유한다.

경계 진술이 두 클래스에 있다.

// 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.
// 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-starterallowed_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 — 보존이 생성자 불변식이다

Duration required = brokerRetention.plus(maxRedeliveryWindow);
if (retention.compareTo(required) < 0) {
  throw new MessagingConfigurationException("CLAIM_CHECK_RETENTION_TOO_SHORT", ...);
}

javadoc이 이유를 적는다.

// :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-apiInboxRepository.purgeProcessedBefore javadoc이 요구하고 강제하지 않는 것과 같은 형태의 규칙인데, 이쪽은 생성자가 강제한다. 같은 저장소에서 같은 종류의 시간 관계 규칙을 한 곳은 강제하고 한 곳은 문서로만 둔다 — 그 leaf §17이 소유한다.

문턱과 목적지 payload 상한을 분리한 이유도 명시돼 있다.

// :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 — 순서와 미삭제

// :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상태와 직접 연결된다 — REJECTEDAMBIGUOUS를 구분할 수 없는 시점에 삭제하면 모호한 발행의 payload를 지운다.

오프로드된 메시지는 payload를 아예 갖지 않는다.

// 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
// :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 전에 본다

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이 늦게 도는 저장소에서 잘못된 설정이 숨는 것을 막는다.

fetchnullCLAIM_CHECK_NOT_FOUND로 번역하고 메시지가 두 원인을 나열한다 — "it was either reaped early or never written".

예외 승격이 코드 접미사로 판정된다.

} 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

// :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-apiFailureDescriptor.defaultRetryablePOISON_MESSAGE를 false로 두는 것과 일치한다.

이 예외가 MessagingException을 확장하는 저장소 내 두 곳 중 하나다(다른 하나는 core-api 자신의 23개). analysis/messaging/messaging-core-api.md §12.1(b)가 그 사실을 관측했다.


5. 주요 실행 경로

발행: publisher.offload(encodedPayload) → 문턱 이하면 인라인 → 초과면 store.putOffloaded(빈 바이트, reference)

소비: resolver.resolve(inline, reference, now) → reference 없으면 인라인 → 만료 확인 → store.get → null이면 NOT_FOUND → guard.verify(만료·크기·digest) → _MISMATCHClaimCheckIntegrityException

두 경로 모두 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.sha256NoSuchAlgorithmExceptionIllegalStateException("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-checkallowed_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:22FakeStore다. 즉 이 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-policyPayloadPolicyclaimCheckThresholdBytes 필드를 갖고, 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.verifyvalidation.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-policyPayloadLimitGuard가 상한 초과 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도, 오프로드를 부르는 발행 경로도 없다. 그리고 DestinationProfileclaimCheckThresholdBytes를 선언하고 검증까지 하므로 설정 표면은 존재한다. 설정할 수 있고 아무 효과가 없는 값이다.
  • 확인 방법. 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-policyPayloadPolicy.claimCheckThresholdBytes(목적지별, DestinationProfileValidator:49가 검사)와 이 leaf의 ClaimCheckPolicy.thresholdBytes(전역). 두 값을 대조하는 코드가 없다.
  • 근거. evidence/raw/290 §B.
  • 왜 문제인가. 배선되면 실제 동작은 후자를 따르고 전자는 선언만 남는다. 목적지별로 다른 문턱을 두려던 설계가 전역 정책 하나에 덮인다.
  • 확인 방법. 두 필드와 검증기 확인.
  • 후보. ClaimCheckPublisher가 목적지 프로파일의 값을 읽거나, PayloadPolicy에서 그 필드를 제거한다.
  • 다음 단계. REFERENCE 후보(같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다).

P3 — 예외 승격이 에러 코드 문자열 접미사에 의존한다

  • 사실. ClaimCheckResolver.verifyvalidation.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