{ "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": 31172, "line": 31172 }, "current_section": { "heading": { "line": 31172, "level": 3, "text": "messaging-outbox-jdbc-postgresql 완전 해부" }, "start_line": 31172, "end_line": 32196, "text": "### messaging-outbox-jdbc-postgresql 완전 해부\n\n> 상태: COMPLETE\n> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n> 분석 범위: `src/messaging/messaging-outbox-jdbc-postgresql`\n> SSOT owner: `messaging-outbox-jdbc-postgresql`\n> integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n\n---\n\n#### 0. SSOT identity / 커버리지와 숫자 지도\n\n- registered leaf id: `messaging-outbox-jdbc-postgresql`\n- canonical state `analysisFile`: §A19-MESSAGING-OUTBOX-JDBC-POSTGRESQL\n- source path: `src/messaging/messaging-outbox-jdbc-postgresql`\n- registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-reliability-api\", \"messaging-policy\", \"messaging-observability\", \"messaging-admin-api\"]`\n- registry `runtime_memberships`: **`[\"app-bootstrap\"]`** — 배포된다\n\n##### 숫자\n\n| 항목 | 수 |\n|---|---:|\n| production Java 파일 | 13 |\n| test Java 파일 | 8 |\n| 전체 LOC (Java) | 4,416 |\n| SQL 마이그레이션 | **4** (V1~V4) |\n| 기타 리소스 | 1 (`debezium/outbox-event-router.properties`) |\n| test 메서드(실행 확인) | **76** (`EVD-313`) |\n| 그중 컨테이너 IT | **28** (Postgres 21 + admin journal 7) — **실제 실행됨** |\n| 선언된 의존 | project 5 + vendor 2(impl) + vendor 4(test) |\n| leaf 밖에서 import 하는 파일 | 3 (starter 2 + app-bootstrap 계약 테스트 1) |\n\n13개 production 타입:\n\n| 타입 | LOC | 역할 | src/main 생성 |\n|---|---:|---|---:|\n| `JdbcOutboxRepository` | 722 | `OutboxRepository` 의 PostgreSQL 구현 | **0** |\n| `JdbcAdminOperationJournal` | 326 | `AdminOperationJournal` 의 PostgreSQL 구현 | **0** |\n| `OutboxRelay` | 231 | 한 번의 릴레이 패스 | 1 (starter) |\n| `OutboxRelayWorker` | 199 | 패스를 스케줄링·구동 | 1 (starter) |\n| `DebeziumOutboxEventRouter` | 151 | CDC 커넥터 설정·헤더 매핑 | 1 (자기 참조) |\n| `OutboxRetryScheduler` | 134 | 백오프와 시도 예산 | 2 |\n| `OutboxEnvelopeFactory` | 124 | 행 → 발행 봉투 | **0** |\n| `OutboxProperties` | 81 | 설정과 그 사이의 불변식 | — |\n| `DebeziumOutboxRecordMapper` | 79 | CDC 가 낼 레코드의 모델 | **0** |\n| `DebeziumOutboxProfile` | 67 | 릴레이 모드 선택 + 상호배제 | 1 (자기 팩토리) |\n| `OutboxCleanupJob` | 58 | 보존기간 지난 PUBLISHED 행 삭제 | 1 (starter) |\n| `DebeziumMappedRecord` | 54 | CDC 출력 레코드 | — |\n| `OutboxRelayReport` | 50 | 패스 1회 결과 | — |\n\n##### Coverage ledger\n\n| scope/file group | count | disposition | reason |\n|---|---:|---|---|\n| `src/main/java/**` (13) | 13 | `FULL_READ` | 전 파일 본문 확인 |\n| `src/main/resources/db/migration/**` (4) | 4 | `FULL_READ` | V1~V4 전문 |\n| `src/main/resources/debezium/*.properties` (1) | 1 | `FULL_READ` | 43줄 전문 |\n| `src/test/java/**` (8) | 8 | `STRUCTURAL_ONLY` | 76개 테스트 메서드 인벤토리 전수 + 판정에 필요한 구간(대역 구현, purge·Debezium·이스케이프 단언)만 본문 확인. 전 파일 축자 통독은 하지 않았다 |\n| `build.gradle` | 1 | `FULL_READ` | 24줄 |\n| `build/**` | — | `EXCLUDED` | 빌드 산출물 (단, jshell 탐침에 컴파일된 클래스를 사용 — `EVD-314`) |\n\n`UNCLASSIFIED` 0.\n\n---\n\n#### 1. 모듈의 정체와 경계\n\n**트랜잭셔널 아웃박스의 PostgreSQL 구현**이다. 비즈니스 트랜잭션이 쓰고 릴레이가 배출한다. 여기에 더해 `messaging-admin-api` 의 파괴적 작업 저널 구현도 같이 산다 — 그 이유가 build.gradle 에 적혀 있다.\n\n```groovy\n// build.gradle:9-13\n// The destructive-operation journal lives here because it needs exactly what the outbox needs:\n// one relational database every replica can see, and a migration lane that already exists. The\n// contract it implements belongs to the admin API.\napi project(':messaging:messaging-admin-api')\n```\n\n이 리프의 축은 하나다: **\"모르는 것을 실패로 취급하지 않는다.\"**\n\n```java\n// OutboxRelay.java:17-27\n/**\n * Publishes outbox rows, treating an unknown outcome as retryable rather than final.\n *\n *
The relay's correctness rests on one rule: an ambiguous publish is retried under the same\n * message id. Minting a new id would turn a possibly-delivered message into a\n * definitely-second message, and no downstream deduplication could recover from it. Marking it\n * failed instead would lose a message the broker may already hold.\n *\n *
The relay therefore guarantees at-least-once publication and nothing more. Effectively-once\n * downstream effects come from pairing it with an Inbox — which is why the platform never\n * advertises the outbox as exactly-once.\n */\n```\n\n마지막 문장이 중요하다 — 이 리프가 자기 보장의 상한을 스스로 명시한다.\n\n경계: 브로커를 모른다(`MessagePublisher` 포트만 안다). 스프링 컨텍스트를 모른다(`spring-jdbc`/`spring-tx` 는 `implementation` 이며 트랜잭션 동기화 조회에만 쓴다). 배선은 starter 몫이다.\n\n---\n\n#### 2. 의존성과 런타임 배선\n\n```groovy\n// build.gradle 전문 (24줄)\napply plugin: 'java-library'\n\ndependencies {\n api project(':messaging:messaging-core-api')\n api project(':messaging:messaging-reliability-api')\n api project(':messaging:messaging-policy')\n api project(':messaging:messaging-observability')\n api project(':messaging:messaging-admin-api') // + 위 주석\n\n implementation 'org.springframework:spring-jdbc'\n implementation 'org.springframework:spring-tx'\n\n // Live-database certification. The reliability patterns are claims about transaction\n // boundaries and uniqueness constraints, and only a real database can settle them.\n testImplementation project(':messaging:messaging-testkit')\n testImplementation 'org.testcontainers:testcontainers-postgresql'\n testImplementation 'org.testcontainers:testcontainers-junit-jupiter'\n testImplementation 'org.postgresql:postgresql'\n}\n```\n\ntestcontainers 주석이 이 리프의 성격을 요약한다 — \"신뢰성 패턴은 트랜잭션 경계와 유일성 제약에 대한 주장이고, 그것을 결판낼 수 있는 것은 실제 데이터베이스뿐이다.\" 그리고 그 레인이 **실제로 돈다**(§10).\n\nstarter 가 만드는 빈(`EVD-312`):\n\n```java\n// MessagingReliabilityAutoConfiguration.java\n:63 new OutboxRetryScheduler(properties, Duration.ofMinutes(1))\n:89 new OutboxRelay(...)\n:109 new OutboxRelayWorker(relay, scheduler)\n:141 new OutboxCleanupJob(outbox, properties, 20)\n:170 new InboxCleanupJob(inbox, policy, 20)\n// MessagingOutboxRelayLifecycle.java\n:42 worker.start();\n```\n\nstarter 가 만들지 **않는** 것: `JdbcOutboxRepository`, `OutboxEnvelopeFactory`, `JdbcAdminOperationJournal`. 셋 다 애플리케이션이 `DataSource`/`ProducerId` 를 알고 직접 등록해야 한다. `AdminOperationJournal` 의 기본값은 `InMemoryAdminOperationJournal` 이며, 프로덕션 프로파일에서는 `MessagingAdminDurabilityValidator` 가 그것을 거부한다(§A19-MESSAGING-ADMIN-RUNTIME §4.4 참조).\n\n---\n\n#### 3. 패키지/컴포넌트 지도\n\n단일 패키지 `dev.caskeleton.messaging.outbox`. 두 갈래의 배출 경로가 있고, 한쪽만 살아 있다.\n\n```\n [비즈니스 트랜잭션]\n | JdbcOutboxRepository.append(record) — 호출자의 커넥션에 합류, 없으면 거절\n v\n messaging_outbox 테이블\n |\n +--- 경로 A: 폴링 릴레이 (배선됨)\n | OutboxRelayWorker.start() -> runPass()\n | -> OutboxRelay.runOnce(now)\n | claimBatch(owner, batchSize, lease, now, maxAttempts) FOR UPDATE SKIP LOCKED\n | -> OutboxEnvelopeFactory.toEnvelope(row)\n | -> MessagePublisher.publish(...)\n | -> markPublished / markAmbiguous / markExhausted / markFailed (펜싱 술어)\n | -> OutboxRetryScheduler.backoff(unproductivePasses)\n |\n +--- 경로 B: CDC 릴레이 (배선 안 됨 — §12.1)\n DebeziumOutboxProfile(CHANGE_DATA_CAPTURE, prefix, flag)\n -> DebeziumOutboxRecordMapper.map(row) -> DebeziumMappedRecord [모델]\n -> DebeziumOutboxEventRouter.connectorConfiguration(prefix) [Java 설정]\n debezium/outbox-event-router.properties [배포 설정 — 드리프트]\n\n messaging_admin_operation 테이블\n | JdbcAdminOperationJournal (begin/checkpoint/complete/fail/find)\n```\n\n---\n\n#### 4. 계약·불변식·상태 모델\n\n##### 4.1 스키마 — 마이그레이션 4개가 이력을 담고 있다\n\n**V1** — `message_id` 를 대리키가 아니라 기본키로 삼는다.\n\n```sql\n-- V1__messaging_outbox.sql:3-5\n-- Written by the business transaction, drained by the relay. message_id is the primary key rather\n-- than a surrogate: it is the logical identity the relay must preserve across every retry, and\n-- making it the key means no code path can accidentally publish the same row under a new id.\n```\n\n인덱스도 근거가 있다. 부분 인덱스인 이유(\"PUBLISHED rows accumulate until the retention job removes them\"), `IN_FLIGHT` 를 포함하는 이유(\"A relay that dies mid-publish leaves rows in that state ... omitting them here would strand those messages\").\n\n**V2** — 펜싱 토큰. 주석이 시나리오를 그대로 적는다.\n\n```sql\n-- V2__messaging_outbox_lease_fencing.sql:3-14\n-- V1 recorded only lease_expires_at, so a claim said when it would end and nothing about who held\n-- it. ... :\n-- relay A claims the row and calls the broker\n-- the lease expires; relay B reclaims it, publishes, and records PUBLISHED\n-- relay A finally times out and records AMBIGUOUS over the top\n-- The row is now claimable again and the message is published a second time. Making the lease\n-- longer than the publish timeout lowers the odds; it does not turn a GC pause, a scheduler stall\n-- or a slow broker into a data constraint. A token does ...\n```\n\n\"확률을 낮추는 것과 데이터 제약으로 만드는 것은 다르다\" — 이 리프에서 가장 좋은 한 줄이다. `EXHAUSTED` 상태 추가와 `next_attempt_at` 인덱스도 여기서 들어온다.\n\n**V3** — admin 저널. 복합 기본키 `(approval_ticket, plan_digest)` 의 근거가 `messaging-admin-api` 의 것과 동일하게 적혀 있다.\n\n**V4** — 정경 메타데이터 12컬럼. 왜 봉투 blob 이 아니라 컬럼인지가 명확하다.\n\n```sql\n-- V4:11-14\n-- Columns rather than a versioned envelope blob. Both round-trip the values faithfully; only one of\n-- them lets the relay answer an operator's questions. \"Which tenant is the backlog for\", \"which\n-- correlation is stuck\", \"which rows carry a schema this consumer cannot read\" are SELECTs against\n-- this table if the fields are columns, and payload decoding of the whole backlog if they are not.\n```\n\n그리고 밀반입 문제를 명시한다 — \"smuggled through the header map under the reserved `msg.*` names ... a row whose header map contains `msg.id` overwrites another message's identity on the wire\".\n\nDB 레벨 제약을 Java 와 이중으로 거는 이유도 적혀 있다.\n\n```sql\n-- V4:33-36\n-- The same bound TenantContext enforces in Java. Stated here as well because the relay, the CDC\n-- connector and any operator query read this table directly: a tenant slug that only the\n-- application validates is a tenant slug that an INSERT from anywhere else can violate ...\nALTER TABLE messaging_outbox ADD CONSTRAINT ck_messaging_outbox_tenant\n CHECK (tenant IS NULL OR tenant ~ '^[a-z0-9][a-z0-9._-]{0,63}$');\n```\n\n마지막으로 **생성 컬럼**이 두 릴레이의 합의를 하나로 만든다.\n\n```sql\n-- V4:55-66\n-- Debezium's Event Router takes the message key from a column. It was pointed at `destination`,\n-- which made the key the topic name — every message on a topic sharing one key, so every message\n-- landing on one partition, and keyed ordering meaning nothing. The polling relay meanwhile used\n-- the partition key when the row had one and the message id when it did not.\n--\n-- A generated column states that fallback once, in the place both relays read, instead of leaving\n-- it as a rule each of them implements separately and one of them gets wrong.\nALTER TABLE messaging_outbox\n ADD COLUMN routing_key TEXT GENERATED ALWAYS AS (COALESCE(partition_key, message_id::TEXT)) STORED;\n```\n\n**이 수정이 배포되는 properties 파일에는 도달하지 않았다.** §12.4(a).\n\n##### 4.2 `append` — 이 리프의 전체 메커니즘\n\n```java\n// JdbcOutboxRepository.java:37-46\n/**\n *
{@link #append} deliberately takes no connection of its own: it uses the one the caller is\n * already inside, which is the entire mechanism. An outbox row written on a separate connection\n * commits independently of the business change and reopens the window the pattern exists to close.\n */\n```\n\n그리고 그것을 **강제**한다.\n\n```java\n// :203-218\nrequireActiveTransaction(\"OUTBOX_TRANSACTION_REQUIRED\", \"appending to the outbox\");\nConnection connection = DataSourceUtils.getConnection(dataSource);\n```\n\n세 가지를 본다(`:228-245`): 활성 트랜잭션이 있는가 / 읽기 전용이 아닌가 / **이 DataSource 에 바인딩되어 있는가**. 세 번째가 특히 좋다 — 다른 DataSource 의 트랜잭션 안에서 append 하면 둘이 독립적으로 커밋된다.\n\n```java\n// :221-227\n/**\n *
Fail-fast rather than \"work anyway\": an append that silently runs outside the caller's\n * transaction produces exactly the ghost publication this repository exists to prevent, and it\n * produces it only on the rollback path — which is the path nobody exercises before production.\n */\n```\n\n`append(Connection, OutboxRecord)` 가 package-private 으로 내려간 이력도 적혀 있다(`:155-165`) — 예전에는 그것이 public 이었고 \"안전한 경로가 호출자가 알아야만 하는 경로\" 였다.\n\n##### 4.3 청구(claim)와 펜싱 — 두 세대가 공존한다\n\n**신세대** `CLAIM`(`:112-142`)은 소유자와 토큰을 기록하고 재시도 시계를 술어에 포함한다.\n\n```sql\nWHERE status IN ('PENDING', 'AMBIGUOUS', 'IN_FLIGHT')\n AND (lease_expires_at IS NULL OR lease_expires_at <= ?)\n -- The retry clock lives in the row, not in the relay's memory. Without these two\n -- predicates an AMBIGUOUS row became claimable again on the very next pass, so a\n -- broker outage meant the whole backlog was republished every poll interval and the\n -- configured attempt budget was a number nothing consulted.\n AND (next_attempt_at IS NULL OR next_attempt_at <= ?)\n AND attempts < ?\nORDER BY created_at LIMIT ? FOR UPDATE SKIP LOCKED\n...\nSET status='IN_FLIGHT', lease_expires_at=?, lease_owner=?, lease_token = o.lease_token + 1\n```\n\n토큰 증가가 청구와 같은 문장 안에서, 서버에서 일어난다 — \"two relays racing for the same row cannot receive the same number\"(`:106-111`).\n\n종결 쓰기는 전부 펜싱 술어를 단다.\n\n```java\n// :400-403\nString sql = setClause\n + \"WHERE message_id = ? AND status = 'IN_FLIGHT' AND lease_owner = ? AND lease_token = ?\";\n```\n\n그리고 0행을 삼키지 않는다.\n\n```java\n// :392-398\n/**\n *
The predicate carries the owner and the token as well as the id, so a relay that stalled\n * past its lease writes nothing: another relay's claim incremented the token, and this update\n * matches zero rows. Zero is reported rather than swallowed — a stale write means this worker may\n * have produced a duplicate publication, which is exactly what an operator needs to see.\n */\n```\n\n**구세대** `LEASE`(`:80-104`)와 `markPublished(MessageId)` / `markAmbiguous(MessageId, ...)` / `markFailed(MessageId, ...)` / `releaseLease(MessageId)` 는 소유자·토큰을 다루지 않는다. 그리고 남기는 행 상태가 다르다(§12.3(a)).\n\n##### 4.4 `OutboxRelay.runOnce` — 세 결과, 다섯 카운터\n\n```java\n// :169-218 (요약)\nswitch (result.completion()) {\n case CONFIRMED -> markPublished(lease, now) APPLIED? published++ : stale++\n case AMBIGUOUS -> {\n int spent = record.attempts() + 1;\n scheduler.parkReason(spent)\n .map(reason -> markExhausted(lease, reason, now))\n .orElseGet(() -> markAmbiguous(lease, code, now, scheduler.nextAttemptAt(now, spent)));\n APPLIED? (isExhausted(spent) ? exhausted++ : ambiguous++) : stale++\n }\n case REJECTED -> markFailed(lease, code, now) APPLIED? failed++ : stale++\n default -> throw new IllegalStateException(\"unhandled publish completion: \" + …);\n}\n```\n\n`spent = attempts + 1` 의 근거가 붙어 있다.\n\n```java\n// :179-181\n// The attempt this pass just spent. The claim predicate and the row both count attempts\n// after the transition, so the budget has to be judged on the same number the next claim\n// will read, or the last attempt is spent twice.\n```\n\n`EXHAUSTED` 를 별도 상태로 두는 근거도.\n\n```java\n// :186-188\n// A row that has spent its budget without an answer is parked under its own\n// status. Leaving it AMBIGUOUS makes it a row the claim predicate silently skips\n// forever, which looks identical to a healthy backlog on every dashboard.\n```\n\n`default ->` 분기의 존재 이유까지 적혀 있다(`:213-215`) — 새 completion 상수가 생기면 조용히 `IN_FLIGHT` 로 남기는 대신 크게 실패하도록.\n\n`OutboxRelayReport` 의 다섯 카운터가 각각 다른 운영 신호라는 것도 명시적이다(`:5-18`) — ambiguous 는 확인 문제, failed 는 계약/토폴로지 문제, staleLeases 는 \"중복 발행의 가시화된 형태\", exhausted 는 \"redrive 가 필요한 것\".\n\n##### 4.5 `OutboxProperties` — 설정 간의 관계를 생성자가 강제한다\n\n```java\n// :7-14\n/**\n *
The lease duration is the dangerous one. If it is shorter than the time a publish can take, a\n * second relay claims the row while the first is still waiting for a confirm, and the message is\n * published twice — under the same id, so consumers with an inbox survive it, but consumers without\n * one do not. The constructor therefore requires the lease to exceed the publish timeout by a\n * margin rather than merely to be positive.\n */\npublic static final double REQUIRED_LEASE_FACTOR = 2.0;\n```\n\n`leaseDuration >= publishTimeout * 2` 를 생성자가 강제하고 `OUTBOX_LEASE_TOO_SHORT` 로 거절한다. 기본값(30초 / 5초)이 그 규칙을 만족하는지 자체 테스트가 있다(`theDefaultsSatisfyTheirOwnRule`).\n\n##### 4.6 `OutboxEnvelopeFactory` — 정경 사실을 컬럼에서 되살린다\n\n```java\n// :20-37\n/**\n *
The identity comes from the row, never from a fresh mint. ...\n *\n *
So does everything else the envelope carries. This used to rebuild correlation, causation,\n * tenant, trace and the schema reference as empty, and read the routing keys out of the row's\n * header map — so a message that travelled through the outbox reached its consumer with less\n * provenance than one published directly, and the publish path became part of the message's\n * meaning. ...\n *\n *
Reserved header names in the row are refused outright, with no exception for the routing keys.\n * ... Now that the keys are columns, the rule is the simple one: an outbox row cannot write into\n * the platform's namespace at all.\n */\n```\n\n예약 이름을 만나면 `RESERVED_HEADER_IN_OUTBOX_ROW` 로 **던진다**(`:70-77`). 부재 값 처리도 정직하다 — `occurredAt` 이 없으면 `createdAt` 을 쓰고 그 이유를 적는다(\"the business transaction that wrote the row is the one the fact occurred in\", `:87-89`), `producer` 가 없으면 릴레이 소유 서비스로 귀속한다(`:91-92`).\n\n##### 4.7 `JdbcAdminOperationJournal` — DB 제약이 경쟁을 결판낸다\n\n```java\n// :22-32\n/**\n *
Lives beside the outbox because it needs the same thing the outbox needs and nothing more: one\n * relational database that every replica can see. The uniqueness that stops a second execution is\n * the primary key on {@code (approval_ticket, plan_digest)}, enforced by the database rather than\n * by a check-then-act in application code — two replicas that read \"no row\" at the same instant\n * would both proceed, and only the constraint makes exactly one of them win.\n */\n```\n\n`INSERT ... ON CONFLICT DO NOTHING` 이 1행이면 신규 청구, 0행이면 기존 행을 읽어 `refuseIfNotResumable` 후 `TAKE_OVER`. 인수 SQL 자체가 조건을 담는다.\n\n```sql\nWHERE approval_ticket = ? AND plan_digest = ? AND lease_token = ?\n -- Only a failed operation or one whose lease ran out may be taken over. A live STARTED row\n -- means another replica is executing it right now.\n AND (state = 'FAILED' OR lease_expires_at <= ?)\nRETURNING lease_token, items_completed\n```\n\n읽기와 인수 사이의 경쟁도 처리한다 — `RETURNING` 이 0행이면 \"another replica took it over between the read and this update\"(`:181-186`)로 거절.\n\n그리고 `items_completed` 는 `GREATEST` 로 단조 증가한다(`CHECKPOINT`/`SETTLE` SQL). 이것이 `DefaultMessagingAdminService` 가 낡은 값을 넘겨도 진행이 되돌아가지 않는 이유이며, 인터페이스가 요구하지 않는 성질이라는 점은 §A19-MESSAGING-ADMIN-RUNTIME §12.4(c)에 있다.\n\n---\n\n#### 5. 주요 실행 경로\n\n**쓰기** — 비즈니스 트랜잭션 → `append(record)` → 트랜잭션 3중 검사 → `DataSourceUtils.getConnection` → INSERT(22컬럼).\n\n**배출** — `MessagingOutboxRelayLifecycle` → `worker.start()` → `runPass()` → `relay.runOnce(now)` → 청구/발행/종결 → `scheduler.backoff(unproductive)` → 다음 패스 자기 스케줄링.\n\n**정리** — `OutboxCleanupJob.runOnce(now)` → `cutoff = now - retention` → `purgePublishedBefore(cutoff)` **무제한 오버로드** ×(최대 `maxBatches`, 실제로는 2회) → §12.1(a).\n\n**admin 저널** — `begin` → INSERT ON CONFLICT / TAKE_OVER → `checkpoint` × N → `complete` 또는 `fail`.\n\n---\n\n#### 6. 실패 경로와 복구/번역\n\n| 상황 | 처리 | 위치 |\n|---|---|---|\n| 트랜잭션 없이 append | `OUTBOX_TRANSACTION_REQUIRED` | `JdbcOutboxRepository:228-235` |\n| 읽기 전용 트랜잭션 | 〃 | `:236-239` |\n| 다른 DataSource 의 트랜잭션 | 〃 | `:240-246` |\n| append SQL 실패 | `OUTBOX_APPEND_FAILED` | `:196-199` |\n| 그 밖의 쿼리 실패 | `OUTBOX_QUERY_FAILED` | `:594-600` |\n| 종결 쓰기가 0행 | `OutboxTransitionResult.STALE_LEASE` (예외 아님) | `:413-415` |\n| 미지의 `PublishCompletion` | `IllegalStateException` | `OutboxRelay:216-217` |\n| 리스가 발행 타임아웃보다 짧음 | `OUTBOX_LEASE_TOO_SHORT` | `OutboxProperties:52-58` |\n| 행 헤더에 예약 이름 | `RESERVED_HEADER_IN_OUTBOX_ROW` | `OutboxEnvelopeFactory:70-77` |\n| 승인 이미 실행됨 | `APPROVAL_ALREADY_EXECUTED` | `JdbcAdminOperationJournal:117-124` |\n| 다른 런타임이 실행 중 | `ADMIN_OPERATION_IN_FLIGHT` | `:125-132`, `:181-186` |\n| 리스 상실 후 쓰기 | `ADMIN_OPERATION_LEASE_LOST` | `:263-271` |\n| 저널 도달 불가 | `ADMIN_JOURNAL_UNAVAILABLE` | `:206-208` 등 |\n| 두 릴레이 동시 활성 | `DUPLICATE_OUTBOX_RELAY` | `DebeziumOutboxProfile:54-59` (**호출부 0**) |\n| 릴레이 없음 | `NO_OUTBOX_RELAY` | `:60-65` (**호출부 0**) |\n\n`OutboxRelayWorker` 의 패스 실패 처리가 특히 명시적이다.\n\n```java\n// :184-190\n} catch (RuntimeException passFailed) {\n // A failed pass must not stop the loop: the scheduled task's own exception would cancel every\n // future pass, turning one broker error into a relay that never runs again. The failure is\n // counted and the next pass backs off as if nothing was published, which is true.\n```\n\n종료도 인터럽트가 아니라 드레인이다.\n\n```java\n// :99-106\n/**\n *
Draining rather than interrupting is the whole point. A pass killed between its claim and\n * its terminal write leaves rows {@code IN_FLIGHT} holding a lease, and nothing may touch them\n * until that lease expires — so an orderly shutdown would produce exactly the stall that a crash\n * produces.\n */\n```\n\n---\n\n#### 7. 트랜잭션·동시성·수명주기\n\n**두 가지 커넥션 획득 방식이 공존한다.**\n\n| 메서드 | 획득 | 효과 |\n|---|---|---|\n| `JdbcOutboxRepository.append(record)` | `DataSourceUtils.getConnection` | 호출자 트랜잭션에 합류 |\n| 그 외 전부 (`withConnection`) | `dataSource.getConnection()` + try-with-resources | 풀에서 새 커넥션, 독립 커밋 |\n| `JdbcAdminOperationJournal` 전 메서드 | `DataSourceUtils.getConnection` | 트랜잭션 있으면 합류 |\n\n릴레이 연산이 비즈니스 트랜잭션에 합류하면 안 되므로 `withConnection` 의 선택은 타당하다. 다만 그 판단이 주석으로 남아 있지 않고, 같은 리프의 저널은 반대 방식을 쓴다. §17 P3.\n\n**동시성 제어는 전부 데이터베이스에 있다.** `FOR UPDATE SKIP LOCKED`(청구), 서버측 토큰 증가, 펜싱 술어, `ON CONFLICT DO NOTHING`, 복합 기본키. Java 쪽에 락이 없다.\n\n**수명주기**: `OutboxRelayWorker` 는 데몬 스레드 1개, `setExecuteExistingDelayedTasksAfterShutdownPolicy(false)`, `start()` 멱등, `stop(deadline)` 드레인 후 실패 시 `shutdownNow()`. 셋 다 근거 주석이 있다(`:79-88`, `:92`, `:121-122`).\n\n---\n\n#### 8. 설정·기능 플래그·환경 차이\n\n| 값 | 출처 | 기본 | 비고 |\n|---|---|---|---|\n| `batchSize` | `OutboxProperties` | 100 | |\n| `leaseDuration` | 〃 | 30초 | `>= publishTimeout × 2` 강제 |\n| `publishTimeout` | 〃 | 5초 | |\n| `pollInterval` | 〃 | 500ms | 백오프의 기준 간격 |\n| `retentionAfterPublish` | 〃 | 3일 | |\n| `maxAttempts` | 〃 | 10 | 청구 술어의 `attempts < ?` |\n| `maxInterval` | starter `:63` | **1분** | `OutboxRetryScheduler.standard()` 는 5분 |\n| `maxBatches` | starter `:141` | **20 하드코딩** | 실질 무의미 (§12.1(a)) |\n| relay owner | `OutboxRelay.defaultOwner()` | `pid@uuid8` | 프로세스당 안정 |\n| CDC 모드 | `DebeziumOutboxProfile` | — | **어떤 프로퍼티에도 연결 안 됨** |\n\n`maxInterval` 이 두 값(1분 / 5분)으로 갈리는 것은 결함이 아니다 — starter 가 명시적으로 넘기고, `standard()` 는 호출자가 정책을 주지 않은 경우의 기본값이다.\n\n---\n\n#### 9. 퍼시스턴스/외부 시스템 세부\n\n**테이블 2개.** `messaging_outbox`(V1+V2+V4, 최종 34컬럼 + 생성 컬럼 1), `messaging_admin_operation`(V3, 11컬럼).\n\n**인덱스 4개**, 전부 부분 인덱스: `ix_..._claimable`, `ix_..._published_at`, `ix_..._next_attempt`, `ix_..._tenant_backlog`, 그리고 `ix_messaging_admin_operation_live`.\n\n**헤더 직렬화는 손으로 쓴 JSON** 이다.\n\n```java\n// :604-610\n/**\n *
Hand-rolled rather than pulled from a JSON library so this module keeps no codec dependency:\n * outbox headers are always flat string pairs, validated by {@code MessageHeaders} before they\n * ever reach here.\n */\n```\n\n이스케이프는 제어문자까지 처리하며 그 이력이 적혀 있다(`:630-636`). **그러나 역파싱의 종료 판정에 결함이 있다 — §12.1(b), `EVD-314` 에서 런타임 재현했다.**\n\n---\n\n#### 10. 테스트 레인과 실제 증명 범위\n\n`EVD-313`: `./gradlew :messaging:messaging-outbox-jdbc-postgresql:test --rerun-tasks` → **76 tests, 0 failures, 0 skipped**.\n\n| 클래스 | 수 | 종류 |\n|---|---:|---|\n| `OutboxPostgresIT` | **21** | 컨테이너 (Postgres) |\n| `DebeziumOutboxRecordMapperTest` | 16 | 단위 |\n| `OutboxOperationsTest` | 10 | 단위 (대역) |\n| `OutboxRelayTest` | 9 | 단위 (대역) |\n| `AdminOperationJournalPostgresIT` | **7** | 컨테이너 (Postgres) |\n| `OutboxEnvelopeFactoryTest` | 6 | 단위 |\n| `JdbcOutboxTransactionRequirementTest` | 4 | 단위 |\n| `OutboxRelayWorkerTest` | 3 | 단위 (스레드) |\n\n**컨테이너 레인 28건이 실제로 실행되었다** — `skipped=\"0\"` 이고 `tests>0`. `docker version` 은 client 29.1.3 / server 29.6.1 을 보고하고 `/var/run/docker.sock` 이 마운트되어 있다(`EVD-313`).\n\n> 이는 앞선 리프 문서들이 \"컨테이너 필요 — 미실행\" 으로 남긴 항목들(messaging-testkit 의 인증 레인 등)이 **실행 불가가 아니라 아직 실행하지 않은 것**임을 뜻한다. 해당 리프 분석 시 실행한다.\n\n`OutboxPostgresIT` 가 실제로 증명하는 것 중 강한 것들:\n\n- `theRowAndTheBusinessChangeCommitTogetherOrNotAtAll` — 아웃박스의 존재 이유 그 자체.\n- `aSupersededRelayCannotOverwriteTheOutcomeOfTheOneThatReplacedIt` / `twoRelaysClaimingConcurrentlyGetDisjointRowsAndDistinctTokens` / `anExpiryReclaimKeepsTheMessageIdAndAdvancesTheToken` — V2 펜싱의 3대 성질.\n- `anAmbiguousRowWaitsForItsBackoffBeforeItIsClaimedAgain` / `aRowOutOfAttemptsIsNotClaimedAgain` / `anExhaustedRowIsDistinctFromARejectedOne` — 재시도 시계가 행에 있다는 주장.\n- `everyCanonicalColumnRoundTripsThroughTheDatabase` / `theRelayCanSelectOneTenantsBacklogWithoutDecodingAPayload` / `theStoredRoutingKeyIsTheOneBothRelaysWouldUse` / `aTenantThatBreaksTheSlugBoundIsRefusedByTheDatabase` — V4 의 네 가지 주장.\n\n증명되지 **않는** 것:\n\n- 정리 작업이 실제로 나눠 지운다는 것 (§12.1(a)).\n- 역슬래시로 끝나는 헤더 값의 왕복 (§12.1(b)). `aHeaderValueWithControlCharactersRoundTrips` 는 제어문자만 본다.\n- 배포되는 `.properties` 가 Java 설정과 일치한다는 것 (§12.4(a)).\n- 두 릴레이 상호배제가 기동에서 강제된다는 것 (§12.1(c)).\n- 구세대 `MessageId` 기반 전이가 신세대와 같은 행 상태를 남긴다는 것 (§12.3(a)).\n\n---\n\n#### 11. 빌드/ArchUnit/CI 강제 지점\n\n이 리프 고유의 Gradle 게이트는 없다. 루트 공통 게이트만 적용된다. 컨테이너 IT 가 `test` 태그에서 제외되지 **않는다** — 즉 Docker 가 있는 환경에서는 일반 `test` 로 함께 돈다. `messaging-kafka` 의 인증 레인이 별도 태그로 분리된 것(그 리프 문서 §6 참조)과 대비된다.\n\n`app-bootstrap` 의 `MessagingCapabilityRegistryContractTest:61` 이 `\"debezium\"` 문자열을 능력 목록에 갖고 있다 — 이 리프의 CDC 경로가 플랫폼 능력으로 선언되어 있다는 뜻이다. 그 선언과 §12.1(c)의 미배선 사이의 대조는 §A18 재검증 시 다룬다.\n\n---\n\n#### 12. 실제 사용 여부와 negative-space probes\n\n##### 12.1 Public surface reachability\n\n**(a) [P1] 정리 작업이 무제한 DELETE 를 쏜다** (`EVD-311`, `EVD-294`)\n\n`OutboxRepository` 는 purge 오버로드를 둘 갖고, 구현도 둘 다 있다.\n\n```java\n// JdbcOutboxRepository.java:486-518 bounded\n// The CTE picks a bounded set of ids with SKIP LOCKED and deletes exactly those. An unbounded\n// DELETE holds locks and writes WAL in proportion to the whole backlog, which stalls the relay\n// and the business writes behind retention.\nWITH expired AS (SELECT message_id FROM messaging_outbox\n WHERE status='PUBLISHED' AND published_at < ?\n ORDER BY published_at LIMIT ? FOR UPDATE SKIP LOCKED)\nDELETE FROM messaging_outbox o USING expired e WHERE o.message_id = e.message_id\n\n// JdbcOutboxRepository.java:519-533 unbounded\nDELETE FROM messaging_outbox WHERE status = 'PUBLISHED' AND published_at < ?\n```\n\n호출자는 무제한 쪽을 부른다.\n\n```java\n// OutboxCleanupJob.java:48-55\nfor (int batch = 0; batch < maxBatches; batch++) {\n int deleted = outbox.purgePublishedBefore(cutoff); // 무제한\n removed += deleted;\n if (deleted == 0) break;\n}\n```\n\n1회차가 전체를 지우고 2회차가 0을 반환해 break 한다. `maxBatches=20`(starter `:141`)은 실질적으로 죽은 값이다.\n\n**발동 조건 보정(`EVD-316`).** 이 잡은 starter 빈이지만 **스케줄되지 않는다.** `MessagingReliabilityAutoConfiguration` 클래스 javadoc(`:32-34`)이 그렇게 설계했다고 적는다 — *\"The cleanup jobs are beans but no scheduler is registered for them. Scheduling is the application's decision: a service running several replicas usually wants one of them to run cleanup, and auto-registering a fixed-rate task would have every replica delete the same rows.\"* 따라서 기본 배포에서는 `runOnce` 가 한 번도 호출되지 않는다. 무제한 DELETE 는 **애플리케이션이 그 지시대로 잡을 스케줄하는 순간** 발동한다.\n\n테스트가 이것을 가리는 방식이 inbox 쪽과 동일하다.\n\n```java\n// OutboxOperationsTest.java:120-134 RecordingRepository\n@Override public int purgePublishedBefore(Instant publishedBefore, int limit) {\n return Math.min(purgePublishedBefore(publishedBefore), limit); // 전부 지우고 숫자만 깎는다\n}\n@Override public int purgePublishedBefore(Instant publishedBefore) {\n cutoffs.add(publishedBefore);\n return pass < deletions.size() ? deletions.get(pass++) : 0; // 스크립트\n}\n```\n\n`cleanupDeletesInBoundedBatchesRatherThanOneLongStatement` 는 `List.of(1000, 1000, 250)` 을 스크립트로 넣고 `removed == 2250`, `cutoffs.size() == 4` 를 단언한다. \"나눠 지운다\" 는 관측이 전적으로 대역이 만든 것이다. 실 DB 테스트(`OutboxPostgresIT:202`)도 무제한 쪽만 부른다.\n\n**(b) [P2] 역슬래시로 끝나는 헤더 값이 헤더 맵을 깨뜨린다** (`EVD-314` — 런타임 재현)\n\n```java\n// JdbcOutboxRepository.java:657-664\nprivate static int findClosingQuote(String text, int from) {\n for (int index = from; index < text.length(); index++) {\n if (text.charAt(index) == '\"' && text.charAt(index - 1) != '\\\\') { return index; }\n }\n return text.length();\n}\n```\n\n닫는 따옴표 판정이 \"바로 앞 글자가 역슬래시가 아니다\" 뿐이다. `escape` 가 값 끝의 역슬래시를 둘로 늘리므로, 닫는 따옴표 앞이 역슬래시가 되어 종료를 놓친다.\n\n컴파일된 클래스에 jshell + 리플렉션으로 `private static toJson`/`fromJson` 을 직접 호출해 재현했다(애플리케이션 소스 무수정).\n\n```\ncase 3 in={x-a=a\\} json={\"x-a\":\"a\\\\\"} out={x-a=a\\\"} EQUAL? false\ncase 4 in={x-a=a\\, x-b=second} json={\"x-a\":\"a\\\\\",\"x-b\":\"second\"} out={x-a=a\\\",, :=x-a, a\\\",=second} EQUAL? false\ncase 5 in={x-a=a\\b} json={\"x-a\":\"a\\\\b\"} out={x-a=a\\b} EQUAL? true\nnew HeaderValue(\"a\\\") -> OK, value=a\\\n```\n\n값이 **끝에** 역슬래시를 가질 때만 깨지고, 뒤에 헤더가 하나라도 더 있으면 맵 전체가 붕괴한다 — 키 `:` 와 키 `a\\\",` 가 생기고 `x-b` 는 사라진다. `HeaderValue` 는 제어문자만 금지하므로(`WireSafeText.require`) 이 입력은 플랫폼 자신의 검증 타입을 통과한다.\n\n**헤더 주입으로는 이어지지 않는다.** 어긋남이 키/값 경계를 밀어내므로 예약 이름은 키가 아니라 값이 되고, 쓰기 경로의 `MessageHeaders.application(...)` 이 애초에 예약 이름을 거절한다. 데이터 손상이지 취약점은 아니다.\n\n**(c) CDC 경로 전체가 배선되지 않았다** (`EVD-312`)\n\n```\ngit grep -n \"requireExactlyOneRelay|DebeziumOutboxProfile.polling|RelayMode\" -- src\n 전부 DebeziumOutboxProfile.java 자기 자신 + DebeziumOutboxRecordMapperTest\n```\n\n`DebeziumOutboxProfile` 클래스 javadoc(`:9-13`)은 \"the incompatibility is therefore enforced at startup instead of documented\" 라고 쓴다. 기동 시 `requireExactlyOneRelay` 를 부르는 코드가 없다. `DebeziumOutboxRecordMapper` 는 프로덕션에서 생성되지 않는다. 즉 두 릴레이가 동시에 켜지는 구성을 막는 주체가 없고, CDC 모드를 선택할 프로퍼티도 없다.\n\n**(d) 세 타입이 starter 밖 배선을 요구한다.** `JdbcOutboxRepository`(src/main 생성 0), `OutboxEnvelopeFactory`(0), `JdbcAdminOperationJournal`(0). 애플리케이션이 등록하지 않으면 릴레이 빈은 `OutboxRepository` 를 주입받지 못한다.\n\n##### 12.2 Conditional sibling comparison\n\n**대조군 1 — 배선된 것 vs 안 된 것.** `OutboxRelayWorker` javadoc(`:18-21`)이 과거 결함을 기록한다: \"The relay, its retry scheduler and the attempt budget all existed and nothing ever called `runOnce`. An outbox whose relay is never driven is the worst shape of all\". 그리고 그 수정이 실제로 배선까지 완료되어 있다(`MessagingOutboxRelayLifecycle:42 worker.start()`). **같은 리프 안에서 `requireExactlyOneRelay` 는 같은 상태로 남아 있다.**\n\n**대조군 2 — 커넥션 획득.** `append` 는 `DataSourceUtils`, 나머지는 raw `dataSource.getConnection()`, `JdbcAdminOperationJournal` 은 전부 `DataSourceUtils`. §7.\n\n**대조군 3 — inbox 와의 대칭.** `InboxCleanupJob`/`OutboxCleanupJob` 은 같은 형태이며 같은 결함을 갖는다(`EVD-294`). starter 가 둘 다 `maxBatches=20` 으로 만든다.\n\n**대조군 4 — 컨테이너 레인 정책.** 이 리프의 IT 는 `test` 에 포함되어 함께 돈다. `messaging-kafka` 의 인증 레인은 태그로 분리되고 Docker 가드도 없다. 두 정책이 공존하는 이유는 각 리프에 설명되어 있다(전자는 skip 가능, 후자는 skip 이 성공으로 보고되면 안 됨).\n\n##### 12.3 Duplicate mechanism sweep\n\n**(a) 전이 메서드가 두 세대이며 남기는 행 상태가 다르다.**\n\n| 항목 | 신세대 (`OutboxLease`) | 구세대 (`MessageId`) |\n|---|---|---|\n| 술어 | `message_id AND status='IN_FLIGHT' AND lease_owner=? AND lease_token=?` | `message_id` 만 |\n| `markPublished` SET | `status, published_at, lease_expires_at=NULL, lease_owner=NULL, next_attempt_at=NULL, attempts+1` | `status, published_at, lease_expires_at=NULL, attempts+1` |\n| `markAmbiguous` SET | `… lease_owner=NULL, last_failure_code, attempts+1, next_attempt_at=?` | `… last_failure_code, attempts+1` |\n| 결과 타입 | `OutboxTransitionResult` | `void` |\n| 청구 SQL | `CLAIM` (owner/token 기록) | `LEASE` (기록 안 함) |\n\n구세대로 PUBLISHED 된 행은 `lease_owner` 와 `next_attempt_at` 이 남는다. 그 컬럼들은 청구 술어와 부분 인덱스가 읽는 값이다. 두 세대 중 어느 것도 `@Deprecated` 가 아니라는 점은 §A19-MESSAGING-RELIABILITY-API 에 기록되어 있고, 여기서는 **상태 차이가 구체적으로 무엇인지**가 추가된다.\n\n**(b) Debezium 설정이 두 표현으로 존재한다.** §12.4(a).\n\n**(c) 손으로 쓴 JSON 코덱이 이 리프에도 있다.** `JdbcOutboxRepository.toJson/fromJson/escape/unescape` — `BrokerCertificationEvidence`(messaging-testkit), `InMemoryAdminOperationJournal.key`(messaging-admin-runtime)와 같은 계열의 선택이다. 각각 이유가 적혀 있고(\"이 모듈은 코덱 의존을 두지 않는다\"), 각각 다른 방식으로 구현되어 있다. 그중 하나에서 파싱 결함이 나왔다(§12.1(b)).\n\n##### 12.4 Documentation / measured-count drift\n\n**(a) [P2] 배포되는 커넥터 설정이 수정 이전 버전이다** (`EVD-310`)\n\n| 항목 | Java `connectorConfiguration` | `debezium/outbox-event-router.properties` |\n|---|---|---|\n| `event.key` | `routing_key` | **`destination`** |\n| `route.topic.replacement` | `topicPrefix + ${routedByValue}` | `${routedByValue}` |\n| `event.timestamp` | (없음) | `created_at` |\n| `additional.placement` 항목 수 | **15** | **4** |\n\nproperties 에 없는 11개: `created_at`, `destination`, `producer`, `occurred_at`, `correlation_id`, `causation_id`, `tenant`, `partition_key`, `ordering_key`, `traceparent`, `tracestate`, `baggage` — **V4 가 추가한 정경 메타데이터 전부**다.\n\n`DebeziumOutboxEventRouter` javadoc(`:21-26`)과 V4 주석(`:55-63`)이 둘 다 \"`destination` 을 키로 쓰면 한 토픽의 모든 메시지가 한 파티션에 몰린다\" 를 고쳤다고 말한다. 배포되는 파일에는 그 수정이 없다.\n\n그리고 두 표현을 잇는 것이 없다.\n\n```\ngit grep -rn \"outbox-event-router\" -- src\nexit 1 (출력 없음)\n```\n\nJava 쪽은 오히려 **의도적으로 견고한 테스트**가 지키고 있다.\n\n```java\n// DebeziumOutboxRecordMapperTest.java:154-162\nvoid theRoutedKeyIsNotTheTopicName() {\n // Literals, not the class's own constants: comparing a configuration value against the constant\n // that produced it asserts that the router agrees with itself, which it always will.\n assertThat(new DebeziumOutboxEventRouter().connectorConfiguration(\"prod.\"))\n .as(\"keying by destination puts every message on a topic onto one partition\")\n .containsEntry(\"transforms.outbox.table.field.event.key\", \"routing_key\")\n .containsEntry(\"transforms.outbox.route.by.field\", \"destination\");\n}\n```\n\n리터럴 대조까지 하는 테스트가 Java 를 지키고, 운영자가 배포하는 파일은 아무도 지키지 않는다.\n\n**(b) `aggregateIdAsPartitionKey` 는 커넥터에 도달할 수 없다.** `DebeziumOutboxRecordMapper` 는 그 플래그로 분기해 `Optional.empty()` 를 낼 수 있지만(`:70-73`), `connectorConfiguration(String topicPrefix)` 는 프로필을 받지 않고 `event.key` 를 항상 `routing_key` 로 고정한다. 기본값(`polling()` → `false`)에서 모델은 \"키 없음\" 을 예측하고 실제 커넥터는 키를 붙인다. 이 클래스의 존재 이유가 \"Produces what Debezium's Event Router will emit\"(`:11`)인 만큼 무해하지 않다.\n\n**(c) 백오프 지터가 복제본을 분산시키지 못한다** (`EVD-312`)\n\n```java\n// OutboxRetryScheduler.java:18-20\n/**\n *
Jitter is applied deterministically from the attempt count rather than randomly. Several relay\n * instances that all started at deployment time would otherwise synchronise their retries into a\n * thundering herd ...\n */\n// :107\nlong jittered = capped - (capped / 8) * (exponent % 3);\n```\n\n`jittered` 는 `exponent` 만의 함수이고 `exponent` 는 워커의 `unproductivePasses` 카운터다. 같은 시각에 배포되어 같은 브로커 장애를 겪는 복제본들은 같은 카운터를 갖게 되므로 **같은 backoff 를 계산한다.** 지터는 시도 횟수에 따라 값을 바꿀 뿐 인스턴스에 따라 바꾸지 않는다.\n\n(행 단위 백오프 `nextAttemptAt` 은 `next_attempt_at` 컬럼에 기록되므로 이 문제와 무관하다. javadoc 이 말하는 \"several relay instances … synchronise their retries\" 는 pass 단위 얘기다.)\n\n**(d) 선언 의존은 모두 사용된다.** 5개 project 의존 중 미사용 0건 — 지금까지 본 messaging 리프 중 처음이다.\n\n---\n\n#### 13. Git/설계 문서에서 확인한 변화와 실패 기록\n\nSQL 마이그레이션과 javadoc 이 함께 이력을 이룬다. 여덟 개의 \"이전에는 이랬다\".\n\n| 위치 | 기록된 과거 결함 |\n|---|---|\n| `V2:3-14` | 리스만으로는 stale relay 가 PUBLISHED 위에 AMBIGUOUS 를 덮어썼다 |\n| `V2:25-27` | \"V1's CHECK listed five states, so writing the sixth failed at the constraint rather than at review\" |\n| `V4:6-9` | 정경 필드가 갈 곳이 없어 유실되거나 `msg.*` 로 밀반입되었다 |\n| `V4:56-61` | Debezium 키가 `destination` 이라 한 토픽의 모든 메시지가 한 파티션에 몰렸다 |\n| `JdbcOutboxRepository:155-162` | `append(Connection, …)` 이 public 이었고 안전한 경로가 \"알아야만 하는\" 것이었다 |\n| `JdbcOutboxRepository:205-209` | `append` 가 풀에서 raw 커넥션을 열어 자동 커밋했다 — \"a business transaction that rolled back afterwards left the event behind\" |\n| `JdbcOutboxRepository:630-636` | 이스케이프가 역슬래시와 따옴표만 처리해 제어문자가 JSONB 를 깨뜨렸다 |\n| `OutboxRelay:117-123` | \"The scheduler was built by the auto-configuration and handed to nobody\" |\n| `OutboxRelayWorker:18-21` | \"The relay, its retry scheduler and the attempt budget all existed and nothing ever called `runOnce`\" |\n| `OutboxEnvelopeFactory:27-37` | 정경 필드를 빈 값으로 재구성하고 라우팅 키를 헤더 맵에서 읽었다 |\n| `CLAIM SQL:119-122` | AMBIGUOUS 행이 다음 패스에 바로 재청구되어 시도 예산이 아무도 안 읽는 숫자였다 |\n\n마지막 두 개(`OutboxRelay:117-123`, `OutboxRelayWorker:18-21`)가 이 저장소 전체에서 반복되는 결함 계열 — **\"만들어졌지만 아무도 부르지 않는다\"** — 을 명시적으로 이름 붙인 유일한 자리다. 그리고 이 리프에서는 그 둘이 실제로 고쳐졌다. §12.1(c)의 `requireExactlyOneRelay` 만 같은 상태로 남았다.\n\n---\n\n#### 14. 런타임·터미널 Evidence\n\n| ID | 파일 | 내용 |\n|---|---|---|\n| EVD-310 | `evidence/raw/310-debezium-properties-vs-java-drift.txt` | Java 설정 vs 배포 properties 항목별 대조, 헤더 매핑 15 vs 4, 연결 코드 0건 |\n| EVD-311 | `evidence/raw/311-outbox-cleanup-unbounded-confirmed.txt` | bounded/unbounded 두 SQL 전문, 호출자, starter 배선, 대역의 스크립트 |\n| EVD-312 | `evidence/raw/312-outbox-assembly-and-jitter.txt` | 조립 탐침 전수, 릴레이 기동 확인(대조군), CDC 미배선, 지터 분석 |\n| EVD-313 | `evidence/raw/313-messaging-outbox-jdbc-test-lane.txt` | 76건 통과 + **컨테이너 런타임 가용성 확인** |\n| EVD-314 | `evidence/raw/314-outbox-header-json-roundtrip-corruption.txt` | jshell 리플렉션 재현 5케이스 + 주입 불가 확인 + HeaderValue 수용 확인 |\n\n---\n\n#### 15. 명시적 설계 이유와 추론을 구분한 정리\n\n**코드/주석에 명시된 것**\n\n- `message_id` 를 기본키로 삼은 이유 (`V1:3-5`).\n- 부분 인덱스인 이유, `IN_FLIGHT` 를 청구 대상에 넣는 이유 (`V1:28-36`).\n- 펜싱 토큰이 필요한 이유와 리스 연장이 답이 아닌 이유 (`V2:3-14`).\n- `EXHAUSTED` 를 새 상태로 만든 이유 (`V2:25-27`).\n- 정경 메타데이터를 blob 이 아니라 컬럼으로 둔 이유 (`V4:11-14`).\n- tenant 제약을 DB 에도 거는 이유 (`V4:33-36`).\n- `routing_key` 를 생성 컬럼으로 만든 이유 (`V4:55-63`).\n- `append` 가 호출자 커넥션을 쓰는 이유, 그리고 fail-fast 인 이유 (`JdbcOutboxRepository:37-46, 221-227`).\n- `FOR UPDATE SKIP LOCKED` 의 이유 (`:44-46`).\n- 열 목록을 상수로 뽑은 이유 (`:64-71`).\n- 서버측 토큰 증가의 이유 (`:106-111`).\n- 재시도 시계를 행에 두는 이유 (`CLAIM:119-122`, `markAmbiguous:79-81`).\n- 0행을 STALE_LEASE 로 보고하는 이유 (`:392-398`).\n- bounded purge 가 필요한 이유 (`:164-166`) — 정작 호출되지 않는다.\n- 손으로 쓴 JSON 의 이유, 제어문자 이스케이프의 이유 (`:604-610, 630-636`).\n- 모호를 같은 id 로 재시도하는 이유, at-least-once 상한의 이유 (`OutboxRelay:17-27`).\n- `attempts + 1` 로 예산을 판정하는 이유 (`:179-181`).\n- `EXHAUSTED` 로 주차하는 이유 (`:186-188`).\n- `default ->` 분기의 이유 (`:213-215`).\n- 리스가 발행 타임아웃의 2배여야 하는 이유 (`OutboxProperties:10-14`).\n- pass 백오프와 row 백오프가 서로를 대체하지 않는 이유 (`OutboxRetryScheduler:11-16`).\n- 시프트를 쓰는 이유 (`:102-103`).\n- 데몬 스레드·자기 스케줄링·드레인 종료의 이유 (`OutboxRelayWorker:23-30, 79-88, 99-106`).\n- 패스 실패가 루프를 끝내면 안 되는 이유 (`:184-187`).\n- 정리가 PUBLISHED 만 지우는 이유 (`OutboxCleanupJob:10-14`).\n- 봉투 재구성 시 부재 값 처리의 이유 (`OutboxEnvelopeFactory:87-92`).\n- 예약 이름을 예외 없이 거절하는 이유 (`:33-37`).\n- 저널이 아웃박스 옆에 사는 이유 (`build.gradle:9-13`, `JdbcAdminOperationJournal:24-28`).\n- DB 제약이 경쟁을 결판내는 이유 (`:26-28`).\n- 읽기와 인수 사이 경쟁을 거절하는 이유 (`:181-184`).\n- 두 릴레이 동시 실행이 불가능해야 하는 이유 (`DebeziumOutboxProfile:9-13`, properties `:3-5`).\n- CDC 모델을 Java 로 만든 이유 (`DebeziumOutboxRecordMapper:11-18`).\n- schema subject 만 헤더가 없는 이유 (`DebeziumOutboxEventRouter:28-33`).\n- 부재를 빈 문자열로 쓰지 않는 이유 (`:105-107`).\n- 컨테이너 테스트가 필요한 이유 (`build.gradle:16-17`).\n\n**추론 (근거는 있으나 문서에 없음)**\n\n- `withConnection` 이 `DataSourceUtils` 를 쓰지 않는 것은 릴레이가 비즈니스 트랜잭션에 합류하면 안 되기 때문으로 보인다. 주석은 없고, 같은 리프의 저널은 반대로 한다.\n- `.properties` 가 갱신되지 않은 것은 누락으로 보인다 — Java 쪽 수정에 붙은 근거가 파일 쪽에도 그대로 적용되기 때문. 의도적 분기라는 표시는 없다.\n- `maxBatches=20` 하드코딩이 프로퍼티가 아닌 이유는 알 수 없다.\n- 구세대 `MessageId` 오버로드가 남아 있는 이유, 그리고 그것이 `lease_owner` 를 지우지 않는 것이 의도인지 누락인지.\n- `aggregateIdAsPartitionKey` 가 커넥터 설정에 전달되지 않는 것이 의도인지 누락인지.\n\n---\n\n#### 16. 확인한 것 / 확인하지 못한 것\n\n**확인한 것**\n\n- production 13파일 + SQL 4 + properties 1 전부 본문 확인.\n- 테스트 76건 전건 통과, **컨테이너 IT 28건이 실제로 실행됨** (`EVD-313`).\n- 이 환경에서 Docker 사용 가능 (client 29.1.3 / server 29.6.1, 소켓 마운트).\n- 정리 작업이 무제한 DELETE 를 쏜다는 것 — 두 SQL·호출자·starter 배선·대역 전부 확인 (`EVD-311`).\n- 역슬래시 종결 헤더 값의 왕복 손상 — **jshell 리플렉션으로 런타임 재현** (`EVD-314`).\n- Debezium 설정 두 표현의 항목별 차이와 연결 코드 0건 (`EVD-310`).\n- 조립 탐침 전수, 릴레이 기동 확인, CDC 미배선 (`EVD-312`).\n- 구·신 전이 메서드의 SET 절 차이.\n\n**확인하지 못한 것**\n\n- **테스트 8파일을 축자 통독하지 않았다.** 76개 메서드 이름 전수와 판정에 필요한 구간(대역 구현, purge/Debezium/이스케이프 단언)만 읽었다. 커버리지 원장에 `STRUCTURAL_ONLY` 로 기록했다.\n- §12.1(a)와 (c)의 결과를 실제 배포에서 관측하지 않았다. (a)는 SQL·호출자·배선으로, (c)는 호출부 부재로 도출했다.\n- 실제 Debezium 커넥터를 띄워 properties 의 동작을 확인하지 않았다. 두 설정의 차이는 텍스트 대조로 확인했다.\n- §12.4(c)의 지터 동기화를 다중 인스턴스로 재현하지 않았다. 함수가 `exponent` 만의 함수라는 것은 코드로 확인했다.\n- 구세대 전이 메서드가 실제로 호출되는 배포가 있는지 — 이 저장소에는 없다.\n\n---\n\n#### 17. 손볼 것\n\n##### P1 — 정리 작업이 무제한 DELETE 를 쏘고, 그것을 막는 오버로드는 호출되지 않는다\n\n`OutboxCleanupJob:50` 과 `InboxCleanupJob:56` 이 무제한 오버로드를 부른다. bounded 오버로드(`purgePublishedBefore(Instant, int)` / `purgeProcessedBefore(Instant, int)`)는 두 포트에 선언되고 두 구현에 구현되어 있으며 호출부가 **0건**이다(`EVD-294`, `EVD-311`).\n\n두 잡 모두 starter 빈이지만 스케줄러는 등록되지 않으며, 그것은 의도된 설계다(`EVD-316`). 즉 기본 배포에서는 아무 일도 일어나지 않고, 애플리케이션이 문서 지시대로 잡을 스케줄하는 순간 무제한 DELETE 가 발동한다. 잠재 결함이지 상시 결함이 아니다.\n\nbounded 구현의 주석이 결과를 명시한다: *\"An unbounded DELETE holds locks and writes WAL in proportion to the whole backlog, which stalls the relay and the business writes behind retention.\"* 3일치 백로그가 쌓인 테이블에서 이것은 릴레이 정지와 비즈니스 쓰기 정체를 뜻한다. 두 리프 모두 `runtime_memberships: [\"app-bootstrap\"]` 이고 두 잡 모두 starter 빈이다.\n\n수정은 한 줄이다 — `purgePublishedBefore(cutoff, batchLimit)`. `maxBatches` 가 그제서야 의미를 갖는다. 배치 크기는 새 파라미터가 필요하고, `OutboxProperties.batchSize`(100)를 재사용하거나 별도 값을 둔다.\n\n그리고 **회귀 테스트가 성립하려면 `RecordingRepository` 를 고쳐야 한다.** 현재 대역의 bounded 구현은 `Math.min(unbounded(), limit)` 로, 전부 지우고 숫자만 깎는다. 실제 저장소를 흉내 내려면 보유 행 목록을 갖고 `limit` 만큼만 제거해야 한다.\n\n##### P2 — 배포되는 Debezium 설정이 수정 이전 버전이다\n\n`src/main/resources/debezium/outbox-event-router.properties` 가 `event.key=destination` 을 유지하고 있다. 같은 저장소의 Java(`DebeziumOutboxEventRouter`), V4 마이그레이션 주석, 그리고 전용 테스트(`theRoutedKeyIsNotTheTopicName`)가 모두 그것이 결함이라고 말한다 — \"keying by destination puts every message on a topic onto one partition\".\n\n추가로 헤더 매핑이 15개 중 4개뿐이라, 이 파일로 배포한 CDC 는 tenant·correlation·causation·producer·trace·partition/ordering key 를 **전부 잃는다**. V4 가 존재하는 이유가 그 유실을 막는 것이다.\n\n두 가지가 필요하다.\n\n1. properties 를 Java 설정에서 생성하거나, 최소한 **둘을 대조하는 테스트**를 둔다. `DebeziumOutboxEventRouter.connectorConfiguration(\"\")` 의 항목이 파일에 모두 있는지 확인하는 테스트면 충분하다. 지금은 두 표현을 잇는 코드가 한 줄도 없다.\n2. `aggregateIdAsPartitionKey` 를 `connectorConfiguration` 에 전달하거나, 전달할 수 없다면 `DebeziumOutboxRecordMapper` 에서 그 분기를 제거한다. 지금은 모델이 커넥터가 하지 않을 일을 예측한다.\n\n##### P2 — 역슬래시로 끝나는 헤더 값이 헤더 맵을 깨뜨린다\n\n`findClosingQuote`(`:657-664`)가 이스케이프된 역슬래시를 고려하지 않는다. 값이 역슬래시로 끝나면 파서가 종료 지점을 놓치고, 뒤에 헤더가 더 있으면 맵 전체가 붕괴한다(`EVD-314`, 런타임 재현).\n\n`HeaderValue` 는 제어문자만 금지하므로 이 입력은 플랫폼 검증을 통과한다. 헤더 주입으로 이어지지는 않는다 — 예약 이름은 키가 아니라 값이 되고, 쓰기 경로가 예약 이름을 이미 거절한다.\n\n수정: 종료 판정을 \"앞의 연속된 역슬래시 개수가 짝수\" 로 바꾸거나, 인덱스를 앞에서부터 스캔하며 이스케이프 상태를 추적한다. 후자가 `unescape` 와 대칭이라 낫다.\n\n테스트는 `OutboxPostgresIT.aHeaderValueWithControlCharactersRoundTrips` 옆에 역슬래시 종결 케이스를 추가하면 된다 — 실 DB 왕복까지 확인할 수 있다.\n\n##### P2 — 두 릴레이 상호배제가 기동에서 강제되지 않는다\n\n`DebeziumOutboxProfile.requireExactlyOneRelay(...)` 는 프로덕션 호출부가 0건이다. 클래스 javadoc 은 \"the incompatibility is therefore enforced at startup instead of documented\" 라고 쓴다. properties 파일도 같은 경고를 반복한다(\"Enable this OR the in-process polling relay, never both\").\n\n같은 리프에 정확히 이 형태를 고친 선례가 있다 — `OutboxRelayWorker` 가 \"nothing ever called `runOnce`\" 를 고치고 `MessagingOutboxRelayLifecycle` 로 배선까지 마쳤다. 같은 방식으로 `MessagingReliabilityAutoConfiguration` 에 프로필 빈과 `InitializingBean` 검사를 두면 된다.\n\n배선하려면 CDC 모드를 선택할 프로퍼티도 필요하다 — 지금은 `DebeziumOutboxProfile` 을 만드는 설정 경로 자체가 없다.\n\n##### P3 — 구세대 전이 메서드가 신세대와 다른 행 상태를 남긴다\n\n`markPublished(MessageId, Instant)` 는 `lease_owner` 와 `next_attempt_at` 을 지우지 않는다. `markPublished(OutboxLease, Instant)` 는 지운다. `markAmbiguous`/`markFailed` 도 같다. 두 컬럼은 청구 술어와 부분 인덱스가 읽는 값이다.\n\n이 저장소에 구세대를 부르는 프로덕션 코드는 없다. 그러나 포트에 남아 있고 `@Deprecated` 도 아니므로, 외부 구현이나 향후 코드가 부를 수 있다. 최소한 `@Deprecated` 와 \"신세대를 쓰라\"는 문장이 필요하고, 더 나은 것은 제거다.\n\n##### P3 — 백오프 지터가 인스턴스를 분산시키지 못한다\n\n`jittered = capped - (capped/8) * (exponent % 3)` 는 `exponent` 만의 함수다. 같은 상태의 복제본들은 같은 값을 계산한다. javadoc 이 약속하는 \"thundering herd 방지\" 가 성립하지 않는다.\n\n`OutboxRelay` 가 이미 `defaultOwner()` 로 프로세스별 안정 식별자를 만든다(`pid@uuid8`). 그것의 해시를 지터에 섞으면 결정성(같은 프로세스에서 재현 가능)을 유지하면서 인스턴스 간 위상차가 생긴다. javadoc 이 난수를 거부한 이유(\"a random source would make the schedule impossible to test\")도 그대로 지켜진다.\n\n##### P3 — 커넥션 획득 방식이 리프 안에서 갈린다\n\n`JdbcOutboxRepository.append` 는 `DataSourceUtils`, 나머지는 raw `dataSource.getConnection()`, `JdbcAdminOperationJournal` 은 전부 `DataSourceUtils`. 릴레이 연산이 비즈니스 트랜잭션에 합류하면 안 된다는 판단은 타당하지만 어디에도 적혀 있지 않고, 같은 리프의 저널이 반대로 한다.\n\n`withConnection` 에 한 문장 — \"릴레이 연산은 호출자 트랜잭션에 합류하지 않는다\" — 을 붙이면 `append` 의 상세한 주석과 짝이 맞는다. 저널이 `DataSourceUtils` 를 쓰는 것이 의도인지도 확인이 필요하다.\n\n##### P3 — `maxBatches` 가 하드코딩이고 현재는 의미가 없다\n\nstarter 가 `20` 을 박아 넣는다(`:141`, `:170`). P1 을 고치기 전에는 이 값이 아무 일도 하지 않고, 고친 뒤에는 배치 크기와 함께 조정 대상이 된다. `OutboxProperties`/`InboxRetentionPolicy` 로 옮기는 것이 맞다.\n\n##### 확인된 설계(문제 아님)\n\n- **`append` 의 트랜잭션 3중 검사.** 활성/쓰기 가능/같은 DataSource 바인딩. 세 번째가 특히 드물고 정확하다.\n- **`message_id` 를 기본키로.** 어떤 코드 경로도 새 id 로 같은 행을 발행할 수 없다.\n- **펜싱 토큰을 서버측 한 문장에서 증가.** 두 릴레이가 같은 번호를 받을 수 없다.\n- **종결 쓰기의 owner+token 술어, 그리고 0행을 삼키지 않는 것.** stale 은 중복 발행의 가시화된 형태다.\n- **재시도 시계를 행에 기록.** 프로세스 메모리의 백오프는 재시작에 잊히고 복제본마다 따로 계산된다.\n- **`EXHAUSTED` 를 별도 상태로.** AMBIGUOUS 로 두면 대시보드에서 건강한 백로그와 구별되지 않는다.\n- **`spent = attempts + 1` 로 예산 판정.** 마지막 시도가 두 번 소비되지 않는다.\n- **`default ->` 에서 크게 실패하기.** 새 completion 이 조용히 `IN_FLIGHT` 를 남기지 않는다.\n- **리스 ≥ 발행 타임아웃 × 2 를 생성자가 강제.** 그리고 기본값이 자기 규칙을 만족하는지 테스트가 있다.\n- **정경 메타데이터를 컬럼으로.** 운영자 질문이 SELECT 가 된다.\n- **tenant 제약을 DB 에도.** 애플리케이션 밖 INSERT 를 막는다.\n- **`routing_key` 생성 컬럼.** 두 릴레이의 폴백 규칙을 한 곳에 고정한다 (Java 쪽 한정으로).\n- **봉투 재구성 시 예약 이름을 예외 없이 거절.** 라우팅 키가 컬럼이 된 뒤 규칙이 단순해졌다.\n- **부재를 빈 문자열로 쓰지 않기** (CDC 헤더, 봉투 양쪽).\n- **패스 실패가 루프를 끝내지 않게.** 스케줄된 작업의 예외는 이후 모든 패스를 취소한다.\n- **드레인 종료.** 인터럽트는 크래시와 같은 정체를 만든다.\n- **정리가 PUBLISHED 만 대상으로.** AMBIGUOUS·FAILED 는 사건 중 가장 필요한 행이다.\n- **저널을 아웃박스 옆에 두고 DB 제약으로 경쟁을 결판내기.** check-then-act 는 두 복제본을 모두 통과시킨다.\n- **읽기와 인수 사이의 경쟁을 `RETURNING` 0행으로 거절.**\n- **컨테이너 IT 를 `test` 에 포함.** 이 리프의 주장은 실제 DB 로만 결판난다.\n- **제어문자 이스케이프.** (역슬래시 종결 케이스는 §17 P2.)\n\n---\n\n#### Source anchors\n\n```\nsrc/messaging/messaging-outbox-jdbc-postgresql/build.gradle:1-24\nsrc/config/architecture/modules.json (messaging-outbox-jdbc-postgresql 항목)\n\nmain/resources/db/migration/messaging/V1__messaging_outbox.sql:1-41\nmain/resources/db/migration/messaging/V2__messaging_outbox_lease_fencing.sql:1-39\nmain/resources/db/migration/messaging/V3__messaging_admin_operation_journal.sql:1-37\nmain/resources/db/migration/messaging/V4__messaging_outbox_canonical_metadata.sql:1-66\nmain/resources/debezium/outbox-event-router.properties:1-43\n\nmain/…/JdbcOutboxRepository.java:37-47,50-62,64-78,80-104,106-142,151-153,155-200,203-218,221-246,249-267,270-306,308-318,319-339,340-350,352-370,371-379,381-389,391-421,424-432,434-444,446-454,456-469,471-484,486-518,519-533,535-545,547-591,593-601,603-628,630-655,657-664,666-700,702-722\nmain/…/OutboxRelay.java:17-28,40-44,66-72,90-99,117-123,145-221,223-230\nmain/…/OutboxRelayWorker.java:15-31,34-35,53-90,92-97,99-125,127-130,132-157,159-174,176-193\nmain/…/OutboxRetryScheduler.java:8-21,28-43,45-61,63-80,82-85,87-90,92-109,111-119,121-133\nmain/…/OutboxProperties.java:7-22,31-32,34-59,61-74\nmain/…/OutboxCleanupJob.java:7-15,22-36,38-57\nmain/…/OutboxRelayReport.java:3-26,30-49\nmain/…/OutboxEnvelopeFactory.java:20-38,43-50,52-104,106-123\nmain/…/JdbcAdminOperationJournal.java:22-33,36-72,79-82,84-120,122-140,142-160,162-192,217-235,237-245,247-262,263-271,273-283,285-318,320-324\nmain/…/DebeziumOutboxProfile.java:6-18,22-28,30-36,38-45,47-66\nmain/…/DebeziumOutboxEventRouter.java:10-34,37-47,49-85,87-136,138-150\nmain/…/DebeziumOutboxRecordMapper.java:10-27,30-32,34-43,45-68,70-78\nmain/…/DebeziumMappedRecord.java:8-23,25-34,36-53\n\ntest/…/OutboxPostgresIT.java (메서드 인벤토리 21건; 195-202, 503-521, 538-560 본문 확인)\ntest/…/OutboxOperationsTest.java:105-190 (RecordingRepository + cleanup 3건 본문 확인)\ntest/…/DebeziumOutboxRecordMapperTest.java:150-200 (본문 확인), 58-148 (메서드명)\ntest/…/OutboxRelayTest.java / OutboxRelayWorkerTest.java / OutboxEnvelopeFactoryTest.java /\ntest/…/JdbcOutboxTransactionRequirementTest.java / AdminOperationJournalPostgresIT.java (메서드 인벤토리)\n\nsrc/messaging/messaging-spring-boot-starter/.../MessagingReliabilityAutoConfiguration.java:63,89,109,140-141,169-170\nsrc/messaging/messaging-spring-boot-starter/.../MessagingOutboxRelayLifecycle.java:42\nsrc/messaging/messaging-core-api/.../header/HeaderValue.java:5-25\nsrc/messaging/messaging-inbox-jdbc-postgresql/.../InboxCleanupJob.java:56\nsrc/app-bootstrap/src/test/.../MessagingCapabilityRegistryContractTest.java:61\n```\n\n#### 기록이 인용한 원문 — `21234e38`\n\n> `tech-log-studio/` 의 기록이 인용한 코드가 이 문서에 없었다(`check_evidence --repo`). 인용한 줄은 고정 리비전 `21234e38` 에 실재하는 것을\n> `git grep -F` 로 확인했고, 없던 쪽은 이 문서였다. **옮겨 적은 문장이 아니라 저장소\n> 원문을 담는다** — 기록을 복사해 넣으면 옮겨 적기가 어긋나도 검사기가 더는 못 잡는다.\n\n`src/messaging/messaging-outbox-jdbc-postgresql/src/main/resources/db/migration/messaging/V2__messaging_outbox_lease_fencing.sql:16-23` — `concept-fenced-lease.md` 가 인용한다.\n\n```sql\n ADD COLUMN lease_owner VARCHAR(160),\n ADD COLUMN lease_token BIGINT NOT NULL DEFAULT 0,\n ADD COLUMN next_attempt_at TIMESTAMPTZ;\n\n-- Backfill is unnecessary for correctness — the default is 0 and the first claim increments it —\n-- but the constraint states the invariant the code depends on.\nALTER TABLE messaging_outbox\n ADD CONSTRAINT ck_messaging_outbox_lease_token CHECK (lease_token >= 0);\n```\n\n\n---\n" }, "previous_section": { "heading": { "line": 31168, "level": 2, "text": "A19-MESSAGING-OUTBOX-JDBC-POSTGRESQL. messaging-outbox-jdbc-postgresql" }, "start_line": 31168, "end_line": 31171, "text": "## A19-MESSAGING-OUTBOX-JDBC-POSTGRESQL. messaging-outbox-jdbc-postgresql\n\n> 분석 중에는 `messaging/MESSAGING-OUTBOX-JDBC-POSTGRESQL.md` 파일이었다. 1,002줄.\n" }, "next_section": { "heading": { "line": 32197, "level": 2, "text": "A19-MESSAGING-POLICY. messaging-policy" }, "start_line": 32197, "end_line": 33083, "text": "## A19-MESSAGING-POLICY. messaging-policy\n\n> 분석 중에는 `messaging/MESSAGING-POLICY.md` 파일이었다. 880줄.\n\n### messaging-policy 완전 해부\n\n> 상태: COMPLETE\n> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n> 분석 범위: `src/messaging/messaging-policy`\n> SSOT owner: `messaging-policy`\n> integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n\n---\n\n#### 0. SSOT identity / 커버리지와 숫자 지도\n\n- registered leaf id: `messaging-policy`\n- canonical state `analysisFile`: §A19-MESSAGING-POLICY\n- source path: `src/messaging/messaging-policy`\n- registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-schema-api\"]`\n- registry `runtime_memberships`: `[\"app-bootstrap\"]`\n\n##### 숫자\n\n| 항목 | 수 |\n|---|---:|\n| production Java 파일 | 26 |\n| production LOC | 1,738 |\n| 패키지 | 1 (`dev.caskeleton.messaging.policy`) |\n| test 파일 | 4 |\n| test 메서드(실행 확인) | 42 |\n| 외부(비프로젝트) 의존성 | **0** |\n\n26개 타입을 관심사로 나누면 다섯이다.\n\n| 축 | 타입 |\n|---|---|\n| **목적지 정의** (8) | `DestinationProfile` · `PhysicalDestination` · `SchemaPolicy` · `ProducerPolicy` · `ConsumerPolicy` · `PayloadPolicy` · `DeadLetterPolicy` · `CapabilityTier` |\n| **시작 검증** (1) | `DestinationProfileValidator` |\n| **발행 관문** (3) | `MessagingAdmissionController` · `PayloadLimitGuard` · `InFlightLimiter` |\n| **재시도 판단** (8) | `RetryPolicy` · `RetryMode` · `OrderingImpact` · `RetryContext` · `RetryDecision` · `RetryDecisionEngine` · `DefaultRetryDecisionEngine` · `BackoffCalculator` |\n| **DLQ 조정** (6) | `DeadLetterOrchestrator` · `DeadLetterEnvelopeFactory` · `DeadLetterMetadata` · `DeadLetterResult` · `SourceSettlement` · `FailureDescriptorDefaults`(package-private) |\n\n**다섯 축의 배선 상태가 서로 다르다.** 목적지 정의·시작 검증·발행 관문은 출하 컨텍스트에서 실제로 실행되고, 재시도 판단과 DLQ 조정은 bean으로 생성되지만 주입되는 곳이 없다(§12.1).\n\n##### Coverage ledger\n\n| scope/file group | count | disposition | reason |\n|---|---:|---|---|\n| `src/main/java/**` (26) | 26 | `FULL_READ` | 전 파일 본문 확인 |\n| `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 및 단언 확인 |\n| `build.gradle` | 1 | `FULL_READ` | 6줄 |\n| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |\n| `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n\n`UNCLASSIFIED` 0.\n\n---\n\n#### 1. 모듈의 정체와 경계\n\n이 leaf는 **\"이 목적지는 무엇을 약속하는가\"**를 소유한다. 브로커를 만지지 않고 벤더 의존성이 0이며, 대신 브로커 어댑터가 따라야 할 판단을 미리 계산한다.\n\n경계 규칙 하나가 leaf 전체를 관통한다: **모순은 부팅 실패여야 한다.**\n\n```java\n// DestinationProfileValidator.java:20-24\n *
Every rule here exists because the alternative is a production surprise. A profile that asks\n * for ordered delivery and configures a reordering retry does not fail on the happy path; it fails\n * the first time a message is retried, months later, in a way that looks like a data bug rather\n * than a configuration one. Making the contradiction a boot failure moves that discovery to the\n * deploy that introduced it.\n```\n\n두 번째 경계는 **물리 주소의 격리**다.\n\n```java\n// PhysicalDestination.java:9-11\n *
Held here and nowhere else. Once a topic name reaches application code the logical destination\n * stops being a boundary, and swapping the broker under a service becomes a code change instead of\n * a configuration change.\n```\n\n`messaging-core-api`의 `DestinationName`이 `:`과 `/`를 정규식으로 막고(그쪽 §4), 이 leaf가 물리 주소를 독점한다. 두 leaf가 같은 경계를 양쪽에서 지킨다.\n\n---\n\n#### 2. 의존성과 런타임 배선\n\n들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api). 둘 다 `api`인 이유는 `DestinationProfile`이 `DeliveryGuarantee`·`OrderingScope`·`DestinationKind`·`DestinationName`(core-api)와 `SchemaCompatibility`(schema-api)를 필드로 갖기 때문이다.\n\n나가는 것: `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`.\n\n**실제 배선 지점 넷**(전부 `messaging-spring-boot-starter/MessagingCoreAutoConfiguration`):\n\n| 지점 | 라인 | 상태 |\n|---|---:|---|\n| `new DestinationProfileValidator().validateAll(registered)` | 134 | **실행됨** — 시작 시 전체 registry 검증 |\n| `DestinationProfileValidator` bean | 145–146 | 생성 |\n| `MessagingAdmissionController` bean | 407–417 | 생성 + `DefaultMessagePublisher`·`MessagingEndpoint`·`MessagingShutdownLifecycle`이 주입받음 |\n| `RetryDecisionEngine` bean | 167–169 | 생성, **주입처 없음**(§12.1) |\n| `DeadLetterOrchestrator` bean | 179–181 | 생성, **주입처 없음**(§12.1) |\n\n이 leaf 자체는 Spring 주석을 갖지 않는다 — bean 정의는 전부 starter 쪽에 있다.\n\n---\n\n#### 3. 패키지/컴포넌트 지도\n\n```\n[목적지 정의]\nDestinationProfile ─┬─ PhysicalDestination (topic/exchange/routingKey/queue/subject/stream)\n ├─ SchemaPolicy (codec, compatibility, 닫힌 messageTypes)\n ├─ ProducerPolicy (confirmation, timeout, mandatoryRouting, idempotent)\n ├─ ConsumerPolicy (group, concurrency, maxInFlightPerUnit, prefetch, timeout, manual)\n ├─ RetryPolicy (mode, maxAttempts, backoff, orderingImpact, 카테고리 오버라이드)\n ├─ DeadLetterPolicy (enabled, destination, maxRedriveCount)\n ├─ PayloadPolicy (maxBytes, claimCheckThreshold)\n └─ CapabilityTier (M1/M2/M3)\n\n[시작 검증] DestinationProfileValidator\n ├─ validate(profile) : 프로파일 내부 모순 15가지\n └─ validateAll(profiles) : 중복 이름 + retry/DLQ 그래프 사이클\n\n[발행 관문] MessagingAdmissionController\n ├─ PayloadLimitGuard ── PayloadPolicy\n └─ InFlightLimiter (Semaphore, fair)\n\n[재시도 판단] RetryContext ─→ RetryDecisionEngine ─→ RetryDecision (sealed 5)\n ↑\n DefaultRetryDecisionEngine ── BackoffCalculator\n\n[DLQ 조정] DeadLetterOrchestrator ─┬─ DeadLetterEnvelopeFactory ── DeadLetterMetadata\n └─ SourceSettlement → DeadLetterResult\n```\n\n---\n\n#### 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\n#### 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\n#### 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\n#### 7. 트랜잭션·동시성·수명주기\n\n트랜잭션 없음.\n\n동시성 지점은 `MessagingAdmissionController`와 `InFlightLimiter` 둘이다.\n\n| 지점 | 도구 | 보호 |\n|---|---|---|\n| `perDestination` 맵 | `ConcurrentHashMap` + `computeIfAbsent` | 목적지 카운터 생성 |\n| 목적지 카운터 증가 | `AtomicInteger` CAS 루프 | 천장 초과 방지 |\n| 목적지 카운터 감소 | `getAndUpdate` + 0 clamp | 음수 방지 |\n| 맵 항목 제거 | `computeIfPresent` (원자) | 0일 때만 제거, 누수 방지 |\n| `acceptingNewWork` | `volatile boolean` | 종료 플래그 가시성 |\n| permit | `Semaphore(limit, true)` — **fair** | 유한 대기 보장 |\n| permit 반납 | `availablePermits() < limit` 확인 | 천장 상승 방지 |\n\n`reserve`의 CAS 루프는 `AtomicInteger.updateAndGet`으로 쓸 수 있었지만 조건부 실패(`return false`)가 필요해서 직접 루프를 돈다.\n\n`release`에 **미세한 경합**이 있다. `getAndUpdate`로 감소한 뒤 `computeIfPresent`로 0인 항목을 제거하는데, 그 사이에 다른 스레드가 `computeIfAbsent`로 같은 키를 만들고 증가시킬 수 있다. 그러면 `computeIfPresent`의 람다가 `value.get() == 0`을 보지 못해 제거하지 않는다 — 안전한 방향의 경합이다(누수가 아니라 제거 실패). 반대 순서였다면 살아 있는 카운터를 지울 수 있었다.\n\n`DefaultRetryDecisionEngine`·`BackoffCalculator`·`DeadLetterOrchestrator`·`DeadLetterEnvelopeFactory`·`DestinationProfileValidator`는 전부 상태가 없거나 불변이다. `BackoffCalculator`의 기본 생성자가 `ThreadLocalRandom`을 쓰므로 스레드 안전하다.\n\n수명주기 참여는 `stopAcceptingNewWork()` 하나이고, `MessagingShutdownLifecycle`(starter)이 종료 1단계에서 부른다(`messaging-transport-spi` §12.1 참조).\n\n---\n\n#### 8. 설정·기능 플래그·환경 차이\n\n설정 파일 없음. 상수와 기본값:\n\n| 상수/기본값 | 값 | 위치 |\n|---|---:|---|\n| `PayloadPolicy.DEFAULT_MAX_BYTES` | 1,048,576 | `PayloadPolicy.java:17` (public) |\n| `PayloadPolicy.HARD_MAX_BYTES` | 8,388,608 | `:20` (public) |\n| `ProducerPolicy.defaults()` | `REPLICATION_OR_PERSISTENCE_ACK`, 5초, mandatoryRouting, idempotent | `:34-37` |\n| `ConsumerPolicy.defaults(group)` | concurrency 1, maxInFlightPerUnit 1, prefetch 16, timeout 30초, manual false | `:52-54` |\n| `RetryPolicy.none()` | mode NONE, 1회, 지연 0, PRESERVE | `:115-125` |\n| `DeadLetterPolicy.disabled()` / `.to(dest)` | maxRedrive 0 / 1 | `:32-44` |\n\n**모든 기본값이 보수적이다** — 재시도 없음, 동시성 1, 순서 보존, 확인 최대, DLQ 비활성. 켜는 것이 명시적 선택이다.\n\n`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.\"\n\n`PayloadPolicy.DEFAULT_MAX_BYTES`는 이 저장소에서 1 MiB 상한을 선언하는 다섯 곳 중 하나이고 **정책 축의 자연스러운 주인**이다. 그런데 starter는 이것 대신 `JacksonMessageCodec.DEFAULT_MAX_BYTES`를 참조한다 — §A19-MESSAGING-SCHEMA-JSON §17이 소유한다.\n\n---\n\n#### 9. 퍼시스턴스/외부 시스템 세부\n\n없다. 브로커·DB·파일시스템을 만지지 않는다. `ThreadLocalRandom`(jitter)과 `Semaphore`가 유일한 런타임 자원이다.\n\n---\n\n#### 10. 테스트 레인과 실제 증명 범위\n\n레인: `./gradlew :messaging:messaging-policy:test`. **BUILD SUCCESSFUL, 42 tests, 0 skipped, 0 failures**.\n\n| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |\n|---|---:|---|---|\n| `DestinationProfileValidatorTest` | 13 | 순서/페이로드/DLQ 자기참조/키 리졸버/M1 수동정산/확인/토폴로지/DLQ 필요, **retry↔DLQ 교대 사이클 거절**, **다이아몬드 허용**, 미등록 목적지 거절 | 실제 부팅에서 이 검증이 호출되는지(→ starter가 부른다, §2) |\n| `MessagingAdmissionControllerTest` | 13 | permit 점유/반납, 초과 시 큐잉 대신 거절, backpressure가 retryable, 초과 payload가 permit을 안 먹음, 종료 시 기존 permit 유지, 불균형 반납이 천장을 못 올림, 한 목적지가 전부 못 먹음, 거절이 슬롯을 안 남김, 완료가 둘 다 반납, 미지 목적지 완료가 permit을 안 품, 이중 완료, 배치 두 축, 대기 후 승인 | 실제 부하에서의 공정성 |\n| `RetryDecisionEngineTest` | 10 | 역직렬화 실패 즉시 파킹, 인증/구성 실패 미재시도, 순서 Kafka는 pause, 소진은 DLQ, 비순서 재시도목적지 재발행, blocking은 inline, **지수 증가와 상한**, **full jitter 분포**, 프로파일 오버라이드, at-most-once DLQ 없으면 discard | **이 엔진이 production에서 호출되는지** |\n| `DeadLetterOrchestratorTest` | 6 | 확인 후에만 원본 정산, 모호하면 미정산, 거절되면 미정산, 헤더 부착 | **이 orchestrator가 production에서 호출되는지** |\n\n**두 축의 증명 성격이 다르다.** 검증기와 관문은 배선까지 확인되지만(§2), 재시도 엔진과 DLQ 조정자는 로직만 증명되고 배선은 §12.1이 부정한다. 테스트가 통과한다는 것이 그 코드가 실행된다는 뜻이 아닌 전형적인 예다.\n\n`MessagingAdmissionControllerTest`의 `as(...)` 문구들이 특히 구체적이다 — \"a slot leaked per refusal shrinks the destination's ceiling until it is zero\", \"a permit nobody took cannot be given back; doing so makes the ceiling fiction\". 각 테스트가 어떤 이전 결함을 붙들고 있는지 이름 자체가 말한다.\n\n---\n\n#### 11. 빌드/ArchUnit/CI 강제 지점\n\n| 게이트 | 이 leaf에 대해 |\n|---|---|\n| `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\",\"messaging-schema-api\"]` |\n| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |\n| vendor `api` 규칙 | 벤더 의존성 0 |\n| **부팅 검증** | `MessagingCoreAutoConfiguration:134`가 `validateAll`을 호출 — 이 leaf의 규칙이 실제로 부팅을 막는 유일한 지점 |\n| ArchUnit | 전용 규칙 없음 |\n\n§4.1의 15가지 규칙은 **ArchUnit이 아니라 런타임 시작 시점**에 강제된다. `verifyCleanArchitectureDependencies`가 빌드 타임에 도는 것과 대비된다. 잘못된 프로파일은 컴파일되고, 부팅에서 막힌다.\n\n---\n\n#### 12. 실제 사용 여부와 negative-space probes\n\n원시 증거: `evidence/raw/281-messaging-policy-retry-engine-unwired.txt`.\n\n> **방법 주의.** 이 절의 조립 판정은 `new ([a-zA-Z0-9_.]+\\.)? The relay's correctness rests on one rule: an ambiguous publish is retried under the same"
},
{
"line": 31257,
"text": " * message id. Minting a new id would turn a possibly-delivered message into a"
},
{
"line": 31258,
"text": " * definitely-second message, and no downstream deduplication could recover from it. Marking it"
},
{
"line": 31259,
"text": " * failed instead would lose a message the broker may already hold."
},
{
"line": 31260,
"text": " *"
},
{
"line": 31261,
"text": " * The relay therefore guarantees at-least-once publication and nothing more. Effectively-once"
},
{
"line": 31262,
"text": " * downstream effects come from pairing it with an Inbox — which is why the platform never"
},
{
"line": 31263,
"text": " * advertises the outbox as exactly-once."
},
{
"line": 31264,
"text": " */"
},
{
"line": 31265,
"text": "```"
},
{
"line": 31266,
"text": ""
},
{
"line": 31267,
"text": "마지막 문장이 중요하다 — 이 리프가 자기 보장의 상한을 스스로 명시한다."
},
{
"line": 31268,
"text": ""
},
{
"line": 31269,
"text": "경계: 브로커를 모른다(`MessagePublisher` 포트만 안다). 스프링 컨텍스트를 모른다(`spring-jdbc`/`spring-tx` 는 `implementation` 이며 트랜잭션 동기화 조회에만 쓴다). 배선은 starter 몫이다."
},
{
"line": 31270,
"text": ""
},
{
"line": 31271,
"text": "---"
},
{
"line": 31272,
"text": ""
},
{
"line": 31273,
"text": "#### 2. 의존성과 런타임 배선"
},
{
"line": 31274,
"text": ""
},
{
"line": 31275,
"text": "```groovy"
},
{
"line": 31276,
"text": "// build.gradle 전문 (24줄)"
},
{
"line": 31277,
"text": "apply plugin: 'java-library'"
},
{
"line": 31278,
"text": ""
},
{
"line": 31279,
"text": "dependencies {"
},
{
"line": 31280,
"text": " api project(':messaging:messaging-core-api')"
},
{
"line": 31281,
"text": " api project(':messaging:messaging-reliability-api')"
},
{
"line": 31282,
"text": " api project(':messaging:messaging-policy')"
},
{
"line": 31283,
"text": " api project(':messaging:messaging-observability')"
},
{
"line": 31284,
"text": " api project(':messaging:messaging-admin-api') // + 위 주석"
},
{
"line": 31285,
"text": ""
},
{
"line": 31286,
"text": " implementation 'org.springframework:spring-jdbc'"
},
{
"line": 31287,
"text": " implementation 'org.springframework:spring-tx'"
},
{
"line": 31288,
"text": ""
},
{
"line": 31289,
"text": " // Live-database certification. The reliability patterns are claims about transaction"
},
{
"line": 31290,
"text": " // boundaries and uniqueness constraints, and only a real database can settle them."
},
{
"line": 31291,
"text": " testImplementation project(':messaging:messaging-testkit')"
},
{
"line": 31292,
"text": " testImplementation 'org.testcontainers:testcontainers-postgresql'"
},
{
"line": 31293,
"text": " testImplementation 'org.testcontainers:testcontainers-junit-jupiter'"
},
{
"line": 31294,
"text": " testImplementation 'org.postgresql:postgresql'"
},
{
"line": 31295,
"text": "}"
},
{
"line": 31296,
"text": "```"
},
{
"line": 31297,
"text": ""
},
{
"line": 31298,
"text": "testcontainers 주석이 이 리프의 성격을 요약한다 — \"신뢰성 패턴은 트랜잭션 경계와 유일성 제약에 대한 주장이고, 그것을 결판낼 수 있는 것은 실제 데이터베이스뿐이다.\" 그리고 그 레인이 **실제로 돈다**(§10)."
},
{
"line": 31299,
"text": ""
},
{
"line": 31300,
"text": "starter 가 만드는 빈(`EVD-312`):"
},
{
"line": 31301,
"text": ""
},
{
"line": 31302,
"text": "```java"
},
{
"line": 31303,
"text": "// MessagingReliabilityAutoConfiguration.java"
},
{
"line": 31304,
"text": ":63 new OutboxRetryScheduler(properties, Duration.ofMinutes(1))"
},
{
"line": 31305,
"text": ":89 new OutboxRelay(...)"
},
{
"line": 31306,
"text": ":109 new OutboxRelayWorker(relay, scheduler)"
},
{
"line": 31307,
"text": ":141 new OutboxCleanupJob(outbox, properties, 20)"
},
{
"line": 31308,
"text": ":170 new InboxCleanupJob(inbox, policy, 20)"
},
{
"line": 31309,
"text": "// MessagingOutboxRelayLifecycle.java"
},
{
"line": 31310,
"text": ":42 worker.start();"
},
{
"line": 31311,
"text": "```"
},
{
"line": 31312,
"text": ""
},
{
"line": 31313,
"text": "starter 가 만들지 **않는** 것: `JdbcOutboxRepository`, `OutboxEnvelopeFactory`, `JdbcAdminOperationJournal`. 셋 다 애플리케이션이 `DataSource`/`ProducerId` 를 알고 직접 등록해야 한다. `AdminOperationJournal` 의 기본값은 `InMemoryAdminOperationJournal` 이며, 프로덕션 프로파일에서는 `MessagingAdminDurabilityValidator` 가 그것을 거부한다(§A19-MESSAGING-ADMIN-RUNTIME §4.4 참조)."
},
{
"line": 31314,
"text": ""
},
{
"line": 31315,
"text": "---"
},
{
"line": 31316,
"text": ""
},
{
"line": 31317,
"text": "#### 3. 패키지/컴포넌트 지도"
},
{
"line": 31318,
"text": ""
},
{
"line": 31319,
"text": "단일 패키지 `dev.caskeleton.messaging.outbox`. 두 갈래의 배출 경로가 있고, 한쪽만 살아 있다."
},
{
"line": 31320,
"text": ""
},
{
"line": 31321,
"text": "```"
},
{
"line": 31322,
"text": " [비즈니스 트랜잭션]"
},
{
"line": 31323,
"text": " | JdbcOutboxRepository.append(record) — 호출자의 커넥션에 합류, 없으면 거절"
},
{
"line": 31324,
"text": " v"
},
{
"line": 31325,
"text": " messaging_outbox 테이블"
},
{
"line": 31326,
"text": " |"
},
{
"line": 31327,
"text": " +--- 경로 A: 폴링 릴레이 (배선됨)"
},
{
"line": 31328,
"text": " | OutboxRelayWorker.start() -> runPass()"
},
{
"line": 31329,
"text": " | -> OutboxRelay.runOnce(now)"
},
{
"line": 31330,
"text": " | claimBatch(owner, batchSize, lease, now, maxAttempts) FOR UPDATE SKIP LOCKED"
},
{
"line": 31331,
"text": " | -> OutboxEnvelopeFactory.toEnvelope(row)"
},
{
"line": 31332,
"text": " | -> MessagePublisher.publish(...)"
},
{
"line": 31333,
"text": " | -> markPublished / markAmbiguous / markExhausted / markFailed (펜싱 술어)"
},
{
"line": 31334,
"text": " | -> OutboxRetryScheduler.backoff(unproductivePasses)"
},
{
"line": 31335,
"text": " |"
},
{
"line": 31336,
"text": " +--- 경로 B: CDC 릴레이 (배선 안 됨 — §12.1)"
},
{
"line": 31337,
"text": " DebeziumOutboxProfile(CHANGE_DATA_CAPTURE, prefix, flag)"
},
{
"line": 31338,
"text": " -> DebeziumOutboxRecordMapper.map(row) -> DebeziumMappedRecord [모델]"
},
{
"line": 31339,
"text": " -> DebeziumOutboxEventRouter.connectorConfiguration(prefix) [Java 설정]"
},
{
"line": 31340,
"text": " debezium/outbox-event-router.properties [배포 설정 — 드리프트]"
},
{
"line": 31341,
"text": ""
},
{
"line": 31342,
"text": " messaging_admin_operation 테이블"
},
{
"line": 31343,
"text": " | JdbcAdminOperationJournal (begin/checkpoint/complete/fail/find)"
},
{
"line": 31344,
"text": "```"
},
{
"line": 31345,
"text": ""
},
{
"line": 31346,
"text": "---"
},
{
"line": 31347,
"text": ""
},
{
"line": 31348,
"text": "#### 4. 계약·불변식·상태 모델"
},
{
"line": 31349,
"text": ""
},
{
"line": 31350,
"text": "##### 4.1 스키마 — 마이그레이션 4개가 이력을 담고 있다"
},
{
"line": 31351,
"text": ""
},
{
"line": 31352,
"text": "**V1** — `message_id` 를 대리키가 아니라 기본키로 삼는다."
},
{
"line": 31353,
"text": ""
},
{
"line": 31354,
"text": "```sql"
},
{
"line": 31355,
"text": "-- V1__messaging_outbox.sql:3-5"
},
{
"line": 31356,
"text": "-- Written by the business transaction, drained by the relay. message_id is the primary key rather"
},
{
"line": 31357,
"text": "-- than a surrogate: it is the logical identity the relay must preserve across every retry, and"
},
{
"line": 31358,
"text": "-- making it the key means no code path can accidentally publish the same row under a new id."
},
{
"line": 31359,
"text": "```"
},
{
"line": 31360,
"text": ""
},
{
"line": 31361,
"text": "인덱스도 근거가 있다. 부분 인덱스인 이유(\"PUBLISHED rows accumulate until the retention job removes them\"), `IN_FLIGHT` 를 포함하는 이유(\"A relay that dies mid-publish leaves rows in that state ... omitting them here would strand those messages\")."
},
{
"line": 31362,
"text": ""
},
{
"line": 31363,
"text": "**V2** — 펜싱 토큰. 주석이 시나리오를 그대로 적는다."
},
{
"line": 31364,
"text": ""
},
{
"line": 31365,
"text": "```sql"
},
{
"line": 31366,
"text": "-- V2__messaging_outbox_lease_fencing.sql:3-14"
},
{
"line": 31367,
"text": "-- V1 recorded only lease_expires_at, so a claim said when it would end and nothing about who held"
},
{
"line": 31368,
"text": "-- it. ... :"
},
{
"line": 31369,
"text": "-- relay A claims the row and calls the broker"
},
{
"line": 31370,
"text": "-- the lease expires; relay B reclaims it, publishes, and records PUBLISHED"
},
{
"line": 31371,
"text": "-- relay A finally times out and records AMBIGUOUS over the top"
},
{
"line": 31372,
"text": "-- The row is now claimable again and the message is published a second time. Making the lease"
},
{
"line": 31373,
"text": "-- longer than the publish timeout lowers the odds; it does not turn a GC pause, a scheduler stall"
},
{
"line": 31374,
"text": "-- or a slow broker into a data constraint. A token does ..."
},
{
"line": 31375,
"text": "```"
},
{
"line": 31376,
"text": ""
},
{
"line": 31377,
"text": "\"확률을 낮추는 것과 데이터 제약으로 만드는 것은 다르다\" — 이 리프에서 가장 좋은 한 줄이다. `EXHAUSTED` 상태 추가와 `next_attempt_at` 인덱스도 여기서 들어온다."
},
{
"line": 31378,
"text": ""
},
{
"line": 31379,
"text": "**V3** — admin 저널. 복합 기본키 `(approval_ticket, plan_digest)` 의 근거가 `messaging-admin-api` 의 것과 동일하게 적혀 있다."
},
{
"line": 31380,
"text": ""
},
{
"line": 31381,
"text": "**V4** — 정경 메타데이터 12컬럼. 왜 봉투 blob 이 아니라 컬럼인지가 명확하다."
},
{
"line": 31382,
"text": ""
},
{
"line": 31383,
"text": "```sql"
},
{
"line": 31384,
"text": "-- V4:11-14"
},
{
"line": 31385,
"text": "-- Columns rather than a versioned envelope blob. Both round-trip the values faithfully; only one of"
},
{
"line": 31386,
"text": "-- them lets the relay answer an operator's questions. \"Which tenant is the backlog for\", \"which"
},
{
"line": 31387,
"text": "-- correlation is stuck\", \"which rows carry a schema this consumer cannot read\" are SELECTs against"
},
{
"line": 31388,
"text": "-- this table if the fields are columns, and payload decoding of the whole backlog if they are not."
},
{
"line": 31389,
"text": "```"
},
{
"line": 31390,
"text": ""
},
{
"line": 31391,
"text": "그리고 밀반입 문제를 명시한다 — \"smuggled through the header map under the reserved `msg.*` names ... a row whose header map contains `msg.id` overwrites another message's identity on the wire\"."
},
{
"line": 31392,
"text": ""
},
{
"line": 31393,
"text": "DB 레벨 제약을 Java 와 이중으로 거는 이유도 적혀 있다."
},
{
"line": 31394,
"text": ""
},
{
"line": 31395,
"text": "```sql"
},
{
"line": 31396,
"text": "-- V4:33-36"
},
{
"line": 31397,
"text": "-- The same bound TenantContext enforces in Java. Stated here as well because the relay, the CDC"
},
{
"line": 31398,
"text": "-- connector and any operator query read this table directly: a tenant slug that only the"
},
{
"line": 31399,
"text": "-- application validates is a tenant slug that an INSERT from anywhere else can violate ..."
},
{
"line": 31400,
"text": "ALTER TABLE messaging_outbox ADD CONSTRAINT ck_messaging_outbox_tenant"
},
{
"line": 31401,
"text": " CHECK (tenant IS NULL OR tenant ~ '^[a-z0-9][a-z0-9._-]{0,63}$');"
},
{
"line": 31402,
"text": "```"
},
{
"line": 31403,
"text": ""
},
{
"line": 31404,
"text": "마지막으로 **생성 컬럼**이 두 릴레이의 합의를 하나로 만든다."
},
{
"line": 31405,
"text": ""
},
{
"line": 31406,
"text": "```sql"
},
{
"line": 31407,
"text": "-- V4:55-66"
},
{
"line": 31408,
"text": "-- Debezium's Event Router takes the message key from a column. It was pointed at `destination`,"
},
{
"line": 31409,
"text": "-- which made the key the topic name — every message on a topic sharing one key, so every message"
},
{
"line": 31410,
"text": "-- landing on one partition, and keyed ordering meaning nothing. The polling relay meanwhile used"
},
{
"line": 31411,
"text": "-- the partition key when the row had one and the message id when it did not."
},
{
"line": 31412,
"text": "--"
},
{
"line": 31413,
"text": "-- A generated column states that fallback once, in the place both relays read, instead of leaving"
},
{
"line": 31414,
"text": "-- it as a rule each of them implements separately and one of them gets wrong."
},
{
"line": 31415,
"text": "ALTER TABLE messaging_outbox"
},
{
"line": 31416,
"text": " ADD COLUMN routing_key TEXT GENERATED ALWAYS AS (COALESCE(partition_key, message_id::TEXT)) STORED;"
},
{
"line": 31417,
"text": "```"
},
{
"line": 31418,
"text": ""
},
{
"line": 31419,
"text": "**이 수정이 배포되는 properties 파일에는 도달하지 않았다.** §12.4(a)."
},
{
"line": 31420,
"text": ""
},
{
"line": 31421,
"text": "##### 4.2 `append` — 이 리프의 전체 메커니즘"
},
{
"line": 31422,
"text": ""
},
{
"line": 31423,
"text": "```java"
},
{
"line": 31424,
"text": "// JdbcOutboxRepository.java:37-46"
},
{
"line": 31425,
"text": "/**"
},
{
"line": 31426,
"text": " * {@link #append} deliberately takes no connection of its own: it uses the one the caller is"
},
{
"line": 31427,
"text": " * already inside, which is the entire mechanism. An outbox row written on a separate connection"
},
{
"line": 31428,
"text": " * commits independently of the business change and reopens the window the pattern exists to close."
},
{
"line": 31429,
"text": " */"
},
{
"line": 31430,
"text": "```"
},
{
"line": 31431,
"text": ""
},
{
"line": 31432,
"text": "그리고 그것을 **강제**한다."
},
{
"line": 31433,
"text": ""
},
{
"line": 31434,
"text": "```java"
},
{
"line": 31435,
"text": "// :203-218"
},
{
"line": 31436,
"text": "requireActiveTransaction(\"OUTBOX_TRANSACTION_REQUIRED\", \"appending to the outbox\");"
},
{
"line": 31437,
"text": "Connection connection = DataSourceUtils.getConnection(dataSource);"
},
{
"line": 31438,
"text": "```"
},
{
"line": 31439,
"text": ""
},
{
"line": 31440,
"text": "세 가지를 본다(`:228-245`): 활성 트랜잭션이 있는가 / 읽기 전용이 아닌가 / **이 DataSource 에 바인딩되어 있는가**. 세 번째가 특히 좋다 — 다른 DataSource 의 트랜잭션 안에서 append 하면 둘이 독립적으로 커밋된다."
},
{
"line": 31441,
"text": ""
},
{
"line": 31442,
"text": "```java"
},
{
"line": 31443,
"text": "// :221-227"
},
{
"line": 31444,
"text": "/**"
},
{
"line": 31445,
"text": " * Fail-fast rather than \"work anyway\": an append that silently runs outside the caller's"
},
{
"line": 31446,
"text": " * transaction produces exactly the ghost publication this repository exists to prevent, and it"
},
{
"line": 31447,
"text": " * produces it only on the rollback path — which is the path nobody exercises before production."
},
{
"line": 31448,
"text": " */"
},
{
"line": 31449,
"text": "```"
},
{
"line": 31450,
"text": ""
},
{
"line": 31451,
"text": "`append(Connection, OutboxRecord)` 가 package-private 으로 내려간 이력도 적혀 있다(`:155-165`) — 예전에는 그것이 public 이었고 \"안전한 경로가 호출자가 알아야만 하는 경로\" 였다."
},
{
"line": 31452,
"text": ""
},
{
"line": 31453,
"text": "##### 4.3 청구(claim)와 펜싱 — 두 세대가 공존한다"
},
{
"line": 31454,
"text": ""
},
{
"line": 31455,
"text": "**신세대** `CLAIM`(`:112-142`)은 소유자와 토큰을 기록하고 재시도 시계를 술어에 포함한다."
},
{
"line": 31456,
"text": ""
},
{
"line": 31457,
"text": "```sql"
},
{
"line": 31458,
"text": "WHERE status IN ('PENDING', 'AMBIGUOUS', 'IN_FLIGHT')"
},
{
"line": 31459,
"text": " AND (lease_expires_at IS NULL OR lease_expires_at <= ?)"
},
{
"line": 31460,
"text": " -- The retry clock lives in the row, not in the relay's memory. Without these two"
},
{
"line": 31461,
"text": " -- predicates an AMBIGUOUS row became claimable again on the very next pass, so a"
},
{
"line": 31462,
"text": " -- broker outage meant the whole backlog was republished every poll interval and the"
},
{
"line": 31463,
"text": " -- configured attempt budget was a number nothing consulted."
},
{
"line": 31464,
"text": " AND (next_attempt_at IS NULL OR next_attempt_at <= ?)"
},
{
"line": 31465,
"text": " AND attempts < ?"
},
{
"line": 31466,
"text": "ORDER BY created_at LIMIT ? FOR UPDATE SKIP LOCKED"
},
{
"line": 31467,
"text": "..."
},
{
"line": 31468,
"text": "SET status='IN_FLIGHT', lease_expires_at=?, lease_owner=?, lease_token = o.lease_token + 1"
},
{
"line": 31469,
"text": "```"
},
{
"line": 31470,
"text": ""
},
{
"line": 31471,
"text": "토큰 증가가 청구와 같은 문장 안에서, 서버에서 일어난다 — \"two relays racing for the same row cannot receive the same number\"(`:106-111`)."
},
{
"line": 31472,
"text": ""
},
{
"line": 31473,
"text": "종결 쓰기는 전부 펜싱 술어를 단다."
},
{
"line": 31474,
"text": ""
},
{
"line": 31475,
"text": "```java"
},
{
"line": 31476,
"text": "// :400-403"
},
{
"line": 31477,
"text": "String sql = setClause"
},
{
"line": 31478,
"text": " + \"WHERE message_id = ? AND status = 'IN_FLIGHT' AND lease_owner = ? AND lease_token = ?\";"
},
{
"line": 31479,
"text": "```"
},
{
"line": 31480,
"text": ""
},
{
"line": 31481,
"text": "그리고 0행을 삼키지 않는다."
},
{
"line": 31482,
"text": ""
},
{
"line": 31483,
"text": "```java"
},
{
"line": 31484,
"text": "// :392-398"
},
{
"line": 31485,
"text": "/**"
},
{
"line": 31486,
"text": " * The predicate carries the owner and the token as well as the id, so a relay that stalled"
},
{
"line": 31487,
"text": " * past its lease writes nothing: another relay's claim incremented the token, and this update"
},
{
"line": 31488,
"text": " * matches zero rows. Zero is reported rather than swallowed — a stale write means this worker may"
},
{
"line": 31489,
"text": " * have produced a duplicate publication, which is exactly what an operator needs to see."
},
{
"line": 31490,
"text": " */"
},
{
"line": 31491,
"text": "```"
},
{
"line": 31492,
"text": ""
},
{
"line": 31493,
"text": "**구세대** `LEASE`(`:80-104`)와 `markPublished(MessageId)` / `markAmbiguous(MessageId, ...)` / `markFailed(MessageId, ...)` / `releaseLease(MessageId)` 는 소유자·토큰을 다루지 않는다. 그리고 남기는 행 상태가 다르다(§12.3(a))."
},
{
"line": 31494,
"text": ""
},
{
"line": 31495,
"text": "##### 4.4 `OutboxRelay.runOnce` — 세 결과, 다섯 카운터"
},
{
"line": 31496,
"text": ""
},
{
"line": 31497,
"text": "```java"
},
{
"line": 31498,
"text": "// :169-218 (요약)"
},
{
"line": 31499,
"text": "switch (result.completion()) {"
},
{
"line": 31500,
"text": " case CONFIRMED -> markPublished(lease, now) APPLIED? published++ : stale++"
},
{
"line": 31501,
"text": " case AMBIGUOUS -> {"
},
{
"line": 31502,
"text": " int spent = record.attempts() + 1;"
},
{
"line": 31503,
"text": " scheduler.parkReason(spent)"
},
{
"line": 31504,
"text": " .map(reason -> markExhausted(lease, reason, now))"
},
{
"line": 31505,
"text": " .orElseGet(() -> markAmbiguous(lease, code, now, scheduler.nextAttemptAt(now, spent)));"
},
{
"line": 31506,
"text": " APPLIED? (isExhausted(spent) ? exhausted++ : ambiguous++) : stale++"
},
{
"line": 31507,
"text": " }"
},
{
"line": 31508,
"text": " case REJECTED -> markFailed(lease, code, now) APPLIED? failed++ : stale++"
},
{
"line": 31509,
"text": " default -> throw new IllegalStateException(\"unhandled publish completion: \" + …);"
},
{
"line": 31510,
"text": "}"
},
{
"line": 31511,
"text": "```"
},
{
"line": 31512,
"text": ""
},
{
"line": 31513,
"text": "`spent = attempts + 1` 의 근거가 붙어 있다."
},
{
"line": 31514,
"text": ""
},
{
"line": 31515,
"text": "```java"
},
{
"line": 31516,
"text": "// :179-181"
},
{
"line": 31517,
"text": "// The attempt this pass just spent. The claim predicate and the row both count attempts"
},
{
"line": 31518,
"text": "// after the transition, so the budget has to be judged on the same number the next claim"
},
{
"line": 31519,
"text": "// will read, or the last attempt is spent twice."
},
{
"line": 31520,
"text": "```"
},
{
"line": 31521,
"text": ""
},
{
"line": 31522,
"text": "`EXHAUSTED` 를 별도 상태로 두는 근거도."
},
{
"line": 31523,
"text": ""
},
{
"line": 31524,
"text": "```java"
},
{
"line": 31525,
"text": "// :186-188"
},
{
"line": 31526,
"text": "// A row that has spent its budget without an answer is parked under its own"
},
{
"line": 31527,
"text": "// status. Leaving it AMBIGUOUS makes it a row the claim predicate silently skips"
},
{
"line": 31528,
"text": "// forever, which looks identical to a healthy backlog on every dashboard."
},
{
"line": 31529,
"text": "```"
},
{
"line": 31530,
"text": ""
},
{
"line": 31531,
"text": "`default ->` 분기의 존재 이유까지 적혀 있다(`:213-215`) — 새 completion 상수가 생기면 조용히 `IN_FLIGHT` 로 남기는 대신 크게 실패하도록."
},
{
"line": 31532,
"text": ""
},
{
"line": 31533,
"text": "`OutboxRelayReport` 의 다섯 카운터가 각각 다른 운영 신호라는 것도 명시적이다(`:5-18`) — ambiguous 는 확인 문제, failed 는 계약/토폴로지 문제, staleLeases 는 \"중복 발행의 가시화된 형태\", exhausted 는 \"redrive 가 필요한 것\"."
},
{
"line": 31534,
"text": ""
},
{
"line": 31535,
"text": "##### 4.5 `OutboxProperties` — 설정 간의 관계를 생성자가 강제한다"
},
{
"line": 31536,
"text": ""
},
{
"line": 31537,
"text": "```java"
},
{
"line": 31538,
"text": "// :7-14"
},
{
"line": 31539,
"text": "/**"
},
{
"line": 31540,
"text": " * The lease duration is the dangerous one. If it is shorter than the time a publish can take, a"
},
{
"line": 31541,
"text": " * second relay claims the row while the first is still waiting for a confirm, and the message is"
},
{
"line": 31542,
"text": " * published twice — under the same id, so consumers with an inbox survive it, but consumers without"
},
{
"line": 31543,
"text": " * one do not. The constructor therefore requires the lease to exceed the publish timeout by a"
},
{
"line": 31544,
"text": " * margin rather than merely to be positive."
},
{
"line": 31545,
"text": " */"
},
{
"line": 31546,
"text": "public static final double REQUIRED_LEASE_FACTOR = 2.0;"
},
{
"line": 31547,
"text": "```"
},
{
"line": 31548,
"text": ""
},
{
"line": 31549,
"text": "`leaseDuration >= publishTimeout * 2` 를 생성자가 강제하고 `OUTBOX_LEASE_TOO_SHORT` 로 거절한다. 기본값(30초 / 5초)이 그 규칙을 만족하는지 자체 테스트가 있다(`theDefaultsSatisfyTheirOwnRule`)."
},
{
"line": 31550,
"text": ""
},
{
"line": 31551,
"text": "##### 4.6 `OutboxEnvelopeFactory` — 정경 사실을 컬럼에서 되살린다"
},
{
"line": 31552,
"text": ""
},
{
"line": 31553,
"text": "```java"
},
{
"line": 31554,
"text": "// :20-37"
},
{
"line": 31555,
"text": "/**"
},
{
"line": 31556,
"text": " * The identity comes from the row, never from a fresh mint. ..."
},
{
"line": 31557,
"text": " *"
},
{
"line": 31558,
"text": " * So does everything else the envelope carries. This used to rebuild correlation, causation,"
},
{
"line": 31559,
"text": " * tenant, trace and the schema reference as empty, and read the routing keys out of the row's"
},
{
"line": 31560,
"text": " * header map — so a message that travelled through the outbox reached its consumer with less"
},
{
"line": 31561,
"text": " * provenance than one published directly, and the publish path became part of the message's"
},
{
"line": 31562,
"text": " * meaning. ..."
},
{
"line": 31563,
"text": " *"
},
{
"line": 31564,
"text": " * Reserved header names in the row are refused outright, with no exception for the routing keys."
},
{
"line": 31565,
"text": " * ... Now that the keys are columns, the rule is the simple one: an outbox row cannot write into"
},
{
"line": 31566,
"text": " * the platform's namespace at all."
},
{
"line": 31567,
"text": " */"
},
{
"line": 31568,
"text": "```"
},
{
"line": 31569,
"text": ""
},
{
"line": 31570,
"text": "예약 이름을 만나면 `RESERVED_HEADER_IN_OUTBOX_ROW` 로 **던진다**(`:70-77`). 부재 값 처리도 정직하다 — `occurredAt` 이 없으면 `createdAt` 을 쓰고 그 이유를 적는다(\"the business transaction that wrote the row is the one the fact occurred in\", `:87-89`), `producer` 가 없으면 릴레이 소유 서비스로 귀속한다(`:91-92`)."
},
{
"line": 31571,
"text": ""
},
{
"line": 31572,
"text": "##### 4.7 `JdbcAdminOperationJournal` — DB 제약이 경쟁을 결판낸다"
},
{
"line": 31573,
"text": ""
},
{
"line": 31574,
"text": "```java"
},
{
"line": 31575,
"text": "// :22-32"
},
{
"line": 31576,
"text": "/**"
},
{
"line": 31577,
"text": " * Lives beside the outbox because it needs the same thing the outbox needs and nothing more: one"
},
{
"line": 31578,
"text": " * relational database that every replica can see. The uniqueness that stops a second execution is"
},
{
"line": 31579,
"text": " * the primary key on {@code (approval_ticket, plan_digest)}, enforced by the database rather than"
},
{
"line": 31580,
"text": " * by a check-then-act in application code — two replicas that read \"no row\" at the same instant"
},
{
"line": 31581,
"text": " * would both proceed, and only the constraint makes exactly one of them win."
},
{
"line": 31582,
"text": " */"
},
{
"line": 31583,
"text": "```"
},
{
"line": 31584,
"text": ""
},
{
"line": 31585,
"text": "`INSERT ... ON CONFLICT DO NOTHING` 이 1행이면 신규 청구, 0행이면 기존 행을 읽어 `refuseIfNotResumable` 후 `TAKE_OVER`. 인수 SQL 자체가 조건을 담는다."
},
{
"line": 31586,
"text": ""
},
{
"line": 31587,
"text": "```sql"
},
{
"line": 31588,
"text": "WHERE approval_ticket = ? AND plan_digest = ? AND lease_token = ?"
},
{
"line": 31589,
"text": " -- Only a failed operation or one whose lease ran out may be taken over. A live STARTED row"
},
{
"line": 31590,
"text": " -- means another replica is executing it right now."
},
{
"line": 31591,
"text": " AND (state = 'FAILED' OR lease_expires_at <= ?)"
},
{
"line": 31592,
"text": "RETURNING lease_token, items_completed"
},
{
"line": 31593,
"text": "```"
},
{
"line": 31594,
"text": ""
},
{
"line": 31595,
"text": "읽기와 인수 사이의 경쟁도 처리한다 — `RETURNING` 이 0행이면 \"another replica took it over between the read and this update\"(`:181-186`)로 거절."
},
{
"line": 31596,
"text": ""
},
{
"line": 31597,
"text": "그리고 `items_completed` 는 `GREATEST` 로 단조 증가한다(`CHECKPOINT`/`SETTLE` SQL). 이것이 `DefaultMessagingAdminService` 가 낡은 값을 넘겨도 진행이 되돌아가지 않는 이유이며, 인터페이스가 요구하지 않는 성질이라는 점은 §A19-MESSAGING-ADMIN-RUNTIME §12.4(c)에 있다."
},
{
"line": 31598,
"text": ""
},
{
"line": 31599,
"text": "---"
},
{
"line": 31600,
"text": ""
},
{
"line": 31601,
"text": "#### 5. 주요 실행 경로"
},
{
"line": 31602,
"text": ""
},
{
"line": 31603,
"text": "**쓰기** — 비즈니스 트랜잭션 → `append(record)` → 트랜잭션 3중 검사 → `DataSourceUtils.getConnection` → INSERT(22컬럼)."
},
{
"line": 31604,
"text": ""
},
{
"line": 31605,
"text": "**배출** — `MessagingOutboxRelayLifecycle` → `worker.start()` → `runPass()` → `relay.runOnce(now)` → 청구/발행/종결 → `scheduler.backoff(unproductive)` → 다음 패스 자기 스케줄링."
},
{
"line": 31606,
"text": ""
},
{
"line": 31607,
"text": "**정리** — `OutboxCleanupJob.runOnce(now)` → `cutoff = now - retention` → `purgePublishedBefore(cutoff)` **무제한 오버로드** ×(최대 `maxBatches`, 실제로는 2회) → §12.1(a)."
},
{
"line": 31608,
"text": ""
},
{
"line": 31609,
"text": "**admin 저널** — `begin` → INSERT ON CONFLICT / TAKE_OVER → `checkpoint` × N → `complete` 또는 `fail`."
},
{
"line": 31610,
"text": ""
},
{
"line": 31611,
"text": "---"
},
{
"line": 31612,
"text": ""
},
{
"line": 31613,
"text": "#### 6. 실패 경로와 복구/번역"
},
{
"line": 31614,
"text": ""
},
{
"line": 31615,
"text": "| 상황 | 처리 | 위치 |"
},
{
"line": 31616,
"text": "|---|---|---|"
},
{
"line": 31617,
"text": "| 트랜잭션 없이 append | `OUTBOX_TRANSACTION_REQUIRED` | `JdbcOutboxRepository:228-235` |"
},
{
"line": 31618,
"text": "| 읽기 전용 트랜잭션 | 〃 | `:236-239` |"
},
{
"line": 31619,
"text": "| 다른 DataSource 의 트랜잭션 | 〃 | `:240-246` |"
},
{
"line": 31620,
"text": "| append SQL 실패 | `OUTBOX_APPEND_FAILED` | `:196-199` |"
},
{
"line": 31621,
"text": "| 그 밖의 쿼리 실패 | `OUTBOX_QUERY_FAILED` | `:594-600` |"
},
{
"line": 31622,
"text": "| 종결 쓰기가 0행 | `OutboxTransitionResult.STALE_LEASE` (예외 아님) | `:413-415` |"
},
{
"line": 31623,
"text": "| 미지의 `PublishCompletion` | `IllegalStateException` | `OutboxRelay:216-217` |"
},
{
"line": 31624,
"text": "| 리스가 발행 타임아웃보다 짧음 | `OUTBOX_LEASE_TOO_SHORT` | `OutboxProperties:52-58` |"
},
{
"line": 31625,
"text": "| 행 헤더에 예약 이름 | `RESERVED_HEADER_IN_OUTBOX_ROW` | `OutboxEnvelopeFactory:70-77` |"
},
{
"line": 31626,
"text": "| 승인 이미 실행됨 | `APPROVAL_ALREADY_EXECUTED` | `JdbcAdminOperationJournal:117-124` |"
},
{
"line": 31627,
"text": "| 다른 런타임이 실행 중 | `ADMIN_OPERATION_IN_FLIGHT` | `:125-132`, `:181-186` |"
},
{
"line": 31628,
"text": "| 리스 상실 후 쓰기 | `ADMIN_OPERATION_LEASE_LOST` | `:263-271` |"
},
{
"line": 31629,
"text": "| 저널 도달 불가 | `ADMIN_JOURNAL_UNAVAILABLE` | `:206-208` 등 |"
},
{
"line": 31630,
"text": "| 두 릴레이 동시 활성 | `DUPLICATE_OUTBOX_RELAY` | `DebeziumOutboxProfile:54-59` (**호출부 0**) |"
},
{
"line": 31631,
"text": "| 릴레이 없음 | `NO_OUTBOX_RELAY` | `:60-65` (**호출부 0**) |"
},
{
"line": 31632,
"text": ""
},
{
"line": 31633,
"text": "`OutboxRelayWorker` 의 패스 실패 처리가 특히 명시적이다."
},
{
"line": 31634,
"text": ""
},
{
"line": 31635,
"text": "```java"
},
{
"line": 31636,
"text": "// :184-190"
},
{
"line": 31637,
"text": "} catch (RuntimeException passFailed) {"
},
{
"line": 31638,
"text": " // A failed pass must not stop the loop: the scheduled task's own exception would cancel every"
},
{
"line": 31639,
"text": " // future pass, turning one broker error into a relay that never runs again. The failure is"
},
{
"line": 31640,
"text": " // counted and the next pass backs off as if nothing was published, which is true."
},
{
"line": 31641,
"text": "```"
},
{
"line": 31642,
"text": ""
},
{
"line": 31643,
"text": "종료도 인터럽트가 아니라 드레인이다."
},
{
"line": 31644,
"text": ""
},
{
"line": 31645,
"text": "```java"
},
{
"line": 31646,
"text": "// :99-106"
},
{
"line": 31647,
"text": "/**"
},
{
"line": 31648,
"text": " * Draining rather than interrupting is the whole point. A pass killed between its claim and"
},
{
"line": 31649,
"text": " * its terminal write leaves rows {@code IN_FLIGHT} holding a lease, and nothing may touch them"
},
{
"line": 31650,
"text": " * until that lease expires — so an orderly shutdown would produce exactly the stall that a crash"
},
{
"line": 31651,
"text": " * produces."
},
{
"line": 31652,
"text": " */"
},
{
"line": 31653,
"text": "```"
},
{
"line": 31654,
"text": ""
},
{
"line": 31655,
"text": "---"
},
{
"line": 31656,
"text": ""
},
{
"line": 31657,
"text": "#### 7. 트랜잭션·동시성·수명주기"
},
{
"line": 31658,
"text": ""
},
{
"line": 31659,
"text": "**두 가지 커넥션 획득 방식이 공존한다.**"
},
{
"line": 31660,
"text": ""
},
{
"line": 31661,
"text": "| 메서드 | 획득 | 효과 |"
},
{
"line": 31662,
"text": "|---|---|---|"
},
{
"line": 31663,
"text": "| `JdbcOutboxRepository.append(record)` | `DataSourceUtils.getConnection` | 호출자 트랜잭션에 합류 |"
},
{
"line": 31664,
"text": "| 그 외 전부 (`withConnection`) | `dataSource.getConnection()` + try-with-resources | 풀에서 새 커넥션, 독립 커밋 |"
},
{
"line": 31665,
"text": "| `JdbcAdminOperationJournal` 전 메서드 | `DataSourceUtils.getConnection` | 트랜잭션 있으면 합류 |"
},
{
"line": 31666,
"text": ""
},
{
"line": 31667,
"text": "릴레이 연산이 비즈니스 트랜잭션에 합류하면 안 되므로 `withConnection` 의 선택은 타당하다. 다만 그 판단이 주석으로 남아 있지 않고, 같은 리프의 저널은 반대 방식을 쓴다. §17 P3."
},
{
"line": 31668,
"text": ""
},
{
"line": 31669,
"text": "**동시성 제어는 전부 데이터베이스에 있다.** `FOR UPDATE SKIP LOCKED`(청구), 서버측 토큰 증가, 펜싱 술어, `ON CONFLICT DO NOTHING`, 복합 기본키. Java 쪽에 락이 없다."
},
{
"line": 31670,
"text": ""
},
{
"line": 31671,
"text": "**수명주기**: `OutboxRelayWorker` 는 데몬 스레드 1개, `setExecuteExistingDelayedTasksAfterShutdownPolicy(false)`, `start()` 멱등, `stop(deadline)` 드레인 후 실패 시 `shutdownNow()`. 셋 다 근거 주석이 있다(`:79-88`, `:92`, `:121-122`)."
},
{
"line": 31672,
"text": ""
},
{
"line": 31673,
"text": "---"
},
{
"line": 31674,
"text": ""
},
{
"line": 31675,
"text": "#### 8. 설정·기능 플래그·환경 차이"
},
{
"line": 31676,
"text": ""
},
{
"line": 31677,
"text": "| 값 | 출처 | 기본 | 비고 |"
},
{
"line": 31678,
"text": "|---|---|---|---|"
},
{
"line": 31679,
"text": "| `batchSize` | `OutboxProperties` | 100 | |"
},
{
"line": 31680,
"text": "| `leaseDuration` | 〃 | 30초 | `>= publishTimeout × 2` 강제 |"
},
{
"line": 31681,
"text": "| `publishTimeout` | 〃 | 5초 | |"
},
{
"line": 31682,
"text": "| `pollInterval` | 〃 | 500ms | 백오프의 기준 간격 |"
},
{
"line": 31683,
"text": "| `retentionAfterPublish` | 〃 | 3일 | |"
},
{
"line": 31684,
"text": "| `maxAttempts` | 〃 | 10 | 청구 술어의 `attempts < ?` |"
},
{
"line": 31685,
"text": "| `maxInterval` | starter `:63` | **1분** | `OutboxRetryScheduler.standard()` 는 5분 |"
},
{
"line": 31686,
"text": "| `maxBatches` | starter `:141` | **20 하드코딩** | 실질 무의미 (§12.1(a)) |"
},
{
"line": 31687,
"text": "| relay owner | `OutboxRelay.defaultOwner()` | `pid@uuid8` | 프로세스당 안정 |"
},
{
"line": 31688,
"text": "| CDC 모드 | `DebeziumOutboxProfile` | — | **어떤 프로퍼티에도 연결 안 됨** |"
},
{
"line": 31689,
"text": ""
},
{
"line": 31690,
"text": "`maxInterval` 이 두 값(1분 / 5분)으로 갈리는 것은 결함이 아니다 — starter 가 명시적으로 넘기고, `standard()` 는 호출자가 정책을 주지 않은 경우의 기본값이다."
},
{
"line": 31691,
"text": ""
},
{
"line": 31692,
"text": "---"
},
{
"line": 31693,
"text": ""
},
{
"line": 31694,
"text": "#### 9. 퍼시스턴스/외부 시스템 세부"
},
{
"line": 31695,
"text": ""
},
{
"line": 31696,
"text": "**테이블 2개.** `messaging_outbox`(V1+V2+V4, 최종 34컬럼 + 생성 컬럼 1), `messaging_admin_operation`(V3, 11컬럼)."
},
{
"line": 31697,
"text": ""
},
{
"line": 31698,
"text": "**인덱스 4개**, 전부 부분 인덱스: `ix_..._claimable`, `ix_..._published_at`, `ix_..._next_attempt`, `ix_..._tenant_backlog`, 그리고 `ix_messaging_admin_operation_live`."
},
{
"line": 31699,
"text": ""
},
{
"line": 31700,
"text": "**헤더 직렬화는 손으로 쓴 JSON** 이다."
},
{
"line": 31701,
"text": ""
},
{
"line": 31702,
"text": "```java"
},
{
"line": 31703,
"text": "// :604-610"
},
{
"line": 31704,
"text": "/**"
},
{
"line": 31705,
"text": " * Hand-rolled rather than pulled from a JSON library so this module keeps no codec dependency:"
},
{
"line": 31706,
"text": " * outbox headers are always flat string pairs, validated by {@code MessageHeaders} before they"
},
{
"line": 31707,
"text": " * ever reach here."
},
{
"line": 31708,
"text": " */"
},
{
"line": 31709,
"text": "```"
},
{
"line": 31710,
"text": ""
},
{
"line": 31711,
"text": "이스케이프는 제어문자까지 처리하며 그 이력이 적혀 있다(`:630-636`). **그러나 역파싱의 종료 판정에 결함이 있다 — §12.1(b), `EVD-314` 에서 런타임 재현했다.**"
},
{
"line": 31712,
"text": ""
},
{
"line": 31713,
"text": "---"
},
{
"line": 31714,
"text": ""
},
{
"line": 31715,
"text": "#### 10. 테스트 레인과 실제 증명 범위"
},
{
"line": 31716,
"text": ""
},
{
"line": 31717,
"text": "`EVD-313`: `./gradlew :messaging:messaging-outbox-jdbc-postgresql:test --rerun-tasks` → **76 tests, 0 failures, 0 skipped**."
},
{
"line": 31718,
"text": ""
},
{
"line": 31719,
"text": "| 클래스 | 수 | 종류 |"
},
{
"line": 31720,
"text": "|---|---:|---|"
},
{
"line": 31721,
"text": "| `OutboxPostgresIT` | **21** | 컨테이너 (Postgres) |"
},
{
"line": 31722,
"text": "| `DebeziumOutboxRecordMapperTest` | 16 | 단위 |"
},
{
"line": 31723,
"text": "| `OutboxOperationsTest` | 10 | 단위 (대역) |"
},
{
"line": 31724,
"text": "| `OutboxRelayTest` | 9 | 단위 (대역) |"
},
{
"line": 31725,
"text": "| `AdminOperationJournalPostgresIT` | **7** | 컨테이너 (Postgres) |"
},
{
"line": 31726,
"text": "| `OutboxEnvelopeFactoryTest` | 6 | 단위 |"
},
{
"line": 31727,
"text": "| `JdbcOutboxTransactionRequirementTest` | 4 | 단위 |"
},
{
"line": 31728,
"text": "| `OutboxRelayWorkerTest` | 3 | 단위 (스레드) |"
},
{
"line": 31729,
"text": ""
},
{
"line": 31730,
"text": "**컨테이너 레인 28건이 실제로 실행되었다** — `skipped=\"0\"` 이고 `tests>0`. `docker version` 은 client 29.1.3 / server 29.6.1 을 보고하고 `/var/run/docker.sock` 이 마운트되어 있다(`EVD-313`)."
},
{
"line": 31731,
"text": ""
},
{
"line": 31732,
"text": "> 이는 앞선 리프 문서들이 \"컨테이너 필요 — 미실행\" 으로 남긴 항목들(messaging-testkit 의 인증 레인 등)이 **실행 불가가 아니라 아직 실행하지 않은 것**임을 뜻한다. 해당 리프 분석 시 실행한다."
},
{
"line": 31733,
"text": ""
},
{
"line": 31734,
"text": "`OutboxPostgresIT` 가 실제로 증명하는 것 중 강한 것들:"
},
{
"line": 31735,
"text": ""
},
{
"line": 31736,
"text": "- `theRowAndTheBusinessChangeCommitTogetherOrNotAtAll` — 아웃박스의 존재 이유 그 자체."
},
{
"line": 31737,
"text": "- `aSupersededRelayCannotOverwriteTheOutcomeOfTheOneThatReplacedIt` / `twoRelaysClaimingConcurrentlyGetDisjointRowsAndDistinctTokens` / `anExpiryReclaimKeepsTheMessageIdAndAdvancesTheToken` — V2 펜싱의 3대 성질."
},
{
"line": 31738,
"text": "- `anAmbiguousRowWaitsForItsBackoffBeforeItIsClaimedAgain` / `aRowOutOfAttemptsIsNotClaimedAgain` / `anExhaustedRowIsDistinctFromARejectedOne` — 재시도 시계가 행에 있다는 주장."
},
{
"line": 31739,
"text": "- `everyCanonicalColumnRoundTripsThroughTheDatabase` / `theRelayCanSelectOneTenantsBacklogWithoutDecodingAPayload` / `theStoredRoutingKeyIsTheOneBothRelaysWouldUse` / `aTenantThatBreaksTheSlugBoundIsRefusedByTheDatabase` — V4 의 네 가지 주장."
},
{
"line": 31740,
"text": ""
},
{
"line": 31741,
"text": "증명되지 **않는** 것:"
},
{
"line": 31742,
"text": ""
},
{
"line": 31743,
"text": "- 정리 작업이 실제로 나눠 지운다는 것 (§12.1(a))."
},
{
"line": 31744,
"text": "- 역슬래시로 끝나는 헤더 값의 왕복 (§12.1(b)). `aHeaderValueWithControlCharactersRoundTrips` 는 제어문자만 본다."
},
{
"line": 31745,
"text": "- 배포되는 `.properties` 가 Java 설정과 일치한다는 것 (§12.4(a))."
},
{
"line": 31746,
"text": "- 두 릴레이 상호배제가 기동에서 강제된다는 것 (§12.1(c))."
},
{
"line": 31747,
"text": "- 구세대 `MessageId` 기반 전이가 신세대와 같은 행 상태를 남긴다는 것 (§12.3(a))."
},
{
"line": 31748,
"text": ""
},
{
"line": 31749,
"text": "---"
},
{
"line": 31750,
"text": ""
},
{
"line": 31751,
"text": "#### 11. 빌드/ArchUnit/CI 강제 지점"
},
{
"line": 31752,
"text": ""
},
{
"line": 31753,
"text": "이 리프 고유의 Gradle 게이트는 없다. 루트 공통 게이트만 적용된다. 컨테이너 IT 가 `test` 태그에서 제외되지 **않는다** — 즉 Docker 가 있는 환경에서는 일반 `test` 로 함께 돈다. `messaging-kafka` 의 인증 레인이 별도 태그로 분리된 것(그 리프 문서 §6 참조)과 대비된다."
},
{
"line": 31754,
"text": ""
},
{
"line": 31755,
"text": "`app-bootstrap` 의 `MessagingCapabilityRegistryContractTest:61` 이 `\"debezium\"` 문자열을 능력 목록에 갖고 있다 — 이 리프의 CDC 경로가 플랫폼 능력으로 선언되어 있다는 뜻이다. 그 선언과 §12.1(c)의 미배선 사이의 대조는 §A18 재검증 시 다룬다."
},
{
"line": 31756,
"text": ""
},
{
"line": 31757,
"text": "---"
},
{
"line": 31758,
"text": ""
},
{
"line": 31759,
"text": "#### 12. 실제 사용 여부와 negative-space probes"
},
{
"line": 31760,
"text": ""
},
{
"line": 31761,
"text": "##### 12.1 Public surface reachability"
},
{
"line": 31762,
"text": ""
},
{
"line": 31763,
"text": "**(a) [P1] 정리 작업이 무제한 DELETE 를 쏜다** (`EVD-311`, `EVD-294`)"
},
{
"line": 31764,
"text": ""
},
{
"line": 31765,
"text": "`OutboxRepository` 는 purge 오버로드를 둘 갖고, 구현도 둘 다 있다."
},
{
"line": 31766,
"text": ""
},
{
"line": 31767,
"text": "```java"
},
{
"line": 31768,
"text": "// JdbcOutboxRepository.java:486-518 bounded"
},
{
"line": 31769,
"text": "// The CTE picks a bounded set of ids with SKIP LOCKED and deletes exactly those. An unbounded"
},
{
"line": 31770,
"text": "// DELETE holds locks and writes WAL in proportion to the whole backlog, which stalls the relay"
},
{
"line": 31771,
"text": "// and the business writes behind retention."
},
{
"line": 31772,
"text": "WITH expired AS (SELECT message_id FROM messaging_outbox"
},
{
"line": 31773,
"text": " WHERE status='PUBLISHED' AND published_at < ?"
},
{
"line": 31774,
"text": " ORDER BY published_at LIMIT ? FOR UPDATE SKIP LOCKED)"
},
{
"line": 31775,
"text": "DELETE FROM messaging_outbox o USING expired e WHERE o.message_id = e.message_id"
},
{
"line": 31776,
"text": ""
},
{
"line": 31777,
"text": "// JdbcOutboxRepository.java:519-533 unbounded"
},
{
"line": 31778,
"text": "DELETE FROM messaging_outbox WHERE status = 'PUBLISHED' AND published_at < ?"
},
{
"line": 31779,
"text": "```"
},
{
"line": 31780,
"text": ""
},
{
"line": 31781,
"text": "호출자는 무제한 쪽을 부른다."
},
{
"line": 31782,
"text": ""
},
{
"line": 31783,
"text": "```java"
},
{
"line": 31784,
"text": "// OutboxCleanupJob.java:48-55"
},
{
"line": 31785,
"text": "for (int batch = 0; batch < maxBatches; batch++) {"
},
{
"line": 31786,
"text": " int deleted = outbox.purgePublishedBefore(cutoff); // 무제한"
},
{
"line": 31787,
"text": " removed += deleted;"
},
{
"line": 31788,
"text": " if (deleted == 0) break;"
},
{
"line": 31789,
"text": "}"
},
{
"line": 31790,
"text": "```"
},
{
"line": 31791,
"text": ""
},
{
"line": 31792,
"text": "1회차가 전체를 지우고 2회차가 0을 반환해 break 한다. `maxBatches=20`(starter `:141`)은 실질적으로 죽은 값이다."
},
{
"line": 31793,
"text": ""
},
{
"line": 31794,
"text": "**발동 조건 보정(`EVD-316`).** 이 잡은 starter 빈이지만 **스케줄되지 않는다.** `MessagingReliabilityAutoConfiguration` 클래스 javadoc(`:32-34`)이 그렇게 설계했다고 적는다 — *\"The cleanup jobs are beans but no scheduler is registered for them. Scheduling is the application's decision: a service running several replicas usually wants one of them to run cleanup, and auto-registering a fixed-rate task would have every replica delete the same rows.\"* 따라서 기본 배포에서는 `runOnce` 가 한 번도 호출되지 않는다. 무제한 DELETE 는 **애플리케이션이 그 지시대로 잡을 스케줄하는 순간** 발동한다."
},
{
"line": 31795,
"text": ""
},
{
"line": 31796,
"text": "테스트가 이것을 가리는 방식이 inbox 쪽과 동일하다."
},
{
"line": 31797,
"text": ""
},
{
"line": 31798,
"text": "```java"
},
{
"line": 31799,
"text": "// OutboxOperationsTest.java:120-134 RecordingRepository"
},
{
"line": 31800,
"text": "@Override public int purgePublishedBefore(Instant publishedBefore, int limit) {"
},
{
"line": 31801,
"text": " return Math.min(purgePublishedBefore(publishedBefore), limit); // 전부 지우고 숫자만 깎는다"
},
{
"line": 31802,
"text": "}"
},
{
"line": 31803,
"text": "@Override public int purgePublishedBefore(Instant publishedBefore) {"
},
{
"line": 31804,
"text": " cutoffs.add(publishedBefore);"
},
{
"line": 31805,
"text": " return pass < deletions.size() ? deletions.get(pass++) : 0; // 스크립트"
},
{
"line": 31806,
"text": "}"
},
{
"line": 31807,
"text": "```"
},
{
"line": 31808,
"text": ""
},
{
"line": 31809,
"text": "`cleanupDeletesInBoundedBatchesRatherThanOneLongStatement` 는 `List.of(1000, 1000, 250)` 을 스크립트로 넣고 `removed == 2250`, `cutoffs.size() == 4` 를 단언한다. \"나눠 지운다\" 는 관측이 전적으로 대역이 만든 것이다. 실 DB 테스트(`OutboxPostgresIT:202`)도 무제한 쪽만 부른다."
},
{
"line": 31810,
"text": ""
},
{
"line": 31811,
"text": "**(b) [P2] 역슬래시로 끝나는 헤더 값이 헤더 맵을 깨뜨린다** (`EVD-314` — 런타임 재현)"
},
{
"line": 31812,
"text": ""
},
{
"line": 31813,
"text": "```java"
},
{
"line": 31814,
"text": "// JdbcOutboxRepository.java:657-664"
},
{
"line": 31815,
"text": "private static int findClosingQuote(String text, int from) {"
},
{
"line": 31816,
"text": " for (int index = from; index < text.length(); index++) {"
},
{
"line": 31817,
"text": " if (text.charAt(index) == '\"' && text.charAt(index - 1) != '\\\\') { return index; }"
},
{
"line": 31818,
"text": " }"
},
{
"line": 31819,
"text": " return text.length();"
},
{
"line": 31820,
"text": "}"
},
{
"line": 31821,
"text": "```"
},
{
"line": 31822,
"text": ""
},
{
"line": 31823,
"text": "닫는 따옴표 판정이 \"바로 앞 글자가 역슬래시가 아니다\" 뿐이다. `escape` 가 값 끝의 역슬래시를 둘로 늘리므로, 닫는 따옴표 앞이 역슬래시가 되어 종료를 놓친다."
},
{
"line": 31824,
"text": ""
},
{
"line": 31825,
"text": "컴파일된 클래스에 jshell + 리플렉션으로 `private static toJson`/`fromJson` 을 직접 호출해 재현했다(애플리케이션 소스 무수정)."
},
{
"line": 31826,
"text": ""
},
{
"line": 31827,
"text": "```"
},
{
"line": 31828,
"text": "case 3 in={x-a=a\\} json={\"x-a\":\"a\\\\\"} out={x-a=a\\\"} EQUAL? false"
},
{
"line": 31829,
"text": "case 4 in={x-a=a\\, x-b=second} json={\"x-a\":\"a\\\\\",\"x-b\":\"second\"} out={x-a=a\\\",, :=x-a, a\\\",=second} EQUAL? false"
},
{
"line": 31830,
"text": "case 5 in={x-a=a\\b} json={\"x-a\":\"a\\\\b\"} out={x-a=a\\b} EQUAL? true"
},
{
"line": 31831,
"text": "new HeaderValue(\"a\\\") -> OK, value=a\\"
},
{
"line": 31832,
"text": "```"
},
{
"line": 31833,
"text": ""
},
{
"line": 31834,
"text": "값이 **끝에** 역슬래시를 가질 때만 깨지고, 뒤에 헤더가 하나라도 더 있으면 맵 전체가 붕괴한다 — 키 `:` 와 키 `a\\\",` 가 생기고 `x-b` 는 사라진다. `HeaderValue` 는 제어문자만 금지하므로(`WireSafeText.require`) 이 입력은 플랫폼 자신의 검증 타입을 통과한다."
},
{
"line": 31835,
"text": ""
},
{
"line": 31836,
"text": "**헤더 주입으로는 이어지지 않는다.** 어긋남이 키/값 경계를 밀어내므로 예약 이름은 키가 아니라 값이 되고, 쓰기 경로의 `MessageHeaders.application(...)` 이 애초에 예약 이름을 거절한다. 데이터 손상이지 취약점은 아니다."
},
{
"line": 31837,
"text": ""
},
{
"line": 31838,
"text": "**(c) CDC 경로 전체가 배선되지 않았다** (`EVD-312`)"
},
{
"line": 31839,
"text": ""
},
{
"line": 31840,
"text": "```"
},
{
"line": 31841,
"text": "git grep -n \"requireExactlyOneRelay|DebeziumOutboxProfile.polling|RelayMode\" -- src"
},
{
"line": 31842,
"text": " 전부 DebeziumOutboxProfile.java 자기 자신 + DebeziumOutboxRecordMapperTest"
},
{
"line": 31843,
"text": "```"
},
{
"line": 31844,
"text": ""
},
{
"line": 31845,
"text": "`DebeziumOutboxProfile` 클래스 javadoc(`:9-13`)은 \"the incompatibility is therefore enforced at startup instead of documented\" 라고 쓴다. 기동 시 `requireExactlyOneRelay` 를 부르는 코드가 없다. `DebeziumOutboxRecordMapper` 는 프로덕션에서 생성되지 않는다. 즉 두 릴레이가 동시에 켜지는 구성을 막는 주체가 없고, CDC 모드를 선택할 프로퍼티도 없다."
},
{
"line": 31846,
"text": ""
},
{
"line": 31847,
"text": "**(d) 세 타입이 starter 밖 배선을 요구한다.** `JdbcOutboxRepository`(src/main 생성 0), `OutboxEnvelopeFactory`(0), `JdbcAdminOperationJournal`(0). 애플리케이션이 등록하지 않으면 릴레이 빈은 `OutboxRepository` 를 주입받지 못한다."
},
{
"line": 31848,
"text": ""
},
{
"line": 31849,
"text": "##### 12.2 Conditional sibling comparison"
},
{
"line": 31850,
"text": ""
},
{
"line": 31851,
"text": "**대조군 1 — 배선된 것 vs 안 된 것.** `OutboxRelayWorker` javadoc(`:18-21`)이 과거 결함을 기록한다: \"The relay, its retry scheduler and the attempt budget all existed and nothing ever called `runOnce`. An outbox whose relay is never driven is the worst shape of all\". 그리고 그 수정이 실제로 배선까지 완료되어 있다(`MessagingOutboxRelayLifecycle:42 worker.start()`). **같은 리프 안에서 `requireExactlyOneRelay` 는 같은 상태로 남아 있다.**"
},
{
"line": 31852,
"text": ""
},
{
"line": 31853,
"text": "**대조군 2 — 커넥션 획득.** `append` 는 `DataSourceUtils`, 나머지는 raw `dataSource.getConnection()`, `JdbcAdminOperationJournal` 은 전부 `DataSourceUtils`. §7."
},
{
"line": 31854,
"text": ""
},
{
"line": 31855,
"text": "**대조군 3 — inbox 와의 대칭.** `InboxCleanupJob`/`OutboxCleanupJob` 은 같은 형태이며 같은 결함을 갖는다(`EVD-294`). starter 가 둘 다 `maxBatches=20` 으로 만든다."
},
{
"line": 31856,
"text": ""
},
{
"line": 31857,
"text": "**대조군 4 — 컨테이너 레인 정책.** 이 리프의 IT 는 `test` 에 포함되어 함께 돈다. `messaging-kafka` 의 인증 레인은 태그로 분리되고 Docker 가드도 없다. 두 정책이 공존하는 이유는 각 리프에 설명되어 있다(전자는 skip 가능, 후자는 skip 이 성공으로 보고되면 안 됨)."
},
{
"line": 31858,
"text": ""
},
{
"line": 31859,
"text": "##### 12.3 Duplicate mechanism sweep"
},
{
"line": 31860,
"text": ""
},
{
"line": 31861,
"text": "**(a) 전이 메서드가 두 세대이며 남기는 행 상태가 다르다.**"
},
{
"line": 31862,
"text": ""
},
{
"line": 31863,
"text": "| 항목 | 신세대 (`OutboxLease`) | 구세대 (`MessageId`) |"
},
{
"line": 31864,
"text": "|---|---|---|"
},
{
"line": 31865,
"text": "| 술어 | `message_id AND status='IN_FLIGHT' AND lease_owner=? AND lease_token=?` | `message_id` 만 |"
},
{
"line": 31866,
"text": "| `markPublished` SET | `status, published_at, lease_expires_at=NULL, lease_owner=NULL, next_attempt_at=NULL, attempts+1` | `status, published_at, lease_expires_at=NULL, attempts+1` |"
},
{
"line": 31867,
"text": "| `markAmbiguous` SET | `… lease_owner=NULL, last_failure_code, attempts+1, next_attempt_at=?` | `… last_failure_code, attempts+1` |"
},
{
"line": 31868,
"text": "| 결과 타입 | `OutboxTransitionResult` | `void` |"
},
{
"line": 31869,
"text": "| 청구 SQL | `CLAIM` (owner/token 기록) | `LEASE` (기록 안 함) |"
},
{
"line": 31870,
"text": ""
},
{
"line": 31871,
"text": "구세대로 PUBLISHED 된 행은 `lease_owner` 와 `next_attempt_at` 이 남는다. 그 컬럼들은 청구 술어와 부분 인덱스가 읽는 값이다. 두 세대 중 어느 것도 `@Deprecated` 가 아니라는 점은 §A19-MESSAGING-RELIABILITY-API 에 기록되어 있고, 여기서는 **상태 차이가 구체적으로 무엇인지**가 추가된다."
},
{
"line": 31872,
"text": ""
},
{
"line": 31873,
"text": "**(b) Debezium 설정이 두 표현으로 존재한다.** §12.4(a)."
},
{
"line": 31874,
"text": ""
},
{
"line": 31875,
"text": "**(c) 손으로 쓴 JSON 코덱이 이 리프에도 있다.** `JdbcOutboxRepository.toJson/fromJson/escape/unescape` — `BrokerCertificationEvidence`(messaging-testkit), `InMemoryAdminOperationJournal.key`(messaging-admin-runtime)와 같은 계열의 선택이다. 각각 이유가 적혀 있고(\"이 모듈은 코덱 의존을 두지 않는다\"), 각각 다른 방식으로 구현되어 있다. 그중 하나에서 파싱 결함이 나왔다(§12.1(b))."
},
{
"line": 31876,
"text": ""
},
{
"line": 31877,
"text": "##### 12.4 Documentation / measured-count drift"
},
{
"line": 31878,
"text": ""
},
{
"line": 31879,
"text": "**(a) [P2] 배포되는 커넥터 설정이 수정 이전 버전이다** (`EVD-310`)"
},
{
"line": 31880,
"text": ""
},
{
"line": 31881,
"text": "| 항목 | Java `connectorConfiguration` | `debezium/outbox-event-router.properties` |"
},
{
"line": 31882,
"text": "|---|---|---|"
},
{
"line": 31883,
"text": "| `event.key` | `routing_key` | **`destination`** |"
},
{
"line": 31884,
"text": "| `route.topic.replacement` | `topicPrefix + ${routedByValue}` | `${routedByValue}` |"
},
{
"line": 31885,
"text": "| `event.timestamp` | (없음) | `created_at` |"
},
{
"line": 31886,
"text": "| `additional.placement` 항목 수 | **15** | **4** |"
},
{
"line": 31887,
"text": ""
},
{
"line": 31888,
"text": "properties 에 없는 11개: `created_at`, `destination`, `producer`, `occurred_at`, `correlation_id`, `causation_id`, `tenant`, `partition_key`, `ordering_key`, `traceparent`, `tracestate`, `baggage` — **V4 가 추가한 정경 메타데이터 전부**다."
},
{
"line": 31889,
"text": ""
},
{
"line": 31890,
"text": "`DebeziumOutboxEventRouter` javadoc(`:21-26`)과 V4 주석(`:55-63`)이 둘 다 \"`destination` 을 키로 쓰면 한 토픽의 모든 메시지가 한 파티션에 몰린다\" 를 고쳤다고 말한다. 배포되는 파일에는 그 수정이 없다."
},
{
"line": 31891,
"text": ""
},
{
"line": 31892,
"text": "그리고 두 표현을 잇는 것이 없다."
},
{
"line": 31893,
"text": ""
},
{
"line": 31894,
"text": "```"
},
{
"line": 31895,
"text": "git grep -rn \"outbox-event-router\" -- src"
},
{
"line": 31896,
"text": "exit 1 (출력 없음)"
},
{
"line": 31897,
"text": "```"
},
{
"line": 31898,
"text": ""
},
{
"line": 31899,
"text": "Java 쪽은 오히려 **의도적으로 견고한 테스트**가 지키고 있다."
},
{
"line": 31900,
"text": ""
},
{
"line": 31901,
"text": "```java"
},
{
"line": 31902,
"text": "// DebeziumOutboxRecordMapperTest.java:154-162"
},
{
"line": 31903,
"text": "void theRoutedKeyIsNotTheTopicName() {"
},
{
"line": 31904,
"text": " // Literals, not the class's own constants: comparing a configuration value against the constant"
},
{
"line": 31905,
"text": " // that produced it asserts that the router agrees with itself, which it always will."
},
{
"line": 31906,
"text": " assertThat(new DebeziumOutboxEventRouter().connectorConfiguration(\"prod.\"))"
},
{
"line": 31907,
"text": " .as(\"keying by destination puts every message on a topic onto one partition\")"
},
{
"line": 31908,
"text": " .containsEntry(\"transforms.outbox.table.field.event.key\", \"routing_key\")"
},
{
"line": 31909,
"text": " .containsEntry(\"transforms.outbox.route.by.field\", \"destination\");"
},
{
"line": 31910,
"text": "}"
},
{
"line": 31911,
"text": "```"
},
{
"line": 31912,
"text": ""
},
{
"line": 31913,
"text": "리터럴 대조까지 하는 테스트가 Java 를 지키고, 운영자가 배포하는 파일은 아무도 지키지 않는다."
},
{
"line": 31914,
"text": ""
},
{
"line": 31915,
"text": "**(b) `aggregateIdAsPartitionKey` 는 커넥터에 도달할 수 없다.** `DebeziumOutboxRecordMapper` 는 그 플래그로 분기해 `Optional.empty()` 를 낼 수 있지만(`:70-73`), `connectorConfiguration(String topicPrefix)` 는 프로필을 받지 않고 `event.key` 를 항상 `routing_key` 로 고정한다. 기본값(`polling()` → `false`)에서 모델은 \"키 없음\" 을 예측하고 실제 커넥터는 키를 붙인다. 이 클래스의 존재 이유가 \"Produces what Debezium's Event Router will emit\"(`:11`)인 만큼 무해하지 않다."
},
{
"line": 31916,
"text": ""
},
{
"line": 31917,
"text": "**(c) 백오프 지터가 복제본을 분산시키지 못한다** (`EVD-312`)"
},
{
"line": 31918,
"text": ""
},
{
"line": 31919,
"text": "```java"
},
{
"line": 31920,
"text": "// OutboxRetryScheduler.java:18-20"
},
{
"line": 31921,
"text": "/**"
},
{
"line": 31922,
"text": " * Jitter is applied deterministically from the attempt count rather than randomly. Several relay"
},
{
"line": 31923,
"text": " * instances that all started at deployment time would otherwise synchronise their retries into a"
},
{
"line": 31924,
"text": " * thundering herd ..."
},
{
"line": 31925,
"text": " */"
},
{
"line": 31926,
"text": "// :107"
},
{
"line": 31927,
"text": "long jittered = capped - (capped / 8) * (exponent % 3);"
},
{
"line": 31928,
"text": "```"
},
{
"line": 31929,
"text": ""
},
{
"line": 31930,
"text": "`jittered` 는 `exponent` 만의 함수이고 `exponent` 는 워커의 `unproductivePasses` 카운터다. 같은 시각에 배포되어 같은 브로커 장애를 겪는 복제본들은 같은 카운터를 갖게 되므로 **같은 backoff 를 계산한다.** 지터는 시도 횟수에 따라 값을 바꿀 뿐 인스턴스에 따라 바꾸지 않는다."
},
{
"line": 31931,
"text": ""
},
{
"line": 31932,
"text": "(행 단위 백오프 `nextAttemptAt` 은 `next_attempt_at` 컬럼에 기록되므로 이 문제와 무관하다. javadoc 이 말하는 \"several relay instances … synchronise their retries\" 는 pass 단위 얘기다.)"
},
{
"line": 31933,
"text": ""
},
{
"line": 31934,
"text": "**(d) 선언 의존은 모두 사용된다.** 5개 project 의존 중 미사용 0건 — 지금까지 본 messaging 리프 중 처음이다."
},
{
"line": 31935,
"text": ""
},
{
"line": 31936,
"text": "---"
},
{
"line": 31937,
"text": ""
},
{
"line": 31938,
"text": "#### 13. Git/설계 문서에서 확인한 변화와 실패 기록"
},
{
"line": 31939,
"text": ""
},
{
"line": 31940,
"text": "SQL 마이그레이션과 javadoc 이 함께 이력을 이룬다. 여덟 개의 \"이전에는 이랬다\"."
},
{
"line": 31941,
"text": ""
},
{
"line": 31942,
"text": "| 위치 | 기록된 과거 결함 |"
},
{
"line": 31943,
"text": "|---|---|"
},
{
"line": 31944,
"text": "| `V2:3-14` | 리스만으로는 stale relay 가 PUBLISHED 위에 AMBIGUOUS 를 덮어썼다 |"
},
{
"line": 31945,
"text": "| `V2:25-27` | \"V1's CHECK listed five states, so writing the sixth failed at the constraint rather than at review\" |"
},
{
"line": 31946,
"text": "| `V4:6-9` | 정경 필드가 갈 곳이 없어 유실되거나 `msg.*` 로 밀반입되었다 |"
},
{
"line": 31947,
"text": "| `V4:56-61` | Debezium 키가 `destination` 이라 한 토픽의 모든 메시지가 한 파티션에 몰렸다 |"
},
{
"line": 31948,
"text": "| `JdbcOutboxRepository:155-162` | `append(Connection, …)` 이 public 이었고 안전한 경로가 \"알아야만 하는\" 것이었다 |"
},
{
"line": 31949,
"text": "| `JdbcOutboxRepository:205-209` | `append` 가 풀에서 raw 커넥션을 열어 자동 커밋했다 — \"a business transaction that rolled back afterwards left the event behind\" |"
},
{
"line": 31950,
"text": "| `JdbcOutboxRepository:630-636` | 이스케이프가 역슬래시와 따옴표만 처리해 제어문자가 JSONB 를 깨뜨렸다 |"
},
{
"line": 31951,
"text": "| `OutboxRelay:117-123` | \"The scheduler was built by the auto-configuration and handed to nobody\" |"
},
{
"line": 31952,
"text": "| `OutboxRelayWorker:18-21` | \"The relay, its retry scheduler and the attempt budget all existed and nothing ever called `runOnce`\" |"
},
{
"line": 31953,
"text": "| `OutboxEnvelopeFactory:27-37` | 정경 필드를 빈 값으로 재구성하고 라우팅 키를 헤더 맵에서 읽었다 |"
},
{
"line": 31954,
"text": "| `CLAIM SQL:119-122` | AMBIGUOUS 행이 다음 패스에 바로 재청구되어 시도 예산이 아무도 안 읽는 숫자였다 |"
},
{
"line": 31955,
"text": ""
},
{
"line": 31956,
"text": "마지막 두 개(`OutboxRelay:117-123`, `OutboxRelayWorker:18-21`)가 이 저장소 전체에서 반복되는 결함 계열 — **\"만들어졌지만 아무도 부르지 않는다\"** — 을 명시적으로 이름 붙인 유일한 자리다. 그리고 이 리프에서는 그 둘이 실제로 고쳐졌다. §12.1(c)의 `requireExactlyOneRelay` 만 같은 상태로 남았다."
},
{
"line": 31957,
"text": ""
},
{
"line": 31958,
"text": "---"
},
{
"line": 31959,
"text": ""
},
{
"line": 31960,
"text": "#### 14. 런타임·터미널 Evidence"
},
{
"line": 31961,
"text": ""
},
{
"line": 31962,
"text": "| ID | 파일 | 내용 |"
},
{
"line": 31963,
"text": "|---|---|---|"
},
{
"line": 31964,
"text": "| EVD-310 | `evidence/raw/310-debezium-properties-vs-java-drift.txt` | Java 설정 vs 배포 properties 항목별 대조, 헤더 매핑 15 vs 4, 연결 코드 0건 |"
},
{
"line": 31965,
"text": "| EVD-311 | `evidence/raw/311-outbox-cleanup-unbounded-confirmed.txt` | bounded/unbounded 두 SQL 전문, 호출자, starter 배선, 대역의 스크립트 |"
},
{
"line": 31966,
"text": "| EVD-312 | `evidence/raw/312-outbox-assembly-and-jitter.txt` | 조립 탐침 전수, 릴레이 기동 확인(대조군), CDC 미배선, 지터 분석 |"
},
{
"line": 31967,
"text": "| EVD-313 | `evidence/raw/313-messaging-outbox-jdbc-test-lane.txt` | 76건 통과 + **컨테이너 런타임 가용성 확인** |"
},
{
"line": 31968,
"text": "| EVD-314 | `evidence/raw/314-outbox-header-json-roundtrip-corruption.txt` | jshell 리플렉션 재현 5케이스 + 주입 불가 확인 + HeaderValue 수용 확인 |"
},
{
"line": 31969,
"text": ""
},
{
"line": 31970,
"text": "---"
},
{
"line": 31971,
"text": ""
},
{
"line": 31972,
"text": "#### 15. 명시적 설계 이유와 추론을 구분한 정리"
},
{
"line": 31973,
"text": ""
},
{
"line": 31974,
"text": "**코드/주석에 명시된 것**"
},
{
"line": 31975,
"text": ""
},
{
"line": 31976,
"text": "- `message_id` 를 기본키로 삼은 이유 (`V1:3-5`)."
},
{
"line": 31977,
"text": "- 부분 인덱스인 이유, `IN_FLIGHT` 를 청구 대상에 넣는 이유 (`V1:28-36`)."
},
{
"line": 31978,
"text": "- 펜싱 토큰이 필요한 이유와 리스 연장이 답이 아닌 이유 (`V2:3-14`)."
},
{
"line": 31979,
"text": "- `EXHAUSTED` 를 새 상태로 만든 이유 (`V2:25-27`)."
},
{
"line": 31980,
"text": "- 정경 메타데이터를 blob 이 아니라 컬럼으로 둔 이유 (`V4:11-14`)."
},
{
"line": 31981,
"text": "- tenant 제약을 DB 에도 거는 이유 (`V4:33-36`)."
},
{
"line": 31982,
"text": "- `routing_key` 를 생성 컬럼으로 만든 이유 (`V4:55-63`)."
},
{
"line": 31983,
"text": "- `append` 가 호출자 커넥션을 쓰는 이유, 그리고 fail-fast 인 이유 (`JdbcOutboxRepository:37-46, 221-227`)."
},
{
"line": 31984,
"text": "- `FOR UPDATE SKIP LOCKED` 의 이유 (`:44-46`)."
},
{
"line": 31985,
"text": "- 열 목록을 상수로 뽑은 이유 (`:64-71`)."
},
{
"line": 31986,
"text": "- 서버측 토큰 증가의 이유 (`:106-111`)."
},
{
"line": 31987,
"text": "- 재시도 시계를 행에 두는 이유 (`CLAIM:119-122`, `markAmbiguous:79-81`)."
},
{
"line": 31988,
"text": "- 0행을 STALE_LEASE 로 보고하는 이유 (`:392-398`)."
},
{
"line": 31989,
"text": "- bounded purge 가 필요한 이유 (`:164-166`) — 정작 호출되지 않는다."
},
{
"line": 31990,
"text": "- 손으로 쓴 JSON 의 이유, 제어문자 이스케이프의 이유 (`:604-610, 630-636`)."
},
{
"line": 31991,
"text": "- 모호를 같은 id 로 재시도하는 이유, at-least-once 상한의 이유 (`OutboxRelay:17-27`)."
},
{
"line": 31992,
"text": "- `attempts + 1` 로 예산을 판정하는 이유 (`:179-181`)."
},
{
"line": 31993,
"text": "- `EXHAUSTED` 로 주차하는 이유 (`:186-188`)."
},
{
"line": 31994,
"text": "- `default ->` 분기의 이유 (`:213-215`)."
},
{
"line": 31995,
"text": "- 리스가 발행 타임아웃의 2배여야 하는 이유 (`OutboxProperties:10-14`)."
},
{
"line": 31996,
"text": "- pass 백오프와 row 백오프가 서로를 대체하지 않는 이유 (`OutboxRetryScheduler:11-16`)."
},
{
"line": 31997,
"text": "- 시프트를 쓰는 이유 (`:102-103`)."
},
{
"line": 31998,
"text": "- 데몬 스레드·자기 스케줄링·드레인 종료의 이유 (`OutboxRelayWorker:23-30, 79-88, 99-106`)."
},
{
"line": 31999,
"text": "- 패스 실패가 루프를 끝내면 안 되는 이유 (`:184-187`)."
},
{
"line": 32000,
"text": "- 정리가 PUBLISHED 만 지우는 이유 (`OutboxCleanupJob:10-14`)."
},
{
"line": 32001,
"text": "- 봉투 재구성 시 부재 값 처리의 이유 (`OutboxEnvelopeFactory:87-92`)."
},
{
"line": 32002,
"text": "- 예약 이름을 예외 없이 거절하는 이유 (`:33-37`)."
},
{
"line": 32003,
"text": "- 저널이 아웃박스 옆에 사는 이유 (`build.gradle:9-13`, `JdbcAdminOperationJournal:24-28`)."
},
{
"line": 32004,
"text": "- DB 제약이 경쟁을 결판내는 이유 (`:26-28`)."
},
{
"line": 32005,
"text": "- 읽기와 인수 사이 경쟁을 거절하는 이유 (`:181-184`)."
},
{
"line": 32006,
"text": "- 두 릴레이 동시 실행이 불가능해야 하는 이유 (`DebeziumOutboxProfile:9-13`, properties `:3-5`)."
},
{
"line": 32007,
"text": "- CDC 모델을 Java 로 만든 이유 (`DebeziumOutboxRecordMapper:11-18`)."
},
{
"line": 32008,
"text": "- schema subject 만 헤더가 없는 이유 (`DebeziumOutboxEventRouter:28-33`)."
},
{
"line": 32009,
"text": "- 부재를 빈 문자열로 쓰지 않는 이유 (`:105-107`)."
},
{
"line": 32010,
"text": "- 컨테이너 테스트가 필요한 이유 (`build.gradle:16-17`)."
},
{
"line": 32011,
"text": ""
},
{
"line": 32012,
"text": "**추론 (근거는 있으나 문서에 없음)**"
},
{
"line": 32013,
"text": ""
},
{
"line": 32014,
"text": "- `withConnection` 이 `DataSourceUtils` 를 쓰지 않는 것은 릴레이가 비즈니스 트랜잭션에 합류하면 안 되기 때문으로 보인다. 주석은 없고, 같은 리프의 저널은 반대로 한다."
},
{
"line": 32015,
"text": "- `.properties` 가 갱신되지 않은 것은 누락으로 보인다 — Java 쪽 수정에 붙은 근거가 파일 쪽에도 그대로 적용되기 때문. 의도적 분기라는 표시는 없다."
},
{
"line": 32016,
"text": "- `maxBatches=20` 하드코딩이 프로퍼티가 아닌 이유는 알 수 없다."
},
{
"line": 32017,
"text": "- 구세대 `MessageId` 오버로드가 남아 있는 이유, 그리고 그것이 `lease_owner` 를 지우지 않는 것이 의도인지 누락인지."
},
{
"line": 32018,
"text": "- `aggregateIdAsPartitionKey` 가 커넥터 설정에 전달되지 않는 것이 의도인지 누락인지."
},
{
"line": 32019,
"text": ""
},
{
"line": 32020,
"text": "---"
},
{
"line": 32021,
"text": ""
},
{
"line": 32022,
"text": "#### 16. 확인한 것 / 확인하지 못한 것"
},
{
"line": 32023,
"text": ""
},
{
"line": 32024,
"text": "**확인한 것**"
},
{
"line": 32025,
"text": ""
},
{
"line": 32026,
"text": "- production 13파일 + SQL 4 + properties 1 전부 본문 확인."
},
{
"line": 32027,
"text": "- 테스트 76건 전건 통과, **컨테이너 IT 28건이 실제로 실행됨** (`EVD-313`)."
},
{
"line": 32028,
"text": "- 이 환경에서 Docker 사용 가능 (client 29.1.3 / server 29.6.1, 소켓 마운트)."
},
{
"line": 32029,
"text": "- 정리 작업이 무제한 DELETE 를 쏜다는 것 — 두 SQL·호출자·starter 배선·대역 전부 확인 (`EVD-311`)."
},
{
"line": 32030,
"text": "- 역슬래시 종결 헤더 값의 왕복 손상 — **jshell 리플렉션으로 런타임 재현** (`EVD-314`)."
},
{
"line": 32031,
"text": "- Debezium 설정 두 표현의 항목별 차이와 연결 코드 0건 (`EVD-310`)."
},
{
"line": 32032,
"text": "- 조립 탐침 전수, 릴레이 기동 확인, CDC 미배선 (`EVD-312`)."
},
{
"line": 32033,
"text": "- 구·신 전이 메서드의 SET 절 차이."
},
{
"line": 32034,
"text": ""
},
{
"line": 32035,
"text": "**확인하지 못한 것**"
},
{
"line": 32036,
"text": ""
},
{
"line": 32037,
"text": "- **테스트 8파일을 축자 통독하지 않았다.** 76개 메서드 이름 전수와 판정에 필요한 구간(대역 구현, purge/Debezium/이스케이프 단언)만 읽었다. 커버리지 원장에 `STRUCTURAL_ONLY` 로 기록했다."
},
{
"line": 32038,
"text": "- §12.1(a)와 (c)의 결과를 실제 배포에서 관측하지 않았다. (a)는 SQL·호출자·배선으로, (c)는 호출부 부재로 도출했다."
},
{
"line": 32039,
"text": "- 실제 Debezium 커넥터를 띄워 properties 의 동작을 확인하지 않았다. 두 설정의 차이는 텍스트 대조로 확인했다."
},
{
"line": 32040,
"text": "- §12.4(c)의 지터 동기화를 다중 인스턴스로 재현하지 않았다. 함수가 `exponent` 만의 함수라는 것은 코드로 확인했다."
},
{
"line": 32041,
"text": "- 구세대 전이 메서드가 실제로 호출되는 배포가 있는지 — 이 저장소에는 없다."
},
{
"line": 32042,
"text": ""
},
{
"line": 32043,
"text": "---"
},
{
"line": 32044,
"text": ""
},
{
"line": 32045,
"text": "#### 17. 손볼 것"
},
{
"line": 32046,
"text": ""
},
{
"line": 32047,
"text": "##### P1 — 정리 작업이 무제한 DELETE 를 쏘고, 그것을 막는 오버로드는 호출되지 않는다"
},
{
"line": 32048,
"text": ""
},
{
"line": 32049,
"text": "`OutboxCleanupJob:50` 과 `InboxCleanupJob:56` 이 무제한 오버로드를 부른다. bounded 오버로드(`purgePublishedBefore(Instant, int)` / `purgeProcessedBefore(Instant, int)`)는 두 포트에 선언되고 두 구현에 구현되어 있으며 호출부가 **0건**이다(`EVD-294`, `EVD-311`)."
},
{
"line": 32050,
"text": ""
},
{
"line": 32051,
"text": "두 잡 모두 starter 빈이지만 스케줄러는 등록되지 않으며, 그것은 의도된 설계다(`EVD-316`). 즉 기본 배포에서는 아무 일도 일어나지 않고, 애플리케이션이 문서 지시대로 잡을 스케줄하는 순간 무제한 DELETE 가 발동한다. 잠재 결함이지 상시 결함이 아니다."
},
{
"line": 32052,
"text": ""
},
{
"line": 32053,
"text": "bounded 구현의 주석이 결과를 명시한다: *\"An unbounded DELETE holds locks and writes WAL in proportion to the whole backlog, which stalls the relay and the business writes behind retention.\"* 3일치 백로그가 쌓인 테이블에서 이것은 릴레이 정지와 비즈니스 쓰기 정체를 뜻한다. 두 리프 모두 `runtime_memberships: [\"app-bootstrap\"]` 이고 두 잡 모두 starter 빈이다."
},
{
"line": 32054,
"text": ""
},
{
"line": 32055,
"text": "수정은 한 줄이다 — `purgePublishedBefore(cutoff, batchLimit)`. `maxBatches` 가 그제서야 의미를 갖는다. 배치 크기는 새 파라미터가 필요하고, `OutboxProperties.batchSize`(100)를 재사용하거나 별도 값을 둔다."
},
{
"line": 32056,
"text": ""
},
{
"line": 32057,
"text": "그리고 **회귀 테스트가 성립하려면 `RecordingRepository` 를 고쳐야 한다.** 현재 대역의 bounded 구현은 `Math.min(unbounded(), limit)` 로, 전부 지우고 숫자만 깎는다. 실제 저장소를 흉내 내려면 보유 행 목록을 갖고 `limit` 만큼만 제거해야 한다."
},
{
"line": 32058,
"text": ""
},
{
"line": 32059,
"text": "##### P2 — 배포되는 Debezium 설정이 수정 이전 버전이다"
},
{
"line": 32060,
"text": ""
},
{
"line": 32061,
"text": "`src/main/resources/debezium/outbox-event-router.properties` 가 `event.key=destination` 을 유지하고 있다. 같은 저장소의 Java(`DebeziumOutboxEventRouter`), V4 마이그레이션 주석, 그리고 전용 테스트(`theRoutedKeyIsNotTheTopicName`)가 모두 그것이 결함이라고 말한다 — \"keying by destination puts every message on a topic onto one partition\"."
},
{
"line": 32062,
"text": ""
},
{
"line": 32063,
"text": "추가로 헤더 매핑이 15개 중 4개뿐이라, 이 파일로 배포한 CDC 는 tenant·correlation·causation·producer·trace·partition/ordering key 를 **전부 잃는다**. V4 가 존재하는 이유가 그 유실을 막는 것이다."
},
{
"line": 32064,
"text": ""
},
{
"line": 32065,
"text": "두 가지가 필요하다."
},
{
"line": 32066,
"text": ""
},
{
"line": 32067,
"text": "1. properties 를 Java 설정에서 생성하거나, 최소한 **둘을 대조하는 테스트**를 둔다. `DebeziumOutboxEventRouter.connectorConfiguration(\"\")` 의 항목이 파일에 모두 있는지 확인하는 테스트면 충분하다. 지금은 두 표현을 잇는 코드가 한 줄도 없다."
},
{
"line": 32068,
"text": "2. `aggregateIdAsPartitionKey` 를 `connectorConfiguration` 에 전달하거나, 전달할 수 없다면 `DebeziumOutboxRecordMapper` 에서 그 분기를 제거한다. 지금은 모델이 커넥터가 하지 않을 일을 예측한다."
},
{
"line": 32069,
"text": ""
},
{
"line": 32070,
"text": "##### P2 — 역슬래시로 끝나는 헤더 값이 헤더 맵을 깨뜨린다"
},
{
"line": 32071,
"text": ""
},
{
"line": 32072,
"text": "`findClosingQuote`(`:657-664`)가 이스케이프된 역슬래시를 고려하지 않는다. 값이 역슬래시로 끝나면 파서가 종료 지점을 놓치고, 뒤에 헤더가 더 있으면 맵 전체가 붕괴한다(`EVD-314`, 런타임 재현)."
},
{
"line": 32073,
"text": ""
},
{
"line": 32074,
"text": "`HeaderValue` 는 제어문자만 금지하므로 이 입력은 플랫폼 검증을 통과한다. 헤더 주입으로 이어지지는 않는다 — 예약 이름은 키가 아니라 값이 되고, 쓰기 경로가 예약 이름을 이미 거절한다."
},
{
"line": 32075,
"text": ""
},
{
"line": 32076,
"text": "수정: 종료 판정을 \"앞의 연속된 역슬래시 개수가 짝수\" 로 바꾸거나, 인덱스를 앞에서부터 스캔하며 이스케이프 상태를 추적한다. 후자가 `unescape` 와 대칭이라 낫다."
},
{
"line": 32077,
"text": ""
},
{
"line": 32078,
"text": "테스트는 `OutboxPostgresIT.aHeaderValueWithControlCharactersRoundTrips` 옆에 역슬래시 종결 케이스를 추가하면 된다 — 실 DB 왕복까지 확인할 수 있다."
},
{
"line": 32079,
"text": ""
},
{
"line": 32080,
"text": "##### P2 — 두 릴레이 상호배제가 기동에서 강제되지 않는다"
},
{
"line": 32081,
"text": ""
},
{
"line": 32082,
"text": "`DebeziumOutboxProfile.requireExactlyOneRelay(...)` 는 프로덕션 호출부가 0건이다. 클래스 javadoc 은 \"the incompatibility is therefore enforced at startup instead of documented\" 라고 쓴다. properties 파일도 같은 경고를 반복한다(\"Enable this OR the in-process polling relay, never both\")."
},
{
"line": 32083,
"text": ""
},
{
"line": 32084,
"text": "같은 리프에 정확히 이 형태를 고친 선례가 있다 — `OutboxRelayWorker` 가 \"nothing ever called `runOnce`\" 를 고치고 `MessagingOutboxRelayLifecycle` 로 배선까지 마쳤다. 같은 방식으로 `MessagingReliabilityAutoConfiguration` 에 프로필 빈과 `InitializingBean` 검사를 두면 된다."
},
{
"line": 32085,
"text": ""
},
{
"line": 32086,
"text": "배선하려면 CDC 모드를 선택할 프로퍼티도 필요하다 — 지금은 `DebeziumOutboxProfile` 을 만드는 설정 경로 자체가 없다."
},
{
"line": 32087,
"text": ""
},
{
"line": 32088,
"text": "##### P3 — 구세대 전이 메서드가 신세대와 다른 행 상태를 남긴다"
},
{
"line": 32089,
"text": ""
},
{
"line": 32090,
"text": "`markPublished(MessageId, Instant)` 는 `lease_owner` 와 `next_attempt_at` 을 지우지 않는다. `markPublished(OutboxLease, Instant)` 는 지운다. `markAmbiguous`/`markFailed` 도 같다. 두 컬럼은 청구 술어와 부분 인덱스가 읽는 값이다."
},
{
"line": 32091,
"text": ""
},
{
"line": 32092,
"text": "이 저장소에 구세대를 부르는 프로덕션 코드는 없다. 그러나 포트에 남아 있고 `@Deprecated` 도 아니므로, 외부 구현이나 향후 코드가 부를 수 있다. 최소한 `@Deprecated` 와 \"신세대를 쓰라\"는 문장이 필요하고, 더 나은 것은 제거다."
},
{
"line": 32093,
"text": ""
},
{
"line": 32094,
"text": "##### P3 — 백오프 지터가 인스턴스를 분산시키지 못한다"
},
{
"line": 32095,
"text": ""
},
{
"line": 32096,
"text": "`jittered = capped - (capped/8) * (exponent % 3)` 는 `exponent` 만의 함수다. 같은 상태의 복제본들은 같은 값을 계산한다. javadoc 이 약속하는 \"thundering herd 방지\" 가 성립하지 않는다."
},
{
"line": 32097,
"text": ""
},
{
"line": 32098,
"text": "`OutboxRelay` 가 이미 `defaultOwner()` 로 프로세스별 안정 식별자를 만든다(`pid@uuid8`). 그것의 해시를 지터에 섞으면 결정성(같은 프로세스에서 재현 가능)을 유지하면서 인스턴스 간 위상차가 생긴다. javadoc 이 난수를 거부한 이유(\"a random source would make the schedule impossible to test\")도 그대로 지켜진다."
},
{
"line": 32099,
"text": ""
},
{
"line": 32100,
"text": "##### P3 — 커넥션 획득 방식이 리프 안에서 갈린다"
},
{
"line": 32101,
"text": ""
},
{
"line": 32102,
"text": "`JdbcOutboxRepository.append` 는 `DataSourceUtils`, 나머지는 raw `dataSource.getConnection()`, `JdbcAdminOperationJournal` 은 전부 `DataSourceUtils`. 릴레이 연산이 비즈니스 트랜잭션에 합류하면 안 된다는 판단은 타당하지만 어디에도 적혀 있지 않고, 같은 리프의 저널이 반대로 한다."
},
{
"line": 32103,
"text": ""
},
{
"line": 32104,
"text": "`withConnection` 에 한 문장 — \"릴레이 연산은 호출자 트랜잭션에 합류하지 않는다\" — 을 붙이면 `append` 의 상세한 주석과 짝이 맞는다. 저널이 `DataSourceUtils` 를 쓰는 것이 의도인지도 확인이 필요하다."
},
{
"line": 32105,
"text": ""
},
{
"line": 32106,
"text": "##### P3 — `maxBatches` 가 하드코딩이고 현재는 의미가 없다"
},
{
"line": 32107,
"text": ""
},
{
"line": 32108,
"text": "starter 가 `20` 을 박아 넣는다(`:141`, `:170`). P1 을 고치기 전에는 이 값이 아무 일도 하지 않고, 고친 뒤에는 배치 크기와 함께 조정 대상이 된다. `OutboxProperties`/`InboxRetentionPolicy` 로 옮기는 것이 맞다."
},
{
"line": 32109,
"text": ""
},
{
"line": 32110,
"text": "##### 확인된 설계(문제 아님)"
},
{
"line": 32111,
"text": ""
},
{
"line": 32112,
"text": "- **`append` 의 트랜잭션 3중 검사.** 활성/쓰기 가능/같은 DataSource 바인딩. 세 번째가 특히 드물고 정확하다."
},
{
"line": 32113,
"text": "- **`message_id` 를 기본키로.** 어떤 코드 경로도 새 id 로 같은 행을 발행할 수 없다."
},
{
"line": 32114,
"text": "- **펜싱 토큰을 서버측 한 문장에서 증가.** 두 릴레이가 같은 번호를 받을 수 없다."
},
{
"line": 32115,
"text": "- **종결 쓰기의 owner+token 술어, 그리고 0행을 삼키지 않는 것.** stale 은 중복 발행의 가시화된 형태다."
},
{
"line": 32116,
"text": "- **재시도 시계를 행에 기록.** 프로세스 메모리의 백오프는 재시작에 잊히고 복제본마다 따로 계산된다."
},
{
"line": 32117,
"text": "- **`EXHAUSTED` 를 별도 상태로.** AMBIGUOUS 로 두면 대시보드에서 건강한 백로그와 구별되지 않는다."
},
{
"line": 32118,
"text": "- **`spent = attempts + 1` 로 예산 판정.** 마지막 시도가 두 번 소비되지 않는다."
},
{
"line": 32119,
"text": "- **`default ->` 에서 크게 실패하기.** 새 completion 이 조용히 `IN_FLIGHT` 를 남기지 않는다."
},
{
"line": 32120,
"text": "- **리스 ≥ 발행 타임아웃 × 2 를 생성자가 강제.** 그리고 기본값이 자기 규칙을 만족하는지 테스트가 있다."
},
{
"line": 32121,
"text": "- **정경 메타데이터를 컬럼으로.** 운영자 질문이 SELECT 가 된다."
},
{
"line": 32122,
"text": "- **tenant 제약을 DB 에도.** 애플리케이션 밖 INSERT 를 막는다."
},
{
"line": 32123,
"text": "- **`routing_key` 생성 컬럼.** 두 릴레이의 폴백 규칙을 한 곳에 고정한다 (Java 쪽 한정으로)."
},
{
"line": 32124,
"text": "- **봉투 재구성 시 예약 이름을 예외 없이 거절.** 라우팅 키가 컬럼이 된 뒤 규칙이 단순해졌다."
},
{
"line": 32125,
"text": "- **부재를 빈 문자열로 쓰지 않기** (CDC 헤더, 봉투 양쪽)."
},
{
"line": 32126,
"text": "- **패스 실패가 루프를 끝내지 않게.** 스케줄된 작업의 예외는 이후 모든 패스를 취소한다."
},
{
"line": 32127,
"text": "- **드레인 종료.** 인터럽트는 크래시와 같은 정체를 만든다."
},
{
"line": 32128,
"text": "- **정리가 PUBLISHED 만 대상으로.** AMBIGUOUS·FAILED 는 사건 중 가장 필요한 행이다."
},
{
"line": 32129,
"text": "- **저널을 아웃박스 옆에 두고 DB 제약으로 경쟁을 결판내기.** check-then-act 는 두 복제본을 모두 통과시킨다."
},
{
"line": 32130,
"text": "- **읽기와 인수 사이의 경쟁을 `RETURNING` 0행으로 거절.**"
},
{
"line": 32131,
"text": "- **컨테이너 IT 를 `test` 에 포함.** 이 리프의 주장은 실제 DB 로만 결판난다."
},
{
"line": 32132,
"text": "- **제어문자 이스케이프.** (역슬래시 종결 케이스는 §17 P2.)"
},
{
"line": 32133,
"text": ""
},
{
"line": 32134,
"text": "---"
},
{
"line": 32135,
"text": ""
},
{
"line": 32136,
"text": "#### Source anchors"
},
{
"line": 32137,
"text": ""
},
{
"line": 32138,
"text": "```"
},
{
"line": 32139,
"text": "src/messaging/messaging-outbox-jdbc-postgresql/build.gradle:1-24"
},
{
"line": 32140,
"text": "src/config/architecture/modules.json (messaging-outbox-jdbc-postgresql 항목)"
},
{
"line": 32141,
"text": ""
},
{
"line": 32142,
"text": "main/resources/db/migration/messaging/V1__messaging_outbox.sql:1-41"
},
{
"line": 32143,
"text": "main/resources/db/migration/messaging/V2__messaging_outbox_lease_fencing.sql:1-39"
},
{
"line": 32144,
"text": "main/resources/db/migration/messaging/V3__messaging_admin_operation_journal.sql:1-37"
},
{
"line": 32145,
"text": "main/resources/db/migration/messaging/V4__messaging_outbox_canonical_metadata.sql:1-66"
},
{
"line": 32146,
"text": "main/resources/debezium/outbox-event-router.properties:1-43"
},
{
"line": 32147,
"text": ""
},
{
"line": 32148,
"text": "main/…/JdbcOutboxRepository.java:37-47,50-62,64-78,80-104,106-142,151-153,155-200,203-218,221-246,249-267,270-306,308-318,319-339,340-350,352-370,371-379,381-389,391-421,424-432,434-444,446-454,456-469,471-484,486-518,519-533,535-545,547-591,593-601,603-628,630-655,657-664,666-700,702-722"
},
{
"line": 32149,
"text": "main/…/OutboxRelay.java:17-28,40-44,66-72,90-99,117-123,145-221,223-230"
},
{
"line": 32150,
"text": "main/…/OutboxRelayWorker.java:15-31,34-35,53-90,92-97,99-125,127-130,132-157,159-174,176-193"
},
{
"line": 32151,
"text": "main/…/OutboxRetryScheduler.java:8-21,28-43,45-61,63-80,82-85,87-90,92-109,111-119,121-133"
},
{
"line": 32152,
"text": "main/…/OutboxProperties.java:7-22,31-32,34-59,61-74"
},
{
"line": 32153,
"text": "main/…/OutboxCleanupJob.java:7-15,22-36,38-57"
},
{
"line": 32154,
"text": "main/…/OutboxRelayReport.java:3-26,30-49"
},
{
"line": 32155,
"text": "main/…/OutboxEnvelopeFactory.java:20-38,43-50,52-104,106-123"
},
{
"line": 32156,
"text": "main/…/JdbcAdminOperationJournal.java:22-33,36-72,79-82,84-120,122-140,142-160,162-192,217-235,237-245,247-262,263-271,273-283,285-318,320-324"
},
{
"line": 32157,
"text": "main/…/DebeziumOutboxProfile.java:6-18,22-28,30-36,38-45,47-66"
},
{
"line": 32158,
"text": "main/…/DebeziumOutboxEventRouter.java:10-34,37-47,49-85,87-136,138-150"
},
{
"line": 32159,
"text": "main/…/DebeziumOutboxRecordMapper.java:10-27,30-32,34-43,45-68,70-78"
},
{
"line": 32160,
"text": "main/…/DebeziumMappedRecord.java:8-23,25-34,36-53"
},
{
"line": 32161,
"text": ""
},
{
"line": 32162,
"text": "test/…/OutboxPostgresIT.java (메서드 인벤토리 21건; 195-202, 503-521, 538-560 본문 확인)"
},
{
"line": 32163,
"text": "test/…/OutboxOperationsTest.java:105-190 (RecordingRepository + cleanup 3건 본문 확인)"
},
{
"line": 32164,
"text": "test/…/DebeziumOutboxRecordMapperTest.java:150-200 (본문 확인), 58-148 (메서드명)"
},
{
"line": 32165,
"text": "test/…/OutboxRelayTest.java / OutboxRelayWorkerTest.java / OutboxEnvelopeFactoryTest.java /"
},
{
"line": 32166,
"text": "test/…/JdbcOutboxTransactionRequirementTest.java / AdminOperationJournalPostgresIT.java (메서드 인벤토리)"
},
{
"line": 32167,
"text": ""
},
{
"line": 32168,
"text": "src/messaging/messaging-spring-boot-starter/.../MessagingReliabilityAutoConfiguration.java:63,89,109,140-141,169-170"
},
{
"line": 32169,
"text": "src/messaging/messaging-spring-boot-starter/.../MessagingOutboxRelayLifecycle.java:42"
},
{
"line": 32170,
"text": "src/messaging/messaging-core-api/.../header/HeaderValue.java:5-25"
},
{
"line": 32171,
"text": "src/messaging/messaging-inbox-jdbc-postgresql/.../InboxCleanupJob.java:56"
},
{
"line": 32172,
"text": "src/app-bootstrap/src/test/.../MessagingCapabilityRegistryContractTest.java:61"
},
{
"line": 32173,
"text": "```"
},
{
"line": 32174,
"text": ""
},
{
"line": 32175,
"text": "#### 기록이 인용한 원문 — `21234e38`"
},
{
"line": 32176,
"text": ""
},
{
"line": 32177,
"text": "> `tech-log-studio/` 의 기록이 인용한 코드가 이 문서에 없었다(`check_evidence --repo`). 인용한 줄은 고정 리비전 `21234e38` 에 실재하는 것을"
},
{
"line": 32178,
"text": "> `git grep -F` 로 확인했고, 없던 쪽은 이 문서였다. **옮겨 적은 문장이 아니라 저장소"
},
{
"line": 32179,
"text": "> 원문을 담는다** — 기록을 복사해 넣으면 옮겨 적기가 어긋나도 검사기가 더는 못 잡는다."
},
{
"line": 32180,
"text": ""
},
{
"line": 32181,
"text": "`src/messaging/messaging-outbox-jdbc-postgresql/src/main/resources/db/migration/messaging/V2__messaging_outbox_lease_fencing.sql:16-23` — `concept-fenced-lease.md` 가 인용한다."
},
{
"line": 32182,
"text": ""
},
{
"line": 32183,
"text": "```sql"
},
{
"line": 32184,
"text": " ADD COLUMN lease_owner VARCHAR(160),"
},
{
"line": 32185,
"text": " ADD COLUMN lease_token BIGINT NOT NULL DEFAULT 0,"
},
{
"line": 32186,
"text": " ADD COLUMN next_attempt_at TIMESTAMPTZ;"
},
{
"line": 32187,
"text": ""
},
{
"line": 32188,
"text": "-- Backfill is unnecessary for correctness — the default is 0 and the first claim increments it —"
},
{
"line": 32189,
"text": "-- but the constraint states the invariant the code depends on."
},
{
"line": 32190,
"text": "ALTER TABLE messaging_outbox"
},
{
"line": 32191,
"text": " ADD CONSTRAINT ck_messaging_outbox_lease_token CHECK (lease_token >= 0);"
},
{
"line": 32192,
"text": "```"
},
{
"line": 32193,
"text": ""
},
{
"line": 32194,
"text": ""
},
{
"line": 32195,
"text": "---"
},
{
"line": 32196,
"text": ""
},
{
"line": 32197,
"text": "## A19-MESSAGING-POLICY. messaging-policy"
},
{
"line": 32198,
"text": ""
},
{
"line": 32199,
"text": "> 분석 중에는 `messaging/MESSAGING-POLICY.md` 파일이었다. 880줄."
},
{
"line": 32200,
"text": ""
},
{
"line": 32201,
"text": "### messaging-policy 완전 해부"
},
{
"line": 32202,
"text": ""
},
{
"line": 32203,
"text": "> 상태: COMPLETE"
},
{
"line": 32204,
"text": "> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`"
},
{
"line": 32205,
"text": "> 분석 범위: `src/messaging/messaging-policy`"
},
{
"line": 32206,
"text": "> SSOT owner: `messaging-policy`"
},
{
"line": 32207,
"text": "> integration/family document: §A19 (secondary, INTEGRATION_ONLY)"
},
{
"line": 32208,
"text": ""
},
{
"line": 32209,
"text": "---"
},
{
"line": 32210,
"text": ""
},
{
"line": 32211,
"text": "#### 0. SSOT identity / 커버리지와 숫자 지도"
},
{
"line": 32212,
"text": ""
},
{
"line": 32213,
"text": "- registered leaf id: `messaging-policy`"
},
{
"line": 32214,
"text": "- canonical state `analysisFile`: §A19-MESSAGING-POLICY"
},
{
"line": 32215,
"text": "- source path: `src/messaging/messaging-policy`"
},
{
"line": 32216,
"text": "- registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-schema-api\"]`"
},
{
"line": 32217,
"text": "- registry `runtime_memberships`: `[\"app-bootstrap\"]`"
},
{
"line": 32218,
"text": ""
},
{
"line": 32219,
"text": "##### 숫자"
},
{
"line": 32220,
"text": ""
},
{
"line": 32221,
"text": "| 항목 | 수 |"
},
{
"line": 32222,
"text": "|---|---:|"
},
{
"line": 32223,
"text": "| production Java 파일 | 26 |"
},
{
"line": 32224,
"text": "| production LOC | 1,738 |"
},
{
"line": 32225,
"text": "| 패키지 | 1 (`dev.caskeleton.messaging.policy`) |"
},
{
"line": 32226,
"text": "| test 파일 | 4 |"
},
{
"line": 32227,
"text": "| test 메서드(실행 확인) | 42 |"
},
{
"line": 32228,
"text": "| 외부(비프로젝트) 의존성 | **0** |"
},
{
"line": 32229,
"text": ""
},
{
"line": 32230,
"text": "26개 타입을 관심사로 나누면 다섯이다."
},
{
"line": 32231,
"text": ""
},
{
"line": 32232,
"text": "| 축 | 타입 |"
},
{
"line": 32233,
"text": "|---|---|"
},
{
"line": 32234,
"text": "| **목적지 정의** (8) | `DestinationProfile` · `PhysicalDestination` · `SchemaPolicy` · `ProducerPolicy` · `ConsumerPolicy` · `PayloadPolicy` · `DeadLetterPolicy` · `CapabilityTier` |"
},
{
"line": 32235,
"text": "| **시작 검증** (1) | `DestinationProfileValidator` |"
},
{
"line": 32236,
"text": "| **발행 관문** (3) | `MessagingAdmissionController` · `PayloadLimitGuard` · `InFlightLimiter` |"
},
{
"line": 32237,
"text": "| **재시도 판단** (8) | `RetryPolicy` · `RetryMode` · `OrderingImpact` · `RetryContext` · `RetryDecision` · `RetryDecisionEngine` · `DefaultRetryDecisionEngine` · `BackoffCalculator` |"
},
{
"line": 32238,
"text": "| **DLQ 조정** (6) | `DeadLetterOrchestrator` · `DeadLetterEnvelopeFactory` · `DeadLetterMetadata` · `DeadLetterResult` · `SourceSettlement` · `FailureDescriptorDefaults`(package-private) |"
},
{
"line": 32239,
"text": ""
},
{
"line": 32240,
"text": "**다섯 축의 배선 상태가 서로 다르다.** 목적지 정의·시작 검증·발행 관문은 출하 컨텍스트에서 실제로 실행되고, 재시도 판단과 DLQ 조정은 bean으로 생성되지만 주입되는 곳이 없다(§12.1)."
},
{
"line": 32241,
"text": ""
},
{
"line": 32242,
"text": "##### Coverage ledger"
},
{
"line": 32243,
"text": ""
},
{
"line": 32244,
"text": "| scope/file group | count | disposition | reason |"
},
{
"line": 32245,
"text": "|---|---:|---|---|"
},
{
"line": 32246,
"text": "| `src/main/java/**` (26) | 26 | `FULL_READ` | 전 파일 본문 확인 |"
},
{
"line": 32247,
"text": "| `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 및 단언 확인 |"
},
{
"line": 32248,
"text": "| `build.gradle` | 1 | `FULL_READ` | 6줄 |"
},
{
"line": 32249,
"text": "| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |"
},
{
"line": 32250,
"text": "| `build/**` | — | `EXCLUDED` | 빌드 산출물 |"
},
{
"line": 32251,
"text": ""
},
{
"line": 32252,
"text": "`UNCLASSIFIED` 0."
},
{
"line": 32253,
"text": ""
},
{
"line": 32254,
"text": "---"
},
{
"line": 32255,
"text": ""
},
{
"line": 32256,
"text": "#### 1. 모듈의 정체와 경계"
},
{
"line": 32257,
"text": ""
},
{
"line": 32258,
"text": "이 leaf는 **\"이 목적지는 무엇을 약속하는가\"**를 소유한다. 브로커를 만지지 않고 벤더 의존성이 0이며, 대신 브로커 어댑터가 따라야 할 판단을 미리 계산한다."
},
{
"line": 32259,
"text": ""
},
{
"line": 32260,
"text": "경계 규칙 하나가 leaf 전체를 관통한다: **모순은 부팅 실패여야 한다.**"
},
{
"line": 32261,
"text": ""
},
{
"line": 32262,
"text": "```java"
},
{
"line": 32263,
"text": "// DestinationProfileValidator.java:20-24"
},
{
"line": 32264,
"text": " * Every rule here exists because the alternative is a production surprise. A profile that asks"
},
{
"line": 32265,
"text": " * for ordered delivery and configures a reordering retry does not fail on the happy path; it fails"
},
{
"line": 32266,
"text": " * the first time a message is retried, months later, in a way that looks like a data bug rather"
},
{
"line": 32267,
"text": " * than a configuration one. Making the contradiction a boot failure moves that discovery to the"
},
{
"line": 32268,
"text": " * deploy that introduced it."
},
{
"line": 32269,
"text": "```"
},
{
"line": 32270,
"text": ""
},
{
"line": 32271,
"text": "두 번째 경계는 **물리 주소의 격리**다."
},
{
"line": 32272,
"text": ""
},
{
"line": 32273,
"text": "```java"
},
{
"line": 32274,
"text": "// PhysicalDestination.java:9-11"
},
{
"line": 32275,
"text": " * Held here and nowhere else. Once a topic name reaches application code the logical destination"
},
{
"line": 32276,
"text": " * stops being a boundary, and swapping the broker under a service becomes a code change instead of"
},
{
"line": 32277,
"text": " * a configuration change."
},
{
"line": 32278,
"text": "```"
},
{
"line": 32279,
"text": ""
},
{
"line": 32280,
"text": "`messaging-core-api`의 `DestinationName`이 `:`과 `/`를 정규식으로 막고(그쪽 §4), 이 leaf가 물리 주소를 독점한다. 두 leaf가 같은 경계를 양쪽에서 지킨다."
},
{
"line": 32281,
"text": ""
},
{
"line": 32282,
"text": "---"
},
{
"line": 32283,
"text": ""
},
{
"line": 32284,
"text": "#### 2. 의존성과 런타임 배선"
},
{
"line": 32285,
"text": ""
},
{
"line": 32286,
"text": "들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api). 둘 다 `api`인 이유는 `DestinationProfile`이 `DeliveryGuarantee`·`OrderingScope`·`DestinationKind`·`DestinationName`(core-api)와 `SchemaCompatibility`(schema-api)를 필드로 갖기 때문이다."
},
{
"line": 32287,
"text": ""
},
{
"line": 32288,
"text": "나가는 것: `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`."
},
{
"line": 32289,
"text": ""
},
{
"line": 32290,
"text": "**실제 배선 지점 넷**(전부 `messaging-spring-boot-starter/MessagingCoreAutoConfiguration`):"
},
{
"line": 32291,
"text": ""
},
{
"line": 32292,
"text": "| 지점 | 라인 | 상태 |"
},
{
"line": 32293,
"text": "|---|---:|---|"
},
{
"line": 32294,
"text": "| `new DestinationProfileValidator().validateAll(registered)` | 134 | **실행됨** — 시작 시 전체 registry 검증 |"
},
{
"line": 32295,
"text": "| `DestinationProfileValidator` bean | 145–146 | 생성 |"
},
{
"line": 32296,
"text": "| `MessagingAdmissionController` bean | 407–417 | 생성 + `DefaultMessagePublisher`·`MessagingEndpoint`·`MessagingShutdownLifecycle`이 주입받음 |"
},
{
"line": 32297,
"text": "| `RetryDecisionEngine` bean | 167–169 | 생성, **주입처 없음**(§12.1) |"
},
{
"line": 32298,
"text": "| `DeadLetterOrchestrator` bean | 179–181 | 생성, **주입처 없음**(§12.1) |"
},
{
"line": 32299,
"text": ""
},
{
"line": 32300,
"text": "이 leaf 자체는 Spring 주석을 갖지 않는다 — bean 정의는 전부 starter 쪽에 있다."
},
{
"line": 32301,
"text": ""
},
{
"line": 32302,
"text": "---"
},
{
"line": 32303,
"text": ""
},
{
"line": 32304,
"text": "#### 3. 패키지/컴포넌트 지도"
},
{
"line": 32305,
"text": ""
},
{
"line": 32306,
"text": "```"
},
{
"line": 32307,
"text": "[목적지 정의]"
},
{
"line": 32308,
"text": "DestinationProfile ─┬─ PhysicalDestination (topic/exchange/routingKey/queue/subject/stream)"
},
{
"line": 32309,
"text": " ├─ SchemaPolicy (codec, compatibility, 닫힌 messageTypes)"
},
{
"line": 32310,
"text": " ├─ ProducerPolicy (confirmation, timeout, mandatoryRouting, idempotent)"
},
{
"line": 32311,
"text": " ├─ ConsumerPolicy (group, concurrency, maxInFlightPerUnit, prefetch, timeout, manual)"
},
{
"line": 32312,
"text": " ├─ RetryPolicy (mode, maxAttempts, backoff, orderingImpact, 카테고리 오버라이드)"
},
{
"line": 32313,
"text": " ├─ DeadLetterPolicy (enabled, destination, maxRedriveCount)"
},
{
"line": 32314,
"text": " ├─ PayloadPolicy (maxBytes, claimCheckThreshold)"
},
{
"line": 32315,
"text": " └─ CapabilityTier (M1/M2/M3)"
},
{
"line": 32316,
"text": ""
},
{
"line": 32317,
"text": "[시작 검증] DestinationProfileValidator"
},
{
"line": 32318,
"text": " ├─ validate(profile) : 프로파일 내부 모순 15가지"
},
{
"line": 32319,
"text": " └─ validateAll(profiles) : 중복 이름 + retry/DLQ 그래프 사이클"
},
{
"line": 32320,
"text": ""
},
{
"line": 32321,
"text": "[발행 관문] MessagingAdmissionController"
},
{
"line": 32322,
"text": " ├─ PayloadLimitGuard ── PayloadPolicy"
},
{
"line": 32323,
"text": " └─ InFlightLimiter (Semaphore, fair)"
},
{
"line": 32324,
"text": ""
},
{
"line": 32325,
"text": "[재시도 판단] RetryContext ─→ RetryDecisionEngine ─→ RetryDecision (sealed 5)"
},
{
"line": 32326,
"text": " ↑"
},
{
"line": 32327,
"text": " DefaultRetryDecisionEngine ── BackoffCalculator"
},
{
"line": 32328,
"text": ""
},
{
"line": 32329,
"text": "[DLQ 조정] DeadLetterOrchestrator ─┬─ DeadLetterEnvelopeFactory ── DeadLetterMetadata"
},
{
"line": 32330,
"text": " └─ SourceSettlement → DeadLetterResult"
},
{
"line": 32331,
"text": "```"
},
{
"line": 32332,
"text": ""
},
{
"line": 32333,
"text": "---"
},
{
"line": 32334,
"text": ""
},
{
"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": ""
},
{
"line": 32651,
"text": "#### 7. 트랜잭션·동시성·수명주기"
},
{
"line": 32652,
"text": ""
},
{
"line": 32653,
"text": "트랜잭션 없음."
},
{
"line": 32654,
"text": ""
},
{
"line": 32655,
"text": "동시성 지점은 `MessagingAdmissionController`와 `InFlightLimiter` 둘이다."
},
{
"line": 32656,
"text": ""
},
{
"line": 32657,
"text": "| 지점 | 도구 | 보호 |"
},
{
"line": 32658,
"text": "|---|---|---|"
},
{
"line": 32659,
"text": "| `perDestination` 맵 | `ConcurrentHashMap` + `computeIfAbsent` | 목적지 카운터 생성 |"
},
{
"line": 32660,
"text": "| 목적지 카운터 증가 | `AtomicInteger` CAS 루프 | 천장 초과 방지 |"
},
{
"line": 32661,
"text": "| 목적지 카운터 감소 | `getAndUpdate` + 0 clamp | 음수 방지 |"
},
{
"line": 32662,
"text": "| 맵 항목 제거 | `computeIfPresent` (원자) | 0일 때만 제거, 누수 방지 |"
},
{
"line": 32663,
"text": "| `acceptingNewWork` | `volatile boolean` | 종료 플래그 가시성 |"
},
{
"line": 32664,
"text": "| permit | `Semaphore(limit, true)` — **fair** | 유한 대기 보장 |"
},
{
"line": 32665,
"text": "| permit 반납 | `availablePermits() < limit` 확인 | 천장 상승 방지 |"
},
{
"line": 32666,
"text": ""
},
{
"line": 32667,
"text": "`reserve`의 CAS 루프는 `AtomicInteger.updateAndGet`으로 쓸 수 있었지만 조건부 실패(`return false`)가 필요해서 직접 루프를 돈다."
},
{
"line": 32668,
"text": ""
},
{
"line": 32669,
"text": "`release`에 **미세한 경합**이 있다. `getAndUpdate`로 감소한 뒤 `computeIfPresent`로 0인 항목을 제거하는데, 그 사이에 다른 스레드가 `computeIfAbsent`로 같은 키를 만들고 증가시킬 수 있다. 그러면 `computeIfPresent`의 람다가 `value.get() == 0`을 보지 못해 제거하지 않는다 — 안전한 방향의 경합이다(누수가 아니라 제거 실패). 반대 순서였다면 살아 있는 카운터를 지울 수 있었다."
},
{
"line": 32670,
"text": ""
},
{
"line": 32671,
"text": "`DefaultRetryDecisionEngine`·`BackoffCalculator`·`DeadLetterOrchestrator`·`DeadLetterEnvelopeFactory`·`DestinationProfileValidator`는 전부 상태가 없거나 불변이다. `BackoffCalculator`의 기본 생성자가 `ThreadLocalRandom`을 쓰므로 스레드 안전하다."
},
{
"line": 32672,
"text": ""
},
{
"line": 32673,
"text": "수명주기 참여는 `stopAcceptingNewWork()` 하나이고, `MessagingShutdownLifecycle`(starter)이 종료 1단계에서 부른다(`messaging-transport-spi` §12.1 참조)."
},
{
"line": 32674,
"text": ""
},
{
"line": 32675,
"text": "---"
},
{
"line": 32676,
"text": ""
},
{
"line": 32677,
"text": "#### 8. 설정·기능 플래그·환경 차이"
},
{
"line": 32678,
"text": ""
},
{
"line": 32679,
"text": "설정 파일 없음. 상수와 기본값:"
},
{
"line": 32680,
"text": ""
},
{
"line": 32681,
"text": "| 상수/기본값 | 값 | 위치 |"
},
{
"line": 32682,
"text": "|---|---:|---|"
},
{
"line": 32683,
"text": "| `PayloadPolicy.DEFAULT_MAX_BYTES` | 1,048,576 | `PayloadPolicy.java:17` (public) |"
},
{
"line": 32684,
"text": "| `PayloadPolicy.HARD_MAX_BYTES` | 8,388,608 | `:20` (public) |"
},
{
"line": 32685,
"text": "| `ProducerPolicy.defaults()` | `REPLICATION_OR_PERSISTENCE_ACK`, 5초, mandatoryRouting, idempotent | `:34-37` |"
},
{
"line": 32686,
"text": "| `ConsumerPolicy.defaults(group)` | concurrency 1, maxInFlightPerUnit 1, prefetch 16, timeout 30초, manual false | `:52-54` |"
},
{
"line": 32687,
"text": "| `RetryPolicy.none()` | mode NONE, 1회, 지연 0, PRESERVE | `:115-125` |"
},
{
"line": 32688,
"text": "| `DeadLetterPolicy.disabled()` / `.to(dest)` | maxRedrive 0 / 1 | `:32-44` |"
},
{
"line": 32689,
"text": ""
},
{
"line": 32690,
"text": "**모든 기본값이 보수적이다** — 재시도 없음, 동시성 1, 순서 보존, 확인 최대, DLQ 비활성. 켜는 것이 명시적 선택이다."
},
{
"line": 32691,
"text": ""
},
{
"line": 32692,
"text": "`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.\""
},
{
"line": 32693,
"text": ""
},
{
"line": 32694,
"text": "`PayloadPolicy.DEFAULT_MAX_BYTES`는 이 저장소에서 1 MiB 상한을 선언하는 다섯 곳 중 하나이고 **정책 축의 자연스러운 주인**이다. 그런데 starter는 이것 대신 `JacksonMessageCodec.DEFAULT_MAX_BYTES`를 참조한다 — §A19-MESSAGING-SCHEMA-JSON §17이 소유한다."
},
{
"line": 32695,
"text": ""
},
{
"line": 32696,
"text": "---"
},
{
"line": 32697,
"text": ""
},
{
"line": 32698,
"text": "#### 9. 퍼시스턴스/외부 시스템 세부"
},
{
"line": 32699,
"text": ""
},
{
"line": 32700,
"text": "없다. 브로커·DB·파일시스템을 만지지 않는다. `ThreadLocalRandom`(jitter)과 `Semaphore`가 유일한 런타임 자원이다."
},
{
"line": 32701,
"text": ""
},
{
"line": 32702,
"text": "---"
},
{
"line": 32703,
"text": ""
},
{
"line": 32704,
"text": "#### 10. 테스트 레인과 실제 증명 범위"
},
{
"line": 32705,
"text": ""
},
{
"line": 32706,
"text": "레인: `./gradlew :messaging:messaging-policy:test`. **BUILD SUCCESSFUL, 42 tests, 0 skipped, 0 failures**."
},
{
"line": 32707,
"text": ""
},
{
"line": 32708,
"text": "| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |"
},
{
"line": 32709,
"text": "|---|---:|---|---|"
},
{
"line": 32710,
"text": "| `DestinationProfileValidatorTest` | 13 | 순서/페이로드/DLQ 자기참조/키 리졸버/M1 수동정산/확인/토폴로지/DLQ 필요, **retry↔DLQ 교대 사이클 거절**, **다이아몬드 허용**, 미등록 목적지 거절 | 실제 부팅에서 이 검증이 호출되는지(→ starter가 부른다, §2) |"
},
{
"line": 32711,
"text": "| `MessagingAdmissionControllerTest` | 13 | permit 점유/반납, 초과 시 큐잉 대신 거절, backpressure가 retryable, 초과 payload가 permit을 안 먹음, 종료 시 기존 permit 유지, 불균형 반납이 천장을 못 올림, 한 목적지가 전부 못 먹음, 거절이 슬롯을 안 남김, 완료가 둘 다 반납, 미지 목적지 완료가 permit을 안 품, 이중 완료, 배치 두 축, 대기 후 승인 | 실제 부하에서의 공정성 |"
},
{
"line": 32712,
"text": "| `RetryDecisionEngineTest` | 10 | 역직렬화 실패 즉시 파킹, 인증/구성 실패 미재시도, 순서 Kafka는 pause, 소진은 DLQ, 비순서 재시도목적지 재발행, blocking은 inline, **지수 증가와 상한**, **full jitter 분포**, 프로파일 오버라이드, at-most-once DLQ 없으면 discard | **이 엔진이 production에서 호출되는지** |"
},
{
"line": 32713,
"text": "| `DeadLetterOrchestratorTest` | 6 | 확인 후에만 원본 정산, 모호하면 미정산, 거절되면 미정산, 헤더 부착 | **이 orchestrator가 production에서 호출되는지** |"
},
{
"line": 32714,
"text": ""
},
{
"line": 32715,
"text": "**두 축의 증명 성격이 다르다.** 검증기와 관문은 배선까지 확인되지만(§2), 재시도 엔진과 DLQ 조정자는 로직만 증명되고 배선은 §12.1이 부정한다. 테스트가 통과한다는 것이 그 코드가 실행된다는 뜻이 아닌 전형적인 예다."
},
{
"line": 32716,
"text": ""
},
{
"line": 32717,
"text": "`MessagingAdmissionControllerTest`의 `as(...)` 문구들이 특히 구체적이다 — \"a slot leaked per refusal shrinks the destination's ceiling until it is zero\", \"a permit nobody took cannot be given back; doing so makes the ceiling fiction\". 각 테스트가 어떤 이전 결함을 붙들고 있는지 이름 자체가 말한다."
},
{
"line": 32718,
"text": ""
},
{
"line": 32719,
"text": "---"
},
{
"line": 32720,
"text": ""
},
{
"line": 32721,
"text": "#### 11. 빌드/ArchUnit/CI 강제 지점"
},
{
"line": 32722,
"text": ""
},
{
"line": 32723,
"text": "| 게이트 | 이 leaf에 대해 |"
},
{
"line": 32724,
"text": "|---|---|"
},
{
"line": 32725,
"text": "| `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\",\"messaging-schema-api\"]` |"
},
{
"line": 32726,
"text": "| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |"
},
{
"line": 32727,
"text": "| vendor `api` 규칙 | 벤더 의존성 0 |"
},
{
"line": 32728,
"text": "| **부팅 검증** | `MessagingCoreAutoConfiguration:134`가 `validateAll`을 호출 — 이 leaf의 규칙이 실제로 부팅을 막는 유일한 지점 |"
},
{
"line": 32729,
"text": "| ArchUnit | 전용 규칙 없음 |"
},
{
"line": 32730,
"text": ""
},
{
"line": 32731,
"text": "§4.1의 15가지 규칙은 **ArchUnit이 아니라 런타임 시작 시점**에 강제된다. `verifyCleanArchitectureDependencies`가 빌드 타임에 도는 것과 대비된다. 잘못된 프로파일은 컴파일되고, 부팅에서 막힌다."
},
{
"line": 32732,
"text": ""
},
{
"line": 32733,
"text": "---"
},
{
"line": 32734,
"text": ""
},
{
"line": 32735,
"text": "#### 12. 실제 사용 여부와 negative-space probes"
},
{
"line": 32736,
"text": ""
},
{
"line": 32737,
"text": "원시 증거: `evidence/raw/281-messaging-policy-retry-engine-unwired.txt`."
},
{
"line": 32738,
"text": ""
},
{
"line": 32739,
"text": "> **방법 주의.** 이 절의 조립 판정은 `new ([a-zA-Z0-9_.]+\\.)? The relay's correctness rests on one rule: an ambiguous publish is retried under the same\n31257 | * message id. Minting a new id would turn a possibly-delivered message into a\n31258 | * definitely-second message, and no downstream deduplication could recover from it. Marking it\n31259 | * failed instead would lose a message the broker may already hold.\n31260 | *\n31261 | * The relay therefore guarantees at-least-once publication and nothing more. Effectively-once\n31262 | * downstream effects come from pairing it with an Inbox — which is why the platform never\n31263 | * advertises the outbox as exactly-once.\n31264 | */\n31265 | ```\n31266 | \n31267 | 마지막 문장이 중요하다 — 이 리프가 자기 보장의 상한을 스스로 명시한다.\n31268 | \n31269 | 경계: 브로커를 모른다(`MessagePublisher` 포트만 안다). 스프링 컨텍스트를 모른다(`spring-jdbc`/`spring-tx` 는 `implementation` 이며 트랜잭션 동기화 조회에만 쓴다). 배선은 starter 몫이다.\n31270 | \n31271 | ---\n31272 | \n31273 | #### 2. 의존성과 런타임 배선\n31274 | \n31275 | ```groovy\n31276 | // build.gradle 전문 (24줄)\n31277 | apply plugin: 'java-library'\n31278 | \n31279 | dependencies {\n31280 | api project(':messaging:messaging-core-api')\n31281 | api project(':messaging:messaging-reliability-api')\n31282 | api project(':messaging:messaging-policy')\n31283 | api project(':messaging:messaging-observability')\n31284 | api project(':messaging:messaging-admin-api') // + 위 주석\n31285 | \n31286 | implementation 'org.springframework:spring-jdbc'\n31287 | implementation 'org.springframework:spring-tx'\n31288 | \n31289 | // Live-database certification. The reliability patterns are claims about transaction\n31290 | // boundaries and uniqueness constraints, and only a real database can settle them.\n31291 | testImplementation project(':messaging:messaging-testkit')\n31292 | testImplementation 'org.testcontainers:testcontainers-postgresql'\n31293 | testImplementation 'org.testcontainers:testcontainers-junit-jupiter'\n31294 | testImplementation 'org.postgresql:postgresql'\n31295 | }\n31296 | ```\n31297 | \n31298 | testcontainers 주석이 이 리프의 성격을 요약한다 — \"신뢰성 패턴은 트랜잭션 경계와 유일성 제약에 대한 주장이고, 그것을 결판낼 수 있는 것은 실제 데이터베이스뿐이다.\" 그리고 그 레인이 **실제로 돈다**(§10).\n31299 | \n31300 | starter 가 만드는 빈(`EVD-312`):\n31301 | \n31302 | ```java\n31303 | // MessagingReliabilityAutoConfiguration.java\n31304 | :63 new OutboxRetryScheduler(properties, Duration.ofMinutes(1))\n31305 | :89 new OutboxRelay(...)\n31306 | :109 new OutboxRelayWorker(relay, scheduler)\n31307 | :141 new OutboxCleanupJob(outbox, properties, 20)\n31308 | :170 new InboxCleanupJob(inbox, policy, 20)\n31309 | // MessagingOutboxRelayLifecycle.java\n31310 | :42 worker.start();\n31311 | ```\n31312 | \n31313 | starter 가 만들지 **않는** 것: `JdbcOutboxRepository`, `OutboxEnvelopeFactory`, `JdbcAdminOperationJournal`. 셋 다 애플리케이션이 `DataSource`/`ProducerId` 를 알고 직접 등록해야 한다. `AdminOperationJournal` 의 기본값은 `InMemoryAdminOperationJournal` 이며, 프로덕션 프로파일에서는 `MessagingAdminDurabilityValidator` 가 그것을 거부한다(§A19-MESSAGING-ADMIN-RUNTIME §4.4 참조).\n31314 | \n31315 | ---\n31316 | \n31317 | #### 3. 패키지/컴포넌트 지도\n31318 | \n31319 | 단일 패키지 `dev.caskeleton.messaging.outbox`. 두 갈래의 배출 경로가 있고, 한쪽만 살아 있다.\n31320 | \n31321 | ```\n31322 | [비즈니스 트랜잭션]\n31323 | | JdbcOutboxRepository.append(record) — 호출자의 커넥션에 합류, 없으면 거절\n31324 | v\n31325 | messaging_outbox 테이블\n31326 | |\n31327 | +--- 경로 A: 폴링 릴레이 (배선됨)\n31328 | | OutboxRelayWorker.start() -> runPass()\n31329 | | -> OutboxRelay.runOnce(now)\n31330 | | claimBatch(owner, batchSize, lease, now, maxAttempts) FOR UPDATE SKIP LOCKED\n31331 | | -> OutboxEnvelopeFactory.toEnvelope(row)\n31332 | | -> MessagePublisher.publish(...)\n31333 | | -> markPublished / markAmbiguous / markExhausted / markFailed (펜싱 술어)\n31334 | | -> OutboxRetryScheduler.backoff(unproductivePasses)\n31335 | |\n31336 | +--- 경로 B: CDC 릴레이 (배선 안 됨 — §12.1)\n31337 | DebeziumOutboxProfile(CHANGE_DATA_CAPTURE, prefix, flag)\n31338 | -> DebeziumOutboxRecordMapper.map(row) -> DebeziumMappedRecord [모델]\n31339 | -> DebeziumOutboxEventRouter.connectorConfiguration(prefix) [Java 설정]\n31340 | debezium/outbox-event-router.properties [배포 설정 — 드리프트]\n31341 | \n31342 | messaging_admin_operation 테이블\n31343 | | JdbcAdminOperationJournal (begin/checkpoint/complete/fail/find)\n31344 | ```\n31345 | \n31346 | ---\n31347 | \n31348 | #### 4. 계약·불변식·상태 모델\n31349 | \n31350 | ##### 4.1 스키마 — 마이그레이션 4개가 이력을 담고 있다\n31351 | \n31352 | **V1** — `message_id` 를 대리키가 아니라 기본키로 삼는다.\n31353 | \n31354 | ```sql\n31355 | -- V1__messaging_outbox.sql:3-5\n31356 | -- Written by the business transaction, drained by the relay. message_id is the primary key rather\n31357 | -- than a surrogate: it is the logical identity the relay must preserve across every retry, and\n31358 | -- making it the key means no code path can accidentally publish the same row under a new id.\n31359 | ```\n31360 | \n31361 | 인덱스도 근거가 있다. 부분 인덱스인 이유(\"PUBLISHED rows accumulate until the retention job removes them\"), `IN_FLIGHT` 를 포함하는 이유(\"A relay that dies mid-publish leaves rows in that state ... omitting them here would strand those messages\").\n31362 | \n31363 | **V2** — 펜싱 토큰. 주석이 시나리오를 그대로 적는다.\n31364 | \n31365 | ```sql\n31366 | -- V2__messaging_outbox_lease_fencing.sql:3-14\n31367 | -- V1 recorded only lease_expires_at, so a claim said when it would end and nothing about who held\n31368 | -- it. ... :\n31369 | -- relay A claims the row and calls the broker\n31370 | -- the lease expires; relay B reclaims it, publishes, and records PUBLISHED\n31371 | -- relay A finally times out and records AMBIGUOUS over the top\n31372 | -- The row is now claimable again and the message is published a second time. Making the lease\n31373 | -- longer than the publish timeout lowers the odds; it does not turn a GC pause, a scheduler stall\n31374 | -- or a slow broker into a data constraint. A token does ...\n31375 | ```\n31376 | \n31377 | \"확률을 낮추는 것과 데이터 제약으로 만드는 것은 다르다\" — 이 리프에서 가장 좋은 한 줄이다. `EXHAUSTED` 상태 추가와 `next_attempt_at` 인덱스도 여기서 들어온다.\n31378 | \n31379 | **V3** — admin 저널. 복합 기본키 `(approval_ticket, plan_digest)` 의 근거가 `messaging-admin-api` 의 것과 동일하게 적혀 있다.\n31380 | \n31381 | **V4** — 정경 메타데이터 12컬럼. 왜 봉투 blob 이 아니라 컬럼인지가 명확하다.\n31382 | \n31383 | ```sql\n31384 | -- V4:11-14\n31385 | -- Columns rather than a versioned envelope blob. Both round-trip the values faithfully; only one of\n31386 | -- them lets the relay answer an operator's questions. \"Which tenant is the backlog for\", \"which\n31387 | -- correlation is stuck\", \"which rows carry a schema this consumer cannot read\" are SELECTs against\n31388 | -- this table if the fields are columns, and payload decoding of the whole backlog if they are not.\n31389 | ```\n31390 | \n31391 | 그리고 밀반입 문제를 명시한다 — \"smuggled through the header map under the reserved `msg.*` names ... a row whose header map contains `msg.id` overwrites another message's identity on the wire\".\n31392 | \n31393 | DB 레벨 제약을 Java 와 이중으로 거는 이유도 적혀 있다.\n31394 | \n31395 | ```sql\n31396 | -- V4:33-36\n31397 | -- The same bound TenantContext enforces in Java. Stated here as well because the relay, the CDC\n31398 | -- connector and any operator query read this table directly: a tenant slug that only the\n31399 | -- application validates is a tenant slug that an INSERT from anywhere else can violate ...\n31400 | ALTER TABLE messaging_outbox ADD CONSTRAINT ck_messaging_outbox_tenant\n31401 | CHECK (tenant IS NULL OR tenant ~ '^[a-z0-9][a-z0-9._-]{0,63}$');\n31402 | ```\n31403 | \n31404 | 마지막으로 **생성 컬럼**이 두 릴레이의 합의를 하나로 만든다.\n31405 | \n31406 | ```sql\n31407 | -- V4:55-66\n31408 | -- Debezium's Event Router takes the message key from a column. It was pointed at `destination`,\n31409 | -- which made the key the topic name — every message on a topic sharing one key, so every message\n31410 | -- landing on one partition, and keyed ordering meaning nothing. The polling relay meanwhile used\n31411 | -- the partition key when the row had one and the message id when it did not.\n31412 | --\n31413 | -- A generated column states that fallback once, in the place both relays read, instead of leaving\n31414 | -- it as a rule each of them implements separately and one of them gets wrong.\n31415 | ALTER TABLE messaging_outbox\n31416 | ADD COLUMN routing_key TEXT GENERATED ALWAYS AS (COALESCE(partition_key, message_id::TEXT)) STORED;\n31417 | ```\n31418 | \n31419 | **이 수정이 배포되는 properties 파일에는 도달하지 않았다.** §12.4(a).\n31420 | \n31421 | ##### 4.2 `append` — 이 리프의 전체 메커니즘\n31422 | \n31423 | ```java\n31424 | // JdbcOutboxRepository.java:37-46\n31425 | /**\n31426 | * {@link #append} deliberately takes no connection of its own: it uses the one the caller is\n31427 | * already inside, which is the entire mechanism. An outbox row written on a separate connection\n31428 | * commits independently of the business change and reopens the window the pattern exists to close.\n31429 | */\n31430 | ```\n31431 | \n31432 | 그리고 그것을 **강제**한다.\n31433 | \n31434 | ```java\n31435 | // :203-218\n31436 | requireActiveTransaction(\"OUTBOX_TRANSACTION_REQUIRED\", \"appending to the outbox\");\n31437 | Connection connection = DataSourceUtils.getConnection(dataSource);\n31438 | ```\n31439 | \n31440 | 세 가지를 본다(`:228-245`): 활성 트랜잭션이 있는가 / 읽기 전용이 아닌가 / **이 DataSource 에 바인딩되어 있는가**. 세 번째가 특히 좋다 — 다른 DataSource 의 트랜잭션 안에서 append 하면 둘이 독립적으로 커밋된다.\n31441 | \n31442 | ```java\n31443 | // :221-227\n31444 | /**\n31445 | * Fail-fast rather than \"work anyway\": an append that silently runs outside the caller's\n31446 | * transaction produces exactly the ghost publication this repository exists to prevent, and it\n31447 | * produces it only on the rollback path — which is the path nobody exercises before production.\n31448 | */\n31449 | ```\n31450 | \n31451 | `append(Connection, OutboxRecord)` 가 package-private 으로 내려간 이력도 적혀 있다(`:155-165`) — 예전에는 그것이 public 이었고 \"안전한 경로가 호출자가 알아야만 하는 경로\" 였다.\n31452 | \n31453 | ##### 4.3 청구(claim)와 펜싱 — 두 세대가 공존한다\n31454 | \n31455 | **신세대** `CLAIM`(`:112-142`)은 소유자와 토큰을 기록하고 재시도 시계를 술어에 포함한다.\n31456 | \n31457 | ```sql\n31458 | WHERE status IN ('PENDING', 'AMBIGUOUS', 'IN_FLIGHT')\n31459 | AND (lease_expires_at IS NULL OR lease_expires_at <= ?)\n31460 | -- The retry clock lives in the row, not in the relay's memory. Without these two\n31461 | -- predicates an AMBIGUOUS row became claimable again on the very next pass, so a\n31462 | -- broker outage meant the whole backlog was republished every poll interval and the\n31463 | -- configured attempt budget was a number nothing consulted.\n31464 | AND (next_attempt_at IS NULL OR next_attempt_at <= ?)\n31465 | AND attempts < ?\n31466 | ORDER BY created_at LIMIT ? FOR UPDATE SKIP LOCKED\n31467 | ...\n31468 | SET status='IN_FLIGHT', lease_expires_at=?, lease_owner=?, lease_token = o.lease_token + 1\n31469 | ```\n31470 | \n31471 | 토큰 증가가 청구와 같은 문장 안에서, 서버에서 일어난다 — \"two relays racing for the same row cannot receive the same number\"(`:106-111`).\n31472 | \n31473 | 종결 쓰기는 전부 펜싱 술어를 단다.\n31474 | \n31475 | ```java\n31476 | // :400-403\n31477 | String sql = setClause\n31478 | + \"WHERE message_id = ? AND status = 'IN_FLIGHT' AND lease_owner = ? AND lease_token = ?\";\n31479 | ```\n31480 | \n31481 | 그리고 0행을 삼키지 않는다.\n31482 | \n31483 | ```java\n31484 | // :392-398\n31485 | /**\n31486 | * The predicate carries the owner and the token as well as the id, so a relay that stalled\n31487 | * past its lease writes nothing: another relay's claim incremented the token, and this update\n31488 | * matches zero rows. Zero is reported rather than swallowed — a stale write means this worker may\n31489 | * have produced a duplicate publication, which is exactly what an operator needs to see.\n31490 | */\n31491 | ```\n31492 | \n31493 | **구세대** `LEASE`(`:80-104`)와 `markPublished(MessageId)` / `markAmbiguous(MessageId, ...)` / `markFailed(MessageId, ...)` / `releaseLease(MessageId)` 는 소유자·토큰을 다루지 않는다. 그리고 남기는 행 상태가 다르다(§12.3(a)).\n31494 | \n31495 | ##### 4.4 `OutboxRelay.runOnce` — 세 결과, 다섯 카운터\n31496 | \n31497 | ```java\n31498 | // :169-218 (요약)\n31499 | switch (result.completion()) {\n31500 | case CONFIRMED -> markPublished(lease, now) APPLIED? published++ : stale++\n31501 | case AMBIGUOUS -> {\n31502 | int spent = record.attempts() + 1;\n31503 | scheduler.parkReason(spent)\n31504 | .map(reason -> markExhausted(lease, reason, now))\n31505 | .orElseGet(() -> markAmbiguous(lease, code, now, scheduler.nextAttemptAt(now, spent)));\n31506 | APPLIED? (isExhausted(spent) ? exhausted++ : ambiguous++) : stale++\n31507 | }\n31508 | case REJECTED -> markFailed(lease, code, now) APPLIED? failed++ : stale++\n31509 | default -> throw new IllegalStateException(\"unhandled publish completion: \" + …);\n31510 | }\n31511 | ```\n31512 | \n31513 | `spent = attempts + 1` 의 근거가 붙어 있다.\n31514 | \n31515 | ```java\n31516 | // :179-181\n31517 | // The attempt this pass just spent. The claim predicate and the row both count attempts\n31518 | // after the transition, so the budget has to be judged on the same number the next claim\n31519 | // will read, or the last attempt is spent twice.\n31520 | ```\n31521 | \n31522 | `EXHAUSTED` 를 별도 상태로 두는 근거도.\n31523 | \n31524 | ```java\n31525 | // :186-188\n31526 | // A row that has spent its budget without an answer is parked under its own\n31527 | // status. Leaving it AMBIGUOUS makes it a row the claim predicate silently skips\n31528 | // forever, which looks identical to a healthy backlog on every dashboard.\n31529 | ```\n31530 | \n31531 | `default ->` 분기의 존재 이유까지 적혀 있다(`:213-215`) — 새 completion 상수가 생기면 조용히 `IN_FLIGHT` 로 남기는 대신 크게 실패하도록.\n31532 | \n31533 | `OutboxRelayReport` 의 다섯 카운터가 각각 다른 운영 신호라는 것도 명시적이다(`:5-18`) — ambiguous 는 확인 문제, failed 는 계약/토폴로지 문제, staleLeases 는 \"중복 발행의 가시화된 형태\", exhausted 는 \"redrive 가 필요한 것\".\n31534 | \n31535 | ##### 4.5 `OutboxProperties` — 설정 간의 관계를 생성자가 강제한다\n31536 | \n31537 | ```java\n31538 | // :7-14\n31539 | /**\n31540 | * The lease duration is the dangerous one. If it is shorter than the time a publish can take, a\n31541 | * second relay claims the row while the first is still waiting for a confirm, and the message is\n31542 | * published twice — under the same id, so consumers with an inbox survive it, but consumers without\n31543 | * one do not. The constructor therefore requires the lease to exceed the publish timeout by a\n31544 | * margin rather than merely to be positive.\n31545 | */\n31546 | public static final double REQUIRED_LEASE_FACTOR = 2.0;\n31547 | ```\n31548 | \n31549 | `leaseDuration >= publishTimeout * 2` 를 생성자가 강제하고 `OUTBOX_LEASE_TOO_SHORT` 로 거절한다. 기본값(30초 / 5초)이 그 규칙을 만족하는지 자체 테스트가 있다(`theDefaultsSatisfyTheirOwnRule`).\n31550 | \n31551 | ##### 4.6 `OutboxEnvelopeFactory` — 정경 사실을 컬럼에서 되살린다\n31552 | \n31553 | ```java\n31554 | // :20-37\n31555 | /**\n31556 | * The identity comes from the row, never from a fresh mint. ...\n31557 | *\n31558 | * So does everything else the envelope carries. This used to rebuild correlation, causation,\n31559 | * tenant, trace and the schema reference as empty, and read the routing keys out of the row's\n31560 | * header map — so a message that travelled through the outbox reached its consumer with less\n31561 | * provenance than one published directly, and the publish path became part of the message's\n31562 | * meaning. ...\n31563 | *\n31564 | * Reserved header names in the row are refused outright, with no exception for the routing keys.\n31565 | * ... Now that the keys are columns, the rule is the simple one: an outbox row cannot write into\n31566 | * the platform's namespace at all.\n31567 | */\n31568 | ```\n31569 | \n31570 | 예약 이름을 만나면 `RESERVED_HEADER_IN_OUTBOX_ROW` 로 **던진다**(`:70-77`). 부재 값 처리도 정직하다 — `occurredAt` 이 없으면 `createdAt` 을 쓰고 그 이유를 적는다(\"the business transaction that wrote the row is the one the fact occurred in\", `:87-89`), `producer` 가 없으면 릴레이 소유 서비스로 귀속한다(`:91-92`).\n31571 | \n31572 | ##### 4.7 `JdbcAdminOperationJournal` — DB 제약이 경쟁을 결판낸다\n31573 | \n31574 | ```java\n31575 | // :22-32\n31576 | /**\n31577 | * Lives beside the outbox because it needs the same thing the outbox needs and nothing more: one\n31578 | * relational database that every replica can see. The uniqueness that stops a second execution is\n31579 | * the primary key on {@code (approval_ticket, plan_digest)}, enforced by the database rather than\n31580 | * by a check-then-act in application code — two replicas that read \"no row\" at the same instant\n31581 | * would both proceed, and only the constraint makes exactly one of them win.\n31582 | */\n31583 | ```\n31584 | \n31585 | `INSERT ... ON CONFLICT DO NOTHING` 이 1행이면 신규 청구, 0행이면 기존 행을 읽어 `refuseIfNotResumable` 후 `TAKE_OVER`. 인수 SQL 자체가 조건을 담는다.\n31586 | \n31587 | ```sql\n31588 | WHERE approval_ticket = ? AND plan_digest = ? AND lease_token = ?\n31589 | -- Only a failed operation or one whose lease ran out may be taken over. A live STARTED row\n31590 | -- means another replica is executing it right now.\n31591 | AND (state = 'FAILED' OR lease_expires_at <= ?)\n31592 | RETURNING lease_token, items_completed\n31593 | ```\n31594 | \n31595 | 읽기와 인수 사이의 경쟁도 처리한다 — `RETURNING` 이 0행이면 \"another replica took it over between the read and this update\"(`:181-186`)로 거절.\n31596 | \n31597 | 그리고 `items_completed` 는 `GREATEST` 로 단조 증가한다(`CHECKPOINT`/`SETTLE` SQL). 이것이 `DefaultMessagingAdminService` 가 낡은 값을 넘겨도 진행이 되돌아가지 않는 이유이며, 인터페이스가 요구하지 않는 성질이라는 점은 §A19-MESSAGING-ADMIN-RUNTIME §12.4(c)에 있다.\n31598 | \n31599 | ---\n31600 | \n31601 | #### 5. 주요 실행 경로\n31602 | \n31603 | **쓰기** — 비즈니스 트랜잭션 → `append(record)` → 트랜잭션 3중 검사 → `DataSourceUtils.getConnection` → INSERT(22컬럼).\n31604 | \n31605 | **배출** — `MessagingOutboxRelayLifecycle` → `worker.start()` → `runPass()` → `relay.runOnce(now)` → 청구/발행/종결 → `scheduler.backoff(unproductive)` → 다음 패스 자기 스케줄링.\n31606 | \n31607 | **정리** — `OutboxCleanupJob.runOnce(now)` → `cutoff = now - retention` → `purgePublishedBefore(cutoff)` **무제한 오버로드** ×(최대 `maxBatches`, 실제로는 2회) → §12.1(a).\n31608 | \n31609 | **admin 저널** — `begin` → INSERT ON CONFLICT / TAKE_OVER → `checkpoint` × N → `complete` 또는 `fail`.\n31610 | \n31611 | ---\n31612 | \n31613 | #### 6. 실패 경로와 복구/번역\n31614 | \n31615 | | 상황 | 처리 | 위치 |\n31616 | |---|---|---|\n31617 | | 트랜잭션 없이 append | `OUTBOX_TRANSACTION_REQUIRED` | `JdbcOutboxRepository:228-235` |\n31618 | | 읽기 전용 트랜잭션 | 〃 | `:236-239` |\n31619 | | 다른 DataSource 의 트랜잭션 | 〃 | `:240-246` |\n31620 | | append SQL 실패 | `OUTBOX_APPEND_FAILED` | `:196-199` |\n31621 | | 그 밖의 쿼리 실패 | `OUTBOX_QUERY_FAILED` | `:594-600` |\n31622 | | 종결 쓰기가 0행 | `OutboxTransitionResult.STALE_LEASE` (예외 아님) | `:413-415` |\n31623 | | 미지의 `PublishCompletion` | `IllegalStateException` | `OutboxRelay:216-217` |\n31624 | | 리스가 발행 타임아웃보다 짧음 | `OUTBOX_LEASE_TOO_SHORT` | `OutboxProperties:52-58` |\n31625 | | 행 헤더에 예약 이름 | `RESERVED_HEADER_IN_OUTBOX_ROW` | `OutboxEnvelopeFactory:70-77` |\n31626 | | 승인 이미 실행됨 | `APPROVAL_ALREADY_EXECUTED` | `JdbcAdminOperationJournal:117-124` |\n31627 | | 다른 런타임이 실행 중 | `ADMIN_OPERATION_IN_FLIGHT` | `:125-132`, `:181-186` |\n31628 | | 리스 상실 후 쓰기 | `ADMIN_OPERATION_LEASE_LOST` | `:263-271` |\n31629 | | 저널 도달 불가 | `ADMIN_JOURNAL_UNAVAILABLE` | `:206-208` 등 |\n31630 | | 두 릴레이 동시 활성 | `DUPLICATE_OUTBOX_RELAY` | `DebeziumOutboxProfile:54-59` (**호출부 0**) |\n31631 | | 릴레이 없음 | `NO_OUTBOX_RELAY` | `:60-65` (**호출부 0**) |\n31632 | \n31633 | `OutboxRelayWorker` 의 패스 실패 처리가 특히 명시적이다.\n31634 | \n31635 | ```java\n31636 | // :184-190\n31637 | } catch (RuntimeException passFailed) {\n31638 | // A failed pass must not stop the loop: the scheduled task's own exception would cancel every\n31639 | // future pass, turning one broker error into a relay that never runs again. The failure is\n31640 | // counted and the next pass backs off as if nothing was published, which is true.\n31641 | ```\n31642 | \n31643 | 종료도 인터럽트가 아니라 드레인이다.\n31644 | \n31645 | ```java\n31646 | // :99-106\n31647 | /**\n31648 | * Draining rather than interrupting is the whole point. A pass killed between its claim and\n31649 | * its terminal write leaves rows {@code IN_FLIGHT} holding a lease, and nothing may touch them\n31650 | * until that lease expires — so an orderly shutdown would produce exactly the stall that a crash\n31651 | * produces.\n31652 | */\n31653 | ```\n31654 | \n31655 | ---\n31656 | \n31657 | #### 7. 트랜잭션·동시성·수명주기\n31658 | \n31659 | **두 가지 커넥션 획득 방식이 공존한다.**\n31660 | \n31661 | | 메서드 | 획득 | 효과 |\n31662 | |---|---|---|\n31663 | | `JdbcOutboxRepository.append(record)` | `DataSourceUtils.getConnection` | 호출자 트랜잭션에 합류 |\n31664 | | 그 외 전부 (`withConnection`) | `dataSource.getConnection()` + try-with-resources | 풀에서 새 커넥션, 독립 커밋 |\n31665 | | `JdbcAdminOperationJournal` 전 메서드 | `DataSourceUtils.getConnection` | 트랜잭션 있으면 합류 |\n31666 | \n31667 | 릴레이 연산이 비즈니스 트랜잭션에 합류하면 안 되므로 `withConnection` 의 선택은 타당하다. 다만 그 판단이 주석으로 남아 있지 않고, 같은 리프의 저널은 반대 방식을 쓴다. §17 P3.\n31668 | \n31669 | **동시성 제어는 전부 데이터베이스에 있다.** `FOR UPDATE SKIP LOCKED`(청구), 서버측 토큰 증가, 펜싱 술어, `ON CONFLICT DO NOTHING`, 복합 기본키. Java 쪽에 락이 없다.\n31670 | \n31671 | **수명주기**: `OutboxRelayWorker` 는 데몬 스레드 1개, `setExecuteExistingDelayedTasksAfterShutdownPolicy(false)`, `start()` 멱등, `stop(deadline)` 드레인 후 실패 시 `shutdownNow()`. 셋 다 근거 주석이 있다(`:79-88`, `:92`, `:121-122`).\n31672 | \n31673 | ---\n31674 | \n31675 | #### 8. 설정·기능 플래그·환경 차이\n31676 | \n31677 | | 값 | 출처 | 기본 | 비고 |\n31678 | |---|---|---|---|\n31679 | | `batchSize` | `OutboxProperties` | 100 | |\n31680 | | `leaseDuration` | 〃 | 30초 | `>= publishTimeout × 2` 강제 |\n31681 | | `publishTimeout` | 〃 | 5초 | |\n31682 | | `pollInterval` | 〃 | 500ms | 백오프의 기준 간격 |\n31683 | | `retentionAfterPublish` | 〃 | 3일 | |\n31684 | | `maxAttempts` | 〃 | 10 | 청구 술어의 `attempts < ?` |\n31685 | | `maxInterval` | starter `:63` | **1분** | `OutboxRetryScheduler.standard()` 는 5분 |\n31686 | | `maxBatches` | starter `:141` | **20 하드코딩** | 실질 무의미 (§12.1(a)) |\n31687 | | relay owner | `OutboxRelay.defaultOwner()` | `pid@uuid8` | 프로세스당 안정 |\n31688 | | CDC 모드 | `DebeziumOutboxProfile` | — | **어떤 프로퍼티에도 연결 안 됨** |\n31689 | \n31690 | `maxInterval` 이 두 값(1분 / 5분)으로 갈리는 것은 결함이 아니다 — starter 가 명시적으로 넘기고, `standard()` 는 호출자가 정책을 주지 않은 경우의 기본값이다.\n31691 | \n31692 | ---\n31693 | \n31694 | #### 9. 퍼시스턴스/외부 시스템 세부\n31695 | \n31696 | **테이블 2개.** `messaging_outbox`(V1+V2+V4, 최종 34컬럼 + 생성 컬럼 1), `messaging_admin_operation`(V3, 11컬럼).\n31697 | \n31698 | **인덱스 4개**, 전부 부분 인덱스: `ix_..._claimable`, `ix_..._published_at`, `ix_..._next_attempt`, `ix_..._tenant_backlog`, 그리고 `ix_messaging_admin_operation_live`.\n31699 | \n31700 | **헤더 직렬화는 손으로 쓴 JSON** 이다.\n31701 | \n31702 | ```java\n31703 | // :604-610\n31704 | /**\n31705 | * Hand-rolled rather than pulled from a JSON library so this module keeps no codec dependency:\n31706 | * outbox headers are always flat string pairs, validated by {@code MessageHeaders} before they\n31707 | * ever reach here.\n31708 | */\n31709 | ```\n31710 | \n31711 | 이스케이프는 제어문자까지 처리하며 그 이력이 적혀 있다(`:630-636`). **그러나 역파싱의 종료 판정에 결함이 있다 — §12.1(b), `EVD-314` 에서 런타임 재현했다.**\n31712 | \n31713 | ---\n31714 | \n31715 | #### 10. 테스트 레인과 실제 증명 범위\n31716 | \n31717 | `EVD-313`: `./gradlew :messaging:messaging-outbox-jdbc-postgresql:test --rerun-tasks` → **76 tests, 0 failures, 0 skipped**.\n31718 | \n31719 | | 클래스 | 수 | 종류 |\n31720 | |---|---:|---|\n31721 | | `OutboxPostgresIT` | **21** | 컨테이너 (Postgres) |\n31722 | | `DebeziumOutboxRecordMapperTest` | 16 | 단위 |\n31723 | | `OutboxOperationsTest` | 10 | 단위 (대역) |\n31724 | | `OutboxRelayTest` | 9 | 단위 (대역) |\n31725 | | `AdminOperationJournalPostgresIT` | **7** | 컨테이너 (Postgres) |\n31726 | | `OutboxEnvelopeFactoryTest` | 6 | 단위 |\n31727 | | `JdbcOutboxTransactionRequirementTest` | 4 | 단위 |\n31728 | | `OutboxRelayWorkerTest` | 3 | 단위 (스레드) |\n31729 | \n31730 | **컨테이너 레인 28건이 실제로 실행되었다** — `skipped=\"0\"` 이고 `tests>0`. `docker version` 은 client 29.1.3 / server 29.6.1 을 보고하고 `/var/run/docker.sock` 이 마운트되어 있다(`EVD-313`).\n31731 | \n31732 | > 이는 앞선 리프 문서들이 \"컨테이너 필요 — 미실행\" 으로 남긴 항목들(messaging-testkit 의 인증 레인 등)이 **실행 불가가 아니라 아직 실행하지 않은 것**임을 뜻한다. 해당 리프 분석 시 실행한다.\n31733 | \n31734 | `OutboxPostgresIT` 가 실제로 증명하는 것 중 강한 것들:\n31735 | \n31736 | - `theRowAndTheBusinessChangeCommitTogetherOrNotAtAll` — 아웃박스의 존재 이유 그 자체.\n31737 | - `aSupersededRelayCannotOverwriteTheOutcomeOfTheOneThatReplacedIt` / `twoRelaysClaimingConcurrentlyGetDisjointRowsAndDistinctTokens` / `anExpiryReclaimKeepsTheMessageIdAndAdvancesTheToken` — V2 펜싱의 3대 성질.\n31738 | - `anAmbiguousRowWaitsForItsBackoffBeforeItIsClaimedAgain` / `aRowOutOfAttemptsIsNotClaimedAgain` / `anExhaustedRowIsDistinctFromARejectedOne` — 재시도 시계가 행에 있다는 주장.\n31739 | - `everyCanonicalColumnRoundTripsThroughTheDatabase` / `theRelayCanSelectOneTenantsBacklogWithoutDecodingAPayload` / `theStoredRoutingKeyIsTheOneBothRelaysWouldUse` / `aTenantThatBreaksTheSlugBoundIsRefusedByTheDatabase` — V4 의 네 가지 주장.\n31740 | \n31741 | 증명되지 **않는** 것:\n31742 | \n31743 | - 정리 작업이 실제로 나눠 지운다는 것 (§12.1(a)).\n31744 | - 역슬래시로 끝나는 헤더 값의 왕복 (§12.1(b)). `aHeaderValueWithControlCharactersRoundTrips` 는 제어문자만 본다.\n31745 | - 배포되는 `.properties` 가 Java 설정과 일치한다는 것 (§12.4(a)).\n31746 | - 두 릴레이 상호배제가 기동에서 강제된다는 것 (§12.1(c)).\n31747 | - 구세대 `MessageId` 기반 전이가 신세대와 같은 행 상태를 남긴다는 것 (§12.3(a)).\n31748 | \n31749 | ---\n31750 | \n31751 | #### 11. 빌드/ArchUnit/CI 강제 지점\n31752 | \n31753 | 이 리프 고유의 Gradle 게이트는 없다. 루트 공통 게이트만 적용된다. 컨테이너 IT 가 `test` 태그에서 제외되지 **않는다** — 즉 Docker 가 있는 환경에서는 일반 `test` 로 함께 돈다. `messaging-kafka` 의 인증 레인이 별도 태그로 분리된 것(그 리프 문서 §6 참조)과 대비된다.\n31754 | \n31755 | `app-bootstrap` 의 `MessagingCapabilityRegistryContractTest:61` 이 `\"debezium\"` 문자열을 능력 목록에 갖고 있다 — 이 리프의 CDC 경로가 플랫폼 능력으로 선언되어 있다는 뜻이다. 그 선언과 §12.1(c)의 미배선 사이의 대조는 §A18 재검증 시 다룬다.\n31756 | \n31757 | ---\n31758 | \n31759 | #### 12. 실제 사용 여부와 negative-space probes\n31760 | \n31761 | ##### 12.1 Public surface reachability\n31762 | \n31763 | **(a) [P1] 정리 작업이 무제한 DELETE 를 쏜다** (`EVD-311`, `EVD-294`)\n31764 | \n31765 | `OutboxRepository` 는 purge 오버로드를 둘 갖고, 구현도 둘 다 있다.\n31766 | \n31767 | ```java\n31768 | // JdbcOutboxRepository.java:486-518 bounded\n31769 | // The CTE picks a bounded set of ids with SKIP LOCKED and deletes exactly those. An unbounded\n31770 | // DELETE holds locks and writes WAL in proportion to the whole backlog, which stalls the relay\n31771 | // and the business writes behind retention.\n31772 | WITH expired AS (SELECT message_id FROM messaging_outbox\n31773 | WHERE status='PUBLISHED' AND published_at < ?\n31774 | ORDER BY published_at LIMIT ? FOR UPDATE SKIP LOCKED)\n31775 | DELETE FROM messaging_outbox o USING expired e WHERE o.message_id = e.message_id\n31776 | \n31777 | // JdbcOutboxRepository.java:519-533 unbounded\n31778 | DELETE FROM messaging_outbox WHERE status = 'PUBLISHED' AND published_at < ?\n31779 | ```\n31780 | \n31781 | 호출자는 무제한 쪽을 부른다.\n31782 | \n31783 | ```java\n31784 | // OutboxCleanupJob.java:48-55\n31785 | for (int batch = 0; batch < maxBatches; batch++) {\n31786 | int deleted = outbox.purgePublishedBefore(cutoff); // 무제한\n31787 | removed += deleted;\n31788 | if (deleted == 0) break;\n31789 | }\n31790 | ```\n31791 | \n31792 | 1회차가 전체를 지우고 2회차가 0을 반환해 break 한다. `maxBatches=20`(starter `:141`)은 실질적으로 죽은 값이다.\n31793 | \n31794 | **발동 조건 보정(`EVD-316`).** 이 잡은 starter 빈이지만 **스케줄되지 않는다.** `MessagingReliabilityAutoConfiguration` 클래스 javadoc(`:32-34`)이 그렇게 설계했다고 적는다 — *\"The cleanup jobs are beans but no scheduler is registered for them. Scheduling is the application's decision: a service running several replicas usually wants one of them to run cleanup, and auto-registering a fixed-rate task would have every replica delete the same rows.\"* 따라서 기본 배포에서는 `runOnce` 가 한 번도 호출되지 않는다. 무제한 DELETE 는 **애플리케이션이 그 지시대로 잡을 스케줄하는 순간** 발동한다.\n31795 | \n31796 | 테스트가 이것을 가리는 방식이 inbox 쪽과 동일하다.\n31797 | \n31798 | ```java\n31799 | // OutboxOperationsTest.java:120-134 RecordingRepository\n31800 | @Override public int purgePublishedBefore(Instant publishedBefore, int limit) {\n31801 | return Math.min(purgePublishedBefore(publishedBefore), limit); // 전부 지우고 숫자만 깎는다\n31802 | }\n31803 | @Override public int purgePublishedBefore(Instant publishedBefore) {\n31804 | cutoffs.add(publishedBefore);\n31805 | return pass < deletions.size() ? deletions.get(pass++) : 0; // 스크립트\n31806 | }\n31807 | ```\n31808 | \n31809 | `cleanupDeletesInBoundedBatchesRatherThanOneLongStatement` 는 `List.of(1000, 1000, 250)` 을 스크립트로 넣고 `removed == 2250`, `cutoffs.size() == 4` 를 단언한다. \"나눠 지운다\" 는 관측이 전적으로 대역이 만든 것이다. 실 DB 테스트(`OutboxPostgresIT:202`)도 무제한 쪽만 부른다.\n31810 | \n31811 | **(b) [P2] 역슬래시로 끝나는 헤더 값이 헤더 맵을 깨뜨린다** (`EVD-314` — 런타임 재현)\n31812 | \n31813 | ```java\n31814 | // JdbcOutboxRepository.java:657-664\n31815 | private static int findClosingQuote(String text, int from) {\n31816 | for (int index = from; index < text.length(); index++) {\n31817 | if (text.charAt(index) == '\"' && text.charAt(index - 1) != '\\\\') { return index; }\n31818 | }\n31819 | return text.length();\n31820 | }\n31821 | ```\n31822 | \n31823 | 닫는 따옴표 판정이 \"바로 앞 글자가 역슬래시가 아니다\" 뿐이다. `escape` 가 값 끝의 역슬래시를 둘로 늘리므로, 닫는 따옴표 앞이 역슬래시가 되어 종료를 놓친다.\n31824 | \n31825 | 컴파일된 클래스에 jshell + 리플렉션으로 `private static toJson`/`fromJson` 을 직접 호출해 재현했다(애플리케이션 소스 무수정).\n31826 | \n31827 | ```\n31828 | case 3 in={x-a=a\\} json={\"x-a\":\"a\\\\\"} out={x-a=a\\\"} EQUAL? false\n31829 | case 4 in={x-a=a\\, x-b=second} json={\"x-a\":\"a\\\\\",\"x-b\":\"second\"} out={x-a=a\\\",, :=x-a, a\\\",=second} EQUAL? false\n31830 | case 5 in={x-a=a\\b} json={\"x-a\":\"a\\\\b\"} out={x-a=a\\b} EQUAL? true\n31831 | new HeaderValue(\"a\\\") -> OK, value=a\\\n31832 | ```\n31833 | \n31834 | 값이 **끝에** 역슬래시를 가질 때만 깨지고, 뒤에 헤더가 하나라도 더 있으면 맵 전체가 붕괴한다 — 키 `:` 와 키 `a\\\",` 가 생기고 `x-b` 는 사라진다. `HeaderValue` 는 제어문자만 금지하므로(`WireSafeText.require`) 이 입력은 플랫폼 자신의 검증 타입을 통과한다.\n31835 | \n31836 | **헤더 주입으로는 이어지지 않는다.** 어긋남이 키/값 경계를 밀어내므로 예약 이름은 키가 아니라 값이 되고, 쓰기 경로의 `MessageHeaders.application(...)` 이 애초에 예약 이름을 거절한다. 데이터 손상이지 취약점은 아니다.\n31837 | \n31838 | **(c) CDC 경로 전체가 배선되지 않았다** (`EVD-312`)\n31839 | \n31840 | ```\n31841 | git grep -n \"requireExactlyOneRelay|DebeziumOutboxProfile.polling|RelayMode\" -- src\n31842 | 전부 DebeziumOutboxProfile.java 자기 자신 + DebeziumOutboxRecordMapperTest\n31843 | ```\n31844 | \n31845 | `DebeziumOutboxProfile` 클래스 javadoc(`:9-13`)은 \"the incompatibility is therefore enforced at startup instead of documented\" 라고 쓴다. 기동 시 `requireExactlyOneRelay` 를 부르는 코드가 없다. `DebeziumOutboxRecordMapper` 는 프로덕션에서 생성되지 않는다. 즉 두 릴레이가 동시에 켜지는 구성을 막는 주체가 없고, CDC 모드를 선택할 프로퍼티도 없다.\n31846 | \n31847 | **(d) 세 타입이 starter 밖 배선을 요구한다.** `JdbcOutboxRepository`(src/main 생성 0), `OutboxEnvelopeFactory`(0), `JdbcAdminOperationJournal`(0). 애플리케이션이 등록하지 않으면 릴레이 빈은 `OutboxRepository` 를 주입받지 못한다.\n31848 | \n31849 | ##### 12.2 Conditional sibling comparison\n31850 | \n31851 | **대조군 1 — 배선된 것 vs 안 된 것.** `OutboxRelayWorker` javadoc(`:18-21`)이 과거 결함을 기록한다: \"The relay, its retry scheduler and the attempt budget all existed and nothing ever called `runOnce`. An outbox whose relay is never driven is the worst shape of all\". 그리고 그 수정이 실제로 배선까지 완료되어 있다(`MessagingOutboxRelayLifecycle:42 worker.start()`). **같은 리프 안에서 `requireExactlyOneRelay` 는 같은 상태로 남아 있다.**\n31852 | \n31853 | **대조군 2 — 커넥션 획득.** `append` 는 `DataSourceUtils`, 나머지는 raw `dataSource.getConnection()`, `JdbcAdminOperationJournal` 은 전부 `DataSourceUtils`. §7.\n31854 | \n31855 | **대조군 3 — inbox 와의 대칭.** `InboxCleanupJob`/`OutboxCleanupJob` 은 같은 형태이며 같은 결함을 갖는다(`EVD-294`). starter 가 둘 다 `maxBatches=20` 으로 만든다.\n31856 | \n31857 | **대조군 4 — 컨테이너 레인 정책.** 이 리프의 IT 는 `test` 에 포함되어 함께 돈다. `messaging-kafka` 의 인증 레인은 태그로 분리되고 Docker 가드도 없다. 두 정책이 공존하는 이유는 각 리프에 설명되어 있다(전자는 skip 가능, 후자는 skip 이 성공으로 보고되면 안 됨).\n31858 | \n31859 | ##### 12.3 Duplicate mechanism sweep\n31860 | \n31861 | **(a) 전이 메서드가 두 세대이며 남기는 행 상태가 다르다.**\n31862 | \n31863 | | 항목 | 신세대 (`OutboxLease`) | 구세대 (`MessageId`) |\n31864 | |---|---|---|\n31865 | | 술어 | `message_id AND status='IN_FLIGHT' AND lease_owner=? AND lease_token=?` | `message_id` 만 |\n31866 | | `markPublished` SET | `status, published_at, lease_expires_at=NULL, lease_owner=NULL, next_attempt_at=NULL, attempts+1` | `status, published_at, lease_expires_at=NULL, attempts+1` |\n31867 | | `markAmbiguous` SET | `… lease_owner=NULL, last_failure_code, attempts+1, next_attempt_at=?` | `… last_failure_code, attempts+1` |\n31868 | | 결과 타입 | `OutboxTransitionResult` | `void` |\n31869 | | 청구 SQL | `CLAIM` (owner/token 기록) | `LEASE` (기록 안 함) |\n31870 | \n31871 | 구세대로 PUBLISHED 된 행은 `lease_owner` 와 `next_attempt_at` 이 남는다. 그 컬럼들은 청구 술어와 부분 인덱스가 읽는 값이다. 두 세대 중 어느 것도 `@Deprecated` 가 아니라는 점은 §A19-MESSAGING-RELIABILITY-API 에 기록되어 있고, 여기서는 **상태 차이가 구체적으로 무엇인지**가 추가된다.\n31872 | \n31873 | **(b) Debezium 설정이 두 표현으로 존재한다.** §12.4(a).\n31874 | \n31875 | **(c) 손으로 쓴 JSON 코덱이 이 리프에도 있다.** `JdbcOutboxRepository.toJson/fromJson/escape/unescape` — `BrokerCertificationEvidence`(messaging-testkit), `InMemoryAdminOperationJournal.key`(messaging-admin-runtime)와 같은 계열의 선택이다. 각각 이유가 적혀 있고(\"이 모듈은 코덱 의존을 두지 않는다\"), 각각 다른 방식으로 구현되어 있다. 그중 하나에서 파싱 결함이 나왔다(§12.1(b)).\n31876 | \n31877 | ##### 12.4 Documentation / measured-count drift\n31878 | \n31879 | **(a) [P2] 배포되는 커넥터 설정이 수정 이전 버전이다** (`EVD-310`)\n31880 | \n31881 | | 항목 | Java `connectorConfiguration` | `debezium/outbox-event-router.properties` |\n31882 | |---|---|---|\n31883 | | `event.key` | `routing_key` | **`destination`** |\n31884 | | `route.topic.replacement` | `topicPrefix + ${routedByValue}` | `${routedByValue}` |\n31885 | | `event.timestamp` | (없음) | `created_at` |\n31886 | | `additional.placement` 항목 수 | **15** | **4** |\n31887 | \n31888 | properties 에 없는 11개: `created_at`, `destination`, `producer`, `occurred_at`, `correlation_id`, `causation_id`, `tenant`, `partition_key`, `ordering_key`, `traceparent`, `tracestate`, `baggage` — **V4 가 추가한 정경 메타데이터 전부**다.\n31889 | \n31890 | `DebeziumOutboxEventRouter` javadoc(`:21-26`)과 V4 주석(`:55-63`)이 둘 다 \"`destination` 을 키로 쓰면 한 토픽의 모든 메시지가 한 파티션에 몰린다\" 를 고쳤다고 말한다. 배포되는 파일에는 그 수정이 없다.\n31891 | \n31892 | 그리고 두 표현을 잇는 것이 없다.\n31893 | \n31894 | ```\n31895 | git grep -rn \"outbox-event-router\" -- src\n31896 | exit 1 (출력 없음)\n31897 | ```\n31898 | \n31899 | Java 쪽은 오히려 **의도적으로 견고한 테스트**가 지키고 있다.\n31900 | \n31901 | ```java\n31902 | // DebeziumOutboxRecordMapperTest.java:154-162\n31903 | void theRoutedKeyIsNotTheTopicName() {\n31904 | // Literals, not the class's own constants: comparing a configuration value against the constant\n31905 | // that produced it asserts that the router agrees with itself, which it always will.\n31906 | assertThat(new DebeziumOutboxEventRouter().connectorConfiguration(\"prod.\"))\n31907 | .as(\"keying by destination puts every message on a topic onto one partition\")\n31908 | .containsEntry(\"transforms.outbox.table.field.event.key\", \"routing_key\")\n31909 | .containsEntry(\"transforms.outbox.route.by.field\", \"destination\");\n31910 | }\n31911 | ```\n31912 | \n31913 | 리터럴 대조까지 하는 테스트가 Java 를 지키고, 운영자가 배포하는 파일은 아무도 지키지 않는다.\n31914 | \n31915 | **(b) `aggregateIdAsPartitionKey` 는 커넥터에 도달할 수 없다.** `DebeziumOutboxRecordMapper` 는 그 플래그로 분기해 `Optional.empty()` 를 낼 수 있지만(`:70-73`), `connectorConfiguration(String topicPrefix)` 는 프로필을 받지 않고 `event.key` 를 항상 `routing_key` 로 고정한다. 기본값(`polling()` → `false`)에서 모델은 \"키 없음\" 을 예측하고 실제 커넥터는 키를 붙인다. 이 클래스의 존재 이유가 \"Produces what Debezium's Event Router will emit\"(`:11`)인 만큼 무해하지 않다.\n31916 | \n31917 | **(c) 백오프 지터가 복제본을 분산시키지 못한다** (`EVD-312`)\n31918 | \n31919 | ```java\n31920 | // OutboxRetryScheduler.java:18-20\n31921 | /**\n31922 | * Jitter is applied deterministically from the attempt count rather than randomly. Several relay\n31923 | * instances that all started at deployment time would otherwise synchronise their retries into a\n31924 | * thundering herd ...\n31925 | */\n31926 | // :107\n31927 | long jittered = capped - (capped / 8) * (exponent % 3);\n31928 | ```\n31929 | \n31930 | `jittered` 는 `exponent` 만의 함수이고 `exponent` 는 워커의 `unproductivePasses` 카운터다. 같은 시각에 배포되어 같은 브로커 장애를 겪는 복제본들은 같은 카운터를 갖게 되므로 **같은 backoff 를 계산한다.** 지터는 시도 횟수에 따라 값을 바꿀 뿐 인스턴스에 따라 바꾸지 않는다.\n31931 | \n31932 | (행 단위 백오프 `nextAttemptAt` 은 `next_attempt_at` 컬럼에 기록되므로 이 문제와 무관하다. javadoc 이 말하는 \"several relay instances … synchronise their retries\" 는 pass 단위 얘기다.)\n31933 | \n31934 | **(d) 선언 의존은 모두 사용된다.** 5개 project 의존 중 미사용 0건 — 지금까지 본 messaging 리프 중 처음이다.\n31935 | \n31936 | ---\n31937 | \n31938 | #### 13. Git/설계 문서에서 확인한 변화와 실패 기록\n31939 | \n31940 | SQL 마이그레이션과 javadoc 이 함께 이력을 이룬다. 여덟 개의 \"이전에는 이랬다\".\n31941 | \n31942 | | 위치 | 기록된 과거 결함 |\n31943 | |---|---|\n31944 | | `V2:3-14` | 리스만으로는 stale relay 가 PUBLISHED 위에 AMBIGUOUS 를 덮어썼다 |\n31945 | | `V2:25-27` | \"V1's CHECK listed five states, so writing the sixth failed at the constraint rather than at review\" |\n31946 | | `V4:6-9` | 정경 필드가 갈 곳이 없어 유실되거나 `msg.*` 로 밀반입되었다 |\n31947 | | `V4:56-61` | Debezium 키가 `destination` 이라 한 토픽의 모든 메시지가 한 파티션에 몰렸다 |\n31948 | | `JdbcOutboxRepository:155-162` | `append(Connection, …)` 이 public 이었고 안전한 경로가 \"알아야만 하는\" 것이었다 |\n31949 | | `JdbcOutboxRepository:205-209` | `append` 가 풀에서 raw 커넥션을 열어 자동 커밋했다 — \"a business transaction that rolled back afterwards left the event behind\" |\n31950 | | `JdbcOutboxRepository:630-636` | 이스케이프가 역슬래시와 따옴표만 처리해 제어문자가 JSONB 를 깨뜨렸다 |\n31951 | | `OutboxRelay:117-123` | \"The scheduler was built by the auto-configuration and handed to nobody\" |\n31952 | | `OutboxRelayWorker:18-21` | \"The relay, its retry scheduler and the attempt budget all existed and nothing ever called `runOnce`\" |\n31953 | | `OutboxEnvelopeFactory:27-37` | 정경 필드를 빈 값으로 재구성하고 라우팅 키를 헤더 맵에서 읽었다 |\n31954 | | `CLAIM SQL:119-122` | AMBIGUOUS 행이 다음 패스에 바로 재청구되어 시도 예산이 아무도 안 읽는 숫자였다 |\n31955 | \n31956 | 마지막 두 개(`OutboxRelay:117-123`, `OutboxRelayWorker:18-21`)가 이 저장소 전체에서 반복되는 결함 계열 — **\"만들어졌지만 아무도 부르지 않는다\"** — 을 명시적으로 이름 붙인 유일한 자리다. 그리고 이 리프에서는 그 둘이 실제로 고쳐졌다. §12.1(c)의 `requireExactlyOneRelay` 만 같은 상태로 남았다.\n31957 | \n31958 | ---\n31959 | \n31960 | #### 14. 런타임·터미널 Evidence\n31961 | \n31962 | | ID | 파일 | 내용 |\n31963 | |---|---|---|\n31964 | | EVD-310 | `evidence/raw/310-debezium-properties-vs-java-drift.txt` | Java 설정 vs 배포 properties 항목별 대조, 헤더 매핑 15 vs 4, 연결 코드 0건 |\n31965 | | EVD-311 | `evidence/raw/311-outbox-cleanup-unbounded-confirmed.txt` | bounded/unbounded 두 SQL 전문, 호출자, starter 배선, 대역의 스크립트 |\n31966 | | EVD-312 | `evidence/raw/312-outbox-assembly-and-jitter.txt` | 조립 탐침 전수, 릴레이 기동 확인(대조군), CDC 미배선, 지터 분석 |\n31967 | | EVD-313 | `evidence/raw/313-messaging-outbox-jdbc-test-lane.txt` | 76건 통과 + **컨테이너 런타임 가용성 확인** |\n31968 | | EVD-314 | `evidence/raw/314-outbox-header-json-roundtrip-corruption.txt` | jshell 리플렉션 재현 5케이스 + 주입 불가 확인 + HeaderValue 수용 확인 |\n31969 | \n31970 | ---\n31971 | \n31972 | #### 15. 명시적 설계 이유와 추론을 구분한 정리\n31973 | \n31974 | **코드/주석에 명시된 것**\n31975 | \n31976 | - `message_id` 를 기본키로 삼은 이유 (`V1:3-5`).\n31977 | - 부분 인덱스인 이유, `IN_FLIGHT` 를 청구 대상에 넣는 이유 (`V1:28-36`).\n31978 | - 펜싱 토큰이 필요한 이유와 리스 연장이 답이 아닌 이유 (`V2:3-14`).\n31979 | - `EXHAUSTED` 를 새 상태로 만든 이유 (`V2:25-27`).\n31980 | - 정경 메타데이터를 blob 이 아니라 컬럼으로 둔 이유 (`V4:11-14`).\n31981 | - tenant 제약을 DB 에도 거는 이유 (`V4:33-36`).\n31982 | - `routing_key` 를 생성 컬럼으로 만든 이유 (`V4:55-63`).\n31983 | - `append` 가 호출자 커넥션을 쓰는 이유, 그리고 fail-fast 인 이유 (`JdbcOutboxRepository:37-46, 221-227`).\n31984 | - `FOR UPDATE SKIP LOCKED` 의 이유 (`:44-46`).\n31985 | - 열 목록을 상수로 뽑은 이유 (`:64-71`).\n31986 | - 서버측 토큰 증가의 이유 (`:106-111`).\n31987 | - 재시도 시계를 행에 두는 이유 (`CLAIM:119-122`, `markAmbiguous:79-81`).\n31988 | - 0행을 STALE_LEASE 로 보고하는 이유 (`:392-398`).\n31989 | - bounded purge 가 필요한 이유 (`:164-166`) — 정작 호출되지 않는다.\n31990 | - 손으로 쓴 JSON 의 이유, 제어문자 이스케이프의 이유 (`:604-610, 630-636`).\n31991 | - 모호를 같은 id 로 재시도하는 이유, at-least-once 상한의 이유 (`OutboxRelay:17-27`).\n31992 | - `attempts + 1` 로 예산을 판정하는 이유 (`:179-181`).\n31993 | - `EXHAUSTED` 로 주차하는 이유 (`:186-188`).\n31994 | - `default ->` 분기의 이유 (`:213-215`).\n31995 | - 리스가 발행 타임아웃의 2배여야 하는 이유 (`OutboxProperties:10-14`).\n31996 | - pass 백오프와 row 백오프가 서로를 대체하지 않는 이유 (`OutboxRetryScheduler:11-16`).\n31997 | - 시프트를 쓰는 이유 (`:102-103`).\n31998 | - 데몬 스레드·자기 스케줄링·드레인 종료의 이유 (`OutboxRelayWorker:23-30, 79-88, 99-106`).\n31999 | - 패스 실패가 루프를 끝내면 안 되는 이유 (`:184-187`).\n32000 | - 정리가 PUBLISHED 만 지우는 이유 (`OutboxCleanupJob:10-14`).\n32001 | - 봉투 재구성 시 부재 값 처리의 이유 (`OutboxEnvelopeFactory:87-92`).\n32002 | - 예약 이름을 예외 없이 거절하는 이유 (`:33-37`).\n32003 | - 저널이 아웃박스 옆에 사는 이유 (`build.gradle:9-13`, `JdbcAdminOperationJournal:24-28`).\n32004 | - DB 제약이 경쟁을 결판내는 이유 (`:26-28`).\n32005 | - 읽기와 인수 사이 경쟁을 거절하는 이유 (`:181-184`).\n32006 | - 두 릴레이 동시 실행이 불가능해야 하는 이유 (`DebeziumOutboxProfile:9-13`, properties `:3-5`).\n32007 | - CDC 모델을 Java 로 만든 이유 (`DebeziumOutboxRecordMapper:11-18`).\n32008 | - schema subject 만 헤더가 없는 이유 (`DebeziumOutboxEventRouter:28-33`).\n32009 | - 부재를 빈 문자열로 쓰지 않는 이유 (`:105-107`).\n32010 | - 컨테이너 테스트가 필요한 이유 (`build.gradle:16-17`).\n32011 | \n32012 | **추론 (근거는 있으나 문서에 없음)**\n32013 | \n32014 | - `withConnection` 이 `DataSourceUtils` 를 쓰지 않는 것은 릴레이가 비즈니스 트랜잭션에 합류하면 안 되기 때문으로 보인다. 주석은 없고, 같은 리프의 저널은 반대로 한다.\n32015 | - `.properties` 가 갱신되지 않은 것은 누락으로 보인다 — Java 쪽 수정에 붙은 근거가 파일 쪽에도 그대로 적용되기 때문. 의도적 분기라는 표시는 없다.\n32016 | - `maxBatches=20` 하드코딩이 프로퍼티가 아닌 이유는 알 수 없다.\n32017 | - 구세대 `MessageId` 오버로드가 남아 있는 이유, 그리고 그것이 `lease_owner` 를 지우지 않는 것이 의도인지 누락인지.\n32018 | - `aggregateIdAsPartitionKey` 가 커넥터 설정에 전달되지 않는 것이 의도인지 누락인지.\n32019 | \n32020 | ---\n32021 | \n32022 | #### 16. 확인한 것 / 확인하지 못한 것\n32023 | \n32024 | **확인한 것**\n32025 | \n32026 | - production 13파일 + SQL 4 + properties 1 전부 본문 확인.\n32027 | - 테스트 76건 전건 통과, **컨테이너 IT 28건이 실제로 실행됨** (`EVD-313`).\n32028 | - 이 환경에서 Docker 사용 가능 (client 29.1.3 / server 29.6.1, 소켓 마운트).\n32029 | - 정리 작업이 무제한 DELETE 를 쏜다는 것 — 두 SQL·호출자·starter 배선·대역 전부 확인 (`EVD-311`).\n32030 | - 역슬래시 종결 헤더 값의 왕복 손상 — **jshell 리플렉션으로 런타임 재현** (`EVD-314`).\n32031 | - Debezium 설정 두 표현의 항목별 차이와 연결 코드 0건 (`EVD-310`).\n32032 | - 조립 탐침 전수, 릴레이 기동 확인, CDC 미배선 (`EVD-312`).\n32033 | - 구·신 전이 메서드의 SET 절 차이.\n32034 | \n32035 | **확인하지 못한 것**\n32036 | \n32037 | - **테스트 8파일을 축자 통독하지 않았다.** 76개 메서드 이름 전수와 판정에 필요한 구간(대역 구현, purge/Debezium/이스케이프 단언)만 읽었다. 커버리지 원장에 `STRUCTURAL_ONLY` 로 기록했다.\n32038 | - §12.1(a)와 (c)의 결과를 실제 배포에서 관측하지 않았다. (a)는 SQL·호출자·배선으로, (c)는 호출부 부재로 도출했다.\n32039 | - 실제 Debezium 커넥터를 띄워 properties 의 동작을 확인하지 않았다. 두 설정의 차이는 텍스트 대조로 확인했다.\n32040 | - §12.4(c)의 지터 동기화를 다중 인스턴스로 재현하지 않았다. 함수가 `exponent` 만의 함수라는 것은 코드로 확인했다.\n32041 | - 구세대 전이 메서드가 실제로 호출되는 배포가 있는지 — 이 저장소에는 없다.\n32042 | \n32043 | ---\n32044 | \n32045 | #### 17. 손볼 것\n32046 | \n32047 | ##### P1 — 정리 작업이 무제한 DELETE 를 쏘고, 그것을 막는 오버로드는 호출되지 않는다\n32048 | \n32049 | `OutboxCleanupJob:50` 과 `InboxCleanupJob:56` 이 무제한 오버로드를 부른다. bounded 오버로드(`purgePublishedBefore(Instant, int)` / `purgeProcessedBefore(Instant, int)`)는 두 포트에 선언되고 두 구현에 구현되어 있으며 호출부가 **0건**이다(`EVD-294`, `EVD-311`).\n32050 | \n32051 | 두 잡 모두 starter 빈이지만 스케줄러는 등록되지 않으며, 그것은 의도된 설계다(`EVD-316`). 즉 기본 배포에서는 아무 일도 일어나지 않고, 애플리케이션이 문서 지시대로 잡을 스케줄하는 순간 무제한 DELETE 가 발동한다. 잠재 결함이지 상시 결함이 아니다.\n32052 | \n32053 | bounded 구현의 주석이 결과를 명시한다: *\"An unbounded DELETE holds locks and writes WAL in proportion to the whole backlog, which stalls the relay and the business writes behind retention.\"* 3일치 백로그가 쌓인 테이블에서 이것은 릴레이 정지와 비즈니스 쓰기 정체를 뜻한다. 두 리프 모두 `runtime_memberships: [\"app-bootstrap\"]` 이고 두 잡 모두 starter 빈이다.\n32054 | \n32055 | 수정은 한 줄이다 — `purgePublishedBefore(cutoff, batchLimit)`. `maxBatches` 가 그제서야 의미를 갖는다. 배치 크기는 새 파라미터가 필요하고, `OutboxProperties.batchSize`(100)를 재사용하거나 별도 값을 둔다.\n32056 | \n32057 | 그리고 **회귀 테스트가 성립하려면 `RecordingRepository` 를 고쳐야 한다.** 현재 대역의 bounded 구현은 `Math.min(unbounded(), limit)` 로, 전부 지우고 숫자만 깎는다. 실제 저장소를 흉내 내려면 보유 행 목록을 갖고 `limit` 만큼만 제거해야 한다.\n32058 | \n32059 | ##### P2 — 배포되는 Debezium 설정이 수정 이전 버전이다\n32060 | \n32061 | `src/main/resources/debezium/outbox-event-router.properties` 가 `event.key=destination` 을 유지하고 있다. 같은 저장소의 Java(`DebeziumOutboxEventRouter`), V4 마이그레이션 주석, 그리고 전용 테스트(`theRoutedKeyIsNotTheTopicName`)가 모두 그것이 결함이라고 말한다 — \"keying by destination puts every message on a topic onto one partition\".\n32062 | \n32063 | 추가로 헤더 매핑이 15개 중 4개뿐이라, 이 파일로 배포한 CDC 는 tenant·correlation·causation·producer·trace·partition/ordering key 를 **전부 잃는다**. V4 가 존재하는 이유가 그 유실을 막는 것이다.\n32064 | \n32065 | 두 가지가 필요하다.\n32066 | \n32067 | 1. properties 를 Java 설정에서 생성하거나, 최소한 **둘을 대조하는 테스트**를 둔다. `DebeziumOutboxEventRouter.connectorConfiguration(\"\")` 의 항목이 파일에 모두 있는지 확인하는 테스트면 충분하다. 지금은 두 표현을 잇는 코드가 한 줄도 없다.\n32068 | 2. `aggregateIdAsPartitionKey` 를 `connectorConfiguration` 에 전달하거나, 전달할 수 없다면 `DebeziumOutboxRecordMapper` 에서 그 분기를 제거한다. 지금은 모델이 커넥터가 하지 않을 일을 예측한다.\n32069 | \n32070 | ##### P2 — 역슬래시로 끝나는 헤더 값이 헤더 맵을 깨뜨린다\n32071 | \n32072 | `findClosingQuote`(`:657-664`)가 이스케이프된 역슬래시를 고려하지 않는다. 값이 역슬래시로 끝나면 파서가 종료 지점을 놓치고, 뒤에 헤더가 더 있으면 맵 전체가 붕괴한다(`EVD-314`, 런타임 재현).\n32073 | \n32074 | `HeaderValue` 는 제어문자만 금지하므로 이 입력은 플랫폼 검증을 통과한다. 헤더 주입으로 이어지지는 않는다 — 예약 이름은 키가 아니라 값이 되고, 쓰기 경로가 예약 이름을 이미 거절한다.\n32075 | \n32076 | 수정: 종료 판정을 \"앞의 연속된 역슬래시 개수가 짝수\" 로 바꾸거나, 인덱스를 앞에서부터 스캔하며 이스케이프 상태를 추적한다. 후자가 `unescape` 와 대칭이라 낫다.\n32077 | \n32078 | 테스트는 `OutboxPostgresIT.aHeaderValueWithControlCharactersRoundTrips` 옆에 역슬래시 종결 케이스를 추가하면 된다 — 실 DB 왕복까지 확인할 수 있다.\n32079 | \n32080 | ##### P2 — 두 릴레이 상호배제가 기동에서 강제되지 않는다\n32081 | \n32082 | `DebeziumOutboxProfile.requireExactlyOneRelay(...)` 는 프로덕션 호출부가 0건이다. 클래스 javadoc 은 \"the incompatibility is therefore enforced at startup instead of documented\" 라고 쓴다. properties 파일도 같은 경고를 반복한다(\"Enable this OR the in-process polling relay, never both\").\n32083 | \n32084 | 같은 리프에 정확히 이 형태를 고친 선례가 있다 — `OutboxRelayWorker` 가 \"nothing ever called `runOnce`\" 를 고치고 `MessagingOutboxRelayLifecycle` 로 배선까지 마쳤다. 같은 방식으로 `MessagingReliabilityAutoConfiguration` 에 프로필 빈과 `InitializingBean` 검사를 두면 된다.\n32085 | \n32086 | 배선하려면 CDC 모드를 선택할 프로퍼티도 필요하다 — 지금은 `DebeziumOutboxProfile` 을 만드는 설정 경로 자체가 없다.\n32087 | \n32088 | ##### P3 — 구세대 전이 메서드가 신세대와 다른 행 상태를 남긴다\n32089 | \n32090 | `markPublished(MessageId, Instant)` 는 `lease_owner` 와 `next_attempt_at` 을 지우지 않는다. `markPublished(OutboxLease, Instant)` 는 지운다. `markAmbiguous`/`markFailed` 도 같다. 두 컬럼은 청구 술어와 부분 인덱스가 읽는 값이다.\n32091 | \n32092 | 이 저장소에 구세대를 부르는 프로덕션 코드는 없다. 그러나 포트에 남아 있고 `@Deprecated` 도 아니므로, 외부 구현이나 향후 코드가 부를 수 있다. 최소한 `@Deprecated` 와 \"신세대를 쓰라\"는 문장이 필요하고, 더 나은 것은 제거다.\n32093 | \n32094 | ##### P3 — 백오프 지터가 인스턴스를 분산시키지 못한다\n32095 | \n32096 | `jittered = capped - (capped/8) * (exponent % 3)` 는 `exponent` 만의 함수다. 같은 상태의 복제본들은 같은 값을 계산한다. javadoc 이 약속하는 \"thundering herd 방지\" 가 성립하지 않는다.\n32097 | \n32098 | `OutboxRelay` 가 이미 `defaultOwner()` 로 프로세스별 안정 식별자를 만든다(`pid@uuid8`). 그것의 해시를 지터에 섞으면 결정성(같은 프로세스에서 재현 가능)을 유지하면서 인스턴스 간 위상차가 생긴다. javadoc 이 난수를 거부한 이유(\"a random source would make the schedule impossible to test\")도 그대로 지켜진다.\n32099 | \n32100 | ##### P3 — 커넥션 획득 방식이 리프 안에서 갈린다\n32101 | \n32102 | `JdbcOutboxRepository.append` 는 `DataSourceUtils`, 나머지는 raw `dataSource.getConnection()`, `JdbcAdminOperationJournal` 은 전부 `DataSourceUtils`. 릴레이 연산이 비즈니스 트랜잭션에 합류하면 안 된다는 판단은 타당하지만 어디에도 적혀 있지 않고, 같은 리프의 저널이 반대로 한다.\n32103 | \n32104 | `withConnection` 에 한 문장 — \"릴레이 연산은 호출자 트랜잭션에 합류하지 않는다\" — 을 붙이면 `append` 의 상세한 주석과 짝이 맞는다. 저널이 `DataSourceUtils` 를 쓰는 것이 의도인지도 확인이 필요하다.\n32105 | \n32106 | ##### P3 — `maxBatches` 가 하드코딩이고 현재는 의미가 없다\n32107 | \n32108 | starter 가 `20` 을 박아 넣는다(`:141`, `:170`). P1 을 고치기 전에는 이 값이 아무 일도 하지 않고, 고친 뒤에는 배치 크기와 함께 조정 대상이 된다. `OutboxProperties`/`InboxRetentionPolicy` 로 옮기는 것이 맞다.\n32109 | \n32110 | ##### 확인된 설계(문제 아님)\n32111 | \n32112 | - **`append` 의 트랜잭션 3중 검사.** 활성/쓰기 가능/같은 DataSource 바인딩. 세 번째가 특히 드물고 정확하다.\n32113 | - **`message_id` 를 기본키로.** 어떤 코드 경로도 새 id 로 같은 행을 발행할 수 없다.\n32114 | - **펜싱 토큰을 서버측 한 문장에서 증가.** 두 릴레이가 같은 번호를 받을 수 없다.\n32115 | - **종결 쓰기의 owner+token 술어, 그리고 0행을 삼키지 않는 것.** stale 은 중복 발행의 가시화된 형태다.\n32116 | - **재시도 시계를 행에 기록.** 프로세스 메모리의 백오프는 재시작에 잊히고 복제본마다 따로 계산된다.\n32117 | - **`EXHAUSTED` 를 별도 상태로.** AMBIGUOUS 로 두면 대시보드에서 건강한 백로그와 구별되지 않는다.\n32118 | - **`spent = attempts + 1` 로 예산 판정.** 마지막 시도가 두 번 소비되지 않는다.\n32119 | - **`default ->` 에서 크게 실패하기.** 새 completion 이 조용히 `IN_FLIGHT` 를 남기지 않는다.\n32120 | - **리스 ≥ 발행 타임아웃 × 2 를 생성자가 강제.** 그리고 기본값이 자기 규칙을 만족하는지 테스트가 있다.\n32121 | - **정경 메타데이터를 컬럼으로.** 운영자 질문이 SELECT 가 된다.\n32122 | - **tenant 제약을 DB 에도.** 애플리케이션 밖 INSERT 를 막는다.\n32123 | - **`routing_key` 생성 컬럼.** 두 릴레이의 폴백 규칙을 한 곳에 고정한다 (Java 쪽 한정으로).\n32124 | - **봉투 재구성 시 예약 이름을 예외 없이 거절.** 라우팅 키가 컬럼이 된 뒤 규칙이 단순해졌다.\n32125 | - **부재를 빈 문자열로 쓰지 않기** (CDC 헤더, 봉투 양쪽).\n32126 | - **패스 실패가 루프를 끝내지 않게.** 스케줄된 작업의 예외는 이후 모든 패스를 취소한다.\n32127 | - **드레인 종료.** 인터럽트는 크래시와 같은 정체를 만든다.\n32128 | - **정리가 PUBLISHED 만 대상으로.** AMBIGUOUS·FAILED 는 사건 중 가장 필요한 행이다.\n32129 | - **저널을 아웃박스 옆에 두고 DB 제약으로 경쟁을 결판내기.** check-then-act 는 두 복제본을 모두 통과시킨다.\n32130 | - **읽기와 인수 사이의 경쟁을 `RETURNING` 0행으로 거절.**\n32131 | - **컨테이너 IT 를 `test` 에 포함.** 이 리프의 주장은 실제 DB 로만 결판난다.\n32132 | - **제어문자 이스케이프.** (역슬래시 종결 케이스는 §17 P2.)\n32133 | \n32134 | ---\n32135 | \n32136 | #### Source anchors\n32137 | \n32138 | ```\n32139 | src/messaging/messaging-outbox-jdbc-postgresql/build.gradle:1-24\n32140 | src/config/architecture/modules.json (messaging-outbox-jdbc-postgresql 항목)\n32141 | \n32142 | main/resources/db/migration/messaging/V1__messaging_outbox.sql:1-41\n32143 | main/resources/db/migration/messaging/V2__messaging_outbox_lease_fencing.sql:1-39\n32144 | main/resources/db/migration/messaging/V3__messaging_admin_operation_journal.sql:1-37\n32145 | main/resources/db/migration/messaging/V4__messaging_outbox_canonical_metadata.sql:1-66\n32146 | main/resources/debezium/outbox-event-router.properties:1-43\n32147 | \n32148 | main/…/JdbcOutboxRepository.java:37-47,50-62,64-78,80-104,106-142,151-153,155-200,203-218,221-246,249-267,270-306,308-318,319-339,340-350,352-370,371-379,381-389,391-421,424-432,434-444,446-454,456-469,471-484,486-518,519-533,535-545,547-591,593-601,603-628,630-655,657-664,666-700,702-722\n32149 | main/…/OutboxRelay.java:17-28,40-44,66-72,90-99,117-123,145-221,223-230\n32150 | main/…/OutboxRelayWorker.java:15-31,34-35,53-90,92-97,99-125,127-130,132-157,159-174,176-193\n32151 | main/…/OutboxRetryScheduler.java:8-21,28-43,45-61,63-80,82-85,87-90,92-109,111-119,121-133\n32152 | main/…/OutboxProperties.java:7-22,31-32,34-59,61-74\n32153 | main/…/OutboxCleanupJob.java:7-15,22-36,38-57\n32154 | main/…/OutboxRelayReport.java:3-26,30-49\n32155 | main/…/OutboxEnvelopeFactory.java:20-38,43-50,52-104,106-123\n32156 | main/…/JdbcAdminOperationJournal.java:22-33,36-72,79-82,84-120,122-140,142-160,162-192,217-235,237-245,247-262,263-271,273-283,285-318,320-324\n32157 | main/…/DebeziumOutboxProfile.java:6-18,22-28,30-36,38-45,47-66\n32158 | main/…/DebeziumOutboxEventRouter.java:10-34,37-47,49-85,87-136,138-150\n32159 | main/…/DebeziumOutboxRecordMapper.java:10-27,30-32,34-43,45-68,70-78\n32160 | main/…/DebeziumMappedRecord.java:8-23,25-34,36-53\n32161 | \n32162 | test/…/OutboxPostgresIT.java (메서드 인벤토리 21건; 195-202, 503-521, 538-560 본문 확인)\n32163 | test/…/OutboxOperationsTest.java:105-190 (RecordingRepository + cleanup 3건 본문 확인)\n32164 | test/…/DebeziumOutboxRecordMapperTest.java:150-200 (본문 확인), 58-148 (메서드명)\n32165 | test/…/OutboxRelayTest.java / OutboxRelayWorkerTest.java / OutboxEnvelopeFactoryTest.java /\n32166 | test/…/JdbcOutboxTransactionRequirementTest.java / AdminOperationJournalPostgresIT.java (메서드 인벤토리)\n32167 | \n32168 | src/messaging/messaging-spring-boot-starter/.../MessagingReliabilityAutoConfiguration.java:63,89,109,140-141,169-170\n32169 | src/messaging/messaging-spring-boot-starter/.../MessagingOutboxRelayLifecycle.java:42\n32170 | src/messaging/messaging-core-api/.../header/HeaderValue.java:5-25\n32171 | src/messaging/messaging-inbox-jdbc-postgresql/.../InboxCleanupJob.java:56\n32172 | src/app-bootstrap/src/test/.../MessagingCapabilityRegistryContractTest.java:61\n32173 | ```\n32174 | \n32175 | #### 기록이 인용한 원문 — `21234e38`\n32176 | \n32177 | > `tech-log-studio/` 의 기록이 인용한 코드가 이 문서에 없었다(`check_evidence --repo`). 인용한 줄은 고정 리비전 `21234e38` 에 실재하는 것을\n32178 | > `git grep -F` 로 확인했고, 없던 쪽은 이 문서였다. **옮겨 적은 문장이 아니라 저장소\n32179 | > 원문을 담는다** — 기록을 복사해 넣으면 옮겨 적기가 어긋나도 검사기가 더는 못 잡는다.\n32180 | \n32181 | `src/messaging/messaging-outbox-jdbc-postgresql/src/main/resources/db/migration/messaging/V2__messaging_outbox_lease_fencing.sql:16-23` — `concept-fenced-lease.md` 가 인용한다.\n32182 | \n32183 | ```sql\n32184 | ADD COLUMN lease_owner VARCHAR(160),\n32185 | ADD COLUMN lease_token BIGINT NOT NULL DEFAULT 0,\n32186 | ADD COLUMN next_attempt_at TIMESTAMPTZ;\n32187 | \n32188 | -- Backfill is unnecessary for correctness — the default is 0 and the first claim increments it —\n32189 | -- but the constraint states the invariant the code depends on.\n32190 | ALTER TABLE messaging_outbox\n32191 | ADD CONSTRAINT ck_messaging_outbox_lease_token CHECK (lease_token >= 0);\n32192 | ```\n32193 | \n32194 | \n32195 | ---\n32196 | \n32197 | ## A19-MESSAGING-POLICY. messaging-policy\n32198 | \n32199 | > 분석 중에는 `messaging/MESSAGING-POLICY.md` 파일이었다. 880줄.\n32200 | \n32201 | ### messaging-policy 완전 해부\n32202 | \n32203 | > 상태: COMPLETE\n32204 | > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n32205 | > 분석 범위: `src/messaging/messaging-policy`\n32206 | > SSOT owner: `messaging-policy`\n32207 | > integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n32208 | \n32209 | ---\n32210 | \n32211 | #### 0. SSOT identity / 커버리지와 숫자 지도\n32212 | \n32213 | - registered leaf id: `messaging-policy`\n32214 | - canonical state `analysisFile`: §A19-MESSAGING-POLICY\n32215 | - source path: `src/messaging/messaging-policy`\n32216 | - registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-schema-api\"]`\n32217 | - registry `runtime_memberships`: `[\"app-bootstrap\"]`\n32218 | \n32219 | ##### 숫자\n32220 | \n32221 | | 항목 | 수 |\n32222 | |---|---:|\n32223 | | production Java 파일 | 26 |\n32224 | | production LOC | 1,738 |\n32225 | | 패키지 | 1 (`dev.caskeleton.messaging.policy`) |\n32226 | | test 파일 | 4 |\n32227 | | test 메서드(실행 확인) | 42 |\n32228 | | 외부(비프로젝트) 의존성 | **0** |\n32229 | \n32230 | 26개 타입을 관심사로 나누면 다섯이다.\n32231 | \n32232 | | 축 | 타입 |\n32233 | |---|---|\n32234 | | **목적지 정의** (8) | `DestinationProfile` · `PhysicalDestination` · `SchemaPolicy` · `ProducerPolicy` · `ConsumerPolicy` · `PayloadPolicy` · `DeadLetterPolicy` · `CapabilityTier` |\n32235 | | **시작 검증** (1) | `DestinationProfileValidator` |\n32236 | | **발행 관문** (3) | `MessagingAdmissionController` · `PayloadLimitGuard` · `InFlightLimiter` |\n32237 | | **재시도 판단** (8) | `RetryPolicy` · `RetryMode` · `OrderingImpact` · `RetryContext` · `RetryDecision` · `RetryDecisionEngine` · `DefaultRetryDecisionEngine` · `BackoffCalculator` |\n32238 | | **DLQ 조정** (6) | `DeadLetterOrchestrator` · `DeadLetterEnvelopeFactory` · `DeadLetterMetadata` · `DeadLetterResult` · `SourceSettlement` · `FailureDescriptorDefaults`(package-private) |\n32239 | \n32240 | **다섯 축의 배선 상태가 서로 다르다.** 목적지 정의·시작 검증·발행 관문은 출하 컨텍스트에서 실제로 실행되고, 재시도 판단과 DLQ 조정은 bean으로 생성되지만 주입되는 곳이 없다(§12.1).\n32241 | \n32242 | ##### Coverage ledger\n32243 | \n32244 | | scope/file group | count | disposition | reason |\n32245 | |---|---:|---|---|\n32246 | | `src/main/java/**` (26) | 26 | `FULL_READ` | 전 파일 본문 확인 |\n32247 | | `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 및 단언 확인 |\n32248 | | `build.gradle` | 1 | `FULL_READ` | 6줄 |\n32249 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |\n32250 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n32251 | \n32252 | `UNCLASSIFIED` 0.\n32253 | \n32254 | ---\n32255 | \n32256 | #### 1. 모듈의 정체와 경계\n32257 | \n32258 | 이 leaf는 **\"이 목적지는 무엇을 약속하는가\"**를 소유한다. 브로커를 만지지 않고 벤더 의존성이 0이며, 대신 브로커 어댑터가 따라야 할 판단을 미리 계산한다.\n32259 | \n32260 | 경계 규칙 하나가 leaf 전체를 관통한다: **모순은 부팅 실패여야 한다.**\n32261 | \n32262 | ```java\n32263 | // DestinationProfileValidator.java:20-24\n32264 | * Every rule here exists because the alternative is a production surprise. A profile that asks\n32265 | * for ordered delivery and configures a reordering retry does not fail on the happy path; it fails\n32266 | * the first time a message is retried, months later, in a way that looks like a data bug rather\n32267 | * than a configuration one. Making the contradiction a boot failure moves that discovery to the\n32268 | * deploy that introduced it.\n32269 | ```\n32270 | \n32271 | 두 번째 경계는 **물리 주소의 격리**다.\n32272 | \n32273 | ```java\n32274 | // PhysicalDestination.java:9-11\n32275 | * Held here and nowhere else. Once a topic name reaches application code the logical destination\n32276 | * stops being a boundary, and swapping the broker under a service becomes a code change instead of\n32277 | * a configuration change.\n32278 | ```\n32279 | \n32280 | `messaging-core-api`의 `DestinationName`이 `:`과 `/`를 정규식으로 막고(그쪽 §4), 이 leaf가 물리 주소를 독점한다. 두 leaf가 같은 경계를 양쪽에서 지킨다.\n32281 | \n32282 | ---\n32283 | \n32284 | #### 2. 의존성과 런타임 배선\n32285 | \n32286 | 들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api). 둘 다 `api`인 이유는 `DestinationProfile`이 `DeliveryGuarantee`·`OrderingScope`·`DestinationKind`·`DestinationName`(core-api)와 `SchemaCompatibility`(schema-api)를 필드로 갖기 때문이다.\n32287 | \n32288 | 나가는 것: `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`.\n32289 | \n32290 | **실제 배선 지점 넷**(전부 `messaging-spring-boot-starter/MessagingCoreAutoConfiguration`):\n32291 | \n32292 | | 지점 | 라인 | 상태 |\n32293 | |---|---:|---|\n32294 | | `new DestinationProfileValidator().validateAll(registered)` | 134 | **실행됨** — 시작 시 전체 registry 검증 |\n32295 | | `DestinationProfileValidator` bean | 145–146 | 생성 |\n32296 | | `MessagingAdmissionController` bean | 407–417 | 생성 + `DefaultMessagePublisher`·`MessagingEndpoint`·`MessagingShutdownLifecycle`이 주입받음 |\n32297 | | `RetryDecisionEngine` bean | 167–169 | 생성, **주입처 없음**(§12.1) |\n32298 | | `DeadLetterOrchestrator` bean | 179–181 | 생성, **주입처 없음**(§12.1) |\n32299 | \n32300 | 이 leaf 자체는 Spring 주석을 갖지 않는다 — bean 정의는 전부 starter 쪽에 있다.\n32301 | \n32302 | ---\n32303 | \n32304 | #### 3. 패키지/컴포넌트 지도\n32305 | \n32306 | ```\n32307 | [목적지 정의]\n32308 | DestinationProfile ─┬─ PhysicalDestination (topic/exchange/routingKey/queue/subject/stream)\n32309 | ├─ SchemaPolicy (codec, compatibility, 닫힌 messageTypes)\n32310 | ├─ ProducerPolicy (confirmation, timeout, mandatoryRouting, idempotent)\n32311 | ├─ ConsumerPolicy (group, concurrency, maxInFlightPerUnit, prefetch, timeout, manual)\n32312 | ├─ RetryPolicy (mode, maxAttempts, backoff, orderingImpact, 카테고리 오버라이드)\n32313 | ├─ DeadLetterPolicy (enabled, destination, maxRedriveCount)\n32314 | ├─ PayloadPolicy (maxBytes, claimCheckThreshold)\n32315 | └─ CapabilityTier (M1/M2/M3)\n32316 | \n32317 | [시작 검증] DestinationProfileValidator\n32318 | ├─ validate(profile) : 프로파일 내부 모순 15가지\n32319 | └─ validateAll(profiles) : 중복 이름 + retry/DLQ 그래프 사이클\n32320 | \n32321 | [발행 관문] MessagingAdmissionController\n32322 | ├─ PayloadLimitGuard ── PayloadPolicy\n32323 | └─ InFlightLimiter (Semaphore, fair)\n32324 | \n32325 | [재시도 판단] RetryContext ─→ RetryDecisionEngine ─→ RetryDecision (sealed 5)\n32326 | ↑\n32327 | DefaultRetryDecisionEngine ── BackoffCalculator\n32328 | \n32329 | [DLQ 조정] DeadLetterOrchestrator ─┬─ DeadLetterEnvelopeFactory ── DeadLetterMetadata\n32330 | └─ SourceSettlement → DeadLetterResult\n32331 | ```\n32332 | \n32333 | ---\n32334 | \n32335 | #### 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 | \n32651 | #### 7. 트랜잭션·동시성·수명주기\n32652 | \n32653 | 트랜잭션 없음.\n32654 | \n32655 | 동시성 지점은 `MessagingAdmissionController`와 `InFlightLimiter` 둘이다.\n32656 | \n32657 | | 지점 | 도구 | 보호 |\n32658 | |---|---|---|\n32659 | | `perDestination` 맵 | `ConcurrentHashMap` + `computeIfAbsent` | 목적지 카운터 생성 |\n32660 | | 목적지 카운터 증가 | `AtomicInteger` CAS 루프 | 천장 초과 방지 |\n32661 | | 목적지 카운터 감소 | `getAndUpdate` + 0 clamp | 음수 방지 |\n32662 | | 맵 항목 제거 | `computeIfPresent` (원자) | 0일 때만 제거, 누수 방지 |\n32663 | | `acceptingNewWork` | `volatile boolean` | 종료 플래그 가시성 |\n32664 | | permit | `Semaphore(limit, true)` — **fair** | 유한 대기 보장 |\n32665 | | permit 반납 | `availablePermits() < limit` 확인 | 천장 상승 방지 |\n32666 | \n32667 | `reserve`의 CAS 루프는 `AtomicInteger.updateAndGet`으로 쓸 수 있었지만 조건부 실패(`return false`)가 필요해서 직접 루프를 돈다.\n32668 | \n32669 | `release`에 **미세한 경합**이 있다. `getAndUpdate`로 감소한 뒤 `computeIfPresent`로 0인 항목을 제거하는데, 그 사이에 다른 스레드가 `computeIfAbsent`로 같은 키를 만들고 증가시킬 수 있다. 그러면 `computeIfPresent`의 람다가 `value.get() == 0`을 보지 못해 제거하지 않는다 — 안전한 방향의 경합이다(누수가 아니라 제거 실패). 반대 순서였다면 살아 있는 카운터를 지울 수 있었다.\n32670 | \n32671 | `DefaultRetryDecisionEngine`·`BackoffCalculator`·`DeadLetterOrchestrator`·`DeadLetterEnvelopeFactory`·`DestinationProfileValidator`는 전부 상태가 없거나 불변이다. `BackoffCalculator`의 기본 생성자가 `ThreadLocalRandom`을 쓰므로 스레드 안전하다.\n32672 | \n32673 | 수명주기 참여는 `stopAcceptingNewWork()` 하나이고, `MessagingShutdownLifecycle`(starter)이 종료 1단계에서 부른다(`messaging-transport-spi` §12.1 참조).\n32674 | \n32675 | ---\n32676 | \n32677 | #### 8. 설정·기능 플래그·환경 차이\n32678 | \n32679 | 설정 파일 없음. 상수와 기본값:\n32680 | \n32681 | | 상수/기본값 | 값 | 위치 |\n32682 | |---|---:|---|\n32683 | | `PayloadPolicy.DEFAULT_MAX_BYTES` | 1,048,576 | `PayloadPolicy.java:17` (public) |\n32684 | | `PayloadPolicy.HARD_MAX_BYTES` | 8,388,608 | `:20` (public) |\n32685 | | `ProducerPolicy.defaults()` | `REPLICATION_OR_PERSISTENCE_ACK`, 5초, mandatoryRouting, idempotent | `:34-37` |\n32686 | | `ConsumerPolicy.defaults(group)` | concurrency 1, maxInFlightPerUnit 1, prefetch 16, timeout 30초, manual false | `:52-54` |\n32687 | | `RetryPolicy.none()` | mode NONE, 1회, 지연 0, PRESERVE | `:115-125` |\n32688 | | `DeadLetterPolicy.disabled()` / `.to(dest)` | maxRedrive 0 / 1 | `:32-44` |\n32689 | \n32690 | **모든 기본값이 보수적이다** — 재시도 없음, 동시성 1, 순서 보존, 확인 최대, DLQ 비활성. 켜는 것이 명시적 선택이다.\n32691 | \n32692 | `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.\"\n32693 | \n32694 | `PayloadPolicy.DEFAULT_MAX_BYTES`는 이 저장소에서 1 MiB 상한을 선언하는 다섯 곳 중 하나이고 **정책 축의 자연스러운 주인**이다. 그런데 starter는 이것 대신 `JacksonMessageCodec.DEFAULT_MAX_BYTES`를 참조한다 — §A19-MESSAGING-SCHEMA-JSON §17이 소유한다.\n32695 | \n32696 | ---\n32697 | \n32698 | #### 9. 퍼시스턴스/외부 시스템 세부\n32699 | \n32700 | 없다. 브로커·DB·파일시스템을 만지지 않는다. `ThreadLocalRandom`(jitter)과 `Semaphore`가 유일한 런타임 자원이다.\n32701 | \n32702 | ---\n32703 | \n32704 | #### 10. 테스트 레인과 실제 증명 범위\n32705 | \n32706 | 레인: `./gradlew :messaging:messaging-policy:test`. **BUILD SUCCESSFUL, 42 tests, 0 skipped, 0 failures**.\n32707 | \n32708 | | 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |\n32709 | |---|---:|---|---|\n32710 | | `DestinationProfileValidatorTest` | 13 | 순서/페이로드/DLQ 자기참조/키 리졸버/M1 수동정산/확인/토폴로지/DLQ 필요, **retry↔DLQ 교대 사이클 거절**, **다이아몬드 허용**, 미등록 목적지 거절 | 실제 부팅에서 이 검증이 호출되는지(→ starter가 부른다, §2) |\n32711 | | `MessagingAdmissionControllerTest` | 13 | permit 점유/반납, 초과 시 큐잉 대신 거절, backpressure가 retryable, 초과 payload가 permit을 안 먹음, 종료 시 기존 permit 유지, 불균형 반납이 천장을 못 올림, 한 목적지가 전부 못 먹음, 거절이 슬롯을 안 남김, 완료가 둘 다 반납, 미지 목적지 완료가 permit을 안 품, 이중 완료, 배치 두 축, 대기 후 승인 | 실제 부하에서의 공정성 |\n32712 | | `RetryDecisionEngineTest` | 10 | 역직렬화 실패 즉시 파킹, 인증/구성 실패 미재시도, 순서 Kafka는 pause, 소진은 DLQ, 비순서 재시도목적지 재발행, blocking은 inline, **지수 증가와 상한**, **full jitter 분포**, 프로파일 오버라이드, at-most-once DLQ 없으면 discard | **이 엔진이 production에서 호출되는지** |\n32713 | | `DeadLetterOrchestratorTest` | 6 | 확인 후에만 원본 정산, 모호하면 미정산, 거절되면 미정산, 헤더 부착 | **이 orchestrator가 production에서 호출되는지** |\n32714 | \n32715 | **두 축의 증명 성격이 다르다.** 검증기와 관문은 배선까지 확인되지만(§2), 재시도 엔진과 DLQ 조정자는 로직만 증명되고 배선은 §12.1이 부정한다. 테스트가 통과한다는 것이 그 코드가 실행된다는 뜻이 아닌 전형적인 예다.\n32716 | \n32717 | `MessagingAdmissionControllerTest`의 `as(...)` 문구들이 특히 구체적이다 — \"a slot leaked per refusal shrinks the destination's ceiling until it is zero\", \"a permit nobody took cannot be given back; doing so makes the ceiling fiction\". 각 테스트가 어떤 이전 결함을 붙들고 있는지 이름 자체가 말한다.\n32718 | \n32719 | ---\n32720 | \n32721 | #### 11. 빌드/ArchUnit/CI 강제 지점\n32722 | \n32723 | | 게이트 | 이 leaf에 대해 |\n32724 | |---|---|\n32725 | | `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\",\"messaging-schema-api\"]` |\n32726 | | `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |\n32727 | | vendor `api` 규칙 | 벤더 의존성 0 |\n32728 | | **부팅 검증** | `MessagingCoreAutoConfiguration:134`가 `validateAll`을 호출 — 이 leaf의 규칙이 실제로 부팅을 막는 유일한 지점 |\n32729 | | ArchUnit | 전용 규칙 없음 |\n32730 | \n32731 | §4.1의 15가지 규칙은 **ArchUnit이 아니라 런타임 시작 시점**에 강제된다. `verifyCleanArchitectureDependencies`가 빌드 타임에 도는 것과 대비된다. 잘못된 프로파일은 컴파일되고, 부팅에서 막힌다.\n32732 | \n32733 | ---\n32734 | \n32735 | #### 12. 실제 사용 여부와 negative-space probes\n32736 | \n32737 | 원시 증거: `evidence/raw/281-messaging-policy-retry-engine-unwired.txt`.\n32738 | \n32739 | > **방법 주의.** 이 절의 조립 판정은 `new ([a-zA-Z0-9_.]+\\.)?