Architectureadvanced검토 2026.08

Durable Outbox 운영 설계

Outbox 이벤트를 잃지 않고 재시도·격리·복구·정리하기 위한 Relay 운영 수명주기를 설계한다.

#outbox#durability#relay#retry#operations

Overview

Transactional Outbox가 업무 Row와 이벤트 Row를 같은 로컬 Transaction에 저장해 Dual Write 불일치를 막는 패턴이라면, Durable Outbox는 그다음 질문을 다룬다. Relay가 중간에 죽거나 Broker가 장시간 멈추고, 같은 이벤트가 두 번 발행되며, Outbox Table이 수억 건으로 커져도 어떻게 유실 없이 복구할 것인가? 즉 패턴의 이름보다 이벤트의 전체 수명주기와 실패 상태를 운영 가능한 데이터로 만드는 것이 핵심이다.

한 문장 설명: 발송 요청을 장부에 적는 데서 끝내지 않고, 누가 가져갔는지·언제 다시 시도할지·배달됐는지·왜 격리됐는지까지 추적하는 내구성 있는 우편 운영 체계다.

이 문서에서 Outbox의 기본 원자성은 Transactional Outbox가 담당하고, Polling과 Log-based CDC의 구현 차이는 Outbox Relay와 CDC가 담당한다. 여기서는 상태 전이, 재시도, 중복, 장애 복구, 관측과 보관 정책에 집중한다.

Source 구분: Local Transaction, Kafka Partition Ordering, Producer/Consumer Idempotency와 Polling/CDC 맥락은 연결된 Notion 학습 기록을 통합했다. 아래 Claim Lease, 상태 머신, 장애 조합별 복구, 운영 지표와 Retention 설계는 원문보다 상세한 Dev Atlas 보충이다. 제품과 DB 버전에 따라 문법과 보장 범위를 공식 문서로 다시 확인해야 한다.

핵심 용어

용어의미운영 질문
Outbox Event업무 변경과 같은 Transaction으로 저장한 발행 의도업무 Row는 있는데 Event Row가 없는 상태가 가능한가?
Relay미발행 이벤트를 Claim하고 Broker에 Publish하는 Worker여러 대가 경쟁할 때 중복 Claim을 어떻게 막는가?
NEW아직 Relay가 가져가지 않은 상태오래된 NEW가 쌓이면 언제 경보할 것인가?
CLAIMED특정 Worker가 제한 시간 동안 처리권을 가진 상태Worker가 죽으면 Claim을 누가 회수하는가?
PUBLISHEDBroker ACK를 확인하고 완료 표시한 상태어느 시점부터 Archive 또는 Cleanup할 수 있는가?
FAILED발행 실패 후 다시 시도하거나 격리할 상태재시도 가능 오류와 Poison Event를 어떻게 구분하는가?
Claim Lease영구 Lock 대신 만료 시간이 있는 임시 소유권처리 시간보다 짧거나 지나치게 긴 Lease의 영향은?
At-least-once유실을 피하는 대신 한 번 이상 전달될 수 있는 모델Consumer의 Idempotency는 어디에 저장하는가?
Relay LagEvent 생성부터 발행 완료까지 걸린 지연평균보다 p95/p99와 Oldest Event Age가 왜 중요한가?
Poison Event반복해도 성공하지 않아 정상 흐름을 방해하는 이벤트DLQ, 수동 Replay, 수정 이력을 어떻게 감사하는가?
Retention완료 이벤트를 유지하는 기간과 삭제 규칙Replay·감사 요구와 저장 비용의 균형은?

왜 필요한가

published BOOLEAN 하나만 있어도 데모는 동작한다. 하지만 Production에서는 “발행 중” 상태가 없으므로 여러 Relay가 같은 Row를 동시에 가져갈 수 있고, 실패 원인과 시도 횟수가 없어 무한 재시도를 통제하기 어렵다. Publish 성공 직후 Process가 죽으면 DB에는 미발행으로 남아 재전송된다. 반대로 전송 전에 완료 처리하면 그 사이 Crash에서 이벤트가 영구 유실된다.

Durability는 단지 DB Disk에 Row가 있다는 뜻이 아니다. 다음 조건을 함께 만족해야 한다.

  1. 업무 변경과 발행 의도가 원자적으로 남는다.
  2. Relay 장애 후 소유권이 회수되고 처리 가능한 Event가 다시 선택된다.
  3. 일시 장애는 Retry하되 영구 오류는 정상 Event와 분리된다.
  4. 중복 전달을 정상 상황으로 보고 Consumer가 비즈니스 결과를 멱등하게 만든다.
  5. 적체를 숫자로 발견하고, Broker 장애가 DB 장애로 확산되기 전에 Backpressure를 건다.
  6. 완료 데이터의 Partition, Archive, Retention, Cleanup 정책이 정해져 있다.
  7. 수동 Replay도 자동 처리와 같은 검증·감사·권한 규칙을 따른다.

