마이크로서비스에서 분산 트랜잭션을 위한 Saga 패턴

1. 개요

모놀리식 애플리케이션에서는 메서드에 @Transactional을 붙이고 데이터베이스가 나머지를 처리합니다: 모든 것이 커밋되거나 모두 롤백됩니다. 마이크로서비스로 전환할 때는 각 서비스가 자신의 데이터베이스를 가지므로, 단일 ACID 트랜잭션으로 전체 비즈니스 로직을 포괄할 수 없습니다.

이 글은 이 문제를 해결하는 가장 일반적인 방법인 Saga 패턴을 소개합니다. 이 글을 읽은 후에는 Saga의 두 가지 유형(choreography와 orchestration)을 이해하고, Java 21로 간단한 Saga orchestrator를 작성하고, compensation 흐름을 테스트하며, Saga를 프로덕션에 배포할 때 흔히 발생하는 오류들을 알게 될 것입니다.

2. 문제: 하나의 비즈니스 로직, 여러 데이터베이스

세 가지 서비스로 이루어진 주문 흐름을 생각해 봅시다:

  1. InventoryService 창고에 상품을 보유합니다.
  2. PaymentService 고객의 돈을 차감합니다.
  3. ShippingService 배송 주문을 생성합니다.

1단계는 성공했지만 고객의 잔액이 부족하여 2단계가 실패한다고 가정하세요. 이제 상품은 창고에 보관되어 있지만 아무도 비용을 지불하지 않았습니다. PaymentService에서 InventoryService의 데이터베이스를 ’롤백’할 수 없습니다.

전통적인 해결책은 **2PC(two-phase commit)**입니다. 그러나 2PC는 마이크로서비스에서는 거의 사용되지 않습니다. 왜냐하면:

  • 모든 당사자의 응답을 기다리는 동안 리소스를 잠그므로 처리량이 대폭 감소합니다.
  • 코디네이터가 단일 실패 지점이 됩니다.
  • Kafka, MongoDB 또는 타사 REST API 같은 많은 시스템이 XA 트랜잭션을 지원하지 않습니다.

3. Saga란 무엇인가?

Saga는 하나의 큰 트랜잭션을 일련의 로컬 트랜잭션으로 나눕니다. 각 로컬 트랜잭션은 서비스 내에서 실행되고 즉시 해당 서비스의 데이터베이스에 커밋됩니다. 각 단계에는 해당 단계를 ’취소’하는 비즈니스 작업인 **보상 트랜잭션(compensating transaction)**이 수반됩니다.

단계 작업 보상
1 상품 보유 (reserve) 상품 반환 (release)
2 결제 차감 (charge) 환금 (refund)
3 배송 생성 (ship) 배송 취소 (cancel)

단계 n이 실패하면, Saga는 단계 n-1, n-2, …, 1의 보상을 역순으로 실행합니다.

실제 롤백과의 두 가지 중요한 차이점:

  • 보상은 역사를 삭제하지 않습니다. 예를 들어 환금 거래 같은 새로운 변경을 생성합니다. 짧은 시간 동안 다른 서비스가 이미 중간 상태를 봤을 수 있습니다.
  • Saga는 **최종 일관성(eventual consistency)**을 보장하지만, ACID처럼 격리(isolation)를 보장하지 않습니다.

Saga를 조율하는 두 가지 방법이 있습니다:

  • Choreography(합창): 각 서비스가 이벤트를 수신하고 다음 단계를 스스로 결정합니다. 중앙 제어 장치가 없습니다.
  • Orchestration(오케스트레이션): orchestrator가 순서대로 각 서비스를 호출하고 오류 발생 시 보상을 실행할 책임이 있습니다.

4. Java 21을 사용한 Orchestration Saga

이 섹션에서는 내부 메커니즘을 명확하게 보기 위해 프레임워크에 의존하지 않는 순수 Java orchestrator를 구축합니다.

4.1. Saga 단계 정의

import java.util.function.Consumer;

public interface SagaStep<C> {
    String name();
    void execute(C context);
    void compensate(C context);

    static <C> SagaStep<C> of(String name, Consumer<C> action, Consumer<C> compensation) {
        return new SagaStep<>() {
            public String name() { return name; }
            public void execute(C ctx) { action.accept(ctx); }
            public void compensate(C ctx) { compensation.accept(ctx); }
        };
    }
}

