Published on

Java Stream 병렬처리가 느려지는 7가지 함정

Authors

서버 코드에서 parallelStream() 을 붙였는데 오히려 느려지는 경험은 흔합니다. 병렬 처리는 CPU 코어를 더 쓰는 기능이 아니라, 작업을 분할하고 스케줄링하고 합치는 오버헤드를 감수하면서도 이득이 날 때만 효과가 납니다. 특히 Java Stream의 병렬화는 내부적으로 ForkJoinPool.commonPoolSpliterator 분할 전략에 크게 의존하기 때문에, 데이터 구조·연산 형태·동기화·GC·외부 I/O 같은 요소가 조금만 어긋나도 성능이 급락합니다.

아래 7가지 함정은 실무에서 병렬 스트림이 느려지는 대표 원인입니다. 각 항목마다 “왜 느려지는지”와 “어떻게 피하는지”를 코드와 함께 정리합니다.

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

병렬 스트림은 요소를 쪼개고(split), 워커 스레드에 할당하고, 결과를 합치는 비용이 있습니다. 연산이 아주 가볍거나 데이터가 작으면 이 오버헤드가 계산 비용보다 커집니다.

증상

  • 요소당 연산이 단순 산술, 짧은 문자열 처리 수준
  • 데이터 크기가 수천~수만 이하
  • 단일 스레드가 이미 L1/L2 캐시에 잘 맞는 작업

예시

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

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

    long t1 = System.nanoTime();
    long s1 = xs.stream().mapToLong(x -> x + 1).sum();
    long t2 = System.nanoTime();

    long t3 = System.nanoTime();
    long s2 = xs.parallelStream().mapToLong(x -> x + 1).sum();
    long t4 = System.nanoTime();

    System.out.println("seq=" + (t2 - t1) / 1_000_000 + "ms, par=" + (t4 - t3) / 1_000_000 + "ms");
    System.out.println(s1 + "," + s2);
  }
}

대응

  • 병렬화는 “요소당 연산이 충분히 무거운지”부터 판단
  • 간단한 변환/필터는 순차가 더 빠른 경우가 많음
  • 성능 측정은 가능하면 JMH 사용(워밍업/인라이닝/GC 영향 제거)

2) 데이터 소스가 분할에 불리한 구조(특히 LinkedList, Stream.generate)

병렬 스트림의 핵심은 Spliterator균등하게 쪼개질수록 이득이 난다는 점입니다. ArrayList 나 배열은 인덱스 기반으로 반씩 쪼개기 쉬운 반면, LinkedList 는 분할 자체가 비싸고 균등 분할도 어렵습니다.

흔한 함정

  • LinkedList.parallelStream()
  • Stream.iterate(...) / Stream.generate(...) 기반 무한/준무한 스트림
  • I/O 스트림을 그대로 병렬화

예시: LinkedList

import java.util.*;

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

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

    System.out.println(sum);
  }
}

대응

  • 병렬 처리를 고려한다면 소스를 ArrayList / 배열 / IntStream.range 로 바꾸기
  • 가능한 경우 primitive 스트림(IntStream, LongStream) 사용
long sum = java.util.stream.IntStream.range(0, 1_000_000)
    .parallel()
    .mapToLong(x -> (long) x * x)
    .sum();

3) 공유 상태(락, synchronized, Atomic*)로 직렬화됨

병렬 스트림에서 가장 흔한 실수는 “병렬로 돌리되 결과를 공유 컬렉션에 넣는” 패턴입니다. 이때 synchronizedList, ConcurrentHashMap 업데이트, AtomicLong.addAndGet 같은 동기화 지점이 병목이 되어 결국 직렬에 가깝게 동작합니다.

나쁜 예: 공유 리스트에 add

import java.util.*;

