안녕세계
[EDA] Transactional Outbox와 Inbox 본문
[EDA] Transactional Outbox와 Inbox
Junhong Kim 2025. 8. 23. 23:32반드시 보내고, 한 번만 반영하기
분산 시스템에서 한 요청에서 DB 데이터를 생성(또는 갱신)하고 Kafka로 메시지를 발행해야하는 dual-write 상황에서, 두 시스템 중 하나가 write에 실패하면 데이터 정합성이 깨질 수 있습니다. 또한, Kafka는 최소 한 번(at-least-once) 전달을 기본으로 하므로 동일한 메시지가 여러 번 도착할 수도 있습니다. 본 포스팅에서는 이러한 문제를 Transactional Outbox와 Inbox 패턴으로 메시지를 '반드시 보내고, 한 번만 반영'하는지 설명합니다.
Outbox Pattern 📮
Outbox의 사전적 의미: (발송 예정 이메일의) 임시 보관함
Outbox Pattern의 핵심은 같은 트랜잭션에서 '도메인 데이터 생성(또는 갱신) + outbox_event 테이블에 event 생성'을 함께 커밋하는 것입니다. 즉, 트랜잭션 커밋이 성공되는 순간에 '이 event는 반드시 전송되어야 한다'는 것을 outbox_event 테이블에 event를 기록합니다. outbox_event 테이블에 저장된 event는 이후 Polling Publisher(애플리케이션 스케줄러) 또는 CDC(Debezium)가 event 데이터를 읽어 Kafka 메시지를 발행합니다.
예를 들어, 출고요청이 들어오면 '출고 데이터를 생성하고, 재고할당은 비동기로 되어야한다'고 가정합니다. 이때, 같은 트랜잭션에서 도메인 데이터인 출고 데이터는 생성하고 재고할당 요청을 outbox_event 테이블에 event 데이터를 저장해둡니다. (outbox_event 테이블의 event 데이터는 consumer가 읽거나 수정하는 것이 아닌 publisher 처리해야합니다)
Outbox Event 처리 - Polling Publisher와 CDC(Debezium)
앞서 outbox_event 테이블에 저장된 event 데이터를, 실제로 Kafka로 메시지로 전송하는 방법은 크게 두 가지가 있습니다.
첫 번째는 Polling Publisher 방식입니다. 애플리케이션이 일정 주기로 outbox_event 테이블 데이터의 상태를 확인해 'NEW(신규)' 또는 재시도 시간이 도래한 'FAILED(실패됨)' 데이터를 가져와서 Kafka 메시지로 전송한 뒤 결과에 따라 해당 행을 'SENT(전송됨)' 또는 FAILED(실패됨)으로 갱신합니다. 이때 Publisher가 메시지를 보낸 후 SENT로 바꾸기 전에 예외가 발생하하면 같은 event가 한 번 더 발행될 수 있으므로, Kafka producer는 멱등 전송(acks=all, enable.idempotence=true)을 반드시 켜고, Kafka consumer는 멱등 처리(Inbox 패턴 등)로 중복 처리를 무효화해야 합니다. 또한 재시도 중 순서 역전 가능성을 줄이기 위해 max.in.flight.requests.per.connection은 5 이하로 두는 편이 안전합니다.
두 번째는 CDC(Debezium) 방식입니다. 애플리케이션은 비즈니스 트랜잭션에서 outbox_event 테이블에 INSERT만 수행하고, Debezium Outbox Router가 데이터베이스 변경 로그를 구독해 해당 이벤트를 즉시 Kafka로 발행합니다. 이 방식에서는 보통 'SENT(전송됨)', 'FAILED(실패됨)' 같은 상태 업데이트를 테이블에 남기지 않으며, 발행 여부와 실패는 Connector Metric, Offset, DLQ를 통해 관측합니다.

