Published on

MSA Saga 보상 트랜잭션 설계 실수 7가지

Authors

서로 다른 마이크로서비스가 각자 로컬 트랜잭션을 커밋하는 MSA 환경에서는, 실패 시점에 전체를 되돌리는 전통적인 2PC(분산 트랜잭션)가 현실적으로 어렵습니다. 그래서 많이 선택하는 것이 **사가(Saga)**이고, 그 핵심이 바로 보상 트랜잭션(Compensating Transaction) 입니다.

문제는 “실패하면 보상하면 되지”라는 단순한 접근이 실제 운영에서 거의 항상 깨진다는 점입니다. 보상은 Undo가 아니라 새로운 비즈니스 트랜잭션이며, 네트워크/재시도/중복 실행/시간 지연/부분 성공 같은 분산 시스템의 모든 어려움을 그대로 안고 갑니다. 이 글에서는 현장에서 자주 터지는 보상 트랜잭션 설계 실수 7가지를 짚고, 각 실수별로 예방 설계와 구현 팁을 제공합니다.


사가(Saga)와 보상 트랜잭션을 다시 정의하기

사가에는 크게 두 형태가 있습니다.

  • 오케스트레이션(Orchestration): 중앙 오케스트레이터가 단계별 커맨드를 호출하고, 실패 시 보상 커맨드를 호출
  • 코레오그래피(Choreography): 서비스들이 이벤트를 발행/구독하며 흐름이 구성되고, 실패 이벤트에 반응해 보상 진행

보상 트랜잭션은 다음 특징을 가집니다.

  • 원 트랜잭션을 “정확히” 되돌리는 것이 아니라, 비즈니스적으로 동등한 상태로 되돌리는 것
  • 항상 즉시 실행되지 않을 수 있으며, 지연재시도가 전제
  • 중복 실행될 수 있으므로 멱등성(idempotency) 이 필수

이 전제를 놓치면 아래 실수들로 직행합니다.


실수 1) 보상을 ‘DB 롤백’처럼 설계한다

가장 흔한 오해는 “결제 승인했으면 승인 취소 API 호출하면 끝”처럼 보상을 단순 롤백으로 보는 것입니다. 하지만 현실에서는:

  • 결제 취소 가능 시간/부분취소/취소 수수료
  • 재고는 이미 다른 주문에 재할당
  • 쿠폰/포인트는 이미 사용 처리 후 만료

등으로 인해 완전한 역연산이 불가능한 경우가 많습니다.

예방 설계

  • 보상은 역연산(undo) 이 아니라 상태 전이(state transition) 로 모델링
  • “원상복구” 대신 정의 가능한 목표 상태를 둠
    • 예: PAID -> REFUND_PENDING -> REFUNDED
    • 예: RESERVED -> RELEASE_REQUESTED -> RELEASED

코드 예시(상태 전이 기반 도메인 모델)

public enum PaymentStatus {
    AUTHORIZED,
    CAPTURED,
    REFUND_PENDING,
    REFUNDED,
    REFUND_FAILED
}

public class Payment {
    private PaymentStatus status;

    public void requestRefund() {
        if (status != PaymentStatus.CAPTURED) {
            throw new IllegalStateException("Only captured payments can be refunded");
        }
        status = PaymentStatus.REFUND_PENDING;
    }

    public void markRefunded() {
        if (status != PaymentStatus.REFUND_PENDING) {
            throw new IllegalStateException("Refund must be pending");
        }
        status = PaymentStatus.REFUNDED;
    }
}

실수 2) 보상 트랜잭션에 멱등성이 없다

분산 환경에서 재시도는 기본값입니다. HTTP 타임아웃, 메시지 중복 전달, 컨슈머 재처리 등으로 같은 보상 요청이 여러 번 실행될 수 있습니다. 멱등성이 없으면:

  • 환불이 2번 나감
  • 재고가 음수로 풀림
  • 포인트가 중복 복구됨

이런 사고는 “정합성”이 아니라 “돈” 문제로 번집니다.

참고로 타임아웃/재시도는 애플리케이션 로직이 아니라 인프라와 클라이언트 정책에서도 쉽게 발생합니다. 운영에서 타임아웃을 다루는 감각은 필수인데, 유사한 장애 감각은 OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드 같은 글에서 다루는 접근(재현→관측→정책 조정)과도 닮아 있습니다.

예방 설계

  • 모든 커맨드/보상 커맨드에 Idempotency Key(=Command ID) 를 부여
  • 처리 결과를 저장하고, 같은 키로 재요청 시 동일 결과를 반환
  • 가능하면 외부 결제/배송 같은 연동도 idempotency 지원 여부를 확인

코드 예시(멱등 처리 테이블)

