Published on

Go 마이크로서비스 Saga 보상 트랜잭션 설계

Authors

마이크로서비스로 쪼개진 순간, 한 번의 사용자 요청이 여러 서비스의 데이터 변경을 연쇄적으로 일으킵니다. 문제는 이 변경들이 더 이상 하나의 DB 트랜잭션으로 묶이지 않는다는 점입니다. 결제는 성공했는데 주문 생성이 실패하거나, 재고는 차감됐는데 배송 생성이 누락되는 식의 불일치가 현실적으로 발생합니다.

이 글은 Go 기반 마이크로서비스 환경에서 Saga 패턴을 적용해 보상 트랜잭션(Compensation Transaction) 을 설계하는 방법을 다룹니다. 특히 운영에서 자주 터지는 중복 처리, 재시도 폭주, 부분 성공, 메시지 유실/중복, 보상 실패 같은 문제를 중심으로, 구현 가능한 수준의 구조와 코드 예제를 제공합니다.

관련해서 중복 처리와 보상 설계를 더 깊게 다룬 글도 함께 참고하면 좋습니다: MSA 사가(Saga) 중복처리·보상트랜잭션 설계 실전

Saga 패턴을 다시 정의하기: 핵심은 “일관성”이 아니라 “복구 가능성”

Saga는 분산 트랜잭션을 ACID로 보장하지 않습니다. 대신 다음을 목표로 합니다.

  • 각 서비스는 자기 DB에서 로컬 트랜잭션만 보장한다
  • 전체 플로우는 결국 일관성(Eventual Consistency) 을 갖는다
  • 실패 시 이전 단계들을 되돌리는 보상 트랜잭션을 실행한다
  • 재시도/중복/순서 뒤바뀜이 있어도 안전하게 수렴하도록 설계한다

여기서 중요한 관점 전환은 “실패를 없애자”가 아니라 “실패해도 복구되게 만들자”입니다.

Orchestration vs Choreography: Go 서비스에서 무엇을 택할까

Saga 구현은 크게 두 가지입니다.

1) Orchestration(오케스트레이션)

중앙 오케스트레이터(사가 매니저)가 단계별로 명령을 내립니다.

  • 장점: 흐름이 명확, 관측/재처리/타임아웃 관리가 쉬움
  • 단점: 오케스트레이터가 복잡해짐, 단일 장애 지점 가능

2) Choreography(코레오그래피)

서비스들이 이벤트를 발행하고 서로 구독하며 다음 단계를 진행합니다.

  • 장점: 결합도가 낮고 확장성이 좋음
  • 단점: 전체 플로우 파악이 어려움, 디버깅/재처리 난이도 상승

실무에서 Go로 빠르게 안정성을 확보해야 한다면, 오케스트레이션 기반이 운영 난이도가 낮습니다. 특히 보상 트랜잭션을 “어떤 순서로, 어디까지 완료됐는지” 추적해야 하므로 중앙 상태 머신이 유리합니다.

보상 트랜잭션 설계 원칙 6가지

보상은 단순히 “반대로 실행”이 아닙니다. 다음 원칙을 만족해야 운영에서 버팁니다.

1) 각 단계는 멱등성(Idempotency)을 가져야 한다

재시도는 무조건 발생합니다. 네트워크 타임아웃, 컨슈머 리밸런싱, 프로듀서 재전송 등으로 같은 명령이 여러 번 도착할 수 있습니다.

  • Idempotency-Key(예: saga_id + step)를 저장하고
  • 이미 처리된 키면 같은 결과를 반환해야 합니다

2) 보상도 멱등해야 한다

보상 실행이 실패해 재시도할 수 있습니다. 보상도 “두 번 실행돼도 안전”해야 합니다.

3) 보상은 “완벽한 롤백”이 아니라 “비즈니스적으로 수용 가능한 복구”다

예를 들어 결제 승인 취소는 가능하지만, 이미 배송이 시작되면 배송 취소 대신 “반품 프로세스 시작”이 보상이 될 수 있습니다.

