Published on

Java Stream 성능 함정 - boxing·parallel 오용 잡기

Authors

서버 코드에서 Stream은 가독성과 선언형 스타일을 제공하지만, "빠르다"는 보장은 없습니다. 특히 boxing(기본형이 래퍼로 바뀌는 과정), parallelStream()의 무분별한 적용은 CPU/GC/스케줄링 비용을 키워 오히려 느려지는 전형적인 함정입니다. 이 글은 Stream을 성능 관점에서 안전하게 쓰는 기준을 정리합니다.

아래 내용은 마이크로 최적화가 아니라, 대부분의 서비스에서 실제로 비용이 커지는 패턴(대량 데이터 처리, 핫패스, 배치/집계)에 집중합니다.

1) Stream이 느려지는 구조적 이유

Stream 파이프라인은 대개 다음 비용이 합쳐집니다.

  • 람다 호출 비용: 루프 대비 인라이닝이 제한되거나, 디버깅/프로파일링에서 추적이 어려워지는 경우가 있음
  • 중간 연산 체인 비용: map/filter/flatMap이 길어질수록 호출 경로가 깊어짐
  • 할당(Allocation): boxing, Optional, Map.Entry, List 생성 등
  • GC 압력: 작은 객체가 대량으로 생기면 Young GC가 잦아짐
  • parallel의 스레드/태스크 분할 비용: ForkJoin 풀의 분할·훔치기(work-stealing)·병합 비용

Stream은 "한 줄"로 보이지만 런타임에서는 꽤 많은 일이 일어납니다. 따라서 핫패스에서는 비용 모델을 먼저 생각해야 합니다.

2) Boxing 함정: Stream<Integer>가 만드는 GC 폭탄

가장 흔한 문제는 기본형을 래퍼로 바꾸는 boxing입니다.

2.1 흔한 실수: map에서 Integer로 변환

List<Integer> nums = IntStream.range(0, 10_000_000)
    .boxed() // int -> Integer (10M 객체)
    .toList();

long sum = nums.stream()
    .mapToLong(Integer::longValue)
    .sum();

위 코드는 Integer 객체를 1천만 개 생성합니다. 이후 다시 mapToLong으로 기본형으로 돌아오니, 쓸데없는 할당과 GC만 유발합니다.

2.2 해결: 기본형 스트림을 끝까지 유지

long sum = IntStream.range(0, 10_000_000)
    .asLongStream()
    .sum();
  • IntStream, LongStream, DoubleStream을 적극 사용
  • 중간에 boxed()를 넣는 순간 비용이 급증할 수 있음

2.3 Collectors.toList()toList()도 함정이 될 수 있음

기본형 스트림은 컬렉션으로 모으는 순간 boxing이 발생합니다.

List<Integer> list = IntStream.range(0, 1_000_000)
    .boxed()
    .toList();

정말로 List<Integer>가 필요한지 먼저 확인하세요.

  • 단순 집계면 sum()/average()/summaryStatistics()로 끝내기
  • 인덱스 기반 처리면 배열(int[])이 더 적합할 수 있음

3) reduce 오용: 빠른 집계를 느리게 만드는 패턴

Stream에서 reduce는 만능처럼 보이지만, 기본형 집계는 전용 연산이 더 빠르고 안전합니다.

3.1 잘못된 예: 박싱 + reduce

int sum = Stream.of(1, 2, 3, 4, 5)
    .reduce(0, (a, b) -> a + b); // Integer 연산

여기서는 Stream<Integer>라서 박싱/언박싱이 반복됩니다.

3.2 권장: 기본형 스트림 + 전용 집계

int sum = IntStream.of(1, 2, 3, 4, 5).sum();

또는 객체 스트림이라면 mapToInt로 변환 후 집계합니다.

int totalPrice = orders.stream()
    .mapToInt(Order::price)
    .sum();

4) flatMap과 임시 컬렉션: 숨은 할당을 의심하라

flatMap 자체가 나쁜 게 아니라, 그 안에서 매번 새 리스트/스트림을 만드는 패턴이 문제입니다.

