사가 패턴: 마이크로서비스 아키텍처의 분산 트랜잭션

사가 패턴: 마이크로서비스 아키텍처의 분산 트랜잭션

기존의 모놀리식 애플리케이션에서는 여러 엔터티에서 데이터 일관성을 유지하는 것이 간단합니다. 관계형 데이터베이스 엔진은 로컬 SQL 트랜잭션 내에 포함된 ACID(원자성, 일관성, 격리, 내구성) 보장을 제공합니다. 주문, 지불 공제 또는 재고 예약이 중간에 실패하는 경우 ROLLBACK을 호출하면 모든 데이터베이스 수정 사항이 즉시 되돌려집니다.

그러나 최신 마이크로서비스 아키텍처로 마이그레이션하면 데이터 관리가 근본적으로 변화합니다. 도메인 자율성과 독립적인 확장성을 보장하기 위해 각 마이크로서비스는 전용 데이터베이스를 소유합니다. 전자상거래 결제 처리와 같은 단일 비즈니스 작업은 이제 여러 서비스 경계와 데이터베이스 엔진(예: 주문용 PostgreSQL, 결제용 DynamoDB, 재고용 Redis)에 걸쳐 있습니다.

분산형 마이크로서비스는 단일 데이터베이스 트랜잭션에 의존할 수 없기 때문에 네트워크 경계 전반에 걸쳐 데이터 일관성을 유지하는 것은 분산 시스템 엔지니어링에서 가장 어려운 문제 중 하나가 됩니다.

시스템 가용성이나 성능을 희생하지 않고 이 문제를 해결하기 위해 소프트웨어 설계자는 Saga Pattern에 의존합니다.

이 심층 분석에서는 기존 분산 트랜잭션이 실패하는 이유를 살펴보고, Saga 패턴의 핵심 메커니즘을 분석하고, 안무와 오케스트레이션을 비교하고, 격리 대책을 분석하고, GoJava에서 프로덕션 코드 구현을 검사하고, 실제 실패 롤백을 안전하게 처리하는 방법을 알아봅니다.


근본 문제: 마이크로서비스에서 2단계 커밋(2PC)이 실패하는 이유

Saga 패턴을 채택하기 전에 엔지니어들은 종종 다음과 같은 질문을 합니다. 왜 마이크로서비스 전체에서 기존의 2단계 커밋(2PC/XA)을 사용할 수 없나요?

2PC의 메커니즘

2단계 커밋은 두 단계로 중앙 트랜잭션 관리자를 사용하여 여러 데이터베이스 노드에 걸쳐 분산 트랜잭션을 조정합니다.

  1. 준비 단계: 트랜잭션 관리자는 참여하는 모든 데이터베이스 노드에 필요한 행을 준비하고 잠그도록 요청합니다. 참가자는 YES 또는 NO에 투표합니다.
  2. 커밋 단계: 모든 참가자가 YES에 투표한 경우 관리자는 모든 노드에 COMMIT 명령을 발행합니다. 노드가 NO에 투표했거나 시간 초과된 경우 ROLLBACK을 발행합니다.
Client ----> Transaction Manager
                   |
     +-------------+-------------+
     | (Prepare)   | (Prepare)   | (Prepare)
     v             v             v
 Order DB     Payment DB    Inventory DB

2PC가 마이크로서비스에 대한 안티 패턴인 이유

2PC는 강력한 일관성을 보장하지만 클라우드 기반 마이크로서비스 환경에서는 몇 가지 아키텍처 결함으로 인해 작동하지 않습니다.

  1. 차단 및 리소스 경합: 데이터베이스 행은 다단계 네트워크 핸드셰이크 전체에서 잠긴 상태로 유지됩니다. 네트워크 대기 시간이 증가하거나 서비스 속도가 느려지면 잠금이 열린 상태로 유지되어 연결 풀을 빠르게 소비하고 계단식 시스템 오류가 발생합니다.
  2. 가용성 병목 현상: 2PC에서 시스템 가용성은 모든 참가자 가용성의 에 의해 제한됩니다($A_{total} = A_1 \times A_2 \times \dots \times A_n$). 준비 단계 중에 단일 서비스 또는 데이터베이스 노드가 오프라인 상태가 되면 전체 전역 트랜잭션이 무기한 차단됩니다.
  3. 서비스 자율성 상실: 2PC는 서비스가 네트워크 API 전반에 걸쳐 데이터베이스 수준 XA 프로토콜을 노출하도록 강제하여 데이터베이스 엔진을 직접 연결합니다.
  4. 브로커 제약: Apache Kafka 또는 RabbitMQ와 같은 처리량이 높은 메시지 브로커는 기본적으로 관계형 데이터베이스 전반의 기존 XA 2PC 트랜잭션에 참여하지 않습니다.

