{ "schema_version": "1.0", "document": "/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md", "document_sha256": "8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d", "line_count": 47035, "line_number_space": "canonical-source-with-managed-blocks-collapsed", "anchor": { "kind": "line", "value": 32611, "line": 32611 }, "current_section": { "heading": { "line": 32611, "level": 4, "text": "5. 주요 실행 경로" }, "start_line": 32611, "end_line": 32622, "text": "#### 5. 주요 실행 경로\n\n**시작:** `MessagingCoreAutoConfiguration:134` → `validateAll(registered)` → 프로파일별 15검사 + 중복 이름 + 사이클 그래프 → 실패 시 `IllegalArgumentException`으로 부팅 중단\n\n**발행:** `DefaultMessagePublisher` → `admission.admit(destination, bytes)` → 크기 → 종료 여부 → 목적지 슬롯 → 프로세스 permit → (발행) → `admission.complete(destination)`\n\n**재시도 판단:** `RetryContext(profile, deliveryMetadata, failure, capabilities, ...)` → `engine.decide(...)` → `RetryDecision` 5종 중 하나 — **이 경로는 출하 컨텍스트에서 호출되지 않는다**(§12.1)\n\n**DLQ:** `orchestrator.deadLetter(profile, delivery, failure, settlement)` → 헤더 6개 추가 → 발행 → CONFIRMED면 원본 정산 — **이 경로도 호출되지 않는다**(§12.1)\n\n---\n" }, "previous_section": { "heading": { "line": 32335, "level": 4, "text": "4. 계약·불변식·상태 모델" }, "start_line": 32335, "end_line": 32610, "text": "#### 4. 계약·불변식·상태 모델\n\n##### 4.1 `DestinationProfileValidator.validate` — 15가지 모순 거절\n\n프로파일 하나에 대해 순서대로 검사한다.\n\n| # | 거절 조건 | 왜 |\n|---:|---|---|\n| 1 | `retry.orderingImpact == PRESERVE && retry.reorders()` | 정책이 자기 자신과 모순 |\n| 2 | `isOrdered() && retry.orderingImpact == ALLOW_REORDER` | 순서 목적지가 재정렬 재시도를 허용 |\n| 3 | `payload.maxBytes > 8,388,608` | 절대 상한 초과 |\n| 4 | `claimCheckThreshold > payload.maxBytes` | 오프로드 문턱이 상한보다 큼 |\n| 5 | DLQ가 자기 자신을 가리킴 | 무한 루프 |\n| 6 | retry 목적지가 자기 자신을 가리킴 | 무한 루프 |\n| 7 | `orderingScope == KEY && !keyResolverConfigured` | 키 기반 순서인데 키 추출기 없음 |\n| 8 | `tier == M1 && consumer.manualSettlement` | M1이 수동 정산을 쓰면 정산 순서가 앱으로 새 나감 |\n| 9 | `AT_LEAST_ONCE && producer.confirmation == NONE` | 확인 없는 at-least-once는 보장이 아님 |\n| 10 | `production && topologyAutoCreate` | 운영에서 앱이 토폴로지를 만듦 |\n| 11 | `orderingScope == DESTINATION && consumer.concurrency > 1` | 목적지 전체 순서는 동시성 1을 요구 |\n| 12 | `isOrdered() && maxInFlightPerOrderingUnit > 1` | 순서 단위 안 동시 처리 |\n| 13 | `physical.isEmpty()` | 물리 주소 없음 |\n| 14 | `retry.mode == NONE && maxAttempts > 1` | 모드와 횟수 모순 |\n| 15 | `retry.mode == RETRY_DESTINATION && retryDestination.isEmpty()` | 목적지 없는 재시도 목적지 모드 |\n| 16 | `maxAttempts > 1 && mode != NONE && !deadLetter.enabled` | 재시도하는데 소진 후 갈 곳 없음 |\n\n11번과 12번이 짝이다 — 전자는 목적지 수준 동시성, 후자는 순서 단위 안 동시성. 둘 다 있어야 \"순서 보장\"이 실제로 성립한다.\n\n##### 4.2 `validateAll` — 두 종류의 간선을 하나의 그래프로\n\n이 leaf에서 가장 정교한 판단이다.\n\n```java\n// :131-136\n// One graph carrying both edge kinds, not two walks.\n//\n// Walking retry and dead-letter separately misses a cycle that alternates between them: A's\n// retry points at B and B's dead letter points back at A. Neither single-edge walk revisits a\n// node, both pass, and a poison message loops between the two destinations forever. The label\n// is kept per edge so the reported path still says which kind each hop was.\n```\n\n`Edge` enum이 `RETRY`와 `DEAD_LETTER` 둘을 갖고, `walk`가 두 간선을 동시에 따라간다.\n\n**`onPath`가 전역 방문 집합이 아니라 현재 경로다.**\n\n```java\n// :164-169\n *
{@code onPath} is the current walk rather than everything ever seen, so a diamond — two\n * destinations that both forward to a third — is not mistaken for a loop.\nwalk(nextProfile, byName, new LinkedHashSet<>(onPath), branch);\n```\n\n각 분기마다 `new LinkedHashSet<>(onPath)`로 복사하므로 형제 분기가 서로의 방문 기록을 오염시키지 않는다. 다이아몬드(A→C, B→C)는 사이클이 아니고, 그것을 사이클로 판정하면 정상 구성이 부팅에 실패한다.\n\n테스트가 두 경우를 각각 붙든다 — `aMixedEdgeCycleIsRejected`(retry/DLQ 교대 사이클 거절)와 `aSharedDeadLetterIsNotACycle`(다이아몬드 허용).\n\n미등록 목적지도 여기서 잡힌다 — `anUnregisteredRetryDestinationIsRejected`.\n\n**비용 주의.** 매 분기마다 `onPath`와 `path`를 복사하므로 시간·공간이 경로 수에 지수적이다. 목적지 수가 수십 개인 정상 구성에서는 문제가 없지만, 이 성질이 어디에도 기록되지 않았다 — §17의 P3.\n\n##### 4.3 `MessagingAdmissionController` — 순서가 계약이다\n\n```java\n// :13-16\n *
Order matters and is fixed here rather than left to each adapter: the payload limit is checked\n * before a permit is taken. An oversized message can never succeed, so letting it occupy a\n * scarce in-flight permit while it is being rejected would let a stream of bad messages starve the\n * good ones.\n```\n\n`admit`의 실제 순서:\n\n1. `payloadGuard.checkPayload` → 초과면 `MessageTooLargeException`\n2. `acceptingNewWork` 확인 → 종료 중이면 `MessageBackpressureException(\"SHUTTING_DOWN\")`\n3. `reserve(destination)` — 목적지별 CAS 루프 → 초과면 `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED`\n4. `limiter.tryAcquire()` — 프로세스 전역 semaphore, 유한 대기 → 실패면 목적지 슬롯 **반납 후** `IN_FLIGHT_LIMIT_EXCEEDED`\n\n**두 개의 천장이 있는 이유**도 명시돼 있다.\n\n```java\n// :23-26\n *
Two ceilings, because one is not enough. The per-destination ceiling stops a single slow\n * downstream from consuming every permit in the process, and the process-wide ceiling stops the sum\n * of well-behaved destinations from exhausting memory — without it, adding a destination silently\n * raises what the process can be holding at once.\n```\n\n**거절이 모호하지 않은 것이 설계의 핵심**이다 — \"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상태 발행 결과와 직접 연결된다.\n\n**세 가지 누수 방지**가 코드에 있다.\n\n```java\n} catch (InterruptedException interrupted) {\n // The destination slot was taken a moment ago and no publish will use it, so it goes back\n // here: a slot leaked per interruption shrinks the destination's ceiling until it is zero.\n release(destination);\n```\n\n```java\npublic void complete(String destination) {\n if (!release(destination)) {\n // A completion for a destination that holds nothing: either it names the wrong destination or\n // it is a second completion for the same publish. Returning the process permit anyway frees\n // one nobody took, and the process-wide ceiling then reads below what is really in flight and\n // admits more work than the process can carry.\n return;\n }\n limiter.release();\n}\n```\n\n```java\n// release():195-197\n// Drop the entry at zero, atomically, so the map does not accumulate one counter per\n// destination ever published to for the life of the process.\nperDestination.computeIfPresent(destination, (key, value) -> value.get() == 0 ? null : value);\n```\n\n세 번째는 장기 실행 누수 방지다 — 목적지 이름이 동적이면(예: 테넌트별) 맵이 무한히 자란다.\n\n`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.\"\n\n`release()`가 `availablePermits() < limit`를 확인하고 반납한다 — \"an unbalanced release would raise the ceiling silently and the limiter would stop limiting anything.\"\n\n##### 4.4 `DefaultRetryDecisionEngine` — 고정된 판단 순서\n\n```java\n// :10-15\n *
The order is fixed and evaluated top to bottom. Retryability is checked before the attempt\n * budget so that a deserialization failure is parked on its first delivery instead of being\n * replayed three more times against a payload that cannot change. The ordering-preserving strategy\n * is checked before the re-publishing one so that an ordered destination can never fall through to\n * a strategy that reorders it, even if both are technically configured.\n```\n\n실제 순서:\n\n| # | 조건 | 결정 |\n|---:|---|---|\n| 1 | `!isRetryable(...)` | `park(context)` — DLQ가 있으면 `DeadLetter`, `AT_MOST_ONCE`이고 DLQ 없으면 `Reject`, 그 외 `DeadLetter` |\n| 2 | `attempt >= maxAttempts` | `DeadLetter` |\n| 3 | `orderingImpact == PRESERVE && isOrdered() && capabilities.orderedStream()` | `PauseAndRetry(delay)` |\n| 4 | `mode == PAUSE_PARTITION` | `PauseAndRetry(delay)` |\n| 5 | `mode == RETRY_DESTINATION && ALLOW_REORDER && retryDestination.isPresent()` | `PublishToRetryDestination` |\n| 6 | `mode == INLINE \\|\\| BLOCKING` | `RetryInline(delay)` |\n| 7 | `mode == BROKER_DELAYED && capabilities.delayedDelivery()` | `PublishToRetryDestination` |\n| 8 | (그 외) | `DeadLetter` |\n\n**capability가 입력이다.**\n\n```java\n// RetryContext.java:11-13\n *
Capabilities are an input rather than an assumption: the same policy resolves to\n * pause-and-retry on a partitioned Kafka topic and to a retry destination on a queue that cannot\n * pause, and the engine must not pick a strategy the adapter cannot actually carry out.\n```\n\n3번과 7번이 그것을 쓴다 — `orderedStream()`이 false면 pause 전략이 선택되지 않고, `delayedDelivery()`가 false면 `BROKER_DELAYED`가 8번으로 떨어져 DLQ가 된다. **조용한 성능 저하 대신 명시적 파킹**이다.\n\n`isRetryable`의 3단 판정:\n\n```java\nif (policy.nonRetryableCategories().contains(category)) return false; // 명시적 제외 최우선\nif (policy.retryableCategories().contains(category)) return true; // 명시적 허용\nreturn descriptorRetryable && FailureDescriptorDefaults.retryable(category); // 둘 다 만족해야\n```\n\n마지막 줄이 **AND**다 — descriptor가 retryable이라 해도 카테고리 기본값이 false면 재시도하지 않는다. `RetryPolicy` 생성자가 두 집합의 교집합을 거절하므로(§4.5) 1·2번이 동시에 참일 수 없다.\n\n`FailureDescriptorDefaults`는 package-private 위임자다 — \"kept in one place so policy and engine cannot disagree\". 실제로는 `FailureDescriptor.defaultRetryable`(core-api)를 그대로 부른다. 한 줄 짜리 간접층이지만 정책 쪽에서 기본값을 바꿔야 할 때 바꿀 지점을 명시한다.\n\n##### 4.5 `RetryPolicy` — 기본값이 \"재시도 없음\"\n\n```java\n// :13-15\n *
Automatic retry is opt-in. The default for an ordinary destination is zero attempts, because a\n * retry that reorders a stream, multiplies a non-idempotent side effect, or hammers a throttled\n * downstream is worse than a visible failure.\n```\n\n`none()`이 `mode=NONE, maxAttempts=1, delays=ZERO, multiplier=1.0, jitter=false, orderingImpact=PRESERVE, 두 집합 비어 있음`이다.\n\n생성자 검증 여섯:\n- `maxAttempts >= 1` (첫 전달 포함)\n- 두 지연 음수 아님\n- `maxDelay >= initialDelay`\n- `multiplier >= 1.0`\n- 두 카테고리 집합을 `Set.copyOf`로 복사\n- **두 집합의 교집합 거절** — \"a failure category cannot be both retryable and non-retryable\"\n\n`reorders()`가 `RETRY_DESTINATION || BROKER_DELAYED`다 — 이 둘만 메시지를 원래 순서 단위 밖으로 옮긴다. `RetryMode` javadoc이 같은 사실을 반대편에서 적는다.\n\n##### 4.6 `BackoffCalculator` — full jitter\n\n```java\n// :11-14\n *
The delay is {@code min(maxDelay, initialDelay * multiplier^(attempt-1))}. Full jitter then\n * picks uniformly from {@code [0, delay]} rather than shaving a small percentage off. That matters\n * when a downstream recovers: without jitter every consumer that failed in the same second retries\n * in the same second, and the recovery is immediately undone by the retry storm.\n```\n\n`randomFraction`이 `DoubleSupplier`로 주입 가능해서 테스트가 결정론적이다. 테스트가 두 각도를 본다 — `backoffGrowsExponentiallyAndIsCappedByMaxDelay`와 `fullJitterSpreadsRetriesAcrossTheWholeWindow`.\n\n`capped <= 0`이면 `Duration.ZERO`를 반환하므로 `initialDelay=0`인 정책에서 곱셈이 무의미해지는 경우를 방어한다.\n\n##### 4.7 `DeadLetterOrchestrator` — 하나의 불변식\n\n```java\n// :21-29\n *
This ordering is the single invariant that stops dead lettering from becoming data loss. If\n * the source were acknowledged first, a failed dead letter publish would leave no copy of the\n * message anywhere: the broker has released it and the dead letter destination never received it.\n * So the source stays unsettled on anything other than a confirmed publish, including an ambiguous\n * one, and the message is redelivered instead of disappearing.\n *\n *
An ambiguous dead letter publish therefore produces a duplicate rather than a loss. That is\n * the intended trade: the dead letter destination is read by humans who can spot a duplicate, and\n * it is the only side of the trade that is recoverable.\n```\n\n구현이 그 문장 그대로다.\n\n```java\n.thenCompose(result -> {\n if (result.completion() != PublishCompletion.CONFIRMED) {\n return CompletableFuture.completedFuture(new DeadLetterResult(result, false));\n }\n return settleAfterConfirmation(result, settlement);\n});\n```\n\n`CONFIRMED`가 아니면 — `REJECTED`든 `AMBIGUOUS`든 — 원본을 정산하지 않는다. `messaging-core-api`의 3상태가 여기서 실제 분기가 된다.\n\n`SourceSettlement`이 콜백으로 주입되는 이유도 적혀 있다 — \"so that the ordering constraint … lives in one place instead of being re-implemented by every adapter.\"\n\n##### 4.8 `DeadLetterEnvelopeFactory` — 예약 헤더 6개, payload 불변\n\n```java\n// :16-21\n *
The payload and the logical {@code messageId} are carried through untouched. That is what\n * makes a redrive a genuine replay rather than a new message: an Inbox downstream still recognises\n * it, and an operator can correlate the dead letter with the original publish.\n *\n *
Failure context is written into reserved headers, never into the payload, so redriving does\n * not require unwrapping a platform-specific structure.\n```\n\n쓰는 헤더: `FAILURE_CATEGORY`, `FAILURE_CODE`, `ORIGIN_DESTINATION`, `RETRY_ATTEMPT`, `FIRST_FAILURE_AT`, `LAST_FAILURE_AT`. 전부 `ReservedHeaders`의 상수를 쓴다(리터럴 아님).\n\n`MessageHeaders.platform(headers)`를 쓴다 — 예약 이름을 쓸 수 있는 factory다(`messaging-core-api` §4.8). 이것이 core-api의 두 factory 분리가 실제로 필요한 이유를 보여주는 유일한 production 사용처다.\n\n여섯 헤더 중 `RETRY_ATTEMPT`·`FIRST_FAILURE_AT`·`LAST_FAILURE_AT`·`FAILURE_CATEGORY`·`FAILURE_CODE`·`ORIGIN_DESTINATION`은 전부 `CanonicalEnvelopeHeaders`가 \"platform bookkeeping\"으로 분류한 8개에 속한다 — 봉투 필드가 없어서 헤더로만 이동할 수 있는 것들이다. 두 leaf의 분류가 정확히 맞물린다.\n\n##### 4.9 `DeadLetterMetadata` — 일부러 작다\n\n```java\n// :11-13\n *
Deliberately small. A dead letter destination is read by operators, exported to tickets, and\n * often retained far longer than the source topic, so it holds a category, a code, and timing — not\n * a stack trace, not the exception message, and not the original headers.\n```\n\n`messaging-core-api`의 `FailureDescriptor` javadoc(\"a DLQ is read by more people than the log is\")과 같은 판단을 다른 층에서 반복한다.\n\n**한 가지 관측.** `DeadLetterOrchestrator`가 `DeadLetterMetadata`를 만들 때 `firstFailureAt`과 `lastFailureAt`에 **같은 값**(`delivery.metadata().receivedAt()`)을 넣는다.\n\n```java\nInstant failedAt = delivery.metadata().receivedAt();\nDeadLetterMetadata metadata = new DeadLetterMetadata(..., failedAt, failedAt);\n```\n\n즉 두 필드가 구분되어 선언됐지만 현재 유일한 생산 경로에서는 항상 같다. 첫 실패 시각을 이전 시도에서 이어받는 코드가 없다 — §17의 P3.\n\n---\n" }, "next_section": { "heading": { "line": 32623, "level": 4, "text": "6. 실패 경로와 복구/번역" }, "start_line": 32623, "end_line": 32650, "text": "#### 6. 실패 경로와 복구/번역\n\n| 코드 | 예외 | 위치 | 조건 |\n|---|---|---|---|\n| `PAYLOAD_LIMIT_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 목적지 상한 초과 |\n| `BATCH_COUNT_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 배치 항목 수 초과 |\n| `BATCH_BYTES_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 배치 총 바이트 초과 |\n| `SHUTTING_DOWN` | `MessageBackpressureException` | `MessagingAdmissionController` | 종료 중 |\n| `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED` | `MessageBackpressureException` | 같음 | 목적지 천장 |\n| `IN_FLIGHT_LIMIT_EXCEEDED` | `MessageBackpressureException` | 같음 | 프로세스 천장 |\n| `ADMISSION_INTERRUPTED` | `MessageBackpressureException` | 같음 | 대기 중 인터럽트 |\n| `DEAD_LETTER_NOT_CONFIGURED` | `MessagingConfigurationException` | `DeadLetterOrchestrator` | DLQ 미설정 목적지를 DLQ하려 함 |\n\n**배치 상한이 두 축인 이유**가 적혀 있다.\n\n```java\n// PayloadLimitGuard.java:16-18\n *
Batches are limited by count and bytes. A count limit alone lets a handful of large\n * messages exceed the broker's frame; a byte limit alone lets a huge number of tiny messages exceed\n * its request timeout.\n```\n\n`checkBatch`가 각 항목에 대해 `checkPayload`도 부르므로 **개별 상한 · 개수 상한 · 총합 상한** 셋이 함께 적용된다.\n\n프로파일 검증 실패는 `IllegalArgumentException`이다 — `MessagingException` 계층 밖이다. 시작 시점의 구성 오류이지 메시지 실패가 아니므로 일관적이다. 다만 `MessagingConfigurationException`(\"Raised at startup wherever possible\")이 존재하는데 쓰이지 않는다 — §17의 P3.\n\n---\n" }, "context_range": { "start_line": 32335, "end_line": 32650 }, "context_lines": [ { "line": 32335, "text": "#### 4. 계약·불변식·상태 모델" }, { "line": 32336, "text": "" }, { "line": 32337, "text": "##### 4.1 `DestinationProfileValidator.validate` — 15가지 모순 거절" }, { "line": 32338, "text": "" }, { "line": 32339, "text": "프로파일 하나에 대해 순서대로 검사한다." }, { "line": 32340, "text": "" }, { "line": 32341, "text": "| # | 거절 조건 | 왜 |" }, { "line": 32342, "text": "|---:|---|---|" }, { "line": 32343, "text": "| 1 | `retry.orderingImpact == PRESERVE && retry.reorders()` | 정책이 자기 자신과 모순 |" }, { "line": 32344, "text": "| 2 | `isOrdered() && retry.orderingImpact == ALLOW_REORDER` | 순서 목적지가 재정렬 재시도를 허용 |" }, { "line": 32345, "text": "| 3 | `payload.maxBytes > 8,388,608` | 절대 상한 초과 |" }, { "line": 32346, "text": "| 4 | `claimCheckThreshold > payload.maxBytes` | 오프로드 문턱이 상한보다 큼 |" }, { "line": 32347, "text": "| 5 | DLQ가 자기 자신을 가리킴 | 무한 루프 |" }, { "line": 32348, "text": "| 6 | retry 목적지가 자기 자신을 가리킴 | 무한 루프 |" }, { "line": 32349, "text": "| 7 | `orderingScope == KEY && !keyResolverConfigured` | 키 기반 순서인데 키 추출기 없음 |" }, { "line": 32350, "text": "| 8 | `tier == M1 && consumer.manualSettlement` | M1이 수동 정산을 쓰면 정산 순서가 앱으로 새 나감 |" }, { "line": 32351, "text": "| 9 | `AT_LEAST_ONCE && producer.confirmation == NONE` | 확인 없는 at-least-once는 보장이 아님 |" }, { "line": 32352, "text": "| 10 | `production && topologyAutoCreate` | 운영에서 앱이 토폴로지를 만듦 |" }, { "line": 32353, "text": "| 11 | `orderingScope == DESTINATION && consumer.concurrency > 1` | 목적지 전체 순서는 동시성 1을 요구 |" }, { "line": 32354, "text": "| 12 | `isOrdered() && maxInFlightPerOrderingUnit > 1` | 순서 단위 안 동시 처리 |" }, { "line": 32355, "text": "| 13 | `physical.isEmpty()` | 물리 주소 없음 |" }, { "line": 32356, "text": "| 14 | `retry.mode == NONE && maxAttempts > 1` | 모드와 횟수 모순 |" }, { "line": 32357, "text": "| 15 | `retry.mode == RETRY_DESTINATION && retryDestination.isEmpty()` | 목적지 없는 재시도 목적지 모드 |" }, { "line": 32358, "text": "| 16 | `maxAttempts > 1 && mode != NONE && !deadLetter.enabled` | 재시도하는데 소진 후 갈 곳 없음 |" }, { "line": 32359, "text": "" }, { "line": 32360, "text": "11번과 12번이 짝이다 — 전자는 목적지 수준 동시성, 후자는 순서 단위 안 동시성. 둘 다 있어야 \"순서 보장\"이 실제로 성립한다." }, { "line": 32361, "text": "" }, { "line": 32362, "text": "##### 4.2 `validateAll` — 두 종류의 간선을 하나의 그래프로" }, { "line": 32363, "text": "" }, { "line": 32364, "text": "이 leaf에서 가장 정교한 판단이다." }, { "line": 32365, "text": "" }, { "line": 32366, "text": "```java" }, { "line": 32367, "text": "// :131-136" }, { "line": 32368, "text": "// One graph carrying both edge kinds, not two walks." }, { "line": 32369, "text": "//" }, { "line": 32370, "text": "// Walking retry and dead-letter separately misses a cycle that alternates between them: A's" }, { "line": 32371, "text": "// retry points at B and B's dead letter points back at A. Neither single-edge walk revisits a" }, { "line": 32372, "text": "// node, both pass, and a poison message loops between the two destinations forever. The label" }, { "line": 32373, "text": "// is kept per edge so the reported path still says which kind each hop was." }, { "line": 32374, "text": "```" }, { "line": 32375, "text": "" }, { "line": 32376, "text": "`Edge` enum이 `RETRY`와 `DEAD_LETTER` 둘을 갖고, `walk`가 두 간선을 동시에 따라간다." }, { "line": 32377, "text": "" }, { "line": 32378, "text": "**`onPath`가 전역 방문 집합이 아니라 현재 경로다.**" }, { "line": 32379, "text": "" }, { "line": 32380, "text": "```java" }, { "line": 32381, "text": "// :164-169" }, { "line": 32382, "text": " *
{@code onPath} is the current walk rather than everything ever seen, so a diamond — two" }, { "line": 32383, "text": " * destinations that both forward to a third — is not mistaken for a loop." }, { "line": 32384, "text": "walk(nextProfile, byName, new LinkedHashSet<>(onPath), branch);" }, { "line": 32385, "text": "```" }, { "line": 32386, "text": "" }, { "line": 32387, "text": "각 분기마다 `new LinkedHashSet<>(onPath)`로 복사하므로 형제 분기가 서로의 방문 기록을 오염시키지 않는다. 다이아몬드(A→C, B→C)는 사이클이 아니고, 그것을 사이클로 판정하면 정상 구성이 부팅에 실패한다." }, { "line": 32388, "text": "" }, { "line": 32389, "text": "테스트가 두 경우를 각각 붙든다 — `aMixedEdgeCycleIsRejected`(retry/DLQ 교대 사이클 거절)와 `aSharedDeadLetterIsNotACycle`(다이아몬드 허용)." }, { "line": 32390, "text": "" }, { "line": 32391, "text": "미등록 목적지도 여기서 잡힌다 — `anUnregisteredRetryDestinationIsRejected`." }, { "line": 32392, "text": "" }, { "line": 32393, "text": "**비용 주의.** 매 분기마다 `onPath`와 `path`를 복사하므로 시간·공간이 경로 수에 지수적이다. 목적지 수가 수십 개인 정상 구성에서는 문제가 없지만, 이 성질이 어디에도 기록되지 않았다 — §17의 P3." }, { "line": 32394, "text": "" }, { "line": 32395, "text": "##### 4.3 `MessagingAdmissionController` — 순서가 계약이다" }, { "line": 32396, "text": "" }, { "line": 32397, "text": "```java" }, { "line": 32398, "text": "// :13-16" }, { "line": 32399, "text": " *
Order matters and is fixed here rather than left to each adapter: the payload limit is checked" }, { "line": 32400, "text": " * before a permit is taken. An oversized message can never succeed, so letting it occupy a" }, { "line": 32401, "text": " * scarce in-flight permit while it is being rejected would let a stream of bad messages starve the" }, { "line": 32402, "text": " * good ones." }, { "line": 32403, "text": "```" }, { "line": 32404, "text": "" }, { "line": 32405, "text": "`admit`의 실제 순서:" }, { "line": 32406, "text": "" }, { "line": 32407, "text": "1. `payloadGuard.checkPayload` → 초과면 `MessageTooLargeException`" }, { "line": 32408, "text": "2. `acceptingNewWork` 확인 → 종료 중이면 `MessageBackpressureException(\"SHUTTING_DOWN\")`" }, { "line": 32409, "text": "3. `reserve(destination)` — 목적지별 CAS 루프 → 초과면 `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED`" }, { "line": 32410, "text": "4. `limiter.tryAcquire()` — 프로세스 전역 semaphore, 유한 대기 → 실패면 목적지 슬롯 **반납 후** `IN_FLIGHT_LIMIT_EXCEEDED`" }, { "line": 32411, "text": "" }, { "line": 32412, "text": "**두 개의 천장이 있는 이유**도 명시돼 있다." }, { "line": 32413, "text": "" }, { "line": 32414, "text": "```java" }, { "line": 32415, "text": "// :23-26" }, { "line": 32416, "text": " *
Two ceilings, because one is not enough. The per-destination ceiling stops a single slow" }, { "line": 32417, "text": " * downstream from consuming every permit in the process, and the process-wide ceiling stops the sum" }, { "line": 32418, "text": " * of well-behaved destinations from exhausting memory — without it, adding a destination silently" }, { "line": 32419, "text": " * raises what the process can be holding at once." }, { "line": 32420, "text": "```" }, { "line": 32421, "text": "" }, { "line": 32422, "text": "**거절이 모호하지 않은 것이 설계의 핵심**이다 — \"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상태 발행 결과와 직접 연결된다." }, { "line": 32423, "text": "" }, { "line": 32424, "text": "**세 가지 누수 방지**가 코드에 있다." }, { "line": 32425, "text": "" }, { "line": 32426, "text": "```java" }, { "line": 32427, "text": "} catch (InterruptedException interrupted) {" }, { "line": 32428, "text": " // The destination slot was taken a moment ago and no publish will use it, so it goes back" }, { "line": 32429, "text": " // here: a slot leaked per interruption shrinks the destination's ceiling until it is zero." }, { "line": 32430, "text": " release(destination);" }, { "line": 32431, "text": "```" }, { "line": 32432, "text": "" }, { "line": 32433, "text": "```java" }, { "line": 32434, "text": "public void complete(String destination) {" }, { "line": 32435, "text": " if (!release(destination)) {" }, { "line": 32436, "text": " // A completion for a destination that holds nothing: either it names the wrong destination or" }, { "line": 32437, "text": " // it is a second completion for the same publish. Returning the process permit anyway frees" }, { "line": 32438, "text": " // one nobody took, and the process-wide ceiling then reads below what is really in flight and" }, { "line": 32439, "text": " // admits more work than the process can carry." }, { "line": 32440, "text": " return;" }, { "line": 32441, "text": " }" }, { "line": 32442, "text": " limiter.release();" }, { "line": 32443, "text": "}" }, { "line": 32444, "text": "```" }, { "line": 32445, "text": "" }, { "line": 32446, "text": "```java" }, { "line": 32447, "text": "// release():195-197" }, { "line": 32448, "text": "// Drop the entry at zero, atomically, so the map does not accumulate one counter per" }, { "line": 32449, "text": "// destination ever published to for the life of the process." }, { "line": 32450, "text": "perDestination.computeIfPresent(destination, (key, value) -> value.get() == 0 ? null : value);" }, { "line": 32451, "text": "```" }, { "line": 32452, "text": "" }, { "line": 32453, "text": "세 번째는 장기 실행 누수 방지다 — 목적지 이름이 동적이면(예: 테넌트별) 맵이 무한히 자란다." }, { "line": 32454, "text": "" }, { "line": 32455, "text": "`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.\"" }, { "line": 32456, "text": "" }, { "line": 32457, "text": "`release()`가 `availablePermits() < limit`를 확인하고 반납한다 — \"an unbalanced release would raise the ceiling silently and the limiter would stop limiting anything.\"" }, { "line": 32458, "text": "" }, { "line": 32459, "text": "##### 4.4 `DefaultRetryDecisionEngine` — 고정된 판단 순서" }, { "line": 32460, "text": "" }, { "line": 32461, "text": "```java" }, { "line": 32462, "text": "// :10-15" }, { "line": 32463, "text": " *
The order is fixed and evaluated top to bottom. Retryability is checked before the attempt" }, { "line": 32464, "text": " * budget so that a deserialization failure is parked on its first delivery instead of being" }, { "line": 32465, "text": " * replayed three more times against a payload that cannot change. The ordering-preserving strategy" }, { "line": 32466, "text": " * is checked before the re-publishing one so that an ordered destination can never fall through to" }, { "line": 32467, "text": " * a strategy that reorders it, even if both are technically configured." }, { "line": 32468, "text": "```" }, { "line": 32469, "text": "" }, { "line": 32470, "text": "실제 순서:" }, { "line": 32471, "text": "" }, { "line": 32472, "text": "| # | 조건 | 결정 |" }, { "line": 32473, "text": "|---:|---|---|" }, { "line": 32474, "text": "| 1 | `!isRetryable(...)` | `park(context)` — DLQ가 있으면 `DeadLetter`, `AT_MOST_ONCE`이고 DLQ 없으면 `Reject`, 그 외 `DeadLetter` |" }, { "line": 32475, "text": "| 2 | `attempt >= maxAttempts` | `DeadLetter` |" }, { "line": 32476, "text": "| 3 | `orderingImpact == PRESERVE && isOrdered() && capabilities.orderedStream()` | `PauseAndRetry(delay)` |" }, { "line": 32477, "text": "| 4 | `mode == PAUSE_PARTITION` | `PauseAndRetry(delay)` |" }, { "line": 32478, "text": "| 5 | `mode == RETRY_DESTINATION && ALLOW_REORDER && retryDestination.isPresent()` | `PublishToRetryDestination` |" }, { "line": 32479, "text": "| 6 | `mode == INLINE \\|\\| BLOCKING` | `RetryInline(delay)` |" }, { "line": 32480, "text": "| 7 | `mode == BROKER_DELAYED && capabilities.delayedDelivery()` | `PublishToRetryDestination` |" }, { "line": 32481, "text": "| 8 | (그 외) | `DeadLetter` |" }, { "line": 32482, "text": "" }, { "line": 32483, "text": "**capability가 입력이다.**" }, { "line": 32484, "text": "" }, { "line": 32485, "text": "```java" }, { "line": 32486, "text": "// RetryContext.java:11-13" }, { "line": 32487, "text": " *
Capabilities are an input rather than an assumption: the same policy resolves to" }, { "line": 32488, "text": " * pause-and-retry on a partitioned Kafka topic and to a retry destination on a queue that cannot" }, { "line": 32489, "text": " * pause, and the engine must not pick a strategy the adapter cannot actually carry out." }, { "line": 32490, "text": "```" }, { "line": 32491, "text": "" }, { "line": 32492, "text": "3번과 7번이 그것을 쓴다 — `orderedStream()`이 false면 pause 전략이 선택되지 않고, `delayedDelivery()`가 false면 `BROKER_DELAYED`가 8번으로 떨어져 DLQ가 된다. **조용한 성능 저하 대신 명시적 파킹**이다." }, { "line": 32493, "text": "" }, { "line": 32494, "text": "`isRetryable`의 3단 판정:" }, { "line": 32495, "text": "" }, { "line": 32496, "text": "```java" }, { "line": 32497, "text": "if (policy.nonRetryableCategories().contains(category)) return false; // 명시적 제외 최우선" }, { "line": 32498, "text": "if (policy.retryableCategories().contains(category)) return true; // 명시적 허용" }, { "line": 32499, "text": "return descriptorRetryable && FailureDescriptorDefaults.retryable(category); // 둘 다 만족해야" }, { "line": 32500, "text": "```" }, { "line": 32501, "text": "" }, { "line": 32502, "text": "마지막 줄이 **AND**다 — descriptor가 retryable이라 해도 카테고리 기본값이 false면 재시도하지 않는다. `RetryPolicy` 생성자가 두 집합의 교집합을 거절하므로(§4.5) 1·2번이 동시에 참일 수 없다." }, { "line": 32503, "text": "" }, { "line": 32504, "text": "`FailureDescriptorDefaults`는 package-private 위임자다 — \"kept in one place so policy and engine cannot disagree\". 실제로는 `FailureDescriptor.defaultRetryable`(core-api)를 그대로 부른다. 한 줄 짜리 간접층이지만 정책 쪽에서 기본값을 바꿔야 할 때 바꿀 지점을 명시한다." }, { "line": 32505, "text": "" }, { "line": 32506, "text": "##### 4.5 `RetryPolicy` — 기본값이 \"재시도 없음\"" }, { "line": 32507, "text": "" }, { "line": 32508, "text": "```java" }, { "line": 32509, "text": "// :13-15" }, { "line": 32510, "text": " *
Automatic retry is opt-in. The default for an ordinary destination is zero attempts, because a" }, { "line": 32511, "text": " * retry that reorders a stream, multiplies a non-idempotent side effect, or hammers a throttled" }, { "line": 32512, "text": " * downstream is worse than a visible failure." }, { "line": 32513, "text": "```" }, { "line": 32514, "text": "" }, { "line": 32515, "text": "`none()`이 `mode=NONE, maxAttempts=1, delays=ZERO, multiplier=1.0, jitter=false, orderingImpact=PRESERVE, 두 집합 비어 있음`이다." }, { "line": 32516, "text": "" }, { "line": 32517, "text": "생성자 검증 여섯:" }, { "line": 32518, "text": "- `maxAttempts >= 1` (첫 전달 포함)" }, { "line": 32519, "text": "- 두 지연 음수 아님" }, { "line": 32520, "text": "- `maxDelay >= initialDelay`" }, { "line": 32521, "text": "- `multiplier >= 1.0`" }, { "line": 32522, "text": "- 두 카테고리 집합을 `Set.copyOf`로 복사" }, { "line": 32523, "text": "- **두 집합의 교집합 거절** — \"a failure category cannot be both retryable and non-retryable\"" }, { "line": 32524, "text": "" }, { "line": 32525, "text": "`reorders()`가 `RETRY_DESTINATION || BROKER_DELAYED`다 — 이 둘만 메시지를 원래 순서 단위 밖으로 옮긴다. `RetryMode` javadoc이 같은 사실을 반대편에서 적는다." }, { "line": 32526, "text": "" }, { "line": 32527, "text": "##### 4.6 `BackoffCalculator` — full jitter" }, { "line": 32528, "text": "" }, { "line": 32529, "text": "```java" }, { "line": 32530, "text": "// :11-14" }, { "line": 32531, "text": " *
The delay is {@code min(maxDelay, initialDelay * multiplier^(attempt-1))}. Full jitter then" }, { "line": 32532, "text": " * picks uniformly from {@code [0, delay]} rather than shaving a small percentage off. That matters" }, { "line": 32533, "text": " * when a downstream recovers: without jitter every consumer that failed in the same second retries" }, { "line": 32534, "text": " * in the same second, and the recovery is immediately undone by the retry storm." }, { "line": 32535, "text": "```" }, { "line": 32536, "text": "" }, { "line": 32537, "text": "`randomFraction`이 `DoubleSupplier`로 주입 가능해서 테스트가 결정론적이다. 테스트가 두 각도를 본다 — `backoffGrowsExponentiallyAndIsCappedByMaxDelay`와 `fullJitterSpreadsRetriesAcrossTheWholeWindow`." }, { "line": 32538, "text": "" }, { "line": 32539, "text": "`capped <= 0`이면 `Duration.ZERO`를 반환하므로 `initialDelay=0`인 정책에서 곱셈이 무의미해지는 경우를 방어한다." }, { "line": 32540, "text": "" }, { "line": 32541, "text": "##### 4.7 `DeadLetterOrchestrator` — 하나의 불변식" }, { "line": 32542, "text": "" }, { "line": 32543, "text": "```java" }, { "line": 32544, "text": "// :21-29" }, { "line": 32545, "text": " *
This ordering is the single invariant that stops dead lettering from becoming data loss. If" }, { "line": 32546, "text": " * the source were acknowledged first, a failed dead letter publish would leave no copy of the" }, { "line": 32547, "text": " * message anywhere: the broker has released it and the dead letter destination never received it." }, { "line": 32548, "text": " * So the source stays unsettled on anything other than a confirmed publish, including an ambiguous" }, { "line": 32549, "text": " * one, and the message is redelivered instead of disappearing." }, { "line": 32550, "text": " *" }, { "line": 32551, "text": " *
An ambiguous dead letter publish therefore produces a duplicate rather than a loss. That is" }, { "line": 32552, "text": " * the intended trade: the dead letter destination is read by humans who can spot a duplicate, and" }, { "line": 32553, "text": " * it is the only side of the trade that is recoverable." }, { "line": 32554, "text": "```" }, { "line": 32555, "text": "" }, { "line": 32556, "text": "구현이 그 문장 그대로다." }, { "line": 32557, "text": "" }, { "line": 32558, "text": "```java" }, { "line": 32559, "text": ".thenCompose(result -> {" }, { "line": 32560, "text": " if (result.completion() != PublishCompletion.CONFIRMED) {" }, { "line": 32561, "text": " return CompletableFuture.completedFuture(new DeadLetterResult(result, false));" }, { "line": 32562, "text": " }" }, { "line": 32563, "text": " return settleAfterConfirmation(result, settlement);" }, { "line": 32564, "text": "});" }, { "line": 32565, "text": "```" }, { "line": 32566, "text": "" }, { "line": 32567, "text": "`CONFIRMED`가 아니면 — `REJECTED`든 `AMBIGUOUS`든 — 원본을 정산하지 않는다. `messaging-core-api`의 3상태가 여기서 실제 분기가 된다." }, { "line": 32568, "text": "" }, { "line": 32569, "text": "`SourceSettlement`이 콜백으로 주입되는 이유도 적혀 있다 — \"so that the ordering constraint … lives in one place instead of being re-implemented by every adapter.\"" }, { "line": 32570, "text": "" }, { "line": 32571, "text": "##### 4.8 `DeadLetterEnvelopeFactory` — 예약 헤더 6개, payload 불변" }, { "line": 32572, "text": "" }, { "line": 32573, "text": "```java" }, { "line": 32574, "text": "// :16-21" }, { "line": 32575, "text": " *
The payload and the logical {@code messageId} are carried through untouched. That is what" }, { "line": 32576, "text": " * makes a redrive a genuine replay rather than a new message: an Inbox downstream still recognises" }, { "line": 32577, "text": " * it, and an operator can correlate the dead letter with the original publish." }, { "line": 32578, "text": " *" }, { "line": 32579, "text": " *
Failure context is written into reserved headers, never into the payload, so redriving does" }, { "line": 32580, "text": " * not require unwrapping a platform-specific structure." }, { "line": 32581, "text": "```" }, { "line": 32582, "text": "" }, { "line": 32583, "text": "쓰는 헤더: `FAILURE_CATEGORY`, `FAILURE_CODE`, `ORIGIN_DESTINATION`, `RETRY_ATTEMPT`, `FIRST_FAILURE_AT`, `LAST_FAILURE_AT`. 전부 `ReservedHeaders`의 상수를 쓴다(리터럴 아님)." }, { "line": 32584, "text": "" }, { "line": 32585, "text": "`MessageHeaders.platform(headers)`를 쓴다 — 예약 이름을 쓸 수 있는 factory다(`messaging-core-api` §4.8). 이것이 core-api의 두 factory 분리가 실제로 필요한 이유를 보여주는 유일한 production 사용처다." }, { "line": 32586, "text": "" }, { "line": 32587, "text": "여섯 헤더 중 `RETRY_ATTEMPT`·`FIRST_FAILURE_AT`·`LAST_FAILURE_AT`·`FAILURE_CATEGORY`·`FAILURE_CODE`·`ORIGIN_DESTINATION`은 전부 `CanonicalEnvelopeHeaders`가 \"platform bookkeeping\"으로 분류한 8개에 속한다 — 봉투 필드가 없어서 헤더로만 이동할 수 있는 것들이다. 두 leaf의 분류가 정확히 맞물린다." }, { "line": 32588, "text": "" }, { "line": 32589, "text": "##### 4.9 `DeadLetterMetadata` — 일부러 작다" }, { "line": 32590, "text": "" }, { "line": 32591, "text": "```java" }, { "line": 32592, "text": "// :11-13" }, { "line": 32593, "text": " *
Deliberately small. A dead letter destination is read by operators, exported to tickets, and" }, { "line": 32594, "text": " * often retained far longer than the source topic, so it holds a category, a code, and timing — not" }, { "line": 32595, "text": " * a stack trace, not the exception message, and not the original headers." }, { "line": 32596, "text": "```" }, { "line": 32597, "text": "" }, { "line": 32598, "text": "`messaging-core-api`의 `FailureDescriptor` javadoc(\"a DLQ is read by more people than the log is\")과 같은 판단을 다른 층에서 반복한다." }, { "line": 32599, "text": "" }, { "line": 32600, "text": "**한 가지 관측.** `DeadLetterOrchestrator`가 `DeadLetterMetadata`를 만들 때 `firstFailureAt`과 `lastFailureAt`에 **같은 값**(`delivery.metadata().receivedAt()`)을 넣는다." }, { "line": 32601, "text": "" }, { "line": 32602, "text": "```java" }, { "line": 32603, "text": "Instant failedAt = delivery.metadata().receivedAt();" }, { "line": 32604, "text": "DeadLetterMetadata metadata = new DeadLetterMetadata(..., failedAt, failedAt);" }, { "line": 32605, "text": "```" }, { "line": 32606, "text": "" }, { "line": 32607, "text": "즉 두 필드가 구분되어 선언됐지만 현재 유일한 생산 경로에서는 항상 같다. 첫 실패 시각을 이전 시도에서 이어받는 코드가 없다 — §17의 P3." }, { "line": 32608, "text": "" }, { "line": 32609, "text": "---" }, { "line": 32610, "text": "" }, { "line": 32611, "text": "#### 5. 주요 실행 경로" }, { "line": 32612, "text": "" }, { "line": 32613, "text": "**시작:** `MessagingCoreAutoConfiguration:134` → `validateAll(registered)` → 프로파일별 15검사 + 중복 이름 + 사이클 그래프 → 실패 시 `IllegalArgumentException`으로 부팅 중단" }, { "line": 32614, "text": "" }, { "line": 32615, "text": "**발행:** `DefaultMessagePublisher` → `admission.admit(destination, bytes)` → 크기 → 종료 여부 → 목적지 슬롯 → 프로세스 permit → (발행) → `admission.complete(destination)`" }, { "line": 32616, "text": "" }, { "line": 32617, "text": "**재시도 판단:** `RetryContext(profile, deliveryMetadata, failure, capabilities, ...)` → `engine.decide(...)` → `RetryDecision` 5종 중 하나 — **이 경로는 출하 컨텍스트에서 호출되지 않는다**(§12.1)" }, { "line": 32618, "text": "" }, { "line": 32619, "text": "**DLQ:** `orchestrator.deadLetter(profile, delivery, failure, settlement)` → 헤더 6개 추가 → 발행 → CONFIRMED면 원본 정산 — **이 경로도 호출되지 않는다**(§12.1)" }, { "line": 32620, "text": "" }, { "line": 32621, "text": "---" }, { "line": 32622, "text": "" }, { "line": 32623, "text": "#### 6. 실패 경로와 복구/번역" }, { "line": 32624, "text": "" }, { "line": 32625, "text": "| 코드 | 예외 | 위치 | 조건 |" }, { "line": 32626, "text": "|---|---|---|---|" }, { "line": 32627, "text": "| `PAYLOAD_LIMIT_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 목적지 상한 초과 |" }, { "line": 32628, "text": "| `BATCH_COUNT_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 배치 항목 수 초과 |" }, { "line": 32629, "text": "| `BATCH_BYTES_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 배치 총 바이트 초과 |" }, { "line": 32630, "text": "| `SHUTTING_DOWN` | `MessageBackpressureException` | `MessagingAdmissionController` | 종료 중 |" }, { "line": 32631, "text": "| `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED` | `MessageBackpressureException` | 같음 | 목적지 천장 |" }, { "line": 32632, "text": "| `IN_FLIGHT_LIMIT_EXCEEDED` | `MessageBackpressureException` | 같음 | 프로세스 천장 |" }, { "line": 32633, "text": "| `ADMISSION_INTERRUPTED` | `MessageBackpressureException` | 같음 | 대기 중 인터럽트 |" }, { "line": 32634, "text": "| `DEAD_LETTER_NOT_CONFIGURED` | `MessagingConfigurationException` | `DeadLetterOrchestrator` | DLQ 미설정 목적지를 DLQ하려 함 |" }, { "line": 32635, "text": "" }, { "line": 32636, "text": "**배치 상한이 두 축인 이유**가 적혀 있다." }, { "line": 32637, "text": "" }, { "line": 32638, "text": "```java" }, { "line": 32639, "text": "// PayloadLimitGuard.java:16-18" }, { "line": 32640, "text": " *
Batches are limited by count and bytes. A count limit alone lets a handful of large" }, { "line": 32641, "text": " * messages exceed the broker's frame; a byte limit alone lets a huge number of tiny messages exceed" }, { "line": 32642, "text": " * its request timeout." }, { "line": 32643, "text": "```" }, { "line": 32644, "text": "" }, { "line": 32645, "text": "`checkBatch`가 각 항목에 대해 `checkPayload`도 부르므로 **개별 상한 · 개수 상한 · 총합 상한** 셋이 함께 적용된다." }, { "line": 32646, "text": "" }, { "line": 32647, "text": "프로파일 검증 실패는 `IllegalArgumentException`이다 — `MessagingException` 계층 밖이다. 시작 시점의 구성 오류이지 메시지 실패가 아니므로 일관적이다. 다만 `MessagingConfigurationException`(\"Raised at startup wherever possible\")이 존재하는데 쓰이지 않는다 — §17의 P3." }, { "line": 32648, "text": "" }, { "line": 32649, "text": "---" }, { "line": 32650, "text": "" } ], "numbered_context": "32335 | #### 4. 계약·불변식·상태 모델\n32336 | \n32337 | ##### 4.1 `DestinationProfileValidator.validate` — 15가지 모순 거절\n32338 | \n32339 | 프로파일 하나에 대해 순서대로 검사한다.\n32340 | \n32341 | | # | 거절 조건 | 왜 |\n32342 | |---:|---|---|\n32343 | | 1 | `retry.orderingImpact == PRESERVE && retry.reorders()` | 정책이 자기 자신과 모순 |\n32344 | | 2 | `isOrdered() && retry.orderingImpact == ALLOW_REORDER` | 순서 목적지가 재정렬 재시도를 허용 |\n32345 | | 3 | `payload.maxBytes > 8,388,608` | 절대 상한 초과 |\n32346 | | 4 | `claimCheckThreshold > payload.maxBytes` | 오프로드 문턱이 상한보다 큼 |\n32347 | | 5 | DLQ가 자기 자신을 가리킴 | 무한 루프 |\n32348 | | 6 | retry 목적지가 자기 자신을 가리킴 | 무한 루프 |\n32349 | | 7 | `orderingScope == KEY && !keyResolverConfigured` | 키 기반 순서인데 키 추출기 없음 |\n32350 | | 8 | `tier == M1 && consumer.manualSettlement` | M1이 수동 정산을 쓰면 정산 순서가 앱으로 새 나감 |\n32351 | | 9 | `AT_LEAST_ONCE && producer.confirmation == NONE` | 확인 없는 at-least-once는 보장이 아님 |\n32352 | | 10 | `production && topologyAutoCreate` | 운영에서 앱이 토폴로지를 만듦 |\n32353 | | 11 | `orderingScope == DESTINATION && consumer.concurrency > 1` | 목적지 전체 순서는 동시성 1을 요구 |\n32354 | | 12 | `isOrdered() && maxInFlightPerOrderingUnit > 1` | 순서 단위 안 동시 처리 |\n32355 | | 13 | `physical.isEmpty()` | 물리 주소 없음 |\n32356 | | 14 | `retry.mode == NONE && maxAttempts > 1` | 모드와 횟수 모순 |\n32357 | | 15 | `retry.mode == RETRY_DESTINATION && retryDestination.isEmpty()` | 목적지 없는 재시도 목적지 모드 |\n32358 | | 16 | `maxAttempts > 1 && mode != NONE && !deadLetter.enabled` | 재시도하는데 소진 후 갈 곳 없음 |\n32359 | \n32360 | 11번과 12번이 짝이다 — 전자는 목적지 수준 동시성, 후자는 순서 단위 안 동시성. 둘 다 있어야 \"순서 보장\"이 실제로 성립한다.\n32361 | \n32362 | ##### 4.2 `validateAll` — 두 종류의 간선을 하나의 그래프로\n32363 | \n32364 | 이 leaf에서 가장 정교한 판단이다.\n32365 | \n32366 | ```java\n32367 | // :131-136\n32368 | // One graph carrying both edge kinds, not two walks.\n32369 | //\n32370 | // Walking retry and dead-letter separately misses a cycle that alternates between them: A's\n32371 | // retry points at B and B's dead letter points back at A. Neither single-edge walk revisits a\n32372 | // node, both pass, and a poison message loops between the two destinations forever. The label\n32373 | // is kept per edge so the reported path still says which kind each hop was.\n32374 | ```\n32375 | \n32376 | `Edge` enum이 `RETRY`와 `DEAD_LETTER` 둘을 갖고, `walk`가 두 간선을 동시에 따라간다.\n32377 | \n32378 | **`onPath`가 전역 방문 집합이 아니라 현재 경로다.**\n32379 | \n32380 | ```java\n32381 | // :164-169\n32382 | *
{@code onPath} is the current walk rather than everything ever seen, so a diamond — two\n32383 | * destinations that both forward to a third — is not mistaken for a loop.\n32384 | walk(nextProfile, byName, new LinkedHashSet<>(onPath), branch);\n32385 | ```\n32386 | \n32387 | 각 분기마다 `new LinkedHashSet<>(onPath)`로 복사하므로 형제 분기가 서로의 방문 기록을 오염시키지 않는다. 다이아몬드(A→C, B→C)는 사이클이 아니고, 그것을 사이클로 판정하면 정상 구성이 부팅에 실패한다.\n32388 | \n32389 | 테스트가 두 경우를 각각 붙든다 — `aMixedEdgeCycleIsRejected`(retry/DLQ 교대 사이클 거절)와 `aSharedDeadLetterIsNotACycle`(다이아몬드 허용).\n32390 | \n32391 | 미등록 목적지도 여기서 잡힌다 — `anUnregisteredRetryDestinationIsRejected`.\n32392 | \n32393 | **비용 주의.** 매 분기마다 `onPath`와 `path`를 복사하므로 시간·공간이 경로 수에 지수적이다. 목적지 수가 수십 개인 정상 구성에서는 문제가 없지만, 이 성질이 어디에도 기록되지 않았다 — §17의 P3.\n32394 | \n32395 | ##### 4.3 `MessagingAdmissionController` — 순서가 계약이다\n32396 | \n32397 | ```java\n32398 | // :13-16\n32399 | *
Order matters and is fixed here rather than left to each adapter: the payload limit is checked\n32400 | * before a permit is taken. An oversized message can never succeed, so letting it occupy a\n32401 | * scarce in-flight permit while it is being rejected would let a stream of bad messages starve the\n32402 | * good ones.\n32403 | ```\n32404 | \n32405 | `admit`의 실제 순서:\n32406 | \n32407 | 1. `payloadGuard.checkPayload` → 초과면 `MessageTooLargeException`\n32408 | 2. `acceptingNewWork` 확인 → 종료 중이면 `MessageBackpressureException(\"SHUTTING_DOWN\")`\n32409 | 3. `reserve(destination)` — 목적지별 CAS 루프 → 초과면 `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED`\n32410 | 4. `limiter.tryAcquire()` — 프로세스 전역 semaphore, 유한 대기 → 실패면 목적지 슬롯 **반납 후** `IN_FLIGHT_LIMIT_EXCEEDED`\n32411 | \n32412 | **두 개의 천장이 있는 이유**도 명시돼 있다.\n32413 | \n32414 | ```java\n32415 | // :23-26\n32416 | *
Two ceilings, because one is not enough. The per-destination ceiling stops a single slow\n32417 | * downstream from consuming every permit in the process, and the process-wide ceiling stops the sum\n32418 | * of well-behaved destinations from exhausting memory — without it, adding a destination silently\n32419 | * raises what the process can be holding at once.\n32420 | ```\n32421 | \n32422 | **거절이 모호하지 않은 것이 설계의 핵심**이다 — \"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상태 발행 결과와 직접 연결된다.\n32423 | \n32424 | **세 가지 누수 방지**가 코드에 있다.\n32425 | \n32426 | ```java\n32427 | } catch (InterruptedException interrupted) {\n32428 | // The destination slot was taken a moment ago and no publish will use it, so it goes back\n32429 | // here: a slot leaked per interruption shrinks the destination's ceiling until it is zero.\n32430 | release(destination);\n32431 | ```\n32432 | \n32433 | ```java\n32434 | public void complete(String destination) {\n32435 | if (!release(destination)) {\n32436 | // A completion for a destination that holds nothing: either it names the wrong destination or\n32437 | // it is a second completion for the same publish. Returning the process permit anyway frees\n32438 | // one nobody took, and the process-wide ceiling then reads below what is really in flight and\n32439 | // admits more work than the process can carry.\n32440 | return;\n32441 | }\n32442 | limiter.release();\n32443 | }\n32444 | ```\n32445 | \n32446 | ```java\n32447 | // release():195-197\n32448 | // Drop the entry at zero, atomically, so the map does not accumulate one counter per\n32449 | // destination ever published to for the life of the process.\n32450 | perDestination.computeIfPresent(destination, (key, value) -> value.get() == 0 ? null : value);\n32451 | ```\n32452 | \n32453 | 세 번째는 장기 실행 누수 방지다 — 목적지 이름이 동적이면(예: 테넌트별) 맵이 무한히 자란다.\n32454 | \n32455 | `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.\"\n32456 | \n32457 | `release()`가 `availablePermits() < limit`를 확인하고 반납한다 — \"an unbalanced release would raise the ceiling silently and the limiter would stop limiting anything.\"\n32458 | \n32459 | ##### 4.4 `DefaultRetryDecisionEngine` — 고정된 판단 순서\n32460 | \n32461 | ```java\n32462 | // :10-15\n32463 | *
The order is fixed and evaluated top to bottom. Retryability is checked before the attempt\n32464 | * budget so that a deserialization failure is parked on its first delivery instead of being\n32465 | * replayed three more times against a payload that cannot change. The ordering-preserving strategy\n32466 | * is checked before the re-publishing one so that an ordered destination can never fall through to\n32467 | * a strategy that reorders it, even if both are technically configured.\n32468 | ```\n32469 | \n32470 | 실제 순서:\n32471 | \n32472 | | # | 조건 | 결정 |\n32473 | |---:|---|---|\n32474 | | 1 | `!isRetryable(...)` | `park(context)` — DLQ가 있으면 `DeadLetter`, `AT_MOST_ONCE`이고 DLQ 없으면 `Reject`, 그 외 `DeadLetter` |\n32475 | | 2 | `attempt >= maxAttempts` | `DeadLetter` |\n32476 | | 3 | `orderingImpact == PRESERVE && isOrdered() && capabilities.orderedStream()` | `PauseAndRetry(delay)` |\n32477 | | 4 | `mode == PAUSE_PARTITION` | `PauseAndRetry(delay)` |\n32478 | | 5 | `mode == RETRY_DESTINATION && ALLOW_REORDER && retryDestination.isPresent()` | `PublishToRetryDestination` |\n32479 | | 6 | `mode == INLINE \\|\\| BLOCKING` | `RetryInline(delay)` |\n32480 | | 7 | `mode == BROKER_DELAYED && capabilities.delayedDelivery()` | `PublishToRetryDestination` |\n32481 | | 8 | (그 외) | `DeadLetter` |\n32482 | \n32483 | **capability가 입력이다.**\n32484 | \n32485 | ```java\n32486 | // RetryContext.java:11-13\n32487 | *
Capabilities are an input rather than an assumption: the same policy resolves to\n32488 | * pause-and-retry on a partitioned Kafka topic and to a retry destination on a queue that cannot\n32489 | * pause, and the engine must not pick a strategy the adapter cannot actually carry out.\n32490 | ```\n32491 | \n32492 | 3번과 7번이 그것을 쓴다 — `orderedStream()`이 false면 pause 전략이 선택되지 않고, `delayedDelivery()`가 false면 `BROKER_DELAYED`가 8번으로 떨어져 DLQ가 된다. **조용한 성능 저하 대신 명시적 파킹**이다.\n32493 | \n32494 | `isRetryable`의 3단 판정:\n32495 | \n32496 | ```java\n32497 | if (policy.nonRetryableCategories().contains(category)) return false; // 명시적 제외 최우선\n32498 | if (policy.retryableCategories().contains(category)) return true; // 명시적 허용\n32499 | return descriptorRetryable && FailureDescriptorDefaults.retryable(category); // 둘 다 만족해야\n32500 | ```\n32501 | \n32502 | 마지막 줄이 **AND**다 — descriptor가 retryable이라 해도 카테고리 기본값이 false면 재시도하지 않는다. `RetryPolicy` 생성자가 두 집합의 교집합을 거절하므로(§4.5) 1·2번이 동시에 참일 수 없다.\n32503 | \n32504 | `FailureDescriptorDefaults`는 package-private 위임자다 — \"kept in one place so policy and engine cannot disagree\". 실제로는 `FailureDescriptor.defaultRetryable`(core-api)를 그대로 부른다. 한 줄 짜리 간접층이지만 정책 쪽에서 기본값을 바꿔야 할 때 바꿀 지점을 명시한다.\n32505 | \n32506 | ##### 4.5 `RetryPolicy` — 기본값이 \"재시도 없음\"\n32507 | \n32508 | ```java\n32509 | // :13-15\n32510 | *
Automatic retry is opt-in. The default for an ordinary destination is zero attempts, because a\n32511 | * retry that reorders a stream, multiplies a non-idempotent side effect, or hammers a throttled\n32512 | * downstream is worse than a visible failure.\n32513 | ```\n32514 | \n32515 | `none()`이 `mode=NONE, maxAttempts=1, delays=ZERO, multiplier=1.0, jitter=false, orderingImpact=PRESERVE, 두 집합 비어 있음`이다.\n32516 | \n32517 | 생성자 검증 여섯:\n32518 | - `maxAttempts >= 1` (첫 전달 포함)\n32519 | - 두 지연 음수 아님\n32520 | - `maxDelay >= initialDelay`\n32521 | - `multiplier >= 1.0`\n32522 | - 두 카테고리 집합을 `Set.copyOf`로 복사\n32523 | - **두 집합의 교집합 거절** — \"a failure category cannot be both retryable and non-retryable\"\n32524 | \n32525 | `reorders()`가 `RETRY_DESTINATION || BROKER_DELAYED`다 — 이 둘만 메시지를 원래 순서 단위 밖으로 옮긴다. `RetryMode` javadoc이 같은 사실을 반대편에서 적는다.\n32526 | \n32527 | ##### 4.6 `BackoffCalculator` — full jitter\n32528 | \n32529 | ```java\n32530 | // :11-14\n32531 | *
The delay is {@code min(maxDelay, initialDelay * multiplier^(attempt-1))}. Full jitter then\n32532 | * picks uniformly from {@code [0, delay]} rather than shaving a small percentage off. That matters\n32533 | * when a downstream recovers: without jitter every consumer that failed in the same second retries\n32534 | * in the same second, and the recovery is immediately undone by the retry storm.\n32535 | ```\n32536 | \n32537 | `randomFraction`이 `DoubleSupplier`로 주입 가능해서 테스트가 결정론적이다. 테스트가 두 각도를 본다 — `backoffGrowsExponentiallyAndIsCappedByMaxDelay`와 `fullJitterSpreadsRetriesAcrossTheWholeWindow`.\n32538 | \n32539 | `capped <= 0`이면 `Duration.ZERO`를 반환하므로 `initialDelay=0`인 정책에서 곱셈이 무의미해지는 경우를 방어한다.\n32540 | \n32541 | ##### 4.7 `DeadLetterOrchestrator` — 하나의 불변식\n32542 | \n32543 | ```java\n32544 | // :21-29\n32545 | *
This ordering is the single invariant that stops dead lettering from becoming data loss. If\n32546 | * the source were acknowledged first, a failed dead letter publish would leave no copy of the\n32547 | * message anywhere: the broker has released it and the dead letter destination never received it.\n32548 | * So the source stays unsettled on anything other than a confirmed publish, including an ambiguous\n32549 | * one, and the message is redelivered instead of disappearing.\n32550 | *\n32551 | *
An ambiguous dead letter publish therefore produces a duplicate rather than a loss. That is\n32552 | * the intended trade: the dead letter destination is read by humans who can spot a duplicate, and\n32553 | * it is the only side of the trade that is recoverable.\n32554 | ```\n32555 | \n32556 | 구현이 그 문장 그대로다.\n32557 | \n32558 | ```java\n32559 | .thenCompose(result -> {\n32560 | if (result.completion() != PublishCompletion.CONFIRMED) {\n32561 | return CompletableFuture.completedFuture(new DeadLetterResult(result, false));\n32562 | }\n32563 | return settleAfterConfirmation(result, settlement);\n32564 | });\n32565 | ```\n32566 | \n32567 | `CONFIRMED`가 아니면 — `REJECTED`든 `AMBIGUOUS`든 — 원본을 정산하지 않는다. `messaging-core-api`의 3상태가 여기서 실제 분기가 된다.\n32568 | \n32569 | `SourceSettlement`이 콜백으로 주입되는 이유도 적혀 있다 — \"so that the ordering constraint … lives in one place instead of being re-implemented by every adapter.\"\n32570 | \n32571 | ##### 4.8 `DeadLetterEnvelopeFactory` — 예약 헤더 6개, payload 불변\n32572 | \n32573 | ```java\n32574 | // :16-21\n32575 | *
The payload and the logical {@code messageId} are carried through untouched. That is what\n32576 | * makes a redrive a genuine replay rather than a new message: an Inbox downstream still recognises\n32577 | * it, and an operator can correlate the dead letter with the original publish.\n32578 | *\n32579 | *
Failure context is written into reserved headers, never into the payload, so redriving does\n32580 | * not require unwrapping a platform-specific structure.\n32581 | ```\n32582 | \n32583 | 쓰는 헤더: `FAILURE_CATEGORY`, `FAILURE_CODE`, `ORIGIN_DESTINATION`, `RETRY_ATTEMPT`, `FIRST_FAILURE_AT`, `LAST_FAILURE_AT`. 전부 `ReservedHeaders`의 상수를 쓴다(리터럴 아님).\n32584 | \n32585 | `MessageHeaders.platform(headers)`를 쓴다 — 예약 이름을 쓸 수 있는 factory다(`messaging-core-api` §4.8). 이것이 core-api의 두 factory 분리가 실제로 필요한 이유를 보여주는 유일한 production 사용처다.\n32586 | \n32587 | 여섯 헤더 중 `RETRY_ATTEMPT`·`FIRST_FAILURE_AT`·`LAST_FAILURE_AT`·`FAILURE_CATEGORY`·`FAILURE_CODE`·`ORIGIN_DESTINATION`은 전부 `CanonicalEnvelopeHeaders`가 \"platform bookkeeping\"으로 분류한 8개에 속한다 — 봉투 필드가 없어서 헤더로만 이동할 수 있는 것들이다. 두 leaf의 분류가 정확히 맞물린다.\n32588 | \n32589 | ##### 4.9 `DeadLetterMetadata` — 일부러 작다\n32590 | \n32591 | ```java\n32592 | // :11-13\n32593 | *
Deliberately small. A dead letter destination is read by operators, exported to tickets, and\n32594 | * often retained far longer than the source topic, so it holds a category, a code, and timing — not\n32595 | * a stack trace, not the exception message, and not the original headers.\n32596 | ```\n32597 | \n32598 | `messaging-core-api`의 `FailureDescriptor` javadoc(\"a DLQ is read by more people than the log is\")과 같은 판단을 다른 층에서 반복한다.\n32599 | \n32600 | **한 가지 관측.** `DeadLetterOrchestrator`가 `DeadLetterMetadata`를 만들 때 `firstFailureAt`과 `lastFailureAt`에 **같은 값**(`delivery.metadata().receivedAt()`)을 넣는다.\n32601 | \n32602 | ```java\n32603 | Instant failedAt = delivery.metadata().receivedAt();\n32604 | DeadLetterMetadata metadata = new DeadLetterMetadata(..., failedAt, failedAt);\n32605 | ```\n32606 | \n32607 | 즉 두 필드가 구분되어 선언됐지만 현재 유일한 생산 경로에서는 항상 같다. 첫 실패 시각을 이전 시도에서 이어받는 코드가 없다 — §17의 P3.\n32608 | \n32609 | ---\n32610 | \n32611 | #### 5. 주요 실행 경로\n32612 | \n32613 | **시작:** `MessagingCoreAutoConfiguration:134` → `validateAll(registered)` → 프로파일별 15검사 + 중복 이름 + 사이클 그래프 → 실패 시 `IllegalArgumentException`으로 부팅 중단\n32614 | \n32615 | **발행:** `DefaultMessagePublisher` → `admission.admit(destination, bytes)` → 크기 → 종료 여부 → 목적지 슬롯 → 프로세스 permit → (발행) → `admission.complete(destination)`\n32616 | \n32617 | **재시도 판단:** `RetryContext(profile, deliveryMetadata, failure, capabilities, ...)` → `engine.decide(...)` → `RetryDecision` 5종 중 하나 — **이 경로는 출하 컨텍스트에서 호출되지 않는다**(§12.1)\n32618 | \n32619 | **DLQ:** `orchestrator.deadLetter(profile, delivery, failure, settlement)` → 헤더 6개 추가 → 발행 → CONFIRMED면 원본 정산 — **이 경로도 호출되지 않는다**(§12.1)\n32620 | \n32621 | ---\n32622 | \n32623 | #### 6. 실패 경로와 복구/번역\n32624 | \n32625 | | 코드 | 예외 | 위치 | 조건 |\n32626 | |---|---|---|---|\n32627 | | `PAYLOAD_LIMIT_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 목적지 상한 초과 |\n32628 | | `BATCH_COUNT_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 배치 항목 수 초과 |\n32629 | | `BATCH_BYTES_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 배치 총 바이트 초과 |\n32630 | | `SHUTTING_DOWN` | `MessageBackpressureException` | `MessagingAdmissionController` | 종료 중 |\n32631 | | `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED` | `MessageBackpressureException` | 같음 | 목적지 천장 |\n32632 | | `IN_FLIGHT_LIMIT_EXCEEDED` | `MessageBackpressureException` | 같음 | 프로세스 천장 |\n32633 | | `ADMISSION_INTERRUPTED` | `MessageBackpressureException` | 같음 | 대기 중 인터럽트 |\n32634 | | `DEAD_LETTER_NOT_CONFIGURED` | `MessagingConfigurationException` | `DeadLetterOrchestrator` | DLQ 미설정 목적지를 DLQ하려 함 |\n32635 | \n32636 | **배치 상한이 두 축인 이유**가 적혀 있다.\n32637 | \n32638 | ```java\n32639 | // PayloadLimitGuard.java:16-18\n32640 | *
Batches are limited by count and bytes. A count limit alone lets a handful of large\n32641 | * messages exceed the broker's frame; a byte limit alone lets a huge number of tiny messages exceed\n32642 | * its request timeout.\n32643 | ```\n32644 | \n32645 | `checkBatch`가 각 항목에 대해 `checkPayload`도 부르므로 **개별 상한 · 개수 상한 · 총합 상한** 셋이 함께 적용된다.\n32646 | \n32647 | 프로파일 검증 실패는 `IllegalArgumentException`이다 — `MessagingException` 계층 밖이다. 시작 시점의 구성 오류이지 메시지 실패가 아니므로 일관적이다. 다만 `MessagingConfigurationException`(\"Raised at startup wherever possible\")이 존재하는데 쓰이지 않는다 — §17의 P3.\n32648 | \n32649 | ---\n32650 | ",
"headings": [
{
"line": 1,
"level": 1,
"text": "clean-architecture-backend-template — 상세 분석 (통합 정본)"
},
{
"line": 40,
"level": 2,
"text": "0. 이 문서를 읽는 법"
},
{
"line": 60,
"level": 2,
"text": "1. Project map — 숫자로 먼저"
},
{
"line": 62,
"level": 3,
"text": "1.1 빌드와 레지스트리"
},
{
"line": 81,
"level": 3,
"text": "1.2 가족별 분모와 출하 여부"
},
{
"line": 94,
"level": 3,
"text": "1.3 leaf별 규모 (main Java 기준 상위)"
},
{
"line": 119,
"level": 3,
"text": "1.4 이 표에서 읽어야 할 것"
},
{
"line": 168,
"level": 2,
"text": "2. Architectural boundaries — 무엇이 경계를 강제하는가"
},
{
"line": 173,
"level": 3,
"text": "2.1 강제 장치 목록"
},
{
"line": 189,
"level": 3,
"text": "2.2 `CleanArchitectureTest`의 규칙 14종"
},
{
"line": 212,
"level": 3,
"text": "2.3 검증된 경계 — 실제로 성립하는 것"
},
{
"line": 266,
"level": 3,
"text": "2.4 경계가 열려 있는 지점"
},
{
"line": 300,
"level": 2,
"text": "3. Representative execution paths"
},
{
"line": 302,
"level": 3,
"text": "3.1 HTTP 요청 — 출하 경로"
},
{
"line": 364,
"level": 3,
"text": "3.2 트랜잭션 — `application-core` 포트에서 PostgreSQL local timeout까지"
},
{
"line": 453,
"level": 3,
"text": "3.3 메시지 발행 — messaging 플랫폼"
},
{
"line": 494,
"level": 3,
"text": "3.4 gRPC — 채택 시점 경로"
},
{
"line": 518,
"level": 3,
"text": "3.5 알림 발송 — 논리적 수락과 provider 불확실성"
},
{
"line": 539,
"level": 2,
"text": "4. Data and state"
},
{
"line": 541,
"level": 3,
"text": "4.1 관계형 — `persistence-jpa` (605 파일 / main 350 / 27,744 LOC)"
},
{
"line": 654,
"level": 3,
"text": "4.2 문서형 — `persistence-mongo` (497 파일 / main 351 / 22,924 LOC)"
},
{
"line": 705,
"level": 3,
"text": "4.3 messaging 신뢰성 저장소 (`19` §7)"
},
{
"line": 757,
"level": 3,
"text": "4.4 fileserver / objectstorage / cache-redis"
},
{
"line": 788,
"level": 2,
"text": "5. Failure and operational behavior"
},
{
"line": 790,
"level": 3,
"text": "5.1 실패 분류 — 세 개의 계층"
},
{
"line": 824,
"level": 3,
"text": "5.2 관측 — 태그를 유한하게, 그리고 그 대가"
},
{
"line": 854,
"level": 3,
"text": "5.3 시작 검증기 — 법칙과 그 예외"
},
{
"line": 903,
"level": 3,
"text": "5.4 admin plane — 가장 잘 조립된 게이트"
},
{
"line": 939,
"level": 3,
"text": "5.5 gRPC 구현 층의 원자성 (`20` §7)"
},
{
"line": 1011,
"level": 2,
"text": "6. Tests and verification coverage"
},
{
"line": 1013,
"level": 3,
"text": "6.1 실행한 것"
},
{
"line": 1025,
"level": 3,
"text": "6.2 실행하지 않은 것과 그 이유"
},
{
"line": 1047,
"level": 3,
"text": "6.3 fail-closed 레인 규약"
},
{
"line": 1071,
"level": 3,
"text": "6.4 완전히 닫힌 게이트 하나 — messaging 인증 체인"
},
{
"line": 1111,
"level": 3,
"text": "6.5 evidence manifest — JPA의 R1/R2 분리"
},
{
"line": 1125,
"level": 3,
"text": "6.6 게이트가 통과하면서 아무것도 증명하지 않는 경우 — 14건"
},
{
"line": 1156,
"level": 2,
"text": "7. 이 저장소에서 반복된 네 가지 형태"
},
{
"line": 1160,
"level": 3,
"text": "7.1 형태 A — 판정하는 코드는 있고, 부르는 코드가 없다"
},
{
"line": 1203,
"level": 3,
"text": "7.2 형태 B — 게이트가 통과하면서 아무것도 증명하지 않는다"
},
{
"line": 1214,
"level": 3,
"text": "7.3 형태 C — 중복 장치에서 조립된 쪽이 약한 쪽이다"
},
{
"line": 1239,
"level": 3,
"text": "7.4 형태 D — 문서 드리프트, 그리고 그 방향"
},
{
"line": 1274,
"level": 3,
"text": "7.5 공시 스펙트럼 — 자기 미완성을 얼마나 말했는가"
},
{
"line": 1289,
"level": 3,
"text": "7.6 학습 전이 — messaging → grpc"
},
{
"line": 1308,
"level": 2,
"text": "8. Confirmed problems"
},
{
"line": 1310,
"level": 3,
"text": "8.1 P1 — 지금 출하되는 아티팩트에서 틀린 동작"
},
{
"line": 1349,
"level": 3,
"text": "8.2 P2 — 명확한 실패 시나리오를 가진 실질적 공백"
},
{
"line": 1392,
"level": 3,
"text": "8.3 심각도가 등급 때문에 낮아진 것"
},
{
"line": 1403,
"level": 2,
"text": "9. Reusable criteria and rules"
},
{
"line": 1452,
"level": 2,
"text": "10. Explicit project decisions"
},
{
"line": 1457,
"level": 3,
"text": "10.1 계약과 경계"
},
{
"line": 1468,
"level": 3,
"text": "10.2 실패와 불확실성"
},
{
"line": 1480,
"level": 3,
"text": "10.3 조립과 활성화"
},
{
"line": 1492,
"level": 3,
"text": "10.4 데이터와 경계값"
},
{
"line": 1506,
"level": 3,
"text": "10.5 증거와 게이트"
},
{
"line": 1523,
"level": 2,
"text": "11. Unresolved questions"
},
{
"line": 1564,
"level": 2,
"text": "12. Evidence index"
},
{
"line": 1581,
"level": 2,
"text": "13. Limits of this analysis"
},
{
"line": 1632,
"level": 2,
"text": "14. 사이클 2 — 18개 리프 재검증과 23개 리프 전수 통독"
},
{
"line": 1634,
"level": 3,
"text": "14.1 18개 리프 재검증"
},
{
"line": 1668,
"level": 3,
"text": "14.2 23개 리프 전수 통독"
},
{
"line": 1747,
"level": 2,
"text": "부록 A. 모듈 문서 지도"
},
{
"line": 1779,
"level": 2,
"text": "부록 B. 자주 쓸 명령"
},
{
"line": 1825,
"level": 2,
"text": "부록 C. 다시 읽는다면 이 순서"
},
{
"line": 1839,
"level": 1,
"text": "제2부 — 모듈 분석 전문"
},
{
"line": 1845,
"level": 2,
"text": "A00. project-overview"
},
{
"line": 1849,
"level": 3,
"text": "Project Overview"
},
{
"line": 1856,
"level": 4,
"text": "분석 기준 revision"
},
{
"line": 1867,
"level": 4,
"text": "최종 커버리지"
},
{
"line": 1884,
"level": 4,
"text": "Build and module map"
},
{
"line": 1939,
"level": 4,
"text": "Dependency direction"
},
{
"line": 1945,
"level": 4,
"text": "Runtime entry points"
},
{
"line": 1951,
"level": 4,
"text": "Persistence / messaging / external systems"
},
{
"line": 1955,
"level": 4,
"text": "Test topology"
},
{
"line": 1960,
"level": 4,
"text": "Configuration and operational surfaces"
},
{
"line": 1964,
"level": 4,
"text": "분석할 bounded scopes (계획 — 실제 문서 배치는 위 \"최종 커버리지\" 참조)"
},
{
"line": 1977,
"level": 4,
"text": "아직 단정하지 않는 것 (분석 시작 시점의 목록)"
},
{
"line": 1993,
"level": 2,
"text": "A01. domain-core"
},
{
"line": 1997,
"level": 3,
"text": "domain-core 상세 분석"
},
{
"line": 2000,
"level": 4,
"text": "SSOT identity — 2026-08-31 재검증"
},
{
"line": 2015,
"level": 4,
"text": "분석 범위와 결론 상태"
},
{
"line": 2026,
"level": 4,
"text": "1. Quantified scope map"
},
{
"line": 2028,
"level": 5,
"text": "Owned source"
},
{
"line": 2042,
"level": 4,
"text": "2. Coverage ledger"
},
{
"line": 2062,
"level": 4,
"text": "3. 이 모듈이 실제로 소유하는 것"
},
{
"line": 2064,
"level": 5,
"text": "관찰: 재사용 가능한 도메인 “내용”보다 도메인 모델링 계약을 소유한다"
},
{
"line": 2073,
"level": 4,
"text": "4. Identifier contract"
},
{
"line": 2075,
"level": 5,
"text": "`ResourceId