티스토리 뷰
분산 트랜잭션에서 이벤트 기반 아키텍처로 전환하기 3편 : Transactional Outbox 패턴 - DB 변경과 메시지 발행을 원자적으로 처리하는 방법
ebson 2026. 3. 11. 22:022편에서 Saga 패턴의 서비스 간 통신이 왜 비동기 메시징으로 설계되는지 살펴보았습니다. 비동기 메시징은 시간적 결합을 제거하고 장애 전파를 차단하여 Saga를 안정적으로 진행할 수 있게 합니다. 그런데 비동기 메시징을 도입하면 곧바로 마주치는 문제가 있습니다. 서비스가 자신의 데이터베이스를 업데이트하고 메시지 브로커에 이벤트를 발행하는 두 가지 작업을, 어떻게 원자적으로 처리할 것인가의 문제입니다.
이 글에서는 이 문제의 본질인 이중 쓰기 문제(Dual Write Problem)를 정의하고, 이를 해결하는 Transactional Outbox 패턴의 구조와 Message Relay 구현 전략을 정리합니다.
이중 쓰기 문제
Saga의 각 단계에서 서비스는 두 가지 작업을 수행해야 합니다. 자신의 데이터베이스에 비즈니스 데이터를 저장하는 것과, 다음 서비스가 반응할 수 있도록 메시지 브로커에 이벤트를 발행하는 것입니다. 문제는 데이터베이스와 메시지 브로커가 서로 다른 인프라라는 점입니다.
데이터베이스 커밋이 성공한 후 메시지 발행이 실패하는 시나리오를 생각해 봅니다. 주문 서비스가 주문 상태를 "결제 대기"로 업데이트했지만, 결제 서비스에 이벤트를 전달하지 못합니다. 데이터베이스에는 변경이 반영되었는데 다음 단계로의 진행이 누락되는 것입니다. 반대의 경우도 마찬가지입니다. 메시지는 발행되었는데 데이터베이스 커밋이 실패하면, 아직 확정되지 않은 변경에 대한 이벤트가 다음 서비스로 전파됩니다.
Microservices.io의 Transactional Outbox 문서에서는 이 문제를 다음과 같이 설명합니다.
"a service must atomically update its database and publish a message/event. It cannot use the traditional mechanism of a distributed transaction that spans the database and the message broker."
데이터베이스 업데이트와 메시지 발행을 원자적으로 처리해야 하지만, 데이터베이스와 메시지 브로커에 걸친 전통적인 분산 트랜잭션은 사용할 수 없다는 것입니다. 1편에서 살펴본 것처럼 2PC는 MSA 환경에서 가용성과 확장성을 심각하게 제약하며, 메시지 브로커가 XA 트랜잭션을 지원하지 않는 경우도 많습니다.
이것이 이중 쓰기 문제(Dual Write Problem)의 본질입니다. 두 개의 서로 다른 시스템에 대한 쓰기를 하나의 원자적 단위로 묶을 수 없는 상황에서, 어떻게 둘 사이의 정합성을 보장할 것인가. 같은 문서에서도 이 문제의 맥락을 명확히 합니다.
"The Saga and Domain event patterns create the need for this pattern."
Saga 패턴과 도메인 이벤트 패턴이 이중 쓰기 문제를 필연적으로 발생시킨다는 것입니다. 비동기 메시징을 Saga의 통신 방식으로 채택하는 순간, 이중 쓰기 문제는 반드시 해결해야 하는 과제가 됩니다.
Outbox 패턴의 구조
Transactional Outbox 패턴은 이 문제를 우아하게 해결합니다. 핵심 아이디어는 단순합니다. 메시지 브로커에 직접 발행하는 대신, 발행할 메시지를 데이터베이스의 Outbox 테이블에 함께 저장하는 것입니다.
Microservices.io에서는 이 패턴의 동작을 다음과 같이 설명합니다.
"write the message/event to a database OUTBOX table as part of the transaction that updates business objects"
비즈니스 데이터 변경과 Outbox 테이블에 대한 메시지 쓰기를 동일한 로컬 트랜잭션으로 처리합니다. 둘 다 같은 데이터베이스에 대한 쓰기이므로 로컬 ACID 트랜잭션으로 원자성이 보장됩니다. 비즈니스 데이터가 커밋되면 메시지도 반드시 커밋되고, 롤백되면 메시지도 함께 롤백됩니다.
관계형 데이터베이스를 사용하는 경우, Outbox 테이블은 메시지의 목적지(토픽), 페이로드, 생성 시각 등을 저장하는 별도의 테이블로 구성됩니다. 서비스의 비즈니스 로직은 데이터를 변경하는 SQL과 Outbox 테이블에 메시지를 삽입하는 SQL을 하나의 트랜잭션 안에서 실행합니다. NoSQL 데이터베이스의 경우에는 비즈니스 엔티티의 도큐먼트에 발행할 이벤트를 속성으로 포함시키는 방식으로 동일한 원자성을 확보할 수 있습니다.
이 구조에서 Outbox 테이블은 메시지 브로커로의 발행을 보장하는 중간 저장소 역할을 합니다. 데이터베이스 트랜잭션이 커밋된 이상, Outbox에 저장된 메시지는 언젠가 반드시 메시지 브로커에 발행될 것이라는 보장이 생기는 것입니다. 실제 메시지 브로커로의 발행은 별도의 프로세스인 Message Relay가 담당합니다.
Message Relay - Outbox에서 브로커로
Outbox 테이블에 메시지가 저장되었다면, 이를 읽어서 메시지 브로커에 실제로 발행하는 역할이 필요합니다. 이 역할을 수행하는 것이 Message Relay입니다. Message Relay는 Outbox 테이블에 쌓인 미발행 메시지를 감지하고, 이를 메시지 브로커(예: Kafka)에 발행한 뒤, 해당 메시지를 발행 완료로 표시하거나 삭제합니다.
Message Relay의 구현에는 크게 두 가지 전략이 있습니다. Polling Publisher와 Transaction Log Tailing입니다. 각 전략은 Outbox 테이블의 변경을 감지하는 방식에서 근본적인 차이가 있으며, 이 차이가 성능, 운영 복잡도, 적용 가능한 데이터베이스 범위를 결정합니다.
Polling Publisher
Polling Publisher는 이름 그대로 Outbox 테이블을 주기적으로 폴링하여 미발행 메시지를 조회하고 발행하는 방식입니다. 일정 간격으로 SELECT 쿼리를 실행하여 아직 발행되지 않은 메시지를 가져오고, 메시지 브로커에 발행한 뒤 해당 레코드를 발행 완료로 업데이트하거나 삭제합니다.
Microservices.io의 Polling Publisher 문서에서는 이 방식의 장점으로 "Works with any SQL database"를 꼽습니다. 관계형 데이터베이스라면 별도의 인프라 없이 SQL 쿼리만으로 구현할 수 있으므로, 도입 장벽이 낮고 구현이 직관적입니다.
반면 폴링 주기에 따른 트레이드오프가 존재합니다. 폴링 간격을 짧게 설정하면 메시지 발행 지연은 줄어들지만 데이터베이스에 대한 부하가 증가합니다. 간격을 길게 설정하면 부하는 줄지만 이벤트 전파가 지연됩니다. 또한 폴링 시점에 여러 메시지가 동시에 조회될 경우, 발행 순서를 정확히 유지하는 것이 까다로울 수 있다는 점도 Microservices.io에서 언급하는 단점입니다. NoSQL 데이터베이스의 경우 쿼리 기능의 제약으로 적용이 어려울 수 있다는 점도 고려해야 합니다.
그럼에도 Polling Publisher는 구현의 단순함이라는 강력한 장점을 갖고 있습니다. 특별한 인프라 의존성 없이 애플리케이션 레벨에서 구현할 수 있으므로, 메시지 발행 지연에 대한 요구사항이 엄격하지 않은 시스템이나 초기 도입 단계에서 실용적인 선택이 될 수 있습니다.
Transaction Log Tailing
Transaction Log Tailing은 데이터베이스의 트랜잭션 로그를 직접 읽어 Outbox 테이블의 변경을 감지하는 방식입니다. 관계형 데이터베이스는 모든 변경 사항을 트랜잭션 로그에 기록합니다. MySQL의 binlog, PostgreSQL의 WAL(Write-Ahead Log)이 대표적입니다. Transaction Log Tailing은 이 로그를 실시간으로 추적하여 Outbox 테이블에 새 레코드가 삽입되면 이를 감지하고 메시지 브로커에 발행합니다.
Microservices.io의 Transaction Log Tailing 문서에서는 이 방식의 장점으로 "No 2PC"와 "Guaranteed to be accurate"를 꼽습니다. 트랜잭션 로그는 데이터베이스가 커밋한 변경 사항만을 포함하므로, 커밋되지 않은 변경이 발행되는 일이 구조적으로 불가능합니다. 폴링과 달리 변경이 발생하는 즉시 감지할 수 있어 이벤트 전파 지연도 최소화됩니다.
이 방식을 구현하는 대표적인 도구가 Debezium입니다. Debezium은 Change Data Capture(CDC) 플랫폼으로, MySQL binlog나 PostgreSQL WAL을 읽어 변경 이벤트를 Kafka 토픽으로 발행합니다. AWS 환경에서는 DynamoDB Streams이 유사한 역할을 수행합니다.
다만 Transaction Log Tailing은 데이터베이스별로 트랜잭션 로그의 형식과 접근 방식이 다르므로 데이터베이스 종속적인 구현이 필요합니다. Debezium 같은 CDC 도구를 운영하기 위한 추가 인프라(예: Kafka Connect 클러스터)도 필요합니다. 또한 Polling Publisher와 마찬가지로 메시지가 중복 발행될 가능성이 있어, 소비자 측에서의 중복 처리 대비가 필요합니다.
어떤 전략을 선택할 것인가
두 전략의 선택은 시스템의 요구사항과 운영 환경에 따라 달라집니다. Polling Publisher는 추가 인프라 없이 SQL 데이터베이스만으로 구현할 수 있어 도입이 간편합니다. 이벤트 전파에 수초 수준의 지연이 허용되고, 초기 도입 단계에서 빠르게 Outbox 패턴을 적용하고 싶다면 합리적인 선택입니다.
Transaction Log Tailing은 실시간에 가까운 이벤트 전파가 필요하고, 대량의 메시지를 처리해야 하며, CDC 인프라를 운영할 여력이 있는 환경에 적합합니다. 데이터베이스 폴링으로 인한 부하 없이 변경을 즉시 감지하므로, 고처리량 시스템에서 성능상의 이점이 있습니다.
실무에서는 Polling Publisher로 시작하여 시스템이 성장하면서 Transaction Log Tailing으로 전환하는 점진적 접근도 가능합니다. Outbox 테이블의 구조는 동일하게 유지되고 Message Relay의 구현만 교체하면 되므로, 이러한 전환이 비교적 자연스럽습니다.
Orchestration과 Choreography에서의 Outbox
Outbox 패턴은 Saga의 구현 방식과 무관하게 적용되지만, Orchestration과 Choreography에서 Outbox가 사용되는 구조에는 차이가 있습니다. Orchestration 방식에서는 중앙의 Orchestrator가 각 서비스에 명령(Command)을 보내고, 서비스는 결과를 응답합니다. 이 구조에서 Orchestrator는 다음 서비스에 보낼 명령을 Outbox 테이블에 기록하고, Message Relay가 이를 Kafka로 발행합니다. 각 참여 서비스도 작업 결과를 Orchestrator에 전달하기 위해 동일한 방식으로 Outbox에 응답 메시지를 기록합니다. 명령과 응답 모두 Outbox를 경유하므로, 양방향 통신에서 이중 쓰기 문제가 해결됩니다.
Choreography 방식에서는 각 서비스가 독립적으로 도메인 이벤트를 발행합니다. 주문 서비스가 OrderPlaced 이벤트를 자신의 Outbox에 기록하면, 주문 서비스의 Message Relay가 이를 Kafka로 발행합니다. 재고 서비스가 이를 수신하여 재고를 차감하고, InventoryReserved 이벤트를 재고 서비스의 Outbox에 기록합니다. 각 서비스가 자체 Outbox와 Message Relay를 독립적으로 운영하는 구조입니다. 중앙 조율자가 없으므로 각 서비스가 자신의 이벤트 발행에 대한 원자성을 스스로 보장해야 합니다.
어떤 방식이든 핵심은 동일합니다. 비즈니스 데이터 변경과 메시지 기록을 하나의 로컬 트랜잭션으로 묶고, 실제 발행은 Message Relay에
위임하는 것입니다.
메시지 순서 보장과 최소 1회 발행
Outbox 패턴을 적용할 때 두 가지 중요한 특성을 이해해야 합니다.
첫째, 메시지의 순서 보장입니다. Microservices.io에서는 이를 명시적으로 언급합니다.
"Messages must be sent to the message broker in the order they were sent by the service."
서비스가 발행한 순서대로 메시지가 브로커에 전달되어야 한다는 것입니다. 예를 들어 하나의 주문에 대해 "생성 → 결제 완료 → 배송 시작" 순서로 이벤트가 발생했다면, 메시지 브로커에도 이 순서가 유지되어야 합니다. 순서가 뒤바뀌면 수신 서비스에서 잘못된 상태 전이가 발생할 수 있습니다. Message Relay를 구현할 때 Outbox 테이블의 레코드를 생성 시각 또는 시퀀스 순서대로 발행하도록 설계해야 하는 이유입니다. Kafka를 사용하는 경우, 동일한 집계(Aggregate)에 대한 메시지를 같은 파티션으로 라우팅하면 파티션 내 순서 보장을 활용할 수 있습니다.
둘째, Message Relay의 최소 1회 발행(at-least-once delivery) 특성입니다. Message Relay가 메시지를 브로커에 발행한 직후, 해당 메시지를 발행 완료로 표시하기 전에 장애가 발생하면 어떻게 될까요? 재시작 후 같은 메시지를 다시 발행하게 됩니다. 즉, 메시지가 중복 발행될 수 있습니다.
이는 Outbox 패턴의 구조적 특성입니다. 정확히 1회 발행(exactly-once)을 보장하려면 브로커 발행과 Outbox 상태 업데이트를 원자적으로 처리해야 하는데, 이는 다시 이중 쓰기 문제로 귀결됩니다. 따라서 Outbox 패턴은 최소 1회 발행을 보장하되, 중복 처리의 책임을 수신 측에 위임합니다. 메시지를 수신하는 서비스에서 멱등성(Idempotency)을 보장하는 설계가 필수적인 이유입니다. 이 주제는 다음 편에서 재시도와 에러핸들링을 다루며 깊이 있게 살펴보겠습니다.
정리
Outbox 패턴은 비동기 메시징 기반 Saga에서 필연적으로 발생하는 이중 쓰기 문제를 해결하는 패턴입니다. 메시지 브로커에 직접 발행하는 대신 비즈니스 데이터와 함께 Outbox 테이블에 메시지를 기록하여 로컬 트랜잭션의 원자성을 활용합니다. 실제 발행은 Message Relay가 담당하며, Polling Publisher와 Transaction Log Tailing이라는 두 가지 구현 전략이 있습니다.
Polling Publisher는 SQL 쿼리 기반으로 도입이 간편하지만 폴링 주기에 따른 지연과 부하 트레이드오프가 있고, Transaction Log Tailing은 데이터베이스의 트랜잭션 로그를 직접 읽어 실시간에 가까운 감지가 가능하지만 CDC 인프라 운영이 필요합니다.
Outbox 패턴은 메시지의 순서 보장을 요구하며, 구조적으로 최소 1회 발행 특성을 갖습니다. 이는 수신 서비스에서의 멱등성 보장이 전제되어야 한다는 것을 의미합니다. 다음 편에서는 Saga의 실패 시나리오에 대한 복구 전략과 재시도, 멱등성, 실패 격리 등 에러핸들링의 구체적인 설계를 다루겠습니다.
References
- Chris Richardson, Microservices Patterns (Manning, 2018) — Chapter 4: Managing transactions with sagas
- Microservices.io — Transactional Outbox: https://microservices.io/patterns/data/transactional-outbox.html
- Microservices.io — Polling Publisher: https://microservices.io/patterns/data/polling-publisher.html
- Microservices.io — Transaction Log Tailing: https://microservices.io/patterns/data/transaction-log-tailing.html
'STUDY' 카테고리의 다른 글
- Total
- Today
- Yesterday
- Java Performance
- 동시성처리
- Redis vs DB
- Redis 성능 개선
- 백엔드 아키텍처
- TTL 설계
- DB 인덱스 성능
- 백엔드 성능
- Initialization-on-Demand Holder Idiom
- Eager Initialization
- 캐시와 인덱스
- spring batch 5
- Cache Aside
- 캐시 성능 비교
- Redis 캐시 전략
- 트래픽 처리
- Spring Batch
- Cache Penetration
- 스레드 생명주기
- 캐시 장애
- DB 트랜잭션
- Cache Avalanche
- Hot Key 문제
- 백엔드 성능 설계
- Enum 기반 싱글톤
- 트랜잭션 관리
- Double-Checked Locking
- InterruptedException
- 백엔드 성능 튜닝
- mybatis
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |

