분산 트랜잭션에서의 Saga 패턴 - Go 예제 포함

마이크로서비스에서 Saga 패턴을 사용한 트랜잭션

Page content

Saga 패턴은 분산 트랜잭션을 일련의 로컬 트랜잭션과 보상 동작으로 나누어 분산 트랜잭션 문제를 우아하게 해결합니다.

분산 잠금을 사용하여 서비스 간 작업을 차단하는 대신, Saga는 최종 일관성을 통해 가역적인 단계의 시퀀스를 통해 실행되며, 이는 장기적인 비즈니스 프로세스에 이상적입니다.

마이크로 서비스 아키텍처에서 서비스 간 데이터 일관성을 유지하는 것은 가장 큰 도전 중 하나입니다. 전통적인 ACID 트랜잭션은 여러 서비스와 독립적인 데이터베이스를 사용하는 작업에 작동하지 않아, 개발자들이 데이터 무결성을 보장하기 위한 대안적인 접근법을 찾고 있습니다.

이 가이드는 Go에서 Saga 패턴의 구현을 실용적인 예제를 통해 보여주며, 오케스트레이션과 코레오티지 접근법을 모두 다룹니다. Go 기초에 대한 빠른 참조가 필요하다면, Go Cheat Sheet은 유용한 개요를 제공합니다.

분산 트랜잭션과 함께하는 건설 근로자 이 멋진 이미지는 AI 모델 Flux 1 dev에 의해 생성되었습니다.

Saga 패턴 이해

Saga 패턴은 1987년 헥터 가르시아-몰리나와 키타니 살렘에 의해 처음 설명되었습니다. 마이크로 서비스의 맥락에서, 이는 각 트랜잭션이 단일 서비스 내 데이터를 업데이트하는 로컬 트랜잭션의 시퀀스입니다. 만약 어떤 단계가 실패하면, 이전 단계의 효과를 되돌리는 보상 트랜잭션이 실행됩니다.

전통적인 분산 트랜잭션과 달리 두 단계 커밋(2PC)을 사용하지 않으며, Saga는 서비스 간 잠금을 유지하지 않아 장기적인 비즈니스 프로세스에 적합합니다. 이 교환 조건은 강한 일관성 대신 최종 일관성을 제공합니다.

주요 특징

  • 분산 잠금 없음: 각 서비스는 자체 로컬 트랜잭션을 관리
  • 보상 동작: 모든 작업에는 대응하는 롤백 메커니즘이 있음
  • 최종 일관성: 시스템은 결국 일관된 상태에 도달
  • 장기 실행: 몇 초, 분, 또는 심지어 몇 시간이 걸리는 프로세스에 적합

Saga 패턴 구현 접근법

Saga 패턴을 구현하는 두 가지 주요 접근법은 오케스트레이션과 코레오티지입니다.

오케스트레이션 패턴

오케스트레이션에서는 전체 트랜잭션 흐름을 관리하는 중앙 조정자(오케스트레이터)가 있습니다. 오케스트레이터는 다음과 같은 책임을 지닙니다:

  • 서비스를 올바른 순서로 호출
  • 실패를 처리하고 보상 트랜잭션을 트리거
  • Saga의 상태 유지
  • 재시도 및 타임아웃 조정

장점:

  • 중앙 집중식 제어 및 가시성
  • 전체 흐름의 이해 및 디버깅이 더 쉬움
  • 오류 처리 및 복구가 더 잘됨
  • 전체 흐름의 테스트가 더 간단

단점:

  • 단일 고장 지점(이를 완화할 수 있음)
  • 유지 관리가 필요한 추가 서비스
  • 복잡한 흐름에 대한 병목 현상 가능성

Go 예제:

type OrderSagaOrchestrator struct {
    orderService    OrderService
    paymentService  PaymentService
    inventoryService InventoryService
    shippingService ShippingService
}

