프로그래밍 PROGRAMMING/아키텍쳐

분산 트랜잭션을 안전하게 처리하는 Saga 패턴 설계 가이드

매운할라피뇽 2026. 3. 11. 08:25
반응형

개요

마이크로서비스 아키텍처에서 여러 서비스에 걸친 데이터 일관성을 유지하는 일은 생각보다 훨씬 복잡합니다. 단일 데이터베이스를 사용하던 모놀리식 환경에서는 ACID 트랜잭션 하나로 해결할 수 있었지만, 서비스마다 독립된 데이터베이스를 보유하는 분산 환경에서는 이 방법이 통하지 않습니다. 2PC(Two-Phase Commit) 같은 분산 트랜잭션 프로토콜은 이론적으로 가능하지만, 서비스 간 강한 결합과 낮은 가용성이라는 현실적인 문제를 남깁니다.
Saga 패턴은 이 문제를 해결하기 위해 등장한 아키텍처 설계 기법입니다. 긴 트랜잭션을 여러 개의 로컬 트랜잭션으로 분해하고, 각 단계가 실패할 경우 보상 트랜잭션(Compensating Transaction)을 통해 이전 상태로 되돌리는 방식으로 최종 일관성(Eventual Consistency)을 보장합니다. 이 글에서는 Saga 패턴의 두 가지 구현 방식인 ChoreographyOrchestration을 비교하고, Java 코드 예제를 통해 실제 프로젝트에 적용하는 방법을 단계별로 살펴봅니다.


Saga 패턴의 두 가지 설계 방식

Saga 패턴을 구현하는 방법은 크게 두 가지로 나뉩니다.

Choreography 방식

각 서비스가 이벤트를 발행하고, 다른 서비스가 이를 구독해 연쇄적으로 동작하는 방식입니다. 중앙 조정자 없이 서비스들이 자율적으로 협력하므로 결합도가 낮다는 장점이 있지만, 전체 흐름을 한눈에 파악하기 어려워 디버깅이 복잡해질 수 있습니다.

Orchestration 방식

Saga Orchestrator라는 중앙 조정자가 각 서비스에 명령을 내리고, 응답에 따라 다음 단계를 결정하는 방식입니다. 전체 비즈니스 흐름이 오케스트레이터에 집중되므로 가시성이 높고, 실패 처리 로직을 한 곳에서 관리할 수 있습니다. 현업에서 복잡한 비즈니스 프로세스를 다룰 때 더 널리 채택됩니다.


Orchestration 기반 Saga 구현하기

주문(Order) → 결제(Payment) → 재고(Inventory) 순서로 처리되는 전자상거래 시나리오를 예로 들겠습니다. 각 단계가 실패하면 이전 단계의 보상 트랜잭션이 역순으로 실행됩니다.

아래는 오케스트레이터의 핵심 로직입니다. 각 단계를 Step 인터페이스로 추상화하고, 실패 시 compensate() 메서드를 역순으로 호출합니다.

// Saga 단계를 나타내는 인터페이스
public interface SagaStep<T> {
    void execute(T context);       // 정방향 트랜잭션
    void compensate(T context);    // 보상 트랜잭션
}

// 주문 생성 단계
public class CreateOrderStep implements SagaStep<OrderContext> {
    private final OrderService orderService;

    @Override
    public void execute(OrderContext ctx) {
        String orderId = orderService.createOrder(ctx.getItems());
        ctx.setOrderId(orderId);
        System.out.println("[1] 주문 생성 완료: " + orderId);
    }

    @Override
    public void compensate(OrderContext ctx) {
        orderService.cancelOrder(ctx.getOrderId());
        System.out.println("[1-보상] 주문 취소: " + ctx.getOrderId());
    }
}

// 결제 처리 단계
public class ProcessPaymentStep implements SagaStep<OrderContext> {
    private final PaymentService paymentService;

    @Override
    public void execute(OrderContext ctx) {
        String paymentId = paymentService.charge(ctx.getUserId(), ctx.getAmount());
        ctx.setPaymentId(paymentId);
        System.out.println("[2] 결제 완료: " + paymentId);
    }

