Published on

MSA 사가 패턴 보상 트랜잭션 실패 원인 7가지

Authors

마이크로서비스에서 사가(Saga) 패턴을 도입하면 분산 트랜잭션을 피하면서도 비즈니스 일관성을 맞출 수 있습니다. 하지만 운영에서 진짜 어려운 지점은 “실패했을 때 되돌리는 보상 트랜잭션(Compensation Transaction)”이 기대대로 동작하지 않는 순간입니다. 보상은 단순히 반대 작업을 한 번 호출하는 문제가 아니라, 메시징/중복/순서/권한/타임아웃/데이터 모델까지 얽힌 분산 시스템의 실패 모드가 그대로 드러나는 구간입니다.

이 글에서는 MSA 사가에서 보상 트랜잭션이 실패하는 원인을 7가지로 분류하고, 각 원인별로 증상, 근본 원인, 예방/대응, 코드 예시를 함께 정리합니다. 사가 자체 설계 패턴은 아래 글과 함께 보면 더 빠르게 연결됩니다.

전제: “보상은 롤백이 아니라 또 하나의 비즈니스 트랜잭션”

보상 트랜잭션은 DB 롤백처럼 자동/원자적으로 되돌리는 기능이 아닙니다. 이미 외부에 반영된 상태(결제 승인, 재고 차감, 쿠폰 사용 등)를 새로운 비즈니스 규칙으로 상쇄하는 작업입니다. 따라서 다음 성질을 기본 전제로 둬야 합니다.

  • 보상은 실패할 수 있다(외부 시스템 장애, 권한, 네트워크 등)
  • 보상은 재시도될 수 있다(최소 1회 전달, 컨슈머 재시작)
  • 보상은 늦게 도착할 수 있다(지연/역순)
  • 보상은 완벽히 원상복구가 아닐 수 있다(수수료, 환율, 만료)

이 전제 위에서 “왜 보상이 실패하는가”를 7가지로 쪼개보겠습니다.

1) 보상 API가 멱등(idempotent)하지 않다

증상

  • 동일 보상 이벤트가 두 번 처리되며 금액이 두 번 환불됨
  • 반대로, 두 번째 호출에서 409 또는 500이 나며 사가가 영구 실패 상태로 남음

근본 원인

메시징 기반 사가는 보통 at-least-once 전달을 전제로 합니다. 즉, 중복 이벤트는 정상입니다. 문제는 보상 엔드포인트가 멱등하지 않으면 중복 호출이 곧 데이터 오염으로 이어진다는 점입니다.

예방/대응

  • 보상 명령에 commandId 또는 sagaId + step 기반의 idempotency key를 포함
  • 소비 측에서 처리 이력 테이블로 중복을 차단(유니크 키)
  • 외부 결제사/재고 시스템도 멱등 키를 지원하는지 확인

코드 예시 (Spring Boot, 처리 이력으로 멱등 보장)

아래 예시는 command_id 유니크 제약으로 중복 보상을 차단합니다. 부등호 문자는 MDX 이슈를 피하기 위해 코드 블록 안에서만 사용합니다.

CREATE TABLE compensation_log (
  command_id VARCHAR(64) PRIMARY KEY,
  saga_id    VARCHAR(64) NOT NULL,
  step       VARCHAR(32) NOT NULL,
  created_at TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@Transactional
public void compensate(CompensationCommand cmd) {
    // 1) 중복 처리 방지
    try {
        compensationLogRepository.insert(cmd.commandId(), cmd.sagaId(), cmd.step());
    } catch (DuplicateKeyException e) {
        return; // 이미 처리됨
    }

    // 2) 실제 보상 로직
    paymentClient.refund(cmd.paymentId(), cmd.amount(), cmd.commandId());
    orderRepository.markCompensated(cmd.orderId());
}

2) 이벤트 순서 역전 또는 지연으로 “이미 다른 상태”가 됐다

