포스트

Saga 패턴의 불편한 진실: 정말 필요한가?

Saga 패턴의 불편한 진실: 정말 필요한가?

“Saga 패턴을 구현하는 데 한 달이 걸렸고, 디버깅하는 데 석 달이 걸렸으며, 운영하는 데는 평생이 걸릴 것 같다.”
— 익명의 백엔드 엔지니어, 2025


들어가며: 왜 우리는 Saga에 대해 이야기해야 하는가

당신이 Kafka를 운영해봤고, 메시지 큐 아키텍처를 이해하고 있으며, 분산 시스템의 복잡성을 경험했다면, 아마도 이미 다음과 같은 의문을 품고 있을 것이다:

“왜 우리는 마이크로서비스를 도입하면서 Saga 패턴이라는 또 다른 복잡성을 추가해야 하는가?”

이것은 매우 정당한 질문이다. 사실 이 질문을 하지 않는다면, 당신은 아직 Saga 패턴의 진짜 복잡성을 경험하지 못한 것일 수도 있다.

이 문서는 Saga 패턴을 옹호하기 위한 것이 아니다. 오히려 Saga 패턴이 언제 필요한지, 언제 과도한지, 그리고 우리가 정말로 지불해야 하는 비용이 무엇인지를 냉정하게 분석한다. Kafka를 운영해본 당신이라면, 메시지 순서 보장의 어려움, 정확히 한 번 전달의 불가능함, 그리고 분산 시스템의 불확실성을 이미 알고 있을 것이다. 이 모든 것 위에 Saga를 올린다는 것이 무엇을 의미하는지 함께 살펴보자.

1부: Saga 패턴이 해결하려는 문제

1.1 분산 트랜잭션의 딜레마

모놀리스에서 우리는 ACID 트랜잭션을 당연하게 여겼다. 주문 생성 시나리오를 보자:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
BEGIN TRANSACTION;
  -- 1. 재고 확인 및 예약
  UPDATE inventory 
  SET reserved = reserved + 1 
  WHERE product_id = 123 AND available >= 1;
  
  -- 2. 주문 생성
  INSERT INTO orders (user_id, product_id, amount) 
  VALUES (456, 123, 29.99);
  
  -- 3. 결제 기록
  INSERT INTO payments (order_id, amount, status) 
  VALUES (LAST_INSERT_ID(), 29.99, 'COMPLETED');
  
  -- 4. 포인트 차감
  UPDATE user_points 
  SET balance = balance - 2999 
  WHERE user_id = 456;
  
COMMIT; -- 모든 것이 성공하거나, 모든 것이 롤백

이것은 아름답다. 원자적이고, 일관적이며, 격리되어 있고, 지속적이다. 무엇이 잘못될 수 있겠는가?

그런데 마이크로서비스로 가면:

1
2
3
4
5
6
7
8
9
10
┌─────────────────┐
│ Order Service   │ → POST /inventory/reserve
└─────────────────┘ → POST /payment/charge
                    → POST /user/deductPoints
                    → POST /notification/send

4개의 HTTP 호출
4개의 독립적인 데이터베이스
4개의 실패 가능 지점
4^4 = 256가지 가능한 상태 조합

문제는 명백하다: 3번째 호출이 실패하면? 1, 2번은 이미 성공했는데? 어떻게 되돌릴 것인가?

1.2 “그냥 롤백하면 되잖아요?”

초보 아키텍트의 대답이다. 하지만 현실은 그렇게 간단하지 않다.

시나리오: 결제는 성공했는데 주문 생성이 실패

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
1. Inventory Service: 재고 예약 → ✓ 성공
2. Payment Service: 신용카드 청구 → ✓ 성공
3. Order Service: 주문 생성 → ✗ 실패 (DB 다운)
4. 이제 어떻게 하나?

옵션 A: Payment Service에 환불 요청
  POST /payment/refund
  문제: 이것도 실패할 수 있음
  문제: Payment Service가 다운되었다면?
  문제: 네트워크 타임아웃으로 응답 못 받으면?
  
옵션 B: 재시도
  주문 생성 다시 시도
  문제: 멱등성 보장해야 함
  문제: 몇 번까지 재시도?
  문제: 재시도 중 재고가 다시 팔렸다면?
  
옵션 C: 수동 개입
  티켓 생성, 관리자 호출
  문제: 확장 불가능
  문제: 고객 경험 최악

이것이 Saga 패턴이 존재하는 이유다. 하지만 이것이 Saga가 정당화되는 이유는 아니다.

1.3 정말로 분산 트랜잭션이 필요한가?

여기서 멈춰서 근본적인 질문을 해야 한다:

“우리는 정말로 서비스를 분리해야 하는가?”

대부분의 경우, 답은 “아니오”다.

1
2
3
4
5
6
7
8
9
10
11
12
13
주문 생성 프로세스가 정말로 4개 서비스에 걸쳐야 하나?

실제 비즈니스 요구사항:
- 사용자가 주문 버튼을 누른다
- 재고가 있으면 주문이 생성된다
- 결제가 처리된다
- 고객이 확인을 받는다

이것은 하나의 트랜잭션이다.
하나의 비즈니스 연산이다.
하나의 bounded context다.

그렇다면 왜 4개로 쪼갰는가?

Chris Richardson이 “마이크로서비스 패턴”에서 말했듯이:

“마이크로서비스는 분산 시스템의 복잡성을 받아들이고 그 대가로 독립적 배포 가능성을 얻는 거래다. 하지만 대부분의 팀은 독립적 배포가 필요하지 않다.”

Saga 패턴이 필요하다는 것은, 당신이 이미 잘못된 서비스 경계를 그었다는 강력한 신호일 수 있다.

2부: Saga 패턴의 두 얼굴

2.1 Orchestration Saga: 중앙 집중식 접근

개념: 하나의 Orchestrator가 전체 프로세스를 제어한다.

1
2
3
4
5
6
7
8
9
10
11
12
                    ┌──────────────────┐
                    │ Order Saga       │
                    │ Orchestrator     │
                    └──────────────────┘
                            │
        ┌───────────────────┼───────────────────┐
        │                   │                   │
        ▼                   ▼                   ▼
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│ Inventory   │     │ Payment     │     │ Notification│
│ Service     │     │ Service     │     │ Service     │
└─────────────┘     └─────────────┘     └─────────────┘

코드 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
public class OrderSagaOrchestrator {
    
    @Autowired
    private InventoryServiceClient inventoryService;
    
    @Autowired
    private PaymentServiceClient paymentService;
    
    @Autowired
    private OrderServiceClient orderService;
    
    @Autowired
    private SagaStateRepository sagaStateRepo;
    
    public SagaResult executeOrderSaga(CreateOrderRequest request) {
        // 1. Saga 상태 초기화
        SagaState saga = new SagaState(UUID.randomUUID());
        saga.setState(SagaStateEnum.STARTED);
        sagaStateRepo.save(saga);
        
        try {
            // 2. Step 1: Reserve Inventory
            saga.setState(SagaStateEnum.RESERVING_INVENTORY);
            sagaStateRepo.save(saga);
            
            ReservationResponse reservation = 
                inventoryService.reserve(request.getProductId(), request.getQuantity());
            
            saga.setReservationId(reservation.getId());
            saga.setState(SagaStateEnum.INVENTORY_RESERVED);
            sagaStateRepo.save(saga);
            
            // 3. Step 2: Process Payment
            saga.setState(SagaStateEnum.PROCESSING_PAYMENT);
            sagaStateRepo.save(saga);
            
            PaymentResponse payment = 
                paymentService.charge(request.getPaymentInfo(), request.getAmount());
            
            saga.setPaymentId(payment.getId());
            saga.setState(SagaStateEnum.PAYMENT_PROCESSED);
            sagaStateRepo.save(saga);
            
            // 4. Step 3: Create Order
            saga.setState(SagaStateEnum.CREATING_ORDER);
            sagaStateRepo.save(saga);
            
            OrderResponse order = 
                orderService.create(reservation.getId(), payment.getId());
            
            saga.setOrderId(order.getId());
            saga.setState(SagaStateEnum.COMPLETED);
            sagaStateRepo.save(saga);
            
            return SagaResult.success(order);
            
        } catch (InventoryServiceException e) {
            // Step 1에서 실패 - 보상 불필요
            saga.setState(SagaStateEnum.FAILED);
            sagaStateRepo.save(saga);
            return SagaResult.failure("Inventory unavailable");
            
        } catch (PaymentServiceException e) {
            // Step 2에서 실패 - 재고 예약 취소 필요
            saga.setState(SagaStateEnum.COMPENSATING);
            sagaStateRepo.save(saga);
            
            try {
                inventoryService.cancelReservation(saga.getReservationId());
                saga.setState(SagaStateEnum.COMPENSATED);
            } catch (Exception compensationError) {
                // 보상도 실패! 이제 어떻게?
                saga.setState(SagaStateEnum.COMPENSATION_FAILED);
                // 수동 개입 필요
                alertOps(saga, compensationError);
            }
            sagaStateRepo.save(saga);
            return SagaResult.failure("Payment failed");
            
        } catch (OrderServiceException e) {
            // Step 3에서 실패 - 결제 환불 + 재고 취소
            saga.setState(SagaStateEnum.COMPENSATING);
            sagaStateRepo.save(saga);
            
            List<CompensationError> errors = new ArrayList<>();
            
            // 역순으로 보상
            try {
                paymentService.refund(saga.getPaymentId());
            } catch (Exception e1) {
                errors.add(new CompensationError("Payment", e1));
            }
            
            try {
                inventoryService.cancelReservation(saga.getReservationId());
            } catch (Exception e2) {
                errors.add(new CompensationError("Inventory", e2));
            }
            
            if (errors.isEmpty()) {
                saga.setState(SagaStateEnum.COMPENSATED);
            } else {
                saga.setState(SagaStateEnum.COMPENSATION_FAILED);
                saga.setCompensationErrors(errors);
                alertOps(saga, errors);
            }
            sagaStateRepo.save(saga);
            return SagaResult.failure("Order creation failed");
        }
    }
}

이것은 “간단한” 버전이다. 실제로는 여기에 추가로 필요한 것들:

  1. 재시도 로직: 네트워크 타임아웃 처리
  2. 멱등성 보장: 같은 요청 중복 처리 방지
  3. 타임아웃 처리: 각 단계마다 타임아웃
  4. 상태 복구: Orchestrator가 재시작되면?
  5. 동시성 제어: 같은 주문 동시 처리 방지
  6. 모니터링: 각 단계의 성공/실패율 추적
  7. 알림: 실패 시 담당자 호출

