Outbox는 Polling과 CDC에서 무엇을 맞바꾸는가

seonwoo_jung·약 16시간 전

1. 도입

서비스에서 데이터베이스에 주문을 저장하고, 동시에 Kafka 같은 메시지 브로커로 OrderCreated 이벤트를 발행한다고 해보자. 코드만 보면 간단하다. 트랜잭션 안에서 주문을 저장하고, 그 다음 이벤트를 보내면 된다. 그런데 실제 장애 상황을 넣어 보면 금방 찝찝해진다.

DB 커밋은 성공했는데 이벤트 발행 직전에 프로세스가 죽으면 어떻게 될까? 반대로 이벤트는 발행됐는데 DB 커밋이 실패하면 소비자는 존재하지 않는 주문을 보게 된다. DB와 메시지 브로커를 하나의 원자적 트랜잭션으로 묶지 않는 이상, 두 작업 사이에는 언제나 틈이 생긴다.

Chris Richardson의 Microservices Patterns에서 소개하는 Transactional Outbox 패턴은 이 틈을 줄이기 위한 대표적인 방법이다. 핵심은 "비즈니스 데이터 변경"과 "발행할 메시지 기록"을 같은 DB 트랜잭션에 넣는 것이다. 이후 별도의 relay가 outbox 테이블을 읽어 브로커로 보낸다.

이 글에서는 Outbox 자체보다, 그 다음 질문을 정리했다. outbox 테이블에 쌓인 메시지를 어떻게 가져와 발행할 것인가? 가장 흔한 선택지는 주기적으로 테이블을 조회하는 Polling 방식과, DB 변경 로그를 읽는 CDC(Change Data Capture) 방식이다. CDC 구현체로는 Debezium이 자주 언급된다.

Outbox는 "저장과 발행을 한 번에 한다"가 아니라, "저장과 발행할 기록을 한 트랜잭션에 남긴다"에 가깝다.

2. Outbox 패턴의 핵심

Outbox 패턴은 서비스가 자기 DB에 비즈니스 데이터를 저장할 때, 같은 트랜잭션 안에서 메시지 발행 요청도 outbox 테이블에 함께 저장하는 패턴이다. 예를 들어 주문 생성 API라면 orders 테이블에 주문을 넣고, outbox_events 테이블에 OrderCreated 이벤트를 넣는다.

흐름을 단순화하면 다음과 같다.

Client
  |
  v
Order Service
  |
  |  같은 DB 트랜잭션
  v
+-----------------------------+
| orders        insert order  |
| outbox_events insert event  |
+-----------------------------+
  |
  v
Message Relay
  |
  v
Kafka / RabbitMQ / SNS ...

이 방식의 장점은 서비스 로직이 DB 커밋과 메시지 발행 사이에서 직접 흔들리지 않는다는 점이다. DB 트랜잭션이 커밋되면 주문과 이벤트 기록이 함께 남고, 롤백되면 둘 다 사라진다. 발행이 나중에 실패하더라도 outbox 테이블에 기록이 남아 있으므로 재시도할 수 있다.

물론 이것이 정확히 한 번 발행을 자동으로 보장한다는 뜻은 아니다. relay가 브로커로 메시지를 보낸 뒤 outbox 상태 업데이트 전에 죽으면 같은 메시지를 다시 보낼 수 있다. 그래서 소비자는 보통 idempotent하게 만들어야 한다. Microservices Patterns에서도 Transactional Outbox는 메시지 relay와 소비자의 중복 처리 가능성을 함께 고려하는 패턴으로 설명된다.

문제는 여기서부터다. outbox에 저장된 이벤트를 relay가 어떻게 감지할 것인가?

대표적으로 두 방식이 있다.

방식핵심 아이디어대표 도구
Polling Publisheroutbox 테이블을 주기적으로 조회한다애플리케이션 배치, 스케줄러
Transaction Log Tailing / CDCDB 변경 로그를 읽어 이벤트를 감지한다Debezium, DB 커넥터

둘 다 같은 outbox 테이블을 바라볼 수 있지만, 운영 성격은 꽤 다르다.

3. Polling 방식은 단순함을 얻고 DB 부하를 감수한다

Polling 방식은 말 그대로 relay가 일정 주기로 outbox 테이블을 조회한다. 아직 발행되지 않은 행을 가져와 브로커에 보내고, 성공하면 발행 완료 상태로 바꾸거나 삭제한다.

가장 작은 형태의 의사 코드는 다음과 비슷하다.

while (true) {
    List<OutboxEvent> events = outboxRepository.findPending(limit);

    for (OutboxEvent event : events) {
        publisher.publish(event.topic(), event.payload());
        outboxRepository.markPublished(event.id()); // 중복 발행 가능 지점
    }

    Thread.sleep(pollIntervalMillis);
}

