Published on

Java Stream 병렬화가 느린 6가지 이유와 해결

Authors

서버 코드에서 parallelStream() 을 붙였는데 오히려 느려지는 경험은 흔합니다. 이유는 단순히 “병렬화 오버헤드” 한 문장으로 끝나지 않습니다. Java Stream 병렬화는 내부적으로 ForkJoinPoolSpliterator 분할 전략, 컬렉터의 결합 비용, 스레드 간 경쟁(락/캐시/메모리 대역폭) 같은 요소가 맞물려 성능이 결정됩니다.

이 글은 병렬 스트림이 느려지는 대표적인 6가지 원인을 증상진단 포인트, 해결책 중심으로 정리합니다. 마지막에 “병렬화해도 되는지” 빠르게 판단하는 체크리스트도 제공합니다.

먼저: 병렬 스트림은 어디서 돌까

병렬 스트림은 기본적으로 ForkJoinPool.commonPool() 을 사용합니다. 즉, 애플리케이션 전체에서 공용으로 쓰는 풀에 작업이 올라가며, 다른 라이브러리나 프레임워크도 같은 풀을 사용할 수 있습니다. 또한 병렬화는 Spliterator 가 데이터를 어떻게 쪼개는지에 크게 좌우됩니다.

간단한 확인 코드입니다.

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

public class ParallelWhere {
  public static void main(String[] args) {
    List<Integer> xs = IntStream.range(0, 20).boxed().toList();

    xs.parallelStream().forEach(i -> {
      System.out.println(i + " on " + Thread.currentThread().getName());
    });
  }
}

출력 스레드 이름에 ForkJoinPool.commonPool-worker-* 가 보이면 공용 풀에서 실행 중입니다.

느린 이유 1: 작업이 너무 작아 분할·스케줄링 오버헤드가 이김

증상

  • map 이나 filter 가 매우 가볍고, 원소 수가 수천~수만 수준일 때 병렬이 더 느림
  • CPU 사용률이 애매하게 올라가거나, 오히려 문맥 전환만 늘어남

왜 느린가

병렬 스트림은 작업을 쪼개고(분할), 워커 스레드에 배분하고, 결과를 합치는 비용이 있습니다. 각 원소당 연산이 작으면 이 오버헤드가 실제 계산보다 커집니다.

해결

  • 원소당 비용을 키우거나(예: 복잡한 계산, 큰 파싱) 그렇지 않다면 순차 스트림 유지
  • 가능하면 배치 처리로 원소당 처리량을 키우기
  • 병렬화 대상은 최소 수십만 이상, 혹은 원소당 연산이 충분히 무거운지 측정으로 확인

측정은 반드시 JMH로 하세요. 단순 System.nanoTime() 은 워밍업/인라이닝/GC 영향으로 잘못 결론내기 쉽습니다.

import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class StreamBench {

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

  @Benchmark
  public long sequential() {
    return IntStream.range(0, n)
        .mapToLong(i -> i * 3L + 7)
        .sum();
  }

  @Benchmark
  public long parallel() {
    return IntStream.range(0, n)
        .parallel()
        .mapToLong(i -> i * 3L + 7)
        .sum();
  }
}

느린 이유 2: 데이터 소스가 분할에 불리한 구조(나쁜 Spliterator)

증상

  • ArrayList 는 빨라지는데 LinkedListIterator 기반 소스는 병렬이 거의 효과 없음
  • 병렬로 돌려도 특정 워커만 바쁘고 나머지는 놀거나, 분할이 크게 한두 번만 일어남

왜 느린가

병렬 스트림은 Spliterator.trySplit() 로 작업을 쪼갭니다. ArrayList 는 인덱스 기반으로 반씩 쪼개기 쉬워 균등 분할이 잘 됩니다. 반면 LinkedList 는 중간 지점을 찾는 비용이 크고 분할이 비효율적이라 병렬화 이점이 줄어듭니다.

해결

  • 병렬화를 고려한다면 배열/ArrayList/프리미티브 스트림 같은 분할 친화 구조로 변환
  • LinkedList 는 병렬화 전에 new ArrayList<>(list) 로 복사하는 편이 나을 때가 많음
import java.util.*;

