- Published on
Java Stream 병렬처리 성능폭망 원인 5가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 CPU 코어가 많고 데이터도 크면 parallelStream() 한 줄로 성능이 날아오를 것 같지만, 실무에서는 오히려 느려지거나 지연 시간이 튀는 일이 흔합니다. 이유는 단순히 "병렬이라서"가 아니라, ForkJoinPool 스케줄링 특성, 데이터 소스의 분할 가능성, 작업의 성격, 공유 자원 경쟁, 그리고 JVM 레벨의 오버헤드가 동시에 얽히기 때문입니다.
이 글에서는 Java Stream 병렬처리 성능이 폭망하는 대표 원인 5가지를 재현 가능한 코드와 함께 설명하고, 각 원인별로 어떤 식으로 진단하고 우회할지까지 정리합니다.
병렬화는 "더 많은 스레드"가 아니라 "더 적은 대기"를 만드는 기술입니다. 대기가 줄지 않으면 병렬화는 비용만 추가합니다.
병렬 Stream의 기본 전제: 무엇이 병렬화되는가
stream().parallel() 또는 parallelStream()은 내부적으로 Spliterator로 데이터를 쪼개고, 기본적으로 ForkJoinPool.commonPool()에서 태스크를 실행합니다. 이때 성능은 아래 조건에 크게 좌우됩니다.
- 소스가 잘 쪼개지는가(분할 비용과 균등 분배)
- 각 요소 처리 비용이 충분히 큰가(오버헤드 대비 작업량)
- 작업이 CPU bound인가, I/O bound인가
- 공유 자원(락, DB 커넥션, 캐시, 로그)에 경쟁이 없는가
- 호출 스레드나 다른 프레임워크와 공용 풀 충돌이 없는가
아래부터는 실무에서 가장 많이 터지는 5가지를 뽑아 설명합니다.
1) 작업이 너무 가벼워서 오버헤드가 이긴다
병렬 Stream은 태스크 분할, 큐잉, 워크 스틸링, 스레드 컨텍스트 등 고정 오버헤드가 있습니다. 요소당 연산이 아주 가벼우면, 병렬화로 얻는 이득보다 오버헤드가 커져서 느려집니다.
재현 예시: 가벼운 산술 연산
import java.util.stream.IntStream;
public class ParallelOverheadDemo {
public static void main(String[] args) {
int n = 50_000_000;
long t1 = System.currentTimeMillis();
long sum1 = IntStream.range(0, n)
.map(i -> i * 2 + 1)
.asLongStream()
.sum();
long t2 = System.currentTimeMillis();
long t3 = System.currentTimeMillis();
long sum2 = IntStream.range(0, n)
.parallel()
.map(i -> i * 2 + 1)
.asLongStream()
.sum();
long t4 = System.currentTimeMillis();
System.out.println("seq=" + (t2 - t1) + "ms sum=" + sum1);
System.out.println("par=" + (t4 - t3) + "ms sum=" + sum2);
}
}
환경에 따라 다르지만, 이런 류는 parallel()이 더 느리게 나오는 경우가 많습니다.
대응
- 요소당 작업이 충분히 무거운지 먼저 확인합니다. 경험적으로는 요소당 수십 마이크로초 미만이면 병렬화 이득이 작습니다.
- 마이크로벤치마크는 JMH로 측정합니다.
System.currentTimeMillis()는 워밍업, JIT, GC 영향에 취약합니다. - 단순 집계는 Stream보다 루프가 빠를 수 있습니다.
2) 데이터 소스가 잘 쪼개지지 않는다(나쁜 Spliterator)
병렬 Stream의 핵심은 "잘 분할되는 소스"입니다. ArrayList나 배열은 반으로 쪼개기 쉬워 병렬화에 유리하지만, LinkedList, Iterator 기반 스트림, BufferedReader.lines() 같은 소스는 분할 비용이 크거나 균등 분배가 어렵습니다.
재현 예시: LinkedList의 병렬 처리
import java.util.LinkedList;
import java.util.List;
public class BadSplitDemo {
public static void main(String[] args) {
List<Integer> list = new LinkedList<>();
for (int i = 0; i < 5_000_000; i++) list.add(i);
long t1 = System.currentTimeMillis();
long s1 = list.stream().mapToLong(i -> i * 3L).sum();
long t2 = System.currentTimeMillis();
long t3 = System.currentTimeMillis();
long s2 = list.parallelStream().mapToLong(i -> i * 3L).sum();
long t4 = System.currentTimeMillis();
System.out.println("seq=" + (t2 - t1) + "ms " + s1);
System.out.println("par=" + (t4 - t3) + "ms " + s2);
}
}
LinkedList는 랜덤 액세스가 느리고 분할이 비효율적이라 병렬화가 손해가 되기 쉽습니다.
대응
- 병렬 처리가 필요하면 소스를
ArrayList나 배열로 바꿉니다. - 파일 라인 처리처럼 분할이 어려운 소스는 병렬 Stream이 답이 아닐 수 있습니다. 배치 단위로 읽어서 청크를 병렬 처리하거나, 명시적 큐 기반 파이프라인을 고려합니다.
3) I/O bound 작업을 병렬 Stream으로 돌린다
병렬 Stream은 기본적으로 CPU 코어 수 정도의 병렬성을 가정합니다. 그런데 작업이 DB 호출, HTTP 호출, 디스크 I/O처럼 대기가 긴 I/O bound라면, ForkJoinPool의 소수 스레드로는 대기를 숨기기 어렵고 오히려 공용 풀을 막아 다른 작업까지 느리게 만들 수 있습니다.
특히 Spring Boot에서 parallelStream()으로 DB를 두드리면, 다음 문제가 한꺼번에 터집니다.
- 커넥션 풀(HikariCP) 고갈
- DB 락 경합 증가
- 공용 풀 스레드가 I/O 대기로 묶여 CPU 작업도 지연
관련해서 커넥션 풀 고갈 패턴은 아래 글과 맥락이 같습니다.
재현 예시: I/O 흉내(슬립)를 병렬로 돌리기
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class IoBoundDemo {
static String fakeCall(int i) {
try {
Thread.sleep(50); // I/O 대기 흉내
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "ok-" + i;
}
public static void main(String[] args) {
List<Integer> ids = IntStream.range(0, 200).boxed().collect(Collectors.toList());
long t1 = System.currentTimeMillis();
var r1 = ids.stream().map(IoBoundDemo::fakeCall).collect(Collectors.toList());
long t2 = System.currentTimeMillis();
long t3 = System.currentTimeMillis();
var r2 = ids.parallelStream().map(IoBoundDemo::fakeCall).collect(Collectors.toList());
long t4 = System.currentTimeMillis();
System.out.println("seq=" + (t2 - t1) + "ms size=" + r1.size());
System.out.println("par=" + (t4 - t3) + "ms size=" + r2.size());
}
}
parallelStream()이 빨라질 수는 있지만, 공용 풀을 I/O 대기로 묶는 순간 서버 전체의 tail latency가 악화될 수 있습니다.
대응
- I/O는
CompletableFuture와 별도ExecutorService로 격리합니다. - DB, HTTP는 동시성 제한(세마포어, bulkhead)을 둡니다.
- 프레임워크가 이미 비동기 모델(WebFlux 등)을 쓰면 병렬 Stream을 섞지 않습니다.
4) 공유 자원 경합: synchronized, 락, 원자 변수, 로그
병렬 처리는 공유 자원을 만나는 순간 급격히 무너집니다. 대표적으로 아래가 흔합니다.
synchronized블록ReentrantLockAtomicLong같은 단일 핫스팟 카운터ConcurrentHashMap에 높은 충돌로compute연산이 몰림- 로깅이 동기 I/O 또는 appender 락을 잡음
재현 예시: AtomicLong 핫스팟
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.IntStream;
public class ContentionDemo {
public static void main(String[] args) {
AtomicLong counter = new AtomicLong();
long t1 = System.currentTimeMillis();
IntStream.range(0, 50_000_000)
.parallel()
.forEach(i -> counter.incrementAndGet());
long t2 = System.currentTimeMillis();
System.out.println("counter=" + counter.get() + " time=" + (t2 - t1) + "ms");
}
}
이 코드는 CPU를 많이 쓰면서도 스케일이 잘 안 나옵니다. 모든 스레드가 같은 메모리 위치를 두드리며 캐시 라인 경합이 발생하기 때문입니다.
대응
- 병렬 집계는
LongAdder같은 분산 카운터를 고려합니다. - 가능하면
collect를 사용해 스레드 로컬로 누적한 뒤 합칩니다. - 로깅은 병렬 루프 내부에서 최소화합니다.
5) 공용 ForkJoinPool 충돌과 블로킹 호출
parallelStream()은 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 문제는 이 풀이 애플리케이션 전체에서 공유된다는 점입니다.
- 다른 라이브러리도 common pool을 사용하면 서로 간섭합니다.
- 병렬 Stream 내부에서 블로킹을 하면, 풀의 워커가 묶여 데드락에 가까운 정체가 생길 수 있습니다.
- 서블릿 요청 스레드에서
parallelStream()을 과도하게 쓰면, 요청 처리량이 출렁입니다.
재현 예시: 공용 풀 병목을 만들기 쉬운 패턴
아래처럼 병렬 루프 안에서 또 다른 병렬 작업이나 블로킹을 호출하면 위험합니다.
import java.util.stream.IntStream;
public class CommonPoolStarvationDemo {
static void blockingWork() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) {
long t1 = System.currentTimeMillis();
IntStream.range(0, 200)
.parallel()
.forEach(i -> {
// 블로킹 호출
blockingWork();
});
long t2 = System.currentTimeMillis();
System.out.println("time=" + (t2 - t1) + "ms");
}
}
이 자체는 단순하지만, 실무에서는 여기서 DB 호출, 외부 API, 파일 I/O가 들어가며 common pool이 쉽게 포화됩니다.
대응
- 병렬 Stream을 써야 한다면, 공용 풀에 의존하지 말고 전용 풀을 고려합니다.
import java.util.concurrent.ForkJoinPool;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class CustomPoolDemo {
public static void main(String[] args) throws Exception {
ForkJoinPool pool = new ForkJoinPool(8);
try {
var result = pool.submit(() ->
IntStream.range(0, 1_000_000)
.parallel()
.map(i -> i * 2)
.boxed()
.collect(Collectors.toList())
).get();
System.out.println("size=" + result.size());
} finally {
pool.shutdown();
}
}
}
- 블로킹 작업은 병렬 Stream 대신 명시적
ExecutorService로 격리하고, 동시성 한도를 둡니다. - 운영 환경에서는 스레드 덤프와 지표로 common pool 포화를 관찰합니다.
실무 체크리스트: 병렬 Stream 도입 전 10분 점검
아래 질문에 하나라도 "아니오"가 많으면 병렬 Stream은 보류하는 편이 안전합니다.
- 소스가 배열 또는
ArrayList처럼 잘 분할되는가 - 요소당 작업이 충분히 무거운가
- 작업이 CPU bound인가
- 공유 자원(락, 커넥션, 캐시, 로그)에 경합이 없는가
- 순서 보장이 필요 없나(
forEachOrdered,sorted는 병렬 이득을 크게 깎음) - 예외 처리와 재시도가 병렬 환경에서 안전한가
- common pool을 다른 곳에서 많이 쓰지 않나
- GC 압박(객체 생성 폭증)이 없나
- 성능 측정을 JMH 또는 실트래픽 리플레이로 했나
- tail latency가 중요한 API 경로에 넣는 건 아닌가
결론: 병렬 Stream은 만능이 아니라 "조건부 최적화"
parallelStream()은 코드량 대비 성능을 쉽게 얻을 수 있는 도구지만, 다음 조건에서 특히 성능이 폭망하기 쉽습니다.
- 작업이 너무 가벼움(오버헤드가 이김)
- 소스가 분할에 부적합함(나쁜
Spliterator) - I/O bound 작업을 공용 풀에서 병렬화함
- 공유 자원 경합(락, 원자 변수, 로깅)
- common pool 충돌 및 블로킹으로 인한 스레드 기아
병렬화는 "쓰면 빨라진다"가 아니라, 데이터 구조, 작업 특성, 실행 풀, 공유 자원을 함께 설계해야 성과가 납니다. 특히 서버 애플리케이션에서는 처리량뿐 아니라 지연 시간 분산까지 같이 보면서 적용 여부를 결정하는 것이 안전합니다.
추가로 운영 장애 관점에서 "겉으로는 CPU가 남는데 응답이 느려지는" 유형은 원인 파악이 어렵습니다. 리소스 병목 진단 습관을 들이면 병렬화 문제도 훨씬 빨리 잡힙니다. 예를 들어 디스크가 꽉 찬 것처럼 보이는데 실제로는 열린 삭제 파일이 공간을 잡아먹는 케이스처럼, 원인이 다른 곳에 숨어 있는 경우가 많습니다.