Published on

MSA Saga 보상 트랜잭션 설계와 디버깅 실전

Authors

서로 다른 마이크로서비스가 각자 DB를 소유하는 MSA에서는 전통적인 2PC(2-phase commit) 기반 분산 트랜잭션을 적용하기 어렵습니다. 대신 Saga로 “로컬 트랜잭션들의 시퀀스”를 구성하고, 중간에 실패가 나면 보상 트랜잭션(Compensation) 으로 이미 반영된 변경을 되돌립니다.

문제는 여기서부터입니다. 보상은 단순한 rollback이 아니라 비동기·부분 성공·중복 이벤트·지연·타임아웃이 뒤엉킨 현실에서 동작해야 합니다. 이 글은 (1) 보상 설계 원칙, (2) 구현 패턴(오케스트레이션/코레오그래피), (3) 멱등성·순서·재시도·가시성, (4) 디버깅 체크리스트를 중심으로 정리합니다.

Saga와 보상 트랜잭션의 핵심: “되돌리기”가 아니라 “상태 전이”

보상 트랜잭션은 DB의 undo 로그처럼 과거를 지우는 개념이 아닙니다. 대부분의 도메인에서는 취소/환불/예약 해제/재고 복원처럼 “반대 방향의 새로운 상태 전이”입니다.

따라서 설계의 출발점은 아래 질문입니다.

  • 어떤 단계까지 성공했는지 정확히 기록할 수 있는가?
  • 보상이 실행되면 시스템이 어떤 일관된 최종 상태로 수렴해야 하는가?
  • 보상이 실패하거나 지연되어도 사용자/운영이 이해 가능한가(예: CANCELINGCANCELED)?

보상은 항상 가능하지 않다: Compensatable vs Retriable

보상 트랜잭션이 “항상 가능”하다고 가정하면 곧바로 운영 장애로 이어집니다.

  • Compensatable(보상 가능): 재고 예약 취소, 결제 승인 취소(승인 직후), 포인트 적립 취소 등
  • Not fully compensatable(완전 보상 불가): 외부 송금 완료, 이미 출고된 배송, 이미 발행된 세금계산서 등

완전 보상 불가 영역은 다음 중 하나가 필요합니다.

  • 사전 예약/홀드(hold) 후 확정(commit) 모델로 변경(예: 결제는 capture를 늦추고 authorize만)
  • 수동 처리 큐(운영자介入)와 명확한 상태 머신
  • 정정(Adjustment) 트랜잭션으로 회계적 상쇄(되돌림이 아니라 추가 기록)

오케스트레이션 vs 코레오그래피: 디버깅 난이도를 좌우한다

Saga 구현 방식은 크게 두 가지입니다.

1) 오케스트레이션(Orchestration)

중앙의 Saga Orchestrator가 각 서비스에 커맨드를 보내고 결과를 받아 다음 단계를 결정합니다.

  • 장점: 흐름이 명시적이라 관찰/디버깅이 쉽다, 타임아웃/재시도 정책을 한 곳에서 통제
  • 단점: 오케스트레이터가 복잡해지고 SPOF가 될 수 있음(하지만 HA로 완화 가능)

2) 코레오그래피(Choreography)

각 서비스가 이벤트를 발행하고 다른 서비스가 구독하여 다음 동작을 수행합니다.

  • 장점: 결합도가 낮고 확장에 유리
  • 단점: 이벤트 흐름이 분산되어 원인 추적이 어렵고, “누가 보상을 시작해야 하는가?”가 모호해질 수 있음

실무에서는 “핵심 결제/주문” 같은 중요 플로우는 오케스트레이션을, 주변 기능은 코레오그래피를 섞는 경우가 많습니다.

보상 설계 원칙 7가지

1) 보상은 멱등(Idempotent)해야 한다

보상 커맨드는 네트워크 재시도, 브로커 중복 전달, 소비자 재처리로 여러 번 실행될 수 있습니다.

  • 예: CancelInventoryReservation(orderId)가 2번 와도 재고가 2번 늘어나면 안 됨
  • 해결: 보상 실행 여부를 저장하거나, 보상 대상 리소스를 “예약 토큰” 단위로 관리

2) 각 단계는 “커밋 완료 시점”을 명확히 기록해야 한다

보상은 “어디까지 성공했는지”를 알아야 합니다.

  • 오케스트레이션: Saga 상태 테이블에 step별 상태 기록
  • 코레오그래피: 각 서비스가 자신의 로컬 상태에 “해당 Saga에 참여했는지/완료했는지” 기록

3) 보상은 ‘반대 작업’이지 ‘이전 상태 복구’가 아니다