각 단계는 두 가지 동작을 가집니다: 정방향 흐름의 execute와 롤백 흐름의 compensate. Generic C는 단계들 간에 공유되는 context로, 예를 들어 주문 정보입니다. Factory 메서드 of는 람다로 단계를 간결하게 선언할 수 있게 해줍니다.

4.2. Orchestrator

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;

public record SagaResult(boolean success, String failedStep, String reason) {
    static SagaResult ok() { return new SagaResult(true, null, null); }
    static SagaResult failed(String step, String reason) { return new SagaResult(false, step, reason); }
}

public class SagaOrchestrator<C> {
    private final List<SagaStep<C>> steps;

    public SagaOrchestrator(List<SagaStep<C>> steps) {
        this.steps = List.copyOf(steps);
    }

    public SagaResult run(C context) {
        Deque<SagaStep<C>> completed = new ArrayDeque<>();
        for (SagaStep<C> step : steps) {
            try {
                step.execute(context);
                completed.push(step);
            } catch (RuntimeException ex) {
                compensate(completed, context);
                return SagaResult.failed(step.name(), ex.getMessage());
            }
        }
        return SagaResult.ok();
    }

    private void compensate(Deque<SagaStep<C>> completed, C context) {
        while (!completed.isEmpty()) {
            SagaStep<C> step = completed.pop();
            try {
                step.compensate(context);
            } catch (RuntimeException ex) {
                // 프로덕션: 재시도를 위해 저장하고, 무시해서는 안 됩니다
                System.err.println("Compensation failed at " + step.name() + ": " + ex.getMessage());
            }
        }
    }
}

주요 사항:

  • completed는 스택입니다 (push/pop을 사용하는 ArrayDeque). 성공한 단계는 스택에 푸시되므로, 팝할 때 정확한 역순을 얻습니다.
  • 실패한 단계는 스택에 포함되지 않습니다. 아직 커밋되지 않았으므로 보상할 필요가 없습니다.
  • 실패한 보상은 나머지 보상을 중단하지 않아야 합니다. 따라서 각 compensate 호출은 자체 try/catch로 감싸집니다.

4.3. 서비스 모의 구현

import java.math.BigDecimal;
import java.util.*;

public record Order(String id, String customerId, String sku, int quantity, BigDecimal amount) {}

public class InventoryService {
    private final Map<String, Integer> stock = new HashMap<>(Map.of("BOOK-01", 10));
    public void reserve(Order o) {
        int left = stock.get(o.sku()) - o.quantity();
        if (left < 0) throw new IllegalStateException("Out of stock");
        stock.put(o.sku(), left);
    }
    public void release(Order o) { stock.merge(o.sku(), o.quantity(), Integer::sum); }
    public int available(String sku) { return stock.get(sku); }
}

public class PaymentService {
    private final Map<String, BigDecimal> balances = new HashMap<>(Map.of("alice", new BigDecimal("100")));
    public void charge(Order o) {
        BigDecimal balance = balances.get(o.customerId());
        if (balance.compareTo(o.amount()) < 0) throw new IllegalStateException("Insufficient funds");
        balances.put(o.customerId(), balance.subtract(o.amount()));
    }
    public void refund(Order o) { balances.merge(o.customerId(), o.amount(), BigDecimal::add); }
    public BigDecimal balance(String customerId) { return balances.get(customerId); }
}

public class ShippingService {
    private final Set<String> shipments = new HashSet<>();
    public void ship(Order o) { shipments.add(o.id()); }
    public void cancel(Order o) { shipments.remove(o.id()); }
    public boolean hasShipment(String orderId) { return shipments.contains(orderId); }
}

실제로는 위의 각 메서드는 REST/gRPC 호출이거나 다른 서비스로 보내는 메시지입니다. 이 모의 구현은 설명과 테스트용으로만 제공됩니다.

4.4. 주문 Saga로 조합

import java.math.BigDecimal;
import java.util.List;

