Published on

Java Stream 병렬 처리 성능 폭망 원인 6가지

Authors

서버 코드에서 parallelStream()을 붙였는데 오히려 더 느려지는 경험은 흔합니다. Java Stream 병렬 처리는 "CPU 코어를 더 쓰니 빨라지겠지"라는 직관과 달리, 분할 비용·스케줄링·메모리/락 경합·I/O 대기·풀 공유 문제로 쉽게 역효과가 납니다.

이 글에서는 성능이 폭망하는 대표 원인 6가지를 "왜 느려지는지" 관점에서 설명하고, 바로 적용 가능한 진단 및 개선 코드를 함께 제공합니다.

참고로 병렬 처리의 핵심은 "작업이 CPU-bound인지, I/O-bound인지"를 먼저 분리하는 것입니다. API 호출처럼 외부 대기 시간이 지배적이면 재시도/타임아웃/큐잉 설계가 더 중요합니다. 관련해서는 OpenAI API 429 폭탄 대응 실전 가이드도 함께 보면 좋습니다.

병렬 Stream의 기본 동작을 먼저 이해하기

  • stream().parallel() 또는 parallelStream()은 내부적으로 ForkJoinPool.commonPool()을 사용합니다.
  • 작업은 Spliterator로 쪼개져(fork) 여러 워커 스레드에서 실행되고, 결과를 다시 합칩니다(join).
  • 병렬화 이득은 대략 다음 조건에서만 잘 나옵니다.
    • 작업 1건당 연산량이 충분히 큼(분할/스케줄링 오버헤드보다 큼)
    • 데이터 분할이 효율적(균등하게 쪼개짐)
    • 공유 상태/락/메모리 병목이 적음

이 전제가 깨지는 순간, parallelStream()은 "스레드만 늘어난 느린 코드"가 되기 쉽습니다.

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

병렬 처리에는 고정 비용이 있습니다.

  • Spliterator 분할 비용
  • 태스크 생성 및 work-stealing 스케줄링 비용
  • 스레드 간 컨텍스트 전환
  • 결과 병합 비용

작업이 단순한 산술 연산이나 짧은 문자열 처리처럼 매우 작으면, 병렬화 오버헤드가 전체 시간을 잡아먹습니다.

나쁜 예

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

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

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

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

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

이런 케이스는 대개 직렬이 더 빠르거나 비슷합니다.

개선 체크

  • 1건당 처리 시간이 짧으면 병렬화를 포기하거나, 배치 단위로 묶어 작업량을 키우세요.
  • 성능 측정은 반드시 JMH로 하세요(워밍업/컴파일 최적화 영향 제거).

원인 2) 데이터 소스/분할이 비효율적이라 워커가 놀게 됨

병렬 Stream의 성능은 Spliterator가 얼마나 잘 쪼개는지에 크게 좌우됩니다.

  • ArrayList, 배열, IntStream.range() 같은 "인덱스 기반"은 분할이 쉽고 균등합니다.
  • LinkedList, Iterator 기반, 생성형 Stream은 분할 효율이 떨어져 병렬화 이득이 작습니다.

대표적으로 위험한 예: LinkedList

import java.util.*;

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

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

    System.out.println(sum);
  }
}

LinkedList는 분할을 위해 앞에서부터 순회해야 해서 분할 비용이 커지고, 워커 간 작업 분배도 나빠집니다.

개선 체크

  • 병렬 처리가 필요하면 입력 컬렉션을 ArrayList 또는 배열로 바꾸는 것만으로도 개선되는 경우가 많습니다.
  • Stream 파이프라인 앞단에서 toArray()로 물리적 연속성을 확보하는 것도 방법입니다(단, 메모리 비용 주의).

원인 3) 공유 상태(락, synchronized, atomic)로 경합이 발생

병렬 처리에서 가장 흔한 폭망 패턴은 "공유 변수에 누적"입니다.

  • synchronized 블록
  • AtomicLong.incrementAndGet() 같은 원자 연산
  • ConcurrentHashMap.compute() 같은 고경합 업데이트

이런 코드는 스레드를 늘릴수록 락/캐시 라인 경합이 증가해 더 느려집니다.

