- Published on
MSA Saga 보상 트랜잭션 실패 재처리 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 서비스가 각자 DB를 갖는 MSA에서 분산 트랜잭션을 ACID로 묶기 어렵기 때문에, 현실적인 대안으로 Saga(사가) 패턴을 선택하는 경우가 많습니다. 문제는 “정방향(Forward) 단계”보다 “보상(Compensation) 단계”가 더 까다롭다는 점입니다. 정방향은 사용자 요청으로 시작되지만, 보상은 장애·타임아웃·부분 실패 같은 비정상 상황에서만 발동되고, 그 순간 시스템은 이미 불안정합니다.
이 글은 보상 트랜잭션이 실패했을 때 재처리를 어떻게 설계해야 하는지를 중심으로, 데이터 모델·재시도 알고리즘·멱등성·운영 전략을 한 번에 정리합니다.
> 참고: Kubernetes/EKS 환경에서 네트워크 egress가 간헐적으로 끊기면 보상 호출이 연쇄 실패할 수 있습니다. 원인 추적은 EKS Pod egress 간헐 끊김 - SNAT·NAT GW 추적법을 함께 보세요.
1) 보상 실패 재처리가 어려운 이유
1.1 보상은 “되돌리기”가 아니라 “새로운 상태 전이”
보상은 단순히 이전 값을 복구하는 게 아니라, 업무적으로 의미 있는 반대 작업입니다.
- 결제 승인(Charge) 보상 = 결제 취소(Refund)
- 재고 차감(Reserve/Decrement) 보상 = 재고 복원(Release/Increment)
- 쿠폰 사용(MarkUsed) 보상 = 쿠폰 사용 취소(MarkUnused)
이 작업들은 외부 시스템(결제 PG, 배송사 등)과 얽히고, “정확히 이전 상태”가 아닌 “취소 상태”로의 전이가 됩니다. 즉, 보상도 정방향과 동일하게 도메인 규칙·멱등성·감사 로그가 필요합니다.
1.2 보상 실패는 더 자주 “부분 성공”을 만든다
보상은 여러 단계가 역순으로 실행됩니다.
- A → B → C 수행 후 C에서 실패
- 보상은 B’ → A’ 수행
- 그런데 B’ 성공, A’ 실패 같은 부분 성공이 흔합니다.
이때 재처리는 “A’만 다시” 해야 하는데, 설계가 없으면 전체 보상을 다시 실행하거나(중복 부작용), 상태를 잃어버립니다.
1.3 장애 원인이 일시적/영구적 섞여 있다
- 일시적(Transient): 네트워크 타임아웃, 429/5xx, DB lock 경합
- 영구적(Permanent): 권한 문제(403), 잘못된 입력, 이미 취소됨 등
예를 들어 AWS 자원 권한 문제로 403이 나면 무한 재시도는 비용만 태웁니다. EKS에서 IAM/IRSA 설정 문제로 403이 발생하는 케이스는 EKS Pod에서 AWS Secrets Manager 403 해결 가이드처럼 “재시도”가 아니라 “설정 수정”이 정답입니다.
2) 목표: 보상 실패 재처리의 설계 원칙
보상 재처리 설계는 결국 아래 4가지를 만족해야 합니다.
- 정확히 한 번처럼 보이기(Effectively-once): 최소 한 번(at-least-once) 실행되더라도 중복 부작용이 없어야 함
- 부분 성공을 안전하게 이어가기: 실패한 보상 단계만 재개 가능해야 함
- 무한 재시도 방지: 영구 실패는 빠르게 격리하고 사람이 개입할 수 있어야 함
- 관측 가능성(Observability): 어떤 사가 인스턴스가 어디서 막혔는지 추적 가능해야 함
이를 위해 실무에서 가장 많이 쓰는 조합은:
- Saga Log(사가 상태 저장) + Outbox(메시지 발행 신뢰성)
- 보상 요청/응답 멱등성 키
- 재시도 정책(지수 백오프 + 지터) + DLQ/격리 큐
- 리컨실리에이션(주기적 정합성 점검) + 수동 복구 도구
3) 데이터 모델: “재처리 가능한 상태”를 저장하라
보상 실패를 재처리하려면 “어디까지 했는지”가 DB에 남아야 합니다. 메모리/캐시에만 있으면 프로세스 재시작 시 유실됩니다.
3.1 Saga 인스턴스 테이블 예시
아래는 오케스트레이션 기반(중앙 사가 오케스트레이터)에서 흔한 형태입니다.
-- PostgreSQL 예시
create table saga_instance (
saga_id uuid primary key,
saga_type text not null,
status text not null, -- RUNNING, COMPENSATING, COMPLETED, FAILED
current_step int not null,
compensation_step int not null default 0,
version int not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
last_error_code text,
last_error_msg text,
next_retry_at timestamptz,
retry_count int not null default 0
);
create table saga_step_log (
saga_id uuid not null,
step_no int not null,
direction text not null, -- FORWARD / COMPENSATION
status text not null, -- STARTED / SUCCEEDED / FAILED
idempotency_key text not null,
request_json jsonb,
response_json jsonb,
error_code text,
error_msg text,
created_at timestamptz not null default now(),
primary key (saga_id, step_no, direction)
);
핵심은 다음입니다.
compensation_step: 보상 단계가 어디까지 완료됐는지saga_step_log: 각 단계별 요청/응답/에러를 남겨 재처리 판단 근거로 사용idempotency_key: 동일 단계 재호출 시 중복 부작용을 방지하는 키
3.2 Outbox 테이블(이벤트 발행 신뢰성)
보상은 종종 “다른 서비스에 보상 명령을 발행”하는 형태입니다. 이때 DB 업데이트와 메시지 발행이 분리되면 유실/중복이 발생합니다. Outbox로 해결합니다.
create table outbox (
id bigserial primary key,
aggregate_id uuid not null,
event_type text not null,
payload jsonb not null,
status text not null default 'NEW', -- NEW, PUBLISHED, FAILED
created_at timestamptz not null default now(),
published_at timestamptz
);
create index on outbox(status, created_at);
오케스트레이터는 트랜잭션 안에서:
saga_step_log에 “보상 명령 생성” 기록outbox에 메시지 적재
를 함께 커밋하고, 별도 퍼블리셔가 outbox를 읽어 브로커(Kafka/SQS 등)에 발행합니다.
4) 멱등성: 보상 재처리의 생명줄
재처리는 결국 “같은 보상 요청을 다시 보낼 수 있음”을 전제로 합니다. 따라서 보상 API/커맨드는 반드시 멱등해야 합니다.
4.1 멱등성 키 전략
- 키 구성:
sagaId + stepNo + direction(또는businessTxId + action) - 저장 위치: 보상 대상 서비스의 DB에 “처리 이력” 테이블로 저장
create table idempotency_record (
idempotency_key text primary key,
status text not null, -- SUCCEEDED, FAILED
response_json jsonb,
created_at timestamptz not null default now()
);
보상 API 처리 흐름:
- 요청의
Idempotency-Key로 레코드 조회 - 있으면 저장된 응답을 그대로 반환(또는 성공 처리)
- 없으면 실제 보상 로직 수행 후 결과 저장
4.2 “이미 보상됨”을 성공으로 간주하기
보상은 종종 “이미 취소된 결제”처럼 중복 호출이 정상입니다.
RefundAlreadyProcessed같은 에러는 409로 내보내기보단, 200 OK + 상태=ALREADY_DONE 형태로 흡수하면 오케스트레이터가 단순해집니다.
5) 재시도 정책: 무작정 재시도는 독이다
5.1 분류: 재시도 가능한 실패 vs 불가능한 실패
오케스트레이터는 실패를 분류해야 합니다.
- 재시도 가능: timeout, connection reset, 429, 503, deadlock
- 재시도 불가: 400(검증 실패), 403(권한), 404(리소스 없음이 의미상 영구), 도메인 규칙 위반
이 분류를 위해 보상 대상 서비스는 에러를 기계가 읽을 수 있는 코드로 반환해야 합니다.
{
"errorCode": "PERMISSION_DENIED",
"message": "IRSA role is missing secretsmanager:GetSecretValue",
"retryable": false
}
5.2 지수 백오프 + 지터, 그리고 상한
- 1m, 2m, 4m, 8m…
- 랜덤 지터(±20%)로 동시 재시도 폭주 방지
- 최대 재시도 횟수/최대 지연 상한(예: 1시간) 설정
5.3 DLQ/격리 큐로 “사람이 볼 수 있게”
일정 횟수 이상 실패하거나 retryable=false면:
- 사가 상태를
FAILED로 전환 - 실패 원인/마지막 요청/응답을
saga_step_log에 저장 - DLQ로 보내거나 “운영 테이블”에 적재
이렇게 해야 장애가 “조용히 무한 루프”로 숨지 않습니다.
6) 재처리 알고리즘: 보상은 ‘역순 + 체크포인트’
6.1 오케스트레이터 의사코드
아래는 DB에 체크포인트를 저장하며 보상을 재개하는 전형적 흐름입니다.
from datetime import datetime, timedelta
import random
MAX_RETRY = 10
def next_backoff_seconds(retry_count: int) -> int:
base = min(60 * (2 ** retry_count), 3600) # up to 1 hour
jitter = base * random.uniform(-0.2, 0.2)
return int(base + jitter)
def compensate(saga_id: str):
saga = load_saga_for_update(saga_id) # SELECT ... FOR UPDATE
if saga.status not in ("COMPENSATING", "FAILED"):
return
step = saga.compensation_step
while step > 0:
log = load_step_log(saga_id, step, direction="COMPENSATION")
# 이미 성공한 단계는 건너뛴다(재처리 핵심)
if log and log.status == "SUCCEEDED":
step -= 1
continue
try:
idempotency_key = f"{saga_id}:{step}:COMP"
resp = call_compensation_api(step, saga_id, idempotency_key)
save_step_success(saga_id, step, resp)
saga.compensation_step = step - 1
saga.retry_count = 0
update_saga(saga)
step -= 1
except CompensationError as e:
save_step_failure(saga_id, step, e)
if not e.retryable or saga.retry_count >= MAX_RETRY:
saga.status = "FAILED"
update_saga(saga)
publish_to_dlq(saga_id, step, e)
return
saga.retry_count += 1
delay = next_backoff_seconds(saga.retry_count)
saga.next_retry_at = datetime.utcnow() + timedelta(seconds=delay)
update_saga(saga)
return
saga.status = "COMPLETED"
update_saga(saga)
포인트는 명확합니다.
- 단계별 성공 로그가 있으면 skip: 전체 보상을 다시 하지 않음
- 실패 시
next_retry_at에 스케줄링: 워커가 폴링/스케줄러가 재기동 - 영구 실패는
FAILED로 격리
6.2 동시성 제어
재처리 워커가 여러 대면 같은 사가를 동시에 잡을 수 있습니다.
SELECT ... FOR UPDATE SKIP LOCKED로 워커 간 분산 락version컬럼으로 낙관적 락
-- 재처리 대상 사가를 워커가 안전하게 가져오기
select saga_id
from saga_instance
where status in ('COMPENSATING')
and (next_retry_at is null or next_retry_at <= now())
order by updated_at
for update skip locked
limit 50;
7) 메시징 기반 보상에서의 재처리: 커맨드 중복과 순서
오케스트레이터가 보상 커맨드를 Kafka/SQS로 발행하는 경우 재처리는 더 단순해질 수 있지만, 다음을 반드시 고려해야 합니다.
- 중복 소비: 브로커는 at-least-once가 일반적 → 소비자 멱등 필수
- 순서 보장: 동일 sagaId에 대한 커맨드는 파티션 키를 sagaId로 고정
- 가시성 타임아웃/재전달: SQS는 visibility timeout 설정이 보상 처리 시간보다 짧으면 중복 폭발
8) 운영 관점: “재처리 버튼”보다 중요한 것들
8.1 리컨실리에이션(정합성 점검) 잡
보상은 100% 자동화가 어렵습니다. 따라서 주기적으로 “정방향 상태와 외부 시스템 상태”를 대조하는 잡이 필요합니다.
- 결제는 취소됐는데 주문은 결제완료 상태
- 재고는 복원됐는데 주문은 취소 실패
리컨실리에이션 결과는 별도 테이블/대시보드로 노출하고, 수동 조치 큐로 넣습니다.
8.2 관측: 사가 단위 트레이싱/로그
sagaId를 모든 로그/메트릭/트레이스의 공통 키로 사용- 단계별 latency, 실패율, retry 횟수 분포를 메트릭화
네트워크 이슈가 섞이면 “보상 API가 느려서 타임아웃 → 재시도 폭주”가 됩니다. 이때 egress/NAT 병목은 애플리케이션 로그만으로는 안 보이므로, 앞서 언급한 네트워크 추적 글(EKS Pod egress 간헐 끊김 - SNAT·NAT GW 추적법) 같은 인프라 관측이 중요합니다.
8.3 재처리 도구(운영자 UI/CLI)
실무에서는 “DLQ에 쌓인 사가를 어떻게 처리할 것인가”가 승부처입니다.
필수 기능:
- 사가 상세 조회(단계 로그, 마지막 에러, 외부 요청/응답)
- 특정 단계부터 재시도(예: A’만 재시도)
- 강제 성공 처리(비즈니스 승인 하에)
- 보상 대신 대체 조치(수동 환불 완료 체크 등)
9) 흔한 안티패턴과 대안
9.1 안티패턴: 보상 실패 시 전체 롤백을 다시 시도
- 이미 성공한 보상까지 다시 호출 → 중복 부작용
- 대안: 단계별 체크포인트 + 멱등성
9.2 안티패턴: “무조건 재시도”
- 403/400 같은 영구 실패도 계속 재시도
- 대안: retryable 분류 + DLQ 격리
9.3 안티패턴: 보상 요청을 동기 HTTP 체인으로만 구성
- 한 서비스 장애가 전체 보상 체인을 막음
- 대안: outbox + 메시징으로 비동기화, 또는 최소한 타임아웃/서킷브레이커 적용
10) 체크리스트: 보상 실패 재처리 설계에 필요한 것
- 보상 API/커맨드가 멱등인가? (Idempotency-Key + 처리 이력 저장)
- 사가 상태/단계 로그가 DB에 영속화되는가?
- outbox로 메시지 발행 신뢰성을 확보했는가?
- 재시도 가능/불가능 실패를 구분하는가?
- 지수 백오프+지터, 최대 재시도, DLQ가 있는가?
-
SELECT FOR UPDATE SKIP LOCKED등으로 워커 동시성 제어가 되는가? - 리컨실리에이션 잡과 수동 조치 도구가 있는가?
- sagaId 기반 트레이싱/메트릭이 있는가?
결론
MSA에서 Saga 보상 트랜잭션의 실패는 “예외 케이스”가 아니라 항상 발생하는 운영 이벤트입니다. 따라서 재처리는 단순한 retry 버튼이 아니라, 멱등성(중복 무해화), 체크포인트(부분 성공 이어가기), 정책(재시도/격리), **관측/운영 도구(사람이 개입 가능한 구조)**까지 포함한 시스템 설계 문제로 접근해야 합니다.
이 네 축을 갖추면, 보상 실패가 나더라도 서비스는 멈추지 않고(자동 재처리), 무한 루프에 빠지지 않으며(DLQ), 최종적으로는 사람이 안전하게 마무리할 수 있는(운영 도구) 상태가 됩니다.