Published on

MSA Saga 보상 트랜잭션 설계 실전 가이드

Authors

MSA에서 분산 트랜잭션을 다루는 순간, 개발팀은 두 가지 선택지 앞에 섭니다. 강한 일관성을 위해 2PC 같은 전통적 분산 트랜잭션을 시도하거나, 결국 현실적인 운영 복잡도를 감당하며 Saga로 최종 일관성을 받아들이는 길입니다. 대부분의 팀이 Saga를 택하지만, 진짜 난이도는 “오케스트레이션/코레오그래피”가 아니라 보상 트랜잭션(compensation) 을 어떻게 설계하느냐에 있습니다.

이 글은 “Saga를 적용했다” 수준을 넘어, 실제 장애·재시도·중복 이벤트·부분 실패가 일상인 운영 환경에서 보상 트랜잭션을 안전하게 설계하는 방법을 다룹니다.

Saga에서 보상 트랜잭션이 어려운 이유

단일 DB 트랜잭션의 ROLLBACK은 강력합니다. 반면 Saga의 보상은 다음 특성 때문에 설계가 까다롭습니다.

  • 이미 외부로 전파된 부작용(side effect) 이 있다
    • 결제 승인, 쿠폰 소진, 재고 차감, 배송 요청 등은 되돌리기 어렵거나 비용이 듭니다.
  • 시간이 흐른 뒤에 실패가 관측 될 수 있다
    • 네트워크 타임아웃, 메시지 지연, 소비자 장애로 인해 “성공/실패”가 늦게 확정됩니다.
  • 중복 실행이 기본(default) 이다
    • 최소 한 번(at-least-once) 전달, 재시도 정책 때문에 동일 명령/이벤트가 여러 번 올 수 있습니다.
  • 순서가 뒤틀릴 수 있다
    • 이벤트가 역순으로 도착하거나, 보상 이벤트가 원 이벤트보다 늦게 도착할 수 있습니다.

따라서 보상은 단순히 “반대 작업을 호출”하는 것이 아니라, 상태 머신 + 멱등성 + 재시도 + 관측성을 함께 설계해야 합니다.

전제: 주문 Saga 예시 도메인

예시는 전형적인 주문 흐름으로 설명합니다.

  1. Order 서비스: 주문 생성
  2. Inventory 서비스: 재고 예약
  3. Payment 서비스: 결제 승인
  4. Shipping 서비스: 배송 생성

실패 시 보상 예시:

  • 결제 승인 실패 => 재고 예약 취소
  • 배송 생성 실패 => 결제 취소(환불) => 재고 예약 취소

핵심은 “보상의 방향”이 아니라, 언제, 어떤 조건에서, 어떤 방식으로 보상을 실행하느냐입니다.

보상 트랜잭션 설계 원칙 7가지

1) 보상은 ‘반대 연산’이 아니라 ‘의미 있는 상태 전이’로 정의

예를 들어 재고 차감의 반대는 단순히 수량을 더하는 게 아닙니다. “예약(reservation)” 모델을 쓰면 보상은 다음처럼 명확해집니다.

  • Forward: reserve(orderId, sku, qty)
  • Compensation: cancelReservation(orderId)

재고를 직접 차감/복구하는 방식보다, 예약 레코드의 상태 전이가 훨씬 안전합니다.

2) 보상은 반드시 멱등(idempotent)해야 한다

Saga 오케스트레이터가 타임아웃으로 재시도하거나, 메시지가 중복 전달되면 보상은 여러 번 호출될 수 있습니다. 따라서 보상 API는 다음을 만족해야 합니다.

  • 같은 sagaId 또는 orderId로 여러 번 호출해도 결과가 동일
  • 이미 취소된 예약을 다시 취소해도 200 OK 또는 의미 있는 409를 반환

3) 보상은 “되돌리기”가 아니라 “취소/환불/무효화”가 될 수 있다

현실에서 결제는 “취소”가 아니라 “환불”이 될 수 있고, 배송은 “삭제”가 아니라 “회수 요청”일 수 있습니다. 보상은 기술적 대칭성보다 비즈니스적으로 가능한 액션으로 설계해야 합니다.

4) 보상은 항상 성공하지 않는다. 실패를 모델링하라

환불 API가 실패하거나, 재고 서비스가 다운될 수 있습니다. 이때 “보상 실패”는 시스템이 멈추는 원인이 됩니다.

  • 보상도 재시도 대상
  • 재시도 한도를 넘으면 NEEDS_MANUAL_INTERVENTION 같은 상태로 전이
  • 운영자가 처리할 수 있는 데이터(원인, 마지막 에러, 재시도 횟수)를 남김

5) 타임아웃과 데드라인을 설계에 포함

분산 환경에서 타임아웃은 “실패”가 아니라 “모름(unknown)”입니다. 특히 동기 호출 기반 오케스트레이션은 DEADLINE_EXCEEDED가 연쇄 장애로 번지기 쉽습니다. 데드라인/타임아웃을 방어적으로 설계하는 방법은 다음 글도 함께 보시면 좋습니다.

