Kafka 마스터 — Saga Orchestrator (오케스트레이터)

2026-05-03확률과 통계 마스터 노트

카프카 심화편 (9~15편) 6편. Saga 오케스트레이터가 중앙에서 모든 서비스를 지시하는 구조, 명령(Command) 토픽 + 응답(Response) 토픽 페어, 워크플로우 단계(step) 추상화, Happy Path와 Negative Path의 명시적 롤백 흐름, 코레오그래피와의 결정적 차이(흐름 가시성·디버깅·SPOF), 어느 패턴을 선택할 것인가의 가이드까지.

이 글은 카프카 마스터 노트 시리즈의 열네 번째 편입니다. 5편(코레오그래피)에서 이벤트로만 협력하는 패턴을 봤다면, 이번엔 반대 축 — 중앙 오케스트레이터가 모든 흐름을 지시하는 패턴.

코레오그래피의 흐름 추적 어려움을 해결합니다. 대신 SPOF 위험·중앙 의존도가 트레이드오프. 어느 쪽이 더 나은가는 시스템 복잡도에 달림.

처음 오케스트레이터가 어렵게 느껴지는 이유

처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, 명령 토픽 vs 응답 토픽이 한 번에 등장합니다. 왜 두 종류가 필요? 둘째, **"코레오그래피와 뭐가 결정적으로 다른가"**가 막연합니다. 둘 다 결국 Kafka 메시지로 통신하는 거 아닌가?

해결법은 한 가지예요. "교향악단 지휘자" 비유로 묶는 것. 코레오그래피 = 재즈 잼(즉흥, 서로 듣고 반응), 오케스트레이터 = 지휘자(중앙에서 지휘). 명령 = 지휘봉, 응답 = 연주 결과. 이 그림이 잡히면 두 패턴의 차이가 결정적으로 보입니다.

오케스트레이터 큰 그림

[Order Service (Orchestrator)]
   ↓ payment-request 토픽 (명령)
[Payment Service]
   ↓ payment-response 토픽 (응답)
[Order Service]
   ↓ inventory-request 토픽 (명령, 결제 OK 시)
[Inventory Service]
   ↓ inventory-response 토픽 (응답)
[Order Service]
   ↓ shipping-request 토픽 (명령, 재고 OK 시)
[Shipping Service]
   ↓ shipping-response 토픽 (응답)
[Order Service]
   → 모두 OK → OrderCompleted
   → 실패 → 이전 단계 롤백

Order Service가 모든 단계를 직접 지시. 다른 서비스는 명령을 받고 응답만.

명령 토픽 vs 응답 토픽

각 서비스에 두 토픽 페어:

서비스 명령 토픽 응답 토픽
Payment payment-request payment-response
Inventory inventory-request inventory-response
Shipping shipping-request shipping-response

각 서비스 = "request 받아서 처리 → response 발행".

여기서 정말 중요한 시험 함정 — 코레오그래피는 도메인 이벤트, 오케스트레이터는 명령/응답. 도메인 이벤트(OrderCreated) = 사실 통보 / 명령(DeductPayment) = 지시. 의미 다름.

명령·응답 메시지 구조

// 명령 (Command)
public sealed interface PaymentCommand
    permits DeductPayment, RefundPayment {
}
public record DeductPayment(String orderId, BigDecimal amount, String userId) implements PaymentCommand {}
public record RefundPayment(String orderId) implements PaymentCommand {}

// 응답 (Response)
public sealed interface PaymentResponse
    permits PaymentDeducted, PaymentDeclined, PaymentRefunded {
}
public record PaymentDeducted(String orderId) implements PaymentResponse {}
public record PaymentDeclined(String orderId, String reason) implements PaymentResponse {}

Happy Path — 오케스트레이터 흐름

// Order Service (Orchestrator)
@Bean
public Consumer<OrderCommand> orderHandler() {
    return command -> {
        if (command instanceof CreateOrder create) {
            // 1. Order 생성 (DB)
            orderRepo.save(create.toEntity());

            // 2. Payment 단계 시작
            streamBridge.send("payment-request",
                new DeductPayment(create.orderId(), create.amount(), create.userId()));
        }
    };
}

@Bean
public Consumer<PaymentResponse> onPaymentResponse() {
    return response -> {
        switch (response) {
            case PaymentDeducted ok -> {
                // 3. Inventory 단계
                streamBridge.send("inventory-request",
                    new DeductInventory(ok.orderId(), getItems(ok.orderId())));
            }
            case PaymentDeclined fail -> {
                // 결제 실패 → Order 취소
                orderRepo.markFailed(fail.orderId());
            }
        }
    };
}

@Bean
public Consumer<InventoryResponse> onInventoryResponse() {
    return response -> {
        switch (response) {
            case InventoryDeducted ok -> {
                // 4. Shipping 단계
                streamBridge.send("shipping-request",
                    new ScheduleShipping(ok.orderId()));
            }
            case InventoryDeclined fail -> {
                // 재고 실패 → Payment 롤백
                streamBridge.send("payment-request",
                    new RefundPayment(fail.orderId()));
                orderRepo.markFailed(fail.orderId());
            }
        }
    };
}

각 응답마다 다음 단계 결정.

Negative Path — 명시적 롤백

1. CreateOrder → Payment OK → Inventory FAIL

2. Order Service:
   InventoryDeclined 받음
   → 명시적으로 RefundPayment 명령 발행
   → payment-request 토픽

3. Payment Service:
   RefundPayment 받음 → 환불 처리
   → PaymentRefunded 발행

4. Order Service:
   PaymentRefunded 받음 → Order 취소 완료

