Java Reactive Programming 핵심 정리 시리즈 9편. Reactive Batching 완전 정복 — buffer(List 모음) vs window(Flux 내 Flux) vs groupBy(키별 분기) 차이, bufferTimeout 실전 패턴, groupBy 카디널리티 위험, 실시간 배치 분석 설계까지 택배 박스 비유로 친절하게 풀어쓴 9편.
이 글은 Java Reactive Programming 핵심 정리 시리즈의 아홉 번째 편입니다. 개별 아이템을 하나씩 처리하는 것만으로는 부족할 때가 많습니다. 5초마다 매출 보고서를 만들거나, 1000건마다 DB에 한꺼번에 쓰거나, 카테고리별로 데이터를 분리해서 처리해야 할 때 — Reactive Batching 연산자 세 가지가 해답입니다.
배치·윈도잉·그룹핑 단원이 처음엔 어렵게 느껴지는 이유
이유는 네 가지예요.
첫째, 결과 타입이 다단계 중첩이라 눈에 잘 안 들어옵니다. buffer는 Flux, >
window는 Flux, groupBy는 Flux — 제네릭 중첩이 한 단계씩 올라갈 때마다 머릿속 모델이 복잡해집니다.
둘째, buffer와 window는 비슷해 보이는데 처리 시점이 달라서 언제 뭘 쓰는지 기준이 잡히지 않아요.
셋째, groupBy를 무한 스트림에 잘못 쓰면 메모리가 서서히 고갈됩니다. 문서만 봐서는 왜 위험한지 직관적으로 보이지 않습니다.
넷째, GroupedFlux를 구독하지 않으면 데이터가 흐르지 않는다는 사실을 모르면 코드는 돌아가는데 아무 출력도 없는 황당한 상황을 만납니다.
해결법은 비유입니다. buffer = "택배 박스에 채워서 한꺼번에 발송", window = "박스 안에 또 박스 — 내부에서 실시간 처리", groupBy = "키별로 컨베이어 벨트 분기" — 이 세 그림으로 결과 타입과 처리 시점이 한 번에 잡힙니다.
배치 연산자 세 줄 비교
먼저 세 연산자의 핵심 차이를 한눈에 봅니다.
| 연산자 | 결과 타입 | 원소 접근 | 처리 시점 | 메모리 |
|---|---|---|---|---|
buffer | Flux | 완성 후 | 리스트 완성 시 | 높음 (리스트 보관) |
window | Flux | 실시간 | 새 윈도우 열릴 때 | 낮음 (스트리밍) |
groupBy | Flux | 실시간 | 새 키 발견 시 | 중간 |
여기서 시험 함정이 하나 있어요. buffer는 Flux이고 >
window는 Flux입니다. 두 이름이 비슷해서 결과 타입을 반대로 외우는 경우가 많아요. buffer는 박스에 담긴 리스트, window는 내부에 또 하나의 스트림이라는 점을 기억하세요.
buffer — "택배 박스에 채워서 발송"
buffer는 아이템을 List에 모아 리스트가 완성되면 한 번에 Flux로 발행합니다. 배치 DB 쓰기처럼 "N개 모아서 한 번에 처리"가 필요한 상황에 딱 맞아요.>
buffer(n) — 개수 기반
Flux<String> events = Flux.interval(Duration.ofMillis(200))
.map(i -> "event" + (i + 1));
// buffer(n): N개마다 리스트 방출
events.take(10)
.buffer(3)
.subscribe(list -> System.out.println("배치: " + list));
// 출력:
// 배치: [event1, event2, event3]
// 배치: [event4, event5, event6]
// 배치: [event7, event8, event9]
// 배치: [event10] ← 마지막 불완전 배치도 발행됨 (스트림 완료 시)
여기서 시험 함정이 하나 있어요. buffer(n)에서 스트림이 완료될 때 마지막 배치가 n개 미만이어도 발행됩니다. 항상 n개짜리 배치만 처리하려면 filter(list -> list.size() == n)을 추가해야 해요.
buffer(Duration) — 시간 기반
// 500ms마다 수집된 아이템 리스트 방출
events.buffer(Duration.ofMillis(500))
.subscribe(list ->
System.out.println("시간 배치 (" + list.size() + "개): " + list));
// 실용 사례: 5초마다 수익 보고서 생성
orderStream()
.buffer(Duration.ofSeconds(5))
.map(orders -> generateRevenueReport(orders))
.subscribe(report -> System.out.println("보고서: " + report));
buffer(n, skip) — 슬라이딩 윈도우
// buffer(3, 1): 크기 3, 1개씩 이동 (겹치는 윈도우)
events.take(5)
.buffer(3, 1)
.subscribe(list -> System.out.println("슬라이딩: " + list));
// 출력:
// 슬라이딩: [event1, event2, event3]
// 슬라이딩: [event2, event3, event4]
// 슬라이딩: [event3, event4, event5]
이동 평균 계산 등 겹치는 구간이 필요한 집계에 씁니다.
bufferTimeout — 개수 OR 시간, 먼저 충족되는 쪽
buffer(n)은 n개가 모이지 않으면 영원히 기다립니다. 데이터가 드문 스트림에서 마지막 배치가 묻혀 버리는 상황을 막으려면 bufferTimeout을 씁니다.
// 문제: buffer(3)은 3개가 안 모이면 무한 대기
events.take(10)
.concatWith(Flux.never()) // 스트림이 완료되지 않음
.buffer(3)
.subscribe(list -> System.out.println(list));
// event10 이후 무한 대기!
// 해결: bufferTimeout으로 시간 제한
events.take(10)
.concatWith(Flux.never())
.bufferTimeout(3, Duration.ofSeconds(1)) // 3개 또는 1초 중 먼저
.subscribe(list -> System.out.println("버퍼: " + list));
// 마지막 [event10]은 1초 후 타임아웃으로 발행됨
한 줄 정리 — buffer는 리스트로 모음, bufferTimeout은 개수+시간 중 먼저 충족 시 발행.
window — "박스 안에 또 박스 — 실시간 처리"
window는 buffer와 유사하지만 결과가 Flux입니다. 리스트로 모아서 내보내는 게 아니라 새 내부 스트림(Flux)을 열고 아이템을 실시간으로 흘려 보내요. 각 윈도우 내에서 filter, map 같은 연산자를 적용할 수 있어 메모리 효율이 높습니다.
// window(n): N개마다 새 Flux<String> 방출
events.take(10)
.window(3)
.flatMap(windowFlux -> {
// windowFlux는 최대 3개 아이템을 가진 내부 Flux
return windowFlux.collectList(); // 수동으로 List 수집 예시
})
.subscribe(list -> System.out.println("윈도우: " + list));
// 각 윈도우 내에서 필터 적용 (buffer로는 불가능 — 완성 후만 가능)
events.take(12)
.window(4)
.flatMap(windowFlux ->
windowFlux
.filter(e -> !e.contains("5")) // 윈도우 안에서 실시간 필터
.collectList()
)
.subscribe(filtered -> System.out.println("필터됨: " + filtered));
// windowTimeout: 시간 기반 윈도우
events.windowTimeout(5, Duration.ofSeconds(1))
.flatMap(w -> w.collectList())
.subscribe(list -> System.out.println("시간 윈도우: " + list));
여기서 시험 함정이 하나 있어요. window는 내부 Flux를 반드시 구독해야 합니다. flatMap으로 감싸지 않으면 내부 Flux가 구독되지 않아 데이터가 흐르지 않아요.
buffer vs window 선택 기준:
- 배치 작업(DB 저장, 보고서 생성) →
buffer(완성된 리스트 필요) - 스트리밍 처리, 실시간 집계 →
window(낮은 메모리, 중간 처리 가능)
한 줄 정리 — window는 Flux
groupBy — "키별로 컨베이어 벨트 분기"
groupBy는 스트림을 키 함수에 따라 여러 하위 스트림으로 분리합니다. 결과는 Flux이고, 각 GroupedFlux에는 .key() 메서드로 키를 꺼낼 수 있어요.
// 기본 예시: 짝수/홀수 분리
Flux.range(1, 10)
.groupBy(n -> n % 2 == 0 ? "짝수" : "홀수")
.flatMap(groupedFlux -> {
System.out.println("그룹 키: " + groupedFlux.key());
return groupedFlux
.collectList()
.map(list -> groupedFlux.key() + ": " + list);
})
.subscribe(System.out::println);
// 출력:
// 그룹 키: 홀수
// 그룹 키: 짝수
// 홀수: [1, 3, 5, 7, 9]
// 짝수: [2, 4, 6, 8, 10]
// 실용 예시: 카테고리별 주문 + 5초 배치 처리
orderStream()
.groupBy(Order::getCategory) // 카테고리별 분리
.flatMap(categoryGroup -> {
String category = categoryGroup.key();
return categoryGroup
.buffer(Duration.ofSeconds(5)) // 카테고리별로 5초 배치
.map(orders -> processOrders(category, orders));
})
.subscribe(result -> System.out.println("결과: " + result));
여기서 중요한 시험 함정이 하나 있어요. groupBy는 높은 카디널리티(고유 키 수)에서 메모리 위험이 있습니다. 각 고유 키마다 GroupedFlux가 하나씩 생성됩니다. 무한 스트림에서 아이템마다 다른 키를 사용하면 GroupedFlux가 무한히 쌓이면서 메모리가 고갈됩니다.
// 위험한 코드: 무한 스트림에서 각 아이템마다 다른 키
Flux.interval(Duration.ofMillis(100))
.groupBy(i -> i) // 각 아이템이 고유 키 → 무한 GroupedFlux 생성!
.flatMap(gf -> gf.take(1))
.subscribe(System.out::println);
// 메모리 누수 위험!
// 안전한 코드: 낮은 카디널리티 키
Flux.interval(Duration.ofMillis(100))
.groupBy(i -> i % 5) // 0,1,2,3,4 다섯 그룹만
.flatMap(gf -> gf.take(10).collectList().map(l -> gf.key() + ": " + l))
.subscribe(System.out::println);
그리고 GroupedFlux를 구독하지 않으면 데이터가 흐르지 않습니다. groupBy 다음에 flatMap 없이 subscribe만 하면 그룹 키는 출력되지만 내부 데이터는 나오지 않아요.
// 잘못된 코드: GroupedFlux를 구독하지 않음
Flux.range(1, 10)
.groupBy(n -> n % 2)
.subscribe(groupedFlux -> {
System.out.println("그룹: " + groupedFlux.key());
// 내부 데이터 구독 없음 → 아무것도 안 나옴!
});
// 올바른 코드: flatMap으로 내부 구독
Flux.range(1, 10)
.groupBy(n -> n % 2)
.flatMap(groupedFlux ->
groupedFlux.collectList()
.map(list -> groupedFlux.key() + ": " + list)
)
.subscribe(System.out::println);
groupBy 사용 3원칙
- 키의 카디널리티(고유 키 수)가 적을 때만 사용 (상태·카테고리·타입)
- 항상
flatMap으로 각 GroupedFlux 구독 - 무한 스트림에서는 각 그룹에
take나buffer추가
한 줄 정리 — groupBy는 키별 독립 스트림, 카디널리티 낮게 유지, flatMap으로 내부 구독 필수.
실전 패턴 — 실시간 배치 분석
실무에서 가장 자주 쓰는 패턴은 groupBy + buffer 조합입니다. 스트림을 카테고리별로 분기한 뒤 시간 배치로 집계하는 패턴이에요.
// 실시간 주문 데이터를 카테고리별로 그룹핑 후 5초마다 수익 집계
public void startRealtimeAnalysis() {
orderService.getOrderStream() // Flux<Order>
.groupBy(Order::getCategory)
.flatMap(categoryGroup ->
categoryGroup
.buffer(Duration.ofSeconds(5)) // 5초 배치
.map(orders -> {
int totalRevenue = orders.stream()
.mapToInt(Order::getPrice)
.sum();
return categoryGroup.key() + ": " + totalRevenue + "원";
})
)
.subscribe(report ->
System.out.println(LocalDateTime.now() + " - " + report));
Thread.sleep(60000);
}
// 슬라이딩 이동 평균 계산
Flux.interval(Duration.ofMillis(100))
.map(i -> Math.random() * 100)
.buffer(10, 1) // 10개 슬라이딩 윈도우
.map(window -> window.stream()
.mapToDouble(Double::doubleValue).average().orElse(0))
.subscribe(avg -> System.out.printf("이동 평균: %.2f%n", avg));
자세한 Project Reactor 배치 연산자 사양은 Project Reactor 공식 문서에서 확인할 수 있어요.
연산자 선택 가이드 요약
| 상황 | 연산자 |
|---|---|
| N개마다 일괄 처리 (DB 저장 등) | buffer(n) |
| T초마다 일괄 처리 (보고서 생성) | buffer(Duration) |
| N개 or T초 중 먼저 (실시간+배치) | bufferTimeout(n, Duration) |
| 배치 내 실시간 스트리밍 처리 | window(n) |
| 키별 독립 처리 (카테고리, 타입) | groupBy(keyFn) |
| 겹치는 윈도우 (이동 평균) | buffer(n, skip) |
Reactive Batching 완전 정복 — 압축 노트
여기까지가 배치·윈도잉·그룹핑의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- Reactive Batching = "택배 박스 포장·내부 박스·컨베이어 분기"
buffer=Flux— 리스트로 모음, 완성 후 발행- >
window=Flux— 내부 스트림, 실시간 처리 가능> groupBy=Flux— 키별 독립 스트림> - buffer vs window — buffer는 전체 배치 완성 후, window는 열리는 순간부터 실시간
- 메모리 — buffer가 더 많이 씀 (리스트 전체 보관), window는 스트리밍이라 낮음
buffer(n)마지막 불완전 배치도 발행됨 — 스트림 완료 시 n개 미만도 나옴bufferTimeout(n, Duration)— n개 or T초 중 먼저, 느린 스트림 마지막 배치 보장buffer(n, skip)— 슬라이딩 윈도우, 이동 평균 계산에 활용groupBy카디널리티 주의 — 고유 키 수가 많을수록 메모리 위험- 무한 스트림에서 고유 키 groupBy = 메모리 누수 — i % 5 같이 낮은 카디널리티 키 사용
- GroupedFlux 구독 안 하면 데이터 흐름 없음 — 반드시
flatMap으로 내부 구독 - groupBy 3원칙 — 낮은 카디널리티 / flatMap 내부 구독 / 무한 스트림은 take+buffer 추가
- DB 배치 저장 →
buffer(n) - 주기적 보고서 →
buffer(Duration) - 실시간 스트리밍 집계 →
window - 카테고리·타입·상태별 분기 →
groupBy - 이동 평균·겹치는 구간 →
buffer(n, skip) - 그룹핑 + 시간 배치 조합 →
groupBy+buffer(Duration)안에서
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — Reactive Programming 입문
- 2편 — Mono 완전 정복
- 3편 — Flux 완전 정복
- 4편 — 연산자 (map·flatMap·filter·reduce 등)
- 5편 — Hot & Cold Publishers
- 6편 — Threading & Schedulers
- 7편 — Backpressure (배압)
- 8편 — Publisher 결합 (zip·merge·concat 등)
- 9편 — Batching·Windowing·Grouping (현재 글)
- 10편 — Repeat & Retry
- 11편 — Sinks
- 12편 — Context
- 13편 — 단위 테스트 (StepVerifier)