Inbox Pattern 📬
Inbox의 사전적 의미: 받은편지함(새로 수신된 이메일이 있는 곳)
producer를 통해 메시지가 보내졌다면, consumer는 DB 데이터를 생성(또는 갱신)할 때 멱등성이 지켜져야합니다. 멱등성을 지키기 위한 방법으로 이미 처리한 메시지를 기록하는 inbox_processed 테이블을 두고, 수신된 메시지가 이전에 처리된적이 있는지 inbox_processed 테이블의 데이터를 확인합니다. 그 다음에 Kafka consumer 오프셋을 커밋합니다. 오프셋을 먼저 커밋하면 장애 시 메시지가 다시 오지 않아 DB 반영이 유실될 수 있기 때문입니다. 반대로, 이 순서를 지키면 같은 메시지가 다시 와도 Inbox 조회로 즉시 건너뛰어 정합성이 유지됩니다.
*멱등성: 멱등한 작업의 결과는 한 번 수행하든 여러 번 수행하든 같아야 한다.
Dead Letter Queue
Outbox 패턴 대신에 DLQ(Dead Letter Queue)를 쓰고 싶다는 생각이 들 수 있지만, 두 가지는 역할이 다릅니다. DLQ는 본질적으로 컨슈머(또는 커넥터) 측 실패를 안전하게 격리하는 표준입니다. 역직렬화 오류, 권한 불일치처럼 재시도해도 의미가 없는(비재시도성) 예외는 즉시 DLQ로 보내고, 그 외 오류는 지수 백오프 재시도 후에도 실패할 때 DLQ로 격리합니다. 이렇게 하면 본 처리 흐름은 유지되고, 관리자는 DLQ가 발행된 원인 분석 후 재발행할 수 있습니다.
프로듀서 쪽은 우선순위가 다릅니다. Outbox 패턴 + 멱등 전송(idempotence) + 재시도(delivery.timeout.ms로 상한 관리)로 발행 보장과 이중쓰기 정합성을 확보하는 것이 기본으로 합니다. 다만 예외적으로, 프로듀서 단계에서도 스키마/직렬화/도메인 검증 실패처럼 ‘비재시도성 데이터 오류’가 발견되면, 원본 토픽의 흐름을 막지 않기 위해 별도 DLQ(카프카 내 다른 토픽 또는 외부 큐 (SQS))로 격리하는 패턴을 선택적으로 사용할 수 있습니다. 브로커/네트워크 같은 일시 장애는 DLQ 영역이 아니라 프로듀서 재시도/멱등 전송으로 복구해야 하는 영역입니다.
*정리하자면 DLQ는 consumer '실패 격리', Outbox Pattern은 producer '메시지 유실 방지'입니다. 프로듀서 DLQ는 표준은 아니지만, 비재시도성 데이터 오류 격리 용도로 선택할 수 있습니다.
요약
분산 환경에서 한 요청이 DB를 갱신하고 동시에 Kafka로 이벤트를 발행하면 정합성이 깨질 수 있고(at-least-once로 중복 수신도 발생 가능). 이를 막기 위해 Transactional Outbox로 도메인 변경과 outbox_event INSERT를 한 트랜잭션으로 커밋하고, 이후 Polling Publisher 또는 CDC(Debezium)로 발행합니다. Polling은 메시지 전송 직후 크래시로 중복 발행이 가능하므로 Kafka producer 멱등 전송을 켜야 합니다. 수신 측은 Inbox 패턴으로 멱등성을 확보합니다. 처리 전 inbox_processed 테이블에서 '이미 처리했는지' 확인하고, 도메인 변경 + 처리 이력 INSERT를 같은 트랜잭션으로 커밋한 뒤에 Kafka 오프셋을 커밋합니다. 재처리 실패 격리는 DLQ가 담당합니다. consumer는 역직렬화/권한 등 비재시도 예외는 즉시 DLQ, 그 외는 지수 백오프 재시도 후에도 실패 시 DLQ로 보내면 됩니다. 반면, producer에서 DLQ 사용은 비권장이며, producer는 Outbox + 멱등 전송 + 재시도로 유실 방지에 집중하는 것이 바람직합니다.
Outbox: 반드시 보내기, Inbox: 한 번만 반영, DLQ: consumer 실패 격리
참고
'Server > Architecture' 카테고리의 다른 글
| [헥사고날 아키텍처] 포트와 어댑터(Ports and Adapters) (1) | 2024.11.23 |
|---|---|
| [아키텍처 테스트] ArchUnit (4) | 2024.10.31 |