- Published on
Saga 오케스트레이션 - 보상 트랜잭션 중복 실행 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
마이크로서비스에서 Saga는 분산 트랜잭션을 대체하는 현실적인 패턴이지만, 운영에서 가장 골치 아픈 문제 중 하나가 보상 트랜잭션(Compensation) 의 중복 실행입니다. 오케스트레이터가 재시도하거나, 메시지 브로커가 at-least-once 전달을 보장하거나, 네트워크 지연으로 타임아웃이 발생하면 같은 보상이 두 번 호출될 수 있습니다.
문제는 보상 자체가 “되돌리기”라서 안전할 것 같지만, 실제로는 환불 중복, 재고 두 번 증가, 예약 두 번 취소처럼 금전·정합성 이슈로 이어진다는 점입니다. 이 글에서는 Saga 오케스트레이션에서 보상 중복 실행을 막는 방법을 상태 모델링부터 저장소 제약, 메시징 패턴, 구현 코드까지 한 번에 정리합니다.
참고로, 이런 류의 문제는 결국 “중복”을 어떻게 다루느냐의 문제이기도 합니다. 데이터 파이프라인에서 조인 키 중복으로 행이 폭증하는 원인과 대응이 유사한 면이 있으니, 필요하면 pandas merge 후 행 수 폭증? 중복키 진단·해결도 함께 보면 사고방식에 도움이 됩니다.
왜 보상 트랜잭션이 중복 실행되는가
보상 중복은 보통 아래 조합에서 발생합니다.
1) at-least-once 메시징 + 소비자 재처리
Kafka, SQS, RabbitMQ 등에서 흔히 쓰는 전달 보장은 대개 at-least-once입니다. 즉, 동일 메시지가 중복 전달될 수 있고, 소비자가 처리 도중 죽으면 재처리됩니다.
2) 오케스트레이터의 재시도 정책
오케스트레이터가 HTTP 500, 타임아웃, 네트워크 오류를 만나면 보상 호출을 재시도합니다. 하지만 실제로는 첫 호출이 성공했는데 응답만 유실되었을 수 있습니다.
3) 타임아웃 기반 실패 판정의 함정
오케스트레이터가 T초 내 응답이 없으면 실패로 간주하고 보상을 시작했는데, 늦게 도착한 성공 응답이 뒤늦게 반영되면 성공과 보상이 교차합니다.
4) 중복된 커맨드/이벤트 발행
Outbox가 없거나, 트랜잭션 경계가 불명확하면 같은 상태 전이를 두 번 커밋하면서 “보상하라” 이벤트가 중복 발행될 수 있습니다.
핵심은 이겁니다.
- 중복 호출은 “버그”가 아니라 “분산 시스템의 기본값”이다
- 따라서 보상은 반드시 멱등(idempotent) 하거나, 최소한 중복 실행이 불가능한 구조여야 한다
목표: 보상을 “한 번만”이 아니라 “한 번처럼” 실행하기
분산 환경에서 “정확히 한 번(exactly-once)”을 끝까지 보장하기는 어렵습니다. 대신 실무적으로는 다음 목표를 둡니다.
- 동일 Saga 단계의 보상 요청이 여러 번 와도 결과는 동일해야 한다(멱등)
- 오케스트레이터는 중복 요청을 최대한 줄이고, 중복이 와도 식별 가능해야 한다
- 저장소 레벨에서 중복 실행을 구조적으로 차단한다(유니크 키, 상태 전이 제약)
이 3가지를 조합하면 “보상 중복 실행”을 사실상 제거할 수 있습니다.
설계 1: 오케스트레이터를 상태 머신으로 만들고 전이를 원자적으로 저장
가장 중요한 기반은 Saga 인스턴스 상태 머신입니다. 각 단계에 대해 STARTED, COMPLETED, COMPENSATING, COMPENSATED 같은 상태를 두고, 상태 전이를 DB에서 원자적으로 보장합니다.
예를 들어 payment 단계 보상을 실행하려면, 반드시 상태가 COMPLETED에서 COMPENSATING으로 전이되어야만 하게 만들고, 이 전이가 성공한 단 하나의 워커만 보상을 수행하도록 합니다.
예시 테이블 스키마
아래 예시는 RDB 기준입니다.
CREATE TABLE saga_step (
saga_id VARCHAR(64) NOT NULL,
step_name VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
last_updated_at TIMESTAMP NOT NULL,
version BIGINT NOT NULL,
PRIMARY KEY (saga_id, step_name)
);
-- 보상 실행 자체를 "한 번만" 기록하기 위한 별도 테이블
CREATE TABLE saga_compensation_log (
saga_id VARCHAR(64) NOT NULL,
step_name VARCHAR(64) NOT NULL,
compensation_id VARCHAR(128) NOT NULL,
executed_at TIMESTAMP NOT NULL,
PRIMARY KEY (saga_id, step_name),
UNIQUE (compensation_id)
);
포인트는 두 가지입니다.
saga_step은 상태 머신의 단일 진실 공급원(source of truth)saga_compensation_log는 보상 실행을 “기록”이 아니라 “락”처럼 사용
상태 전이 원자성: UPDATE ... WHERE status = ...
아래처럼 조건부 업데이트로 전이를 걸면, 동시에 여러 워커가 보상을 시도해도 단 하나만 성공합니다.
UPDATE saga_step
SET status = 'COMPENSATING', version = version + 1, last_updated_at = NOW()
WHERE saga_id = ?
AND step_name = ?
AND status = 'COMPLETED';
영향받은 행 수가 1이면 내가 락을 잡은 것이고, 0이면 이미 누군가 보상 중이거나 보상이 끝난 겁니다.
설계 2: 보상 호출에 멱등 키를 넣고, 수신 서비스에서 유니크 제약으로 차단
오케스트레이터가 아무리 잘해도 네트워크·재시도·중복 전달은 남습니다. 그래서 보상 대상 서비스(예: 결제 서비스) 가 최종 방어선을 가져야 합니다.
멱등 키 구성
보상 호출에는 아래를 포함시키는 것이 안전합니다.
sagaIdstepNamecompensationId(권장:sagaId+stepName+attempt또는 UUID)
중요한 점은 compensationId가 “재시도에도 동일”해야 한다는 것입니다. 즉, 오케스트레이터 재시도는 같은 compensationId로 보내야 합니다.
결제 서비스(보상 수신)에서의 처리 패턴
아래는 Spring Boot 스타일의 의사 코드입니다.
@Transactional
public RefundResult compensatePayment(RefundCommand cmd) {
// 1) 멱등 로그 선점: 이미 처리했으면 즉시 동일 결과 반환
// 유니크 키로 중복 삽입이 실패하도록 설계
try {
refundLogRepository.insert(
cmd.compensationId(),
cmd.sagaId(),
cmd.orderId(),
cmd.amount()
);
} catch (DuplicateKeyException e) {
return refundLogRepository.findResult(cmd.compensationId());
}
// 2) 실제 환불 로직
Payment payment = paymentRepository.findByOrderId(cmd.orderId());
if (payment.isRefunded()) {
// 비즈니스 상태도 멱등하게
refundLogRepository.markSuccess(cmd.compensationId(), "already_refunded");
return RefundResult.ok("already_refunded");
}
payment.refund(cmd.amount());
paymentRepository.save(payment);
refundLogRepository.markSuccess(cmd.compensationId(), "refunded");
return RefundResult.ok("refunded");
}
여기서 중복 차단의 본질은 try insert입니다.
- 첫 요청은 로그 삽입 성공 후 환불 수행
- 두 번째 요청은 유니크 충돌로 삽입 실패하고, 저장된 결과를 그대로 반환
이 패턴은 DB에 쓰기 부하를 만들 수 있으니, 쓰기 병목이 있는 환경이라면 인덱스·로그 구조를 점검해야 합니다. 대규모 쓰기 스파이크와 로그/버퍼풀 튜닝 관점은 MySQL 8.0 쓰기폭증 - REDO 로그·버퍼풀 튜닝도 참고할 만합니다.
설계 3: 오케스트레이터에서도 “보상 실행 로그”를 유니크 키로 잠근다
보상 대상 서비스에서 멱등을 보장하더라도, 오케스트레이터가 중복 보상을 마구 호출하면 비용과 지연이 커집니다. 따라서 오케스트레이터도 보상 실행을 “한 번만 트리거”하도록 방지선을 둡니다.
오케스트레이터 보상 실행 플로우
saga_step상태를COMPENSATING으로 전이 시도- 성공한 워커만
saga_compensation_log에INSERT INSERT성공한 경우에만 외부 서비스 보상 호출- 성공 시
COMPENSATED, 실패 시 재시도 정책에 따라 유지
function compensateStep(sagaId, stepName):
updated = update saga_step set status=COMPENSATING where status=COMPLETED
if updated == 0:
return // 다른 워커가 처리 중이거나 이미 완료
// 실행 로그로 2차 잠금
ok = insert into saga_compensation_log(sagaId, stepName, compensationId)
if not ok:
return
call compensation API with compensationId
if success:
update saga_step set status=COMPENSATED
else:
// 재시도 시에도 compensationId는 동일하게 유지
retry later
이 구조가 중요한 이유는, 상태 전이만으로는 “외부 호출 직전 장애” 같은 케이스에서 애매해질 수 있기 때문입니다. 예를 들어 COMPENSATING으로 바꾼 뒤 프로세스가 죽으면, 다음 워커는 상태가 COMPENSATING인 것을 보고 어떻게 할지 결정해야 합니다.
이때 saga_compensation_log에 “외부 호출을 시작했는지”가 남아 있으면, 복구 로직이 명확해집니다.
설계 4: 타임아웃과 늦은 응답으로 인한 교차 실행 막기
중복 보상 못지않게 위험한 것이 “성공과 보상이 교차”하는 상황입니다.
- 단계 A 호출이 실제로는 성공
- 오케스트레이터는 타임아웃으로 실패 판정
- 보상 시작
- 뒤늦게 성공 응답 도착
이를 막으려면 오케스트레이터가 단계 완료 이벤트를 처리할 때도 상태 머신 전이를 조건부로 해야 합니다.
예시:
STARTED에서만COMPLETED로 전이 가능- 이미
COMPENSATING이상이면 완료 응답은 무시하거나 별도 경로로 처리
UPDATE saga_step
SET status = 'COMPLETED', version = version + 1, last_updated_at = NOW()
WHERE saga_id = ?
AND step_name = ?
AND status = 'STARTED';
이렇게 하면 늦은 성공 응답이 와도 0 rows affected가 되어 “이미 실패로 간주하고 보상 중”임을 감지할 수 있습니다.
설계 5: Outbox 패턴으로 “중복 보상 이벤트 발행” 자체를 줄이기
오케스트레이터가 DB에 상태를 저장하고, 그 뒤 메시지를 발행하는 구조라면 두 작업이 분리되어 중복/유실이 발생할 수 있습니다. 이때는 Outbox 패턴이 사실상 필수입니다.
- Saga 상태 변경과 Outbox 레코드 삽입을 같은 DB 트랜잭션으로 묶기
- 퍼블리셔는 Outbox를 폴링하거나 CDC로 읽어 발행
- Outbox 레코드는
event_id유니크로 중복 발행을 방지
CREATE TABLE outbox (
event_id VARCHAR(128) PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP NULL
);
오케스트레이터는 보상 필요 상태로 바꾸는 트랜잭션 안에서 outbox에 “보상 커맨드”를 넣고, 발행은 별도 워커가 담당합니다.
구현 팁: 재시도는 “같은 멱등 키”로, 백오프는 지수로
보상 호출 실패 시 재시도는 필요하지만, 다음 원칙이 중요합니다.
- 재시도는 동일한
compensationId로 수행 - 백오프는 지수 백오프(예:
1s,2s,4s,8s) + 지터 - 최대 재시도 횟수 이후에는
DEAD_LETTER또는 운영 알람
또한 재시도 워커가 바쁘게 루프를 돌며 CPU를 태우지 않도록 “공정 대기”가 필요합니다. Go 기반 워커라면 select의 default 사용이 CPU 100%를 만들 수 있으니, 워커 구현 시 Go select 기본값이 CPU 100%? 공정 대기 패턴 같은 패턴을 참고하세요.
체크리스트: 보상 중복 실행 방지에 필요한 최소 요건
아래 중 1개만 적용하면 빈틈이 생기기 쉽고, 조합이 중요합니다.
- 오케스트레이터 단계 상태를 상태 머신으로 모델링
- 상태 전이는
UPDATE ... WHERE status = ...같은 조건부 원자 전이 - 보상 실행 로그(또는 락 테이블)를 두고
INSERT유니크로 선점 - 보상 대상 서비스도 멱등 키를 받아 유니크 제약으로 중복 차단
- 타임아웃/늦은 응답에 대비해 완료 처리도 조건부 전이
- Outbox로 상태 변경과 이벤트 발행을 원자적으로 묶기
마무리
Saga 오케스트레이션에서 보상 트랜잭션 중복 실행은 “드물게 생기는 예외 케이스”가 아니라, 재시도·중복 전달·타임아웃이 있는 한 반드시 언젠가 발생하는 정상 시나리오입니다.
해결책은 단일 기법이 아니라,
- 오케스트레이터의 상태 머신 + 원자 전이
- 보상 실행 로그 선점
- 보상 수신 측 멱등성(유니크 키)
- Outbox 기반 이벤트 발행
을 함께 적용해 중복이 와도 안전한 시스템으로 만드는 것입니다. 이렇게 설계하면 “보상을 한 번만 실행”하는 것을 넘어, 분산 시스템의 현실을 받아들이면서도 결과는 “한 번처럼” 안정적으로 만들 수 있습니다.