이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- outbox table을 운영 DB 안에 둘 때 어떤 상태와 index를 관리해야 하는가?
- pending, failed, published row를 각각 어떤 기준으로 알림, 재시도, 정리해야 하는가?
- outbox cleanup이 감사와 재처리 요구를 깨지 않게 하려면 무엇을 남겨야 하는가?
- Java/Spring 서비스에서 outbox 운영 지표와 runbook은 어디에 연결되어야 하는가?
개요
outbox는 dual write를 복구 가능한 문제로 바꾸지만, 운영하지 않으면 outbox 자체가 장애 원인이 된다.
pending row가 쌓이면 downstream event가 늦어진다.
failed row가 방치되면 누락이 고착된다.
published row를 무기한 보관하면 운영 DB table과 index가 계속 커진다.
따라서 outbox pattern은 schema보다 상태 관리, 알림, 재시도, cleanup까지 포함해야 완성된다.
상태 모델
outbox 상태는 운영자가 이해할 수 있어야 한다.
PENDING
-> PUBLISHING
-> PUBLISHED
-> FAILED작은 프로젝트에서는 PENDING, PUBLISHED, FAILED만으로 시작해도 된다.
중요한 것은 각 상태의 의미가 명확해야 한다는 점이다.
PENDING: 아직 발행되지 않은 event다.PUBLISHING: publisher가 잡고 처리 중인 event다.PUBLISHED: Kafka 발행 성공을 확인한 event다.FAILED: 자동 재시도 한도를 넘었거나 사람이 봐야 하는 event다.
상태가 모호하면 장애 때 “다시 보내도 되는가”를 판단하기 어렵다.
운영 Schema
운영용 outbox table은 상태와 조사 정보를 포함한다.
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_key varchar(120) NOT NULL,
payload jsonb NOT NULL,
status varchar(20) NOT NULL,
retry_count int NOT NULL DEFAULT 0,
last_error text NULL,
locked_by varchar(80) NULL,
locked_at timestamp NULL,
created_at timestamp NOT NULL DEFAULT now(),
published_at timestamp NULL
);pending 조회는 자주 실행되므로 partial index가 필요하다.
CREATE INDEX idx_outbox_pending_created
ON outbox_events (created_at)
WHERE status = 'PENDING';상태별 count와 oldest age를 빠르게 볼 수 있어야 한다.
Pending 운영
pending은 정상 상태일 수도 있고 장애 신호일 수도 있다.
API 트래픽이 많으면 잠깐 pending이 생기는 것은 자연스럽다.
하지만 oldest pending age가 SLA를 넘으면 downstream 지연이다.
SELECT count(*) AS pending_count,
min(created_at) AS oldest_pending_at
FROM outbox_events
WHERE status = 'PENDING';운영 알림은 count보다 age가 더 중요할 때가 많다.
10만 개 pending이 30초 안에 처리되는 시스템과 100개 pending이 2시간 묶인 시스템은 위험도가 다르다.
Failed 운영
failed row는 버려진 row가 아니다.
조사 대기열이다.
failed에는 최소한 exception class, error message, retry count, 마지막 시각이 남아야 한다.
원인이 schema mismatch라면 consumer 또는 payload 수정이 필요하다.
원인이 Kafka timeout이라면 재시도하면 성공할 수 있다.
원인이 권한이나 topic 삭제라면 운영자가 환경을 먼저 복구해야 한다.
failed row를 재발행하는 admin command는 범위와 승인 절차를 가져야 한다.
Published 정리
published row는 영원히 보관하면 안 된다.
운영 DB의 index와 vacuum, backup, migration 비용이 커진다.
하지만 너무 빨리 지우면 감사와 재처리 근거가 사라진다.
정리 기준은 다음 질문으로 정한다.
- 장애 조사에 며칠치 event 원본이 필요한가?
- 법적 또는 감사 보관 요구가 있는가?
- consumer 재처리 기준은 Kafka retention에 있는가, outbox에 있는가?
- payload에 개인정보가 있어 장기 보관이 위험한가?
보통은 published row를 일정 기간 보관한 뒤 archive table이나 object storage로 옮기고 삭제한다.
Cleanup Job
cleanup은 작은 batch로 실행한다.
DELETE FROM outbox_events
WHERE status = 'PUBLISHED'
AND published_at < now() - interval '14 days'
LIMIT 1000;DB에 따라 DELETE ... LIMIT 문법은 다르므로 실제 구현에서는 id range로 잘라 지운다.
cleanup job도 job history와 checkpoint를 남긴다.
cleanup이 오래 걸리면 API transaction과 publisher query에 영향을 줄 수 있다.
peak 시간에는 멈출 수 있어야 한다.
Publisher Lock
여러 publisher가 동시에 도는 구조에서는 같은 row를 중복으로 잡지 않게 해야 한다.
SELECT id
FROM outbox_events
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;lock을 잡은 row를 발행한 뒤 status를 갱신한다.
publisher가 crash하면 PUBLISHING 상태로 오래 남을 수 있다.
이때는 locked_at 기준으로 다시 PENDING으로 돌리는 복구 절차가 필요하다.
운영 지표
- outbox pending count: 발행 대기량이다.
- oldest pending age: downstream 최대 지연이다.
- publish success/failure count: publisher 결과다.
- failed count by event type: 사람이 봐야 할 event 분포다.
- publishing stuck count: lock을 잡고 오래 멈춘 row다.
- cleanup delete count: 정리 진행량이다.
- outbox table size: 운영 DB 부담이다.
Runbook
outbox runbook은 다음 절차를 포함한다.
1. pending age와 failed count 확인
2. publisher instance와 Kafka topic 상태 확인
3. failed row의 event type, aggregate id, error class 분류
4. 일시 장애면 제한된 범위로 재발행
5. payload/schema 문제면 코드 또는 데이터 수정 후 재발행
6. consumer dedup metric과 downstream 검증 쿼리 확인
7. 작업자, 범위, 결과 기록수동 재발행은 반드시 event id 범위나 created_at 범위를 남긴다.
개인 프로젝트 기준
개인 프로젝트에서는 복잡한 운영 시스템보다 세 가지를 보여주면 충분하다.
- pending/failed/published 상태를 가진 outbox table
- pending age와 failed count를 조회하는 admin endpoint 또는 SQL
- published row cleanup job과 실행 로그
이 정도만 있어도 outbox를 “패턴 예제”가 아니라 운영 가능한 구조로 설명할 수 있다.
위험 신호!
- outbox table에 status와 retry_count가 없다.
- pending count만 보고 oldest pending age를 보지 않는다.
- failed row를 삭제로 처리한다.
- published row 보관 기간이 정해져 있지 않다.
- cleanup job이 운영 DB peak 시간과 충돌한다.
확인 질문
확인 질문
- outbox row가 30분째 pending이면 사용자 기능에 어떤 영향이 있는가?
- event consumer별 SLA와 downstream 최신성으로 답해야 한다.
- failed row를 재발행해도 안전한가?
- consumer idempotency와 event id 기준 dedup이 있어야 한다.
- published row를 지워도 감사와 재처리 요구가 깨지지 않는가?
- 보관 기간, archive 위치, Kafka retention과 함께 판단해야 한다.