단일 데이터베이스에서는 ACID 트랜잭션 하나로 모든 것을 묶을 수 있다. 그러나 마이크로서비스 환경에서는 서비스마다 DB가 분리되어 있어 2PC(Two-Phase Commit)는 가용성 문제를 일으키고 실용적이지 않다. Saga Pattern은 하나의 긴 트랜잭션을 여러 개의 로컬 트랜잭션으로 분리하고, 실패 시 Compensation(보상 트랜잭션)으로 롤백하는 방식으로 이 문제를 해결한다.
Distributed Transaction은 서로 다른 서비스·DB에 걸쳐 원자성을 보장해야 하는 작업이다. 주문 생성 → 결제 → 재고 차감이 각각 다른 서비스에서 일어날 때, 결제는 성공했는데 재고 차감이 실패하면 데이터 불일치가 발생한다.
2PC(Two-Phase Commit): 분산 참여자 전원이 커밋에 동의해야만 커밋하는 프로토콜. 코디네이터 장애 시 블로킹 문제가 있다.
Saga를 구현하는 방식은 두 가지다.
| 방식 | 제어 주체 | 결합도 | 복잡도 |
|---|---|---|---|
| Choreography | 각 서비스 (이벤트 기반) | 낮음 | 흐름 추적 어려움 |
| Orchestration | 중앙 Orchestrator | 높음 | 흐름 명확 |
Choreography 방식에서는 각 서비스가 이벤트를 발행하고 다른 서비스가 구독해 다음 단계를 처리한다.
Order Service Payment Service Inventory Service
| | |
|-- OrderCreated ------>| |
| |-- PaymentCompleted -->|
| | |-- InventoryReserved
|<-----------------------------------------------|
Orchestration 방식에서는 Saga Orchestrator가 각 서비스를 순서대로 호출한다.
Saga Orchestrator
|
1. POST /payments --> Payment Service
|
2. POST /inventory --> Inventory Service
|
3. PATCH /orders --> Order Service (confirm)
Orchestrator: 사가의 단계별 실행 순서와 실패 처리를 중앙에서 관리하는 컴포넌트.
각 로컬 트랜잭션에 대응하는 Compensation Transaction을 미리 정의해야 한다. 재고 차감 단계에서 실패하면, 이미 완료된 결제를 취소하는 보상 트랜잭션이 역순으로 실행된다.
정상 흐름: 주문생성 → 결제 → 재고차감
실패 발생: ↑ 실패
보상 흐름: 주문취소 ← 결제취소 ←
TypeScript로 Orchestrator의 보상 로직을 단순화하면 다음과 같다.
async function runSaga(steps: SagaStep[]): Promise<void> {
const completed: SagaStep[] = [];
for (const step of steps) {
try {
await step.execute();
completed.push(step);
} catch (err) {
// 역순으로 보상 트랜잭션 실행
for (const done of completed.reverse()) {
await done.compensate();
}
throw err;
}
}
}
SagaStep: execute(실행)와 compensate(보상) 메서드를 가진 단위 작업 인터페이스.
Saga는 최종 일관성(Eventual Consistency)을 전제로 한다. 중간 상태가 외부에 노출될 수 있으므로 UI에서 "처리 중" 상태를 명시적으로 다뤄야 한다.
멱등성(Idempotency) 확보가 필수다. 네트워크 재시도 시 동일 요청이 중복 처리되지 않도록 각 단계에 idempotency key를 적용한다.
Choreography 방식은 서비스 수가 늘어날수록 이벤트 흐름 추적이 어려워진다. 이 경우 Distributed Tracing(예: Jaeger, Zipkin)을 함께 도입해 전체 Saga 흐름을 가시화하는 것이 좋다.