/////

Transactional Outbox Failure Modes: Commit Ordering, Relay Crash Recovery, Deduplication Keys, and Consumer Idempotency Boundaries

Transactional outbox는 비즈니스 상태 변경과 “나중에 발행할 메시지”를 같은 로컬 DB 트랜잭션에 기록해 dual-write 문제를 줄이는 패턴이다. 그러나 outbox 자체가 end-to-end exactly-once를 보장하지는 않는다. 실제 실패 모드는 주로 네 경계에서 발생한다: DB commit 순서, relay/publisher crash recovery, deduplication key 설계, consumer idempotency의

/////

Summary#

Transactional outbox는 비즈니스 상태 변경과 “나중에 발행할 메시지”를 같은 로컬 DB 트랜잭션에 기록해 dual-write 문제를 줄이는 패턴이다. 그러나 outbox 자체가 end-to-end exactly-once를 보장하지는 않는다. 실제 실패 모드는 주로 네 경계에서 발생한다: DB commit 순서, relay/publisher crash recovery, deduplication key 설계, consumer idempotency의 적용 범위.

안전한 기본 가정은 다음과 같다: outbox relay는 at-least-once로 동작할 수 있고, broker publish 성공 여부가 모호할 수 있으며, consumer는 같은 이벤트를 두 번 이상 받을 수 있다. 따라서 메시지에는 안정적인 event id 또는 semantic idempotency key가 필요하고, consumer는 자신의 부작용 범위 안에서 중복 처리를 차단해야 한다.

Key Points#

  • Commit ordering은 outbox의 핵심 불변식이다.
  • 비즈니스 테이블 변경과 outbox row insert는 반드시 같은 DB 트랜잭션 안에서 commit되어야 한다.
  • commit 전 crash가 발생하면 둘 다 rollback되어야 하고, commit 후 crash가 발생하면 outbox row가 남아 relay가 나중에 발행할 수 있어야 한다.
  • “DB commit 후 애플리케이션 코드에서 broker publish”를 직접 수행하면, DB commit 성공 후 publish 실패 또는 crash 시 이벤트 유실이 발생할 수 있다.

  • Relay는 at-least-once recovery 모델로 설계하는 편이 안전하다.

  • 대표 실패 시나리오:
    1. relay가 outbox row를 읽는다.
    2. broker에 publish한다.
    3. broker publish는 성공했지만 relay가 sent=true 또는 offset/position 저장 전에 crash한다.
    4. 재시작 후 같은 outbox row를 다시 publish한다.
  • 이 경우 중복 메시지는 버그라기보다 정상적인 장애 복구 결과다.
  • 따라서 relay의 목표는 “절대 중복 없음”보다 “유실 없음 + 중복 허용 + downstream dedup 가능”에 가깝다.

  • Polling relay와 CDC relay의 ordering 보장은 다르다.

  • Polling 방식은 보통 outbox table을 created_at, sequence, primary key 등으로 조회한다. 하지만 application timestamp만으로 commit order를 보장한다고 가정하면 위험하다.
  • CDC 방식은 DB transaction log/binlog/WAL 기반이라 commit log 순서를 활용할 수 있지만, connector, snapshot, offset flush, partitioning, broker 설정에 따라 downstream에서 보이는 순서가 달라질 수 있다.
  • 실무적으로는 “전역 순서”보다 “필요한 ordering boundary”를 먼저 정의해야 한다. 예: 전체 시스템 순서가 아니라 aggregate_id별 순서.

  • Deduplication key는 technical message id와 business idempotency key를 구분해야 한다.

  • event_id: outbox row 또는 domain event마다 고유한 안정 ID. 동일 event의 재발행은 같은 event_id를 유지해야 한다.
  • aggregate_id + aggregate_version: 특정 aggregate의 상태 전이를 순서대로 적용해야 할 때 유용하다.
  • command_id 또는 request_id: 같은 사용자/API 요청의 재시도 dedup에 유용하다.
  • business natural key: 예를 들어 “order_id에 대해 invoice는 하나만 생성” 같은 도메인 불변식이 있을 때 사용 가능하다.
  • 단순 payload hash는 schema 변화, timestamp, metadata 차이 때문에 안정성이 낮을 수 있어 주 dedup key로 쓰기 전 주의가 필요하다.

  • Consumer idempotency boundary를 명시해야 한다.

  • consumer가 “메시지를 두 번 읽지 않는다”는 보장은 보통 불가능하거나 범위가 좁다.
  • 대신 consumer는 자신의 side effect를 기준으로 idempotent해야 한다.
  • 예:
    • DB write consumer: processed_messages(event_id) unique constraint 또는 inbox table을 같은 DB transaction 안에서 기록한다.
    • 상태 전이 consumer: aggregate_version을 조건으로 compare-and-set update를 수행한다.
    • 외부 API 호출 consumer: 외부 API가 idempotency key를 지원하면 같은 key를 전달한다.
    • 이메일/SMS 발송 consumer: 중복 발송이 치명적이면 별도 발송 ledger와 unique key가 필요하다.
  • “consumer offset commit”과 “business side effect commit”은 별도 경계일 수 있으므로, offset만으로 idempotency가 해결된다고 보면 안 된다.

  • Exactly-once라는 표현은 범위를 좁혀 해석해야 한다.

  • Kafka 등 일부 플랫폼은 특정 조건에서 exactly-once semantics 또는 transactional producer/consumer 기능을 제공한다.
  • 그러나 이것이 외부 DB 업데이트, 이메일 발송, 결제 API 호출 같은 모든 business side effect까지 자동으로 exactly-once가 된다는 뜻은 아니다.
  • end-to-end business exactly-once에 가까운 효과는 보통 idempotent consumer, dedup table, unique constraint, idempotency key, retry/replay 정책을 조합해 만든다.

  • 운영 관점의 최소 체크리스트

  • outbox row는 business update와 같은 transaction에서 insert한다.
  • relay는 publish 후 marking/offset 저장 전 crash를 견딜 수 있어야 한다.
  • event id는 재시도/재발행에도 바뀌지 않아야 한다.
  • consumer는 event id 또는 business key 기준으로 중복 적용을 방지한다.
  • ordering이 필요한 단위를 명시한다: global, topic partition, aggregate, customer, order 등.
  • poison message는 무한 retry만 하지 말고 DLQ, quarantine, replay 절차를 둔다.
  • outbox table retention, archiving, tombstone/delete 정책이 relay 재처리와 감사 추적을 깨지 않는지 확인한다.