예를 들어 재고 서비스에서 재고 차감이 아니라 “예약(reserve)”을 먼저 하고, 최종 확정 시 “커밋(commit)”을 하도록 모델링하면 보상이 쉬워집니다.

  • Reserve의 보상은 ReleaseReservation
  • Commit의 보상은 도메인에 따라 불가능하거나 Return 같은 별도 프로세스

4) 타임아웃은 실패의 한 종류다(그리고 가장 흔하다)

분산 환경에서 “응답이 늦다”는 사실상 실패로 취급됩니다. 특히 L7/LB/Ingress 타임아웃에 의해 408/504가 발생하면 호출자는 실패로 판단하고 보상을 시작할 수 있습니다.

5) 재시도 정책은 “커맨드”와 “쿼리”를 분리해서 설계한다

  • 커맨드(상태 변경): 멱등 키 + 제한된 재시도 + DLQ
  • 쿼리(조회): 캐시/서킷브레이커/백오프

6) 관찰 가능성(Observability)은 설계의 일부다

디버깅 가능한 Saga는 처음부터 로그/메트릭/트레이싱이 설계에 포함됩니다.

  • 모든 이벤트/커맨드에 sagaId, step, correlationId, causationId 포함
  • “보상 시작”과 “보상 완료”를 별도 이벤트로 남김

7) 사용자가 보는 상태 머신을 분리하라

내부적으로는 보상이 진행 중인데 사용자에게는 “실패”로만 보이면 CS가 폭증합니다.

  • PENDINGCONFIRMED
  • 실패 시 CANCELINGCANCELED
  • 외부 연동이 길면 CANCELING 상태를 충분히 노출

구현 예시: Spring Boot 기반 오케스트레이션 + Outbox

아래는 핵심 아이디어를 보여주는 축약 예시입니다.

  • Orchestrator가 CreateOrderSaga를 시작
  • 각 단계는 커맨드를 발행
  • 서비스는 로컬 트랜잭션으로 상태 변경 + Outbox 기록
  • Outbox 릴레이가 브로커로 발행

1) Saga 상태 테이블(간단 모델)

CREATE TABLE saga_instance (
  saga_id        VARCHAR(64) PRIMARY KEY,
  saga_type      VARCHAR(64) NOT NULL,
  status         VARCHAR(32) NOT NULL, -- RUNNING, COMPENSATING, COMPLETED, FAILED
  current_step   VARCHAR(64),
  created_at     TIMESTAMP NOT NULL,
  updated_at     TIMESTAMP NOT NULL
);

CREATE TABLE saga_step (
  saga_id      VARCHAR(64) NOT NULL,
  step_name    VARCHAR(64) NOT NULL,
  status       VARCHAR(32) NOT NULL, -- STARTED, SUCCEEDED, FAILED, COMPENSATED
  updated_at   TIMESTAMP NOT NULL,
  PRIMARY KEY (saga_id, step_name)
);

2) Outbox(이벤트 발행의 원자성)

CREATE TABLE outbox (
  id           BIGSERIAL PRIMARY KEY,
  aggregate_id VARCHAR(64) NOT NULL,
  event_type   VARCHAR(128) NOT NULL,
  payload      JSONB NOT NULL,
  created_at   TIMESTAMP NOT NULL,
  published_at TIMESTAMP
);

CREATE UNIQUE INDEX ux_outbox_dedup
ON outbox(aggregate_id, event_type, created_at);

3) 보상 커맨드 멱등 처리(서비스 측)

재고 서비스에서 “예약 해제”가 중복 호출돼도 안전하도록 reservation 레코드 기준으로 처리합니다.

@Service
public class InventoryCompensationService {

  private final ReservationRepository reservationRepository;

  @Transactional
  public void releaseReservation(String sagaId, String orderId) {
    // 멱등: 이미 RELEASED면 아무 것도 하지 않음
    Reservation r = reservationRepository
        .findBySagaIdAndOrderIdForUpdate(sagaId, orderId)
        .orElseThrow(() -> new IllegalStateException("reservation not found"));

    if (r.getStatus() == ReservationStatus.RELEASED) {
      return;
    }

    r.setStatus(ReservationStatus.RELEASED);
    r.setReleasedAt(Instant.now());
    reservationRepository.save(r);

    // 재고 수량 복원은 reservation의 수량을 기반으로 정확히 1회만 반영
    // (별도 stock ledger를 쓰면 더 안전)
  }
}

4) 오케스트레이터의 “실패 → 보상 시작” 흐름(개념 코드)

public class CreateOrderSagaOrchestrator {

  public void onPaymentFailed(String sagaId) {
    sagaStore.updateStatus(sagaId, "COMPENSATING");

    // 역순으로 보상 커맨드 발행
    commandBus.send(new ReleaseInventoryReservationCommand(sagaId));
    commandBus.send(new CancelOrderCommand(sagaId));
  }
}

핵심은 “보상 순서”가 일반적으로 정방향의 역순이라는 점과, 각 커맨드가 중복 실행돼도 안전해야 한다는 점입니다.

