Published on

MSA 사가 보상 트랜잭션 설계 체크리스트

Authors

모놀리식에서는 데이터베이스 트랜잭션 하나로 원자성을 얻기 쉬웠지만, MSA에서는 서비스와 데이터 저장소가 분리되면서 같은 방식이 통하지 않습니다. 이때 자주 선택하는 접근이 사가(Saga) 패턴이고, 사가의 핵심은 실패했을 때 되돌리는 방법 즉 보상 트랜잭션(compensating transaction)을 명확히 설계하는 것입니다.

보상 트랜잭션은 단순히 반대 연산을 수행하는 함수가 아닙니다. 네트워크 재시도, 중복 메시지, 순서 뒤바뀜, 부분 실패, 장시간 지연 같은 분산 시스템의 현실을 전제로 해야 합니다. 이 글은 “보상 트랜잭션을 설계할 때 무엇을 반드시 확인해야 하는가”를 체크리스트 형태로 정리합니다.

참고로 사가를 이벤트 소싱과 함께 쓰는 경우도 많은데, 스냅샷이나 이벤트 중복/유실이 보상 로직과 충돌하면 장애가 커집니다. 관련해서는 Event Sourcing 스냅샷 꼬임 - 중복·유실 복구 전략도 함께 보면 좋습니다.

1) 먼저 결정해야 할 것: 오케스트레이션 vs 코레오그래피

사가 구현은 크게 두 가지로 나뉩니다.

  • 오케스트레이션(Orchestration): 중앙 오케스트레이터가 각 서비스에 커맨드를 보내고 성공/실패에 따라 다음 단계 또는 보상을 지시
  • 코레오그래피(Choreography): 각 서비스가 이벤트를 발행하고 다른 서비스가 이를 구독하여 다음 동작을 수행

보상 트랜잭션 관점에서의 차이는 다음과 같습니다.

  • 오케스트레이션: 보상 실행의 순서조건을 중앙에서 통제하기 쉬움. 대신 오케스트레이터가 단일 장애 지점이 될 수 있어 HA 설계가 필요.
  • 코레오그래피: 서비스 결합이 느슨하지만, 보상 실행의 전파, 순서, 중복 통제가 어려워지고 디버깅 난이도가 상승.

체크포인트:

  • 보상 실행 순서를 강하게 보장해야 하는 비즈니스인가
  • 장애 시 운영자가 중앙에서 “현재 사가가 어디까지 갔는지”를 확인해야 하는가
  • 팀/조직 구조상 중앙 오케스트레이터를 운영할 역량이 있는가

2) 보상은 “되돌리기”가 아니라 “의미를 복구”하는 것

보상 트랜잭션은 수학적 역함수처럼 깔끔하지 않습니다. 예를 들어 결제 승인 후 재고 차감이 실패했다면, 결제를 취소(환불)하는 것이 일반적인 보상인데, 실제로는 다음 같은 변수가 있습니다.

  • 부분 환불만 가능한 결제 수단
  • 취소 가능 시간이 지난 경우
  • 결제 취소가 비동기 처리되고 완료까지 시간이 걸리는 경우
  • 이미 고객에게 알림이 나간 경우

따라서 보상 트랜잭션은 원상복구가 아니라 비즈니스적으로 일관된 상태로 수렴시키는 작업입니다.

체크포인트:

  • 보상이 불가능한 경우(irreversible)를 정의했는가
  • 보상이 불가능할 때의 대체 플로우(수동 처리, CS 티켓, 크레딧 지급 등)가 있는가
  • “완전 취소”가 아닌 “정정 이벤트”로 처리해야 하는 단계가 있는가

3) 사가 상태 모델링: 상태 전이표를 먼저 그려라

보상 설계의 시작은 상태 모델입니다. 최소한 아래를 사가 단위로 정의해야 합니다.

  • 단계 목록(예: ReserveInventory, AuthorizePayment, CreateOrder)
  • 각 단계의 성공/실패 상태
  • 보상 단계(예: ReleaseInventory, VoidPayment, CancelOrder)
  • 최종 상태(성공, 실패-보상완료, 실패-보상부분실패, 수동조치필요)

체크포인트:

  • 사가 인스턴스의 상태가 저장되는가(메모리 금지)
  • 상태 전이가 단조롭게 진행되도록 설계했는가(되돌아가는 전이를 최소화)
  • 운영자가 상태만 보고 다음 액션을 판단할 수 있는가

4) 멱등성(idempotency)은 보상이 아니라 “모든 단계”의 기본값

분산 환경에서 메시지는 중복될 수 있고, API 호출은 재시도됩니다. 따라서 커맨드와 보상 모두 멱등해야 합니다.

  • 같은 sagaIdstep으로 같은 요청이 여러 번 와도 결과가 동일해야 함
  • 보상도 마찬가지로 “이미 보상된 상태”면 성공으로 처리해야 함

체크포인트:

  • 각 단계에 idempotencyKey를 설계했는가(보통 sagaId-stepName)
  • 서비스가 멱등 키를 저장하고 중복 요청을 무해화하는가
  • 멱등 처리의 저장소가 TTL로 날아가면서 장기 재시도에 문제를 만들지 않는가

