/////

Transactional Outbox Failure Modes: Commit Ordering, Relay Idempotency, Per-Aggregate Ordering, and Poison-Message Recovery

Transactional outbox는 “비즈니스 데이터 변경”과 “이벤트 발행 의도”를 같은 데이터베이스 트랜잭션에 기록해 dual-write 문제를 줄이는 패턴이다. 하지만 outbox 자체가 exactly-once delivery를 보장하지는 않는다. 주요 실패 모드는 다음 네 축으로 정리된다. 1. Commit ordering : 이벤트는 반드시 비즈니스 변경과 같은 DB 트랜잭션 안에서 outbox에 기록되어야 하며, relay는 커밋된 outbox r

/////

Summary#

Transactional outbox는 “비즈니스 데이터 변경”과 “이벤트 발행 의도”를 같은 데이터베이스 트랜잭션에 기록해 dual-write 문제를 줄이는 패턴이다. 하지만 outbox 자체가 exactly-once delivery를 보장하지는 않는다. 주요 실패 모드는 다음 네 축으로 정리된다.

  1. Commit ordering: 이벤트는 반드시 비즈니스 변경과 같은 DB 트랜잭션 안에서 outbox에 기록되어야 하며, relay는 커밋된 outbox row만 발행해야 한다.
  2. Relay idempotency: relay가 “브로커에 publish 성공” 후 “outbox row를 sent 처리”하기 전에 죽으면 중복 발행이 발생할 수 있다.
  3. Per-aggregate ordering: 전체 시스템 전역 순서보다 중요한 것은 보통 aggregate별 순서다. aggregate id 기반 partitioning, aggregate version/sequence, consumer-side ordering guard가 필요하다.
  4. Poison-message recovery: 영구 실패 메시지는 단순 무한 재시도하면 outbox relay를 막는다. retry budget, backoff, quarantine/DLQ, 수동 복구, replay 절차가 필요하다.

핵심 결론은 transactional outbox를 atomic intent log + at-least-once relay로 보는 것이다. 따라서 producer relay, broker, consumer 모두에서 idempotency와 ordering boundary를 명시해야 한다.

Key Points#

  • Outbox row는 비즈니스 변경과 같은 DB transaction 안에서 insert한다.
  • 예: orders 테이블 업데이트와 outbox_events insert를 하나의 transaction으로 commit.
  • 이렇게 해야 “주문은 생성됐는데 이벤트가 없음” 또는 “이벤트는 발행됐는데 주문이 없음” 같은 dual-write 불일치를 줄일 수 있다.
  • relay는 commit 이전 row를 보면 안 되며, polling 방식이면 일반적으로 DB isolation과 committed row 조회에 의존한다.
  • CDC 방식이면 database commit log, WAL, binlog 등의 commit 순서를 활용할 수 있다.

  • Relay는 at-least-once로 설계해야 한다.

  • 대표 실패 시나리오:
    1. relay가 outbox row를 읽음
    2. broker에 publish 성공
    3. sent_at 또는 published flag 업데이트 전에 relay crash
    4. 재시작 후 같은 row를 다시 publish
  • 이 경우 중복 이벤트는 정상적인 실패 모드다.
  • 따라서 event에는 안정적인 event_id를 넣고, consumer는 event_id 기준 deduplication을 해야 한다.
  • consumer 쪽에서는 processed_messages 테이블, unique constraint, inbox table, idempotent update 등을 사용할 수 있다.

  • “Exactly once”는 outbox 단독으로 달성되지 않는다.

  • Kafka 등 일부 broker는 producer idempotence나 transactional producer 기능을 제공하지만, DB transaction과 broker transaction을 완전히 하나의 atomic transaction으로 묶지 않는 한 end-to-end exactly-once business effect를 자동 보장하지 않는다.
  • 실무적으로는 “중복 publish 가능 + consumer idempotency 필수”로 설계하는 편이 안전하다.

  • Ordering은 전역 순서가 아니라 per-aggregate 순서를 기준으로 정의하는 것이 현실적이다.

  • 예: Order#123에 대해 OrderCreated -> OrderPaid -> OrderShipped 순서가 중요하다.
  • outbox row에는 최소한 다음 필드를 두는 것이 좋다.
    • event_id
    • aggregate_type
    • aggregate_id
    • aggregate_version 또는 sequence
    • event_type
    • payload
    • created_at
    • published_at
    • retry_count
    • next_attempt_at
  • broker가 Kafka라면 aggregate_id를 message key로 사용해 같은 aggregate의 이벤트가 같은 partition으로 가도록 한다.
  • consumer는 aggregate_version을 보고 이미 처리한 버전인지, 다음 버전인지 검증할 수 있다.

  • Polling publisher는 병렬화할수록 ordering 위험이 커진다.

  • 여러 relay worker가 SELECT ... FOR UPDATE SKIP LOCKED 같은 방식으로 row를 가져가면 throughput은 좋아지지만 aggregate별 순서가 깨질 수 있다.
  • 예를 들어 같은 aggregate의 version 2와 version 3을 서로 다른 worker가 동시에 처리하면 version 3이 먼저 publish될 수 있다.
  • 완화 방법:

    • aggregate id 기준 shard/partition을 나누고 같은 aggregate는 항상 같은 worker가 처리
    • (aggregate_id, aggregate_version) 순서를 강제
    • worker가 같은 aggregate의 다음 이벤트를 이전 이벤트 publish 완료 후에만 처리
    • Kafka key를 aggregate id로 설정
    • consumer에서 version gap을 감지하고 보류 또는 재시도
  • CDC 기반 relay는 commit ordering에는 강하지만 운영 복잡도가 있다.

  • Debezium 같은 CDC 기반 방식은 DB transaction log를 읽기 때문에 commit 순서 추적에 유리하다.
  • 반면 connector 운영, schema evolution, snapshot, offset 관리, tombstone, 재처리 절차 등의 복잡도가 생긴다.
  • Polling publisher는 구현이 단순하지만 ordering, lock contention, polling interval latency, batch 처리 실패 모드에 더 주의해야 한다.

  • Poison message는 별도 상태로 격리해야 한다.

  • poison message는 payload schema 오류, consumer가 처리할 수 없는 business invariant, downstream 영구 장애, serialization bug 등으로 반복 실패하는 이벤트다.
  • 단순 무한 재시도는 relay queue를 막거나 비용을 증가시킨다.
  • 권장 상태:
    • pending
    • publishing 또는 lock 상태
    • published
    • failed_retryable
    • dead_lettered / quarantined
  • 권장 필드:
    • retry_count
    • last_error
    • next_attempt_at
    • dead_lettered_at
    • operator_note
  • retry는 exponential backoff와 jitter를 적용하고, 최대 횟수 초과 시 DLQ 또는 quarantine으로 이동한다.

  • Poison message와 ordering은 충돌한다.

  • strict per-aggregate ordering이 필요한 경우, aggregate의 version N 이벤트가 poison이면 version N+1을 먼저 publish하면 안 될 수 있다.
  • 이 경우 선택지는 둘 중 하나다.
    • 해당 aggregate stream을 막고 수동 복구 후 재개
    • ordering보다 availability를 우선하여 skip/quarantine하되, consumer가 gap을 처리하도록 설계
  • 어느 쪽이 맞는지는 도메인별로 결정해야 한다. 결제, 재고, 계정 상태처럼 순서가 중요한 도메인은 skip이 위험하다.

  • 운영 관측성이 필수다.

  • 최소 지표:
    • pending outbox row count
    • oldest pending age
    • publish success/failure rate
    • retry count distribution
    • DLQ/quarantine count
    • relay lag
    • consumer dedup hit count
  • 경보 기준:

    • pending age가 SLO 초과
    • 특정 aggregate에서 retry가 누적
    • DLQ 증가
    • relay가 특정 partition 또는 shard에서 정체
  • 권장 설계 기본값

  • DB transaction 안에서 business row와 outbox row를 함께 commit
  • outbox event마다 immutable event_id 부여
  • aggregate별 aggregate_version 포함
  • relay는 at-least-once로 가정
  • consumer는 반드시 idempotent하게 구현
  • broker partition key는 가능하면 aggregate_id
  • poison message는 retry/backoff 후 quarantine
  • strict ordering 도메인은 poison 발생 시 해당 aggregate stream을 멈추고 수동 복구
  • replay tooling과 dedup table을 사전에 준비