증상

  • 보상 이벤트가 늦게 도착해 이미 주문이 재시도되어 성공했는데, 늦은 보상으로 다시 취소됨
  • 재고 보상이 먼저 처리되고 결제 보상이 나중에 처리되어 재고가 일시적으로 과다해짐

근본 원인

Kafka 같은 로그 기반 브로커에서도 파티션 키 설계가 잘못되면 동일 사가 흐름이 여러 파티션으로 흩어져 순서가 깨질 수 있습니다. 또한 네트워크/컨슈머 지연으로 “늦게 도착한 이벤트”는 언제든 발생합니다.

예방/대응

  • 사가 단위로 동일 파티션을 타도록 sagaId를 파티션 키로 사용
  • 각 스텝에 version 또는 sequence를 두고, 오래된 명령은 무시
  • 상태 머신을 명확히 두고, 전이 불가능한 상태에서는 보상을 거부하되 “거부가 곧 실패”가 되지 않도록 설계(예: 이미 성공한 사가라면 보상은 no-op)

코드 예시 (시퀀스 기반 무시)

public void onCompensation(CompensationCommand cmd) {
    SagaState state = sagaStateRepository.find(cmd.sagaId());
    if (cmd.sequence() < state.getLastAppliedSequence()) {
        return; // 늦게 도착한 명령
    }

    // 상태 전이 검증
    if (!state.canApply(cmd.step())) {
        return; // 이미 다른 상태면 no-op 처리
    }

    applyCompensation(cmd);
    sagaStateRepository.updateLastAppliedSequence(cmd.sagaId(), cmd.sequence());
}

3) 보상 로직이 “정확한 역연산”이 아니다 (비가역성)

증상

  • 결제 취소는 가능하지만 쿠폰 복구는 만료되어 불가
  • 포인트 차감 보상 시, 사용 내역이 합쳐져 원래 상태로 복원 불가
  • 환불 수수료/환율 반영으로 금액이 달라져 정산이 꼬임

근본 원인

보상은 수학적 역함수가 아닙니다. 특히 외부 시스템이 개입되면 “되돌릴 수 없음”이 자주 발생합니다. 그럼에도 보상 트랜잭션을 단순 반대 API 호출로 생각하면 운영에서 반드시 막힙니다.

예방/대응

  • 보상 불가능 케이스를 명시적으로 모델링: COMPENSATION_PENDING_MANUAL, NEEDS_REVIEW
  • “원상복구” 대신 “상쇄(Adjust)”로 설계: 차액 정산, 보정 포인트 지급 등
  • 원본 스냅샷을 저장: 보상 시 필요한 값을 이벤트에 포함하거나 별도 저장소에 보관

코드 예시 (보상 불가 상태를 정상 플로우로 승격)

try {
    couponClient.restore(cmd.couponId());
} catch (CouponExpiredException e) {
    sagaStateRepository.markNeedsManual(cmd.sagaId(), "COUPON_EXPIRED");
    // 보상 실패를 무한 재시도로 몰지 않고, 운영 처리로 넘김
}

4) 보상 트랜잭션이 “부분 성공”하고 재시도로 중복 부작용이 난다

증상

  • 환불은 성공했는데 주문 상태 업데이트가 실패하여 재시도 시 환불이 또 시도됨
  • 재고는 복구됐는데 배송 취소가 실패하여 사가가 계속 흔들림

근본 원인

보상 처리도 여러 자원(내 DB, 외부 API, 메시지 발행)을 엮습니다. 이때 원자성이 없으면 “어디까지 됐는지”가 불명확해지고, 재시도가 부작용을 키웁니다.

예방/대응

  • 보상도 스텝을 쪼개고 각 스텝을 멱등 처리
  • 외부 호출 전후로 로컬 상태를 먼저 기록해 재시도 시 분기
  • 메시지 발행은 Outbox 패턴으로 로컬 트랜잭션에 묶기