CAP 정리에 따르면 분산 시스템은 네트워크 파티션(P)에서 강력한 일관성(C)과 고가용성(A) 중에서 선택해야 합니다. 최신 마이크로서비스 시스템은 가용성 및 파티션 허용을 우선시하여 즉각적인 일관성을 최종 일관성(기본: 기본적으로 사용 가능, 소프트 상태, 최종 일관성)으로 교환합니다.


사가 패턴이란 무엇입니까?

Saga 패턴은 원래 1987년 Hector Garcia-Molina와 Kenneth Salem이 데이터베이스 관리 시스템에서 장기 트랜잭션을 처리하기 위한 메커니즘으로 제안한 것입니다. 최신 마이크로서비스에서 Saga는 개별 로컬 트랜잭션의 시퀀스를 나타냅니다.

단일 전역 잠금 내에서 전체 다중 서비스 흐름을 래핑하는 대신 Saga는 일련의 독립적인 로컬 데이터베이스 트랜잭션($T_1, T_2, \dots, T_n$)으로 비즈니스 프로세스를 실행합니다.

각 로컬 트랜잭션은 단일 마이크로서비스 데이터베이스의 데이터를 업데이트하고 도메인 이벤트 또는 메시지를 게시합니다. 이 이벤트는 다운스트림 서비스에서 다음 로컬 트랜잭션($T_{i+1}$)을 트리거합니다.

[Order Service]         [Payment Service]       [Inventory Service]
  Local Tx T1 -------------> Local Tx T2 -------------> Local Tx T3
(Create Order)              (Process Payment)            (Reserve Stock)

순방향 실행과 역방향 보상

모든 로컬 트랜잭션이 성공하면 Saga가 성공적으로 완료됩니다(Forward Execution).

그러나 로컬 트랜잭션이 중간에 실패하는 경우(예: 항목 재고가 없어서 $T_3$ 실패) 해당 로컬 트랜잭션이 이미 해당 데이터베이스에 커밋되었기 때문에 Saga는 이전 단계($T_1, T_2$)에 대해 단순히 데이터베이스 ROLLBACK을(를) 호출할 수 없습니다.

이미 커밋된 로컬 트랜잭션을 실행 취소하려면 Saga가 보상 트랜잭션($C_{n-1}, \dots, C_1$)을 역순으로 실행해야 합니다.

Forward Flow:    T1 (Create Order) ---> T2 (Charge Card) ---> T3 (Reserve Inventory - FAILS)
                                                                       |
Backward Rollback:  C1 (Cancel Order) <--- C2 (Refund Card) <----------+

사가 거래의 분류

강력한 Saga 워크플로우를 설계하려면 트랜잭션 순서의 모든 단계를 세 가지 구조 유형 중 하나로 분류해야 합니다.

거래 유형 설명 멱등성 및 롤백 요구 사항
보상 가능한 거래 돌아올 수 없는 지점 이전에 실행된 단계입니다. 다운스트림 단계가 실패하면 실행 취소되거나 되돌릴 수 있습니다. 해당 보상 거래($C_i$)가 있어야 합니다.
피벗 거래 Saga의 결정적인 단계입니다. 피벗이 성공하면 Saga가 완료됩니다. 실패하면 Saga가 롤백됩니다. 보상할 수도 없고 재심할 수도 없습니다. 롤백과 앞으로 완료 사이의 경계를 표시합니다.
재시도 가능한 트랜잭션 피벗 트랜잭션 이후 실행되는 단계입니다. 결국 성공이 보장되며 보상이 필요하지 않습니다. 엄격히 멱등성이어야 합니다. 성공할 때까지 자동으로 재시도됩니다.

전자상거래 결제 예시 분석

