병렬 스트림
병렬 스트림 (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
commonPool은 JVM 전체에서 공유된다. 즉, 내 병렬 스트림과 다른 곳의 병렬 처리가 같은 풀을 경쟁해서 쓴다.
커스텀 풀로 격리하기
// 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() |
매우 낮음 | 분할 불가, 순차 처리와 같음 |
LinkedList나 Stream.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 연산 | 느림 | 빠름 |
| 공유 상태 | 안전 | 레이스 컨디션 주의 |
| 디버깅 | 쉬움 | 어려움 |