Published on

Saga 패턴 보상 트랜잭션 실패 원인 6가지

Authors

서로 다른 마이크로서비스에 걸친 분산 트랜잭션을 다룰 때, Saga 패턴은 2PC(2-Phase Commit)의 강한 결합을 피하면서도 “결과적 일관성(eventual consistency)”을 달성하는 현실적인 선택지입니다. 하지만 Saga의 핵심 안전장치인 보상 트랜잭션(compensation) 이 기대대로 동작하지 않으면, 시스템은 부분 성공 + 부분 실패 상태로 고착되고 운영 장애로 이어집니다.

이 글은 “왜 보상 트랜잭션이 실패하는가?”를 원인 중심으로 6가지로 분해하고, 각 원인에 대한 징후·대응·코드 레벨의 구현 포인트를 제공합니다. 특히 보상은 단순히 취소 API 하나 더 만드는 문제가 아니라, 데이터 모델·메시징·재시도·관측성이 함께 맞물려야 성공합니다.

또한 메시징/중복 처리와 관련된 내용은 Kafka Exactly-Once 깨질 때 중복처리 방지 전략에서 더 깊게 다룬 바 있으니 함께 참고하면 좋습니다.

Saga 보상 트랜잭션의 전제: “원자적 롤백”이 아니다

보상 트랜잭션은 DB 트랜잭션의 ROLLBACK처럼 원자적으로 이전 상태로 되돌리는 것이 아닙니다. 보상은 보통 다음 성격을 갖습니다.

  • 비동기: 이벤트/커맨드로 실행되고, 실패/지연이 발생할 수 있음
  • 부분적: 원상복구가 불가능한 경우(예: 외부 결제 취소 기한, 배송 시작 등)가 존재
  • 멱등성 필요: 재시도/중복 전달이 기본 전제
  • 순서 의존: “무엇을 먼저 보상할지”가 중요

따라서 보상 실패는 “예외 케이스”가 아니라, 설계가 흡수해야 하는 “정상 경로”에 가깝습니다.

실패 원인 1) 보상 트랜잭션이 멱등하지 않다

증상

  • 메시지 중복 전달(브로커 재전송, 컨슈머 재시작) 후 보상 API가 여러 번 호출됨
  • 재시도 로직이 활성화되면 보상이 중복 적용되어 잔고/재고가 음수로 깨짐
  • 동일 요청에 대해 409 Conflict, Already canceled 같은 에러가 연쇄적으로 발생

왜 발생하나

분산 시스템에서는 “최소 한 번(at-least-once)” 전달이 흔합니다. Exactly-once를 기대하고 설계하면, 보상은 쉽게 깨집니다.

대응

  • 보상 커맨드에 고유한 idempotency key를 포함
  • 보상 수행 여부를 저장하는 dedup 테이블(또는 outbox/inbox 패턴) 도입
  • 보상 로직을 “상태 전이(state transition)”로 모델링(이미 취소 상태면 성공으로 처리)

예시 코드 (Spring + JPA, 보상 멱등 처리)

@Entity
@Table(name = "compensation_log", uniqueConstraints = {
  @UniqueConstraint(columnNames = {"idempotencyKey"})
})
public class CompensationLog {
  @Id @GeneratedValue
  private Long id;

  private String idempotencyKey;
  private String sagaId;
  private String step;
  private Instant createdAt = Instant.now();

  // getters/setters
}

@Service
public class RefundCompensationService {
  private final CompensationLogRepository logRepo;
  private final PaymentGatewayClient paymentClient;

  @Transactional
  public void compensateRefund(String sagaId, String idempotencyKey, String paymentId) {
    if (logRepo.existsByIdempotencyKey(idempotencyKey)) {
      // 이미 처리됨: 멱등 성공
      return;
    }

    paymentClient.refund(paymentId); // 외부 호출은 실패 가능

    CompensationLog log = new CompensationLog();
    log.setSagaId(sagaId);
    log.setStep("PAYMENT_REFUND");
    log.setIdempotencyKey(idempotencyKey);
    logRepo.save(log);
  }
}

> 포인트: “외부 호출 성공 후 로그 저장”은 여전히 원자성이 없습니다. 실무에서는 inbox/outbox와 결합하거나, 외부 시스템이 idempotency key를 지원하도록 맞추는 게 더 안전합니다.

실패 원인 2) 보상 실행 순서가 잘못되었다(의존성 역전)

증상

  • 재고를 먼저 복구했는데 결제 환불이 실패하여 “재고만 풀리고 돈은 받은 상태”가 됨
  • 배송 취소보다 결제 취소가 먼저 수행되어 위약금/정산 로직이 꼬임