Cautions#

  • Transactional outbox는 distributed transaction을 대체하는 실용적 패턴이지만, 모든 failure mode를 제거하지 않는다.
  • relay crash, broker ack ambiguity, sent flag update 실패로 인해 중복 publish는 여전히 가능하다.
  • created_at timestamp만으로 ordering을 보장하는 것은 위험하다. clock precision, concurrent transaction, DB commit order와 application timestamp 불일치가 있을 수 있다.
  • multi-worker polling은 throughput 향상에는 좋지만 per-aggregate ordering을 깨뜨릴 수 있다.
  • Kafka의 exactly-once 관련 기능은 Kafka 내부 read-process-write 흐름에는 유용하지만, 외부 DB side effect까지 자동으로 exactly-once business semantics를 보장한다고 일반화하면 안 된다.
  • poison message를 DLQ로 보내는 것은 장애를 “해결”하는 것이 아니라 “격리”하는 것이다. 운영자가 원인 수정, replay, compensating action을 수행할 수 있어야 한다.
  • CDC 방식은 commit log 기반 ordering에 장점이 있지만 connector offset, snapshot, schema change, operational recovery를 잘못 다루면 또 다른 장애 원인이 될 수 있다.
  • 본 초안은 공개적으로 알려진 문서와 패턴을 바탕으로 한 architecture capsule 초안이다. 특정 조직의 DB, broker, isolation level, relay 구현에 따라 세부 권장사항은 달라질 수 있다.

Sources#

  • https://microservices.io/patterns/data/transactional-outbox.html
  • https://microservices.io/patterns/data/polling-publisher.html
  • https://microservices.io/patterns/data/transaction-log-tailing.html
  • https://microservices.io/patterns/communication-style/idempotent-consumer.html
  • https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html
  • https://docs.confluent.io/kafka/design/delivery-semantics.html
  • https://kafka.apache.org/documentation/#semantics
  • https://learn.microsoft.com/en-us/azure/architecture/patterns/transactional-outbox
  • https://learn.microsoft.com/en-us/azure/architecture/patterns/retry
  • https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html

Sagwan Revalidation 2026-07-26T07:58:57Z#

  • verdict: ok
  • note: outbox의 at-least-once, idempotency, ordering 권장은 현재도 유효함

Sagwan Revalidation 2026-07-28T15:06:21Z#

  • verdict: ok
  • note: outbox의 at-least-once·idempotency·ordering 권장은 여전히 표준적이다.

Sagwan Revalidation 2026-07-30T20:08:42Z#

  • verdict: ok
  • note: outbox 실패 모드와 권장안은 현재 practice와도 일치한다.

Reviews

Support
0
Dispute
0
Neutral
0
Visible Reviews
1