
Stream.collect(Collectors.toList()) 와 Stream.toList() 는 다르다!
어느날 production 의 레거시 로직에서 발생한 UnsupportedOperationException...
무엇이 문제였을까요?
java 로 백엔드 개발을 하다 보면,
가장 흔한 패턴 중 하나가
특정 데이터를 원하는 형태로 변환하거나 추출하는 것입니다.
이때는 Stream API 를 사용하면 편리하고, 일반적으로 좋은 가독성을 유지할 수 있습니다.
아래 코드를 살펴봅시다.
List<String> imageUrls = articleImageRepository.findByArticle(article)
.stream()
.map(ArticleImage::getImageUrl)
.collect(Collectors.toList());
위 코드 스니펫에서는 articleImage 엔티티 리스트를 쿼리해온 뒤에, ImageUrl 만 List 으로 추출하고 있습니다.
여기서 주목해야 할 부분은 collect(Collectors.toList() 입니다.
해당 부분에 커서를 가져다 대면 intelliJ 에서는 아래와 같은 메세지를 띄워줍니다.
`collect(toList())` can be replaced with 'toList()'
- Replace 'collect(toList())' with 'toList()'?
이 제안을 무심코 수락하다가는, 예상치 못한 잠재적인 버그를 서비스에 심을 수 있습니다.
🔥 Stream.collect(Collectors.toList()) 와 Stream.toList() 는 명백하게 다르다!
주요한 차이점은 아래와 같습니다.
<- Stream.collect(Collectors.toList()) 는 가변 리스트를 반환한다.
- Stream.toList() 는 불변 리스트를 반환한다.
jdk 16 부터 도입된 메서드 인 Stream.toList() 는 불변 리스트를 반환하며, 외부에서 수정 시도 시 UnsupportedOperationException 이 터집니다.
List<String> mutableList = Stream.of("a", "b").collect(Collectors.toList());
mutableList.add("c"); // ✅ 가능
List<String> immutableList = Stream.of("a", "b").toList();
immutableList.add("c"); // 🔥 UnsupportedOperationException 발생
필요한 구현체가 불변 리스트인지, 가변리스트인지 요구사항을 정확하게 파악하고 사용하는 것이 중요합니다.
IntelliJ, SonarQube 등에서 collect(Collectors.toList()) 를 Stream.toList() 으로 변경하는 것을 권할 수 있지만,
두 메서드의 기능적인 차이점을 명확히 인지하지 못한 채 받아들이면
상황에 따라 꽤나 심각한 버그가 발생할 수도 있겠죠.
돌다리도 두들겨 보고 건너야 할 것 같습니다.
시간적으로 허용된다면 반드시 내부 구현 코드를 확인해서, 스스로 납득하는 것이 가장 기억에 오래 남는 것 같습니다.
jdk 17 기준으로 살펴보겠습니다.
먼저 Stream.collect(Collectors.toList()) 부터 따라가봅시다.
/**
* Accumulates the elements of this stream into a {@code List}. The elements in
* the list will be in this stream's encounter order, if one exists. The returned List
* is unmodifiable; calls to any mutator method will always cause
* {@code UnsupportedOperationException} to be thrown. There are no
* guarantees on the implementation type or serializability of the returned List.
*
* <p>The returned instance may be <a href="{@docRoot}/java.base/java/lang/doc-files/ValueBased.html">value-based</a>.
* Callers should make no assumptions about the identity of the returned instances.
* Identity-sensitive operations on these instances (reference equality ({@code ==}),
* identity hash code, and synchronization) are unreliable and should be avoided.
*
* <p>This is a <a href="package-summary.html#StreamOps">terminal operation</a>.
*
* @apiNote If more control over the returned object is required, use
* {@link Collectors#toCollection(Supplier)}.
*
* @implSpec The implementation in this interface returns a List produced as if by the following:
* <pre>{@code
* Collections.unmodifiableList(new ArrayList<>(Arrays.asList(this.toArray())))
* }</pre>
*
* @implNote Most instances of Stream will override this method and provide an implementation
* that is highly optimized compared to the implementation in this interface.
*
* @return a List containing the stream elements
*
* @since 16
*/
@SuppressWarnings("unchecked")
default List<T> toList() {
return (List<T>) Collections.unmodifiableList(new ArrayList<>(Arrays.asList(this.toArray())));
}
/**
* Returns an <a href="Collection.html#unmodview">unmodifiable view</a> of the
* specified list. Query operations on the returned list "read through" to the
* specified list, and attempts to modify the returned list, whether
* direct or via its iterator, result in an
* {@code UnsupportedOperationException}.<p>
*
* The returned list will be serializable if the specified list
* is serializable. Similarly, the returned list will implement
* {@link RandomAccess} if the specified list does.
*
* @implNote This method may return its argument if the argument is already unmodifiable.
* @param <T> the class of the objects in the list
* @param list the list for which an unmodifiable view is to be returned.
* @return an unmodifiable view of the specified list.
*/
@SuppressWarnings("unchecked")
public static <T> List<T> unmodifiableList(List<? extends T> list) {
if (list.getClass() == UnmodifiableList.class || list.getClass() == UnmodifiableRandomAccessList.class) {
return (List<T>) list;
}
return (list instanceof RandomAccess ?
new UnmodifiableRandomAccessList<>(list) :
new UnmodifiableList<>(list));
}
이번에는 collect(Collectors.toList()) 를 봅시다.
/**
* Returns a {@code Collector} that accumulates the input elements into a
* new {@code List}. There are no guarantees on the type, mutability,
* serializability, or thread-safety of the {@code List} returned; if more
* control over the returned {@code List} is required, use {@link #toCollection(Supplier)}.
*
* @param <T> the type of the input elements
* @return a {@code Collector} which collects all the input elements into a
* {@code List}, in encounter order
*/
public static <T>
Collector<T, ?, List<T>> toList() {
return new CollectorImpl<>(ArrayList::new, List::add,
(left, right) -> { left.addAll(right); return left; },
CH_ID);
}
무엇이 다른가요?
toList() 에서 반환하는 구현체는 Collectors.UnmodifiableRandomAccessList, Collectors.UnmodifiableList 으로 불변 리스트이지만
collect(Collectors.toList()) 에서 반환하는 구현체는 ArrayList 으로 가변 리스트입니다.
불변 리스트의 원소를 수정하려고 하면 UnsupportedOperationException 이 발생하고,
해당 예외는 runtime 에, 해당 line 이 수행될 때에만 발생하므로 찾기 어렵죠.
UnsupportedOperationException 이 triggering 되는 코드는 UnmodifiableList 내부 코드를 확인해서 확신을 얻을 수 있습니다.
public static <T> List<T> unmodifiableList(List<? extends T> list) {
// ... 생략 ...
}
/**
* @serial include
*/
static class UnmodifiableList<E> extends UnmodifiableCollection<E>
implements List<E> {
// ... 생략 ...
public E set(int index, E element) {
throw new UnsupportedOperationException();
}
public void add(int index, E element) {
throw new UnsupportedOperationException();
}
public E remove(int index) {
throw new UnsupportedOperationException();
}
public boolean addAll(int index, Collection<? extends E> c) {
throw new UnsupportedOperationException();
}
@Override
public void replaceAll(UnaryOperator<E> operator) {
throw new UnsupportedOperationException();
}
@Override
public void sort(Comparator<? super E> c) {
throw new UnsupportedOperationException();
}
// ... 생략 ...
public ListIterator<E> listIterator(final int index) {
return new ListIterator<E>() {
// ... 생략 ...
public void remove() {
throw new UnsupportedOperationException();
}
public void set(E e) {
throw new UnsupportedOperationException();
}
public void add(E e) {
throw new UnsupportedOperationException();
}
};
// ... 생략 ...
}
P.S.
항상 의심해...