Saga Pattern: 로컬 커밋과 역순 보상
Saga Pattern은 여러 서비스에 걸친 작업을 단일 분산 락 대신 로컬 트랜잭션과 보상 트랜잭션으로 나누어 처리하는 방식이다. 핵심은 각 단계를 커밋하되 실패하면 역순으로 보상하고, 그 과정에서 메시지 유실, 중복 처리, isolation 부재를 별도로 설계하는 데 있다.
Script Companion
오디오와 함께 스크립트 보기
- 01
Saga Pattern이 필요한 상황은 주문 서비스, 결제 서비스, 재고 서비스처럼 하나의 업무가 여러 서비스에 걸쳐 있을 때 드러난다. 결제는 성공했는데 재고 차감이 실패하면, 단일 DB 트랜잭션처럼 ROLLBACK으로 되돌릴 수 없다. 그래서 Saga는 전체 흐름을 작은 로컬 트랜잭션들의 체인으로 보고, 실패한 지점 이후에는 이미 성공한 단계를 역순으로 보상한다. 공식처럼 말하면 T1, T2, Tn으로 진행하고, 실패하면 Cn, C2, C1 방향으로 되돌리는 구조다.
- 02
프론트엔드 경험으로 비유하면 Redux-Saga나 Redux-Thunk에서 비동기 액션 체인을 관리하고, 실패 시 rollback 액션을 dispatch하는 흐름과 닮아 있다. 백엔드의 Saga Pattern은 이 사고를 서비스 수준으로 확장한 것이다. 다만 여기서 중요한 차이는 각 서비스가 자기 데이터베이스에 이미 커밋을 끝낸다는 점이다. 따라서 Saga의 보상은 과거 커밋을 취소하는 마법이 아니라, 환불이나 재고 복구처럼 새로운 역연산을 실행하는 방식이다.
- 03
Saga가 등장한 배경에는 2PC, 즉 2 Phase Commit의 한계가 있다. 2PC는 코디네이터가 참여자에게 준비 여부를 묻고, 모두 준비되면 확정하라고 지시하는 두 단계 방식이다. 하지만 Prepare 이후 코디네이터가 죽으면 참여한 DB들이 Lock을 보유한 채 대기할 수 있고, 분산 Lock 때문에 처리량도 낮아진다. 각 서비스가 독립 DB를 가져야 하는 MSA 원칙과도 충돌하며, SQS, Redis, 외부 API 같은 non-DB 리소스는 2PC에 참여하기 어렵다.
- 04
Saga를 구현하는 대표 방식은 Choreography와 Orchestration이다. Choreography Saga는 중앙 오케스트레이터 없이 각 서비스가 이벤트를 발행하고, 다음 서비스가 그 이벤트를 구독해 처리한다. 이벤트 기반이라 느슨하게 결합되고 서비스 수가 적을 때 적합하다. 반대로 Orchestration Saga는 Saga Orchestrator가 각 서비스에 커맨드를 보내고 응답을 받아 다음 단계를 결정한다. 흐름 파악이 쉬워 복잡한 비즈니스 로직에는 Orchestration이 더 잘 맞는다.
- 05
보상 트랜잭션은 Saga의 중심이지만, 아무 역연산이나 붙인다고 충분하지는 않다. 첫째, 멱등성이 필요하다. SQS는 At-Least-Once 특성상 중복 메시지가 올 수 있으므로, 같은 보상 트랜잭션이 여러 번 실행되어도 결과가 같아야 한다. 둘째, 재시도 가능해야 한다. 네트워크 오류나 외부 서비스 장애로 보상 자체가 실패할 수 있기 때문이다. 결제 환불 API 호출이 실패하거나 이미 처리된 주문이라는 에러가 돌아오는 상황까지 설계 범위에 들어간다.
- 06
Saga에서 특히 위험한 지점은 DB는 커밋됐는데 SQS나 EventBridge 발행이 실패하는 경우다. 이러면 다음 서비스가 이벤트를 받지 못해 Saga가 멈춘다. 그래서 Outbox Pattern을 함께 사용한다. 보상 트랜잭션 이벤트를 DB 트랜잭션 안에 원자적으로 기록해 메시지 유실을 막는 방식이다. 현재 스택 기준으로는 Aurora PostgreSQL 호환 클러스터에 outbox 테이블을 두고, NestJS의 @nestjs/schedule Cron Job으로 Relay를 5초 간격 실행하는 구성이 제시된다. 트래픽이 높으면 Debezium + MSK 기반 CDC 방식으로 전환할 수 있다.
- 07
Saga는 최종 일관성을 다루지만, ACID의 I, 즉 Isolation은 포기한다. 단일 DB 트랜잭션은 커밋 전 중간 상태를 외부에 숨기지만, Saga는 각 단계가 로컬 트랜잭션으로 커밋되기 때문에 중간 상태가 다른 트랜잭션에 노출될 수 있다. 이 문제에 대한 대응으로 Semantic Lock은 처리 중인 리소스에 명시적 상태 플래그를 둔다. Commutative Operations는 실행 순서가 결과에 영향을 주지 않게 만든다. Pessimistic Lock은 SELECT FOR UPDATE로 강제 직렬화하지만, 처리량 비용이 커서 결제 확인처럼 정합성이 절대적으로 중요한 단계에 제한해야 한다.
- 08
선택 기준은 단순히 Saga가 더 최신이라는 식이 아니다. 모든 서비스가 같은 DB를 쓰고 트랜잭션 처리량이 낮다면 2PC가 선택지가 될 수 있다. 반대로 서비스별 DB를 가지고 높은 처리량이 필요하다면 Saga와 Semantic Lock 조합이 맞다. Chris Richardson은 Saga의 isolation 부재 때문에 여러 Saga와 트랜잭션이 동시에 실행될 때 데이터 이상이 생길 수 있고, 개발자가 countermeasure를 직접 설계해야 한다고 강조한다. 그래서 실제 결정은 어느 단계에 어떤 대응 전략을 적용할지로 내려온다.
- 09
운영 중에는 세 가지 실패가 자주 문제가 된다. 보상 트랜잭션 자체가 실패하면 SQS Dead Letter Queue의 maxReceiveCount: 3 같은 설정, saga_state의 compensation_status, 수동 처리 대시보드나 Slack 알람이 필요하다. 이벤트 중복 처리에서는 OrderCreated 이벤트가 두 번 처리되어 이중 청구가 날 수 있으므로 messageId 기반 중복 방지나 SQS FIFO의 MessageDeduplicationId를 고려한다. 상태 추적이 안 되면 Saga Correlation ID, saga_state 테이블, CloudWatch Logs Insights로 어느 단계에서 멈췄는지 찾아야 한다.
- 10
정리하면 Saga Pattern은 분산 환경에서 단일 ROLLBACK을 기대하지 않고, 로컬 트랜잭션과 보상 트랜잭션으로 흐름을 설계하는 방식이다. Choreography는 이벤트 기반의 느슨한 결합에, Orchestration은 중앙 제어와 흐름 추적에 강점이 있다. Outbox Pattern은 DB 저장과 이벤트 발행 사이의 유실을 막고, 보상 트랜잭션은 멱등성과 재시도 가능성을 가져야 한다. 마지막 판단은 2PC와 Saga 중 하나를 고르는 일이 아니라, 서비스 수, 처리량, isolation 요구, 실패 시 비용을 기준으로 각 단계의 countermeasure를 정하는 일이다.
같은 레이어
L9에서 이어 듣기
- 설계 원칙을 운영 가능한 코드로 잇기 길이 미정
- Clean Architecture의 의존성 규칙 길이 미정
- DDD 기본기: 도메인 언어와 경계 설계 길이 미정
- Twelve-Factor App 운영 원칙 길이 미정
- CAP과 일관성으로 보는 분산 시스템 선택 길이 미정
- MSA 패턴, 분리의 이득과 운영 비용 길이 미정
- CQRS와 이벤트 소싱의 운영 경계 길이 미정
- TDD와 테스트 피라미드로 설계하는 테스트 전략 길이 미정
- 대규모 웹 크롤러의 큐, 정중함, 중복 제거 길이 미정
- API 계약으로 안전하게 서비스 경계를 진화시키기 길이 미정
- URL Shortener와 Rate Limiter로 보는 시스템 디자인 길이 미정