    @Override
    public void compensate(OrderContext ctx) {
        paymentService.refund(ctx.getPaymentId());
        System.out.println("[2-보상] 환불 처리: " + ctx.getPaymentId());
    }
}

다음은 위 단계들을 조합해 실행하는 오케스트레이터입니다.

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

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

    public void execute(T context) {
        int executedCount = 0;
        try {
            for (SagaStep<T> step : steps) {
                step.execute(context);
                executedCount++;
            }
        } catch (Exception e) {
            System.out.println("Saga 실패 — 보상 트랜잭션 시작: " + e.getMessage());
            // 실패 지점부터 역순으로 보상 트랜잭션 실행
            for (int i = executedCount - 1; i >= 0; i--) {
                try {
                    steps.get(i).compensate(context);
                } catch (Exception compensateEx) {
                    // 보상 실패는 별도 Dead Letter Queue 등으로 처리
                    System.err.println("보상 트랜잭션 실패 (재시도 필요): " + compensateEx.getMessage());
                }
            }
        }
    }
}

// 실행 예시
List<SagaStep<OrderContext>> steps = List.of(
    new CreateOrderStep(orderService),
    new ProcessPaymentStep(paymentService),
    new ReserveInventoryStep(inventoryService)
);

SagaOrchestrator<OrderContext> orchestrator = new SagaOrchestrator<>(steps);
orchestrator.execute(new OrderContext(userId, items, amount));
// 정상 흐름 출력
[1] 주문 생성 완료: ORD-20260303-001
[2] 결제 완료: PAY-88291
[3] 재고 예약 완료: INV-55103

// 재고 부족으로 실패 시 출력
[1] 주문 생성 완료: ORD-20260303-002
[2] 결제 완료: PAY-88292
Saga 실패 — 보상 트랜잭션 시작: 재고 부족
[2-보상] 환불 처리: PAY-88292
[1-보상] 주문 취소: ORD-20260303-002

핵심 포인트: 오케스트레이터는 실패한 단계의 인덱스를 기억하고, 그 이전까지 실행된 단계들만 보상합니다. 보상 자체가 실패하는 경우를 대비해 Dead Letter Queue나 별도 재시도 메커니즘을 함께 설계해야 합니다.


Saga 패턴 설계 시 고려해야 할 사항

멱등성(Idempotency) 보장

네트워크 지연이나 재시도로 인해 동일한 명령이 중복 전달될 수 있습니다. 각 서비스의 execute()compensate() 메서드는 반드시 멱등성을 갖도록 설계해야 합니다. paymentIdorderId 같은 고유 식별자를 활용해 중복 처리를 방지하는 것이 일반적인 모범 사례(best practices)입니다.

Saga 상태 영속화

오케스트레이터 서비스 자체가 재시작되더라도 Saga의 진행 상태를 복구할 수 있어야 합니다. 각 단계의 완료 여부와 컨텍스트 정보를 데이터베이스에 저장하는 Saga Log 테이블을 도입하면 장애 복구 확장성을 크게 높일 수 있습니다.

고립성(Isolation) 부재 대응

Saga는 ACID의 I(Isolation)를 보장하지 않습니다. 트랜잭션이 진행 중인 중간 상태가 다른 요청에 노출될 수 있으므로, 시맨틱 잠금(Semantic Lock) 이나 주문 상태를 PENDING으로 표시하는 등의 방어 설계가 필요합니다.


맺음말

Saga 패턴은 마이크로서비스 환경에서 분산 트랜잭션의 복잡성을 관리하는 검증된 아키텍처 접근법입니다. 모든 상황에 적합한 것은 아니며, 서비스 수가 적거나 비즈니스 흐름이 단순한 경우에는 오히려 과한 설계가 될 수 있습니다. 그러나 결제·주문·물류처럼 여러 도메인이 협력하는 복잡한 비즈니스 프로세스에서는 Saga 패턴이 데이터 일관성과 서비스 자율성을 동시에 확보하는 실용적인 해법이 됩니다.

반응형