콘텐츠로 이동

병렬 스트림

병렬 스트림 (Parallel Stream)

왜 쓰는지

일반 스트림은 단일 스레드로 처음부터 끝까지 순서대로 처리한다. 데이터가 100만 건이어도 코어가 16개여도, 코어 1개만 쓴다.

병렬 스트림: 데이터를 여러 조각으로 쪼개 각 코어에 분배하고, 각자 처리한 결과를 마지막에 합친다. CPU 코어를 모두 활용해 처리 시간을 단축하는 것이 목적이다.


ForkJoinPool — 병렬 스트림의 실행 엔진

병렬 스트림은 내부적으로 ForkJoinPool을 사용한다.

ForkJoin이란?

Fork(분기): 작업을 더 작은 단위로 재귀적으로 쪼갠다. Join(합류): 쪼갠 작업들이 완료되면 결과를 합친다.

// 개념적 흐름
전체 리스트 [1~100만]
    → Fork → [1~50만] / [50만~100만]
        → Fork → [1~25만] / [25만~50만] / ...
            → 각 스레드가 처리
        → Join → 결과 합산
    → Join → 최종 결과

스레드 수

기본으로 ForkJoinPool.commonPool()을 공유해서 사용한다. 기본 스레드 수 = CPU 코어 수 - 1 (메인 스레드가 1개를 쓰므로)

// 확인 방법
ForkJoinPool.commonPool().getParallelism(); // 8코어면 7 반환

// JVM 옵션으로 변경 가능
// -Djava.util.concurrent.ForkJoinPool.common.parallelism=4

commonPoolJVM 전체에서 공유된다. 즉, 내 병렬 스트림과 다른 곳의 병렬 처리가 같은 풀을 경쟁해서 쓴다.

커스텀 풀로 격리하기

// commonPool 대신 전용 풀 사용
ForkJoinPool customPool = new ForkJoinPool(4);
List<Integer> result = customPool.submit(() ->
    list.parallelStream()
        .filter(n -> n % 2 == 0)
        .collect(Collectors.toList())
).get();
customPool.shutdown();

다른 작업의 영향을 받지 않아야 할 때, 또는 스레드 수를 명시적으로 제한할 때 사용한다.


작업 훔치기 알고리즘 (Work Stealing)

ForkJoinPool이 일반 스레드풀과 가장 다른 핵심 메커니즘이다.

일반 스레드풀의 문제

일반 ThreadPoolExecutor는 작업 큐가 하나다. 모든 스레드가 하나의 큐를 두고 경쟁한다.

[작업 큐] ← 모든 스레드가 여기서 꺼냄
Task1, Task2, Task3, Task4, Task5 ...

Thread-1: 큐에서 Task1 꺼냄
Thread-2: 큐에서 Task2 꺼냄
Thread-3: 큐에서 Task3 꺼냄 (큐 접근 경합 발생)

스레드가 많아질수록 큐 접근 경합(contention)이 심해진다.

ForkJoinPool의 해결책 — 스레드별 데크(Deque)

각 스레드가 자신만의 작업 큐(덱, double-ended queue)를 가진다.

Thread-1 Deque: [Task1-1] [Task1-2] [Task1-3]  ← 앞에서 꺼내 처리
Thread-2 Deque: [Task2-1] [Task2-2]             ← 앞에서 꺼내 처리
Thread-3 Deque: []                              ← 할 일 없음!
Thread-4 Deque: [Task4-1] [Task4-2] [Task4-3]  ← 앞에서 꺼내 처리

작업 훔치기 발동

Thread-3의 큐가 비어있으면, 다른 스레드의 큐 뒤쪽(tail)에서 작업을 훔쳐온다.

// 훔치기 전
Thread-3 Deque: []                               ← 비어있음
Thread-4 Deque: [Task4-1] [Task4-2] [Task4-3]   ← 바쁨

// Thread-3이 Thread-4의 뒤쪽에서 훔침
Thread-3 Deque: [Task4-3]   ← 훔쳐온 작업 처리
Thread-4 Deque: [Task4-1] [Task4-2]

왜 뒤쪽에서 훔치나?

  • 원래 스레드는 앞(head)에서 꺼내 처리한다 (LIFO 방식, 캐시 지역성 좋음)
  • 훔치는 스레드는 뒤(tail)에서 가져간다

앞·뒤가 분리되므로 동기화 충돌 최소화. 앞에서 꺼내는 스레드와 뒤에서 훔치는 스레드가 거의 충돌하지 않는다.

결과

  • 노는 스레드가 없다 → CPU 낭비 없음
  • 작업량이 스레드마다 불균등해도 자동으로 균형을 맞춤
  • 큐 접근 경합이 적어 확장성이 좋음