실제 코드는 500-1000줄이 넘어간다.

2.2 Choreography Saga: 이벤트 기반 접근

개념: 중앙 Orchestrator 없이 각 서비스가 이벤트를 발행하고 구독한다.

1
2
3
4
5
6
7
8
9
10
11
Order Service → OrderCreated 이벤트 발행
                      ↓
           ┌──────────┴──────────┐
           ↓                     ↓
    Inventory Service      Payment Service
    재고 예약                 결제 처리
           ↓                     ↓
    InventoryReserved    PaymentProcessed
    이벤트 발행           이벤트 발행
           ↓                     ↓
    각자 다음 액션         각자 다음 액션

Kafka 경험자인 당신은 이미 알고 있을 것이다:

이것은 디버깅 지옥으로 가는 지름길이다.

문제 1: 추적 불가능성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
주문이 생성되지 않았다. 왜?

로그를 확인해보니:
- Order Service: OrderCreated 이벤트 발행 ✓
- Kafka: 이벤트 수신 ✓
- Inventory Service: 구독... 그런데 로그가 없음
  
왜 Inventory Service가 이벤트를 받지 못했나?
- Consumer 다운?
- Partition rebalancing?
- Deserialization 실패?
- Consumer lag?
- 컨슈머 그룹 설정 오류?

이제 3개 서비스의 로그를 시간순으로 정렬하며
분산 추적을 시도한다...

문제 2: 순서 보장의 악몽

Kafka를 운영해봤다면 알 것이다. 파티션 내에서만 순서가 보장된다.

1
2
3
4
5
6
7
8
9
10
11
같은 주문에 대한 이벤트:
- OrderCreated (partition 0)
- PaymentProcessed (partition 2)
- InventoryReserved (partition 1)

도착 순서:
1. PaymentProcessed (어? 주문이 없는데?)
2. InventoryReserved (결제 정보가 없는데?)
3. OrderCreated (이미 늦음)

결과: 데이터 불일치

문제 3: 보상 이벤트의 혼돈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
정상 플로우:
OrderCreated → InventoryReserved → PaymentProcessed → Completed

실패 플로우:
OrderCreated → InventoryReserved → PaymentFailed → ???

보상 이벤트:
- PaymentFailed → CancelInventoryReservation 이벤트
- 하지만 이 이벤트도 실패할 수 있음
- 재시도? 몇 번?
- Dead Letter Queue로?
- 그러면 수동 처리?

이벤트 체인이 길어질수록:
- 디버깅 난이도 지수 증가
- 실패 시나리오 조합 폭발
- 운영 복잡도 통제 불능

Kafka 운영 경험자로서 당신은 알 것이다:

  • Consumer lag 모니터링
  • Rebalancing 이슈
  • Exactly-once semantics의 한계
  • Schema evolution 문제

이 모든 것 위에 Saga를 올린다는 것은 복잡성의 제곱이다.

2.3 현실: 둘 다 어렵다

Orchestration의 문제:

  • 중앙 집중적 복잡성
  • Orchestrator가 SPOF (Single Point of Failure)
  • 모든 서비스 호출이 동기적
  • Orchestrator가 모든 서비스를 알아야 함

Choreography의 문제:

  • 분산된 복잡성
  • 전체 흐름 추적 불가능
  • 이벤트 순서와 타이밍 문제
  • 순환 의존성 위험

공통 문제:

  • 보상 트랜잭션의 복잡성
  • 부분 실패 상태 처리
  • 멱등성 보장
  • 상태 관리와 복구
  • 테스트의 어려움

3부: 구현과 운영의 현실

3.1 구현 비용

간단한 Saga 하나를 구현하는 데 필요한 것:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1주차: 설계
- Saga 단계 정의
- 보상 트랜잭션 설계
- 상태 머신 설계
- 에러 시나리오 분석

2-3주차: 구현
- Saga Orchestrator 코드
- 상태 저장소 (DB)
- 각 단계별 로직
- 보상 로직
- 재시도 메커니즘
- 타임아웃 처리

4주차: 테스트
- 정상 플로우 테스트
- 각 단계별 실패 테스트
- 보상 실패 테스트
- 동시성 테스트
- 성능 테스트

5-6주차: 통합
- 실제 서비스들과 통합
- End-to-end 테스트
- 버그 수정
- 더 많은 버그 수정

최소: 6주
현실: 2-3개월

그리고 이것은 하나의 Saga에 대한 것이다.

일반적인 e-커머스 앱:

  • 주문 생성 Saga
  • 주문 취소 Saga
  • 환불 Saga
  • 배송 Saga
  • 재고 조정 Saga

5-10개의 Saga = 1-2명의 개발자가 1년

3.2 운영 비용

Saga 패턴을 프로덕션에서 운영한다는 것:

1
2
3
4
5
6
7
8
9
10
모니터링해야 할 것:
- Saga 성공률
- 각 단계별 성공률
- 보상 실행 빈도
- 평균 실행 시간
- 타임아웃 발생률
- 보상 실패율 (!!!)
- 수동 개입 필요 케이스
- 상태별 Saga 분포
- Dead letter queue 크기

