Published on

MSA Saga 보상 트랜잭션 멱등성·중복처리 패턴

Authors

서로 다른 마이크로서비스가 각자 DB 트랜잭션을 갖는 MSA에서, 분산 트랜잭션을 2PC로 묶기 어렵다면 현실적인 대안은 Saga입니다. 문제는 Saga가 "언젠가" 일관성을 맞추는 대신, 실패·재시도·중복 메시지를 정상 시나리오로 받아들여야 한다는 점입니다. 특히 보상 트랜잭션은 한 번만 실행돼야 할 것 같지만, 실제 운영에서는 같은 보상 요청이 여러 번 도착하거나, 부분 실패 후 재시도되거나, 순서가 뒤바뀌는 일이 흔합니다.

이 글은 Saga의 보상 트랜잭션을 멱등하게 만들고, 메시지 중복처리를 안정적으로 구현하는 패턴을 정리합니다. 목표는 간단합니다.

  • 보상 요청이 중복으로 와도 결과가 동일해야 함
  • 보상 실행 도중 장애가 나도 재시도로 복구돼야 함
  • 관측 가능하게(추적 가능하게) 상태가 남아야 함

왜 보상 트랜잭션은 중복 실행되는가

분산 환경에서 중복은 버그가 아니라 설계 전제입니다.

  • 브로커의 at-least-once 전달: 컨슈머가 처리 후 ack를 못 하면 같은 메시지가 재전달됩니다.
  • 타임아웃 기반 재시도: 오케스트레이터가 "응답이 없다"고 판단해 같은 커맨드를 다시 보냅니다.
  • 네트워크 분할: 성공 응답이 유실돼 발신 측이 실패로 오인합니다.
  • 컨슈머 크래시: 처리 중 죽고 재기동 후 같은 레코드를 다시 처리합니다.

따라서 "보상은 한 번만 호출" 같은 가정은 깨집니다. 보상은 반드시 멱등이어야 하고, 중복처리 저장소가 있어야 합니다.

핵심 용어 정리: 멱등성 vs 중복처리

  • 멱등성(Idempotency): 같은 요청을 여러 번 수행해도 최종 상태가 동일
  • 중복처리(Deduplication): 같은 요청을 여러 번 받더라도 실제 처리(사이드이펙트)는 한 번만 수행

실무에서는 둘 다 필요합니다.

  • 외부 결제 취소 API처럼 "취소"가 멱등하지 않을 수 있으니, 내부에서 중복처리로 한 번만 호출
  • 내부 DB 업데이트는 멱등 쿼리로 설계(조건부 업데이트)하여 재시도 안전성 확보

Saga 보상 설계의 3가지 기본 원칙

1) 보상은 "반대 작업"이 아니라 "상태 전이"로 모델링

예: "주문 생성"의 보상은 "주문 삭제"가 아니라 "주문 상태를 CANCELLED로 전이"시키는 편이 안전합니다.

  • 삭제는 추적 불가능, 재시도 시 404 등으로 흐름이 꼬임
  • 상태 전이는 중복 요청을 자연스럽게 흡수 가능

2) 보상은 외부 부작용을 최소화하고, 필요하면 중복처리로 감싼다

외부 API 호출(환불, 쿠폰 복구 등)은 중복 호출 비용이 큽니다. 내부에서 "한 번만" 보장해야 합니다.

3) 모든 커맨드·이벤트에 상관관계 ID를 부여한다

traceId, sagaId, stepId, idempotencyKey 같은 식별자는 디버깅과 중복처리의 기반입니다.

패턴 1: 멱등성 키 테이블(Processed Commands)

가장 직관적인 방법은 "이 요청을 처리했는지"를 DB에 기록하는 것입니다.

테이블 예시

  • 키는 최소 sagaId + step + action 조합을 추천합니다.
  • 결과(성공/실패), 마지막 처리 시각, 응답 페이로드를 저장하면 재전달 시 빠르게 응답할 수 있습니다.
