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>
32 KiB
messaging-claim-check 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-claim-checkSSOT owner:messaging-claim-checkintegration/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가 그것을 만들고 검증하는 동작을 소유한다.
경계 진술이 두 클래스에 있다.
// 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-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 — 보존이 생성자 불변식이다
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-api의 InboxRepository.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상태와 직접 연결된다 — REJECTED와 AMBIGUOUS를 구분할 수 없는 시점에 삭제하면 모호한 발행의 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이 늦게 도는 저장소에서 잘못된 설정이 숨는 것을 막는다.
fetch가 null을 CLAIM_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-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한다는 위협 모델 —
ClaimCheckIntegrityGuardjavadoc - 검증이 선택 불가인 이유 —
ClaimCheckResolverjavadoc - 저장이 발행보다 먼저인 이유 —
ClaimCheckPublisherjavadoc - 실패 시 삭제하지 않는 이유 — 같은 javadoc
- payload와 참조를 함께 나르지 않는 이유 — 인라인 주석
- 만료를 fetch 전에 보는 이유 —
resolve인라인 주석 - 보존 규칙과 그것을 생성자가 강제하는 이유 —
ClaimCheckPolicyjavadoc - 문턱과 목적지 상한이 다른 이유 — 같은 javadoc
- digest 불일치가 재시도 불가인 이유, 만료와 구분하는 이유 —
ClaimCheckIntegrityExceptionjavadoc _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에서 호출되지 않는다.ClaimCheckPublisherjavadoc이 "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 | — |