9.9 KiB
9.9 KiB
title, source_type, status, confidence, tags, related_projects, last_reviewed
| title | source_type | status | confidence | tags | related_projects | last_reviewed | ||||
|---|---|---|---|---|---|---|---|---|---|---|
| Transactional Outbox Pattern (SKIP LOCKED polling vs CDC) | llm-generated | draft | medium |
|
|
2026-05-22 |
Transactional Outbox Pattern (SKIP LOCKED polling vs CDC)
Layer:
wiki/concepts/— 일반 개념. 프로젝트 적용 사실은 raw/project-notes/ca-skeleton-operational-contract 등 project 문서 참조.
Summary
Transactional outbox는 "DB write + 외부 메시지 publish"라는 두 시스템에 걸친 원자성 요구를 단일 RDB 트랜잭션 + 비동기 publisher로 우회하는 패턴입니다. 도메인 변경과 같은 트랜잭션에서 outbox 테이블에 이벤트 row를 INSERT하고, 별도 publisher가 그 row를 polling(또는 CDC)으로 읽어 broker에 발행함으로써 dual-write 문제(두 시스템 중 하나만 성공)를 제거합니다. polling 구현체에서는 PostgreSQL/MySQL의 FOR UPDATE SKIP LOCKED로 다중 publisher 간 row 경합을 해소합니다.
Standard (공식 정의)
- microservices.io / Chris Richardson: outbox 패턴의 원형 정의. 서비스가 DB 트랜잭션 내에
OUTBOX테이블에 이벤트를 기록하고, 별도 message relay가 이 테이블을 읽어 broker로 publish. dual-write를 명시적 anti-pattern으로 두고 outbox/event sourcing을 두 정식 대안으로 제시. - PostgreSQL
FOR UPDATE SKIP LOCKED: 9.5+.SELECT ... FOR UPDATE대상 row 중 다른 트랜잭션이 이미 잠근 row를 차단 없이 skip. queue 형태의 워크로드(outbox claim, job queue)에 사용 권장. 잠금은 row 단위, 트랜잭션 종료 시 해제. - MySQL 8.0+
SKIP LOCKED: PostgreSQL과 동일한 의미. 8.0 이전 버전은 미지원 — advisory lock으로 fallback. - Debezium: 오픈소스 CDC 플랫폼. DB write-ahead log(Postgres logical replication / MySQL binlog)을 읽어 변경 이벤트를 Kafka 등 broker로 전달. outbox 테이블도 다른 테이블과 동일하게 WAL/binlog로 캡처.
- Kafka Connect Outbox Event Router (Debezium SMT): Debezium이 캡처한 outbox row를 Single Message Transform 단계에서 Kafka topic/key/headers로 라우팅. outbox row schema 규약(
aggregatetype,aggregateid,type,payload)을 요구. - delivery semantic: outbox + 비동기 publish는 at-least-once가 기본이며 exactly-once가 아님. consumer 측에서
eventId또는idempotencyKey기반 dedupe가 필수.
한계 / 주의점
각 구현 옵션별 trade-off.
SKIP LOCKED polling
- publish lag = polling interval + claim transaction + broker publish. 일반적으로 수 초~수 분 수준이며 sub-second lag 요구에는 부적합.
- outbox 테이블이 단조 증가 → archived/published row cleanup 정책 필수 (TTL 삭제 또는 partition rotation). 누락 시 인덱스 비대 및 vacuum 비용 증가.
- 단일 DB가 SSOT여야 함. 멀티 DB에 도메인 write가 분산되면 outbox 1개로 해소 불가.
- multi-instance publisher 운영 시 동일 row 중복 claim 방지는 SKIP LOCKED 자체가 보장하지만, publish 후 commit 실패 시 재시도로 인한 중복 publish 가능 → consumer dedupe가 정합성의 일부.
Debezium CDC
- WAL/binlog 기반이므로 publish lag이 polling보다 짧음(밀리초~초 단위).
- 단, Kafka Connect 클러스터, connector 설정/스키마, replica slot 관리, snapshot 운영 인력이 추가로 필요. 인프라 비용·운영 학습 비용이 폴링 대비 크게 큼.
- Postgres에서는 logical replication slot이 누적되면 WAL 디스크가 증가하는 운영 risk가 있음(slot lag 모니터링 필수).
- 마이그레이션 트리거는 보통 "polling lag SLO 위반" 또는 "DB load가 polling 쿼리로 포화"이며, 그 가정이 깨지지 않으면 도입 정당화 어려움.
Kafka Connect Outbox SMT (Debezium event router)
- payload 변환·라우팅 로직이 connector 설정 + SMT 규약에 묶임. 복잡한 payload 가공이나 multi-topic fan-out은 SMT 표현력의 한계가 있음.
- outbox row schema가 Debezium event router 규약에 종속 → 자유로운 컬럼 설계가 어려움.
Dual-write (anti-pattern, negative reference)
- 애플리케이션 코드에서 DB commit과 broker publish를 순차로 직접 호출하는 형태. 둘 사이에 프로세스 종료/장애가 끼면 정합성이 깨짐.
- outbox 도입의 근거 그 자체이므로, "왜 outbox인가"의 답은 항상 dual-write 실패 시나리오에서 출발.
- 외부 publish 없이 in-process consumer만 있는 경우라면 트랜잭션 commit 후 in-process dispatch도 허용 가능 — 하지만 외부 transport가 끼는 순간 outbox가 기본값.
Event sourcing
- 흔히 "outbox 대안"으로 묶이지만 실제로는 도메인 모델 자체를 이벤트 스트림으로 교체하는 결정이며, 단순 publish 정합성 문제 해결이 아님.
- 도메인 재설계, 스냅샷·재구성 운영, 쿼리 모델(CQRS) 분리 비용 동반. 단지 "이벤트 발행이 필요해서" event sourcing으로 가는 것은 trade-off 오판.
Spring @TransactionalEventListener
AFTER_COMMITphase에서 in-process bean으로 이벤트 dispatch. JVM 프로세스 내부에서만 동작.- commit 직후 publish 실패(예: 외부 broker 호출 예외, 프로세스 강제 종료)에 대한 영속 큐가 없음 → 재시작 시 유실. 외부 broker로 가는 integration event 발행에는 부적합.
- 도메인 이벤트의 in-process side effect 트리거 용도로만 안전.
Netflix DBLog 류 자체 CDC
- Debezium보다 더 큰 자체 인프라 투자. 일반 백엔드 팀이 도입할 baseline 아님. 비교 시 "왜 Debezium도 부담이라 polling을 골랐는가"의 대조군으로만 사용.
Project Application
- wiki/projects/ca-tmpl/transactional-outbox-pattern — ca-tmpl 의사결정 기록 (현재
documented-only, Phase C2 미진입). 실제 구현 여부는 project 문서 참조. - raw/branch-notes/feature-domain-event-outbox-contract — outbox publisher SSOT, row status(
PENDING/IN_FLIGHT/PUBLISHED/FAILED/DEAD), per-aggregate FIFO, claim transaction(READ_COMMITTED+FOR UPDATE SKIP LOCKED), at-least-once + consumer dedupe 결정. - raw/branch-notes/feature-background-job-async-contract — outbox publisher가 consume하는 retry/DLQ vocabulary(exp backoff with jitter, max 3, DLQ exhausted) SSOT.
- raw/project-notes/ca-skeleton-operational-contract (§11 Adapter Failure, §14 Transaction/Concurrency, §29 Topic 3) — outbox 패턴이 어떤 운영 계약 안에서 어떤 위치를 차지하는지의 canonical map.
Interview Questions
- 왜 dual-write는 안 되는가? outbox는 dual-write의 어떤 실패 모드를 어떻게 제거하는가?
- SKIP LOCKED polling은 publish lag과 어떤 trade-off를 가지는가? lag을 줄이려면 polling interval만 줄이면 되는가?
- Debezium CDC로 마이그레이션을 결정하는 트리거는 무엇인가? (어떤 가정이 깨졌을 때?)
- outbox 테이블 cleanup(archived row 삭제/파티셔닝)을 누락하면 어떤 문제가 생기는가?
- outbox가 exactly-once를 보장하지 않는 이유와, 그 위에서 consumer가 정합성을 유지하는 메커니즘(idempotency key)을 설명할 수 있는가?
Do Not Overclaim
- "outbox = exactly-once delivery"라고 말하지 않기. 정확한 표현은 at-least-once delivery + idempotent consumer.
- "Debezium을 곧 도입할 것"이라고 말하지 않기. CDC migration은 polling lag SLO나 DB 부하 가정이 깨질 때만 정당화되며, 현 시점에는 가정이 유지된다고만 말할 것.
- "outbox만 있으면 정합성이 보장된다"고 말하지 않기. publisher 측의 retry/DLQ, consumer 측의 dedupe, outbox row cleanup 정책이 함께 있어야 운영 가능.
- "SKIP LOCKED가 race condition을 다 막아준다"고 말하지 않기. SKIP LOCKED는 claim 단계의 row 경합만 해소하며, publish 후 commit 실패로 인한 재발행은 별개의 문제.
- "event sourcing이 outbox의 상위 호환이다"라고 말하지 않기. 둘은 해결하려는 문제의 층위가 다름(전달 정합성 vs 도메인 모델링).
- 본인이 polling publisher를 운영해 본 측정값이 없다면 lag 수치를 단정적으로 말하지 않기.
Sources
- Pattern: Transactional outbox (microservices.io) — outbox 원형 정의 / Chris Richardson
- PostgreSQL: SELECT — The Locking Clause —
FOR UPDATE SKIP LOCKED의미론 - Debezium documentation — Outbox Event Router — Kafka Connect SMT
- Spring Framework —
@TransactionalEventListener— in-process only 한계 - raw/official-docs/outbox-skip-locked-microservices-io — outbox 원형 raw 발췌
- raw/official-docs/skip-locked-postgres-docs — Postgres SKIP LOCKED 원리
- raw/official-docs/outbox-debezium-official-docs — Debezium 공식 문서
- raw/official-docs/spring-transactional-event-listener — Spring 공식 문서
- raw/official-docs/event-sourcing-vs-outbox-microservices-io — outbox vs event sourcing
- raw/official-docs/dual-write-antipattern-microservices-io — dual-write negative reference
- raw/company-tech-blogs/outbox-woowahan-techblog-pattern — 우아한형제들 polling 사례
- raw/company-tech-blogs/outbox-wix-engineering-debezium — Wix Debezium migration 사례
- raw/company-tech-blogs/outbox-confluent-kafka-connect-smt — Confluent Kafka Connect outbox SMT
- raw/company-tech-blogs/outbox-netflix-domain-events-cdc — Netflix DBLog 자체 CDC
- raw/project-notes/ca-skeleton-operational-contract — §11 / §14 / §29 Topic 3 canonical map