다음 네 단계로 구성된 전자상거래 주문 결제를 생각해 보세요.

  1. $T_1$: 보류 주문 생성 (보상 가능) $\to$ $C_1$: 주문 취소를 통해 취소합니다.
  2. $T_2$: 결제 승인 (보상 가능) $\to$ $C_2$: 결제 환불을 통해 실행 취소합니다.
  3. $T_3$: 재고 비축 (Pivot Transaction) $\to$ 재고 할당이 성공하면 주문이 확정됩니다. 실패하면 $C_2$ 및 $C_1$를 트리거합니다.
  4. $T_4$: 배송 요청 발송 (재시도 가능) $\to$ 피벗 후에 실행됩니다. 배송될 때까지 재시도했습니다.

사가 건축 스타일: 안무와 오케스트레이션

분산 시스템에서 Saga 패턴을 구현하는 데는 안무(분산형) 및 오케스트레이션(중앙형)이라는 두 가지 기본 아키텍처 스타일이 있습니다.

사가 패턴 구현 스타일: 안무와 오케스트레이션 아키텍처 다이어그램

스타일 1: 안무(이벤트 중심 분산화)

안무 기반 사가에는 중앙 컨트롤러나 코디네이터가 없습니다. 대신 마이크로서비스는 중앙 이벤트 버스(예: Apache Kafka, NATS 또는 RabbitMQ)에 게시된 도메인 이벤트를 수신하여 비동기적으로 통신합니다.

워크플로 메커니즘

  1. 주문 서비스는 $T_1$(보류 주문 생성)을 실행하고 OrderCreated 이벤트를 Kafka에 보냅니다.
  2. 결제 서비스OrderCreated을 수신하고 $T_2$(신용 카드 청구)를 실행하고 PaymentCompleted 이벤트(또는 PaymentFailed)를 발생시킵니다.
  3. Inventory ServicePaymentCompleted를 수신하고 $T_3$을 실행합니다(항목 예약). 품목의 재고가 없으면 InventoryReservationFailed이 표시됩니다.
  4. 결제 서비스InventoryReservationFailed을 듣고 $C_2$를 실행합니다(환불 발행).
  5. 주문 서비스PaymentRefunded을 수신하고 $C_1$을 실행합니다(주문이 취소된 것으로 표시).

안무의 장점

  • 느슨한 결합: 서비스는 이벤트 주제만 구독합니다. 그들은 다른 서비스 구현에 대해 모릅니다.
  • 높은 처리량 및 분산화: 직접 이벤트 게시/구독은 중앙 오케스트레이터 병목 현상을 제거합니다.
  • 짧은 작업 흐름을 위한 단순성: 간단한 2~3단계 작업 흐름을 위해 설정이 쉽습니다.

안무의 단점

  • 순환 종속성 위험: 서비스는 결국 서로의 이벤트를 수신하게 되어 복잡한 순환 종속성을 생성할 수 있습니다.
  • 어려운 코드 추적성: 엔드투엔드 비즈니스 프로세스를 이해하려면 여러 코드베이스 저장소에 걸친 추적 논리가 필요합니다.
  • 이벤트 스토밍 및 복잡성: 단계 수가 증가함에 따라(예: 10개 이상의 서비스) 오류 엣지 케이스를 관리하면 이벤트 상태가 폭발적으로 증가합니다.

스타일 2: 오케스트레이션(중앙 집중식 작업 흐름 관리)

오케스트레이션 기반 사가에서는 사가 오케스트레이터라고 알려진 전용 마이크로서비스가 분산 트랜잭션의 전체 수명 주기를 제어합니다. 오케스트레이터는 중앙 조정자 역할을 하여 참여하는 마이크로서비스에 명시적인 명령을 내리고 응답 이벤트를 수신합니다.

워크플로 메커니즘

  1. 클라이언트는 Saga Orchestrator에 주문 요청을 보냅니다.
  2. Orchestrator는 Order ServiceCreateOrder 명령을 보냅니다. 주문 서비스는 OrderCreated을(를) 반환합니다.
  3. Orchestrator는 상태 머신을 업데이트하고 ProcessPayment 명령을 결제 서비스에 보냅니다. 결제 서비스에서 PaymentSuccessful을(를) 반환합니다.
  4. Orchestrator는 Inventory ServiceReserveInventory 명령을 보냅니다. Inventory Service는 InventoryFailed (Out of Stock)을(를) 반환합니다.
  5. Orchestrator는 오류를 감지하고 보상 흐름을 시작합니다.
    • 결제 서비스RefundPayment 명령을 보냅니다.
    • 주문 서비스CancelOrder 명령을 보냅니다.
  6. Orchestrator는 Saga 실행을 FAILED로 표시합니다.

