Post

믿을 수 있는 서비스 만들기 (1) - Outbox Design

믿을 수 있는 서비스 만들기 (1) - Outbox Design

여러 Batch Task를 순차적으로 실행해야 할 때, Redis Pub/Sub이든 Kafka 토픽이든 “이벤트를 발행하고 → 리스너가 다음 Task를 호출한다”는 구조를 쉽게 떠올릴 수 있다. 그러나 바로 여기서 문제가 생긴다.

  • Task 수행 중 오류가 발생하면, 후속 Task가 정상적으로 호출되지 않는다.
  • Task는 성공했지만 이벤트 발행이 실패하거나 네트워크 순단으로 유실될 수도 있다.
  • 같은 이벤트를 여러 워커가 동시에 수신해 중복 실행이 일어날 가능성도 존재한다.

이러한 문제는 “특정 미들웨어”가 마법처럼 해결해줄 수 있는 문제가 아니다. 추가 인프라를 크게 늘리지 않고 정확히 한번(Exactly‑Once) 실행을 보장하기 위해, 우리는 Transactional Outbox Pattern + Polling을 도입했다.

핵심 관심사는 하나다. “Task가 정상적으로 완료되면, 그 사실을 단 한 번, 확실히 Outbox 테이블에 기록한다.”

목표

  1. 순차 실행 : Task A → B → C … 정의된 의존 순서대로 자동 트리거
  2. 자동 재시도 : 장애·오류 발생 시 백오프 재시도 & 데드레터 처리
  3. 다중 워커 : 여러 워커 인스턴스가 번갈아 스텝을 처리하되, ‑ 한 워커는 한 스텝씩만 실행 → 충돌 없이 병렬 처리

테이블 구성

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로 모든 Taskoutbox_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
후속 Taskpre_cnt = pre_cnt‑1
  · 0 → READY 전이, visible_at = now()
Failure TXattempts++, visible_at += backoff (일정 주기 이후), status=READY (재시도)
완료 판정배치 모든 Task 가 DONEbatch_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로 전환되므로 별도의 INSERTMERGE 문이 필요 없다. 덕분에 트랜잭션 경계도 명확하고 데드락 가능성도 낮아진다.

3. 운영 지표를 한눈에 파악할 수 있다

READY / PROCESSING / DONE 현황이 모두 outbox_evt 한 테이블에 담기므로, 단일 쿼리(또는 대시보드)로 현재 백로그·실행 중 작업·완료율을 즉시 확인할 수 있다. 모니터링 구성이 간단해지고 알림 조건도 명확해진다.

4. 일반적인 배치 규모에 적합하다

대부분의 ETL·정산·파일 처리 배치는 Task 수가 수십 개 내외이며, 조건 분기도 단순한 경우가 많다. 이런 환경에서는 초기 INSERT 부하가 크지 않고, 불필요한 Task 행이 시스템에 부담을 주지도 않는다.


주의할 점과 대응 방안

  • Task 수가 수천—수만 개로 늘어나는 경우

    초기 INSERT가 병목이 될 수 있다. 이때는 파티셔닝(예: batch_id 단위 파티션) 또는 “필요할 때 INSERT” 방식(후속 Task를 동적으로 삽입)으로 전환하는 것이 좋다.

  • 복잡한 조건 분기가 존재하는 경우

    조건이 거짓인 Task도 선삽입 되므로 READY 큐가 불필요하게 커질 수 있다. 조건 평가 후 즉시 status = SKIPPED로 바꿔 주어 Poller의 조회 범위를 최소화하면 문제를 완화할 수 있다.


이와 같이 전 Task 선삽입 방식스냅샷 안정성·SQL 단순성·운영 편의성 측면에서 이점을 제공하며, 일반적인 배치 워크플로에 가장 실용적인 선택지로 작동한다.

어플리케이션의 코드는 다음 시간에 …

This post is licensed under CC BY 4.0 by the author.