4) 단계별로 “가역성”을 설계하라

보상이 불가능한 단계는 사가 뒤쪽으로 미루거나, 아예 별도 프로세스로 격리합니다.

5) 상태는 외부(사가 저장소)에 남기고, 재처리 가능해야 한다

오케스트레이터가 죽어도 DB에 남은 사가 상태로 복구되어야 합니다.

6) 타임아웃과 데드레터(또는 휴먼 인터벤션) 플랜이 있어야 한다

보상이 영원히 성공하지 않을 수 있습니다. 일정 횟수/시간 이후에는 “수동 처리 큐”로 넘기는 것이 현실적입니다.

데이터 모델: Saga 인스턴스와 Step 로그

오케스트레이션 기반이라면 최소한 다음이 필요합니다.

  • sagas 테이블: 사가 인스턴스 상태
  • saga_steps 테이블: 각 단계 실행/보상 상태, 시도 횟수, 마지막 에러

예시(개념):

  • sagas: id, type, status, created_at, updated_at
  • saga_steps: saga_id, step_name, status, attempts, last_error, updated_at

상태 값 예시:

  • 실행: PENDING, DONE, FAILED
  • 보상: COMPENSATING, COMPENSATED, COMPENSATION_FAILED

통신 방식: HTTP 동기 호출만으로는 부족하다

사가를 HTTP 체인으로만 만들면 다음 문제가 커집니다.

  • 중간 서비스 장애 시 전체가 블로킹
  • 타임아웃이 곧 실패로 전이
  • 재시도 정책이 서비스마다 달라 일관성 깨짐

권장 조합은 다음입니다.

  • 명령/이벤트는 메시지 브로커(Kafka, NATS JetStream, SQS 등)
  • 로컬 트랜잭션과 이벤트 발행은 Outbox 패턴

Outbox를 쓰면 “DB 커밋은 됐는데 이벤트 발행은 실패” 같은 분산 원자성 문제를 줄일 수 있습니다.

운영에서 DB 락/경계 설계가 꼬이면 사가가 아니라 DB에서 터질 수도 있습니다. 애그리게이트 경계로 락/데드락이 생기는 케이스는 다음 글도 참고하세요: DDD Aggregate 경계 실수로 락·데드락 터질 때

Go 예제: 사가 오케스트레이터(상태 머신) 골격

아래는 “주문 생성 사가”를 예로 든 간단한 구조입니다.

  • Step1: 주문 생성(주문 서비스)
  • Step2: 결제 승인(결제 서비스)
  • Step3: 재고 차감(재고 서비스)
  • 실패 시 보상: 재고 복원, 결제 취소, 주문 취소(역순)

사가 상태와 스텝 정의

package saga

import (
	"context"
	"errors"
	"fmt"
	"time"
)

type Status string

const (
	StatusPending       Status = "PENDING"
	StatusRunning       Status = "RUNNING"
	StatusSucceeded     Status = "SUCCEEDED"
	StatusFailed        Status = "FAILED"
	StatusCompensating  Status = "COMPENSATING"
	StatusCompensated   Status = "COMPENSATED"
	StatusManualAction  Status = "MANUAL_ACTION"
)

type StepStatus string

const (
	StepPending      StepStatus = "PENDING"
	StepDone         StepStatus = "DONE"
	StepFailed       StepStatus = "FAILED"
	StepCompensated  StepStatus = "COMPENSATED"
)

type Step struct {
	Name       string
	Do         func(ctx context.Context, sagaID string) error
	Compensate func(ctx context.Context, sagaID string) error
}

type Store interface {
	CreateSaga(ctx context.Context, sagaID, sagaType string) error
	SetSagaStatus(ctx context.Context, sagaID string, st Status) error
	GetSagaStatus(ctx context.Context, sagaID string) (Status, error)

	InitStep(ctx context.Context, sagaID, stepName string) error
	SetStepStatus(ctx context.Context, sagaID, stepName string, st StepStatus, lastErr string) error
	GetStepStatus(ctx context.Context, sagaID, stepName string) (StepStatus, error)
}

