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

108 KiB
Raw Blame History

19. messaging platform family — 25 leaf 통합 분석

  • 분석 대상 리비전: a24ece9cf797f7ea647e33bf846b115208ed1ba5
  • 범위: src/messaging/**src/config/architecture/modules.json에 등록된 25개 leaf
  • 분모: git 추적 파일 550개 (main Java 364 / 약 29,900 LOC · test Java 119 · 나머지 build/resource/doc)
  • 가족 로컬 권위 문서: src/messaging/CLAUDE.md (132줄)
  • 테스트 레인: 25개 leaf의 :test 전량 — BUILD SUCCESSFUL (2m 27s, 105 actionable tasks, exit 0)

0. 이 문서가 다른 모듈 문서와 다른 점

앞선 18개 모듈은 대부분 leaf 하나가 문서 하나였다. messaging은 25개 leaf가 하나의 플랫폼을 이루고, leaf 경계를 넘는 계약(capability 선언 → 프로파일 검증 → 인증 증거)이 이 가족의 실제 설계다. 그래서 분모를 25개 합산으로 잡고, 7개 sub-scope로 나눠 읽되 발견은 leaf가 아니라 계약 단위로 기록한다.

그리고 이 가족은 지금까지 분석한 어떤 모듈보다 자기 결함을 먼저 적어 놓았다. src/messaging/CLAUDE.md가 미해결 P0(MSG-015)을 명시하고, StartupProfileValidation의 javadoc이 자기가 고친 결함을 서술하며, KafkaMessagingTransport는 자기가 되돌린 capability 플래그의 이력을 남긴다. 따라서 이 문서의 일차 작업은 공개된 주장을 검증하는 것이고, 그 검증 결과가 "주장대로 성립한다"인 항목은 결함이 아니라 검증된 사실로 기록한다.


1. 분모와 커버리지 원장

1.1 등록 leaf 25개 — 파일 수 · 의존 폭 · 런타임 멤버십

modules.json에서 gradle_path:messaging:으로 시작하는 leaf를 전량 열거하고, 각 source_path에 대해 git ls-files로 파일 수를 세었다.

# leaf 파일 허용 의존 runtime_memberships
1 messaging-core-api 95 0 app-bootstrap
2 messaging-kafka 61 7 app-bootstrap
3 messaging-spring-boot-starter 44 17 app-bootstrap
4 messaging-rabbit 33 7 app-bootstrap
5 messaging-policy 32 2 app-bootstrap
6 messaging-admin-api 28 2 app-bootstrap
7 messaging-outbox-jdbc-postgresql 28 5 app-bootstrap
8 messaging-testkit 23 4 []
9 messaging-admin-runtime 20 6 app-bootstrap
10 messaging-transport-spi 19 3 app-bootstrap
11 messaging-observability 17 1 app-bootstrap
12 messaging-security 17 1 app-bootstrap
13 messaging-reliability-api 15 1 app-bootstrap
14 messaging-schema-api 15 1 app-bootstrap
15 messaging-inbox-jdbc-postgresql 13 2 app-bootstrap
16 messaging-pulsar-experimental 12 7 []
17 messaging-runtime-core 12 6 app-bootstrap
18 messaging-claim-check 11 2 app-bootstrap
19 messaging-nats-experimental 11 7 []
20 messaging-spring-cloud-stream-bridge 10 3 []
21 messaging-schema-avro 8 2 []
22 messaging-kafka-share-experimental 7 4 []
23 messaging-cloudevents 6 2 app-bootstrap
24 messaging-schema-json 6 2 app-bootstrap
25 messaging-schema-protobuf 6 2 []
합계 549

분모 550 = leaf 549 + 가족 공통 파일 1개(src/messaging/CLAUDE.md). 미배정 0.

출하 18 / build-only 7. runtime_memberships가 비어 있는 7개는 모듈 18(app-bootstrap)에서 확인한 것과 같은 등급이다 — 빌드되고 테스트되지만 어떤 런타임에도 오르지 않는다:

messaging-testkit
messaging-pulsar-experimental
messaging-nats-experimental
messaging-spring-cloud-stream-bridge
messaging-schema-avro
messaging-kafka-share-experimental
messaging-schema-protobuf

이 구분이 이 문서 전체에서 심각도를 가르는 축이다. 모듈 17(websocket) §26.6에서 확립한 원칙 — 런타임에 오르지 않는 leaf의 미조립은 오늘의 사고가 아니라 채택 시점의 부채 — 를 그대로 적용한다. 반대로 app-bootstrap 멤버인 18개 leaf의 미조립은 지금 출하되는 아티팩트의 결함이다.

주의할 이름 두 가지. 등록된 것은 messaging-inbox-jdbc-postgresql / messaging-outbox-jdbc-postgresql이고, CloudEvents leaf의 id는 messaging-cloudevents(schema 접두사 없음)다. 디스크에 messaging-inbox-jpa / messaging-outbox-jpa 두 디렉터리가 보이지만 git 추적 파일이 0개이고 modules.json에도 없으므로 분모에서 제외한다.

1.1b sub-scope 분할

# sub-scope leaf 파일
01 core contracts core-api · transport-spi · reliability-api · runtime-core 141
02 schema schema-api · schema-json · schema-avro · schema-protobuf · cloudevents 41
03 policy · security · observability policy · security · observability 66
04 brokers kafka · rabbit · kafka-share-experimental · pulsar-experimental · nats-experimental · spring-cloud-stream-bridge 134
05 reliability stores outbox-jdbc-postgresql · inbox-jdbc-postgresql · claim-check 52
06 admin admin-api · admin-runtime 48
07 assembly · testkit · 가족 거버넌스 spring-boot-starter · testkit · CLAUDE.md 68
합계 25 leaf 550

1.2 커버리지 원장 (sub-scope 01)

leaf main test main LOC 등급
messaging-core-api 85 8 3,948 FULL_READ (핵심 계약 40 파일) / STRUCTURAL_ONLY (나머지)
messaging-transport-spi 13 4 776 FULL_READ
messaging-reliability-api 13 0 817 FULL_READ
messaging-runtime-core 6 4 787 FULL_READ

messaging-core-api의 패키지 분포:

26  api/error          (예외 24 + FailureCategory + FailureDescriptor)
17  api/publish
13  api/delivery
12  api                (값 타입: MessageId, CorrelationId, CausationId, TenantContext, TraceContext, UuidV7, WireSafeText, ...)
 7  api/destination
 5  api/settlement
 5  api/header

증거: evidence/raw/243-messaging-core-contracts-inventory.txt


2. 이 가족이 공개한 주장과 검증 결과

src/messaging/CLAUDE.md는 Stable 승격 조건 5개를 명시한다. 그중 코드로 검증 가능한 3개를 먼저 확인했다.

2.1 MSG-022 — "예외 타입을 문자열로 판별하지 않는다" → 성립

grep -rn "getClass().getName().contains|getSimpleName().contains|getMessage().contains" */src/main
→ 0 matches

25개 leaf 전체 main 소스에서 0건. 증거: 246-messaging-mss022-no-string-matching.txt.

이 규칙은 앞선 모듈들에서 반복해서 깨진 것이다(모듈 11 httpclient, 모듈 13 notification). messaging 가족에서는 지켜진다.

2.2 "NetworkFaultScenario 전 항목에 evidence가 있거나, 없는 항목이 knownGaps로 명시된다" → 성립

시나리오는 5개다:

시나리오 Phase 기대 결과 Kafka 증거
connection-refused BEFORE_TRANSMISSION REJECTED 없음 (knownGap)
connection-cut-after-write AFTER_TRANSMISSION AMBIGUOUS
confirm-timeout AFTER_TRANSMISSION AMBIGUOUS
high-latency (30s) AFTER_TRANSMISSION AMBIGUOUS
settlement-lost DURING_SETTLEMENT REDELIVERED

커밋된 manifest는 4줄, 전부 messaging-kafka / apache/kafka:4.1.0. 빠진 1개는 침묵하지 않는다CertifiedEvidence.knownGaps(adapter)all() - covered파생하고, 테스트가 그 값을 이름과 이유까지 붙여 단언한다:

void aScenarioWithNoLineInTheManifestIsAGapRatherThanAnAbsence() {
  assertThat(CertifiedEvidence.knownGaps("messaging-kafka"))
      .as("a Kafka producer buffers before it learns a connection exists, so this stays unproven")
      .contains(NetworkFaultScenario.CONNECTION_REFUSED);
  assertThat(CertifiedEvidence.knownGaps("messaging-rabbit"))
      .as("no lane runs a fault scenario against RabbitMQ, so every scenario is a gap")
      .containsExactlyElementsOf(NetworkFaultScenario.all());
}

knownGaps가 손으로 유지되는 목록이 아니라 manifest에서 계산된다는 것이 핵심이다. 증거: 244-messaging-fault-scenarios-vs-evidence.txt.

2.3 "게이트는 커밋된 manifest와 이번 실행의 출력을 대조한다" → 성립

messaging-kafka/build.gradleverifyMessagingCertificationEvidence가 실제로 그 대조를 한다:

