Outbox Pattern으로 DB 저장과 메시지 발행의 원자성 문제 해결하기
분산 시스템을 개발하다 보면 한 가지 골치 아픈 상황에 부딪히게 됩니다. 데이터베이스에 데이터를 저장한 뒤 Kafka나 RabbitMQ 같은 메시지 브로커에 이벤트를 발행해야 할 때, 둘 중 하나만 성공하고 나머지가 실패하면 어떻게 될까요? DB 저장은 성공했지만 메시지 발행이 실패하면 다른 서비스는 이 변경 사실을 영영 알 수 없고, 반대로 메시지는 발행됐지만 DB 저장이 롤백되면 존재하지 않는 데이터에 대한 이벤트가 떠돌게 됩니다.
Outbox Pattern은 바로 이 문제, 즉 DB 저장과 메시지 발행의 원자성을 보장하기 위해 고안된 패턴입니다.
왜 원자성 문제가 발생하는가
DB 트랜잭션과 메시지 브로커는 본질적으로 서로 다른 시스템입니다. 관계형 데이터베이스는 ACID 트랜잭션을 지원하지만, 대부분의 메시지 브로커는 DB 트랜잭션 범위 안에 포함될 수 없습니다. 따라서 아래와 같은 코드 흐름은 언뜻 자연스러워 보이지만 실제로는 안전하지 않습니다.
// 위험한 패턴 예시
transaction.begin();
orderRepository.save(order);
transaction.commit();
messageBroker.publish("order.created", orderEvent); // 여기서 실패하면?
트랜잭션이 커밋된 후 메시지 발행 전에 애플리케이션이 크래시되거나 네트워크 오류가 발생하면, DB에는 주문이 저장됐지만 다른 서비스는 이를 알 수 없습니다. 반대로 트랜잭션 내부에서 메시지를 먼저 발행하려 해도, 트랜잭션이 롤백되면 이미 발행된 메시지는 회수할 방법이 없습니다.
이처럼 두 시스템에 걸친 작업을 하나의 원자적 단위로 처리하는 것이 분산 환경에서의 핵심 난제입니다.
Outbox Pattern의 핵심 아이디어
Outbox Pattern의 핵심은 단순합니다. 메시지를 바로 브로커에 발행하는 대신, 같은 DB 트랜잭션 안에 outbox 테이블에 함께 저장하는 것입니다. 그런 다음 별도의 프로세스가 outbox 테이블을 주기적으로 읽어 메시지 브로커에 실제로 발행합니다.
이렇게 하면 비즈니스 데이터 저장과 메시지 기록이 하나의 DB 트랜잭션으로 묶이므로, 둘 다 커밋되거나 둘 다 롤백됩니다. 원자성이 보장되는 것입니다.
흐름을 정리하면 다음과 같습니다.
- 애플리케이션이 비즈니스 로직을 처리하며 DB에 데이터를 저장한다.
- 같은 트랜잭션 내에서 발행할 이벤트를 outbox 테이블에도 저장한다.
- 트랜잭션이 성공적으로 커밋된다.
- 별도의 메시지 릴레이 프로세스가 outbox 테이블의 미발행 레코드를 읽어 메시지 브로커에 발행한다.
- 발행이 완료된 레코드는 처리 완료 상태로 업데이트하거나 삭제한다.
Outbox 테이블 설계
Outbox 테이블은 보통 다음과 같은 컬럼으로 구성합니다.
| 컬럼 | 설명 |
|---|---|
| id | 고유 식별자 (UUID 권장) |
| aggregate_type | 이벤트 대상 엔티티 종류 (예: Order, User) |
| aggregate_id | 대상 엔티티의 식별자 |
| event_type | 이벤트 유형 (예: OrderCreated) |
| payload | 직렬화된 이벤트 본문 (JSON 등) |
| created_at | 레코드 생성 시각 |
| published_at | 발행 완료 시각 (null이면 미발행) |
published_at이 null인 레코드를 릴레이 프로세스가 주기적으로 조회해 처리합니다. 처리 후에는 해당 컬럼을 현재 시각으로 업데이트하거나, 레코드를 별도 아카이브 테이블로 이동시킬 수 있습니다.
메시지 릴레이 구현 방식
폴링 기반 릴레이
가장 단순한 방식은 스케줄러가 주기적으로 outbox 테이블을 폴링하는 것입니다. 일정 간격마다 미발행 레코드를 조회하고, 메시지 브로커에 발행한 뒤 상태를 갱신합니다.
@Scheduled(fixedDelay = 1000)
public void relayMessages() {
List<OutboxEvent> pending = outboxRepository.findUnpublished();
for (OutboxEvent event : pending) {
messageBroker.publish(event.getEventType(), event.getPayload());
outboxRepository.markAsPublished(event.getId());
}
}
이 방식은 구현이 단순하지만 폴링 주기만큼의 지연이 발생하고, DB에 지속적인 부하를 줄 수 있습니다. 다수의 인스턴스가 동시에 실행될 경우 중복 발행을 방지하기 위해 레코드에 락을 걸거나 낙관적 잠금을 적용해야 합니다.
CDC(Change Data Capture) 기반 릴레이
더 정교한 방식은 CDC를 활용하는 것입니다. Debezium 같은 CDC 도구는 DB의 변경 로그(WAL, binlog 등)를 실시간으로 읽어 outbox 테이블에 삽입된 레코드를 감지하고, 곧바로 메시지 브로커에 전달합니다.
CDC 방식은 폴링 방식보다 지연이 훨씬 짧고 DB 폴링 부하가 없으며, 이미 발행된 레코드의 상태를 별도로 업데이트할 필요도 없어 운영 부담이 줄어듭니다. 다만 Debezium 같은 추가 인프라가 필요하고 운영 복잡도가 올라가는 것은 감수해야 합니다.
중복 발행과 멱등성 처리
Outbox Pattern을 사용하더라도 메시지가 중복으로 발행될 가능성은 존재합니다. 릴레이 프로세스가 메시지를 발행한 직후, 상태를 업데이트하기 전에 장애가 발생하면 재시작 시 같은 메시지가 다시 발행될 수 있습니다.
따라서 이 패턴은 최소 한 번 전달(at-least-once delivery) 을 보장하며, 정확히 한 번(exactly-once)은 보장하지 않습니다. 이를 보완하려면 소비자 측에서 멱등성을 처리해야 합니다.
일반적인 방법은 outbox 레코드의 id를 메시지 헤더에 포함해 전달하고, 소비자가 이미 처리한 메시지 ID를 별도 테이블에 기록해 중복 처리를 방지하는 것입니다.
주의해야 할 점
Outbox 테이블 크기 관리
처리가 완료된 레코드를 바로 삭제하거나 주기적으로 정리하지 않으면 outbox 테이블이 빠르게 비대해질 수 있습니다. 보존이 필요한 경우에는 아카이브 테이블로 이동하거나 TTL 정책을 적용하는 것이 좋습니다.
순서 보장
폴링 기반 릴레이에서 여러 레코드를 병렬로 처리하면 메시지 발행 순서가 뒤바뀔 수 있습니다. 순서가 중요한 도메인이라면 aggregate_id 기준으로 순차적으로 처리하거나, 메시지 브로커의 파티션 키를 적절히 설정해야 합니다.
릴레이 프로세스의 단일 장애점
릴레이 프로세스가 단일 인스턴스로 동작하면 해당 프로세스 장애 시 메시지 발행이 중단됩니다. 가용성을 높이려면 다중 인스턴스를 운영하되, 중복 처리를 막기 위한 분산 락 또는 리더 선출 메커니즘이 필요합니다.
언제 Outbox Pattern을 선택해야 할까
Outbox Pattern이 항상 필요한 것은 아닙니다. 단일 서비스 내에서 이벤트 처리가 완결되거나, 일시적인 메시지 유실이 허용되는 요구사항이라면 굳이 이 패턴을 도입할 필요가 없습니다.
반면 다음과 같은 상황이라면 도입을 적극적으로 고려할 만합니다.
- 마이크로서비스 간 이벤트 기반 통신을 사용하며, 데이터 일관성이 중요한 경우
- 결제, 주문, 재고 등 비즈니스 크리티컬한 도메인에서 이벤트 유실이 허용되지 않는 경우
- 분산 트랜잭션(2PC)을 피하고 더 가벼운 방법으로 원자성을 확보하고 싶은 경우
Outbox Pattern은 2단계 커밋(2PC)처럼 강력한 분산 트랜잭션 없이도 데이터 일관성을 실용적으로 달성할 수 있는 방법입니다. DB라는 이미 신뢰할 수 있는 저장소를 중간 매개로 활용한다는 발상이 이 패턴의 핵심입니다.
정리
Outbox Pattern은 분산 시스템에서 DB 저장과 메시지 발행의 원자성을 보장하기 위한 실용적인 해결책입니다. 구현 방식은 단순한 폴링부터 CDC 기반의 정교한 방식까지 다양하게 선택할 수 있으며, 각각 트레이드오프가 있으므로 팀의 인프라 역량과 요구사항에 맞게 선택하는 것이 중요합니다.
중복 발행 가능성을 전제로 소비자 측에서 멱등성을 보장하고, outbox 테이블 크기와 릴레이 프로세스의 가용성을 꾸준히 관리한다면 이 패턴은 마이크로서비스 아키텍처에서 믿을 수 있는 이벤트 발행 메커니즘으로 자리 잡을 수 있습니다.