func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
    sagaID := generateSagaID()
    
    // 단계 1: 주문 생성
    orderID, err := o.orderService.Create(order)
    if err != nil {
        return err
    }
    
    // 단계 2: 재고 예약
    if err := o.inventoryService.Reserve(order.Items); err != nil {
        o.orderService.Cancel(orderID) // 보상
        return err
    }
    
    // 단계 3: 결제 처리
    paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
    if err != nil {
        o.inventoryService.Release(order.Items) // 보상
        o.orderService.Cancel(orderID)          // 보상
        return err
    }
    
    // 단계 4: 배송 생성
    if err := o.shippingService.CreateShipment(orderID); err != nil {
        o.paymentService.Refund(paymentID)      // 보상
        o.inventoryService.Release(order.Items) // 보상
        o.orderService.Cancel(orderID)          // 보상
        return err
    }
    
    return nil
}

코레오티지 패턴

코레오티지에서는 중앙 조정자가 없습니다. 각 서비스는 무엇을 해야 하는지 알고 있으며, 이벤트를 통해 소통합니다. 서비스는 이벤트를 듣고 적절히 반응합니다. 이 이벤트 기반 접근법은 AWS Kinesis와 같은 메시지 스트리밍 플랫폼과 결합하여 마이크로 서비스 간 이벤트 분배에 확장 가능한 인프라를 제공합니다. 이벤트 기반 마이크로 서비스를 Kinesis로 구현하는 방법에 대한 종합 가이드는 AWS Kinesis를 사용한 이벤트 기반 마이크로 서비스 구축를 참조하십시오.

장점:

  • 분산 및 확장 가능
  • 단일 고장 지점 없음
  • 서비스 간 느슨한 결합
  • 이벤트 기반 아키텍처에 자연스러운 적합

단점:

  • 전체 흐름을 이해하기 어려움
  • 디버깅 및 추적 어려움
  • 복잡한 오류 처리
  • 순환 의존성 위험

이벤트 기반 아키텍처 예제:

// 주문 서비스
type OrderService struct {
    eventBus EventBus
    repo     OrderRepository
}

func (s *OrderService) CreateOrder(order Order) (string, error) {
    orderID, err := s.repo.Save(order)
    if err != nil {
        return "", err
    }
    
    s.eventBus.Publish("OrderCreated", OrderCreatedEvent{
        OrderID:    orderID,
        CustomerID: order.CustomerID,
        Items:      order.Items,
        Total:      order.Total,
    })
    
    return orderID, nil
}

func (s *OrderService) HandlePaymentFailed(event PaymentFailedEvent) error {
    return s.repo.Cancel(event.OrderID) // 보상
}

// 결제 서비스
type PaymentService struct {
    eventBus EventBus
    client   PaymentClient
}

func (s *PaymentService) HandleOrderCreated(event OrderCreatedEvent) {
    paymentID, err := s.client.Charge(event.CustomerID, event.Total)
    if err != nil {
        s.eventBus.Publish("PaymentFailed", PaymentFailedEvent{
            OrderID: event.OrderID,
        })
        return
    }
    
    s.eventBus.Publish("PaymentSucceeded", PaymentSucceededEvent{
        OrderID:   event.OrderID,
        PaymentID: paymentID,
    })
}

func (s *PaymentService) HandleInventoryReservationFailed(event InventoryReservationFailedEvent) error {
    // 보상: 결제 환불
    return s.client.Refund(event.PaymentID)
}

보상 전략

보상은 Saga 패턴의 핵심입니다. 각 작업에는 효과를 되돌릴 수 있는 대응하는 보상이 있어야 합니다.

보상 유형

  1. 가역적 작업: 직접 취소할 수 있는 작업

    • 예: 예약된 재고 해제, 결제 환불
  2. 보상 동작: 반대 효과를 달성하는 다른 작업

    • 예: 삭제 대신 주문 취소
  3. 비관적 보상: 이후에 해제할 수 있는 자원을 사전에 할당

    • 예: 결제 전 재고 예약
  4. 낙관적 보상: 작업을 실행하고 필요 시 보상

    • 예: 재고가 없을 경우 결제 후 환불

중복성 요구사항

모든 작업 및 보상은 중복성(이드empo티언트)이 있어야 합니다. 이는 실패한 작업을 재시도해도 중복 효과가 발생하지 않도록 보장합니다.