4.1 안티패턴: 매 요소마다 List 생성

long count = users.stream()
    .flatMap(u -> List.of(u.id(), u.managerId()).stream())
    .filter(Objects::nonNull)
    .count();

List.of(...)가 매번 생성됩니다. 데이터가 크면 할당이 폭증합니다.

4.2 대안: 조건 분기 + 기본형 스트림(가능하면)

long count = users.stream()
    .flatMap(u -> {
        String a = u.id();
        String b = u.managerId();
        if (a == null && b == null) return Stream.empty();
        if (a == null) return Stream.of(b);
        if (b == null) return Stream.of(a);
        return Stream.of(a, b);
    })
    .count();

더 나아가, 정말 핫한 경로라면 Stream 대신 명시적 루프가 이길 때가 많습니다.

5) parallelStream() 오용: "병렬 = 빠름"은 거의 틀리다

병렬 스트림은 다음 조건에서만 이점이 나기 쉽습니다.

  • 작업이 CPU 바운드이고
  • 요소 수가 충분히 크며
  • 각 요소 처리 비용이 충분히 무겁고
  • 공유 상태/락/IO가 없고
  • 분할이 효율적인 자료구조(예: ArrayList, 배열)이며
  • 최종 결합(reduction)이 저렴하고 연관 법칙을 만족

이 조건이 깨지면 병렬화 오버헤드가 이득을 삼켜버립니다.

5.1 흔한 실패 사례 1: I/O 작업을 병렬 스트림에 넣기

List<Result> results = ids.parallelStream()
    .map(id -> httpClient.fetch(id))
    .toList();
  • 병렬 스트림은 기본적으로 ForkJoinPool.commonPool()을 사용
  • I/O는 블로킹이 많아 풀을 잠식하고, 다른 작업까지 영향
  • 타임아웃/재시도까지 겹치면 지연이 폭발

I/O 병렬성은 스트림이 아니라 전용 비동기/스레드풀/리액티브로 모델링하는 편이 안전합니다. (블로킹을 런타임에 섞어 쓰다 장애가 나는 패턴은 다른 생태계에서도 반복됩니다. 예: Tokio runtime 패닉 - blocking_in_place 원인·해결)

5.2 흔한 실패 사례 2: 공유 mutable 상태

List<Integer> out = new ArrayList<>();
nums.parallelStream().forEach(out::add); // 데이터 레이스/예외/누락

병렬 스트림에서 forEach로 외부 컬렉션을 수정하면 깨집니다.

대안:

List<Integer> out = nums.parallelStream()
    .map(x -> x * 2)
    .toList();

또는 병렬이 꼭 필요하면 collect를 올바르게 사용해야 합니다.

5.3 흔한 실패 사례 3: 작은 작업을 병렬로 쪼개기

long c = IntStream.range(0, 100_000)
    .parallel()
    .filter(x -> (x & 1) == 0)
    .count();

필터 한 번 하는 정도의 가벼운 작업은 분할/스케줄링/병합 오버헤드가 더 큽니다.

6) parallel을 쓰더라도 꼭 점검할 것

6.1 commonPool은 공유 자원이다

병렬 스트림은 기본적으로 공용 풀을 씁니다. 서비스 내 다른 코드(예: CompletableFuture의 기본 실행)도 같은 풀을 사용하면 서로 간섭합니다.

필요하면 전용 ForkJoinPool로 격리하세요.

ForkJoinPool pool = new ForkJoinPool(8);
try {
    long sum = pool.submit(() ->
        IntStream.range(0, 10_000_000)
            .parallel()
            .asLongStream()
            .sum()
    ).get();
} finally {
    pool.shutdown();
}

주의: 격리했다고 무조건 좋아지지 않습니다. CPU 코어 수, 다른 워크로드, GC, 컨텍스트 스위칭까지 함께 봐야 합니다.

6.2 순서 보장이 필요하면 비용이 더 든다

forEachOrdered는 병렬에서 순서를 맞추느라 병목이 생길 수 있습니다.

nums.parallelStream().forEachOrdered(System.out::println);