핵심 원리와 상태 모델

stateDiagram-v2; [*] --> NEW: local commit; NEW --> CLAIMED: claim + lease; FAILED --> CLAIMED: next_attempt_at 도달; CLAIMED --> PUBLISHED: broker ACK + 상태 저장; CLAIMED --> FAILED: publish 실패 또는 claim_until 만료; FAILED --> [*]: 격리 또는 Retention 종료; PUBLISHED --> [*]: Archive / Cleanup
Outbox 상태는 성공 경로뿐 아니라 실패와 소유권 회수 경로를 명시해야 한다

상태 전이는 Application 내부 메모리가 아니라 DB에 저장한다. CLAIMED는 “Broker가 받았다”가 아니라 “이 Worker가 지금 시도할 권리를 가졌다”는 뜻이다. claim_until이 지나면 다른 Worker가 회수할 수 있어야 한다. PUBLISHED는 Broker ACK를 받은 뒤 기록하지만 ACK와 DB Update는 서로 다른 시스템의 작업이므로 하나의 원자적 Commit이 아니다. 이 틈이 중복 가능성을 만든다.

상태를 너무 세분화하면 운영과 Migration이 복잡해지고, 너무 단순화하면 실패 원인을 잃는다. 최소한 처리 가능 여부, 소유 Worker, Lease 만료, 시도 횟수, 다음 시각, 마지막 오류, 발행 완료 시각은 조회 가능해야 한다. 별도 DEAD 상태를 둘 수도 있고 FAILED와 격리 Table을 조합할 수도 있다. 중요한 것은 이름보다 상태별 허용 전이와 책임자가 명확한가이다.

Table Schema와 Index 설계

다음은 PostgreSQL 계열의 개념 예시다. MySQL 등 다른 DB에서는 자료형, Partial Index, Lock과 Isolation 동작이 다르므로 실제 Version의 공식 문서와 부하 Test로 검증한다.

CREATE TABLE outbox_event (
    event_id          UUID PRIMARY KEY,
    aggregate_type    VARCHAR(80) NOT NULL,
    aggregate_id      VARCHAR(120) NOT NULL,
    aggregate_version BIGINT NOT NULL,
    event_type        VARCHAR(120) NOT NULL,
    schema_version    INT NOT NULL,
    payload           JSONB NOT NULL,
    status            VARCHAR(20) NOT NULL DEFAULT 'NEW',
    next_attempt_at   TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    claimed_by        VARCHAR(120),
    claim_until       TIMESTAMPTZ,
    attempt_count     INT NOT NULL DEFAULT 0,
    last_error_code   VARCHAR(80),
    last_error_message VARCHAR(1000),
    created_at        TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    published_at      TIMESTAMPTZ,
    CONSTRAINT uq_outbox_aggregate_version
      UNIQUE (aggregate_type, aggregate_id, aggregate_version),
    CONSTRAINT ck_outbox_status
      CHECK (status IN ('NEW', 'CLAIMED', 'PUBLISHED', 'FAILED'))
);
 
CREATE INDEX ix_outbox_ready
ON outbox_event (next_attempt_at, created_at)
WHERE status IN ('NEW', 'FAILED');
 
CREATE INDEX ix_outbox_expired_claim
ON outbox_event (claim_until)
WHERE status = 'CLAIMED';
 
CREATE INDEX ix_outbox_published_cleanup
ON outbox_event (published_at)
WHERE status = 'PUBLISHED';

Ready Query의 Filter 후보를 좁히는 Index를 두고, Payload처럼 큰 Column은 Index에 넣지 않는다. 위 (next_attempt_at, created_at) Index도 Range 조건 뒤 ORDER BY created_at의 정렬까지 항상 제거하는 것은 아니므로 실제 Query Plan에서 Sort와 읽은 Row 수를 확인해야 한다. 상태 분포가 대부분 PUBLISHED라면 전체 Index보다 처리 대상만 포함하는 Partial Index가 작고 유리할 수 있다. 단, DB가 Partial Index를 지원하지 않으면 복합 Index와 Partition 설계를 바꿔야 한다. Index를 많이 추가하면 업무 Transaction의 Insert 비용과 Storage가 증가하므로 실제 Query Plan과 Write TPS를 함께 측정한다.

aggregate_version Unique Constraint는 같은 Aggregate의 같은 Version을 두 번 만들지 않는 방어선이다. 이것만으로 Broker 전달 순서가 보장되지는 않는다. Aggregate별 정렬과 Partition Key, Consumer Sequence 검사가 추가로 필요하다.

