# messaging-policy 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-policy` > SSOT owner: `messaging-policy` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-policy` - canonical state `analysisFile`: `analysis/messaging/messaging-policy.md` - source path: `src/messaging/messaging-policy` - registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]` - registry `runtime_memberships`: `["app-bootstrap"]` ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | 26 | | production LOC | 1,738 | | 패키지 | 1 (`dev.caskeleton.messaging.policy`) | | test 파일 | 4 | | test 메서드(실행 확인) | 42 | | 외부(비프로젝트) 의존성 | **0** | 26개 타입을 관심사로 나누면 다섯이다. | 축 | 타입 | |---|---| | **목적지 정의** (8) | `DestinationProfile` · `PhysicalDestination` · `SchemaPolicy` · `ProducerPolicy` · `ConsumerPolicy` · `PayloadPolicy` · `DeadLetterPolicy` · `CapabilityTier` | | **시작 검증** (1) | `DestinationProfileValidator` | | **발행 관문** (3) | `MessagingAdmissionController` · `PayloadLimitGuard` · `InFlightLimiter` | | **재시도 판단** (8) | `RetryPolicy` · `RetryMode` · `OrderingImpact` · `RetryContext` · `RetryDecision` · `RetryDecisionEngine` · `DefaultRetryDecisionEngine` · `BackoffCalculator` | | **DLQ 조정** (6) | `DeadLetterOrchestrator` · `DeadLetterEnvelopeFactory` · `DeadLetterMetadata` · `DeadLetterResult` · `SourceSettlement` · `FailureDescriptorDefaults`(package-private) | **다섯 축의 배선 상태가 서로 다르다.** 목적지 정의·시작 검증·발행 관문은 출하 컨텍스트에서 실제로 실행되고, 재시도 판단과 DLQ 조정은 bean으로 생성되지만 주입되는 곳이 없다(§12.1). ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `src/main/java/**` (26) | 26 | `FULL_READ` | 전 파일 본문 확인 | | `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 및 단언 확인 | | `build.gradle` | 1 | `FULL_READ` | 6줄 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 이 leaf는 **"이 목적지는 무엇을 약속하는가"**를 소유한다. 브로커를 만지지 않고 벤더 의존성이 0이며, 대신 브로커 어댑터가 따라야 할 판단을 미리 계산한다. 경계 규칙 하나가 leaf 전체를 관통한다: **모순은 부팅 실패여야 한다.** ```java // DestinationProfileValidator.java:20-24 *

Every rule here exists because the alternative is a production surprise. A profile that asks * for ordered delivery and configures a reordering retry does not fail on the happy path; it fails * the first time a message is retried, months later, in a way that looks like a data bug rather * than a configuration one. Making the contradiction a boot failure moves that discovery to the * deploy that introduced it. ``` 두 번째 경계는 **물리 주소의 격리**다. ```java // PhysicalDestination.java:9-11 *

Held here and nowhere else. Once a topic name reaches application code the logical destination * stops being a boundary, and swapping the broker under a service becomes a code change instead of * a configuration change. ``` `messaging-core-api`의 `DestinationName`이 `:`과 `/`를 정규식으로 막고(그쪽 §4), 이 leaf가 물리 주소를 독점한다. 두 leaf가 같은 경계를 양쪽에서 지킨다. --- ## 2. 의존성과 런타임 배선 들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api). 둘 다 `api`인 이유는 `DestinationProfile`이 `DeliveryGuarantee`·`OrderingScope`·`DestinationKind`·`DestinationName`(core-api)와 `SchemaCompatibility`(schema-api)를 필드로 갖기 때문이다. 나가는 것: `messaging-transport-spi`, `messaging-runtime-core`, `messaging-kafka`, `messaging-kafka-share-experimental`, `messaging-rabbit`, `messaging-outbox-jdbc-postgresql`, `messaging-admin-api`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-cloud-stream-bridge`, `messaging-spring-boot-starter`, `messaging-testkit`. **실제 배선 지점 넷**(전부 `messaging-spring-boot-starter/MessagingCoreAutoConfiguration`): | 지점 | 라인 | 상태 | |---|---:|---| | `new DestinationProfileValidator().validateAll(registered)` | 134 | **실행됨** — 시작 시 전체 registry 검증 | | `DestinationProfileValidator` bean | 145–146 | 생성 | | `MessagingAdmissionController` bean | 407–417 | 생성 + `DefaultMessagePublisher`·`MessagingEndpoint`·`MessagingShutdownLifecycle`이 주입받음 | | `RetryDecisionEngine` bean | 167–169 | 생성, **주입처 없음**(§12.1) | | `DeadLetterOrchestrator` bean | 179–181 | 생성, **주입처 없음**(§12.1) | 이 leaf 자체는 Spring 주석을 갖지 않는다 — bean 정의는 전부 starter 쪽에 있다. --- ## 3. 패키지/컴포넌트 지도 ``` [목적지 정의] DestinationProfile ─┬─ PhysicalDestination (topic/exchange/routingKey/queue/subject/stream) ├─ SchemaPolicy (codec, compatibility, 닫힌 messageTypes) ├─ ProducerPolicy (confirmation, timeout, mandatoryRouting, idempotent) ├─ ConsumerPolicy (group, concurrency, maxInFlightPerUnit, prefetch, timeout, manual) ├─ RetryPolicy (mode, maxAttempts, backoff, orderingImpact, 카테고리 오버라이드) ├─ DeadLetterPolicy (enabled, destination, maxRedriveCount) ├─ PayloadPolicy (maxBytes, claimCheckThreshold) └─ CapabilityTier (M1/M2/M3) [시작 검증] DestinationProfileValidator ├─ validate(profile) : 프로파일 내부 모순 15가지 └─ validateAll(profiles) : 중복 이름 + retry/DLQ 그래프 사이클 [발행 관문] MessagingAdmissionController ├─ PayloadLimitGuard ── PayloadPolicy └─ InFlightLimiter (Semaphore, fair) [재시도 판단] RetryContext ─→ RetryDecisionEngine ─→ RetryDecision (sealed 5) ↑ DefaultRetryDecisionEngine ── BackoffCalculator [DLQ 조정] DeadLetterOrchestrator ─┬─ DeadLetterEnvelopeFactory ── DeadLetterMetadata └─ SourceSettlement → DeadLetterResult ``` --- ## 4. 계약·불변식·상태 모델 ### 4.1 `DestinationProfileValidator.validate` — 15가지 모순 거절 프로파일 하나에 대해 순서대로 검사한다. | # | 거절 조건 | 왜 | |---:|---|---| | 1 | `retry.orderingImpact == PRESERVE && retry.reorders()` | 정책이 자기 자신과 모순 | | 2 | `isOrdered() && retry.orderingImpact == ALLOW_REORDER` | 순서 목적지가 재정렬 재시도를 허용 | | 3 | `payload.maxBytes > 8,388,608` | 절대 상한 초과 | | 4 | `claimCheckThreshold > payload.maxBytes` | 오프로드 문턱이 상한보다 큼 | | 5 | DLQ가 자기 자신을 가리킴 | 무한 루프 | | 6 | retry 목적지가 자기 자신을 가리킴 | 무한 루프 | | 7 | `orderingScope == KEY && !keyResolverConfigured` | 키 기반 순서인데 키 추출기 없음 | | 8 | `tier == M1 && consumer.manualSettlement` | M1이 수동 정산을 쓰면 정산 순서가 앱으로 새 나감 | | 9 | `AT_LEAST_ONCE && producer.confirmation == NONE` | 확인 없는 at-least-once는 보장이 아님 | | 10 | `production && topologyAutoCreate` | 운영에서 앱이 토폴로지를 만듦 | | 11 | `orderingScope == DESTINATION && consumer.concurrency > 1` | 목적지 전체 순서는 동시성 1을 요구 | | 12 | `isOrdered() && maxInFlightPerOrderingUnit > 1` | 순서 단위 안 동시 처리 | | 13 | `physical.isEmpty()` | 물리 주소 없음 | | 14 | `retry.mode == NONE && maxAttempts > 1` | 모드와 횟수 모순 | | 15 | `retry.mode == RETRY_DESTINATION && retryDestination.isEmpty()` | 목적지 없는 재시도 목적지 모드 | | 16 | `maxAttempts > 1 && mode != NONE && !deadLetter.enabled` | 재시도하는데 소진 후 갈 곳 없음 | 11번과 12번이 짝이다 — 전자는 목적지 수준 동시성, 후자는 순서 단위 안 동시성. 둘 다 있어야 "순서 보장"이 실제로 성립한다. ### 4.2 `validateAll` — 두 종류의 간선을 하나의 그래프로 이 leaf에서 가장 정교한 판단이다. ```java // :131-136 // One graph carrying both edge kinds, not two walks. // // Walking retry and dead-letter separately misses a cycle that alternates between them: A's // retry points at B and B's dead letter points back at A. Neither single-edge walk revisits a // node, both pass, and a poison message loops between the two destinations forever. The label // is kept per edge so the reported path still says which kind each hop was. ``` `Edge` enum이 `RETRY`와 `DEAD_LETTER` 둘을 갖고, `walk`가 두 간선을 동시에 따라간다. **`onPath`가 전역 방문 집합이 아니라 현재 경로다.** ```java // :164-169 *

