# messaging-runtime-core 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-runtime-core` > SSOT owner: `messaging-runtime-core` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-runtime-core` - canonical state `analysisFile`: `analysis/messaging/messaging-runtime-core.md` - source path: `src/messaging/messaging-runtime-core` - registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api", "messaging-policy", "messaging-transport-spi", "messaging-security", "messaging-observability"]` — messaging family에서 두 번째로 많은 의존 - registry `runtime_memberships`: `["app-bootstrap"]` ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | **6** | | production LOC | 787 | | 패키지 | 1 (`dev.caskeleton.messaging.runtime`) | | test 파일 | 4 (테스트 3 + fixture 1) | | test 메서드(실행 확인) | 21 | | 외부(비프로젝트) 의존성 | **0** | 여섯 클래스: | 클래스 | LOC | 역할 | 출하 조립 | |---|---:|---|---| | `DefaultMessagePublisher` | 366 | **유일한 발행 경로** | o (`:446`) | | `DefaultDeliveryProcessor` | 155 | 핸들러 결과 → 정산 | **x** | | `RegisteredMessageCodecs` | 89 | content type → codec | o (`:363`) | | `DestinationProfileRegistry` | 62 | 논리 이름 → 프로파일 | o (`:377`) | | `TransportMessagingRuntime` | 67 | transport를 세대로 포장 | o (`:476`) | | `DeclaredDestinationAccess` | 48 | 기본 접근 정책 | o | ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 | | `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 및 단언 확인 | | `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 **이 leaf는 조립 결함 하나를 고치기 위해 만들어졌다.** 여섯 파일 중 다섯의 javadoc이 "X was an interface with no implementation" 형태로 시작한다. `build.gradle`이 그 사정을 파일 맨 위에 적는다. ```groovy // The central publish and delivery orchestration. // // MessagePublisher was an interface with no implementation anywhere in the new platform: the // brokers implemented MessagingTransport, the core auto-configuration built dead-letter and facade // beans on top of a publisher bean that nothing supplied, and admission, security, runtime leases // and observation existed as beans that no publish path ever called. A starter that filled the gap // with an application-supplied fake would pass a context test while running none of them. ``` 이 진단의 마지막 문장이 핵심이다 — **컨텍스트 테스트를 통과하면서 아무것도 실행하지 않는 조립**이 가능했다는 것. 이 저장소가 반복해서 만나는 형태다. six 파일이 메운 구멍: | 인터페이스(소유 leaf) | 구현이 없었음 | 이 leaf가 채운 것 | |---|---|---| | `MessagePublisher` (core-api) | 어디에도 없음 | `DefaultMessagePublisher` | | `MessageCodecRegistry` (schema-api) | 어디에도 없음 | `RegisteredMessageCodecs` | | `MessagingRuntime` (transport-spi) | 어디에도 없음 | `TransportMessagingRuntime` | | (없음) 논리이름→프로파일 해석 | 아무도 하지 않음 | `DestinationProfileRegistry` | | `DestinationAccessPolicy` 기본값 (security) | `denyAll()`뿐 | `DeclaredDestinationAccess` | | `HandleResult` → 정산 (core-api) | 어댑터가 각자 결정 | `DefaultDeliveryProcessor` | 여섯 중 다섯은 배선됐고 마지막 하나(`DefaultDeliveryProcessor`)는 배선되지 않았다(§12.1). --- ## 2. 의존성과 런타임 배선 들어오는 것: 여섯 project 의존, 전부 `api`. `DefaultMessagePublisher` 한 클래스가 그중 다섯을 생성자로 받으므로 `api`가 맞다. 나가는 것: `messaging-spring-boot-starter`만. **배선 지점 다섯**(전부 `MessagingCoreAutoConfiguration`): | 라인 | 무엇 | |---:|---| | 363 | `RegisteredMessageCodecs.of(JacksonMessageCodec.of(...))` | | 377 | `DestinationProfileRegistry.of(destinations.all())` | | 446 | `new DefaultMessagePublisher(destinations, access, codecs, admission, runtimes, transport)` | | 476 | `new TransportMessagingRuntime(selected.brokerName(), 1L, selected)` — `InitializingBean` 안 | | — | `DeclaredDestinationAccess.of(...)`로 접근 정책 bean | 446의 인자가 **여섯 개**라는 것이 §12.1의 관측 지점이다. --- ## 3. 패키지/컴포넌트 지도 ``` 발행 (조립됨) DefaultMessagePublisher ├── DestinationProfileRegistry 논리 이름 → DestinationProfile ├── DestinationAccessPolicy ← DeclaredDestinationAccess.of(profiles) ├── MessageCodecRegistry ← RegisteredMessageCodecs ├── MessagingAdmissionController (policy) ├── MessagingRuntimeRegistry (transport-spi) → TransportMessagingRuntime ├── MessagingTransport (transport-spi) → Kafka/Rabbit/… └── MessagingObservation ← NO_OBSERVATION (§12.1) 소비 (조립 안 됨) DefaultDeliveryProcessor ├── Function, HandleResult> ├── DeadLetterPublisher (내부 함수형 인터페이스) └── OneShotSettlement → TransportSettlement ``` --- ## 4. 계약·불변식·상태 모델 ### 4.1 `DefaultMessagePublisher` — 순서가 계약이다 ```java // :40-49 *

The order below is fixed, not composed from a map of interceptors. Each stage's position is a * decision: * *