구현 자체는 이해하기 쉽다. 별도의 CDC 인프라 없이도 애플리케이션 코드와 DB만 있으면 시작할 수 있다. SQL로 status = 'PENDING'인 행을 읽고, created_at 또는 증가하는 ID 기준으로 정렬해서 처리하면 된다. 장애가 나도 미처리 행이 DB에 남아 있으므로 다시 읽을 수 있다.

하지만 운영으로 가면 신경 쓸 부분이 늘어난다.

첫째, 조회 주기를 짧게 잡으면 DB 부하가 늘어난다. 이벤트가 거의 없을 때도 relay는 계속 DB를 깨운다. 반대로 주기를 길게 잡으면 이벤트 발행 지연이 커진다. "거의 실시간"이 필요한 시스템에서는 이 간격을 줄이고 싶지만, 그만큼 DB에 반복 조회가 들어간다.

둘째, 여러 relay 인스턴스를 둘 때 락과 경쟁 처리가 필요하다. 같은 outbox 행을 두 인스턴스가 동시에 집어 가면 중복 발행이 늘어난다. SELECT ... FOR UPDATE SKIP LOCKED 같은 DB 기능을 쓰거나, processing 상태 전이를 원자적으로 처리하는 식의 설계가 필요하다. DB마다 지원하는 락 문법과 격리 수준이 다르기 때문에, 이 부분은 특정 DB 문서에 맞춰 확인해야 한다.

셋째, outbox 테이블 관리가 필요하다. 발행 완료된 행을 계속 쌓아두면 인덱스와 테이블 크기가 커진다. 삭제 배치나 파티셔닝이 필요할 수 있다. 특히 조회 조건이 status, created_at, id에 걸린다면 인덱스 설계를 대충 넘기기 어렵다.

Polling은 나쁜 방식이라기보다, 단순함을 DB 쿼리와 애플리케이션 제어로 지불하는 방식에 가깝다. 작은 규모나 이벤트 지연 허용 폭이 넓은 서비스에서는 오히려 가장 설명하기 쉽고 디버깅하기 쉬운 선택이 될 수 있다.

4. CDC 방식은 변경 로그를 얻고 운영 복잡도를 받아들인다

CDC 방식은 outbox 테이블을 직접 반복 조회하기보다, DB의 변경 로그를 읽어 새 행을 감지한다. MySQL이라면 binlog, PostgreSQL이라면 WAL 같은 로그가 여기에 해당한다. Debezium은 이런 DB 변경 로그를 읽어 Kafka 등으로 변경 이벤트를 전달하는 오픈소스 CDC 플랫폼으로 알려져 있다.

Outbox와 Debezium을 함께 쓰는 흐름은 대략 이렇다.

Order Service
  |
  | insert order + insert outbox_event
  v
Database
  |
  | transaction log
  v
Debezium Connector
  |
  v
Kafka topic

이 방식의 매력은 relay가 outbox 테이블을 계속 스캔하지 않아도 된다는 점이다. DB는 어차피 커밋된 변경을 로그에 남긴다. CDC는 그 로그를 따라가며 변경 이벤트를 읽는다. 그래서 지연 시간은 polling interval에 묶이지 않고, 테이블 조회 부하도 줄일 수 있다.

또 하나의 장점은 커밋 순서에 더 자연스럽게 붙을 수 있다는 점이다. CDC는 DB 트랜잭션 로그를 기반으로 하므로, 커밋된 변경을 순서대로 읽는 모델에 가깝다. 물론 정확한 순서 보장 범위는 DB, 커넥터, Kafka 파티셔닝 설계에 따라 달라지므로 "항상 전역 순서가 보장된다"처럼 단정하면 안 된다.

대신 CDC는 운영 면에서 더 무겁다. Debezium 커넥터, Kafka Connect, 스키마 변화, 커넥터 offset, snapshot, 재시작 복구 같은 요소가 시스템의 일부가 된다. 애플리케이션 코드에서는 polling loop가 사라지지만, 플랫폼 운영 지식이 필요해진다.

특히 초기 snapshot과 스키마 변경은 미리 생각해야 한다. 커넥터가 처음 붙을 때 기존 테이블 데이터를 어떻게 읽을지, outbox 테이블의 payload 컬럼 구조가 바뀌면 소비자와 토픽은 어떻게 대응할지 정해야 한다. Debezium의 outbox event router 같은 기능을 쓰면 outbox 테이블 행을 도메인 이벤트 토픽으로 라우팅하는 구성을 만들 수 있지만, 그만큼 설정과 운영 표준이 중요해진다.