{@code onPath} is the current walk rather than everything ever seen, so a diamond — two * destinations that both forward to a third — is not mistaken for a loop. walk(nextProfile, byName, new LinkedHashSet<>(onPath), branch); ``` 각 분기마다 `new LinkedHashSet<>(onPath)`로 복사하므로 형제 분기가 서로의 방문 기록을 오염시키지 않는다. 다이아몬드(A→C, B→C)는 사이클이 아니고, 그것을 사이클로 판정하면 정상 구성이 부팅에 실패한다. 테스트가 두 경우를 각각 붙든다 — `aMixedEdgeCycleIsRejected`(retry/DLQ 교대 사이클 거절)와 `aSharedDeadLetterIsNotACycle`(다이아몬드 허용). 미등록 목적지도 여기서 잡힌다 — `anUnregisteredRetryDestinationIsRejected`. **비용 주의.** 매 분기마다 `onPath`와 `path`를 복사하므로 시간·공간이 경로 수에 지수적이다. 목적지 수가 수십 개인 정상 구성에서는 문제가 없지만, 이 성질이 어디에도 기록되지 않았다 — §17의 P3. ### 4.3 `MessagingAdmissionController` — 순서가 계약이다 ```java // :13-16 *

Order matters and is fixed here rather than left to each adapter: the payload limit is checked * before a permit is taken. An oversized message can never succeed, so letting it occupy a * scarce in-flight permit while it is being rejected would let a stream of bad messages starve the * good ones. ``` `admit`의 실제 순서: 1. `payloadGuard.checkPayload` → 초과면 `MessageTooLargeException` 2. `acceptingNewWork` 확인 → 종료 중이면 `MessageBackpressureException("SHUTTING_DOWN")` 3. `reserve(destination)` — 목적지별 CAS 루프 → 초과면 `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED` 4. `limiter.tryAcquire()` — 프로세스 전역 semaphore, 유한 대기 → 실패면 목적지 슬롯 **반납 후** `IN_FLIGHT_LIMIT_EXCEEDED` **두 개의 천장이 있는 이유**도 명시돼 있다. ```java // :23-26 *

