마이크로서비스에서 분산 트랜잭션을 위한 Saga 패턴
1. 개요
모놀리식 애플리케이션에서는 메서드에 @Transactional을 붙이고 데이터베이스가 나머지를 처리합니다: 모든 것이 커밋되거나 모두 롤백됩니다. 마이크로서비스로 전환할 때는 각 서비스가 자신의 데이터베이스를 가지므로, 단일 ACID 트랜잭션으로 전체 비즈니스 로직을 포괄할 수 없습니다.
이 글은 이 문제를 해결하는 가장 일반적인 방법인 Saga 패턴을 소개합니다. 이 글을 읽은 후에는 Saga의 두 가지 유형(choreography와 orchestration)을 이해하고, Java 21로 간단한 Saga orchestrator를 작성하고, compensation 흐름을 테스트하며, Saga를 프로덕션에 배포할 때 흔히 발생하는 오류들을 알게 될 것입니다.
2. 문제: 하나의 비즈니스 로직, 여러 데이터베이스
세 가지 서비스로 이루어진 주문 흐름을 생각해 봅시다:
- InventoryService 창고에 상품을 보유합니다.
- PaymentService 고객의 돈을 차감합니다.
- 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를 피하고, 보상할 수 없는 작업이 흐름 끝에 오도록 단계 순서를 설계합니다.