예시: 결제 취소 보상 API의 멱등 처리(의사 코드)

// TypeScript-like pseudocode
async function voidPayment(req) {
  const { sagaId, step, paymentId } = req;
  const key = `${sagaId}:${step}:VOID_PAYMENT`;

  const existing = await idempotencyStore.get(key);
  if (existing) return existing.response; // already processed

  // 실제 취소 시도
  const result = await paymentProvider.void(paymentId);

  const response = { status: "OK", providerResult: result };
  await idempotencyStore.put(key, { response }, { ttlSeconds: 60 * 60 * 24 * 30 });

  return response;
}

5) 보상 순서: “역순”이 기본이지만, 항상 맞지는 않다

일반적으로는 실행의 역순으로 보상합니다.

  • 재고 예약 성공 → 결제 승인 성공 → 주문 생성 실패
  • 보상: 주문 생성(없음) → 결제 취소 → 재고 해제

하지만 역순이 항상 정답은 아닙니다.

  • 외부 시스템(배송, 쿠폰, 포인트)에서 취소/정정의 선행 조건이 있을 수 있음
  • “취소 전에 정산 데이터가 생성되면 안 된다” 같은 규칙이 있을 수 있음

체크포인트:

  • 보상 순서가 비즈니스 규칙을 만족하는가
  • 보상 순서가 바뀌어도 안전한가(순서 무관하게 수렴하는가)
  • 보상 중 일부가 실패했을 때 다음 보상을 진행할지 중단할지 정책이 있는가

6) 보상 실패는 반드시 발생한다: 재시도, DLQ, 수동조치 설계

보상은 실패할 수 있습니다. 특히 외부 결제/배송 같은 의존성이 있으면 더 그렇습니다. 따라서 “보상 실패”를 예외가 아니라 정상 시나리오로 취급해야 합니다.

체크포인트:

  • 보상 커맨드 재시도 정책이 있는가(지수 백오프, 최대 횟수)
  • 재시도 중복이 안전한가(멱등)
  • 최종 실패 시 DLQ로 보내고 운영자가 처리할 수 있는가
  • 수동조치 플레이북이 있는가(어떤 화면/쿼리로 무엇을 확인하는지)

운영 관점에서 로그가 핵심인데, 노이즈가 폭증하면 장애 시점에 필요한 신호를 놓칩니다. 로그 보관/압축 정책은 journalctl 로그 폭증? systemd 압축·보관 최적화 같은 관점으로 점검해두는 것이 좋습니다.

7) 데이터 정합성 기준을 명시하라: 강한 정합성 대신 “수렴 조건”

사가에서는 강한 정합성 대신 최종적 정합성을 택합니다. 그렇다면 무엇이 “정상”인지 수치화/명문화해야 합니다.

예:

  • 주문 상태가 CANCELING이면 최대 5분 내 CANCELED 또는 MANUAL_REVIEW로 수렴
  • 결제 승인 후 10분 내 주문이 확정되지 않으면 자동 취소

체크포인트:

  • 각 중간 상태의 최대 체류 시간(SLA)이 정의되어 있는가
  • 타임아웃이 발생하면 어떤 보상이 실행되는가
  • 타임아웃 이후에도 늦게 도착한 성공 이벤트를 어떻게 무해화하는가

8) Outbox/Inbox 패턴으로 “저장과 발행”을 분리하지 마라

보상 설계가 어려운 이유 중 하나는 메시지 발행의 신뢰성입니다. DB 업데이트는 성공했는데 이벤트 발행이 실패하면, 다음 단계가 진행되지 않아 보상이 꼬입니다.

해법으로 흔히 쓰는 것이 Outbox/Inbox 입니다.

  • Outbox: 로컬 트랜잭션에서 비즈니스 데이터 변경과 함께 outbox 테이블에 이벤트를 기록하고, 별도 릴레이가 이벤트를 브로커로 발행
  • Inbox: 소비 측에서 메시지 중복을 방지하기 위해 수신 메시지 ID를 저장

체크포인트:

  • 각 서비스가 Outbox를 통해 이벤트 발행을 보장하는가
  • 소비 측이 Inbox로 중복 소비를 막는가
  • 릴레이 장애 시 적체가 쌓일 때 모니터링/알람이 있는가

9) 보상 트랜잭션의 부작용을 통제하라

보상은 “되돌리기”지만, 실제로는 또 다른 부작용을 만들 수 있습니다.

  • 취소 알림이 중복 발송
  • 포인트 복구가 중복 적립
  • 재고 해제가 이미 판매된 재고를 음수로 만들기

체크포인트:

  • 사용자 커뮤니케이션(알림/메일/SMS)의 멱등성이 확보되어 있는가
  • 회계/정산 데이터는 취소가 아니라 정정 분개로 처리해야 하는가
  • 보상 실행으로 인해 다른 사가가 시작되는 연쇄 효과를 고려했는가

10) 관측성: 사가 단위 트레이싱과 상관관계 ID는 필수

