{ "schema_version": "1.0", "document": "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": 31956, "line": 31956 }, "current_section": { "heading": { "line": 31938, "level": 4, "text": "13. Git/설계 문서에서 확인한 변화와 실패 기록" }, "start_line": 31938, "end_line": 31959, "text": "#### 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" }, "previous_section": { "heading": { "line": 31759, "level": 4, "text": "12. 실제 사용 여부와 negative-space probes" }, "start_line": 31759, "end_line": 31937, "text": "#### 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" }, "next_section": { "heading": { "line": 31960, "level": 4, "text": "14. 런타임·터미널 Evidence" }, "start_line": 31960, "end_line": 31971, "text": "#### 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" }, "context_range": { "start_line": 31759, "end_line": 31971 }, "context_lines": [ { "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": "" } ], "numbered_context": "31759 | #### 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 | ",
"headings": [
{
"line": 1,
"level": 1,
"text": "clean-architecture-backend-template — 상세 분석 (통합 정본)"
},
{
"line": 40,
"level": 2,
"text": "0. 이 문서를 읽는 법"
},
{
"line": 60,
"level": 2,
"text": "1. Project map — 숫자로 먼저"
},
{
"line": 62,
"level": 3,
"text": "1.1 빌드와 레지스트리"
},
{
"line": 81,
"level": 3,
"text": "1.2 가족별 분모와 출하 여부"
},
{
"line": 94,
"level": 3,
"text": "1.3 leaf별 규모 (main Java 기준 상위)"
},
{
"line": 119,
"level": 3,
"text": "1.4 이 표에서 읽어야 할 것"
},
{
"line": 168,
"level": 2,
"text": "2. Architectural boundaries — 무엇이 경계를 강제하는가"
},
{
"line": 173,
"level": 3,
"text": "2.1 강제 장치 목록"
},
{
"line": 189,
"level": 3,
"text": "2.2 `CleanArchitectureTest`의 규칙 14종"
},
{
"line": 212,
"level": 3,
"text": "2.3 검증된 경계 — 실제로 성립하는 것"
},
{
"line": 266,
"level": 3,
"text": "2.4 경계가 열려 있는 지점"
},
{
"line": 300,
"level": 2,
"text": "3. Representative execution paths"
},
{
"line": 302,
"level": 3,
"text": "3.1 HTTP 요청 — 출하 경로"
},
{
"line": 364,
"level": 3,
"text": "3.2 트랜잭션 — `application-core` 포트에서 PostgreSQL local timeout까지"
},
{
"line": 453,
"level": 3,
"text": "3.3 메시지 발행 — messaging 플랫폼"
},
{
"line": 494,
"level": 3,
"text": "3.4 gRPC — 채택 시점 경로"
},
{
"line": 518,
"level": 3,
"text": "3.5 알림 발송 — 논리적 수락과 provider 불확실성"
},
{
"line": 539,
"level": 2,
"text": "4. Data and state"
},
{
"line": 541,
"level": 3,
"text": "4.1 관계형 — `persistence-jpa` (605 파일 / main 350 / 27,744 LOC)"
},
{
"line": 654,
"level": 3,
"text": "4.2 문서형 — `persistence-mongo` (497 파일 / main 351 / 22,924 LOC)"
},
{
"line": 705,
"level": 3,
"text": "4.3 messaging 신뢰성 저장소 (`19` §7)"
},
{
"line": 757,
"level": 3,
"text": "4.4 fileserver / objectstorage / cache-redis"
},
{
"line": 788,
"level": 2,
"text": "5. Failure and operational behavior"
},
{
"line": 790,
"level": 3,
"text": "5.1 실패 분류 — 세 개의 계층"
},
{
"line": 824,
"level": 3,
"text": "5.2 관측 — 태그를 유한하게, 그리고 그 대가"
},
{
"line": 854,
"level": 3,
"text": "5.3 시작 검증기 — 법칙과 그 예외"
},
{
"line": 903,
"level": 3,
"text": "5.4 admin plane — 가장 잘 조립된 게이트"
},
{
"line": 939,
"level": 3,
"text": "5.5 gRPC 구현 층의 원자성 (`20` §7)"
},
{
"line": 1011,
"level": 2,
"text": "6. Tests and verification coverage"
},
{
"line": 1013,
"level": 3,
"text": "6.1 실행한 것"
},
{
"line": 1025,
"level": 3,
"text": "6.2 실행하지 않은 것과 그 이유"
},
{
"line": 1047,
"level": 3,
"text": "6.3 fail-closed 레인 규약"
},
{
"line": 1071,
"level": 3,
"text": "6.4 완전히 닫힌 게이트 하나 — messaging 인증 체인"
},
{
"line": 1111,
"level": 3,
"text": "6.5 evidence manifest — JPA의 R1/R2 분리"
},
{
"line": 1125,
"level": 3,
"text": "6.6 게이트가 통과하면서 아무것도 증명하지 않는 경우 — 14건"
},
{
"line": 1156,
"level": 2,
"text": "7. 이 저장소에서 반복된 네 가지 형태"
},
{
"line": 1160,
"level": 3,
"text": "7.1 형태 A — 판정하는 코드는 있고, 부르는 코드가 없다"
},
{
"line": 1203,
"level": 3,
"text": "7.2 형태 B — 게이트가 통과하면서 아무것도 증명하지 않는다"
},
{
"line": 1214,
"level": 3,
"text": "7.3 형태 C — 중복 장치에서 조립된 쪽이 약한 쪽이다"
},
{
"line": 1239,
"level": 3,
"text": "7.4 형태 D — 문서 드리프트, 그리고 그 방향"
},
{
"line": 1274,
"level": 3,
"text": "7.5 공시 스펙트럼 — 자기 미완성을 얼마나 말했는가"
},
{
"line": 1289,
"level": 3,
"text": "7.6 학습 전이 — messaging → grpc"
},
{
"line": 1308,
"level": 2,
"text": "8. Confirmed problems"
},
{
"line": 1310,
"level": 3,
"text": "8.1 P1 — 지금 출하되는 아티팩트에서 틀린 동작"
},
{
"line": 1349,
"level": 3,
"text": "8.2 P2 — 명확한 실패 시나리오를 가진 실질적 공백"
},
{
"line": 1392,
"level": 3,
"text": "8.3 심각도가 등급 때문에 낮아진 것"
},
{
"line": 1403,
"level": 2,
"text": "9. Reusable criteria and rules"
},
{
"line": 1452,
"level": 2,
"text": "10. Explicit project decisions"
},
{
"line": 1457,
"level": 3,
"text": "10.1 계약과 경계"
},
{
"line": 1468,
"level": 3,
"text": "10.2 실패와 불확실성"
},
{
"line": 1480,
"level": 3,
"text": "10.3 조립과 활성화"
},
{
"line": 1492,
"level": 3,
"text": "10.4 데이터와 경계값"
},
{
"line": 1506,
"level": 3,
"text": "10.5 증거와 게이트"
},
{
"line": 1523,
"level": 2,
"text": "11. Unresolved questions"
},
{
"line": 1564,
"level": 2,
"text": "12. Evidence index"
},
{
"line": 1581,
"level": 2,
"text": "13. Limits of this analysis"
},
{
"line": 1632,
"level": 2,
"text": "14. 사이클 2 — 18개 리프 재검증과 23개 리프 전수 통독"
},
{
"line": 1634,
"level": 3,
"text": "14.1 18개 리프 재검증"
},
{
"line": 1668,
"level": 3,
"text": "14.2 23개 리프 전수 통독"
},
{
"line": 1747,
"level": 2,
"text": "부록 A. 모듈 문서 지도"
},
{
"line": 1779,
"level": 2,
"text": "부록 B. 자주 쓸 명령"
},
{
"line": 1825,
"level": 2,
"text": "부록 C. 다시 읽는다면 이 순서"
},
{
"line": 1839,
"level": 1,
"text": "제2부 — 모듈 분석 전문"
},
{
"line": 1845,
"level": 2,
"text": "A00. project-overview"
},
{
"line": 1849,
"level": 3,
"text": "Project Overview"
},
{
"line": 1856,
"level": 4,
"text": "분석 기준 revision"
},
{
"line": 1867,
"level": 4,
"text": "최종 커버리지"
},
{
"line": 1884,
"level": 4,
"text": "Build and module map"
},
{
"line": 1939,
"level": 4,
"text": "Dependency direction"
},
{
"line": 1945,
"level": 4,
"text": "Runtime entry points"
},
{
"line": 1951,
"level": 4,
"text": "Persistence / messaging / external systems"
},
{
"line": 1955,
"level": 4,
"text": "Test topology"
},
{
"line": 1960,
"level": 4,
"text": "Configuration and operational surfaces"
},
{
"line": 1964,
"level": 4,
"text": "분석할 bounded scopes (계획 — 실제 문서 배치는 위 \"최종 커버리지\" 참조)"
},
{
"line": 1977,
"level": 4,
"text": "아직 단정하지 않는 것 (분석 시작 시점의 목록)"
},
{
"line": 1993,
"level": 2,
"text": "A01. domain-core"
},
{
"line": 1997,
"level": 3,
"text": "domain-core 상세 분석"
},
{
"line": 2000,
"level": 4,
"text": "SSOT identity — 2026-08-31 재검증"
},
{
"line": 2015,
"level": 4,
"text": "분석 범위와 결론 상태"
},
{
"line": 2026,
"level": 4,
"text": "1. Quantified scope map"
},
{
"line": 2028,
"level": 5,
"text": "Owned source"
},
{
"line": 2042,
"level": 4,
"text": "2. Coverage ledger"
},
{
"line": 2062,
"level": 4,
"text": "3. 이 모듈이 실제로 소유하는 것"
},
{
"line": 2064,
"level": 5,
"text": "관찰: 재사용 가능한 도메인 “내용”보다 도메인 모델링 계약을 소유한다"
},
{
"line": 2073,
"level": 4,
"text": "4. Identifier contract"
},
{
"line": 2075,
"level": 5,
"text": "`ResourceId