Published on

MSA 사가 보상 트랜잭션 설계 실패 7가지

Authors

서로 다른 마이크로서비스가 각자 DB를 소유하는 MSA 환경에서, 분산 트랜잭션을 2PC로 강제하기는 어렵습니다. 그래서 많이 선택하는 접근이 Saga(사가) 입니다. 다만 Saga는 “원자적 롤백”이 아니라, 부분 성공이 발생한 뒤 보상(Compensation)으로 일관성을 회복하는 패턴입니다. 이 차이를 설계에서 놓치면 장애 시나리오에서 데이터가 틀어지고, 재처리가 꼬이고, 운영자가 수동 정합성 복구를 하게 됩니다.

이 글은 MSA에서 Saga 보상 트랜잭션을 설계할 때 자주 터지는 실패 7가지를 ‘왜 망하는지 → 어떻게 막는지’ 관점으로 정리합니다.


1) 보상을 “롤백”으로 착각한다: 비가역 작업을 무시

실패 패턴

  • 결제 승인, 쿠폰 사용, 외부 배송 접수처럼 비가역(irreversible) 이거나 비용이 드는 작업을 단순히 “취소 API 호출하면 되겠지”로 가정
  • 외부 시스템이 취소를 지원하지 않거나, 취소가 즉시 반영되지 않거나, 취소 가능 시간이 제한됨

결과

  • “보상 호출 성공” 로그는 남는데 실제로는 취소가 반영되지 않아 금전/재고/배송 정합성이 깨짐
  • 장애가 발생하면 운영자가 외부 시스템 콘솔에서 수동 취소

예방 설계

  • 각 단계별로 가역성 수준을 분류
    • 가역(완전 취소 가능)
    • 보상(상쇄 거래 필요: 환불/반품/재정산)
    • 불가역(사후 정산/운영 프로세스 필요)
  • 불가역 단계는 Saga 후반으로 미루거나, 아예 예약(hold) → 확정(capture) 2단계 모델로 전환

예: 결제는 authorize(가승인) 후 최종 성공 시 capture, 실패 시 void


2) 보상 트랜잭션을 멱등하게 만들지 않는다

실패 패턴

  • 보상 API가 cancel(orderId)처럼 단순 호출이고, 내부적으로 “이미 취소됨”을 처리하지 않음
  • 메시지 브로커의 재전송/중복 전달(At-least-once)을 고려하지 않음

결과

  • 동일 보상이 여러 번 실행되어 환불 중복, 재고 과복원, 포인트 마이너스 등 2차 사고

예방 설계

  • 보상/정방향 모두 멱등 키(Idempotency Key) 를 설계
  • DB에 processed_commands 같은 테이블로 “이 커맨드를 처리했는지” 기록
-- 멱등 처리용 테이블 예시
CREATE TABLE processed_commands (
  command_id VARCHAR(64) PRIMARY KEY,
  processed_at TIMESTAMP NOT NULL
);
// 단순 예시: commandId로 멱등 보장
@Transactional
public void compensateCancelPayment(String commandId, String paymentId) {
    if (processedCommands.existsById(commandId)) return;

    paymentGateway.cancel(paymentId); // 외부 호출
    processedCommands.save(new ProcessedCommand(commandId, Instant.now()));
}

핵심은 “중복 호출되어도 결과가 한 번만 반영”되게 만드는 것입니다.


3) 보상 순서를 “역순 실행”로만 단순화한다

실패 패턴

  • “정방향 A→B→C 실행했으니 보상은 C'→B'→A'”라는 규칙만 적용
  • 실제 도메인에서는 부분 성공 시점, 외부 확정 시점, 비동기 처리 지연 때문에 역순이 항상 안전하지 않음

결과

  • 재고 보상보다 환불이 먼저 실행되어, 재고는 여전히 차감인데 주문은 취소 처리됨
  • 배송이 이미 시작되었는데 주문 취소/환불만 완료되어 고객 CS 폭발

