Published on

MSA 사가 패턴 데이터 불일치, 보상 설계 실전

Authors

MSA에서 사가 패턴을 적용하면 분산 트랜잭션의 원자성을 완벽히 보장하는 대신, 최종 일관성을 목표로 시스템을 설계합니다. 그런데 현장에서는 “사가를 썼는데도 주문은 취소됐는데 재고가 남아 있다”, “결제는 성공했는데 주문 상태가 실패로 남는다” 같은 데이터 불일치가 자주 터집니다.

원인은 사가 자체가 아니라, 보상 트랜잭션을 “롤백 SQL의 대체품”으로 오해하거나, 메시징의 현실(중복, 지연, 순서 뒤바뀜, 부분 장애)을 설계에 반영하지 못했기 때문인 경우가 많습니다. 이 글은 보상 트랜잭션을 어떻게 설계해야 불일치를 줄이는지를 실전 관점에서 정리합니다.

문제의 핵심인 중복과 순서 꼬임은 아래 글에서 더 깊게 다루니 함께 참고하면 좋습니다.

사가를 썼는데도 불일치가 생기는 대표 시나리오

사가가 깨지는 전형적인 흐름은 다음과 같습니다.

  1. A 서비스가 로컬 트랜잭션을 커밋하고 이벤트 발행
  2. B 서비스가 이벤트를 받아 로컬 트랜잭션 수행
  3. 중간에 네트워크 문제나 재시도로 이벤트가 중복 전달되거나, 늦게 도착하거나, 순서가 뒤집힘
  4. 보상 이벤트도 동일한 문제를 겪음
  5. 결과적으로 상태가 서로 어긋남

여기서 중요한 사실은 메시징은 기본적으로 at-least-once인 경우가 많고(즉, 중복 가능), 분산 환경에서 순서 보장도 제한적이라는 점입니다. 특히 쿠버네티스나 EKS 같은 환경에서 gRPC나 네트워크 이슈가 간헐적으로 발생하면 재시도가 더 자주 일어나고, 중복/순서 이슈가 더 빨리 표면화됩니다.

보상 트랜잭션을 “되돌리기”가 아니라 “상태 전이”로 설계

보상 트랜잭션을 단순히 “이전 값을 복구”하는 것으로 설계하면, 다음 문제가 생깁니다.

  • 원래 값이 무엇이었는지 정확히 모르는 경우가 많음
  • 중간에 사람이 개입하거나 다른 프로세스가 상태를 바꿔 되돌릴 수 없는 상황이 생김
  • 부분 성공과 부분 실패를 구분하기 어려움

실전에서는 보상을 명시적 상태 전이로 설계하는 편이 안전합니다.

예를 들어 주문 사가를 단순화하면 다음과 같은 상태 모델이 됩니다.

  • PENDING (주문 생성됨)
  • RESERVED (재고 예약됨)
  • PAID (결제 완료)
  • CONFIRMED (주문 확정)
  • CANCELLED (취소됨)

보상은 “과거로 되돌리기”가 아니라 “취소 상태로 전이시키고, 그에 맞는 후속 작업(재고 해제, 결제 취소)을 수행”하는 방식으로 접근합니다.

실전 원칙 1: 보상은 반드시 멱등하게

사가에서 가장 흔한 불일치 원인은 보상 요청이 두 번 이상 실행되는 경우입니다. 예를 들어 결제 취소 API가 두 번 호출되면 환불이 중복 처리될 수도 있고, 재고 해제 이벤트가 두 번 적용되면 재고가 과다 증가할 수 있습니다.

따라서 보상 트랜잭션은 반드시 멱등성(idempotency) 을 가져야 합니다.

멱등성 구현 패턴: idempotency key + 처리 기록 테이블

  • 각 사가 실행에 sagaId를 부여
  • 각 스텝에 stepName을 부여
  • (sagaId, stepName, action) 조합으로 “이미 처리했는지”를 저장

아래는 Spring 기반의 단순 예시입니다.

// 요청마다 전달되는 키: sagaId + stepName + action
public record IdempotencyKey(String sagaId, String step, String action) {}

@Service
public class CompensationService {

  private final ProcessedActionRepository processedActionRepository;
  private final PaymentGateway paymentGateway;

  @Transactional
  public void refund(IdempotencyKey key, String paymentId, long amount) {
    // 1) 이미 처리했으면 바로 종료
    if (processedActionRepository.existsByKey(key.sagaId(), key.step(), key.action())) {
      return;
    }

    // 2) 외부 호출 전후 순서도 중요하므로, 처리 시작을 먼저 기록하는 방식도 고려
    // 여기서는 성공 후 기록하는 단순 버전
    paymentGateway.refund(paymentId, amount);

    processedActionRepository.save(
      new ProcessedAction(key.sagaId(), key.step(), key.action())
    );
  }
}

