Published on

Java Stream 성능폭발? boxed·collect 병목 6가지

Authors

서버 코드에서 Stream은 읽기 좋은 파이프라인을 만들지만, 트래픽이 늘거나 데이터 크기가 커지는 순간 갑자기 CPU가 치솟고 GC가 폭발하는 경우가 많습니다. 특히 boxed()collect()는 “성능이 나빠질 수 있는 지점”이 아니라, 조건이 맞으면 병목의 중심이 됩니다.

이 글은 Stream 자체를 악마화하지 않습니다. 대신 어떤 패턴이 왜 느려지고, 어떤 대안이 실제로 도움이 되는지를 6가지로 정리합니다.

참고: 이 글은 마이크로벤치마크가 아니라 “실무에서 병목으로 자주 튀어나오는 구조”를 중심으로 설명합니다. 정확한 수치는 JMH로 측정하세요.

0) 먼저 결론: Stream이 느린 게 아니라, 객체화와 수집이 비싸다

Stream 파이프라인에서 비용이 크게 튀는 지점은 대개 아래입니다.

  • primitive 스트림에서 reference 스트림으로 바꾸는 boxed()
  • 중간 결과를 컨테이너에 모으는 collect()
  • Collectors.groupingBy 같은 고수준 수집기 내부의 Map/List 객체 생성
  • toList()가 만들어내는 리스트의 크기 확장 비용

즉, “연산”보다 할당(allocation)과 메모리 접근이 먼저 병목이 됩니다.

1) 병목 1: boxed()로 인한 오토박싱 폭탄

IntStream 같은 primitive 스트림은 빠릅니다. 문제는 boxed()Integer를 만들기 시작할 때입니다.

  • 요소마다 객체가 생성됩니다(캐시 범위 밖이면 특히).
  • 힙 할당량이 증가하고, GC 빈도가 올라갑니다.
  • CPU는 연산보다 메모리 할당과 추적에 시간을 씁니다.

나쁜 예

List<Integer> ids = IntStream.range(0, n)
    .boxed()
    .collect(Collectors.toList());

대안 1: primitive로 끝내기

가능하면 int[], long[]로 끝내세요.

int[] ids = IntStream.range(0, n)
    .toArray();

대안 2: 정말 List<Integer>가 필요하면 “왜 필요한지”부터

외부 API가 List<Integer>를 요구하는지, 내부 로직이 박싱을 요구하는지 재검토하세요. 내부는 primitive로 돌리고, 경계에서만 변환하는 편이 낫습니다.

2) 병목 2: mapToObj로 객체를 대량 생성하는 파이프라인

boxed()가 아니더라도 mapToObj로 DTO를 대량 생성하면 동일한 문제가 생깁니다. 특히 요청당 수만 건을 만들면 할당량이 급증합니다.

나쁜 예

List<UserDto> dtos = users.stream()
    .map(u -> new UserDto(u.id(), u.name()))
    .toList();

대안: 필요할 때만 만들기(지연 생성) 또는 필드만 뽑기

  • 정말로 DTO 리스트가 필요한지 확인
  • 특정 필드 집합만 필요하면 primitive/문자열 배열로 축소
long[] userIds = users.stream()
    .mapToLong(User::id)
    .toArray();

3) 병목 3: collect(toList())의 “중간 결과 리스트” 남발

Stream 파이프라인에서 collect(Collectors.toList())를 여러 번 호출하면, 매번 중간 리스트가 생기고 버려집니다. 이 패턴은 코드 리뷰에서 자주 놓칩니다.

나쁜 예: 중간 리스트가 2번 생성

List<Order> filtered = orders.stream()
    .filter(o -> o.amount() > 0)
    .collect(Collectors.toList());

List<Long> ids = filtered.stream()
    .map(Order::id)
    .collect(Collectors.toList());

대안: 파이프라인을 한 번에

List<Long> ids = orders.stream()
    .filter(o -> o.amount() > 0)
    .map(Order::id)
    .toList();

대안: 결과가 배열이면 배열로

long[] ids = orders.stream()
    .filter(o -> o.amount() > 0)
    .mapToLong(Order::id)
    .toArray();

4) 병목 4: groupingBy가 만드는 맵/리스트 객체 폭발

Collectors.groupingBy는 편하지만, 데이터가 크면 내부적으로 다음을 대량 생성합니다.

  • 키별 List(또는 다운스트림 컨테이너)
  • HashMap 엔트리
  • 경우에 따라 박싱된 키/값 객체

나쁜 예

Map<Long, List<Order>> byUser = orders.stream()
    .collect(Collectors.groupingBy(Order::userId));

대안 1: 다운스트림을 더 가볍게

리스트가 아니라 합계/카운트만 필요하면 summarizing이나 counting으로 컨테이너 생성을 피하세요.

Map<Long, Long> countByUser = orders.stream()
    .collect(Collectors.groupingBy(
        Order::userId,
        Collectors.counting()
    ));

대안 2: primitive 키를 쓰는 specialized map 고려

외부 라이브러리 사용이 가능하다면 fastutil, HPPC 같은 primitive map이 박싱을 줄입니다. 표준 라이브러리만으로는 한계가 있습니다.

대안 3: 정렬 후 단일 패스로 집계

키가 정렬 가능하고 대량 데이터라면, 정렬 후 루프 한 번으로 집계하는 방식이 메모리 효율이 좋습니다.