Claim Lease와 경쟁 제어

여러 Relay가 같은 Row를 가져가는 것을 줄이기 위해 짧은 DB Transaction 안에서 대상 Row를 Lock하고 CLAIMED로 바꾼 뒤 Commit한다. 느린 Network Publish를 Row Lock을 잡은 채 수행하지 않는 것이 중요하다.

BEGIN;
 
SELECT event_id
FROM outbox_event
WHERE status IN ('NEW', 'FAILED')
  AND next_attempt_at <= CURRENT_TIMESTAMP
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 100;
 
UPDATE outbox_event
SET status = 'CLAIMED',
    claimed_by = :worker_id,
    claim_until = :lease_until,
    attempt_count = attempt_count + 1
WHERE event_id IN (:event_ids);
 
COMMIT;

FOR UPDATE SKIP LOCKED는 다른 Relay가 잠근 Row를 기다리지 않고 건너뛰어 병렬 처리량을 높인다. 그러나 DB 종류·Version·Isolation Level에 따라 Lock 범위, Gap Lock, 동일 Snapshot에서 보이는 Row가 다르다. 위 SQL을 그대로 복사하기 전에 선택과 Update가 동일 Row Set을 유지하는지, Starvation이 없는지, Execution Plan이 Ready Index를 쓰는지 확인한다.

Claim Lease는 처리 시간의 상한과 장애 감지 시간을 절충한다. 너무 짧으면 정상 Publish 중 Lease가 만료돼 다른 Worker가 중복 발행하고, 너무 길면 죽은 Worker의 Event 복구가 늦다. p99 Publish 시간보다 여유 있게 잡거나 긴 작업은 Heartbeat로 Lease를 연장한다. 연장 시에는 claimed_by = :worker_id AND status = 'CLAIMED' 조건을 사용해 이미 회수된 Claim을 되살리지 않는다.

Expired Lease 회수는 다음처럼 별도 Scheduler가 하거나 Claim Query가 직접 포함할 수 있다.

UPDATE outbox_event
SET status = 'FAILED',
    claimed_by = NULL,
    claim_until = NULL,
    next_attempt_at = CURRENT_TIMESTAMP,
    last_error_code = 'LEASE_EXPIRED'
WHERE status = 'CLAIMED'
  AND claim_until < CURRENT_TIMESTAMP;

Polling과 CDC 선택

기준Polling RelayLog-based CDC
읽는 위치Outbox Table QueryWAL/Binlog 같은 Transaction Log
지연Poll 주기와 Batch에 영향일반적으로 낮지만 Connector Lag에 영향
DB 부하반복 Select, Update, Index 비용Query는 줄지만 Log 보존·읽기 비용 존재
상태 관리Application이 Claim/Retry/완료를 직접 관리Connector Offset과 Downstream 상태 관리
도입 난이도작은 시스템에서 단순권한, Snapshot, Connector Cluster 필요
복구 단위next_attempt_at, Lease, RowLog Offset, Snapshot, Connector 재시작
Schema 변화Application 배포와 함께 통제DDL, Converter, Schema Registry까지 고려

Polling은 나쁜 방식이 아니라 운영팀이 쉽게 이해하고 복구할 수 있는 좋은 시작점이다. 빈 Poll이 많다면 Adaptive Interval을 쓰고, 적체 시 Batch 크기와 Worker 수를 제한적으로 늘린다. CDC는 대량·저지연에 유리하지만 Connector가 또 하나의 Production System이 된다. CDC가 DB Commit 순서를 읽더라도 Broker Partition과 Consumer 병렬성까지 Global Ordering을 자동 보장하지 않는다.

Retry와 Exponential Backoff

Retry 대상은 “다시 하면 성공할 가능성이 있는가”로 분류한다. Broker Timeout, 일시 Network 단절, Rate Limit은 재시도 후보지만, Schema Validation 실패, 존재하지 않는 Topic, 허용 크기 초과, 필수 Field 누락은 같은 Payload로 반복해도 성공하지 않는 경우가 많다.

Exponential Backoff는 예를 들어 min(base × 2^attempt, maxDelay)로 간격을 늘린다. 모든 Worker가 동시에 재시도하는 Thundering Herd를 피하려면 Full Jitter를 더한다. Retry 횟수만 제한하면 장기 장애 때 빠르게 소진될 수 있으므로 최대 경과 시간과 오류 분류도 함께 둔다.

Duration nextDelay(int attempt) {
    long capMillis = Duration.ofMinutes(15).toMillis();
    long expMillis = Math.min(500L * (1L << Math.min(attempt, 10)), capMillis);
    return Duration.ofMillis(ThreadLocalRandom.current().nextLong(expMillis + 1));
}

