- Published on
Outbox 패턴 중복발행·순서꼬임 실무 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 트랜잭션과 메시지 발행을 분리하면서도 정합성을 지키기 위해 Outbox 패턴을 도입하면, 초기에는 "이제 데이터 유실은 없다"는 안정감을 얻습니다. 하지만 운영 구간에서 반드시 마주치는 두 가지가 있습니다. 첫째는 중복 발행(같은 이벤트가 두 번 이상 브로커로 나감), 둘째는 순서 꼬임(같은 엔티티의 이벤트가 역순으로 처리됨)입니다.
이 글은 "이론적으로는 at-least-once" 같은 선언을 넘어서, 실제 장애를 줄이기 위한 스키마/쿼리/락/리트라이/컨슈머 멱등성/키 기반 순서 보장까지 한 번에 정리합니다.
1) Outbox에서 중복 발행이 생기는 진짜 원인
Outbox의 기본 흐름은 다음과 같습니다.
- 비즈니스 트랜잭션에서 도메인 변경과 함께 outbox 테이블에 이벤트를 기록
- 별도 퍼블리셔가 outbox를 읽어 브로커로 발행
- 발행 성공 시 outbox 레코드를 처리 완료로 마킹
문제는 2와 3 사이, 그리고 재시작/장애/타임아웃에서 터집니다.
1-1) 발행 성공했지만 ACK 마킹 전에 프로세스가 죽는 경우
브로커에는 이미 메시지가 들어갔는데, 퍼블리셔가 DB 업데이트를 못 하고 죽으면 outbox는 "미처리"로 남습니다. 다음 실행에서 같은 레코드를 다시 읽어 재발행합니다.
1-2) 여러 퍼블리셔 인스턴스가 같은 레코드를 잡는 경쟁
폴링 기반에서 흔합니다. 단순히 status = PENDING을 SELECT로 읽기만 하면, 여러 워커가 같은 행을 동시에 읽고 동시에 발행합니다.
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는 "장애를 만드는 구성요소"가 아니라 "장애를 흡수하는 완충지대"가 됩니다.