예방 설계

  • 보상은 역순이 아니라 도메인 불변조건(invariant) 을 만족하도록 설계
  • “어떤 상태에서 어떤 보상이 허용되는가”를 명시한 상태 머신을 둠
stateDiagram-v2
  [*] --> CREATED
  CREATED --> PAID: paymentAuthorized
  PAID --> RESERVED: stockReserved
  RESERVED --> CONFIRMED: paymentCaptured
  RESERVED --> CANCELED: cancelBeforeCapture
  CONFIRMED --> REFUNDING: refundRequested
  REFUNDING --> CANCELED: refundCompleted

보상은 “이전 단계로 되돌리기”가 아니라 “현재 상태에서 허용되는 정합성 회복 경로”여야 합니다.


4) 오케스트레이터 상태 저장을 대충 한다(재시작/재처리 불능)

실패 패턴

  • Saga 오케스트레이터(워크플로 엔진/서비스)가 진행 상태를 메모리나 캐시에만 둠
  • 장애/재배포 시 “어디까지 실행했는지” 모름

결과

  • 중간에 죽으면 재처리 로직이 꼬여 중복 실행 또는 보상 누락
  • 운영자가 로그 보고 수동으로 상태 맞춤

예방 설계

  • 오케스트레이터는 최소한 아래를 영속화
    • sagaId
    • 현재 step
    • 각 step 실행 결과(성공/실패/외부 식별자)
    • 보상 필요 여부
  • 이벤트 기반이면 Outbox 패턴으로 발행 신뢰성 확보
CREATE TABLE saga_instance (
  saga_id VARCHAR(64) PRIMARY KEY,
  state VARCHAR(32) NOT NULL,
  current_step INT NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

CREATE TABLE saga_step_log (
  saga_id VARCHAR(64) NOT NULL,
  step INT NOT NULL,
  action VARCHAR(64) NOT NULL,
  status VARCHAR(16) NOT NULL,
  payload JSON,
  created_at TIMESTAMP NOT NULL,
  PRIMARY KEY (saga_id, step, action)
);

Outbox/이벤트 신뢰성 자체는 별도 주제로 깊지만, “발행 실패로 보상이 영원히 안 감”을 막는 것이 핵심입니다.


5) 타임아웃/재시도를 ‘기술 문제’로만 보고 도메인 정책이 없다

실패 패턴

  • 외부 API 호출 실패 시 무조건 재시도(예: 10회) 또는 즉시 보상
  • 네트워크 지연, 일시 장애, 스로틀링을 고려하지 않고 동일 정책 적용

결과

  • 일시 장애였는데 성급히 보상으로 들어가 불필요한 환불/취소
  • 반대로 확정이 필요한데 재시도만 하다가 고객 대기 시간 폭발

예방 설계

  • 단계별로 정책을 분리
    • retryable(지수 백오프)
    • confirmable(상태 조회로 확정)
    • compensatable(보상 실행)
    • manual(운영介入)
  • “타임아웃은 실패가 아니라 불확실(Unknown)”일 수 있음을 모델에 반영

실무에서 이 ‘불확실 상태’를 무시하면, 결제/주문처럼 돈이 걸린 영역에서 사고가 납니다.

네트워크 이슈가 원인인 경우도 많습니다. 예를 들어 EKS 환경에서 DNS는 되는데 외부 HTTPS만 실패하는 케이스처럼, 애플리케이션 레벨에서 보기엔 “상대 시스템 장애”로 오인하기 쉽습니다. 이런 유형은 인프라 진단 체크리스트도 함께 가져가야 합니다: EKS에서 Pod DNS는 되는데 외부 HTTPS만 실패할 때


6) 동시성/락/데드락을 고려하지 않아 보상 자체가 실패한다

