Published on

Saga 보상 트랜잭션 실패 재처리 설계 가이드

Authors

서로 다른 마이크로서비스가 각자 로컬 트랜잭션을 커밋하는 환경에서, 전체 일관성을 맞추는 대표적인 방법이 Saga 패턴입니다. 문제는 ‘정방향(Forward) 단계’가 실패했을 때 실행되는 보상 트랜잭션(Compensation) 이 생각보다 자주 실패한다는 점입니다. 네트워크 타임아웃, 외부 결제/배송 API 지연, 락 경합, 중복 이벤트, 서비스 재시작 등으로 보상 호출이 누락되거나 부분적으로만 처리되면, 시스템은 영구적으로 어긋난 상태(예: 결제 취소는 됐는데 재고 복구는 안 됨)에 빠질 수 있습니다.

이 글에서는 “보상 실패를 어떻게 재처리(retry/replay)할 것인가”를 중심으로, 정확히 한 번(Exactly-once)에 가깝게 동작하도록 만드는 설계 원칙과 구현 패턴을 정리합니다. 재시도 자체보다 중요한 것은 멱등성, 상태기계, 기록(로그), 관측성, 운영 플로우입니다.

보상 트랜잭션 실패의 현실적인 원인

보상은 보통 원 트랜잭션보다 더 취약합니다. 이유는 다음과 같습니다.

  1. 보상 대상 리소스가 이미 변함
  • 예: 예약(hold) 해제 전에 재고가 다른 주문으로 재할당됨
  1. 외부 시스템의 비결정성
  • 결제 취소 API가 “처리 중”을 반환하거나, 동일 요청에 다른 응답을 줄 수 있음
  1. 타임아웃과 재시도에 의한 중복 실행
  • 호출자는 타임아웃으로 실패로 판단했지만, 서버는 처리 완료
  1. 이벤트 전달의 at-least-once 특성
  • 메시지 브로커/큐는 중복 전달이 기본인 경우가 많음
  1. 보상 순서/의존성 문제
  • 배송 취소는 결제 취소 이후만 가능 같은 제약

타임아웃/지연은 특히 보상에서 빈번합니다. 재시도 설계를 할 때는 단순 고정 재시도보다 지수 백오프+지터가 필수입니다. (재시도·백오프 설계 자체는 Claude API 529 Overloaded 재시도·백오프 설계에서도 같은 원칙으로 설명됩니다.)

핵심 목표: “재처리 가능하고, 중복 실행에도 안전하게”

보상 실패 재처리 설계의 목표는 아래 4가지를 동시에 만족하는 것입니다.

  • (1) 잃어버리지 않기: 보상 필요 상태를 반드시 저장하고, 프로세스 장애에도 남아야 함
  • (2) 중복에 안전: 동일 보상이 여러 번 실행되어도 결과가 일관적이어야 함(멱등)
  • (3) 순서 보장/의존성 관리: 보상 간 선후관계를 모델링
  • (4) 운영 가능: 재처리 큐, DLQ, 수동 개입(재시도/스킵/포기)까지 포함

이 목표를 달성하려면 “보상 호출”을 단순 함수 호출이 아니라 상태를 가진 작업(Job) 으로 취급해야 합니다.

상태 모델: Saga 로그 + 보상 작업 상태기계

가장 먼저 해야 할 일은 사가 인스턴스 단위의 상태보상 단계 단위의 상태를 명확히 저장하는 것입니다.

권장 테이블/문서 모델

  • saga_instance : 사가 전체 상태
  • saga_step : 각 단계(정방향/보상) 상태
  • outbox : 이벤트/커맨드 발행을 위한 아웃박스(선택이 아니라 사실상 필수)

예시(관계형 DB 기준):

-- 사가 인스턴스
CREATE TABLE saga_instance (
  saga_id           VARCHAR(64) PRIMARY KEY,
  saga_type         VARCHAR(64) NOT NULL,
  status            VARCHAR(32) NOT NULL, -- RUNNING, COMPENSATING, COMPLETED, FAILED
  created_at        TIMESTAMP NOT NULL,
  updated_at        TIMESTAMP NOT NULL
);

-- 단계별 상태 (forward/compensation을 같은 테이블로 관리)
CREATE TABLE saga_step (
  saga_id           VARCHAR(64) NOT NULL,
  step_name         VARCHAR(64) NOT NULL,
  direction         VARCHAR(16) NOT NULL, -- FORWARD / COMPENSATE
  status            VARCHAR(32) NOT NULL, -- PENDING, IN_PROGRESS, SUCCEEDED, RETRYING, DEAD
  attempt           INT NOT NULL DEFAULT 0,
  next_retry_at     TIMESTAMP NULL,
  last_error        TEXT NULL,
  idempotency_key   VARCHAR(128) NOT NULL,
  updated_at        TIMESTAMP NOT NULL,
  PRIMARY KEY (saga_id, step_name, direction)
);

