Published on

MSA Saga 보상 트랜잭션 실패 디버깅 가이드

Authors

서로 다른 서비스가 각자 DB를 가지는 MSA에서 Saga 패턴은 “분산 트랜잭션의 현실적인 대안”으로 자주 선택됩니다. 하지만 운영에서 진짜 어려운 부분은 성공 경로가 아니라 실패 경로, 그중에서도 보상 트랜잭션(compensation) 이 기대대로 동작하지 않을 때입니다.

보상은 원자적 롤백이 아니라 “의미적 되돌림”이기 때문에, 네트워크 지연·중복 메시지·부분 실패·순서 역전 같은 현실적인 문제에 그대로 노출됩니다. 이 글은 보상 트랜잭션 실패를 디버깅할 때 무엇을 어떤 순서로 확인해야 하는지를, 로그/상태/메시지/데이터 관점으로 쪼개서 정리합니다.

관련 주제를 더 확장해서 보고 싶다면: MSA Saga 보상 트랜잭션 꼬임 디버깅 실전 글도 함께 참고하면 좋습니다.

1) 보상 실패의 대표 증상 6가지

디버깅은 “현상 분류”가 절반입니다. 보상 실패는 보통 아래 패턴으로 나타납니다.

  1. 보상 이벤트가 아예 발행되지 않음: 오케스트레이터/코레오그래피 어느 쪽이든, 실패를 감지했는데도 보상 커맨드가 안 나감
  2. 보상 이벤트는 발행됐는데 소비가 안 됨: 컨슈머 그룹 문제, 토픽 라우팅, ACL, DLQ 이동 등
  3. 보상이 실행됐는데 효과가 없음: 이미 다른 상태로 바뀌었거나, 멱등성 키가 달라서 무시됨, 조건부 업데이트 실패
  4. 보상이 중복 실행됨: 재시도/리밸런스/타임아웃으로 동일 보상이 여러 번 수행
  5. 보상 순서가 역전됨: Step3 보상을 먼저 하고 Step2 보상을 나중에 해서 데이터가 더 꼬임
  6. 영구 재시도(무한 루프): 재시도 정책이 잘못되어 DLQ로 못 가고 계속 실패

이 6가지 중 어디에 속하는지 먼저 결정하면, 확인해야 할 지점이 확 줄어듭니다.

2) 디버깅의 핵심: “상관관계 ID”와 “상태 머신”

보상 디버깅은 결국 한 트랜잭션(주문 1건 등)의 전체 타임라인을 복원하는 작업입니다. 이때 반드시 필요한 것이 두 가지입니다.

  • Correlation ID(상관관계 ID): sagaId, orderId, traceId 중 최소 1개는 전 구간에 전파
  • Saga 상태 머신: 각 스텝의 상태를 명시적으로 기록 (예: PENDING, DONE, COMPENSATING, COMPENSATED, FAILED)

추천 로그 필드(최소 세트)

  • traceId (분산 추적)
  • sagaId (사가 인스턴스)
  • step (예: reserve-inventory)
  • action (예: do, compensate)
  • messageId (브로커 메시지 식별)
  • idempotencyKey (멱등 제어)
  • attempt (재시도 횟수)
  • version (낙관적 락/이벤트 버전)

이 필드가 없으면, “보상이 왜 실패했는지”가 아니라 “어느 보상인지”부터 헷갈립니다.

3) 체크리스트 1: 오케스트레이터/사가 로그로 타임라인 복원

오케스트레이션 기반 Saga라면, 오케스트레이터(또는 Saga Coordinator)가 단일 진실 소스에 가깝습니다.

확인 순서

  1. 실패를 감지한 지점이 어디인지 확인 (어느 step의 do가 실패했는지)
  2. 실패 직후 보상 커맨드를 “발행”했는지 확인
  3. 발행했다면 메시지 브로커에 실제로 기록되었는지(프로듀서 ACK) 확인
  4. 각 보상 커맨드에 대해 컨슈머가 “수신”했는지 확인
  5. 수신했다면 “처리 성공/실패”와 DB 반영 여부 확인

예시: 구조화 로그(권장)

{
  "level": "ERROR",
  "ts": "2026-02-24T12:34:56.789Z",
  "traceId": "4f2a...",
  "sagaId": "saga-9c1b...",
  "step": "payment",
  "action": "do",
  "result": "FAILED",
  "error": "TIMEOUT",
  "next": "emit_compensation"
}

여기서 중요한 건 에러 자체보다 next 같은 필드로 상태 전이 의도를 남기는 것입니다. 운영에서 “의도는 보상이었는데 코드가 분기 버그로 종료” 같은 케이스가 꽤 많습니다.