디버깅: 보상 트랜잭션이 꼬일 때 가장 먼저 볼 것들

운영에서 자주 만나는 증상은 다음과 같습니다.

  • 주문은 실패로 보이는데 재고는 이미 줄어있음
  • 결제는 취소됐는데 주문 상태가 계속 CANCELING
  • 동일 주문에 보상이 여러 번 실행되어 재고가 과복원
  • 특정 시간대에만 보상 폭주(대개 타임아웃/리소스 고갈)

1) “중복 처리”인지 “순서 뒤바뀜”인지 먼저 구분

  • 중복 처리: 같은 sagaId + step이 여러 번 실행됨
  • 순서 뒤바뀜: COMPENSATECOMMIT보다 먼저 도착/처리됨

대응 방법

  • 메시지 키를 sagaId로 고정해 파티션 내 순서 보장(가능한 경우)
  • 소비자에서 step version/expected state 검증 후 무시(drop) 또는 지연 재처리

2) 타임아웃/리소스 고갈로 인한 “유령 실패” 점검

호출자는 타임아웃으로 실패로 판단해 보상을 시작했지만, 실제로는 서버에서 처리가 완료된 상황이 흔합니다.

  • LB/Ingress 타임아웃(408/504)
  • DB 커넥션 풀 고갈, 스레드 고갈로 응답 지연

특히 Spring 계열에서 동시성 설정을 바꾸거나(가상 스레드 등) 트래픽이 순간적으로 증가하면 DB 풀 고갈로 “실패처럼 보이는 지연”이 생깁니다. 이런 경우 보상이 연쇄적으로 발생합니다. 관련 문제의 진단 관점은 Spring Boot 3 가상스레드에서 HikariCP 고갈 해결도 참고할 수 있습니다.

3) 로그는 ‘한 줄’이 아니라 ‘한 Saga 타임라인’으로 봐야 한다

권장 필드

  • sagaId, orderId
  • step, stepStatus
  • messageId, idempotencyKey
  • retryCount, nextRetryAt
  • producerService, consumerService

가능하면 중앙에서 sagaId로 검색하면 정방향 단계와 보상 단계를 시간순으로 재구성할 수 있어야 합니다.

4) Outbox/CDC 파이프라인 지연 여부 확인

보상 커맨드를 발행했는데 상대 서비스가 못 받는다면, 실제 원인은 애플리케이션이 아니라 Outbox 릴레이/로그 파이프라인일 수 있습니다.

  • Outbox 테이블에 published_at이 비어있는 레코드가 쌓이는지
  • 브로커 lag(consumer lag), DLQ 적재량
  • 로깅 에이전트 지연(관측의 사각지대)

EKS 환경에서 로그 누락/지연은 “보상이 안 돈다”로 오해되기 쉬워서, 로깅 파이프라인도 함께 봐야 합니다: EKS에서 fluent-bit 로그 누락·지연 원인 9가지

5) DLQ에 들어간 메시지는 ‘실패’가 아니라 ‘미해결 상태’다

DLQ로 빠진 보상 메시지를 방치하면, 도메인 데이터는 영원히 중간 상태로 남습니다.

  • DLQ 리드라이브(runbook) 절차 문서화
  • DLQ 메시지에 sagaId, step, 실패 원인(예외 클래스, 외부 응답 코드) 포함
  • 재처리 시 멱등성 보장 여부 점검

실전 팁: 보상 트랜잭션을 “안전하게” 만드는 체크리스트

  • 각 단계/보상 단계에 명시적 상태가 있는가?
  • 커맨드/이벤트에 멱등 키가 있고, 저장소에서 중복을 차단하는가?
  • “타임아웃=실패”로 처리할 때 유령 성공을 흡수할 장치(상태 조회, confirm 이벤트)가 있는가?
  • 보상은 역순으로 실행되며, 부분 보상/부분 실패를 상태로 표현하는가?
  • DLQ/재시도 정책이 도메인 요구(최대 지연 허용)와 맞는가?
  • sagaId로 end-to-end 트레이싱이 가능한가?

마무리

Saga 보상 트랜잭션의 품질은 “보상 로직을 얼마나 잘 짰는가”보다 도메인 상태 모델링, 멱등성, 관찰 가능성, 타임아웃/재시도 설계에 의해 결정됩니다. 특히 운영에서 가장 흔한 실패는 코드 버그가 아니라 지연과 중복이 만든 경계 조건입니다.

처음부터 (1) 단계별 상태 기록, (2) Outbox로 발행 원자성 확보, (3) 보상 멱등성, (4) sagaId 기반 타임라인 디버깅 체계를 갖추면, “분산 트랜잭션의 악몽”을 “관리 가능한 비동기 상태 머신”으로 바꿀 수 있습니다.