Published on

Java Stream 병렬화가 느린 6가지 원인과 해결

Authors

서버 코드에서 stream().parallel() 또는 parallelStream()을 붙였는데도 성능이 오히려 떨어지는 경우가 흔합니다. 병렬 스트림은 ForkJoinPool.commonPool() 위에서 동작하며, 데이터 분할 방식과 작업의 성격, 공유 자원 경합에 따라 오버헤드가 이득을 압도할 수 있습니다.

이 글에서는 병렬화가 느려지는 대표 원인 6가지를 증상과 진단 포인트, 개선 방법, 코드 예제로 정리합니다. 목표는 "병렬을 켜면 빨라진다"가 아니라, 언제 켜야 하고 언제 꺼야 하는지를 판단하는 것입니다.

병렬 스트림의 기본 동작을 먼저 이해하기

병렬 스트림은 대략 다음 단계를 거칩니다.

  1. 소스 컬렉션을 Spliterator로 분할한다
  2. 분할된 청크를 ForkJoinTask로 만들어 워커 스레드에 배치한다
  3. 각 작업이 map filter reduce 등을 수행한다
  4. 최종 결과를 합친다

여기서 느려지는 지점은 크게 두 갈래입니다.

  • 분할과 스케줄링 오버헤드가 크다
  • 실제 작업이 병렬 친화적이지 않다 (공유 상태, I/O, 캐시 미스, 박싱 등)

아래 6가지는 실무에서 가장 자주 만나는 병목입니다.

1) 데이터 분할이 비효율적인 소스 (Spliterator 특성)

증상

  • ArrayList는 그나마 괜찮은데 LinkedListStream.iterate() 같은 소스에서 병렬화가 특히 느림
  • CPU 사용률이 낮거나, 태스크가 잘게 쪼개지지 않아 워커가 놀고 있음

원인

병렬 스트림의 효율은 Spliterator.trySplit()이 얼마나 잘 분할하느냐에 좌우됩니다.

  • ArrayList나 배열은 인덱스 기반으로 균등 분할이 쉬움
  • LinkedList는 중간 지점 탐색이 비싸고 분할이 비효율적
  • Stream.iterate()generate()는 분할 자체가 어렵거나 불가능에 가까움

해결

  • 가능한 한 배열 또는 ArrayList 기반으로 변환 후 병렬 처리
  • 무한/순차 생성 스트림은 병렬화하지 말고, 먼저 자료구조에 담아 분할 가능하게 만들기
import java.util.*;
import java.util.stream.*;

List<Integer> linked = new LinkedList<>();
for (int i = 0; i < 5_000_000; i++) linked.add(i);

// 느릴 가능성이 큼
long a = linked.parallelStream().mapToLong(x -> x * 2L).sum();

// 분할 친화적으로 변환
List<Integer> array = new ArrayList<>(linked);
long b = array.parallelStream().mapToLong(x -> x * 2L).sum();

추가로, IntStream.range(0, n) 같은 프리미티브 범위 스트림은 분할이 매우 효율적입니다.

2) 작업이 너무 작아서 오버헤드가 이득을 압도

증상

  • 요소 개수가 적거나, 각 요소당 연산이 매우 가벼울 때 병렬이 더 느림
  • 마이크로벤치에서 parallel()이 항상 손해

원인

병렬 스트림은 작업 분할, 태스크 생성, 스케줄링, 합치기 비용이 있습니다. 요소당 연산이 수십 나노초~수백 나노초급이면, 병렬화 비용이 더 큽니다.

해결

  • 병렬화는 충분히 큰 데이터 또는 충분히 무거운 연산에서만
  • 가능하면 연산을 묶어 청크 단위로 처리하거나, 단순 연산은 순차로 두기
import java.util.*;

List<Integer> xs = new ArrayList<>();
for (int i = 0; i < 100_000; i++) xs.add(i);

// 단순 합은 병렬 오버헤드가 더 클 수 있음
long sum1 = xs.parallelStream().mapToLong(i -> i).sum();

