Outbox 패턴으로 이벤트 유실 없이 메시지 발행하기

주문은 분명히 저장됐는데, 결제 완료 알림은 가지 않았습니다. 로그를 보니 주문을 DB에 커밋한 직후, 카프카로 이벤트를 보내기 직전에 서버가 재시작됐습니다. 데이터는 남았지만 이벤트는 허공으로 사라졌습니다. 이런 이벤트 유실을 구조적으로 막아주는 것이 Outbox 패턴입니다.

이 문제의 뿌리는 “DB 저장”과 “메시지 발행”이라는, 서로 다른 두 시스템에 대한 쓰기를 하나의 작업처럼 묶을 수 없다는 데 있습니다. 둘 중 하나만 성공하고 다른 하나가 실패하면, 데이터와 이벤트가 어긋납니다.

이 글에서는 왜 이 두 작업을 동시에 신뢰할 수 없는지(이중 쓰기 문제), Outbox 패턴이 그것을 어떻게 푸는지, 그리고 폴링과 CDC 두 가지 발행 방식의 차이와 구현 시 빠지기 쉬운 함정까지 원리 중심으로 정리합니다.


왜 DB 저장과 메시지 발행을 동시에 못 믿나

문제의 이름은 이중 쓰기(dual write)입니다. 하나의 비즈니스 동작이 두 군데에 써야 할 때 생깁니다. 주문을 저장하면서(데이터베이스), 동시에 “주문 생성됨” 이벤트도 발행해야(메시지 브로커) 하는 상황입니다.

문제는 이 둘이 물리적으로 다른 시스템이라는 점입니다. 순서를 어떻게 잡아도 빈틈이 생깁니다. DB를 먼저 커밋하고 브로커로 보내면, 그 사이에 죽었을 때 이벤트가 유실됩니다. 반대로 브로커로 먼저 보내고 DB를 커밋하면, DB가 롤백됐을 때 실제로는 없는 일에 대한 유령 이벤트가 떠돌게 됩니다.

어느 쪽이든 결과는 데이터와 이벤트의 불일치입니다. 알림이 안 가거나, 재고가 안 줄거나, 정산이 어긋납니다. 분산 환경에서 이벤트 기반으로 서비스를 엮을 때 가장 먼저 부딪히는 벽이 바로 이 지점입니다. 이벤트로 서비스를 느슨하게 연결하는 큰 그림은 MSA 서비스 간 호출 해결 방법 비교 가이드에서 다룬 바 있습니다.


@Transactional은 카프카까지 묶어주지 않는다

여기서 흔한 오해가 하나 있습니다. “트랜잭션으로 묶으면 되지 않나”라는 생각입니다. 하지만 스프링의 @Transactional이 보장하는 것은 하나의 데이터베이스 안에서의 원자성일 뿐입니다. 메시지 브로커는 그 트랜잭션 경계 밖에 있습니다. DB 커밋이 성공해도 카프카 전송은 별개의 네트워크 작업이고, 둘은 함께 성공하거나 함께 실패하지 않습니다.

그렇다면 DB와 브로커를 한꺼번에 묶는 분산 트랜잭션(2PC)을 쓰면 되지 않을까요. 이론적으로는 가능하지만, 실무에서는 거의 피합니다. 구현이 복잡하고, 참여 시스템이 모두 2PC를 지원해야 하며, 무엇보다 느리고 가용성을 떨어뜨립니다. 한 참여자가 멈추면 전체가 잠길 수 있습니다.

그래서 현실적인 해법은 방향을 바꾸는 것입니다. 브로커를 트랜잭션에 끌어들이는 대신, 이벤트 발행이라는 행위 자체를 DB 작업으로 바꿔버리는 것 — 이것이 Outbox 패턴의 출발점입니다.


Outbox 패턴의 핵심 아이디어

같은 트랜잭션에 이벤트를 저장하고, 릴레이가 뒤이어 발행