순서가 필요 없다면 forEach가 낫고, 순서가 필요하면 병렬 자체가 손해일 수 있습니다.

7) 실전 체크리스트: Stream 성능 함정 빠르게 찾기

7.1 Boxing 의심 신호

  • boxed()가 보인다
  • Stream<Integer>/Stream<Long>가 대량 데이터를 다룬다
  • map(x -> x) 형태로 래퍼를 만진다
  • Collectors.summingInt 대신 mapToInt().sum()이 가능한데 안 쓴다

7.2 불필요한 객체 생성 의심 신호

  • flatMap 내부에서 List.of, new ArrayList, Stream.of를 매번 만든다
  • Optional을 대량 생성한다 (map에서 Optional.ofNullable 남발)
  • Collectors.groupingBy로 거대한 맵을 만들고 다시 순회한다

7.3 parallel 오용 의심 신호

  • 작업이 I/O 바운드다
  • 요소당 작업이 너무 가볍다
  • 외부 상태를 수정한다
  • forEachOrdered를 쓴다
  • 공용 풀을 쓰는 다른 기능과 섞여 있다

8) 벤치마크는 JMH로, 로그로 추측하지 말기

Stream 성능은 JVM 워밍업, 인라이닝, Escape Analysis, GC 상태에 따라 쉽게 왜곡됩니다. 단순히 시간을 재는 코드(System.nanoTime)는 신뢰하기 어렵습니다. JMH로 재현 가능한 벤치마크를 만드세요.

8.1 JMH 예시: boxing vs primitive

import org.openjdk.jmh.annotations.*;

import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
public class StreamBoxingBenchmark {

    private int[] arr;
    private List<Integer> boxed;

    @Setup
    public void setup() {
        arr = IntStream.range(0, 10_000_000).toArray();
        boxed = IntStream.range(0, 10_000_000).boxed().toList();
    }

    @Benchmark
    public long primitiveSum() {
        return IntStream.of(arr).asLongStream().sum();
    }

    @Benchmark
    public long boxedSum() {
        return boxed.stream().mapToLong(Integer::longValue).sum();
    }
}

이 벤치마크는 boxed 리스트를 만드는 비용은 제외하고도, 순회 중 언박싱 비용과 객체 접근 패턴 차이를 확인할 수 있습니다.

9) 언제 Stream을 쓰고, 언제 루프로 돌아갈까

다음 기준을 권합니다.

  • 핫패스가 아니고 데이터가 크지 않다: Stream을 써도 좋음 (가독성 우선)
  • 대량 데이터 집계/필터링: 기본형 스트림으로 변환해 sum/count/summaryStatistics 등 전용 연산 사용
  • 최고 성능이 필요한 루프: 명시적 for가 여전히 가장 예측 가능
  • 병렬화가 필요: 먼저 알고리즘/자료구조 최적화, 그 다음 병렬. 병렬은 마지막 카드

성능 문제는 대개 "한 줄"이 아니라 시스템 전체에서 증폭됩니다. 예를 들어 캐시/빌드/런타임 자원 공유는 작은 비효율도 크게 만들 수 있습니다. 빌드 파이프라인에서 캐시 전략으로 시간을 줄이는 것처럼(예: Docker BuildKit 캐시로 CI 빌드 70% 단축), 런타임에서도 **병목을 만드는 공유 자원(common pool, GC, 락)**을 먼저 의심하는 태도가 중요합니다.

10) 결론: Stream은 "선언형"이지 "무료"가 아니다

  • boxing은 가장 흔하고 가장 큰 비용 중 하나입니다. 가능하면 기본형 스트림을 끝까지 유지하세요.
  • parallelStream()은 조건이 맞을 때만 효과가 있습니다. I/O, 공유 상태, 가벼운 작업에는 독입니다.
  • 성능 판단은 추측이 아니라 JMH/프로파일링으로 검증하세요.

다음에 Stream 코드가 느리다고 느껴진다면, 먼저 boxed()parallel()을 찾아보세요. 그 두 줄이 GC와 스케줄러를 동시에 흔들고 있을 가능성이 큽니다.