데이터 분할 — Spliterator

병렬 스트림이 데이터를 어떻게 쪼개는지는 Spliterator가 결정한다.

// Spliterator의 역할
// 1. trySplit() : 데이터를 절반으로 분할
// 2. tryAdvance() : 다음 요소 처리
// 3. estimateSize() : 남은 요소 수 추정

자료구조마다 분할 효율이 다르다:

자료구조 분할 효율 이유
ArrayList 높음 인덱스로 정확히 절반 분할 가능
int[], long[] 높음 연속 메모리, 인덱스 분할
LinkedList 낮음 절반 위치를 모르므로 순회 필요
HashSet 보통 버킷 단위로 분할, 불균등 가능
Stream.iterate() 매우 낮음 분할 불가, 순차 처리와 같음

LinkedListStream.iterate()로 만든 스트림은 병렬 스트림으로 전환해도 성능 이득이 없다.


사용법

// 방법 1: 컬렉션에서 직접 생성
List<Integer> result = list.parallelStream()
    .filter(n -> n % 2 == 0)
    .map(n -> n * n)
    .collect(Collectors.toList());

// 방법 2: 기존 스트림을 병렬로 전환
list.stream()
    .parallel()
    .map(String::toUpperCase)
    .toList();

// 방법 3: 배열에서 생성
Arrays.stream(arr).parallel().sum();

언제 써야 하는가

조건 적합 부적합
데이터 크기 수십만 건 이상 수천 건 이하 (스레드 분할 오버헤드 > 이득)
연산 종류 CPU 집약적 (복잡한 계산) I/O 대기 위주 (DB, 파일, 네트워크)
자료구조 ArrayList, 배열 LinkedList, iterate() 기반 스트림
순서 순서 무관 처리 순서가 중요할 때
상태 순수 함수, 상태 없음 공유 변수 접근/변경

I/O 대기 위주 작업은 스레드가 많아도 I/O 완료를 기다리느라 코어를 쓰지 않는다. 이런 상황엔 병렬 스트림보다 CompletableFuture + 전용 스레드풀이 적합하다.


주의할 점

1. 공유 상태 변경 금지 (레이스 컨디션)

// 잘못된 예 — ArrayList는 스레드 안전하지 않음
List<Integer> result = new ArrayList<>();
list.parallelStream().forEach(result::add); // 데이터 유실, 예외 가능

// 올바른 예 — collect()는 내부적으로 스레드별 컨테이너 분리 후 합산
List<Integer> result = list.parallelStream()
    .filter(n -> n > 0)
    .collect(Collectors.toList());

2. 순서 의존 연산

// findFirst()는 순서를 보장해야 해서 병렬에서도 오버헤드 발생
list.parallelStream().findFirst();

// 순서 무관하면 findAny()가 훨씬 빠름
list.parallelStream().findAny();

// forEach는 실행 순서가 보장되지 않음
// 순서 보장이 필요하면 forEachOrdered (단, 병렬 이점 감소)
list.parallelStream().forEachOrdered(System.out::println);

3. reduce 초기값 주의

// 잘못된 예 — 초기값 1이 스레드 수만큼 더해짐
// 4스레드면 1 * 4 = 4가 추가로 곱해진 결과가 나올 수 있음
list.parallelStream().reduce(1, (a, b) -> a * b); // 위험!

// 올바른 예 — 곱셈의 항등원은 1이지만
// 실제로 병렬 reduce에서 초기값은 각 서브태스크마다 적용됨
// 덧셈이면 초기값 0, 곱셈이면 초기값 1 — 항등원만 사용할 것
list.parallelStream().reduce(0, Integer::sum); // 덧셈은 안전

4. commonPool 고갈 위험

// 병렬 스트림 안에서 또 병렬 스트림 사용
// → commonPool의 스레드가 서로를 기다리며 데드락 위험
list.parallelStream()
    .map(item -> subList.parallelStream()  // 위험!
        .filter(...)
        .toList())
    .toList();

// 해결: 내부 스트림은 순차로 처리
list.parallelStream()
    .map(item -> subList.stream()  // 안전
        .filter(...)
        .toList())
    .toList();

특징 요약

항목 순차 스트림 병렬 스트림
실행 방식 단일 스레드, 순서대로 ForkJoinPool 멀티 스레드
내부 알고리즘 없음 Fork/Join + Work Stealing
데이터 분할 없음 Spliterator가 재귀 분할
순서 보장 O X (기본)
소량 데이터 빠름 느림 (오버헤드)
대량 CPU 연산 느림 빠름
공유 상태 안전 레이스 컨디션 주의
디버깅 쉬움 어려움