CREATE TABLE saga_command_log (
  command_id VARCHAR(64) PRIMARY KEY,
  command_type VARCHAR(64) NOT NULL,
  status VARCHAR(32) NOT NULL,
  result_json TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@Transactional
public CommandResult handleRefund(String commandId, RefundRequest req) {
    var existing = commandLogRepository.findById(commandId);
    if (existing.isPresent()) {
        return CommandResult.fromJson(existing.get().getResultJson());
    }

    // 실제 환불 로직
    payment.requestRefund();
    paymentRepository.save(payment);

    var result = CommandResult.success();
    commandLogRepository.save(new CommandLog(commandId, "REFUND", "DONE", result.toJson()));
    return result;
}

실수 3) 보상 순서를 “원 트랜잭션 역순”으로만 고정한다

교과서적으로는 정방향 실행의 역순으로 보상합니다. 하지만 실제로는 역순이 항상 안전하지 않습니다.

예시:

  • 재고 예약 -> 결제 캡처 -> 배송 생성
  • 실패 시 역순 보상: 배송 취소 -> 결제 환불 -> 재고 해제

여기서 배송 취소가 실패하거나 지연되면 환불을 늦춰야 할까? 반대로 환불을 먼저 하면 고객 경험은 좋아지지만 배송이 이미 출고되면 손실이 커질 수 있습니다.

예방 설계

  • 보상 순서를 “역순”이 아니라 리스크/비용/고객경험 기준으로 재정의
  • 보상 간 의존성을 명시하고, 필요하면 병렬 보상(독립 단계)을 허용
  • 보상 불가 단계(배송 출고 등)는 “보상”이 아니라 후속 처리(Claim/CS workflow) 로 분리

코드 예시(의존성 기반 보상 플로우)

compensationPlan:
  - step: cancelShipment
    dependsOn: []
    timeout: 2m
  - step: refundPayment
    dependsOn: [cancelShipment]
    timeout: 30s
  - step: releaseInventory
    dependsOn: [refundPayment]
    timeout: 30s

실수 4) 보상을 “즉시 동기 호출”로만 처리한다

오케스트레이터가 실패를 감지하자마자 각 서비스 보상을 동기 HTTP 체인으로 호출하면, 장애 시 다음이 터집니다.

  • 한 서비스 지연이 전체 사가를 블로킹
  • 재시도 폭증으로 연쇄 부하
  • 타임아웃이 늘어나며 실패가 더 늘어나는 악순환

이건 특히 인그레스/로드밸런서 레벨에서 5xx/504로 관측되며, 원인은 애플리케이션이 아니라 “동기 체인 설계”인 경우가 많습니다. 비슷한 운영 관점은 EKS ALB Ingress 504(5xx) 간헐 발생 원인·해결 같은 케이스에서 크게 체감합니다.

예방 설계

  • 보상은 기본적으로 비동기(메시지/잡) 로 전환
  • 오케스트레이터는 “호출”이 아니라 보상 작업을 스케줄
  • 각 보상 단계는 독립적으로 재시도/백오프/서킷브레이커 적용

코드 예시(Outbox로 보상 이벤트 발행)

CREATE TABLE outbox (
  id BIGSERIAL PRIMARY KEY,
  aggregate_id VARCHAR(64) NOT NULL,
  event_type VARCHAR(64) NOT NULL,
  payload_json TEXT NOT NULL,
  published BOOLEAN NOT NULL DEFAULT FALSE,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@Transactional
public void scheduleCompensation(String sagaId, String type, String payloadJson) {
    outboxRepository.save(new OutboxEvent(sagaId, type, payloadJson));
    // 같은 트랜잭션에서 커밋되어야 "저장했는데 발행 못함"을 줄일 수 있음
}

실수 5) “부분 성공”을 상태 모델에 넣지 않는다

사가의 실패는 보통 완전 실패가 아니라 부분 성공입니다.

  • 결제는 성공했는데 재고 예약이 실패
  • 재고는 됐는데 쿠폰 적용이 실패
  • 보상 중 환불은 성공했는데 재고 해제가 실패

부분 성공을 모델링하지 않으면 운영자는 “지금 이 주문이 어떤 상태인지” 알 수 없고, 자동 복구도 불가능해집니다.

예방 설계

  • 사가 인스턴스에 대해 명시적 상태 머신을 둠
  • 단계별 상태: PENDING / DONE / COMPENSATING / COMPENSATED / FAILED
  • UI/CS/운영툴에서 조회 가능한 단일 진실 소스(Single Source of Truth) 를 마련

코드 예시(사가 상태 테이블)

CREATE TABLE saga_instance (
  saga_id VARCHAR(64) PRIMARY KEY,
  saga_type VARCHAR(64) NOT NULL,
  status VARCHAR(32) NOT NULL,
  current_step VARCHAR(64),
  last_error TEXT,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE saga_step (
  saga_id VARCHAR(64) NOT NULL,
  step_name VARCHAR(64) NOT NULL,
  status VARCHAR(32) NOT NULL,
  attempt INT NOT NULL DEFAULT 0,
  last_error TEXT,
  PRIMARY KEY (saga_id, step_name)
);

실수 6) 보상에 “관측 가능성(Observability)”이 없다

