마스터하기: 분산 트랜잭션과 사가(Saga) 패턴 - 마이크로서비스 시대의 데이터 일관성 유지 전략

1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

소프트웨어 아키텍처가 모놀리식(Monolithic)에서 마이크로서비스(Microservices)로 진화하면서 많은 이점을 얻었지만, 동시에 새로운 도전 과제에 직면하게 되었습니다. 그중 하나가 바로 **분산 트랜잭션(Distributed Transaction)**입니다. 그리고 이 분산 트랜잭션 문제를 해결하기 위한 강력한 패턴 중 하나가 바로 사가(Saga) 패턴입니다.
사가(Saga) 패턴이란?
사가 패턴은 여러 서비스에 걸쳐 수행되는 일련의 로컬 트랜잭션으로 구성된 비즈니스 프로세스입니다. 여기서 각 로컬 트랜잭션은 단일 서비스 내에서 원자적(Atomic)으로 완료되며, 해당 서비스의 데이터베이스를 업데이트합니다. 만약 사가 프로세스 도중 어떤 단계에서 실패가 발생하면, 이전에 성공했던 로컬 트랜잭션들을 되돌리기 위한 **보상 트랜잭션(Compensating Transaction)**이 순차적으로 실행되어 전체 시스템을 일관된 상태로 되돌립니다.
탄생 배경: 모놀리스의 ACID와 마이크로서비스의 도전
전통적인 모놀리식 아키텍처에서는 단일 데이터베이스를 사용하기 때문에, 여러 작업이 하나의 트랜잭션으로 묶여 ACID(원자성, 일관성, 고립성, 지속성) 속성을 쉽게 보장할 수 있었습니다. 예를 들어, 은행 송금 시 출금과 입금이라는 두 가지 작업은 하나의 데이터베이스 트랜잭션으로 묶여 둘 다 성공하거나 둘 다 실패하는 '전부 아니면 전무(all-or-nothing)' 방식으로 처리되었습니다.
하지만 마이크로서비스 아키텍처에서는 각 서비스가 독립적인 데이터베이스를 가지는 것이 일반적입니다. 이는 서비스 간의 결합도를 낮추고 확장성을 높이는 데 필수적이지만, 동시에 여러 서비스에 걸친 비즈니스 트랜잭션의 데이터 일관성을 보장하기 어렵게 만듭니다. 전통적인 분산 트랜잭션 기법인 2단계 커밋(Two-Phase Commit, 2PC)은 마이크로서비스 환경에서 다음과 같은 문제점을 가집니다.
- 성능 저하: 2PC는 모든 참여자들이 커밋에 동의할 때까지 자원을 잠그기 때문에, 서비스 수가 많아질수록 지연 시간이 길어지고 처리량이 감소합니다.
- 가용성 문제: 한 참여자라도 실패하면 전체 트랜잭션이 중단될 수 있어 시스템의 가용성이 저하됩니다.
- 느슨한 결합 저해: 서비스 간에 강한 동기적 종속성을 생성하여 마이크로서비스의 핵심 가치인 느슨한 결합을 해칩니다.
이러한 문제점 때문에 마이크로서비스 환경에서는 2PC 대신 최종 일관성(Eventual Consistency) 모델을 기반으로 하는 사가 패턴이 대안으로 부상했습니다. 사가 패턴은 즉각적인 ACID 속성을 포기하는 대신, 비즈니스 프로세스 완료 시점에는 데이터가 일관성을 갖도록 보장하면서, 서비스 간의 느슨한 결합과 높은 가용성을 유지할 수 있게 해줍니다.
왜 중요한가?
사가 패턴은 마이크로서비스 아키텍처에서 다음과 같은 이유로 매우 중요합니다.
- 데이터 일관성 유지: 분리된 데이터베이스를 사용하는 여러 서비스에 걸쳐 비즈니스 로직이 실행될 때, 전체 프로세스의 성공 또는 실패에 따라 데이터를 일관된 상태로 유지할 수 있게 합니다.
- 느슨한 결합 유지: 서비스들이 서로 동기적으로 직접 호출하여 강한 종속성을 만드는 대신, 비동기 메시징이나 중앙 코디네이터를 통해 상호작용함으로써 서비스 간의 독립성을 보장합니다.
- 확장성 및 가용성 향상: 각 로컬 트랜잭션은 독립적으로 실행되므로, 전체 시스템의 확장성과 가용성에 미치는 영향이 적습니다. 특정 서비스의 장애가 전체 시스템을 멈추게 하지 않고, 보상 트랜잭션을 통해 복구될 수 있습니다.
- 복잡한 비즈니스 로직 처리: 주문, 결제, 배송 등 여러 단계를 거치는 복잡한 비즈니스 워크플로우를 마이크로서비스 환경에서 안정적으로 구현할 수 있는 기반을 제공합니다.
2. 핵심 원리 설명: 비유와 다이어그램 활용