포인트는 “중복 이벤트가 와도 결과가 1회와 같아야 한다”입니다. 외부 결제사 API도 멱등 키를 지원하는 경우가 많으니, 가능한 한 외부 시스템까지 멱등성 전파를 고려하세요.

실전 원칙 2: 보상은 “반대 작업”이 아니라 “안전한 해제”로

재고 예약을 예로 들면,

  • 작업: reserve(quantity)
  • 보상: release(quantity)

처럼 보이지만, 실제로는 단순히 수량을 더하거나 빼는 연산이면 위험합니다. 중복 보상이나 순서 꼬임이 생기면 재고가 깨집니다.

더 안전한 방식은 “예약 레코드”를 만들고, 보상은 그 레코드를 상태 전이시키는 것입니다.

재고 예약 테이블 기반 설계

  • inventory_reservation
    • reservation_id
    • order_id
    • sku
    • qty
    • status = ACTIVE 또는 RELEASED

보상은 ACTIVERELEASED로 바꾸는 작업이므로 멱등하게 만들기 쉽습니다.

-- 보상(해제): 이미 RELEASED면 영향 없음
update inventory_reservation
set status = 'RELEASED'
where reservation_id = :reservationId
  and status = 'ACTIVE';

이렇게 하면 같은 해제 이벤트가 여러 번 와도 결과는 동일합니다.

실전 원칙 3: 순서 보장에 기대지 말고 “상태 머신”으로 방어

메시지가 순서대로 온다는 가정은 쉽게 깨집니다. 흔한 케이스는 다음과 같습니다.

  • 결제 성공 이벤트가 늦게 도착했는데, 그 전에 주문 취소 보상이 먼저 적용됨
  • 예약 해제 이벤트가 예약 이벤트보다 먼저 도착

이때 “이벤트가 왔으니 무조건 적용”하면 데이터가 어긋납니다.

해결책은 각 서비스가 자신의 로컬 상태를 상태 머신으로 관리하고, 허용되지 않는 전이는 거부하거나 보류하는 것입니다.

주문 서비스 상태 전이 예시

  • PENDING 에서만 RESERVED로 전이 가능
  • CANCELLED 상태에서는 PAID 이벤트가 와도 무시하거나, 별도 보정 플로우로 보냄
public enum OrderStatus {
  PENDING, RESERVED, PAID, CONFIRMED, CANCELLED
}

@Transactional
public void onPaymentSucceeded(String orderId, String sagaId) {
  Order order = orderRepository.findByIdForUpdate(orderId);

  if (order.getStatus() == OrderStatus.CANCELLED) {
    // 이미 취소된 주문에 결제 성공이 늦게 도착한 케이스
    // 1) 무시 + 경보, 또는
    // 2) 자동 환불을 트리거하는 보정 프로세스로 라우팅
    compensationPublisher.publishRefundRequested(sagaId, order.getPaymentId(), order.getAmount());
    return;
  }

  if (order.getStatus() != OrderStatus.RESERVED) {
    // 순서가 어긋난 이벤트. 재시도 큐로 보내거나 보류 테이블에 저장
    throw new IllegalStateException("Invalid transition: " + order.getStatus() + " -> PAID");
  }

  order.setStatus(OrderStatus.PAID);
}

여기서 findByIdForUpdate 같은 잠금은 같은 주문에 대한 이벤트가 동시에 들어올 때 레이스 컨디션을 줄이는 데 도움이 됩니다.

실전 원칙 4: 오케스트레이션 vs 코레오그래피, 불일치 유형에 따라 선택

  • 오케스트레이션(중앙 조정자): 한 곳에서 단계 진행과 보상을 통제

    • 장점: 흐름이 명확, 관측/재처리 용이
    • 단점: 조정자 복잡도 증가, SPOF 우려(하지만 HA로 완화 가능)
  • 코레오그래피(이벤트 기반 자율): 각 서비스가 이벤트를 구독해 다음 행동

    • 장점: 결합도 낮음
    • 단점: 순서/중복/가시성 문제가 더 자주 발생, 디버깅 난이도 상승

데이터 불일치가 잦고, 규정/정산/환불처럼 “정확성이 핵심”인 도메인은 오케스트레이션이 실무적으로 유리한 경우가 많습니다.

실전 원칙 5: Outbox + Inbox로 “커밋과 발행” 불일치를 제거

사가 불일치의 또 다른 축은 다음입니다.

  • DB 커밋은 됐는데 이벤트 발행이 실패
  • 이벤트는 발행됐는데 DB 커밋이 롤백

이 문제를 줄이는 대표 패턴이 Transactional Outbox 입니다.

Outbox 테이블 예시

  • 비즈니스 데이터 커밋과 같은 트랜잭션에서 outbox에 이벤트를 적재
  • 별도 퍼블리셔가 outbox를 읽어 브로커로 발행
