Published on

Java Stream 병렬화가 느린 이유와 Spliterator 튜닝

Authors

서버 코드에서 stream()parallelStream()으로 바꾸면 “코어를 더 쓰니까 빨라지겠지”라고 기대하기 쉽습니다. 하지만 실무에서는 오히려 느려지거나, 지연 시간이 튀거나, CPU는 100%인데 처리량이 안 나오는 경우가 흔합니다.

이 글은 왜 Java Stream 병렬화가 느려지는지 원인을 구조적으로 분해하고, 병렬 성능을 좌우하는 Spliterator를 어떻게 설계하고 튜닝해야 하는지까지 다룹니다.

parallelStream이 느려지는 대표 원인 7가지

1) 작업이 너무 작아서 분할·스케줄링 오버헤드가 더 큼

병렬 스트림은 내부적으로 ForkJoinPool.commonPool()에서 태스크를 쪼개고 훔쳐오기(work-stealing)로 실행합니다. 이때 발생하는 오버헤드는 다음을 포함합니다.

  • 분할을 위한 trySplit() 호출 비용
  • 태스크 객체 생성 및 큐잉 비용
  • 스레드 간 컨텍스트 전환 및 캐시 무효화
  • 결과 병합(reduction) 비용

연산이 단순한 map이나 filter 위주이고 요소 수가 적다면, 병렬화로 얻는 이득보다 오버헤드가 커져 역효과가 납니다.

2) 데이터 소스가 병렬 분할에 불리한 구조

병렬 스트림 성능은 “연산”보다 “입력 소스의 분할 가능성”에 강하게 좌우됩니다.

  • ArrayList는 인덱스 기반으로 반씩 쪼개기 쉬움
  • LinkedList는 중간 지점 찾기가 비싸고 분할이 어려움
  • HashSet은 분할은 되지만 순회 locality가 떨어질 수 있음
  • Stream.generate 같은 무한/비정형 소스는 분할 품질이 나쁠 수 있음

특히 LinkedList는 병렬 스트림과 궁합이 매우 나쁜 편에 속합니다.

3) 박싱과 객체 할당이 병렬에서 더 비싸게 느껴짐

Stream<Integer>처럼 박싱 타입을 쓰면 요소마다 객체 접근과 언박싱이 발생합니다. 병렬화하면 스레드별로 더 많은 객체를 만지면서 GC 압력이 커지고, 캐시 미스도 증가합니다.

가능하면 IntStream, LongStream, DoubleStream 같은 primitive 스트림을 사용하세요.

4) 병렬 스트림은 기본적으로 공용 풀을 씀

parallelStream()은 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 문제는 이 풀이 애플리케이션 전체에서 공유된다는 점입니다.

  • 다른 라이브러리도 common pool을 사용하면 경쟁 발생
  • 블로킹 I/O를 병렬 스트림에서 수행하면 풀의 워커가 묶여 전체가 느려짐
  • 서버 환경에서는 예측 불가능한 tail latency가 생김

병렬 스트림은 CPU 바운드 작업에만 쓰는 것이 안전합니다.

5) 순서 보장(ordered)으로 인한 병합 비용

소스가 ORDERED 특성을 가지면, 병렬 실행 후에도 원래 순서를 맞추기 위해 추가 비용이 듭니다.

  • forEachOrdered는 특히 비용이 큼
  • sorted()는 병렬로 해도 병합 비용이 커질 수 있음

순서가 중요하지 않다면 unordered()를 고려할 가치가 있습니다.

6) false sharing과 캐시 locality 문제

스레드가 같은 캐시 라인에 있는 데이터를 동시에 갱신하면 false sharing이 발생해 성능이 급락할 수 있습니다. 예를 들어 병렬로 배열의 인접 요소에 누적 값을 쓰는 패턴은 주의해야 합니다.

7) 측정 방법이 잘못되어 “느린 것처럼 보이는” 경우

