카프카 심화편 (9~15편) 3편. Fan-Out 패턴(1 메시지 → N 토픽)이 토픽별 독립 보존·부하 분산을 가능케 하는 원리, Fan-In 패턴(N 토픽 → 1 컨슈머)으로 여러 스트림을 한 곳에서 집계, Flux.zip/combineLatest로 Fan-In 구현, 토픽 분리 vs 통합의 트레이드오프, Kafka Streams와의 차이까지.
이 글은 카프카 마스터 노트 시리즈의 열한 번째 편입니다. 2편(StreamBridge)에서 동적 라우팅을 봤다면, 이번엔 메시지의 분산·집계 기본 패턴 — Fan-Out / Fan-In.
마이크로서비스 통신의 두 축. 하나의 이벤트를 여러 서비스가 받아 처리(Fan-Out), 여러 서비스 결과를 하나로 모음(Fan-In). 이 두 패턴이 분산 시스템의 토대.
처음 Fan-Out / Fan-In이 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, 이름이 추상적입니다. Fan-Out·Fan-In이 정확히 어떤 모양? 둘째, **"왜 토픽을 분리하나"**가 막연합니다. 한 토픽으로 통합하면 안 되나?
해결법은 한 가지예요. "부채(Fan)" 모양 비유로 묶는 것. Fan-Out = 부채가 펼쳐지듯 하나가 N으로, Fan-In = 부채가 접히듯 N이 하나로. 토픽 분리 = 각 부서가 자기 우편함을 가짐, 통합 = 모두가 한 우편함을 공유. 이 그림이 잡히면 트레이드오프가 보입니다.
Fan-Out — 1 → N 분배
[OrderCreated 이벤트]
↓
┌───┴───┬─────┬─────┐
↓ ↓ ↓ ↓
[Payment] [Inv] [Ship] [Notif]
한 이벤트를 여러 서비스가 받아 처리.
두 가지 구현 방식
방식 1 — 단일 토픽 + 다중 그룹
order-events 토픽
├── group=payment-service
├── group=inventory-service
├── group=shipping-service
└── group=notification-service
각 그룹이 독립적으로 모든 메시지 소비. 4편(기초편 (1~8편))에서 본 패턴.
장점 — 단순, 토픽 적음. 단점 — 토픽별 보존 정책 같음, 모든 서비스가 모든 메시지 봄.
방식 2 — 다중 토픽 (StreamBridge)
public void publish(OrderEvent order) {
streamBridge.send("payment-events", order);
streamBridge.send("inventory-events", order);
streamBridge.send("shipping-events", order);
streamBridge.send("notification-events", order);
}
[OrderCreated]
↓ Fan-Out (multiple sends)
├── payment-events 토픽 → Payment Service
├── inventory-events 토픽 → Inventory Service
├── shipping-events 토픽 → Shipping Service
└── notification-events 토픽 → Notification Service
장점:
- 토픽별 독립 보존 정책 — 알림은 1일, 결제는 30일
- 각 서비스 부하 분리
- 토픽 단위 보안 (ACL 차등)
- 스키마 진화 독립
단점 — 토픽 수 늘어남, 발행 측 부담.
여기서 정말 중요한 시험 함정 — 마이크로서비스에선 다중 토픽 권장. 각 도메인이 자기 토픽 가짐 = 명확한 경계. 서비스 추가도 토픽 신설로 격리.
Fan-In — N → 1 집계
[Payment] → payment-events ─┐
[Inventory] → inventory-events ─┼→ [Order Service] (모두 모음)
[Shipping] → shipping-events ─┘
Order Service가 여러 결과를 모아 최종 상태 결정.
단순 Fan-In — 다중 구독
@Bean
public Consumer<PaymentEvent> onPayment() { ... }
@Bean
public Consumer<InventoryEvent> onInventory() { ... }
@Bean
public Consumer<ShippingEvent> onShipping() { ... }
각 토픽에 별도 컨슈머. DB로 상태 모음.
동기 집계 — Flux.zip
@Bean
public Function<Flux<OrderEvent>, Flux<OrderResult>> aggregate() {
return orders -> Flux.zip(
kafkaConsumer.receiveFrom("payment-events"),
kafkaConsumer.receiveFrom("inventory-events"),
kafkaConsumer.receiveFrom("shipping-events")
)
.map(tuple -> aggregate(tuple.getT1(), tuple.getT2(), tuple.getT3()));
}
여기서 시험 함정이 하나 있어요. Flux.zip은 같은 인덱스끼리 묶음. 모든 스트림이 같은 페이스로 도착해야. Order별 매칭은 ID 기반 별도 처리.
실전 — 주문 결과 집계
여러 서비스 응답을 Order Service가 모음:
@Service
public class OrderAggregator {
private Map<String, OrderState> states = new ConcurrentHashMap<>();
@Bean
public Consumer<PaymentEvent> onPayment() {
return event -> {
states.computeIfAbsent(event.getOrderId(), k -> new OrderState())
.setPayment(event);
tryComplete(event.getOrderId());
};
}
@Bean
public Consumer<InventoryEvent> onInventory() {
return event -> {
states.computeIfAbsent(event.getOrderId(), k -> new OrderState())
.setInventory(event);
tryComplete(event.getOrderId());
};
}
private void tryComplete(String orderId) {
OrderState state = states.get(orderId);
if (state.isComplete()) {
// 둘 다 도착 → 최종 처리
states.remove(orderId);
publishResult(state);
}
}
}
여기서 정말 중요한 시험 함정 — 메모리 기반 집계는 인스턴스 다운에 취약. 운영 환경엔 DB 또는 Redis 사용. 또는 Kafka Streams의 KTable.
토픽 분리 전략
도메인 단위
order-events (주문 도메인)
payment-events (결제 도메인)
inventory-events (재고 도메인)
이벤트 종류별
order-created
order-updated
order-cancelled
우선순위별
high-priority-events
normal-priority-events
low-priority-events
데이터 양·보존별
events-7days
events-30days
events-365days
여기서 시험 함정이 하나 있어요. 너무 세분화하면 토픽 폭발. 한 도메인 안 이벤트는 한 토픽에 + 헤더로 종류 구분도 가능.
Fan-Out vs Kafka Streams
Kafka Streams는 스트림 처리 라이브러리 — branching, joining, aggregation 모두 자체 제공.
// Kafka Streams
stream.split()
.branch((k, v) -> v.isHighValue(), Branched.as("high"))
.branch((k, v) -> v.isUrgent(), Branched.as("urgent"))
.defaultBranch();
| 측면 | SCS + StreamBridge | Kafka Streams |
|---|---|---|
| 학습 곡선 | 낮음 | 높음 |
| 상태 관리 | DB·Redis | KTable·StateStore |
| Reactive | O | X |
| 복잡 패턴 | 직접 구현 | 라이브러리 제공 |
여기서 정말 중요한 시험 함정 — WebFlux 마이크로서비스 → SCS / 데이터 분석 파이프라인 → Kafka Streams. 도구 선택이 다름.
Fan-Out 시 일관성
여러 토픽에 발행 시:
streamBridge.send("payment-events", order); // 성공
streamBridge.send("inventory-events", order); // 실패!
부분 발행 = 일관성 깨짐. 해결 — Outbox Pattern (이 시리즈 15편).
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 3편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- Fan-Out = 1 → N 분배 (부채 펼치듯)
- Fan-In = N → 1 집계 (부채 접듯)
- Fan-Out 두 방식 — 단일 토픽 + 다중 그룹 / 다중 토픽
- 마이크로서비스 = 다중 토픽 권장 (도메인 경계)
- 토픽별 독립 — 보존·부하·보안·스키마
- Fan-In — 다중 구독 / Flux.zip
Flux.zip은 같은 인덱스 묶음 (페이스 같아야)- ID별 매칭은 별도 처리 (Map·DB·Redis)
- 메모리 집계는 인스턴스 다운에 취약 — DB 또는 Redis
- 또는 Kafka Streams KTable
- 토픽 분리 전략 — 도메인 / 이벤트 종류 / 우선순위 / 보존 기간
- 너무 세분화 = 토픽 폭발
- 도메인 안 이벤트는 한 토픽 + 헤더로 종류 구분 가능
- SCS + StreamBridge vs Kafka Streams
- WebFlux 마이크로서비스 → SCS
- 데이터 파이프라인 → Kafka Streams
- 학습 곡선·상태 관리·Reactive·복잡 패턴 차이
- Fan-Out 시 부분 발행 = 일관성 깨짐 → Outbox Pattern (7편)
시리즈 다른 편
- 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 — Fan-Out 에서 더 깊이.
다음 글(4편)에서는 Spring Cloud Stream Tips — content-type, Native Encoding, DLT, 파티셔닝, typeId까지 풀어 갑니다.