왜 발생하나

Saga 단계는 보통 정방향 실행 순서가 있고, 보상은 그 역순으로 수행되어야 안전합니다. 하지만 구현 중에 이벤트 기반으로 흩어지면 역순 보장이 깨지기 쉽습니다.

대응

  • 보상은 항상 역순(LIFO)으로 실행한다는 규칙을 오케스트레이터/상태 머신에 내장
  • 각 단계에 선행 조건(precondition) 을 둬서, 이전 단계 보상 완료 전에는 다음 보상을 시작하지 않도록 함
  • 보상 간 “독립적”이라고 가정하지 말고, 의존성 그래프를 명시

예시 코드 (간단한 상태 머신)

from enum import Enum

class Step(Enum):
    RESERVE_INVENTORY = 1
    AUTHORIZE_PAYMENT = 2
    CREATE_SHIPMENT = 3

COMPENSATE = {
    Step.CREATE_SHIPMENT: "cancel_shipment",
    Step.AUTHORIZE_PAYMENT: "void_or_refund",
    Step.RESERVE_INVENTORY: "release_inventory",
}

def compensate_in_reverse(completed_steps, api):
    for step in reversed(completed_steps):
        fn = getattr(api, COMPENSATE[step])
        fn()  # 실패 시 재시도/보류 큐로

실패 원인 3) 보상 시점에 원본 상태가 변했다(경합/동시성)

증상

  • 주문 취소 보상 중인데, 고객이 동시에 주문 변경/재결제를 시도
  • 재고 보상 시점에 이미 다른 주문이 재고를 소비하여 “원상복구”가 불가능
  • DB에서 OptimisticLockException 또는 데드락이 증가

왜 발생하나

Saga는 길게는 수 초~수 분 동안 진행될 수 있고, 그 사이에 동일 리소스(주문, 재고, 계정)에 대한 다른 작업이 들어옵니다. 보상은 “과거 상태로 되돌리기”가 아니라 “현재 상태에서 안전한 반대 작업”이어야 합니다.

대응

  • 비즈니스 키(예: orderId)에 대한 상태 전이 제약을 강하게 둠
  • 주문 상태를 PENDING -> CONFIRMED -> CANCELED처럼 단방향으로 설계하고, 보상은 “상태 전이”로 표현
  • 재고는 단순 +1이 아니라 reservation 토큰 기반으로 해제(누가 잡아둔 재고인지 식별)

동시성과 잠금으로 인한 장애는 DB 레벨에서도 드러납니다. 데드락 재현/해결 흐름은 MySQL Deadlock 1213 재현·로그·인덱스로 해결도 참고할 만합니다.

실패 원인 4) 외부 시스템 제약으로 “진짜 롤백”이 불가능하다

증상

  • PG(결제) 환불이 영업일/정산 상태에 따라 거절
  • 항공권/숙박/배송 등은 취소 수수료·취소 마감으로 인해 원상복구 불가
  • 이미 발행된 쿠폰/포인트가 사용되어 취소가 불가능

왜 발생하나

보상 트랜잭션은 내부 DB만 다루는 게 아니라 외부 도메인 규칙과 정책에 종속됩니다. “실패하면 되돌린다”는 기술적 가정이 비즈니스 현실과 충돌합니다.

대응

  • 보상 설계를 “rollback”이 아니라 reconciliation(정산/조정) 으로 확장
  • 보상 실패 시 자동 재시도만 하지 말고, 수동 처리 큐(ops queue) 로 넘길 기준을 정의
  • 사용자에게 노출되는 상태를 CANCEL_REQUESTED, CANCEL_FAILED_NEEDS_SUPPORT처럼 분리
  • 금전/정산이 걸리면 회계 이벤트(ledger) 로 남기고 사후 조정 가능하게 설계

실패 원인 5) 메시징/이벤트 전달의 불일치(유실·중복·순서 뒤바뀜)

증상

  • 보상 커맨드가 발행되었는데 컨슈머가 받지 못함(유실)
  • 같은 보상 이벤트가 여러 번 소비됨(중복)
  • CancelRequested보다 CancelCompleted가 먼저 처리되는 등 순서가 뒤바뀜

왜 발생하나

브로커 장애, 컨슈머 리밸런싱, 네트워크 파티션, 잘못된 파티셔닝 키 등이 원인입니다. 특히 “순서 보장”은 파티션 키 설계에 크게 좌우됩니다.

