본문 바로가기
Backend2026년 9월 4일20분 읽기

아웃박스 패턴(Transactional Outbox) — 이중 쓰기 문제와 신뢰성 있는 이벤트 발행

YS
김영삼
조회 9
아웃박스 패턴(Transactional Outbox) — 이중 쓰기 문제와 신뢰성 있는 이벤트 발행

DB에 데이터를 저장하면서 동시에 메시지 브로커로 이벤트를 보내려는 순간, 두 시스템 중 하나가 실패하면 데이터는 어긋난다. 아웃박스 패턴은 이벤트를 같은 트랜잭션 안의 테이블에 함께 커밋해서 이 "이중 쓰기" 구멍을 원천 봉쇄한다.

핵심은 하나다. DB 커밋과 이벤트 발행을 두 번의 원격 호출로 나누지 말고, 로컬 트랜잭션 하나로 묶은 뒤 발행은 나중에 별도 프로세스가 책임진다. 이 글은 dual write 문제의 정확한 원인부터 outbox 테이블 스키마, 릴레이/CDC 발행 방식, at-least-once 하에서의 멱등 소비까지 실전 코드로 정리한다.

주문 서비스를 만들다 보면 반드시 만나는 장면이 있다. orders 테이블에 INSERT 하고, 그 다음 줄에서 Kafka로 OrderPlaced 이벤트를 쏜다. 데모에서는 잘 돈다. 그런데 트래픽이 오르고 브로커가 잠깐 흔들리는 날, "결제는 됐는데 배송 이벤트가 안 왔다"는 CS가 올라온다. 나도 이걸로 새벽에 로그를 뒤진 적이 있다. 원인은 코드 버그가 아니라 구조였다.

항목값 / 특성
해결하는 문제이중 쓰기(dual write) — DB와 브로커에 각각 쓰다 한쪽 실패 시 불일치
전달 보장at-least-once (정확히 한 번 아님 → 소비자 멱등 필수)
발행 방식폴링 릴레이 or CDC(Debezium 등 로그 기반)
추가 지연(latency)폴링: 폴링 간격만큼(보통 수백 ms~수 초) / CDC: 대개 수십~수백 ms
필수 부가 장치이벤트 고유 ID + 소비자 측 중복 제거(dedup) 저장소
잘 맞는 상황RDB 트랜잭션 + 비동기 이벤트/사가(Saga)를 함께 쓰는 서비스

이중 쓰기(dual write) 문제가 정확히 뭔가

이중 쓰기는 하나의 논리적 작업을 서로 다른 두 저장소에 각각 써야 하는데, 둘을 하나의 트랜잭션으로 묶을 수단이 없는 상황을 말한다. DB 커밋과 브로커 전송은 별개의 네트워크 호출이라 "둘 다 성공"을 원자적으로 보장할 방법이 기본적으로 없다. 순서를 어떻게 배치해도 구멍이 남는다.

두 가지 흔한 배치를 보자.

  • DB 먼저, 발행 나중: 커밋은 됐는데 그 직후 프로세스가 죽거나 브로커가 타임아웃 나면 → 이벤트 유실. 데이터는 바뀌었는데 세상은 모른다.
  • 발행 먼저, DB 나중: 이벤트는 나갔는데 커밋이 롤백되면 → 유령 이벤트. 존재하지 않는 주문에 대해 배송이 시작된다.

"그럼 2단계 커밋(XA/2PC)을 쓰면 되잖아?"라고 할 수 있다. 이론상 맞다. 그런데 Kafka는 XA 트랜잭션 리소스 매니저가 아니고, 대부분의 현대 브로커와 DB 조합에서 분산 2PC는 성능·운영 비용·장애 모드 때문에 사실상 기피 대상이다. 실무에서 XA를 붙였다가 코디네이터 장애로 트랜잭션이 in-doubt 상태로 매달리는 걸 본 뒤로, 나는 이 방향을 권하지 않는다.

참고 이중 쓰기는 Kafka에만 국한된 문제가 아니다. DB 저장 + 검색엔진 색인, DB 저장 + 캐시 무효화, DB 저장 + 외부 API 호출 — 전부 같은 구조의 함정이다. 아웃박스는 이 모든 경우에 응용된다.

아웃박스 패턴은 어떻게 이 구멍을 막나