재시도 상태 저장 자체가 실패할 수도 있다. 이때 Lease가 만료되면 다시 Claim되므로 유실 대신 중복 쪽으로 기울어진다. Outbox 설계는 보통 이 선택을 의도한다. Retry 폭주가 업무 DB Connection Pool을 소진하지 않도록 Relay 전용 Pool, 작은 Batch, Circuit Breaker, 전역 발행 속도 제한을 검토한다.

중복이 발생하는 정확한 창

가장 중요한 실패는 다음 순서다.

  1. Relay가 Broker에 Event를 Publish한다.
  2. Broker가 저장하고 ACK를 반환한다.
  3. Relay가 PUBLISHED로 Update하기 전에 Process가 종료되거나 DB Update가 실패한다.
  4. Lease 만료 뒤 다른 Relay가 같은 Event를 다시 Publish한다.

Broker와 DB를 하나의 분산 Transaction으로 묶지 않는 한 이 창을 완전히 제거하기 어렵다. Producer Idempotency가 동일 Producer Session의 재전송을 줄여도 Process 재기동, 다른 Producer, Consumer DB Side Effect까지 모두 해결하지 않는다. 따라서 event_id를 안정적으로 유지하고 Consumer는 멱등성·중복 전달과 순서의 Inbox/Unique Constraint로 비즈니스 효과를 한 번만 반영한다.

“Exactly Once”라는 표현은 경계를 빼면 위험하다. Broker 내부 Record 중복 억제와 주문 DB Update·Email 발송·외부 결제 호출까지 한 번만 일어나는 것은 서로 다른 보장이다. Durable Outbox는 일반적으로 At-least-once 전달과 관측 가능한 복구를 목표로 한다.

Ordering과 Idempotency

Global Ordering은 모든 Aggregate를 한 줄에 세우므로 처리량과 가용성을 크게 떨어뜨린다. 대부분은 주문 ID, 계정 ID 같은 aggregate_id를 Broker Partition Key로 쓰고 aggregate_version을 증가시켜 Aggregate 내부 Ordering만 요구한다.

Relay가 여러 Row를 병렬 Publish하면 먼저 생성된 Event가 항상 먼저 ACK된다고 가정할 수 없다. 같은 Aggregate는 한 Worker가 순차 처리하거나, Broker가 동일 Key를 같은 Partition으로 보내도록 하고 Producer의 In-flight 설정을 검토한다. Consumer는 Version이 현재 값 + 1인지 확인하고, 이전 Event가 없으면 지연 Queue에 보류하거나 Source of Truth를 조회해 보정한다.

Idempotency는 Event ID 중복 제거에서 끝나지 않는다. Consumer Inbox Insert와 업무 Update가 같은 Local Transaction이어야 한다. Email처럼 DB 밖 Side Effect는 자체 Idempotency Key, 발송 이력, Provider의 중복 억제 기능을 결합한다. Dedup Retention보다 오래된 Replay가 올 수 있다면 보존 기간을 늘리거나 Aggregate Version이라는 추가 방어선을 사용한다.

Poison Event와 수동 Replay

Poison Event는 정상 흐름에서 분리하지 않으면 매 Batch의 앞자리를 차지하고 Relay Lag을 키운다. 최대 시도 횟수나 최대 경과 시간을 넘기면 FAILED 격리 상태 또는 별도 DLQ Table/Topic으로 이동한다. 격리는 삭제가 아니다. 원본 Payload, Schema Version, 오류 분류, 최초·최종 실패 시각, 시도 횟수, 원래 Event ID를 보존한다.

수동 Replay 도구에는 다음 안전장치가 필요하다.

  • 운영자 인증과 최소 권한, 승인 절차
  • 원본과 수정본의 Diff, 수정 사유와 Ticket 번호
  • Dry-run Validation과 대상 건수 제한
  • 기존 Event ID 유지 또는 새 ID 발급 규칙
  • Replay 시작자·시각·결과의 Audit Log
  • 동일 Consumer Idempotency 경로 사용
  • 대량 Replay의 Rate Limit과 즉시 중단 기능

Payload를 손으로 고쳐 정상 Queue에 바로 넣으면 감사 추적과 재현성이 사라진다. Schema 변환기를 Version별 코드로 관리하고 원본은 불변으로 보관하는 편이 낫다. 개인정보는 DLQ에 무기한 남기지 않고 Masking, Encryption, 접근 기록, 삭제 요청 전파를 설계한다.

Backpressure와 장기 Broker 장애

Broker가 멈춘 동안 업무 Transaction은 계속 Outbox를 Insert할 수 있다. 이것이 Outbox의 장점이지만 무한 Buffer는 아니다. Disk, WAL/Binlog, Backup 시간, Index 크기, Replica Lag이 한계에 도달하면 결국 업무 DB 전체 장애로 번진다.

