- Published on
Java Stream 성능폭탄 - boxed() 제거 5가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 CPU가 애매하게 치솟고, GC가 잦아지고, p99 지연이 늘어났는데 코드 리뷰를 해보면 의외로 흔한 범인이 있습니다. 바로 Java Stream 파이프라인 중간에 끼어든 boxed() 입니다.
boxed() 자체는 “primitive 스트림을 래퍼 객체 스트림으로 바꾸는” 단순한 변환이지만, 그 순간부터 오토박싱 객체 할당, 힙 메모리 사용량 증가, GC 압력 증가, 캐시 친화성 저하가 연쇄적으로 발생할 수 있습니다. 특히 대량 데이터(수십만~수천만) 처리, 요청당 여러 번 반복되는 집계, 배치 작업에서 성능폭탄이 되기 쉽습니다.
이 글은 boxed()를 무조건 금지하자는 얘기가 아니라, 불필요한 boxed()를 제거하는 5가지 실전 패턴을 제시합니다. 마지막에는 JMH로 “진짜로 빨라졌는지” 검증하는 방법도 포함합니다.
성능 문제는 흔히 타임아웃으로 표면화됩니다. 스트림이 병목이 되면 gRPC/HTTP 타임아웃으로 이어지기도 하니, 장애 진단 관점은 Spring Boot gRPC DEADLINE_EXCEEDED 타임아웃 진단도 함께 참고하면 좋습니다.
boxed()가 왜 성능폭탄이 되나
1) 객체 할당과 GC 압력
IntStream 같은 primitive 스트림은 내부적으로 int를 다룹니다. 하지만 boxed()를 호출하면 각 원소가 Integer 객체로 바뀝니다.
- 원소
N개면 객체도 대략N개(일부 캐시 범위 제외) 생성 - 힙 점유 증가
- Young GC 빈도 증가, 상황에 따라 Old Gen 승격 증가
Integer는 -128부터 127까지 캐시가 있지만, 데이터가 그 범위를 벗어나면 대부분 새 객체가 됩니다. 게다가 캐시 범위 안이라도 “객체 참조를 다루는 비용”은 남습니다.
2) 메모리 접근 패턴 악화
primitive 배열/스트림은 연속된 메모리(개념적으로) 접근이 가능하지만, 래퍼 객체는 포인터 추적(pointer chasing) 이 필요합니다. CPU 캐시 효율이 떨어지고, 대량 처리에서 차이가 커집니다.
3) 다운스트림 연산이 연쇄적으로 느려짐
boxed() 이후에는 Stream<Integer>이므로 mapToInt로 다시 내려오거나, Collectors에서 오토언박싱이 발생합니다. 즉,
int→Integer(박싱)Integer→int(언박싱)
이 왕복이 파이프라인 곳곳에서 반복될 수 있습니다.
제거 1) boxed() 없이 primitive 스트림 종단 연산을 끝까지 사용
가장 흔한 실수는 “집계만 하면 되는데도” 중간에 boxed()를 넣는 것입니다.
나쁜 예
int sum = IntStream.range(0, n)
.boxed()
.reduce(0, Integer::sum);
좋은 예
int sum = IntStream.range(0, n)
.sum();
sum, average, min, max, count, summaryStatistics 같은 종단 연산은 primitive 스트림에 이미 최적화되어 있습니다.
자주 쓰는 대체표
reduce(Integer::sum)→sum()reduce(Math::min)→min()collect(toList())후 루프 →summaryStatistics()또는sum()등
제거 2) collect(toList())가 필요하면 “마지막 1회만” 박싱
리스트가 꼭 필요해서 List<Integer>로 만들어야 한다면, 박싱은 피할 수 없습니다. 하지만 중요한 건 박싱을 파이프라인 중간에서 조기에 하지 말고, 정말 필요한 시점에 한 번만 하라는 것입니다.
나쁜 예: 중간부터 박싱해서 이후 연산도 객체로 수행
List<Integer> result = IntStream.range(0, n)
.boxed()
.filter(x -> x % 2 == 0)
.map(x -> x * 3)
.toList();
좋은 예: primitive로 최대한 처리 후 마지막에만 박싱
List<Integer> result = IntStream.range(0, n)
.filter(x -> x % 2 == 0)
.map(x -> x * 3)
.boxed()
.toList();
이렇게 하면 filter, map이 primitive 기반으로 실행되어 CPU와 메모리 측면에서 유리합니다.
제거 3) Collectors.groupingBy 대신 primitive 친화 구조로 누적
boxed()가 자주 등장하는 곳이 groupingBy입니다. IntStream에서 groupingBy를 쓰려면 보통 boxed()가 필요하니까요.
하지만 키가 int이고 값이 카운트/합계 같은 단순 집계라면, Map<Integer, ...> 자체를 재고할 수 있습니다.
예: 모듈로 그룹 카운트
나쁜 예
Map<Integer, Long> counts = IntStream.range(0, n)
.boxed()
.collect(java.util.stream.Collectors.groupingBy(
x -> x % 10,
java.util.stream.Collectors.counting()
));
좋은 예 1: 배열 누적(키 범위가 작고 연속적일 때)
long[] counts = new long[10];
IntStream.range(0, n).forEach(x -> counts[x % 10]++);
좋은 예 2: HashMap에 직접 누적(키 범위가 크지만 집계가 단순할 때)
java.util.Map<Integer, Long> counts = new java.util.HashMap<>();
IntStream.range(0, n).forEach(x -> {
int key = x % 10;
counts.merge(key, 1L, Long::sum);
});
배열 누적은 특히 빠릅니다. groupingBy는 내부적으로 많은 람다 호출과 객체 경로를 거치고, 박싱까지 얹히면 비용이 커집니다.
제거 4) distinct()/sorted() 때문에 박싱하지 말고 primitive 버전을 우선
distinct()나 sorted()를 쓰려고 boxed()를 붙이는 경우가 있습니다. 하지만 primitive 스트림에도 distinct()와 sorted()가 있습니다.
나쁜 예
int[] arr = ...;
int[] out = java.util.Arrays.stream(arr)
.boxed()
.distinct()
.sorted()
.mapToInt(Integer::intValue)
.toArray();
좋은 예
int[] arr = ...;
int[] out = java.util.Arrays.stream(arr)
.distinct()
.sorted()
.toArray();
추가로, sorted()는 데이터 크기에 따라 비용이 급격히 커집니다. 진짜로 정렬이 필요한지(예: 상위 k개만 필요하면 partial sort나 힙 사용)도 함께 점검하세요.
제거 5) Map 조회/조인 때문에 박싱하지 말고, 키 타입을 재설계
스트림 처리 중간에 Map<Integer, V> 조회를 하려고 boxed()를 붙이는 패턴이 흔합니다.
예를 들어 IntStream에서 id를 만들고, Map<Integer, User>에서 유저를 조회하는 코드:
흔한 코드(박싱 유발)
Map<Integer, User> users = ...;
List<User> out = IntStream.range(0, n)
.boxed()
.map(users::get)
.filter(java.util.Objects::nonNull)
.toList();
여기서 핵심은 Map 키가 Integer라서 조회에 박싱이 필요하다는 점입니다. 해결책은 상황별로 다릅니다.
좋은 예 1: id가 연속 범위면 List/배열로 바꾸기
List<User> usersById = ...; // index == id
List<User> out = IntStream.range(0, n)
.mapToObj(usersById::get)
.filter(java.util.Objects::nonNull)
.toList();
mapToObj는 primitive에서 객체로 바꾸지만, 불필요한 Integer 박싱을 만들지 않고 바로 User로 갑니다.
좋은 예 2: 키가 sparse 하면 primitive map 라이브러리 고려
표준 JDK만으로는 int 키를 primitive로 저장하는 맵이 없습니다. 성능이 정말 중요하고 키가 매우 많다면 fastutil, HPPC 같은 라이브러리의 Int2ObjectMap 류를 고려할 수 있습니다.
외부 라이브러리를 도입하기 어렵다면, 최소한 boxed()를 “파이프라인 중간”에서 남발하지 말고, 조회가 꼭 필요할 때만 Integer.valueOf를 사용해 비용을 국소화하세요.
Map<Integer, User> users = ...;
List<User> out = IntStream.range(0, n)
.mapToObj(i -> users.get(Integer.valueOf(i)))
.filter(java.util.Objects::nonNull)
.toList();
이 방식도 박싱은 발생하지만, Stream<Integer>로 전체 파이프라인을 객체화하는 것보다 비용이 줄어드는 경우가 많습니다.
보너스: boxed()가 “괜찮은” 경우
- 데이터 규모가 작고(수천 이하), 코드 가독성이 압도적으로 중요할 때
- 이미 객체 스트림이 필요한 도메인 모델 변환 단계일 때
- 병목이 I/O이고 CPU/GC가 여유로운 서비스일 때
하지만 성능 회귀가 의심되는 상황이라면 “괜찮겠지”가 아니라 측정으로 결론을 내야 합니다.
JMH로 boxed() 비용 측정하기
마이크로벤치마크는 System.nanoTime()으로 대충 재면 JIT/워밍업/데드코드 제거 때문에 쉽게 틀립니다. JMH를 권합니다.
아래는 단순 합계에서 boxed()가 끼어든 경우와 아닌 경우를 비교하는 예시입니다.
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
@State(Scope.Thread)
public class BoxedBenchmark {
@Param({"1000000", "5000000"})
int n;
@Benchmark
public int sumPrimitive() {
return IntStream.range(0, n).sum();
}
@Benchmark
public int sumBoxedReduce() {
return IntStream.range(0, n)
.boxed()
.reduce(0, Integer::sum);
}
}
측정할 때는 다음도 같이 보세요.
-prof gc로 GC 할당량/횟수 확인- 데이터 분포(캐시 범위, 랜덤/연속)에 따른 차이
- 실제 서비스는 스트림 외 로직이 섞이므로, “핵심 루프”를 분리한 벤치마크를 추가
성능 이슈가 누적되면 결국 타임아웃과 재시도로 번지고, 재시도 폭주가 또 다른 장애를 만들 수 있습니다. 재시도 설계 관점은 Claude 3 API 529/503 과부하 재시도·백오프 설계도 참고할 만합니다.
체크리스트: 코드 리뷰에서 boxed() 제거 포인트
IntStream/LongStream/DoubleStream에서boxed()가 보이면 “정말 필요?”를 먼저 질문- 집계는 primitive 종단 연산으로 끝낼 수 있는지 확인
boxed()가 필요하더라도 파이프라인 마지막으로 미룰 수 있는지 확인groupingBy/toMap이 필요하면 자료구조를 바꿀 수 있는지(배열, 누적, 전용 맵)mapToObj로 바로 도메인 객체로 변환할 수 있는지(불필요한Integer단계 제거)
결론
boxed()는 코드 한 줄로 데이터 타입을 바꾸지만, 런타임에서는 객체 할당과 GC라는 큰 비용을 만들 수 있습니다. 핵심은 “박싱이 필요한 순간”을 정확히 고르고, 그 전까지는 primitive 스트림의 장점을 끝까지 활용하는 것입니다.
정리하면 다음 5가지만 기억해도 boxed()로 인한 성능폭탄을 상당 부분 막을 수 있습니다.
- 집계는 primitive 종단 연산(
sum,min,summaryStatistics)으로 끝내기 List<Integer>가 필요해도 박싱은 마지막 1회로 미루기groupingBy대신 배열/직접 누적으로 집계하기distinct/sorted는 primitive 스트림 버전 우선 사용Map<Integer, V>조회 때문에 박싱한다면 키 타입/자료구조를 재설계하거나mapToObj로 우회
다음에 프로파일링에서 GC가 튀거나 p99가 미묘하게 늘면, 스트림 파이프라인에 boxed()가 숨어 있는지부터 확인해 보세요.