카프카 심화편 (9~15편) 2편. 정적 바인딩의 한계(1 빈 = 1 destination 고정)와 StreamBridge가 푸는 방법, 런타임에 토픽 동적 결정 패턴, 콘텐츠 기반 라우팅 구현(switch + StreamBridge.send), 부수 효과로 발행하는 흐름, acknowledgement 호출 시점, 정적 vs 동적 바인딩 선택 기준까지.
이 글은 카프카 마스터 노트 시리즈의 열 번째 편입니다. 1편(SCS 기초)에서 정적 바인딩(Function·Supplier·Consumer)을 다졌다면, 이번엔 그 한계를 푸는 도구 — StreamBridge.
마이크로서비스에서 모든 라우팅을 정적으로 결정 못합니다. 주문 금액별 토픽, 사용자 등급별 채널, 동적 알림 발송… 런타임 결정이 필요할 때가 있어요. StreamBridge가 그 답.
처음 StreamBridge가 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, **"왜 정적 바인딩으로 안 되나"**가 막연합니다. Function 빈을 여러 개 만들면 되지 않나? 둘째, "부수 효과(side effect)" 라는 말이 모호합니다. 함수형 빈이 아닌데 어떻게 메시지가 발행되나?
해결법은 한 가지예요. "우체통 vs 우체부" 비유로 묶는 것. 정적 바인딩 = 항상 같은 우체통(고정 destination), StreamBridge = 들고 다니는 우체부(어디든 보낼 수 있음). 부수 효과 = 우체부가 처리 도중 슬쩍 보내는 편지. 이 그림이 잡히면 흐름이 보입니다.
정적 바인딩의 한계
@Bean
public Function<OrderEvent, PaymentEvent> orderToPayment() {
return order -> new PaymentEvent(...);
}
spring.cloud.stream.bindings.orderToPayment-out-0:
destination: payment-events
이 빈은 항상 payment-events 토픽으로. 다른 토픽으로 보낼 수 없음.
왜 문제?
// 주문 금액별 다른 토픽
public void process(OrderEvent order) {
if (order.getAmount() > 1000) {
// → high-value-orders 토픽
} else if (order.isUrgent()) {
// → urgent-orders 토픽
} else {
// → normal-orders 토픽
}
}
3개 다른 토픽 = 3개 빈? 빈 폭발. 그리고 동적 토픽명(order-{userId})은 빈으로 표현 불가.
StreamBridge — 동적 발행
@Service
public class OrderRouter {
@Autowired
private StreamBridge streamBridge;
public void route(OrderEvent order) {
String destination = decideTopic(order); // 런타임 결정
streamBridge.send(destination, order);
}
private String decideTopic(OrderEvent order) {
if (order.getAmount() > 1000) return "high-value-orders";
if (order.isUrgent()) return "urgent-orders";
return "normal-orders";
}
}
StreamBridge.send(destination, payload) — 어느 토픽이든 런타임에 결정.
여기서 정말 중요한 시험 함정 — StreamBridge는 Spring Cloud Stream 빈. @Autowired로 주입. 직접 new X.
콘텐츠 기반 라우팅 패턴
@Bean
public Consumer<Flux<OrderEvent>> orderRouter(StreamBridge streamBridge) {
return orders -> orders
.doOnNext(order -> {
String destination = switch (order.getType()) {
case PRIORITY -> "priority-orders";
case BULK -> "bulk-orders";
case STANDARD -> "standard-orders";
};
streamBridge.send(destination, order);
})
.subscribe();
}
여기서 시험 함정이 하나 있어요. StreamBridge.send()는 동기 호출. Reactive 흐름에 박을 땐 부수 효과(side effect)로 처리. doOnNext·tap 같은 곳에서.
Function + StreamBridge 결합
Function의 출력을 발행하는 동시에, 조건부 추가 발행:
@Bean
public Function<OrderEvent, PaymentEvent> processWithBranching(StreamBridge streamBridge) {
return order -> {
// 일반 출력 (정적 바인딩)
PaymentEvent payment = toPayment(order);
// 조건부 추가 (동적 라우팅)
if (order.isVip()) {
streamBridge.send("vip-orders", order);
}
return payment;
};
}
여기서 정말 중요한 시험 함정 — 함수형 빈의 반환값 = 정적 라우팅 / StreamBridge = 동적 라우팅. 둘 결합 가능.
acknowledgement 호출 시점
수동 acknowledgement 모드에선 처리 + 발행 모두 끝난 후 ack:
@Bean
public Consumer<Message<OrderEvent>> processOrder(StreamBridge streamBridge) {
return message -> {
OrderEvent order = message.getPayload();
Acknowledgment ack = message.getHeaders()
.get(KafkaHeaders.ACKNOWLEDGMENT, Acknowledgment.class);
try {
streamBridge.send("processed-orders", processed(order));
ack.acknowledge(); // 발행 성공 후
} catch (Exception e) {
// ack 안 함 → 재처리
}
};
}
정적 vs 동적 — 선택 기준
| 상황 | 선택 |
|---|---|
| 토픽 1개 고정 | 정적 (Function) |
| 토픽 N개 고정 (조건 분기) | StreamBridge |
동적 토픽명 (user-{id}) |
StreamBridge |
| Pure 변환 | 정적 |
| 부수 효과 발행 | StreamBridge |
여기서 시험 함정이 하나 있어요. 혼합도 OK. 함수형 빈으로 주 흐름 + StreamBridge로 보조 알림 흐름.
메시지 + 헤더 함께
streamBridge.send(
"events",
MessageBuilder.withPayload(order)
.setHeader("trace-id", traceId)
.setHeader("source", "order-service")
.build()
);
Message<T> 빌더로 헤더 포함.
출력 채널 동적 생성
spring.cloud.stream.dynamic-destination-cache-size: 10
자주 쓰는 destination 캐시. 매번 생성 비용 회피.
통합 테스트
@Autowired
private StreamBridge streamBridge;
@Autowired
private TestChannelBinderConfiguration testBinder;
@Test
void test() {
streamBridge.send("test-topic", testEvent);
// OutputDestination 검증
Message<byte[]> received = testBinder.getOutputDestination()
.receive(1000, "test-topic");
assertNotNull(received);
}
spring-cloud-stream-test-binder 의존성으로.
실전 예시 — 다단 라우팅
@Bean
public Consumer<Flux<OrderEvent>> orderProcessor(StreamBridge bridge) {
return orders -> orders
.doOnNext(order -> {
// 1. 알림 (조건부)
if (order.isFirstTime()) {
bridge.send("welcome-emails", order);
}
// 2. 분석 (모든 주문)
bridge.send("analytics-events", order);
// 3. 처리 (등급별)
String tier = order.getCustomerTier();
bridge.send("orders-" + tier, order);
// 4. 메트릭
bridge.send("metrics-events", new MetricEvent(order));
})
.subscribe();
}
한 컨슈머 + 4 동적 발행. 마이크로서비스 분산의 출발점.
동적 라우팅 안티패턴
여기서 정말 중요한 시험 함정 — 너무 많은 동적 토픽 = 운영 지옥. 토픽 폭발(order-user-1, order-user-2, ...) 시 메타데이터 부담·모니터링 어려움. 동적 토픽은 신중히. 사용자 단위는 Key 활용 권장.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 2편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- 정적 바인딩 한계 — 1 빈 = 1 destination 고정
- 다중 토픽·동적 토픽 표현 X
- StreamBridge = 런타임 동적 발행
@Autowired로 주입streamBridge.send(destination, payload)- 동기 호출 — Reactive 흐름엔 부수 효과로
doOnNext·tap에서 호출- 콘텐츠 기반 라우팅 — switch + StreamBridge 표준 패턴
- Function + StreamBridge 혼합 OK
- 함수형 빈 반환 = 정적 / StreamBridge = 동적
- ack는 처리 + 발행 모두 끝난 후
- 발행 실패 시 ack 안 하기
- 정적 vs 동적 선택 — 토픽 1 고정 vs N 조건 분기 vs 동적 토픽명
Message<T>빌더로 헤더 포함dynamic-destination-cache-size로 destination 캐시- 테스트 —
TestChannelBinderConfiguration+ OutputDestination - 너무 많은 동적 토픽은 운영 지옥 — 사용자별 토픽 X, Key 활용
- 메타데이터 부담·모니터링 어려움
시리즈 다른 편
- 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
공식 문서: Spring Cloud Stream — StreamBridge 에서 더 깊이.
다음 글(3편)에서는 Fan-Out / Fan-In — 1 메시지를 N 토픽으로, N 토픽을 1 컨슈머로 묶는 분산 패턴까지 풀어 갑니다.