장애 대응 시나리오:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
새벽 3시, 온콜 엔지니어의 전화:
"Order Saga 성공률이 60%로 떨어졌습니다"

디버깅 과정:
1. 어느 단계에서 실패?
   - Inventory? Payment? Order?
   
2. 원인은?
   - 서비스 다운?
   - 네트워크 이슈?
   - DB 과부하?
   - 버그?
   
3. 보상은 정상 작동?
   - 40%가 보상 실행 중
   - 보상 성공률은?
   - 실패한 보상은 어떻게?
   
4. 비즈니스 영향은?
   - 고객이 결제는 됐는데 주문이 없다고 함
   - 재고는 예약됐는데 주문이 취소됨
   - 중복 주문 발생
   
5. 수동 처리:
   - 실패한 Saga 100개 수동 확인
   - 데이터 정합성 검증
   - 고객 보상 처리

한 달에 한 번은 발생한다.

3.3 기술 부채의 복리

Saga 패턴은 기술 부채를 복리로 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
초기: "깔끔한 Saga 구현"
- 3개 단계
- 명확한 보상 로직
- 100줄 코드

6개월 후: "비즈니스 요구사항 변경"
- 새 단계 추가 (포인트 적립)
- 조건부 로직 추가
- 300줄 코드

1년 후: "또 다른 변경"
- A/B 테스트 지원
- 프로모션 로직
- 외부 API 통합
- 700줄 코드
- 누구도 전체를 이해 못 함

2년 후: "레거시 Saga"
- 15개 단계
- 중첩된 조건문
- 1500줄 코드
- 원작자는 이미 퇴사
- 수정 두려움
- "잘 작동하면 건드리지 마"

4부: 대안들 - Saga 없이 살 수 있는가?

4.1 대안 1: 서비스 경계 재설계

가장 좋은 해결책: 문제를 없애라

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Before: 4개 서비스에 걸친 주문
┌───────────┐   ┌───────────┐   ┌───────────┐   ┌───────────┐
│ Order     │ → │ Inventory │ → │ Payment   │ → │Notification│
│ Service   │   │ Service   │   │ Service   │   │ Service   │
└───────────┘   └───────────┘   └───────────┘   └───────────┘
     Saga 필요!

After: 하나의 Order Service (Modular Monolith)
┌─────────────────────────────────────────────┐
│ Order Service                               │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  │
│  │ Order    │  │Inventory │  │ Payment  │  │
│  │ Module   │  │ Module   │  │ Module   │  │
│  └──────────┘  └──────────┘  └──────────┘  │
│         단일 DB 트랜잭션                     │
└─────────────────────────────────────────────┘
     Saga 불필요!

질문해야 할 것:

  • Order, Inventory, Payment가 정말 독립적으로 배포되어야 하나?
  • 각각 다른 팀이 소유해야 하나?
  • 다른 확장 요구사항이 있나?

대부분의 경우 아니오다.

4.2 대안 2: 최종 일관성 수용

모든 것이 즉시 일관될 필요는 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
주문 생성:
1. 주문 즉시 생성 (낙관적 잠금)
2. 백그라운드 작업으로 검증
3. 문제 있으면 주문 취소

이점:
- 빠른 사용자 응답
- Saga 불필요
- 단순한 에러 처리

트레이드오프:
- 일부 주문이 취소될 수 있음
- "죄송합니다, 재고 부족으로 주문이 취소되었습니다"
- 고객이 받아들일 수 있는가?

많은 경우: 예

Amazon이 하는 방식:

  • 재고 확인 없이 주문 받음
  • 나중에 재고 확인
  • 없으면 그때 알림
  • 고객은 이미 익숙함

4.3 대안 3: 동기 트랜잭션 with 2PC

2-Phase Commit (2PC):

“Saga보다 나쁘다”고들 하지만, 정말 그런가?

1
2
3
4
5
6
7
8
9
10
11
12
2PC의 문제:
- Coordinator가 SPOF
- 블로킹 프로토콜
- 성능 저하

Saga의 문제:
- 복잡한 상태 관리
- 보상 트랜잭션
- 디버깅 지옥

솔직히: 둘 다 나쁘다
차이: 2PC는 적어도 이해하기 쉽다

대안: XA 트랜잭션 (JTA)

Java 생태계에서는 이미 있다:

1
2
3
4
5
6
7
public void createOrder(OrderRequest request) {
    // XA 트랜잭션 자동 관리
    inventoryRepository.reserve(...);
    orderRepository.save(...);
    paymentRepository.charge(...);
    // 모두 커밋되거나 모두 롤백
}

왜 안 쓰나?

  • “마이크로서비스와 철학이 안 맞는다”
  • 하지만 Saga는 맞는가?

4.4 대안 4: 이벤트 소싱

개념: 상태 대신 이벤트를 저장한다.

1
2
3
4
5
6
7
8
9
10
11
전통적 방식:
Order { status: "COMPLETED" }

이벤트 소싱:
Events:
1. OrderCreated
2. InventoryReserved
3. PaymentProcessed
4. OrderCompleted

현재 상태 = 모든 이벤트의 재생

장점:

  • 완벽한 감사 로그
  • 시간 여행 가능
  • 복잡한 비즈니스 로직 표현 용이

단점:

  • 엄청난 복잡도
  • 이벤트 스키마 진화 문제
  • 쿼리 어려움
  • Saga보다 더 어려움

