- Published on
Java Stream 성능폭발? boxed·collect 병목 6가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 Stream은 읽기 좋은 파이프라인을 만들지만, 트래픽이 늘거나 데이터 크기가 커지는 순간 갑자기 CPU가 치솟고 GC가 폭발하는 경우가 많습니다. 특히 boxed()와 collect()는 “성능이 나빠질 수 있는 지점”이 아니라, 조건이 맞으면 병목의 중심이 됩니다.
이 글은 Stream 자체를 악마화하지 않습니다. 대신 어떤 패턴이 왜 느려지고, 어떤 대안이 실제로 도움이 되는지를 6가지로 정리합니다.
참고: 이 글은 마이크로벤치마크가 아니라 “실무에서 병목으로 자주 튀어나오는 구조”를 중심으로 설명합니다. 정확한 수치는 JMH로 측정하세요.
0) 먼저 결론: Stream이 느린 게 아니라, 객체화와 수집이 비싸다
Stream 파이프라인에서 비용이 크게 튀는 지점은 대개 아래입니다.
- primitive 스트림에서 reference 스트림으로 바꾸는
boxed() - 중간 결과를 컨테이너에 모으는
collect() Collectors.groupingBy같은 고수준 수집기 내부의Map/List객체 생성toList()가 만들어내는 리스트의 크기 확장 비용
즉, “연산”보다 할당(allocation)과 메모리 접근이 먼저 병목이 됩니다.
1) 병목 1: boxed()로 인한 오토박싱 폭탄
IntStream 같은 primitive 스트림은 빠릅니다. 문제는 boxed()로 Integer를 만들기 시작할 때입니다.
- 요소마다 객체가 생성됩니다(캐시 범위 밖이면 특히).
- 힙 할당량이 증가하고, GC 빈도가 올라갑니다.
- CPU는 연산보다 메모리 할당과 추적에 시간을 씁니다.
나쁜 예
List<Integer> ids = IntStream.range(0, n)
.boxed()
.collect(Collectors.toList());
대안 1: primitive로 끝내기
가능하면 int[], long[]로 끝내세요.
int[] ids = IntStream.range(0, n)
.toArray();
대안 2: 정말 List<Integer>가 필요하면 “왜 필요한지”부터
외부 API가 List<Integer>를 요구하는지, 내부 로직이 박싱을 요구하는지 재검토하세요. 내부는 primitive로 돌리고, 경계에서만 변환하는 편이 낫습니다.
2) 병목 2: mapToObj로 객체를 대량 생성하는 파이프라인
boxed()가 아니더라도 mapToObj로 DTO를 대량 생성하면 동일한 문제가 생깁니다. 특히 요청당 수만 건을 만들면 할당량이 급증합니다.
나쁜 예
List<UserDto> dtos = users.stream()
.map(u -> new UserDto(u.id(), u.name()))
.toList();
대안: 필요할 때만 만들기(지연 생성) 또는 필드만 뽑기
- 정말로 DTO 리스트가 필요한지 확인
- 특정 필드 집합만 필요하면 primitive/문자열 배열로 축소
long[] userIds = users.stream()
.mapToLong(User::id)
.toArray();
3) 병목 3: collect(toList())의 “중간 결과 리스트” 남발
Stream 파이프라인에서 collect(Collectors.toList())를 여러 번 호출하면, 매번 중간 리스트가 생기고 버려집니다. 이 패턴은 코드 리뷰에서 자주 놓칩니다.
나쁜 예: 중간 리스트가 2번 생성
List<Order> filtered = orders.stream()
.filter(o -> o.amount() > 0)
.collect(Collectors.toList());
List<Long> ids = filtered.stream()
.map(Order::id)
.collect(Collectors.toList());
대안: 파이프라인을 한 번에
List<Long> ids = orders.stream()
.filter(o -> o.amount() > 0)
.map(Order::id)
.toList();
대안: 결과가 배열이면 배열로
long[] ids = orders.stream()
.filter(o -> o.amount() > 0)
.mapToLong(Order::id)
.toArray();
4) 병목 4: groupingBy가 만드는 맵/리스트 객체 폭발
Collectors.groupingBy는 편하지만, 데이터가 크면 내부적으로 다음을 대량 생성합니다.
- 키별
List(또는 다운스트림 컨테이너) HashMap엔트리- 경우에 따라 박싱된 키/값 객체
나쁜 예
Map<Long, List<Order>> byUser = orders.stream()
.collect(Collectors.groupingBy(Order::userId));
대안 1: 다운스트림을 더 가볍게
리스트가 아니라 합계/카운트만 필요하면 summarizing이나 counting으로 컨테이너 생성을 피하세요.
Map<Long, Long> countByUser = orders.stream()
.collect(Collectors.groupingBy(
Order::userId,
Collectors.counting()
));
대안 2: primitive 키를 쓰는 specialized map 고려
외부 라이브러리 사용이 가능하다면 fastutil, HPPC 같은 primitive map이 박싱을 줄입니다. 표준 라이브러리만으로는 한계가 있습니다.
대안 3: 정렬 후 단일 패스로 집계
키가 정렬 가능하고 대량 데이터라면, 정렬 후 루프 한 번으로 집계하는 방식이 메모리 효율이 좋습니다.
5) 병목 5: Collectors.toMap의 충돌 처리와 리사이징
toMap은 다음 이유로 느려질 수 있습니다.
- 키 충돌이 발생하면 merge 함수가 자주 호출됨
- 기본
HashMap은 크기를 모르면 여러 번 리사이즈 - 값이 박싱 타입이면 추가 비용
나쁜 예: 충돌 가능 + 리사이즈
Map<String, User> byEmail = users.stream()
.collect(Collectors.toMap(User::email, u -> u));
대안 1: 충돌 정책을 명시
Map<String, User> byEmail = users.stream()
.collect(Collectors.toMap(
User::email,
u -> u,
(a, b) -> a
));
대안 2: 크기를 알면 미리 용량을 잡아 수집
표준 toMap으로는 초기 용량을 직접 주기 어렵습니다. 대신 collect의 3-인자 형태로 HashMap을 미리 만들 수 있습니다.
int expected = users.size();
Map<String, User> byEmail = users.stream().collect(
() -> new HashMap<>((int) (expected / 0.75f) + 1),
(m, u) -> m.put(u.email(), u),
HashMap::putAll
);
이 패턴은 코드가 길어지지만, 대량 데이터에서 리사이즈 비용을 줄이는 데 효과가 있습니다.
6) 병목 6: parallelStream()이 오히려 느려지는 경우
병렬 스트림은 “CPU 코어가 많으면 빨라진다”가 아니라, 분할/병합 비용과 작업 단위가 맞아야 이득이 납니다.
느려지는 대표 조건:
- 요소 수가 적거나 연산이 가벼움(오버헤드가 더 큼)
collect()로 큰 맵/리스트를 병합(Combiner 비용 증가)- 공유 자원 접근(락, DB, Redis, HTTP 호출)
- ForkJoinPool 공용 풀 경쟁(서버 전체에 영향)
나쁜 예: 외부 I/O를 병렬로
List<Result> results = ids.parallelStream()
.map(id -> externalApiCall(id))
.toList();
대안: 병렬화는 Stream이 아니라 실행 모델로
I/O 병렬화는 CompletableFuture나 리액티브/가상 스레드 등으로 제어하는 편이 안전합니다.
ExecutorService es = Executors.newFixedThreadPool(64);
try {
List<CompletableFuture<Result>> futures = ids.stream()
.map(id -> CompletableFuture.supplyAsync(() -> externalApiCall(id), es))
.toList();
List<Result> results = futures.stream()
.map(CompletableFuture::join)
.toList();
} finally {
es.shutdown();
}
병렬 스트림은 CPU 바운드 연산에서, 수집 결과가 단순하고 작업이 충분히 클 때만 고려하세요.
JMH로 확인하는 최소 벤치마크 뼈대
Stream 성능 논쟁은 측정 없이는 결론이 나지 않습니다. 아래는 비교 실험을 위한 JMH 스켈레톤입니다.
@State(Scope.Thread)
public class StreamBench {
@Param({"1000", "1000000"})
int n;
int[] data;
@Setup
public void setup() {
data = IntStream.range(0, n).toArray();
}
@Benchmark
public List<Integer> boxedToList() {
return IntStream.of(data)
.boxed()
.collect(Collectors.toList());
}
@Benchmark
public int[] primitiveToArray() {
return IntStream.of(data)
.toArray();
}
}
포인트는 “Stream vs for”가 아니라, boxed()와 collect()가 들어가는 순간 할당량이 어떻게 변하는지 확인하는 것입니다.
실무 체크리스트: Stream을 유지하면서 병목 줄이기
boxed()는 경계에서만, 내부는 primitive 스트림으로 끝내기collect(toList())중간 결과를 만들지 말고 파이프라인을 한 번에groupingBy는 리스트가 필요할 때만, 아니면counting/summing등으로 다운스트림 최소화toMap은 충돌 정책과 용량(리사이즈)을 의식하기parallelStream()은 CPU 바운드 + 충분히 큰 작업에서만, 서버 환경에서는 특히 신중- 결과 컨테이너가 정말 필요한지(리스트로 다 모아야 하는지)부터 재검토
마무리: 병목은 대개 “객체를 너무 많이 만든 것”에서 시작한다
Stream은 선언형이라 병목이 숨기 쉽습니다. 하지만 프로파일러로 보면 많은 경우 원인은 단순합니다.
- 박싱으로 객체가 폭증했고
- 수집으로 리스트/맵이 무거워졌고
- GC와 메모리 대역폭이 한계에 도달했다
성능 튜닝은 결국 “덜 만들고, 덜 모으고, 경계에서만 변환”하는 방향으로 수렴합니다.
관련해서 서버 병목을 다루는 글도 함께 보면 좋습니다. 예를 들어 Spring Boot 3 Redis 세션 병목 - Lettuce 튜닝처럼, 애플리케이션 병목은 컬렉션/메모리뿐 아니라 I/O 계층에서도 자주 터집니다. 또한 트랜잭션 경계 문제로 성능과 정합성이 동시에 무너지는 케이스는 Spring Boot 3.2에서 @Transactional 무시되는 7가지도 참고할 만합니다.