JIT 워밍업, GC, CPU 스케일링, 다른 워크로드 간섭이 있으면 결과가 흔들립니다. 간단한 System.nanoTime() 측정은 신뢰하기 어렵고, 마이크로벤치마크는 JMH를 권장합니다.

성능 최적화 글을 자주 읽는 분이라면, 모델·하드웨어·커널 레벨 최적화가 체감 성능을 좌우한다는 점에서 GPU 커널 최적화 사례도 참고할 만합니다. 예를 들어 Transformers 로컬 LLM 속도 2배 - FlashAttention2 적용처럼 “알고리즘보다 메모리 접근과 분할 전략”이 병목인 경우가 많습니다.

Spliterator가 병렬 성능을 결정한다

Stream의 병렬화는 본질적으로 “입력을 얼마나 잘 쪼개서 워커들에게 균등하게 나눠주느냐” 문제입니다. 여기서 핵심이 Spliterator입니다.

Spliterator의 주요 메서드는 다음과 같습니다.

  • tryAdvance(Consumer) : 요소 하나를 소비
  • trySplit() : 현재 범위를 둘로 쪼개 새 Spliterator를 반환
  • estimateSize() : 남은 요소 수 추정
  • characteristics() : SIZED, SUBSIZED, ORDERED, IMMUTABLE 등 특성 플래그

병렬 스트림은 trySplit()을 반복 호출해 작업을 분할합니다. 따라서 trySplit()

  • 싸게 동작하고
  • 균등하게 분할하고
  • estimateSize()가 정확하며
  • SIZED/SUBSIZED 특성이 적절히 설정

되어야 병렬 효율이 좋아집니다.

안 좋은 Spliterator의 전형: 분할이 비싸거나 불균등

예를 들어 연결 리스트 기반으로 “중간을 찾아 반으로 쪼개기”를 구현하면, 분할할 때마다 O(n) 탐색이 들어가 전체가 O(n log n)처럼 부풀 수 있습니다.

또 다른 문제는 분할이 한쪽으로 치우치는 경우입니다.

  • 계속 1개씩만 쪼개짐
  • 앞쪽만 쪼개지고 뒤쪽은 큰 덩어리로 남음

이러면 워커 간 부하가 불균등해지고, 최종적으로 가장 큰 덩어리를 잡은 워커가 전체 시간을 결정합니다.

Spliterator 튜닝 전략 6가지

1) 가능한 한 SIZEDSUBSIZED를 만족시키기

SIZED는 정확한 크기를 알고 있음을 의미하고, SUBSIZED는 분할된 spliterator들도 정확한 크기를 안다는 의미입니다. 이 특성이 있으면 프레임워크가 더 공격적으로 분할하고, 병합 전략도 최적화하기 쉽습니다.

배열, 범위, 고정 길이 버퍼 등은 SIZED | SUBSIZED를 갖도록 설계하는 것이 좋습니다.

2) estimateSize()를 과소/과대 추정하지 않기

크기 추정이 부정확하면 분할 전략이 흔들립니다.

  • 과소 추정: 충분히 분할하지 못해 병렬성이 낮아짐
  • 과대 추정: 지나치게 분할해 오버헤드 증가

가능하면 정확한 값을 반환하세요.

3) trySplit()의 분할 단위를 “너무 작지 않게”

무조건 반으로 쪼개는 것이 항상 최선은 아닙니다. 요소당 연산이 매우 가벼우면, 최소 청크 크기(threshold)를 두고 그 이하에서는 분할하지 않는 편이 낫습니다.

4) ORDERED가 필요 없다면 제거

characteristics()에서 ORDERED를 제거하거나, 파이프라인에서 unordered()를 사용하면 병합 비용이 줄어드는 경우가 있습니다.

단, 비즈니스 로직이 순서에 의존하지 않는지 반드시 확인해야 합니다.

5) 데이터 구조를 바꿔서 분할 비용을 제거

