이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Transactional Outbox Pattern은 dual write 문제를 어떤 구조로 줄이는가?
  • outbox table에는 어떤 column과 index가 필요하며, payload version은 왜 중요한가?
  • polling publisher를 만들 때 lock, retry, status 전이를 어떻게 다루는가?
  • outbox를 써도 consumer idempotency와 cleanup이 왜 필요한가?

개요

Transactional Outbox Pattern은 business data 변경과 event 기록을 같은 DB transaction에 넣는 패턴이다.

Kafka publish는 transaction 밖에서 나중에 수행한다.

핵심은 “발행을 즉시 성공시킨다”가 아니다.

“발행해야 할 사실을 잃어버리지 않는다”가 핵심이다.

DB commit이 성공하면 outbox row도 남는다.

publisher가 죽어도 outbox row를 기준으로 다시 발행할 수 있다.

기본 흐름

API request
  -> service transaction begin
  -> orders row update
  -> outbox_events row insert
  -> service transaction commit
 
publisher
  -> pending outbox read
  -> Kafka publish
  -> status update

service transaction 안에는 DB에서 원자적으로 처리할 수 있는 것만 넣는다.

Kafka broker와 통신하는 발행은 transaction 밖으로 분리한다.

이 분리 덕분에 발행 실패를 outbox 상태로 추적할 수 있다.

Outbox Schema

outbox table은 단순 payload 저장소가 아니다.

CREATE TABLE outbox_events (
    id uuid PRIMARY KEY,
    aggregate_type varchar(60) NOT NULL,
    aggregate_id varchar(100) NOT NULL,
    event_type varchar(100) NOT NULL,
    event_version int NOT NULL,
    event_key varchar(120) NOT NULL,
    payload jsonb NOT NULL,
    status varchar(20) NOT NULL DEFAULT 'PENDING',
    retry_count int NOT NULL DEFAULT 0,
    last_error text NULL,
    created_at timestamp NOT NULL DEFAULT now(),
    published_at timestamp NULL
);
 
CREATE INDEX idx_outbox_pending
    ON outbox_events (status, created_at)
    WHERE status = 'PENDING';

aggregate_id는 business 영향 범위를 찾는 key다.

event_key는 Kafka partition key로 사용된다.

event_version은 consumer가 payload 변경을 판단하는 기준이다.

status, retry_count, last_error는 운영 복구를 위한 column이다.

Service Transaction

Spring service는 business 변경과 outbox insert를 같은 transaction에서 처리한다.

@Transactional
public void pay(OrderPayCommand command) {
    Order order = orderRepository.getById(command.orderId());
    order.markPaid(command.paymentId(), command.amount());
 
    outboxRepository.save(OutboxEvent.of(
        UUID.randomUUID(),
        "Order",
        order.id(),
        "OrderPaid",
        1,
        order.id(),
        OrderPaidPayload.from(order)
    ));
}

이 method 안에서는 Kafka를 직접 호출하지 않는다.

transaction이 rollback되면 order 변경과 outbox row가 함께 사라진다.

transaction이 commit되면 event 발행 근거가 DB에 남는다.

Polling Publisher

가장 단순한 publisher는 pending row를 주기적으로 읽어 Kafka로 보낸다.

동시에 여러 publisher가 돌 수 있다면 row lock을 잡아야 한다.

SELECT *
FROM outbox_events
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;

SKIP LOCKED는 이미 다른 publisher가 잡은 row를 건너뛰게 한다.

publisher는 batch를 너무 크게 잡지 않는다.

batch가 크면 한 번의 장애에서 재시도 범위가 커지고 DB lock 시간이 길어진다.

Status 전이

outbox status는 단순 boolean보다 명시적인 값이 낫다.

PENDING -> PUBLISHING -> PUBLISHED
                 -> FAILED

Publish 성공과 중복

Kafka publish 성공 후 outbox status update가 실패할 수 있다.

그러면 같은 outbox row가 다시 발행된다.

따라서 outbox publisher는 at-least-once 발행으로 보는 것이 현실적이다.

consumer는 event id 기준으로 중복을 건너뛰어야 한다.

outbox를 썼다는 사실만으로 exactly-once가 되지 않는다.

순서

outbox row의 created_at 순서와 DB commit 순서는 완전히 같지 않을 수 있다.

정확한 commit 순서가 중요하면 DB sequence, transaction id, CDC 기반 순서를 검토한다.

Kafka에서도 순서는 partition key 기준이다.

주문별 순서가 중요하면 같은 aggregate_idevent_key로 사용해 같은 partition에 보낸다.

여러 aggregate를 가로지르는 전역 순서를 기대하면 병렬성과 충돌한다.

실패 모델

  • service transaction 밖에서 outbox row를 insert한다.
  • outbox insert 후 Kafka도 즉시 보내고, 실패 처리를 두 갈래로 만든다.
  • publisher가 row lock 없이 pending을 읽어 여러 인스턴스가 같은 row를 발행한다.
  • publish 성공 후 status update 실패를 중복 가능성으로 보지 않는다.
  • published row를 무기한 보관해 운영 DB table이 커진다.

이 실패들은 대부분 정상 플로우 테스트에서는 보이지 않는다.

publisher crash, Kafka timeout, DB lock 경합을 의도적으로 테스트해야 한다.

운영 지표

  • outbox pending count: 발행 대기량이다.
  • oldest pending age: 가장 오래 지연된 event다.
  • publish success/failure count: Kafka 발행 결과다.
  • status stuck count: PUBLISHING이나 FAILED에 오래 머무는 row다.
  • duplicate publish count: 같은 outbox id가 여러 번 발행된 횟수다.
  • cleanup lag: published row 정리가 밀린 시간이다.

outbox 지표는 API 지표와 함께 봐야 한다.

주문 API 성공률은 높은데 outbox pending age가 늘면 downstream은 늦어진다.

개인 프로젝트 기준

개인 프로젝트에서는 polling publisher 하나로 시작해도 된다.

필수 요소는 다음이다.

  • outbox table schema
  • business row와 outbox row를 같은 @Transactional에서 저장하는 코드
  • pending row를 읽어 Kafka 또는 log로 발행하는 scheduler
  • 발행 실패 시 FAILED 또는 retry_count를 남기는 처리
  • 같은 outbox row가 두 번 발행돼도 consumer가 한 번만 반영하는 테스트

이 정도면 outbox의 핵심인 “잃어버리지 않는 발행 근거”를 설명할 수 있다.

위험 신호!

  • outbox table이 payload dump column 하나뿐이다.
  • event version과 event key가 없다.
  • publisher가 pending row를 lock 없이 병렬로 읽는다.
  • outbox를 쓰기 때문에 consumer 중복 처리가 필요 없다고 말한다.
  • cleanup 정책 없이 운영 DB에 published row를 계속 쌓는다.

확인 질문

확인 질문

  • business row 변경과 outbox insert가 같은 DB transaction 안에 있는가?
    • 같은 @Transactional 경계와 같은 datasource 안에 있어야 한다.
  • publish 성공 후 status update가 실패하면 어떤 일이 생기는가?
    • 같은 event가 다시 발행될 수 있으므로 consumer dedup이 필요하다.
  • outbox row가 오래 pending이면 누가 어떤 절차로 처리하는가?
    • pending age alert, retry policy, failed 분류, 재발행 runbook이 있어야 한다.

참고 문서