Set<String> ran = claims(produced.get().asFile)     // build/messaging-certification/...jsonl
Set<String> shipped = claims(committed)             // messaging-testkit/src/main/resources/...jsonl
if (ran != shipped) {
    def unproven = shipped - ran
    def unrecorded = ran - shipped
    throw new GradleException(... "claimed but not produced: ${unproven}" ...)
}
  • 양방향이다 — 손으로 추가한 줄(shipped - ran)도, 기록되지 않은 실행 결과(ran - shipped)도 실패시킨다.
  • gitCommit·observedAt은 정규식으로 제거한 뒤 비교한다. 그래서 커밋된 manifest의 gitCommit: e98b56eb03ec…가 분석 리비전 a24ece9c…보다 오래된 것은 설계상 정상이고 결함이 아니다.
  • outputs.upToDateWhen { false } — "이전 실행에서 서빙될 수 있는 게이트는 그 실행에 대한 증거"라는 주석과 함께 캐시를 막는다.
  • CI(.github/workflows/messaging-certification.yml)가 src/messaging/** PR마다 이 게이트를 돌린다. 레인이 아니라 게이트를 돌리고, 게이트가 레인에 의존한다.
  • 인증 레인만 Docker 가드를 일부러 달지 않았다 — "skip하는 인증 레인은 아무도 띄우지 않은 브로커에 대해 성공을 보고한다". 대신 test에서 태그를 제외해 랩톱 빌드를 깨지 않는다.

증거: 250-messaging-certification-gate-chain.txt.

판정: 이 세 주장은 성립한다. 지금까지 19개 모듈에서 "문서가 주장한 통제가 실제로 회로가 닫힌" 첫 사례다.


3. sub-scope 01 — core contracts (141 파일)

3.1 하나의 publish 경로

DefaultMessagePublisher(runtime-core)가 이 플랫폼의 유일한 publish 경로다. 클래스 javadoc이 자기 존재 이유를 적는다:

"MessagePublisher was an interface with no implementation. The brokers implemented MessagingTransport...; the core auto-configuration built a dead-letter orchestrator and blocking/reactive facades on top of a publisher bean nothing supplied; and the admission controller, access policy, runtime registry and observation existed as beans that no publish ever called. An application that filled the gap with its own publisher would pass a context test while running none of them."

고정된 단계 순서: resolve → authorize → encode → admit → lease → send → normalize. 각 단계의 위치가 근거와 함께 서술된다.

  • destination·access가 encode보다 먼저 — 인가되지 않은 publish가 payload를 직렬화하면 claim-check나 로그가 그 바이트를 들고 있게 된다.
  • encode가 admit보다 먼저 — admission 한도가 바이트 기준이고, 바이트 수는 인코딩 전에 알 수 없다.
  • runtime lease가 send 직전 마지막 — 이미 in-flight 카운트에 잡힌 메시지 밑에서 rotation이 transport를 갈아끼우지 못하게.

그리고 획득한 것은 성공·실패·예외·취소 모든 경로에서 정확히 한 번 반환된다 — "실패 경로에서 새는 permit은 실패 한 번에 하나씩 줄어들다 아무것도 받지 않게 되는 limiter다."

3.2 증거를 먼저 기록하고 결론을 나중에 고른다

PublishEvidence는 record compact constructor로 표현 불가능한 조합을 거부한다:

if (!brokerAccepted && confirmationLevel != ConfirmationLevel.NONE)
    throw new IllegalArgumentException("confirmation level requires broker acceptance: " + ...);
if (transmission == TransmissionEvidence.NOT_TRANSMITTED && brokerAccepted)
    throw new IllegalArgumentException("untransmitted message cannot be broker accepted");

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."

ConfirmationLevel(NONE / BROKER_ACK / REPLICATION_OR_PERSISTENCE_ACK)이 요청한 ConfirmationRequirement별도 타입으로 유지되는 이유도 명시돼 있다 — "'we asked for replication and got a leader ack'가 표현 가능하고, 따라서 거부 가능하도록."

그리고 DefaultMessagePublisher의 실패 분류가 이 구분을 지킨다:

지점 completion 근거
access 거부 / encode 실패 REJECTED + notTransmitted() "아무것도 이 프로세스를 떠나지 않았으므로 결과는 확정적이다. ambiguous로 보고하면 어떤 브로커도 보지 못한 메시지에 대해 caller를 reconciliation으로 보낸다."
준비 중 데드라인 소진 REJECTED 아직 전송 전
transport 단계 실패 / 데드라인 AMBIGUOUS + retryable=true "요청이 wire 위에 있었으므로 브로커가 들고 있을 수 있다."

sanitized(Throwable)타입만 남기고 메시지를 버린다 — "드라이버 메시지는 routing key, payload 조각, connection string을 담을 수 있고 FailureDescriptor는 로깅·export되도록 설계됐다." 이것이 §2.1의 MSG-022가 0건인 이유이기도 하다.

3.3 데드라인이 caller의 것이다

private Duration remainingBudget(Duration timeout, long startedAt) {
    return timeout.minus(elapsedSince(startedAt));
}

호출 시점부터 측정한다 — "느린 destination 조회나 큰 encode가 브로커 대기와 같은 예산을 쓴다; transport 호출만 재면 준비에 걸린 시간만큼 총합이 데드라인을 초과한다."

그리고 withDeadlineinFlight.toCompletableFuture().copy().orTimeout(...)복사본에 건다. 만료가 transport 자신의 stage를 완료시키지 않게 해서 어댑터가 자기 in-flight publish의 소유권을 유지한다. javadoc이 이전 상태를 적는다: "transports accepted request.options() and read nothing from it, so the deadline belonged to whatever the broker client happened to be configured with — a Rabbit publish waiting on a confirm that never arrives had no deadline at all."

3.4 P2 — capability 12개 중 main 코드가 읽는 것은 3개, 거부하는 것은 1개

MessagingCapabilities의 클래스 javadoc이 이 record의 계약을 선언한다:

"When a profile asks for something absent here the platform fails loudly — at startup where possible, otherwise with a capability exception — rather than quietly degrading, because a silently weakened guarantee is indistinguishable from a working one until the incident."

12개 플래그 전체에 대해 main/test 참조를 센 결과:

플래그 main test main에서 하는 일
brokerAcknowledgement 0 1
replicationOrPersistenceEvidence 0 0
perMessageSettlement 0 1
batchSettlement 0 0
orderedStream 1 1 DefaultRetryDecisionEngine:49 — 있으면 순서보존 재시도 선택
keyedOrdering 0 3
replay 0 2
delayedDelivery 1 0 DefaultRetryDecisionEngine:64 — 있으면 BROKER_DELAYED 사용
brokerTransaction 0 3
deduplicatedPublish 1 1 DefaultMessagePublisher:250없으면 예외
nativeDeadLetter 0 1
topologyManagement 0 0

(census의 orderedStream main=5 중 4건은 Spring ObjectProvider.orderedStream()으로 이름이 겹친 오탐이다. 실제 플래그 참조는 1건.)

따라서:

  • 12개 중 9개는 모든 브로커 어댑터가 선언하지만 main 코드 어디서도 읽지 않는다.
  • 읽는 3개 중 2개(orderedStream, delayedDelivery)는 거부가 아니라 분기다 — 없으면 재시도 엔진이 조용히 다른 모드를 고른다. 이것이 javadoc이 "quietly degrading"이라 부른 그 동작이다.
  • 거부하는 것은 deduplicatedPublish 하나뿐이다:
private void requireSupportedOptions(DestinationProfile profile, PublishOptions options) {
  if (options.deduplication().isPresent()
      && !transport.capabilities(profile.name()).capabilities().deduplicatedPublish()) {
    throw new MessagingConfigurationException("PUBLISH_DEDUPLICATION_UNSUPPORTED", ...);
  }
}

메서드 이름은 requireSupportedOptions(복수)이지만 검사하는 옵션은 하나다. PublishOptionstimeout · confirmation · deduplication 세 성분이고, 그중 confirmation은 여기서 검사되지 않는다.

실패 시나리오. PublishOptions.defaults()ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK을 기본값으로 쓴다(PublishOptions:43). replicationOrPersistenceEvidence=false인 브로커에 붙은 destination으로 publish하면 — 중앙 경로는 아무것도 거부하지 않고, 어댑터는 요청받은 requirement를 그대로 ConfirmationLevel로 되돌려준다:

// KafkaPublishMapper:95-101, RabbitConfirmCoordinator:265-268 — 같은 식이 두 곳에 있다
ConfirmationLevel level =
    requirement == ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK
        ? ConfirmationLevel.REPLICATION_OR_PERSISTENCE_ACK
        : ConfirmationLevel.BROKER_ACK;

보고되는 confirmation level은 브로커가 준 것이 아니라 프로파일이 요구한 것이다. Kafka 매퍼의 javadoc이 이 위험을 알고 있고, 그것을 다른 장치에 위임한다 — "the producer is configured with acks=all for any profile asking for replication evidence and the profile guard refuses the combination where it is not." 그 profile guard가 무엇인지는 §3.5와 §4.4에서 확인한다.

ConfirmationLevel의 javadoc이 약속한 "표현 가능하고 따라서 거부 가능"의 거부 쪽 절반이 이 경로에 없다. 두 어댑터가 requirement를 level로 그대로 승격시키는 한, PublishEvidence.confirmed(level)이 담는 값은 관측이 아니라 요청의 반복이다.

증거: 247-messaging-capability-flag-enforcement-census.txt

3.5 P2 — 8개 profile validator 중 조립에서 실행되는 것은 3개

StartupProfileValidation의 javadoc이 이미 한 번 고쳐진 같은 결함을 서술한다:

"The Kafka, RabbitMQ and security validators were all beans and none of them was injected anywhere: the context published a validator per broker and validated nothing. A profile that promises a guarantee its broker cannot give — an exactly-once claim on a non-transactional producer, a quorum ack on a single replica, a plaintext credential on a production listener — then boots cleanly and fails on the first message that depends on it."

수정 방식도 정확하다 — InitializingBean.afterPropertiesSet으로 돌려서 "컨텍스트가 아직 만들어지는 중에 실패하고 원인이 된 프로파일 bean이 스택에 이름으로 남게" 한다. 그리고 프로파일을 ObjectProvider가 아니라 Supplier로 받는다 — 애플리케이션이 선언한 bean과 app.messaging에서 컴파일된 프로파일 두 출처를 모두 보기 위해서다.

그 수정이 적용된 것은 2개다. main에 존재하는 profile validator 8개 전체의 도달성:

validator leaf 출하? 조립 지점 실행되는가
DestinationProfileValidator policy 출하 MessagingCoreAutoConfiguration:134 (validateAll 직접 호출)
KafkaProfileValidator kafka 출하 KafkaMessagingAutoConfiguration:58StartupProfileValidation
RabbitProfileValidator rabbit 출하 RabbitMessagingAutoConfiguration:54StartupProfileValidation
KafkaTransactionProfileValidator kafka (출하) 출하 KafkaMessagingAutoConfiguration:74@Bean 선언만, 주입처 없음
KafkaShareProfileValidator kafka-share build-only KafkaShareGroupRegistrar가 보유 — 그 registrar는 테스트에서만 생성 ✘ (등급 일치)
NatsJetStreamProfileValidator nats build-only 참조가 javadoc 문장 하나뿐 ✘ (등급 일치)
PulsarProfileValidator pulsar build-only 참조 0건 — 테스트조차 없다 ✘ (등급 일치)
BindingProfileValidator scs-bridge build-only 테스트에서만 생성 ✘ (등급 일치)

아래 넷은 build-only leaf에 있다(§1.1). 어떤 런타임에도 오르지 않으므로 조립 지점이 없는 것이 등급과 일치한다 — 모듈 17 §26.6의 원칙대로 오늘의 사고가 아니라 채택 시점의 부채다. 다만 PulsarProfileValidator테스트조차 없어서 다른 셋과도 다르다: 나머지 build-only validator 3개는 최소한 단위 테스트가 그 규칙을 실행한다.

출하되는 leaf에서 조립되지 않은 validator는 KafkaTransactionProfileValidator 하나다. 그래서 이 항목만 P2다.

KafkaTransactionProfileValidator가 가장 날카롭다. 같은 자동설정 클래스 안에서, StartupProfileValidation으로 감싼 kafkaProfileStartupValidation(:56) 바로 아래에 @Bean 선언만 있고(:74) 아무도 주입하지 않는다. 그 validator의 javadoc은 자기 마지막 규칙을 이렇게 설명한다:

"The last rule is the important one. A destination that declares INBOX_TRANSACTIONAL is telling the platform its side effect lives in a database, and a Kafka transaction cannot span that. Allowing both to be configured together would let a team read 'transactional' twice and conclude the whole path is atomic when the two halves can still diverge."

그 규칙을 포함해 5개 규칙(transactional id prefix 필요 / idempotence 필요 / acks=all 필요 / manual commit 필요 / INBOX_TRANSACTIONAL 금지)이 한 번도 실행되지 않는다.

실패 시나리오. Kafka 트랜잭션을 쓰는 destination에 externalSideEffectGuarantee = INBOX_TRANSACTIONAL을 같이 설정한다. 부팅은 깨끗하게 성공한다. 브로커 트랜잭션은 Kafka 쪽만 커밋하고 DB 쪽은 별도 커밋이므로, 두 커밋 사이에 프로세스가 죽으면 갈라진다. 팀은 설정 두 곳에서 "transactional"을 읽었으므로 원자성을 가정하고 보정 로직을 쓰지 않았다. 이것이 validator가 막으려던 바로 그 상황이고, 막는 코드는 존재하며 테스트도 없고 호출도 없다.

그리고 acks=all 규칙이 §3.4가 위임한 그 profile guard다 — Kafka 매퍼의 javadoc이 "the profile guard refuses the combination where it is not"라고 말한 대상. 그 guard가 실행되지 않으므로 §3.4의 위임이 닫히지 않는다.

증거: 248-messaging-profile-validator-reachability.txt, 249-messaging-startup-profile-validation.txt

3.6 P3 — messaging-reliability-api는 main 13파일 · 817 LOC에 테스트가 0개다

sub-scope 01의 네 leaf 중 유일하게 src/test가 없다.

messaging-core-api        main=85  test=8
messaging-transport-spi   main=13  test=4
messaging-reliability-api main=13  test=0     <-
messaging-runtime-core    main=6   test=4

담고 있는 것은 Outbox/Inbox 계약이다 — OutboxRecord, OutboxStatus, OutboxLease, OutboxTransitionResult, OutboxCanonicalMetadata, InboxRecord, InboxResult, InboxRepository, IdempotentMessageHandler, ReliableMessagePublisher, TransactionalMessageAction, ClaimCheckReference, OutboxRepository. 대부분 interface·record·enum이지만 OutboxTransitionResult/OutboxStatus는 상태 전이 규칙을 담을 수 있는 타입이다.

sub-scope 05(messaging-outbox-jdbc·-inbox-jdbc)에서 구현 쪽 테스트가 이 계약을 어디까지 덮는지 확인한다(§7).

3.7 P3/기록 — CertifiedEvidenceTest의 첫 테스트는 이름이 주장하는 것을 증명하지 않는다

@Test
void everyRecordedScenarioIsALineTheCertificationLaneWrote() throws IOException {
  List<String> manifestLines = manifest();
  assertThat(manifestLines).as("the certification lane writes this file; nothing else may").isNotEmpty();
  assertThat(CertifiedEvidence.recorded())
      .as("coverage is read from the manifest, not from a list somebody maintained beside it")
      .containsExactlyElementsOf(manifestLines.stream().map(BrokerCertificationEvidence::fromJson).toList());
}

이 단언은 같은 파일을 두 경로로 읽어 비교한다 — CertifiedEvidence.recorded()가 그 manifest를 파싱한 결과이므로, 증명되는 것은 로더가 충실하다는 것뿐이다. 클래스 javadoc이 스스로 세운 기준("the only thing that can tell them apart is where the values come from — which is what these tests check")을 이 테스트 하나만으로는 충족하지 못한다.

그러나 그 기준을 실제로 지키는 장치는 다른 곳에 있다 — §2.3의 verifyMessagingCertificationEvidence가 실행 산출물과 커밋본을 대조하고 CI가 그것을 돌린다. 따라서 이것은 결함이 아니라 이름과 배치의 문제다. test 레인에서 도는 이 단언이 이름만 보면 출처를 보증하는 것처럼 읽히고, 실제 보증은 Docker가 필요한 별도 레인에 있다. 기록으로 남긴다.


4. sub-scope 02 — schema (41 파일)

leaf 출하? main test 내용
messaging-schema-api 출하 10 3 MessageCodec·MessageCodecRegistry·EncodedMessage·MessageContractKey·SchemaReference·SchemaRegistry·SchemaCompatibility·SchemaCompatibilityValidator·RawBytesMessageCodec·BoundedByteSink
messaging-schema-json 출하 1 3 JacksonMessageCodec
messaging-schema-avro build-only 2 3 AvroMessageCodec·AvroCompatibilityGate
messaging-schema-protobuf build-only 2 1 ProtobufMessageCodec·ProtobufMessageContract
messaging-cloudevents 출하 3 1 CloudEventMapper·DefaultCloudEventMapper·CloudEventExtensions

4.1 검증된 설계 — 인코딩 한도가 보고 기준이 아니라 할당 경계다

BoundedByteSinkOutputStream을 상속해 한도를 넘기는 write에서 실패한다. javadoc이 이전 상태를 적는다:

"Every codec here used to serialize into an unbounded buffer and compare bytes.length to the configured maximum afterwards. That makes the maximum a reporting threshold rather than an allocation bound: a payload whose graph expands to hundreds of megabytes exhausts the heap while being written, and the check that would have rejected it never runs. Under a broker consumer that is a process-wide outage caused by one message."

그리고 ByteArrayOutputStream(Math.min(maxBytes, 8_192)) — "1 GiB 한도가 1 GiB를 미리 잡으면 안 된다."

세 코덱(JSON·Avro·Protobuf) 전부가 이 sink를 쓴다(도달성 확인). 예외 코드는 코덱별로 다르다(PAYLOAD_TOO_LARGE / AVRO_PAYLOAD_TOO_LARGE) — 같은 예외 타입에 코덱 고유 코드를 실어 보내는 방식이고 일관된다.

4.2 검증된 설계 — 기본 코덱을 "먼저 등록된 것"으로 고르지 않는다

RegisteredMessageCodecs.of(defaultCodec, codecs...)는 기본 코덱을 명시 인자로만 받고, 두 가지를 거부한다:

  • raw-bytes 코덱을 기본으로 지정하는 것 — "인코딩을 선언하지 않은 모든 destination이 스키마 검증을 조용히 건너뛰게 된다. 그럴 수 있는 registry는 선언 누락을 검사 생략으로 바꾼다."
  • 한 content type을 두 코덱이 주장하는 것 — "런타임에 해결할 선호가 아니다: 어느 쪽이 이기든 다른 쪽으로 컴파일된 consumer가 wire의 바이트를 읽는 방식을 결정한다."

그리고 기본 계약 카탈로그가 비어 있음이다. 자동설정 javadoc:

"The message contracts ... default to empty, which means no message type can be encoded and every publish is refused at preparation. That is fail-closed on purpose: a codec that accepted an unregistered type would serialise whatever object it was handed and put bytes on a topic that consumers compiled against a different shape will read."

4.3 P2 — 스키마 호환성 검증기는 출하 leaf에 있고, main 코드에서 호출되지 않는다

SchemaCompatibilityValidator(messaging-schema-api, 출하)의 main 참조는 0건이다. 테스트 1개뿐.

이 클래스가 하는 일은 두 가지다:

  1. versionsToCheck(subject) — 호환성 모드에 따라 후보 스키마를 비교해야 할 버전 목록(transitive면 전체 이력, pairwise면 직전 하나)을 돌려준다.
  2. requireProductionMode(subject, destination)NONE_EXPERIMENTAL을 production destination에서 거부한다.

두 번째의 근거가 클래스 javadoc에 있다:

"NONE_EXPERIMENTAL is refused for production destinations. A mode that checks nothing is useful while a message type is being designed and actively dangerous once a retained log exists, because the log outlives every consumer that could still read it."

그리고 그 모드는 설정으로 켤 수 있다. DestinationSettings.Schema:

@DefaultValue("BACKWARD_TRANSITIVE") SchemaCompatibility compatibility,

@DefaultValue가 안전한 값이지만, app.messaging.destinations.<name>.schema.compatibility=NONE_EXPERIMENTAL을 쓰면 그대로 통과한다 — DestinationProfileValidator의 16개 규칙에 스키마 항목이 없고(§3.5), requireProductionMode는 호출되지 않는다.

부수적으로 SchemaRegistry에는 main 구현이 하나도 없다. 유일한 구현은 SchemaCompatibilityValidatorTestFixedRegistry다. 즉 검증기가 읽어야 할 스키마 이력의 출처 자체가 프로덕션에 존재하지 않는다. SchemaCompatibilityValidator를 호출하려 해도 넘길 registry가 없다 — §3.5의 MessageCodecRegistry가 겪었고 RegisteredMessageCodecs로 해결된 것과 같은 모양이며, 이쪽은 아직 해결되지 않았다.

실패 시나리오. 운영자가 한 destination에 compatibility: NONE_EXPERIMENTAL을 설정한다(설계 중이라는 이유로, 정당하게). 그 설정이 그대로 프로덕션으로 나간다. Kafka 보존 로그에 v1 스키마로 쓴 메시지가 남고, 이후 v2가 필드를 삭제하며 배포된다. 어떤 게이트도 그 삭제를 막지 않았고, 로그를 다시 읽는 consumer가 파싱에 실패한다. 이것이 javadoc이 "the log outlives every consumer"라고 적은 상황이며, 막는 코드는 존재하고 호출되지 않는다.

4.4 P2 — 호환성 게이트를 가진 두 포맷은 build-only이고, 출하되는 유일한 코덱에는 게이트가 없다

코덱 출하? 조립 지점 호환성 게이트
JacksonMessageCodec (JSON) 출하 MessagingCoreAutoConfiguration:366 messagingCodecs 없음
AvroMessageCodec build-only 없음 AvroCompatibilityGate (테스트에서만 실행)
ProtobufMessageCodec build-only 없음 ProtobufCompatibilityTest
RawBytesMessageCodec 출하(leaf) 없음 — 기본 코덱 금지 대상 n/a
DefaultCloudEventMapper 출하 없음 n/a

위험 방향이 뒤집혀 있다. 스키마 진화 검사가 존재하는 두 포맷(Avro·Protobuf)은 어떤 런타임에도 오르지 않고, 실제로 wire에 바이트를 쓰는 유일한 코덱(JSON)에는 포맷 수준의 호환성 게이트가 없다. §4.3의 포맷 독립 검증기(SchemaCompatibilityValidator)가 그 공백을 메울 자리인데 그것도 호출되지 않는다.

JSON의 진화 위험이 Avro보다 작은 것은 사실이지만 0은 아니다 — 필드 삭제, 타입 변경, enum 값 제거는 Jackson에서 런타임 역직렬화 실패로 나타난다. 그리고 SchemaPolicy가 destination마다 compatibility 모드를 선언하게 되어 있으므로(SchemaPolicy(codec, compatibility, messageTypes)), 선언은 있고 집행이 없는 상태다.

build-only 두 leaf의 미조립 자체는 등급과 일치하므로 결함이 아니다. 결함은 출하되는 쪽에 대응하는 게이트가 없다는 비대칭이다.

4.5 P2 — messaging-cloudevents는 출하 leaf이고 starter의 의존이며 소비자가 없다

modules.json:  messaging-cloudevents  runtime_memberships = ["app-bootstrap"]
build.gradle:  implementation project(':messaging:messaging-cloudevents')   <- starter가 문다

그런데 main 코드에서 DefaultCloudEventMapper를 만드는 곳은 0곳이고, CloudEventMapper·CloudEventExtensions를 참조하는 main 파일은 DefaultCloudEventMapper 자신뿐이다. 자동설정 28개 클래스 어디에도 CloudEvents 이름이 없다.

이 leaf는 3개 main 파일 전부가 출하 아티팩트에 들어가고 아무 경로에서도 호출되지 않는다. §1.1의 build-only 면제가 적용되지 않는다 — runtime_memberships["app-bootstrap"]이므로 이것은 출하되는 죽은 표면이다.

CloudEvents는 wire 상호운용 규격이므로, 이 상태의 구체적 위험은 "안 쓰이는 코드가 있다"가 아니라 CloudEvents 헤더로 메시지를 받을 것으로 기대하는 외부 소비자와의 계약이 아무 데서도 성립하지 않는다는 것이다. CanonicalEnvelopeHeaders(core-api)와 CloudEventExtensions 사이의 매핑이 존재하지만 어떤 publish/consume 경로도 그 매핑을 통과하지 않는다.

증거: 252-messaging-schema-and-observability-reachability.txt, 254-messaging-autoconfiguration-imports.txt


5. sub-scope 03 — policy · security · observability (66 파일)

leaf 출하? main test
messaging-policy 출하 26 4
messaging-security 출하 12 3
messaging-observability 출하 9 6

5.1 P2 — 출하되는 publish 경로는 관측을 하나도 기록하지 않는다

DefaultMessagePublisherMessagingObservation생성자 인자로 받는다. 그 필드의 javadoc이 이유를 적는다:

"MessagingObservation existed as a bean and no publish path called it, so the platform's own metrics described nothing. It is a constructor argument rather than an optional decorator because an unobserved publish path is how 'the dashboards were empty during the incident' happens."

수정은 runtime-core에서 정확히 이뤄졌다. 그런데 조립이 그 수정을 되돌린다. 자동설정이 고르는 것은 6인자 생성자다:

// MessagingCoreAutoConfiguration:445-447
return new dev.caskeleton.messaging.runtime.DefaultMessagePublisher(
    destinations, access, codecs, admission, runtimes, transport);

그 6인자 생성자는 8인자 생성자에 NO_OBSERVATION을 넘긴다 — 다섯 메서드가 전부 빈 본문인 익명 구현이다.

그리고 관측 leaf 전체에 프로덕션 소비자가 없다. messaging-observability(9 main, 출하)의 도달성:

타입 종류 main 참조 파일 조립
MessagingMetrics final class implements MessagingObservation (Micrometer) 0 없음
MessagingTracer final class 0 없음
MessagingAuditSink interface 0 main 구현 없음
DefaultMessagingObservationConvention final class 0 없음
MessagingRedactor final class 5 @Bean 있음 (:253)
CardinalityGuard final class 3 @Bean 있음 (:264)

MessagingCoreAutoConfiguration에서 관측 관련으로 등장하는 이름은 MessagingRedactorCardinalityGuard 둘뿐이다(grep 결과 4행). 즉 방출자는 하나도 만들지 않고 방출자의 협력자 두 개만 bean으로 공개한다. 그 두 bean은 아무도 주입받지 않는다.

MessagingMetrics는 버려진 코드가 아니다 — 카디널리티 설계가 구체적이다:

"Every tag set passes the CardinalityGuard before a meter is created. That ordering is the whole point: a meter registry never forgets a series, so a single tag carrying a message id permanently inflates the backend. ... Logical messages and physical attempts are separate meters. One message redelivered four times is one publish and five attempts; a single counter would make a redelivery storm read as traffic growth and hide the incident."

실패 시나리오. app.messaging.enabled=true로 이 플랫폼을 켠 배포에서 재배달 폭풍이 발생한다. messaging.publish 계열 메트릭이 하나도 만들어지지 않았으므로 대시보드는 비어 있고, MessagingAuditSink 구현이 없으므로 감사 로그도 없으며, MessagingTracer가 조립되지 않았으므로 트레이스 링크도 없다. 남는 것은 애플리케이션 로그뿐이고, DefaultMessagePublisher가 예외 메시지를 의도적으로 버리므로(§3.2) 거기에는 예외 타입 이름만 있다.

정보를 버리는 그 결정(§3.2)은 관측 경로가 살아 있을 때 옳다. 관측 경로가 no-op일 때 그 둘이 합쳐지면 진단 가능한 흔적이 남지 않는다. 이것이 이 발견을 P3이 아니라 P2로 두는 이유다.

증거: 253-messaging-observation-assembly.txt

5.2 P2 — 브로커 ACL 매니페스트의 자기 점검이 존재하지 않는다

BrokerAclManifest(messaging-security, 출하) — main 참조 0건, 테스트 1건.

record의 javadoc이 자기 용도를 명시한다:

"Written down so the grant can be reviewed and diffed rather than discovered from a broker dump. The manifest is what the platform checks itself against at startup: a runtime that holds more than it declares is a finding, because the extra permission is the one nobody reasoned about. Destructive permissions are named separately from ordinary ones. DELETE_TOPIC and PURGE are not 'write, but more'; they destroy data an application can never restore, so an application runtime declaring one is rejected outright."

두 문장 모두 실행되는 코드가 아니다:

  • "checks itself against at startup" — 그 startup 검사가 없다. StartupProfileValidation으로 감싼 것은 Kafka·Rabbit 브로커 프로파일 두 개뿐이다(§3.5).
  • "an application runtime declaring one is rejected outright" — 그 거부도 없다. DELETE_TOPIC/PURGE를 선언한 매니페스트를 만드는 것을 막는 코드가 record 자신에도 없다(compact constructor는 principal/pattern 공백만 검사).

브로커 권한이 선언보다 넓은지 확인하려면 브로커에 질의해야 하므로 "runtime이 선언보다 많이 들고 있는지"의 완전한 검사는 어댑터 협조가 필요하다. 그러나 매니페스트 자체의 파괴적 권한 거부는 순수 로컬 검사이고, javadoc이 "rejected outright"라고 단정한 것이며, 지금 아무 곳에서도 일어나지 않는다.

5.3 P3 — 접근 검사가 두 갈래로 존재하고, 조립된 쪽이 진단이 약한 쪽이다 (§8.3)

같은 권한 검사가 두 형태로 있다.

(a) 조립된 쪽DefaultMessagePublisherDestinationAccessPolicy를 직접 호출:

if (!access.mayPublish(destination.name())) {
  return rejected("PUBLISH_FORBIDDEN",
      "this application may not publish to '" + destination.name().value() + '\'', startedAt);
}

FailureCategory.CONFIGURATION · retryable=falsePublishResult를 돌려준다.

(b) 조립되지 않은 쪽DestinationAccessValidator (main 참조 0건, 테스트 0건):

public void requirePublish(DestinationName destination) {
  if (!policy.mayPublish(destination)) {
    throw new MessageAuthorizationException("DESTINATION_PUBLISH_DENIED",
        "the producer credential may not publish to " + destination.value());
  }
}

클래스 javadoc: "Runs before the broker's ACL, and the failure it raises names the logical destination and the role. A broker ACL denial arrives as a connection-level error with no application context, which makes 'which module tried to publish where' an investigation rather than a log line."

두 경로의 차이는 분류다. (a)는 인가 거부를 CONFIGURATION으로 분류하고, (b)는 MessageAuthorizationException을 던진다. messaging-core-api의 26개 예외에는 MessageAuthorizationException이 명시적으로 있고 FailureCategory에도 대응 항목이 있을 것으로 기대되는데, 실제 publish 경로는 그 타입을 쓰지 않는다.

권한 거부가 CONFIGURATION으로 집계되면 "설정 실수"와 "권한 침해 시도"가 같은 버킷에 들어간다. 실제 검사 자체는 (a)에서 수행되므로 보안 구멍은 아니다 — 분류와 진단의 문제이고, 중복 장치 중 조립되지 않은 쪽이 더 정확한 분류를 갖고 있다.

5.4 P3 — 자격 증명 회전 개념이 두 번 표현되고, 하나만 살아 있다 (§8.3)

  • 살아 있는 쪽: CredentialRuntimeRegistry + CredentialRuntime.isDueForRotation(now). 레지스트리가 resolve 시점에 회전을 수행하고, dueForRotation(now)로 회전 창에 든 자격 증명 id를 열거한다. 동시성 계약이 테스트로 고정돼 있다 — CredentialRotationContractTest가 "100개 동시 resolve가 한 번만 fetch하고 한 세대만 공개"를 검증하고, 이전 결함(get→fetch→put→clear 무동기화로 교체본 하나가 clear 없이 버려져 아무도 소유하지 않는 비밀이 메모리에 남던 상태)을 서술한다.
  • 죽은 쪽: CredentialRotationPlan record — main 참조 0, 테스트 참조 0. isDue(Instant)를 갖고 있고 "Rotation is scheduled ahead of expiry rather than triggered by an authentication failure"라는 같은 원칙을 담는다.

같은 개념의 두 표현 중 하나가 완전히 죽어 있다. 살아 있는 쪽이 더 나은 쪽(동시성 계약 + 테스트)이므로 위험은 낮다. 기록으로 남기고, 제거 대상 후보로 표시한다.

5.5 검증된 설계 — 재시도 결정이 capability를 읽는 두 지점

DefaultRetryDecisionEngine이 §3.4에서 센 12개 플래그 중 2개를 읽는 유일한 main 코드다:

// :49  순서 보존이 필요한데 destination이 ordered stream이면
&& context.capabilities().orderedStream()
// :64  BROKER_DELAYED 모드인데 브로커가 지연 전달을 지원하면
if (policy.mode() == RetryMode.BROKER_DELAYED && context.capabilities().delayedDelivery())

둘 다 분기이지 거부가 아니다. 재시도 엔진에서는 이것이 합리적이다 — 브로커가 지연 전달을 못 하면 인프로세스 백오프로 떨어지는 것이 정상 동작이다. §3.4가 문제 삼는 것은 이 두 지점이 아니라, 거부해야 하는 나머지 플래그에 거부가 없다는 점이다.

5.6 P3/기록 — messaging-security의 비밀 유출 검사는 관측 leaf에 있고, 정적 스캐너로 이중화돼 있다

messaging-observability의 테스트 6개 중 3개가 유출 방지 계약이다 — MessagingSecretLeakTest, SecretLeakStaticScanTest, SecretLeakScannerCharacterizationTest. 마지막 것이 특히 드물다: 스캐너 자신의 동작을 특성화 테스트로 고정한다(스캐너가 무엇을 잡고 무엇을 놓치는지를 테스트로 적어 둔다).

MessagingRedactor(main 참조 5, bean 등록됨)가 런타임 편집을 담당하고, 정적 스캔이 소스 수준을 담당한다. 두 층이 서로를 대체하지 않고 보완한다.

다만 §5.1과 겹쳐 읽으면 한계가 보인다 — MessagingRedactorMessagingMetrics·MessagingAuditSink·MessagingAuditEvent·MessagingObservation에서 참조되는데, 그 넷 중 조립되는 것이 없다. 즉 런타임 편집기는 bean으로 존재하지만 편집할 대상 경로가 조립돼 있지 않다. 정적 스캔은 조립과 무관하게 계속 유효하다.


6. sub-scope 04 — brokers (134 파일)

leaf 출하? main test main LOC 등급(CompatibilityMatrix)
messaging-kafka 출하 34 24 3,427 STABLE
messaging-rabbit 출하 20 10 2,443 EXPERIMENTAL
messaging-pulsar-experimental build-only 8 2 663 EXPERIMENTAL
messaging-nats-experimental build-only 7 2 755 EXPERIMENTAL
messaging-kafka-share-experimental build-only 4 1 190 EXPERIMENTAL
messaging-spring-cloud-stream-bridge build-only 6 2 507 표에 없음

6.1 검증된 설계 — 전송 선택이 classpath 사고가 아니라 속성이다

MessagingProviderSelection의 javadoc이 이전 상태를 적는다:

"Selection used to be @ConditionalOnClass: Kafka assembled because a Kafka class was on the classpath and Rabbit because a Rabbit class was, so an application with both libraries — one transitive dependency is enough — assembled both and published through whichever bean won a @ConditionalOnMissingBean race. Nothing failed; the message simply went somewhere nobody chose."

그리고 starter가 messaging-kafkamessaging-rabbit둘 다 물면서 각각 클라이언트 라이브러리를 api로 노출하므로 그 조건은 모든 채택자에게 참이었다 — 즉 @ConditionalOnClass는 선택할 수 없는 조건이었다.

지금은 세 가지가 침묵이 아니라 startup 오류다: (1) 레지스트리 밖 broker id, (2) 등록됐지만 클라이언트 라이브러리가 없는 broker, (3) messaging이 켜졌는데 broker가 비어 있는 경우.

그리고 MessagingPlatformRootAutoConfiguration이 master switch를 단독 소유한다:

@AutoConfiguration
@ConditionalOnProperty(prefix = MessagingSettings.PREFIX, name = "enabled", havingValue = "true")
@Import({MessagingCoreAutoConfiguration.class, MessagingProviderSelection.class,
         MessagingReliabilityAutoConfiguration.class, MessagingAdminAutoConfiguration.class})

matchIfMissing이 없다 — 기본 꺼짐, fail-closed. AutoConfiguration.imports에 등록된 클래스도 이 하나뿐이다. 자식 설정은 조건을 갖지 않는다:

"one root owning the condition, importing children that carry none, so a bean added to any child next month is gated without anyone remembering to repeat a condition."

이것이 모듈 14 §8.1이 web 어댑터에서 없다고 판정한 바로 그 구조다 — messaging은 갖고 있다.

6.2 P2 — messaging-rabbit은 출하되지만 선택할 수 없고, 운영 문서는 그것을 말하지 않는다

MessagingProviderSelectionBROKERS_WITHOUT_A_TRANSPORT 맵이 있다:

static final Map<String, String> BROKERS_WITHOUT_A_TRANSPORT =
    Map.of("rabbit",
        "the Rabbit adapter ships its validators and security configuration but no "
            + "MessagingTransport: its native channel publisher is not implemented, so a publish "
            + "has nothing to travel on");

확인:

  • RabbitChannelPublisher는 interface이고 구현이 어디에도 없다(main·test 통틀어 implements RabbitChannelPublisher 0건).
  • RabbitMessagingAutoConfiguration에는 MessagingTransport bean이 없다. KafkaMessagingAutoConfiguration에는 있다(:164 messagingKafkaTransport).

코드 쪽은 훌륭하다. 선택 오류를 다른 선택 오류들 옆에서 이름으로 거부하고, javadoc이 "Registered is not the same as assemblable"라고 구분을 명시하며, "An entry leaves this map on the day its transport does exist"로 해제 조건까지 적는다. 이것은 결함이 아니라 fail-closed 설계다.

결함은 운영 문서다. docs/messaging/support-matrix.md의 브로커 등급 표:

브로커 등급 인증 기준 Stable 기능 제한
RabbitMQ Experimental 4.3.x exchange/routing, publisher confirm, mandatory return, manual ACK, quorum queue, retry queue, DLQ 장애 시나리오 레인 미실행 — 증거 없음. stream 및 특수 plugin 미지원

제한 칸이 말하는 것은 "증거가 없다"뿐이다. "전송이 구현되지 않아 선택 자체가 startup 오류"라는 사실이 어디에도 없다. 기능 칸은 publisher confirm·mandatory return·quorum queue를 나열하고 있어, 읽는 사람은 "검증은 덜 됐지만 쓸 수는 있는 옵션"으로 이해한다.

실패 시나리오. 팀이 지원 매트릭스를 읽고 RabbitMQ를 고른다(Experimental이지만 기능 목록이 요구사항을 덮으므로 합리적 선택이다). app.messaging.enabled=true, app.messaging.broker=rabbit으로 배포한다. 애플리케이션이 부팅에 실패한다. 실패 메시지 자체는 정확하지만, 그 정보가 선택을 하기 전에 도달했어야 한다. 그리고 이 상태는 임시가 아니다 — 2,443 LOC짜리 어댑터가 20개 main 파일로 출하 아티팩트에 들어 있고, 그중 전송 경로만 없다.

즉 이 어댑터에서 "장치는 있고 회로가 닫히지 않았다"가 코드에서는 정직하게 공개돼 있고 문서에서만 공개되지 않았다.

6.3 P1 — 지원 매트릭스가 Kafka의 deduplicatedPublishO로 적고, 코드는 false이며, 그 차이가 정확히 코드가 경고한 피해다

docs/messaging/support-matrix.md의 capability 표(5개 어댑터 × 12개 플래그 = 60칸)를 코드의 MessagingCapabilities 배열과 전수 대조했다. 일치하지 않는 칸은 하나다.

record 성분 순서: brokerAcknowledgement, replicationOrPersistenceEvidence, perMessageSettlement,
                  batchSettlement, orderedStream, keyedOrdering, replay, delayedDelivery,
                  brokerTransaction, deduplicatedPublish, nativeDeadLetter, topologyManagement

KafkaMessagingTransport.CAPABILITIES =
    (true, true, true, true, true, true, true, false, true, FALSE, false, true)
                                                              ^^^^^ deduplicatedPublish

문서:

| deduplicatedPublish | O | X | X | X | O |
                        ^ Kafka

Rabbit·Kafka Share·Pulsar·NATS의 48칸은 전부 코드와 일치한다(NATS의 deduplicatedPublish=trueO, Rabbit의 nativeDeadLetter=trueO, Pulsar의 keyedOrderingKey_Shared만으로 두 배열 차이까지 반영 — 문서가 코드보다 더 정밀한 칸도 있다). 틀린 칸은 Kafka의 dedup 하나다.

그리고 그 칸이 하필 §3.4에서 확인한 12개 중 유일하게 실제로 거부를 발생시키는 플래그다. 코드의 javadoc은 그 값이 true였다가 바뀐 이력과 이유를 길게 적는다:

"It was true, justified by producer idempotence. Producer idempotence deduplicates sequence retries within one producer session: the producer id is reassigned on restart, so the same logical message published again after a crash is a new sequence and the broker stores it twice. The capability an application reads is 'the broker suppresses duplicate publishes of a stable id', which needs a persistent message-id store that this platform does not have.

Declaring it true means PublishDeduplication is accepted and silently does nothing — the caller believes the broker is deduplicating and skips the idempotency it would otherwise build. False makes that request a startup failure instead, which is the whole reason the flag exists."

문서가 지금 그 true를 말하고 있다.

실패 시나리오 (코드 javadoc이 서술한 것 그대로, 문서를 통해 발생):

  1. 팀이 지원 매트릭스에서 Kafka deduplicatedPublish = O를 읽는다.
  2. "브로커가 안정된 id의 중복 publish를 억제한다"는 문서상 보장을 근거로, 소비자 측 멱등성 구현을 생략한다 — 이것이 그 플래그를 읽는 유일한 이유다.
  3. 프로듀서가 크래시 후 재시작하며 같은 논리 메시지를 다시 publish한다. producer id가 재할당됐으므로 브로커는 새 시퀀스로 보고 두 번 저장한다.
  4. 중복이 소비자에 도달하고, 생략된 멱등성이 있어야 할 자리에 아무것도 없다.

코드는 옳고, 코드가 옳게 하려고 남긴 문서가 틀렸다. 이 항목을 P1로 두는 이유는 세 가지다 — (a) 지금 배포 가능한 유일한 브로커(§6.2)에 대한 것이고, (b) 데이터 정합성 보장에 관한 것이며, (c) 코드 자신이 "이 믿음이 피해"라고 이름 붙인 바로 그 믿음을 문서가 만들어 낸다.

부수 관찰(결함 아님). Rabbit의 delayedDelivery는 코드가 true, 문서가 retry queue로 대행이다. AMQP에 네이티브 지연 전달이 없고 RabbitRetryQueueTopology(TTL+DLX)가 그것을 대행하므로, 문서 쪽이 코드 플래그보다 더 정확하다. MessagingCapabilities가 "브로커 네이티브"와 "어댑터 제공"을 구분하지 않는다는 점을 기록으로 남긴다.

증거: 255-messaging-capability-doc-vs-code-drift.txt

6.4 P2 — 지원 매트릭스가 "모든 messaging leaf는 build-only"라고 적고, 가족 권위 문서는 그 문장이 틀렸다고 이미 기록했다

docs/messaging/support-matrix.md:

"또한 registry의 messaging leaf는 모두 runtime_memberships가 비어 있다. 이는 build-only / incubating — 어느 composition root에도 편입되지 않았다는 뜻이며, 아래의 등급과는 다른 축이다."

src/messaging/CLAUDE.md (같은 저장소, 이 가족의 로컬 권위):

"이 절은 한동안 사실이 아닌 채로 남아 있었다. 'registry의 모든 messaging leaf는 runtime_memberships가 비어 있고 따라서 build-only'라고 쓰여 있었는데, 다섯 어댑터 remediation이 messaging-spring-boot-starterapp-bootstrap 의존성으로 넣으면서 그 closure 전체가 런타임 classpath에 올라갔다. 정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다."

레지스트리 실측(§1.1): 출하 18 / build-only 7.

같은 저장소의 두 문서가 정반대를 말하고, 한쪽은 자기가 틀렸었다는 사실과 그 원인(산문에서 세는 것)까지 적어 두었으면서, 다른 쪽은 고쳐지지 않았다. 그리고 고쳐지지 않은 쪽이 운영자용 문서다.

이 드리프트의 실질적 무게는 이 문서 전체의 심각도 판정 축과 같다(§1.1). 지원 매트릭스만 읽은 운영자는 messaging이 아무것도 출하하지 않는다고 결론 내리는데, 실제로는 messaging-kafka·messaging-rabbit·messaging-spring-boot-starter·messaging-security·messaging-observability·messaging-cloudevents·outbox/inbox/claim-check·admin plane이 전부 app-bootstrap 아티팩트에 실려 있다.

6.5 P2 — 한 아티팩트 안의 서로 모르는 Kafka 스택 두 개 (MSG-015, 가족 문서가 미해결로 표시)

src/messaging/CLAUDE.md가 MSG-015를 P0 미해결로 들고 있다. 현재 상태를 코드로 확인했다.

app-bootstrap seam messaging platform
설정 클래스 bootstrap/messaging/KafkaSenderConfig autoconfigure/KafkaMessagingAutoConfiguration
producer bean kafkaSeamProducer : Producer<String,String> messagingKafkaProducer : Producer<byte[],byte[]>
조건 @ConditionalOnProperty(name="app.messaging.broker", havingValue="kafka") root의 app.messaging.enabled=trueMessagingProviderSelectionbroker=kafka
의존 방향 adapter-outbound-messaging은 platform에 의존하지 않음 platform은 adapter를 모름

두 스택이 같은 속성 이름 위에서 켜진다. 그리고 조건의 깊이가 다르다:

  • app.messaging.enabled=false(기본) + app.messaging.broker=kafkaseam 스택만 올라온다. 플랫폼의 master switch가 꺼져 있는데도 app.messaging.broker가 다른 Kafka producer 하나를 만든다.
  • enabled=true + broker=kafka둘 다 올라온다. 직렬화 방식이 다른(String vs byte[]) 두 producer가 같은 클러스터를 향해 각자 열린다.

bean 이름 충돌은 해소됐다 — KafkaSenderConfig의 javadoc이 그 과정을 적는다:

"Sharing the method name messagingKafkaProducer made the context refuse to start with a BeanDefinitionOverrideException, and a type-scoped @ConditionalOnMissingBean would have been worse: whichever configuration lost the race would leave its own stack without a producer while the other stack's, with incompatible serializers, sat in its place."

그 판단은 옳다. 그러나 결과적으로 이 결함의 유일한 가시적 증상이 제거됐다. 지금은 부팅이 성공하고, 두 스택이 조용히 공존한다. CLAUDE.md의 문장이 그대로 성립한다 — "지금 안전한 이유는 설계가 아니라 기본값이다app.messaging.enabled=false."

정정할 부분이 하나 있다. CLAUDE.md는 안전의 근거를 enabled=false에 둔다. 그런데 seam 스택은 enabled를 보지 않고 broker만 본다. 따라서 enabled=false는 두 스택 중 하나만 막는다. 기본값이 지켜 주는 범위가 문서가 말하는 것보다 좁다.

이 발견은 모듈 12(adapter-outbound-messaging)의 반대편이다. 그쪽에서는 "플랫폼에 도달하는 bridge가 없다"로 기록했고, 이쪽에서 확인한 것은 그 bridge의 부재가 두 개의 producer 조건 사이의 비대칭으로 나타난다는 점이다.

증거: 256-..., 257-messaging-msg015-two-kafka-stacks.txt

6.6 검증된 설계 — 등급이 boolean이 아니라 증거에서 파생된다

CompatibilityMatrix.Entry.hasLiveBrokerCertification():

public boolean hasLiveBrokerCertification() {
  return BrokerFailureMatrix.from(CertifiedEvidence.recorded()).hasLiveBrokerCoverage(adapter);
}

javadoc: "Read from the evidence rather than declared. As a field it was a boolean an author set next to the tier, and RabbitMQ carried true while no fault scenario had ever been executed against it — the same shape as the coverage matrix that used to assert its own constants."

CompatibilityMatrixTest가 강제하는 규칙 9개:

everyStableAdapterRunsTheSharedContract
noExperimentalAdapterIsEnabledByDefault
theStableSetIsExactlyWhatALaneHasCertified
aCertificationClaimCannotBeMadeWithoutEvidence
kafkaIsCertifiedAgainstWhatTheLanesRun
theSharedContractStillDeclaresEveryRequiredTest
anUnknownAdapterIsNotSilentlyTreatedAsSupported
everyStableAdapterIsCertifiedAgainstALiveBroker
noExperimentalAdapterClaimsLiveBrokerCertification
anAdapterMustCertifyAtLeastOneBrokerVersion

그리고 버전 목록에도 같은 규율이 적용됐다 — Kafka 항목의 주석: "4.1 only. The declared 4.2/4.3 were never run: the fixtures pin apache/kafka:4.1.0 and the locked client is 4.1.1, so the matrix and the support document agreed with each other and with nothing that had executed."

이 문장이 §6.3·§6.4가 남아 있는 이유를 설명한다. 문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다는 원칙이 여기서는 적용됐고, capability 표와 membership 문장에는 아직 적용되지 않았다. capability는 MessagingCapabilities에 손으로 쓴 12개 boolean이고(§3.4), 그것을 검증하는 테스트가 없다.

6.7 P3 — CompatibilityMatrixEXTENSION 등급이 있고 항목이 없으며, bridge leaf가 표 밖에 있다

Tier enum은 세 값을 갖는다 — STABLE, EXPERIMENTAL, EXTENSION("Adapter SPI only; outside the supported set"). ENTRIES 5개는 전부 STABLE 또는 EXPERIMENTAL이고 EXTENSION을 쓰는 항목은 없다.

한편 messaging-spring-cloud-stream-bridge(6 main, 507 LOC, build-only)는 ENTRIES에 없다. CompatibilityMatrix.of("...")는 미등록 이름에 예외를 던지므로 조용히 지원되는 것으로 취급되지는 않는다 — 그 점은 anUnknownAdapterIsNotSilentlyTreatedAsSupported가 강제한다.

그런데 docs/messaging/support-matrix.md의 브로커 등급 표에는 Artemis/JMS | Extension | 범위 밖 | adapter SPI만 | 별도 ADR + Contract Suite 통과 필요 행이 있다. 즉 문서에는 EXTENSION 등급 행이 있고 코드 ENTRIES에는 없으며, 저장소에 실재하는 bridge leaf는 양쪽 어디에도 없다.

EXTENSION이 정확히 bridge를 서술한다 — MessagingBindingBridge의 javadoc이 "The bridge is an interoperability seam, not a second messaging API"라고 하고, StreamBridgePolicyGuard가 "ordering scope, retry policy, dead letter destination을 선언한 destination은 native adapter로 가야 한다"로 정책 경계를 강제한다. build-only 등급이므로 오늘의 사고는 아니다. 표의 빈 등급과 표 밖의 leaf를 기록으로 남긴다.

6.8 검증된 설계 — 예약 헤더 위조 방어가 두 출하 어댑터에서 대칭이다

ReservedHeaders(msg.id, msg.type, msg.tenant 등 15+개)에 대해, 애플리케이션이 canonical 값을 덮어쓰지 못하게 하는 가드가 Kafka와 Rabbit 양쪽 매퍼에 동일하게 있다:

envelope.headers().asMap().forEach((name, value) -> {
  if (CanonicalEnvelopeHeaders.restatesEnvelopeField(name.value())) {
    throw new MessagingConfigurationException("RESERVED_HEADER_FORGED", ...);
  }
  put(headers, name.value(), value.value());
});

두 매퍼의 주석도 같은 근거를 든다 — 봉투의 헤더 맵이 마지막에 쓰이고 소비자가 lastHeader를 읽으므로, 검사 없는 이름이 방금 쓴 canonical 값을 덮는다. "A forged msg.id corrupts another message's inbox deduplication, not merely its own delivery."

그리고 가드 범위가 정확히 좁다 — "Only names that restate an envelope field are refused. Retry attempt, failure and redrive bookkeeping have no envelope field to be read back from, so refusing them would make every retry-topic and dead-letter re-publish fail on the guard meant to protect identity."

테스트도 양쪽에 있다: KafkaEnvelopeRoundTripTest.aForgedMessageIdIsStillRefused + 전용 ReservedHeaderForgeryTest, RabbitEnvelopeRoundTripTest.aForgedMessageIdIsStillRefused. 비대칭 없음.

6.9 P3/기록 — experimental 어댑터 3종의 "AdapterContractTest"는 공유 계약을 돌리지 않는다

MessagingAdapterContract(testkit main)를 실제로 참조하는 어댑터 테스트는 두 개뿐이다:

messaging-kafka/src/test/.../KafkaProducerContractTest.java
messaging-rabbit/src/test/.../RabbitAdapterContractTest.java

PulsarAdapterContractTest·NatsAdapterContractTest는 이름에 "AdapterContract"가 들어가지만 그 타입을 참조하지 않는다. CompatibilityMatrix.ENTRIES가 이 사실과 일치한다 — pulsar·nats·kafka-share는 runsSharedContract = false이고, everyStableAdapterRunsTheSharedContract가 STABLE에만 그것을 요구한다. 선언과 코드가 일치하므로 결함이 아니다. 다만 테스트 이름이 계약 실행을 시사하므로 기록한다.

CrossBrokerContractSuitemessaging-testkit/src/test에 있다 — 테스트 소스셋이므로 다른 leaf가 의존할 수 없다. 릴리스 게이트 역할은 §2.3의 verifyMessagingCertificationEvidence(Gradle)가 수행한다.


7. sub-scope 05 — reliability stores (52 파일)

leaf 출하? main test main LOC 마이그레이션
messaging-outbox-jdbc-postgresql 출하 13 8 2,276 V1·V2·V3·V4
messaging-inbox-jdbc-postgresql 출하 6 4 542 V2
messaging-claim-check 출하 6 3 418

세 leaf 모두 runtime_memberships = ["app-bootstrap"]이다.

7.1 P2 — outbox/inbox 체인 전체가 만족되지 않는 @ConditionalOnBean 뒤에 있다

MessagingReliabilityAutoConfiguration의 조건 사슬:

@ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class})
public OutboxRelay outboxRelay(OutboxRepository outbox, ..., OutboxEnvelopeFactory envelopes, ...)

@ConditionalOnBean(OutboxRelay.class)
public OutboxRelayWorker outboxRelayWorker(...)

@ConditionalOnBean(OutboxRelayWorker.class)
public MessagingOutboxRelayLifecycle outboxRelayLifecycle(...)

@ConditionalOnBean(OutboxRepository.class)
public OutboxCleanupJob outboxCleanupJob(...)

@ConditionalOnBean(InboxRepository.class)
public InboxCleanupJob inboxCleanupJob(...)

@ConditionalOnBean(IdempotentConsumer.class)
public TransactionalInboxHandler<Object> transactionalInboxHandler(...)

사슬의 뿌리는 OutboxRepositoryInboxRepository bean이다. 그 두 port의 구현은 각각 JdbcOutboxRepositoryJdbcInboxRepository 하나씩뿐이고, 둘 다 main 코드에서 참조가 0건이다 — 어떤 자동설정도, 어떤 다른 main 클래스도 생성하지 않는다. @Bean도 없다.

따라서 사슬 전체가 조립되지 않는다. app.messaging.enabled=true로 플랫폼을 켜도 outbox relay는 없고, relay worker도, lifecycle도, cleanup job도, inbox cleanup도, transactional inbox handler도 없다. 13 + 6 = 19개 main 파일, 2,818 LOC가 전부 조용히 비어 있다.

사이클 2 보강 — 원인은 조건이 아니라 중복이다(EVD-335). 위 서술은 현상으로는 맞지만 원인을 가린다. 스타터의 클래스 javadoc 은 이 조건들을 결함이 아니라 계약으로 서술한다 — "The platform cannot provide those: they write inside the application's own transaction, against the application's own datasource." 그렇다면 남는 질문은 출하 애플리케이션이 그 계약을 이행하는가인데, 답은 이행하지 않고 자기 outbox 를 갖고 있다다.

스택 A (출하) 스택 B (어둡다)
포트 application-core/.../outbox/OutboxStorePort 외 15파일 messaging-reliability-api/.../OutboxRepository
구현 persistence-jpa/.../outbox/OutboxStoreAdapter@Repository JdbcOutboxRepository — 스테레오타입 없음
구동 app-bootstrap/.../outbox/OutboxConfig + OutboxRelayScheduler@Scheduled 없음

수정 방향이 반대가 된다. "조건이 만족되지 않는다"로 읽으면 app-bootstrap 에 빈을 등록하는 수정이 되고, "outbox 가 둘이다"로 읽으면 어느 쪽이 정본인지 먼저 결정해야 하는 문제가 된다. 두 스택은 저장 모델도 발행 경로도 다르므로, 둘을 동시에 켜면 같은 업무 이벤트가 두 테이블에 적히거나 두 번 발행될 수 있다.

이것이 §5.1(관측)과 다른 점은 실패가 더 조용하다는 것이다. 관측은 no-op 구현이 명시적으로 존재하지만, 여기서는 @ConditionalOnBean이 만족되지 않아 bean이 아예 생기지 않는다. Spring은 그것을 오류로 보고하지 않는다 — 조건부 bean이 조건을 만족하지 못하는 것은 정상 동작이다. 즉 "outbox가 꺼져 있음"과 "outbox가 조립될 수 없음"이 런타임에서 구별되지 않는다.

@ConditionalOnBean(OutboxRepository.class)가 애플리케이션이 직접 repository bean을 등록하는 것을 전제하는 확장점이라면 그것은 정당한 설계다. 그러나 그렇다면 저장소 안에 구현이 존재하는데 그것을 등록하는 자동설정이 없다는 점이 설명되지 않는다 — JdbcOutboxRepository는 이 저장소가 만든 PostgreSQL 전용 구현이고, messaging-spring-boot-starter가 그 leaf를 implementation으로 물고 있다. MessagingCoreAutoConfigurationMessageCodecRegistry에 대해 한 것(§4.2 — 기본 구현을 @ConditionalOnMissingBean으로 제공)과 대비된다.

실패 시나리오. 팀이 outbox 패턴을 쓰려고 app.messaging.enabled=true를 켜고 docs/messaging/outbox-inbox.md를 따라 destination에 outbox를 설정한다. 부팅은 성공한다. 애플리케이션 트랜잭션이 outbox 행을 쓰려 해도 OutboxRepository bean이 없으므로 주입 지점에서 컨텍스트가 실패하거나(애플리케이션이 주입을 시도한 경우), 아무 일도 일어나지 않는다(플랫폼 경로만 쓴 경우). 후자에서는 relay가 없으므로 행이 쌓이지도 않고 발행되지도 않는다.

7.2 P2 — messaging 마이그레이션 스트림을 적용하는 곳이 없고, 적용하려는 순간 버전이 충돌한다

(a) 적용되지 않는다. 합성 루트의 Flyway 기본 위치는 PostgreSqlPersistenceConfig:115가 고정한다:

configuration.locations("classpath:db/migration/postgresql");

조건부 스트림은 각자 자기 위치와 history table을 갖는다 — NotificationSchemaStream.LOCATION = "classpath:db/migration/jpa/notification-platform", fileserver 스트림 등. db/migration/messaging을 이름으로 부르는 main 코드는 저장소 전체에 0건이다. 참조는 세 개의 IT(InboxPostgresIT, OutboxPostgresIT, AdminOperationJournalPostgresIT)가 자기 테스트 컨테이너에 직접 적용할 때뿐이다.

messaging_outbox · messaging_inbox · admin operation journal 테이블은 출하 배포 어디에서도 생성되지 않는다. §7.1과 합치면 일관은 있다 — repository bean이 없으니 테이블도 필요 없다. 그러나 persistence-jpa leaf가 같은 모양의 결함을 세 번 고치고 그 이력을 javadoc에 남겨 두었다:

"PostgreSqlSameStoreInboxAdapter ... its tables live only in db/migration/jpa/inbox. The bean existed, its tables did not, and the failure arrived either at ..." (같은 문장이 PostgreSqlImmutableOutboxAppendAdapter, PostgreSqlPollingDeliveryAdapter에도 있다)

messaging 쪽은 그 반대 순서다 — 테이블 정의는 있고 bean이 없다. 어느 쪽이든 스키마와 코드가 같은 조립 결정 아래 있지 않다는 같은 문제다.

(b) 적용하려 하면 충돌한다. 두 leaf가 같은 classpath 리소스 디렉터리 db/migration/messaging/에 각자 번호를 매긴다:

messaging-inbox-jdbc-postgresql   : V2__messaging_inbox.sql                (CREATE TABLE messaging_inbox)
messaging-outbox-jdbc-postgresql  : V1__messaging_outbox.sql
                                    V2__messaging_outbox_lease_fencing.sql (ALTER TABLE messaging_outbox ×4, CREATE INDEX)
                                    V3__messaging_admin_operation_journal.sql
                                    V4__messaging_outbox_canonical_metadata.sql

V2가 두 개다. 두 jar가 한 classpath에 있고 Flyway가 classpath:db/migration/messaging을 스캔하면 "Found more than one migration with version 2"로 실패한다. 지금 실패하지 않는 유일한 이유는 (a) — 아무도 그 위치를 Flyway에 주지 않기 때문이다.

각 leaf의 IT는 자기 jar의 리소스만 보므로 이 충돌을 재현하지 못한다 — InboxPostgresIT:199V2__messaging_inbox.sql을 파일명으로 직접 읽고, OutboxPostgresIT:249는 자기 디렉터리를 나열한다. 두 leaf를 한 classpath에 올린 상태를 검증하는 테스트가 없다.

원래 구현 계획서는 분리된 위치를 지정했다:

docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md:3010
  modules/messaging/messaging-outbox-jpa/.../db/migration/messaging-outbox/V1__create_messaging_outbox.sql
docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md:3011
  modules/messaging/messaging-inbox-jpa/.../db/migration/messaging-inbox/V1__create_messaging_inbox.sql
:3032  migrate("classpath:db/migration/messaging-outbox");
:3055  migrate("classpath:db/migration/messaging-inbox");

구현이 두 스트림을 하나로 합치면서 번호 조정은 하지 않았다.

실패 시나리오. outbox를 실제로 쓰려는 팀이 §7.1을 해결한다(repository bean 등록). 다음 단계는 테이블 생성이고, 유일한 방법은 db/migration/messaging을 Flyway 위치에 추가하는 것이다. 그 순간 부팅이 duplicate version으로 실패한다. 고치려면 마이그레이션 번호를 바꿔야 하는데, 다른 환경에 이미 적용된 뒤라면 checksum/version 이력을 손대는 작업이 된다 — 가장 늦게, 가장 비싸게 발견되는 자리다.

증거: 259-messaging-flyway-migration-namespace.txt

7.3 검증된 설계 — outbox lease가 소유자와 fencing token을 갖는다

V2__messaging_outbox_lease_fencing.sql의 헤더가 고친 결함을 서술한다:

"V1 recorded only lease_expires_at, so a claim said when it would end and nothing about who held it. The relay's terminal writes matched on message_id alone, which means a worker that stalled past its lease could still write: relay A claims the row and calls the broker the lease expires; relay B reclaims it, publishes, and records PUBLISHED relay A finally times out and records AMBIGUOUS over the top The row is now claimable again and the message is published a second time. Making the lease longer than the publish timeout lowers the odds; it does not turn a GC pause, a scheduler stall ..."

lease 만료를 시간으로만 다루는 것과 소유권 토큰으로 다루는 것의 차이를 정확히 짚는다. 그리고 실제 인프라 테스트가 이 계열의 결함 두 건을 잡았다는 기록이 docs/messaging/support-matrix.md에 남아 있다 — "Outbox IN_FLIGHT 고아 행: lease 쿼리가 PENDING/AMBIGUOUS만 클레임 대상으로 봐서, publish 도중 죽은 relay가 남긴 행이 lease 만료 후에도 영영 회수되지 않았다."

inbox 쪽 설계도 같은 수준이다 — V2__messaging_inbox.sql:

"The composite primary key is the deduplication mechanism: reserving a message is an INSERT that either succeeds or violates the key, inside the same transaction as the handler's side effect. Two independent consumers of the same event each get their own row, so one cannot suppress the other."

(message_id, consumer_id) 복합 키로 소비자별 dedup을 보장한다. 이 leaf들은 코드 품질 문제가 아니라 조립 문제다.

7.4 P3 — claim-check는 starter에 배선 코드가 한 줄도 없다

messaging-claim-check(6 main, 418 LOC, 출하)의 ClaimCheckPublisher·ClaimCheckResolver는 main 참조 0건이고, MessagingReliabilityAutoConfigurationClaimCheck 문자열이 등장하지 않는다.

ClaimCheckIntegrityGuard·ClaimCheckPolicy·ClaimCheckStore는 leaf 내부에서 서로를 참조하므로 leaf는 내부적으로 일관되지만, 바깥에서 들어오는 경로가 없다.

DestinationProfileValidator는 claim check를 알고 있다 — profile.payload().claimCheckThresholdBytes() > profile.payload().maxBytes()를 거부한다. 즉 프로파일은 claim check 임계값을 선언할 수 있고 검증도 받지만, 그 임계값을 넘는 payload에 대해 claim check를 수행하는 코드가 조립되지 않는다. 임계값은 설정 가능하고 효과는 없다.

§7.1보다 낮은 P3으로 두는 이유는 claim check가 outbox와 달리 부재 시 동작이 명확하기 때문이다 — payload가 그대로 전송되고, 크기 한도(BoundedByteSink, §4.1)에 걸리면 MessageTooLargeException으로 명시적으로 실패한다. 조용한 잘못된 성공이 아니라 시끄러운 실패다.


8. sub-scope 06 — admin (48 파일)

leaf 출하? main test main LOC
messaging-admin-api 출하 25 1 1,613
messaging-admin-runtime 출하 12 6 1,253

8.1 검증된 설계 — admin plane의 게이트가 이 가족에서 가장 잘 조립돼 있다

MessagingAdminAutoConfiguration:

@ConditionalOnProperty(prefix = "app.messaging.admin", name = "enabled", havingValue = "true")

matchIfMissing 없음 — 기본 꺼짐. javadoc:

"An application that acquires the admin plane by adding a starter to its classpath is exactly the situation the plane's guards exist to prevent — the guards would still refuse an unapproved operation, but the beans would be reachable from any code in the process."

세 가지가 특히 정확하다:

  1. DestructiveOperationGuard(false) — "an application runtime never holds an admin credential, so the guard refuses the operations that would need one. An operator tool overrides this bean with true."
  2. DestructiveMessagingAdmin은 의도적으로 bean이 아니다 — javadoc이 명시한다: "No bean for it is ever auto-configured: an operator tool that needs purge or delete registers one itself, with an admin credential this runtime does not hold." 부재를 문서화한 것이 이 가족에서 드물다.
  3. 비내구 journal + 시작 검증기 쌍InMemoryAdminOperationJournal이 기본값이고 isDurable()=false를 스스로 선언하며, MessagingAdminDurabilityValidator가 production 프로파일에서 그것을 거부한다:
throw new MessagingConfigurationException("ADMIN_JOURNAL_NOT_DURABLE",
    "the destructive-operation journal in use (" + journal.getClass().getSimpleName()
    + ") is not durable, and profiles " + active + " include a production profile; supply an "
    + "AdminOperationJournal bean backed by the shared database (JdbcAdminOperationJournal) so "
    + "one approval cannot be executed twice across replicas or across a restart");

메시지가 무엇을 공급해야 하는지 클래스 이름으로 말한다. 그리고 InitializingBean.afterPropertiesSet으로 돌아 컨텍스트 구성 중에 실패한다(§3.5의 StartupProfileValidation과 같은 패턴). javadoc이 이전 상태를 적는다 — "The previous default was an in-memory map registered by this starter, and nothing in the application said so. Two replicas each had their own copy, so both could claim the same approval at the same moment; a restart forgot every claim... Both failures look like a successful redrive until somebody counts the duplicates."

이 세 개가 §5.2(BrokerAclManifest의 startup 자기점검이 없음)와 정확히 대비된다. 같은 저장소, 같은 가족에서 하나는 완성됐고 하나는 안 됐다.

8.2 P2 — admin 스위치가 가드를 켜고 서비스는 켜지 않는다

app.messaging.admin.enabled=true가 만드는 bean은 넷이다: DestructiveOperationGuard, AdminOperationJournal, MessagingAdminDurabilityValidator, (BrokerTopologyInspector가 있을 때) CompositeTopologyValidator.

만들지 않는 것:

타입 leaf main 참조 부재가 문서화됐는가
DestructiveMessagingAdmin admin-runtime 2 — javadoc이 명시
DefaultMessagingAdminService admin-runtime 0 아니오
HmacApprovalVerifier admin-api 0 (test 4) 아니오
TopologyValidationRuntime admin-runtime 0 아니오
RedriveService / ReplayService admin-runtime 2 / 1 아니오

DefaultMessagingAdminServiceMessagingAdminService(인터페이스, main 참조 2)의 유일한 구현이다. 즉 admin plane을 켜도 admin 서비스가 없다.

HmacApprovalVerifierApprovalVerifier의 유일한 구현이고, 테스트 4개(ApprovalForgeryTest 포함)가 그것을 검증한다. ApprovedRedrivePlan/ApprovedReplayPlan/VerifiedApproval/PlanDigest(main 참조 10)로 이루어진 승인 사슬 전체가 검증자 없이는 시작될 수 없다.

부재의 등급이 넷 다 다르지 않은데 문서화는 하나만 됐다. DestructiveMessagingAdmin의 부재에는 명확한 이유가 있다("이 런타임은 admin 자격 증명을 갖지 않는다"). 나머지 넷에는 이유가 적혀 있지 않고, 그중 DefaultMessagingAdminServiceHmacApprovalVerifier는 파괴적이지 않은 admin 동작(redrive/replay의 승인·실행)에 필요한 것이다.

실패 시나리오. 운영 절차서(docs/messaging/retry-dlq-redrive.md)에 따라 사고 대응 중 redrive를 실행하려 한다. app.messaging.admin.enabled=true로 켠다. 부팅은 성공하고 가드·journal·durability 검증기가 올라온다. 그런데 MessagingAdminService bean이 없으므로 redrive를 호출할 대상이 없다. 사고 한가운데에서, 켰다고 생각한 기능이 없다는 것을 발견한다 — MessagingAdminDurabilityValidator의 javadoc이 경계한 상황("the gap only shows up during the incident the operation was run to resolve, which is the worst possible moment to discover it")과 정확히 같은 시점이다.

8.3 P3 — messaging-admin-api는 main 25파일 · 1,613 LOC에 테스트 파일이 1개다

messaging-admin-api      main=25  test=1   (DestructiveOperationGuardTest)
messaging-admin-runtime  main=12  test=6

messaging-admin-api가 담고 있는 것은 승인·다이제스트·토폴로지 계약이다 — ApprovalVerifier, HmacApprovalVerifier, ApprovalGrant, VerifiedApproval, PlanDigest, ApprovedRedrivePlan, ApprovedReplayPlan, DestructiveOperation, TopologyManifest, TopologyValidationReport 등 보안에 직결되는 타입들이다.

다만 이것은 보이는 것보다 덜 나쁘다. 실제 검증은 messaging-admin-runtime의 테스트 6개가 수행한다 — ApprovalForgeryTest(위조 승인 거부), ApprovedPlanExecutionTest, RedriveResumptionTest, AdminOperationJournalTest, TopologyValidationRuntimeTest, TopologyValidatorTest. HmacApprovalVerifier의 test 참조가 4건인 것이 그 증거다.

즉 계약 leaf에 테스트가 없는 것이 아니라 테스트가 소비자 leaf에 있다. 계약 자체의 경계 조건(예: PlanDigest의 정규화, ApprovalGrant의 만료)이 별도로 고정돼 있는지는 확인되지 않는다. 기록으로 남긴다.

8.4 검증된 설계 — actuator 엔드포인트가 읽기 전용이고 재식별 표면을 만들지 않는다

MessagingEndpoint(@Endpoint(id = "messaging")):

"Read-only. An actuator endpoint is reachable by anything that can reach the management port, so a write operation here would be an unauthenticated pause or purge in most deployments — the admin plane exists for those, with approvals and an audit trail this endpoint deliberately does not duplicate.

What it reports is bounded and free of per-message identity: destination names, guarantees, capability tiers, and in-flight counts. No message ids, no keys, no payloads — an actuator response is a diagnostic surface, and a diagnostic surface that echoes message content is a re-identification surface too."

그리고 in-flight 카운트의 출처를 바로잡은 이력이 있다 — "They used to come from a second limiter that no publish ever touched, so the endpoint reported zero in flight under any load — a diagnostic that is worse than absent, because it looks like an answer."

이 문장이 §5.1의 관측 결함을 판정하는 기준이기도 하다. 다만 §5.1과 달리 여기서는 카운트가 실제 게이트(MessagingAdmissionController, main 참조 4)에서 온다.


9. sub-scope 07 — assembly · testkit · 가족 거버넌스 (68 파일)

대상 출하? main test main LOC
messaging-spring-boot-starter 출하 28 10 3,528
messaging-testkit build-only 13 6 1,109
src/messaging/CLAUDE.md 132줄

9.1 검증된 설계 — 설정 위생 3층

starter가 세 개의 설정 검증기를 전부 bean으로 등록한다(MessagingCoreAutoConfiguration:52·72·106). 셋 다 InitializingBean이므로 컨텍스트 구성 중에 실패한다.

(1) MessagingPrefixMigrationValidator — 죽은 prefix를 거부한다.

"There were three namespaces. The starter's binder read backend.messaging; the deployed runtime and its APP_MESSAGING_* environment variables read app.messaging; and the configuration reference documented a bare messaging ... Two of the three bound nothing, so a deployment configured exactly as documented changed no behaviour and produced no error — the settings were simply inert."

그리고 alias를 만들지 않기로 한 결정도 기록돼 있다(MessagingSettings:19) — "An alias that works is a fourth thing to keep in sync."

(2) MessagingConfigurationKeyValidator — 바인딩되는 섹션 안의 오타를 거부한다.

"A misspelt prefix is loud — the whole section is missing and someone notices. A misspelt key inside a section that does bind is the opposite: the entry appears, the platform starts, and the one setting the operator came to change is the only one that did not take. consumer.prefech: 64 is a throughput change that never happened, and the first evidence is the incident it was supposed to prevent."

그리고 적법 키를 settings record에서 파생한다 — "A list would be [a second thing to keep in sync]". 이것이 모듈 18의 env-keys.yaml(수동 목록 + 삼자 일치 테스트)보다 한 단계 위의 접근이다.

(3) MessagingCredentialRequirementValidator — production 프로파일에 자격 증명 출처가 없을 때 행동 가능한 오류 하나로 실패한다.

"An application that enabled the starter without one therefore failed on 'no qualifying bean of type CredentialRuntimeRegistry' — a message about this platform's internals, naming a type the operator has never heard of, when the thing they have to do is supply a credential source."

범위가 production 프로파일로 좁혀진 이유도 명시된다 — "A local profile that connects to a broker with no authentication is a legitimate development setup; the same profile marked production is not, and the two must not be satisfiable by the same configuration."

이 셋 + §8.1의 MessagingAdminDurabilityValidator + §3.5의 두 StartupProfileValidation으로, starter는 6개의 시작 시점 검증기를 실제로 돌린다. 이 저장소에서 가장 촘촘한 조립 검증 층이다.

9.2 검증된 설계 — 꺼진 상태가 계약으로 고정돼 있다

MessagingPlatformRootAutoConfiguration의 javadoc:

"app.messaging.enabled=false means no beans, no clients, no threads, and no bound detail namespace. MessagingStarterOffContractTest holds that against a full context including this starter's imports and the vendor auto-configurations Spring Boot would contribute."

"vendor 자동설정까지 포함한 전체 컨텍스트에서" 검증한다는 점이 중요하다 — 마스터 스위치가 자기 자식만 막고 Spring Boot가 기여하는 Kafka/Rabbit 자동설정은 놔두는 상태가 흔한 실패인데, 그것을 테스트가 붙들고 있다.

9.3 P2 — 문서 계약 테스트가 존재하고, 그 커버리지 경계가 §6.3·§6.4의 드리프트 위치를 정확히 예측한다

MessagingDocumentationContractTest(testkit)가 존재한다. 클래스 javadoc이 목적을 정확히 적는다:

"Docs rot silently. A support matrix claiming an adapter is Stable outlives the day someone demoted it, and nothing fails — the tests still pass, the build is green, and the only signal is an operator making a decision on a page that stopped being true months ago.

The assertions are deliberately narrow: they check the claims a reader would act on, not prose. Asserting on wording would make every edit a test failure and the check would be deleted."

단언 8개:

# 테스트 무엇을 붙드는가 결과
1 everyDocumentTheSupportMatrixPromisesExists 9개 문서 존재
2 theSupportMatrixNamesExactlyTheAdaptersTheCodeCallsStable STABLE 어댑터 이름
3 theSupportMatrixDoesNotCallAnExperimentalAdapterStable EXPERIMENTAL이 Stable로 적히지 않음
4 theDocumentedKafkaVersionsMatchWhatTheCodeCertifies Kafka 버전 문자열
5 theUnsupportedListStillNamesTheTwoConstantsThatDoNotExist EXACTLY_ONCE·GLOBAL 언급
6 noEnumConstantTheDocsDenyActuallyExists 그 두 상수가 코드에 없음
7 theExperimentalPolicyStatesThatExperimentalIsOffByDefault contains("false") 약함
8 everyDocumentHasContent 각 문서 500자 초과 약함

커버리지 밖에 있는 것:

  • capability 표(5 어댑터 × 12 플래그 = 60칸) — 여기에 §6.3의 단 하나의 오류가 있다.
  • runtime membership 문장 — 여기에 §6.4의 드리프트가 있다.
  • 브로커 등급 표의 "제한" 칸 — 여기에 §6.2(Rabbit이 선택 불가라는 사실의 누락)가 있다.

즉 이 문서에서 발견한 문서 드리프트 3건 전부가 이 테스트의 단언 8개가 닿지 않는 곳에 있다. 우연이 아니다 — 테스트가 붙드는 항목(등급 이름, 버전 문자열, 존재하지 않는 상수)은 정확히 옳고, 붙들지 않는 항목만 틀렸다.

이것이 이 테스트를 결함으로 만들지는 않는다. javadoc이 "deliberately narrow"라고 밝혔고, 그 선택에는 이유가 있다("Asserting on wording would make every edit a test failure and the check would be deleted"). 결함은 그 경계가 어디인지가 문서에도 테스트에도 적혀 있지 않다는 것이다 — 이 테스트를 통과한 문서가 "코드와 일치하도록 검증됐다"로 읽힌다.

두 개의 약한 단언도 같은 성질이다. theExperimentalPolicyStatesThatExperimentalIsOffByDefault가 실제로 하는 것은 read("experimental-policy.md").contains("false")다 — 문서 어디엔가 "false"라는 문자열이 있으면 통과한다. 이름이 약속하는 것("experimental이 기본 꺼짐이라고 명시한다")과 검증하는 것의 거리가 이 가족의 다른 테스트들과 다르다.

capability 표는 기계로 검증 가능하다. CompatibilityMatrix.entries()가 어댑터 이름을 갖고 있고, 각 어댑터의 MessagingCapabilities는 코드 상수이며, 표는 마크다운 파이프 테이블이다. #2·#4가 하는 것과 같은 방식으로 대조할 수 있다.

증거: 261-messaging-documentation-contract-test-coverage.txt

9.4 P3/기록 — 가족 권위 문서가 자기 드리프트를 고친 방식

src/messaging/CLAUDE.md는 이 저장소에서 읽은 가족 문서 중 가장 규율이 있다.

  • 첫 문장이 SSOT 경계를 정한다 — "leaf 목록·gradle path·허용 의존성은 modules.json이 SSOT이며 이 문서는 그것을 복제하지 않는다."
  • Family 규칙 표가 leaf id가 아니라 역할로 정의된다 — "family는 디렉터리 이름의 접두사가 아니라 아래 표의 역할로 정해진다."
  • 이름 결정의 근거를 남긴다 — outbox-jdbc-postgresql로 개명한 이유("jpa라는 이름은 사용하지 않는 기술을 광고하고, vendor 중립 port가 어디 있는지도 숨겼다").
  • 자기 오류를 고친 방식이 정확하다 — "이 절은 한동안 사실이 아닌 채로 남아 있었다… 정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다."

마지막 문장이 §6.4의 해법이기도 하다. docs/messaging/support-matrix.md가 아직 세고 있고, 그래서 아직 틀려 있다.

한 가지 정정할 점(§6.5): CLAUDE.md는 MSG-015의 현재 안전 근거를 app.messaging.enabled=false에 둔다. 그러나 app-bootstrap의 seam 스택은 enabled가 아니라 app.messaging.broker만 본다. 기본값이 지켜 주는 범위가 문서가 말하는 것보다 좁다.

9.5 P3 — MessagingPublicSurfaceContractTest가 가족 밖(app-bootstrap)에 있다

CLAUDE.md의 public surface 정책이 "이 규칙은 문서가 아니라 MessagingPublicSurfaceContractTest가 붙들고 있다"고 말한다. 그 테스트의 위치:

src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingPublicSurfaceContractTest.java

src/messaging/** 밖이다. 결과:

  • messaging leaf만 바꾸고 :messaging:*:test만 돌리면 이 계약은 검증되지 않는다.
  • CI의 messaging-certification 워크플로는 src/messaging/** PR에서 돌지만 그것은 Kafka 인증 레인이고 이 테스트가 아니다.

api/implementation 분리는 이 가족의 정책이고 위반은 이 가족의 build.gradle에서 발생하는데, 그것을 잡는 테스트는 합성 루트의 레인에 있다. 모듈 18 §4.1c가 기록한 것과 같은 형태 — "A gate that is red in a lane nobody runs locally is a gate that reports whatever the last person to run it saw." 여기서는 레인이 다르다는 정도이므로 P3이다.


10. 네 가지 필수 negative-space 탐침

10.1 §8.1 도달성 — 조립 지점이 없는 main 타입

출하 leaf에 한정한 결과(main 참조 0건, 선언 파일 자신 제외):

타입 leaf 무엇이 실행되지 않는가
KafkaTransactionProfileValidator kafka Kafka 트랜잭션 5개 전제 조건 검사 §3.5
SchemaCompatibilityValidator schema-api NONE_EXPERIMENTAL production 거부 + 비교 대상 버전 산출 §4.3
SchemaRegistry (구현 없음) schema-api 스키마 이력의 출처 §4.3
RawBytesMessageCodec schema-api (기본 코덱 금지 대상 — 의도적) §4.4
DefaultCloudEventMapper · CloudEventMapper · CloudEventExtensions cloudevents CloudEvents 상호운용 전체 §4.5
MessagingMetrics · MessagingTracer · MessagingAuditSink · DefaultMessagingObservationConvention observability 관측 전체 §5.1
BrokerAclManifest security ACL 자기 점검 + 파괴적 권한 거부 §5.2
DestinationAccessValidator security (중복 장치 — 다른 경로가 검사 수행) §5.3
CredentialRotationPlan security (중복 장치 — 레지스트리가 회전 수행) §5.4
JdbcOutboxRepository · JdbcInboxRepository outbox/inbox-jdbc outbox/inbox 체인 전체 §7.1
DebeziumOutboxRecordMapper outbox-jdbc Debezium CDC 경로 §7.1
ClaimCheckPublisher · ClaimCheckResolver claim-check claim check 전체 §7.4
HmacApprovalVerifier admin-api 승인 검증 (유일 구현) §8.2
DefaultMessagingAdminService admin-runtime admin 서비스 (유일 구현) §8.2
TopologyValidationRuntime admin-runtime 토폴로지 검증 실행 §8.2

build-only leaf의 미도달(PulsarProfileValidator, NatsJetStreamProfileValidator, KafkaShareProfileValidator, BindingProfileValidator, avro/protobuf 코덱)은 등급과 일치하므로 이 표에 넣지 않았다.

10.2 §8.2 조건부 형제 비교

형제 쌍 차이 판정
KafkaProfileValidator vs KafkaTransactionProfileValidator 같은 자동설정 클래스, 하나는 StartupProfileValidation으로 감싸고 하나는 @Bean P2 §3.5
messaging-schema-json(출하, 게이트 없음) vs -avro/-protobuf(build-only, 게이트 있음) 호환성 게이트가 출하되지 않는 쪽에만 P2 §4.4
MessagingRedactor·CardinalityGuard(bean 있음) vs MessagingMetrics·MessagingTracer(bean 없음) 협력자는 조립, 방출자는 미조립 P2 §5.1
Kafka(transport bean 있음) vs Rabbit(transport bean 없음, 선택 거부) 코드는 대칭적으로 공개, 문서만 비대칭 P2 §6.2
DestructiveMessagingAdmin(부재가 문서화됨) vs DefaultMessagingAdminService·HmacApprovalVerifier(부재가 문서화 안 됨) 같은 등급의 부재 4건 중 1건만 설명 P2 §8.2
Kafka 헤더 위조 가드 vs Rabbit 헤더 위조 가드 차이 없음 — 구현·주석·테스트 모두 대칭 결함 아님 §6.8
MessagingCoreAutoConfigurationMessageCodecRegistry 기본 제공 vs MessagingReliabilityAutoConfigurationOutboxRepository 미제공 한쪽은 @ConditionalOnMissingBean으로 기본 구현, 한쪽은 만족되지 않는 @ConditionalOnBean P2 §7.1

10.3 §8.3 중복 장치 쓸기

개념 장치 A (조립됨) 장치 B (미조립) 판정
destination 접근 검사 DefaultMessagePublisher.access.mayPublishREJECTED/CONFIGURATION DestinationAccessValidatorMessageAuthorizationException P3 §5.3 — 미조립 쪽이 분류가 더 정확
자격 증명 회전 CredentialRuntimeRegistry + CredentialRuntime.isDueForRotation (동시성 계약 테스트 있음) CredentialRotationPlan.isDue (참조 0) P3 §5.4 — 조립된 쪽이 더 나음
Kafka producer messagingKafkaProducer(platform, byte[]) kafkaSeamProducer(bootstrap seam, String) — 둘 다 조립됨 P2 §6.5 — 둘 다 살아 있는 중복
인증 증거 검증 verifyMessagingCertificationEvidence(Gradle, 실행 산출물 대조) CertifiedEvidenceTest.everyRecordedScenarioIsALineTheCertificationLaneWrote(같은 파일 두 번 읽음) P3/기록 §3.7
지원 등급의 출처 CompatibilityMatrix.hasLiveBrokerCertification()(증거 파생) (이전) tier 옆의 boolean 필드 — 이미 제거됨 해결됨 §6.6

10.4 §8.4 문서·카운트 드리프트

# 주장 실제 판정
1 support-matrix.md: Kafka deduplicatedPublish = O KafkaMessagingTransport.CAPABILITIES[9] = false (javadoc이 true→false 변경 이력과 이유를 서술) P1 §6.3
2 support-matrix.md: "registry의 messaging leaf는 모두 runtime_memberships가 비어 있다" 출하 18 / build-only 7. src/messaging/CLAUDE.md가 이미 이 문장이 틀렸다고 기록 P2 §6.4
3 support-matrix.md RabbitMQ 행의 "제한" 칸이 "장애 시나리오 미실행"만 언급 BROKERS_WITHOUT_A_TRANSPORT에 등록 — 선택 자체가 startup 오류 P2 §6.2
4 MessagingCapabilities javadoc: "the platform fails loudly — at startup where possible" 12개 중 거부하는 것 1개, 그것도 publish 시점 P2 §3.4
5 BrokerAclManifest javadoc: "checks itself against at startup" / "rejected outright" 두 검사 모두 존재하지 않음 P2 §5.2
6 NatsJetStreamTransport javadoc: "NatsJetStreamProfileValidator refuses the combination at startup" 그 validator를 생성하는 코드가 없음 (build-only leaf) P3 §3.5
7 인증 manifest의 gitCommit: e98b56eb03ec… vs 분석 리비전 a24ece9c… 게이트가 gitCommit·observedAt을 제거한 뒤 비교 — 설계상 정상 결함 아님 §2.3
8 messaging-cloudevents = "schema" 계열로 보이는 이름 등록 id는 messaging-cloudevents(schema 접두사 없음), 디스크의 -inbox-jpa/-outbox-jpa는 추적 파일 0 기록 §1.1
9 CompatibilityMatrix.Tier.EXTENSION 사용하는 항목 0개. 문서에는 Artemis/JMS 행이 있고, 실재하는 bridge leaf는 양쪽 어디에도 없음 P3 §6.7
10 MessagingDocumentationContractTest 단언 8개 발견된 문서 드리프트 3건 전부가 커버리지 밖 P2 §9.3

11. 발견 종합 — P1 1건 · P2 14건 · P3 10건

심각도 발견 위치
P1 6.3 지원 매트릭스가 Kafka deduplicatedPublishO로 적고 코드는 false이며, 그 믿음이 코드 javadoc이 지목한 피해다 docs/messaging/support-matrix.md · KafkaMessagingTransport:62
P2 3.4 capability 12개 중 main이 읽는 것 3개, 거부하는 것 1개 — javadoc의 "fails loudly"가 성립하지 않음 MessagingCapabilities · DefaultMessagePublisher:250
P2 3.5 출하 leaf의 KafkaTransactionProfileValidator@Bean만 있고 호출되지 않음 KafkaMessagingAutoConfiguration:74
P2 4.3 SchemaCompatibilityValidator 미호출 + SchemaRegistry main 구현 없음 → NONE_EXPERIMENTAL이 production으로 통과 messaging-schema-api
P2 4.4 호환성 게이트가 build-only 포맷에만 있고 출하되는 JSON 코덱에는 없음 schema-avro/-protobuf vs schema-json
P2 4.5 messaging-cloudevents가 출하 leaf이자 starter 의존인데 소비자 0 messaging-cloudevents
P2 5.1 출하 publish 경로가 NO_OBSERVATION으로 조립 — 메트릭·트레이스·감사 전부 없음 MessagingCoreAutoConfiguration:445
P2 5.2 BrokerAclManifest의 startup 자기 점검·파괴적 권한 거부가 존재하지 않음 messaging-security
P2 6.2 Rabbit이 선택 불가(BROKERS_WITHOUT_A_TRANSPORT)라는 사실이 운영 문서에 없음 support-matrix.md
P2 6.4 운영 문서가 "모든 leaf build-only"라고 적음 — 가족 권위 문서는 이미 정정 support-matrix.md
P2 6.5 서로 모르는 Kafka 스택 두 개(MSG-015). seam은 enabled를 보지 않아 기본값 보호 범위가 문서보다 좁음 KafkaSenderConfig:38
P2 7.1 outbox/inbox 체인 전체가 만족되지 않는 @ConditionalOnBean 뒤 — 2,818 LOC 무효 MessagingReliabilityAutoConfiguration
P2 7.2 messaging 마이그레이션 스트림을 적용하는 곳이 없고, 적용하면 V2 중복으로 실패 db/migration/messaging/
P2 8.2 admin 스위치가 가드 4개를 켜고 서비스·승인 검증기는 켜지 않음 MessagingAdminAutoConfiguration
P2 9.3 문서 계약 테스트의 커버리지 밖에 문서 드리프트 3건이 전부 위치 MessagingDocumentationContractTest
P3 3.6 messaging-reliability-api main 13파일·817 LOC에 테스트 0 messaging-reliability-api
P3 3.7 everyRecordedScenarioIsALineTheCertificationLaneWrote가 같은 파일을 두 경로로 비교 CertifiedEvidenceTest
P3 5.3 접근 검사 중복 — 조립된 쪽이 인가 거부를 CONFIGURATION으로 분류 §5.3
P3 5.4 자격 증명 회전 개념 중복, 하나는 참조 0 CredentialRotationPlan
P3 5.6 유출 편집기는 bean이지만 편집 대상 경로가 미조립 MessagingRedactor
P3 6.7 Tier.EXTENSION 미사용 + bridge leaf가 매트릭스 밖 CompatibilityMatrix
P3 6.9 experimental 3종의 "AdapterContractTest"가 공유 계약을 돌리지 않음(선언과는 일치) pulsar/nats
P3 7.4 claim check 배선 0 — 임계값은 설정·검증되지만 효과 없음 messaging-claim-check
P3 8.3 messaging-admin-api main 25파일·1,613 LOC에 테스트 1개(검증은 소비자 leaf에 있음) messaging-admin-api
P3 9.5 MessagingPublicSurfaceContractTest가 가족 밖 레인에 있음 app-bootstrap

11.1 이 가족에서 검증된(결함 아님) 설계 — 12건

앞선 18개 모듈과 달리, 이 가족에서는 공개된 주장이 실제로 성립하는 항목이 다수다. 기록해 둔다.

  1. MSG-022 — 예외 타입 문자열 판별 0건 (§2.1)
  2. fault scenario 커버리지 — 미커버 항목이 knownGaps파생되고 이유까지 단언됨 (§2.2)
  3. 인증 게이트 — 실행 산출물 ↔ 커밋 manifest 양방향 대조, 캐시 금지, CI 연결, Docker 가드 의도적 부재 (§2.3)
  4. 단일 publish 경로 — 고정 순서, 각 단계 위치의 근거, 모든 경로에서 정확히 한 번 반환 (§3.1)
  5. 증거 우선 결론 후행PublishEvidence compact constructor가 불가능한 조합 거부, 확정/모호 구분 (§3.2)
  6. caller 소유 데드라인 — 호출 시점부터 측정, 복사본에 timeout (§3.3)
  7. BoundedByteSink — 한도를 넘는 write에서 실패하는 할당 경계 (§4.1)
  8. RegisteredMessageCodecs — 기본 코덱 명시 필수, raw-bytes 기본 금지, content type 중복 거부, 계약 카탈로그 fail-closed (§4.2)
  9. 전송 선택 — classpath가 아니라 속성, 닫힌 레지스트리, 세 가지 startup 오류, 단일 master switch (§6.1)
  10. 등급의 증거 파생hasLiveBrokerCertification()이 boolean 필드가 아님, 9개 규칙 테스트 (§6.6)
  11. 예약 헤더 위조 방어 — 두 출하 어댑터에서 구현·근거·테스트 모두 대칭 (§6.8)
  12. admin plane 게이트 + 설정 위생 6층 — 기본 꺼짐, 비파괴 기본값, 부재의 문서화, 비내구 journal 거부, prefix/key/credential 검증기 (§8.1, §9.1)

11.2 이 가족이 앞선 18개 모듈과 다른 점

지금까지 반복해서 기록한 형태는 **"장치는 있고 회로가 닫히지 않았다"**였다. messaging에서도 그 형태가 15개 타입에 나타난다(§10.1). 그러나 원인이 다르다.

앞선 모듈들에서 회로가 열려 있던 이유는 조립하는 쪽이 그 장치를 몰랐기 때문이었다(모듈 14 §8.1 — 합성 루트가 다섯 패키지를 스캔에서 빼고 넘겨받는 자동설정을 만들지 않았다). messaging에서는 조립하는 쪽이 있고, 잘 만들어져 있으며, 일부를 물지 않았다. MessagingCoreAutoConfiguration은 28개 클래스 중 하나이고 6개의 시작 검증기를 돌리며 MessageCodecRegistry·DestinationProfileRegistry·DestinationAccessPolicy를 직접 제공한다. 같은 클래스가 MessagingObservation은 no-op로 두고(§5.1), OutboxRepository@ConditionalOnBean으로 남긴다(§7.1).

그래서 이 가족의 결함은 누락이 아니라 선택의 비일관이다. 그리고 그 비일관이 어디에 있는지를 판정하는 기준을 이 가족 자신이 제공한다 — StartupProfileValidation의 javadoc("were all beans and none of them was injected anywhere"), DefaultMessagePublisher의 javadoc("existed as beans that no publish ever called"), RegisteredMessageCodecs의 javadoc("an interface with no implementation anywhere"). 세 문장이 같은 결함 유형을 이름으로 부르고 있고, 이 문서의 §10.1 표는 그 유형이 아직 15곳에 남아 있다는 목록이다.


12. 검증

12.1 테스트 레인

25개 messaging leaf의 :test 전량 실행:

./gradlew :messaging:messaging-admin-api:test :messaging:messaging-admin-runtime:test \
          ... (25개 전량) --console=plain

BUILD SUCCESSFUL in 2m 27s
105 actionable tasks: 59 executed, 46 up-to-date
GRADLE_EXIT=0

증거: 263-messaging-test-lane-result.txt

돌지 않은 레인(환경 제약, 결함 아님):

  • messagingCertificationTest / verifyMessagingCertificationEvidence — Docker 컨테이너 필요. 이 레인은 의도적으로 Docker 가드가 없어 컨테이너 없이는 실패한다(§2.3). 컨테이너 안에서 분석 중이므로 실행하지 않았다.
  • *PostgresIT, *BrokerIT, KafkaAmbiguityChaosIT 등 Testcontainers 계열 — DockerAvailability 가드로 skip된다.

12.2 소스 트리 변경 없음

git status --short   →   (출력 없음)

문서 파이프라인은 애플리케이션 소스를 수정하지 않았다.

12.3 커버리지 원장 최종

sub-scope leaf 파일 FULL_READ STRUCTURAL_ONLY
01 core contracts 4 141 DefaultMessagePublisher, PublishEvidence, ConfirmationLevel, MessagingCapabilities, CapabilityRegistry, DestinationCapabilities, transport-spi 13, reliability-api 13, runtime-core 6 core-api error 26 · delivery 13 · header 5 등
02 schema 5 41 SchemaCompatibilityValidator, BoundedByteSink, RegisteredMessageCodecs, 4개 코덱 헤더 Avro/Protobuf 내부
03 policy·security·observability 3 66 DestinationProfileValidator(16 규칙), MessagingMetrics 헤더, BrokerAclManifest, DestinationAccessValidator, CredentialRotationPlan, CredentialRuntimeRegistry policy 26 중 재시도/DLQ 내부
04 brokers 6 134 KafkaMessagingTransport, RabbitMessagingTransport, 두 HeaderMapper 전문, KafkaPublishMapper, RabbitConfirmCoordinator, MessagingProviderSelection, CompatibilityMatrix Kafka 34 중 재시도/트랜잭션 내부, 실험 어댑터 3종
05 reliability stores 3 52 MessagingReliabilityAutoConfiguration 조건 사슬, 마이그레이션 5개 헤더 JDBC 구현 내부
06 admin 2 48 MessagingAdminAutoConfiguration, MessagingAdminDurabilityValidator, MessagingEndpoint admin-api 25 중 승인 타입 내부
07 assembly·testkit 2+1 68 MessagingPlatformRootAutoConfiguration, 3개 설정 검증기, CertifiedEvidence, NetworkFaultScenario, MessagingDocumentationContractTest, CLAUDE.md 전문 starter 28 중 facade 3종
25 550

EXCLUDED: 없음. 미배정: 0.

12.4 증거

evidence/raw/236263 (28개 파일). 이번 문서에서 새로 생성한 것은 243263(21개)이다.