Kafka 경험자의 시각: 이벤트 소싱 = Kafka를 DB로 쓰는 것 가능하지만, 정말 필요한가?

4.5 대안 5: 프로세스 매니저 패턴

Saga의 더 구조화된 버전:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Process Manager:
- 명시적 상태 머신
- 각 상태의 전환 조건
- 타임아웃 처리 내장
- 보상 로직 분리

Saga와의 차이:
- 더 명확한 상태 관리
- 더 쉬운 테스트
- 더 나은 가시성

트레이드오프:
- 더 많은 코드
- 더 많은 테이블
- 여전히 복잡함

솔직히: Saga의 다른 이름

5부: 언제 Saga가 정당화되는가?

5.1 정당화되는 경우 (드물다)

시나리오 1: 진짜 독립적인 서비스들

1
2
3
4
5
6
7
8
9
10
11
12
13
14
예: 여행 예약 시스템

1. 항공편 예약 (외부 API)
2. 호텔 예약 (외부 API)
3. 렌터카 예약 (외부 API)
4. 결제 (우리 시스템)

특징:
- 정말로 독립적인 시스템들
- 각각 다른 회사
- 서비스 경계 변경 불가
- 2PC 불가능 (외부 시스템)

이 경우: Saga가 유일한 옵션

시나리오 2: 장기 실행 프로세스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
예: 보험 청구 처리

1. 청구 접수
2. 의료 기록 확인 (수동, 며칠 소요)
3. 사기 검사 (자동, 수시간)
4. 승인 절차 (수동, 며칠 소요)
5. 지급

특징:
- 프로세스가 며칠-몇 주 소요
- 사람의 개입 필요
- 동기 트랜잭션 불가능

이 경우: Saga 필요

시나리오 3: 비즈니스가 요구하는 독립성

1
2
3
4
5
예: 멀티 테넌트 플랫폼

고객사 A의 주문이 고객사 B에 영향 주면 안 됨
→ 정말로 격리된 서비스 필요
→ Saga 필요할 수 있음

5.2 정당화되지 않는 경우 (대부분)

안티패턴 1: “마이크로서비스니까 Saga”

1
2
3
4
5
6
7
Architect: "우리 마이크로서비스 하니까 Saga 필요해요"
Engineer: "왜요?"
Architect: "마이크로서비스 책에 나와요"
Engineer: "하지만 우리 서비스들 깊이 결합되어 있는데요?"
Architect: "그래서 Saga로 관리해야죠"
Engineer: "...그럼 왜 분리했죠?"
Architect: "...확장성?"

이것은 순환 논리다.

안티패턴 2: “나중을 위한 준비”

1
2
3
4
5
6
7
현재: 단일 팀, 모놀리스, 잘 작동
계획: "언젠가 100명 팀이 되면 서비스 분리할 거야"
행동: 지금 Saga 준비

문제: YAGNI (You Aren't Gonna Need It)
현실: 대부분 100명 안 됨
      되더라도 요구사항 완전히 달라짐

안티패턴 3: “이력서 주도 개발”

1
2
3
4
5
6
7
진짜 이유:
"Saga 패턴 경험을 이력서에 쓰고 싶어요"
"컨퍼런스에서 발표하고 싶어요"
"최신 기술 트렌드 따라가고 싶어요"

비즈니스 이유:
없음

5.3 체크리스트: Saga가 필요한가?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
□ 서비스들이 정말 독립적으로 배포되어야 하는가?
  (다른 팀, 다른 언어, 다른 릴리스 주기)
  
□ 서비스 경계를 변경할 수 없는가?
  (외부 시스템, 레거시, 정치적 이유)
  
□ 프로세스가 장기 실행되는가?
  (수 시간 이상, 사람 개입 필요)
  
□ 2PC가 정말 불가능한가?
  (외부 시스템, 성능 요구사항)
  
□ 최종 일관성으로 해결 안 되는가?
  (비즈니스가 즉시 일관성 요구)
  
□ Saga 구현/운영 비용을 감당할 수 있는가?
  (전담 팀, 충분한 시간, 예산)

5개 이상 체크: Saga 고려 가능
3개 이하: 다른 방법 찾기

6부: 실제 구현 시 고려사항

6.1 멱등성: 가장 중요하고 가장 어려운 것

문제:

1
2
3
4
5
6
7
Payment Service에 결제 요청:
POST /payment/charge

네트워크 타임아웃 발생
- 결제가 됐나? 안 됐나?
- 재시도해야 하나?
- 재시도하면 중복 청구?

해결: 멱등성 키

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public PaymentResponse charge(
    @RequestHeader("Idempotency-Key") String idempotencyKey,
    @RequestBody PaymentRequest request
) {
    // 1. 같은 키로 이미 처리했는지 확인
    PaymentResponse cached = cache.get(idempotencyKey);
    if (cached != null) {
        return cached; // 같은 응답 반환
    }
    
    // 2. 결제 처리
    PaymentResponse response = processPayment(request);
    
    // 3. 결과 캐싱 (24시간)
    cache.put(idempotencyKey, response, 24.hours());
    
    return response;
}

문제들:

1
2
3
4
5
6
7
- 멱등성 키를 누가 생성?
- 얼마나 오래 저장?
- 저장소가 다운되면?
- 캐시 무효화는?
- 부분 실패는?

모든 서비스, 모든 엔드포인트에 필요

6.2 타임아웃과 재시도

타임아웃 설정의 딜레마:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
너무 짧으면:
- 정상 요청도 실패 처리
- 불필요한 재시도
- 중복 처리

너무 길면:
- Saga 전체가 느려짐
- 리소스 낭비
- 사용자 경험 악화

적절한 값은?
- 서비스마다 다름
- 부하에 따라 다름
- 끊임없는 튜닝 필요

재시도 전략:

1
2
3
4
5
6
7
8
    maxAttempts = 3,
    backoff = @Backoff(delay = 1000, multiplier = 2),
    include = { TimeoutException.class, ConnectionException.class },
    exclude = { BusinessException.class }
)
public PaymentResponse charge(PaymentRequest request) {
    // ...
}

