처음엔 당연하다고 생각했다. 실패하면 rollback 하면 되는 거 아닌가.
근데 서비스가 나뉘기 시작하면 그게 안 된다. 이전 글에서 주문 하나에 결제, 배송, 재고가 다 엮인다고 했는데, Kafka로 비동기 처리는 해결했다 치자. 근데 이런 상황은 어떻게 되는 걸까?
결제는 성공했는데, 재고 차감에서 실패하면?
모놀리스였으면 그냥 @Transactional 하나로 전체 rollback. 근데 MSA에선 결제 서비스 DB, 재고 서비스 DB가 따로 있다. 한쪽만 성공한 채로 끝나버린다.
이게 MSA의 핵심 난제다.

모놀리스 구조에서 트랜잭션은 단순하다.
주문 처리 시작
├── 결제 처리 ← 같은 DB
├── 재고 차감 ← 같은 DB
└── 배송 등록 ← 같은 DB
전부 성공 → COMMIT
하나라도 실패 → 전체 ROLLBACK주문 처리 시작
├── 결제 처리 ← 같은 DB
├── 재고 차감 ← 같은 DB
└── 배송 등록 ← 같은 DB
전부 성공 → COMMIT
하나라도 실패 → 전체 ROLLBACK같은 DB 안에서 일어나는 일이라 ACID 트랜잭션이 다 책임진다.
스프링에서 @Transactional 하나 붙이면 끝나는 이유가 이것이다.

MSA의 핵심 원칙 중 하나가 "서비스마다 DB를 독립적으로 가진다" 는 거다.
왜냐면 DB를 공유하면 결국 서비스가 DB에서 커플링되기 때문이다. 배포도 같이, 장애도 같이, 스케일도 같이 가야 한다. MSA를 쓰는 이유가 없어진다.
그래서 이렇게 나뉜다:
| 서비스 | DB |
|---|---|
| 주문 서비스 | 주문 DB |
| 결제 서비스 | 결제 DB |
| 재고 서비스 | 재고 DB |
| 배송 서비스 | 배송 DB |
이 상태에서 "결제 성공 + 재고 차감 실패" 가 나면?
그리고 이건 단순히 서버 내부 문제가 아니다.
사용자는 카드 결제 완료 알림까지 받았는데, 실제로는 주문이 실패 상태가 되어버린다.
사용자 입장에선 돈은 빠져나갔고, 주문은 없다. 이게 MSA에서 트랜잭션 처리를 제대로 안 했을 때 나타나는 비즈니스 문제다. 기술 문제가 아니라 고객 신뢰 문제가 된다.
@Transactional은 같은 DB 연결 안에서만 유효하다. 네트워크 너머의 다른 서비스 DB까지 rollback 해주는 마법은 없다.
이 문제를 해결하려는 첫 번째 접근이 2PC (Two-Phase Commit) 다.

이름 그대로 두 단계로 진행된다.
Phase 1: Prepare
Phase 2: Commit or Rollback
이론상 완벽해 보인다. 근데 실제 MSA 환경에서 쓰기가 굉장히 어렵다.
문제 1: 블로킹 Prepare 단계에서 모든 서비스가 응답할 때까지 락을 잡고 기다린다. 서비스가 수십 개면 그만큼 대기 시간이 늘어난다.
문제 2: 단일 장애점 Coordinator가 죽으면? Prepare까지만 된 상태로 모든 서비스가 락을 잡고 기다린다. 전체 시스템이 멈춰버린다.
문제 3: 네트워크 분단 Commit 신호를 보냈는데 일부 서비스에만 도달하면? 절반은 커밋, 절반은 미커밋. 오히려 더 복잡한 상태가 된다.
문제 4: 확장성 서비스가 5개일 때는 그럭저럭 버틸 수 있다. 근데 50개가 되면? Coordinator 하나가 50개 서비스의 응답을 기다리고, 50개 서비스가 동시에 락을 잡는다. 서비스가 늘수록 병목이 심해지는 구조다. MSA를 쓰는 이유 자체가 독립적 확장인데, 2PC는 그걸 정면으로 막는다.
MSA는 수십~수백 개의 서비스가 네트워크로 연결된 구조다. 네트워크 장애는 언제든 생긴다. 2PC는 이 환경에서 너무 취약하다.
2PC가 "전부 동시에 커밋하자" 라는 접근이었다면, Saga는 방향을 바꾼다.
각 서비스가 자기 트랜잭션을 독립적으로 처리하고, 실패하면 이미 성공한 것들을 직접 되돌린다.
전체를 하나의 트랜잭션으로 묶는 대신, 로컬 트랜잭션의 연속으로 나눈다.