4) 체크리스트 2: 메시지 브로커에서 유실·중복·순서 문제 찾기

보상 트랜잭션 실패는 애플리케이션 버그가 아니라 메시징 특성(최소 1회 전달, 순서 보장 범위 제한) 때문에 발생하는 경우가 많습니다.

4-1) 유실처럼 보이는 케이스

  • 프로듀서가 send는 했는데 ACK 전에 프로세스가 죽음
  • 트랜잭션 아웃박스(outbox) 없이 DB 커밋과 이벤트 발행이 분리됨
  • 컨슈머가 받았지만 처리 전에 크래시, 오프셋 커밋이 이미 됨

해결 방향은 보통 Outbox 패턴 + 컨슈머 멱등 처리로 귀결됩니다.

4-2) 중복 보상 케이스

  • 컨슈머 리밸런스 또는 처리 타임아웃으로 재전달
  • 재시도 정책이 “같은 메시지”를 여러 번 실행

이때 핵심은 “보상은 멱등이어야 한다”인데, 말은 쉬워도 구현이 까다롭습니다. 아래처럼 멱등성 테이블 또는 상태 전이 조건을 강제해야 합니다.

-- 예: saga_step 테이블(개념)
-- (saga_id, step, phase) 유니크 제약으로 중복 실행 방지
INSERT INTO saga_step(saga_id, step, phase, status, updated_at)
VALUES (:sagaId, 'inventory', 'COMPENSATE', 'STARTED', now());

이미 STARTED가 들어가 있으면 동일 보상을 다시 시작하지 않게 만들 수 있습니다.

4-3) 순서 역전 케이스

순서가 중요한 보상이라면, 아래를 확인해야 합니다.

  • 동일 키(예: orderId)가 같은 파티션으로 가는지
  • 컨슈머가 병렬 처리하면서 같은 주문을 동시에 처리하지 않는지
  • 재시도 큐와 본 큐의 우선순위/지연으로 인해 뒤늦게 도착하는 메시지가 없는지

특히 gRPC 스트리밍이나 장시간 연결 기반 처리에서 재연결/재시도로 순서가 깨지는 경우가 있어, 통신 계층의 재시도 설계도 함께 봐야 합니다. 관련해서는 gRPC 스트리밍 끊김 대응 - Retry·Circuit Breaker 설계도 참고할 만합니다.

5) 체크리스트 3: 보상 로직 자체의 실패(데이터/락/조건)

보상 이벤트는 잘 도착하는데도 “보상 효과가 없다”면, 대부분은 아래 중 하나입니다.

5-1) 조건부 업데이트 실패

예를 들어 재고 예약 취소 보상이 다음처럼 구현되어 있다고 가정해봅시다.

UPDATE inventory_reservation
SET status = 'CANCELED'
WHERE reservation_id = :rid
  AND status = 'RESERVED';

이 쿼는 멱등에 유리하지만, 이미 상태가 CONFIRMED로 바뀌었거나 다른 프로세스가 건드렸다면 업데이트 0건이 됩니다. 이때 보상을 성공으로 볼지 실패로 볼지 정책이 필요합니다.

  • 0건 업데이트를 “이미 보상된 것으로 간주”하면 중복에 강해짐
  • 0건 업데이트를 “데이터 불일치”로 간주하면 조기 탐지가 가능

운영 성격에 따라 다르지만, 디버깅 단계에서는 0건 업데이트를 반드시 로그로 남기고 지표로 올리는 것이 중요합니다.

5-2) 낙관적 락/버전 충돌

이벤트 소싱 또는 버전 필드를 쓰는 경우, 보상도 동일하게 버전 충돌이 납니다.

UPDATE orders
SET status = 'CANCELED', version = version + 1
WHERE order_id = :orderId
  AND version = :expectedVersion;

여기서 expectedVersion이 오래된 값이면 보상은 계속 실패합니다. 해결은 보통:

  • 보상 커맨드에 “기대 버전”을 싣지 말고, 상태 기반 전이로 처리
  • 또는 최신 버전을 읽고 재시도(단, 무한 루프 방지)

5-3) 외부 의존성으로 인한 보상 실패

결제 취소 같은 보상은 외부 API 호출이 포함됩니다. 이때 실패는 더 복잡해집니다.

  • 결제 취소 API는 성공했는데 응답이 타임아웃(클라이언트는 실패로 인식)
  • 실제로는 취소됐는데 재시도하면서 “이미 취소됨” 에러

이런 경우는 외부 시스템의 멱등 키(예: idempotency-key 헤더)와 취소 상태 조회가 필요합니다.

6) 재시도·DLQ·수동 개입 지점 설계(디버깅을 쉽게 만드는 장치)