문제:

  • 어떤 예외를 재시도?
  • 몇 번 재시도?
  • 재시도 간격은?
  • Exponential backoff?
  • Circuit breaker도 필요?

당신이 Kafka를 운영했다면: 재시도 지옥을 알 것이다.

  • Consumer retry
  • Producer retry
  • Dead letter queue
  • Manual replay

Saga는 이것의 N배

6.3 분산 추적

Saga 없이도 어렵다. Saga와 함께는 필수.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
OpenTelemetry로 추적:

Trace ID: abc-123
├─ Span 1: Saga Start
├─ Span 2: Reserve Inventory
│  ├─ Span 2.1: DB Query
│  └─ Span 2.2: Cache Update
├─ Span 3: Process Payment
│  ├─ Span 3.1: External API Call
│  ├─ Span 3.2: DB Transaction
│  └─ Span 3.3: Event Publish
├─ Span 4: Create Order
└─ Span 5: Saga Complete

실패 시:
├─ Span 6: Compensation Start
├─ Span 7: Cancel Inventory
└─ Span 8: Refund Payment

비용:

  • 추적 데이터 저장소
  • 시각화 도구
  • 학습 곡선
  • 성능 오버헤드

하지만 없으면: 디버깅 불가능

7부: 비용-편익 분석

7.1 실제 비용 계산

Saga 하나 구현:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
개발:
- 설계: 40시간
- 구현: 80시간
- 테스트: 60시간
- 통합: 40시간
총: 220시간 = 1.5 개월 (1명)

인프라:
- 상태 저장소: $200/월
- 분산 추적: $500/월
- 모니터링: $300/월
- 추가 서비스 인스턴스: $400/월
총: $1,400/월 = $16,800/년

운영:
- 온콜 시간: 주 4시간
- 사건 대응: 월 2회 × 3시간
- 문서화/교육: 분기 20시간
총: 연 280시간

유지보수:
- 버그 수정: 연 40시간
- 기능 변경: 연 80시간
총: 연 120시간

총 비용 (1년):
- 초기 개발: $22,000 (시급 $100 기준)
- 인프라: $16,800
- 운영: $28,000
- 유지보수: $12,000
총: $78,800

이것은 하나의 Saga다.

일반적 앱: 5-10개 Saga 총 비용: $400,000 - $800,000/년

7.2 대안 비용

모듈러 모놀리스:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
개발:
- 모듈 경계 설계: 20시간
- 구현: 40시간
- 테스트: 30시간
총: 90시간 = 2주

인프라:
- 추가 비용: $0 (기존 인프라)

운영:
- 온콜: 거의 없음
- DB 트랜잭션이 처리
- 실패 시나리오 단순

총 비용: $9,000

절감: $69,800 (Saga 대비)

ROI:

1
2
3
4
5
6
7
8
9
Saga를 정당화하려면:
$69,800 이상의 가치 창출

가치:
- 독립 배포? 실제 필요한가?
- 확장성? 모놀리스도 확장 가능
- 팀 자율성? 7명 팀에?

대부분 경우: ROI 음수

7.3 숨겨진 비용

팀 생산성:

1
2
3
4
5
6
7
8
9
10
11
12
Saga 전:
- 새 기능: 2주
- 버그 수정: 1일
- 이해도: 높음

Saga 후:
- 새 기능: 4주 (Saga 수정 포함)
- 버그 수정: 3일 (분산 디버깅)
- 이해도: 낮음 (복잡도)

생산성 감소: 40-50%
연간 기회 비용: 측정 불가

인적 비용:

1
2
3
4
5
6
7
8
9
10
개발자 좌절감:
"왜 이렇게 복잡해야 하죠?"
"간단한 기능인데 왜 일주일이나?"
"디버깅을 어떻게 해야 하나요?"

결과:
- 이직률 증가
- 재채용 비용
- 노하우 손실
- 팀 사기 저하

8부: 아키텍트들은 왜 Saga를 밀어붙이는가?

8.1 이론과 현실의 괴리

아키텍트의 세계:

1
2
3
4
- 화이트보드에 그리는 깔끔한 박스들
- 컨퍼런스의 성공 사례들
- 책의 베스트 프랙티스들
- "넷플릭스가 이렇게 합니다"

엔지니어의 세계:

1
2
3
4
- 새벽 3시의 프로덕션 장애
- 디버깅할 수 없는 분산 시스템
- 보상 트랜잭션 실패
- "이게 왜 안 되는 거야?"