대응

  • Outbox 패턴으로 DB 커밋과 이벤트 발행을 결합(최소한 유실을 줄임)
  • 컨슈머는 inbox/dedup으로 중복을 흡수
  • 동일 Saga(예: orderId) 기준으로 같은 파티션에 들어가게 키 설계
  • 이벤트에 버전/시퀀스 번호를 넣고, 컨슈머가 역전된 이벤트를 무시/지연 처리

중복 처리 방지와 멱등 설계는 Exactly-once가 깨지는 현실에서 특히 중요합니다. 자세한 전략은 Kafka Exactly-Once 깨질 때 중복처리 방지 전략를 함께 보면 전체 그림이 잡힙니다.

예시 코드 (이벤트 시퀀스 검증)

-- 주문별로 마지막 처리 시퀀스를 저장
CREATE TABLE order_event_cursor (
  order_id VARCHAR(64) PRIMARY KEY,
  last_seq BIGINT NOT NULL
);
def handle_event(order_id: str, seq: int, cursor_repo, apply):
    last = cursor_repo.get_last_seq(order_id) or 0
    if seq <= last:
        return  # 중복/역전 이벤트 무시

    apply()  # 도메인 반영
    cursor_repo.upsert_last_seq(order_id, seq)

실패 원인 6) 인프라 리소스 문제로 보상 워커가 죽는다(OOM/CrashLoop)

증상

  • 보상 작업이 특정 시간대에 몰리면 워커가 재시작을 반복
  • 큐 적체가 증가하고, 타임아웃이 연쇄적으로 발생
  • 재시도 폭풍(retry storm)으로 시스템이 더 불안정해짐

왜 발생하나

보상은 “장애 시 더 많이 실행되는” 특성이 있습니다. 즉, 장애 상황에서 트래픽이 감소하는 게 아니라 보상/재시도가 증가합니다. 이때 워커 메모리 누수, 과도한 동시성, 큰 페이로드 처리 등으로 OOM 또는 CrashLoopBackOff가 발생하기 쉽습니다.

대응

  • 보상 워커에 동시성 제한(semaphore), 지수 백오프, 서킷 브레이커 적용
  • 실패 유형별로 재시도 가능/불가능을 구분(4xx는 즉시 DLQ)
  • Kubernetes 환경이라면 리소스/Probe 설정과 로그를 기준으로 원인 분리
  • OOM이면 cgroup 제한/메모리 사용량/GC 로그를 확인해 누수 또는 버퍼 폭증을 찾기

운영 관점의 디버깅은 아래 글들이 직접적인 도움이 됩니다.

예시 코드 (지수 백오프 + 재시도 상한)

func retryWithBackoff(max int, base time.Duration, fn func() error) error {
    var err error
    for i := 0; i < max; i++ {
        err = fn()
        if err == nil {
            return nil
        }
        // 간단한 지수 백오프 (jitter는 실무에서 권장)
        time.Sleep(base * time.Duration(1<<i))
    }
    return err
}

보상 실패를 줄이는 실무 체크리스트

보상 트랜잭션 실패는 “코드 버그”만의 문제가 아니라, 시스템의 기본 성질에서 비롯됩니다. 아래 체크리스트로 설계를 점검해보면 실패율을 크게 낮출 수 있습니다.

  • 멱등성: 모든 보상 커맨드/API가 중복 호출되어도 안전한가?
  • 역순 보상: 단계 의존성을 명시했고, 역순 실행이 보장되는가?
  • 상태 머신: 보상은 -1 연산이 아니라 “상태 전이”로 모델링되어 있는가?
  • 메시징 신뢰성: outbox/inbox, 파티션 키, 시퀀스 검증이 있는가?
  • 재시도 정책: 무한 재시도 대신, 실패 유형별 분기와 DLQ/수동 큐가 있는가?
  • 운영 내구성: 보상 워커가 장애 상황에서 더 바빠진다는 점을 고려해 리소스/동시성을 제한했는가?

마무리

Saga 패턴은 분산 환경에서 강력하지만, 보상 트랜잭션은 “언젠가 실패한다”는 전제 위에 서 있습니다. 따라서 목표는 보상을 100% 성공시키는 것이 아니라, 실패해도 시스템이 일관된 방향으로 수렴하도록 만드는 것입니다.

이번 글의 6가지 원인(멱등성 부재, 순서 오류, 동시성 경합, 외부 제약, 메시징 불일치, 인프라 리소스 문제)을 기준으로 현재 시스템의 보상 흐름을 점검해보면, 장애가 “가끔 나는 미스터리”에서 “예측 가능한 설계 문제”로 바뀌기 시작할 것입니다.