Java 아키텍처·Spring 용어 사전
MSAdual write · CDC · Debezium

Outbox 패턴

DB 저장과 이벤트 발행을 한 트랜잭션으로 묶는 패턴. 같은 DB의 outbox 테이블에 쓰고 릴레이가 발행한다.

"DB 저장"과 "이벤트 발행"을 원자적으로 만드는 패턴.

무엇이 문제인가 — 이중 쓰기

@Transactional
void order() {
    repo.save(order);                              // ① DB
    kafkaTemplate.send("orders", event);           // ② 브로커
}
  • ① 은 성공, ②는 실패 → 주문은 있는데 알림·정산이 안 간다
  • ② 는 성공, ①은 롤백 → 없는 주문의 이벤트가 나갔다

서로 다른 두 시스템에 쓰기 때문에 한 트랜잭션으로 묶을 수 없다 (2PC 는 앞서 본 이유로 피한다)

"결제는 됐는데 알림이 안 갔다" 의 정체다.

해법 — 같은 DB에 쓴다

@Transactional
void order() {
    repo.save(order);                    // 같은 DB
    outboxRepo.save(new Outbox(         // 같은 트랜잭션
        "Order", order.getId(), toJson(event)));
}   // 둘 다 커밋되거나 둘 다 롤백된다 — 원자성 확보
CREATE TABLE outbox (
  id            BIGINT PRIMARY KEY,
  aggregate_type VARCHAR(50),
  aggregate_id  VARCHAR(50),
  payload       JSONB,
  created_at    TIMESTAMPTZ DEFAULT now(),
  published_at  TIMESTAMPTZ            -- NULL 이면 아직 미발행
);

그다음 누가 발행하나

  • ① 폴링 릴레이

    • published_at IS NULL 인 행을 주기적으로 읽어 발행하고 표시한다
    • ✅ 단순하다 ❌ 폴링 지연 · DB 부하
  • ② CDC (Debezium 등)

    • DB 의 변경 로그(WAL/binlog)를 읽어 outbox 삽입을 감지해 발행한다
    • ✅ 지연이 적고 DB 부하가 없다 ❌ 인프라가 하나 는다

여전히 중복은 생긴다

발행 후 published_at 을 표시하기 직전에 죽으면

  • 재시작 후 같은 이벤트를 또 발행한다

즉 Outbox 로도 at-least-once 다

  • 소비자가 반드시 멱등해야 한다

"Outbox + 멱등 컨슈머" 가 분산 정합의 골드 스탠더드로 불리는 이유

커밋 후 발행만으로는 왜 부족한가

@TransactionalEventListener(phase = AFTER_COMMIT)
void publish(OrderPlaced e) { kafkaTemplate.send(...); }

커밋 직후·발행 직전에 인스턴스가 죽으면 이벤트가 영영 사라진다

  • 유실을 감당할 수 있으면 이걸로 충분하다 (가볍다)
  • 절대 잃으면 안 되면 Outbox 로 간다

선택 기준은 "유실을 감당할 수 있는가" 하나다.

면접 함정

  • "Outbox를 쓰면 중복이 없다" → 유실을 막을 뿐 중복은 남는다.
  • "트랜잭션 안에서 발행하면 된다" → 브로커는 롤백되지 않는다. 오히려 최악이다.

폴링 릴레이 구현

@Scheduled(fixedDelay = 500)
@Transactional
void relay() {
    // SKIP LOCKED 로 여러 인스턴스가 서로 다른 행을 집어 간다
    List<Outbox> batch = outboxRepo.findUnpublished(100);   // FOR UPDATE SKIP LOCKED
    for (Outbox o : batch) {
        kafkaTemplate.send(o.topic(), o.aggregateId(), o.payload());
        o.markPublished();
    }
}
-- 리포지토리 쿼리
SELECT * FROM outbox WHERE published_at IS NULL
 ORDER BY id LIMIT 100
 FOR UPDATE SKIP LOCKED;      -- 워커가 여러 대여도 경합 없이 나눠 갖는다

SKIP LOCKED 가 핵심이다. 없으면 인스턴스들이 같은 행을 두고 줄을 서서 사실상 한 대만 일하게 된다.

outbox 테이블을 비워야 한다

-- 발행된 행을 방치하면 계속 커지고 인덱스도 무거워진다
DELETE FROM outbox WHERE published_at < now() - INTERVAL '7 days';
-- 또는 파티셔닝해서 오래된 파티션을 통째로 DROP

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 마이크로서비스 — Saga·Outbox·서킷브레이커