Published on

Outbox 패턴 중복발행·순서꼬임 실무 해결법

Authors

서버 트랜잭션과 메시지 발행을 분리하면서도 정합성을 지키기 위해 Outbox 패턴을 도입하면, 초기에는 "이제 데이터 유실은 없다"는 안정감을 얻습니다. 하지만 운영 구간에서 반드시 마주치는 두 가지가 있습니다. 첫째는 중복 발행(같은 이벤트가 두 번 이상 브로커로 나감), 둘째는 순서 꼬임(같은 엔티티의 이벤트가 역순으로 처리됨)입니다.

이 글은 "이론적으로는 at-least-once" 같은 선언을 넘어서, 실제 장애를 줄이기 위한 스키마/쿼리/락/리트라이/컨슈머 멱등성/키 기반 순서 보장까지 한 번에 정리합니다.

1) Outbox에서 중복 발행이 생기는 진짜 원인

Outbox의 기본 흐름은 다음과 같습니다.

  1. 비즈니스 트랜잭션에서 도메인 변경과 함께 outbox 테이블에 이벤트를 기록
  2. 별도 퍼블리셔가 outbox를 읽어 브로커로 발행
  3. 발행 성공 시 outbox 레코드를 처리 완료로 마킹

문제는 2와 3 사이, 그리고 재시작/장애/타임아웃에서 터집니다.

1-1) 발행 성공했지만 ACK 마킹 전에 프로세스가 죽는 경우

브로커에는 이미 메시지가 들어갔는데, 퍼블리셔가 DB 업데이트를 못 하고 죽으면 outbox는 "미처리"로 남습니다. 다음 실행에서 같은 레코드를 다시 읽어 재발행합니다.

1-2) 여러 퍼블리셔 인스턴스가 같은 레코드를 잡는 경쟁

폴링 기반에서 흔합니다. 단순히 status = PENDINGSELECT로 읽기만 하면, 여러 워커가 같은 행을 동시에 읽고 동시에 발행합니다.

1-3) 네트워크 타임아웃, 브로커의 "모호한 성공"

프로듀서가 타임아웃을 받았다고 해서 실제로 브로커에 저장되지 않았다고 단정할 수 없습니다. 이 경우 리트라이가 중복을 만듭니다.

이 패턴은 OpenAI나 외부 API 호출에서도 동일합니다. 재시도는 필요하지만, 멱등 키와 중복 방지 장치가 없으면 재시도가 곧 중복 실행이 됩니다. 재시도/큐잉 설계의 관점은 다음 글도 함께 참고하면 좋습니다.

2) 순서 꼬임은 왜 더 자주 발생하는가

중복은 멱등으로 흡수할 수 있지만, 순서 꼬임은 상태머신을 망가뜨립니다.

2-1) outbox 테이블에서 전역 정렬만 하고 발행하는 경우

예를 들어 ORDER BY created_at로만 발행하면, 같은 엔티티의 이벤트가 항상 인접해 있다는 보장이 없습니다. 또한 여러 퍼블리셔가 병렬로 처리하면 순서가 더 쉽게 섞입니다.

2-2) 브로커 파티셔닝과 키 설계 미스매치

Kafka 같은 파티션 기반 브로커에서 키가 다르면 파티션이 달라지고, 파티션이 다르면 순서 보장이 깨집니다. 같은 aggregate에 대한 이벤트인데 키를 이벤트마다 다르게 잡으면 순서가 깨집니다.

2-3) 컨슈머 측 동시성

컨슈머가 메시지를 병렬 처리하면서 동일 엔티티의 이벤트를 동시에 처리하면 역전이 발생합니다. 특히 "처리 시간"이 이벤트마다 다를 때 쉽게 뒤집힙니다.

3) 스키마: 중복과 순서 문제를 줄이는 outbox 테이블 설계

실무에서 가장 효과가 큰 건 스키마에 "운영에 필요한 칼럼"을 넣는 것입니다.

3-1) 추천 스키마 예시 (PostgreSQL)

CREATE TABLE outbox_event (
  id              BIGSERIAL PRIMARY KEY,
  event_id        UUID NOT NULL,
  aggregate_type  TEXT NOT NULL,
  aggregate_id    TEXT NOT NULL,
  aggregate_seq   BIGINT NOT NULL,
  event_type      TEXT NOT NULL,
  payload         JSONB NOT NULL,
  status          TEXT NOT NULL DEFAULT 'PENDING',
  locked_by       TEXT,
  locked_at       TIMESTAMPTZ,
  published_at    TIMESTAMPTZ,
  publish_attempt INT NOT NULL DEFAULT 0,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (event_id),
  UNIQUE (aggregate_type, aggregate_id, aggregate_seq)
);

CREATE INDEX idx_outbox_pending ON outbox_event (status, created_at);
CREATE INDEX idx_outbox_agg_order ON outbox_event (aggregate_type, aggregate_id, aggregate_seq);

핵심은 두 가지입니다.

  • event_id 유니크로 동일 이벤트의 중복 기록을 차단
  • aggregate_seq같은 aggregate 내 순서를 DB 레벨에서 모델링