아이디어는 단순합니다. 이벤트를 곧바로 브로커로 보내지 않고, 비즈니스 데이터와 같은 데이터베이스의 별도 테이블(outbox)에 함께 저장합니다. 주문을 저장하는 그 트랜잭션 안에서, “발행해야 할 이벤트”도 하나의 행으로 같이 INSERT하는 것입니다.

이렇게 하면 두 쓰기가 모두 같은 로컬 트랜잭션에 속하게 됩니다. 같이 커밋되거나 같이 롤백되므로, 주문은 저장됐는데 이벤트만 사라지는 일이 원리적으로 불가능해집니다. 데이터와 이벤트의 일관성이 데이터베이스의 트랜잭션 보장 위에 올라타는 셈입니다.

그다음, 별도의 발행 프로세스(흔히 메시지 릴레이라고 부릅니다)가 outbox 테이블을 읽어 실제로 브로커에 전송하고, 전송이 끝난 행에 발행 완료 표시를 합니다. 저장과 발행을 시간적으로 분리해, 저장의 일관성을 먼저 확보한 뒤 발행은 비동기로 뒤따르게 하는 구조입니다.


구현 1 — 같은 트랜잭션에서 데이터와 이벤트를 함께 저장한다

핵심은 비즈니스 저장과 outbox 저장을 하나의 @Transactional 메서드 안에 두는 것입니다.

@Transactional
public void placeOrder(OrderRequest req) {
    Order order = orderRepository.save(new Order(req)); // 1) 비즈니스 데이터

    OutboxEvent event = OutboxEvent.of(
        "OrderCreated",
        order.getId(),
        toJson(new OrderCreatedPayload(order))         // 2) 같은 트랜잭션에 이벤트 적재
    );
    outboxRepository.save(event);
    // 두 INSERT는 하나의 로컬 트랜잭션 → 함께 커밋되거나 함께 롤백된다
}

outbox 테이블에는 보통 이벤트 종류(type), 어떤 엔티티에 대한 것인지(aggregate_id), 직렬화된 본문(payload), 발행 여부(published), 생성 시각(created_at) 정도를 둡니다. 여기서 중요한 건 이 메서드 안에서는 브로커를 전혀 건드리지 않는다는 점입니다. 발행은 다음 단계의 책임입니다.

스프링 안에서 트랜잭션 커밋 시점에 맞춰 후속 처리를 다루는 방법이 궁금하다면, @TransactionalEventListener, 코드가 진화하는 4단계도 함께 보면 이 구조가 더 선명해집니다.


구현 2 — Relay가 Outbox를 읽어 발행한다: 폴링 vs CDC

저장된 이벤트를 브로커로 내보내는 릴레이는 크게 두 방식으로 구현합니다.

구분폴링 퍼블리셔CDC (Debezium 등)
동작 방식주기적으로 미발행 행 SELECT 후 발행DB 로그(binlog·WAL)를 추적해 캡처
구현 난이도낮음 (애플리케이션 코드)높음 (별도 인프라 필요)
DB 부하폴링 쿼리 부하 있음낮음 (로그 기반)
발행 지연폴링 주기만큼거의 실시간
적합한 규모중소 규모, 단순한 구성대규모, 낮은 지연이 중요할 때

폴링 퍼블리셔는 가장 단순합니다. 스케줄러가 주기적으로 발행되지 않은 행을 꺼내 브로커로 보내고 상태를 갱신합니다.

@Scheduled(fixedDelay = 1000)
@Transactional
public void publishOutbox() {
    List<OutboxEvent> events =
        outboxRepository.findTop100ByPublishedFalseOrderByCreatedAt();

    for (OutboxEvent e : events) {
        kafkaTemplate.send(e.getType(), e.getAggregateId(), e.getPayload());
        e.markPublished(); // 발행 완료 표시 (또는 행 삭제)
    }
}

