JAVA

Java Stream 실전 활용 & 주의점: groupingBy, flatMap, Optional 연계부터 병렬 스트림 함정까지

IT Lab 2026. 2. 22. 20:00

Java 17 기준 Stream을 실무에서 자주 쓰는 groupingBy/flatMap/Optional 패턴으로 정리하고, 성능 이슈와 parallelStream 함정을 안전하게 피하는 방법을 설명합니다.

 

Stream API를 “필터-맵-수집” 정도로만 쓰다 보면, 조금만 복잡한 요구사항(그룹핑, 중첩 컬렉션 펼치기, Optional 연계)이 나오자마자 코드가 급격히 읽기 어려워질 때가 있어요. 게다가 성능까지 신경 쓰기 시작하면 “Stream이 느린가?” “parallelStream으로 해결하면 되나?” 같은 고민이 따라옵니다. 이번 글에서는 실무에서 자주 부딪히는 패턴과 함정을 한 번에 정리해 보겠습니다.

핵심 개념: Java Stream을 “읽기 좋게, 안전하게, 빠르게” 쓰는 기준

Java Stream 파이프라인(소스-중간연산-최종연산) 흐름 다이어그램

1) groupingBy: “분류”가 아니라 “요약”까지 한 번에 끝내기

Collectors.groupingBy()는 단순히 리스트를 키별로 묶는 것에서 끝나지 않습니다. downstream collector(두 번째 인자)를 붙이면 집계/요약을 그룹핑과 동시에 처리할 수 있어요.
예를 들어 “부서별 인원 목록”이 아니라 “부서별 평균 급여, 인원 수” 같은 결과를 만들 때, 중간 자료구조를 만들지 않는 것이 포인트입니다.

2) flatMap: 중첩 구조를 “펼치는” 순간이 가독성의 분기점

map()은 1:1 변환이고, flatMap()은 1:N 변환 결과를 한 줄로 펼칩니다.
실무에서는 List<List<T>>, List<Set<T>>, “주문 -> 주문항목들” 같은 구조가 흔해서, flatMap()을 잘 쓰면 루프 중첩이 사라지고 의도가 선명해집니다.

3) Optional 연계: “값이 있을 때만 스트림에 흘려보내기”

Java 9+에서 Optional.stream()이 추가되면서, Optional을 억지로 isPresent()로 분기하지 않아도 됩니다.
Optional을 “조건부로 0개 또는 1개를 흘려보내는 스트림”으로 취급하면, 파이프라인이 훨씬 깔끔해져요.

4) 성능 이슈: Stream 자체보다 “할당/박싱/자료구조”가 병목인 경우가 많습니다

Stream이 느리다고 느껴지는 대부분의 케이스는 아래 중 하나입니다.

  • 불필요한 중간 컬렉션 생성 (예: collect(toList()) 후 다시 stream)
  • 오토박싱/언박싱 (예: Stream<Integer> vs IntStream)
  • groupingBy로 큰 Map을 만들면서 메모리 압박
  • sorted() 같은 고비용 연산을 무심코 사용

5) 병렬 스트림 함정: “빠를 수도 있지만, 더 자주 느려지고 위험해집니다”

parallelStream()은 만능 스위치가 아닙니다. 다음 조건이 맞지 않으면 오히려 손해가 큽니다.

  • 데이터가 충분히 크고(수십만~수백만 단위 이상), 연산이 CPU 바운드이며,
  • 공유 상태(side effect)가 없고,
  • 순서 보장이 필요 없거나 비용이 낮고,
  • ForkJoinPool 공용 풀 사용이 서비스 전반에 영향을 줘도 괜찮을 때

특히 웹 애플리케이션에서 공용 ForkJoinPool.commonPool()을 건드리면, 다른 비동기 작업까지 같이 느려지는 일이 생길 수 있어요.

실무에서 자주 쓰는 선택 가이드 (요약 표)

주제 추천 패턴 피하면 좋은 패턴 이유
그룹핑 + 집계 groupingBy(key, downstream) groupingBy 후 루프 돌며 재집계 중간 자료구조/반복 감소
중첩 컬렉션 flatMap(Collection::stream) map()으로 Stream<Stream<T>> 만들기 결과가 중첩되어 후처리 필요
Optional 연계 optional.stream() isPresent() + get() 분기 제거, 파이프라인 유지
숫자 연산 IntStream/LongStream Stream<Integer> 박싱 비용 절감
병렬 처리 명확한 근거 있을 때만 parallel() 무조건 parallelStream() 공용 풀/오버헤드/부작용
flowchart LR
  A["Source"] --> B["Intermediate operations"]
  B --> C["Terminal operation"]
  C --> D["Result or side effect"]

Stream은 “소스 → 중간 연산 → 최종 연산” 파이프라인으로 지연(lazy) 실행됩니다.

코드 예제: groupingBy + flatMap + Optional.stream + 성능/병렬 주의점까지 한 번에

아래 코드는 Java 17에서 그대로 실행됩니다. “주문 목록”에서 고객별 주문 금액 합계, 고객별 구매한 상품 ID 목록(중복 제거), Optional 쿠폰을 자연스럽게 합치기, 그리고 병렬 스트림을 쓸 때의 위험한 예/안전한 예를 함께 보여줍니다.

import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Function;
import java.util.stream.Collectors;

public class StreamPracticalDemo {

    // --- Domain ---
    record Customer(long id, String name) {}

    record LineItem(long productId, int quantity, int unitPrice) {
        int amount() { return quantity * unitPrice; }
    }

    record Coupon(String code, int discountAmount) {}