가장 강력한 최적화는 종종 Spliterator 튜닝이 아니라 “입력을 바꾸는 것”입니다.

  • LinkedListArrayList로 변환 후 처리
  • 커스텀 컬렉션이라면 내부를 chunked array로 설계

변환 비용이 있더라도 전체 파이프라인이 길면 이득이 날 수 있습니다.

6) 블로킹 작업은 병렬 스트림에서 분리

병렬 스트림은 CPU 바운드에 최적화되어 있습니다. 네트워크 호출, 디스크 I/O, 락 경합이 큰 작업은 별도의 ExecutorService로 분리하거나 비동기 모델을 쓰는 편이 안전합니다.

운영 환경에서 이런 “공유 리소스 경쟁”이 문제를 만드는 패턴은 쿠버네티스에서도 자주 나타납니다. 예를 들어 노드 디스크 GC로 인한 축출처럼 시스템 레벨 이벤트가 지연을 만들 수 있는데, 이런 사례는 EKS에서 nodefs ImageGC로 Pod가 Evicted될 때 같은 글과 결이 비슷합니다. 병렬 스트림도 결국 같은 머신 리소스를 두고 경쟁합니다.

코드 예제 1: LinkedList에서 parallelStream이 느린 이유 재현

아래 예시는 LinkedListArrayList의 병렬 스트림 성능이 왜 크게 갈릴 수 있는지 보여줍니다. 마이크로벤치마크는 JMH가 정석이지만, 개념 확인용으로 간단히 작성합니다.

LinkedList는 분할에 불리해 병렬화 효율이 떨어질 수 있습니다.

import java.util.*;
import java.util.concurrent.ThreadLocalRandom;

public class ParallelStreamDemo {
    static long sumParallel(List<Integer> list) {
        return list.parallelStream()
                .mapToLong(Integer::longValue)
                .sum();
    }

    public static void main(String[] args) {
        int n = 5_000_000;

        List<Integer> arrayList = new ArrayList<>(n);
        for (int i = 0; i < n; i++) arrayList.add(i);

        List<Integer> linkedList = new LinkedList<>();
        for (int i = 0; i < n; i++) linkedList.add(i);

        // 워밍업
        for (int i = 0; i < 3; i++) {
            sumParallel(arrayList);
            sumParallel(linkedList);
        }

        long t1 = System.nanoTime();
        long a = sumParallel(arrayList);
        long t2 = System.nanoTime();

        long t3 = System.nanoTime();
        long b = sumParallel(linkedList);
        long t4 = System.nanoTime();

        System.out.println("arrayList sum=" + a + " timeMs=" + (t2 - t1) / 1_000_000);
        System.out.println("linkedList sum=" + b + " timeMs=" + (t4 - t3) / 1_000_000);
    }
}

포인트는 “연산은 같지만 입력 소스의 Spliterator 품질이 다르다”입니다.

코드 예제 2: 커스텀 Spliterator로 청크 기반 분할 튜닝

실무에서는 로그 라인, 바이너리 레코드, 고정 길이 메시지처럼 “큰 배열 버퍼를 일정 크기 단위로 처리”하는 경우가 많습니다. 이런 경우 Spliterator를 청크 단위로 분할하도록 만들면 병렬 효율이 좋아집니다.

아래는 int[]를 일정 청크 크기 이상일 때만 분할하는 Spliterator.OfInt 예시입니다.

import java.util.Spliterator;
import java.util.function.IntConsumer;

public final class ChunkedIntArraySpliterator implements Spliterator.OfInt {
    private final int[] data;
    private int index;
    private final int fence;
    private final int minChunk;

    public ChunkedIntArraySpliterator(int[] data, int origin, int fence, int minChunk) {
        this.data = data;
        this.index = origin;
        this.fence = fence;
        this.minChunk = Math.max(1, minChunk);
    }

    @Override
    public OfInt trySplit() {
        int lo = index;
        int hi = fence;
        int remaining = hi - lo;

        // 너무 작으면 분할하지 않음
        if (remaining <= minChunk) return null;

        int mid = lo + remaining / 2;
        index = mid;
        return new ChunkedIntArraySpliterator(data, lo, mid, minChunk);
    }