CDC 방식은 애플리케이션이 직접 폴링하지 않고, Debezium 같은 도구가 데이터베이스의 트랜잭션 로그를 읽어 outbox 테이블의 변경을 실시간으로 캡처해 카프카로 흘려보냅니다. DB에 폴링 부하를 주지 않고 지연도 짧지만, 별도 인프라 운영이라는 비용이 따릅니다. 어떤 브로커로 보낼지에 대한 판단은 Kafka vs RabbitMQ, 무엇을 언제 선택할까를 참고하면 도움이 됩니다.


빠지기 쉬운 함정: 중복 발행과 순서

Outbox 패턴은 이벤트 유실은 막아주지만, 중복 발행까지 막아주지는 않습니다. 위 폴링 코드에서 kafkaTemplate.send()는 성공했는데 markPublished() 직전에 프로세스가 죽으면, 다음 주기에 같은 이벤트가 한 번 더 발행됩니다. 즉 Outbox는 기본적으로 최소 한 번(at-least-once) 전달을 보장합니다.

따라서 소비하는 쪽은 같은 이벤트를 두 번 받아도 결과가 같도록 멱등성(idempotency)을 갖춰야 합니다. 이벤트마다 고유 ID를 부여하고, 이미 처리한 ID는 건너뛰는 방식이 가장 흔합니다. 중복 처리를 막는 동시성 제어가 필요하다면 Redis 분산락 구현하기의 접근도 활용할 수 있습니다.

순서도 신경 써야 합니다. 같은 엔티티에 대한 이벤트는 발생 순서대로 처리돼야 의미가 맞습니다. 카프카라면 aggregate_id를 메시지 키로 사용해 같은 키의 이벤트가 같은 파티션으로 가도록 하면, 파티션 단위 순서가 보장됩니다. 마지막으로, 발행이 끝난 outbox 행은 주기적으로 삭제하거나 아카이브해 테이블이 무한정 커지지 않게 관리해야 합니다.


자주 묻는 질문 (FAQ)

Q1. Outbox 테이블은 비즈니스 DB와 같은 곳에 둬야 하나요?

네, 반드시 같은 데이터베이스(같은 트랜잭션 범위)에 둬야 합니다. 비즈니스 데이터와 이벤트가 하나의 로컬 트랜잭션으로 함께 커밋되는 것이 이 패턴의 전부입니다. 별도 DB에 두면 다시 이중 쓰기 문제로 돌아갑니다.

Q2. 폴링 방식은 DB에 부담이 되지 않나요?

어느 정도 부담은 있습니다. 그래서 미발행 행에 인덱스를 두고, 한 번에 가져오는 건수를 제한하며, 폴링 주기를 적절히 잡는 것이 중요합니다. 부하나 지연이 문제가 될 만큼 규모가 커지면 CDC 방식으로 전환을 검토합니다.

Q3. 이벤트가 중복으로 발행될 수 있다면 신뢰성이 떨어지는 것 아닌가요?

유실 없이 “최소 한 번”을 보장하는 것이 핵심이며, 중복은 소비자 멱등성으로 흡수하는 것이 정석입니다. 분산 환경에서 “정확히 한 번”은 매우 비싸기 때문에, 대부분 “최소 한 번 + 멱등 처리” 조합으로 신뢰성을 확보합니다.


정리하며

Outbox 패턴의 본질은 화려한 기술이 아니라 발상의 전환입니다. 믿을 수 없는 두 시스템에 동시에 쓰려고 애쓰는 대신, 이벤트 발행을 데이터베이스 트랜잭션 안으로 끌어들여 저장의 일관성에 발행을 얹는 것입니다. 같은 트랜잭션에 이벤트를 함께 저장하고, 릴레이가 그것을 읽어 발행하며, 소비자는 멱등성으로 중복을 흡수합니다. 이 세 박자가 맞물릴 때 “주문은 저장됐는데 이벤트는 사라지는” 사고를 구조적으로 막을 수 있습니다. 규모가 작다면 폴링으로 단순하게 시작하고, 지연과 부하가 문제가 될 때 CDC로 넓혀 가는 것이 현실적인 도입 순서입니다.

댓글 남기기