사가 패턴은 크게 두 가지 방식으로 구현될 수 있습니다: 오케스트레이션(Orchestration) 방식과 코레오그래피(Choreography) 방식입니다. 두 방식 모두 로컬 트랜잭션과 보상 트랜잭션이라는 핵심 개념을 공유하지만, 사가의 흐름을 제어하는 방식에서 차이를 보입니다.
비유: 해외여행 예약하기
사가 패턴을 이해하기 위해 "해외여행 예약"이라는 비즈니스 프로세스를 비유로 들어봅시다. 이 프로세스는 보통 다음과 같은 여러 단계로 구성됩니다.
- 항공권 예약
- 호텔 예약
- 렌터카 예약
모놀리식 환경: 이 모든 단계를 하나의 큰 트랜잭션으로 묶어 처리합니다. 만약 렌터카 예약이 실패하면, 항공권과 호텔 예약도 모두 취소되어 원래 상태로 돌아갑니다.
마이크로서비스 환경 (사가 패턴 미적용): 각 단계를 담당하는 서비스(항공권 서비스, 호텔 서비스, 렌터카 서비스)가 독립적으로 동작합니다. 항공권 예약 성공, 호텔 예약 성공, 하지만 렌터카 예약 실패! 이 경우, 항공권과 호텔은 예약되었지만 렌터카가 없어 여행 계획이 꼬이는 '부분 실패' 상태가 됩니다. 시스템은 일관되지 않은 상태에 빠지게 됩니다.
마이크로서비스 환경 (사가 패턴 적용): 렌터카 예약이 실패하면, 이전에 성공했던 호텔 예약과 항공권 예약을 자동으로 취소하여 모든 것이 원래 상태로 돌아가거나, 일관된 다른 상태로 전환됩니다.
이제 사가의 두 가지 구현 방식을 자세히 살펴보겠습니다.
2.1. 오케스트레이션(Orchestration) 방식
오케스트레이션 방식에서는 중앙 집중식의 **사가 오케스트레이터(Saga Orchestrator)**가 사가의 모든 단계를 관리하고 실행합니다. 오케스트레이터는 각 참여 서비스(Saga Participants)에 명령을 보내고, 그 결과를 받아 다음 단계를 결정합니다. 마치 여행사 직원이 고객의 요청에 따라 항공, 호텔, 렌터카 예약을 순서대로 진행하고, 문제가 생기면 직접 나서서 취소 절차를 조율하는 것과 같습니다.
작동 방식:
- 시작: 클라이언트 요청이 오케스트레이터를 트리거합니다.
- 명령 전송: 오케스트레이터는 첫 번째 로컬 트랜잭션을 수행하도록 서비스 A에 명령을 보냅니다.
- 결과 수신: 서비스 A는 로컬 트랜잭션을 수행한 후 결과를 오케스트레이터에게 보고합니다.
- 다음 단계 결정: 오케스트레이터는 서비스 A의 결과에 따라 다음 로컬 트랜잭션을 수행하도록 서비스 B에 명령을 보내거나, 실패 시 보상 트랜잭션을 시작합니다.
- 보상 트랜잭션: 만약 중간 단계에서 실패하면, 오케스트레이터는 이전에 성공했던 서비스들에게 보상 트랜잭션을 실행하도록 명령합니다.
장점:
- 사가의 흐름을 한눈에 파악하기 쉽습니다.
- 중앙에서 오류 처리 및 보상 로직을 관리하기 용이합니다.
- 각 서비스는 자신의 로컬 트랜잭션에만 집중할 수 있어 단순해집니다.
단점:
- 오케스트레이터가 단일 실패 지점(Single Point of Failure)이 될 수 있습니다.
- 오케스트레이터에 비즈니스 로직이 집중되어 복잡해질 수 있습니다.
- 오케스트레이터와 서비스 간의 통신 부하가 발생할 수 있습니다.
graph TD
A[클라이언트 요청] --> B{Saga Orchestrator};
B --> C1[명령: 서비스 A (로컬 트랜잭션)];
C1 --> D1[서비스 A 결과];
D1 -- 성공 --> C2[명령: 서비스 B (로컬 트랜잭션)];
C2 --> D2[서비스 B 결과];
D2 -- 성공 --> C3[명령: 서비스 C (로컬 트랜잭션)];
C3 --> D3[서비스 C 결과];
D3 -- 성공 --> E[Saga 완료];
D2 -- 실패 --> F2[명령: 서비스 B 보상 트랜잭션];
F2 --> F1[명령: 서비스 A 보상 트랜잭션];
F1 --> G[Saga 실패 및 롤백];
2.2. 코레오그래피(Choreography) 방식
코레오그래피 방식에서는 중앙 오케스트레이터 없이, 각 서비스가 **이벤트(Event)**를 발행하고 다른 서비스들이 이 이벤트를 구독하여 다음 단계를 자율적으로 수행합니다. 마치 댄서들이 음악에 맞춰 각자 동작을 수행하며 전체 안무를 완성하듯이, 각 서비스가 서로의 이벤트를 듣고 반응하면서 사가가 진행됩니다.
작동 방식:
- 시작: 첫 번째 서비스(예: 주문 서비스)가 로컬 트랜잭션을 수행한 후, 관련 이벤트를 발행합니다 (예:
OrderCreatedEvent). - 이벤트 구독: 다음 단계를 담당하는 서비스(예: 재고 서비스)가
OrderCreatedEvent를 구독하고, 자신의 로컬 트랜잭션을 수행합니다 (예: 재고 감소). - 이벤트 발행: 재고 서비스는 자신의 로컬 트랜잭션이 성공하면 새로운 이벤트(
InventoryReservedEvent)를 발행합니다. - 연속 진행: 다른 서비스(예: 결제 서비스)가
InventoryReservedEvent를 구독하고, 자신의 로컬 트랜잭션을 수행합니다. - 보상 트랜잭션: 만약 특정 서비스에서 로컬 트랜잭션이 실패하면, 해당 서비스는 실패 이벤트를 발행하고, 이 이벤트를 구독하는 다른 서비스들은 각각 보상 트랜잭션을 실행하여 상태를 되돌립니다.
장점:
- 서비스 간의 결합도가 매우 낮습니다.
- 중앙 집중식 단일 실패 지점이 없습니다.
- 새로운 서비스를 추가하거나 기존 서비스를 변경하기 쉽습니다.
단점:
- 사가의 전체 흐름을 추적하고 이해하기 어렵습니다 (특히 복잡한 사가).
- 보상 트랜잭션의 순서와 의존성을 관리하기 복잡할 수 있습니다.
- 순환 의존성(Circular Dependency)이 발생할 위험이 있습니다.
graph TD
A[클라이언트 요청] --> B[서비스 A (로컬 트랜잭션)];
B -- OrderCreatedEvent 발행 --> C[이벤트 브로커];
C -- OrderCreatedEvent 구독 --> D[서비스 B (로컬 트랜잭션)];
D -- InventoryReservedEvent 발행 --> C;
C -- InventoryReservedEvent 구독 --> E[서비스 C (로컬 트랜잭션)];
E -- PaymentProcessedEvent 발행 --> C;
C -- PaymentProcessedEvent 구독 --> F[Saga 완료];
D -- InventoryFailedEvent 발행 --> C;
C -- InventoryFailedEvent 구독 --> G[서비스 B 보상 트랜잭션];
C -- InventoryFailedEvent 구독 --> H[서비스 A 보상 트랜잭션];
H --> I[Saga 실패 및 롤백];
두 방식 모두 장단점이 명확하므로, 사가의 복잡성, 팀의 규모, 시스템의 요구사항 등을 고려하여 적절한 방식을 선택해야 합니다. 일반적으로 간단한 사가는 코레오그래피로, 복잡하고 여러 단계가 있는 사가는 오케스트레이션으로 구현하는 것이 관리하기 더 용이합니다.
3. 코드 예제 2개 (Python)
여기서는 간단한 온라인 쇼핑 주문 과정을 사가 패턴으로 구현하는 예제를 Python으로 살펴보겠습니다. 주문 과정은 '재고 예약' -> '결제 처리' 두 단계로 구성됩니다.
3.1. 예제 1: 오케스트레이션 방식 (주문 처리)
오케스트레이션 방식에서는 OrderService가 사가 오케스트레이터 역할을 하여 InventoryService와 PaymentService를 직접 호출하고, 실패 시 보상 트랜잭션을 지시합니다.
import time
from typing import Dict, Any
# ====================================================================
# 1. Saga 참여 서비스 (Saga Participants)
# 각 서비스는 자체 로컬 트랜잭션과 보상 트랜잭션을 가집니다.
# ====================================================================
class InventoryService:
def __init__(self):
self.reserved_items: Dict[str, int] = {} # 예약된 재고 상태
self.available_items: Dict[str, int] = {"item_A": 10, "item_B": 5} # 실제 재고
def reserve_items(self, order_id: str, item_id: str, quantity: int) -> bool:
"""재고를 예약하는 로컬 트랜잭션"""
print(f"[InventoryService] 주문 {order_id}: {item_id} {quantity}개 재고 예약 시도...")
if self.available_items.get(item_id, 0) >= quantity:
self.available_items[item_id] -= quantity
self.reserved_items[order_id] = self.reserved_items.get(order_id, 0) + quantity
print(f"[InventoryService] 주문 {order_id}: {item_id} {quantity}개 재고 예약 성공. 남은 재고: {self.available_items[item_id]}")
return True
else:
print(f"[InventoryService] 주문 {order_id}: {item_id} {quantity}개 재고 부족. 현재 {self.available_items.get(item_id, 0)}개.")
return False
def compensate_reserve_items(self, order_id: str, item_id: str, quantity: int) -> bool:
"""재고 예약을 취소하는 보상 트랜잭션"""
print(f"[InventoryService] 주문 {order_id}: {item_id} {quantity}개 재고 예약 취소 시도 (보상 트랜잭션)...")
if order_id in self.reserved_items:
self.available_items[item_id] = self.available_items.get(item_id, 0) + quantity
# 실제로는 reserved_items에서 해당 order_id의 예약 정보를 삭제해야 합니다.
# 여기서는 예시를 위해 단순화합니다.
self.reserved_items.pop(order_id, None)
print(f"[InventoryService] 주문 {order_id}: {item_id} {quantity}개 재고 예약 취소 성공. 현재 재고: {self.available_items[item_id]}")
return True
print(f"[InventoryService] 주문 {order_id}: {item_id} {quantity}개 재고 예약 취소 실패 (이미 취소되었거나 존재하지 않음).")
return False
class PaymentService:
def __init__(self):
self.processed_payments: Dict[str, float] = {} # 처리된 결제 내역
def process_payment(self, order_id: str, amount: float) -> bool:
"""결제를 처리하는 로컬 트랜잭션"""
print(f"[PaymentService] 주문 {order_id}: {amount}원 결제 시도...")
# 실제 결제 시스템 연동 로직 (가상으로 성공/실패)
if amount < 1000: # 특정 금액 이하는 실패 가정
print(f"[PaymentService] 주문 {order_id}: {amount}원 결제 실패 (금액 부족 또는 시스템 오류).")
return False
self.processed_payments[order_id] = amount
print(f"[PaymentService] 주문 {order_id}: {amount}원 결제 성공.")
return True
def compensate_payment(self, order_id: str, amount: float) -> bool:
"""결제를 취소하는 보상 트랜잭션"""
print(f"[PaymentService] 주문 {order_id}: {amount}원 결제 취소 시도 (보상 트랜잭션)...")
if order_id in self.processed_payments:
self.processed_payments.pop(order_id)
print(f"[PaymentService] 주문 {order_id}: {amount}원 결제 취소 성공.")
return True
print(f"[PaymentService] 주문 {order_id}: {amount}원 결제 취소 실패 (이미 취소되었거나 존재하지 않음).")
return False
# ====================================================================
# 2. Saga 오케스트레이터 (Orchestrator)
# 사가의 전체 흐름을 관리하고, 실패 시 보상 트랜잭션을 지시합니다.
# ====================================================================
class OrderSagaOrchestrator:
def __init__(self, inventory_service: InventoryService, payment_service: PaymentService):
self.inventory_service = inventory_service
self.payment_service = payment_service
def create_order(self, order_id: str, item_id: str, quantity: int, amount: float) -> bool:
print(f"\n--- Saga 시작: 주문 {order_id} (오케스트레이션) ---")
saga_successful = False
# 1. 재고 예약 단계 (Step 1: Reserve Inventory)
print("단계 1: 재고 예약")
if not self.inventory_service.reserve_items(order_id, item_id, quantity):
print(f"사가 실패: 재고 예약 실패 (주문 {order_id})")
return False
# 2. 결제 처리 단계 (Step 2: Process Payment)
print("단계 2: 결제 처리")
if not self.payment_service.process_payment(order_id, amount):
print(f"사가 실패: 결제 처리 실패 (주문 {order_id}) - 보상 트랜잭션 시작")
# 결제 실패 시, 재고 예약 보상
self.inventory_service.compensate_reserve_items(order_id, item_id, quantity)
return False
# 모든 단계 성공
saga_successful = True
print(f"--- Saga 성공: 주문 {order_id} (오케스트레이션) ---")
return saga_successful
# ====================================================================
# 3. 테스트 시나리오
# ====================================================================
if __name__ == "__main__":
inventory = InventoryService()
payment = PaymentService()
orchestrator = OrderSagaOrchestrator(inventory, payment)
# 시나리오 1: 모든 단계 성공
print("--- 성공 시나리오 ---")
orchestrator.create_order("order_001", "item_A", 2, 5000)
print(f"최종 재고 A: {inventory.available_items['item_A']}")
print(f"최종 결제 내역 001: {payment.processed_payments.get('order_001')}")
# 시나리오 2: 결제 실패 -> 재고 예약 보상
print("\n--- 결제 실패 시나리오 (보상 트랜잭션 발생) ---")
orchestrator.create_order("order_002", "item_A", 3, 500) # 결제 금액 500원으로 실패 유도
print(f"최종 재고 A: {inventory.available_items['item_A']}") # 재고가 원래대로 복구되었는지 확인
print(f"최종 결제 내역 002: {payment.processed_payments.get('order_002')}") # 결제 내역이 없는지 확인
# 시나리오 3: 재고 부족 실패 (첫 단계 실패 -> 보상 필요 없음)
print("\n--- 재고 부족 시나리오 ---")
orchestrator.create