보상은 보통 장애 상황에서 실행됩니다. 그런데 로그에 refund failed 한 줄만 남으면, 원인 분석은 지옥이 됩니다.

필수로 있어야 할 것:

  • 사가 ID(상관관계 ID), 커맨드 ID
  • 단계별 시작/종료 시간, 시도 횟수
  • 외부 연동 호출의 응답 코드/에러 분류(재시도 가능 vs 불가)
  • 대시보드에서 “보상 대기/실패 적체”를 보는 큐/잡 지표

특히 스레드/동시성 모델 변화(예: 가상 스레드 도입) 이후 지연이 늘어나면 보상 큐가 뒤늦게 폭발하기도 합니다. 이런 진단 감각은 Spring Boot 3 가상스레드 적용 후 지연·데드락 진단처럼 “지연이 어떻게 증폭되는지”를 파고드는 방식이 도움이 됩니다.

코드 예시(구조화 로깅 + 상관관계 ID)

public void compensateRefund(String sagaId, String commandId) {
    MDC.put("sagaId", sagaId);
    MDC.put("commandId", commandId);
    long start = System.currentTimeMillis();

    try {
        log.info("compensation.start step=refund");
        refundService.refund(commandId);
        log.info("compensation.success step=refund elapsedMs={}", System.currentTimeMillis() - start);
    } catch (RetryableException e) {
        log.warn("compensation.retryable step=refund elapsedMs={} err={}",
                System.currentTimeMillis() - start, e.toString());
        throw e;
    } finally {
        MDC.clear();
    }
}

실수 7) “보상 불가능” 케이스를 설계에서 배제한다

현실에는 보상이 불가능하거나, 보상 비용이 너무 큰 케이스가 존재합니다.

  • 외부 시스템이 취소를 지원하지 않음
  • 취소 가능 시간 초과
  • 이미 출고/사용/소비됨
  • 법/정책상 환불이 제한됨

이런 케이스를 무시하면 시스템은 결국 영구 실패 상태를 쌓아두고, 운영자는 수동 처리로 소진됩니다.

예방 설계

  • 각 단계에 대해 보상 가능 여부를 사전에 분류
    • COMPENSATABLE, REQUIRES_MANUAL, NON_COMPENSATABLE
  • 보상 실패가 일정 횟수/시간을 넘으면 Dead Letter + 티켓/알림으로 전환
  • 고객 커뮤니케이션(환불 지연 안내 등)을 자동화할 이벤트도 함께 설계

코드 예시(재시도 한도 초과 시 DLQ 전송)

public void runCompensationStep(SagaStep step) {
    try {
        execute(step);
        markDone(step);
    } catch (Exception e) {
        step.incrementAttempt();
        if (step.getAttempt() >= 5) {
            dlqPublisher.publish("COMPENSATION_FAILED", step.toJson());
            markFailed(step, e);
        } else {
            scheduleRetry(step, backoff(step.getAttempt()));
        }
    }
}

설계 체크리스트(실전용)

아래 항목을 “예/아니오”로 점검해보면, 보상 설계의 구멍이 빠르게 드러납니다.

  • 보상은 역연산이 아닌 도메인 상태 전이로 정의되어 있는가?
  • 모든 커맨드/보상 커맨드가 멱등인가(저장소 기반 중복 방지 포함)?
  • 보상 순서가 단순 역순이 아니라 리스크 기반으로 조정 가능한가?
  • 보상이 비동기로 실행되어 장애 전파를 줄이는가?
  • 부분 성공/보상 진행 중 상태가 사가 상태 머신에 반영되는가?
  • 사가 ID로 end-to-end 추적 가능한 관측성(로그/메트릭/트레이스)이 있는가?
  • 보상 불가능 케이스가 프로세스(수동 처리/알림/DLQ) 로 설계되어 있는가?

마무리: “정합성”보다 “운영 가능성”이 먼저다

사가에서 보상 트랜잭션은 정합성을 위한 장치이지만, 실제로는 운영 가능성을 결정하는 설계 요소입니다. 멱등성, 상태 모델, 비동기 실행, 관측성, 보상 불가능 케이스까지 포함해 설계하지 않으면 시스템은 언젠가 “되돌릴 수 없는 중간 상태”를 쌓고 멈춥니다.

보상 설계가 어렵게 느껴진다면, 먼저 각 단계에 대해 다음 한 문장으로 정의해보세요.

  • “이 단계가 성공한 뒤 실패하면, 우리는 무엇을 어떤 상태로 만들면 비즈니스적으로 수용 가능한가?”

이 문장에 답이 명확해질수록, 사가는 더 단단해집니다.