public class SharedState {
  public static void main(String[] args) {
    List<Integer> result = Collections.synchronizedList(new ArrayList<>());

    java.util.stream.IntStream.range(0, 1_000_000)
        .parallel()
        .forEach(result::add);

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

좋은 예: collect 로 병합(스레드 로컬 누적 후 결합)

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

public class CollectProperly {
  public static void main(String[] args) {
    List<Integer> result = IntStream.range(0, 1_000_000)
        .parallel()
        .boxed()
        .collect(Collectors.toList());

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

대응 체크리스트

  • forEach 에서 외부 상태 변경 금지
  • 누적은 reduce / collect 사용
  • 카운팅은 mapToLong(...).sum() 같은 내장 집계를 우선

4) 블로킹 I/O를 병렬 스트림으로 처리하면 commonPool이 잠김

병렬 스트림은 기본적으로 ForkJoinPool.commonPool 을 씁니다. 이 풀은 CPU 바운드 작업에 맞춰 설계되어 있고, 스레드 수가 보통 코어 수 - 1 정도입니다. 여기서 DB 호출, HTTP 호출, 파일 I/O처럼 블로킹 작업을 병렬 스트림으로 돌리면 워커가 대기 상태로 묶여 다른 작업도 같이 느려집니다.

나쁜 예: HTTP 호출을 parallelStream 으로

List<String> urls = List.of("https://a", "https://b");

List<String> bodies = urls.parallelStream()
    .map(url -> httpGet(url)) // 블로킹
    .toList();

대응

  • 블로킹 I/O는 별도 스레드풀(큰 풀) + CompletableFuture 로 분리
  • 또는 논블로킹 클라이언트 사용
import java.util.*;
import java.util.concurrent.*;

public class IoWithExecutor {
  static String httpGet(String url) {
    // 블로킹 호출이라고 가정
    return url;
  }

  public static void main(String[] args) throws Exception {
    ExecutorService ioPool = Executors.newFixedThreadPool(64);

    List<String> urls = List.of("https://a", "https://b");

    List<CompletableFuture<String>> futures = urls.stream()
        .map(u -> CompletableFuture.supplyAsync(() -> httpGet(u), ioPool))
        .toList();

    List<String> bodies = futures.stream().map(CompletableFuture::join).toList();
    System.out.println(bodies);

    ioPool.shutdown();
  }
}

이 문제는 “스레드가 대기하면서 풀 전체 처리량이 떨어지는” 전형적인 패턴입니다. 비슷한 형태의 병목 분석/튜닝 관점은 Spring Boot DB 커넥션 고갈 - HikariCP 튜닝 가이드에서도 참고할 만합니다.

5) limit, findFirst, 정렬 등 ‘순서’ 제약이 병렬 효율을 깎음

병렬 스트림은 순서 제약이 들어가면 합치는 단계에서 추가 비용이 들거나, 일부 연산은 병렬화 이점이 크게 줄어듭니다.

병렬에 불리한 연산들

  • sorted() (전체 정렬은 비용이 크고 병합 단계가 큼)
  • distinct() (해시 구조 공유/병합)
  • limit(n) + ordered stream (필요 이상으로 처리)
  • findFirst() (순서 보장을 위해 동기화/조기 종료가 비효율)

개선 포인트

  • 순서가 필요 없다면 unordered() 를 명시
  • findFirst() 대신 가능하면 findAny()
import java.util.*;

public class OrderConstraints {
  public static void main(String[] args) {
    List<Integer> xs = new ArrayList<>();
    for (int i = 0; i < 10_000_000; i++) xs.add(i);

    // 순서 제약 완화
    Optional<Integer> any = xs.parallelStream()
        .unordered()
        .filter(x -> x % 9_999_983 == 0)
        .findAny();

    System.out.println(any.orElse(-1));
  }
}

6) 박싱/언박싱과 객체 할당 폭증으로 GC가 병목

병렬 스트림에서 객체를 많이 만들면 각 스레드가 동시에 할당을 밀어붙여 GC 압력이 급증합니다. 특히 Stream<Integer> 같은 박싱 스트림에서 map(x -> x * 2) 를 반복하면 오토박싱 객체가 쏟아집니다.

나쁜 예: 박싱 스트림

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

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

    long sum = xs.parallelStream()
        .map(x -> x + 1) // Integer 객체 생성/박싱 가능
        .mapToLong(Integer::longValue)
        .sum();