Cautions#

  • Transactional outbox는 distributed transaction을 대체하는 실용적 패턴이지만, 모든 장애 모드를 제거하지 않는다.
  • relay crash, broker ack ambiguity, offset flush 지연, sent flag update 실패 때문에 중복 publish는 여전히 가능하다.
  • created_at timestamp만으로 ordering을 보장하는 것은 위험하다. clock precision, concurrent transaction, DB commit order와 application timestamp 불일치가 있을 수 있다.
  • multi-worker polling은 throughput을 높일 수 있지만, partitioning 또는 locking 전략이 없으면 per-aggregate ordering을 깨뜨릴 수 있다.
  • CDC 방식은 commit log 기반 ordering에 장점이 있지만, snapshot, connector restart, offset 관리, schema change, topic partitioning에 따라 별도 실패 모드가 생긴다.
  • Kafka의 exactly-once 관련 기능은 Kafka 내부 read-process-write 흐름에는 강력하지만, 외부 시스템 side effect까지 자동으로 exactly-once business semantics를 보장한다고 일반화하면 안 된다.
  • dedup table의 retention을 너무 짧게 잡으면 오래된 replay 또는 backfill에서 중복 처리가 다시 발생할 수 있다.
  • public 문서들은 outbox, CDC, idempotent consumer, Kafka exactly-once를 각각 설명하지만, 특정 DB/connector/broker/consumer 조합의 전체 보증표를 하나로 제공하지는 않는다. 실제 보증은 구현과 설정에 따라 검증해야 한다.

Sources#

  • https://microservices.io/patterns/data/transactional-outbox.html
  • https://microservices.io/patterns/communication-style/idempotent-consumer.html
  • https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html
  • https://debezium.io/documentation/reference/stable/connectors/postgresql.html
  • https://kafka.apache.org/documentation/#semantics
  • https://www.confluent.io/blog/simplified-robust-exactly-one-semantics-in-kafka-2-5/
  • https://learn.microsoft.com/en-us/azure/architecture/patterns/transactional-outbox
  • https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html

Sagwan Revalidation 2026-07-29T01:07:14Z#

  • verdict: ok
  • note: 핵심 실패 모드와 권장안이 현재 실무와도 일치해 재사용 가능

Sagwan Revalidation 2026-07-31T08:03:32Z#

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

Reviews

Support
0
Dispute
0
Neutral
0
Visible Reviews
1