aggregate_seq는 보통 "해당 aggregate의 버전"으로 잡습니다. 예를 들어 주문(order)의 상태 변경이 일어날 때마다 version+1하고, 그 값을 outbox에도 저장합니다.

4) 퍼블리셔: "한 번만 집어가게" 만드는 락과 클레임 전략

4-1) 잘못된 접근: 그냥 읽고 발행

SELECT * FROM outbox_event
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 100;

이 방식은 멀티 인스턴스에서 중복 발행을 거의 보장합니다.

4-2) 권장 접근: SELECT ... FOR UPDATE SKIP LOCKED로 클레임

PostgreSQL 기준으로, "읽기"가 아니라 "가져가기"를 원자적으로 수행합니다.

BEGIN;

WITH cte AS (
  SELECT id
  FROM outbox_event
  WHERE status = 'PENDING'
    AND (locked_at IS NULL OR locked_at < now() - interval '2 minutes')
  ORDER BY created_at
  FOR UPDATE SKIP LOCKED
  LIMIT 100
)
UPDATE outbox_event e
SET status = 'PROCESSING',
    locked_by = 'publisher-1',
    locked_at = now(),
    publish_attempt = publish_attempt + 1
FROM cte
WHERE e.id = cte.id
RETURNING e.*;

COMMIT;

포인트는 다음과 같습니다.

  • FOR UPDATE SKIP LOCKED로 다른 워커가 잡은 행은 건너뜀
  • locked_at 타임아웃으로 "죽은 워커"가 잡고 있던 행을 회수
  • publish_attempt로 재시도를 관측 가능하게 만들기

4-3) 발행 후 완료 마킹은 반드시 조건부로

발행 성공 후 업데이트할 때는, 상태가 PROCESSING이고 locked_by가 나 자신인 경우에만 완료로 바꿔야 합니다.

UPDATE outbox_event
SET status = 'PUBLISHED',
    published_at = now()
WHERE id = $1
  AND status = 'PROCESSING'
  AND locked_by = $2;

이렇게 하면 락 만료로 다른 워커가 회수한 레코드를, 오래 걸린 워커가 뒤늦게 덮어쓰는 사고를 줄입니다.

5) "중복 발행"은 없앨 수 없고, 줄이고 흡수해야 한다

Outbox는 보통 at-least-once를 전제로 합니다. 즉, 최종적으로는 컨슈머가 멱등해야 합니다. 다만 운영 비용을 줄이기 위해 "발행 중복" 자체도 최대한 줄이는 게 좋습니다.

5-1) 프로듀서 멱등 키를 브로커 메시지에 포함

메시지 헤더나 바디에 event_id를 넣고, 컨슈머가 이를 기반으로 dedup 합니다.

{
  "event_id": "b3b2b2b7-1b2b-4d7c-9c1a-2a4f7c3a9c11",
  "aggregate_type": "order",
  "aggregate_id": "ORD-10001",
  "aggregate_seq": 42,
  "event_type": "OrderPaid",
  "payload": { "paid_at": "2026-02-24T10:00:00Z" }
}

5-2) 컨슈머 dedup 테이블(또는 캐시)로 멱등 처리

DB에 processed_event 같은 테이블을 두고 event_id를 유니크로 잡습니다.

CREATE TABLE processed_event (
  event_id UUID PRIMARY KEY,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

컨슈머 처리 로직은 다음처럼 "먼저 삽입"하고, 유니크 충돌이면 스킵합니다.

BEGIN;

INSERT INTO processed_event(event_id)
VALUES ($1)
ON CONFLICT DO NOTHING;

-- 삽입 성공 여부를 확인한 뒤에만 비즈니스 처리

COMMIT;

이 방식은 단순하고 강력하지만, processed_event가 무한히 커집니다. 운영에서는 보통 다음 중 하나를 섞습니다.

  • TTL 가능한 Redis에 event_id를 일정 기간 저장
  • DB는 파티셔닝 후 오래된 파티션 드롭
  • 이벤트가 "영원히 중복되면 안 되는" 종류만 DB에 저장

6) 순서 보장: aggregate 단위로 "한 줄"을 만들기

6-1) 가장 안전한 원칙: 같은 aggregate는 같은 파티션 키

Kafka를 쓴다면 키를 aggregate_id로 고정합니다. 그러면 파티션 내 순서가 지켜집니다.

  • 키: aggregate_id
  • 값: 이벤트 페이로드

단, 컨슈머가 파티션 내에서도 병렬 처리하면 순서가 깨질 수 있으니, 같은 키에 대해 동시 처리를 막아야 합니다.

6-2) 컨슈머에서 aggregate_seq로 재정렬 또는 가드

"항상 순서대로 오게" 만드는 것보다, "순서가 어긋나면 처리하지 않고 보류"하는 가드가 실무적으로 유용합니다.

예: 주문 aggregate의 마지막 처리 시퀀스를 저장하고, 다음 시퀀스가 아니면 재시도 큐로 보냅니다.