-- 아웃박스(이벤트/커맨드 발행)
CREATE TABLE outbox (
  id               BIGSERIAL PRIMARY KEY,
  aggregate_id     VARCHAR(64) NOT NULL,
  event_type       VARCHAR(128) NOT NULL,
  payload          JSONB NOT NULL,
  status           VARCHAR(32) NOT NULL, -- NEW, PUBLISHED, FAILED
  created_at       TIMESTAMP NOT NULL
);

상태기계 설계 포인트

  • 보상 단계는 최소 PENDING → IN_PROGRESS → SUCCEEDED 흐름을 갖습니다.
  • 실패 시 RETRYING으로 전이하고 next_retry_at을 계산합니다.
  • 일정 횟수/기간 초과 시 DEAD로 보내고 운영자가 처리합니다.
  • 중요한 점: 상태 전이 자체가 트랜잭션으로 원자적이어야 합니다.

멱등성: “보상은 여러 번 호출될 수 있다”를 전제로

보상 재처리에서 멱등성은 선택이 아니라 전제입니다.

멱등성 키 전략

  • idempotency_key = sagaId + stepName + direction
  • 외부 API 호출 시 헤더/필드로 전달(가능하다면)
  • 내부 DB 업데이트는 “이미 처리됨”을 감지 가능해야 함

예: 재고 복구 보상

  • inventory_release(reservationId)가 아니라
  • inventory_compensate(orderId, sagaId) 같이 사가 문맥을 포함

DB에서의 멱등 처리 예시

-- 보상 적용 이력을 남기는 테이블
CREATE TABLE compensation_applied (
  idempotency_key VARCHAR(128) PRIMARY KEY,
  applied_at      TIMESTAMP NOT NULL
);

-- 보상 실행 시
-- 1) 먼저 insert 시도 (이미 있으면 중복 실행)
INSERT INTO compensation_applied(idempotency_key, applied_at)
VALUES (:key, now())
ON CONFLICT DO NOTHING;

-- 2) rowcount=1일 때만 실제 보상 로직 수행

이 패턴은 “중복 이벤트/재시도/타임아웃”을 모두 흡수합니다.

재시도 큐 설계: 폴링 워커 + 스케줄링(또는 지연 큐)

보상 실패 재처리는 보통 두 가지 방식 중 하나로 구현합니다.

  1. DB 기반 스케줄링: next_retry_at <= now() 인 작업을 워커가 주기적으로 가져가 처리
  2. 메시지 브로커 지연 큐: delay/retry 토픽을 사용해 재시도 시점을 브로커에 위임

운영 단순성과 이식성을 고려하면 1)도 충분히 강력합니다.

워커의 동시성 제어(중복 처리 방지)

여러 워커가 같은 보상 작업을 동시에 집어가지 않게 해야 합니다.

  • PostgreSQL: SELECT ... FOR UPDATE SKIP LOCKED
  • MySQL: 비슷한 락 전략 또는 상태 업데이트 CAS