보상 실패를 “언젠가 되겠지”로 방치하면, 결국 운영자가 DB를 직접 만지게 됩니다. 디버깅 가능성을 높이려면 재시도 체계를 설계로 박아야 합니다.

권장 정책

  • 즉시 재시도: 3회 내, 짧은 지수 백오프
  • 지연 재시도 큐: 5분, 30분, 2시간 등 단계적
  • 최종 실패: DLQ로 이동 + 알림
  • DLQ 이벤트에는 원본 메시지, 에러, 시도 횟수, 마지막 처리 노드 정보를 포함

또한 DLQ에 들어간 건을 “어떻게 재처리할지”가 명확해야 합니다.

  • 같은 메시지를 그대로 재발행(단, 멱등 보장 필수)
  • 운영자 승인 후 재발행
  • 사가 상태를 FAILED로 확정하고 별도 환불/정산 프로세스로 넘김

7) 관측성: 분산 추적 + 지표 + 로그의 결합 포인트

보상 디버깅은 단일 도구로 끝나지 않습니다.

  • 로그: 개별 사건의 원인
  • 지표: 시스템적 패턴(특정 step에서 실패율 급증)
  • 트레이싱: 호출 체인과 지연/타임아웃

추천 지표

  • saga_step_success_total{step,phase}
  • saga_step_failure_total{step,phase,error}
  • saga_compensation_lag_seconds{step} (실패 발생부터 보상 완료까지)
  • dlq_depth{topic}

보상 랙이 증가하면, 보상 자체가 실패하지 않아도 “고객 관점에서는 실패”가 됩니다.

8) 운영에서 자주 보는 원인: 프로세스 재시작과 배포

보상 실패를 따라가다 보면, 의외로 애플리케이션 로직이 아니라 “프로세스가 계속 죽고 재시작”하는 문제가 원인인 경우가 많습니다.

  • 컨슈머가 처리 중 죽어서 중복 실행
  • 오케스트레이터가 상태 저장 전에 죽어서 보상 발행 누락

이때는 서비스 재시작 원인을 함께 추적해야 합니다. systemd 기반 환경이라면 아래 글이 직접적으로 도움이 됩니다.

Kubernetes 환경에서는 OOMKilled, liveness probe, 노드 이슈 등도 함께 봐야 합니다.

9) 실전 디버깅 플로우(30분 내 원인 범주화)

현장에서 “지금 주문들이 꼬였다” 상황에서 빠르게 범주화하는 순서를 정리하면 다음과 같습니다.

  1. 단일 케이스 선택: orderId 1개를 잡고 끝까지 파기
  2. 사가 상태 확인: 오케스트레이터 DB 또는 saga 상태 저장소에서 step별 상태 확인
  3. 보상 커맨드 발행 여부 확인: 프로듀서 로그 + 브로커 기록
  4. 컨슈머 수신/처리 확인: 컨슈머 로그에서 messageId로 검색
  5. DB 반영 확인: 보상 쿼리의 영향 행 수, 버전 충돌, 상태 전이 조건 확인
  6. 중복/순서 문제 확인: 파티션 키, 컨슈머 병렬도, 재시도 큐 지연
  7. 외부 API 확인: 결제/배송 등은 “실제 반영 여부” 조회 API로 진실 확인
  8. 최종 조치 결정: 재처리, 수동 보정, 사가 실패 확정 중 선택

이 플로우가 정착되면, “재현이 어려운 분산 장애”도 대부분은 1시간 내에 원인 범주를 좁힐 수 있습니다.

10) 예방 팁: 보상 설계를 디버깅 친화적으로 만들기

마지막으로, 보상 실패 디버깅을 줄이는 설계 팁을 정리합니다.

  • 보상은 가능하면 상태 전이 기반으로 구현 (삭제보다 CANCELED 같은 상태가 추적에 유리)
  • 모든 커맨드/이벤트에 sagaId, step, phase를 포함
  • Outbox 패턴으로 “DB 커밋과 이벤트 발행” 결합
  • 컨슈머는 “멱등 + 재진입 가능”하게
  • 재시도는 무한 루프가 아니라 DLQ로 수렴
  • 운영자가 볼 수 있는 “사가 뷰”를 만든다 (주문 1건의 step 타임라인)

보상 트랜잭션은 실패를 전제로 하는 설계입니다. 따라서 디버깅도 “특별한 이벤트”가 아니라, 시스템의 일부로 내재화해야 합니다. 위 체크리스트와 관측성/재시도 설계를 갖추면, 보상 실패는 더 이상 공포가 아니라 관리 가능한 운영 항목이 됩니다.