# messaging-outbox-jdbc-postgresql 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-outbox-jdbc-postgresql` > SSOT owner: `messaging-outbox-jdbc-postgresql` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-outbox-jdbc-postgresql` - canonical state `analysisFile`: `analysis/messaging/messaging-outbox-jdbc-postgresql.md` - source path: `src/messaging/messaging-outbox-jdbc-postgresql` - registry `allowed_dependencies`: `["messaging-core-api", "messaging-reliability-api", "messaging-policy", "messaging-observability", "messaging-admin-api"]` - registry `runtime_memberships`: **`["app-bootstrap"]`** — 배포된다 ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | 13 | | test Java 파일 | 8 | | 전체 LOC (Java) | 4,416 | | SQL 마이그레이션 | **4** (V1~V4) | | 기타 리소스 | 1 (`debezium/outbox-event-router.properties`) | | test 메서드(실행 확인) | **76** (`EVD-313`) | | 그중 컨테이너 IT | **28** (Postgres 21 + admin journal 7) — **실제 실행됨** | | 선언된 의존 | project 5 + vendor 2(impl) + vendor 4(test) | | leaf 밖에서 import 하는 파일 | 3 (starter 2 + app-bootstrap 계약 테스트 1) | 13개 production 타입: | 타입 | LOC | 역할 | src/main 생성 | |---|---:|---|---:| | `JdbcOutboxRepository` | 722 | `OutboxRepository` 의 PostgreSQL 구현 | **0** | | `JdbcAdminOperationJournal` | 326 | `AdminOperationJournal` 의 PostgreSQL 구현 | **0** | | `OutboxRelay` | 231 | 한 번의 릴레이 패스 | 1 (starter) | | `OutboxRelayWorker` | 199 | 패스를 스케줄링·구동 | 1 (starter) | | `DebeziumOutboxEventRouter` | 151 | CDC 커넥터 설정·헤더 매핑 | 1 (자기 참조) | | `OutboxRetryScheduler` | 134 | 백오프와 시도 예산 | 2 | | `OutboxEnvelopeFactory` | 124 | 행 → 발행 봉투 | **0** | | `OutboxProperties` | 81 | 설정과 그 사이의 불변식 | — | | `DebeziumOutboxRecordMapper` | 79 | CDC 가 낼 레코드의 모델 | **0** | | `DebeziumOutboxProfile` | 67 | 릴레이 모드 선택 + 상호배제 | 1 (자기 팩토리) | | `OutboxCleanupJob` | 58 | 보존기간 지난 PUBLISHED 행 삭제 | 1 (starter) | | `DebeziumMappedRecord` | 54 | CDC 출력 레코드 | — | | `OutboxRelayReport` | 50 | 패스 1회 결과 | — | ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `src/main/java/**` (13) | 13 | `FULL_READ` | 전 파일 본문 확인 | | `src/main/resources/db/migration/**` (4) | 4 | `FULL_READ` | V1~V4 전문 | | `src/main/resources/debezium/*.properties` (1) | 1 | `FULL_READ` | 43줄 전문 | | `src/test/java/**` (8) | 8 | `STRUCTURAL_ONLY` | 76개 테스트 메서드 인벤토리 전수 + 판정에 필요한 구간(대역 구현, purge·Debezium·이스케이프 단언)만 본문 확인. 전 파일 축자 통독은 하지 않았다 | | `build.gradle` | 1 | `FULL_READ` | 24줄 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 (단, jshell 탐침에 컴파일된 클래스를 사용 — `EVD-314`) | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 **트랜잭셔널 아웃박스의 PostgreSQL 구현**이다. 비즈니스 트랜잭션이 쓰고 릴레이가 배출한다. 여기에 더해 `messaging-admin-api` 의 파괴적 작업 저널 구현도 같이 산다 — 그 이유가 build.gradle 에 적혀 있다. ```groovy // build.gradle:9-13 // The destructive-operation journal lives here because it needs exactly what the outbox needs: // one relational database every replica can see, and a migration lane that already exists. The // contract it implements belongs to the admin API. api project(':messaging:messaging-admin-api') ``` 이 리프의 축은 하나다: **"모르는 것을 실패로 취급하지 않는다."** ```java // OutboxRelay.java:17-27 /** * Publishes outbox rows, treating an unknown outcome as retryable rather than final. * *

The relay's correctness rests on one rule: an ambiguous publish is retried under the same * message id. Minting a new id would turn a possibly-delivered message into a * definitely-second message, and no downstream deduplication could recover from it. Marking it * failed instead would lose a message the broker may already hold. * *