func (s *PaymentService) Refund(paymentID string) error {
    // 이미 환불되었는지 확인
    payment, err := s.getPayment(paymentID)
    if err != nil {
        return err
    }
    
    if payment.Status == "refunded" {
        return nil // 이미 환불됨, 중복성 있음
    }
    
    // 환불 처리
    return s.processRefund(paymentID)
}

최고의 실천 방법

1. Saga 상태 관리

각 Saga 인스턴스의 상태를 유지하여 진행 상황을 추적하고 복구를 가능하게 합니다. Saga 상태를 데이터베이스에 저장할 때, 성능과 유지 관리에 적합한 ORM을 선택하는 것이 중요합니다. PostgreSQL 기반 구현을 고려할 경우, PostgreSQL용 Go ORM 비교: GORM vs Ent vs Bun vs sqlc에서 제공하는 비교를 참조하여 Saga 상태 저장 요구사항에 가장 적합한 것을 선택하십시오:

type SagaState struct {
    ID           string
    Status       SagaStatus
    Steps        []SagaStep
    CurrentStep  int
    CreatedAt    time.Time
    UpdatedAt    time.Time
}

type SagaStep struct {
    Service     string
    Operation   string
    Status      StepStatus
    Compensated bool
    Data        map[string]interface{}
}

2. 타임아웃 처리

각 단계에 타임아웃을 구현하여 Saga가 무한히 멈춰 있는 것을 방지하십시오:

type SagaOrchestrator struct {
    timeout time.Duration
}

func (o *SagaOrchestrator) ExecuteWithTimeout(step SagaStep) error {
    ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
    defer cancel()
    
    done := make(chan error, 1)
    go func() {
        done <- step.Execute()
    }()
    
    select {
    case err := <-done:
        return err
    case <-ctx.Done():
        // 타임아웃 발생, 보상
        if err := step.Compensate(); err != nil {
            return fmt.Errorf("보상 실패: %w", err)
        }
        return fmt.Errorf("단계 %s가 %v 후 타임아웃 발생", step.Name(), o.timeout)
    }
}

3. 재시도 논리

일시적인 실패에 대해 지수 백오프를 구현하십시오:

func retryWithBackoff(operation func() error, maxRetries int) error {
    backoff := time.Second
    for i := 0; i < maxRetries; i++ {
        err := operation()
        if err == nil {
            return nil
        }
        
        if !isTransientError(err) {
            return err
        }
        
        time.Sleep(backoff)
        backoff *= 2
    }
    return fmt.Errorf("작업이 %d 회 재시도 후 실패", maxRetries)
}

4. Saga 상태를 위한 이벤트 소싱

완전한 감사 추적을 위해 이벤트 소싱을 사용하십시오. 이벤트 저장소와 재생 메커니즘을 구현할 때, Go 제네릭은 유형 안전하고 재사용 가능한 이벤트 처리 코드를 생성하는 데 도움이 됩니다. Go에서 제네릭을 사용한 고급 패턴에 대한 자세한 내용은 Go 제네릭: 사용 사례 및 패턴을 참조하십시오.

type SagaEvent struct {
    SagaID    string
    EventType string
    Payload   []byte
    Timestamp time.Time
    Version   int64
}

type SagaEventStore struct {
    store EventRepository
}

func (s *SagaEventStore) AppendEvent(sagaID string, eventType string, payload interface{}) error {
    data, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("payload marshaling 실패: %w", err)
    }
    
    version, err := s.store.GetNextVersion(sagaID)
    if err != nil {
        return fmt.Errorf("version 가져오기 실패: %w", err)
    }
    
    event := SagaEvent{
        SagaID:    sagaID,
        EventType: eventType,
        Payload:   data,
        Timestamp: time.Now(),
        Version:   version,
    }
    
    return s.store.Save(event)
}

func (s *SagaEventStore) ReplaySaga(sagaID string) (*Saga, error) {
    events, err := s.store.GetEvents(sagaID)
    if err != nil {
        return nil, fmt.Errorf("이벤트 가져오기 실패: %w", err)
    }
    
    saga := NewSaga()
    for _, event := range events {
        if err := saga.Apply(event); err != nil {
            return nil, fmt.Errorf("이벤트 적용 실패: %w", err)
        }
    }
    
    return saga, nil
}