public class SplitFriendly {
  public static void main(String[] args) {
    List<Integer> linked = new LinkedList<>();
    for (int i = 0; i < 1_000_000; i++) linked.add(i);

    // 병렬화 전에 분할 친화 구조로 변환
    List<Integer> array = new ArrayList<>(linked);

    long sum = array.parallelStream()
        .mapToLong(x -> (long) x * x)
        .sum();

    System.out.println(sum);
  }
}

느린 이유 3: I/O 바운드 작업을 common pool에서 병렬화함

증상

  • HTTP 호출, DB 조회, 파일 읽기 같은 I/O 를 parallelStream() 에 넣었더니 지연이 증가
  • 타임아웃이 늘거나, 다른 기능까지 느려짐

왜 느린가

ForkJoinPool 은 CPU 바운드 작업에 최적화된 워크-스틸링 풀입니다. I/O 처럼 블로킹이 많으면 워커 스레드가 멈춰 서고, 공용 풀 고갈로 다른 병렬 작업까지 영향을 받습니다.

해결

  • I/O 는 병렬 스트림 대신 전용 스레드 풀 + CompletableFuture 또는 리액티브 모델 사용
  • 그래도 스트림 스타일을 유지하고 싶다면, 최소한 전용 ForkJoinPool 로 격리

전용 풀 격리 예시입니다.

import java.util.List;
import java.util.concurrent.*;

public class DedicatedPool {
  static String blockingCall(String s) {
    try { Thread.sleep(50); } catch (InterruptedException e) { throw new RuntimeException(e); }
    return s.toUpperCase();
  }

  public static void main(String[] args) throws Exception {
    List<String> xs = List.of("a", "b", "c", "d", "e", "f");

    ForkJoinPool pool = new ForkJoinPool(32); // I/O 성격이면 별도 풀로 격리

    List<String> out = pool.submit(() ->
        xs.parallelStream()
          .map(DedicatedPool::blockingCall)
          .toList()
    ).get();

    pool.shutdown();
    System.out.println(out);
  }
}

참고로, 운영 환경에서 “공용 리소스 때문에 전체가 느려지는” 문제는 원인 파악과 격리가 핵심입니다. 비슷한 진단 관점은 systemd 서비스 자동 재시작 무한루프 진단 가이드 처럼 시스템 레벨에서도 동일하게 적용됩니다.

느린 이유 4: 공유 상태·락·동기화로 병렬 이점이 사라짐

증상

  • forEach 안에서 synchronized 를 쓰거나, 공유 Map 에 계속 put
  • 로그를 과도하게 찍거나, 메트릭 카운터를 락으로 보호
  • CPU 사용률은 높은데 처리량이 안 나옴

왜 느린가

병렬 처리는 “각 스레드가 독립적으로 일하고 마지막에 합치기”가 이상적입니다. 중간에 공유 상태를 업데이트하면 락 경쟁이 생기고, 캐시 라인 핑퐁(특히 카운터)까지 발생해 병렬 이득이 급락합니다.

해결

  • forEach 내부에서 공유 상태 변경을 피하고, collect 로 모은 뒤 단일 스레드에서 후처리
  • 꼭 카운팅이 필요하면 LongAdder 같은 경쟁 완화 구조 사용
  • Collectors.toConcurrentMap 도 만능이 아니며, 키 충돌/병합 비용이 크면 느립니다

안 좋은 예:

import java.util.*;

public class BadSharedState {
  public static void main(String[] args) {
    List<String> xs = new ArrayList<>();
    for (int i = 0; i < 100000; i++) xs.add("k" + (i % 100));

    Map<String, Integer> counts = new HashMap<>();

    xs.parallelStream().forEach(k -> {
      synchronized (counts) {
        counts.put(k, counts.getOrDefault(k, 0) + 1);
      }
    });

    System.out.println(counts.size());
  }
}

좋은 예(병렬 수집 후 병합 비용 최소화):

import java.util.*;
import java.util.stream.*;

public class GoodCollect {
  public static void main(String[] args) {
    List<String> xs = new ArrayList<>();
    for (int i = 0; i < 100000; i++) xs.add("k" + (i % 100));

    Map<String, Long> counts = xs.parallelStream()
        .collect(Collectors.groupingByConcurrent(
            k -> k,
            Collectors.counting()
        ));

    System.out.println(counts.get("k1"));
  }
}

