Published on

MSA Saga 보상 트랜잭션 실패 재처리 설계

Authors

서로 다른 서비스가 각자 DB를 갖는 MSA에서 분산 트랜잭션을 ACID로 묶기 어렵기 때문에, 현실적인 대안으로 Saga(사가) 패턴을 선택하는 경우가 많습니다. 문제는 “정방향(Forward) 단계”보다 “보상(Compensation) 단계”가 더 까다롭다는 점입니다. 정방향은 사용자 요청으로 시작되지만, 보상은 장애·타임아웃·부분 실패 같은 비정상 상황에서만 발동되고, 그 순간 시스템은 이미 불안정합니다.

이 글은 보상 트랜잭션이 실패했을 때 재처리를 어떻게 설계해야 하는지를 중심으로, 데이터 모델·재시도 알고리즘·멱등성·운영 전략을 한 번에 정리합니다.

> 참고: Kubernetes/EKS 환경에서 네트워크 egress가 간헐적으로 끊기면 보상 호출이 연쇄 실패할 수 있습니다. 원인 추적은 EKS Pod egress 간헐 끊김 - SNAT·NAT GW 추적법을 함께 보세요.

1) 보상 실패 재처리가 어려운 이유

1.1 보상은 “되돌리기”가 아니라 “새로운 상태 전이”

보상은 단순히 이전 값을 복구하는 게 아니라, 업무적으로 의미 있는 반대 작업입니다.

  • 결제 승인(Charge) 보상 = 결제 취소(Refund)
  • 재고 차감(Reserve/Decrement) 보상 = 재고 복원(Release/Increment)
  • 쿠폰 사용(MarkUsed) 보상 = 쿠폰 사용 취소(MarkUnused)

이 작업들은 외부 시스템(결제 PG, 배송사 등)과 얽히고, “정확히 이전 상태”가 아닌 “취소 상태”로의 전이가 됩니다. 즉, 보상도 정방향과 동일하게 도메인 규칙·멱등성·감사 로그가 필요합니다.

1.2 보상 실패는 더 자주 “부분 성공”을 만든다

보상은 여러 단계가 역순으로 실행됩니다.

  • A → B → C 수행 후 C에서 실패
  • 보상은 B’ → A’ 수행
  • 그런데 B’ 성공, A’ 실패 같은 부분 성공이 흔합니다.

이때 재처리는 “A’만 다시” 해야 하는데, 설계가 없으면 전체 보상을 다시 실행하거나(중복 부작용), 상태를 잃어버립니다.

1.3 장애 원인이 일시적/영구적 섞여 있다

  • 일시적(Transient): 네트워크 타임아웃, 429/5xx, DB lock 경합
  • 영구적(Permanent): 권한 문제(403), 잘못된 입력, 이미 취소됨 등

예를 들어 AWS 자원 권한 문제로 403이 나면 무한 재시도는 비용만 태웁니다. EKS에서 IAM/IRSA 설정 문제로 403이 발생하는 케이스는 EKS Pod에서 AWS Secrets Manager 403 해결 가이드처럼 “재시도”가 아니라 “설정 수정”이 정답입니다.

2) 목표: 보상 실패 재처리의 설계 원칙

보상 재처리 설계는 결국 아래 4가지를 만족해야 합니다.

  1. 정확히 한 번처럼 보이기(Effectively-once): 최소 한 번(at-least-once) 실행되더라도 중복 부작용이 없어야 함
  2. 부분 성공을 안전하게 이어가기: 실패한 보상 단계만 재개 가능해야 함
  3. 무한 재시도 방지: 영구 실패는 빠르게 격리하고 사람이 개입할 수 있어야 함
  4. 관측 가능성(Observability): 어떤 사가 인스턴스가 어디서 막혔는지 추적 가능해야 함

이를 위해 실무에서 가장 많이 쓰는 조합은:

  • Saga Log(사가 상태 저장) + Outbox(메시지 발행 신뢰성)
  • 보상 요청/응답 멱등성 키
  • 재시도 정책(지수 백오프 + 지터) + DLQ/격리 큐
  • 리컨실리에이션(주기적 정합성 점검) + 수동 복구 도구