주문 흐름 예시:
1. 주문 서비스 → 주문 생성 (로컬 트랜잭션)
2. 결제 서비스 → 결제 처리 (로컬 트랜잭션)
3. 재고 서비스 → 재고 차감 (로컬 트랜잭션) ← 실패1. 주문 서비스 → 주문 생성 (로컬 트랜잭션)
2. 결제 서비스 → 결제 처리 (로컬 트랜잭션)
3. 재고 서비스 → 재고 차감 (로컬 트랜잭션) ← 실패재고 차감에서 실패하면?
3. 재고 서비스 → 차감 실패
2. 결제 서비스 → 결제 취소 (보상 트랜잭션)
1. 주문 서비스 → 주문 취소 (보상 트랜잭션)3. 재고 서비스 → 차감 실패
2. 결제 서비스 → 결제 취소 (보상 트랜잭션)
1. 주문 서비스 → 주문 취소 (보상 트랜잭션)이미 성공한 것들을 거꾸로 되돌리는 보상 트랜잭션(Compensating Transaction) 이 핵심이다.
실제로 이런 상황이다:
쇼핑몰에서 10만원짜리 상품을 주문했다. 카드 결제까지 완료됐는데, 알고 보니 재고가 없었다. 이때 Saga는 결제 서비스에 "결제 취소" 보상 트랜잭션을 날린다. 사용자 카드로 10만원이 환불된다.
DB rollback이 아니다. 이미 커밋된 결제를 새로운 취소 트랜잭션으로 되돌리는 것이다. 그게 보상 트랜잭션의 의미다.
Saga를 구현하는 방식은 두 가지다.

서비스들이 이벤트로 서로 소통한다. 중앙에서 지휘하는 주체가 없다.
주문 서비스 → "주문생성됨" 이벤트 발행
결제 서비스 → 이벤트 수신 → 결제 처리 → "결제완료됨" 이벤트 발행
재고 서비스 → 이벤트 수신 → 재고 차감 → "재고차감됨" 이벤트 발행주문 서비스 → "주문생성됨" 이벤트 발행
결제 서비스 → 이벤트 수신 → 결제 처리 → "결제완료됨" 이벤트 발행
재고 서비스 → 이벤트 수신 → 재고 차감 → "재고차감됨" 이벤트 발행장점: 서비스 간 결합도가 낮다. 새 서비스 추가해도 기존 서비스 건드릴 필요 없음.
단점: 전체 흐름이 코드 한 곳에 없다. 어디서 문제가 생겼는지 추적하기 어렵다.
Kafka 같은 메시지 브로커랑 자연스럽게 잘 맞는다.