``` 실제 순서 여덟 단계: | # | 단계 | 실패 시 | |---:|---|---| | 1 | `destinations.require(name)` | `DESTINATION_NOT_REGISTERED` → `REJECTED` | | 2 | `requireSupportedOptions(profile, options)` | `PUBLISH_DEDUPLICATION_UNSUPPORTED` → `REJECTED` | | 3 | `access.mayPublish(name)` | `PUBLISH_FORBIDDEN` → `REJECTED` (**인코딩 전**) | | 4 | `encode(message)` | `PUBLISH_PREPARATION_FAILED` → `REJECTED` | | 5 | 남은 예산 확인 | `PUBLISH_DEADLINE_EXCEEDED` → `REJECTED` | | 6 | `admission.admit(name, bytes)` | 예외 전파(`MessageTooLargeException`/`MessageBackpressureException`) | | 7 | `runtimes.acquire(broker)` | `PUBLISH_RUNTIME_UNAVAILABLE` → `REJECTED` | | 8 | `transport.publish(...)` + 마감 | 타임아웃 → `AMBIGUOUS` / 그 외 예외 → `AMBIGUOUS` | **1–7은 전부 `REJECTED`, 8만 `AMBIGUOUS`다.** 그 경계가 정확히 "바이트가 프로세스를 떠났는가"다. ```java } catch (RuntimeException beforeTheWire) { // Nothing left this process, so the outcome is definite. Reporting it as ambiguous would send // the caller into reconciliation for a message no broker ever saw. return rejected("PUBLISH_PREPARATION_FAILED", sanitized(beforeTheWire), startedAt); } ``` `messaging-core-api`의 3상태(§4.1)가 여기서 실제 분기가 된다. 그리고 `rejected(...)`가 만드는 `PublishResult`는 `PublishEvidence.notTransmitted()`를 쓰므로 `PublishResult` 생성자의 14가지 금지 조합 검증을 자연히 통과한다. **3번이 4번보다 먼저인 이유**가 인라인 주석에 있다. ```java // Before encoding: an unauthorized publish must not serialise the payload, because the // encoded bytes are what a claim-check or a log would then be holding. ``` ### 4.2 예산은 호출 시점부터 센다 ```java // :131-138 *

Measured from the call, not from the send. {@code PublishOptions.timeout()} is documented as * the publish operation's deadline, so a slow destination lookup or a large encode spends the * same budget the broker wait does; timing only the transport call would let the total exceed the * deadline by however long preparation took. ``` `remainingBudget`이 `timeout - elapsedSince(startedAt)`이고, 0 이하면 전송 전에 `REJECTED`로 끝낸다 — "Sending anyway would start a message the caller has already stopped waiting for." ### 4.3 마감을 복사본에 건다 ```java // :143-154 *

The bound is applied to a copy so that expiry never completes the transport's own stage: the * adapter still owns its in-flight publish and its own bookkeeping. The permit and the runtime * lease are released when the copy completes, which is deliberate — holding them until a stalled * broker answers is how a rotation waits forever on a generation nobody is using. private static CompletableFuture withDeadline( CompletionStage inFlight, Duration remaining) { return inFlight.toCompletableFuture().copy() .orTimeout(remaining.toMillis(), TimeUnit.MILLISECONDS); } ``` `.copy()`가 핵심이다. `orTimeout`을 원본에 걸면 만료가 어댑터의 stage를 완료시켜 어댑터의 자기 정리가 깨진다. 복사본에 걸면 만료는 이쪽 경로만 끝내고 어댑터는 자기 in-flight를 계속 소유한다. 그 대가도 명시돼 있다 — permit과 lease는 **복사본이 완료될 때** 반납되므로, 브로커가 나중에 응답해도 이미 반납된 상태다. 그것이 의도다("holding them until a stalled broker answers is how a rotation waits forever"). ### 4.4 획득한 것은 모든 경로에서 정확히 한 번 반납된다 ```java // :51-53 *

Everything acquired is released exactly once, on every path — success, failure, exception and * cancellation. A permit or lease that leaks on the failure path is a limiter that shrinks by one * per failure until it stops accepting anything. ``` 두 경로가 있다. ```java .handle((result, failure) -> { // One release per acquisition, whatever happened. held.close(); admission.complete(destination.name().value()); ... }); ``` ```java } catch (RuntimeException beforeTheSend) { if (lease != null) { lease.close(); } admission.complete(destination.name().value()); return rejected("PUBLISH_RUNTIME_UNAVAILABLE", ...); } ``` `handle`은 `whenComplete`와 달리 실패를 삼키고 값을 반환하므로 두 경우가 한 블록에서 처리된다. `lease.close()`는 `MessagingRuntimeLease` 계약상 멱등이고(`transport-spi` §4.1), `admission.complete`도 미보유 목적지에 대해 무해하다(`messaging-policy` §4.3). **한 가지 비대칭.** 6번(`admit`)이 예외를 던지면 그 예외가 그대로 호출자에게 전파된다 — `try` 블록 밖이다. 다른 모든 실패는 `PublishResult`로 정규화되는데 admission 실패만 예외다. `MessageTooLargeException`·`MessageBackpressureException`은 `MessagingException`이므로 호출자가 `FailureDescriptor`를 얻을 수 있지만, 반환 타입이 `CompletionStage`인 메서드가 **동기적으로 throw**한다. §17. ### 4.5 `requireSupportedOptions` — 조용한 no-op을 막는다 ```java // :65-72 *

The transports accept {@code request.options()} and read nothing from it, so an option this * destination cannot honour has to be refused here or it is honoured nowhere. A caller asking for * broker-side deduplication got a publish with no deduplication and no error, and then skipped * the idempotency it would otherwise have written — which is exactly the case {@code * PublishDeduplication}'s own javadoc says must be a startup failure rather than a silent no-op. ``` `messaging-core-api`의 `PublishDeduplication` javadoc("Requesting this on a broker without the `deduplicatedPublish` capability is a startup failure, not a silent no-op")이 여기서 실제 검사가 된다. 다만 **startup이 아니라 publish 시점**이다 — javadoc이 요구한 시점과 실제 시점이 다르다. §17. 그리고 "The transports accept `request.options()` and read nothing from it"은 이 leaf가 관측한 어댑터 쪽 사실이다. 어댑터 leaf SSOT들이 그것을 확인해야 한다. ### 4.6 `encode` — 폴백이 기본 codec이다 ```java private MessageEnvelope encode(MessageEnvelope message) { MessageCodec codec = codecs.find(message.contentType()).orElseGet(codecs::defaultCodec); ... } ``` 봉투의 content type에 맞는 codec이 없으면 기본 codec으로 인코딩한다. **content type을 무시하는 폴백**이다 — 봉투가 `application/avro`를 선언해도 registry에 Avro codec이 없으면 JSON으로 인코딩되고, `EncodedMessage`의 content type은 codec이 정하므로(`ContentType.JSON`) 봉투 선언과 실제 인코딩이 갈라진다. 그리고 출하 registry에는 JSON 하나뿐이다(`analysis/messaging/messaging-schema-json.md` §2). §17. `RegisteredMessageCodecs.defaultCodec()`이 raw bytes일 수 없다는 것은 그 클래스가 생성자에서 강제한다(§4.8). ### 4.7 `DestinationProfileRegistry` — 폴백 없는 조회 ```java // :13-18 *

Nothing resolved a logical destination to a profile before this: the brokers took an * already-resolved {@code DestinationProfile} and the publisher that would have produced one did * not exist. A registry rather than a lookup with a fallback, because a destination nobody declared * has no physical name, no ordering guarantee and no payload bound — publishing to it would mean * inventing all three at the call site. ``` `require`가 미등록 목적지에 `MessagingConfigurationException("DESTINATION_NOT_REGISTERED")`을 던지고 메시지가 세 가지 부재를 나열한다. `empty()` factory도 있다 — "every publish is refused until a destination is declared". ### 4.8 `RegisteredMessageCodecs` — 기본 codec은 명시 선택 ```java // :18-27 *

The default codec is a deliberate choice rather than "the first one registered". Selecting one * by iteration order means the encoding a message is written with depends on how the map was * populated, which is a wire-format decision made by accident. The registry takes it explicitly and * refuses to be constructed without it. * *

The raw-bytes codec is never eligible as the default — that is the contract's own rule, and * the reason is that raw bytes silently disable schema validation for every destination that forgot * to declare an encoding. ``` 두 가지를 생성자에서 거절한다. ```java if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) { throw ... } ... MessageCodec existing = into.putIfAbsent(codec.contentType(), codec); if (existing != null && existing != codec) { // Two codecs for one content type is not a preference to resolve at runtime: whichever wins // decides how bytes on the wire are read by a consumer that was compiled against the other. throw new IllegalArgumentException("two codecs claim content type " + ...); } ``` **클래스가 아니라 content type으로 raw-bytes를 거절**하는 것이 `messaging-schema-api`의 규칙보다 넓다 — 그 leaf §12.2가 소유한다. ### 4.9 `TransportMessagingRuntime` — 얇은 포장 `MessagingRuntime` 구현으로 `brokerName`·`generation`·`transport` 셋을 들고 `close()`가 CAS로 멱등이다. ```java // close():61-62 // Idempotent: the registry closes a drained generation, and a context shutdown may close it // again. Closing a transport twice is not an error worth propagating into shutdown. ``` `DefaultMessagingRuntimeRegistry`(transport-spi)도 자체 `closed` CAS를 갖는다 — **두 층이 각각 멱등**이다. 중복 방어이지만 `transport-spi`의 `Generation.forceClose()`가 이미 한 번만 부르므로 이쪽 CAS는 컨텍스트 종료 경로를 위한 것이다. **generation이 항상 `1L`이다.** starter의 유일한 설치 지점(`:476`)이 리터럴 `1L`을 넘긴다. `MessagingRuntime.generation()` javadoc은 "increasing with each replacement"라고 하고, `TransportMessagingRuntime` javadoc은 "the credential generation a rotation increments"라고 한다. 회전 코드가 없으므로 항상 1이다. §17. ### 4.10 `DeclaredDestinationAccess` — 기본값의 세 번째 선택지 ```java // :13-32 *

{@link DestinationAccessPolicy} is three sets of destination names and has a {@code denyAll()} * factory. Neither is a usable default on its own: * *

* *

So the default is neither: a deployment may publish to the destinations it declared. * … a message to a destination nobody declared is not an access-control edge case, it is a typo or * a module reaching past its own contract. * *

Consume and administer stay empty. A publisher's default has no business granting either, and * a deployment that needs them replaces this bean — which is the point of it being a bean. ``` **publish만 허용하고 consume·administer는 빈 집합**이다. 이것이 §12.1의 소비 경로 미조립과 정합적이다 — 기본 접근 정책이 소비를 허용하지 않는다. ### 4.11 `DefaultDeliveryProcessor` — 두 규칙 (미조립) ```java // :27-36 *

  • One terminal call. A delivery is acknowledged, requeued or discarded once. * A second call is a programming error … acknowledging after a requeue tells the broker the * message is done while a copy is already in flight. *
  • Dead-letter before acknowledgement. The source is acknowledged only after * the dead-letter publish is confirmed. … ``` `OneShotSettlement`이 `AtomicBoolean` CAS로 한 번을 강제하고, 두 번째 호출은 `CompletableFuture.failedFuture(IllegalStateException)`을 반환한다 — 예외를 던지지 않고 stage로 보고한다. 핸들러 예외 처리에 이전 결함이 기록돼 있다. ```java } catch (RuntimeException handlerFailed) { // A handler that threw is a retry, not a discard. Treating an exception as "this message is // undeliverable" is how a transient bug in one consumer silently drops a day of traffic — // and it is exactly what the Rabbit consumer did by folding handler exceptions into its // deserialization-failure path. return settlement.requeue(retryDelay); } ``` `result == null`도 requeue다. 그런데 그것을 서술하는 `missingResult()` 정적 메서드가 있고 **아무도 부르지 않는다** — `HANDLER_RETURNED_NOTHING` 코드가 만들어지지만 어떤 경로도 그 descriptor를 사용하지 않는다. §17. DLQ 분기의 두 주석이 trade를 명시한다. ```java ? settlement.acknowledge() // Confirmed: the message exists somewhere else, so removing it here is safe. : settlement.requeue(retryDelay); // Not confirmed — rejected or ambiguous. Requeueing risks a // duplicate; acknowledging loses the message outright, and a // duplicate is the recoverable half of that choice. ``` `messaging-policy`의 `DeadLetterOrchestrator`가 같은 불변식을 다른 형태로 구현한다(§12.3). --- ## 5. 주요 실행 경로 **발행(조립됨):** §4.1의 8단계. **소비(미조립):** `TransportDelivery` → `handler.apply(envelope)` → `HandleResult` 4분기 → `OneShotSettlement`로 정확히 한 번 정산. **세대 설치(조립됨):** `InitializingBean` → `transport.getIfAvailable()` → null이면 조용히 반환(이유가 주석에 있음) → `new TransportMessagingRuntime(brokerName, 1L, transport)` → `runtimes.install(...)`. --- ## 6. 실패 경로와 복구/번역 `DefaultMessagePublisher`가 만드는 결과: | 코드 | completion | category | 언제 | |---|---|---|---| | `PUBLISH_FORBIDDEN` | `REJECTED` | `CONFIGURATION` | 접근 정책 거부 | | `PUBLISH_PREPARATION_FAILED` | `REJECTED` | `CONFIGURATION` | 해석·인코딩 중 예외 | | `PUBLISH_DEADLINE_EXCEEDED` | `REJECTED` | `CONFIGURATION` | 전송 전 예산 소진 | | `PUBLISH_RUNTIME_UNAVAILABLE` | `REJECTED` | `CONFIGURATION` | lease 획득 실패 | | `PUBLISH_DEADLINE_EXCEEDED` | `AMBIGUOUS` | `AMBIGUOUS` | 전송 후 마감 | | `PUBLISH_OUTCOME_UNKNOWN` | `AMBIGUOUS` | `AMBIGUOUS` | 전송 후 그 외 실패 | 같은 코드 `PUBLISH_DEADLINE_EXCEEDED`가 **두 completion에 쓰인다.** 전송 전이면 `REJECTED`, 후면 `AMBIGUOUS`다. 코드만 보는 대시보드는 두 경우를 구분할 수 없다 — completion을 함께 봐야 한다. §17. `sanitized(Throwable)`가 메시지가 아니라 **타입 이름만** 남긴다. ```java // :175-180 *

    A driver message can carry a routing key, a payload fragment or a connection string, and a * {@code FailureDescriptor} is designed to be logged and exported. return cause.getClass().getSimpleName(); ``` `messaging-core-api`의 `FailureDescriptor` javadoc("no payload, no stack trace, no credential")과 같은 관심사다. `isDeadline`과 `sanitized` 둘 다 `CompletionException`을 한 겹 벗긴다 — 비동기 경로에서 원인이 감싸지기 때문이다. `DefaultDeliveryProcessor`는 예외를 던지지 않는다. 이중 정산만 `failedFuture`로 보고한다. --- ## 7. 트랜잭션·동시성·수명주기 트랜잭션 없음. | 지점 | 도구 | 보호 | |---|---|---| | `OneShotSettlement.settled` | `AtomicBoolean` CAS | 정확히 한 번 정산 | | `TransportMessagingRuntime.closed` | `AtomicBoolean` CAS | 정확히 한 번 transport close | | `RegisteredMessageCodecs.byContentType` | `Map.copyOf` | 불변 | | `DestinationProfileRegistry.profiles` | `Map.copyOf` | 불변 | | `withDeadline`의 `.copy()` | `CompletableFuture` | 어댑터 stage와 이쪽 경로 분리 | `DefaultMessagePublisher` 자체는 불변이고 상태를 갖지 않는다 — 필드 여덟이 전부 final 협력자다. `lease`만 메서드 지역 변수이고 `handle` 람다가 `held`라는 effectively-final 복사본으로 캡처한다. 수명주기 참여는 `TransportMessagingRuntime.close()`뿐이고, 그것을 부르는 것은 registry(회전 시)와 컨텍스트 종료 두 경로다. --- ## 8. 설정·기능 플래그·환경 차이 설정 없음. 이 leaf의 모든 값은 생성자 인자다. **주입 가능한 두 지점**이 테스트 가능성을 만든다. | 인자 | 기본 | 목적 | |---|---|---| | `LongSupplier nanoTime` | `System::nanoTime` | 경과 시간을 sleep 없이 테스트 | | `MessagingObservation observation` | `NO_OBSERVATION` | 관측 주입 | 두 번째의 기본값이 §12.1의 발견 지점이다. `TransportMessagingRuntime`의 `generation`은 생성자 인자이고 유일한 호출자가 `1L`을 넘긴다. --- ## 9. 퍼시스턴스/외부 시스템 세부 없다. 브로커 접촉은 `MessagingTransport` 인터페이스 뒤에 있다. --- ## 10. 테스트 레인과 실제 증명 범위 레인: `./gradlew :messaging:messaging-runtime-core:test`. **BUILD SUCCESSFUL, 21 tests, 0 skipped, 0 failures**. | 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 | |---|---:|---|---| | `DefaultMessagePublisherTest` | 10 | 8단계 순서, 각 실패의 completion·code, 마감 전후 구분, permit/lease 반납, 관측 호출 | 실제 브로커. **출하 조립이 관측을 넘기는지** | | `DefaultDeliveryProcessorTest` | 7 | `HandleResult` 4분기 → 정산, 핸들러 예외 → requeue, DLQ 확인 후 ack / 미확인 시 requeue, 이중 정산 거절 | **production에서 호출되는지**(§12.1) | | `RegisteredMessageCodecsTest` | 4 | raw-bytes 기본 거절, content type 충돌 거절, 조회 | — | `DefaultMessagePublisherTest:271`이 익명 `MessagingObservation`을 만들어 관측 호출을 확인한다. 즉 **테스트는 8인자 생성자를 쓰고 출하는 6인자를 쓴다.** 테스트가 검증하는 경로와 출하되는 경로가 이 인자 하나만큼 다르다. `RecordingTransport`(`:426`)가 `MessagingTransport`를 구현해 전송을 대체한다. 그래서 이 레인은 "발행 오케스트레이션이 옳다"를 증명하고 "어댑터가 계약을 지킨다"는 증명하지 않는다. --- ## 11. 빌드/ArchUnit/CI 강제 지점 | 게이트 | 이 leaf에 대해 | |---|---| | `verifyCleanArchitectureDependencies` | 여섯 project 의존 | | `verifyRuntimeModuleMembership` | `["app-bootstrap"]` | | vendor `api` 규칙 | 벤더 의존성 0 | | ArchUnit | 전용 규칙 없음 | `MessagingStarterOffContractTest`(starter leaf)가 이 leaf의 조립 이력을 문자열로 언급한다 — "DeadLetterOrchestrator had nothing to depend on. DefaultMessagePublisher …". 그 테스트가 무엇을 실제로 강제하는지는 starter leaf SSOT가 소유한다. --- ## 12. 실제 사용 여부와 negative-space probes 원시 증거: `evidence/raw/283-runtime-core-observation-noop.txt`. ### 12.1 Public surface reachability | 타입 | leaf 밖 파일 | 출하 조립 | |---|---:|---| | `DefaultMessagePublisher` | 2 | **o** — `MessagingCoreAutoConfiguration:446` | | `TransportMessagingRuntime` | 1 | **o** — `:476` | | `RegisteredMessageCodecs` | 1 | **o** — `:363` | | `DestinationProfileRegistry` | 1 | **o** — `:377` | | `DeclaredDestinationAccess` | 1 | **o** | | `DefaultDeliveryProcessor` | **0** | **x** — `src/main` 생성 0, `src/test` 1 | **(a) 소비 경로의 유일한 오케스트레이터가 조립되지 않는다** `DefaultDeliveryProcessor`는 leaf 밖 참조가 0이고 `src/main`에서 생성되지 않는다. 이것이 `analysis/messaging/messaging-policy.md` §12.1이 관측한 "소비 경로 전체 미조립"의 중심이다 — 어댑터의 consumer registrar들도, 재시도 실행자도, DLQ 발행자도 전부 조립되지 않는다. 이 클래스의 javadoc은 자기가 **고친** 문제를 서술한다 — "Each broker adapter decided for itself what a retry or a dead-letter meant, so '_the platform decides when and in what order the settlement happens_' … described a decision nobody made in one place." 그 결정을 한 곳에 모았고, 그 한 곳이 배선되지 않았다. **(b) 관측이 구현·호출부·인자를 모두 갖추고도 no-op이다** 네 조각이 있다. | 조각 | 상태 | |---|---| | `MessagingObservation` 인터페이스 (observability) | 존재 | | `MessagingMetrics implements MessagingObservation` | 존재 | | `DefaultMessagePublisher.observe(...)` 호출부 | 존재, 모든 발행 결과를 기록 | | 8인자 생성자 (관측 주입) | 존재 | | **출하 조립** | **6인자 생성자 → `NO_OBSERVATION`** | | **`MessagingMetrics` bean** | **없음** | ```java // MessagingCoreAutoConfiguration.java:446-447 return new dev.caskeleton.messaging.runtime.DefaultMessagePublisher( destinations, access, codecs, admission, runtimes, transport); ``` 그리고 `MessagingMetrics`는 저장소 전체에서 **자기 테스트에서만** 생성된다(`MessagingMetricCardinalityTest`, `MessagingSecretLeakTest`). starter는 `MessagingMetrics`의 **두 협력자를 bean으로 만든다** — `MessagingRedactor`(:253)와 `CardinalityGuard`(:264). `MessagingMetrics`의 생성자는 `(registry, CardinalityGuard, MessagingRedactor)`를 받는다(테스트가 그렇게 호출한다). 즉 **재료 둘은 배선됐고 그것을 조립하는 bean이 없다.** 이 클래스의 javadoc이 그 상황을 예언한다. ```java // DefaultMessagePublisher.java:74-78 *

    {@code 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. ``` **이전 상태:** bean은 있고 호출하는 경로가 없었다. **현재 상태:** 호출하는 경로는 있고 bean이 없다. 두 상태의 관측 결과는 같다 — 메트릭이 비어 있다. 고침이 간극을 닫은 것이 아니라 **반대편으로 옮겼다.** 그리고 "constructor argument rather than an optional decorator"라는 선택이 그것을 막지 못했다 — 인자를 기본값으로 채우는 짧은 생성자가 함께 존재하기 때문이다. **(c) 배선된 것은 확실히 배선됐다** 발행 경로 다섯이 전부 `src/main`에서 생성된다(§2 표). 대조군으로서 이 사실이 (a)와 (b)의 판정을 뒷받침한다 — 검색 방법이 조립을 놓치는 것이 아니라 실제로 조립되지 않은 것이다. **한계.** 정적 검색이다. `ObjectProvider` 지연 조회는 `MessageContracts`와 `MessagingTransport` 두 곳에만 쓰이고 둘 다 확인했다. 파생 프로젝트가 `MessagingObservation` bean을 제공하면 `@ConditionalOnMissingBean(MessagePublisher.class)` 때문에 publisher bean 자체를 대체해야 한다 — 관측만 끼워 넣을 수는 없다. ### 12.2 Conditional sibling comparison 이 leaf에 bean은 없다. starter 쪽 sibling 비교가 유의미하다. `MessagingCoreAutoConfiguration`이 이 leaf의 타입을 만드는 지점 다섯의 조건: | 대상 | 조건 | |---|---| | `RegisteredMessageCodecs` | `@ConditionalOnMissingBean(MessageCodecRegistry.class)` | | `DestinationProfileRegistry` | `@ConditionalOnMissingBean` | | `DefaultMessagePublisher` | `@ConditionalOnMissingBean(MessagePublisher.class)` | | `TransportMessagingRuntime` | 조건 없음 — `InitializingBean` 안, `transport.getIfAvailable()` null 검사 | | `DeclaredDestinationAccess` | `@ConditionalOnMissingBean` | **네 번째만 조건 대신 런타임 null 검사를 쓴다.** 그 이유가 주석에 있다. ```java // Not a silent skip of a check: MessagingProviderSelection is what guarantees a transport // when a broker is selected, and it refuses startup by name when one is not. This // configuration is also loadable on its own — an adopter composing the policy primitives // without a transport — and demanding one here would refuse that. ``` 즉 "transport 없이도 로드 가능해야 한다"가 명시적 요구이고, 그 요구가 `@ConditionalOnBean` 대신 런타임 분기를 쓰게 했다. 부재 시 조용히 반환하지만 그것이 조용한 스킵이 아님을 주석이 다른 게이트(`MessagingProviderSelection`)로 설명한다. 그 게이트의 실제 동작은 starter leaf SSOT가 확인해야 한다. ### 12.3 Duplicate mechanism sweep **(a) DLQ 순서 불변식이 두 곳에 구현돼 있다** | | `messaging-policy` `DeadLetterOrchestrator` | 이 leaf `DefaultDeliveryProcessor` | |---|---|---| | 불변식 | 확인 후에만 원본 정산 | 확인 후에만 ack | | 미확인 시 | 정산하지 않음(`sourceSettled=false`) | **requeue** | | 헤더 | 예약 헤더 6개 부착 | 없음 | | 발행 주체 | `MessagePublisher` | `DeadLetterPublisher` 함수형 인터페이스 | **미확인 시 동작이 다르다.** policy 쪽은 "정산하지 않는다"(브로커가 알아서 재전달), 이쪽은 "명시적으로 requeue한다". 둘 다 메시지를 잃지 않지만 `requeue(delay)`는 지연을 지정하고 무정산은 브로커의 기본 재전달 타이밍을 따른다. 둘 다 조립되지 않았으므로 오늘 충돌하지 않는다. `analysis/messaging/messaging-policy.md` §12.3(b)가 같은 사건을 반대편에서 기록한다. **(b) 재시도 지연이 두 출처** `DefaultDeliveryProcessor`의 `retryDelay`는 **생성자 인자 하나**다. 시도 횟수를 세지 않고 백오프도 없다. `messaging-policy`의 `BackoffCalculator`(지수 + full jitter + 상한)와 대비된다. 같은 leaf 문서 §12.3(a)가 소유한다. **(c) 멱등 종료가 두 층** `TransportMessagingRuntime.close()`와 `DefaultMessagingRuntimeRegistry.Generation.forceClose()`(transport-spi) 둘 다 CAS로 한 번을 보장한다. 중복이지만 **의도된 중복**이다 — 이쪽 주석이 "the registry closes a drained generation, and a context shutdown may close it again"이라고 두 경로를 명시한다. 결함 아님. **(d) content type 폴백** `encode`가 `codecs.find(contentType).orElseGet(codecs::defaultCodec)`으로 폴백한다. `RegisteredMessageCodecs.find`는 미등록이면 `Optional.empty()`를 주고, `defaultCodec()`은 JSON이다. 즉 **선언된 content type과 실제 인코딩이 갈라질 수 있는 유일한 지점**이고, 그 갈라짐이 조용하다. §17. ### 12.4 Documentation / measured-count drift | 문서 주장 | 재측정 | 결과 | |---|---|---| | build.gradle 주석: `MessagePublisher`에 구현이 없었다 | 현재 이 leaf가 구현하고 `:446`에서 조립 | **해소됨** | | `TransportMessagingRuntime` javadoc: registry가 비어 있어 모든 발행이 실패했다 | 현재 `:476`이 설치 | **해소됨** | | `DefaultMessagePublisher` javadoc: 관측 bean이 있고 호출 경로가 없었다 | 현재 호출 경로가 있고 bean이 없다 | **반전됨**(§12.1b) | | `DefaultDeliveryProcessor` javadoc: 어댑터가 각자 결정했다 | 한 곳에 모았으나 조립되지 않음 | **부분 해소** | | `MessagingRuntime.generation()` javadoc: "increasing with each replacement" | 유일한 설치가 리터럴 `1L` | **미실현** | | `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) | 세 번째와 다섯 번째가 이 leaf의 §17 항목이 된다. --- ## 13. Git/설계 문서에서 확인한 변화와 실패 기록 이 leaf는 **통째로 하나의 수정**이다. MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다(`DeclaredDestinationAccess`, `TransportMessagingRuntime`, `MessagingCoreAutoConfiguration:461`). | 위치 | 이전 상태 | 그것이 만든 실패 | |---|---|---| | `build.gradle` 주석 | `MessagePublisher` 구현 없음 | 자동설정이 없는 bean 위에 DLQ·facade bean을 쌓음. admission·security·lease·observation이 bean으로 존재하되 어떤 발행도 부르지 않음 | | `TransportMessagingRuntime` javadoc | `MessagingRuntime` 구현 없음 | registry가 빈 채로 만들어져 모든 발행이 `PUBLISH_RUNTIME_UNAVAILABLE` — 목적지 해석·접근 확인·인코딩을 **전부 마친 뒤에** | | `DestinationProfileRegistry` javadoc | 논리 이름→프로파일 해석 없음 | 어댑터는 해석된 프로파일을 받는데 그것을 만들 publisher가 없었음 | | `DefaultDeliveryProcessor` javadoc | `HandleResult`→정산 연결 없음 | 각 어댑터가 retry/dead-letter의 뜻을 각자 결정 | | `DefaultDeliveryProcessor` 핸들러 예외 주석 | Rabbit consumer가 핸들러 예외를 역직렬화 실패 경로로 접음 | 한 consumer의 일시적 버그가 하루치 트래픽을 조용히 버림 | | `requireSupportedOptions` javadoc | transport가 `options`를 읽지 않음 | 중복 억제를 요청한 호출자가 억제도 오류도 못 받고, 그래서 쓸 idempotency를 건너뜀 | | `withDeadline` javadoc | transport가 마감을 무시 | 확인이 오지 않는 Rabbit publish에 마감이 없어 호출자 스레드가 완료 불가능한 stage에 묶임 | `build.gradle` 주석의 마지막 문장이 이 leaf 전체의 교훈이다 — "A starter that filled the gap with an application-supplied fake would pass a context test while running none of them." --- ## 14. 런타임·터미널 Evidence | id | 종류 | 파일 | 무엇을 보여주는가 | 한계 | |---|---|---|---|---| | EVD-283 | command | `evidence/raw/283-runtime-core-observation-noop.txt` | 여섯 타입 참조 수, 발행 경로 조립 지점, `DefaultDeliveryProcessor` src/main=0, 관측 4조각과 끊긴 한 지점, `MessagingMetrics`가 테스트에서만 생성됨, starter가 만드는 관측 bean 둘 | 정적 검색. 파생 프로젝트의 대체 조립 미포함 | | EVD-284 | command | `./gradlew :messaging:messaging-runtime-core:test --rerun-tasks` | BUILD SUCCESSFUL, 21 / 0 / 0 | 브로커 대체(`RecordingTransport`) | --- ## 15. 명시적 설계 이유와 추론을 구분한 정리 **명시적** - 이 leaf가 존재하는 이유와 이전 결함 — `build.gradle` 주석 - 발행 8단계의 순서가 고정된 이유와 각 위치의 근거 — `DefaultMessagePublisher` javadoc - 접근 확인이 인코딩보다 먼저인 이유 — 인라인 주석 - 전송 전 실패가 `REJECTED`인 이유 — 인라인 주석 - 예산을 호출 시점부터 세는 이유 — `remainingBudget` javadoc - 마감을 복사본에 거는 이유와 그 대가 — `withDeadline` javadoc - 모든 경로에서 정확히 한 번 반납하는 이유 — 클래스 javadoc + 인라인 주석 - 지원하지 않는 옵션을 거절하는 이유 — `requireSupportedOptions` javadoc - 기본 codec을 명시 인자로 받는 이유, raw-bytes 금지 이유 — `RegisteredMessageCodecs` javadoc - 폴백 없는 목적지 조회 이유 — `DestinationProfileRegistry` javadoc - 기본 접근 정책이 deny도 allow도 아닌 이유 — `DeclaredDestinationAccess` javadoc - 핸들러 예외가 retry인 이유 — 인라인 주석 - DLQ 미확인 시 requeue를 고른 이유 — 인라인 주석 - transport 부재를 조용히 넘기는 것이 조용한 스킵이 아닌 이유 — `InitializingBean` 안 주석 - 관측을 생성자 인자로 둔 이유 — `observation` 필드 javadoc **추론** - 출하 조립이 6인자 생성자를 쓰는 것이 의도인지 → **추론이 아니라 미상.** 어디에도 근거가 없고, 8인자 생성자와 `MessagingMetrics`가 둘 다 존재한다는 점이 미완을 시사한다. - `generation`이 항상 1인 것은 회전 코드가 없기 때문이다 → **추론**. 회전 코드 부재는 관측이다. - `DefaultDeliveryProcessor` 미조립이 미완인지 확장점인지 → **미상**. --- ## 16. 확인한 것 / 확인하지 못한 것 **확인한 것** - 6개 클래스 787줄 전문의 계약과 순서 결정 - 21개 테스트가 통과하고 무엇을 단언하는지 - 다섯 클래스가 출하 컨텍스트에서 조립되고 정확히 어느 라인인지 - `DefaultDeliveryProcessor`가 `src/main`에서 생성되지 않는다는 것 - 관측의 네 조각 중 마지막 하나(bean)가 없고, 출하가 no-op 생성자를 쓴다는 것 - `MessagingMetrics`가 자기 테스트에서만 생성되고, 그 협력자 둘은 bean으로 존재한다는 것 - `generation`이 유일한 설치 지점에서 리터럴 `1L`이라는 것 **확인하지 못한 것** - **6인자 생성자 선택이 의도인지.** 커밋이 대량 커밋 4개뿐이고 이 선택을 설명하는 기록이 없다. - `MessagingProviderSelection`이 실제로 transport 부재를 이름으로 거절하는지 — starter leaf가 소유한다. - 어댑터들이 `request.options()`를 정말 읽지 않는지 — 이 leaf의 javadoc이 그렇게 주장하고, 각 어댑터 leaf가 확인해야 한다. - 실제 브로커에서 `withDeadline`의 `.copy()` 전략이 어댑터 정리와 어떻게 상호작용하는지. 컨테이너 레인이 있으나 실행하지 않았다. - 파생 프로젝트가 publisher bean 전체를 대체해 관측을 넣는지. --- ## 17. 손볼 것 ### P2 — 관측이 구현·호출부·주입 자리를 모두 갖추고도 출하에서 no-op이다 - **사실.** `DefaultMessagePublisher`가 모든 발행 결과를 `observation.recordPublish(...)`로 기록하고, 관측을 "constructor argument rather than an optional decorator"로 받는다. `MessagingMetrics`가 `MessagingObservation`을 구현한다. 그런데 출하 조립(`MessagingCoreAutoConfiguration:446`)은 **6인자 생성자**를 써서 `NO_OBSERVATION`을 넣고, `MessagingMetrics`는 저장소 전체에서 자기 테스트에서만 생성된다. starter는 `MessagingMetrics`의 협력자 둘(`MessagingRedactor:253`, `CardinalityGuard:264`)을 bean으로 만든다. - **근거.** `evidence/raw/283` §D. - **왜 문제인가.** 이 필드의 javadoc이 정확히 이 상황을 막으려고 쓰였다 — "an unobserved publish path is how 'the dashboards were empty during the incident' happens". 그리고 같은 javadoc이 **이전 결함**을 "bean은 있고 호출 경로가 없었다"로 기록한다. 지금은 반대다 — 호출 경로가 있고 bean이 없다. 관측 결과는 같다. **고침이 간극을 닫은 게 아니라 반대편으로 옮겼다.** "decorator가 아니라 생성자 인자"라는 선택도 막지 못했는데, 인자를 기본값으로 채우는 짧은 생성자가 함께 있기 때문이다. - **확인 방법.** `evidence/raw/283` §D 재실행. 또는 `:446`의 인자 수와 `:138-146` 생성자 시그니처 대조. - **후보.** (a) `MessagingMetrics` bean을 만들고 publisher가 8인자 생성자를 쓰게 한다. (b) 6인자 생성자를 제거해 관측을 명시 인자로 강제한다. (c) 관측이 배선되지 않았음을 `support-matrix.md`에 표시한다. - **다음 단계.** **CASE 후보.** 재현이 정적이고, "장치는 있고 회로가 닫히지 않았다"의 변형 중 **회로가 반대편에서 끊긴** 사례라 독립적으로 가치가 있다. 그리고 "생성자 기본값이 있는 필수 협력자는 필수가 아니다"가 **REFERENCE 후보**다. ### P2 — 소비 오케스트레이터가 조립되지 않는다 - **사실.** `DefaultDeliveryProcessor`는 leaf 밖 참조 0, `src/main` 생성 0, `src/test` 생성 1이다. - **근거.** `evidence/raw/283` §A·§C. - **왜 문제인가.** 이 클래스가 고친 문제("각 어댑터가 retry/dead-letter의 뜻을 각자 결정")가 배선 없이는 그대로 남는다. 그리고 `DeclaredDestinationAccess`가 consume 권한을 빈 집합으로 두는 것과 정합적이다 — 기본 구성은 소비를 상정하지 않는다. - **확인 방법.** `git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?DefaultDeliveryProcessor\s*\(' -- src` - **다음 단계.** `analysis/messaging/messaging-policy.md` §17의 "출하 컨텍스트가 발행은 하고 소비는 하지 못한다"와 **동일 사건**이다. 소유는 cross-scope 또는 starter leaf. 여기서는 교차 참조만 남긴다. ### P3 — 선언된 content type과 실제 인코딩이 조용히 갈라질 수 있다 - **사실.** `encode`가 `codecs.find(message.contentType()).orElseGet(codecs::defaultCodec)`으로 폴백한다. 출하 registry에는 JSON codec 하나만 등록된다. 봉투가 `application/avro`를 선언해도 JSON으로 인코딩되고, `EncodedMessage`의 content type은 codec이 정하므로 `application/json`이 된다. - **근거.** `DefaultMessagePublisher.java:97-102`, `RegisteredMessageCodecs.find`, `MessagingCoreAutoConfiguration:363`(varargs 비어 있음). - **왜 문제인가.** 실패하지 않고 **다른 포맷으로 성공**한다. 소비 측이 봉투의 원래 선언을 믿고 디코더를 고르면 어긋난다. `DestinationProfile.schema().codec()`이 목적지의 codec을 선언하는데 그 값과 대조하는 코드가 이 경로에 없다. - **확인 방법.** 등록되지 않은 content type의 봉투를 발행해 `EncodedMessage.contentType()`을 확인. - **후보.** 미등록 content type을 `MessagingConfigurationException`으로 거절하거나, `profile.schema().codec()`과 대조한다. - **다음 단계.** **CASE 후보.** 조용한 성공이라는 형태가 `messaging-core-api`의 "조용한 성능 저하 금지" 설계와 정면으로 어긋난다. ### P3 — 같은 실패 코드가 두 completion에 쓰인다 - **사실.** `PUBLISH_DEADLINE_EXCEEDED`가 전송 전이면 `REJECTED`(`:16-21`), 전송 후면 `AMBIGUOUS`(`:42-47`)로 붙는다. - **근거.** 두 위치. - **왜 문제인가.** 두 경우의 운영자 행동이 정반대다 — 전자는 버려도 안전, 후자는 같은 `messageId`로만 재발행. `FailureDescriptor.code`가 "stable, machine-readable code"이고 대시보드가 그것으로 집계하는데, 이 코드는 completion을 함께 보지 않으면 판단을 뒤집는다. - **확인 방법.** `git grep -n 'PUBLISH_DEADLINE_EXCEEDED' -- src/messaging/messaging-runtime-core` - **후보.** 전송 전을 `PUBLISH_DEADLINE_BEFORE_SEND`처럼 분리한다. - **다음 단계.** **REFERENCE 후보**(안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다). ### P3 — admission 실패만 예외로 전파된다 - **사실.** 8단계 중 admission(`:24`)만 `try` 블록 밖이고, `MessageTooLargeException`·`MessageBackpressureException`이 그대로 던져진다. 나머지 실패는 전부 `CompletionStage`로 정규화된다. - **근거.** `DefaultMessagePublisher.java:198`(admit 호출 위치)과 그 앞뒤 try 블록 범위. - **왜 문제인가.** 반환 타입이 `CompletionStage`인 메서드가 동기적으로 throw한다. `.publish(...).exceptionally(...)`로만 처리하는 호출자는 이 두 예외를 놓친다. 두 예외 다 `MessagingException`이라 `FailureDescriptor`는 있지만 전달 방식이 다른 실패들과 다르다. - **확인 방법.** 상한 초과 payload로 `publish`를 호출하고 반환 stage가 아니라 호출 자체가 던지는지 확인. - **후보.** admission을 `try` 안으로 넣어 `rejected(...)`로 정규화하거나, javadoc에 동기 throw를 명시한다. - **다음 단계.** **REFERENCE 후보**(`CompletionStage`를 반환하는 메서드는 동기적으로 던지지 않는다). ### P3 — `generation`이 항상 1이다 - **사실.** 유일한 설치 지점(`MessagingCoreAutoConfiguration:476`)이 리터럴 `1L`을 넘긴다. `MessagingRuntime.generation()` javadoc은 "increasing with each replacement", `TransportMessagingRuntime` javadoc은 "the credential generation a rotation increments"라고 한다. - **근거.** `:476`, 두 javadoc. - **왜 문제인가.** 오늘 회전 코드가 없으므로 무해하다. 다만 `DefaultMessagingRuntimeRegistry`의 세대 드레인 로직(transport-spi §4.2)이 세대 구분을 전제하고, 진단에서 generation을 읽는 사람은 항상 1을 본다. 회전을 붙일 때 이 리터럴이 잊히면 두 세대가 같은 번호를 갖는다. - **확인 방법.** `git grep -n 'TransportMessagingRuntime(' -- src/main` - **후보.** 자격증명 회전 카운터에서 값을 가져오거나, 회전이 없음을 주석으로 남긴다. - **다음 단계.** **REFERENCE 후보**(증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다). ### P3 — `missingResult()`가 아무 데도 쓰이지 않는다 - **사실.** `DefaultDeliveryProcessor.missingResult()`(package-private static)가 `HANDLER_RETURNED_NOTHING` descriptor를 만든다. `result == null` 분기는 그것을 쓰지 않고 바로 `settlement.requeue(retryDelay)`를 부른다. - **근거.** `DefaultDeliveryProcessor.java:77-79`, `:146-154`. - **왜 문제인가.** 핸들러가 null을 반환한 경우와 `HandleResult.Retry`를 반환한 경우가 정산 수준에서 구분되지 않는다. 전자는 프로그래밍 오류이고 후자는 정상 흐름인데 같은 requeue가 된다. descriptor는 만들어졌으나 흐르지 않는다. - **확인 방법.** `git grep -n 'missingResult' -- src` - **후보.** null 분기에서 descriptor를 관측이나 로그로 흘리거나, 메서드를 제거한다. - **다음 단계.** **REFERENCE 후보**(만들어 두고 흘리지 않는 진단값은 진단이 아니다). ### 확인된 설계(문제 아님) - 발행 8단계의 고정 순서와 각 위치의 명시된 근거 - 전송 전/후 경계가 `REJECTED`/`AMBIGUOUS`를 가르는 것 - 예산을 호출 시점부터 세는 것 - 마감을 복사본에 걸어 어댑터의 stage를 완료시키지 않는 것과, permit/lease를 그 시점에 반납한다는 명시적 trade - 성공·실패·예외 모든 경로에서 lease와 permit을 정확히 한 번 반납하는 것 - 지원하지 않는 발행 옵션을 조용히 무시하지 않고 거절하는 것 - 기본 codec을 명시 인자로 받고 raw-bytes를 content type 기준으로 거절하는 것 - 폴백 없는 목적지 조회 - 기본 접근 정책이 "선언한 목적지에만 발행"인 것과 consume·administer를 비워 두는 것 - 정확히 한 번 정산(CAS)과 핸들러 예외를 retry로 취급하는 것 - 실패 서술에 예외 메시지가 아니라 타입 이름만 남기는 것 --- ## Source anchors | id | kind | path | revision | what it proves | limitations | |---|---|---|---|---|---| | MRC-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 6개, memberships `["app-bootstrap"]` | 선언 | | MRC-002 | build | `messaging-runtime-core/build.gradle` | same | 이 leaf가 존재하는 이유(MSG-INT-003 진단) | — | | MRC-003 | code | `.../runtime/DefaultMessagePublisher.java` 전문 | same | §4.1–4.6, §6 | 브로커 대체 테스트만 | | MRC-004 | code | `.../runtime/DefaultDeliveryProcessor.java` 전문 | same | §4.11 두 규칙, 핸들러 예외 이력 | 조립되지 않음(§12.1a) | | MRC-005 | code | `.../runtime/RegisteredMessageCodecs.java` | same | §4.8 기본 codec 규칙과 충돌 거절 | — | | MRC-006 | code | `.../runtime/DestinationProfileRegistry.java` | same | §4.7 폴백 없는 조회 | — | | MRC-007 | code | `.../runtime/TransportMessagingRuntime.java` | same | §4.9 멱등 종료, generation 인자 | 항상 1(§17) | | MRC-008 | code | `.../runtime/DeclaredDestinationAccess.java` | same | §4.10 기본 접근 정책의 세 번째 선택지 | — | | MRC-009 | test | `DefaultMessagePublisherTest` (10) | same | 8단계와 실패 정규화, 관측 호출 | **8인자 생성자 사용** | | MRC-010 | test | `DefaultDeliveryProcessorTest` (7) | same | 4분기 정산, 이중 정산 거절 | 배선 미증명 | | MRC-011 | test | `RegisteredMessageCodecsTest` (4) | same | 기본 codec 규칙 | — | | MRC-012 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:363,377,446,461-478` | same | 다섯 조립 지점과 6인자 생성자 선택 | 해당 leaf SSOT가 소유 | | MRC-013 | cross-leaf code | `messaging-observability/.../MessagingMetrics.java:29` | same | `MessagingObservation`의 유일한 구현 | 테스트에서만 생성 | | MRC-014 | cross-leaf code | `messaging-policy/.../DeadLetterOrchestrator.java` | same | 경쟁하는 DLQ 구현 | 해당 leaf SSOT가 소유 | | EVD-283 | command | `evidence/raw/283-runtime-core-observation-noop.txt` | same | §12.1 전부 | 정적 검색 | | EVD-284 | command | `./gradlew :messaging:messaging-runtime-core:test --rerun-tasks` | same | 21 / 0 / 0 | `RecordingTransport` 대체 |