개요

Java Stream API는 Java 8에서 도입된 이후 컬렉션 처리의 표준이 되었습니다. 하지만 많은 개발자가 기본적인 filter-map-collect 패턴에 머물러 있으며, 고급 기능이나 성능 최적화 기법을 충분히 활용하지 못하고 있습니다. 이 글에서는 Gatherer(Java 22+), 커스텀 Collector, 병렬 스트림의 올바른 사용법 등 고급 활용법을 다룹니다.

핵심 개념

Stream API의 고급 기능과 성능 특성을 이해하는 것이 중요합니다.

  • Gatherer (Java 22+): 중간 연산을 커스터마이징할 수 있는 새로운 API로, 기존에 불가능했던 스트림 변환을 가능하게 합니다.
  • 커스텀 Collector: Collector.of()로 복잡한 수집 로직을 캡슐화합니다.
  • Short-circuiting: findFirst(), anyMatch(), limit() 등은 전체 스트림을 순회하지 않습니다.
  • Lazy Evaluation: 중간 연산은 종단 연산이 호출될 때까지 실행되지 않습니다.
  • 병렬 스트림: ForkJoinPool을 사용하며, 데이터 크기와 연산 비용에 따라 효과가 달라집니다.

실전 예제

고급 스트림 활용 패턴들입니다.

// 1. Gatherer를 활용한 슬라이딩 윈도우
List<List<Integer>> windows = IntStream.rangeClosed(1, 10)
    .boxed()
    .gather(Gatherers.windowSliding(3))
    .toList();
// [[1,2,3], [2,3,4], [3,4,5], ... [8,9,10]]

// 2. 커스텀 Collector: 통계 집계
record SalesStats(long count, double total, double avg, double max) {}

Collector<Order, ?, SalesStats> salesStatsCollector = Collector.of(
    () -> new double[]{0, 0, Double.MIN_VALUE, 0},  // supplier
    (acc, order) -> {                                  // accumulator
        acc[0]++;
        acc[1] += order.amount();
        acc[2] = Math.max(acc[2], order.amount());
        acc[3] = acc[1] / acc[0];
    },
    (a, b) -> {                                        // combiner (병렬용)
        a[0] += b[0];
        a[1] += b[1];
        a[2] = Math.max(a[2], b[2]);
        a[3] = a[1] / a[0];
        return a;
    },
    acc -> new SalesStats((long) acc[0], acc[1], acc[3], acc[2])
);

SalesStats stats = orders.stream().collect(salesStatsCollector);

// 3. flatMap + groupingBy 복합 활용
Map<String, List<String>> tagToTitles = posts.stream()
    .flatMap(post -> post.tags().stream()
        .map(tag -> Map.entry(tag, post.title())))
    .collect(Collectors.groupingBy(
        Map.Entry::getKey,
        Collectors.mapping(Map.Entry::getValue, Collectors.toList())
    ));

성능 최적화 패턴입니다.

// 잘못된 예: 불필요한 박싱/언박싱
long sum = list.stream()
    .map(String::length)      // Stream<Integer> - 박싱 발생
    .reduce(0, Integer::sum);  // 언박싱 발생

// 올바른 예: 기본형 특화 스트림 사용
long sum = list.stream()
    .mapToInt(String::length)  // IntStream - 박싱 없음
    .sum();

// 병렬 스트림 올바른 사용
// 좋은 예: 큰 데이터 + 독립적 연산
long count = hugeList.parallelStream()      // 100만 건 이상
    .filter(item -> expensiveCheck(item))    // CPU 집약적
    .count();

// 나쁜 예: 작은 데이터 또는 순서 의존
list.parallelStream()                        // 100건 미만
    .forEach(item -> sharedList.add(item));  // 스레드 안전하지 않음!

// toList() vs collect(Collectors.toList()) - Java 16+
List<String> unmodifiable = stream.toList();          // 불변 리스트
List<String> modifiable = stream.collect(Collectors.toList()); // 가변 리스트

활용 팁

  • mapToInt(), mapToLong() 등 기본형 특화 스트림을 사용하면 오토박싱 비용을 제거하여 대량 데이터 처리 시 유의미한 성능 향상을 얻을 수 있습니다.
  • 병렬 스트림은 데이터가 10만 건 이상이고, 각 요소의 연산 비용이 높을 때만 효과적입니다. 작은 데이터에서는 오히려 느려집니다.
  • Stream.toList()는 불변 리스트를 반환하므로, 이후 수정이 필요한 경우 collect(Collectors.toList())를 사용하세요.
  • 디버깅 시 peek()을 활용하면 스트림 파이프라인 중간 결과를 확인할 수 있지만, 프로덕션에서는 제거하세요.
  • 재사용이 필요한 복잡한 수집 로직은 커스텀 Collector로 분리하여 코드 중복을 줄이세요.

마무리

Stream API는 단순한 반복문 대체를 넘어, 데이터 처리 파이프라인을 선언적으로 구성하는 강력한 도구입니다. Java 22의 Gatherer와 같은 새로운 기능이 계속 추가되고 있어, 표현력은 더욱 강해지고 있습니다. 성능 최적화 원칙을 숙지하고, 상황에 맞는 적절한 기법을 선택하면 생산성과 성능 모두를 잡을 수 있습니다.