CDC도 중복에서 자유롭지는 않다. 커넥터 재시작, 브로커 재전송, 소비자 재처리 상황에서는 같은 이벤트가 다시 보일 수 있다. 따라서 event id를 두고 소비자 쪽에서 중복 처리를 하거나, 처리 결과를 idempotent하게 만드는 설계는 여전히 필요하다.

5. Polling과 CDC의 선택 기준

두 방식을 비교할 때 "어느 쪽이 더 좋은가"보다 "어디에 복잡도를 둘 것인가"로 보는 편이 이해하기 좋았다.

관점PollingCDC(Debezium)
시작 난이도낮다. 애플리케이션 코드와 SQL로 구현 가능높다. 커넥터와 메시징 인프라 운영이 필요
발행 지연polling interval 영향을 받는다변경 로그 기반이라 낮게 가져가기 쉽다
DB 부하주기적 조회와 락 관리가 필요반복 조회는 줄지만 로그 설정이 필요
확장여러 relay 경쟁 제어가 핵심커넥터, 토픽, 파티션 설계가 핵심
장애 대응코드와 DB 상태를 직접 보면 된다커넥터 offset과 Kafka Connect 상태까지 봐야 한다
팀 적합성작은 팀, 단순 운영에 유리CDC/Kafka 운영 역량이 있는 팀에 유리

내가 실무 설계에서 먼저 확인할 질문은 세 가지다.

첫째, 이벤트 발행 지연을 얼마나 허용할 수 있는가. 수 초 단위 지연이 괜찮고 트래픽이 크지 않다면 polling이 충분할 수 있다. 반대로 많은 서비스가 outbox를 쓰고, 낮은 지연과 높은 처리량이 필요하다면 CDC가 더 자연스러울 수 있다.

둘째, 팀이 어떤 운영 복잡도에 익숙한가. DB 쿼리와 배치 작업을 잘 다루는 팀이라면 polling의 문제를 통제하기 쉽다. 이미 Kafka Connect와 Debezium을 운영하고 있다면 CDC의 추가 비용이 작아진다. 반대로 CDC 경험이 거의 없다면, 단순한 outbox 하나 때문에 플랫폼 복잡도가 크게 늘어날 수 있다.

셋째, 장애를 어떻게 관측하고 복구할 것인가. Polling은 outbox_events 테이블에 pending 행이 얼마나 쌓였는지, relay가 어디까지 처리했는지 보면 된다. CDC는 커넥터 lag, offset, 토픽 라우팅, dead letter 처리까지 함께 봐야 한다. 어느 쪽이든 "발행 안 된 이벤트 수", "가장 오래된 pending 이벤트 age", "중복 처리율" 같은 지표가 필요하다.

작게 시작하는 서비스라면 polling으로 outbox의 의미를 먼저 안정화하고, 이벤트 양과 지연 요구가 커질 때 CDC로 옮기는 전략도 가능하다. 다만 이때 outbox 테이블의 이벤트 ID, aggregate ID, event type, payload, created_at 같은 기본 스키마를 처음부터 명확히 잡아두면 전환 비용이 줄어든다.

6. 정리

Outbox 패턴은 DB 저장과 메시지 발행 사이의 원자성 문제를 직접 발행이 아니라 "발행할 사실을 같은 트랜잭션에 기록"하는 방식으로 다룬다. 그 다음 relay 구현에서 Polling과 CDC가 갈린다.

Polling은 단순하고 애플리케이션에서 제어하기 쉽지만, DB 반복 조회와 락 경쟁, 테이블 정리가 숙제다. CDC는 낮은 지연과 로그 기반 흐름을 얻을 수 있지만, Debezium이나 Kafka Connect 같은 운영 구성 요소를 받아들여야 한다.

결국 선택 기준은 기술의 우열보다 요구사항과 팀의 운영 능력이다. 이벤트 수가 적고 지연 허용 폭이 넓다면 Polling이 충분히 실용적이다. 이미 CDC 인프라가 있고 여러 서비스의 이벤트 발행을 표준화하려면 Debezium 기반 Outbox가 더 잘 맞을 수 있다.

다음에 더 파고들 주제는 두 가지다. 하나는 outbox 이벤트 소비자의 idempotency 설계이고, 다른 하나는 Debezium outbox event router를 사용할 때 토픽 라우팅과 스키마 버전을 어떻게 관리할지다.

참고 자료

  • Chris Richardson, Microservices Patterns — Transactional Outbox, Polling Publisher, Transaction Log Tailing 패턴
  • Debezium 공식 문서 — Outbox Event Router 및 CDC 커넥터 문서

0개의 댓글