// 이런 케이스는 순차가 더 안정적
long sum2 = xs.stream().mapToLong(i -> i).sum();

팁: 병렬화 여부는 "감"이 아니라 측정으로 결정해야 합니다. 단, System.currentTimeMillis()로 재면 노이즈가 큽니다. JMH 같은 프레임워크로 측정하는 편이 안전합니다.

3) 공유 상태와 동기화 경합 (synchronized, lock, Atomic)

증상

  • CPU 사용률은 높지만 처리량이 안 나옴
  • 스레드 덤프에서 BLOCKED 또는 락 경합이 보임
  • AtomicLong 같은 원자 연산이 병렬에서 병목

원인

병렬 스트림에서 forEach로 공유 변수를 업데이트하면 거의 항상 경합이 발생합니다.

나쁜 예시는 다음과 같습니다.

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

AtomicLong acc = new AtomicLong();
List<Integer> xs = Arrays.asList(1,2,3,4,5);

xs.parallelStream().forEach(x -> acc.addAndGet(x));

원자 연산은 안전하지만, 업데이트가 한 지점으로 몰리면 병렬성이 사라집니다.

해결

  • 공유 상태 업데이트 대신 리덕션 연산 사용 (sum, reduce, collect)
  • Collectors.groupingByConcurrent 같은 병렬 친화 수집기 활용
import java.util.*;

List<Integer> xs = Arrays.asList(1,2,3,4,5);

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

그룹핑도 마찬가지입니다.

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

record Item(String key, int value) {}

List<Item> items = List.of(new Item("a",1), new Item("a",2), new Item("b",3));

Map<String, List<Item>> grouped = items.parallelStream()
    .collect(Collectors.groupingByConcurrent(Item::key));

4) I/O 바운드 작업을 병렬 스트림으로 처리 (블로킹)

증상

  • DB 호출, HTTP 호출, 파일 I/O를 parallelStream()으로 돌렸더니 더 느려짐
  • 타임아웃이 늘거나, 외부 시스템 부하만 증가

원인

병렬 스트림은 기본적으로 CPU 바운드 작업에 적합합니다. 그런데 I/O는 블로킹이 발생하고, commonPool 워커가 블로킹되면 다른 CPU 작업까지 같이 밀립니다.

또한 외부 시스템은 동시 요청 수가 늘수록 성능이 선형으로 좋아지지 않고, 오히려 큐잉과 리트라이로 악화될 수 있습니다.

해결

  • I/O는 병렬 스트림 대신 전용 스레드 풀로 동시성 제어
  • 동시 요청 수를 제한하고, 타임아웃과 벌크헤드 패턴 적용

아래는 CompletableFuture와 전용 풀로 동시성을 분리하는 예입니다.

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

ExecutorService ioPool = Executors.newFixedThreadPool(32);

List<String> ids = IntStream.range(0, 10_000)
    .mapToObj(i -> "id-" + i)
    .toList();

List<CompletableFuture<String>> futures = ids.stream()
    .map(id -> CompletableFuture.supplyAsync(() -> fetchFromRemote(id), ioPool))
    .toList();

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

ioPool.shutdown();

static String fetchFromRemote(String id) {
    // HTTP/DB 호출 가정
    return id;
}

I/O 타임아웃이 문제를 만든다는 점에서는, 분산 환경에서의 타임아웃 진단과도 결이 비슷합니다. 예를 들어 gRPC 호출이 지연될 때의 접근은 이 글도 참고할 만합니다: Go gRPC context deadline exceeded 원인·해결

5) 박싱과 객체 할당 폭증 (GC 압박)

증상

  • 병렬로 돌리면 GC 시간이 늘고, 처리량이 떨어짐
  • map에서 객체를 많이 만들수록 병렬이 손해

원인

Stream<Integer> 같은 래퍼 타입 스트림은 박싱과 언박싱이 발생합니다. 병렬에서는 스레드마다 중간 객체가 생성되고, 합치는 과정에서도 임시 객체가 늘 수 있습니다. 결과적으로 GC가 병목이 됩니다.