오케스트레이션의 장점

  • 중앙 집중식 비즈니스 로직: 워크플로 상태 및 비즈니스 로직이 단일 오케스트레이터 서비스 또는 상태 시스템에 지역화됩니다.
  • 순환 종속성 없음: 마이크로서비스는 오케스트레이터의 명령에 응답합니다. 다른 다운스트림 서비스에 의존하거나 이에 대해 알지 못합니다.
  • 명확한 모니터링 및 디버깅: 종단 간 트랜잭션 상태는 오케스트레이터의 상태 저장소(예: PostgreSQL 또는 Temporal/Camunda와 같은 워크플로 엔진)에 명시적으로 저장됩니다.
  • 더 쉬워진 오류 처리: 새 단계 추가 또는 롤백 규칙 변경은 전적으로 오케스트레이터 내에서 관리됩니다.

오케스트레이션의 단점

  • 오케스트레이터 복잡성: 오케스트레이터에 너무 많은 도메인 논리를 배치하여 “스마트 오케스트레이터, 멍청한 서비스” 안티 패턴으로 전환할 위험이 있습니다.
  • 잠재적인 단일 실패 지점: 오케스트레이터는 가용성이 높고 상태 저장이 가능하도록 렌더링되어야 합니다.

비교 매트릭스: 안무와 오케스트레이션

기능 안무 오케스트레이션
제어 구조 분산형(이벤트 게시/구독) 중앙 집중식(Saga 코디네이터/상태 머신)
커플링 매우 낮음(서비스가 이벤트를 소비함) 중간(서비스가 코디네이터의 명령을 수락함)
프로세스 가시성 낮음(로그 파일 전체에 분산) 높음(단일 상태 저장소가 워크플로를 시각화함)
가장 적합한 대상 간단한 워크플로(2~4개 서비스 단계) 복잡한 기업 워크플로우(5단계 이상, 분기 논리)
툴링/프레임워크 Kafka, RabbitMQ, NATS, AWS EventBridge Temporal.io, AWS Step Functions, Camunda, Axon

격리 과제 및 대책(“ACID 빼기 I” 처리)

Saga의 로컬 트랜잭션은 로컬 데이터베이스에 즉시 커밋되기 때문에 Saga 패턴에는 기존 ACID 보장의 **격리(I)**가 부족합니다.

Saga가 계속 실행되는 동안 클라이언트가 $T_1$에 의해 수정된 데이터베이스 행을 읽으면 커밋되지 않은 중간 상태를 읽는 것입니다. 다운스트림 단계가 실패하고 보상($C_1$)을 트리거하는 경우 클라이언트는 더티 읽기를 수행한 것입니다.

격리 부족으로 인해 발생하는 일반적인 이상 현상

  1. 업데이트 손실: Saga A가 기록을 업데이트합니다. Saga A가 완료되기 전에 Saga B는 동일한 레코드를 덮어씁니다. Saga A가 실패하여 보상을 실행하면 Saga B의 업데이트를 덮어씁니다.
  2. Dirty Reads: 고객은 Saga A에서 업데이트한 사용 가능한 재고($T_1$)를 읽습니다. Saga A는 다운스트림($T_3$)에 실패하고 재고를 복원($C_1$)했지만 고객은 이미 오래된 데이터를 기반으로 주문했습니다.
  3. 반복 불가능한 읽기: 서비스는 $T_1$ 단계에서 데이터를 읽고 $T_3$ 단계에서 다시 읽지만, 다른 동시 Saga가 그 사이에 데이터를 수정했습니다.

대응 및 완화 전략

격리가 부족함에도 불구하고 데이터 무결성을 유지하기 위해 소프트웨어 설계자는 특정 격리 설계 패턴을 구현합니다.

1. 의미론적 잠금(보류 중/플래그 상태)

로컬 트랜잭션 $T_1$이 데이터베이스 레코드를 업데이트할 때 상태 필드를 PENDING 또는 APPROVAL_REQUIRED(예: ORDER_PENDING_PAYMENT)로 설정합니다.

이 레코드를 읽는 다른 동시 Sagas는 의미론적 잠금 플래그를 확인하고 상태가 COMMITTED 또는 CANCELLED로 변경될 때까지 해당 동작을 차단하거나 변경해야 합니다.

2. 주문 확정

