- Published on
Java Stream 병렬처리 성능 역전 7가지 함정
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 성능 튜닝을 하다 보면 stream()을 parallelStream()으로 바꿨을 뿐인데 처리량이 늘기는커녕 지연이 증가하는 경우를 자주 만납니다. Java Stream 병렬 처리는 “CPU 코어를 더 쓰니 빨라진다”가 아니라, 분할 비용·스케줄링·동기화·메모리 대역폭·GC·I/O 대기 같은 현실적인 병목에 따라 성패가 갈립니다.
이 글에서는 병렬 스트림이 성능을 “개선”이 아니라 “역전”시키는 대표적인 7가지 함정을 정리하고, 각 함정별로 어떤 코드가 문제인지, 어떻게 확인하고 고칠지까지 함께 다룹니다.
추가로 Spliterator/Collector 관점의 더 깊은 튜닝은 다음 글도 참고할 수 있습니다: Java Stream 병렬 처리 느려짐? Spliterator·Collector 튜닝
병렬 스트림의 기본 전제: 공짜 점심은 없다
병렬 스트림은 보통 ForkJoinPool.commonPool()을 사용합니다. 즉,
- 작업 분할(스플리팅)
- 태스크 생성 및 큐잉
- 워커 스레드 스케줄링
- 결과 병합
이 모든 오버헤드를 감당하고도 남을 만큼 작업 단위가 충분히 크고 독립적이어야 이득이 납니다. 반대로 작업이 작거나 공유 자원이 많거나, 분할이 비효율적이면 오히려 느려집니다.
아래 함정들을 보면 “왜 느려지는지”가 구체적으로 보일 겁니다.
함정 1) 작업이 너무 작아 오버헤드가 이긴다
가장 흔한 케이스입니다. 원소마다 하는 일이 가벼우면 병렬화 오버헤드가 실제 계산보다 커집니다.
import java.util.*;
import java.util.stream.*;
public class SmallWorkload {
public static void main(String[] args) {
List<Integer> data = IntStream.range(0, 5_000_000).boxed().toList();
long t1 = System.nanoTime();
long s1 = data.stream().mapToLong(x -> x + 1).sum();
long t2 = System.nanoTime();
long t3 = System.nanoTime();
long s2 = data.parallelStream().mapToLong(x -> x + 1).sum();
long t4 = System.nanoTime();
System.out.println(s1 + " " + (t2 - t1));
System.out.println(s2 + " " + (t4 - t3));
}
}
체크 포인트
- 원소당 연산이 단순 산술/필드 접근 수준인가
- 데이터 크기가 수천~수만 단위로 작거나, 연산이 매우 빠른가
개선 방향
- 병렬화는 “원소 수”보다 원소당 비용이 큰지로 판단
- 간단한 연산은 오히려 루프가 더 빠를 때가 많음
- 반드시 벤치마크는 JMH로(워밍업/인라이닝/GC 영향을 제거)
함정 2) 데이터 소스가 분할에 불리하다 (특히 LinkedList)
병렬 스트림은 Spliterator로 데이터를 쪼개는데, 컬렉션의 구조에 따라 분할 효율이 극단적으로 달라집니다.
ArrayList는 인덱스 기반 분할이 쉬워 병렬에 유리LinkedList는 분할이 비싸거나 균등 분할이 어려워 병렬에 불리
List<Integer> linked = new LinkedList<>();
for (int i = 0; i < 2_000_000; i++) linked.add(i);
long sum = linked.parallelStream()
.mapToLong(x -> x * 2L)
.sum();
체크 포인트
- 소스가
LinkedList,Stream.iterate,Iterator기반인가 spliterator().characteristics()에SIZED,SUBSIZED가 부족한가
개선 방향
- 가능하면
ArrayList/배열로 변환 후 병렬 처리 - 생성 단계부터 분할 친화적 구조로 설계
함정 3) 공용 ForkJoinPool 고갈 및 간섭
병렬 스트림은 기본적으로 공용 풀을 씁니다. 문제는 애플리케이션 어딘가에서 이미 공용 풀을 빡빡하게 쓰고 있으면, 내 병렬 스트림이 스레드 경쟁으로 느려지거나 지연이 출렁입니다.
예를 들어 다음이 동시에 존재하면 위험합니다.
- 병렬 스트림
CompletableFuture.supplyAsync의 기본 실행자(역시 commonPool)- 프레임워크 내부의 포크조인 사용
재현 예시(간섭)
import java.util.concurrent.*;
import java.util.stream.*;
public class CommonPoolInterference {
public static void main(String[] args) {
// commonPool을 먼저 바쁘게 만든다
for (int i = 0; i < 100; i++) {
CompletableFuture.runAsync(() -> {
long x = 0;
for (long k = 0; k < 2_000_000_000L; k++) x += k;
});
}
// 그 와중에 parallelStream 실행
long sum = IntStream.range(0, 10_000_000)
.parallel()
.mapToLong(i -> i)
.sum();
System.out.println(sum);
}
}
개선 방향
- 병렬 스트림을 공용 풀에서 분리하고 싶다면, 전용
ForkJoinPool에서 실행
import java.util.concurrent.*;
import java.util.stream.*;
ForkJoinPool pool = new ForkJoinPool(8);
long result = pool.submit(() ->
IntStream.range(0, 10_000_000)
.parallel()
.mapToLong(i -> i * 2L)
.sum()
).join();
- 단, 전용 풀을 늘려도 CPU 코어 수/컨텍스트 스위칭 비용을 고려해야 합니다.
함정 4) 공유 상태(동기화, 락, 원자 연산)로 직렬화된다
병렬 처리가 느려지는 본질적인 이유 중 하나는 공유 상태 업데이트입니다. 예를 들어 synchronized, AtomicLong, ConcurrentHashMap.compute 같은 연산이 병렬 구간에 들어가면, 결국 병렬 태스크들이 같은 자원을 두고 줄을 서게 됩니다.
나쁜 예: AtomicLong 누적
import java.util.concurrent.atomic.*;
import java.util.stream.*;
AtomicLong acc = new AtomicLong();
IntStream.range(0, 20_000_000)
.parallel()
.forEach(i -> acc.addAndGet(i));
이 코드는 각 원소마다 원자 연산을 수행해 캐시 라인 경쟁이 심해지고, 성능이 급락할 수 있습니다.
좋은 예: reduce/sum 사용
long sum = IntStream.range(0, 20_000_000)
.parallel()
.asLongStream()
.sum();
개선 방향
- 가능한 한 무상태(stateless) 함수로 구성
- 누적은
reduce,sum,collect의 병렬 친화적 결합(associative)으로 처리 Collectors.toMap등도 병렬에서 병합 비용이 커질 수 있으니 데이터 특성에 맞는 컬렉터 선택
함정 5) 순서 보장 연산이 병목을 만든다
병렬 스트림에서 forEachOrdered, sorted, limit 같은 연산은 병렬성을 크게 제한하거나, 병렬로 처리해도 병합 단계에서 강한 제약이 생깁니다.
List<Integer> out = IntStream.range(0, 5_000_000)
.parallel()
.boxed()
.sorted() // 전역 정렬은 비용이 크다
.limit(100) // 앞부분만 필요해도 전체 파이프라인 제약 발생 가능
.toList();
체크 포인트
- 병렬 스트림에
sorted가 섞여 있는가 - 출력 순서를 꼭 지켜야 해서
forEachOrdered를 쓰는가
개선 방향
- 순서가 필요 없다면
forEach로 전환 - 상위 N개가 필요하면 전역 정렬 대신 힙 기반 top-K 같은 알고리즘 고려
unordered()힌트를 줄 수 있는 경우 활용
long count = IntStream.range(0, 10_000_000)
.parallel()
.unordered()
.filter(i -> (i & 1) == 0)
.count();
함정 6) I/O 바운드 작업을 병렬 스트림으로 밀어 넣는다
병렬 스트림은 CPU 바운드 작업에 적합합니다. 그런데 네트워크 호출, DB 쿼리, 파일 I/O 같은 블로킹 작업을 병렬 스트림에 넣으면 commonPool 워커가 대기 상태로 묶이고, 전체 시스템 처리량이 악화됩니다.
urls.parallelStream()
.map(url -> httpGet(url)) // 블로킹 I/O
.toList();
이 패턴은 다음 문제를 유발합니다.
- 워커 스레드가 블로킹되어 다른 CPU 작업도 굶김
- 백엔드(예: DB 커넥션 풀) 한도를 초과해 타임아웃 증가
가상 스레드/커넥션 풀 고갈 같은 이슈는 다음 글의 맥락과도 맞닿아 있습니다: Spring Boot 3 가상 스레드 후 DB 커넥션 고갈 해결
개선 방향
- I/O는 병렬 스트림보다 다음 중 하나가 더 안전한 경우가 많습니다.
- 비동기 I/O(예:
HttpClientasync) - 명시적 스레드 풀(크기 제한) + 백프레셔
- 배치 처리(한 번에 여러 건 요청)
- 비동기 I/O(예:
함정 7) 박싱/언박싱과 GC 압력으로 메모리 병목이 난다
병렬 처리에서 CPU보다 먼저 무너지는 게 메모리 대역폭과 GC인 경우가 많습니다. 특히 다음 패턴이 위험합니다.
Stream<Integer>같은 박싱 스트림- 중간 단계에서 객체를 대량 생성(
map에서 DTO 생성 등) flatMap으로 작은 객체를 폭발적으로 생성
List<Integer> data = IntStream.range(0, 30_000_000).boxed().toList();
// 박싱된 Integer를 계속 다루면 메모리/GC 부담이 커진다
long sum = data.parallelStream()
.map(x -> x + 1) // 오토 언박싱/박싱 발생 가능
.mapToLong(Integer::longValue)
.sum();
개선 방향
- 기본형 스트림 사용:
IntStream,LongStream,DoubleStream
long sum = IntStream.range(0, 30_000_000)
.parallel()
.mapToLong(i -> (long) i + 1)
.sum();
- 객체 생성이 많다면 병렬화보다 알고리즘/데이터 표현을 먼저 최적화
- GC 로그로 확인:
-Xlog:gc*(JDK 9+)
실전 점검 체크리스트
병렬 스트림을 적용하기 전/후로 아래를 빠르게 확인하면 “성능 역전”을 상당수 예방할 수 있습니다.
- 작업 단위가 충분히 무거운가: 원소당 연산이 최소 수 마이크로초 이상인지
- 소스가 분할 친화적인가: 배열/
ArrayList/기본형 스트림인지 - 공용 풀 간섭이 있는가: commonPool을 다른 기능이 같이 쓰는지
- 공유 상태가 있는가: 락/원자 연산/공유 맵 업데이트가 파이프라인에 있는지
- 순서 보장 연산이 섞였는가:
sorted,forEachOrdered,limit의 영향 - I/O를 섞었는가: DB/HTTP/파일 처리라면 병렬 스트림이 맞는지
- 메모리/GC가 병목인가: 박싱, 객체 폭증, GC time 비중
결론: parallel은 스위치가 아니라 설계 선택이다
parallelStream()은 “간단한 코드 변경으로 성능이 좋아질 수 있는 옵션”이지만, 동시에 시스템을 느리게 만들 수 있는 강력한 레버이기도 합니다. 위 7가지 함정 중 하나라도 해당되면 병렬화는 기대와 달리 역효과가 날 가능성이 큽니다.
권장 흐름은 다음 순서입니다.
- JMH로 단일 기능을 정확히 측정
- 소스 분할성, 공유 상태, 순서 제약, I/O 여부를 점검
- 필요하면 전용 풀/기본형 스트림/컬렉터 튜닝으로 개선
병렬 스트림이 느린 케이스에서 Spliterator/Collector 레벨로 더 파고들고 싶다면, 앞서 소개한 글(Java Stream 병렬 처리 느려짐? Spliterator·Collector 튜닝)을 함께 읽으면 원인 분석이 훨씬 빨라집니다.