본문 바로가기
Backend2026년 7월 31일6분 읽기

트랜잭셔널 아웃박스 패턴 — 이중 쓰기 문제를 없애는 법

YS
김영삼
조회 4
트랜잭셔널 아웃박스 패턴 — 이중 쓰기 문제를 없애는 법

트랜잭셔널 아웃박스(transactional outbox)는 데이터베이스를 바꾸는 동시에 메시지(이벤트)를 안정적으로 발행해야 할 때, 두 작업이 전부 성공하거나 전부 실패하도록 보장하는 패턴입니다. 핵심 아이디어는 간단합니다. 메시지를 곧바로 브로커에 보내지 말고, 같은 DB 트랜잭션 안에서 outbox 테이블에 한 줄로 기록한 뒤, 별도 프로세스가 그 줄을 읽어 브로커로 내보내는 겁니다.

이 패턴을 처음 만나는 계기는 대개 데이터 불일치 버그입니다. "주문은 DB에 저장됐는데 결제 완료 이벤트가 안 나갔어요." 또는 그 반대. 왜 이런 일이 생길까요?

이중 쓰기(dual write) 문제

순진한 코드는 이렇게 생겼습니다. DB에 저장하고, 곧바로 메시지 큐에 발행합니다.

await db.orders.insert(order);        // (1) DB 커밋
await kafka.publish('order.created', order); // (2) 메시지 발행

문제는 (1)과 (2)가 서로 다른 시스템이라 하나의 트랜잭션으로 묶이지 않는다는 점입니다. 사이에서 프로세스가 죽거나, 카프카가 잠깐 안 되거나, 네트워크가 끊기면 두 시스템의 상태가 어긋납니다.

  • (1) 성공, (2) 실패 → 주문은 있는데 이벤트가 없음. 재고 차감·알림이 영원히 안 일어남.
  • 순서를 바꿔 (2) 먼저 하면 → 이벤트는 나갔는데 (1)이 실패. 존재하지 않는 주문에 대한 이벤트가 떠돌음.

순서를 어떻게 바꿔도 "두 개의 독립 쓰기 사이의 틈"은 사라지지 않습니다. 이게 이중 쓰기 문제입니다.

아웃박스가 틈을 없애는 방법

해법은 발행할 메시지를 비즈니스 데이터와 같은 DB에 저장하는 것입니다. 그러면 둘이 하나의 로컬 트랜잭션으로 원자적으로 커밋됩니다.

BEGIN;
  INSERT INTO orders (id, ...) VALUES (...);
  -- 같은 트랜잭션 안에서 발행할 이벤트를 기록
  INSERT INTO outbox (id, topic, payload, status)
    VALUES (gen_random_uuid(), 'order.created',
            '{"orderId":"...","amount":19900}', 'PENDING');
COMMIT;   -- 주문과 이벤트가 함께 커밋되거나 함께 롤백

이제 "주문은 있는데 이벤트가 없다"는 상태 자체가 불가능해집니다. 커밋이 되면 둘 다 있고, 안 되면 둘 다 없습니다. 남은 일은 outbox의 PENDING 행을 브로커로 실어 나르는 것뿐입니다.

릴레이(relay) — 어떻게 내보내는가

outbox를 읽어 발행하는 방식은 크게 두 가지입니다.

방식동작특징
폴링 퍼블리셔주기적으로 PENDING을 SELECT해 발행단순, DB 부하·약간의 지연
CDC(로그 테일링)DB 트랜잭션 로그를 읽어 발행(예: Debezium)지연 적음, 인프라 복잡

폴링은 이렇게 생겼습니다. 발행에 성공한 행은 상태를 바꾸거나 삭제합니다.

-- 잠금으로 여러 릴레이가 같은 행을 집지 않게
SELECT * FROM outbox WHERE status = 'PENDING'
  ORDER BY created_at LIMIT 100
  FOR UPDATE SKIP LOCKED;
-- 각 행을 브로커에 발행 후
UPDATE outbox SET status = 'SENT' WHERE id = ?;

FOR UPDATE SKIP LOCKED가 여기서 진가를 발휘합니다. 릴레이를 여러 개 띄워도 서로 다른 행을 집어 중복 없이 병렬 처리할 수 있어요.

반드시 알아야 할 함정

아웃박스는 정확히-한-번(exactly-once)이 아니라 최소-한-번(at-least-once)을 보장합니다. 발행에 성공했는데 상태 업데이트 직전에 죽으면, 다음 릴레이가 그 행을 다시 발행합니다. 그래서 소비자 쪽은 반드시 멱등하게 만들어야 합니다. 메시지에 고유 ID를 담고, 소비자가 이미 본 ID면 무시하는 식으로요.

솔직히 처음엔 "아웃박스 = 중복 없음"으로 오해했습니다. 아닙니다. 아웃박스가 지우는 건 유실이지 중복이 아닙니다. 중복 제거는 소비자의 몫입니다. 이걸 놓치면 이벤트가 두 번 처리되는 사고가 납니다.

또 하나, outbox 테이블이 무한히 커지지 않도록 SENT 행을 주기적으로 정리(파티션 드롭이나 배치 삭제)하는 것도 잊지 마세요.

자주 묻는 질문

2단계 커밋(2PC)을 쓰면 되지 않나요?

이론적으로는 DB와 브로커를 분산 트랜잭션으로 묶을 수 있지만, 2PC는 느리고 참여자 중 하나만 죽어도 전체가 멈추는 등 운영이 까다롭습니다. 카프카 같은 현대 브로커는 XA를 잘 지원하지도 않고요. 아웃박스는 로컬 트랜잭션 하나만 쓰므로 훨씬 단순하고 견고합니다.

메시지 순서는 보장되나요?

outbox에 created_at이나 시퀀스로 순서를 남기고 릴레이가 그 순서로 발행하면 대체로 유지됩니다. 다만 병렬 릴레이나 파티션이 여럿이면 전역 순서는 깨질 수 있으니, 순서가 중요한 이벤트는 같은 파티션 키(예: 주문 ID)로 묶어야 합니다.

NoSQL에서도 아웃박스가 되나요?

됩니다. 단일 문서 트랜잭션을 지원하면(MongoDB의 다중 문서 트랜잭션, DynamoDB의 트랜잭션 쓰기 등) 비즈니스 데이터와 outbox 항목을 원자적으로 함께 쓸 수 있습니다. 원리는 동일합니다.

outbox 대신 상태 컬럼만으로 처리하면 안 되나요?

이벤트가 단순하면 엔티티 자체에 published 플래그를 두는 변형도 씁니다. 다만 여러 종류의 이벤트, 재발행, 페이로드 보관이 필요해지면 별도 outbox 테이블이 훨씬 깔끔합니다. 확장을 생각하면 처음부터 테이블로 두는 편을 권합니다.

댓글 0

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