믿을 수 있는 서비스 만들기 (1) - Outbox Design
여러 Batch Task를 순차적으로 실행해야 할 때, Redis Pub/Sub이든 Kafka 토픽이든 “이벤트를 발행하고 → 리스너가 다음 Task를 호출한다”는 구조를 쉽게 떠올릴 수 있다. 그러나 바로 여기서 문제가 생긴다.
- Task 수행 중 오류가 발생하면, 후속 Task가 정상적으로 호출되지 않는다.
- Task는 성공했지만 이벤트 발행이 실패하거나 네트워크 순단으로 유실될 수도 있다.
- 같은 이벤트를 여러 워커가 동시에 수신해 중복 실행이 일어날 가능성도 존재한다.
이러한 문제는 “특정 미들웨어”가 마법처럼 해결해줄 수 있는 문제가 아니다. 추가 인프라를 크게 늘리지 않고 정확히 한번(Exactly‑Once) 실행을 보장하기 위해, 우리는 Transactional Outbox Pattern + Polling을 도입했다.
핵심 관심사는 하나다. “Task가 정상적으로 완료되면, 그 사실을 단 한 번, 확실히 Outbox 테이블에 기록한다.”
목표
- 순차 실행 : Task A → B → C … 정의된 의존 순서대로 자동 트리거
- 자동 재시도 : 장애·오류 발생 시 백오프 재시도 & 데드레터 처리
- 다중 워커 : 여러 워커 인스턴스가 번갈아 스텝을 처리하되, ‑ 한 워커는 한 스텝씩만 실행 → 충돌 없이 병렬 처리
테이블 구성
1
2
3
/* ---------- 상태 ENUM ---------- */
CREATE TYPE outbox_status AS ENUM ('READY', 'PROCESSING', 'DONE', 'FAILED');
CREATE TYPE batch_status AS ENUM ('RUNNING', 'DONE', 'FAILED', 'ABORTED');
1
2
3
4
5
6
7
/* ---------- DAG(의존관계) 테이블 ---------- */
CREATE TABLE task_dep (
version INTEGER NOT NULL,
from_key TEXT NOT NULL,
to_key TEXT NOT NULL,
PRIMARY KEY (version, from_key, to_key)
);
1
2
3
4
5
6
7
8
9
/* ---------- 배치 인스턴스 ---------- */
CREATE TABLE batch_instance (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
dag_version INTEGER NOT NULL,
biz_key TEXT NOT NULL,
status batch_status NOT NULL DEFAULT 'RUNNING',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* ---------- Outbox / Task 큐 ---------- */
CREATE TABLE outbox_evt (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
batch_id BIGINT NOT NULL REFERENCES batch_instance(id) ON DELETE CASCADE,
task_key TEXT NOT NULL,
pre_cnt INTEGER NOT NULL, -- 남은 선행 수 (0 ⇒ 실행 가능)
status outbox_status NOT NULL DEFAULT 'READY',
worker_id TEXT,
visible_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 백오프용
attempts INTEGER NOT NULL DEFAULT 0,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
컴포넌트 & 실행 흐름
| 컴포넌트 | 책임 |
|---|---|
| Pusher (Starter) | ① batch_instance INSERT(status=RUNNING)② 동일 TX로 모든 Task 를 outbox_evt 에 INSERT· pre_cnt = indegree· status = READY |
| Poller (다중 워커) | 주기적(@Scheduled)으로SELECT … FROM outbox_evt WHERE status='READY' AND pre_cnt=0 AND visible_at <= now() LIMIT 1 FOR UPDATE SKIP LOCKED;→ status = PROCESSING → 도메인 로직 실행 |
| Success TX | ① 현재 Task status=DONE② 후속 Task 의 pre_cnt = pre_cnt‑1· 0 → READY 전이, visible_at = now() |
| Failure TX | attempts++, visible_at += backoff (일정 주기 이후), status=READY (재시도) |
| 완료 판정 | 배치 모든 Task 가 DONE → batch_instance.status = DONE |
“전 Task 선삽입 + pre_cnt 감소” 전략을 택한 이유
“완료 후 이후 Task 추가” 가 아닌 “전 Task 선삽입 + pre_cnt 감소” 전략을 택한 이유
1. 실행 중 DAG 변경에 안전하다
배치 인스턴스를 만들 때 모든 Task 행을 outbox_evt 테이블에 기록하고, 각 행에 선행 개수(pre_cnt)를 고정값으로 채워 둔다. 실행 도중 DAG 정의가 수정되더라도 이미 생성된 인스턴스에는 영향을 주지 않는다. 배치가 “어느 시점의 DAG 스냅샷”을 그대로 보존한다는 점이 가장 큰 장점이다.
2. 트랜잭션과 SQL 로직이 단순하다
Task 완료 처리 시 필요한 작업은 두 단계뿐이다.
1
2
3
4
UPDATE outbox_evt
SET pre_cnt = pre_cnt - 1,
status = CASE WHEN pre_cnt = 1 THEN 'READY' ELSE status END,
visible_at = CASE WHEN pre_cnt = 1 THEN now() ELSE visible_at END;
pre_cnt가 0이 되면 곧바로 status = READY로 전환되므로 별도의 INSERT 나 MERGE 문이 필요 없다. 덕분에 트랜잭션 경계도 명확하고 데드락 가능성도 낮아진다.
3. 운영 지표를 한눈에 파악할 수 있다
READY / PROCESSING / DONE 현황이 모두 outbox_evt 한 테이블에 담기므로, 단일 쿼리(또는 대시보드)로 현재 백로그·실행 중 작업·완료율을 즉시 확인할 수 있다. 모니터링 구성이 간단해지고 알림 조건도 명확해진다.
4. 일반적인 배치 규모에 적합하다
대부분의 ETL·정산·파일 처리 배치는 Task 수가 수십 개 내외이며, 조건 분기도 단순한 경우가 많다. 이런 환경에서는 초기 INSERT 부하가 크지 않고, 불필요한 Task 행이 시스템에 부담을 주지도 않는다.
주의할 점과 대응 방안
Task 수가 수천—수만 개로 늘어나는 경우
초기
INSERT가 병목이 될 수 있다. 이때는 파티셔닝(예:batch_id단위 파티션) 또는 “필요할 때 INSERT” 방식(후속 Task를 동적으로 삽입)으로 전환하는 것이 좋다.복잡한 조건 분기가 존재하는 경우
조건이 거짓인 Task도 선삽입 되므로
READY큐가 불필요하게 커질 수 있다. 조건 평가 후 즉시status = SKIPPED로 바꿔 주어 Poller의 조회 범위를 최소화하면 문제를 완화할 수 있다.
이와 같이 전 Task 선삽입 방식은 스냅샷 안정성·SQL 단순성·운영 편의성 측면에서 이점을 제공하며, 일반적인 배치 워크플로에 가장 실용적인 선택지로 작동한다.
어플리케이션의 코드는 다음 시간에 …