5. 모니터링 및 가시성

포괄적인 로깅 및 추적을 구현하십시오:

func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
    span := tracer.StartSpan("saga.create_order")
    defer span.Finish()
    
    span.SetTag("saga.id", sagaID)
    span.SetTag("order.id", order.ID)
    
    logger.WithFields(log.Fields{
        "saga_id": sagaID,
        "order_id": order.ID,
        "step": "create_order",
    }).Info("Saga 시작")
    
    // ... Saga 실행
    
    return nil
}

일반적인 패턴 및 반패턴

따르야 할 패턴

  • Saga 조정자 패턴: 오케스트레이션을 위한 전용 서비스 사용
  • 아웃박스 패턴: 신뢰할 수 있는 이벤트 게시 보장
  • 중복성 키: 모든 작업에 고유 키 사용
  • Saga 상태 기계: 상태 기계로 Saga 모델링

피해야 할 반패턴

  • 동기 보상: 보상이 완료될 때까지 기다리지 않음
  • 중첩된 Saga: 다른 Saga를 호출하는 Saga 피함(대신 하위 Saga 사용)
  • 공유 상태: Saga 단계 간 상태 공유 피함
  • 장기 실행 단계: 너무 오래 걸리는 단계는 분해

도구 및 프레임워크

Saga 패턴을 구현하는 데 도움이 되는 여러 프레임워크가 있습니다:

  • Temporal: 내장 Saga 지원이 있는 워크플로우 오케스트레이션 플랫폼
  • Zeebe: 마이크로 서비스 오케스트레이션을 위한 워크플로우 엔진
  • Eventuate Tram: Spring Boot용 Saga 프레임워크
  • AWS Step Functions: 서버리스 워크플로우 오케스트레이션
  • Apache Camel: Saga 지원이 있는 통합 프레임워크

오케스트레이터 서비스가 관리 및 모니터링을 위한 CLI 인터페이스가 필요한 경우, Go에서 Cobra 및 Viper를 사용한 CLI 애플리케이션 구축은 Saga 오케스트레이터와 상호 작용하는 명령줄 도구를 생성하는 데 탁월한 패턴을 제공합니다.

Kubernetes에서 Saga 기반 마이크로 서비스를 배포할 때, 서비스 메시지를 구현하면 관찰 가능성, 보안, 트래픽 관리가 크게 향상됩니다. Istio 및 Linkerd를 사용한 서비스 메시지 구현은 서비스 메시지가 분산 트랜잭션 패턴과 어떻게 보완되는지 설명합니다.

Saga 패턴 사용 시기

Saga 패턴을 사용해야 할 때:

  • ✅ 여러 마이크로 서비스에 걸친 작업
  • ✅ 장기적인 비즈니스 프로세스
  • ✅ 최종 일관성이 허용됨
  • ✅ 분산 잠금을 피해야 함
  • ✅ 서비스가 독립적인 데이터베이스를 사용함

피해야 할 때:

  • ❌ 강한 일관성이 필요함
  • ❌ 간단하고 빠른 작업
  • ❌ 모든 서비스가 동일한 데이터베이스를 공유함
  • ❌ 보상 논리가 너무 복잡함

결론

Saga 패턴은 마이크로 서비스 아키텍처에서 분산 트랜잭션을 관리하는 데 필수적입니다. 이는 복잡성을 도입하지만, 서비스 경계를 넘어서 데이터 일관성을 유지하는 실용적인 솔루션을 제공합니다. 더 나은 제어 및 가시성을 위해 오케스트레이션을 선택하거나, 확장성 및 느슨한 결합을 위해 코레오티지를 선택하십시오. 항상 작업이 중복성 있게 되도록 하며, 적절한 보상 논리를 구현하고, 포괄적인 가시성을 유지하십시오.

성공적인 Saga 구현의 열쇠는 일관성 요구사항을 이해하고, 보상 논리를 신중하게 설계하며, 사용 사례에 적합한 접근법을 선택하는 것입니다. 적절한 구현을 통해 Saga는 분산 시스템에서 데이터 무결성을 유지하는 견고하고 확장 가능한 마이크로 서비스를 구축할 수 있게 해줍니다.

유용한 링크