본문 바로가기
Backend2026년 8월 23일7분 읽기

아웃박스 패턴 — 이벤트를 잃지 않고 확실히 발행하는 법

YS
김영삼
조회 4
아웃박스 패턴 — 이벤트를 잃지 않고 확실히 발행하는 법

아웃박스 패턴(Transactional Outbox)은 DB 변경과 이벤트 발행을 한 트랜잭션으로 묶어, 메시지를 잃지 않고 확실히 보내는 방법이다. 비즈니스 데이터를 저장하는 같은 트랜잭션 안에서 "보낼 이벤트"를 outbox 테이블에 함께 기록하고, 별도 프로세스가 그 행을 읽어 메시지 브로커로 발행한다.

핵심 문제의식은 이거다. DB 커밋과 카프카·RabbitMQ 발행은 서로 다른 시스템이라 하나의 트랜잭션으로 못 묶는다. 그래서 "DB엔 저장됐는데 이벤트는 안 나감" 또는 그 반대가 생긴다. 이 이중 쓰기(dual write) 문제를 우회하는 게 아웃박스다.

이중 쓰기가 왜 위험한가

흔한 코드는 이렇게 생겼다. 주문을 저장하고, 바로 이벤트를 발행한다.

await db.orders.insert(order);        // ① DB 커밋
await kafka.publish('OrderCreated', order);  // ② 브로커 발행

①과 ② 사이에서 프로세스가 죽으면? 주문은 DB에 남았는데 이벤트는 안 나갔다. 재고 차감도, 알림도 안 온다. 반대로 ②를 먼저 하고 ①에서 롤백되면 유령 이벤트가 발행된다. 순서를 어떻게 바꿔도 두 시스템을 원자적으로 묶을 수는 없다. 나도 이걸 "그냥 순서 잘 짜면 되지" 하고 넘겼다가, 배포 중 재시작 타이밍에 이벤트 유실을 겪고서야 심각성을 알았다.

아웃박스로 푸는 법

발행할 이벤트를 브로커로 바로 안 보내고, 같은 DB의 outbox 테이블에 비즈니스 데이터와 함께 커밋한다. 둘이 같은 트랜잭션이라 원자적이다. "주문 저장 성공 = 이벤트 기록 성공"이 보장된다.

CREATE TABLE outbox (
  id           bigserial PRIMARY KEY,
  aggregate    text NOT NULL,      -- 예: 'order'
  event_type   text NOT NULL,      -- 예: 'OrderCreated'
  payload      jsonb NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),
  published_at timestamptz          -- NULL이면 아직 미발행
);

BEGIN;
  INSERT INTO orders(...) VALUES (...);
  INSERT INTO outbox(aggregate, event_type, payload)
    VALUES ('order', 'OrderCreated', $1);
COMMIT;   -- 둘이 한 트랜잭션. 원자적.

그다음 릴레이(relay) 프로세스가 published_at IS NULL인 행을 주기적으로 읽어 브로커로 보내고, 성공하면 발행 시각을 찍는다. 여러 워커가 동시에 돌아도 같은 행을 두 번 안 집게 FOR UPDATE SKIP LOCKED를 쓴다.

-- 릴레이: 미발행 이벤트를 잠금 경쟁 없이 배치로 집기
WITH picked AS (
  SELECT id, event_type, payload FROM outbox
   WHERE published_at IS NULL
   ORDER BY id
   LIMIT 100
   FOR UPDATE SKIP LOCKED
)
SELECT * FROM picked;
-- 발행 성공 후:
UPDATE outbox SET published_at = now() WHERE id = ANY($1);

딱 한 번? 사실은 적어도 한 번

여기서 오해를 짚고 가자. 아웃박스는 "정확히 한 번(exactly-once) 발행"을 주는 게 아니다. 릴레이가 브로커에 보낸 직후, 발행 시각을 찍기 전에 죽으면 재기동 후 같은 이벤트를 또 보낸다. 즉 적어도 한 번(at-least-once)이다. 그래서 소비자 쪽은 반드시 멱등하게 만들어야 한다. 이벤트에 고유 ID를 실어 보내고, 소비자가 "이미 처리한 ID면 무시"하게 짠다.

발행 방식은 두 갈래다. 위처럼 outbox를 폴링하는 방식과, DB의 트랜잭션 로그를 직접 읽는 CDC(Change Data Capture, 예: Debezium) 방식. 폴링은 구현이 단순하고 인프라 의존이 적다. CDC는 폴링 지연이 없고 부하가 낮지만 Debezium·Connect 같은 스택을 얹어야 한다.

방식장점단점
폴링 릴레이단순·의존성 적음폴링 주기만큼 지연·DB 부하
CDC(Debezium)저지연·낮은 부하운영 스택 복잡

운영하며 챙긴 것

발행 끝난 오래된 행은 청소해야 테이블이 안 붓는다. 나는 published_at이 며칠 지난 행을 배치로 지운다. 그리고 폴링 인덱스는 WHERE published_at IS NULL 부분 인덱스로 만들어야 스캔이 싸진다. 전체 인덱스로 잡으면 발행 완료 행까지 커버해 무겁다. 마지막으로 이벤트 순서가 중요하면 aggregate 단위로 파티션 키를 걸어 같은 주문의 이벤트가 순서대로 가게 한다.

자주 묻는 질문

아웃박스 패턴을 쓰면 이벤트 유실이 완전히 없나요?

비즈니스 커밋과 이벤트 기록이 한 트랜잭션이라 "저장은 됐는데 이벤트 기록이 안 됨"은 사라집니다. 릴레이가 언젠가는 그 행을 발행하므로 유실이 아닌 지연으로 바뀌죠. 다만 중복 발행은 가능하니 소비자를 멱등하게 만드는 게 짝을 이룹니다.

메시지 브로커의 트랜잭션 기능을 쓰면 안 되나요?

브로커 트랜잭션은 브로커 안에서의 원자성이지, DB 커밋과 묶어 주진 못합니다. 이중 쓰기 문제의 본질은 서로 다른 두 시스템을 하나로 커밋할 수 없다는 데 있어, DB 안에 이벤트를 함께 저장하는 아웃박스가 이를 우회합니다.

폴링 방식은 DB에 부담이 크지 않나요?

published_at IS NULL 부분 인덱스와 FOR UPDATE SKIP LOCKED 배치 조회를 쓰면 부담이 작습니다. 발행 완료 행을 주기적으로 정리하면 스캔 대상도 항상 소량으로 유지됩니다. 초저지연이 꼭 필요할 때만 CDC로 넘어가면 됩니다.

소비자를 멱등하게 만드는 가장 쉬운 방법은?

이벤트마다 고유 ID를 실어 보내고, 소비자가 처리한 ID를 기록해 두었다가 다시 오면 건너뛰는 방식입니다. 처리 대상 테이블에 유니크 제약을 걸어 두 번째 삽입이 자연히 무시되게 만드는 것도 흔한 패턴입니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록