여기서 정말 중요한 시험 함정 — 오케스트레이터의 보상 = 명시적 명령 발행. 코레오그래피는 OrderCanceled 이벤트만, 오케스트레이터는 RefundPayment 같은 직접 명령. 흐름이 명확.

워크플로우 상태 머신

오케스트레이터 = 사실상 상태 머신:

[Initial]
   ↓ CreateOrder
[OrderCreated]
   ↓ Payment 명령
[PaymentPending]
   ↓ PaymentDeducted (성공)
[PaymentCompleted]
   ↓ Inventory 명령
[InventoryPending]
   ↓ InventoryDeducted
[InventoryCompleted]
   ↓ Shipping 명령
[ShippingPending]
   ↓ ShippingScheduled
[Completed]

실패 분기:
[PaymentPending] → [Failed]
[InventoryPending] → 보상 → [Failed]
[ShippingPending] → 보상 (Inventory + Payment) → [Failed]

상태를 DB에 저장 → 재시작 시 이어서.

CREATE TABLE order_workflow (
    order_id VARCHAR PRIMARY KEY,
    state VARCHAR,         -- 현재 상태
    last_step VARCHAR,
    updated_at TIMESTAMP
);

Step 인터페이스 추상화

public interface SagaStep<C, R> {
    String name();
    Mono<C> command(SagaContext context);
    void onResponse(R response, SagaContext context);
    void compensate(SagaContext context);
}

@Component
public class PaymentStep implements SagaStep<DeductPayment, PaymentResponse> {
    public String name() { return "PAYMENT"; }
    public Mono<DeductPayment> command(SagaContext ctx) {
        return Mono.just(new DeductPayment(ctx.orderId(), ctx.amount(), ctx.userId()));
    }
    public void onResponse(PaymentResponse resp, SagaContext ctx) { ... }
    public void compensate(SagaContext ctx) {
        bridge.send("payment-request", new RefundPayment(ctx.orderId()));
    }
}

각 단계를 객체로. 코드 명확.

코레오그래피 vs 오케스트레이터 — 결정적 차이

측면 Choreography Orchestrator
흐름 가시성 분산·추적 어려움 한 곳에 명확
디버깅 여러 로그 추적 오케스트레이터 로그 하나
결합도 매우 낮음 중앙에 집중
확장 서비스 추가 쉬움 오케스트레이터 변경
SPOF 없음 오케스트레이터
로직 위치 분산 중앙
이벤트 종류 도메인 이벤트 명령 + 응답
적합 규모 소수 서비스·단순 흐름 다수 서비스·복잡 분기

여기서 정말 중요한 시험 함정 — 선택 기준 = "흐름 복잡도". 단순 = 코레오그래피, 복잡 = 오케스트레이터. 5 서비스+ 또는 분기 많으면 오케스트레이터가 디버깅·운영 훨씬 쉬움.

오케스트레이터 단점 보완

SPOF 방어

오케스트레이터를 여러 인스턴스로 (HA)
+ Consumer Group으로 자연스러운 분배
+ 워크플로우 상태 DB로 영속화

결합도 완화

명령·응답 메시지 = 인터페이스
서비스가 다른 명령에 의존 X
오케스트레이터만 모든 명령 알고 있음

통합 테스트

@SpringBootTest
class OrchestratorTest {
    @Test
    void happyPath() {
        // 주문 생성
        publish("order-commands", new CreateOrder("1", new BigDecimal("100"), "user-1"));

        // 단계별 응답 시뮬레이션
        publish("payment-response", new PaymentDeducted("1"));
        publish("inventory-response", new InventoryDeducted("1"));
        publish("shipping-response", new ShippingScheduled("1"));

        // 최종 상태 검증
        await().untilAsserted(() ->
            assertThat(orderRepo.findById("1").getStatus()).isEqualTo(COMPLETED)
        );
    }

    @Test
    void negativePath() {
        publish("order-commands", new CreateOrder("2", ...));
        publish("payment-response", new PaymentDeducted("2"));
        publish("inventory-response", new InventoryDeclined("2", "Out of stock"));

        // RefundPayment 명령이 발행됐는지 검증
        StepVerifier.create(receiver.receive("payment-request").take(1))
            .expectNextMatches(c -> c.value() instanceof RefundPayment)
            .verifyComplete();
    }
}

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 6편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • Orchestrator = 중앙에서 모든 흐름 지시
  • 다른 서비스 = 명령 받고 응답만
  • 명령 토픽 + 응답 토픽 페어 (per 서비스)
  • payment-request / payment-response
  • 코레오 = 도메인 이벤트 / 오케스 = 명령 + 응답
  • 사실 통보 vs 지시
  • Happy Path = 응답마다 다음 단계 결정
  • Negative Path = 명시적 보상 명령 발행
  • 코레오 = OrderCanceled / 오케스 = RefundPayment
  • 오케스 = 상태 머신 (워크플로우 DB)
  • Step 인터페이스 — name·command·onResponse·compensate
  • 코레오 vs 오케스 결정적 차이
  • 흐름 가시성 — 분산 vs 중앙
  • 디버깅 — 어려움 vs 쉬움
  • 결합도 — 낮음 vs 중앙 집중
  • SPOF — 없음 vs 오케스트레이터
  • 단순 = 코레오 / 복잡(5+, 분기 多) = 오케스
  • 오케스 SPOF 방어 — HA 인스턴스 + 워크플로우 DB
  • 결합도 완화 — 명령은 오케스만 알고 있음
  • 테스트 — 단계별 응답 시뮬레이션 + 최종 상태 검증

시리즈 다른 편

공식 문서: Microservices Patterns — Saga Orchestration 에서 더 깊이.

다음 글(7편, 마지막)에서는 Transactional Outbox — DB와 Kafka 발행의 원자성을 보장하는 패턴까지 시리즈 마무리.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!