6) 보상 실행 조건을 명확히: “어디까지 성공했는가”가 기준

보상을 실행하려면, 각 단계의 성공 여부가 확정되어야 합니다. 따라서 Saga 상태 저장소에 다음이 필요합니다.

  • 단계별 상태: PENDING, SUCCEEDED, FAILED, COMPENSATED
  • 단계별 실행/보상 시각
  • 외부 호출의 상관관계 키(sagaId, paymentId, reservationId)

7) 이벤트 발행은 Outbox로 원자화

서비스가 자신의 DB 상태를 바꾸고 이벤트를 발행할 때, 둘 중 하나만 성공하면 “유령 상태”가 생깁니다. 보상 설계가 아무리 좋아도 이벤트가 누락되면 복구가 어렵습니다.

  • 로컬 트랜잭션으로 업데이트 + outbox insert
  • 별도 릴레이가 outbox를 읽어 메시지 브로커로 발행

오케스트레이션 기반 Saga: 상태 머신 설계

오케스트레이터는 “워크플로 엔진”처럼 동작합니다. 아래는 핵심 상태를 단순화한 예시입니다.

  • ORDER_CREATED
  • INVENTORY_RESERVED
  • PAYMENT_APPROVED
  • SHIPPING_CREATED
  • 실패 시 COMPENSATING 단계로 전환

Saga 상태 테이블 예시(PostgreSQL)

create table saga_instance (
  saga_id            uuid primary key,
  order_id           uuid not null,
  state              text not null,
  step               text not null,
  version            int not null default 0,
  last_error         text,
  retry_count        int not null default 0,
  created_at         timestamptz not null default now(),
  updated_at         timestamptz not null default now()
);

create table saga_step (
  saga_id            uuid not null,
  step_name          text not null,
  status             text not null,
  external_ref       text,
  executed_at        timestamptz,
  compensated_at     timestamptz,
  primary key (saga_id, step_name)
);
  • version은 낙관적 락(동시 업데이트 방지)에 사용
  • external_ref에 결제 트랜잭션 ID, 예약 ID 등을 저장해 보상 시 재사용

보상 트랜잭션 API 설계 체크리스트

1) 요청/응답 스키마에 상관관계 키 포함

  • sagaId는 필수
  • 가능하면 idempotencyKey도 별도로 둠(특히 결제/환불)
{
  "sagaId": "2f3c1f2a-5f8a-4f3f-9f74-2a2d2c0d0a11",
  "orderId": "b2c0c2b8-1c7f-4d1a-9a0f-1d3c9a5b4a22",
  "reason": "shipping_failed",
  "idempotencyKey": "refund-b2c0c2b8-1c7f-4d1a-9a0f-1d3c9a5b4a22"
}

2) 멱등 처리 저장소

보상 요청을 받는 서비스는 idempotencyKey 또는 sagaId + stepName 조합으로 처리 결과를 저장합니다.

create table idempotency_record (
  key               text primary key,
  status            text not null,
  response_body     jsonb,
  created_at        timestamptz not null default now()
);
  • 동일 키 재요청 시 저장된 응답을 그대로 반환
  • 보상 요청이 “이미 처리됨”을 빠르게 판별

3) 부분 성공을 허용하는 응답 모델

보상은 외부 연동(결제사, 택배사) 때문에 즉시 확정이 어려울 수 있습니다.

  • COMPLETED: 보상 완료
  • PENDING: 비동기 처리 중(나중에 웹훅/폴링으로 확정)
  • REJECTED: 비즈니스적으로 보상 불가(예: 환불 가능 기간 만료)

예제 코드: Node.js 오케스트레이터(간단 구현)

아래 코드는 “단계 실행 + 실패 시 역순 보상”의 골격을 보여줍니다. 동기 호출 예시이지만, 실제로는 각 호출에 타임아웃과 서킷 브레이커를 붙이고, 상태 저장을 트랜잭션으로 감싸야 합니다.

type StepName = "reserveInventory" | "approvePayment" | "createShipping";

type Step = {
  name: StepName;
  forward: () => Promise<{ externalRef?: string }>;
  compensate: () => Promise<void>;
};

export async function runSaga(steps: Step[]) {
  const executed: Step[] = [];

  try {
    for (const step of steps) {
      const result = await step.forward();
      // TODO: persist step success + externalRef
      executed.push(step);
    }
    // TODO: persist saga success
  } catch (e) {
    // TODO: persist saga compensating
    for (const step of executed.reverse()) {
      try {
        await step.compensate();
        // TODO: persist step compensated
      } catch (ce) {
        // TODO: persist compensation failure and stop or continue based on policy
        throw ce;
      }
    }
    throw e;
  }
}