위험도가 높거나 되돌릴 수 없는 작업이 Saga 실행 후반에 발생하도록 로컬 트랜잭션의 순서를 설계하여 취약성의 창을 최소화합니다.

3. 다시 읽기 유효성 검사(낙관적 동시성 제어)

중요한 단계 또는 보상을 실행하기 전에 대상 데이터베이스 레코드를 다시 읽고 버전 타임스탬프(version_id)를 확인하여 동시 수정이 발생하지 않았는지 확인하세요.

4. 비관적인 견해

경제적 노출을 최소화하기 위해 Saga의 단계를 재정렬합니다(예: 결제 승인을 피벗 거래에 최대한 가깝게 배치).


생산 순서 흐름: 거래 보상

다음은 순방향 실행 실패와 그에 따른 역방향 보상 실행을 보여주는 전체 시퀀스 흐름도입니다.

순방향 실행 실패 및 보상 롤백을 보여주는 Saga 패턴 시퀀스 흐름 다이어그램

실습 코드 구현

안무(Go)와 오케스트레이션(Java Spring Boot)에 대한 프로덕션 준비 구현 예를 살펴보겠습니다.

구현 1: 안무 기반 Saga in Go

이 Go 예에서는 주문 생성을 처리하고 이벤트 브로커를 통해 결제 실패 이벤트를 수신하여 보상 롤백 로직을 실행하는 주문 서비스를 보여줍니다.

package saga

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"time"
)

// Event definitions
type OrderCreatedEvent struct {
	OrderID   string  `json:"order_id"`
	CustomerID string `json:"customer_id"`
	Amount    float64 `json:"amount"`
}

type PaymentFailedEvent struct {
	OrderID string `json:"order_id"`
	Reason  string `json:"reason"`
}

// OrderRepository handles local DB operations
type OrderRepository interface {
	CreateOrder(ctx context.Context, orderID string, amount float64) error
	UpdateOrderStatus(ctx context.Context, orderID string, status string) error
}

// EventBus abstraction for message broker (e.g., Kafka / NATS)
type EventBus interface {
	Publish(topic string, payload []byte) error
	Subscribe(topic string, handler func(payload []byte)) error
}

type OrderSagaChoreographer struct {
	repo     OrderRepository
	eventBus EventBus
}

func NewOrderSagaChoreographer(repo OrderRepository, bus EventBus) *OrderSagaChoreographer {
	c := &OrderSagaChoreographer{repo: repo, eventBus: bus}
	c.registerSubscriptions()
	return c
}

// Step 1: Forward Transaction (T1)
func (s *OrderSagaChoreographer) StartOrderSaga(ctx context.Context, orderID, customerID string, amount float64) error {
	// Execute local database transaction
	err := s.repo.CreateOrder(ctx, orderID, amount)
	if err != nil {
		return fmt.Errorf("failed local DB transaction T1: %w", err)
	}

	// Emit domain event for downstream Payment Service
	event := OrderCreatedEvent{OrderID: orderID, CustomerID: customerID, Amount: amount}
	bytes, _ := json.Marshal(event)
	
	log.Printf("[SAGA][T1] Order %s created. Publishing OrderCreatedEvent...", orderID)
	return s.eventBus.Publish("orders.created", bytes)
}

// Register subscription for compensating events
func (s *OrderSagaChoreographer) registerSubscriptions() {
	_ = s.eventBus.Subscribe("payments.failed", func(payload []byte) {
		var event PaymentFailedEvent
		if err := json.Unmarshal(payload, &event); err != nil {
			log.Printf("[ERROR] Corrupt payment event: %v", err)
			return
		}

		// Execute Compensating Transaction (C1)
		s.handlePaymentFailed(context.Background(), event)
	})
}

// Step C1: Compensating Transaction
func (s *OrderSagaChoreographer) handlePaymentFailed(ctx context.Context, event PaymentFailedEvent) {
	log.Printf("[SAGA][C1] Payment failed for Order %s (Reason: %s). Rolling back local order...", event.OrderID, event.Reason)
	
	// Revert order status to CANCELLED in local DB
	err := s.repo.UpdateOrderStatus(ctx, event.OrderID, "CANCELLED_PAYMENT_FAILED")
	if err != nil {
		log.Printf("[CRITICAL] Failed to execute compensating transaction C1 for Order %s: %v", event.OrderID, err)
		// Trigger alert or write to Dead Letter Queue (DLQ)
		return
	}
	
	log.Printf("[SAGA][SUCCESS] Order %s successfully compensated and cancelled.", event.OrderID)
}