3) 데이터 모델: “재처리 가능한 상태”를 저장하라

보상 실패를 재처리하려면 “어디까지 했는지”가 DB에 남아야 합니다. 메모리/캐시에만 있으면 프로세스 재시작 시 유실됩니다.

3.1 Saga 인스턴스 테이블 예시

아래는 오케스트레이션 기반(중앙 사가 오케스트레이터)에서 흔한 형태입니다.

-- PostgreSQL 예시
create table saga_instance (
  saga_id           uuid primary key,
  saga_type         text not null,
  status            text not null, -- RUNNING, COMPENSATING, COMPLETED, FAILED
  current_step      int  not null,
  compensation_step int  not null default 0,
  version           int  not null default 0,
  created_at        timestamptz not null default now(),
  updated_at        timestamptz not null default now(),
  last_error_code   text,
  last_error_msg    text,
  next_retry_at     timestamptz,
  retry_count       int not null default 0
);

create table saga_step_log (
  saga_id      uuid not null,
  step_no      int not null,
  direction    text not null, -- FORWARD / COMPENSATION
  status       text not null, -- STARTED / SUCCEEDED / FAILED
  idempotency_key text not null,
  request_json jsonb,
  response_json jsonb,
  error_code   text,
  error_msg    text,
  created_at   timestamptz not null default now(),
  primary key (saga_id, step_no, direction)
);

핵심은 다음입니다.

  • compensation_step: 보상 단계가 어디까지 완료됐는지
  • saga_step_log: 각 단계별 요청/응답/에러를 남겨 재처리 판단 근거로 사용
  • idempotency_key: 동일 단계 재호출 시 중복 부작용을 방지하는 키

3.2 Outbox 테이블(이벤트 발행 신뢰성)

보상은 종종 “다른 서비스에 보상 명령을 발행”하는 형태입니다. 이때 DB 업데이트와 메시지 발행이 분리되면 유실/중복이 발생합니다. Outbox로 해결합니다.

create table outbox (
  id           bigserial primary key,
  aggregate_id uuid not null,
  event_type   text not null,
  payload      jsonb not null,
  status       text not null default 'NEW', -- NEW, PUBLISHED, FAILED
  created_at   timestamptz not null default now(),
  published_at timestamptz
);

create index on outbox(status, created_at);

오케스트레이터는 트랜잭션 안에서:

  • saga_step_log에 “보상 명령 생성” 기록
  • outbox에 메시지 적재

를 함께 커밋하고, 별도 퍼블리셔가 outbox를 읽어 브로커(Kafka/SQS 등)에 발행합니다.

4) 멱등성: 보상 재처리의 생명줄

재처리는 결국 “같은 보상 요청을 다시 보낼 수 있음”을 전제로 합니다. 따라서 보상 API/커맨드는 반드시 멱등해야 합니다.

4.1 멱등성 키 전략

  • 키 구성: sagaId + stepNo + direction (또는 businessTxId + action)
  • 저장 위치: 보상 대상 서비스의 DB에 “처리 이력” 테이블로 저장
create table idempotency_record (
  idempotency_key text primary key,
  status          text not null, -- SUCCEEDED, FAILED
  response_json   jsonb,
  created_at      timestamptz not null default now()
);

보상 API 처리 흐름:

  1. 요청의 Idempotency-Key로 레코드 조회
  2. 있으면 저장된 응답을 그대로 반환(또는 성공 처리)
  3. 없으면 실제 보상 로직 수행 후 결과 저장

4.2 “이미 보상됨”을 성공으로 간주하기

보상은 종종 “이미 취소된 결제”처럼 중복 호출이 정상입니다.

  • RefundAlreadyProcessed 같은 에러는 409로 내보내기보단, 200 OK + 상태=ALREADY_DONE 형태로 흡수하면 오케스트레이터가 단순해집니다.

5) 재시도 정책: 무작정 재시도는 독이다

5.1 분류: 재시도 가능한 실패 vs 불가능한 실패