    System.out.println(sum);
  }
}

좋은 예: primitive 스트림 유지

long sum = java.util.stream.IntStream.range(0, 20_000_000)
    .parallel()
    .map(x -> x + 1)
    .asLongStream()
    .sum();

GC가 의심되면 힙 덤프/할당량/GC 로그로 확인해야 합니다. 메모리 병목을 튜닝하는 흐름은 Spring Boot OutOfMemoryError 덤프 분석·튜닝 7단계와도 연결됩니다.

7) commonPool 경쟁(서버 전체에서 공유)과 잘못된 병렬도 설정

병렬 스트림은 기본적으로 전역 풀인 ForkJoinPool.commonPool 을 사용합니다. 문제는 이 풀이 JVM 프로세스 전체에서 공유된다는 점입니다.

실제로 생기는 일

  • 다른 곳에서 CompletableFuture.supplyAsync 를 기본 설정으로 많이 사용
  • 서드파티 라이브러리가 commonPool을 사용
  • 그 결과 병렬 스트림이 사용할 워커가 부족해지고, 컨텍스트 스위칭이 늘고, 지연이 튐

또한 컨테이너 환경에서는 CPU quota 때문에 “보이는 코어 수”와 “실제로 쓸 수 있는 코어”가 다를 수 있어 병렬도가 과하게 잡히기도 합니다.

대응 1: 별도 ForkJoinPool 에서 실행

병렬 스트림 자체는 풀을 인자로 받지 않지만, 풀에서 작업을 실행시키는 방식으로 우회할 수 있습니다.

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

public class CustomPoolParallelStream {
  public static void main(String[] args) throws Exception {
    ForkJoinPool pool = new ForkJoinPool(8);

    List<Integer> xs = java.util.stream.IntStream.range(0, 5_000_000).boxed().toList();

    long sum = pool.submit(() ->
        xs.parallelStream().mapToLong(x -> (long) x * 2).sum()
    ).get();

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

대응 2: 병렬도/경쟁 상태를 관측

  • jcmd 로 스레드 덤프를 떠서 ForkJoinPool.commonPool-worker-* 상태 확인
  • 애플리케이션 레벨에서 병렬 스트림 사용 구간을 줄이고, CPU 바운드 연산만 남기기

운영 환경에서 “공유 리소스 경쟁”은 성능을 가장 흔들어 놓는 원인입니다. 비슷한 방식으로 원인을 추적하는 글로는 systemd 서비스가 계속 재시작될 때 원인 추적법도 참고할 수 있습니다.

병렬 스트림 적용 전 30초 점검표

아래 질문에 대부분 Yes 면 병렬이 이득일 가능성이 큽니다.

  • 요소 수가 충분히 많고(대략 수십만 이상), 요소당 연산이 무겁다
  • 데이터 소스가 배열/ArrayList/IntStream.range 처럼 잘 쪼개진다
  • 연산이 순수 함수에 가깝고 공유 상태 업데이트가 없다
  • 블로킹 I/O가 없다(또는 별도 풀로 분리했다)
  • sorted, distinct, findFirst, limit 같은 순서 제약을 피하거나 완화했다
  • 박싱/객체 할당을 최소화했고 primitive 스트림을 활용했다
  • commonPool 경쟁이 없거나, 별도 ForkJoinPool 로 격리했다

결론: parallelStream() 은 “스위치”가 아니라 “설계 변경”이다

parallelStream() 은 한 줄로 병렬성을 켜지만, 성능은 데이터 구조·연산 특성·동기화·메모리·스레드풀 경쟁까지 함께 설계해야 나옵니다. 위 7가지 함정을 먼저 제거한 뒤, JMH나 운영 지표로 “정말 빨라졌는지”를 확인하는 순서가 안전합니다.

실무 팁으로는, 병렬 스트림은 CPU 바운드 계산에 제한적으로 쓰고, I/O 병렬성은 CompletableFuture 와 명시적 Executor로 분리하는 전략이 가장 실패 확률이 낮습니다.