본문 바로가기
inpilot.dev

DB는 커밋됐는데 이벤트를 못 보냈다 — 아웃박스 패턴

2026-05-30 · 3 min read

$ SELECT count(*) FROM outbox

pending | 1 — 유실이 아니라 미발행

두 시스템에 동시에 쓸 수는 없다

with db.transaction():
    db.execute("INSERT INTO orders ...")
    queue.publish("order.created", payload)   # 여기서 실패하면?

publish가 실패해 트랜잭션이 롤백되면 주문이 사라진다. 반대로 순서를 바꾸면, 발행은 됐는데 커밋이 실패해 없는 주문의 이벤트가 돌아다닌다.

트랜잭션 밖으로 빼도 마찬가지다. 커밋 직후 프로세스가 죽으면 이벤트만 유실된다. DB와 메시지 브로커는 서로 다른 시스템이라 원자적으로 묶을 방법이 없다. (2PC라는 방법이 있긴 한데, 대부분의 매니지드 브로커는 지원 안 하고 느리다.)

이벤트도 DB에 쓴다

같은 DB에 쓰면 같은 트랜잭션에 들어간다. 그게 전부다.

CREATE TABLE outbox (
  id          BIGSERIAL   PRIMARY KEY,
  topic       TEXT        NOT NULL,
  payload     JSONB       NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at TIMESTAMPTZ
);
with db.transaction():
    db.execute("INSERT INTO orders ...")
    db.execute("INSERT INTO outbox (topic, payload) VALUES (%s, %s)",
               "order.created", payload)

주문과 아웃박스가 같은 트랜잭션으로 커밋되고 릴레이가 브로커로 발행한다
유실 창은 사라지지만 중복은 남는다. 그래서 소비자가 멱등해야 한다

주문과 이벤트가 함께 커밋되거나 함께 롤백된다. 원자성이 확보됐다. 발행은 별도 프로세스가 아웃박스를 읽어서 한다.

def relay():
    rows = db.query("""
        SELECT * FROM outbox WHERE published_at IS NULL
        ORDER BY id LIMIT 100
        FOR UPDATE SKIP LOCKED
    """)
    for r in rows:
        queue.publish(r.topic, r.payload)
        db.execute("UPDATE outbox SET published_at = now() WHERE id = %s", r.id)

FOR UPDATE SKIP LOCKED가 핵심이다. 릴레이를 여러 대 띄워도 같은 행을 두 번 집지 않는다. 잠긴 행은 건너뛰고 다음 걸 가져간다. 이거 없이 짜면 릴레이를 스케일 아웃하는 순간 중복 발행이 폭증한다.

중복은 남는다

publish는 성공했는데 UPDATE published_at 직전에 죽으면, 다음 실행에서 같은 이벤트를 또 보낸다. 이건 못 없앤다. 아웃박스가 주는 보장은 정확히 한 번이 아니라 at-least-once다.

그래서 짝이 되는 요구사항이 생긴다. 소비자가 멱등해야 한다.

def handle(event):
    if already_processed(event.id):     # 처리 로그를 PK로 두고 INSERT 충돌로 판정
        return
    ...

여기서 "정확히 한 번"을 만들려고 릴레이를 더 정교하게 짜는 건 방향이 틀렸다. 분산 시스템에서 exactly-once 전달은 만들 수 없고, 중복 전달 + 멱등 처리로 exactly-once 효과를 만드는 게 표준 답이다.

순서

ORDER BY id로 읽어도 발행이 병렬이면 순서가 섞인다. 순서가 중요한 이벤트(주문 생성 → 결제 완료)는 같은 키를 같은 파티션으로 보낸다.

queue.publish(r.topic, r.payload, partition_key=r.payload["order_id"])

전체 순서가 아니라 엔티티 단위 순서만 지키면 대부분 충분하다. 전역 순서를 요구하면 릴레이가 단일 스레드가 되고 처리량이 죽는다.

청소

아웃박스는 무한히 커진다. 발행된 행은 지운다.

DELETE FROM outbox
WHERE published_at IS NOT NULL AND published_at < now() - interval '7 days';

7일을 남기는 이유는 장애 조사 때문이다. "이 이벤트 진짜 보냈냐"는 질문에 답할 수 있는 기간이다. 인덱스는 (published_at) WHERE published_at IS NULL 같은 부분 인덱스로 두면 미발행분 조회가 테이블 크기와 무관해진다.

안 쓰는 게 나을 때

이벤트가 유실돼도 되는 종류(분석용 로그, 알림 하나)면 안 쓴다. 테이블 하나, 릴레이 프로세스 하나, 청소 잡 하나가 늘어나는 비용이 있다.

기준은 단순하다. "이 이벤트가 유실되면 돈이나 데이터가 안 맞는가." 그렇다면 아웃박스, 아니라면 그냥 발행하고 실패는 로그로 남긴다.

검증

def test_no_event_lost_on_crash():
    """publish 직후 죽어도 주문과 이벤트가 함께 남아야 한다"""
    with pytest.raises(BrokenPipe):
        create_order_then_crash_during_relay()
    assert order_exists(oid)
    assert outbox_pending_count() == 1      # 유실이 아니라 미발행 상태로 남음
    relay()
    assert published_count(oid) >= 1        # 재실행하면 반드시 나간다

한 줄 요약

이벤트를 같은 트랜잭션에 INSERT하고, SKIP LOCKED로 릴레이하고, 소비자를 멱등하게 만든다.