최악의 예: 공유 누적

import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.IntStream;

public class Contention {
  public static void main(String[] args) {
    AtomicLong acc = new AtomicLong();

    IntStream.range(0, 10_000_000)
        .parallel()
        .forEach(i -> acc.addAndGet(i));

    System.out.println(acc.get());
  }
}

개선: 리덕션을 사용

import java.util.stream.IntStream;

public class Reduction {
  public static void main(String[] args) {
    long sum = IntStream.range(0, 10_000_000)
        .parallel()
        .asLongStream()
        .sum();

    System.out.println(sum);
  }
}

Stream의 reduce/sum/collect는 병렬 친화적으로 설계되어 있습니다. 특히 Collector는 combiner로 부분 결과를 합치므로 공유 상태 업데이트를 줄일 수 있습니다.

개선 체크

  • forEach에서 외부 상태를 변경하지 마세요.
  • 반드시 병렬 안전한 Collector를 사용하고, 가능하면 불변 객체/로컬 누적을 사용하세요.

원인 4) I/O-bound 작업을 병렬 Stream으로 밀어 넣음

parallelStream()은 CPU를 더 쓰는 모델입니다. 그런데 작업이 DB/HTTP/파일 I/O라면 대부분 시간이 "대기"에 쓰입니다.

  • 병렬 Stream은 기본적으로 commonPool의 제한된 스레드 수로 동작합니다.
  • I/O 대기가 길면 워커가 블로킹되어 풀 전체가 굳습니다.
  • 외부 시스템은 동시 요청이 늘면 rate limit, 커넥션 풀 고갈, 타임아웃이 발생합니다.

이 상황은 단순히 느려지는 것을 넘어 장애로 이어질 수 있습니다. 외부 호출의 타임아웃/데드라인이 중요한 이유는 gRPC에서도 동일합니다. Go gRPC 데드라인 초과 원인과 해결 가이드처럼 "대기 시간"을 제어하는 설계가 우선입니다.

나쁜 예: HTTP 호출을 parallelStream으로

// 예시용 의사 코드
ids.parallelStream()
   .map(id -> httpClient.get("https://api/service/" + id))
   .toList();

개선 방향

  • I/O 작업은 별도의 전용 스레드 풀(크기 제어) + 비동기 API(CompletableFuture)로 분리하세요.
  • 동시성 제한(세마포어, 벌크헤드), 타임아웃, 재시도/백오프를 함께 설계하세요.
import java.util.*;
import java.util.concurrent.*;

public class IoBoundBetter {
  static final ExecutorService ioPool = Executors.newFixedThreadPool(64);

  static String fetch(String id) {
    // 블로킹 I/O라고 가정
    return "ok:" + id;
  }

  public static void main(String[] args) {
    List<String> ids = List.of("a", "b", "c");

    List<CompletableFuture<String>> futures = ids.stream()
        .map(id -> CompletableFuture.supplyAsync(() -> fetch(id), ioPool)
            .orTimeout(2, TimeUnit.SECONDS))
        .toList();

    List<String> results = futures.stream()
        .map(CompletableFuture::join)
        .toList();

    System.out.println(results);
    ioPool.shutdown();
  }
}

핵심은 "병렬 Stream 하나로 CPU 작업과 I/O 작업을 섞지 말 것"입니다.

원인 5) ForkJoinPool.commonPool 공유로 인한 간섭(서버에서 특히 치명적)

parallelStream()은 기본적으로 전역 공유 풀을 씁니다. 이 풀은 애플리케이션 전체에서 공유되므로 다음 문제가 생깁니다.

  • 다른 라이브러리도 commonPool을 사용하면 서로 간섭
  • 한 요청의 무거운 병렬 작업이 다른 요청의 작업을 굶김
  • 블로킹 작업이 섞이면 풀 전체 처리량 급락

특히 웹 서버/배치가 함께 도는 환경에서 "갑자기 전체가 느려졌다"는 현상은 common pool 간섭이 원인인 경우가 많습니다.

개선: 전용 ForkJoinPool에서 실행

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