create table outbox_event (
  id bigserial primary key,
  aggregate_type varchar(50) not null,
  aggregate_id varchar(100) not null,
  event_type varchar(100) not null,
  payload jsonb not null,
  created_at timestamptz not null default now(),
  published_at timestamptz null
);

컨슈머 측은 Inbox(처리 로그) 로 중복 처리를 방지합니다. 즉,

  • Producer: Outbox로 “발행 누락” 방지
  • Consumer: Inbox로 “중복 처리” 방지

이 조합이 갖춰지면 사가의 신뢰도가 크게 올라갑니다.

실전 원칙 6: 보상은 동기 호출보다 “비동기 + 재시도 + DLQ”가 기본

보상 트랜잭션이 외부 시스템(결제, 배송, 알림)과 엮이면, 동기 호출로 즉시 성공을 보장하기 어렵습니다. 그래서 보상은 다음을 기본으로 설계합니다.

  • 비동기 이벤트로 보상 요청 발행
  • 지수 백오프 재시도
  • 일정 횟수 실패 시 DLQ로 격리
  • 운영자가 재처리할 수 있는 수단 제공

여기서 중요한 것은 “DLQ에 쌓인 보상 실패는 곧 데이터 불일치로 이어질 수 있다”는 점이므로, 알림과 대시보드를 반드시 붙이세요.

실전 원칙 7: 보상 설계의 단위는 ‘서비스’가 아니라 ‘불변의 사실’

보상 트랜잭션을 설계할 때 흔히 하는 실수는 “서비스 A가 실패했으니 A가 한 일을 전부 되돌리자”입니다. 하지만 실무에서는 아래처럼 쪼개야 안전합니다.

  • 되돌릴 수 있는 것: 예약, 홀드, 임시 상태
  • 되돌리기 애매한 것: 외부 결제 승인, 배송 착수, 쿠폰 사용 확정

따라서 도메인 이벤트를 “불변의 사실”로 정의하고,

  • PaymentAuthorized
  • InventoryReserved
  • ShipmentStarted

같은 이벤트가 발생하면, 그 다음은 “취소”가 아니라

  • PaymentRefunded
  • InventoryReleased
  • ShipmentReturnRequested

처럼 새로운 사실을 추가하는 방식으로 모델링하는 편이 추적성과 감사에 유리합니다.

실전 체크리스트: 데이터 불일치가 나면 여기부터 점검

1) 멱등성

  • 보상 API가 동일 요청을 여러 번 받아도 결과가 동일한가
  • (sagaId, step, action) 단위로 처리 로그가 남는가
  • 외부 시스템 호출에도 멱등 키를 전달하는가

2) 순서 방어

  • 상태 머신으로 허용되지 않는 전이를 차단하는가
  • 늦게 도착한 이벤트(예: 취소 후 결제 성공)를 처리하는 보정 플로우가 있는가

3) 발행/구독 신뢰성

  • Outbox로 발행 누락을 막는가
  • Inbox로 중복 소비를 막는가
  • DLQ와 재처리 도구가 있는가

4) 관측 가능성

  • 사가 단위로 트레이싱이 가능한가(로그에 sagaId 포함)
  • “어디 스텝에서 멈췄는지”가 대시보드로 보이는가

예시: 주문-재고-결제 사가의 보상 흐름(오케스트레이션)

아래는 오케스트레이터가 각 스텝을 진행하다 실패 시 역순으로 보상하는 개념 예시입니다.

sagaId = newSagaId()

try:
  step1: OrderCreated (local)
  step2: InventoryReserve (command)
  step3: PaymentAuthorize (command)
  step4: OrderConfirm (local)

except any failure:
  if step3 succeeded:
    emit PaymentRefundRequested(sagaId)
  if step2 succeeded:
    emit InventoryReleaseRequested(sagaId)
  emit OrderCancelRequested(sagaId)

핵심은 “보상은 역순”보다도,

  • 각 스텝 성공 여부를 기록하고
  • 보상 이벤트가 중복/지연되어도 안전하며
  • 최종적으로 CANCELLED 또는 CONFIRMED 같은 종결 상태로 수렴

하도록 만드는 것입니다.

마무리: 사가의 불일치는 ‘보상’이 아니라 ‘운영 가능한 설계’의 문제

사가 패턴은 분산 환경의 현실을 받아들이는 선택입니다. 그래서 보상 트랜잭션의 품질은 코드 몇 줄이 아니라,

  • 멱등성
  • 상태 머신
  • Outbox/Inbox
  • 재시도/DLQ
  • 관측/재처리

같은 운영 가능한 설계의 총합으로 결정됩니다.

특히 중복 처리와 순서 꼬임은 “언젠가 반드시” 발생하니, 처음부터 그 전제를 깔고 설계하는 것이 가장 비용이 적게 듭니다. 중복과 순서 이슈에 대한 더 구체적인 패턴(키 설계, 이벤트 버전, 재처리 전략)은 아래 글을 함께 보시면 바로 적용하기 좋습니다.