The relay therefore guarantees at-least-once publication and nothing more. Effectively-once * downstream effects come from pairing it with an Inbox — which is why the platform never * advertises the outbox as exactly-once. */ ``` 마지막 문장이 중요하다 — 이 리프가 자기 보장의 상한을 스스로 명시한다. 경계: 브로커를 모른다(`MessagePublisher` 포트만 안다). 스프링 컨텍스트를 모른다(`spring-jdbc`/`spring-tx` 는 `implementation` 이며 트랜잭션 동기화 조회에만 쓴다). 배선은 starter 몫이다. --- ## 2. 의존성과 런타임 배선 ```groovy // build.gradle 전문 (24줄) apply plugin: 'java-library' dependencies { api project(':messaging:messaging-core-api') api project(':messaging:messaging-reliability-api') api project(':messaging:messaging-policy') api project(':messaging:messaging-observability') api project(':messaging:messaging-admin-api') // + 위 주석 implementation 'org.springframework:spring-jdbc' implementation 'org.springframework:spring-tx' // Live-database certification. The reliability patterns are claims about transaction // boundaries and uniqueness constraints, and only a real database can settle them. testImplementation project(':messaging:messaging-testkit') testImplementation 'org.testcontainers:testcontainers-postgresql' testImplementation 'org.testcontainers:testcontainers-junit-jupiter' testImplementation 'org.postgresql:postgresql' } ``` testcontainers 주석이 이 리프의 성격을 요약한다 — "신뢰성 패턴은 트랜잭션 경계와 유일성 제약에 대한 주장이고, 그것을 결판낼 수 있는 것은 실제 데이터베이스뿐이다." 그리고 그 레인이 **실제로 돈다**(§10). starter 가 만드는 빈(`EVD-312`): ```java // MessagingReliabilityAutoConfiguration.java :63 new OutboxRetryScheduler(properties, Duration.ofMinutes(1)) :89 new OutboxRelay(...) :109 new OutboxRelayWorker(relay, scheduler) :141 new OutboxCleanupJob(outbox, properties, 20) :170 new InboxCleanupJob(inbox, policy, 20) // MessagingOutboxRelayLifecycle.java :42 worker.start(); ``` starter 가 만들지 **않는** 것: `JdbcOutboxRepository`, `OutboxEnvelopeFactory`, `JdbcAdminOperationJournal`. 셋 다 애플리케이션이 `DataSource`/`ProducerId` 를 알고 직접 등록해야 한다. `AdminOperationJournal` 의 기본값은 `InMemoryAdminOperationJournal` 이며, 프로덕션 프로파일에서는 `MessagingAdminDurabilityValidator` 가 그것을 거부한다(`analysis/messaging/messaging-admin-runtime.md` §4.4 참조). --- ## 3. 패키지/컴포넌트 지도 단일 패키지 `dev.caskeleton.messaging.outbox`. 두 갈래의 배출 경로가 있고, 한쪽만 살아 있다. ``` [비즈니스 트랜잭션] | JdbcOutboxRepository.append(record) — 호출자의 커넥션에 합류, 없으면 거절 v messaging_outbox 테이블 | +--- 경로 A: 폴링 릴레이 (배선됨) | OutboxRelayWorker.start() -> runPass() | -> OutboxRelay.runOnce(now) | claimBatch(owner, batchSize, lease, now, maxAttempts) FOR UPDATE SKIP LOCKED | -> OutboxEnvelopeFactory.toEnvelope(row) | -> MessagePublisher.publish(...) | -> markPublished / markAmbiguous / markExhausted / markFailed (펜싱 술어) | -> OutboxRetryScheduler.backoff(unproductivePasses) | +--- 경로 B: CDC 릴레이 (배선 안 됨 — §12.1) DebeziumOutboxProfile(CHANGE_DATA_CAPTURE, prefix, flag) -> DebeziumOutboxRecordMapper.map(row) -> DebeziumMappedRecord [모델] -> DebeziumOutboxEventRouter.connectorConfiguration(prefix) [Java 설정] debezium/outbox-event-router.properties [배포 설정 — 드리프트] messaging_admin_operation 테이블 | JdbcAdminOperationJournal (begin/checkpoint/complete/fail/find) ``` --- ## 4. 계약·불변식·상태 모델 ### 4.1 스키마 — 마이그레이션 4개가 이력을 담고 있다 **V1** — `message_id` 를 대리키가 아니라 기본키로 삼는다. ```sql -- V1__messaging_outbox.sql:3-5 -- Written by the business transaction, drained by the relay. message_id is the primary key rather -- than a surrogate: it is the logical identity the relay must preserve across every retry, and -- making it the key means no code path can accidentally publish the same row under a new id. ``` 인덱스도 근거가 있다. 부분 인덱스인 이유("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"). **V2** — 펜싱 토큰. 주석이 시나리오를 그대로 적는다. ```sql -- V2__messaging_outbox_lease_fencing.sql:3-14 -- V1 recorded only lease_expires_at, so a claim said when it would end and nothing about who held -- it. ... : -- relay A claims the row and calls the broker -- the lease expires; relay B reclaims it, publishes, and records PUBLISHED -- relay A finally times out and records AMBIGUOUS over the top -- The row is now claimable again and the message is published a second time. Making the lease -- longer than the publish timeout lowers the odds; it does not turn a GC pause, a scheduler stall -- or a slow broker into a data constraint. A token does ... ``` "확률을 낮추는 것과 데이터 제약으로 만드는 것은 다르다" — 이 리프에서 가장 좋은 한 줄이다. `EXHAUSTED` 상태 추가와 `next_attempt_at` 인덱스도 여기서 들어온다. **V3** — admin 저널. 복합 기본키 `(approval_ticket, plan_digest)` 의 근거가 `messaging-admin-api` 의 것과 동일하게 적혀 있다. **V4** — 정경 메타데이터 12컬럼. 왜 봉투 blob 이 아니라 컬럼인지가 명확하다. ```sql -- V4:11-14 -- Columns rather than a versioned envelope blob. Both round-trip the values faithfully; only one of -- them lets the relay answer an operator's questions. "Which tenant is the backlog for", "which -- correlation is stuck", "which rows carry a schema this consumer cannot read" are SELECTs against -- this table if the fields are columns, and payload decoding of the whole backlog if they are not. ``` 그리고 밀반입 문제를 명시한다 — "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". DB 레벨 제약을 Java 와 이중으로 거는 이유도 적혀 있다. ```sql -- V4:33-36 -- The same bound TenantContext enforces in Java. Stated here as well because the relay, the CDC -- connector and any operator query read this table directly: a tenant slug that only the -- application validates is a tenant slug that an INSERT from anywhere else can violate ... ALTER TABLE messaging_outbox ADD CONSTRAINT ck_messaging_outbox_tenant CHECK (tenant IS NULL OR tenant ~ '^[a-z0-9][a-z0-9._-]{0,63}$'); ``` 마지막으로 **생성 컬럼**이 두 릴레이의 합의를 하나로 만든다. ```sql -- V4:55-66 -- Debezium's Event Router takes the message key from a column. It was pointed at `destination`, -- which made the key the topic name — every message on a topic sharing one key, so every message -- landing on one partition, and keyed ordering meaning nothing. The polling relay meanwhile used -- the partition key when the row had one and the message id when it did not. -- -- A generated column states that fallback once, in the place both relays read, instead of leaving -- it as a rule each of them implements separately and one of them gets wrong. ALTER TABLE messaging_outbox ADD COLUMN routing_key TEXT GENERATED ALWAYS AS (COALESCE(partition_key, message_id::TEXT)) STORED; ``` **이 수정이 배포되는 properties 파일에는 도달하지 않았다.** §12.4(a). ### 4.2 `append` — 이 리프의 전체 메커니즘 ```java // JdbcOutboxRepository.java:37-46 /** *