중앙 Orchestrator가 전체 흐름을 지휘한다.
Orchestrator → 결제 서비스에 "결제해" 명령
결제 서비스 → 결제 완료 후 결과 반환
Orchestrator → 재고 서비스에 "차감해" 명령
재고 서비스 → 차감 완료 후 결과 반환Orchestrator → 결제 서비스에 "결제해" 명령
결제 서비스 → 결제 완료 후 결과 반환
Orchestrator → 재고 서비스에 "차감해" 명령
재고 서비스 → 차감 완료 후 결과 반환장점: 전체 흐름이 한 곳에 모여 있어서 파악하기 쉽다. 디버깅도 상대적으로 쉬움.
단점: Orchestrator 자체가 점점 무거워진다. 잘못하면 모든 비즈니스 로직이 여기 몰린다.
| Choreography | Orchestration | |
|---|---|---|
| 지휘자 | 없음 (이벤트 기반) | 있음 (중앙 조율) |
| 결합도 | 낮음 | 중간 |
| 흐름 파악 | 어려움 | 쉬움 |
| 디버깅 | 어려움 | 쉬움 |
| 적합한 상황 | 서비스 수가 많고 독립성 중요 | 흐름이 복잡하고 추적이 중요 |
Saga의 핵심 전제가 있다. 일시적으로 불일치한 상태가 존재할 수 있다.
결제 서비스는 "완료"인데, 아직 재고 서비스가 처리 중인 순간이 있다.
이걸 Eventual Consistency (최종 일관성) 라고 한다.
모놀리스의 @Transactional은 트랜잭션 도중엔 중간 상태를 아무도 못 본다 (Isolation). 근데 Saga는 각 서비스가 독립적으로 커밋하니까 중간 상태가 외부에 노출될 수 있다.
대신 "결국엔 일관성이 맞춰진다"는 걸 보장한다.
그럼 사용자는 중간 상태를 보게 되는 걸까? 맞다. 의도적으로 허용하는 거다.
주문 직후 잠깐 동안은 "결제 완료 / 배송 준비 중" 같은 중간 상태가 사용자 화면에 보일 수 있다. 재고 서비스가 아직 처리 중인 순간이기 때문이다.
쿠팡에서 주문하면 "주문 접수됨 → 결제 확인 중 → 배송 준비 중" 이런 상태가 순서대로 바뀌는 걸 본 적 있을 거다. 그게 Eventual Consistency를 UI에서 자연스럽게 표현하는 방식이다. 한 번에 확정된 상태를 보여주는 게 아니라, 처리가 완료되는 순서대로 상태를 업데이트한다.
이게 성능이랑 트레이드오프다. 강한 일관성을 원하면 모든 서비스가 동기적으로 기다려야 한다. Eventual Consistency를 받아들이면 각 서비스가 독립적으로, 빠르게 처리할 수 있다.
MSA에서 Saga를 쓴다는 건 이 트레이드오프를 의식적으로 선택하는 거다.
| 모놀리스 | MSA + 2PC | MSA + Saga | |
|---|---|---|---|
| 트랜잭션 방식 | 단일 DB 트랜잭션 | 분산 트랜잭션 (동기) | 로컬 트랜잭션 + 보상 (비동기) |
| 실패 처리 | DB rollback | 전체 rollback | 보상 트랜잭션 |
| 일관성 | 강한 일관성 | 강한 일관성 | Eventual Consistency |
| 성능 | 보통 | 낮음 (블로킹) | 높음 |
처음엔 rollback 하면 되는 거 아닌가 싶었다. 근데 서비스가 나뉘면 rollback 자체가 "어떻게?" 라는 질문이 된다.
Saga는 그 질문에 대한 현실적인 답이다. 완벽한 일관성 대신, 보상이라는 개념으로 정합성을 맞춰간다.
Kafka 글에서 메시지 브로커가 왜 필요한지 봤다면, 이번엔 그 위에서 데이터 정합성을 어떻게 지키는지를 봤다.
근데 여기서 자연스럽게 다음 질문이 생긴다.
Saga에서 각 서비스는 로컬 트랜잭션 완료 후 이벤트를 발행한다.근데 DB commit은 성공했는데 Kafka publish가 실패하면?
이벤트가 안 나갔으니 다음 서비스가 반응을 못 한다. Saga 흐름 자체가 끊겨버린다. 이게 Saga를 실제로 구현할 때 맞닥뜨리는 가장 현실적인 문제다.
참고 자료