5) 병목 5: Collectors.toMap의 충돌 처리와 리사이징

toMap은 다음 이유로 느려질 수 있습니다.

  • 키 충돌이 발생하면 merge 함수가 자주 호출됨
  • 기본 HashMap은 크기를 모르면 여러 번 리사이즈
  • 값이 박싱 타입이면 추가 비용

나쁜 예: 충돌 가능 + 리사이즈

Map<String, User> byEmail = users.stream()
    .collect(Collectors.toMap(User::email, u -> u));

대안 1: 충돌 정책을 명시

Map<String, User> byEmail = users.stream()
    .collect(Collectors.toMap(
        User::email,
        u -> u,
        (a, b) -> a
    ));

대안 2: 크기를 알면 미리 용량을 잡아 수집

표준 toMap으로는 초기 용량을 직접 주기 어렵습니다. 대신 collect의 3-인자 형태로 HashMap을 미리 만들 수 있습니다.

int expected = users.size();
Map<String, User> byEmail = users.stream().collect(
    () -> new HashMap<>((int) (expected / 0.75f) + 1),
    (m, u) -> m.put(u.email(), u),
    HashMap::putAll
);

이 패턴은 코드가 길어지지만, 대량 데이터에서 리사이즈 비용을 줄이는 데 효과가 있습니다.

6) 병목 6: parallelStream()이 오히려 느려지는 경우

병렬 스트림은 “CPU 코어가 많으면 빨라진다”가 아니라, 분할/병합 비용과 작업 단위가 맞아야 이득이 납니다.

느려지는 대표 조건:

  • 요소 수가 적거나 연산이 가벼움(오버헤드가 더 큼)
  • collect()로 큰 맵/리스트를 병합(Combiner 비용 증가)
  • 공유 자원 접근(락, DB, Redis, HTTP 호출)
  • ForkJoinPool 공용 풀 경쟁(서버 전체에 영향)

나쁜 예: 외부 I/O를 병렬로

List<Result> results = ids.parallelStream()
    .map(id -> externalApiCall(id))
    .toList();

대안: 병렬화는 Stream이 아니라 실행 모델로

I/O 병렬화는 CompletableFuture나 리액티브/가상 스레드 등으로 제어하는 편이 안전합니다.

ExecutorService es = Executors.newFixedThreadPool(64);
try {
    List<CompletableFuture<Result>> futures = ids.stream()
        .map(id -> CompletableFuture.supplyAsync(() -> externalApiCall(id), es))
        .toList();

    List<Result> results = futures.stream()
        .map(CompletableFuture::join)
        .toList();
} finally {
    es.shutdown();
}

병렬 스트림은 CPU 바운드 연산에서, 수집 결과가 단순하고 작업이 충분히 클 때만 고려하세요.

JMH로 확인하는 최소 벤치마크 뼈대

Stream 성능 논쟁은 측정 없이는 결론이 나지 않습니다. 아래는 비교 실험을 위한 JMH 스켈레톤입니다.

@State(Scope.Thread)
public class StreamBench {

    @Param({"1000", "1000000"})
    int n;

    int[] data;

    @Setup
    public void setup() {
        data = IntStream.range(0, n).toArray();
    }

    @Benchmark
    public List<Integer> boxedToList() {
        return IntStream.of(data)
            .boxed()
            .collect(Collectors.toList());
    }

    @Benchmark
    public int[] primitiveToArray() {
        return IntStream.of(data)
            .toArray();
    }
}

포인트는 “Stream vs for”가 아니라, boxed()collect()가 들어가는 순간 할당량이 어떻게 변하는지 확인하는 것입니다.

실무 체크리스트: Stream을 유지하면서 병목 줄이기

  • boxed()는 경계에서만, 내부는 primitive 스트림으로 끝내기
  • collect(toList()) 중간 결과를 만들지 말고 파이프라인을 한 번에
  • groupingBy는 리스트가 필요할 때만, 아니면 counting/summing 등으로 다운스트림 최소화
  • toMap은 충돌 정책과 용량(리사이즈)을 의식하기
  • parallelStream()은 CPU 바운드 + 충분히 큰 작업에서만, 서버 환경에서는 특히 신중
  • 결과 컨테이너가 정말 필요한지(리스트로 다 모아야 하는지)부터 재검토

마무리: 병목은 대개 “객체를 너무 많이 만든 것”에서 시작한다

Stream은 선언형이라 병목이 숨기 쉽습니다. 하지만 프로파일러로 보면 많은 경우 원인은 단순합니다.

  • 박싱으로 객체가 폭증했고
  • 수집으로 리스트/맵이 무거워졌고
  • GC와 메모리 대역폭이 한계에 도달했다

성능 튜닝은 결국 “덜 만들고, 덜 모으고, 경계에서만 변환”하는 방향으로 수렴합니다.

관련해서 서버 병목을 다루는 글도 함께 보면 좋습니다. 예를 들어 Spring Boot 3 Redis 세션 병목 - Lettuce 튜닝처럼, 애플리케이션 병목은 컬렉션/메모리뿐 아니라 I/O 계층에서도 자주 터집니다. 또한 트랜잭션 경계 문제로 성능과 정합성이 동시에 무너지는 케이스는 Spring Boot 3.2에서 @Transactional 무시되는 7가지도 참고할 만합니다.