느린 이유 5: 잘못된 Collector/Reduce로 결합 비용이 폭증

증상

  • reduce 로 문자열을 더하거나, 리스트를 계속 합치면서 O(n^2) 급으로 느려짐
  • 병렬에서 특히 더 느려짐(결합 단계가 병목)

왜 느린가

병렬 스트림은 부분 결과를 만든 뒤 결합합니다. 결합 연산이 비싸면(예: 불변 객체 누적, 큰 리스트 복사) 병렬화할수록 결합 비용이 더 커질 수 있습니다.

해결

  • 문자열 누적은 Collectors.joining() 또는 StringBuilder 기반 컬렉터 사용
  • 리스트 누적은 toList() 또는 Collectors.toCollection(ArrayList::new) 를 사용하고, 불필요한 reduce 로 병합하지 않기
  • 커스텀 컬렉터를 만들 때는 supplier/accumulator/combiner 가 병렬 친화인지 확인

나쁜 예(문자열 reduce):

String out = xs.parallelStream()
    .reduce("", (a, b) -> a + b); // 결합 시 문자열 복사 폭증

좋은 예:

import java.util.stream.Collectors;

String out = xs.parallelStream()
    .collect(Collectors.joining(","));

느린 이유 6: CPU 바운드인데도 메모리 대역폭/캐시 미스가 병목

증상

  • 스레드를 늘려도 처리량이 거의 안 늘고, 특정 시점부터는 더 느려짐
  • 연산은 단순하지만 큰 배열/객체 그래프를 훑는 작업에서 자주 발생

왜 느린가

병렬화는 CPU 코어를 더 쓰지만, 데이터가 메모리에서 공급되지 못하면(대역폭 한계) 코어가 놀게 됩니다. 또한 객체가 흩어져 있으면 캐시 미스가 늘고, 병렬로 더 많은 코어가 동시에 메모리를 두드리면서 병목이 빨리 옵니다.

해결

  • 박싱을 줄이고 프리미티브 스트림(IntStream, LongStream) 사용
  • 객체 리스트 대신 구조를 개선(예: 필요한 필드만 배열로 분리하는 SoA 스타일)
  • 연산을 묶어 패스 수를 줄이기(한 번 순회에서 여러 계산)

박싱/언박싱을 줄이는 예:

import java.util.*;
import java.util.stream.*;

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

    // 박싱된 Integer 리스트는 메모리/캐시 측면에서 불리
    List<Integer> boxed = IntStream.range(0, n).boxed().toList();
    long a = boxed.parallelStream().mapToLong(i -> (long) i * 2).sum();

    // 프리미티브 스트림이 더 유리한 경우가 많음
    long b = IntStream.range(0, n).parallel().mapToLong(i -> (long) i * 2).sum();

    System.out.println(a + "/" + b);
  }
}

병렬 스트림 적용 전 체크리스트

아래 중 3개 이상 해당하면 parallelStream() 은 재검토하는 편이 좋습니다.

  1. 원소당 연산이 가볍다(단순 매핑/필터링)
  2. 데이터 소스가 LinkedList/Iterator/생성 비용 큰 스트림이다
  3. 작업이 I/O 블로킹이다(HTTP, DB, 파일)
  4. forEach 내부에서 공유 상태를 업데이트한다(락, 동기화, 공용 컬렉션)
  5. reduce/collector 결합 비용이 크다(문자열 더하기, 리스트 병합)
  6. 메모리 대역폭/캐시 미스가 병목일 가능성이 크다(큰 객체 그래프 스캔)

병렬화는 “붙이면 빨라지는 스위치”가 아니라, 분할 가능성 + 독립성 + 결합 비용 + 실행 풀 격리를 함께 설계해야 성과가 납니다. 병목이 어디에 있는지 먼저 측정하고, 그 병목에 맞는 도구를 선택하세요.

성능 문제를 다룰 때는 데이터 계층에서도 같은 원리가 적용됩니다. 예를 들어 조인/집계가 느릴 때 인덱스와 파이프라인을 먼저 점검하듯이, 스트림 병렬화도 내부 분할과 결합 비용을 먼저 확인해야 합니다. 관련해서는 MongoDB \$lookup 느림? 인덱스·파이프라인 튜닝 글의 “병목을 구조적으로 쪼개서 본다”는 접근이 참고가 됩니다.