CREATE TABLE processed_command (
  command_key VARCHAR(128) PRIMARY KEY,
  status VARCHAR(20) NOT NULL,
  response_json TEXT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 예: command_key = "saga:7f3...:payment:compensate"

처리 흐름

  1. 트랜잭션 시작
  2. processed_commandINSERT 시도
  3. 성공하면 "처리 권한" 획득
  4. 실제 보상 로직 실행
  5. 성공 시 상태 업데이트
  6. 커밋

중복 요청은 PRIMARY KEY 충돌로 감지하고, 이미 저장된 response_json을 그대로 반환합니다.

Java 예시(개념 코드)

부등호가 포함되지 않도록 화살표는 -> 대신 텍스트로 설명합니다.

@Transactional
public CompensationResult compensate(String commandKey, String orderId) {
    boolean acquired = processedCommandRepository.tryInsert(commandKey);

    if (!acquired) {
        return processedCommandRepository.loadResult(commandKey);
    }

    // 1) 멱등한 상태 전이
    int updated = orderRepository.markCancelledIfNotCancelled(orderId);

    // 2) 외부 부작용은 dedup 보호 아래에서 수행
    if (updated == 1) {
        paymentGateway.refundOnce(commandKey, orderId);
    }

    CompensationResult result = CompensationResult.ok();
    processedCommandRepository.markSuccess(commandKey, result);
    return result;
}

포인트는 markCancelledIfNotCancelled 같은 조건부 업데이트입니다. 이게 없으면 중복 요청에서 상태가 흔들립니다.

패턴 2: 조건부 업데이트로 DB 멱등성 확보

"이미 취소된 주문"에 대해 다시 취소해도 결과가 같도록 쿼리를 설계합니다.

UPDATE orders
SET status = 'CANCELLED', cancelled_at = CURRENT_TIMESTAMP
WHERE order_id = ?
  AND status != 'CANCELLED';

이 쿼리는 재시도에 안전합니다.

  • 첫 호출은 1 row 업데이트
  • 두 번째 호출은 0 row 업데이트

이 차이를 이용해 "외부 환불 API는 첫 호출에서만" 수행하도록 만들 수 있습니다.

패턴 3: Outbox로 "DB 변경"과 "이벤트 발행"을 원자적으로

보상 트랜잭션이 성공했는데 이벤트 발행이 실패하면, 오케스트레이터는 보상이 안 된 줄 알고 재시도합니다. 그 결과 중복 보상이 발생할 수 있습니다.

이를 막는 대표 해법이 Transactional Outbox입니다.

  • 서비스 DB 트랜잭션 안에서 상태 변경과 outbox 레코드 insert를 같이 커밋
  • 별도 퍼블리셔가 outbox를 읽어 브로커에 발행
CREATE TABLE outbox (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  aggregate_id VARCHAR(64) NOT NULL,
  event_type VARCHAR(64) NOT NULL,
  payload_json TEXT NOT NULL,
  published_at TIMESTAMP NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

보상 성공 시:

BEGIN;

UPDATE orders
SET status = 'CANCELLED'
WHERE order_id = ? AND status != 'CANCELLED';

INSERT INTO outbox(aggregate_id, event_type, payload_json)
VALUES(?, 'OrderCancelled', ?);

COMMIT;

이렇게 하면 "상태는 바뀌었는데 이벤트가 안 나감" 같은 반쪽짜리 성공을 줄일 수 있습니다.

패턴 4: 보상 단계별 상태머신(Compensation State Machine)

Saga는 여러 단계로 이뤄지며, 보상도 여러 단계일 수 있습니다. 각 단계를 상태로 기록하면 재시도 시 어디부터 다시 해야 하는지 명확해집니다.

예: 결제 승인, 재고 차감, 배송 요청 3단계 Saga

  • 정방향 단계: PAYMENT_AUTHED / STOCK_RESERVED / SHIPMENT_REQUESTED
  • 보상 단계: SHIPMENT_CANCELLED / STOCK_RELEASED / PAYMENT_REFUNDED

보상 요청을 받았을 때 현재 상태를 보고 "아직 안 끝난 보상만" 실행합니다.

CREATE TABLE saga_execution (
  saga_id VARCHAR(64) PRIMARY KEY,
  state VARCHAR(64) NOT NULL,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

상태 전이는 반드시 단조롭게(앞으로만) 설계하고, 역행을 금지하세요. 그래야 중복 메시지에서도 안전합니다.

패턴 5: 외부 시스템 호출을 위한 "세컨더리 멱등성 키"

내부 commandKey가 있어도 외부 결제사 API가 멱등성을 지원하지 않거나, 키 정책이 다를 수 있습니다. 이때는 별도 키를 매핑해 저장합니다.

  • 내부 키: sagaId + step
  • 외부 키: 결제사 요구 포맷에 맞춘 refundRequestId
CREATE TABLE external_call_dedup (
  call_key VARCHAR(128) PRIMARY KEY,
  provider VARCHAR(32) NOT NULL,
  provider_request_id VARCHAR(64) NOT NULL,
  status VARCHAR(20) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

외부 호출 전에 INSERT로 잠금을 획득하고, 이미 있으면 호출하지 않습니다.

패턴 6: 메시지 컨슈머의 중복처리(Exactly-once 환상 버리기)

Kafka든 SQS든 대부분은 기본이 at-least-once입니다. 컨슈머는 반드시 중복을 견뎌야 합니다.

컨슈머 중복처리는 보통 2가지 방식입니다.

  • 메시지 ID 저장: messageIdprocessed_message에 저장
  • 업무 키 기반: orderId + eventType + version 같은 비즈니스 키로 중복 판단

이때 중요한 점은 "저장"과 "업무 처리"를 같은 DB 트랜잭션으로 묶는 것입니다.

CREATE TABLE processed_message (
  message_id VARCHAR(128) PRIMARY KEY,
  received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

장애 시나리오로 보는 체크리스트

시나리오 A: 보상 실행 후 ack 전에 컨슈머 크래시

  • 메시지 재전달
  • processed_command가 이미 있으므로 즉시 성공 응답
  • 외부 환불 API는 추가 호출되지 않음

시나리오 B: 상태 업데이트는 성공, 이벤트 발행 실패

  • Outbox가 없으면 오케스트레이터가 재시도하며 중복 보상 위험
  • Outbox가 있으면 퍼블리셔가 재발행 가능

시나리오 C: 보상 중간에 실패(예: 재고 복구 성공, 환불 실패)

  • 상태머신에 단계별 완료 상태 기록
  • 재시도 시 환불 단계만 재수행

구현 팁: 락, 유니크 제약, 그리고 타임아웃

  • 중복처리는 가급적 유니크 인덱스로 해결하세요. 애플리케이션 락은 장애 시 복구가 어렵습니다.
  • INSERT ... ON CONFLICT DO NOTHING 같은 구문을 DB에 맞게 활용하세요.
  • "처리 중" 상태가 영원히 남지 않도록, PROCESSING 레코드에 TTL 또는 워치독을 두는 것도 실전에서 유용합니다.

관측 가능성: 로그만으로는 부족하다

중복과 재시도는 정상 동작이므로, 알람 기준을 잘 세워야 합니다.

  • sagaId 기준으로 단계별 지연 시간 측정
  • 보상 재시도 횟수, 중복 메시지 비율을 메트릭화
  • 실패 원인을 error_code로 정규화

운영에서 "중복이 왜 발생했지"를 추적할 때는 데이터 중복 원인 분석이 중요합니다. 데이터 조인에서 중복 키로 행이 폭증하는 문제를 진단하는 접근은 이벤트 중복 분석에도 힌트를 줍니다: Pandas merge에서 행 폭증? 중복키 진단법

또한 컨슈머가 OOM으로 죽으며 재전달이 폭증하는 경우도 잦습니다. 쿠버네티스 환경이라면 아래 글의 관점으로 CrashLoop와 OOM을 먼저 잡아야 멱등성 설계가 빛을 봅니다: EKS Pod CrashLoopBackOff와 OOMKilled 진단법

예시: 오케스트레이션 Saga에서 보상 커맨드 키 설계

키 설계는 단순할수록 좋고, 충돌 가능성이 낮아야 합니다.

  • commandKey 예시: saga:{sagaId}:step:{stepName}:action:compensate
  • 같은 Saga의 같은 단계 보상은 항상 같은 키
  • 다른 Saga에는 절대 섞이지 않음

TypeScript로 키를 만들 때도 부등호를 쓰는 제네릭 표기는 본문에 노출되면 MDX에서 오해될 수 있으니, 코드는 코드블록 안에만 두는 습관이 안전합니다.

export function compensationKey(params: {
  sagaId: string;
  step: string;
}): string {
  return `saga:${params.sagaId}:step:${params.step}:action:compensate`;
}

흔한 함정 5가지

  1. 보상에서 delete 사용: 재시도 시 "없음" 케이스가 늘고 감사 추적이 어려움
  2. 외부 API를 멱등하다고 가정: 실제로는 중복 환불, 중복 쿠폰 복구가 발생
  3. 이벤트 발행 실패를 무시: 오케스트레이터 재시도와 결합되면 중복 폭발
  4. 상태머신 없이 보상 단계를 즉흥적으로 실행: 부분 성공 후 복구가 어려움
  5. 중복처리 테이블에 결과를 저장하지 않음: 중복 요청에 대해 매번 비싼 조회·계산을 반복

결론

Saga 보상 트랜잭션은 "가끔" 실행되는 예외 흐름이 아니라, 분산 시스템에서 반드시 통과해야 하는 정상 경로입니다. 멱등성과 중복처리를 제대로 설계하면 다음을 얻습니다.

  • 재전달과 재시도에도 안전한 보상 실행
  • 외부 부작용의 중복 호출 방지
  • 장애 후 자동 복구 가능성 증가
  • 운영에서 원인 추적이 가능한 데이터 흔적

정리하면, 실전에서는 processed_command 같은 중복처리 저장소, 조건부 업데이트 기반 DB 멱등성, outbox 기반 발행 신뢰성, 단계별 상태머신을 함께 사용해야 Saga 보상이 안정적으로 굴러갑니다.