- Published on
MSA Saga 패턴 - 보상 트랜잭션 실패 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 마이크로서비스가 각자 DB를 가진 MSA에서 분산 트랜잭션을 2PC로 묶기 어렵기 때문에, 현실적인 대안으로 Saga 패턴을 많이 씁니다. 문제는 “정방향(Forward) 단계는 성공했는데 역방향(Compensation) 단계가 실패”하는 순간부터입니다. 이때는 단순 장애가 아니라 데이터 정합성의 부채가 쌓이고, 재시도 폭주나 중복 보상으로 2차 장애까지 번지기 쉽습니다.
이 글은 보상 트랜잭션 실패를 디버깅할 때의 실전 체크리스트를 제공합니다. 특히 오케스트레이션 기반 Saga를 기준으로 설명하되, 코레오그래피에도 그대로 적용할 수 있는 관측성과 재현 전략을 포함합니다.
Saga에서 “보상 실패”가 특히 위험한 이유
보상 트랜잭션은 단순히 실패한 작업을 되돌리는 함수가 아닙니다. 다음 제약을 동시에 만족해야 합니다.
- 멱등성(idempotency): 같은 보상이 여러 번 호출돼도 결과가 동일해야 함
- 순서/상태 의존성: 보상은 보통 “이미 성공한 단계”에 대해서만 수행해야 함
- 시간 지연: 보상은 수 초 뒤, 혹은 수 분 뒤에 실행될 수 있음
- 외부 시스템 의존: 결제 취소, 재고 롤백, 포인트 회수 등 외부 API가 포함될 수 있음
따라서 보상 실패는 단발성 예외가 아니라 “상태 머신이 멈춘 것”에 가깝고, 디버깅도 상태/이벤트 기반으로 접근해야 합니다.
실패 유형을 먼저 분류하면 디버깅이 빨라진다
보상 트랜잭션 실패는 대체로 아래 6가지 유형으로 나뉩니다.
1) 보상 API 자체 실패(5xx, 타임아웃)
- 네트워크, 다운스트림 장애, 커넥션 고갈
- 타임아웃이 짧아 정상도 실패로 오인
2) 비즈니스 거절(4xx)
- 이미 취소된 결제라
409혹은422 - 취소 가능 기간 만료
3) 중복 호출로 인한 충돌
- 같은 보상이 여러 번 실행
- 락 경합, 유니크 키 충돌
4) 잘못된 보상 순서
- 예: 배송 취소보다 결제 취소가 먼저여야 하는데 순서 반대
5) 이벤트 유실/중복/순서 뒤바뀜
- 메시지 브로커에서 at-least-once로 중복 전달
- 컨슈머 리밸런싱 중 재처리
6) 데이터 레벨 문제(데드락, 인덱스/락 경합)
- 보상은 보통 “핫 로우”를 만져서 경합이 심함
- DB 데드락으로 보상만 반복 실패
특히 6번은 애플리케이션 로그만 봐서는 “그냥 타임아웃”처럼 보이기 때문에, DB 관측이 없으면 원인 도달이 어렵습니다. MySQL을 쓰는 경우 데드락 추적과 인덱스 점검이 중요합니다. 관련해서는 MySQL InnoDB 데드락 원인 추적과 인덱스 튜닝도 함께 참고하면 좋습니다.
디버깅의 출발점: Saga를 “상태 머신”으로 기록하라
보상 실패를 제대로 디버깅하려면, 각 Saga 인스턴스를 다음처럼 상태 머신으로 남겨야 합니다.
saga_id: 전 구간 상관관계 키step: 현재 단계status:RUNNING,COMPENSATING,COMPLETED,FAILEDattempt: 재시도 횟수last_error_code,last_error_messagenext_retry_at
오케스트레이터가 없다면(코레오그래피), 각 서비스가 자신이 처리한 이벤트의 saga_id와 로컬 상태를 남겨야 합니다.
예시: Saga 상태 테이블(PostgreSQL)
create table saga_instance (
saga_id uuid primary key,
status text not null,
current_step text not null,
attempt int not null default 0,
last_error_code text,
last_error_message text,
next_retry_at timestamptz,
updated_at timestamptz not null default now()
);
create index idx_saga_instance_retry
on saga_instance (status, next_retry_at);
여기서 중요한 포인트는 “로그만으로는 부족하고, 재처리 가능한 운영 데이터가 필요하다”는 점입니다.
관측성(Observability): 보상 실패를 한 번에 엮어보기
보상 실패는 서비스 A, B, C를 가로지르기 때문에 “한 화면에서 이어서 보는 능력”이 핵심입니다.
1) 분산 트레이싱에 saga_id를 강제 주입
- HTTP 헤더
x-saga-id같은 형태 - 메시지에도 동일 키를 넣고 컨슈머가 로그에 포함
2) 구조화 로그 필수 필드
saga_id,step,direction(forward/compensate),attempt,error_type
3) 메트릭
compensation_failure_total{step=...}compensation_retry_total{step=...}saga_stuck_gauge(예:COMPENSATING상태로 10분 이상)
4) 알람은 “실패 횟수”보다 “정체 시간” 중심
보상은 일시 실패 후 재시도로 회복될 수 있습니다. 따라서 단순 실패 카운트보다
COMPENSATING상태가 N분 이상 지속next_retry_at가 과거인데도 진행 없음 같은 신호가 더 유효합니다.
재현 전략: 운영에서만 터지는 보상 실패를 로컬로 끌고 오기
보상 실패는 타이밍/경합/네트워크에 민감합니다. 아래 순서로 재현 가능성을 높입니다.
- 운영 트레이스에서 단일
saga_id를 고른다 - 해당 Saga의 이벤트/커맨드 페이로드를 수집한다
- 동일 페이로드로 스테이징에서 재생(replay)한다
- 네트워크 지연/타임아웃을 강제로 주입한다
예시: Node.js에서 보상 API 타임아웃 재현(AbortController)
import fetch from 'node-fetch';
export async function callCompensate(url, body, timeoutMs = 800) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`compensate_failed status=${res.status} body=${text}`);
}
return await res.json();
} finally {
clearTimeout(t);
}
}
이때 타임아웃을 너무 짧게 잡으면 정상 상황도 실패로 처리해 보상 폭주를 만들 수 있습니다. 반대로 너무 길면 워커 스레드/커넥션이 묶여 장애 전파가 커집니다.
네트워크 문제로 보상이 연쇄적으로 지연될 때는 프록시/게이트웨이의 타임아웃과 버퍼링도 함께 봐야 합니다. SSE나 장시간 연결이 섞인 환경이라면 LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트처럼 “중간 계층” 설정이 원인인 경우도 있습니다.
보상 트랜잭션 설계의 핵심: 멱등성과 원자적 가드
보상이 실패하는 진짜 이유 중 상당수는 “보상 자체가 멱등하지 않음”입니다.
패턴 1) 보상 요청에 idempotency_key를 포함
saga_id + step조합을 키로 사용- 서버는 이 키로 처리 결과를 저장하고, 동일 키 재요청에는 같은 결과를 반환
패턴 2) 상태 전이와 보상 실행을 분리하지 말 것
보상 실행 전에 “보상 시작” 상태로 바꾸고, 실행 후 “보상 완료”로 바꾸는 식으로 2단계로 하면 중간 실패에서 꼬이기 쉽습니다.
가능하면 DB 트랜잭션 안에서 다음을 원자적으로 처리합니다.
- 보상 대상 상태인지 확인
- 이미 처리된 보상인지 확인
- 보상 처리 마킹
예시: 보상 멱등 처리(MySQL)
-- compensation_log: (idempotency_key PK, status, created_at)
start transaction;
insert into compensation_log (idempotency_key, status, created_at)
values ('saga-123:PAYMENT_CANCEL', 'STARTED', now())
on duplicate key update idempotency_key = idempotency_key;
-- 이미 완료면 빠르게 종료
select status from compensation_log
where idempotency_key = 'saga-123:PAYMENT_CANCEL'
for update;
-- 여기서 status가 COMPLETED면 commit 후 return
-- 실제 보상에 필요한 로컬 변경(예: 주문 상태)
update orders
set status = 'CANCELLED'
where order_id = 1001 and status in ('PAID', 'PAYMENT_PENDING');
update compensation_log
set status = 'COMPLETED'
where idempotency_key = 'saga-123:PAYMENT_CANCEL';
commit;
포인트는 for update로 멱등 키를 잠그고, 한 번에 한 워커만 보상을 진행하게 만드는 것입니다.
재시도 정책: “무한 재시도”는 장애 증폭기다
보상은 재시도가 필요하지만, 무한 재시도는 운영을 망칩니다. 다음 기준을 권장합니다.
- 네트워크/5xx는 지수 백오프 + 지터
- 4xx(비즈니스 거절)는 즉시 중단하고 수동 처리 큐로 이동
- 최대 재시도 횟수 초과 시
FAILED로 전이하고 알람
예시: 지수 백오프 계산
export function nextBackoffMs(attempt) {
const base = 500; // 0.5s
const cap = 60_000; // 60s
const exp = Math.min(cap, base * (2 ** attempt));
const jitter = Math.floor(Math.random() * 200);
return exp + jitter;
}
여기서 중요한 건 “재시도는 성공률을 올리지만, 동시에 동시성을 올린다”는 점입니다. 보상 워커를 수평 확장하면 멱등/락 설계가 약할 때 중복 보상과 데드락이 바로 터집니다.
DB 관점 디버깅: 데드락과 락 경합이 보상을 멈춘다
보상은 주문/결제 같은 핵심 테이블을 갱신하는 경우가 많고, 동일 주문에 대해 여러 이벤트가 동시에 들어오면 락 경합이 심해집니다.
MySQL에서 확인할 것
SHOW ENGINE INNODB STATUS로 최신 데드락- 트랜잭션이 어떤 인덱스를 타는지
EXPLAIN - 불필요하게 넓은 범위를 잠그는 쿼리(인덱스 미사용)
데드락이 반복되면 “보상 로직이 틀렸다”가 아니라, 다음 중 하나일 가능성이 큽니다.
- 업데이트 조건이 비결정적이라 여러 로우를 순서 다르게 잠금
- 인덱스가 없어 테이블 스캔으로 광범위 락
- 동일 자원을 서로 다른 순서로 잠그는 두 트랜잭션 경로 존재
이 영역은 애플리케이션 수정과 함께 인덱스/쿼리 설계가 같이 들어가야 합니다. (MySQL은 위 내부 링크 참고)
메시지 브로커 디버깅: 중복 전달과 순서 뒤바뀜을 전제로 하라
Saga에서 이벤트 기반으로 보상을 트리거한다면, 브로커는 대개 at-least-once입니다. 즉,
- 중복 이벤트는 정상
- 순서 보장은 파티션 키에 달림
따라서 보상 컨슈머는 다음을 기본으로 가져야 합니다.
- 이벤트
event_id기반 디듀플리케이션 saga_id단위 순서가 필요하면 동일 파티션 키로 라우팅- 처리 실패 시 DLQ로 보내고, DLQ 재처리도 멱등하게
예시: 인바운드 이벤트 디듀플리케이션 테이블
create table inbox (
event_id varchar(64) primary key,
received_at timestamptz not null default now()
);
컨슈머는 처리 시작 전에 inbox에 event_id를 넣고, 이미 존재하면 즉시 종료합니다. 이 패턴을 흔히 inbox/outbox로 묶어 부르며, Saga의 보상 안정성을 크게 올립니다.
“보상 불가” 상태를 인정하고 운영 플로우를 만들기
현실적으로 모든 것을 자동 보상할 수는 없습니다. 예를 들어
- 결제 취소 가능 시간 만료
- 외부 파트너 시스템 장애가 장기화
- 이미 배송 출고 완료
이때 필요한 것은 기술적 재시도가 아니라 업무적 처리입니다.
권장 운영 모델:
FAILED로 전이된 Saga를 조회하는 어드민 화면- 실패 사유(코드/메시지)와 마지막 요청/응답 스냅샷
- “재시도”, “수동 완료 처리”, “부분 환불” 같은 액션
즉, Saga는 100퍼센트 자동 정합성을 보장하는 장치가 아니라, 정합성 복구를 관리 가능한 프로세스로 만드는 장치에 가깝습니다.
실전 디버깅 체크리스트(요약)
- 단일
saga_id를 잡고 forward/compensation 전체 흐름을 타임라인으로 재구성 - 실패 유형을 6가지 중 어디에 넣을지 먼저 결정
- 보상 API의 응답 코드가 4xx인지 5xx인지 분리(정책이 완전히 다름)
- 멱등 키가 있는지, 중복 호출 시 안전한지 확인
- 재시도 정책이 지수 백오프인지, 최대 횟수와 DLQ가 있는지 확인
- DB에서 데드락/락 경합/인덱스 미사용을 확인
- 브로커에서 중복/순서 문제를 전제로 컨슈머를 설계했는지 확인
- 자동 보상이 불가능한 케이스를 위한 운영 플로우를 준비
마무리
보상 트랜잭션 실패 디버깅은 “예외 로그 한 줄”이 아니라, 분산 상태 머신의 정지 원인을 찾는 작업입니다. 성공하는 팀은 공통적으로
saga_id로 모든 신호를 묶고- 멱등성과 디듀플리케이션을 기본값으로 두며
- 재시도와 운영 개입 경계를 명확히 만듭니다.
보상 실패를 한 번 제대로 분류하고, 재현 가능한 형태로 기록하기 시작하면, Saga는 불안정한 임시방편이 아니라 운영 가능한 정합성 전략으로 바뀝니다.