문제: 아키텍트는 운영하지 않는다.

8.2 인센티브의 불일치

아키텍트의 성공 지표:

1
2
3
4
5
✓ 최신 기술 도입
✓ 아키텍처 문서 작성
✓ 컨퍼런스 발표
✓ "혁신적" 솔루션
✓ 이력서에 좋은 키워드

엔지니어의 성공 지표:

1
2
3
4
5
✓ 장애 없는 배포
✓ 빠른 기능 출시
✓ 쉬운 디버깅
✓ 안정적인 시스템
✓ 밤에 잘 자기

이 둘은 종종 충돌한다.

8.3 “다른 회사가 한다”의 함정

Netflix의 Saga:

1
2
3
4
5
- 엔지니어: 500+
- 전담 플랫폼 팀: 100+
- 예산: 수십억 달러
- 트래픽: 일 수억 요청
- 필요성: 실제로 있음

당신 회사의 Saga:

1
2
3
4
5
- 엔지니어: 10명
- 플랫폼 팀: 없음
- 예산: 제한적
- 트래픽: 일 10만 요청
- 필요성: ?

아키텍트의 논리: “Netflix가 하니까 우리도”

올바른 질문: “Netflix의 어떤 문제를 우리도 가지고 있나?”

8.4 복잡성 중독

현상: 일부 아키텍트는 복잡성을 좋아한다.

1
2
3
4
5
심플한 솔루션:
"너무 단순해요. 확장성이..."

복잡한 솔루션:
"멋지네요! 이벤트 소싱에 CQRS에 Saga까지!"

이유:

  • 지적 도전
  • 기술적 자부심
  • 차별화 욕구
  • “엔지니어링” 느낌

문제: 복잡성의 대가는 다른 사람이 치른다.

8.5 책임 회피

시나리오:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Year 1:
Architect: "Saga로 가야 합니다"
Team: "복잡한데요?"
Architect: "장기적으로 좋습니다"
결정: Saga 도입

Year 2:
운영 지옥
Team: "Saga 때문에 개발이 너무 느려요"
Architect: "구현을 잘못한 거죠"
       또는 "팀이 준비가 안 됐네요"
       또는 이미 퇴사

책임: 팀에게

9부: 대안적 접근 - 실용주의

9.1 단계적 진화

Phase 1: 모듈러 모놀리스

1
2
3
4
5
6
7
8
9
10
시작: 하나의 애플리케이션
내부: 명확한 모듈 경계
장점:
- 단순성
- ACID 트랜잭션
- 쉬운 디버깅
- 빠른 개발

이것으로 시작하라.
대부분은 여기서 멈춰도 된다.

Phase 2: 정말 필요하면 추출

1
2
3
4
5
6
7
8
조건:
□ 실제 병목 존재
□ 명확한 bounded context
□ 독립 배포 필요성 입증
□ 팀 준비됨
□ 비용 정당화됨

하나씩, 신중하게 추출

Phase 3: 필요시 Saga

1
2
3
4
5
6
7
8
조건:
□ 서비스 경계 변경 불가
□ 최종 일관성 불가
□ 2PC 불가능
□ 팀에 expertise
□ 운영 체계 갖춤

이때야 비로소 Saga

9.2 하이브리드 접근

모든 것이 이분법이 아니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Core Domain: 모놀리스 유지
- 주문, 결제, 재고
- 단일 트랜잭션
- ACID 보장

Supporting: 서비스 분리
- 추천 시스템
- 분석
- 알림

Generic: 외부 서비스
- 이메일 (SendGrid)
- SMS (Twilio)
- 결제 게이트웨이

Saga가 필요한 부분:
- Core ↔ 외부 서비스 통합
- 장기 실행 프로세스
- 정말 필요한 곳만

9.3 프로세스 단순화

Saga가 필요해 보이면, 먼저 프로세스를 재검토하라.

Before:

1
2
3
주문 생성 → 재고 확인 → 재고 예약 → 
결제 처리 → 주문 확정 → 알림 발송
(6단계, Saga 필요)

After:

1
2
3
4
5
6
7
8
9
주문 생성 (낙관적)
  ↓
백그라운드:
- 재고 확인 (비동기)
- 결제 처리 (비동기)
- 문제 시 주문 취소
- 알림 발송

(단순한 비동기, Saga 불필요)

트레이드오프:

  • 일부 주문 취소될 수 있음
  • 하지만 대부분 성공
  • 훨씬 단순함
  • 고객도 이해함

Amazon, Uber, 대부분의 성공한 서비스들이 이렇게 한다.

9.4 기술 선택의 원칙

Boring Technology 선택:

1
2
3
4
5
6
7
8
9
10
11
Dan McKinley의 원칙:
"혁신 토큰을 현명하게 쓰라"

각 회사는 제한된 혁신 토큰을 가짐
- 새로운 언어: 1 토큰
- 새로운 DB: 1 토큰
- 새로운 아키텍처: 2 토큰
- Saga 패턴: 3 토큰

대부분 회사: 5 토큰
Saga에 3개 쓸 여유 없음

실용주의:

1
2
3
4
5
6
7
8
1. 문제를 정의하라
2. 가장 단순한 솔루션부터
3. 측정하라
4. 필요시 복잡도 추가
5. 다시 측정

