Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-policy.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

58 KiB
Raw Blame History

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 전체를 관통한다: 모순은 부팅 실패여야 한다.

// DestinationProfileValidator.java:20-24
 * <p>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.

두 번째 경계는 물리 주소의 격리다.

// PhysicalDestination.java:9-11
 * <p>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-apiDestinationName:/를 정규식으로 막고(그쪽 §4), 이 leaf가 물리 주소를 독점한다. 두 leaf가 같은 경계를 양쪽에서 지킨다.


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api), messaging-schema-api(api). 둘 다 api인 이유는 DestinationProfileDeliveryGuarantee·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 145146 생성
MessagingAdmissionController bean 407417 생성 + DefaultMessagePublisher·MessagingEndpoint·MessagingShutdownLifecycle이 주입받음
RetryDecisionEngine bean 167169 생성, 주입처 없음(§12.1)
DeadLetterOrchestrator bean 179181 생성, 주입처 없음(§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에서 가장 정교한 판단이다.

// :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이 RETRYDEAD_LETTER 둘을 갖고, walk가 두 간선을 동시에 따라간다.

onPath가 전역 방문 집합이 아니라 현재 경로다.

// :164-169
 * <p>{@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.

비용 주의. 매 분기마다 onPathpath를 복사하므로 시간·공간이 경로 수에 지수적이다. 목적지 수가 수십 개인 정상 구성에서는 문제가 없지만, 이 성질이 어디에도 기록되지 않았다 — §17의 P3.

4.3 MessagingAdmissionController — 순서가 계약이다

// :13-16
 * <p>Order matters and is fixed here rather than left to each adapter: the payload limit is checked
 * <em>before</em> 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

두 개의 천장이 있는 이유도 명시돼 있다.

// :23-26
 * <p>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상태 발행 결과와 직접 연결된다.

세 가지 누수 방지가 코드에 있다.

} 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);
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();
}
// 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);

세 번째는 장기 실행 누수 방지다 — 목적지 이름이 동적이면(예: 테넌트별) 맵이 무한히 자란다.

InFlightLimiterfair 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 — 고정된 판단 순서

// :10-15
 * <p>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가 입력이다.

// RetryContext.java:11-13
 * <p>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단 판정:

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 — 기본값이 "재시도 없음"

// :13-15
 * <p>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

// :11-14
 * <p>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.

randomFractionDoubleSupplier로 주입 가능해서 테스트가 결정론적이다. 테스트가 두 각도를 본다 — backoffGrowsExponentiallyAndIsCappedByMaxDelayfullJitterSpreadsRetriesAcrossTheWholeWindow.

capped <= 0이면 Duration.ZERO를 반환하므로 initialDelay=0인 정책에서 곱셈이 무의미해지는 경우를 방어한다.

4.7 DeadLetterOrchestrator — 하나의 불변식

// :21-29
 * <p>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.
 *
 * <p>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.

구현이 그 문장 그대로다.

.thenCompose(result -> {
  if (result.completion() != PublishCompletion.CONFIRMED) {
    return CompletableFuture.completedFuture(new DeadLetterResult(result, false));
  }
  return settleAfterConfirmation(result, settlement);
});

CONFIRMED가 아니면 — REJECTEDAMBIGUOUS든 — 원본을 정산하지 않는다. 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 불변

// :16-21
 * <p>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.
 *
 * <p>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 — 일부러 작다

// :11-13
 * <p>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-apiFailureDescriptor javadoc("a DLQ is read by more people than the log is")과 같은 판단을 다른 층에서 반복한다.

한 가지 관측. DeadLetterOrchestratorDeadLetterMetadata를 만들 때 firstFailureAtlastFailureAt같은 값(delivery.metadata().receivedAt())을 넣는다.

Instant failedAt = delivery.metadata().receivedAt();
DeadLetterMetadata metadata = new DeadLetterMetadata(..., failedAt, failedAt);

즉 두 필드가 구분되어 선언됐지만 현재 유일한 생산 경로에서는 항상 같다. 첫 실패 시각을 이전 시도에서 이어받는 코드가 없다 — §17의 P3.


5. 주요 실행 경로

시작: MessagingCoreAutoConfiguration:134validateAll(registered) → 프로파일별 15검사 + 중복 이름 + 사이클 그래프 → 실패 시 IllegalArgumentException으로 부팅 중단