public class OrderSagaDemo {
    public static void main(String[] args) {
        var inventory = new InventoryService();
        var payment = new PaymentService();
        var shipping = new ShippingService();

        var saga = new SagaOrchestrator<Order>(List.of(
            SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
            SagaStep.of("charge-payment", payment::charge, payment::refund),
            SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
        ));

        var cheap = new Order("ORD-1", "alice", "BOOK-01", 2, new BigDecimal("40"));
        var pricey = new Order("ORD-2", "alice", "BOOK-01", 3, new BigDecimal("500"));

        System.out.println(saga.run(cheap));
        System.out.println(saga.run(pricey));
        System.out.println("Stock: " + inventory.available("BOOK-01"));
        System.out.println("Balance: " + payment.balance("alice"));
    }
}

결과:

SagaResult[success=true, failedStep=null, reason=null]
SagaResult[success=false, failedStep=charge-payment, reason=Insufficient funds]
Stock: 8
Balance: 60

주문 ORD-1은 세 단계를 모두 실행합니다. 주문 ORD-2는 3권의 책을 보유했지만 결제 단계에서 실패합니다. 그러면 orchestrator는 inventory.release를 호출하므로, 재고는 5가 아닌 8로 돌아갑니다 (10 − 첫 번째 주문의 2).

5. 이벤트를 사용한 Choreography Saga

choreography에서는 어떤 서비스도 전체 흐름을 ‘알지’ 못합니다. 각 서비스는 작업을 완료한 후 이벤트를 발행하고, 다른 서비스가 그 이벤트에 반응합니다. 아래의 예제는 한 프로세스 내에서 Spring의 ApplicationEventPublisher를 사용하여 설명합니다. 실제 시스템에서는 이를 Kafka 또는 RabbitMQ로 대체합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
public record StockReserved(Order order) {}
public record PaymentFailed(Order order, String reason) {}

@Component
public class PaymentHandler {
    private final PaymentService payment;
    private final ApplicationEventPublisher events;

    public PaymentHandler(PaymentService payment, ApplicationEventPublisher events) {
        this.payment = payment;
        this.events = events;
    }

    @EventListener
    public void on(StockReserved event) {
        try {
            payment.charge(event.order());
        } catch (IllegalStateException ex) {
            events.publishEvent(new PaymentFailed(event.order(), ex.getMessage()));
        }
    }
}

@Component
public class InventoryCompensationHandler {
    private final InventoryService inventory;

    public InventoryCompensationHandler(InventoryService inventory) {
        this.inventory = inventory;
    }

    @EventListener
    public void on(PaymentFailed event) {
        inventory.release(event.order());
    }
}

PaymentHandler는 StockReserved를 수신합니다. 결제가 실패하면 PaymentFailed를 발행합니다. InventoryCompensationHandler가 이 이벤트를 수신하고 상품을 반환합니다. 코드가 실행되려면 InventoryService와 PaymentService도 bean으로 선언되어야 합니다.

두 유형의 빠른 비교:

  • Choreography는 흐름이 2-3개 단계만 있을 때 간단하고 결합도가 낮습니다. 흐름이 길어지면 로직이 여러 서비스에 분산되므로 추적하기 어렵고 이벤트 루프가 생기기 쉽습니다.
  • Orchestration은 흐름을 한 곳에 집중시키므로 읽기 쉽고, 테스트하기 쉽고, 상태를 유지하기 쉽습니다. 대신 orchestrator는 신중하게 관리해야 할 컴포넌트가 됩니다.

6. Compensation 동작 테스트

테스트 의존성 추가:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;

class OrderSagaTest {

    private final InventoryService inventory = new InventoryService();
    private final PaymentService payment = new PaymentService();
    private final ShippingService shipping = new ShippingService();

    private final SagaOrchestrator<Order> saga = new SagaOrchestrator<>(List.of(
        SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
        SagaStep.of("charge-payment", payment::charge, payment::refund),
        SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
    ));

    @Test
    void givenInsufficientFunds_whenRunSaga_thenStockIsReleased() {
        var order = new Order("ORD-9", "alice", "BOOK-01", 3, new BigDecimal("500"));

        SagaResult result = saga.run(order);

        assertThat(result.success()).isFalse();
        assertThat(result.failedStep()).isEqualTo("charge-payment");
        assertThat(inventory.available("BOOK-01")).isEqualTo(10);
        assertThat(payment.balance("alice")).isEqualByComparingTo("100");
        assertThat(shipping.hasShipment("ORD-9")).isFalse();
    }