    @Override
    public boolean tryAdvance(IntConsumer action) {
        if (index < fence) {
            action.accept(data[index++]);
            return true;
        }
        return false;
    }

    @Override
    public long estimateSize() {
        return fence - index;
    }

    @Override
    public int characteristics() {
        return Spliterator.SIZED
                | Spliterator.SUBSIZED
                | Spliterator.ORDERED
                | Spliterator.IMMUTABLE;
    }
}

Spliterator를 스트림으로 감싸 병렬 합계를 구하면 다음과 같습니다.

import java.util.Spliterator;
import java.util.stream.StreamSupport;

public class ChunkedSpliteratorUse {
    public static long parallelSum(int[] data, int minChunk) {
        Spliterator.OfInt sp = new ChunkedIntArraySpliterator(data, 0, data.length, minChunk);

        return StreamSupport.intStream(sp, true)
                .asLongStream()
                .sum();
    }
}

여기서 minChunk는 튜닝 파라미터입니다.

  • 너무 작으면 오버헤드 증가
  • 너무 크면 병렬성 감소

일반적으로는 “요소당 연산 비용”과 “코어 수”에 따라 최적점이 달라집니다. CPU 바운드이면서 요소당 연산이 작을수록 minChunk를 키우는 쪽이 유리한 경향이 있습니다.

Spliterator 특성 플래그를 잘못 설정하면 생기는 문제

characteristics()를 과장해서 설정하면(예: 실제로는 크기를 모르는데 SIZED를 준다) 결과가 틀리거나 성능이 악화될 수 있습니다.

  • SIZED인데 estimateSize()가 부정확하면 분할/병합 전략이 깨짐
  • IMMUTABLE인데 실제로 변경되면 동시성 버그 가능
  • CONCURRENT를 잘못 주면 안전하지 않은 병렬 접근이 될 수 있음

즉, Spliterator 튜닝은 “성능”뿐 아니라 “정확성 계약”을 지키는 것이 전제입니다.

parallelStream을 쓰기 전에 체크리스트

  1. 작업이 CPU 바운드인가, 블로킹 I/O가 섞였나
  2. 요소 수가 충분히 큰가
  3. 입력 소스가 잘 쪼개지는 구조인가 (ArrayList, 배열, 범위가 유리)
  4. 박싱을 피했나 (IntStream 등)
  5. 순서가 정말 필요한가 (unordered() 검토)
  6. common pool 경쟁 가능성이 있나
  7. JMH 등으로 재현 가능한 벤치마크를 만들었나

운영에서 성능 이슈를 다룰 때는, 재현과 검증을 빠르게 반복할 수 있는 형태로 문제를 축소하는 게 중요합니다. 이런 접근은 예를 들어 장애 원인을 짧은 시간에 좁혀가는 트러블슈팅 글인 리눅스 logrotate 실패? 권한·SELinux 7분 해결 같은 방식과도 통합니다.

마무리: 병렬화는 “연산”이 아니라 “분할”이 성패를 가른다

Java 병렬 스트림이 느린 이유는 보통 “코어를 덜 써서”가 아니라 다음 중 하나입니다.

  • 분할이 비싸거나 불균등해서 워커가 놀고 있음
  • 오버헤드가 연산보다 큼
  • common pool 경쟁이나 블로킹으로 풀이 막힘
  • 순서 보장과 병합 비용이 큼

Spliterator를 이해하고, trySplit()estimateSize() 그리고 특성 플래그를 올바르게 설계하면 병렬 스트림의 성능은 눈에 띄게 개선될 수 있습니다. 특히 고정 길이 데이터, 배열 기반 버퍼, 대량 레코드 처리처럼 “분할 전략을 통제할 수 있는 입력”에서는 Spliterator 튜닝이 가장 확실한 레버입니다.