카프카 심화편 (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
- 결합도 완화 — 명령은 오케스만 알고 있음
- 테스트 — 단계별 응답 시뮬레이션 + 최종 상태 검증
시리즈 다른 편
- 1편 — EDA·Kafka 기초·KRaft
- 2편 — Topic·Partition·Offset
- 3편 — Producer·Consumer 동작
- 4편 — Consumer Group·리밸런싱
- 5편 — Reactor Kafka
- 6편 — Cluster·HA·Best Practices
- 7편 — 배치·에러·트랜잭션
- 8편 — Spring Kafka·테스트·보안
- 9편 — Spring Cloud Stream 기초
- 10편 — StreamBridge 동적 라우팅
- 11편 — Fan-Out / Fan-In
- 12편 — SCS Tips & Tricks
- 13편 — Saga 코레오그래피
- 14편 — Saga 오케스트레이터 (현재 글)
- 15편 — Transactional Outbox
공식 문서: Microservices Patterns — Saga Orchestration 에서 더 깊이.
다음 글(7편, 마지막)에서는 Transactional Outbox — DB와 Kafka 발행의 원자성을 보장하는 패턴까지 시리즈 마무리.