핵심 아이디어는 단순하다. 이벤트를 브로커로 바로 보내지 말고, 비즈니스 데이터와 같은 DB, 같은 트랜잭션 안의 outbox 테이블에 한 줄로 함께 INSERT 한다. 그러면 "주문 저장"과 "이벤트 기록"은 하나의 로컬 트랜잭션이므로 둘 다 커밋되거나 둘 다 롤백된다. 원자성 문제가 사라진다.

발행은 그 다음이다. 별도의 릴레이(relay) 프로세스가 outbox 테이블을 읽어 브로커로 보내고, 성공한 행을 처리 완료로 표시한다. 발행이 실패하면? 행이 그대로 남아 있으니 다음 주기에 다시 시도한다. 즉 커밋된 이벤트는 결국 반드시 한 번 이상 나간다. 이게 at-least-once의 정체다.

흐름을 정리하면 이렇다.

  1. 트랜잭션 시작 → 비즈니스 테이블 변경 + outbox INSERT → 커밋 (원자적)
  2. 릴레이가 미발행 outbox 행을 조회
  3. 브로커로 발행 → 성공 시 행을 삭제하거나 published_at 표시
  4. 소비자는 이벤트의 고유 ID로 중복을 걸러내며 멱등하게 처리

아웃박스 테이블 스키마는 어떻게 잡나

스키마의 목표는 세 가지다. (1) 이벤트를 고유하게 식별할 id, (2) 소비자가 라우팅할 aggregate_type/topic, (3) 순서 보장과 재시도를 위한 시간·상태 컬럼. 아래는 PostgreSQL 기준 실전 스키마다.

-- 아웃박스 테이블 (PostgreSQL)
CREATE TABLE outbox (
    id            UUID        PRIMARY KEY,            -- 이벤트 고유 ID(멱등 키로 그대로 사용)
    aggregate_type VARCHAR(64) NOT NULL,             -- 예: 'order', 'payment'
    aggregate_id  VARCHAR(64) NOT NULL,              -- 예: 주문번호 (같은 키끼리 순서 보장용)
    event_type    VARCHAR(64) NOT NULL,             -- 예: 'OrderPlaced'
    payload       JSONB       NOT NULL,              -- 이벤트 본문
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_at  TIMESTAMPTZ                          -- NULL이면 아직 미발행
);

-- 미발행 행만 빠르게 스캔하기 위한 부분 인덱스 (핵심)
CREATE INDEX idx_outbox_unpublished
    ON outbox (created_at)
    WHERE published_at IS NULL;

여기서 문서에 잘 안 나오는 포인트 하나. WHERE published_at IS NULL 부분 인덱스가 생각보다 중요하다. 발행된 행이 수백만 건 쌓여도 릴레이 쿼리는 미발행 행만 보므로 인덱스가 작게 유지된다. 이걸 안 하면 테이블이 커질수록 폴링 쿼리가 느려진다. 실제로 이 인덱스 하나 빠뜨려서 릴레이 지연이 계단식으로 늘어나는 걸 겪은 적이 있다.

설계 선택발행 후 삭제(delete)플래그 표시(published_at)
테이블 크기작게 유지됨계속 증가 → 주기적 삭제 필요
감사(audit)/재전송불가(이미 지움)가능(원본 남음)
VACUUM 부담DELETE로 dead tuple 발생UPDATE로 dead tuple 발생
추천폴링 릴레이 기본값으로 무난재전송/감사 필요 시

발행은 폴링 릴레이로 하나, CDC로 하나

outbox 행을 브로커로 옮기는 방법은 크게 둘이다. 애플리케이션이 주기적으로 SELECT 하는 폴링 릴레이와, DB의 트랜잭션 로그(WAL)를 읽는 CDC(Change Data Capture). 둘 다 결과는 같지만 운영 성격이 다르다.

폴링은 코드로 완결되고 인프라 추가가 없다. 대신 폴링 간격만큼 지연이 생기고, 그 간격을 줄이면 DB 부하가 는다. CDC(대표적으로 Debezium)는 WAL을 tail 하므로 지연이 짧고 DB에 폴링 쿼리를 안 날리지만, 커넥터와 Kafka Connect 같은 운영 요소가 늘어난다. 팀에 Kafka Connect 운영 경험이 없다면 나는 폴링부터 시작하라고 한다. 트래픽이 정말 커지면 그때 CDC로 옮겨도 늦지 않다.