관련해서 중복/아웃박스 결합은 아래 글이 실전적입니다.

코드 예시 (보상 처리 단계 기록)

@Transactional
public void compensatePayment(CompensationCommand cmd) {
    CompensationProgress p = progressRepo.getOrCreate(cmd.sagaId(), "PAYMENT");

    if (!p.isRefunded()) {
        paymentClient.refund(cmd.paymentId(), cmd.amount(), cmd.commandId());
        p.markRefunded();
        progressRepo.save(p);
    }

    if (!p.isOrderMarked()) {
        orderRepository.markPaymentCompensated(cmd.orderId());
        p.markOrderMarked();
        progressRepo.save(p);
    }
}

5) 타임아웃/커넥션 고갈로 보상이 “실행 자체를 못한다”

증상

  • 보상 컨슈머가 쌓이고 레이턴시가 폭증, 결국 DLQ로 밀림
  • 503 또는 타임아웃이 연쇄적으로 발생하며 보상 큐가 눈덩이처럼 증가

근본 원인

보상은 장애 상황에서 집중적으로 발생합니다. 즉, 가장 트래픽이 몰리는 순간에 가장 중요한 처리를 해야 합니다. 이때 DB 커넥션 풀, HTTP 커넥션 풀, 스레드 모델이 취약하면 보상은 “논리적으로 실패”하기 전에 “자원 부족”으로 죽습니다.

예방/대응

  • 보상 워커에 별도 리소스 격리: 별도 컨슈머 그룹, 별도 스레드풀/커넥션풀
  • 타임아웃/서킷 브레이커/벌크헤드 적용
  • DB 트랜잭션 범위를 최소화(외부 호출을 트랜잭션 밖으로 분리하거나, 상태 기록 후 호출)

장애 진단 관점에서는 아래 글의 503 분류/대응이 유용합니다.

코드 예시 (트랜잭션 최소화 + 타임아웃)

public void compensate(CompensationCommand cmd) {
    // 1) 먼저 로컬에 "보상 시작" 기록 (짧은 트랜잭션)
    markCompensationStarted(cmd);

    // 2) 외부 호출은 트랜잭션 밖에서, 짧은 타임아웃으로
    paymentClient.refundWithTimeout(cmd.paymentId(), cmd.amount(), cmd.commandId(), 1500);

    // 3) 결과 기록
    markCompensationDone(cmd);
}

@Transactional
void markCompensationStarted(CompensationCommand cmd) {
    sagaStateRepository.markStepRunning(cmd.sagaId(), cmd.step());
}

@Transactional
void markCompensationDone(CompensationCommand cmd) {
    sagaStateRepository.markStepDone(cmd.sagaId(), cmd.step());
}

6) 권한/정책 문제로 보상이 거부된다 (RBAC, 네트워크, 시크릿)

증상

  • 평소엔 정상인데 장애 시점에만 보상 호출이 403 또는 인증 실패
  • 운영 환경에서만 보상 워커가 특정 시스템 접근 불가

근본 원인

보상은 종종 “관리성 API” 또는 “취소 전용 API”를 호출합니다. 이 엔드포인트는 권한 정책이 더 엄격하거나, 네트워크 경로가 다르거나, 별도 시크릿을 요구하는 경우가 많습니다. 또한 쿠버네티스/클라우드에서 서비스 계정 권한이 미세하게 달라 “보상 워커만” 막히는 일이 흔합니다.

예방/대응

  • 보상 워커의 실행 주체(서비스 계정, MI 등)와 권한을 별도로 점검
  • 장애 대응용 권한을 임시로 열기보다는, 정상 설계로 최소 권한을 갖추기
  • 403은 재시도로 해결되지 않으므로 빠르게 “구성 오류”로 분류해 알람

클라우드 권한/방화벽 이슈의 전형적인 디버깅 방식은 다음 글 흐름이 참고됩니다.

코드 예시 (재시도 금지 오류 분류)

