트랜잭셔널 아웃박스 패턴 실전 가이드: 분산 트랜잭션 없이 Exactly-Once 효과 만들기

결제는 성공했는데 메시지는 사라졌다
분산 시스템에서 가장 불편한 진실은 "데이터베이스 커밋"과 "메시지 발행"을 하나의 원자적 동작으로 묶기 어렵다는 점입니다. 주문 서비스가 결제를 승인하고 orders 테이블에 상태를 저장한 뒤, Kafka나 RabbitMQ로 OrderPaid 이벤트를 발행한다고 가정해봅시다. 평소에는 아무 문제 없이 동작합니다. 그런데 데이터베이스 커밋 직후 애플리케이션 프로세스가 죽거나, 메시지 브로커 연결이 순간적으로 끊기면 어떤 일이 벌어질까요? 주문은 이미 결제 완료 상태인데 배송 서비스와 정산 서비스는 그 사실을 영원히 모릅니다.
반대 상황도 위험합니다. 메시지를 먼저 발행하고 데이터베이스 커밋이 실패하면, downstream 서비스는 존재하지 않는 주문을 처리하게 됩니다. 이 문제를 해결하려고 2PC(Two-Phase Commit)를 떠올리지만, 실제 웹 서비스 운영에서 2PC는 대부분 너무 무겁습니다. 데이터베이스와 브로커가 모두 XA 트랜잭션을 지원해야 하고, 장애 시 coordinator 복구까지 책임져야 합니다. 그래서 많은 팀이 이벤트 기반 아키텍처를 도입하면서도 핵심 거래 흐름에서는 결국 동기 API 호출로 되돌아갑니다.
트랜잭셔널 아웃박스 패턴은 이 간극을 현실적으로 메우는 방법입니다. 메시지를 브로커에 직접 발행하지 않고, 비즈니스 데이터와 같은 데이터베이스 트랜잭션 안에 "발행해야 할 이벤트"를 outbox 테이블로 먼저 저장합니다. 커밋이 성공했다면 이벤트도 반드시 남아 있고, 커밋이 실패했다면 이벤트도 남지 않습니다. 이후 별도 relay worker가 outbox를 읽어 브로커로 발행합니다. 이 글에서는 outbox 테이블 설계, relay 워커, idempotency key, 중복 소비 방어, 운영 모니터링까지 실제 서비스에서 필요한 수준으로 정리합니다.
1. 문제의 본질: Dual Write는 언젠가 깨진다
Dual Write는 하나의 비즈니스 명령에서 두 개 이상의 외부 상태를 바꾸는 구조입니다. 주문 생성 API가 데이터베이스에 주문을 저장하고, 동시에 메시지 브로커에 이벤트를 발행하는 것이 대표적입니다. 코드로 보면 단순합니다. insert order, publish event, return 200. 하지만 두 작업 사이에는 항상 실패 지점이 존재합니다.
첫 번째 실패 지점은 데이터베이스 커밋 이후 메시지 발행 전입니다. 이 경우 데이터베이스에는 성공 기록이 남지만 이벤트는 발행되지 않습니다. 재시도 로직을 넣어도 프로세스가 죽으면 메모리에 있던 재시도 작업은 사라집니다. 두 번째 실패 지점은 메시지 발행 이후 데이터베이스 커밋 전입니다. 이벤트는 외부로 나갔지만 정작 원본 데이터는 롤백될 수 있습니다. 세 번째 실패 지점은 메시지 발행 성공 응답을 받지 못한 경우입니다. 브로커는 메시지를 받았지만 네트워크 타임아웃으로 애플리케이션은 실패로 판단하고 다시 발행합니다. 이때 downstream은 같은 이벤트를 두 번 받습니다.
이 문제는 라이브러리나 try-catch로 완전히 해결되지 않습니다. 네트워크와 프로세스 장애는 애플리케이션 코드 바깥에서 발생하기 때문입니다. 핵심은 "실패해도 복구 가능한 상태를 디스크에 남기는 것"입니다. 트랜잭셔널 아웃박스는 이벤트 발행 의도를 데이터베이스에 영속화하여, 이후 어느 시점에든 다시 이어서 처리할 수 있게 만듭니다.
2. Outbox 테이블 설계: 이벤트 발행 의도를 저장한다
아웃박스 테이블은 단순한 큐가 아닙니다. 비즈니스 트랜잭션의 일부이며, 장애 복구 로그이자 운영 관찰 대상입니다. 최소 컬럼은 다음과 같습니다.
| 컬럼 | 역할 |
|---|---|
id | 이벤트 고유 ID. UUID 또는 ULID를 사용합니다. |
aggregate_type | order, payment, shipment처럼 이벤트가 속한 도메인 타입입니다. |
aggregate_id | 주문 ID, 결제 ID 같은 비즈니스 식별자입니다. |
event_type | OrderPaid, PaymentFailed 같은 이벤트 이름입니다. |
payload | 브로커로 보낼 JSON 페이로드입니다. |
idempotency_key | downstream 중복 처리를 막는 비즈니스 키입니다. |
status | pending, published, failed 등 발행 상태입니다. |
retry_count | 발행 재시도 횟수입니다. |
next_retry_at | 백오프 이후 다시 시도할 시각입니다. |
created_at, published_at | 지연 시간과 장애 구간을 추적합니다. |
중요한 점은 outbox 레코드를 비즈니스 변경과 같은 트랜잭션에서 넣는다는 것입니다. 주문 상태를 PAID로 바꾸는 SQL과 OrderPaid outbox insert가 하나의 commit으로 묶여야 합니다. 이 구조에서는 "주문은 결제 완료인데 이벤트가 없다"는 상태가 발생하지 않습니다. 커밋이 성공하면 둘 다 있고, 실패하면 둘 다 없습니다.
페이로드에는 downstream이 처리에 필요한 최소 데이터를 넣습니다. 모든 주문 스냅샷을 이벤트에 넣으면 이벤트 크기가 커지고 개인정보 노출 범위가 넓어집니다. 반대로 ID만 넣으면 downstream이 다시 원본 서비스를 호출해야 해서 결합도가 높아집니다. 실무에서는 이벤트 성격에 따라 두 가지를 섞습니다. 결제 완료처럼 상태 전이가 중요한 이벤트는 금액, 통화, 결제 시각, 주문 ID 정도를 포함하고, 배송지 전체나 사용자 프로필처럼 민감하고 변동이 큰 정보는 별도 조회로 분리합니다.
3. Relay Worker: 큐처럼 읽되 데이터베이스답게 잠근다
Relay worker는 pending 상태의 outbox 레코드를 읽어 브로커로 발행하고, 성공하면 published로 바꿉니다. 여러 워커를 병렬로 띄울 수 있어야 하므로 행 잠금 전략이 필요합니다. PostgreSQL이라면 FOR UPDATE SKIP LOCKED 패턴이 가장 흔합니다. 한 워커가 잡은 행은 다른 워커가 건너뛰므로, 여러 인스턴스가 동시에 같은 이벤트를 발행할 가능성을 줄일 수 있습니다.
하지만 여기서 "줄일 수 있다"는 표현이 중요합니다. 워커가 브로커에 메시지를 발행한 직후, outbox 상태를 published로 업데이트하기 전에 죽을 수 있습니다. 이 경우 재시작한 워커는 같은 outbox 레코드를 다시 읽고 같은 메시지를 다시 발행합니다. 따라서 outbox는 "at-least-once 발행"을 제공한다고 봐야 합니다. exactly-once 발행을 보장한다고 생각하는 순간 설계가 위험해집니다.
실제 목표는 "exactly-once delivery"가 아니라 "exactly-once effect"입니다. 메시지가 두 번 전달될 수는 있지만, 비즈니스 결과는 한 번만 반영되도록 만드는 것입니다. 이를 위해 producer 쪽에는 안정적인 이벤트 ID와 idempotency key가 필요하고, consumer 쪽에는 처리 이력 테이블이나 유니크 제약이 필요합니다.
Relay의 재시도 정책은 단순해야 합니다. 브로커 일시 장애, 네트워크 타임아웃, 인증 토큰 만료처럼 회복 가능한 오류는 exponential backoff로 재시도합니다. 페이로드 스키마 오류처럼 재시도해도 성공하지 않을 오류는 별도 dead 상태로 보내고 알림을 발생시킵니다. 무한 재시도는 장애를 감춥니다. 재시도 횟수, 마지막 오류 메시지, 다음 재시도 시각을 운영자가 볼 수 있어야 합니다.
4. Idempotency Key: 중복 이벤트를 비즈니스 관점에서 접는다
이벤트 ID는 메시지 단위의 고유 식별자입니다. 반면 idempotency key는 비즈니스 효과 단위의 고유 식별자입니다. 둘은 비슷해 보이지만 목적이 다릅니다. 예를 들어 OrderPaid 이벤트가 네트워크 문제로 두 번 발행되면 이벤트 ID는 같을 수도 있고 다를 수도 있습니다. 하지만 downstream 정산 서비스가 보아야 할 idempotency key는 settlement:order:{orderId}:payment:{paymentId}처럼 동일해야 합니다.
Consumer는 메시지를 처리하기 전에 processed_messages 같은 테이블에 idempotency key를 insert합니다. 이 컬럼에 유니크 인덱스를 걸어두면, 이미 처리한 메시지는 insert 단계에서 충돌합니다. 충돌이 발생하면 비즈니스 로직을 다시 실행하지 않고 ack를 반환합니다. 이 방식은 단순하지만 강력합니다. 중복 배송, 중복 포인트 적립, 중복 이메일 발송 같은 사고를 상당 부분 막아줍니다.
주의할 점은 "읽고 나서 쓰기"가 아니라 "쓰기부터 시도"해야 한다는 것입니다. 먼저 select processed where key = ?로 확인하고 없으면 처리하는 방식은 동시성 경쟁에 취약합니다. 두 consumer가 동시에 같은 메시지를 받으면 둘 다 없음으로 판단한 뒤 비즈니스 로직을 실행할 수 있습니다. 유니크 제약을 가진 insert를 트랜잭션의 첫 단계로 두는 것이 더 안전합니다.
Idempotency key의 보존 기간도 설계해야 합니다. 결제나 정산처럼 절대 중복되면 안 되는 도메인은 장기 보존이 맞습니다. 이메일 알림처럼 며칠 뒤 중복이 큰 문제가 아닌 도메인은 TTL을 둘 수 있습니다. 다만 TTL을 너무 짧게 잡으면 지연 재시도 이벤트가 다시 도착했을 때 중복 처리가 뚫립니다. outbox의 최대 재시도 기간과 브로커의 retention 기간보다 길게 잡는 것이 기본입니다.
5. 순서 보장: 모든 이벤트에 전역 순서를 기대하지 않는다
아웃박스를 도입하면 이벤트가 안정적으로 발행되지만, 순서 문제는 여전히 남습니다. 같은 주문에 대해 OrderCreated, OrderPaid, OrderCancelled가 발생할 수 있습니다. Consumer가 이 이벤트를 순서대로 받지 못하면 상태가 꼬일 수 있습니다.
해결책은 전역 순서가 아니라 aggregate 단위 순서입니다. 같은 주문 ID에 속한 이벤트는 같은 파티션 키로 보내고, 이벤트에 version을 포함합니다. Consumer는 현재 알고 있는 version보다 낮거나 같은 이벤트를 무시합니다. version이 하나 건너뛰면 잠시 보류하거나 원본 서비스를 조회해 상태를 보정합니다. 모든 이벤트에 완벽한 순서를 강제하려고 단일 파티션을 쓰면 처리량이 급격히 떨어집니다. 순서가 필요한 단위를 좁히는 것이 설계의 핵심입니다.
상태 전이 이벤트와 알림 이벤트도 분리하는 편이 좋습니다. 주문 상태를 바꾸는 이벤트는 순서와 멱등성이 중요합니다. 반면 "고객에게 푸시 알림 보내기"는 순서보다 중복 방지가 중요합니다. 같은 브로커를 쓰더라도 토픽과 consumer group을 분리하면 장애 격리가 쉬워집니다.
6. 운영 모니터링: Outbox는 조용히 쌓이면 장애다
아웃박스 패턴을 적용한 뒤 가장 흔한 운영 실수는 테이블이 큐처럼 쌓이는 것을 늦게 알아차리는 것입니다. Relay worker가 멈추면 사용자 API는 계속 성공합니다. 비즈니스 데이터와 outbox insert는 정상 커밋되기 때문입니다. 겉으로 보기에는 서비스가 멀쩡하지만 downstream 이벤트는 지연되고 있습니다.
반드시 봐야 할 지표는 네 가지입니다. 첫째, pending 레코드 수입니다. 평소 기준선을 벗어나면 relay 처리량이나 브로커 상태를 확인해야 합니다. 둘째, 가장 오래된 pending 레코드의 나이입니다. 이것은 이벤트 지연 시간의 상한입니다. 셋째, retry_count 분포입니다. 특정 이벤트 타입만 재시도가 늘면 스키마 호환성이나 downstream 장애를 의심할 수 있습니다. 넷째, dead letter 건수입니다. 이 값은 0에 가까워야 하며, 발생하면 사람이 개입해야 합니다.
테이블 관리도 중요합니다. published 레코드를 영원히 보관하면 outbox 테이블과 인덱스가 계속 커집니다. 보통 최근 며칠 또는 몇 주만 운영 조회용으로 남기고, 오래된 레코드는 아카이브 테이블이나 오브젝트 스토리지로 이동합니다. 파티셔닝을 적용하면 날짜 단위로 삭제가 쉬워집니다. 단, idempotency 판단에 필요한 consumer의 처리 이력과 producer outbox 보존 정책을 혼동하면 안 됩니다. outbox는 발행 로그이고, processed message 테이블은 소비 멱등성 로그입니다.
7. 도입 체크리스트
- 비즈니스 데이터 변경과 outbox insert가 같은 데이터베이스 트랜잭션 안에 있는가
- 이벤트 ID와 idempotency key를 구분해서 설계했는가
- Consumer가 유니크 제약 기반으로 중복 처리를 막는가
- Relay worker가
SKIP LOCKED또는 동등한 방식으로 병렬 처리 가능한가 - 브로커 발행 성공 후 상태 업데이트 전 장애가 나도 안전한가
- pending count, oldest pending age, retry count, dead letter 지표를 알림으로 연결했는가
- published 레코드 정리와 아카이브 정책이 있는가
- aggregate 단위 순서 보장과 version 처리 규칙이 문서화되어 있는가
결론: 분산 트랜잭션을 피하되 실패를 숨기지 않는다
트랜잭셔널 아웃박스는 마법이 아닙니다. 메시지를 정확히 한 번 전달해주지도 않고, consumer의 멱등성 설계를 대신해주지도 않습니다. 대신 가장 중요한 약속을 지킵니다. 비즈니스 변경이 커밋됐다면 이벤트 발행 의도도 반드시 남고, 실패했다면 둘 다 남지 않습니다. 이 작은 보장이 운영에서 엄청난 차이를 만듭니다.
분산 시스템의 신뢰성은 실패가 없어서 생기는 것이 아니라, 실패 이후 이어서 처리할 수 있는 상태를 남길 때 생깁니다. Outbox는 그 상태를 데이터베이스 안에 명시적으로 남기는 패턴입니다. 여기에 idempotency key, consumer 처리 이력, 재시도 지표, dead letter 운영 절차가 붙으면 "한 번만 실행되는 것처럼 보이는" 안정적인 비즈니스 효과를 만들 수 있습니다. 결제, 정산, 배송, 포인트처럼 돈과 상태가 얽힌 도메인이라면, 이벤트 기반 아키텍처의 첫 번째 안전장치로 검토할 만합니다.