해결

  • 가능한 한 프리미티브 스트림 사용 (IntStream, LongStream, DoubleStream)
  • 중간 단계에서 불필요한 객체 생성 줄이기
  • mapToInt mapToLong 등을 적극 활용
import java.util.*;
import java.util.stream.*;

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

// 박싱 기반
long a = xs.parallelStream().mapToLong(i -> i * 2L).sum();

// 프리미티브 기반으로 재구성
long b = IntStream.range(0, 5_000_000)
    .parallel()
    .mapToLong(i -> i * 2L)
    .sum();

6) commonPool 경쟁과 스레드 수 미스매치

증상

  • 어떤 요청에서는 빠르다가, 트래픽이 오르면 급격히 느려짐
  • 애플리케이션 다른 곳에서도 CompletableFuture 병렬 작업을 쓰고 있고, 서로 간섭
  • 컨테이너 환경에서 CPU 제한이 있는데도 스레드가 과하거나 부족

원인

병렬 스트림은 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 이 풀은 JVM 전체에서 공유되므로, 다른 병렬 작업과 경쟁하면 성능이 흔들립니다.

또한 commonPool의 병렬도는 CPU 코어 수를 기준으로 정해지는데, 컨테이너의 CPU quota, 하이퍼스레딩, 실제 워크로드 특성과 맞지 않으면 비효율이 발생합니다.

해결

  • 병렬 스트림을 중요한 경로에서 쓴다면 전용 ForkJoinPool로 격리
  • 병렬도는 벤치마크로 조정
  • 요청 처리 스레드와 commonPool이 섞여 병목이 생기지 않게 설계

전용 풀에서 병렬 스트림을 실행하는 예시는 다음과 같습니다.

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

ForkJoinPool pool = new ForkJoinPool(8); // 워크로드에 맞게 조정

long result = pool.submit(() ->
    IntStream.range(0, 20_000_000)
        .parallel()
        .mapToLong(i -> (long) i * i)
        .sum()
).join();

pool.shutdown();

주의: 이 방식은 "병렬 스트림이 항상 전용 풀을 쓴다"가 아니라, 해당 submit 블록 내부에서만 그 풀을 사용하도록 유도하는 패턴입니다.

병렬 스트림 성능 진단 체크리스트

아래 질문에 예가 많을수록 parallel()은 느릴 확률이 큽니다.

  1. 소스가 LinkedList 또는 분할 어려운 스트림인가
  2. 요소당 작업이 가벼운가
  3. synchronized lock Atomic*로 공유 상태를 갱신하는가
  4. DB/HTTP/파일 등 블로킹 I/O인가
  5. 박싱/객체 생성이 많고 GC가 늘어나는가
  6. commonPool을 다른 작업과 공유하며 경쟁하는가

이런 방식의 "원인별 진단" 접근은 데이터베이스에서 인덱스가 안 타는 이유를 체크리스트로 좁혀가는 것과 비슷합니다. 병목을 체계적으로 분해하는 관점은 다음 글도 참고가 됩니다: PostgreSQL JSONB 인덱스가 안타는 이유 7가지

결론: 병렬은 옵션이 아니라 설계 결정

병렬 스트림은 CPU 바운드, 순수 함수형 변환, 분할 가능한 데이터, 공유 상태 없음이라는 조건에서 강력합니다. 반대로 I/O, 동기화 경합, 박싱, 작은 작업, commonPool 경쟁이 섞이면 느려지는 것이 정상입니다.

실무 권장 흐름은 다음과 같습니다.

  • 먼저 순차 스트림 또는 루프로 알고리즘과 할당을 최적화
  • 병렬화가 의미 있는 구간만 분리해 전용 풀 또는 다른 동시성 모델로 제어
  • 마지막으로 JMH나 프로파일러로 측정해 parallel() 적용 여부를 확정

병렬화는 "한 줄"로 보이지만, 실제로는 런타임 스케줄러와 메모리, 락, 외부 시스템까지 포함하는 종합 최적화 문제입니다.