Backpressure 정책은 서비스 중요도에 따라 단계화한다.

  1. Relay는 Bounded 동시성·Batch 크기·전용 Connection Pool로 스스로 DB를 압박하지 않는다.
  2. 적체량과 Oldest Event Age가 Warning을 넘으면 Auto Scaling 또는 Poll 간격을 조정한다.
  3. Critical 수준에서는 비필수 이벤트 생성 중단, 요청 Rate Limit, 기능 Degradation을 적용한다.
  4. 최대 저장 가능 시간과 복구 처리량을 Capacity Test로 미리 계산한다.
  5. Broker 복구 뒤 무제한 Catch-up으로 Consumer DB를 무너뜨리지 않도록 Drain Rate를 제한한다.

유입이 초당 5천 건인데 복구 후 처리량이 초당 4천 건이면 적체는 영원히 줄지 않는다. 정상 Peak보다 높은 지속 가능한 Catch-up Capacity가 있어야 한다. Payload 평균 크기뿐 아니라 Index, MVCC Bloat, WAL과 Backup 증가분까지 용량에 포함한다.

Partition, Archive, Retention과 Cleanup

Outbox Table은 쓰기와 Ready 조회가 활발한 Hot 영역과 발행 완료된 Cold 영역을 분리해야 한다. 날짜 기반 Partition은 오래된 완료 데이터를 Partition Drop으로 빠르게 제거할 수 있지만 event_id Unique Constraint, 늦은 Event, Partition 수와 Query Pruning을 고려해야 한다. 상태 기반 Physical Partition은 상태 Update가 Row 이동을 일으킬 수 있어 DB 특성을 확인한다.

Retention 기간은 다음 요구로 결정한다.

  • Broker와 Consumer의 최대 Replay 기간
  • 장애 조사와 법적 Audit 기간
  • Dedup/Inbox 보존 기간
  • 개인정보 최소 보관·삭제 요구
  • Storage와 Backup/Restore 목표

PUBLISHED 직후 삭제하면 감사·장애 조사·Replay 근거가 사라지고 Consumer Dedup 보존 기간과 불일치할 수 있다. 일정 기간 보존한 뒤 Archive Store로 옮기고, 이동 완료가 확인된 Partition부터 Cleanup한다. Delete를 수백만 건 한 Transaction으로 실행하면 Lock, WAL, Replica Lag이 커질 수 있으므로 작은 Chunk 또는 Partition Drop을 사용한다. Cleanup Worker도 실패 지표와 재시작 지점을 가진 Production Job으로 취급한다.

Archive는 “언젠가 볼 수도 있는 Dump”가 아니라 검색 Key, 암호화, Lifecycle, 복원 절차가 정의된 저장소여야 한다. 실제 Replay 전에 현재 Schema로 변환 가능하고 Idempotency 보존 기간과 충돌하지 않는지 검증한다.

관측 지표와 경보

단순 Table Row Count는 문제의 심각도를 충분히 말하지 못한다. 다음을 함께 본다.

지표의미해석 예시
Relay Lag p50/p95/p99생성부터 PUBLISHED까지 지연Tail만 상승하면 특정 Partition/오류 가능
Oldest Event Age가장 오래된 처리 가능 Event의 나이SLA 위반을 가장 직관적으로 나타냄
NEW/FAILED/CLAIMED Count상태별 적체CLAIMED 급증은 느린 Publish 또는 Lease 문제
Expired Claim Count죽은 Worker 또는 Lease 부족반복 상승하면 Timeout과 Lease 재설계
Publish Success/Failure RateBroker 연동 건강도Error Code/Topic별로 분리해야 유용
Attempt HistogramRetry 분포긴 꼬리는 Poison Event 또는 장기 장애 신호
DLQ Inflow/Replay Result격리와 복구 품질유입만 보고 Replay 실패를 놓치지 않음
Table/Index/Partition Size저장 한계증가 속도로 고갈 예상 시각 계산
Cleanup LagRetention 종료 후 미삭제 지연Backup/Query 성능 악화 전 경보

Alert는 평균보다 Oldest Event Age와 지속 시간에 기반하는 편이 좋다. 배포 직후 짧은 Spike와 실제 정지를 구분하고, 업무 중요 Event Type별 SLO를 분리한다. Dashboard에는 Broker 상태, DB Pool, Relay Instance, Consumer Lag을 같은 시간축으로 배치해 병목 이동을 찾는다.

장애 조합별 복구

