- Published on
Java Stream 성능폭발? Gatherers와 toList 차이
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 Java Stream을 쓰다 보면 어느 순간부터 “특정 요청만 유독 느리다”, “GC가 튄다”, “CPU가 100%로 붙는다” 같은 ‘성능폭발’ 징후를 만납니다. 원인은 대개 Stream 자체가 아니라 종단 연산에서의 물질화(materialization), 불필요한 중간 컬렉션, 그리고 병렬화 시의 오버헤드가 합쳐진 결과입니다.
이번 글에서는 특히 많이 혼동하는 toList()와 Collectors.toList()의 차이를 정리하고, JDK 22+에서 본격적으로 쓸 수 있는 Gatherers(스트림 파이프라인을 더 유연하게 만드는 중간 연산 빌더)가 어떤 문제를 줄여주는지 살펴봅니다.
또한 “성능이 터졌을 때”의 진짜 원인이 Stream이 아니라 스레드/스케줄링/블로킹일 수도 있으니, 병렬 처리까지 함께 보는 관점도 덧붙입니다. (관련해서는 Spring Boot 3 가상스레드 적용 후 성능저하 원인도 함께 보면 좋습니다.)
1) toList() vs Collectors.toList() 핵심 차이
두 API는 겉보기엔 동일하게 List를 만들지만, 실제 특성이 다릅니다.
1-1. 반환 리스트의 변경 가능성(mutability)
stream.toList()- 수정 불가(unmodifiable) 리스트를 반환합니다.
add,remove등을 하면UnsupportedOperationException이 발생할 수 있습니다.
stream.collect(Collectors.toList())- 보통 **수정 가능한
ArrayList**가 반환됩니다. - 단, 명세상 “반드시
ArrayList”를 보장하진 않습니다(구현 의존).
- 보통 **수정 가능한
import java.util.*;
import java.util.stream.*;
public class ToListVsCollectors {
public static void main(String[] args) {
var a = Stream.of(1, 2, 3).toList();
// a.add(4); // 런타임 예외 가능
var b = Stream.of(1, 2, 3).collect(Collectors.toList());
b.add(4); // 일반적으로 OK
System.out.println(a.getClass());
System.out.println(b.getClass());
}
}
실무에서 이 차이가 “성능폭발”을 직접 만들진 않지만, 방어적 복사를 유발해 간접적으로 비용이 커질 수 있습니다.
예를 들어 toList()가 불변이라서 후속 로직에서 수정이 필요하면 다음과 같은 패턴이 생깁니다.
var list = stream.toList();
var mutable = new ArrayList<>(list); // 여기서 한 번 더 복사
대량 데이터에서 이 복사는 메모리와 CPU를 추가로 씁니다. 수정이 필요하다면 처음부터 목적에 맞게 수집하세요.
var mutable = stream.collect(Collectors.toCollection(ArrayList::new));
1-2. 최적화 여지(특히 사이징)
toList()는 JDK 구현이 스트림의 크기를 더 잘 추론할 수 있는 경우가 있어, 내부적으로 더 공격적인 최적화를 할 여지가 있습니다. 반면 Collectors.toList()는 일반적인 Collector 경로를 타기 때문에, 상황에 따라 약간의 오버헤드가 있을 수 있습니다.
다만 이 차이는 보통 미세한 수준이고, 진짜 폭발은 다음에서 옵니다.
- 중간에
map/filter후 여러 번 리스트로 물질화 flatMap으로 폭발적인 확장- 병렬 스트림에서 과도한 분할/병합 비용
- 박싱/언박싱, 람다 캡처, 불필요한 객체 생성
2) “Stream 성능폭발”이 생기는 전형적인 패턴
2-1. 중간 결과를 자꾸 toList()로 끊는 경우
var step1 = users.stream()
.filter(User::isActive)
.toList();
var step2 = step1.stream()
.map(User::getEmail)
.toList();
var step3 = step2.stream()
.distinct()
.toList();
이 코드는 파이프라인을 3번 돌리면서 리스트를 3번 만들고, 객체/배열 재할당도 반복합니다. 데이터가 커질수록 GC 압력이 급격히 증가합니다.
가능하면 한 번의 파이프라인으로 끝내거나, 정말 필요하다면 컬렉션 단계 자체를 줄이세요.
var emails = users.stream()
.filter(User::isActive)
.map(User::getEmail)
.distinct()
.toList();
2-2. flatMap이 데이터 폭증을 만드는 경우
var allItems = orders.stream()
.flatMap(o -> o.items().stream())
.toList();
주문 수가 N, 주문당 아이템이 평균 M이면 결과는 N*M입니다. 문제는 N과 M이 둘 다 커지는 순간 리스트가 폭발한다는 점입니다. 이때 toList()냐 Collectors.toList()냐는 본질이 아닙니다.
해결은 보통 다음 중 하나입니다.
- 상한을 두고 자르기:
limit/takeWhile같은 흐름 제어 - 페이지네이션/커서 기반 조회로 입력 자체를 줄이기
- 결과를 전부 모으지 말고 스트리밍 처리(예: 바로 DB batch insert, 바로 전송)
여기서 Gatherers가 도움을 줄 수 있습니다.
3) Gatherers란 무엇이고, 왜 성능에 유리할까
Gatherers는 JDK 22에서 preview로 들어온 스트림 확장(이후 버전에서 지속 개선)으로, 스트림 중간 연산을 더 유연하게 구성할 수 있게 합니다. 핵심 가치는 다음입니다.
- 중간 연산에서 상태를 가진 변환을 더 자연스럽게 표현
map/filter/flatMap만으로 표현하면 중간 컬렉션이 필요했던 패턴을 줄임- 일부 패턴에서 불필요한 물질화를 피하고, 단일 패스로 처리
즉 “리스트로 한 번 모아서 처리”를 “흐름으로 처리”로 바꾸는 데 유리합니다.
주의: Gatherers는 JDK 버전에 따라 preview 여부/패키지/사용 방법이 달라질 수 있습니다. 도입 전에는 팀의 런타임/JDK 정책을 먼저 확인하세요.
4) Gatherers로 줄일 수 있는 비용 패턴
4-1. 배치 처리: 중간 리스트를 만들지 않고 chunk 단위로 처리
대량 데이터를 한 번에 toList()로 모으면 메모리가 터집니다. 보통은 배치로 잘라서 처리하고 싶습니다.
기존에는 직접 루프를 돌거나, 복잡한 커스텀 Spliterator를 만들기도 했습니다. Gatherers를 쓰면 “흐름에서 배치로 묶기”를 더 선언적으로 표현할 수 있습니다.
아래 코드는 개념 예시입니다(실제 API는 JDK 버전에 맞춰 확인하세요).
import java.util.stream.*;
// import java.util.stream.Gatherers; // JDK 버전에 따라 필요
public class BatchWithGatherers {
static void processBatch(java.util.List<Integer> batch) {
// DB batch insert, 외부 API 호출, 파일 write 등
}
public static void main(String[] args) {
IntStream.range(0, 1_000_000)
.boxed()
// .gather(Gatherers.windowFixed(1000))
// .forEach(BatchWithGatherers::processBatch);
.limit(0) // placeholder
.forEach(x -> {});
}
}
핵심은 끝까지 toList()로 한 방에 모으지 말고, “배치 단위로 흘려보내며 처리”하는 구조로 바꾸는 것입니다.
4-2. 중복 제거/상태 기반 필터링을 중간 단계에서 제어
distinct()는 편하지만 내부적으로 상태를 들고(예: HashSet) 모든 요소를 기억해야 할 수 있습니다. 데이터가 크면 메모리 사용량이 커집니다.
Gatherers는 “상태를 가진 연산”을 좀 더 유연하게 구성할 수 있어, 예를 들어 “최근 10분 내 중복만 제거” 같은 제한된 상태를 두는 식으로 메모리를 제어하는 설계를 할 수 있습니다.
이런 식의 상태 기반 제어는 분산 처리/재처리에서도 중요합니다. 이벤트 처리에서 중복/재시도 설계가 문제라면 Kafka Exactly-Once 깨질 때 원인·해결 7가지도 참고할 만합니다.
5) toList() 선택 기준: 성능보다 “의도”가 먼저
5-1. toList()를 추천하는 경우
- 결과를 읽기 전용으로 쓰고 싶다
- API 계약상 수정되면 안 된다
- 파이프라인 마지막에서 간결하게 마무리하고 싶다
List<String> names = users.stream()
.map(User::getName)
.toList();
5-2. Collectors.toList() 또는 toCollection을 추천하는 경우
- 결과를 이후에 수정해야 한다
- 구체 컬렉션을 지정하고 싶다
var mutable = users.stream()
.map(User::getName)
.collect(Collectors.toCollection(ArrayList::new));
5-3. 진짜 성능이 중요하면 “리스트를 만들지 않는 설계”부터
- 가능하면
forEach로 바로 소비(단, 부작용 관리) reduce/collect로 필요한 결과만 축약- 배치/윈도우/샘플링 등으로 상한을 둠
- 병렬 스트림은 측정 후 도입(특히 블로킹 I/O 섞이면 역효과)
병렬화를 고민한다면, 가상 스레드/플랫폼 스레드/parallel stream의 상호작용이 성능을 좌우할 수 있습니다. 관련 이슈는 Spring Boot 3 가상스레드 적용 후 성능저하 원인에서 더 깊게 다룹니다.
6) 마이크로벤치마크로 확인하는 방법(JMH)
Stream 성능 논쟁은 체감/추측으로 흐르기 쉽습니다. 최소한 JMH로 측정해보면 “내 데이터/내 JDK/내 옵션”에서의 답을 얻을 수 있습니다.
import org.openjdk.jmh.annotations.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.*;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
public class StreamToListBench {
@Param({"1000000"})
int size;
List<Integer> data;
@Setup
public void setup() {
data = IntStream.range(0, size).boxed().toList();
}
@Benchmark
public List<Integer> toListTerminal() {
return data.stream()
.filter(x -> (x & 1) == 0)
.map(x -> x + 1)
.toList();
}
@Benchmark
public List<Integer> collectorsToListTerminal() {
return data.stream()
.filter(x -> (x & 1) == 0)
.map(x -> x + 1)
.collect(Collectors.toList());
}
@Benchmark
public List<Integer> toCollectionArrayList() {
return data.stream()
.filter(x -> (x & 1) == 0)
.map(x -> x + 1)
.collect(Collectors.toCollection(ArrayList::new));
}
}
이 벤치마크로 확인해야 할 포인트는 “어느 쪽이 3% 빠르냐”가 아니라 다음입니다.
- 결과 리스트가 커질 때 GC 시간이 어떻게 변하는가
- 파이프라인을 여러 번 끊어 물질화할 때 비용이 얼마나 증가하는가
- 병렬 스트림을 켰을 때 처리량이 실제로 오르는가(대부분은 오히려 떨어질 수도)
7) 결론: 성능폭발의 범인은 대개 toList()가 아니다
정리하면:
stream.toList()와Collectors.toList()의 가장 큰 차이는 불변 vs 가변 성격입니다.- 성능 차이는 미세할 수 있지만, 불변 리스트 때문에 추가 복사가 생기면 비용이 커질 수 있습니다.
- “성능폭발”은 보통
- 중간 컬렉션 남발,
flatMap폭증,distinct같은 상태 연산의 메모리 사용,- 병렬화 오버헤드 같은 구조적 원인에서 옵니다.
- Gatherers는 이런 구조를 단일 패스/흐름 기반으로 바꾸는 선택지를 제공해, 대량 처리에서 특히 유용합니다.
다음에 Stream이 느려졌다면 toList()부터 의심하기보다, “내가 지금 왜 리스트로 전부 모으고 있지?”를 먼저 점검해보는 게 가장 빠른 해결책입니다.