이 코드는 “보상의 역순 실행”만 보여주지만, 실전에서는 아래가 추가됩니다.

  • forward/compensate 호출에 timeoutretry with backoff
  • saga_step 테이블에 각 단계 성공/실패/보상 완료를 기록
  • 프로세스 크래시 후 재시작해도 이어서 실행 가능한 재개(resume) 로직

실전에서 자주 터지는 케이스와 설계 해법

케이스 A: 결제 승인 응답이 타임아웃, 실제로는 승인됨

오케스트레이터는 “승인 실패”로 보고 재고를 해제할 수 있습니다. 그런데 나중에 결제 승인 웹훅이 도착하면?

해법:

  • 결제 승인 요청은 반드시 idempotencyKey를 사용
  • 결제 서비스는 “승인 요청 상태”를 저장하고, 동일 키면 동일 결과 반환
  • 오케스트레이터는 타임아웃 시 즉시 보상하지 말고 PAYMENT_UNKNOWN 상태로 두고 확인 작업 수행

케이스 B: 보상 이벤트가 중복 소비되어 재고가 두 번 풀림

해법:

  • 재고 서비스는 cancelReservation(orderId)를 멱등으로 구현
  • 예약 레코드 상태가 이미 CANCELLED면 그대로 성공 처리

케이스 C: 보상 중 일부만 성공하고 나머지 실패

예: 재고는 풀렸는데 환불이 실패.

해법:

  • Saga 결과를 PARTIALLY_COMPENSATED로 모델링
  • 실패한 보상 단계만 별도 재시도 큐에 넣고, 재시도 횟수 초과 시 운영 티켓 생성
  • 운영자가 “환불 재시도”를 누를 수 있는 어드민 액션 제공

케이스 D: Exactly-once를 믿고 설계를 단순화했다가 데이터가 어긋남

Kafka나 스트리밍 플랫폼의 Exactly-once는 조건이 까다롭고, 운영 중 깨지는 순간이 있습니다. 보상 설계는 “중복이 온다”를 기본으로 해야 합니다.

코레오그래피 기반 Saga에서 보상 설계 포인트

코레오그래피는 서비스들이 이벤트를 구독하며 다음 단계를 진행합니다. 이 경우 보상은 보통 “실패 이벤트”를 발행해 연쇄적으로 취소가 이어지게 합니다.

주의점:

  • 실패 이벤트도 멱등해야 함
  • 이벤트 순서 뒤틀림을 고려해, 소비자는 “현재 상태에서 이 이벤트를 적용 가능한가”를 검사
  • 이벤트 스키마에 sagaId, step, occurredAt, causationId(원인 이벤트 ID)를 포함

이 구조에서는 특히 “어떤 서비스가 전체 진행률을 알고 있는가”가 약해지므로, 별도의 프로세스 뷰(Projection) 또는 사가 트래커가 필요합니다.

운영 관점: 보상 트랜잭션 관측성(Observability)

보상은 성공해도 조용히 지나가면 안 됩니다. “보상이 얼마나 자주 일어나는지” 자체가 시스템 건강 지표입니다.

추천 지표:

  • saga_total, saga_failed_total, saga_compensated_total
  • 단계별 실패율: step_failure_rate{step="approvePayment"}
  • 보상 소요 시간 p95/p99
  • 보상 재시도 횟수 분포

추천 로그 필드:

  • sagaId, orderId, stepName, attempt, externalRef, errorCode

트레이싱:

  • 각 서비스 호출에 동일 traceId를 전파
  • 보상 호출도 forward 호출과 같은 trace에 연결(또는 link로 연결)

실전 체크리스트 요약

  • 보상은 “반대 작업”이 아니라 “비즈니스적으로 의미 있는 취소/환불/무효화”로 정의
  • 모든 forward/compensate는 멱등이어야 함
  • 타임아웃은 실패가 아니라 “모름”일 수 있으니 UNKNOWN 상태를 모델링
  • 단계별 상태와 외부 참조 ID를 저장해, 재시작 후에도 재개 가능하게
  • Outbox로 상태 변경과 이벤트 발행을 원자화
  • 보상 실패를 정상 시나리오로 보고 재시도/수동개입 플로우 설계

마무리

Saga 패턴에서 보상 트랜잭션은 “실패했을 때 되돌리는 코드”가 아니라, 분산 시스템의 불확실성을 견디는 두 번째 정상 플로우입니다. 멱등성, 상태 머신, 타임아웃의 의미(unknown), outbox, 관측성을 함께 설계하면 보상은 더 이상 공포가 아니라 통제 가능한 운영 메커니즘이 됩니다.

다음 단계로는, 현재 팀의 도메인에서 “되돌릴 수 없는 부작용” 목록을 먼저 만들고(결제, 쿠폰, 포인트, 배송 등), 각 항목을 “취소/환불/정정” 중 무엇으로 보상할지 정의한 뒤, 그 보상 API를 멱등으로 만드는 작업부터 시작하는 것을 추천합니다.