var ErrRetryable = errors.New("retryable")

func isRetryable(err error) bool {
	return errors.Is(err, ErrRetryable)
}

오케스트레이터 실행 로직(정방향 + 보상)

package saga

import (
	"context"
	"fmt"
	"time"
)

type Orchestrator struct {
	Store       Store
	Steps       []Step
	MaxAttempts int
	Backoff     func(attempt int) time.Duration
}

func (o *Orchestrator) Run(ctx context.Context, sagaID, sagaType string) error {
	if o.MaxAttempts <= 0 {
		o.MaxAttempts = 5
	}
	if o.Backoff == nil {
		o.Backoff = func(attempt int) time.Duration {
			return time.Duration(attempt) * 200 * time.Millisecond
		}
	}

	if err := o.Store.CreateSaga(ctx, sagaID, sagaType); err != nil {
		return err
	}
	_ = o.Store.SetSagaStatus(ctx, sagaID, StatusRunning)

	// 정방향 실행
	for _, step := range o.Steps {
		_ = o.Store.InitStep(ctx, sagaID, step.Name)

		st, _ := o.Store.GetStepStatus(ctx, sagaID, step.Name)
		if st == StepDone {
			continue // 재실행 시 멱등
		}

		err := o.runWithRetry(ctx, sagaID, step.Name, step.Do)
		if err != nil {
			_ = o.Store.SetSagaStatus(ctx, sagaID, StatusFailed)
			// 보상 시작
			_ = o.Store.SetSagaStatus(ctx, sagaID, StatusCompensating)
			compErr := o.compensate(ctx, sagaID)
			if compErr != nil {
				_ = o.Store.SetSagaStatus(ctx, sagaID, StatusManualAction)
				return fmt.Errorf("saga failed: %w; compensation failed: %v", err, compErr)
			}
			_ = o.Store.SetSagaStatus(ctx, sagaID, StatusCompensated)
			return fmt.Errorf("saga failed and compensated: %w", err)
		}

		_ = o.Store.SetStepStatus(ctx, sagaID, step.Name, StepDone, "")
	}

	_ = o.Store.SetSagaStatus(ctx, sagaID, StatusSucceeded)
	return nil
}

func (o *Orchestrator) runWithRetry(
	ctx context.Context,
	sagaID, stepName string,
	fn func(context.Context, string) error,
) error {
	var last error
	for attempt := 1; attempt <= o.MaxAttempts; attempt++ {
		err := fn(ctx, sagaID)
		if err == nil {
			return nil
		}
		last = err
		_ = o.Store.SetStepStatus(ctx, sagaID, stepName, StepFailed, err.Error())

		if !isRetryable(err) {
			return err
		}
		select {
		case <-time.After(o.Backoff(attempt)):
		case <-ctx.Done():
			return ctx.Err()
		}
	}
	return last
}

func (o *Orchestrator) compensate(ctx context.Context, sagaID string) error {
	// 역순 보상
	for i := len(o.Steps) - 1; i >= 0; i-- {
		step := o.Steps[i]
		st, _ := o.Store.GetStepStatus(ctx, sagaID, step.Name)
		if st != StepDone {
			continue // 완료된 것만 보상
		}
		if step.Compensate == nil {
			continue
		}
		err := o.runWithRetry(ctx, sagaID, step.Name+"_COMP", step.Compensate)
		if err != nil {
			return err
		}
		_ = o.Store.SetStepStatus(ctx, sagaID, step.Name, StepCompensated, "")
	}
	return nil
}

이 구조의 핵심은 다음입니다.

  • Step 상태를 저장해서 프로세스가 죽어도 재실행 가능
  • 정방향/보상 모두 재시도 정책을 통일
  • 완료된 단계만 보상해 “중간 단계 미실행 보상” 같은 모순을 방지

