Reactive Batching 완전 정복 — 배치·윈도잉·그룹핑 핵심 정리

2026-05-03AWS SAA-C03 스터디

Java Reactive Programming 핵심 정리 시리즈 9편. Reactive Batching 완전 정복 — buffer(List 모음) vs window(Flux 내 Flux) vs groupBy(키별 분기) 차이, bufferTimeout 실전 패턴, groupBy 카디널리티 위험, 실시간 배치 분석 설계까지 택배 박스 비유로 친절하게 풀어쓴 9편.

📚 Java Reactive Programming 핵심 정리 · 9편 / 14편 — 배치·윈도잉·그룹핑 핵심 정리

이 글은 Java Reactive Programming 핵심 정리 시리즈의 아홉 번째 편입니다. 개별 아이템을 하나씩 처리하는 것만으로는 부족할 때가 많습니다. 5초마다 매출 보고서를 만들거나, 1000건마다 DB에 한꺼번에 쓰거나, 카테고리별로 데이터를 분리해서 처리해야 할 때 — Reactive Batching 연산자 세 가지가 해답입니다.

배치·윈도잉·그룹핑 단원이 처음엔 어렵게 느껴지는 이유

이유는 네 가지예요.

첫째, 결과 타입이 다단계 중첩이라 눈에 잘 안 들어옵니다. bufferFlux>, windowFlux>, groupByFlux> — 제네릭 중첩이 한 단계씩 올라갈 때마다 머릿속 모델이 복잡해집니다.

둘째, bufferwindow는 비슷해 보이는데 처리 시점이 달라서 언제 뭘 쓰는지 기준이 잡히지 않아요.

셋째, groupBy를 무한 스트림에 잘못 쓰면 메모리가 서서히 고갈됩니다. 문서만 봐서는 왜 위험한지 직관적으로 보이지 않습니다.

넷째, GroupedFlux를 구독하지 않으면 데이터가 흐르지 않는다는 사실을 모르면 코드는 돌아가는데 아무 출력도 없는 황당한 상황을 만납니다.

해결법은 비유입니다. buffer = "택배 박스에 채워서 한꺼번에 발송", window = "박스 안에 또 박스 — 내부에서 실시간 처리", groupBy = "키별로 컨베이어 벨트 분기" — 이 세 그림으로 결과 타입과 처리 시점이 한 번에 잡힙니다.

배치 연산자 세 줄 비교

먼저 세 연산자의 핵심 차이를 한눈에 봅니다.

연산자결과 타입원소 접근처리 시점메모리
bufferFlux>완성 후리스트 완성 시높음 (리스트 보관)
windowFlux>실시간새 윈도우 열릴 때낮음 (스트리밍)
groupByFlux>실시간새 키 발견 시중간

여기서 시험 함정이 하나 있어요. bufferFlux>이고 windowFlux>입니다. 두 이름이 비슷해서 결과 타입을 반대로 외우는 경우가 많아요. 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 — "박스 안에 또 박스 — 실시간 처리"

windowbuffer와 유사하지만 결과가 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원칙

  1. 키의 카디널리티(고유 키 수)가 적을 때만 사용 (상태·카테고리·타입)
  2. 항상 flatMap으로 각 GroupedFlux 구독
  3. 무한 스트림에서는 각 그룹에 takebuffer 추가

한 줄 정리 — 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) 안에서

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

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

답글 남기기

error: Content is protected !!