최근 이벤트 기반 아키텍처를 공부하면서 Transactional Outbox Pattern에 대해 정리해보았다.
처음에는 단순히 "DB에 저장하고 Kafka에 이벤트를 보내면 되는 것 아닌가?"라고 생각했는데,
실제로는 이 두 작업을 하나의 트랜잭션처럼 안전하게 처리하기가 어렵다는 문제가 있었다.
이 글에서는 이 문제를 해결하기 위한 Transactional Outbox Pattern이 무엇이고, 왜 필요한지 정리해보려고 한다.
1. 먼저 문제가 되는 상황
주문이 생성되는 상황을 생각해보자.
주문 서비스에서는 다음 두 가지 작업을 해야 한다.
- DB에 주문을 저장한다.
- 다른 서비스에 주문 생성 이벤트를 전달한다.
예를 들어 Kafka를 사용한다면 다음과 같은 코드가 될 수 있다.
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
kafkaTemplate.send("order-created", order);
}
겉보기에는 간단해 보이지만 문제가 있다.
DB와 Kafka는 서로 다른 시스템이기 때문에 하나의 DB 트랜잭션으로 묶을 수 없다.
따라서 다음과 같은 상황이 발생할 수 있다.
DB 저장 성공
↓
Kafka 이벤트 발행 실패
그러면 DB에는 주문이 존재하지만 다른 서비스는 주문이 생성되었다는 사실을 알 수 없다.
반대의 경우도 가능하다.
Kafka 이벤트 발행 성공
↓
DB 저장 실패
이 경우에는 실제 주문은 존재하지 않는데 다른 서비스에서는 주문 생성 이벤트를 전달받게 된다.
이처럼 하나의 비즈니스 작업에서 서로 다른 시스템에 데이터를 기록해야 할 때 발생하는 문제를 흔히 Dual Write 문제라고 한다.
Transactional Outbox는 이러한 문제를 해결하기 위한 패턴이다.
2. Transactional Outbox란?
핵심 아이디어는 간단하다.
Kafka에 바로 이벤트를 발행하는 대신, 이벤트를 DB에 먼저 저장한다.
즉 주문 데이터와 이벤트 정보를 같은 DB 트랜잭션 안에서 함께 저장한다.
DB Transaction
┌────────────────────────────┐
│ │
│ 주문 저장 │
│ + │
│ Outbox 이벤트 저장 │
│ │
└────────────────────────────┘
↓
COMMIT
예를 들어 주문을 생성하면 DB에 다음과 같이 저장할 수 있다.
orders
id user_id status
-----------------------
101 10 PAID
그리고 별도의 Outbox 테이블에도 이벤트를 저장한다.
outbox
id event_type payload status
--------------------------------------------------
1 ORDER_CREATED {...} READY
중요한 것은 두 작업이 같은 DB 트랜잭션에 포함된다는 것이다.
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
outboxRepository.save(
new OutboxEvent(
"ORDER_CREATED",
order.getId()
)
);
}
주문 저장과 Outbox 저장 중 하나라도 실패하면 전체 트랜잭션이 롤백된다.
따라서 다음과 같은 상태를 피할 수 있다.
주문은 저장됐는데
이벤트가 없음
또는
이벤트는 있는데
주문이 없음
이것이 Transactional Outbox의 핵심이다.
3. 그런데 Outbox에 저장했다고 끝나는 것은 아니다
여기서 한 가지 의문이 생긴다.
"그럼 Outbox 테이블에 저장한 이벤트를 Kafka에는 누가 보내지?"
DB 트랜잭션이 끝난 뒤 별도의 Publisher 또는 Worker가 Outbox 테이블을 조회한다.
DB
┌───────────────┐
│ orders │
│ outbox │
└───────┬───────┘
│
│ 조회
▼
Outbox Worker
│
│ publish
▼
Kafka
예를 들어 Worker가 다음과 같은 Outbox 데이터를 발견했다고 하자.
id: 1
event_type: ORDER_CREATED
payload: {"orderId": 101}
status: READY
Worker는 이 데이터를 Kafka에 발행한다.
Outbox
│
│ ORDER_CREATED
▼
Kafka
이렇게 하면 주문을 생성하는 트랜잭션 안에서 Kafka까지 직접 호출할 필요가 없어진다.
4. 왜 이렇게 하면 문제가 해결될까?
가장 중요한 부분이다.
다시 처음의 문제를 생각해보자.
DB 저장과 이벤트 저장이 실패하는 경우
BEGIN TRANSACTION
주문 저장 성공
Outbox 저장 실패
ROLLBACK
두 데이터가 모두 롤백된다.
따라서 주문만 저장되고 이벤트가 사라지는 상황을 방지할 수 있다.
DB 저장과 Outbox 저장이 모두 성공하는 경우
BEGIN TRANSACTION
주문 저장 성공
Outbox 저장 성공
COMMIT
이제 DB에는 주문과 Outbox 이벤트가 모두 존재한다.
그 이후 애플리케이션이 장애가 발생하더라도 Outbox 데이터가 DB에 남아 있기 때문에 나중에 다시 발행할 수 있다.
즉 중요한 것은 이벤트를 Kafka에 바로 보내는 대신, DB 트랜잭션 안에서 먼저 안전하게 기록해두는 것이다.
5. Outbox Worker에서 장애가 발생하면?
여기서 또 다른 문제가 생긴다.
예를 들어 Worker가 Outbox 이벤트를 Kafka에 발행했다고 하자.
Outbox
↓
Kafka 발행 성공
↓
"발행 완료" 상태로 변경
그런데 Kafka 발행 직후 Worker가 장애가 발생하면 어떻게 될까?
Outbox 조회
↓
Kafka 발행 성공
↓
💥 Worker 장애
↓
발행 완료 상태 저장 실패
그러면 Worker가 다시 시작했을 때 해당 이벤트가 아직 처리되지 않은 것으로 판단하고 Kafka에 다시 발행할 수 있다.
결과적으로 같은 이벤트가 두 번 발행될 수 있다.
따라서 Transactional Outbox를 사용한다고 해서 이벤트가 정확히 한 번만 발행되는 것은 아니다.
오히려 일반적으로는 중복 발행 가능성을 고려하고 소비자가 멱등적으로 처리하도록 설계한다.
AWS의 Transactional Outbox 가이드에서도 Message Relay가 같은 메시지를 중복 발행할 수 있으므로 소비자가 이미 처리한 메시지를 식별하는 등의 멱등성 처리를 권장한다.
6. 그래서 이벤트에 ID를 둔다
이런 이유로 Outbox 이벤트에는 보통 고유한 ID를 둔다.
outbox
event_id event_type payload
-----------------------------------------
abc-123 ORDER_CREATED {...}
소비자는 이벤트 ID를 기준으로 이미 처리한 이벤트인지 확인할 수 있다.
이벤트 수신
↓
event_id = abc-123
↓
이미 처리했는가?
↙ ↘
YES NO
↓ ↓
무시 비즈니스 처리
↓
처리 기록 저장
이렇게 하면 동일한 이벤트가 여러 번 전달되더라도 실제 비즈니스 처리는 한 번만 수행하도록 만들 수 있다.
즉 Transactional Outbox에서는 중복 전달 가능성을 없애기보다, 중복되어도 안전하게 처리할 수 있도록 만드는 것이 중요하다.
7. Outbox 이벤트는 어떻게 Kafka로 전달할까?
Outbox 테이블에서 Kafka로 이벤트를 전달하는 방법은 여러 가지가 있다.
대표적으로 Polling 방식과 CDC(Change Data Capture) 방식이 있다.
Polling 방식
Worker가 주기적으로 Outbox 테이블을 조회한다.
Outbox
↑
│ 주기적으로 조회
│
Worker
│
↓
Kafka
예를 들어 일정 주기로 다음과 같은 쿼리를 실행할 수 있다.
SELECT *
FROM outbox
WHERE status = 'READY'
ORDER BY id;
조회한 이벤트를 Kafka에 발행한 뒤 처리 상태를 변경한다.
구현이 비교적 단순하다는 장점이 있지만, 주기적인 DB 조회가 필요하다.
CDC 방식
DB의 변경 로그를 이용해서 Outbox 테이블에 새로운 이벤트가 추가된 것을 감지하고 이를 메시지 브로커로 전달하는 방법도 있다.
DB
↓
Transaction Log
↓
CDC
↓
Kafka
MySQL의 binlog나 PostgreSQL의 WAL 등을 활용할 수 있다.
이 경우 애플리케이션이 Outbox 테이블을 직접 계속 polling하지 않고 데이터베이스의 변경 사항을 기반으로 이벤트를 전달할 수 있다.
8. Transactional Outbox의 핵심은 무엇일까?
처음에는 Outbox라는 이름 때문에
"이벤트를 밖으로 보내기 위해 저장해두는 테이블인가?"
라고 생각했는데, 조금 더 정확하게 이해하면 다음과 같다.
DB 변경과 이벤트 기록을 동일한 트랜잭션으로 묶어 데이터의 일관성을 확보하는 것이 핵심이다.
그리고 그 이후의 이벤트 발행은 별도의 프로세스가 담당한다.
하나의 DB Transaction
┌─────────────────────────────┐
│ │
│ 주문 저장 │
│ + │
│ Outbox 저장 │
│ │
└──────────────┬──────────────┘
│
COMMIT
│
▼
Outbox Worker
│
▼
Kafka
│
┌────────┴────────┐
▼ ▼
결제 서비스 알림 서비스
따라서 역할을 나누면 다음과 같다.
DB
→ 비즈니스 데이터 저장
Outbox
→ 발행해야 할 이벤트를 DB 트랜잭션 안에서 함께 저장
Outbox Worker / CDC
→ Outbox의 이벤트를 메시지 브로커로 전달
Kafka
→ 이벤트를 여러 소비자에게 전달하고 보존
Consumer
→ 이벤트를 받아 실제 비즈니스 처리
9. 정리
Transactional Outbox는 DB 변경과 메시지 발행 사이에서 발생하는 Dual Write 문제를 해결하기 위한 패턴이다.
DB와 Kafka를 하나의 트랜잭션으로 묶는 대신, 이벤트를 Outbox 테이블에 DB 데이터와 함께 저장한다.
기존 방식
DB 저장
+
Kafka 발행
→ 서로 다른 시스템이라 한 트랜잭션으로 묶기 어려움
Transactional Outbox
DB Transaction
┌─────────────────┐
│ DB 저장 │
│ Outbox 저장 │
└────────┬────────┘
│
COMMIT
↓
Outbox Worker
↓
Kafka
이 구조를 사용하면 DB 트랜잭션이 성공했는데 이벤트가 사라지는 문제를 줄일 수 있다.
다만 Transactional Outbox가 중복 발행까지 없애주는 것은 아니다.
메시지를 Kafka에 발행한 직후 Worker가 장애가 발생하면 같은 이벤트가 다시 발행될 수 있기 때문에, 소비자 측에서는 이벤트 ID 등을 이용한 멱등성 처리를 고려해야 한다.
결국 Transactional Outbox를 한 문장으로 정리하면,
"DB 변경과 함께 발행해야 할 이벤트를 Outbox에 같은 트랜잭션으로 저장하고, 별도의 프로세스가 이를 메시지 브로커로 전달하는 패턴"이라고 할 수 있다.
끗
'CS 정리' 카테고리의 다른 글
| 서킷 브레이커(Circuit Breaker)란? 외부 API가 죽었을 때 우리 서버까지 같이 죽지 않게 하기 (0) | 2026.08.23 |
|---|---|
| Transactional Outbox에서 여러 워커가 이벤트를 안전하게 선점하는 방법 (0) | 2026.08.22 |
| SSE(Server-Sent Events) 딥다이브 - EventSource부터 재연결, Last-Event-ID까지 (2) | 2026.08.20 |
| SSE와 Polling, 실시간 데이터 전달 방식 이해하기 (0) | 2026.08.19 |
| 트랜잭션 ACID (0) | 2025.09.09 |