    @Test
    void givenValidOrder_whenRunSaga_thenAllStepsCommitted() {
        var order = new Order("ORD-10", "alice", "BOOK-01", 1, new BigDecimal("30"));

        assertThat(saga.run(order).success()).isTrue();
        assertThat(inventory.available("BOOK-01")).isEqualTo(9);
        assertThat(payment.balance("alice")).isEqualByComparingTo("70");
        assertThat(shipping.hasShipment("ORD-10")).isTrue();
    }
}

첫 번째 테스트는 Saga의 가장 중요한 측면을 확인합니다: 실패 후 모든 부작용이 되돌려졌습니다. 재고는 10으로 돌아가고, 잔액은 유지되며, 배송이 생성되지 않습니다. 두 번째 테스트는 성공한 흐름이 세 단계 모두를 커밋하는지 확인합니다.

7. 흔한 실수와 예방 방법

주의: 이 글의 Orchestrator는 메모리에 상태를 보유합니다. 프로세스가 중간에 죽으면 어느 단계에 도달했는지 알 수 없으며, 보상할 수도 없습니다. 프로덕션에서는 각 단계 후에 Saga 상태(현재 단계, 완료된 단계)를 데이터베이스에 저장하고 재시작 시 재개할 수 있도록 합니다.

보상은 멱등성이어야 합니다(idempotent). 메시지가 여러 번 다시 전송될 수 있으므로, refund를 두 번 실행해도 두 번 환금되지 않아야 합니다. 처리된 거래의 orderId를 저장하고 중복 요청을 무시하세요.

보상이 ’포기’해서는 안 됩니다. 4.2절의 로그인 catch 블록은 설명용입니다. 실제로는 실패한 보상을 백오프(backoff)와 함께 재시도해야 합니다. 여러 번 시도 후에도 실패하면 사람이 처리하도록 dead-letter queue로 보냅니다.

Dual write. 서비스가 동시에 데이터베이스를 커밋하고 Kafka에 메시지를 보내면, 둘 중 하나가 실패할 수 있습니다. Transactional Outbox를 사용하세요: 같은 로컬 트랜잭션 내에서 이벤트를 outbox 테이블에 쓰고, 다른 프로세스가 그 테이블을 읽어 발행합니다.

격리 부족. 차감과 환금 사이에 사용자가 잔액이 일시적으로 감소한 것을 볼 수 있습니다. semantic lock을 사용하여 이를 완화할 수 있습니다. 예를 들어 Saga가 완료될 때까지 주문 상태를 PENDING으로 설정합니다. 또 다른 방법은 단계를 재정렬하여 가장 실패하기 쉬운 단계를 먼저 수행하는 것입니다.

보상할 수 없는 단계가 있습니다. 보낸 이메일은 회수할 수 없습니다. 이런 단계들(pivot 또는 retriable step이라고 함)을 Saga 끝에 배치하세요, 모든 실패 가능한 단계가 성공한 후에.

Saga를 사용하면 안 되는 경우:

  • 모든 데이터가 하나의 데이터베이스에 있습니다. 그러면 간단하게 로컬 트랜잭션을 사용하세요.
  • 비즈니스에서 강한 일관성이 절대적으로 필요하고 중간 상태를 허용할 수 없습니다. 예를 들어 일부 핵심 회계 원장 작업. 이 경우 서비스 경계를 다시 고려해야 합니다.
  • 흐름에 한 서비스만 데이터를 쓰고 다른 서비스는 읽기만 합니다.

8. 결론

Saga는 분산 트랜잭션을 보상 트랜잭션과 함께하는 일련의 로컬 트랜잭션으로 대체하므로, 2PC 없이 시스템이 최종 일관성을 달성할 수 있습니다. 스택을 사용하여 역순으로 보상하는 작은 orchestrator를 구축했고, 이벤트를 통한 choreography의 조율 방식을 보았으며, 오류가 발생하면 모든 부작용이 되돌려지는지 확인하는 테스트를 작성했습니다.

Saga를 프로덕션에 배포할 때 네 가지를 기억하세요: Saga 상태를 지속적으로 저장하고, 멱등성 있는 보상을 재시도하며, Transactional Outbox를 사용하여 dual write를 피하고, 보상할 수 없는 작업이 흐름 끝에 오도록 단계 순서를 설계합니다.

microservicessagadistributed transactionspring bootjava