비교 축폴링 릴레이CDC (Debezium 등)
지연폴링 간격에 종속(수백 ms~수 초)낮음(대개 수십~수백 ms)
DB 부하반복 SELECT(부분 인덱스로 완화)WAL 읽기, 폴링 쿼리 없음
운영 복잡도낮음(앱 코드 안)높음(커넥터·Connect 클러스터)
순서 보장created_at/id 정렬로 직접 제어로그 순서 그대로(파티션 키 주의)
시작 권장소~중규모, 첫 도입대규모·저지연 요구 시

실전 코드 1 — 주문 저장과 이벤트를 한 트랜잭션에

가장 중요한 규칙: 비즈니스 INSERT와 outbox INSERT는 반드시 같은 트랜잭션이어야 한다. 커넥션을 따로 열거나 오토커밋이 켜져 있으면 원자성이 깨진다. 아래는 Node.js(pg) 예시다.

// 주문 생성 서비스 — 비즈니스 쓰기와 outbox 쓰기를 하나의 트랜잭션으로 묶는다
import { randomUUID } from 'crypto';

async function placeOrder(pool, order) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    // 1) 비즈니스 데이터
    await client.query(
      'INSERT INTO orders (id, user_id, amount, status) VALUES ($1,$2,$3,$4)',
      [order.id, order.userId, order.amount, 'PLACED']
    );

    // 2) 같은 트랜잭션 안에서 outbox에 이벤트 기록
    await client.query(
      `INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload)
       VALUES ($1,$2,$3,$4,$5)`,
      [
        randomUUID(),          // 이벤트 고유 ID = 소비자 멱등 키
        'order',
        order.id,
        'OrderPlaced',
        JSON.stringify({ orderId: order.id, amount: order.amount }),
      ]
    );

    await client.query('COMMIT');   // 둘 다 커밋 or 둘 다 롤백
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
}

이 시점에서 이벤트는 아직 Kafka로 안 나갔다. 하지만 커밋된 이상 outbox에 안전하게 박혀 있으니, 발행은 릴레이가 책임진다. 애플리케이션 요청 응답은 여기서 끝내도 된다.

실전 코드 2 — 릴레이가 안전하게 집어가는 법

릴레이 인스턴스를 여러 개 띄우면 같은 행을 두 릴레이가 동시에 집어갈 수 있다. 이걸 막는 표준 트릭이 FOR UPDATE SKIP LOCKED다. 잠긴 행은 건너뛰고 자기 몫만 잠가서 가져오므로, 워커를 수평 확장해도 중복 처리가 줄어든다.

-- 릴레이: 미발행 행을 배치로 잠그고 가져오기 (여러 워커 안전)
BEGIN;

SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at          -- 순서 보장
FOR UPDATE SKIP LOCKED       -- 다른 워커가 잡은 행은 건너뜀
LIMIT 100;

-- (애플리케이션이 이 배치를 브로커로 발행)
-- 발행 성공한 id들에 대해:
UPDATE outbox SET published_at = now() WHERE id = ANY($1);

COMMIT;
주의 "브로커 발행 성공 → UPDATE 커밋" 사이에 프로세스가 죽으면 그 행은 다음에 다시 발행된다. 이건 버그가 아니라 at-least-once의 정상 동작이다. 여기서 "그럼 중복이 나가잖아"를 해결하려고 발행 순서를 뒤집으면 유실 위험이 생긴다. 중복은 소비자 쪽 멱등으로 흡수하는 게 정석이다.

at-least-once와 멱등 소비 — 중복은 소비자가 흡수한다

아웃박스는 "정확히 한 번(exactly-once)"이 아니라 "최소 한 번(at-least-once)"을 보장한다. 즉 같은 이벤트가 두 번 이상 도착할 수 있다는 전제 위에서 설계해야 한다. 그래서 소비자는 반드시 멱등해야 한다. 멱등의 정의는 간단하다 — 같은 이벤트를 몇 번 처리해도 결과가 한 번 처리한 것과 같아야 한다.