Saga가 답이면:
문제를 잘못 정의했을 가능성

결론: Saga에 대한 균형잡힌 시각

Saga 패턴은 도구다

Saga는 나쁘지 않다. 하지만 대부분의 경우 과도하다.

1
2
3
4
5
6
7
Saga가 적절한 경우:
- 외부 시스템 통합
- 장기 실행 프로세스
- 정말 독립적인 서비스들
- 충분한 리소스

→ 전체의 5% 미만

대부분의 경우 필요 없다

1
2
3
4
5
6
7
8
9
10
당신의 상황:
- 10명 팀
- 단일 제품
- 빠른 출시 필요
- 제한된 예산

→ 모듈러 모놀리스
→ 명확한 경계
→ 단순한 배포
→ Saga 없이

아키텍트에게

실제로 구현하고 운영할 사람의 의견을 들어라.

1
2
3
4
5
6
7
다음 번 Saga 제안 전:
□ 새벽 3시 온콜 자원하기
□ 1달간 직접 구현해보기
□ 6개월간 운영해보기

그래도 Saga를 원하면:
진짜 필요한 것이다

엔지니어에게

“아니오”라고 말할 용기를 가져라.

1
2
3
4
5
6
7
8
9
10
11
12
아키텍트: "Saga가 필요합니다"
당신: "왜요?"
아키텍트: "마이크로서비스니까요"
당신: "우리 서비스 경계가 맞나요?"
아키텍트: "..."
당신: "더 단순한 방법은?"
아키텍트: "하지만 베스트 프랙티스..."
당신: "우리 컨텍스트에서 베스트인가요?"

데이터로 논쟁하라.
대안을 제시하라.
비용을 계산하라.

최종 조언

Saga 패턴을 도입하기 전에 물어라:

  1. 정말 분산 트랜잭션이 필요한가?
    • 서비스 경계를 재설계할 수 없나?
    • 모듈러 모놀리스로는 안 되나?
  2. 최종 일관성으로는 안 되나?
    • 즉시 일관성이 비즈니스 요구사항인가?
    • 고객이 받아들일 수 없나?
  3. 2PC는 정말 불가능한가?
    • 성능이 정말 문제인가?
    • 측정해봤나?
  4. 구현/운영 비용을 감당할 수 있나?
    • 전담 인력이 있나?
    • 시간 여유가 있나?
    • 예산이 있나?
  5. 더 단순한 대안을 모두 시도했나?
    • 프로세스 단순화
    • 비동기 처리
    • 백그라운드 검증

5개 모두 “예”라면: Saga를 고려하라.

하나라도 “아니오”라면: 더 단순한 방법을 찾아라.


에필로그: Kafka 운영자의 관점

당신이 Kafka를 운영해봤다면, 이미 알고 있다:

  • 분산 시스템은 예측 불가능하다
  • 메시지 순서는 보장하기 어렵다
  • Exactly-once는 환상이다
  • 디버깅은 악몽이다
  • 운영은 끝없는 싸움이다

Saga는 이 모든 것 위에 또 다른 레이어를 추가한다.

Kafka만으로도 충분히 복잡하다. 정말로 Saga까지 더하고 싶은가?

대부분의 경우, 답은 아니오여야 한다.

단순하게 시작하라.
측정하라.
필요시 복잡도를 추가하라.
하지만 Saga는 최후의 수단이어야 한다.

최고의 아키텍처는 Saga가 필요 없는 아키텍처다.


참고 자료

핵심 자료:

  1. Richardson, Chris. “Microservices Patterns” - Saga 패턴 상세 설명
  2. Fowler, Martin. “Patterns of Enterprise Application Architecture”
  3. Vernon, Vaughn. “Implementing Domain-Driven Design” - Bounded Context와 서비스 경계
  4. Newman, Sam. “Building Microservices, 2nd Edition” - 실용적 접근

비판적 시각:

  1. McKinley, Dan. “Choose Boring Technology” - 혁신 토큰 개념
  2. Kleppmann, Martin. “Designing Data-Intensive Applications” - 분산 트랜잭션의 현실
  3. “Microservices Killed Our Startup” - 실패 사례 (2025)
  4. Amazon Prime Video 모놀리스 회귀 사례 (2023)

분산 시스템:

  1. “Fallacies of Distributed Computing” - Peter Deutsch
  2. CAP Theorem - Eric Brewer
  3. Kafka Documentation - 실전 경험의 바탕

대안적 접근:

  1. “Modular Monolith: A Primer” - Kamil Grzybek
  2. Spring Modulith Documentation
  3. “MonolithFirst” - Martin Fowler

작성 일자: 2026-01-10

저자 노트: 이 문서는 Saga 패턴을 비난하기 위한 것이 아닙니다. Saga는 특정 상황에서 필요하고 유용한 패턴입니다. 하지만 현실에서는 필요성이 과대평가되고, 복잡성이 과소평가되며, 대안이 충분히 고려되지 않는 경우가 많습니다.

Kafka 운영 경험과 메시지 큐 아키텍처 지식을 가진 엔지니어의 관점에서, Saga 패턴의 현실적인 비용과 대안들을 솔직하게 다루었습니다.

목표는 더 현명한 의사결정을 돕는 것입니다. 때로는 “하지 않기”가 최선의 선택일 수 있습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.