서비스 측 구현 포인트: 멱등 키와 로컬 트랜잭션

사가 오케스트레이터가 아무리 잘해도, 각 서비스가 멱등하지 않으면 중복 요청에서 데이터가 깨집니다.

아래는 결제 서비스에서 “결제 승인” 명령을 멱등 처리하는 예시입니다.

결제 승인 멱등 처리(간단 예시)

package payment

import (
	"context"
	"database/sql"
	"errors"
)

type Service struct {
	DB *sql.DB
}

// idempotencyKey 예: sagaID + ":PAYMENT_AUTHORIZE"
func (s *Service) Authorize(ctx context.Context, idempotencyKey, orderID string, amount int64) error {
	tx, err := s.DB.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()

	// 1) 이미 처리했는지 확인
	var exists int
	err = tx.QueryRowContext(ctx,
		"select 1 from payment_idempotency where key = $1",
		idempotencyKey,
	).Scan(&exists)
	if err == nil {
		// 이미 승인 처리됨: 멱등 성공
		return tx.Commit()
	}
	if !errors.Is(err, sql.ErrNoRows) {
		return err
	}

	// 2) 결제 승인 처리(외부 PG 호출은 보통 트랜잭션 밖에서 해야 함)
	// 여기서는 예시로 DB 처리만 표현
	_, err = tx.ExecContext(ctx,
		"insert into payments(order_id, amount, status) values ($1,$2,'AUTHORIZED')",
		orderID, amount,
	)
	if err != nil {
		return err
	}

	// 3) 멱등 키 기록
	_, err = tx.ExecContext(ctx,
		"insert into payment_idempotency(key, created_at) values ($1, now())",
		idempotencyKey,
	)
	if err != nil {
		return err
	}

	return tx.Commit()
}

주의할 점:

  • 실제로는 PG(결제 게이트웨이) 호출이 포함되는데, 외부 호출을 DB 트랜잭션 안에서 수행하면 락이 길어져 장애를 키웁니다.
  • 보통은 “요청 기록(멱등 키 선점) → 외부 호출 → 결과 반영” 같은 2단계로 나누고, 외부 호출 결과를 저장할 때도 멱등하게 처리합니다.

Outbox 패턴: DB 커밋과 이벤트 발행의 간극 메우기

사가에서 흔한 사고는 이겁니다.

  • 서비스 A가 DB에는 반영했는데
  • 이벤트 발행이 실패해서
  • 다음 단계가 영원히 시작되지 않음

Outbox는 서비스 DB에 outbox 테이블을 두고 로컬 트랜잭션으로 함께 커밋한 뒤, 별도 퍼블리셔가 outbox를 읽어 브로커로 발행합니다.

Outbox 테이블 기반 퍼블리셔(개념 코드)

package outbox

import (
	"context"
	"database/sql"
	"time"
)

type Publisher interface {
	Publish(ctx context.Context, topic string, key string, value []byte) error
}

type Worker struct {
	DB        *sql.DB
	Publisher Publisher
}

func (w *Worker) Run(ctx context.Context) error {
	ticker := time.NewTicker(500 * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-ticker.C:
			if err := w.flushOnce(ctx); err != nil {
				// 로깅 후 계속 (재시도는 다음 tick에서)
			}
		}
	}
}

func (w *Worker) flushOnce(ctx context.Context) error {
	tx, err := w.DB.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()

	rows, err := tx.QueryContext(ctx,
		"select id, topic, key, payload from outbox where published_at is null order by id limit 50 for update skip locked",
	)
	if err != nil {
		return err
	}
	defer rows.Close()

	type msg struct {
		id      int64
		topic   string
		key     string
		payload []byte
	}
	msgs := make([]msg, 0, 50)

	for rows.Next() {
		var m msg
		if err := rows.Scan(&m.id, &m.topic, &m.key, &m.payload); err != nil {
			return err
		}
		msgs = append(msgs, m)
	}

	for _, m := range msgs {
		if err := w.Publisher.Publish(ctx, m.topic, m.key, m.payload); err != nil {
			return err
		}
		_, err := tx.ExecContext(ctx,
			"update outbox set published_at = now() where id = $1",
			m.id,
		)
		if err != nil {
			return err
		}
	}

	return tx.Commit()
}