장애 시점DB 상태Broker 상태예상 결과복구 절차
업무 Row 저장 전 Application Crash미Commit없음업무와 Event 모두 없음요청 Idempotency 정책에 따라 재요청
업무 Row 후 Outbox Insert 전 Crash같은 Transaction Rollback없음둘 다 없음원자성 Test와 Transaction 경계 확인
Local Commit 후 Relay 전 CrashNEW 보존없음지연만 발생Relay 재기동이 Ready Row Claim
Claim Commit 후 Publish 전 Worker CrashCLAIMED없음Lease 동안 지연claim_until 만료 후 FAILED/NEW로 회수
Publish Timeout, 실제 Broker 저장 실패CLAIMED/FAILED없음Retry 필요오류 분류 후 Exponential Backoff + Jitter
Publish Timeout, 실제 Broker 저장 성공CLAIMED/FAILEDEvent 존재중복 가능같은 event_id로 Retry, Consumer Idempotency
ACK 후 PUBLISHED Update 실패CLAIMEDEvent 존재재Claim 시 중복Lease 회수 후 재발행, Inbox에서 중복 흡수
PUBLISHED Update 성공 후 Relay CrashPUBLISHEDEvent 존재정상Retention 기간 뒤 Archive/Cleanup
DB 장애, Broker 정상상태 Update 불가Event 존재 가능성공 여부 불확실유실 쪽으로 추측하지 말고 DB 복구 후 재시도
Broker 장기 장애NEW/FAILED 증가발행 불가DB 적체·용량 압력Rate Limit/Degradation, 용량 경보, 복구 후 제한 Drain
잘못된 Schema/PayloadFAILED 반복거절 또는 Consumer 실패Poison Event격리, 원인 수정, 승인된 Replay
CDC Offset 유실Outbox 보존일부 중복/누락 위험재Snapshot 범위 불확실보존된 Outbox와 Offset/Audit 대조, 멱등 Replay

복구 Runbook에는 “어떤 버튼을 누른다”보다 판단 근거가 있어야 한다. 마지막 정상 발행 시각, Oldest Event, Broker Offset, DB 상태 분포, 배포 변경점, 안전한 Replay 속도와 중단 조건을 명시한다. 훈련 환경에서 Worker Kill, DB Failover, ACK 유실, Disk 압박을 주입해 Runbook이 실제로 작동하는지 검증한다.

Example

업무 Service는 Broker를 호출하지 않고 같은 Transaction 안에서 업무 Row와 Outbox Row를 저장한다.

@Transactional
public void payOrder(long orderId, Money amount) {
    Order order = orderRepository.getForUpdate(orderId);
    order.markPaid(amount);
    orderRepository.save(order);
 
    outboxRepository.append(OutboxEvent.newEvent(
        UUID.randomUUID(), "Order", String.valueOf(orderId),
        order.version(), "OrderPaid", 1, OrderPaidPayload.from(order)
    ));
}

Claim은 짧은 Transaction에서 끝내고 발행은 Transaction 밖에서 수행한다.

@Repository
public class JdbcOutboxRepository {
    private final NamedParameterJdbcTemplate jdbc;
 
    @Transactional
    public List<OutboxEvent> claimReady(String workerId, Instant leaseUntil, int limit) {
        List<UUID> ids = jdbc.queryForList("""
            SELECT event_id
            FROM outbox_event
            WHERE status IN ('NEW', 'FAILED')
              AND next_attempt_at <= CURRENT_TIMESTAMP
            ORDER BY created_at
            FOR UPDATE SKIP LOCKED
            LIMIT :limit
            """, Map.of("limit", limit), UUID.class);
 
        if (ids.isEmpty()) return List.of();
 
        jdbc.update("""
            UPDATE outbox_event
               SET status = 'CLAIMED', claimed_by = :worker,
                   claim_until = :leaseUntil,
                   attempt_count = attempt_count + 1
             WHERE event_id IN (:ids)
            """, Map.of("worker", workerId, "leaseUntil", leaseUntil, "ids", ids));
        return findAll(ids);
    }
}

일부 Driver나 DB는 LIMIT Binding, IN List Expansion, SKIP LOCKED 지원 방식이 다르다. 실제 구현은 Dialect에 맞춘 SQL과 Integration Test를 사용하고, 동일 Transaction/Connection에서 Select와 Update가 실행되는지 확인한다.