CREATE TABLE aggregate_checkpoint (
  aggregate_type TEXT NOT NULL,
  aggregate_id   TEXT NOT NULL,
  last_seq       BIGINT NOT NULL,
  PRIMARY KEY (aggregate_type, aggregate_id)
);

처리 시 의사코드:

1) 현재 이벤트 seq를 읽는다
2) checkpoint.last_seq + 1 이 아니면 보류(재시도)
3) 비즈니스 처리
4) checkpoint.last_seq를 seq로 업데이트

이때 보류가 무한 재시도로 변질되지 않게, 재시도 횟수와 데드레터 큐를 둬야 합니다. 워커/큐의 무한 재시도는 "중복 실행"과 결합되어 장애를 크게 만듭니다. Celery 운영에서 비슷한 케이스를 다룬 글도 참고할 만합니다.

7) 퍼블리셔 배치/폴링 튜닝: "순서"와 "처리량"의 트레이드오프

7-1) 전역 폴링은 쉽지만, 순서 요구가 강하면 위험

전역 created_at 기준으로 100개씩 집어가면 처리량은 잘 나오지만, 특정 aggregate에 대한 이벤트가 여러 워커로 흩어질 수 있습니다.

7-2) 실무 대안: aggregate 단위로 클레임

요구사항이 "같은 주문의 이벤트는 반드시 순서대로"라면, outbox를 집어갈 때부터 aggregate를 기준으로 묶습니다.

전략 예시:

  • 1차로 aggregate_id 목록을 클레임
  • 각 aggregate에 대해 aggregate_seq 오름차순으로 처리

다만 구현 복잡도가 올라가므로, 보통은 "브로커 키로 파티션 순서 보장"과 "컨슈머 가드"로 해결하고, 퍼블리셔는 단순하게 유지하는 편이 유지보수에 유리합니다.

8) 장애 시나리오별 체크리스트

8-1) 중복 발행이 급증했다

  • 퍼블리셔가 멀티 인스턴스인데 SKIP LOCKED 같은 클레임이 없는지 확인
  • locked_at 만료 시간이 너무 짧아 "정상 처리 중 회수"가 발생하는지 확인
  • 브로커 타임아웃이 잦아 모호한 성공이 늘었는지 확인
  • 퍼블리셔 재시작 루프가 있는지 확인(짧은 주기로 죽고 살아나면 중복이 폭증)

8-2) 순서 꼬임이 발생했다

  • 브로커 파티션 키가 aggregate_id로 고정되어 있는지 확인
  • 컨슈머가 같은 파티션에서 병렬 처리하고 있지 않은지 확인
  • 이벤트에 aggregate_seq가 존재하고, 컨슈머가 이를 검증하는지 확인

8-3) outbox가 쌓이면서 전체 지연이 커졌다

  • DB 인덱스가 status, created_at에 맞게 있는지 확인
  • 퍼블리셔 배치 크기와 폴링 주기를 조정
  • DB 자체가 느려졌다면 복제 지연이나 I/O 병목을 같이 점검

DB 지연이 outbox 적체로 이어지는 경우가 많습니다. 특히 복제 지연이 커지면 읽기 분리 구조에서 outbox 조회가 꼬일 수 있어, 아래 글의 튜닝 관점도 도움이 됩니다.

9) 실무 권장 아키텍처 요약

9-1) 반드시 지킬 것

  • outbox 레코드에 event_id 유니크
  • 같은 aggregate에 aggregate_seq를 부여하고 유니크 제약
  • 퍼블리셔는 FOR UPDATE SKIP LOCKED 등으로 "클레임"을 원자적으로
  • 컨슈머는 event_id 기반 멱등 처리(저장소는 DB 또는 TTL 캐시)
  • 순서가 중요한 도메인은 aggregate_id를 메시지 키로 고정하고, 컨슈머에서 aggregate_seq 가드

9-2) 있으면 운영이 편해지는 것

  • publish_attempt, locked_at, locked_by로 관측/디버깅 가능성 확보
  • 재시도 정책에 지수 백오프와 데드레터 큐
  • outbox 적체량, 처리율, 평균 락 점유 시간, 중복률(컨슈머 dedup hit)을 메트릭으로 수집

10) 마무리: "중복은 허용하되, 순서와 멱등을 설계하라"

Outbox 패턴은 데이터 유실을 줄여주지만, 중복과 순서 문제를 자동으로 해결해주지는 않습니다. 실무에서는 다음 한 줄로 정리됩니다.

  • 발행은 중복될 수 있다: 그러니 event_id로 멱등을 강제하라.
  • 순서는 의도적으로 보장해야 한다: 같은 aggregate는 같은 키로 흘리고, aggregate_seq로 가드하라.

이 두 가지를 스키마 제약, 퍼블리셔 클레임, 컨슈머 멱등 처리로 겹겹이 쌓으면, Outbox는 "장애를 만드는 구성요소"가 아니라 "장애를 흡수하는 완충지대"가 됩니다.