발행: DefaultMessagePublisheradmission.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하려 함

배치 상한이 두 축인 이유가 적혀 있다.

// PayloadLimitGuard.java:16-18
 * <p>Batches are limited by count <em>and</em> 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. 트랜잭션·동시성·수명주기

트랜잭션 없음.

동시성 지점은 MessagingAdmissionControllerInFlightLimiter 둘이다.

지점 도구 보호
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이 부정한다. 테스트가 통과한다는 것이 그 코드가 실행된다는 뜻이 아닌 전형적인 예다.

MessagingAdmissionControllerTestas(...) 문구들이 특히 구체적이다 — "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:134validateAll을 호출 — 이 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_.]+\.)?<Type>\s*\( 패턴으로 재확인한 것이다. 처음에 new <Type>(로만 검색해 오탐을 냈다 — 이 저장소는 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은 만들어지고 아무 데도 주입되지 않는다

// 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도 같다

// :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<T>의 저장소 참조 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) 배선된 축은 확실히 배선됐다

  • DestinationProfileValidatorMessagingCoreAutoConfiguration:134에서 validateAll(registered) 호출. 부팅을 실제로 막는다.
  • MessagingAdmissionControllerDefaultMessagePublisher(발행 관문)·MessagingEndpoint(관측)·MessagingShutdownLifecycle(종료 1단계) 셋이 주입받는다.
  • PayloadLimitGuard·InFlightLimiter·PayloadPolicy → admission controller 안에서 실행된다.

한계. 정적 git grep이다. 리플렉션·ObjectProvider 지연 조회·@Autowired 필드 주입은 덮지 못한다. 다만 이 저장소의 messaging 자동설정은 전부 생성자 주입 @Bean 메서드이고(51개 전수 확인), ObjectProviderMessageContractsMessagingTransport 두 곳에만 쓰인다.

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 DefaultDeliveryProcessorDeadLetterPublisher 함수형 인터페이스
순서 보장 확인 후 정산 (명시) 확인 후 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 메시지가 두 목적지 사이를 영원히 순환
admitInterruptedException 주석 인터럽트 시 목적지 슬롯 미반납 인터럽트마다 슬롯이 새서 목적지 천장이 0까지 줄어듦
complete 주석 미보유 목적지에도 프로세스 permit 반납 아무도 안 가져간 permit을 돌려줘 전역 천장이 실제 in-flight보다 낮게 읽힘 → 감당 못 할 만큼 승인
release 주석 0인 카운터를 맵에 잔류 발행한 적 있는 모든 목적지의 카운터가 프로세스 수명 동안 누적
InFlightLimiter.release 주석 불균형 반납 허용 천장이 조용히 올라가 limiter가 아무것도 제한하지 않음

세 번째와 다섯 번째가 같은 형태다 — 반납이 획득보다 많으면 제한이 사라진다. messaging-transport-spiGracefulShutdownCoordinator.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에 소비 경로를 나중으로 미룬 기록이 없다.
  • firstFailureAtlastFailureAt을 같은 값으로 채우는 것이 임시인지 → 미상.
  • 브로커별 검증기와 중립 검증기의 호출 순서 → 미상(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으로 만들어지고 주입되는 곳이 없다

  • 사실. MessagingCoreAutoConfigurationRetryDecisionEngine(:167)과 DeadLetterOrchestrator(:179)를 @Bean @ConditionalOnMissingBean으로 만든다. 두 타입을 받는 production 코드는 각각 KafkaRetryExecutorKafkaDeadLetterPublisher/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<T> 참조 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과 분기 대조.
  • 후보. DefaultDeliveryProcessorRetryDecisionEngineDeadLetterOrchestrator를 위임받도록 합치거나, 한쪽을 제거한다.
  • 다음 단계. CASE 후보(같은 책임의 두 구현이 서로를 모른다). messaging-runtime-core leaf SSOT와 공동 소유.

P3 — DLQ 메타데이터의 두 시각이 항상 같다

  • 사실. DeadLetterMetadatafirstFailureAtlastFailureAt을 별도 필드로 선언하는데, 유일한 생산 지점인 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의 DeadLetterOrchestratorMessagingConfigurationException("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 순수 단위