try {
    paymentClient.refund(cmd.paymentId(), cmd.amount(), cmd.commandId());
} catch (HttpException e) {
    if (e.status() == 401 || e.status() == 403) {
        // 구성/권한 문제: 재시도 큐에 넣지 말고 즉시 운영 알람
        alert("COMPENSATION_AUTH_FAILURE", cmd.sagaId(), e.getMessage());
        sagaStateRepository.markNeedsManual(cmd.sagaId(), "AUTH_FAILURE");
        return;
    }
    throw e; // 나머지는 재시도 대상
}

7) 관측성 부족으로 “실패 원인”을 못 찾아 보상이 방치된다

증상

  • DLQ에 메시지가 쌓이는데 어떤 주문/어떤 스텝인지 추적이 안 됨
  • 로그는 있는데 sagaId가 없어 상관관계 분석이 불가
  • 재시도 정책이 과도해 비용만 증가하고, 근본 원인은 계속 남음

근본 원인

보상은 실패 시나리오의 중심인데, 많은 팀이 성공 경로만 로깅/트레이싱합니다. 특히 사가 오케스트레이터와 참여자 서비스가 분리되면, “어디서 실패했는지”를 잇는 상관관계 키가 없으면 복구가 거의 불가능해집니다.

예방/대응

  • 모든 이벤트/로그/트레이스에 sagaId, orderId, step, commandId를 포함
  • 메트릭: 보상 성공률, 평균 지연, DLQ 적체량, 재시도 횟수 분포
  • DLQ는 단순 적재가 아니라 “재처리 도구”까지 포함(리플레이 시 멱등 보장 필수)

코드 예시 (구조화 로그 필드)

log.info("compensation.start sagaId={} orderId={} step={} commandId={}",
        cmd.sagaId(), cmd.orderId(), cmd.step(), cmd.commandId());

try {
    compensate(cmd);
    log.info("compensation.success sagaId={} step={} commandId={}",
            cmd.sagaId(), cmd.step(), cmd.commandId());
} catch (Exception e) {
    log.error("compensation.fail sagaId={} step={} commandId={} err={}",
            cmd.sagaId(), cmd.step(), cmd.commandId(), e.toString());
    throw e;
}

운영 체크리스트: 보상 실패를 줄이는 10가지 질문

아래 질문에 “예”라고 답할수록 보상 실패가 장애로 번지는 확률이 줄어듭니다.

  1. 보상 명령은 commandId 기반으로 멱등 처리되는가
  2. 동일 사가 흐름이 동일 파티션/순서를 보장받는가
  3. 늦게 도착한 보상 이벤트를 무시할 수 있는가(시퀀스/버전)
  4. 보상 불가능 케이스가 상태 모델에 포함되어 있는가(수동 처리 포함)
  5. 보상 처리도 부분 성공을 기록하고 이어서 재개할 수 있는가
  6. 외부 호출 타임아웃/서킷 브레이커/벌크헤드가 적용되어 있는가
  7. 보상 워커 리소스(스레드/DB풀/커넥션풀)가 격리되어 있는가
  8. 401/403 같은 비재시도 오류를 즉시 분류/알람하는가
  9. 로그/트레이스에 sagaIdstep이 항상 포함되는가
  10. DLQ 재처리 도구가 있고, 재처리가 멱등하게 안전한가

마무리

사가 패턴에서 보상 트랜잭션 실패는 “예외 상황”이 아니라, 분산 시스템이 정상적으로 겪는 실패 모드의 집합입니다. 따라서 해결책도 특정 라이브러리 하나로 끝나지 않고, 멱등성, 순서/지연 내성, 부분 성공 관리, 자원 격리, 권한 설계, 관측성이 함께 맞물려야 합니다.

보상 설계를 더 구체적인 패턴(Outbox, 오케스트레이션/코레오그래피, DLQ 리플레이) 관점으로 확장하고 싶다면 아래 글을 함께 참고하세요.