포인트:

  • for update skip locked로 다중 워커 경쟁을 안전하게 처리
  • 발행 성공 후에만 published_at 갱신
  • 발행 실패는 그대로 두고 다음 루프에서 재시도

보상 트랜잭션의 난제: “이미 외부로 나간 효과”

보상 설계에서 가장 어려운 케이스는 외부 시스템에 영향이 나간 경우입니다.

  • 결제 승인 후 취소(가능)
  • 배송 생성 후 취소(상태에 따라 불가능)
  • 쿠폰 사용 처리 후 복구(정책에 따라 다름)

이때는 보상을 다음 중 하나로 모델링합니다.

  • 취소(cancel): 즉시 무효화 가능
  • 환불(refund): 이미 정산이 진행된 경우 금전 흐름을 반대로
  • 정정(adjustment): 재고/포인트를 보정하는 별도 거래
  • 수동 처리(manual action): 자동 복구 불가, 운영 프로세스 필요

사가 상태에 MANUAL_ACTION 같은 종단 상태를 두는 이유가 여기에 있습니다.

관측 가능성(Observability): 사가 ID를 전 구간에 전파하라

운영에서 사가는 “성공률”보다 “실패했을 때 얼마나 빨리 원인 추적이 되느냐”가 더 중요합니다.

권장 사항:

  • 모든 로그에 saga_id, step, order_id 포함
  • OpenTelemetry로 트레이싱 시 saga_id를 baggage 또는 span attribute로 전파
  • 메시지 헤더에도 saga_id를 넣어 컨슈머 로그와 연결

Kubernetes에서 장애가 나면 사가 실패가 연쇄적으로 늘어납니다. 빠른 원인 파악을 위해서는 런타임 진단 루틴이 필수인데, 이 글도 같이 보면 도움이 됩니다: K8s CrashLoopBackOff 원인별 10분 진단법

실전 체크리스트

사가 보상 트랜잭션을 “구현은 했는데 운영에서 불안한” 상태로 두지 않으려면 아래를 점검하세요.

  • 각 Step Do/Compensate가 멱등인가
  • 중복 메시지/재시도에도 데이터가 증가/중복되지 않는가
  • Step 상태 저장으로 재시작 시 이어서 진행 가능한가
  • Outbox로 이벤트 발행 유실 가능성을 줄였는가
  • 보상 불가 케이스에 MANUAL_ACTION 같은 플랜B가 있는가
  • 타임아웃/서킷 브레이커/백오프가 중앙에서 일관되게 적용되는가
  • 사가 단위 대시보드(성공/실패/보상/수동처리)가 있는가

마무리

Go 마이크로서비스에서 Saga 보상 트랜잭션은 “패턴 적용” 자체보다 멱등성, 상태 저장, Outbox, 재시도 정책, 관측 가능성을 얼마나 촘촘히 설계했는지가 성패를 가릅니다. 특히 보상은 롤백이 아니라 “비즈니스적으로 수용 가능한 복구”라는 점을 팀 내에서 합의하고, 보상 불가 케이스를 수동 처리 플로우까지 포함해 모델링해야 운영에서 흔들리지 않습니다.

다음 단계로는 실제 메시지 브로커(Kafka 또는 JetStream) 기반으로 명령/이벤트 스키마를 잡고, 사가 스토어(PostgreSQL 등)와 함께 “재처리 도구(관리 UI 또는 CLI)”까지 준비하면 훨씬 안정적인 분산 트랜잭션 운영이 가능합니다.