분산 트랜잭션의 사가 패턴 - Go 예제 포함
사가 패턴을 사용한 마이크로서비스의 트랜잭션
Saga 패턴은 보상 작업을 가진 일련의 로컬 트랜잭션으로 분산 트랜잭션을 분할하여 우아한 해결책을 제공합니다.
서비스 간 작업을 차단할 수 있는 분산 잠금에 의존하는 대신, Saga는 가역적 단계의 시퀀스를 통해 궁극적 일관성을 가능하게 하여 장기 실행 비즈니스 프로세스에 이상적입니다.
마이크로서비스 아키텍처에서 서비스 간 데이터 일관성을 유지하는 것은 가장 어려운 문제 중 하나입니다. 전통적인 ACID 트랜잭션은 독립된 데이터베이스를 가진 여러 서비스에 걸쳐 작업이 확장될 때 작동하지 않으며, 이는 개발자들에게 데이터 무결성을 보장하기 위한 대안적 접근 방식을 모색하게 만듭니다.
이 가이드는 오케스트레이션과 안무(연주) 접근법을 모두 다루는 실용적인 예제와 함께 Go에서 Saga 패턴 구현을 시연합니다. Go 기초에 대한 빠른 참조가 필요하시면 Go 치트시트에서 유용한 개요를 확인하실 수 있습니다.
이 멋진 이미지는 AI 모델 Flux 1 dev에 의해 생성되었습니다.
Saga 패턴 이해하기
Saga 패턴은 1987년 Hector Garcia-Molina와 Kenneth Salem이 처음 설명했습니다. 마이크로서비스 컨텍스트에서 이는 각 트랜잭션이 단일 서비스 내의 데이터를 업데이트하는 로컬 트랜잭션의 시퀀스입니다. 어떤 단계라도 실패하면, 이전 단계의 효과를 되돌리기 위해 보상 트랜잭션이 실행됩니다.
두 단계 커밋(2PC)을 사용하는 전통적인 분산 트랜잭션과 달리, Saga는 서비스 간 잠금을 유지하지 않아 장기 실행 비즈니스 프로세스에 적합합니다. 여기서 트레이드오프는 강인한 일관성이 아닌 궁극적 일관성입니다.
주요 특징
- 분산 잠금 없음: 각 서비스는 자체 로컬 트랜잭션을 관리합니다.
- 보상 작업: 모든 작업에는 대응하는 롤백 메커니즘이 있습니다.
- 궁극적 일관성: 시스템은 결국 일관된 상태에 도달합니다.
- 장기 실행: 초, 분, 심지어 시간 단위의 프로세스에 적합합니다.
Saga 구현 접근법
Saga 패턴을 구현하는 두 가지 주요 접근법이 있습니다: 오케스트레이션(Orchestration)과 안무(Choreography).
오케스트레이션 패턴
오케스트레이션에서는 중앙 조정자(오케스트레이터)가 전체 트랜잭션 흐름을 관리합니다. 오케스트레이터는 다음 역할을 담당합니다:
- 올바른 순서로 서비스를 호출
- 실패 처리 및 보상 트리거
- Saga 상태 유지
- 재시도 및 시간 초과 조정
장점:
- 중앙 집중식 제어 및 가시성
- 이해 및 디버깅이 용이
- 더 나은 오류 처리 및 복구
- 전체 흐름의 테스트가 단순함
단점:
- 단일 고장 지점(비록 완화될 수 있더라도)
- 유지해야 할 추가 서비스
- 복잡한 흐름에서 병목 현상이 발생할 수 있음
Go 예제:
type OrderSagaOrchestrator struct {
orderService OrderService
paymentService PaymentService
inventoryService InventoryService
shippingService ShippingService
}
func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
sagaID := generateSagaID()
// Step 1: Create order
orderID, err := o.orderService.Create(order)
if err != nil {
return err
}
// Step 2: Reserve inventory
if err := o.inventoryService.Reserve(order.Items); err != nil {
o.orderService.Cancel(orderID) // Compensate
return err
}
// Step 3: Process payment
paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
if err != nil {
o.inventoryService.Release(order.Items) // Compensate
o.orderService.Cancel(orderID) // Compensate
return err
}
// Step 4: Create shipment
if err := o.shippingService.CreateShipment(orderID); err != nil {
o.paymentService.Refund(paymentID) // Compensate
o.inventoryService.Release(order.Items) // Compensate
o.orderService.Cancel(orderID) // Compensate
return err
}
return nil
}
안무 패턴
안무에서는 중앙 조정자가 없습니다. 각 서비스는 무엇을 해야 하는지 알고 이벤트를 통해 통신합니다. 서비스는 이벤트를 수신하고 적절하게 반응합니다. 이러한 이벤트 기반 접근법은 마이크로서비스 간 이벤트 배포를 위한 확장 가능한 인프라를 제공하는 AWS Kinesis와 같은 메시지 스트리밍 플랫폼과 결합될 때 특히 강력합니다. Kinesis를 사용한 이벤트 기반 마이크로서비스 구현에 대한 포괄적인 가이드는 AWS Kinesis를 사용한 이벤트 기반 마이크로서비스 빌딩을 참조하십시오.
장점:
- 분산형 및 확장 가능
- 단일 고장 지점 없음
- 서비스가 느슨하게 결합됨
- 이벤트 기반 아키텍처에 자연스럽게 적합
단점:
- 전체 흐름을 이해하기 어려움
- 디버깅 및 추적 어려움
- 복잡한 오류 처리
- 순환 의존성 위험
이벤트 기반 아키텍처 예제:
// Order Service
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
}
// Note: s.repo.Save followed by s.eventBus.Publish is a dual-write.
// In production, replace this with the transactional outbox pattern so the
// event is written atomically with the order row and published by a relay.
func (s *OrderService) HandlePaymentFailed(event PaymentFailedEvent) error {
return s.repo.Cancel(event.OrderID) // Compensation
}
// Payment Service
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 {
// Compensation: refund payment
return s.client.Refund(event.PaymentID)
}
보상 전략
보상은 Saga 패턴의 핵심입니다. 각 작업에는 그 효과를 역전시킬 수 있는 대응 보상이 있어야 합니다.
보상 유형
-
가역적 작업: 직접 되돌릴 수 있는 작업
- 예: 예약된 재고 해제, 환불
-
보상 작업: 반대 효과를 달성하는 다른 작업
- 예: 삭제하는 대신 주문 취소
-
비관적 보상: 해제할 수 있는 자원을 사전 할당
- 예: 결제 전 재고 예약
-
낙관적 보상: 작업 실행 후 필요 시 보상
- 예: 결제 먼저 처리, 재고 부족 시 환불
멱등성 요구사항
모든 작업 및 보상은 멱등(idempotent)해야 합니다. 이는 실패한 작업의 재시도가 중복 효과를 유발하지 않도록 보장합니다. 마찬가지로 각 Saga 참여자가 로컬 커밋 후 이벤트를 신뢰할 수 있게 발행하도록 하는 것도 중요합니다. 데이터베이스 쓰기 및 브로커 발행 간의 간극을 해소하는 표준 방법은 트랜잭셔널 아웃박스 패턴입니다.
func (s *PaymentService) Refund(paymentID string) error {
// Check if already refunded
payment, err := s.getPayment(paymentID)
if err != nil {
return err
}
if payment.Status == "refunded" {
return nil // Already refunded, idempotent
}
// Process refund
return s.processRefund(paymentID)
}
모범 사례
1. Saga 상태 관리
진도를 추적하고 복구를 활성화하기 위해 각 Saga 인스턴스의 상태를 유지합니다. Saga 상태를 데이터베이스에 영속화할 때, 적절한 ORM 선택은 성능과 유지 관리성에 중요합니다. PostgreSQL 기반 구현의 경우, Saga 상태 저장소 요구사항에 가장 적합한 것을 선택하기 위해 PostgreSQL용 Go ORM 비교: GORM vs Ent vs Bun vs sqlc에서 비교를 고려하십시오:
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():
// Timeout occurred, compensate
if err := step.Compensate(); err != nil {
return fmt.Errorf("compensation failed: %w", err)
}
return fmt.Errorf("step %s timed out after %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("operation failed after %d retries", 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("failed to marshal payload: %w", err)
}
version, err := s.store.GetNextVersion(sagaID)
if err != nil {
return fmt.Errorf("failed to get 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("failed to get events: %w", err)
}
saga := NewSaga()
for _, event := range events {
if err := saga.Apply(event); err != nil {
return nil, fmt.Errorf("failed to apply event: %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 started")
// ... saga execution
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 인터페이스가 필요한 오케스트레이터 서비스의 경우, Cobra & Viper를 사용한 Go CLI 애플리케이션 빌딩은 Saga 오케스트레이터와 상호 작용하기 위한 명령줄 도구를 생성하는 훌륭한 패턴을 제공합니다.
Kubernetes에서 Saga 기반 마이크로서비스를 배포할 때, 서비스 메시를 구현하면 관측 가능성, 보안 및 트래픽 관리가 크게 개선될 수 있습니다. Istio 및 Linkerd를 사용한 서비스 메시 구현은 서비스 메시가 분산 트레이싱 및 서킷 브레이킹과 같은 크로스 컷팅 관심사를 제공함으로써 분산 트랜잭션 패턴을 어떻게 보완하는지 다룹니다.
Saga 패턴 사용 시기
다음과 같은 경우 Saga 패턴을 사용하십시오:
- ✅ 작업이 여러 마이크로서비스에 걸쳐 있음
- ✅ 장기 실행 비즈니스 프로세스
- ✅ 궁극적 일관성이 허용됨
- ✅ 분산 잠금을 피해야 함
- ✅ 서비스가 독립된 데이터베이스를 가짐
다음과 같은 경우 피하십시오:
- ❌ 강인한 일관성이 필요함
- ✅ 작업이 단순하고 빠름
- ❌ 모든 서비스가 동일한 데이터베이스를 공유함
- ❌ 보상 로직이 너무 복잡함
결론
Saga 패턴은 마이크로서비스 아키텍처에서 분산 트랜잭션을 관리하는 데 필수적입니다. 복잡성을 도입하지만, 서비스 경계 간 데이터 일관성을 유지하기 위한 실용적인 솔루션을 제공합니다. 더 나은 제어 및 가시성을 위해 오케스트레이션을 선택하거나, 확장성 및 느슨한 결합을 위해 안무를 선택하십시오. 항상 작업이 멱등적임을 보장하고, 적절한 보상 로직을 구현하며, 포괄적인 관측 가능성을 유지하십시오.
성공적인 Saga 구현의 핵심은 일관성 요구사항을 이해하고, 보상 로직을 신중하게 설계하며, 사용 사례에 적합한 올바른 접근법을 선택하는 것입니다. 적절한 구현으로 Saga는 분산 시스템 전반에 데이터 무결성을 유지하는 탄력적이고 확장 가능한 마이크로서비스를 구축할 수 있게 해줍니다.
유용한 링크
- Microservices Patterns by Chris Richardson
- Saga Pattern - Martin Fowler
- Eventuate Tram Saga Framework
- Temporal Workflow Engine
- AWS Step Functions Documentation
- Go Cheat Sheet
- Go Generics: Use Cases and Patterns
- Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc
- Implementing CQRS in Go
- Building CLI Applications in Go with Cobra & Viper
- Implementing Service Mesh with Istio and Linkerd
- Building Event-Driven Microservices with AWS Kinesis