가장 확실한 방법은 처리한 이벤트 ID를 기록해두고, 이미 본 ID면 건너뛰는 것이다. 그리고 이 dedup 기록과 실제 처리(예: 재고 차감)를 같은 트랜잭션에 넣어야 "기록은 됐는데 처리는 안 된" 반쪽 상태를 막는다.

-- 소비자 측 멱등 처리 (PostgreSQL)
CREATE TABLE processed_events (
    event_id   UUID PRIMARY KEY,
    handled_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 컨슈머 로직
BEGIN;
  -- 이미 처리한 이벤트면 아무 것도 안 함 (ON CONFLICT로 중복 흡수)
  INSERT INTO processed_events (event_id)
  VALUES ($1)
  ON CONFLICT (event_id) DO NOTHING;

  -- 방금 INSERT가 실제로 적용됐을 때만(=처음 보는 이벤트) 비즈니스 처리
  -- 애플리케이션에서 affected rows == 0 이면 커밋만 하고 종료
  UPDATE inventory SET qty = qty - $2 WHERE sku = $3;
COMMIT;

여기서 실무 팁. ON CONFLICT DO NOTHING의 반환값(영향 행 수)으로 "처음 보는가"를 판별한 뒤, 처음일 때만 비즈니스 로직을 태우는 게 깔끔하다. 그리고 processed_events는 무한히 커지므로 보존 기간(예: 7~30일)을 정해 파티션이나 주기적 삭제로 관리해야 한다. 소비자가 그 기간보다 오래 밀리는 경우가 없다는 가정을 반드시 확인할 것.

순서 보장과 파티셔닝은 어떻게 되나

전역 순서(global ordering)를 보장하려 들면 병렬성이 죽는다. 현실적인 목표는 같은 집계(aggregate) 단위의 순서다. 예를 들어 "같은 주문의 이벤트들"은 순서가 지켜져야 하지만, 서로 다른 주문끼리는 순서가 섞여도 된다.

그래서 Kafka로 보낼 때 메시지 키를 aggregate_id 잡는다. 같은 키는 같은 파티션으로 가고, 파티션 안에서는 순서가 보장되므로 "주문 단위 순서 + 파티션 간 병렬"을 동시에 얻는다. 릴레이 쪽에서는 ORDER BY created_at, id로 집어가되, 같은 aggregate_id 이벤트가 순서를 넘겨받도록 배치를 구성한다.

참고 CDC를 쓰면 WAL 순서가 곧 커밋 순서라 릴레이보다 순서 다루기가 자연스럽다. 다만 Debezium 아웃박스 이벤트 라우터를 쓸 때도 파티션 키를 aggregate_id(예: 헤더/키 컬럼)로 지정해줘야 주문 단위 순서가 유지된다. 기본 설정만 믿지 말 것.

흔한 함정과 운영에서 데인 것들

패턴 자체는 단순한데, 운영에서 발목 잡히는 지점은 대체로 정해져 있다.

  • outbox INSERT를 다른 커넥션/오토커밋으로: 가장 흔한 실수. 트랜잭션이 분리되는 순간 아웃박스의 존재 이유가 사라진다. ORM 쓸 때 특히 조심 — 세션/트랜잭션 경계가 눈에 안 보인다.
  • 테이블 무한 증식: 발행 후 삭제 안 하고 플래그만 찍으면 outbox가 수억 건이 된다. 보관 정책(예: 발행 후 3일 뒤 삭제)을 처음부터 넣어라.
  • dead tuple / VACUUM: PostgreSQL에서 잦은 UPDATE/DELETE는 dead tuple을 쌓는다. autovacuum 튜닝을 안 하면 부풀어 오른다. 파티셔닝 후 오래된 파티션을 DROP하는 게 삭제보다 깔끔할 때가 많다.
  • payload에 전체 엔티티를 통째로: 이벤트가 비대해지고 스키마 결합이 커진다. 소비자가 정말 필요한 필드만 넣거나, ID만 보내고 조회하게 하는 것도 방법(단, 조회 시점 데이터가 바뀔 수 있음은 감안).
  • 소비자 멱등을 나중으로 미룸: "일단 발행부터, 멱등은 나중에" — 이러면 첫 장애 때 중복 부과·중복 메일이 나간다. 멱등은 선택이 아니라 아웃박스의 필수 짝이다.

언제는 쓰지 말아야 하나

아웃박스가 항상 정답은 아니다. 몇 가지 경우엔 오히려 과한 도구다.

  • 이벤트 자체가 없는 시스템: 비동기 통합이나 다른 서비스에 알릴 일이 없다면 굳이 테이블과 릴레이를 둘 이유가 없다.
  • DB 트랜잭션이 없는 저장소: 애초에 원자적 로컬 트랜잭션을 못 거는 스토어라면 아웃박스의 전제가 성립하지 않는다.
  • 초저지연이 절대 조건: 폴링 지연조차 허용 못 하고 CDC 운영도 부담이면, 다른 아키텍처(예: 이벤트 소싱, 로그 우선 설계)를 검토하는 게 낫다.
  • 단순 요청-응답으로 충분: 동기 호출 하나로 끝나는 흐름에 아웃박스를 얹는 건 복잡도만 늘린다.

개인적으로는 "RDB 트랜잭션이 있고, 커밋과 함께 무언가를 비동기로 알려야 한다"는 조건이 겹칠 때 거의 반사적으로 아웃박스를 먼저 떠올린다. 사가(Saga) 패턴과도 궁합이 좋다 — 각 단계의 로컬 트랜잭션 결과를 아웃박스로 신뢰성 있게 다음 단계에 전달할 수 있으니까.

자주 묻는 질문

아웃박스 패턴은 exactly-once(정확히 한 번)를 보장하나요?

아니요. at-least-once(최소 한 번)를 보장합니다. 발행 후 상태 갱신 사이에 장애가 나면 같은 이벤트가 재발행될 수 있어요. 따라서 소비자를 멱등하게 만들어 중복을 흡수하면, 결과적으로 "사실상 한 번 처리된 것과 같은" 효과를 얻습니다.

2단계 커밋(2PC/XA)을 쓰면 아웃박스가 필요 없지 않나요?

이론상 원자성은 해결되지만, Kafka 등 대부분의 브로커는 XA 리소스로 잘 맞지 않고 분산 2PC는 성능·운영·장애 복구 비용이 큽니다. 코디네이터 장애 시 트랜잭션이 in-doubt로 매달리는 문제도 있고요. 그래서 실무에서는 로컬 트랜잭션만 쓰는 아웃박스가 선호됩니다.

폴링 릴레이와 CDC 중 무엇부터 시작해야 하나요?

첫 도입이거나 Kafka Connect 운영 경험이 없다면 폴링 릴레이를 권합니다. 앱 코드 안에서 완결되고 인프라 추가가 없습니다. 지연이 문제가 되거나 규모가 커지면 그때 Debezium 같은 CDC로 옮기면 됩니다.

outbox 테이블은 발행 후 삭제해야 하나요, 플래그만 찍어야 하나요?

감사나 재전송이 필요 없다면 발행 후 삭제가 무난합니다(테이블이 작게 유지됨). 재전송·감사가 필요하면 published_at 플래그를 남기되, 반드시 보관 정책과 주기적 정리(또는 파티션 DROP)를 함께 두세요. 안 그러면 무한히 커집니다.

이벤트 순서는 어떻게 보장하나요?

전역 순서 대신 집계(aggregate) 단위 순서를 목표로 하세요. Kafka 메시지 키를 aggregate_id로 잡으면 같은 키는 같은 파티션으로 가서 그 안에서 순서가 지켜지고, 서로 다른 집계는 병렬 처리됩니다.

릴레이를 여러 대 띄우면 같은 이벤트를 중복 발행하지 않나요?

SELECT ... FOR UPDATE SKIP LOCKED로 각 워커가 서로 다른 행만 잠가 가져오면 정상 상황의 중복은 크게 줄어듭니다. 다만 장애 재시도로 인한 중복 가능성은 남으므로, 최종 안전장치는 여전히 소비자 멱등입니다.

MongoDB처럼 RDB가 아니어도 아웃박스를 쓸 수 있나요?

문서를 갱신하면서 같은 원자적 쓰기 범위 안에 outbox 문서를 함께 넣을 수 있으면 가능합니다. MongoDB는 문서 단위 원자성이나 다중 문서 트랜잭션을 활용하고, CDC는 change streams로 구현할 수 있습니다. 핵심 전제는 "비즈니스 변경과 이벤트 기록을 원자적으로 함께 쓸 수 있는가"입니다.

댓글 0

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