# 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_.]+\.)?