- Published on
Java Stream 성능 폭발? boxing·toList 함정 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 Stream을 쓰면 코드가 깔끔해지고, 병렬 처리도 쉽게 붙일 수 있다는 기대가 생깁니다. 그런데 실서비스 트래픽이나 배치 작업에서 어느 순간부터 CPU가 치솟고 GC가 난리 나면서 “Stream이 성능을 폭발시켰다”는 이야기가 나오곤 합니다.
문제는 Stream 자체가 느리다기보다, Stream 파이프라인에서 무심코 발생하는 boxing/unboxing, 불필요한 중간 컬렉션, 그리고 toList() 사용 방식이 비용을 크게 키우는 경우가 많다는 점입니다. 이 글에서는 성능이 터지는 패턴을 재현하고, 어디서 비용이 발생하는지, 그리고 어떻게 안전하게 고칠지 정리합니다.
1) Stream이 느려지는 구조적 이유: 객체·할당·가상 호출
Stream 파이프라인은 대략 다음 비용을 가집니다.
- 요소마다 람다 호출(가상 호출/인라이닝 실패 가능)
- 중간 연산 체인의 상태 머신(특히
flatMap,distinct,sorted) - 박싱된 래퍼 객체 생성(예:
int를Integer로) - 최종 연산에서 컬렉션을 만들며 재할당/복사
즉, 단순한 루프에서 “원시 타입 배열을 순회하며 더한다” 같은 작업은 JIT이 아주 공격적으로 최적화하는 반면, Stream은 추상화 레이어가 두껍기 때문에 같은 연산이라도 비용이 커질 수 있습니다.
다만, Stream이 항상 느린 것은 아닙니다. IO 바운드, 데이터 크기가 작거나, 명확한 병렬화 이득이 있는 경우엔 생산성과 가독성을 얻는 편이 낫습니다. 핵심은 병목이 되는 구간에서만 정확히 피하는 것입니다.
2) 성능 폭발의 1순위: boxing/unboxing 지뢰
2-1) 흔한 실수: map으로 Integer 만들기
다음 코드는 겉보기에 문제 없어 보이지만, 요소마다 Integer 객체를 만들 수 있습니다.
List<Integer> ids = users.stream()
.map(User::getId) // getId()가 int여도 여기서 boxing될 수 있음
.toList();
Stream<User>에서map(User::getId)의 결과는Stream<Integer>가 됩니다.int가Integer로 boxing되며 객체가 생성됩니다.- 대량 데이터에서 이 객체들이 GC 압력을 크게 늘립니다.
2-2) 해결: primitive stream으로 끝까지 밀기
가능하면 원시 타입 스트림을 사용하세요.
int[] ids = users.stream()
.mapToInt(User::getId) // IntStream
.toArray();
또는 합계/최댓값 같은 집계라면 더 직접적입니다.
long total = orders.stream()
.mapToLong(Order::amount)
.sum();
mapToInt, mapToLong, mapToDouble은 boxing 없이 동작하고, 집계 연산도 원시 타입으로 처리됩니다.
2-3) “어차피 List가 필요”할 때는?
UI/JSON 응답 때문에 List<Integer>가 꼭 필요할 수 있습니다. 그 경우에도 다음을 고려하세요.
- 정말
List<Integer>가 필요한지(예: 내부 처리만이면int[]로 충분한지) - 변환 지점을 최대한 마지막으로 미루기
IntStream idStream = users.stream().mapToInt(User::getId);
// ... 중간에 필터/정렬/집계 등 원시 타입으로 처리
List<Integer> ids = idStream.boxed().toList();
boxed()는 결국 boxing을 하지만, 최소한 파이프라인 중간에 불필요한 객체 생성을 줄이고, 원시 타입 기반 연산을 활용할 수 있습니다.
3) toList() 함정: 불변 리스트와 숨은 복사 비용
Java 16+의 Stream.toList()는 편리하지만, 다음 특성이 있습니다.
- 결과가 수정 불가능(unmodifiable) 리스트입니다.
- 구현은 JDK 내부 최적화가 있지만, 상황에 따라 배열 크기 확장/복사가 발생할 수 있습니다.
- 이후에
new ArrayList<>(stream.toList())같은 패턴을 쓰면 한 번 더 복사합니다.
3-1) 안 좋은 패턴: toList() 후 다시 복사
List<Foo> list = new ArrayList<>(foos.stream()
.filter(Foo::isActive)
.toList());
여기서 비용은 최소 2번입니다.
toList()가 내부 배열을 만들며 요소를 담음new ArrayList<>(...)가 다시 한 번 요소를 복사
데이터가 크면 이 복사가 눈에 띄게 비쌉니다.
3-2) 해결: 처음부터 원하는 컬렉션으로 수집
수정 가능한 리스트가 필요하면 다음처럼 바로 수집하세요.
List<Foo> list = foos.stream()
.filter(Foo::isActive)
.collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new));
또는 단순히 Collectors.toList()도 가능하지만, 구현이 명시적으로 ArrayList를 보장하진 않습니다(대부분 ArrayList로 오지만 스펙상 고정은 아님).
List<Foo> list = foos.stream()
.filter(Foo::isActive)
.collect(java.util.stream.Collectors.toList());
정리하면:
- 불변 리스트가 OK:
stream.toList() - 가변 리스트가 필요:
toCollection(ArrayList::new) - 추가 복사 피하기:
toList()결과를 다시new ArrayList<>(...)로 감싸지 말기
3-3) 반대로 “불변 리스트”가 성능에 유리할 때
불변 리스트는 이후 변경이 없다는 가정이 가능해지고, 방어적 복사를 줄일 수 있어 전체 시스템 비용을 낮추는 경우도 있습니다. 예를 들어 API 응답 DTO 구성 후 절대 변경하지 않는다면 toList()가 오히려 좋은 선택입니다.
즉, 함정은 toList() 자체가 아니라 필요도 없는데 다시 복사하거나, 가변 리스트로 착각하는 것입니다.
4) 또 다른 폭탄: sorted, distinct, flatMap의 메모리/CPU 비용
Stream에서 아래 연산은 본질적으로 무겁습니다.
sorted(): 전체를 모아서 정렬해야 하므로O(n log n)+ 버퍼 메모리distinct():HashSet을 써서 중복 제거(해시 비용 + 메모리)flatMap(): 중첩 스트림 생성/해제 및 람다 호출 비용 증가
4-1) distinct()를 피할 수 있으면 피하기
예를 들어 원본이 이미 유니크하거나, DB에서 DISTINCT로 처리할 수 있다면 애플리케이션에서 distinct()를 돌리는 것 자체가 낭비입니다.
// 애플리케이션에서 distinct() 하기 전에
// 데이터 소스에서 유니크 보장/정렬 보장 여부를 먼저 확인
List<Long> ids = rows.stream()
.map(Row::id)
.distinct()
.toList();
가능하다면:
- 쿼리에서
DISTINCT처리 - 혹은 애초에 Set으로 받기
4-2) sorted()는 “정말 필요한가”부터 확인
정렬은 사용자에게 보여줘야 하는 경우가 아니라면, 종종 습관적으로 붙습니다. 특히 대량 데이터에서 정렬은 CPU와 메모리를 동시에 먹습니다.
5) 병렬 스트림이 만능이 아닌 이유
parallelStream()은 다음 조건에서 역효과가 날 수 있습니다.
- 요소가 작고 연산이 가벼움(스레드 분할/병합 오버헤드가 더 큼)
- 공유 자원을 건드림(락/경합)
Collectors.toList()같은 수집에서 병합 비용 증가
List<Result> results = inputs.parallelStream()
.map(this::lightweightFn)
.toList();
이런 경우엔 단일 스레드 루프가 더 빠를 수 있습니다. 병렬화는 “CPU를 태울 만큼 무거운 작업”에서만 이득이 납니다.
6) 실전 처방전: 바꾸기 쉬운 순서대로
6-1) 1단계: primitive stream으로 교체
map대신mapToInt/mapToLongsum,average,max같은 원시 집계 사용
long sum = items.stream().mapToLong(Item::price).sum();
6-2) 2단계: toList() 사용 의도를 명확히
- 불변으로 써도 되는가? 그렇다면
toList()유지 - 가변이 필요하면
toCollection(ArrayList::new)로 한 번에
List<String> mutable = stream.collect(
java.util.stream.Collectors.toCollection(java.util.ArrayList::new)
);
6-3) 3단계: 중간 컬렉션 만들지 않기
다음처럼 “필터 결과를 리스트로 만들고 다시 스트림”은 대량 데이터에서 손해입니다.
List<Foo> tmp = foos.stream().filter(Foo::ok).toList();
long count = tmp.stream().mapToLong(Foo::value).sum();
한 번에 끝내세요.
long sum = foos.stream()
.filter(Foo::ok)
.mapToLong(Foo::value)
.sum();
6-4) 4단계: 루프가 더 낫다면 루프로
핵심 경로(hot path)에서 극단적으로 단순한 연산은 for 루프가 가장 빠르고 예측 가능할 때가 많습니다.
long sum = 0;
for (int i = 0; i < arr.length; i++) {
int v = arr[i];
if ((v & 1) == 0) sum += v;
}
Stream은 “표현력”이 장점이고, 루프는 “최저 오버헤드”가 장점입니다. 둘 중 하나가 절대적으로 우월한 게 아니라, 병목 구간에서만 선택하면 됩니다.
7) 측정 없이는 최적화도 없다: JMH로 확인하기
마이크로벤치마크는 반드시 JMH를 권장합니다. 단순 System.nanoTime()은 JIT 워밍업, 데드코드 제거, 인라이닝 때문에 결과가 왜곡되기 쉽습니다.
아래는 boxing 차이를 보기 위한 예시 뼈대입니다.
@org.openjdk.jmh.annotations.State(org.openjdk.jmh.annotations.Scope.Thread)
public class StreamBench {
private int[] data;
@org.openjdk.jmh.annotations.Setup
public void setup() {
data = java.util.stream.IntStream.range(0, 10_000_000).toArray();
}
@org.openjdk.jmh.annotations.Benchmark
public long intStreamSum() {
return java.util.stream.IntStream.of(data)
.filter(x -> (x & 1) == 0)
.asLongStream()
.sum();
}
@org.openjdk.jmh.annotations.Benchmark
public long boxedStreamSum() {
return java.util.Arrays.stream(data)
.boxed()
.filter(x -> (x & 1) == 0)
.mapToLong(Integer::longValue)
.sum();
}
}
이런 테스트를 돌려보면, 데이터 크기가 커질수록 boxed() 경로가 GC와 할당 비용으로 급격히 불리해지는 패턴을 확인할 가능성이 큽니다.
8) 체크리스트: “Stream 성능 폭발” 예방 규칙
- 원시 타입이면
IntStream/LongStream/DoubleStream을 우선 사용 boxed()는 정말 필요할 때만, 그리고 가능한 마지막에toList()는 불변 리스트임을 전제로 사용하고, 가변이 필요하면toCollection(ArrayList::new)toList()결과를 다시new ArrayList<>(...)로 감싸는 이중 복사 금지distinct()/sorted()/flatMap()은 비용이 큰 연산임을 인지하고 데이터 크기 기준으로 재검토parallelStream()은 측정 후 적용(가벼운 연산에는 역효과 가능)- 최종 결론은 JMH/프로파일러로 확인
9) 마무리: 성능 문제는 “원인 분해”가 답이다
Stream은 잘 쓰면 생산성을 크게 올리지만, boxing과 컬렉션 수집 방식이 꼬이면 CPU와 메모리가 동시에 터지는 형태로 나타납니다. 특히 대량 데이터 처리에서 Stream<Integer> 같은 박싱 스트림은 GC 비용을 폭발시키는 대표 원인입니다.
성능 튜닝은 감으로 하는 게 아니라, 원인을 분해하고(박싱인지, 정렬인지, 수집인지), 가장 큰 비용부터 제거하는 순서로 접근해야 합니다. 이런 접근은 다른 장애/성능 이슈에서도 동일하게 통합니다. 예를 들어 원인 추적과 진단이 중요한 케이스는 MySQL InnoDB 데드락 로그로 원인 쿼리 추적 같은 글의 방식처럼 “증거 기반”으로 접근하는 게 가장 빠릅니다.
또한 성능 문제는 종종 환경/운영 이슈와 함께 나타납니다. 재시작 루프처럼 증상이 비슷해 보이는 문제를 분리 진단하는 방법은 systemd 서비스 재시작 루프 진단 - 로그·유닛·쉘도 함께 참고하면 도움이 됩니다.
핵심 경로에서 Stream을 쓴다면, 오늘 정리한 두 가지( boxing 과 toList() 수집 전략 )만 제대로 잡아도 “왜 갑자기 느려졌지?” 같은 성능 폭발 상황의 상당 부분을 예방할 수 있습니다.