보상 설계가 제대로 되었는지 확인하려면, 장애 시 “한 사가 인스턴스의 타임라인”을 재구성할 수 있어야 합니다.

체크포인트:

  • 모든 로그/이벤트/메트릭에 sagaId, orderId 같은 상관관계 ID가 포함되는가
  • 분산 트레이싱에서 각 단계가 하나의 trace로 묶이는가
  • 보상 실행 횟수, 보상 실패율, DLQ 적재량이 대시보드로 보이는가

쿠버네티스 환경이라면 장애가 보상 실패로만 보이고 실제 원인은 파드 OOM 같은 경우도 흔합니다. 인프라 레벨 진단은 K8s CrashLoopBackOff - OOMKilled·Probe 실패 진단 같은 체크리스트와 함께 보는 것이 실전에서 도움이 됩니다.

11) 체크리스트: 보상 트랜잭션 설계 시 꼭 묻는 질문 20개

아래는 설계 리뷰 때 그대로 사용할 수 있는 질문 목록입니다.

비즈니스/도메인

  1. 이 단계는 보상이 가능한가, 불가능한가
  2. 불가능하다면 대체 플로우는 무엇인가
  3. 보상 시 고객에게 어떤 상태를 보여줄 것인가(CANCELING 같은 중간 상태 포함)
  4. 보상으로 인해 회계/정산 데이터가 틀어지지 않는가

상태/흐름

  1. 사가 상태 머신이 문서화되어 있는가
  2. 각 단계의 성공/실패/타임아웃이 정의되어 있는가
  3. 보상 순서가 역순인지, 예외가 있는지 근거가 있는가
  4. 부분 보상 성공 시 최종 상태는 무엇인가

기술/신뢰성

  1. 모든 단계가 멱등한가
  2. 보상도 멱등한가
  3. 중복 메시지, 순서 뒤바뀜(out-of-order)을 허용하는가
  4. Outbox/Inbox로 저장과 발행/소비 중복을 통제하는가
  5. 재시도 정책(백오프, 최대 횟수, 서킷 브레이커)이 있는가
  6. 최종 실패 시 DLQ 및 수동조치 경로가 있는가

운영/관측

  1. sagaId로 전체 타임라인을 추적할 수 있는가
  2. 보상 실패율과 DLQ 적재량 알람이 있는가
  3. 운영자가 “지금 이 주문은 왜 취소가 안 됐는지”를 UI나 쿼리로 확인 가능한가
  4. 데이터 정합성 검증 배치(리컨실리에이션)가 있는가

성능/확장

  1. 보상 폭주 시(외부 장애로 대량 취소) 큐 적체와 처리량을 감당할 수 있는가
  2. 보상 처리의 우선순위(고객 영향 큰 건 먼저)가 필요한가

12) 예시 시나리오: 주문 사가와 보상 설계(간단 모델)

주문 생성 사가를 예로 들면 단계와 보상은 다음처럼 잡을 수 있습니다.

  • ReserveInventory (재고 예약)
    • 보상: ReleaseInventory
  • AuthorizePayment (결제 승인)
    • 보상: VoidPayment 또는 RefundPayment
  • CreateOrder (주문 레코드 확정)
    • 보상: CancelOrder (상태 전이)

오케스트레이터 기반으로 의사 코드를 쓰면 아래와 같습니다.

# Python-like pseudocode

def run_order_saga(saga_id, order_id):
    steps_done = []

    try:
        reserve_inventory(saga_id, order_id)
        steps_done.append("ReserveInventory")

        authorize_payment(saga_id, order_id)
        steps_done.append("AuthorizePayment")

        create_order(saga_id, order_id)
        steps_done.append("CreateOrder")

        mark_saga_success(saga_id)

    except Exception as e:
        mark_saga_failed(saga_id, reason=str(e))

        # compensate in reverse order
        for step in reversed(steps_done):
            try:
                if step == "CreateOrder":
                    cancel_order(saga_id, order_id)
                elif step == "AuthorizePayment":
                    void_payment(saga_id, order_id)
                elif step == "ReserveInventory":
                    release_inventory(saga_id, order_id)
            except Exception as ce:
                record_compensation_failure(saga_id, step, str(ce))
                # 정책에 따라 계속 진행하거나 중단

        finalize_saga_as_compensated_or_manual(saga_id)

여기서 중요한 포인트는 보상 실패를 기록하고, 사가의 최종 상태가 COMPENSATED인지 MANUAL_REVIEW인지 명확히 남기는 것입니다. 그래야 운영이 가능합니다.

마무리

MSA에서 사가는 “분산 트랜잭션의 대안”이라기보다, 실패를 전제로 한 일관성 수렴 메커니즘입니다. 보상 트랜잭션을 잘 설계하려면 역연산을 구현하는 수준을 넘어, 멱등성, 메시징 신뢰성(Outbox/Inbox), 타임아웃과 수동조치, 관측성까지 함께 묶어야 합니다.

위 체크리스트를 설계 리뷰 템플릿으로 고정해두면, 사가가 커질수록 더 자주 터지는 부분 실패중복/재시도 문제를 초기에 잡아낼 수 있습니다.