-- 처리할 보상 단계 하나를 락으로 가져오기
WITH cte AS (
  SELECT saga_id, step_name
  FROM saga_step
  WHERE direction='COMPENSATE'
    AND status IN ('PENDING','RETRYING')
    AND (next_retry_at IS NULL OR next_retry_at <= now())
  ORDER BY updated_at ASC
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE saga_step s
SET status='IN_PROGRESS', attempt=attempt+1, updated_at=now()
FROM cte
WHERE s.saga_id=cte.saga_id AND s.step_name=cte.step_name AND s.direction='COMPENSATE'
RETURNING s.*;

이렇게 “가져오기+상태전이”를 한 트랜잭션으로 묶으면, 워커가 죽어도 다음 워커가 이어서 처리할 수 있습니다.

백오프/지터/최대 재시도: 실패 유형별로 다르게

보상 실패에는 재시도 가능한 실패재시도해도 소용없는 실패가 섞여 있습니다.

  • 재시도 가치 높음: 타임아웃, 429/503, 일시적 네트워크 오류
  • 재시도 가치 낮음: 4xx(권한/검증), “이미 취소 불가” 같은 비즈니스 불변 조건

따라서 실패를 분류하고, 분류 결과에 따라 next_retry_atDEAD 전이를 결정해야 합니다.

import random
from datetime import datetime, timedelta

RETRYABLE = {"TIMEOUT", "UNAVAILABLE", "THROTTLED"}

def compute_next_retry(attempt: int, base_seconds: int = 2, cap_seconds: int = 300):
    # exponential backoff with full jitter
    exp = min(cap_seconds, base_seconds * (2 ** max(0, attempt - 1)))
    sleep = random.uniform(0, exp)
    return datetime.utcnow() + timedelta(seconds=sleep)

def classify_error(http_status: int | None, code: str | None):
    if code in RETRYABLE:
        return "RETRY"
    if http_status is not None and http_status >= 500:
        return "RETRY"
    if http_status in (408, 429):
        return "RETRY"
    return "DEAD"

gRPC를 사용한다면 DEADLINE_EXCEEDED 같은 타임아웃 계열은 재시도 후보가 될 수 있지만, 전체 시스템에서 타임아웃이 폭증하면 재시도가 오히려 장애를 증폭시킵니다. 이 경우 타임아웃 원인(리소스, 네트워크, 설정)을 먼저 잡아야 합니다. 관련 트러블슈팅은 EKS에서 gRPC DEADLINE_EXCEEDED 폭증 해결을 참고할 수 있습니다.

아웃박스/사가 로그: “기록이 곧 재처리의 기반”

보상 재처리 설계에서 흔한 실패는 다음입니다.

  • DB에는 상태가 바뀌었는데 이벤트 발행이 누락됨
  • 이벤트는 발행됐는데 DB 상태가 반영되지 않음

이를 막는 대표 패턴이 Transactional Outbox입니다.

보상 커맨드 발행을 아웃박스로 처리

예: 오케스트레이터가 COMPENSATE_INVENTORY 커맨드를 발행해야 한다면,

  • saga_step 상태 업데이트와
  • outbox insert를

하나의 DB 트랜잭션으로 묶습니다.

BEGIN;

UPDATE saga_step
SET status='PENDING', updated_at=now()
WHERE saga_id=:sagaId AND step_name='Inventory' AND direction='COMPENSATE';

INSERT INTO outbox(aggregate_id, event_type, payload, status, created_at)
VALUES (
  :sagaId,
  'COMPENSATE_INVENTORY',
  jsonb_build_object('sagaId', :sagaId, 'orderId', :orderId, 'idempotencyKey', :key),
  'NEW',
  now()
);

COMMIT;

아웃박스 퍼블리셔는 NEW를 읽어 브로커로 발행하고, 성공 시 PUBLISHED로 바꿉니다. 이 구조가 있어야 “보상 실패 재처리”가 재현 가능한(replayable) 작업이 됩니다.

보상 순서와 의존성: 역순 보상 + 조건부 보상

사가 보상은 보통 “성공한 정방향 단계의 역순”으로 실행합니다. 하지만 실무에서는 조건이 더 붙습니다.

  • 어떤 단계는 부분 성공이 존재(예: 결제 승인만 되고 캡처는 안 됨)
  • 어떤 단계는 시간이 지나면 보상 불가(예: 배송이 이미 출고됨)

따라서 saga_step에는 단순 성공/실패 외에 다음 정보를 추가하는 것이 좋습니다.

  • forward_result(예: payment_auth_id)
  • compensation_policy(예: 출고 전까지만 취소 가능)

보상 실행 시에는 “무조건 호출”이 아니라 현재 도메인 상태를 조회하고, 보상이 의미 있는지 판단해야 합니다. 의미가 없다면 SUCCEEDED(또는 SKIPPED)로 종료해 사가가 앞으로 진행되게 해야 합니다.

운영 설계: DLQ, 수동 재처리, 그리고 관측성

보상 실패 재처리는 결국 운영 문제로 귀결됩니다.

DEAD(또는 DLQ)로 보낸 뒤 무엇을 할 것인가

  • 자동 재시도 상한 초과
  • 비재시도 오류(권한/검증)
  • 도메인 제약으로 보상 불가

이 경우 필요한 것은 “알람”만이 아니라 조치 가능한 도구입니다.

권장 운영 기능:

  • GET /sagas/{sagaId}: 단계별 상태/마지막 오류/시도 횟수
  • POST /sagas/{sagaId}/retry?step=Inventory: 특정 단계 재시도
  • POST /sagas/{sagaId}/mark-succeeded?step=...: 운영자 승인 하 스킵(감사로그 필수)

관측성 체크리스트

  • 보상 단계별 메트릭: 성공/실패/재시도 횟수, 평균 지연
  • attempt 분포(특정 단계만 반복 실패하는지)
  • 외부 의존성별 오류율/지연
  • 사가 종단 시간(보상 때문에 완료까지 몇 분 걸리는지)

네트워크/인증 문제는 보상 실패의 흔한 원인입니다. 예를 들어 외부 API 키/권한이 깨지면 모든 보상이 일괄 실패합니다. 이런 경우에는 재시도만으로는 해결되지 않으므로, 인증·권한 점검 체계를 갖추는 것이 중요합니다. 유사한 진단 접근은 OpenAI Responses API 401 403 인증오류 점검 가이드처럼 “재시도 전에 원인 제거”가 우선입니다.

구현 예시: 간단한 보상 워커(의사 코드)

아래는 DB 스케줄링 기반의 보상 워커 흐름입니다.

from contextlib import contextmanager

MAX_ATTEMPTS = 10

@contextmanager
def tx(db):
    db.begin()
    try:
        yield
        db.commit()
    except Exception:
        db.rollback()
        raise

def run_once(db, client):
    with tx(db):
        step = db.fetch_one("""
            -- 앞서 소개한 UPDATE ... RETURNING 패턴을 함수로 감쌈
            SELECT * FROM pick_and_mark_in_progress();
        """)
        if not step:
            return None

    # 트랜잭션 밖에서 외부 호출(락 오래 잡지 않기)
    try:
        # 멱등 키를 포함해 외부/내부 보상 수행
        client.compensate(step["step_name"], step["idempotency_key"], step["saga_id"])
        outcome = ("SUCCEEDED", None, None)
    except Exception as e:
        err = str(e)
        # 여기서는 단순화: classify_error로 RETRY/DEAD 결정
        action = "RETRY" if "timeout" in err.lower() else "DEAD"
        outcome = ("RETRYING" if action == "RETRY" else "DEAD", err, action)

    with tx(db):
        if outcome[0] == "SUCCEEDED":
            db.execute("""
                UPDATE saga_step
                SET status='SUCCEEDED', last_error=NULL, next_retry_at=NULL, updated_at=now()
                WHERE saga_id=:saga_id AND step_name=:step_name AND direction='COMPENSATE'
            """, step)
        else:
            # attempt 기반으로 next_retry_at 계산
            current = db.fetch_one("""
                SELECT attempt FROM saga_step
                WHERE saga_id=:saga_id AND step_name=:step_name AND direction='COMPENSATE'
            """, step)

            if current["attempt"] >= MAX_ATTEMPTS or outcome[2] == "DEAD":
                db.execute("""
                    UPDATE saga_step
                    SET status='DEAD', last_error=:err, updated_at=now()
                    WHERE saga_id=:saga_id AND step_name=:step_name AND direction='COMPENSATE'
                """, {**step, "err": outcome[1]})
            else:
                next_at = compute_next_retry(current["attempt"])
                db.execute("""
                    UPDATE saga_step
                    SET status='RETRYING', last_error=:err, next_retry_at=:next_at, updated_at=now()
                    WHERE saga_id=:saga_id AND step_name=:step_name AND direction='COMPENSATE'
                """, {**step, "err": outcome[1], "next_at": next_at})

    return step

핵심은 다음 두 가지입니다.

  • 외부 호출은 DB 트랜잭션 밖에서 수행(락/커넥션 점유 최소화)
  • 결과 반영은 다시 트랜잭션으로 원자적으로 업데이트

체크리스트: 보상 실패 재처리 설계에서 자주 놓치는 것들

  • 보상 API가 멱등하지 않다 → idempotency_key + 적용 이력 테이블로 보완
  • “보상 호출”을 그냥 이벤트 핸들러로만 처리 → 작업 상태(saga_step)로 승격
  • 재시도는 있는데 상한이 없다 → 최대 시도/최대 시간 + DEAD 큐
  • 실패 원인 분류가 없다 → retryable vs non-retryable
  • 운영자가 손댈 방법이 없다 → 재시도/스킵/재발행 엔드포인트 + 감사로그
  • 관측성이 없다 → 단계별 메트릭/로그/트레이싱

마무리

Saga 패턴에서 보상 트랜잭션 실패는 “예외 케이스”가 아니라 “정상적으로 발생하는 사건”에 가깝습니다. 따라서 설계의 중심을 ‘성공 경로’가 아니라 실패 후 재처리 가능성에 둬야 합니다.

정리하면, 보상 실패 재처리의 정답은 단일 기술이 아니라 다음의 조합입니다.

  • 상태기계 기반의 사가 로그
  • 멱등성 키와 중복 실행 방어
  • 아웃박스로 유실 없는 커맨드/이벤트 발행
  • 지수 백오프+지터, 실패 분류, DEAD 처리
  • 운영 도구와 관측성

이 조합을 갖추면 “보상이 실패했는데 어떻게 하지?”가 아니라, “보상은 언젠가 성공하거나, 실패해도 추적 가능하고 조치 가능하다”는 운영 가능한 시스템으로 진화합니다.