{@link #append} deliberately takes no connection of its own: it uses the one the caller is * already inside, which is the entire mechanism. An outbox row written on a separate connection * commits independently of the business change and reopens the window the pattern exists to close. */ ``` 그리고 그것을 **강제**한다. ```java // :203-218 requireActiveTransaction("OUTBOX_TRANSACTION_REQUIRED", "appending to the outbox"); Connection connection = DataSourceUtils.getConnection(dataSource); ``` 세 가지를 본다(`:228-245`): 활성 트랜잭션이 있는가 / 읽기 전용이 아닌가 / **이 DataSource 에 바인딩되어 있는가**. 세 번째가 특히 좋다 — 다른 DataSource 의 트랜잭션 안에서 append 하면 둘이 독립적으로 커밋된다. ```java // :221-227 /** *

Fail-fast rather than "work anyway": an append that silently runs outside the caller's * transaction produces exactly the ghost publication this repository exists to prevent, and it * produces it only on the rollback path — which is the path nobody exercises before production. */ ``` `append(Connection, OutboxRecord)` 가 package-private 으로 내려간 이력도 적혀 있다(`:155-165`) — 예전에는 그것이 public 이었고 "안전한 경로가 호출자가 알아야만 하는 경로" 였다. ### 4.3 청구(claim)와 펜싱 — 두 세대가 공존한다 **신세대** `CLAIM`(`:112-142`)은 소유자와 토큰을 기록하고 재시도 시계를 술어에 포함한다. ```sql WHERE status IN ('PENDING', 'AMBIGUOUS', 'IN_FLIGHT') AND (lease_expires_at IS NULL OR lease_expires_at <= ?) -- The retry clock lives in the row, not in the relay's memory. Without these two -- predicates an AMBIGUOUS row became claimable again on the very next pass, so a -- broker outage meant the whole backlog was republished every poll interval and the -- configured attempt budget was a number nothing consulted. AND (next_attempt_at IS NULL OR next_attempt_at <= ?) AND attempts < ? ORDER BY created_at LIMIT ? FOR UPDATE SKIP LOCKED ... SET status='IN_FLIGHT', lease_expires_at=?, lease_owner=?, lease_token = o.lease_token + 1 ``` 토큰 증가가 청구와 같은 문장 안에서, 서버에서 일어난다 — "two relays racing for the same row cannot receive the same number"(`:106-111`). 종결 쓰기는 전부 펜싱 술어를 단다. ```java // :400-403 String sql = setClause + "WHERE message_id = ? AND status = 'IN_FLIGHT' AND lease_owner = ? AND lease_token = ?"; ``` 그리고 0행을 삼키지 않는다. ```java // :392-398 /** *

The predicate carries the owner and the token as well as the id, so a relay that stalled * past its lease writes nothing: another relay's claim incremented the token, and this update * matches zero rows. Zero is reported rather than swallowed — a stale write means this worker may * have produced a duplicate publication, which is exactly what an operator needs to see. */ ``` **구세대** `LEASE`(`:80-104`)와 `markPublished(MessageId)` / `markAmbiguous(MessageId, ...)` / `markFailed(MessageId, ...)` / `releaseLease(MessageId)` 는 소유자·토큰을 다루지 않는다. 그리고 남기는 행 상태가 다르다(§12.3(a)). ### 4.4 `OutboxRelay.runOnce` — 세 결과, 다섯 카운터 ```java // :169-218 (요약) switch (result.completion()) { case CONFIRMED -> markPublished(lease, now) APPLIED? published++ : stale++ case AMBIGUOUS -> { int spent = record.attempts() + 1; scheduler.parkReason(spent) .map(reason -> markExhausted(lease, reason, now)) .orElseGet(() -> markAmbiguous(lease, code, now, scheduler.nextAttemptAt(now, spent))); APPLIED? (isExhausted(spent) ? exhausted++ : ambiguous++) : stale++ } case REJECTED -> markFailed(lease, code, now) APPLIED? failed++ : stale++ default -> throw new IllegalStateException("unhandled publish completion: " + …); } ``` `spent = attempts + 1` 의 근거가 붙어 있다. ```java // :179-181 // The attempt this pass just spent. The claim predicate and the row both count attempts // after the transition, so the budget has to be judged on the same number the next claim // will read, or the last attempt is spent twice. ``` `EXHAUSTED` 를 별도 상태로 두는 근거도. ```java // :186-188 // A row that has spent its budget without an answer is parked under its own // status. Leaving it AMBIGUOUS makes it a row the claim predicate silently skips // forever, which looks identical to a healthy backlog on every dashboard. ``` `default ->` 분기의 존재 이유까지 적혀 있다(`:213-215`) — 새 completion 상수가 생기면 조용히 `IN_FLIGHT` 로 남기는 대신 크게 실패하도록. `OutboxRelayReport` 의 다섯 카운터가 각각 다른 운영 신호라는 것도 명시적이다(`:5-18`) — ambiguous 는 확인 문제, failed 는 계약/토폴로지 문제, staleLeases 는 "중복 발행의 가시화된 형태", exhausted 는 "redrive 가 필요한 것". ### 4.5 `OutboxProperties` — 설정 간의 관계를 생성자가 강제한다 ```java // :7-14 /** *

The lease duration is the dangerous one. If it is shorter than the time a publish can take, a * second relay claims the row while the first is still waiting for a confirm, and the message is * published twice — under the same id, so consumers with an inbox survive it, but consumers without * one do not. The constructor therefore requires the lease to exceed the publish timeout by a * margin rather than merely to be positive. */ public static final double REQUIRED_LEASE_FACTOR = 2.0; ``` `leaseDuration >= publishTimeout * 2` 를 생성자가 강제하고 `OUTBOX_LEASE_TOO_SHORT` 로 거절한다. 기본값(30초 / 5초)이 그 규칙을 만족하는지 자체 테스트가 있다(`theDefaultsSatisfyTheirOwnRule`). ### 4.6 `OutboxEnvelopeFactory` — 정경 사실을 컬럼에서 되살린다 ```java // :20-37 /** *

The identity comes from the row, never from a fresh mint. ... * *

So does everything else the envelope carries. This used to rebuild correlation, causation, * tenant, trace and the schema reference as empty, and read the routing keys out of the row's * header map — so a message that travelled through the outbox reached its consumer with less * provenance than one published directly, and the publish path became part of the message's * meaning. ... * *

Reserved header names in the row are refused outright, with no exception for the routing keys. * ... Now that the keys are columns, the rule is the simple one: an outbox row cannot write into * the platform's namespace at all. */ ``` 예약 이름을 만나면 `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`). ### 4.7 `JdbcAdminOperationJournal` — DB 제약이 경쟁을 결판낸다 ```java // :22-32 /** *

Lives beside the outbox because it needs the same thing the outbox needs and nothing more: one * relational database that every replica can see. The uniqueness that stops a second execution is * the primary key on {@code (approval_ticket, plan_digest)}, enforced by the database rather than * by a check-then-act in application code — two replicas that read "no row" at the same instant * would both proceed, and only the constraint makes exactly one of them win. */ ``` `INSERT ... ON CONFLICT DO NOTHING` 이 1행이면 신규 청구, 0행이면 기존 행을 읽어 `refuseIfNotResumable` 후 `TAKE_OVER`. 인수 SQL 자체가 조건을 담는다. ```sql WHERE approval_ticket = ? AND plan_digest = ? AND lease_token = ? -- Only a failed operation or one whose lease ran out may be taken over. A live STARTED row -- means another replica is executing it right now. AND (state = 'FAILED' OR lease_expires_at <= ?) RETURNING lease_token, items_completed ``` 읽기와 인수 사이의 경쟁도 처리한다 — `RETURNING` 이 0행이면 "another replica took it over between the read and this update"(`:181-186`)로 거절. 그리고 `items_completed` 는 `GREATEST` 로 단조 증가한다(`CHECKPOINT`/`SETTLE` SQL). 이것이 `DefaultMessagingAdminService` 가 낡은 값을 넘겨도 진행이 되돌아가지 않는 이유이며, 인터페이스가 요구하지 않는 성질이라는 점은 `analysis/messaging/messaging-admin-runtime.md` §12.4(c)에 있다. --- ## 5. 주요 실행 경로 **쓰기** — 비즈니스 트랜잭션 → `append(record)` → 트랜잭션 3중 검사 → `DataSourceUtils.getConnection` → INSERT(22컬럼). **배출** — `MessagingOutboxRelayLifecycle` → `worker.start()` → `runPass()` → `relay.runOnce(now)` → 청구/발행/종결 → `scheduler.backoff(unproductive)` → 다음 패스 자기 스케줄링. **정리** — `OutboxCleanupJob.runOnce(now)` → `cutoff = now - retention` → `purgePublishedBefore(cutoff)` **무제한 오버로드** ×(최대 `maxBatches`, 실제로는 2회) → §12.1(a). **admin 저널** — `begin` → INSERT ON CONFLICT / TAKE_OVER → `checkpoint` × N → `complete` 또는 `fail`. --- ## 6. 실패 경로와 복구/번역 | 상황 | 처리 | 위치 | |---|---|---| | 트랜잭션 없이 append | `OUTBOX_TRANSACTION_REQUIRED` | `JdbcOutboxRepository:228-235` | | 읽기 전용 트랜잭션 | 〃 | `:236-239` | | 다른 DataSource 의 트랜잭션 | 〃 | `:240-246` | | append SQL 실패 | `OUTBOX_APPEND_FAILED` | `:196-199` | | 그 밖의 쿼리 실패 | `OUTBOX_QUERY_FAILED` | `:594-600` | | 종결 쓰기가 0행 | `OutboxTransitionResult.STALE_LEASE` (예외 아님) | `:413-415` | | 미지의 `PublishCompletion` | `IllegalStateException` | `OutboxRelay:216-217` | | 리스가 발행 타임아웃보다 짧음 | `OUTBOX_LEASE_TOO_SHORT` | `OutboxProperties:52-58` | | 행 헤더에 예약 이름 | `RESERVED_HEADER_IN_OUTBOX_ROW` | `OutboxEnvelopeFactory:70-77` | | 승인 이미 실행됨 | `APPROVAL_ALREADY_EXECUTED` | `JdbcAdminOperationJournal:117-124` | | 다른 런타임이 실행 중 | `ADMIN_OPERATION_IN_FLIGHT` | `:125-132`, `:181-186` | | 리스 상실 후 쓰기 | `ADMIN_OPERATION_LEASE_LOST` | `:263-271` | | 저널 도달 불가 | `ADMIN_JOURNAL_UNAVAILABLE` | `:206-208` 등 | | 두 릴레이 동시 활성 | `DUPLICATE_OUTBOX_RELAY` | `DebeziumOutboxProfile:54-59` (**호출부 0**) | | 릴레이 없음 | `NO_OUTBOX_RELAY` | `:60-65` (**호출부 0**) | `OutboxRelayWorker` 의 패스 실패 처리가 특히 명시적이다. ```java // :184-190 } catch (RuntimeException passFailed) { // A failed pass must not stop the loop: the scheduled task's own exception would cancel every // future pass, turning one broker error into a relay that never runs again. The failure is // counted and the next pass backs off as if nothing was published, which is true. ``` 종료도 인터럽트가 아니라 드레인이다. ```java // :99-106 /** *

Draining rather than interrupting is the whole point. A pass killed between its claim and * its terminal write leaves rows {@code IN_FLIGHT} holding a lease, and nothing may touch them * until that lease expires — so an orderly shutdown would produce exactly the stall that a crash * produces. */ ``` --- ## 7. 트랜잭션·동시성·수명주기 **두 가지 커넥션 획득 방식이 공존한다.** | 메서드 | 획득 | 효과 | |---|---|---| | `JdbcOutboxRepository.append(record)` | `DataSourceUtils.getConnection` | 호출자 트랜잭션에 합류 | | 그 외 전부 (`withConnection`) | `dataSource.getConnection()` + try-with-resources | 풀에서 새 커넥션, 독립 커밋 | | `JdbcAdminOperationJournal` 전 메서드 | `DataSourceUtils.getConnection` | 트랜잭션 있으면 합류 | 릴레이 연산이 비즈니스 트랜잭션에 합류하면 안 되므로 `withConnection` 의 선택은 타당하다. 다만 그 판단이 주석으로 남아 있지 않고, 같은 리프의 저널은 반대 방식을 쓴다. §17 P3. **동시성 제어는 전부 데이터베이스에 있다.** `FOR UPDATE SKIP LOCKED`(청구), 서버측 토큰 증가, 펜싱 술어, `ON CONFLICT DO NOTHING`, 복합 기본키. Java 쪽에 락이 없다. **수명주기**: `OutboxRelayWorker` 는 데몬 스레드 1개, `setExecuteExistingDelayedTasksAfterShutdownPolicy(false)`, `start()` 멱등, `stop(deadline)` 드레인 후 실패 시 `shutdownNow()`. 셋 다 근거 주석이 있다(`:79-88`, `:92`, `:121-122`). --- ## 8. 설정·기능 플래그·환경 차이 | 값 | 출처 | 기본 | 비고 | |---|---|---|---| | `batchSize` | `OutboxProperties` | 100 | | | `leaseDuration` | 〃 | 30초 | `>= publishTimeout × 2` 강제 | | `publishTimeout` | 〃 | 5초 | | | `pollInterval` | 〃 | 500ms | 백오프의 기준 간격 | | `retentionAfterPublish` | 〃 | 3일 | | | `maxAttempts` | 〃 | 10 | 청구 술어의 `attempts < ?` | | `maxInterval` | starter `:63` | **1분** | `OutboxRetryScheduler.standard()` 는 5분 | | `maxBatches` | starter `:141` | **20 하드코딩** | 실질 무의미 (§12.1(a)) | | relay owner | `OutboxRelay.defaultOwner()` | `pid@uuid8` | 프로세스당 안정 | | CDC 모드 | `DebeziumOutboxProfile` | — | **어떤 프로퍼티에도 연결 안 됨** | `maxInterval` 이 두 값(1분 / 5분)으로 갈리는 것은 결함이 아니다 — starter 가 명시적으로 넘기고, `standard()` 는 호출자가 정책을 주지 않은 경우의 기본값이다. --- ## 9. 퍼시스턴스/외부 시스템 세부 **테이블 2개.** `messaging_outbox`(V1+V2+V4, 최종 34컬럼 + 생성 컬럼 1), `messaging_admin_operation`(V3, 11컬럼). **인덱스 4개**, 전부 부분 인덱스: `ix_..._claimable`, `ix_..._published_at`, `ix_..._next_attempt`, `ix_..._tenant_backlog`, 그리고 `ix_messaging_admin_operation_live`. **헤더 직렬화는 손으로 쓴 JSON** 이다. ```java // :604-610 /** *

Hand-rolled rather than pulled from a JSON library so this module keeps no codec dependency: * outbox headers are always flat string pairs, validated by {@code MessageHeaders} before they * ever reach here. */ ``` 이스케이프는 제어문자까지 처리하며 그 이력이 적혀 있다(`:630-636`). **그러나 역파싱의 종료 판정에 결함이 있다 — §12.1(b), `EVD-314` 에서 런타임 재현했다.** --- ## 10. 테스트 레인과 실제 증명 범위 `EVD-313`: `./gradlew :messaging:messaging-outbox-jdbc-postgresql:test --rerun-tasks` → **76 tests, 0 failures, 0 skipped**. | 클래스 | 수 | 종류 | |---|---:|---| | `OutboxPostgresIT` | **21** | 컨테이너 (Postgres) | | `DebeziumOutboxRecordMapperTest` | 16 | 단위 | | `OutboxOperationsTest` | 10 | 단위 (대역) | | `OutboxRelayTest` | 9 | 단위 (대역) | | `AdminOperationJournalPostgresIT` | **7** | 컨테이너 (Postgres) | | `OutboxEnvelopeFactoryTest` | 6 | 단위 | | `JdbcOutboxTransactionRequirementTest` | 4 | 단위 | | `OutboxRelayWorkerTest` | 3 | 단위 (스레드) | **컨테이너 레인 28건이 실제로 실행되었다** — `skipped="0"` 이고 `tests>0`. `docker version` 은 client 29.1.3 / server 29.6.1 을 보고하고 `/var/run/docker.sock` 이 마운트되어 있다(`EVD-313`). > 이는 앞선 리프 문서들이 "컨테이너 필요 — 미실행" 으로 남긴 항목들(messaging-testkit 의 인증 레인 등)이 **실행 불가가 아니라 아직 실행하지 않은 것**임을 뜻한다. 해당 리프 분석 시 실행한다. `OutboxPostgresIT` 가 실제로 증명하는 것 중 강한 것들: - `theRowAndTheBusinessChangeCommitTogetherOrNotAtAll` — 아웃박스의 존재 이유 그 자체. - `aSupersededRelayCannotOverwriteTheOutcomeOfTheOneThatReplacedIt` / `twoRelaysClaimingConcurrentlyGetDisjointRowsAndDistinctTokens` / `anExpiryReclaimKeepsTheMessageIdAndAdvancesTheToken` — V2 펜싱의 3대 성질. - `anAmbiguousRowWaitsForItsBackoffBeforeItIsClaimedAgain` / `aRowOutOfAttemptsIsNotClaimedAgain` / `anExhaustedRowIsDistinctFromARejectedOne` — 재시도 시계가 행에 있다는 주장. - `everyCanonicalColumnRoundTripsThroughTheDatabase` / `theRelayCanSelectOneTenantsBacklogWithoutDecodingAPayload` / `theStoredRoutingKeyIsTheOneBothRelaysWouldUse` / `aTenantThatBreaksTheSlugBoundIsRefusedByTheDatabase` — V4 의 네 가지 주장. 증명되지 **않는** 것: - 정리 작업이 실제로 나눠 지운다는 것 (§12.1(a)). - 역슬래시로 끝나는 헤더 값의 왕복 (§12.1(b)). `aHeaderValueWithControlCharactersRoundTrips` 는 제어문자만 본다. - 배포되는 `.properties` 가 Java 설정과 일치한다는 것 (§12.4(a)). - 두 릴레이 상호배제가 기동에서 강제된다는 것 (§12.1(c)). - 구세대 `MessageId` 기반 전이가 신세대와 같은 행 상태를 남긴다는 것 (§12.3(a)). --- ## 11. 빌드/ArchUnit/CI 강제 지점 이 리프 고유의 Gradle 게이트는 없다. 루트 공통 게이트만 적용된다. 컨테이너 IT 가 `test` 태그에서 제외되지 **않는다** — 즉 Docker 가 있는 환경에서는 일반 `test` 로 함께 돈다. `messaging-kafka` 의 인증 레인이 별도 태그로 분리된 것(그 리프 문서 §6 참조)과 대비된다. `app-bootstrap` 의 `MessagingCapabilityRegistryContractTest:61` 이 `"debezium"` 문자열을 능력 목록에 갖고 있다 — 이 리프의 CDC 경로가 플랫폼 능력으로 선언되어 있다는 뜻이다. 그 선언과 §12.1(c)의 미배선 사이의 대조는 `analysis/18-app-bootstrap.md` 재검증 시 다룬다. --- ## 12. 실제 사용 여부와 negative-space probes ### 12.1 Public surface reachability **(a) [P1] 정리 작업이 무제한 DELETE 를 쏜다** (`EVD-311`, `EVD-294`) `OutboxRepository` 는 purge 오버로드를 둘 갖고, 구현도 둘 다 있다. ```java // JdbcOutboxRepository.java:486-518 bounded // The CTE picks a bounded set of ids with SKIP LOCKED and deletes exactly those. An unbounded // DELETE holds locks and writes WAL in proportion to the whole backlog, which stalls the relay // and the business writes behind retention. WITH expired AS (SELECT message_id FROM messaging_outbox WHERE status='PUBLISHED' AND published_at < ? ORDER BY published_at LIMIT ? FOR UPDATE SKIP LOCKED) DELETE FROM messaging_outbox o USING expired e WHERE o.message_id = e.message_id // JdbcOutboxRepository.java:519-533 unbounded DELETE FROM messaging_outbox WHERE status = 'PUBLISHED' AND published_at < ? ``` 호출자는 무제한 쪽을 부른다. ```java // OutboxCleanupJob.java:48-55 for (int batch = 0; batch < maxBatches; batch++) { int deleted = outbox.purgePublishedBefore(cutoff); // 무제한 removed += deleted; if (deleted == 0) break; } ``` 1회차가 전체를 지우고 2회차가 0을 반환해 break 한다. `maxBatches=20`(starter `:141`)은 실질적으로 죽은 값이다. **발동 조건 보정(`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 는 **애플리케이션이 그 지시대로 잡을 스케줄하는 순간** 발동한다. 테스트가 이것을 가리는 방식이 inbox 쪽과 동일하다. ```java // OutboxOperationsTest.java:120-134 RecordingRepository @Override public int purgePublishedBefore(Instant publishedBefore, int limit) { return Math.min(purgePublishedBefore(publishedBefore), limit); // 전부 지우고 숫자만 깎는다 } @Override public int purgePublishedBefore(Instant publishedBefore) { cutoffs.add(publishedBefore); return pass < deletions.size() ? deletions.get(pass++) : 0; // 스크립트 } ``` `cleanupDeletesInBoundedBatchesRatherThanOneLongStatement` 는 `List.of(1000, 1000, 250)` 을 스크립트로 넣고 `removed == 2250`, `cutoffs.size() == 4` 를 단언한다. "나눠 지운다" 는 관측이 전적으로 대역이 만든 것이다. 실 DB 테스트(`OutboxPostgresIT:202`)도 무제한 쪽만 부른다. **(b) [P2] 역슬래시로 끝나는 헤더 값이 헤더 맵을 깨뜨린다** (`EVD-314` — 런타임 재현) ```java // JdbcOutboxRepository.java:657-664 private static int findClosingQuote(String text, int from) { for (int index = from; index < text.length(); index++) { if (text.charAt(index) == '"' && text.charAt(index - 1) != '\\') { return index; } } return text.length(); } ``` 닫는 따옴표 판정이 "바로 앞 글자가 역슬래시가 아니다" 뿐이다. `escape` 가 값 끝의 역슬래시를 둘로 늘리므로, 닫는 따옴표 앞이 역슬래시가 되어 종료를 놓친다. 컴파일된 클래스에 jshell + 리플렉션으로 `private static toJson`/`fromJson` 을 직접 호출해 재현했다(애플리케이션 소스 무수정). ``` case 3 in={x-a=a\} json={"x-a":"a\\"} out={x-a=a\"} EQUAL? false 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 case 5 in={x-a=a\b} json={"x-a":"a\\b"} out={x-a=a\b} EQUAL? true new HeaderValue("a\") -> OK, value=a\ ``` 값이 **끝에** 역슬래시를 가질 때만 깨지고, 뒤에 헤더가 하나라도 더 있으면 맵 전체가 붕괴한다 — 키 `:` 와 키 `a\",` 가 생기고 `x-b` 는 사라진다. `HeaderValue` 는 제어문자만 금지하므로(`WireSafeText.require`) 이 입력은 플랫폼 자신의 검증 타입을 통과한다. **헤더 주입으로는 이어지지 않는다.** 어긋남이 키/값 경계를 밀어내므로 예약 이름은 키가 아니라 값이 되고, 쓰기 경로의 `MessageHeaders.application(...)` 이 애초에 예약 이름을 거절한다. 데이터 손상이지 취약점은 아니다. **(c) CDC 경로 전체가 배선되지 않았다** (`EVD-312`) ``` git grep -n "requireExactlyOneRelay|DebeziumOutboxProfile.polling|RelayMode" -- src 전부 DebeziumOutboxProfile.java 자기 자신 + DebeziumOutboxRecordMapperTest ``` `DebeziumOutboxProfile` 클래스 javadoc(`:9-13`)은 "the incompatibility is therefore enforced at startup instead of documented" 라고 쓴다. 기동 시 `requireExactlyOneRelay` 를 부르는 코드가 없다. `DebeziumOutboxRecordMapper` 는 프로덕션에서 생성되지 않는다. 즉 두 릴레이가 동시에 켜지는 구성을 막는 주체가 없고, CDC 모드를 선택할 프로퍼티도 없다. **(d) 세 타입이 starter 밖 배선을 요구한다.** `JdbcOutboxRepository`(src/main 생성 0), `OutboxEnvelopeFactory`(0), `JdbcAdminOperationJournal`(0). 애플리케이션이 등록하지 않으면 릴레이 빈은 `OutboxRepository` 를 주입받지 못한다. ### 12.2 Conditional sibling comparison **대조군 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` 는 같은 상태로 남아 있다.** **대조군 2 — 커넥션 획득.** `append` 는 `DataSourceUtils`, 나머지는 raw `dataSource.getConnection()`, `JdbcAdminOperationJournal` 은 전부 `DataSourceUtils`. §7. **대조군 3 — inbox 와의 대칭.** `InboxCleanupJob`/`OutboxCleanupJob` 은 같은 형태이며 같은 결함을 갖는다(`EVD-294`). starter 가 둘 다 `maxBatches=20` 으로 만든다. **대조군 4 — 컨테이너 레인 정책.** 이 리프의 IT 는 `test` 에 포함되어 함께 돈다. `messaging-kafka` 의 인증 레인은 태그로 분리되고 Docker 가드도 없다. 두 정책이 공존하는 이유는 각 리프에 설명되어 있다(전자는 skip 가능, 후자는 skip 이 성공으로 보고되면 안 됨). ### 12.3 Duplicate mechanism sweep **(a) 전이 메서드가 두 세대이며 남기는 행 상태가 다르다.** | 항목 | 신세대 (`OutboxLease`) | 구세대 (`MessageId`) | |---|---|---| | 술어 | `message_id AND status='IN_FLIGHT' AND lease_owner=? AND lease_token=?` | `message_id` 만 | | `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` | | `markAmbiguous` SET | `… lease_owner=NULL, last_failure_code, attempts+1, next_attempt_at=?` | `… last_failure_code, attempts+1` | | 결과 타입 | `OutboxTransitionResult` | `void` | | 청구 SQL | `CLAIM` (owner/token 기록) | `LEASE` (기록 안 함) | 구세대로 PUBLISHED 된 행은 `lease_owner` 와 `next_attempt_at` 이 남는다. 그 컬럼들은 청구 술어와 부분 인덱스가 읽는 값이다. 두 세대 중 어느 것도 `@Deprecated` 가 아니라는 점은 `analysis/messaging/messaging-reliability-api.md` 에 기록되어 있고, 여기서는 **상태 차이가 구체적으로 무엇인지**가 추가된다. **(b) Debezium 설정이 두 표현으로 존재한다.** §12.4(a). **(c) 손으로 쓴 JSON 코덱이 이 리프에도 있다.** `JdbcOutboxRepository.toJson/fromJson/escape/unescape` — `BrokerCertificationEvidence`(messaging-testkit), `InMemoryAdminOperationJournal.key`(messaging-admin-runtime)와 같은 계열의 선택이다. 각각 이유가 적혀 있고("이 모듈은 코덱 의존을 두지 않는다"), 각각 다른 방식으로 구현되어 있다. 그중 하나에서 파싱 결함이 나왔다(§12.1(b)). ### 12.4 Documentation / measured-count drift **(a) [P2] 배포되는 커넥터 설정이 수정 이전 버전이다** (`EVD-310`) | 항목 | Java `connectorConfiguration` | `debezium/outbox-event-router.properties` | |---|---|---| | `event.key` | `routing_key` | **`destination`** | | `route.topic.replacement` | `topicPrefix + ${routedByValue}` | `${routedByValue}` | | `event.timestamp` | (없음) | `created_at` | | `additional.placement` 항목 수 | **15** | **4** | properties 에 없는 11개: `created_at`, `destination`, `producer`, `occurred_at`, `correlation_id`, `causation_id`, `tenant`, `partition_key`, `ordering_key`, `traceparent`, `tracestate`, `baggage` — **V4 가 추가한 정경 메타데이터 전부**다. `DebeziumOutboxEventRouter` javadoc(`:21-26`)과 V4 주석(`:55-63`)이 둘 다 "`destination` 을 키로 쓰면 한 토픽의 모든 메시지가 한 파티션에 몰린다" 를 고쳤다고 말한다. 배포되는 파일에는 그 수정이 없다. 그리고 두 표현을 잇는 것이 없다. ``` git grep -rn "outbox-event-router" -- src exit 1 (출력 없음) ``` Java 쪽은 오히려 **의도적으로 견고한 테스트**가 지키고 있다. ```java // DebeziumOutboxRecordMapperTest.java:154-162 void theRoutedKeyIsNotTheTopicName() { // Literals, not the class's own constants: comparing a configuration value against the constant // that produced it asserts that the router agrees with itself, which it always will. assertThat(new DebeziumOutboxEventRouter().connectorConfiguration("prod.")) .as("keying by destination puts every message on a topic onto one partition") .containsEntry("transforms.outbox.table.field.event.key", "routing_key") .containsEntry("transforms.outbox.route.by.field", "destination"); } ``` 리터럴 대조까지 하는 테스트가 Java 를 지키고, 운영자가 배포하는 파일은 아무도 지키지 않는다. **(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`)인 만큼 무해하지 않다. **(c) 백오프 지터가 복제본을 분산시키지 못한다** (`EVD-312`) ```java // OutboxRetryScheduler.java:18-20 /** *

Jitter is applied deterministically from the attempt count rather than randomly. Several relay * instances that all started at deployment time would otherwise synchronise their retries into a * thundering herd ... */ // :107 long jittered = capped - (capped / 8) * (exponent % 3); ``` `jittered` 는 `exponent` 만의 함수이고 `exponent` 는 워커의 `unproductivePasses` 카운터다. 같은 시각에 배포되어 같은 브로커 장애를 겪는 복제본들은 같은 카운터를 갖게 되므로 **같은 backoff 를 계산한다.** 지터는 시도 횟수에 따라 값을 바꿀 뿐 인스턴스에 따라 바꾸지 않는다. (행 단위 백오프 `nextAttemptAt` 은 `next_attempt_at` 컬럼에 기록되므로 이 문제와 무관하다. javadoc 이 말하는 "several relay instances … synchronise their retries" 는 pass 단위 얘기다.) **(d) 선언 의존은 모두 사용된다.** 5개 project 의존 중 미사용 0건 — 지금까지 본 messaging 리프 중 처음이다. --- ## 13. Git/설계 문서에서 확인한 변화와 실패 기록 SQL 마이그레이션과 javadoc 이 함께 이력을 이룬다. 여덟 개의 "이전에는 이랬다". | 위치 | 기록된 과거 결함 | |---|---| | `V2:3-14` | 리스만으로는 stale relay 가 PUBLISHED 위에 AMBIGUOUS 를 덮어썼다 | | `V2:25-27` | "V1's CHECK listed five states, so writing the sixth failed at the constraint rather than at review" | | `V4:6-9` | 정경 필드가 갈 곳이 없어 유실되거나 `msg.*` 로 밀반입되었다 | | `V4:56-61` | Debezium 키가 `destination` 이라 한 토픽의 모든 메시지가 한 파티션에 몰렸다 | | `JdbcOutboxRepository:155-162` | `append(Connection, …)` 이 public 이었고 안전한 경로가 "알아야만 하는" 것이었다 | | `JdbcOutboxRepository:205-209` | `append` 가 풀에서 raw 커넥션을 열어 자동 커밋했다 — "a business transaction that rolled back afterwards left the event behind" | | `JdbcOutboxRepository:630-636` | 이스케이프가 역슬래시와 따옴표만 처리해 제어문자가 JSONB 를 깨뜨렸다 | | `OutboxRelay:117-123` | "The scheduler was built by the auto-configuration and handed to nobody" | | `OutboxRelayWorker:18-21` | "The relay, its retry scheduler and the attempt budget all existed and nothing ever called `runOnce`" | | `OutboxEnvelopeFactory:27-37` | 정경 필드를 빈 값으로 재구성하고 라우팅 키를 헤더 맵에서 읽었다 | | `CLAIM SQL:119-122` | AMBIGUOUS 행이 다음 패스에 바로 재청구되어 시도 예산이 아무도 안 읽는 숫자였다 | 마지막 두 개(`OutboxRelay:117-123`, `OutboxRelayWorker:18-21`)가 이 저장소 전체에서 반복되는 결함 계열 — **"만들어졌지만 아무도 부르지 않는다"** — 을 명시적으로 이름 붙인 유일한 자리다. 그리고 이 리프에서는 그 둘이 실제로 고쳐졌다. §12.1(c)의 `requireExactlyOneRelay` 만 같은 상태로 남았다. --- ## 14. 런타임·터미널 Evidence | ID | 파일 | 내용 | |---|---|---| | EVD-310 | `evidence/raw/310-debezium-properties-vs-java-drift.txt` | Java 설정 vs 배포 properties 항목별 대조, 헤더 매핑 15 vs 4, 연결 코드 0건 | | EVD-311 | `evidence/raw/311-outbox-cleanup-unbounded-confirmed.txt` | bounded/unbounded 두 SQL 전문, 호출자, starter 배선, 대역의 스크립트 | | EVD-312 | `evidence/raw/312-outbox-assembly-and-jitter.txt` | 조립 탐침 전수, 릴레이 기동 확인(대조군), CDC 미배선, 지터 분석 | | EVD-313 | `evidence/raw/313-messaging-outbox-jdbc-test-lane.txt` | 76건 통과 + **컨테이너 런타임 가용성 확인** | | EVD-314 | `evidence/raw/314-outbox-header-json-roundtrip-corruption.txt` | jshell 리플렉션 재현 5케이스 + 주입 불가 확인 + HeaderValue 수용 확인 | --- ## 15. 명시적 설계 이유와 추론을 구분한 정리 **코드/주석에 명시된 것** - `message_id` 를 기본키로 삼은 이유 (`V1:3-5`). - 부분 인덱스인 이유, `IN_FLIGHT` 를 청구 대상에 넣는 이유 (`V1:28-36`). - 펜싱 토큰이 필요한 이유와 리스 연장이 답이 아닌 이유 (`V2:3-14`). - `EXHAUSTED` 를 새 상태로 만든 이유 (`V2:25-27`). - 정경 메타데이터를 blob 이 아니라 컬럼으로 둔 이유 (`V4:11-14`). - tenant 제약을 DB 에도 거는 이유 (`V4:33-36`). - `routing_key` 를 생성 컬럼으로 만든 이유 (`V4:55-63`). - `append` 가 호출자 커넥션을 쓰는 이유, 그리고 fail-fast 인 이유 (`JdbcOutboxRepository:37-46, 221-227`). - `FOR UPDATE SKIP LOCKED` 의 이유 (`:44-46`). - 열 목록을 상수로 뽑은 이유 (`:64-71`). - 서버측 토큰 증가의 이유 (`:106-111`). - 재시도 시계를 행에 두는 이유 (`CLAIM:119-122`, `markAmbiguous:79-81`). - 0행을 STALE_LEASE 로 보고하는 이유 (`:392-398`). - bounded purge 가 필요한 이유 (`:164-166`) — 정작 호출되지 않는다. - 손으로 쓴 JSON 의 이유, 제어문자 이스케이프의 이유 (`:604-610, 630-636`). - 모호를 같은 id 로 재시도하는 이유, at-least-once 상한의 이유 (`OutboxRelay:17-27`). - `attempts + 1` 로 예산을 판정하는 이유 (`:179-181`). - `EXHAUSTED` 로 주차하는 이유 (`:186-188`). - `default ->` 분기의 이유 (`:213-215`). - 리스가 발행 타임아웃의 2배여야 하는 이유 (`OutboxProperties:10-14`). - pass 백오프와 row 백오프가 서로를 대체하지 않는 이유 (`OutboxRetryScheduler:11-16`). - 시프트를 쓰는 이유 (`:102-103`). - 데몬 스레드·자기 스케줄링·드레인 종료의 이유 (`OutboxRelayWorker:23-30, 79-88, 99-106`). - 패스 실패가 루프를 끝내면 안 되는 이유 (`:184-187`). - 정리가 PUBLISHED 만 지우는 이유 (`OutboxCleanupJob:10-14`). - 봉투 재구성 시 부재 값 처리의 이유 (`OutboxEnvelopeFactory:87-92`). - 예약 이름을 예외 없이 거절하는 이유 (`:33-37`). - 저널이 아웃박스 옆에 사는 이유 (`build.gradle:9-13`, `JdbcAdminOperationJournal:24-28`). - DB 제약이 경쟁을 결판내는 이유 (`:26-28`). - 읽기와 인수 사이 경쟁을 거절하는 이유 (`:181-184`). - 두 릴레이 동시 실행이 불가능해야 하는 이유 (`DebeziumOutboxProfile:9-13`, properties `:3-5`). - CDC 모델을 Java 로 만든 이유 (`DebeziumOutboxRecordMapper:11-18`). - schema subject 만 헤더가 없는 이유 (`DebeziumOutboxEventRouter:28-33`). - 부재를 빈 문자열로 쓰지 않는 이유 (`:105-107`). - 컨테이너 테스트가 필요한 이유 (`build.gradle:16-17`). **추론 (근거는 있으나 문서에 없음)** - `withConnection` 이 `DataSourceUtils` 를 쓰지 않는 것은 릴레이가 비즈니스 트랜잭션에 합류하면 안 되기 때문으로 보인다. 주석은 없고, 같은 리프의 저널은 반대로 한다. - `.properties` 가 갱신되지 않은 것은 누락으로 보인다 — Java 쪽 수정에 붙은 근거가 파일 쪽에도 그대로 적용되기 때문. 의도적 분기라는 표시는 없다. - `maxBatches=20` 하드코딩이 프로퍼티가 아닌 이유는 알 수 없다. - 구세대 `MessageId` 오버로드가 남아 있는 이유, 그리고 그것이 `lease_owner` 를 지우지 않는 것이 의도인지 누락인지. - `aggregateIdAsPartitionKey` 가 커넥터 설정에 전달되지 않는 것이 의도인지 누락인지. --- ## 16. 확인한 것 / 확인하지 못한 것 **확인한 것** - production 13파일 + SQL 4 + properties 1 전부 본문 확인. - 테스트 76건 전건 통과, **컨테이너 IT 28건이 실제로 실행됨** (`EVD-313`). - 이 환경에서 Docker 사용 가능 (client 29.1.3 / server 29.6.1, 소켓 마운트). - 정리 작업이 무제한 DELETE 를 쏜다는 것 — 두 SQL·호출자·starter 배선·대역 전부 확인 (`EVD-311`). - 역슬래시 종결 헤더 값의 왕복 손상 — **jshell 리플렉션으로 런타임 재현** (`EVD-314`). - Debezium 설정 두 표현의 항목별 차이와 연결 코드 0건 (`EVD-310`). - 조립 탐침 전수, 릴레이 기동 확인, CDC 미배선 (`EVD-312`). - 구·신 전이 메서드의 SET 절 차이. **확인하지 못한 것** - **테스트 8파일을 축자 통독하지 않았다.** 76개 메서드 이름 전수와 판정에 필요한 구간(대역 구현, purge/Debezium/이스케이프 단언)만 읽었다. 커버리지 원장에 `STRUCTURAL_ONLY` 로 기록했다. - §12.1(a)와 (c)의 결과를 실제 배포에서 관측하지 않았다. (a)는 SQL·호출자·배선으로, (c)는 호출부 부재로 도출했다. - 실제 Debezium 커넥터를 띄워 properties 의 동작을 확인하지 않았다. 두 설정의 차이는 텍스트 대조로 확인했다. - §12.4(c)의 지터 동기화를 다중 인스턴스로 재현하지 않았다. 함수가 `exponent` 만의 함수라는 것은 코드로 확인했다. - 구세대 전이 메서드가 실제로 호출되는 배포가 있는지 — 이 저장소에는 없다. --- ## 17. 손볼 것 ### P1 — 정리 작업이 무제한 DELETE 를 쏘고, 그것을 막는 오버로드는 호출되지 않는다 `OutboxCleanupJob:50` 과 `InboxCleanupJob:56` 이 무제한 오버로드를 부른다. bounded 오버로드(`purgePublishedBefore(Instant, int)` / `purgeProcessedBefore(Instant, int)`)는 두 포트에 선언되고 두 구현에 구현되어 있으며 호출부가 **0건**이다(`EVD-294`, `EVD-311`). 두 잡 모두 starter 빈이지만 스케줄러는 등록되지 않으며, 그것은 의도된 설계다(`EVD-316`). 즉 기본 배포에서는 아무 일도 일어나지 않고, 애플리케이션이 문서 지시대로 잡을 스케줄하는 순간 무제한 DELETE 가 발동한다. 잠재 결함이지 상시 결함이 아니다. 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 빈이다. 수정은 한 줄이다 — `purgePublishedBefore(cutoff, batchLimit)`. `maxBatches` 가 그제서야 의미를 갖는다. 배치 크기는 새 파라미터가 필요하고, `OutboxProperties.batchSize`(100)를 재사용하거나 별도 값을 둔다. 그리고 **회귀 테스트가 성립하려면 `RecordingRepository` 를 고쳐야 한다.** 현재 대역의 bounded 구현은 `Math.min(unbounded(), limit)` 로, 전부 지우고 숫자만 깎는다. 실제 저장소를 흉내 내려면 보유 행 목록을 갖고 `limit` 만큼만 제거해야 한다. ### P2 — 배포되는 Debezium 설정이 수정 이전 버전이다 `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". 추가로 헤더 매핑이 15개 중 4개뿐이라, 이 파일로 배포한 CDC 는 tenant·correlation·causation·producer·trace·partition/ordering key 를 **전부 잃는다**. V4 가 존재하는 이유가 그 유실을 막는 것이다. 두 가지가 필요하다. 1. properties 를 Java 설정에서 생성하거나, 최소한 **둘을 대조하는 테스트**를 둔다. `DebeziumOutboxEventRouter.connectorConfiguration("")` 의 항목이 파일에 모두 있는지 확인하는 테스트면 충분하다. 지금은 두 표현을 잇는 코드가 한 줄도 없다. 2. `aggregateIdAsPartitionKey` 를 `connectorConfiguration` 에 전달하거나, 전달할 수 없다면 `DebeziumOutboxRecordMapper` 에서 그 분기를 제거한다. 지금은 모델이 커넥터가 하지 않을 일을 예측한다. ### P2 — 역슬래시로 끝나는 헤더 값이 헤더 맵을 깨뜨린다 `findClosingQuote`(`:657-664`)가 이스케이프된 역슬래시를 고려하지 않는다. 값이 역슬래시로 끝나면 파서가 종료 지점을 놓치고, 뒤에 헤더가 더 있으면 맵 전체가 붕괴한다(`EVD-314`, 런타임 재현). `HeaderValue` 는 제어문자만 금지하므로 이 입력은 플랫폼 검증을 통과한다. 헤더 주입으로 이어지지는 않는다 — 예약 이름은 키가 아니라 값이 되고, 쓰기 경로가 예약 이름을 이미 거절한다. 수정: 종료 판정을 "앞의 연속된 역슬래시 개수가 짝수" 로 바꾸거나, 인덱스를 앞에서부터 스캔하며 이스케이프 상태를 추적한다. 후자가 `unescape` 와 대칭이라 낫다. 테스트는 `OutboxPostgresIT.aHeaderValueWithControlCharactersRoundTrips` 옆에 역슬래시 종결 케이스를 추가하면 된다 — 실 DB 왕복까지 확인할 수 있다. ### P2 — 두 릴레이 상호배제가 기동에서 강제되지 않는다 `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"). 같은 리프에 정확히 이 형태를 고친 선례가 있다 — `OutboxRelayWorker` 가 "nothing ever called `runOnce`" 를 고치고 `MessagingOutboxRelayLifecycle` 로 배선까지 마쳤다. 같은 방식으로 `MessagingReliabilityAutoConfiguration` 에 프로필 빈과 `InitializingBean` 검사를 두면 된다. 배선하려면 CDC 모드를 선택할 프로퍼티도 필요하다 — 지금은 `DebeziumOutboxProfile` 을 만드는 설정 경로 자체가 없다. ### P3 — 구세대 전이 메서드가 신세대와 다른 행 상태를 남긴다 `markPublished(MessageId, Instant)` 는 `lease_owner` 와 `next_attempt_at` 을 지우지 않는다. `markPublished(OutboxLease, Instant)` 는 지운다. `markAmbiguous`/`markFailed` 도 같다. 두 컬럼은 청구 술어와 부분 인덱스가 읽는 값이다. 이 저장소에 구세대를 부르는 프로덕션 코드는 없다. 그러나 포트에 남아 있고 `@Deprecated` 도 아니므로, 외부 구현이나 향후 코드가 부를 수 있다. 최소한 `@Deprecated` 와 "신세대를 쓰라"는 문장이 필요하고, 더 나은 것은 제거다. ### P3 — 백오프 지터가 인스턴스를 분산시키지 못한다 `jittered = capped - (capped/8) * (exponent % 3)` 는 `exponent` 만의 함수다. 같은 상태의 복제본들은 같은 값을 계산한다. javadoc 이 약속하는 "thundering herd 방지" 가 성립하지 않는다. `OutboxRelay` 가 이미 `defaultOwner()` 로 프로세스별 안정 식별자를 만든다(`pid@uuid8`). 그것의 해시를 지터에 섞으면 결정성(같은 프로세스에서 재현 가능)을 유지하면서 인스턴스 간 위상차가 생긴다. javadoc 이 난수를 거부한 이유("a random source would make the schedule impossible to test")도 그대로 지켜진다. ### P3 — 커넥션 획득 방식이 리프 안에서 갈린다 `JdbcOutboxRepository.append` 는 `DataSourceUtils`, 나머지는 raw `dataSource.getConnection()`, `JdbcAdminOperationJournal` 은 전부 `DataSourceUtils`. 릴레이 연산이 비즈니스 트랜잭션에 합류하면 안 된다는 판단은 타당하지만 어디에도 적혀 있지 않고, 같은 리프의 저널이 반대로 한다. `withConnection` 에 한 문장 — "릴레이 연산은 호출자 트랜잭션에 합류하지 않는다" — 을 붙이면 `append` 의 상세한 주석과 짝이 맞는다. 저널이 `DataSourceUtils` 를 쓰는 것이 의도인지도 확인이 필요하다. ### P3 — `maxBatches` 가 하드코딩이고 현재는 의미가 없다 starter 가 `20` 을 박아 넣는다(`:141`, `:170`). P1 을 고치기 전에는 이 값이 아무 일도 하지 않고, 고친 뒤에는 배치 크기와 함께 조정 대상이 된다. `OutboxProperties`/`InboxRetentionPolicy` 로 옮기는 것이 맞다. ### 확인된 설계(문제 아님) - **`append` 의 트랜잭션 3중 검사.** 활성/쓰기 가능/같은 DataSource 바인딩. 세 번째가 특히 드물고 정확하다. - **`message_id` 를 기본키로.** 어떤 코드 경로도 새 id 로 같은 행을 발행할 수 없다. - **펜싱 토큰을 서버측 한 문장에서 증가.** 두 릴레이가 같은 번호를 받을 수 없다. - **종결 쓰기의 owner+token 술어, 그리고 0행을 삼키지 않는 것.** stale 은 중복 발행의 가시화된 형태다. - **재시도 시계를 행에 기록.** 프로세스 메모리의 백오프는 재시작에 잊히고 복제본마다 따로 계산된다. - **`EXHAUSTED` 를 별도 상태로.** AMBIGUOUS 로 두면 대시보드에서 건강한 백로그와 구별되지 않는다. - **`spent = attempts + 1` 로 예산 판정.** 마지막 시도가 두 번 소비되지 않는다. - **`default ->` 에서 크게 실패하기.** 새 completion 이 조용히 `IN_FLIGHT` 를 남기지 않는다. - **리스 ≥ 발행 타임아웃 × 2 를 생성자가 강제.** 그리고 기본값이 자기 규칙을 만족하는지 테스트가 있다. - **정경 메타데이터를 컬럼으로.** 운영자 질문이 SELECT 가 된다. - **tenant 제약을 DB 에도.** 애플리케이션 밖 INSERT 를 막는다. - **`routing_key` 생성 컬럼.** 두 릴레이의 폴백 규칙을 한 곳에 고정한다 (Java 쪽 한정으로). - **봉투 재구성 시 예약 이름을 예외 없이 거절.** 라우팅 키가 컬럼이 된 뒤 규칙이 단순해졌다. - **부재를 빈 문자열로 쓰지 않기** (CDC 헤더, 봉투 양쪽). - **패스 실패가 루프를 끝내지 않게.** 스케줄된 작업의 예외는 이후 모든 패스를 취소한다. - **드레인 종료.** 인터럽트는 크래시와 같은 정체를 만든다. - **정리가 PUBLISHED 만 대상으로.** AMBIGUOUS·FAILED 는 사건 중 가장 필요한 행이다. - **저널을 아웃박스 옆에 두고 DB 제약으로 경쟁을 결판내기.** check-then-act 는 두 복제본을 모두 통과시킨다. - **읽기와 인수 사이의 경쟁을 `RETURNING` 0행으로 거절.** - **컨테이너 IT 를 `test` 에 포함.** 이 리프의 주장은 실제 DB 로만 결판난다. - **제어문자 이스케이프.** (역슬래시 종결 케이스는 §17 P2.) --- ## Source anchors ``` src/messaging/messaging-outbox-jdbc-postgresql/build.gradle:1-24 src/config/architecture/modules.json (messaging-outbox-jdbc-postgresql 항목) main/resources/db/migration/messaging/V1__messaging_outbox.sql:1-41 main/resources/db/migration/messaging/V2__messaging_outbox_lease_fencing.sql:1-39 main/resources/db/migration/messaging/V3__messaging_admin_operation_journal.sql:1-37 main/resources/db/migration/messaging/V4__messaging_outbox_canonical_metadata.sql:1-66 main/resources/debezium/outbox-event-router.properties:1-43 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 main/…/OutboxRelay.java:17-28,40-44,66-72,90-99,117-123,145-221,223-230 main/…/OutboxRelayWorker.java:15-31,34-35,53-90,92-97,99-125,127-130,132-157,159-174,176-193 main/…/OutboxRetryScheduler.java:8-21,28-43,45-61,63-80,82-85,87-90,92-109,111-119,121-133 main/…/OutboxProperties.java:7-22,31-32,34-59,61-74 main/…/OutboxCleanupJob.java:7-15,22-36,38-57 main/…/OutboxRelayReport.java:3-26,30-49 main/…/OutboxEnvelopeFactory.java:20-38,43-50,52-104,106-123 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 main/…/DebeziumOutboxProfile.java:6-18,22-28,30-36,38-45,47-66 main/…/DebeziumOutboxEventRouter.java:10-34,37-47,49-85,87-136,138-150 main/…/DebeziumOutboxRecordMapper.java:10-27,30-32,34-43,45-68,70-78 main/…/DebeziumMappedRecord.java:8-23,25-34,36-53 test/…/OutboxPostgresIT.java (메서드 인벤토리 21건; 195-202, 503-521, 538-560 본문 확인) test/…/OutboxOperationsTest.java:105-190 (RecordingRepository + cleanup 3건 본문 확인) test/…/DebeziumOutboxRecordMapperTest.java:150-200 (본문 확인), 58-148 (메서드명) test/…/OutboxRelayTest.java / OutboxRelayWorkerTest.java / OutboxEnvelopeFactoryTest.java / test/…/JdbcOutboxTransactionRequirementTest.java / AdminOperationJournalPostgresIT.java (메서드 인벤토리) src/messaging/messaging-spring-boot-starter/.../MessagingReliabilityAutoConfiguration.java:63,89,109,140-141,169-170 src/messaging/messaging-spring-boot-starter/.../MessagingOutboxRelayLifecycle.java:42 src/messaging/messaging-core-api/.../header/HeaderValue.java:5-25 src/messaging/messaging-inbox-jdbc-postgresql/.../InboxCleanupJob.java:56 src/app-bootstrap/src/test/.../MessagingCapabilityRegistryContractTest.java:61 ```