구현 2: Java의 오케스트레이션 기반 Saga(Spring Boot)

이 Java 예제에서는 상태 머신 패턴을 사용하여 Saga Orchestrator를 구축하여 순방향 명령을 조정하고 다운스트림 단계가 실패할 때 보상 롤백을 실행합니다.

package com.ghaznix.saga.orchestrator;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.UUID;

public enum SagaState {
    STARTED,
    ORDER_CREATED,
    PAYMENT_PROCESSED,
    INVENTORY_RESERVED,
    COMPLETED,
    COMPENSATING_PAYMENT,
    COMPENSATING_ORDER,
    FAILED
}

@Service
public class OrderSagaOrchestrator {

    private static final Logger log = LoggerFactory.getLogger(OrderSagaOrchestrator.class);

    private final OrderServiceClient orderClient;
    private final PaymentServiceClient paymentClient;
    private final InventoryServiceClient inventoryClient;

    public OrderSagaOrchestrator(OrderServiceClient orderClient,
                                PaymentServiceClient paymentClient,
                                InventoryServiceClient inventoryClient) {
        this.orderClient = orderClient;
        this.paymentClient = paymentClient;
        this.inventoryClient = inventoryClient;
    }

    public boolean executeOrderSaga(String customerId, String productId, double amount, int quantity) {
        String sagaId = UUID.randomUUID().toString();
        log.info("[SAGA {}] Starting Order Saga Workflow...", sagaId);
        
        SagaState currentState = SagaState.STARTED;
        String orderId = null;
        String paymentId = null;

        try {
            // Step 1: Forward Local Tx T1 - Create Order
            log.info("[SAGA {}][T1] Sending CreateOrder command...", sagaId);
            orderId = orderClient.createOrder(customerId, productId, amount);
            currentState = SagaState.ORDER_CREATED;

            // Step 2: Forward Local Tx T2 - Process Payment
            log.info("[SAGA {}][T2] Sending ProcessPayment command for Order {}...", sagaId, orderId);
            paymentId = paymentClient.chargePayment(orderId, customerId, amount);
            currentState = SagaState.PAYMENT_PROCESSED;

            // Step 3: Forward Local Tx T3 (Pivot Step) - Reserve Inventory
            log.info("[SAGA {}][T3] Sending ReserveInventory command for Product {}...", sagaId, productId);
            boolean stockReserved = inventoryClient.reserveStock(productId, quantity);

            if (!stockReserved) {
                throw new InventoryAllocationException("Stock allocation failed: Item out of stock.");
            }

            currentState = SagaState.INVENTORY_RESERVED;
            log.info("[SAGA {}][SUCCESS] Saga completed successfully!", sagaId);
            return true;

        } catch (Exception ex) {
            log.error("[SAGA {}][FAILURE] Step failed during state {}. Triggering Compensation...", sagaId, currentState, ex);
            rollbackSaga(sagaId, currentState, orderId, paymentId);
            return false;
        }
    }

    private void rollbackSaga(String sagaId, SagaState failedState, String orderId, String paymentId) {
        log.info("[SAGA {}] Initiating backward compensating transactions from state: {}", sagaId, failedState);

        // Compensate Step 2 if payment was processed
        if (failedState == SagaState.PAYMENT_PROCESSED || failedState == SagaState.INVENTORY_RESERVED) {
            try {
                log.info("[SAGA {}][C2] Executing Payment Refund compensation for Payment {}...", sagaId, paymentId);
                paymentClient.refundPayment(paymentId);
            } catch (Exception e) {
                log.error("[CRITICAL][SAGA {}] Payment refund C2 failed! Manual intervention or DLQ required.", sagaId, e);
            }
        }

        // Compensate Step 1 if order was created
        if (failedState != SagaState.STARTED && orderId != null) {
            try {
                log.info("[SAGA {}][C1] Executing Order Cancel compensation for Order {}...", sagaId, orderId);
                orderClient.cancelOrder(orderId);
            } catch (Exception e) {
                log.error("[CRITICAL][SAGA {}] Order cancellation C1 failed!", sagaId, e);
            }
        }

        log.info("[SAGA {}] Saga compensation workflow finished. Final State: FAILED.", sagaId);
    }
}

프로덕션 필수 사항: Saga와 트랜잭션 아웃박스 패턴의 결합

