- Published on
Java Stream toList vs Collectors.toList 차이·성능
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 개발을 하다 보면 스트림 파이프라인의 마지막에 리스트로 모으는 코드가 아주 자주 등장합니다. 그런데 Java 16부터 Stream.toList()가 추가되면서 기존의 collect(Collectors.toList())와 무엇이 다른지, 성능은 어떤지, 어떤 것을 표준으로 삼아야 하는지 혼란이 생기곤 합니다.
이 글에서는 두 API의 계약(Contract) 차이, 변경 가능성(mutability), 널 처리, 병렬 스트림에서의 동작, 성능 관점의 힌트를 정리하고, 팀 코딩 컨벤션을 정하기 위한 실전 기준까지 제안합니다.
결론부터: 언제 무엇을 쓰나
- 대부분의 경우:
stream.toList()를 기본값으로 추천합니다.- 이유: 반환 리스트가 **수정 불가(unmodifiable)**라서 사이드 이펙트를 줄이고, 구현이 표준 라이브러리에서 더 공격적으로 최적화될 여지가 큽니다.
- 반환 리스트를 반드시 수정해야 하는 경우:
collect(Collectors.toCollection(ArrayList::new))를 명시적으로 사용합니다.- 이유:
Collectors.toList()는 “대체로ArrayList”이지만 명세상 보장되지 않기 때문입니다.
- 이유:
API 차이: 가장 중요한 건 “반환 리스트의 변경 가능성”
Stream.toList()의 계약
Stream.toList()는 수정 불가 리스트를 반환합니다. 즉, 아래 코드는 런타임에 예외가 발생합니다.
List<Integer> list = Stream.of(1, 2, 3).toList();
list.add(4); // UnsupportedOperationException
이 성질은 장점이 큽니다.
- 호출자가 리스트를 수정하면서 원본 데이터 흐름을 오염시키는 실수를 줄입니다.
- DTO/응답 모델 조립 시 “이 리스트는 결과물이며 더 이상 변하면 안 된다”는 의도를 코드로 표현합니다.
- 동시성 관점에서도(완전한 thread-safe는 아니지만) 불변에 가까운 구조가 안전합니다.
Collectors.toList()의 계약
collect(Collectors.toList())는 변경 가능 리스트를 반환하는 경우가 많지만, 구체 타입/특성은 명세로 보장되지 않습니다.
List<Integer> list = Stream.of(1, 2, 3)
.collect(Collectors.toList());
list.add(4); // 보통은 동작하지만, "반드시" 그렇다고 말할 수는 없음
실무에서 문제 되는 지점은 “대부분 된다”가 아니라, 라이브러리/런타임/최적화에 따라 달라질 여지가 있다는 점입니다. 팀 컨벤션으로 “수정 가능한 리스트”가 필요하다면, 아래처럼 의도를 명확히 하는 편이 좋습니다.
List<Integer> list = Stream.of(1, 2, 3)
.collect(Collectors.toCollection(ArrayList::new));
list.add(4); // 확실히 가능
성능 관점: 왜 toList()가 유리하다고들 말할까
벤치마크 숫자 자체는 JVM 버전, 데이터 크기, 파이프라인 구성, escape analysis 여부 등에 따라 크게 달라집니다. 다만 구조적으로 다음 이유 때문에 toList()가 유리하거나 최소한 불리하지 않은 경우가 많습니다.
1) 구현이 JDK 내부에서 더 직접적일 수 있음
Stream.toList()는 스트림의 내부 표현을 더 잘 아는 JDK가 “리스트로 끝나는 경우”를 위해 경로를 최적화할 수 있는 여지가 있습니다. 반면 collect(Collectors.toList())는 일반적인 Collector 메커니즘을 타며, 누적기(accumulator)와 결합기(combiner)를 거치는 형태라 오버헤드가 생길 수 있습니다.
2) 결과가 수정 불가이므로 방어적 복사 비용을 줄일 수 있음
API 사용자가 결과를 수정하지 못한다는 보장이 있으면, 라이브러리 내부에서 불필요한 복사를 피할 가능성이 커집니다. 특히 다른 계층으로 넘길 때 “혹시 바뀔까”를 걱정하며 복사하는 패턴이 줄어듭니다.
3) 병렬 스트림에서의 결합 비용
병렬 스트림은 부분 결과를 합치는 과정이 핵심입니다. Collector 기반은 결합기 로직을 반드시 고려해야 하고, 구현에 따라 중간 리스트들이 만들어졌다 합쳐지며 비용이 늘 수 있습니다. toList()는 JDK가 내부적으로 더 효율적인 방식으로 병합할 수 있습니다.
다만 병렬 스트림 자체가 항상 빠른 선택은 아닙니다. API 응답 조립 같은 짧은 작업에 병렬 스트림을 쓰면 오히려 느려질 수 있고, 운영 중 레이턴시를 악화시킬 수 있습니다. 대용량 트래픽 환경에서의 방어 설계는 별도로 정리한 글도 참고할 만합니다: Spring Boot 대용량 트래픽 대비 API Rate Limiting 설계
실무에서 자주 밟는 함정 5가지
1) toList() 결과를 수정하려다 장애가 나는 경우
레거시 코드에서 collect(Collectors.toList())로 받아 수정하던 습관이 남아 있으면, 리팩터링 중 toList()로 바꿨을 때 런타임에 UnsupportedOperationException이 터집니다.
안전한 패턴은 “수정이 필요하면 처음부터 수정 가능한 컬렉션으로 만든다”입니다.
List<String> names = users.stream()
.map(User::getName)
.collect(Collectors.toCollection(ArrayList::new));
names.add("admin");
2) “수정 가능한 리스트”가 필요하면 Collectors.toList() 대신 더 명시적으로
Collectors.toList()는 대부분 ArrayList를 주지만, 명세로 보장되지 않습니다. 팀 차원에서 예측 가능성을 원하면 아래 둘 중 하나를 선택하세요.
- 수정 가능한
ArrayList가 필요:toCollection(ArrayList::new) - 수정 불가 리스트가 필요:
toUnmodifiableList()또는toList()
List<Integer> mutable = stream.collect(Collectors.toCollection(ArrayList::new));
List<Integer> unmodifiable = stream.collect(Collectors.toUnmodifiableList());
3) 결과 리스트의 null 처리
스트림은 null 요소를 포함할 수 있고, 두 방식 모두 기본적으로 null을 그대로 담습니다.
List<String> list = Stream.of("a", null, "b").toList();
// ["a", null, "b"]
문제는 이후 코드에서 list.stream()을 다시 돌리거나, JSON 직렬화 과정에서 null이 의도치 않게 내려가는 경우입니다. 필터링을 파이프라인에서 명확히 하세요.
List<String> list = Stream.of("a", null, "b")
.filter(Objects::nonNull)
.toList();
4) “불변”과 “수정 불가”는 다르다
toList()가 돌려주는 것은 보통 “수정 불가”입니다. 리스트 구조(추가/삭제/교체)는 막지만, 요소 객체 자체가 가변이면 내부 상태는 바뀔 수 있습니다.
record Box(StringBuilder value) {}
List<Box> boxes = Stream.of(new Box(new StringBuilder("x"))).toList();
boxes.get(0).value().append("y"); // 요소 내부는 변경 가능
진짜 불변을 원하면 요소 타입도 불변으로 설계해야 합니다.
5) API 경계에서 컬렉션을 어떻게 넘길지
컨트롤러/서비스/리포지토리 경계에서 리스트를 넘길 때, “호출자가 수정해도 되는가”를 정하지 않으면 팀 내에서 버그가 반복됩니다.
- 외부로 반환하는 DTO/응답:
toList()처럼 수정 불가를 기본으로 - 내부 알고리즘에서 누적/정렬/추가가 필요한 경우:
ArrayList를 명시적으로
이런 경계 설계는 인증/인가 토큰 같은 민감한 데이터 흐름에서도 동일하게 중요합니다. 토큰/세션 객체를 불변으로 유지하는 습관은 보안 사고를 줄입니다: OAuth2 PKCE+JWT에서 토큰 탈취 막는 7가지
마이그레이션 가이드: Collectors.toList()에서 toList()로 바꿔도 될까
다음 체크리스트로 판단하면 안전합니다.
- 결과 리스트에 대해
add,remove,set,sort등 구조 변경을 하는가?- 한다면
toList()로 바꾸면 안 됩니다.
- 한다면
- 결과 리스트를 다른 메서드에 넘기는데, 그 메서드가 리스트를 수정하는가?
- 메서드 시그니처만 봐선 모를 때가 많으니 호출부/구현부를 확인해야 합니다.
- “수정 불가”가 오히려 의도에 맞는가?
- 맞다면
toList()로 바꾸는 것이 더 안전합니다.
- 맞다면
예시: 안전한 리팩터링
// before
List<Long> ids = users.stream()
.map(User::getId)
.collect(Collectors.toList());
return ids;
// after
List<Long> ids = users.stream()
.map(User::getId)
.toList();
return ids;
예시: 바꾸면 안 되는 케이스
List<Long> ids = users.stream()
.map(User::getId)
.toList();
Collections.sort(ids); // UnsupportedOperationException 가능
이 경우는 아래처럼 고치면 됩니다.
List<Long> ids = users.stream()
.map(User::getId)
.collect(Collectors.toCollection(ArrayList::new));
ids.sort(Comparator.naturalOrder());
벤치마크를 직접 하고 싶다면: JMH 스켈레톤
미세 성능은 추측보다 측정이 낫습니다. 아래는 JMH로 두 방식을 비교하는 최소 스켈레톤입니다.
import org.openjdk.jmh.annotations.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.*;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class StreamToListBenchmark {
@Param({"10", "1000", "100000"})
int size;
List<Integer> input;
@Setup
public void setup() {
input = IntStream.range(0, size).boxed().toList();
}
@Benchmark
public List<Integer> toList() {
return input.stream()
.map(i -> i + 1)
.toList();
}
@Benchmark
public List<Integer> collectorsToList() {
return input.stream()
.map(i -> i + 1)
.collect(Collectors.toList());
}
@Benchmark
public List<Integer> toCollectionArrayList() {
return input.stream()
.map(i -> i + 1)
.collect(Collectors.toCollection(ArrayList::new));
}
}
포인트는 Collectors.toList() vs toList()만 보지 말고, **정말 필요한 리스트 특성(수정 가능 여부, 타입)**을 포함해서 비교하는 것입니다. 실무에서는 성능보다도 “예상 가능한 계약”이 더 큰 비용을 아낍니다.
팀 컨벤션 추천안
- Java 16 이상 코드베이스라면
- 기본:
stream.toList() - 수정 필요:
collect(Collectors.toCollection(ArrayList::new)) - 수정 불가를 명시적으로 강조:
collect(Collectors.toUnmodifiableList())
- 기본:
이렇게 정하면 코드 리뷰에서 “왜 이걸 썼는지”를 설명하기가 쉬워지고, 런타임 예외나 방어적 복사 같은 숨은 비용이 줄어듭니다.
운영 환경에서 문제를 줄이는 방법은 결국 명세가 분명한 선택을 기본값으로 두는 것입니다. toList()는 그 기본값으로 삼기 좋은 API입니다.