오케스트레이터는 실패를 분류해야 합니다.

  • 재시도 가능: timeout, connection reset, 429, 503, deadlock
  • 재시도 불가: 400(검증 실패), 403(권한), 404(리소스 없음이 의미상 영구), 도메인 규칙 위반

이 분류를 위해 보상 대상 서비스는 에러를 기계가 읽을 수 있는 코드로 반환해야 합니다.

{
  "errorCode": "PERMISSION_DENIED",
  "message": "IRSA role is missing secretsmanager:GetSecretValue",
  "retryable": false
}

5.2 지수 백오프 + 지터, 그리고 상한

  • 1m, 2m, 4m, 8m…
  • 랜덤 지터(±20%)로 동시 재시도 폭주 방지
  • 최대 재시도 횟수/최대 지연 상한(예: 1시간) 설정

5.3 DLQ/격리 큐로 “사람이 볼 수 있게”

일정 횟수 이상 실패하거나 retryable=false면:

  • 사가 상태를 FAILED로 전환
  • 실패 원인/마지막 요청/응답을 saga_step_log에 저장
  • DLQ로 보내거나 “운영 테이블”에 적재

이렇게 해야 장애가 “조용히 무한 루프”로 숨지 않습니다.

6) 재처리 알고리즘: 보상은 ‘역순 + 체크포인트’

6.1 오케스트레이터 의사코드

아래는 DB에 체크포인트를 저장하며 보상을 재개하는 전형적 흐름입니다.

from datetime import datetime, timedelta
import random

MAX_RETRY = 10

def next_backoff_seconds(retry_count: int) -> int:
    base = min(60 * (2 ** retry_count), 3600)  # up to 1 hour
    jitter = base * random.uniform(-0.2, 0.2)
    return int(base + jitter)


def compensate(saga_id: str):
    saga = load_saga_for_update(saga_id)  # SELECT ... FOR UPDATE

    if saga.status not in ("COMPENSATING", "FAILED"):
        return

    step = saga.compensation_step
    while step > 0:
        log = load_step_log(saga_id, step, direction="COMPENSATION")

        # 이미 성공한 단계는 건너뛴다(재처리 핵심)
        if log and log.status == "SUCCEEDED":
            step -= 1
            continue

        try:
            idempotency_key = f"{saga_id}:{step}:COMP"
            resp = call_compensation_api(step, saga_id, idempotency_key)
            save_step_success(saga_id, step, resp)
            saga.compensation_step = step - 1
            saga.retry_count = 0
            update_saga(saga)
            step -= 1

        except CompensationError as e:
            save_step_failure(saga_id, step, e)

            if not e.retryable or saga.retry_count >= MAX_RETRY:
                saga.status = "FAILED"
                update_saga(saga)
                publish_to_dlq(saga_id, step, e)
                return

            saga.retry_count += 1
            delay = next_backoff_seconds(saga.retry_count)
            saga.next_retry_at = datetime.utcnow() + timedelta(seconds=delay)
            update_saga(saga)
            return

    saga.status = "COMPLETED"
    update_saga(saga)

포인트는 명확합니다.

  • 단계별 성공 로그가 있으면 skip: 전체 보상을 다시 하지 않음
  • 실패 시 next_retry_at에 스케줄링: 워커가 폴링/스케줄러가 재기동
  • 영구 실패는 FAILED로 격리

6.2 동시성 제어

재처리 워커가 여러 대면 같은 사가를 동시에 잡을 수 있습니다.

  • SELECT ... FOR UPDATE SKIP LOCKED로 워커 간 분산 락
  • version 컬럼으로 낙관적 락
-- 재처리 대상 사가를 워커가 안전하게 가져오기
select saga_id
from saga_instance
where status in ('COMPENSATING')
  and (next_retry_at is null or next_retry_at <= now())
order by updated_at
for update skip locked
limit 50;

7) 메시징 기반 보상에서의 재처리: 커맨드 중복과 순서