안무와 오케스트레이션 모두에서 로컬 트랜잭션($T_i$)을 실행하려면 네트워크를 통해 도메인 이벤트나 명령을 게시해야 합니다.

서비스가 SQL 데이터베이스를 업데이트한 다음 Kafka에 메시지를 게시하는 경우 데이터베이스 커밋 후 네트워크 결함으로 인해 자동 이벤트 손실이 발생합니다. 반대로, 데이터베이스 커밋 전에 메시지를 게시하면 팬텀 이벤트 처리가 발생합니다.

이 문제를 해결하려면 Sagas를 Transactional Outbox Pattern과 쌍을 이루어야 합니다.

[Service Database Transaction Boundary]
+-----------------------------------------------------+
| 1. INSERT INTO business_table (orders/payments)    |
| 2. INSERT INTO outbox_table (event_payload)        |
+-----------------------------------------------------+
                          |
             (CDC / Polling Message Relay)
                          |
                          v
               [Message Broker / Kafka]

동일한 로컬 데이터베이스 트랜잭션 내의 outbox 테이블에 이벤트 페이로드를 유지함으로써 원자성이 보장됩니다. Debezium 또는 폴링 릴레이와 같은 비동기 백그라운드 프로세스는 보낼 편지함 테이블에서 읽고 이벤트를 Kafka에 안정적으로 게시합니다.

또한 모든 다운스트림 서비스 소비자는 재시도 중 중복된 메시지 전달로 인해 중복 요금이나 인벤토리 할당이 발생하지 않도록 멱등성(고유한 idempotency_key 또는 메시지 중복 제거 헤더 사용)을 구현해야 합니다.


아키텍처 결정 체크리스트

마이크로서비스 애플리케이션을 위한 분산 트랜잭션을 설계할 때 다음과 같은 실용적인 의사 결정 매트릭스를 사용하세요.

                  Do you need cross-service data consistency?
                                     |
                    +----------------+----------------+
                    | No                              | Yes
                    v                                 v
         Standard Single Service            Can you accept Eventual
            Local Database                    Consistency (BASE)?
                                                      |
                                     +----------------+----------------+
                                     | No                              | Yes
                                     v                                 v
                          Use Monolithic Core            Adopt Saga Pattern
                          with Single ACID DB                 |
                                                              |
                                             How complex is the workflow?
                                                              |
                                            +-----------------+-----------------+
                                            | Simple (2-3 steps)                | Complex (4+ steps/branches)
                                            v                                   v
                                   Choreography Saga                   Orchestration Saga
                                   (Event-Driven Bus)                  (Temporal/Custom State Machine)

결론 및 주요 시사점

사가 패턴은 리소스를 잠그거나 시스템 가용성을 희생하지 않고 마이크로서비스 경계를 ​​넘어 분산 트랜잭션을 관리하기 위한 필수 아키텍처 패턴입니다.

요약 체크리스트:

  1. 클라우드 네이티브 마이크로서비스에서 2PC/XA 포기: 2단계 커밋은 엄격한 잠금, 높은 대기 시간 및 심각한 가용성 병목 현상을 유발합니다.
  2. 트랜잭션을 로컬 단계로 나누기: 전역 작업을 반전 보상 트랜잭션($C_1 \dots C_{n-1}$)과 쌍을 이루는 로컬 트랜잭션($T_1 \dots T_n$)으로 나눕니다.
  3. 올바른 아키텍처 스타일 선택:
    • 느슨한 결합을 사용하는 간단한 2~3단계 이벤트 중심 흐름에는 안무를 사용합니다.
    • 중앙 집중식 가시성, 분기 및 상태 머신 추적이 필요한 복잡한 비즈니스 워크플로에는 조정을 사용하세요.
  4. 격리 대책 구현: 의미 체계 잠금(PENDING 플래그) 및 다시 읽기 낙관적 잠금을 사용하여 더티 읽기 및 업데이트 손실로부터 보호합니다.
  5. 신뢰할 수 있는 메시징 보장: 항상 Saga 구현을 트랜잭션 발신함 패턴과 페어링하고 멱등성 소비자를 적용하여 재시도를 안전하게 처리합니다.

Saga 패턴을 신중하게 구현하면 네트워크가 실패하더라도 탄력성과 일관성을 유지하는 가용성이 높고 확장 가능한 마이크로서비스를 구축할 수 있습니다.