public void publishBatch() {
    List<OutboxEvent> claimed = repository.claimReady(
        instanceId, clock.instant().plusSeconds(30), 100
    );
 
    for (OutboxEvent event : claimed) {
        try {
            broker.publish(event.aggregateId(), event.eventId(), event.payload())
                  .get(PUBLISH_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
            repository.markPublished(event.eventId(), instanceId, clock.instant());
        } catch (Exception failure) {
            Duration delay = backoffPolicy.nextDelay(event.attemptCount());
            repository.markFailed(
                event.eventId(), instanceId,
                clock.instant().plus(delay), classify(failure), safeMessage(failure)
            );
        }
    }
}

markPublishedmarkFailedWHERE event_id = ? AND status = 'CLAIMED' AND claimed_by = ? 조건으로 현재 소유자만 상태를 바꾸게 한다. ACK 후 markPublished가 실패하면 Event는 다시 발행될 수 있으며 이는 의도된 At-least-once 동작이다. Publish Timeout에서 Future를 취소해도 Broker가 이미 저장했을 수 있으므로 “취소 성공 = 미발행”으로 단정하지 않는다.

@Scheduled(fixedDelayString = "${outbox.lease-recovery-delay:5000}")
@Transactional
public void recoverExpiredLeases() {
    jdbc.update("""
        UPDATE outbox_event
           SET status = 'FAILED', claimed_by = NULL, claim_until = NULL,
               next_attempt_at = CURRENT_TIMESTAMP,
               last_error_code = 'LEASE_EXPIRED'
         WHERE status = 'CLAIMED'
           AND claim_until < CURRENT_TIMESTAMP
        """, Map.of());
}

Scheduler 중복 실행은 조건부 Update가 방어하지만, 회수율과 원인을 Metric으로 남겨야 한다. Lease 만료가 잦다면 Worker Crash인지, Broker Latency 증가인지, 설정이 짧은지 구분한다.

Trade-off

선택장점비용과 위험
OutboxDual Write 유실 방지, 복구 근거 보존Table·Relay·중복·Cleanup 운영 증가
짧은 Claim Lease빠른 장애 회수느린 정상 작업의 중복 증가
긴 Claim Lease정상 작업 중복 감소Worker Crash 복구 지연
Polling단순하고 Application이 통제Query 부하와 Poll 지연
CDC저지연·대량 Change 전달Connector/Offset/Snapshot 전문성 필요
긴 Retention감사·Replay 여유Storage, Backup, 개인정보 비용
높은 병렬성Catch-up 처리량 증가순서, Broker/DB/Consumer 과부하

Outbox가 모든 서비스에 필요한 것은 아니다. 유실돼도 되는 Cache Invalidation이나 동일 DB 내부 작업에는 더 단순한 재계산·Scheduler가 적합할 수 있다. 반대로 결제·정산처럼 강한 감사와 즉시 일관성이 필요하면 Saga 보상, 업무 상태 조회, 사람 승인까지 포함한 더 큰 설계가 필요하다.

흔한 오해

  • PUBLISHED 상태가 있으면 중복이 사라지는 것이 아니다. ACK와 상태 Update 사이의 실패 창이 남는다.
  • Retry 횟수를 크게 잡는다고 내구성이 높아지지 않는다. Poison Event 격리와 복구 절차가 없으면 적체만 커진다.
  • FOR UPDATE SKIP LOCKED가 Ordering까지 보장하지 않는다. 경쟁을 줄이는 Lock 전략일 뿐이다.
  • CDC를 쓰면 Consumer Idempotency가 필요 없다는 주장은 틀리다. Connector 재시작과 Offset 복구도 중복을 만들 수 있다.
  • Broker ACK는 Consumer 업무 반영 완료가 아니다. Broker 내구성과 End-to-end 비즈니스 결과는 다른 경계다.
  • Outbox를 무기한 보관하면 안전하다는 생각도 위험하다. DB 성능, Backup, 개인정보와 삭제 의무가 악화된다.
  • 상태를 메모리에만 두고 DB Row는 NEW로 유지하면 Worker 재기동 때 처리 근거와 동시성 통제가 약해진다.

Production Considerations

배포 전 다음을 체크한다.

  • 업무 Row와 Outbox Row가 같은 DataSource, 같은 Local Transaction인지 Integration Test로 증명한다.
  • event_id, Aggregate Key/Version, Event Type, Schema Version, 발생 시각과 Trace ID를 표준 Envelope로 정의한다.
  • Payload에 비밀번호·Token·불필요한 개인정보를 넣지 않고 크기 제한을 둔다.
  • Ready Query와 Cleanup Query의 Execution Plan, Index 크기, Peak Insert TPS를 측정한다.
  • Relay Worker 수, Batch, Broker In-flight, DB Pool의 합이 Downstream Capacity를 넘지 않게 한다.
  • Timeout, Exponential Backoff, 최대 시도·경과 시간, Poison Event 분류표를 설정한다.
  • Claim Lease와 Heartbeat, Graceful Shutdown 때 새 Claim 중단과 진행 중 Publish 종료 순서를 정의한다.
  • Relay Lag, Oldest Event Age, Expired Claim, Failure Code, DLQ와 Cleanup Lag Alert를 만든다.
  • Broker 장기 장애 시 저장 가능 시간, Rate Limit, 기능 Degradation과 복구 Drain 속도를 Runbook에 둔다.
  • Consumer Inbox/Unique Constraint와 Side Effect Idempotency를 End-to-end Test한다.
  • Schema 호환성, Rollback 배포, 과거 Event Replay를 Test한다.
  • Partition, Archive, Retention, 개인정보 삭제와 실제 복원 절차를 정기 점검한다.
  • 수동 Replay는 승인·Audit·속도 제한·중단 기능을 거친다.

운영 준비의 완료 기준은 “Happy Path가 발행된다”가 아니다. ACK 유실, Worker Kill, Lease 만료, Broker 1시간 장애, 잘못된 Payload, DB Failover, 대량 Replay와 Cleanup을 통과하고 각 상황을 Dashboard에서 구분할 수 있어야 한다.

다른 사람에게 설명한다면

30초 설명: “Transactional Outbox는 업무 데이터와 이벤트를 같은 DB Transaction에 저장합니다. Durable Outbox는 거기서 더 나아가 Relay가 이벤트를 Claim하고, 실패하면 Backoff로 재시도하며, Worker가 죽으면 Lease 만료로 회수하고, Poison Event는 격리합니다. Broker ACK 뒤 상태 저장에 실패하면 중복될 수 있으므로 Consumer Idempotency가 필수이고, Relay Lag과 Oldest Event Age를 운영 지표로 봅니다.”

2분 설명: “DB와 Broker에 직접 Dual Write하면 한쪽만 성공할 수 있어서 업무 Row와 Outbox Row를 로컬 Transaction으로 Commit합니다. 여러 Relay는 FOR UPDATE SKIP LOCKED로 짧게 Row를 Claim하고 Network Publish는 Transaction 밖에서 합니다. Claim에는 만료 시간이 있어 죽은 Worker의 Event를 회수합니다. Broker ACK와 DB의 PUBLISHED Update는 원자적이지 않기 때문에 ACK 뒤 Crash 시 같은 Event가 다시 나가며, 안정적인 Event ID와 Consumer Inbox가 이를 흡수합니다. 일시 오류는 Exponential Backoff와 Jitter로 재시도하고 영구 오류는 DLQ로 격리해 승인된 도구로 Replay합니다. 장기 Broker 장애에서는 Outbox가 무한 Queue가 아니므로 Backpressure와 용량 한계가 필요합니다. 마지막으로 Relay Lag, Oldest Event Age, Expired Claim, DLQ, Table 크기와 Cleanup Lag을 관찰하고 Partition·Archive·Retention까지 수명주기로 관리합니다.”

Interview Questions

  1. Transactional Outbox와 Durable Outbox 운영 설계의 범위 차이는 무엇인가?
    • Follow-up: 업무 Row와 Event Row가 다른 DB라면 어떤 보장이 깨지는가?
  2. Publish 성공 후 PUBLISHED Update 전에 Relay가 죽으면 어떤 일이 생기는가?
    • Follow-up: Producer Idempotency만으로 Consumer DB 중복까지 제거할 수 있는가?
  3. Claim Lease가 필요한 이유와 너무 짧거나 긴 경우의 문제는 무엇인가?
    • Follow-up: 긴 Publish에 Heartbeat를 쓸 때 소유권 경쟁을 어떻게 막는가?
  4. FOR UPDATE SKIP LOCKED를 쓰는 이유와 확인해야 할 DB별 차이는 무엇인가?
    • Follow-up: Network 호출을 DB Transaction 안에서 하면 어떤 장애가 확산되는가?
  5. Polling과 CDC는 지연, DB 부하, 복구 복잡도에서 어떻게 다른가?
    • Follow-up: CDC Offset 유실 후 재Snapshot에서 중복을 어떻게 흡수하는가?
  6. Retry 가능한 오류와 Poison Event를 어떻게 분류하는가?
    • Follow-up: Exponential Backoff에 Jitter가 필요한 이유는 무엇인가?
  7. Aggregate Ordering을 지키면서 처리량을 확보하는 방법은?
    • Follow-up: Sequence Gap을 발견한 Consumer는 어떻게 복구할 수 있는가?
  8. Broker가 하루 동안 중단되면 Outbox가 업무 DB를 보호한다고 말할 수 있는가?
    • Follow-up: 저장 가능 시간과 Catch-up Capacity를 어떻게 계산하는가?
  9. Relay 상태를 관찰할 때 Row Count보다 Oldest Event Age가 유용한 이유는?
  10. Retention과 Consumer Dedup 보존 기간은 어떤 관계가 있는가?

SOURCE REFERENCES

이 문서의 근거

본문은 Dev Atlas 안에서 완결되며, 검증이 필요할 때만 원문을 확인할 수 있습니다.

원문 출처 보기 2