- Published on
MSA에서 Saga 보상트랜잭션 설계 7패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 서비스가 각자 DB를 소유하는 MSA에서는, 단일 DB 트랜잭션처럼 BEGIN/COMMIT/ROLLBACK으로 모든 변경을 한 번에 묶기 어렵습니다. 그래서 “성공하면 다음 단계로, 실패하면 이전 단계들을 되돌린다”는 Saga가 자주 선택됩니다. 문제는 여기서 끝이 아닙니다. Saga의 핵심 난제는 ‘정방향 트랜잭션’이 아니라, 보상(Compensation) 트랜잭션을 어떻게 설계하느냐에 있습니다.
보상은 단순히 “취소 API 하나 만들기”가 아닙니다. 이미 외부로 이벤트가 나갔고, 다른 서비스가 그 이벤트를 소비했고, 시간 차로 상태가 바뀌었을 수 있습니다. 또한 보상은 원자적 롤백이 아니라 새로운 비즈니스 트랜잭션이므로, 멱등성·동시성·재시도·관측 가능성까지 포함한 설계가 필요합니다.
아래에서는 MSA에서 Saga 보상트랜잭션을 설계할 때 자주 쓰는 7가지 패턴을 실전 관점으로 정리합니다.
전제: Saga 보상 설계에서 반드시 합의해야 할 것
보상 패턴을 적용하기 전에 팀/조직 차원에서 다음을 명확히 해두면 실패 비용이 크게 줄어듭니다.
- 보상의 목표: “완전한 원상복구”인지, “비즈니스적으로 수용 가능한 정정”인지
- 최종 일관성 범위: 몇 초/분까지 허용되는지, 사용자에게 어떤 UX로 노출할지
- 불가역 단계(Non-compensatable step): 환불 불가 결제, 외부 배송 시작 등 되돌릴 수 없는 단계가 있는지
- 관측 가능성: 각 단계의 상태 전이, 재시도 횟수, DLQ 적재 등을 추적할 수 있는지
또한 이벤트 기반 Saga라면, 메시지 중복/재전송이 기본 가정입니다. 운영 중에는 캐시/상태 꼬임처럼 “분명 처리했는데 다시 처리되는” 문제가 자주 나타납니다. 프런트/백엔드 어디든 캐시 일관성 이슈는 보상 설계와 맞물리므로, 비슷한 결의 문제를 다룬 글도 참고가 됩니다: Next.js 14 RSC 캐시 꼬임·stale 데이터 해결법
패턴 1) 역연산(Undo) 보상: 가장 직관적이지만 제약이 많은 방식
개념
정방향 작업이 A라면 보상은 A^-1(역연산)입니다.
- 재고 차감(Reserve/Decrease) → 재고 복구(Increase)
- 포인트 적립(Add) → 포인트 차감(Subtract)
장점
- 설계가 직관적이고, 도메인 규칙이 명확하면 구현이 쉽습니다.
단점/주의
- “정확히 원상복구”가 가능한 도메인에만 적합합니다.
- **시간에 따라 상태가 변하는 자원(재고, 좌석)**은 보상 시점에 이미 다른 주문이 들어왔을 수 있습니다.
- 보상 호출이 중복되면 더 큰 장애(재고가 과복구 등)가 생기므로 멱등성이 필수입니다.
코드 예시 (Spring Boot, 멱등 보상)
@RestController
@RequiredArgsConstructor
public class InventoryController {
private final InventoryService inventoryService;
@PostMapping("/inventory/reserve")
public void reserve(@RequestBody ReserveCmd cmd,
@RequestHeader("Idempotency-Key") String key) {
inventoryService.reserve(cmd, key);
}
@PostMapping("/inventory/compensate")
public void compensate(@RequestBody CompensateCmd cmd,
@RequestHeader("Idempotency-Key") String key) {
inventoryService.compensate(cmd, key);
}
}
@Service
@RequiredArgsConstructor
class InventoryService {
private final IdempotencyStore store;
private final InventoryRepository repo;
@Transactional
public void compensate(CompensateCmd cmd, String key) {
if (store.alreadyProcessed(key)) return;
repo.increase(cmd.sku(), cmd.qty());
store.markProcessed(key);
}
}
패턴 2) 보상 이벤트(Compensation Event) + 상태머신: “취소 요청”을 이벤트로 모델링
개념
보상을 동기 API로 즉시 처리하려 하지 않고, 보상 자체를 이벤트로 발행합니다.
OrderCancelledRequested→ 각 서비스가 자기 영역에서 취소/복구 수행- 결과는
InventoryCompensated,PaymentRefunded같은 이벤트로 다시 흘려보냄
장점
- 서비스 간 결합이 줄고, 장애/지연에 강합니다.
- 보상 진행 상황을 “상태 전이”로 추적하기 쉽습니다.
단점/주의
- 이벤트 순서/중복/지연을 전제로 설계해야 합니다.
- 상태머신이 복잡해질 수 있으므로, Saga 인스턴스 ID와 단계별 상태를 저장해야 합니다.
구현 팁
- Saga 오케스트레이터(또는 프로세스 매니저)가
PENDING → COMPENSATING → COMPENSATED/FAILED상태를 관리 - 각 소비자는 at-least-once를 가정하고 멱등 처리
패턴 3) 예약(Reserve) → 확정(Confirm) → 취소(Cancel) 2단계 커밋 유사 패턴
개념
정방향을 단일 “완료”로 만들지 않고, 일단 예약(hold) 후 모든 단계가 준비되면 확정합니다.
- Inventory:
reserve(재고 홀드) →confirm(차감 확정) /cancel(홀드 해제) - Payment:
authorize(승인 보류) →capture(매입) /void(승인 취소)
장점
- 보상이 단순해집니다. “되돌리기”가 아니라 “예약 해제”가 되기 때문입니다.
- 불가역 단계(배송 시작 등) 이전까지 위험을 낮춥니다.
단점/주의
- 예약 리소스가 누적되면 시스템이 잠길 수 있습니다(홀드 폭증).
- 따라서 TTL(만료), 스케줄러/워커로 만료 예약 자동 해제가 필요합니다.
운영 관점
예약이 많아지면 DB 락/데드락도 증가할 수 있습니다. 특히 재고/주문 테이블이 핫스팟이 되면 InnoDB 데드락이 자주 발생합니다. 데드락 분석과 인덱스 튜닝 관점은 다음 글이 도움됩니다: MySQL InnoDB 데드락 원인 추적과 인덱스 튜닝
패턴 4) 보상 대신 정정(Compensating Action) 패턴: 원상복구가 아니라 “회계적으로 맞추기”
개념
보상이 “완전히 이전 상태로 되돌림”이 아니라, 정정 레코드를 추가하여 최종 합계를 맞추는 방식입니다.
예:
- 원장(ledger) 기반 포인트/정산: 취소 시
-100정정 트랜잭션을 추가 - 결제: 환불 레코드를 별도로 적재하고 정산에서 상쇄
장점
- 감사/추적에 강합니다(왜/언제/누가 정정했는지 남음).
- 동시성에 더 안전합니다(상태를 덮어쓰기보다 append가 유리).
단점/주의
- 조회 모델이 복잡해집니다(합산/스냅샷 필요).
- 사용자에게 “취소됐는데 내역이 두 줄로 보임” 같은 UX 이슈가 생길 수 있어 표시 정책이 필요합니다.
코드 예시 (원장 모델, SQL)
-- 포인트 원장
CREATE TABLE point_ledger (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
saga_id VARCHAR(64) NOT NULL,
entry_type ENUM('EARN','SPEND','ADJUST') NOT NULL,
amount INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_saga_entry (saga_id, entry_type)
);
-- 보상(정정) 입력: saga_id 기준 멱등 처리
INSERT INTO point_ledger(user_id, saga_id, entry_type, amount)
VALUES(123, 'saga-abc', 'ADJUST', -100);
패턴 5) 재시도/지연/서킷브레이커를 보상 흐름에 내장: “보상은 더 자주 실패한다”
개념
보상은 정상 흐름이 아니라 비정상 상황에서 실행되므로, 의존 서비스가 이미 불안정할 가능성이 큽니다. 따라서 보상 실행 경로에 다음을 기본 탑재합니다.
- 지수 백오프 재시도
- 최대 재시도 초과 시 DLQ
- 서킷 브레이커로 연쇄 장애 방지
- 보상 워커(비동기)로 격리
장점
- 장애 전파를 줄이고, 운영자가 개입할 시간을 벌어줍니다.
단점/주의
- “언제까지 재시도할 것인가”를 정책으로 정의해야 합니다.
- 재시도 중 사용자에게 보여줄 상태(취소 처리 중 등)를 정해야 합니다.
구현 팁
- 보상 워커는 idempotency-key + saga-step 단위로 중복 실행 방지
- 실패 원인(4xx/5xx/timeout)에 따라 재시도 정책 분리
패턴 6) Outbox + 보상 로그(Compensation Log): 실행/발행의 원자성 확보
개념
보상 트랜잭션에서 흔한 사고는 다음입니다.
- 로컬 DB는 보상 처리 완료
- 그런데 이벤트 발행이 실패
- 다른 서비스는 보상 완료를 모름 → 상태 불일치
이를 막기 위해 Outbox 패턴으로 “DB 커밋과 이벤트 발행”을 분리하고, 발행은 재시도 가능한 비동기 퍼블리셔가 담당합니다. 보상도 동일하게 **보상 로그(어떤 step을 어떤 키로 실행했는지)**를 남깁니다.
장점
- 메시지 브로커 장애에도 데이터 정합성을 지키기 쉽습니다.
단점/주의
- outbox 테이블 청소/아카이빙, 발행 지연 모니터링이 필요합니다.
코드 예시 (Outbox 테이블 + 퍼블리셔 의사코드)
CREATE TABLE outbox (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
aggregate_type VARCHAR(50) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSON NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMP NULL,
KEY idx_unpublished (published_at, created_at)
);
# publisher worker pseudo
while True:
rows = db.query("SELECT * FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 100")
for row in rows:
try:
broker.publish(row.event_type, row.payload)
db.execute("UPDATE outbox SET published_at = NOW() WHERE id = %s", row.id)
except Exception:
# leave unpublished for retry
continue
패턴 7) 보상 불가 단계의 격리: “되돌릴 수 없는 것”을 Saga 뒤로 미루거나 분리
개념
현실적으로 모든 단계가 보상 가능하지 않습니다. 예를 들어:
- 외부 택배사에 송장 생성/픽업 요청
- 환불 불가 상품권 발급
- 규제/감사상 취소가 불가능한 처리
이때 전략은 두 가지입니다.
- 불가역 단계를 최대한 뒤로 미루기: 앞단에서 reserve/authorize로 안전장치를 두고, 마지막에만 irreversible 실행
- 격리된 프로세스로 분리: 별도 승인/검증을 거쳐 실행하고, 실패 시 “보상”이 아니라 “고객센터/운영 처리”로 전환
장점
- 보상 설계를 억지로 일반화하다가 더 큰 사고를 내는 것을 막습니다.
단점/주의
- 사용자 커뮤니케이션(취소 불가 안내)과 운영 프로세스가 필요합니다.
실전 체크리스트: 7패턴을 적용할 때 공통으로 보는 것
- 멱등성 키 설계:
sagaId + step + actionType조합을 권장 - 중복 이벤트 처리: 소비자에서 “이미 처리됨”을 빠르게 판정(유니크 키/처리 로그)
- 순서 역전 대비:
CANCEL이벤트가CONFIRM보다 먼저 도착할 수 있음 - 상태 모델: 단일 status로 끝내지 말고 단계별 상태/타임스탬프/오류코드 저장
- 타임아웃/만료 정책: 예약 TTL, 보상 재시도 기간, DLQ 보관 기간
- 관측 가능성: sagaId로 로그/트레이싱/메트릭을 한 번에 묶기
- 운영 도구: DLQ 재처리, 특정 saga 강제 보상/중단, 수동 정정 입력
추가로, 보상 과정에서 장애가 길어지면 커넥션 고갈/누수 경고 같은 2차 문제가 터지기 쉽습니다. 특히 보상 워커가 재시도 폭주를 일으키면 DB 커넥션 풀이 먼저 무너집니다. 이런 류의 장애 징후를 추적하는 방법은 다음 글이 실무적으로 유용합니다: Spring Boot 3.2 HikariCP 커넥션 누수 경고 추적법
마무리: “보상은 롤백이 아니라 제품 기능”
Saga 보상트랜잭션은 DB 롤백의 대체재가 아니라, 실패한 분산 비즈니스 프로세스를 안전하게 마무리하는 제품 기능에 가깝습니다. 그래서 설계의 중심은 코드가 아니라 정책(멱등성, 만료, 불가역 단계, 운영 개입)입니다.
정리하면:
- 원상복구가 가능한 단계는 **역연산(Undo)**로 단순하게
- 가능한 한 reserve/confirm/cancel로 보상 비용을 낮추고
- 원장 기반 도메인은 **정정(append-only)**로 감사 가능성을 확보하며
- Outbox/재시도/DLQ/상태머신으로 “실패가 일상인 경로”를 제품 수준으로 끌어올리는 것이 핵심입니다.
이 7패턴을 팀의 도메인 특성에 맞게 조합하면, ‘취소/실패’가 많은 환경에서도 일관성과 운영 가능성을 함께 잡을 수 있습니다.