# 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`로 **파생**하고, 테스트가 그 값을 이름과 이유까지 붙여 단언한다: ```java 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.gradle`의 `verifyMessagingCertificationEvidence`가 실제로 그 대조를 한다: ```groovy Set ran = claims(produced.get().asFile) // build/messaging-certification/...jsonl Set 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로 **표현 불가능한 조합을 거부**한다: ```java 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의 것이다 ```java private Duration remainingBudget(Duration timeout, long startedAt) { return timeout.minus(elapsedSince(startedAt)); } ``` 호출 시점부터 측정한다 — "느린 destination 조회나 큰 encode가 브로커 대기와 같은 예산을 쓴다; transport 호출만 재면 준비에 걸린 시간만큼 총합이 데드라인을 초과한다." 그리고 `withDeadline`은 `inFlight.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` 하나뿐**이다: ```java private void requireSupportedOptions(DestinationProfile profile, PublishOptions options) { if (options.deduplication().isPresent() && !transport.capabilities(profile.name()).capabilities().deduplicatedPublish()) { throw new MessagingConfigurationException("PUBLISH_DEDUPLICATION_UNSUPPORTED", ...); } } ``` 메서드 이름은 `requireSupportedOptions`(복수)이지만 검사하는 옵션은 하나다. `PublishOptions`는 `timeout` · `confirmation` · `deduplication` 세 성분이고, 그중 `confirmation`은 여기서 검사되지 않는다. **실패 시나리오.** `PublishOptions.defaults()`가 `ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK`을 기본값으로 쓴다(`PublishOptions:43`). `replicationOrPersistenceEvidence=false`인 브로커에 붙은 destination으로 publish하면 — 중앙 경로는 아무것도 거부하지 않고, 어댑터는 요청받은 requirement를 그대로 `ConfirmationLevel`로 되돌려준다: ```java // 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:58` → `StartupProfileValidation` | **✔** | | `RabbitProfileValidator` | rabbit | 출하 | `RabbitMessagingAutoConfiguration:54` → `StartupProfileValidation` | **✔** | | `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`의 첫 테스트는 이름이 주장하는 것을 증명하지 않는다 ```java @Test void everyRecordedScenarioIsALineTheCertificationLaneWrote() throws IOException { List 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 검증된 설계 — 인코딩 한도가 보고 기준이 아니라 할당 경계다 `BoundedByteSink`는 `OutputStream`을 상속해 **한도를 넘기는 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`: ```java @DefaultValue("BACKWARD_TRANSITIVE") SchemaCompatibility compatibility, ``` `@DefaultValue`가 안전한 값이지만, `app.messaging.destinations..schema.compatibility=NONE_EXPERIMENTAL`을 쓰면 그대로 통과한다 — `DestinationProfileValidator`의 16개 규칙에 스키마 항목이 없고(§3.5), `requireProductionMode`는 호출되지 않는다. **부수적으로 `SchemaRegistry`에는 main 구현이 하나도 없다.** 유일한 구현은 `SchemaCompatibilityValidatorTest`의 `FixedRegistry`다. 즉 검증기가 읽어야 할 스키마 이력의 출처 자체가 프로덕션에 존재하지 않는다. `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 경로는 관측을 하나도 기록하지 않는다 `DefaultMessagePublisher`는 `MessagingObservation`을 **생성자 인자**로 받는다. 그 필드의 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인자 생성자다: ```java // 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`에서 관측 관련으로 등장하는 이름은 `MessagingRedactor`와 `CardinalityGuard` **둘뿐**이다(`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) 조립된 쪽** — `DefaultMessagePublisher`가 `DestinationAccessPolicy`를 직접 호출: ```java if (!access.mayPublish(destination.name())) { return rejected("PUBLISH_FORBIDDEN", "this application may not publish to '" + destination.name().value() + '\'', startedAt); } ``` `FailureCategory.CONFIGURATION` · `retryable=false`인 `PublishResult`를 돌려준다. **(b) 조립되지 않은 쪽** — `DestinationAccessValidator` (main 참조 0건, 테스트 0건): ```java 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 코드다: ```java // :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과 겹쳐 읽으면 한계가 보인다 — `MessagingRedactor`는 `MessagingMetrics`·`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-kafka`와 `messaging-rabbit`을 **둘 다** 물면서 각각 클라이언트 라이브러리를 `api`로 노출하므로 그 조건은 모든 채택자에게 참이었다 — 즉 `@ConditionalOnClass`는 선택할 수 없는 조건이었다. 지금은 세 가지가 침묵이 아니라 **startup 오류**다: (1) 레지스트리 밖 broker id, (2) 등록됐지만 클라이언트 라이브러리가 없는 broker, (3) messaging이 켜졌는데 broker가 비어 있는 경우. 그리고 `MessagingPlatformRootAutoConfiguration`이 master switch를 단독 소유한다: ```java @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`은 출하되지만 선택할 수 없고, 운영 문서는 그것을 말하지 않는다 `MessagingProviderSelection`에 `BROKERS_WITHOUT_A_TRANSPORT` 맵이 있다: ```java static final Map 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의 `deduplicatedPublish`를 `O`로 적고, 코드는 `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=true`→`O`, Rabbit의 `nativeDeadLetter=true`→`O`, Pulsar의 `keyedOrdering`이 `Key_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-starter`를 `app-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` | `messagingKafkaProducer` : `Producer` | | 조건 | `@ConditionalOnProperty(name="app.messaging.broker", havingValue="kafka")` | root의 `app.messaging.enabled=true` → `MessagingProviderSelection` → `broker=kafka` | | 의존 방향 | `adapter-outbound-messaging`은 platform에 의존하지 않음 | platform은 adapter를 모름 | **두 스택이 같은 속성 이름 위에서 켜진다.** 그리고 조건의 깊이가 다르다: - `app.messaging.enabled=false`(기본) + `app.messaging.broker=kafka` → **seam 스택만** 올라온다. 플랫폼의 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()`: ```java 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 — `CompatibilityMatrix`에 `EXTENSION` 등급이 있고 항목이 없으며, 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 양쪽 매퍼에 동일하게** 있다: ```java 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에만 그것을 요구한다. **선언과 코드가 일치하므로 결함이 아니다.** 다만 테스트 이름이 계약 실행을 시사하므로 기록한다. `CrossBrokerContractSuite`는 `messaging-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`의 조건 사슬: ```java @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 transactionalInboxHandler(...) ``` 사슬의 뿌리는 `OutboxRepository`와 `InboxRepository` bean이다. 그 두 port의 구현은 각각 `JdbcOutboxRepository`와 `JdbcInboxRepository` 하나씩뿐이고, **둘 다 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`으로 물고 있다. `MessagingCoreAutoConfiguration`이 `MessageCodecRegistry`에 대해 한 것(§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`가 고정한다: ```java 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:199`는 `V2__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건이고, `MessagingReliabilityAutoConfiguration`에 `ClaimCheck` 문자열이 등장하지 않는다. `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`: ```java @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 프로파일에서 그것을 거부한다: ```java 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 | 아니오 | `DefaultMessagingAdminService`는 `MessagingAdminService`(인터페이스, main 참조 2)의 **유일한 구현**이다. 즉 admin plane을 켜도 admin 서비스가 없다. `HmacApprovalVerifier`는 `ApprovalVerifier`의 유일한 구현이고, 테스트 4개(`ApprovalForgeryTest` 포함)가 그것을 검증한다. `ApprovedRedrivePlan`/`ApprovedReplayPlan`/`VerifiedApproval`/`PlanDigest`(main 참조 10)로 이루어진 승인 사슬 전체가 **검증자 없이는 시작될 수 없다.** **부재의 등급이 넷 다 다르지 않은데 문서화는 하나만 됐다.** `DestructiveMessagingAdmin`의 부재에는 명확한 이유가 있다("이 런타임은 admin 자격 증명을 갖지 않는다"). 나머지 넷에는 이유가 적혀 있지 않고, 그중 `DefaultMessagingAdminService`와 `HmacApprovalVerifier`는 파괴적이지 않은 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 | | `MessagingCoreAutoConfiguration`의 `MessageCodecRegistry` 기본 제공 vs `MessagingReliabilityAutoConfiguration`의 `OutboxRepository` 미제공 | 한쪽은 `@ConditionalOnMissingBean`으로 기본 구현, 한쪽은 만족되지 않는 `@ConditionalOnBean` | **P2** §7.1 | ### 10.3 §8.3 중복 장치 쓸기 | 개념 | 장치 A (조립됨) | 장치 B (미조립) | 판정 | |---|---|---|---| | destination 접근 검사 | `DefaultMessagePublisher.access.mayPublish` → `REJECTED`/`CONFIGURATION` | `DestinationAccessValidator` → `MessageAuthorizationException` | 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 `deduplicatedPublish`를 `O`로 적고 코드는 `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/236`–`263` (28개 파일). 이번 문서에서 새로 생성한 것은 `243`–`263`(21개)이다.