The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
61 KiB
messaging-outbox-jdbc-postgresql 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-outbox-jdbc-postgresqlSSOT owner:messaging-outbox-jdbc-postgresqlintegration/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 에 적혀 있다.
// 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')
이 리프의 축은 하나다: "모르는 것을 실패로 취급하지 않는다."
// OutboxRelay.java:17-27
/**
* Publishes outbox rows, treating an unknown outcome as retryable rather than final.
*
* <p>The relay's correctness rests on one rule: an ambiguous publish is retried <em>under the same
* message id</em>. 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.
*
* <p>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. 의존성과 런타임 배선
// 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):
// 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 를 대리키가 아니라 기본키로 삼는다.
-- 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 — 펜싱 토큰. 주석이 시나리오를 그대로 적는다.
-- 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 이 아니라 컬럼인지가 명확하다.
-- 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 와 이중으로 거는 이유도 적혀 있다.
-- 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}$');
마지막으로 생성 컬럼이 두 릴레이의 합의를 하나로 만든다.
-- 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 — 이 리프의 전체 메커니즘
// JdbcOutboxRepository.java:37-46
/**
* <p>{@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.
*/
그리고 그것을 강제한다.
// :203-218
requireActiveTransaction("OUTBOX_TRANSACTION_REQUIRED", "appending to the outbox");
Connection connection = DataSourceUtils.getConnection(dataSource);
세 가지를 본다(:228-245): 활성 트랜잭션이 있는가 / 읽기 전용이 아닌가 / 이 DataSource 에 바인딩되어 있는가. 세 번째가 특히 좋다 — 다른 DataSource 의 트랜잭션 안에서 append 하면 둘이 독립적으로 커밋된다.
// :221-227
/**
* <p>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)은 소유자와 토큰을 기록하고 재시도 시계를 술어에 포함한다.
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).
종결 쓰기는 전부 펜싱 술어를 단다.
// :400-403
String sql = setClause
+ "WHERE message_id = ? AND status = 'IN_FLIGHT' AND lease_owner = ? AND lease_token = ?";
그리고 0행을 삼키지 않는다.
// :392-398
/**
* <p>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 — 세 결과, 다섯 카운터
// :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 의 근거가 붙어 있다.
// :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 를 별도 상태로 두는 근거도.
// :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 — 설정 간의 관계를 생성자가 강제한다
// :7-14
/**
* <p>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 — 정경 사실을 컬럼에서 되살린다
// :20-37
/**
* <p>The identity comes from the row, never from a fresh mint. ...
*
* <p>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. ...
*
* <p>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 제약이 경쟁을 결판낸다
// :22-32
/**
* <p>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 자체가 조건을 담는다.
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 의 패스 실패 처리가 특히 명시적이다.
// :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.
종료도 인터럽트가 아니라 드레인이다.
// :99-106
/**
* <p>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 이다.
// :604-610
/**
* <p>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 오버로드를 둘 갖고, 구현도 둘 다 있다.
// 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 < ?
호출자는 무제한 쪽을 부른다.
// 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 쪽과 동일하다.
// 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 — 런타임 재현)
// 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 쪽은 오히려 의도적으로 견고한 테스트가 지키고 있다.
// 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)
// OutboxRetryScheduler.java:18-20
/**
* <p>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 가 존재하는 이유가 그 유실을 막는 것이다.
두 가지가 필요하다.
- properties 를 Java 설정에서 생성하거나, 최소한 둘을 대조하는 테스트를 둔다.
DebeziumOutboxEventRouter.connectorConfiguration("")의 항목이 파일에 모두 있는지 확인하는 테스트면 충분하다. 지금은 두 표현을 잇는 코드가 한 줄도 없다. 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 는 두 복제본을 모두 통과시킨다.
- 읽기와 인수 사이의 경쟁을
RETURNING0행으로 거절. - 컨테이너 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