Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-core-api.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

68 KiB

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 전문:

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이 선언하는 것은 하나다:

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번에는 코드 주석이 직접 달려 있다.

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() → 거절

SettlementEvidencebrokerConfirmed && !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.guaranteeEnumsDoNotAdvertiseUnsupportedSemanticsvalues()EXACTLY_ONCEGLOBAL이 없음을 단언한다(CoreValueTypesTest.java:25-29). 이름이 다시 추가되면 테스트가 깨진다.

4.5 wire 안전성: 한 곳에 모은 규칙

WireSafeText(WireSafeText.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.

HeaderNameWireSafeText를 쓰지 않고 자체 정규식 [a-zA-Z0-9!#$%&'*+._|~-]+(HTTP token)을 쓴다. 더 엄격하다 — 공백·콜론·비ASCII를 전부 배제한다. 그리고 trim하지 않고 선행/후행 공백을 거절한다. 주석이 이유를 적는다:

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개를 [._\-]+로 쪼갠 세그먼트 단위로 검사, 그리고 인접 세그먼트를 붙여서 한 번 더 검사
// 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로 판정한다.

// 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: 타입 이름과 실제 검증의 정렬

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 루프를 돈다.

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.handleResultPermitsExactlyTheFourDeclaredOutcomesgetPermittedSubclasses()로 이 집합을 고정한다.

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-110messagingVerificationSkeletons(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에서 실행):

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:223instanceof로 분류
  • ClaimCheckIntegrityException.java:20extends
  • PublishResults.java:37instanceof로 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가 선언하는 것:

// 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)이 실제로 받는 것:

// 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,216DeadLetterOrchestratorTest.java:12,226. production 소스에서 DeliveryContext를 만드는 코드는 저장소에 없다. DeliveryContext의 javadoc이 설명하는 graceful drain 협력("a long-running handler that can wind down early shortens the drain")은 현재 배선으로는 일어날 수 없다.

한편 MessageDeliveryDeliveryMetadata는 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:104metadataFor(partition, slice, now)
  • RabbitBatchConsumerRegistrar.java:139release(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-starterapp-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이다. 핸들러 결과를 정산으로 바꾸는 유일한 지점 DefaultDeliveryProcessorFunction<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) DefaultDeliveryProcessorMessageHandler<T>를 받도록 시그니처를 맞춘다 — MessageDelivery를 조립해야 하므로 DeliveryContext 생성 책임을 runtime에 준다. (b) MessageHandler·DeliveryContext를 이 leaf에서 제거하고 실제 계약만 남긴다. (c) 파생 프로젝트가 구현하는 확장점이라면 그 사실을 javadoc과 support-matrix.md에 명시한다.
  • 다음 단계. 세 선택지는 "저장소 밖 소비자가 있는가"라는 미지수에 걸린다(§16). 그 답을 먼저 정해야 한다 → OPEN QUESTION 후보. 답이 정해지면 CASE 승격 가능.

P2 — 배치 metadata를 만들고 넘길 곳이 없다

  • 사실. KafkaBatchConsumerRegistrar:104RabbitBatchConsumerRegistrar:139BatchDeliveryMetadata를 만들고, 두 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 순수 단위 레인