public class DedicatedPool {
  public static void main(String[] args) throws Exception {
    ForkJoinPool pool = new ForkJoinPool(8); // 서비스에 맞게 튜닝

    long result = pool.submit(() ->
        IntStream.range(0, 10_000_000)
            .parallel()
            .asLongStream()
            .map(x -> x * 2)
            .sum()
    ).get();

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

이 방식은 "병렬 Stream은 쓰되, 실행 풀은 격리"하는 전략입니다.

개선 체크

  • 서버 애플리케이션에서는 commonPool에 의존하는 병렬화를 신중히 사용하세요.
  • 블로킹 가능성이 있는 작업은 commonPool에 두지 마세요.

원인 6) 메모리 대역폭/캐시 미스/false sharing으로 CPU가 놀게 됨

병렬 처리는 CPU 코어를 늘리지만, 메모리 접근 패턴이 나쁘면 병목은 메모리로 이동합니다.

  • 대용량 배열/객체를 여러 스레드가 동시에 스캔하면 메모리 대역폭이 포화
  • 객체 그래프가 크면 캐시 미스 증가
  • 인접한 메모리(같은 캐시 라인)에 서로 다른 스레드가 쓰면 false sharing 발생

이 경우 CPU 사용률은 높아 보이지만 실제 처리량은 오르지 않거나 감소합니다.

전형적 패턴: 거대한 객체 리스트를 병렬로 변환

// 예시: 큰 객체를 많이 만지고, 중간 객체를 대량 생성
var out = bigList.parallelStream()
    .map(x -> expensiveToCopyAndAllocate(x))
    .toList();

개선 체크

  • 박싱/언박싱과 중간 객체 생성을 줄이세요(가능하면 IntStream, LongStream 같은 primitive stream 활용).
  • 데이터 레이아웃을 연속적으로(배열/구조체 유사) 바꾸면 캐시 효율이 좋아집니다.
  • 병렬화보다 먼저 알고리즘/자료구조 최적화가 우선인 경우가 많습니다.

빠르게 진단하는 체크리스트

아래 질문에 "예"가 많을수록 병렬 Stream이 느려질 확률이 큽니다.

  1. 작업 1건이 1ms 미만의 가벼운 연산인가?
  2. 입력이 LinkedList, Iterator, 생성형 Stream인가?
  3. forEach 안에서 공유 상태를 업데이트하는가?
  4. DB/HTTP/파일 I/O 같은 블로킹 작업이 섞였는가?
  5. 서버에서 commonPool을 공유하며 여러 요청이 동시에 병렬 Stream을 쓰는가?
  6. 중간 객체를 많이 만들거나 메모리 스캔이 지배적인가?

성능 문제는 코드만 봐서는 결론이 안 나는 경우가 많습니다. JFR, async-profiler로 CPU/락/할당(Allocation)과 스레드 상태를 함께 보세요. 특히 락 경합이 섞이면 DB 쿼리에서 IN이 느려지는 것처럼(겉보기 단순하지만 내부 비용이 큼) 병목이 예상 밖에서 터집니다. 관련 관점은 MySQL·PostgreSQL에서 느린 IN 쿼리, JOIN이 더 빠른 이유도 참고할 만합니다.

결론: 병렬 Stream은 "조건부 도구"다

parallelStream()은 잘 맞는 문제에서는 구현 비용 대비 큰 이득을 주지만, 다음 원칙을 어기면 성능이 폭망하기 쉽습니다.

  • 작은 작업을 무작정 병렬화하지 말 것
  • 분할이 잘 되는 데이터 구조를 선택할 것
  • 공유 상태 업데이트를 피하고 리덕션/컬렉터를 사용할 것
  • I/O 작업은 별도 풀과 비동기 모델로 분리할 것
  • commonPool 간섭을 피하기 위해 풀 격리를 고려할 것
  • 메모리 병목(캐시/대역폭/할당)을 먼저 의심할 것

병렬화는 "스레드를 늘리는 기술"이 아니라 "병목을 올바른 곳으로 옮기는 기술"에 가깝습니다. 먼저 병목이 CPU인지, 락인지, I/O인지 확인한 뒤 그에 맞는 도구(병렬 Stream, 전용 풀, 비동기, 배치/큐잉)를 선택하는 것이 가장 빠른 최적화입니다.