    record Order(long id,
                 Customer customer,
                 LocalDate orderedAt,
                 List<LineItem> items,
                 Optional<Coupon> coupon) {

        int totalAmount() {
            int itemsSum = items.stream().mapToInt(LineItem::amount).sum();
            int discount = coupon.map(Coupon::discountAmount).orElse(0);
            return Math.max(0, itemsSum - discount);
        }
    }

    public static void main(String[] args) {
        List<Order> orders = sampleOrders();

        // 1) groupingBy + downstream: 고객별 총 구매금액 합계
        Map<Long, Integer> totalAmountByCustomerId =
                orders.stream()
                        .collect(Collectors.groupingBy(
                                o -> o.customer().id(),
                                Collectors.summingInt(Order::totalAmount)
                        ));

        // 2) flatMap: 고객별 구매한 productId 목록(중복 제거)
        Map<Long, Set<Long>> productIdsByCustomerId =
                orders.stream()
                        .collect(Collectors.groupingBy(
                                o -> o.customer().id(),
                                Collectors.flatMapping(
                                        o -> o.items().stream().map(LineItem::productId),
                                        Collectors.toSet()
                                )
                        ));

        // 3) Optional.stream(): 전체 주문에서 쿠폰 코드만 모으기 (Optional이 비어 있으면 0개로 흘러감)
        List<String> usedCouponCodes =
                orders.stream()
                        .flatMap(o -> o.coupon().stream())
                        .map(Coupon::code)
                        .distinct()
                        .sorted()
                        .toList();

        // 4) 성능: 숫자 합계는 mapToInt로 박싱 회피
        int grandTotal =
                orders.stream()
                        .mapToInt(Order::totalAmount)
                        .sum();

        // 출력
        System.out.println("totalAmountByCustomerId = " + totalAmountByCustomerId);
        System.out.println("productIdsByCustomerId = " + productIdsByCustomerId);
        System.out.println("usedCouponCodes = " + usedCouponCodes);
        System.out.println("grandTotal = " + grandTotal);

        // 5) 병렬 스트림 함정 예시: 공유 상태(side effect) 업데이트는 매우 위험
        // 아래 코드는 '동작은 할 수도' 있지만, 레이스 컨디션/가시성 문제로 결과가 틀릴 수 있습니다.
        int[] unsafeCounter = new int[1];
        orders.parallelStream().forEach(o -> unsafeCounter[0]++); // 절대 이렇게 쓰지 마세요
        System.out.println("unsafeCounter (WRONG) = " + unsafeCounter[0]);

        // 안전한 방식: reduce/collect 같은 리덕션을 사용
        long safeCount = orders.parallelStream().count();
        System.out.println("safeCount = " + safeCount);

        // 병렬이 항상 빠르지 않다는 점도 기억해 두세요.
        // (데이터 크기/연산 비용/환경에 따라 달라서, 꼭 벤치마크가 필요합니다.)
    }

    // --- Sample data ---
    static List<Order> sampleOrders() {
        Customer alice = new Customer(1, "Alice");
        Customer bob = new Customer(2, "Bob");
        Customer charlie = new Customer(3, "Charlie");

        return List.of(
                new Order(1001, alice, LocalDate.now().minusDays(2),
                        List.of(new LineItem(10, 2, 5000), new LineItem(11, 1, 12000)),
                        Optional.of(new Coupon("WELCOME", 3000))
                ),
                new Order(1002, alice, LocalDate.now().minusDays(1),
                        List.of(new LineItem(10, 1, 5000), new LineItem(12, 3, 2000)),
                        Optional.empty()
                ),
                new Order(1003, bob, LocalDate.now().minusDays(3),
                        List.of(new LineItem(13, 1, 30000)),
                        Optional.of(new Coupon("VIP", 5000))
                ),
                new Order(1004, charlie, LocalDate.now().minusDays(1),
                        List.of(new LineItem(11, 2, 12000), new LineItem(12, 1, 2000)),
                        Optional.empty()
                )
        );
    }
}

실무 팁

💡 실무에서는: groupingBy 결과 Map 타입/크기를 먼저 결정해 보세요

  • 기본 groupingByHashMap을 씁니다. 키가 정렬되어야 하면 groupingBy(..., TreeMap::new, downstream)처럼 맵 팩토리를 명시해 주세요.
  • 그룹 수가 매우 많아질 수 있는 데이터(예: 사용자 ID, 요청 ID)를 무심코 groupingBy하면 메모리 사용량이 급증합니다. “정말로 Map이 필요한지”, “상위 N개만 필요하진 않은지”부터 확인해 보시는 게 안전합니다.

💡 실무에서는: parallelStream은 “공유 자원”이 있는 서버 코드에서 특히 조심해요

  • parallelStream()은 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 애플리케이션 다른 작업도 같은 풀을 쓰면, 특정 API 요청이 공용 풀을 잠식해서 전체 지연이 커질 수 있어요.
  • 병렬화가 필요하면, 스트림 병렬보다 **명시적인 Executor 기반 비동기 처리(CompletableFuture 등)**가 운영/관측 측면에서 더 예측 가능한 경우가 많습니다. (병렬 스트림을 쓰더라도 side effect 없는 리덕션으로만 끝내는 습관이 중요합니다.)

핵심 요약: groupingBy는 downstream으로 “그룹핑+집계”를 한 번에 끝내세요.
핵심 요약: flatMap과 Optional.stream()을 쓰면 중첩/조건부 데이터를 깔끔하게 합칠 수 있습니다.
핵심 요약: 성능과 병렬은 감으로 결정하지 말고(특히 parallelStream), 비용과 부작용을 먼저 점검해 보세요.

다음 글: [#22 Optional 올바르게 쓰기]