Two ceilings, because one is not enough. The per-destination ceiling stops a single slow * downstream from consuming every permit in the process, and the process-wide ceiling stops the sum * of well-behaved destinations from exhausting memory — without it, adding a destination silently * raises what the process can be holding at once. ``` **거절이 모호하지 않은 것이 설계의 핵심**이다 — "Both refusals happen before transmission, so neither is ambiguous — the caller may resubmit under the same message id without risking a duplicate." `messaging-core-api`의 3상태 발행 결과와 직접 연결된다. **세 가지 누수 방지**가 코드에 있다. ```java } catch (InterruptedException interrupted) { // The destination slot was taken a moment ago and no publish will use it, so it goes back // here: a slot leaked per interruption shrinks the destination's ceiling until it is zero. release(destination); ``` ```java public void complete(String destination) { if (!release(destination)) { // A completion for a destination that holds nothing: either it names the wrong destination or // it is a second completion for the same publish. Returning the process permit anyway frees // one nobody took, and the process-wide ceiling then reads below what is really in flight and // admits more work than the process can carry. return; } limiter.release(); } ``` ```java // release():195-197 // Drop the entry at zero, atomically, so the map does not accumulate one counter per // destination ever published to for the life of the process. perDestination.computeIfPresent(destination, (key, value) -> value.get() == 0 ? null : value); ``` 세 번째는 장기 실행 누수 방지다 — 목적지 이름이 동적이면(예: 테넌트별) 맵이 무한히 자란다. `InFlightLimiter`가 **fair semaphore**를 쓰는 이유도 적혀 있다 — "an unfair semaphore lets a late arrival barge ahead of a caller that has already been waiting, which turns a bounded wait into an unbounded one for the unlucky." `release()`가 `availablePermits() < limit`를 확인하고 반납한다 — "an unbalanced release would raise the ceiling silently and the limiter would stop limiting anything." ### 4.4 `DefaultRetryDecisionEngine` — 고정된 판단 순서 ```java // :10-15 *

The order is fixed and evaluated top to bottom. Retryability is checked before the attempt * budget so that a deserialization failure is parked on its first delivery instead of being * replayed three more times against a payload that cannot change. The ordering-preserving strategy * is checked before the re-publishing one so that an ordered destination can never fall through to * a strategy that reorders it, even if both are technically configured. ``` 실제 순서: | # | 조건 | 결정 | |---:|---|---| | 1 | `!isRetryable(...)` | `park(context)` — DLQ가 있으면 `DeadLetter`, `AT_MOST_ONCE`이고 DLQ 없으면 `Reject`, 그 외 `DeadLetter` | | 2 | `attempt >= maxAttempts` | `DeadLetter` | | 3 | `orderingImpact == PRESERVE && isOrdered() && capabilities.orderedStream()` | `PauseAndRetry(delay)` | | 4 | `mode == PAUSE_PARTITION` | `PauseAndRetry(delay)` | | 5 | `mode == RETRY_DESTINATION && ALLOW_REORDER && retryDestination.isPresent()` | `PublishToRetryDestination` | | 6 | `mode == INLINE \|\| BLOCKING` | `RetryInline(delay)` | | 7 | `mode == BROKER_DELAYED && capabilities.delayedDelivery()` | `PublishToRetryDestination` | | 8 | (그 외) | `DeadLetter` | **capability가 입력이다.** ```java // RetryContext.java:11-13 *

Capabilities are an input rather than an assumption: the same policy resolves to * pause-and-retry on a partitioned Kafka topic and to a retry destination on a queue that cannot * pause, and the engine must not pick a strategy the adapter cannot actually carry out. ``` 3번과 7번이 그것을 쓴다 — `orderedStream()`이 false면 pause 전략이 선택되지 않고, `delayedDelivery()`가 false면 `BROKER_DELAYED`가 8번으로 떨어져 DLQ가 된다. **조용한 성능 저하 대신 명시적 파킹**이다. `isRetryable`의 3단 판정: ```java if (policy.nonRetryableCategories().contains(category)) return false; // 명시적 제외 최우선 if (policy.retryableCategories().contains(category)) return true; // 명시적 허용 return descriptorRetryable && FailureDescriptorDefaults.retryable(category); // 둘 다 만족해야 ``` 마지막 줄이 **AND**다 — descriptor가 retryable이라 해도 카테고리 기본값이 false면 재시도하지 않는다. `RetryPolicy` 생성자가 두 집합의 교집합을 거절하므로(§4.5) 1·2번이 동시에 참일 수 없다. `FailureDescriptorDefaults`는 package-private 위임자다 — "kept in one place so policy and engine cannot disagree". 실제로는 `FailureDescriptor.defaultRetryable`(core-api)를 그대로 부른다. 한 줄 짜리 간접층이지만 정책 쪽에서 기본값을 바꿔야 할 때 바꿀 지점을 명시한다. ### 4.5 `RetryPolicy` — 기본값이 "재시도 없음" ```java // :13-15 *

Automatic retry is opt-in. The default for an ordinary destination is zero attempts, because a * retry that reorders a stream, multiplies a non-idempotent side effect, or hammers a throttled * downstream is worse than a visible failure. ``` `none()`이 `mode=NONE, maxAttempts=1, delays=ZERO, multiplier=1.0, jitter=false, orderingImpact=PRESERVE, 두 집합 비어 있음`이다. 생성자 검증 여섯: - `maxAttempts >= 1` (첫 전달 포함) - 두 지연 음수 아님 - `maxDelay >= initialDelay` - `multiplier >= 1.0` - 두 카테고리 집합을 `Set.copyOf`로 복사 - **두 집합의 교집합 거절** — "a failure category cannot be both retryable and non-retryable" `reorders()`가 `RETRY_DESTINATION || BROKER_DELAYED`다 — 이 둘만 메시지를 원래 순서 단위 밖으로 옮긴다. `RetryMode` javadoc이 같은 사실을 반대편에서 적는다. ### 4.6 `BackoffCalculator` — full jitter ```java // :11-14 *

The delay is {@code min(maxDelay, initialDelay * multiplier^(attempt-1))}. Full jitter then * picks uniformly from {@code [0, delay]} rather than shaving a small percentage off. That matters * when a downstream recovers: without jitter every consumer that failed in the same second retries * in the same second, and the recovery is immediately undone by the retry storm. ``` `randomFraction`이 `DoubleSupplier`로 주입 가능해서 테스트가 결정론적이다. 테스트가 두 각도를 본다 — `backoffGrowsExponentiallyAndIsCappedByMaxDelay`와 `fullJitterSpreadsRetriesAcrossTheWholeWindow`. `capped <= 0`이면 `Duration.ZERO`를 반환하므로 `initialDelay=0`인 정책에서 곱셈이 무의미해지는 경우를 방어한다. ### 4.7 `DeadLetterOrchestrator` — 하나의 불변식 ```java // :21-29 *

This ordering is the single invariant that stops dead lettering from becoming data loss. If * the source were acknowledged first, a failed dead letter publish would leave no copy of the * message anywhere: the broker has released it and the dead letter destination never received it. * So the source stays unsettled on anything other than a confirmed publish, including an ambiguous * one, and the message is redelivered instead of disappearing. * *

An ambiguous dead letter publish therefore produces a duplicate rather than a loss. That is * the intended trade: the dead letter destination is read by humans who can spot a duplicate, and * it is the only side of the trade that is recoverable. ``` 구현이 그 문장 그대로다. ```java .thenCompose(result -> { if (result.completion() != PublishCompletion.CONFIRMED) { return CompletableFuture.completedFuture(new DeadLetterResult(result, false)); } return settleAfterConfirmation(result, settlement); }); ``` `CONFIRMED`가 아니면 — `REJECTED`든 `AMBIGUOUS`든 — 원본을 정산하지 않는다. `messaging-core-api`의 3상태가 여기서 실제 분기가 된다. `SourceSettlement`이 콜백으로 주입되는 이유도 적혀 있다 — "so that the ordering constraint … lives in one place instead of being re-implemented by every adapter." ### 4.8 `DeadLetterEnvelopeFactory` — 예약 헤더 6개, payload 불변 ```java // :16-21 *

The payload and the logical {@code messageId} are carried through untouched. That is what * makes a redrive a genuine replay rather than a new message: an Inbox downstream still recognises * it, and an operator can correlate the dead letter with the original publish. * *

Failure context is written into reserved headers, never into the payload, so redriving does * not require unwrapping a platform-specific structure. ``` 쓰는 헤더: `FAILURE_CATEGORY`, `FAILURE_CODE`, `ORIGIN_DESTINATION`, `RETRY_ATTEMPT`, `FIRST_FAILURE_AT`, `LAST_FAILURE_AT`. 전부 `ReservedHeaders`의 상수를 쓴다(리터럴 아님). `MessageHeaders.platform(headers)`를 쓴다 — 예약 이름을 쓸 수 있는 factory다(`messaging-core-api` §4.8). 이것이 core-api의 두 factory 분리가 실제로 필요한 이유를 보여주는 유일한 production 사용처다. 여섯 헤더 중 `RETRY_ATTEMPT`·`FIRST_FAILURE_AT`·`LAST_FAILURE_AT`·`FAILURE_CATEGORY`·`FAILURE_CODE`·`ORIGIN_DESTINATION`은 전부 `CanonicalEnvelopeHeaders`가 "platform bookkeeping"으로 분류한 8개에 속한다 — 봉투 필드가 없어서 헤더로만 이동할 수 있는 것들이다. 두 leaf의 분류가 정확히 맞물린다. ### 4.9 `DeadLetterMetadata` — 일부러 작다 ```java // :11-13 *

Deliberately small. A dead letter destination is read by operators, exported to tickets, and * often retained far longer than the source topic, so it holds a category, a code, and timing — not * a stack trace, not the exception message, and not the original headers. ``` `messaging-core-api`의 `FailureDescriptor` javadoc("a DLQ is read by more people than the log is")과 같은 판단을 다른 층에서 반복한다. **한 가지 관측.** `DeadLetterOrchestrator`가 `DeadLetterMetadata`를 만들 때 `firstFailureAt`과 `lastFailureAt`에 **같은 값**(`delivery.metadata().receivedAt()`)을 넣는다. ```java Instant failedAt = delivery.metadata().receivedAt(); DeadLetterMetadata metadata = new DeadLetterMetadata(..., failedAt, failedAt); ``` 즉 두 필드가 구분되어 선언됐지만 현재 유일한 생산 경로에서는 항상 같다. 첫 실패 시각을 이전 시도에서 이어받는 코드가 없다 — §17의 P3. --- ## 5. 주요 실행 경로 **시작:** `MessagingCoreAutoConfiguration:134` → `validateAll(registered)` → 프로파일별 15검사 + 중복 이름 + 사이클 그래프 → 실패 시 `IllegalArgumentException`으로 부팅 중단 **발행:** `DefaultMessagePublisher` → `admission.admit(destination, bytes)` → 크기 → 종료 여부 → 목적지 슬롯 → 프로세스 permit → (발행) → `admission.complete(destination)` **재시도 판단:** `RetryContext(profile, deliveryMetadata, failure, capabilities, ...)` → `engine.decide(...)` → `RetryDecision` 5종 중 하나 — **이 경로는 출하 컨텍스트에서 호출되지 않는다**(§12.1) **DLQ:** `orchestrator.deadLetter(profile, delivery, failure, settlement)` → 헤더 6개 추가 → 발행 → CONFIRMED면 원본 정산 — **이 경로도 호출되지 않는다**(§12.1) --- ## 6. 실패 경로와 복구/번역 | 코드 | 예외 | 위치 | 조건 | |---|---|---|---| | `PAYLOAD_LIMIT_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 목적지 상한 초과 | | `BATCH_COUNT_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 배치 항목 수 초과 | | `BATCH_BYTES_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 배치 총 바이트 초과 | | `SHUTTING_DOWN` | `MessageBackpressureException` | `MessagingAdmissionController` | 종료 중 | | `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED` | `MessageBackpressureException` | 같음 | 목적지 천장 | | `IN_FLIGHT_LIMIT_EXCEEDED` | `MessageBackpressureException` | 같음 | 프로세스 천장 | | `ADMISSION_INTERRUPTED` | `MessageBackpressureException` | 같음 | 대기 중 인터럽트 | | `DEAD_LETTER_NOT_CONFIGURED` | `MessagingConfigurationException` | `DeadLetterOrchestrator` | DLQ 미설정 목적지를 DLQ하려 함 | **배치 상한이 두 축인 이유**가 적혀 있다. ```java // PayloadLimitGuard.java:16-18 *

Batches are limited by count and bytes. A count limit alone lets a handful of large * messages exceed the broker's frame; a byte limit alone lets a huge number of tiny messages exceed * its request timeout. ``` `checkBatch`가 각 항목에 대해 `checkPayload`도 부르므로 **개별 상한 · 개수 상한 · 총합 상한** 셋이 함께 적용된다. 프로파일 검증 실패는 `IllegalArgumentException`이다 — `MessagingException` 계층 밖이다. 시작 시점의 구성 오류이지 메시지 실패가 아니므로 일관적이다. 다만 `MessagingConfigurationException`("Raised at startup wherever possible")이 존재하는데 쓰이지 않는다 — §17의 P3. --- ## 7. 트랜잭션·동시성·수명주기 트랜잭션 없음. 동시성 지점은 `MessagingAdmissionController`와 `InFlightLimiter` 둘이다. | 지점 | 도구 | 보호 | |---|---|---| | `perDestination` 맵 | `ConcurrentHashMap` + `computeIfAbsent` | 목적지 카운터 생성 | | 목적지 카운터 증가 | `AtomicInteger` CAS 루프 | 천장 초과 방지 | | 목적지 카운터 감소 | `getAndUpdate` + 0 clamp | 음수 방지 | | 맵 항목 제거 | `computeIfPresent` (원자) | 0일 때만 제거, 누수 방지 | | `acceptingNewWork` | `volatile boolean` | 종료 플래그 가시성 | | permit | `Semaphore(limit, true)` — **fair** | 유한 대기 보장 | | permit 반납 | `availablePermits() < limit` 확인 | 천장 상승 방지 | `reserve`의 CAS 루프는 `AtomicInteger.updateAndGet`으로 쓸 수 있었지만 조건부 실패(`return false`)가 필요해서 직접 루프를 돈다. `release`에 **미세한 경합**이 있다. `getAndUpdate`로 감소한 뒤 `computeIfPresent`로 0인 항목을 제거하는데, 그 사이에 다른 스레드가 `computeIfAbsent`로 같은 키를 만들고 증가시킬 수 있다. 그러면 `computeIfPresent`의 람다가 `value.get() == 0`을 보지 못해 제거하지 않는다 — 안전한 방향의 경합이다(누수가 아니라 제거 실패). 반대 순서였다면 살아 있는 카운터를 지울 수 있었다. `DefaultRetryDecisionEngine`·`BackoffCalculator`·`DeadLetterOrchestrator`·`DeadLetterEnvelopeFactory`·`DestinationProfileValidator`는 전부 상태가 없거나 불변이다. `BackoffCalculator`의 기본 생성자가 `ThreadLocalRandom`을 쓰므로 스레드 안전하다. 수명주기 참여는 `stopAcceptingNewWork()` 하나이고, `MessagingShutdownLifecycle`(starter)이 종료 1단계에서 부른다(`messaging-transport-spi` §12.1 참조). --- ## 8. 설정·기능 플래그·환경 차이 설정 파일 없음. 상수와 기본값: | 상수/기본값 | 값 | 위치 | |---|---:|---| | `PayloadPolicy.DEFAULT_MAX_BYTES` | 1,048,576 | `PayloadPolicy.java:17` (public) | | `PayloadPolicy.HARD_MAX_BYTES` | 8,388,608 | `:20` (public) | | `ProducerPolicy.defaults()` | `REPLICATION_OR_PERSISTENCE_ACK`, 5초, mandatoryRouting, idempotent | `:34-37` | | `ConsumerPolicy.defaults(group)` | concurrency 1, maxInFlightPerUnit 1, prefetch 16, timeout 30초, manual false | `:52-54` | | `RetryPolicy.none()` | mode NONE, 1회, 지연 0, PRESERVE | `:115-125` | | `DeadLetterPolicy.disabled()` / `.to(dest)` | maxRedrive 0 / 1 | `:32-44` | **모든 기본값이 보수적이다** — 재시도 없음, 동시성 1, 순서 보존, 확인 최대, DLQ 비활성. 켜는 것이 명시적 선택이다. `PayloadPolicy.HARD_MAX_BYTES = 8 MiB`의 근거도 적혀 있다 — "Raising a broker's frame limit to carry large payloads trades a bounded, testable failure for an unbounded one: it degrades broker memory, replication latency, and consumer recovery all at once." `PayloadPolicy.DEFAULT_MAX_BYTES`는 이 저장소에서 1 MiB 상한을 선언하는 다섯 곳 중 하나이고 **정책 축의 자연스러운 주인**이다. 그런데 starter는 이것 대신 `JacksonMessageCodec.DEFAULT_MAX_BYTES`를 참조한다 — `analysis/messaging/messaging-schema-json.md` §17이 소유한다. --- ## 9. 퍼시스턴스/외부 시스템 세부 없다. 브로커·DB·파일시스템을 만지지 않는다. `ThreadLocalRandom`(jitter)과 `Semaphore`가 유일한 런타임 자원이다. --- ## 10. 테스트 레인과 실제 증명 범위 레인: `./gradlew :messaging:messaging-policy:test`. **BUILD SUCCESSFUL, 42 tests, 0 skipped, 0 failures**. | 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 | |---|---:|---|---| | `DestinationProfileValidatorTest` | 13 | 순서/페이로드/DLQ 자기참조/키 리졸버/M1 수동정산/확인/토폴로지/DLQ 필요, **retry↔DLQ 교대 사이클 거절**, **다이아몬드 허용**, 미등록 목적지 거절 | 실제 부팅에서 이 검증이 호출되는지(→ starter가 부른다, §2) | | `MessagingAdmissionControllerTest` | 13 | permit 점유/반납, 초과 시 큐잉 대신 거절, backpressure가 retryable, 초과 payload가 permit을 안 먹음, 종료 시 기존 permit 유지, 불균형 반납이 천장을 못 올림, 한 목적지가 전부 못 먹음, 거절이 슬롯을 안 남김, 완료가 둘 다 반납, 미지 목적지 완료가 permit을 안 품, 이중 완료, 배치 두 축, 대기 후 승인 | 실제 부하에서의 공정성 | | `RetryDecisionEngineTest` | 10 | 역직렬화 실패 즉시 파킹, 인증/구성 실패 미재시도, 순서 Kafka는 pause, 소진은 DLQ, 비순서 재시도목적지 재발행, blocking은 inline, **지수 증가와 상한**, **full jitter 분포**, 프로파일 오버라이드, at-most-once DLQ 없으면 discard | **이 엔진이 production에서 호출되는지** | | `DeadLetterOrchestratorTest` | 6 | 확인 후에만 원본 정산, 모호하면 미정산, 거절되면 미정산, 헤더 부착 | **이 orchestrator가 production에서 호출되는지** | **두 축의 증명 성격이 다르다.** 검증기와 관문은 배선까지 확인되지만(§2), 재시도 엔진과 DLQ 조정자는 로직만 증명되고 배선은 §12.1이 부정한다. 테스트가 통과한다는 것이 그 코드가 실행된다는 뜻이 아닌 전형적인 예다. `MessagingAdmissionControllerTest`의 `as(...)` 문구들이 특히 구체적이다 — "a slot leaked per refusal shrinks the destination's ceiling until it is zero", "a permit nobody took cannot be given back; doing so makes the ceiling fiction". 각 테스트가 어떤 이전 결함을 붙들고 있는지 이름 자체가 말한다. --- ## 11. 빌드/ArchUnit/CI 강제 지점 | 게이트 | 이 leaf에 대해 | |---|---| | `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` | | `verifyRuntimeModuleMembership` | `["app-bootstrap"]` | | vendor `api` 규칙 | 벤더 의존성 0 | | **부팅 검증** | `MessagingCoreAutoConfiguration:134`가 `validateAll`을 호출 — 이 leaf의 규칙이 실제로 부팅을 막는 유일한 지점 | | ArchUnit | 전용 규칙 없음 | §4.1의 15가지 규칙은 **ArchUnit이 아니라 런타임 시작 시점**에 강제된다. `verifyCleanArchitectureDependencies`가 빌드 타임에 도는 것과 대비된다. 잘못된 프로파일은 컴파일되고, 부팅에서 막힌다. --- ## 12. 실제 사용 여부와 negative-space probes 원시 증거: `evidence/raw/281-messaging-policy-retry-engine-unwired.txt`. > **방법 주의.** 이 절의 조립 판정은 `new ([a-zA-Z0-9_.]+\.)?\s*\(` 패턴으로 재확인한 것이다. 처음에 `new (`로만 검색해 **오탐**을 냈다 — 이 저장소는 `new dev.caskeleton.messaging.runtime.TransportMessagingRuntime(`처럼 정규화된 이름으로 생성하는 곳이 있고, 그 패턴은 그것을 놓친다. 아래 결과는 전부 수정된 패턴의 것이다. ### 12.1 Public surface reachability leaf 밖 참조가 0인 것은 둘이고 성격이 다르다. | 타입 | leaf 밖 | 판정 | |---|---:|---| | `DeadLetterEnvelopeFactory` | 0 | **내부 협력자** — `DeadLetterOrchestrator`가 쓴다. 문제 아님 | | `DeadLetterMetadata` | 0 | 같음 | 나머지 24개는 전부 외부 참조가 있다. `DestinationProfile` 43파일, `RetryDecision` 23, `RetryContext` 18, `SchemaPolicy` 17, `PayloadPolicy` 15, `PhysicalDestination` 13. **참조 수는 이 leaf에서 오해를 낳는다.** 참조가 있어도 실행되지 않을 수 있고, 여기가 정확히 그렇다. **(a) `RetryDecisionEngine` bean은 만들어지고 아무 데도 주입되지 않는다** ```java // MessagingCoreAutoConfiguration.java:165-169 @Bean @ConditionalOnMissingBean public RetryDecisionEngine retryDecisionEngine() { return new DefaultRetryDecisionEngine(new BackoffCalculator()); } ``` 이 타입을 받는 코드는 저장소 전체에서 **하나**다 — `KafkaRetryExecutor`의 필드와 생성자 인자(`KafkaRetryExecutor.java:32,46`). 그리고 `KafkaRetryExecutor`는 **한 번도 생성되지 않는다.** ``` ## D. is each of those dependents ever constructed? KafkaRetryExecutor NEVER CONSTRUCTED ``` 즉 5개 `@Bean` 설정 클래스가 만드는 51개 bean 중 어느 것도 `RetryDecisionEngine`을 인자로 받지 않는다. bean은 매 시작마다 생성되고 컨텍스트에 앉아 있다. **(b) `DeadLetterOrchestrator` bean도 같다** ```java // :177-181 @Bean @ConditionalOnMissingBean public DeadLetterOrchestrator deadLetterOrchestrator(MessagePublisher publisher) { return new DeadLetterOrchestrator(publisher); } ``` 이 타입을 받는 production 코드는 둘 — `KafkaDeadLetterPublisher`(:29)와 `RabbitDeadLetterPublisher`(:47). 둘 다 **NEVER CONSTRUCTED**. **(c) 왜 그런가 — 소비 경로 전체에 production 조립이 없다** ``` ## F. control: the consume path is constructed only in tests KafkaConsumerRegistrar src/main=0 src/test=4 RabbitConsumerRegistrar src/main=0 src/test=1 KafkaBatchConsumerRegistrar src/main=0 src/test=0 RabbitBatchConsumerRegistrar src/main=0 src/test=1 DefaultDeliveryProcessor src/main=0 src/test=1 ``` 대조군으로 발행 경로를 같은 패턴으로 확인하면 전부 production에서 생성된다. ``` ## E. control: the publish path IS constructed in production DefaultMessagePublisher MessagingCoreAutoConfiguration.java:446 TransportMessagingRuntime MessagingCoreAutoConfiguration.java:476 DefaultRetryDecisionEngine MessagingCoreAutoConfiguration.java:168 DeadLetterOrchestrator MessagingCoreAutoConfiguration.java:180 ``` **즉 출하 컨텍스트는 발행할 수 있고 소비할 수 없다.** 재시도와 DLQ는 소비 경로에만 존재하는 개념이므로, 이 leaf의 두 축이 배선되지 않은 것은 그 결과다. 이 사실은 `analysis/messaging/messaging-core-api.md` §12.1이 관측한 것 — `MessageHandler`의 저장소 참조 0 — 에 조립 쪽 설명을 준다. 핸들러를 받을 소비자 런타임이 조립되지 않으므로 핸들러 계약에 소비자가 없다. **(d) `RetryDecision`을 실제로 실행하는 코드는 하나뿐이다** ``` ## G. every file that acts on a RetryDecision variant messaging-kafka/.../KafkaRetryExecutor.java (생성되지 않음) messaging-policy/.../DefaultRetryDecisionEngine.java (생산자) messaging-policy/.../RetryDecision.java (선언) messaging-policy/.../RetryDecisionEngineTest.java (테스트) ``` `messaging-rabbit`은 production 코드에서 `RetryDecision`·`RetryDecisionEngine`·`BackoffCalculator`·`RetryPolicy`를 전혀 참조하지 않는다(테스트 fixture 한 곳 제외). Rabbit에는 `RabbitRetryQueueTopology`가 있는데 그것은 **토폴로지 서술**(TTL 큐 + DLX)이고 `RetryDecision`을 소비하지 않는다. Pulsar·NATS도 0이다. 즉 브로커 중립 재시도 엔진의 실행자가 저장소에 **한 브로커 분량**만 있고, 그마저 조립되지 않았다. **(e) 배선된 축은 확실히 배선됐다** - `DestinationProfileValidator` → `MessagingCoreAutoConfiguration:134`에서 `validateAll(registered)` 호출. 부팅을 실제로 막는다. - `MessagingAdmissionController` → `DefaultMessagePublisher`(발행 관문)·`MessagingEndpoint`(관측)·`MessagingShutdownLifecycle`(종료 1단계) 셋이 주입받는다. - `PayloadLimitGuard`·`InFlightLimiter`·`PayloadPolicy` → admission controller 안에서 실행된다. **한계.** 정적 `git grep`이다. 리플렉션·`ObjectProvider` 지연 조회·`@Autowired` 필드 주입은 덮지 못한다. 다만 이 저장소의 messaging 자동설정은 전부 생성자 주입 `@Bean` 메서드이고(51개 전수 확인), `ObjectProvider`는 `MessageContracts`와 `MessagingTransport` 두 곳에만 쓰인다. ### 12.2 Conditional sibling comparison 이 leaf에는 bean이 없다. 그러나 **starter 쪽 sibling 비교가 결정적이다.** `MessagingCoreAutoConfiguration`의 27개 `@Bean` 중 이 leaf의 타입을 만드는 것은 셋이고, 조건이 전부 같다(`@ConditionalOnMissingBean`). | bean | 조건 | 주입처 | |---|---|---| | `DestinationProfileValidator` | `@ConditionalOnMissingBean` | (직접 호출도 있음, :134) | | `MessagingAdmissionController` | `@ConditionalOnMissingBean` | **3곳** | | `RetryDecisionEngine` | `@ConditionalOnMissingBean` | **0곳** | | `DeadLetterOrchestrator` | `@ConditionalOnMissingBean` | **0곳** | **조건은 같고 결과가 다르다.** 활성화 비대칭이 아니라 **소비 비대칭**이다 — 넷 다 똑같이 만들어지고 둘만 쓰인다. `@ConditionalOnMissingBean`은 "이미 있으면 만들지 마라"를 뜻할 뿐 "쓰이는지"를 말하지 않는다. ### 12.3 Duplicate mechanism sweep **(a) 재시도 메커니즘이 둘이고, 정교한 쪽이 배선되지 않았다** | | `messaging-policy` | `messaging-runtime-core` | |---|---|---| | 구현 | `DefaultRetryDecisionEngine` | `DefaultDeliveryProcessor` | | 입력 | `RetryContext`(프로파일 + 전달 메타 + 실패 + capability) | `HandleResult` | | 재시도 판단 | 6개 모드, 8단 우선순위 | `Retry` → 무조건 requeue | | 지연 | `BackoffCalculator` — 지수 + full jitter + 상한 | 생성자로 받은 **고정 `retryDelay`** | | 시도 횟수 | `attempt >= maxAttempts` 확인 | **확인하지 않음** | | 순서 인식 | `orderingImpact`·`isOrdered()`·`capabilities` | 없음 | | DLQ | 5개 결정 중 하나 | `DeadLetter` → 발행 후 확인되면 ack | | **production 조립** | **없음** | **없음**(테스트만) | 둘 다 조립되지 않았으므로 오늘 경쟁하지 않는다. 그러나 소비 경로를 배선하려는 사람은 **두 개의 서로 다른 재시도 의미론** 중 하나를 골라야 하고, 어느 쪽이 정본인지 코드가 말하지 않는다. `DefaultDeliveryProcessor`의 javadoc은 자기가 "the platform decides when and in what order the settlement happens"를 실현한다고 말하고, `DefaultRetryDecisionEngine`의 javadoc은 자기 순서가 "fixed and evaluated top to bottom"이라고 말한다. **(b) DLQ 경로가 둘** | | `messaging-policy` | `messaging-runtime-core` | |---|---|---| | 구현 | `DeadLetterOrchestrator` | `DefaultDeliveryProcessor`의 `DeadLetterPublisher` 함수형 인터페이스 | | 순서 보장 | 확인 후 정산 (명시) | 확인 후 ack, 미확인이면 requeue (명시) | | 헤더 | 6개 예약 헤더 부착 | **부착하지 않음** | | 결과 | `DeadLetterResult(publishResult, sourceSettled)` | `SettlementResult` | 같은 불변식(확인 전 정산 금지)을 두 곳이 각자 구현한다. 그리고 **한쪽만 실패 컨텍스트를 헤더에 남긴다** — `DefaultDeliveryProcessor` 경로로 DLQ된 메시지는 왜 거기 있는지 알 수 없다. **(c) 1 MiB 상한** — `PayloadPolicy.DEFAULT_MAX_BYTES`가 이 저장소 다섯 곳 중 정책 축의 주인인데 starter가 참조하지 않는다. `analysis/messaging/messaging-schema-json.md` §17이 소유한다. **(d) 프로파일 검증기가 브로커별로 또 있다** `RabbitProfileValidator`, `KafkaProfileValidator`, `KafkaTransactionProfileValidator`가 각 어댑터 leaf에 있고 starter가 bean으로 만든다. 이들은 **브로커 고유 제약**(exchange/queue 조합, 트랜잭션 설정)을 보므로 `DestinationProfileValidator`의 브로커 중립 규칙과 책임이 다르다. 중복이 아니라 계층이다. 다만 호출 순서가 어디에도 명시되지 않았다 — 중립 검증이 먼저인지 브로커 검증이 먼저인지는 starter leaf가 답한다. ### 12.4 Documentation / measured-count drift | 문서 주장 | 재측정 | 결과 | |---|---|---| | `DestinationProfileValidator` javadoc: 모순은 부팅 실패 | `:134`에서 `validateAll` 호출 확인 | **일치** | | `MessagingAdmissionController` javadoc: "The single gate every publish passes" | `DefaultMessagePublisher`가 주입받아 호출 | **일치** | | `PhysicalDestination` javadoc: 물리 주소를 여기서만 보관 | leaf 밖 13파일이 참조하나 전부 `PhysicalDestination` 타입 경유 | **일치** | | `RetryPolicy` javadoc: 자동 재시도는 opt-in | `none()`이 `maxAttempts=1, mode=NONE` | **일치** | | `InFlightLimiter` javadoc: "Section 40.3 of the design specifies…" | 그 설계 문서를 이 저장소에서 찾지 못함 | **미확인** — 아래 참조 | | `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) | **`InFlightLimiter`의 "Section 40.3"이 가리키는 문서를 찾지 못했다.** `docs/messaging/` 아래 10개 파일과 `docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md`에 절 번호 40.3이 없다. 저장소 밖 설계 문서이거나 이전 버전의 흔적이다. 인용된 문구("bounded wait, then `MessageBackpressureException`")는 코드와 일치하므로 내용 drift는 아니고, **참조가 해소되지 않는다**는 것이 관측이다. --- ## 13. Git/설계 문서에서 확인한 변화와 실패 기록 이 leaf의 주석은 이전 결함보다 **왜 이 형태여야 하는가**를 더 많이 적는다. 그중 이전 상태를 직접 서술하는 것은 셋이다. | 위치 | 이전 상태 | 그것이 만든 실패 | |---|---|---| | `validateAll` 주석 | retry 그래프와 DLQ 그래프를 따로 순회 | A의 retry가 B를, B의 DLQ가 A를 가리키는 교대 사이클을 둘 다 통과시킴 → poison 메시지가 두 목적지 사이를 영원히 순환 | | `admit`의 `InterruptedException` 주석 | 인터럽트 시 목적지 슬롯 미반납 | 인터럽트마다 슬롯이 새서 목적지 천장이 0까지 줄어듦 | | `complete` 주석 | 미보유 목적지에도 프로세스 permit 반납 | 아무도 안 가져간 permit을 돌려줘 전역 천장이 실제 in-flight보다 낮게 읽힘 → 감당 못 할 만큼 승인 | | `release` 주석 | 0인 카운터를 맵에 잔류 | 발행한 적 있는 모든 목적지의 카운터가 프로세스 수명 동안 누적 | | `InFlightLimiter.release` 주석 | 불균형 반납 허용 | 천장이 조용히 올라가 limiter가 아무것도 제한하지 않음 | 세 번째와 다섯 번째가 같은 형태다 — **반납이 획득보다 많으면 제한이 사라진다.** `messaging-transport-spi`의 `GracefulShutdownCoordinator.endWork` clamp와 `DefaultMessagingRuntimeRegistry`의 "정확히 한 번 close"도 같은 계열이고, 그 leaf §13이 소유한다. 저장소 전체에서 반복되는 주제다. --- ## 14. 런타임·터미널 Evidence | id | 종류 | 파일 | 무엇을 보여주는가 | 한계 | |---|---|---|---|---| | EVD-281 | command | `evidence/raw/281-messaging-policy-retry-engine-unwired.txt` | 26개 타입 참조 수, 두 bean의 선언, 그 두 타입을 받는 코드 전수, 해당 dependent가 NEVER CONSTRUCTED, 발행 경로 대조군, 소비 경로 src/main=0, `RetryDecision` 실행자 목록, 호출되는 시작 게이트 | 정적 `git grep`. 리플렉션·지연 조회 미포함. **정규화된 생성자 이름을 포함하는 패턴으로 재실행한 결과** | | EVD-282 | command | `./gradlew :messaging:messaging-policy:test --rerun-tasks` | BUILD SUCCESSFUL, 42 / 0 / 0 | 순수 단위. 브로커·Spring 컨텍스트 없음 | --- ## 15. 명시적 설계 이유와 추론을 구분한 정리 **명시적** - 모순을 부팅 실패로 옮기는 이유 — `DestinationProfileValidator` javadoc - 두 간선을 한 그래프로 순회하는 이유와 다이아몬드 오탐 방지 — `validateAll`/`walk` 주석 - payload 검사가 permit 획득보다 먼저인 이유 — `MessagingAdmissionController` javadoc - 천장이 둘인 이유 — 같은 javadoc - 거절이 모호하지 않은 이유 — 같은 javadoc - 세 가지 누수 방지 각각의 이유 — 세 개의 인라인 주석 - fair semaphore와 불균형 반납 방지 — `InFlightLimiter` 주석 - 재시도 판단 순서가 고정된 이유 — `DefaultRetryDecisionEngine` javadoc - capability가 입력인 이유 — `RetryContext` javadoc - 자동 재시도가 opt-in인 이유 — `RetryPolicy` javadoc - full jitter를 쓰는 이유 — `BackoffCalculator` javadoc - DLQ 발행 후 정산 순서와 그 trade — `DeadLetterOrchestrator` javadoc - DLQ 메타데이터를 작게 두는 이유 — `DeadLetterMetadata` javadoc - 물리 주소를 이 leaf에 가두는 이유 — `PhysicalDestination` javadoc - Pulsar 구독명·NATS 스트림이 주소의 일부인 이유 — 두 factory javadoc **추론** - 재시도 엔진과 DLQ 조정자가 미배선인 것은 소비 경로 전체에 조립이 없기 때문이다 → **추론**. 조립 부재는 관측이고 인과는 추론이다. 커밋 메시지나 ADR에 소비 경로를 나중으로 미룬 기록이 없다. - `firstFailureAt`과 `lastFailureAt`을 같은 값으로 채우는 것이 임시인지 → **미상**. - 브로커별 검증기와 중립 검증기의 호출 순서 → **미상**(starter leaf가 소유). **관측했으나 원인을 모름** - `InFlightLimiter` javadoc이 인용하는 "Section 40.3"의 출처 - `MessagingConfigurationException`이 존재하는데 프로파일 검증이 `IllegalArgumentException`을 쓰는 이유 --- ## 16. 확인한 것 / 확인하지 못한 것 **확인한 것** - 26개 타입 1,738줄 전문의 계약과 불변식 - 42개 테스트가 통과하고 무엇을 단언하는지 - 다섯 축 중 셋(목적지 정의·시작 검증·발행 관문)이 출하 컨텍스트에서 실제로 실행된다는 것과 그 정확한 배선 지점 - 두 축(재시도 판단·DLQ 조정)이 bean으로 생성되고 주입처가 0이라는 것 — 그리고 그 이유가 소비 경로 전체의 조립 부재라는 것 - `RetryDecision`을 실행하는 코드가 저장소에 하나뿐이며 그것이 생성되지 않는다는 것 - 재시도와 DLQ 각각에 대해 두 개의 서로 다른 구현이 존재한다는 것 **확인하지 못한 것** - **소비 경로를 배선할 계획이 있는지.** 저장소 안에 답이 없다. 두 재시도 구현 중 어느 쪽이 정본인지도 이 미지수에 걸린다. - 실제 부팅에서 `validateAll`이 어떤 프로파일 집합을 받는지 — `ValidatedDestinationRegistry`가 무엇을 채우는지는 starter leaf가 소유한다. - `walk`의 지수적 복사 비용이 실제 구성에서 문제가 되는 규모. 목적지 수가 큰 배포를 관측하지 못했다. - `InFlightLimiter`의 fair semaphore가 실제 부하에서 주는 처리량 손실. - "Section 40.3"이 가리키는 문서. --- ## 17. 손볼 것 ### P2 — 재시도 엔진과 DLQ 조정자가 bean으로 만들어지고 주입되는 곳이 없다 - **사실.** `MessagingCoreAutoConfiguration`이 `RetryDecisionEngine`(:167)과 `DeadLetterOrchestrator`(:179)를 `@Bean @ConditionalOnMissingBean`으로 만든다. 두 타입을 받는 production 코드는 각각 `KafkaRetryExecutor`와 `KafkaDeadLetterPublisher`/`RabbitDeadLetterPublisher`뿐이고, **셋 다 저장소 어디에서도 생성되지 않는다.** 같은 설정의 51개 bean 중 두 타입을 인자로 받는 `@Bean` 메서드가 없다. - **근거.** `evidence/raw/281` §B·§C·§D. - **왜 문제인가.** 컨텍스트에 두 bean이 앉아 있고 `MessagingAutoConfigurationTest`류의 `hasSingleBean` 검사는 통과한다 — 즉 **bean 존재 검사가 배선을 증명하지 않는다.** 그리고 이 leaf가 가장 공들인 두 축(6개 재시도 모드·8단 판단 순서·full jitter·capability 인식, DLQ 발행-후-정산 불변식·예약 헤더 6개)이 실행되지 않는다. 42개 테스트 중 16개가 이 두 축을 검증한다. - **확인 방법.** `git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?KafkaRetryExecutor\s*\(' -- src` → 매치 없음. `evidence/raw/281` §D 재실행. - **후보.** (a) 소비 경로를 조립한다(§17 다음 항목과 같은 작업). (b) 배선되기 전까지 두 bean을 만들지 않는다 — `@ConditionalOnBean`으로 실제 소비자에 매단다. (c) 미완임을 `support-matrix.md`에 표시한다. - **다음 단계.** **CASE 후보.** 재현이 정적이고 결론이 닫힌다. "bean이 있다"와 "배선됐다"의 구분이 그대로 **REFERENCE 후보**이기도 하다. ### P2 — 출하 컨텍스트가 발행은 하고 소비는 하지 못한다 - **사실.** `KafkaConsumerRegistrar`·`RabbitConsumerRegistrar`·`KafkaBatchConsumerRegistrar`·`RabbitBatchConsumerRegistrar`·`DefaultDeliveryProcessor`·`KafkaRetryExecutor`·`KafkaDeadLetterPublisher`·`RabbitDeadLetterPublisher`가 전부 `src/main` 생성 0이다. 대조군인 발행 경로(`DefaultMessagePublisher`·`TransportMessagingRuntime`)는 `MessagingCoreAutoConfiguration:446,476`에서 생성된다. - **근거.** `evidence/raw/281` §E·§F. - **왜 문제인가.** `messaging-policy`의 두 축이 미배선인 근본 원인이고, `analysis/messaging/messaging-core-api.md` §12.1이 관측한 `MessageHandler` 참조 0의 조립 쪽 설명이다. 그리고 `docs/messaging/support-matrix.md`의 브로커 등급표가 소비 측 보장(순서·정산·재시도)을 서술하는데, 그 보장을 수행할 코드가 조립되지 않는다. - **확인 방법.** `evidence/raw/281` §F 재실행. - **후보.** 소비자 등록을 자동설정에 추가하거나, 소비 경로가 파생 프로젝트의 조립 책임임을 문서화한다. - **다음 단계.** **이 leaf가 아니라 cross-scope 또는 `messaging-spring-boot-starter` leaf가 소유해야 한다.** 여기서는 관측과 교차 참조만 남긴다. **OPEN QUESTION 후보**(소비 경로 조립이 미완인가, 의도적 확장점인가). ### P3 — 재시도와 DLQ 각각에 두 개의 구현이 있고 정본이 표시되지 않았다 - **사실.** 재시도: `DefaultRetryDecisionEngine`(6모드·백오프·순서 인식) vs `DefaultDeliveryProcessor`(고정 지연·시도 횟수 미확인). DLQ: `DeadLetterOrchestrator`(예약 헤더 6개 부착) vs `DefaultDeliveryProcessor.DeadLetterPublisher`(헤더 없음). 둘 다 조립되지 않았다. - **근거.** §12.3(a)(b). `DefaultDeliveryProcessor.java:38-99`. - **왜 문제인가.** 오늘 경쟁하지 않지만, 소비 경로를 배선하는 사람이 둘 중 하나를 고르게 되고 코드가 어느 쪽이 정본인지 말하지 않는다. 두 javadoc이 각각 자기가 플랫폼 규칙의 구현이라고 서술한다. 그리고 선택 결과가 다르다 — `DefaultDeliveryProcessor` 경로로 DLQ된 메시지에는 실패 카테고리·코드·원본 목적지·시도 횟수가 붙지 않는다. - **확인 방법.** 두 클래스의 javadoc과 분기 대조. - **후보.** `DefaultDeliveryProcessor`가 `RetryDecisionEngine`과 `DeadLetterOrchestrator`를 위임받도록 합치거나, 한쪽을 제거한다. - **다음 단계.** **CASE 후보**(같은 책임의 두 구현이 서로를 모른다). `messaging-runtime-core` leaf SSOT와 공동 소유. ### P3 — DLQ 메타데이터의 두 시각이 항상 같다 - **사실.** `DeadLetterMetadata`가 `firstFailureAt`과 `lastFailureAt`을 별도 필드로 선언하는데, 유일한 생산 지점인 `DeadLetterOrchestrator:89-97`이 둘 다 `delivery.metadata().receivedAt()`으로 채운다. - **근거.** 해당 라인. - **왜 문제인가.** 두 헤더(`msg.first-failure-at`, `msg.last-failure-at`)가 DLQ 메시지에 붙는데 항상 같은 값이다. 운영자가 "이 메시지가 얼마나 오래 실패해 왔는가"를 헤더에서 알 수 없다. `ReservedHeaders`가 두 이름을 따로 정의한 목적이 실현되지 않는다. - **확인 방법.** `DeadLetterOrchestrator.java:89` 확인. - **후보.** 이전 시도의 `msg.first-failure-at` 헤더가 있으면 그것을 이어받는다. - **다음 단계.** **CASE 후보.** 단, §17 첫 항목대로 이 코드는 실행되지 않으므로 오늘의 사고가 아니다. ### P3 — 사이클 검사가 경로마다 집합을 복사한다 - **사실.** `walk`가 각 분기마다 `new LinkedHashSet<>(onPath)`와 `new ArrayList<>(path)`를 만든다. 비용이 경로 수에 비례하고, 경로 수는 분기 계수에 지수적이다. - **근거.** `DestinationProfileValidator.java:196-198`. - **왜 문제인가.** 정상 구성(목적지 수십 개, 목적지당 간선 0–2개)에서는 무해하다. 다만 이 성질이 어디에도 기록되지 않았고, `validateAll`은 **부팅 경로**다. 목적지가 수백 개인 배포에서 부팅이 느려지면 원인을 찾기 어렵다. - **확인 방법.** 코드 검토. 목적지 수를 늘려가며 `validateAll` 시간을 측정. - **후보.** 방문 상태를 색칠(white/gray/black)로 바꾸면 복사 없이 O(V+E)가 된다. - **다음 단계.** **REFERENCE 후보**(부팅 경로의 알고리즘 복잡도는 문서화한다). ### P3 — 프로파일 검증 실패가 플랫폼 예외 계층 밖이다 - **사실.** `DestinationProfileValidator`의 16개 거절이 전부 `IllegalArgumentException`이다. `MessagingConfigurationException`이 존재하고 그 javadoc이 "Raised at startup wherever possible"이라고 적는다. - **근거.** `DestinationProfileValidator` 전문, `MessagingConfigurationException` javadoc. - **왜 문제인가.** 부팅 실패이므로 실무 영향은 낮다. 다만 `FailureDescriptor`가 없어 코드·카테고리가 붙지 않고, 같은 leaf의 `DeadLetterOrchestrator`는 `MessagingConfigurationException("DEAD_LETTER_NOT_CONFIGURED")`을 쓴다 — 같은 leaf 안에서 구성 오류를 두 방식으로 보고한다. - **확인 방법.** 두 클래스의 throw 문 대조. - **후보.** 검증 실패를 `MessagingConfigurationException`으로 통일하고 규칙별 안정 코드를 준다. - **다음 단계.** **REFERENCE 후보**(구성 오류는 한 예외 타입과 안정 코드로 보고한다). ### P3 — javadoc이 해소되지 않는 설계 문서를 인용한다 - **사실.** `InFlightLimiter` javadoc이 "Section 40.3 of the design specifies 'bounded wait, then `MessageBackpressureException`'"이라고 적는다. 그 절 번호를 가진 문서를 이 저장소에서 찾지 못했다. - **근거.** `InFlightLimiter.java:11-13`. `docs/messaging/*.md` 10개와 계획 문서에 절 40.3 없음. - **왜 문제인가.** 인용된 내용은 코드와 일치하므로 내용 drift는 아니다. 다만 근거를 확인하려는 사람이 도달할 수 없다. - **확인 방법.** `git grep -n '40\.3' -- docs` - **후보.** 참조를 실제 문서로 바꾸거나 인용만 남기고 절 번호를 뺀다. - **다음 단계.** **REFERENCE 후보**(저장소 밖 문서를 절 번호로 인용하지 않는다). ### 확인된 설계(문제 아님) - 모순을 부팅 실패로 옮기는 16가지 규칙과, 그것이 실제로 시작 시 호출된다는 것 - retry와 DLQ 간선을 하나의 그래프로 순회하고 다이아몬드를 오탐하지 않는 것 - payload 검사를 permit 획득보다 먼저 두는 것 - 두 개의 천장과 세 가지 슬롯 누수 방지 - fair semaphore와 불균형 반납 차단 - capability를 재시도 판단의 입력으로 두어 수행 불가능한 전략을 고르지 않는 것 - 모든 기본값이 보수적인 것(재시도 없음·동시성 1·순서 보존·확인 최대) - DLQ 발행이 확인되기 전에는 원본을 정산하지 않는 것과 그 trade를 명시한 것 - DLQ 헤더에 `ReservedHeaders` 상수를 쓰고 `MessageHeaders.platform`을 쓰는 것 --- ## Source anchors | id | kind | path | revision | what it proves | limitations | |---|---|---|---|---|---| | MPO-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 2개, memberships `["app-bootstrap"]` | 선언 | | MPO-002 | build | `messaging-policy/build.gradle` | same | 벤더 의존성 0 | — | | MPO-003 | code | `.../policy/DestinationProfileValidator.java` 전문 | same | §4.1 16규칙, §4.2 이중 간선 그래프 | 복잡도 미문서화(§17) | | MPO-004 | code | `.../policy/MessagingAdmissionController.java` 전문 | same | §4.3 순서·두 천장·세 누수 방지 | — | | MPO-005 | code | `.../policy/InFlightLimiter.java` | same | fair semaphore, 불균형 반납 차단 | "Section 40.3" 미해소 | | MPO-006 | code | `.../policy/DefaultRetryDecisionEngine.java` | same | §4.4 8단 판단 순서, capability 입력 | production 호출 없음(§12.1) | | MPO-007 | code | `.../policy/{RetryPolicy,RetryMode,RetryDecision,RetryContext,BackoffCalculator,OrderingImpact}.java` | same | 재시도 어휘 전체 | — | | MPO-008 | code | `.../policy/DeadLetterOrchestrator.java` | same | §4.7 발행-후-정산 불변식 | production 호출 없음(§12.1) | | MPO-009 | code | `.../policy/{DeadLetterEnvelopeFactory,DeadLetterMetadata,DeadLetterPolicy,DeadLetterResult,SourceSettlement}.java` | same | DLQ 봉투와 메타데이터 | 두 시각이 항상 같음(§17) | | MPO-010 | code | `.../policy/{DestinationProfile,PhysicalDestination,SchemaPolicy,ProducerPolicy,ConsumerPolicy,PayloadPolicy,CapabilityTier}.java` | same | 목적지 정의 8타입과 기본값 | — | | MPO-011 | test | `DestinationProfileValidatorTest` (13) | same | 규칙별 거절, 교대 사이클, 다이아몬드 | — | | MPO-012 | test | `MessagingAdmissionControllerTest` (13) | same | 관문 동작 전수 | 실부하 아님 | | MPO-013 | test | `RetryDecisionEngineTest` (10) | same | 판단 순서와 백오프/지터 | 배선 미증명 | | MPO-014 | test | `DeadLetterOrchestratorTest` (6) | same | 정산 순서 불변식 | 배선 미증명 | | MPO-015 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:134,145,167,179,407,446,476` | same | 배선된 것과 만들어지기만 한 것 | 해당 leaf SSOT가 소유 | | MPO-016 | cross-leaf code | `messaging-kafka/.../KafkaRetryExecutor.java` | same | `RetryDecision`의 유일한 실행자 | 생성되지 않음 | | MPO-017 | cross-leaf code | `messaging-runtime-core/.../DefaultDeliveryProcessor.java` | same | 경쟁하는 재시도/DLQ 구현 | 해당 leaf SSOT가 소유 | | EVD-281 | command | `evidence/raw/281-messaging-policy-retry-engine-unwired.txt` | same | §12.1 전부 | 정적 검색. 정규화 생성자 패턴 사용 | | EVD-282 | command | `./gradlew :messaging:messaging-policy:test --rerun-tasks` | same | 42 / 0 / 0 | 순수 단위 |