오케스트레이터가 보상 커맨드를 Kafka/SQS로 발행하는 경우 재처리는 더 단순해질 수 있지만, 다음을 반드시 고려해야 합니다.

  • 중복 소비: 브로커는 at-least-once가 일반적 → 소비자 멱등 필수
  • 순서 보장: 동일 sagaId에 대한 커맨드는 파티션 키를 sagaId로 고정
  • 가시성 타임아웃/재전달: SQS는 visibility timeout 설정이 보상 처리 시간보다 짧으면 중복 폭발

8) 운영 관점: “재처리 버튼”보다 중요한 것들

8.1 리컨실리에이션(정합성 점검) 잡

보상은 100% 자동화가 어렵습니다. 따라서 주기적으로 “정방향 상태와 외부 시스템 상태”를 대조하는 잡이 필요합니다.

  • 결제는 취소됐는데 주문은 결제완료 상태
  • 재고는 복원됐는데 주문은 취소 실패

리컨실리에이션 결과는 별도 테이블/대시보드로 노출하고, 수동 조치 큐로 넣습니다.

8.2 관측: 사가 단위 트레이싱/로그

  • sagaId를 모든 로그/메트릭/트레이스의 공통 키로 사용
  • 단계별 latency, 실패율, retry 횟수 분포를 메트릭화

네트워크 이슈가 섞이면 “보상 API가 느려서 타임아웃 → 재시도 폭주”가 됩니다. 이때 egress/NAT 병목은 애플리케이션 로그만으로는 안 보이므로, 앞서 언급한 네트워크 추적 글(EKS Pod egress 간헐 끊김 - SNAT·NAT GW 추적법) 같은 인프라 관측이 중요합니다.

8.3 재처리 도구(운영자 UI/CLI)

실무에서는 “DLQ에 쌓인 사가를 어떻게 처리할 것인가”가 승부처입니다.

필수 기능:

  • 사가 상세 조회(단계 로그, 마지막 에러, 외부 요청/응답)
  • 특정 단계부터 재시도(예: A’만 재시도)
  • 강제 성공 처리(비즈니스 승인 하에)
  • 보상 대신 대체 조치(수동 환불 완료 체크 등)

9) 흔한 안티패턴과 대안

9.1 안티패턴: 보상 실패 시 전체 롤백을 다시 시도

  • 이미 성공한 보상까지 다시 호출 → 중복 부작용
  • 대안: 단계별 체크포인트 + 멱등성

9.2 안티패턴: “무조건 재시도”

  • 403/400 같은 영구 실패도 계속 재시도
  • 대안: retryable 분류 + DLQ 격리

9.3 안티패턴: 보상 요청을 동기 HTTP 체인으로만 구성

  • 한 서비스 장애가 전체 보상 체인을 막음
  • 대안: outbox + 메시징으로 비동기화, 또는 최소한 타임아웃/서킷브레이커 적용

10) 체크리스트: 보상 실패 재처리 설계에 필요한 것

  • 보상 API/커맨드가 멱등인가? (Idempotency-Key + 처리 이력 저장)
  • 사가 상태/단계 로그가 DB에 영속화되는가?
  • outbox로 메시지 발행 신뢰성을 확보했는가?
  • 재시도 가능/불가능 실패를 구분하는가?
  • 지수 백오프+지터, 최대 재시도, DLQ가 있는가?
  • SELECT FOR UPDATE SKIP LOCKED 등으로 워커 동시성 제어가 되는가?
  • 리컨실리에이션 잡과 수동 조치 도구가 있는가?
  • sagaId 기반 트레이싱/메트릭이 있는가?

결론

MSA에서 Saga 보상 트랜잭션의 실패는 “예외 케이스”가 아니라 항상 발생하는 운영 이벤트입니다. 따라서 재처리는 단순한 retry 버튼이 아니라, 멱등성(중복 무해화), 체크포인트(부분 성공 이어가기), 정책(재시도/격리), **관측/운영 도구(사람이 개입 가능한 구조)**까지 포함한 시스템 설계 문제로 접근해야 합니다.

이 네 축을 갖추면, 보상 실패가 나더라도 서비스는 멈추지 않고(자동 재처리), 무한 루프에 빠지지 않으며(DLQ), 최종적으로는 사람이 안전하게 마무리할 수 있는(운영 도구) 상태가 됩니다.