- Published on
Saga 패턴 보상 트랜잭션 설계·디버깅 8단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
분산 트랜잭션이 필요한 순간마다 2PC를 떠올리지만, 마이크로서비스 환경에서는 락/가용성/복잡도 때문에 현실적으로 어렵습니다. 그래서 많이 선택하는 해법이 Saga 패턴이고, Saga의 성패는 결국 보상 트랜잭션(Compensation) 을 얼마나 “되돌릴 수 있게” 설계하느냐에 달려 있습니다.
이 글은 단순 개념 소개가 아니라, 운영에서 실제로 터지는 문제(중복 실행, 부분 실패, 타임아웃, 순서 꼬임, 재시도 폭주)를 기준으로 보상 트랜잭션 설계와 디버깅을 8단계 체크리스트로 정리합니다.
0. 전제: 이 글에서 말하는 Saga의 형태
Saga는 크게 두 가지가 있습니다.
- Choreography: 서비스들이 이벤트를 발행/구독하며 다음 단계를 진행
- Orchestration: 오케스트레이터가 각 서비스 호출을 지휘하고 상태를 관리
보상 트랜잭션 디버깅은 Orchestration이 상대적으로 단순하지만, Choreography도 결국 같은 원칙(상태 추적, 멱등성, 재시도 제어, 관측성)이 필요합니다.
예시 시나리오는 전형적인 주문 흐름으로 가정합니다.
- Order 생성
- Payment 승인
- Inventory 예약
- Shipping 생성
실패 시 역순으로 보상합니다.
1단계: “무엇을 되돌릴지”를 상태 기계로 먼저 고정
보상 설계의 가장 흔한 실패는, 도메인 액션을 “그냥 취소 API 하나”로 뭉개는 것입니다. 실제로는 단계별로 되돌릴 수 있는 범위가 다르고, 어떤 단계는 되돌리기보다 상쇄(정정) 가 맞습니다.
체크리스트
- 각 단계의 성공 상태를 명확히 정의했는가
- 각 단계의 보상 가능 여부(완전 취소, 부분 취소, 정정, 불가)를 분류했는가
- “보상은 역순” 원칙이 항상 성립하는가(외부 시스템 포함)
예시: Saga 상태 모델
아래처럼 Saga 인스턴스 상태를 명시적으로 저장해야 디버깅이 쉬워집니다.
-- saga_instance: 오케스트레이터가 관리하는 사가 실행 기록
create table saga_instance (
saga_id varchar(64) primary key,
saga_type varchar(64) not null,
status varchar(32) not null, -- RUNNING, COMPENSATING, SUCCEEDED, FAILED
current_step int not null,
created_at timestamp not null,
updated_at timestamp not null
);
-- saga_step: 각 스텝의 실행/보상 결과
create table saga_step (
saga_id varchar(64) not null,
step int not null,
action varchar(64) not null,
status varchar(32) not null, -- PENDING, DONE, COMPENSATED, FAILED
idempotency_key varchar(128) not null,
last_error text,
updated_at timestamp not null,
primary key (saga_id, step)
);
핵심은 “성공했으니 다음”이 아니라, 어떤 스텝이 DONE인지가 기록으로 남아야 한다는 점입니다.
2단계: 보상 트랜잭션을 “멱등”하게 만들기
운영에서 보상은 거의 반드시 중복 호출됩니다. 이유는 단순합니다.
- 네트워크 타임아웃으로 호출자는 실패로 인식하지만 서버는 성공했을 수 있음
- 메시지 브로커의 at-least-once 전달
- 오케스트레이터 재시작 후 재처리
따라서 보상 API는 “한 번만 호출된다”는 가정이 절대 성립하지 않습니다.
멱등성 구현 패턴
idempotency_key를 요청에 포함하고, 처리 결과를 저장- 상태 전이를
compare-and-set으로 제한
-- idempotency 테이블
create table idempotency_record (
key varchar(128) primary key,
status varchar(32) not null, -- IN_PROGRESS, SUCCEEDED, FAILED
response_hash varchar(64),
updated_at timestamp not null
);
// TypeScript 예시: 멱등 처리 스케치
async function handleCompensation(req: { key: string; orderId: string }) {
const rec = await db.idempotency.find(req.key);
if (rec?.status === 'SUCCEEDED') return;
// 없으면 생성, 있으면 IN_PROGRESS로 CAS
await db.idempotency.upsertInProgress(req.key);
try {
await cancelPayment(req.orderId);
await db.idempotency.markSucceeded(req.key);
} catch (e) {
await db.idempotency.markFailed(req.key, String(e));
throw e;
}
}
멱등성이 없으면 “보상 재시도”가 “추가 취소/추가 환불/재고 음수”로 이어집니다.
3단계: 보상의 의미를 “정확한 도메인 동작”으로 정의
보상은 단순히 반대 연산이 아닙니다.
- 결제 승인 보상은
cancel일 수도 있고, 승인 이후 캡처가 된 경우refund일 수도 있음 - 재고 예약 보상은
release지만, 이미 피킹이 시작되면adjust가 될 수 있음
설계 팁
- 보상 API 이름을
cancel로 뭉치지 말고, 도메인 상태에 맞춰 분리 - 보상 요청에는 “왜 보상인지” 컨텍스트를 포함
{
"sagaId": "S-20240224-0001",
"reason": "ORDER_FAILED_AFTER_PAYMENT",
"idempotencyKey": "S-20240224-0001:PAYMENT:COMPENSATE",
"payment": {
"paymentId": "P-7788",
"mode": "CANCEL_OR_REFUND"
}
}
이렇게 해두면 장애 분석 시 “어떤 정책으로 보상했는지”가 로그만으로도 남습니다.
4단계: 타임아웃·재시도·서킷브레이커를 “보상 관점”에서 재설계
보상 트랜잭션은 정상 트래픽보다 더 불리한 조건에서 호출됩니다.
- 이미 장애가 발생한 상황에서 실행됨
- 연쇄 실패로 외부 의존성이 흔들리는 중
- 동시 보상 요청이 폭주할 수 있음
따라서 보상 호출의 타임아웃/재시도는 “평소 정책”을 그대로 쓰면 위험합니다.
실전 권장
- 보상 호출은 짧은 타임아웃 + 제한된 재시도
- 재시도는 지수 백오프 + 지터
- 실패 시 즉시 사람에게 알리는 대신, 보상 큐에 적재하고 비동기 재처리
gRPC 기반이라면 타임아웃 설계가 특히 중요합니다. 운영에서 자주 보는 증상이 DEADLINE_EXCEEDED이고, 원인 파악 프레임워크가 필요합니다. 관련해서는 이 글도 함께 보면 좋습니다.
5단계: Outbox/Inbox로 “발행과 저장”을 원자화
Choreography든 Orchestration이든, 결국 이벤트/커맨드를 외부로 내보냅니다. 이때 가장 치명적인 버그가 아래 패턴입니다.
- DB에는 상태 저장 성공
- 메시지 발행 실패
- 결과적으로 다음 스텝이 영원히 실행되지 않음(유령 사가)
해결: Transactional Outbox
create table outbox (
id bigint primary key auto_increment,
aggregate_id varchar(64) not null,
topic varchar(128) not null,
payload json not null,
status varchar(16) not null, -- NEW, SENT, FAILED
created_at timestamp not null
);
업데이트 트랜잭션 안에서 saga_step 갱신과 outbox insert를 함께 처리하고, 별도 퍼블리셔가 outbox를 읽어 발행합니다.
이 구조가 없으면 “보상 이벤트가 발행되지 않아 보상이 안 되는” 유형의 장애를 재현하기도 어렵습니다.
6단계: 동시성 충돌과 데드락을 “사가 단위”로 격리
보상은 대개 역순으로 여러 리소스를 만지며, 동시에 여러 사가가 같은 자원(재고, 쿠폰, 포인트)을 건드립니다. 이때 DB 레벨에서는 락 경합과 데드락이 빈번해집니다.
체크리스트
- 동일 리소스 업데이트 순서를 서비스 전반에서 통일했는가
- 보상 처리 시에도 동일한 락 순서를 지키는가
SELECT ... FOR UPDATE범위가 과도하지 않은가
MySQL InnoDB를 쓴다면 데드락 로그를 읽고 “어떤 쿼 조합이 락 사이클을 만들었는지”를 추적하는 습관이 중요합니다.
보상 트랜잭션 디버깅에서 데드락을 놓치면, 증상은 단순히 “보상 재시도만 계속됨”으로 보이고 실제 원인은 DB 락일 수 있습니다.
7단계: 관측성 3종 세트로 “어디서 멈췄는지”를 즉시 찾기
보상 디버깅의 목표는 하나입니다.
- “사가
sagaId가 지금 어느 스텝에서, 어떤 이유로, 몇 번 재시도하다가 멈췄는가”를 1분 안에 답하기
필수 로그 필드
sagaIdstepaction또는compensationActionidempotencyKeyattempttraceId
{
"level": "ERROR",
"sagaId": "S-20240224-0001",
"step": 2,
"phase": "COMPENSATE",
"action": "PAYMENT_CANCEL",
"attempt": 3,
"idempotencyKey": "S-20240224-0001:PAYMENT:COMPENSATE",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"error": "DEADLINE_EXCEEDED"
}
메트릭 권장
saga_running_total,saga_failed_total,saga_compensating_total- 스텝별 latency 히스토그램
- 보상 재시도 횟수 분포
트레이싱
- 오케스트레이터 span 아래에 각 서비스 호출 span을 붙여 “보상 체인”을 한 화면에서 확인
8단계: 운영 디버깅 런북을 “재현 가능”하게 만든다
보상 장애는 재현이 어렵습니다. 그래서 런북은 “추측”이 아니라, 데이터로 좁혀가는 절차여야 합니다.
런북 예시(요약)
saga_instance에서sagaId조회,status와current_step확인saga_step에서DONE/FAILED스텝과last_error확인- 동일
idempotency_key로 중복 요청 여부 확인 - 타임아웃이면 의존 서비스의 지연/에러율 확인
- DB 락이면 데드락 로그 또는 락 대기 확인
- 메시지 기반이면
outbox에NEW가 쌓였는지 확인 - 재시도 폭주면 워커 동시성/백오프 설정 확인
- 마지막으로 “수동 정정”이 필요한지 판단(자동 보상이 불가능한 케이스 분리)
디스크/메모리 같은 인프라 병목도 함께 본다
보상 워커가 느려지는 원인이 애플리케이션 로직이 아니라, 노드 자원 고갈인 경우도 많습니다. 예를 들어 디스크가 꽉 차 로그/DB/큐가 연쇄적으로 느려질 수 있고, 컨테이너가 OOMKilled로 재시작되며 사가가 반복될 수 있습니다.
코드 예제: 간단한 Orchestrator + 보상 실행 흐름
아래는 “스텝 실행 중 실패하면 역순 보상”의 핵심 구조만 담은 예시입니다. 실제 운영에서는 outbox, 분산락, 워커 큐 등을 붙이지만, 뼈대는 동일합니다.
type Step = {
name: string;
run: () => Promise<void>;
compensate: () => Promise<void>;
};
export async function runSaga(sagaId: string, steps: Step[]) {
const done: number[] = [];
try {
for (let i = 0; i < steps.length; i++) {
await markStepPending(sagaId, i, steps[i].name);
await steps[i].run();
await markStepDone(sagaId, i);
done.push(i);
}
await markSagaSucceeded(sagaId);
} catch (e) {
await markSagaCompensating(sagaId, String(e));
// 역순 보상
for (let j = done.length - 1; j >= 0; j--) {
const i = done[j];
try {
await steps[i].compensate();
await markStepCompensated(sagaId, i);
} catch (ce) {
await markStepFailed(sagaId, i, `COMPENSATION_FAILED: ${String(ce)}`);
// 여기서 즉시 중단할지, 다음 보상을 계속할지는 정책 결정 사항
}
}
await markSagaFailed(sagaId, String(e));
throw e;
}
}
이 코드가 운영 수준이 되려면 다음이 추가되어야 합니다.
compensate()멱등성(2단계)- 보상 실패 시 워커 큐에 적재하여 재시도(4단계)
- 상태/이벤트 저장과 발행 원자화(outbox)(5단계)
- 트레이스/로그/메트릭(7단계)
마무리: 보상은 “취소 버튼”이 아니라 “운영 가능한 되돌리기”
Saga 패턴은 분산 트랜잭션의 은탄환이 아닙니다. 대신, 잘 설계된 보상 트랜잭션은 장애가 나도 시스템을 일관된 방향으로 수렴하게 만들어줍니다.
8단계를 다시 요약하면 다음과 같습니다.
- 상태 기계로 되돌릴 범위를 고정
- 보상 멱등성 확보
- 도메인 의미에 맞는 보상 동작 정의
- 타임아웃/재시도/서킷브레이커를 보상 관점에서 설계
- outbox/inbox로 저장과 발행 원자화
- 동시성/데드락을 사가 단위로 격리
- 관측성(로그/메트릭/트레이싱) 표준화
- 런북으로 재현 가능한 디버깅 절차 확립
이 8단계를 기준으로 기존 사가 흐름을 점검하면, “가끔 터지는 보상 장애”가 아니라 “예상 가능한 운영 이벤트”로 바뀌기 시작합니다.