실패 패턴

  • 보상은 “실패 시 마지막 안전장치”인데, 정작 보상 로직이 트래픽/락 경합에서 데드락 발생
  • 재고/포인트/정산 같은 테이블을 넓게 잠그거나, 인덱스가 없어 갭락/풀스캔

결과

  • 장애 상황에서 보상 트랜잭션이 데드락으로 계속 실패 → 재시도 폭주 → DB 더 느려짐
  • “보상 실패를 보상”하는 악순환

예방 설계

  • 보상 로직은 더 단순하고 짧게, 락 범위를 최소화
  • 정방향/보상 모두 동일한 락 순서로 자원 접근
  • 데드락 재현/로그/인덱싱으로 원인 제거

MySQL을 쓴다면 데드락은 운이 아니라 설계/인덱스 문제인 경우가 많습니다. 재현과 로그 기반으로 잡는 방법은 아래 글이 도움이 됩니다: MySQL Deadlock 1213 재현·로그·인덱스로 해결


7) 관측성(Observability)이 없어 “보상이 실행됐는지” 증명할 수 없다

실패 패턴

  • 로그에 “보상 요청함”만 있고, 실제로 외부 시스템에서 처리됐는지 추적 불가
  • sagaId/correlationId가 서비스 간 전파되지 않음
  • 지표가 없어 보상 적체(백로그)나 실패율을 늦게 발견

결과

  • 고객 문의가 먼저 오고 나서야 장애를 인지
  • 사후 분석에서 “어디서부터 틀어졌는지” 재구성 불가

예방 설계

  • 전 구간 공통 키
    • sagaId, orderId, commandId, correlationId
  • 최소 지표
    • step별 성공/실패 카운트
    • 보상 큐 적체량
    • 보상 평균 지연시간
  • 분산 추적(OTel)에서 외부 호출 span에 idempotencyKey, paymentId 같은 태그 추가

Kubernetes 환경에서는 애플리케이션이 정상(Running)인데도 외부에서 503이 뜨는 등, “보상 호출이 실패했다”로 오인하기 쉬운 케이스가 있습니다. 이런 경우 readiness/endpoint 구성부터 점검해야 합니다: EKS에서 Pod는 Running인데 503가 뜰 때 점검


보상 트랜잭션 설계 체크리스트(실무용)

도메인/정책

  • 각 step의 가역성(취소/상쇄/불가역) 분류가 되어 있는가?
  • 타임아웃을 실패로 단정하지 않고 Unknown 상태를 다루는가?
  • 보상 순서가 “역순”이 아니라 “불변조건 회복” 기준으로 정의되어 있는가?

구현

  • 정방향/보상 모두 멱등키로 중복 실행을 흡수하는가?
  • 오케스트레이터 상태가 영속화되어 재시작 후에도 재개 가능한가?
  • 보상 로직이 락 경합/데드락에 안전하도록 단순화/인덱싱되어 있는가?

운영

  • sagaId/correlationId가 전 서비스에 전파되는가?
  • 보상 실패율/적체량/지연시간 지표와 알람이 있는가?
  • 수동介入이 필요한 케이스(불가역/외부 제약)에 대한 런북이 있는가?

마무리: Saga의 목표는 “되돌리기”가 아니라 “회복 가능성”

Saga 보상 트랜잭션은 트랜잭션의 ACID 롤백을 흉내 내는 기술이 아니라, 실패가 일상인 분산 환경에서 비즈니스 일관성을 회복하는 운영 가능한 설계입니다.

위 7가지 실패 패턴을 피하려면, 보상을 ‘예외 처리 코드’로 두지 말고 도메인 정책(상태/불변조건) + 멱등성 + 영속 오케스트레이션 + 관측성으로 제품 수준에서 다뤄야 합니다. 그렇게 설계하면 장애가 나도 “어디서 무엇이 어떻게 처리되었는지”를 설명할 수 있고, 결국 운영 비용이 급격히 줄어듭니다.