[Java] Stream의 mutable, immutable 리스트 변환 (toList)
자바 Stream을 사용할 때 toList를 잘 사용해야 한다. (불변, 가변 리스트 반환 때문)
📌 서론
Java 16 이후로 스트림 API에는 새로운 메서드인 Stream.toList()가 추가되었다.
이 메서드는 단순히 보면 Stream.collect(Collectors.toList())와 비슷해 보이지만, 중요한 차이점이 있다.
바로 Stream.toList() 불변 리스트를 반환하고 Stream.collect(Collectors.toList())는 가변 리스트를 반환한다는 것이다. (이 차이점 때문에 개발 중이던 비즈니스 로직에서 문제가 발생하기도 했다.)
이 글에서는 두 메서드의 차이점을 이해하고, 각각의 설계 의도를 살펴보자.
1. Stream.toList() 메서드의 내부 구현 이해하기: (immutable list)를 반환한다.
Stream.toList()
- Java 16에서 도입된 Stream.toList()는 불변 리스트를 반환한다. (중요함!)
List<String> immutableList = Stream.of("A", "B", "C").toList();
Stream.toList() 메서드의 구현 살펴보기
- Stream 클래스에 들어가 보면 다음과 같이 toList() 메서드가 선언되어 있다. 이 메서드는 리스트의 클래스 타입에 따라 적절한 UnmodifiableList 또는 UnmodifiableRandomAccessList를 반환한다.
- 메서드 내부를 살펴보면 스트림의 요소들을 새로운 ArrayList에 수집한 후, 이를 Collections.unmodifiableList()로 감싸서 불변 리스트로 변환하고 있다. (더 이상 수정할 수 없는 읽기 전용 리스트로 반환한다는 것이다.)
- Stream.toList() 메서드를 사용하면 새로운 불변 리스트를 반환한다. 이것은 원본 리스트의 복사본을 생성하여, 그 복사본을 불변 리스트로 만드는 것이다. 즉, toList() 메서드가 호출될 때마다 새로운 리스트 객체가 생성되며, 이 리스트는 불변(immutable)이다. 따라서 기존 리스트 자체가 수정되는 것이 아니라, 스트림을 통해 새로운 리스트가 만들어진다는 것을 이해하고 넘어가자.
@SuppressWarnings("unchecked")
default List<T> toList() {
return (List<T>) Collections.unmodifiableList(new ArrayList<>(Arrays.asList(this.toArray())));
}
Stream.toList() 메서드가 사용하는 unmodifiableList() 메서드 살펴보기 (javadoc 번역)
- 이 메서드는 Collections 클래스 내부에 선언되어 있으며 매개변수로 list를 받아서 내부 비즈니스를 통해 수정할 수 없는(read-only) 리스트로 만들어서 반환한다. 여기서 "수정할 수 없는 뷰"라는 의미는 원본 리스트의 내용을 읽을 수는 있지만, 해당 리스트의 내용을 수정할 수 없도록 보장하는 래퍼(wrapper)를 반환한다는 뜻이다. (wrapper의 의미는 아래에서 확인할 수 있다.)
- 반환된 리스트는 조회 연산(읽기)은 원래 리스트의 데이터를 그대로 사용한다. 그러나 반환된 리스트에 대해 직접적으로나 반복자를 통해 수정하려고 시도하면 UnsupportedOperationException 예외가 발생한다.
- 참고로 이 메서드는 만약 전달된 리스트가 이미 수정할 수 없는 상태(unmodifiable)라면, 새로운 객체를 생성하지 않고 전달된 리스트 자체를 그대로 반환할 수 있다. 이는 불필요한 객체 생성을 피하여 성능을 최적화하기 위한 것이라고 한다.
/**
* 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));
}
unmodifiableList가 반환하는 wrapper 구현체를 자세히 알아보자
1. 먼저 wrapper의 의미를 이해하고 넘어가자
- Collections.unmodifiableList() 메서드는 반환할 때 실제로 특정 구현체의 인스턴스를 반환하게 된다. 이 메서드는 단순히 인터페이스 타입인 List<T>를 반환하는 것이 아니라, List 인터페이스를 구현하는 구현체를 반환한다. 이 구현체는 원본 리스트를 감싸는 wrapper 객체로 동작한다.
2. UnmodifiableList 클래스
- UnmodifiableList 클래스는 RandomAccess 인터페이스를 구현하지 않는 리스트를 감싸기 위해 주로 사용된다.
- LinkedList와 같은 리스트는 RandomAccess 인터페이스를 구현하지 않는다. 이유는 LinkedList가 순차적으로 요소에 접근하기 때문에 임의의 인덱스에 빠르게 접근할 수 없기 때문이다. (하단의 클래스 참고)
- UnmodifiableList 클래스는 List 인터페이스를 구현하고, 원본 리스트를 감싸 수정 작업을 제한한다.
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
3. UnmodifiableRandomAccessList 클래스
- RandomAccess 인터페이스를 구현하는 리스트(예: ArrayList)를 감쌀 때 사용된다.
- ArrayList 클래스를 살펴보면 RandomAccess 클래스를 구현하고 있다는 것을 확인할 수 있다. (하단의 클래스 참고)
- UnmodifiableRandomAccessList 클래스도 List 인터페이스를 구현하며, RandomAccess를 구현해 빠른 임의 접근을 지원한다.
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
wrapper 구현체로 응답하는 이유 (UnmodifiableList, UnmodifiableRandomAccessList)
- UnmodifiableList와 UnmodifiableRandomAccessList 클래스는 모두 List의 메서드들 중 수정 관련 메서드(add(), remove(), set() 등)를 오버라이드(override)하여 UnsupportedOperationException을 던지도록 구현되어 있다. 이로 인해, 사용자는 반환된 리스트를 수정할 수 없게 된다. 그렇기 때문에 이렇게 wrapper를 사용해서 감싼 다음 동작을 구현한 것이다.
- UnmodifiableList와 UnmodifiableRandomAccessList는 원본 리스트를 감싸는 래퍼 객체로, 리스트의 수정 작업을 제한한다. 그렇다면 이것은 마치 컬렉션 객체를 직접 노출하지 않고 래퍼를 통해 제어하는 일급 컬렉션과 유사하게 느껴지지 않는가? (사실 이건 혼자만의 생각이다.)
- 내가 이렇게 느낀 이유는 자바의 객체지향적인 설계 방식(캡슐화, 정보 은닉) 때문이다. 개발자가 컬렉션에 직접 접근하여 수정하는 것을 방지하고, 대신 래퍼 객체를 통해서만 접근이 가능하도록 한다는 점만 봐도 그렇다. 그러나 일급 컬렉션은 컬렉션을 감싸는 것뿐만 아니라 컬렉션과 관련된 모든 비즈니스 로직을 응집시키는 데 목적이 있는 반면, UnmodifiableList와 같은 래퍼 클래스는 수정 작업을 제한하는 특정 기능에 중점을 두기 때문에 이 두 가지는 같다고 볼 수 없다고 결론 내렸다.
이제 wrapper 구현체인 UnmodifiableList 클래스를 자세히 알아보자
- UnmodifiableList 내부의 get, set, add, remove 등등 메서드를 살펴보면 get은 list의 데이터를 잘 조회하지만 set, add, remove에서는 UnsupportedOperationException()을 던지는 것을 확인할 수 있다.
- 즉, 이렇게 리스트를 감싸서(wrapping) get만 가능하게 하고 set, add, remove는 불가능하도록 하는 것이다. (불변성 보장)
static class UnmodifiableList<E> extends UnmodifiableCollection<E>
implements List<E> {
@java.io.Serial
private static final long serialVersionUID = -283967356065247728L;
@SuppressWarnings("serial") // Conditionally serializable
final List<? extends E> list;
UnmodifiableList(List<? extends E> list) {
super(list);
this.list = list;
}
public boolean equals(Object o) {return o == this || list.equals(o);}
public int hashCode() {return list.hashCode();}
public E get(int index) {return list.get(index);}
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 int indexOf(Object o) {return list.indexOf(o);}
public int lastIndexOf(Object o) {return list.lastIndexOf(o);}
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();
}
// ... 메서드가 더 있다.
}
2. Stream.toList()에서 사용하는 unmodifiableList 메서드를 직접 사용해 보기
지금부터 unmodifiableList를 직접 호출해서 불변 리스트로 만들어보자.
- 들어가기 전에 알아두면 좋은 것이 있다. Collections.unmodifiableList(); 를 통해 불변 리스트를 만들고 반환값을 List대신 UnmodifiableList로 받아보고 싶을 수도 있다. 그러나 UnmodifiableList 클래스를 살펴보면 접근 제어자가 명시되어 있지 않기 때문에 이 클래스는 패키지-프라이빗(package-private) 수준의 접근 제어자를 가진다.
static class UnmodifiableList<E> extends UnmodifiableCollection<E>
implements List<E> {
}
- "패키지-프라이빗" 수준의 접근 제어자를 가진다는 말은 UnmodifiableList 클래스는 동일한 패키지 내에서만 접근이 가능하고, 다른 패키지에서는 접근할 수 없다는 것이다. 그렇기 때문에 우리가 외부에서 이 클래스에 접근하여 직접 사용할 수는 없다. (혹여나 직접 반환 타입을 보고 싶은 사람이 있을까 싶어 작성했다.)
package org.example;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Example {
public static void main(String[] args) {
List<String> originalList = new ArrayList<>();
originalList.add("A");
originalList.add("B");
// 원본 리스트를 수정할 수 없는 리스트로 감싸기
List<String> unmodifiableList = Collections.unmodifiableList(originalList);
System.out.println("Unmodifiable List: " + unmodifiableList);
// 조회 시도
String unmodifiableListGet = unmodifiableList.get(0);
System.out.println("First element: " + unmodifiableListGet);
// 수정 시도
unmodifiableList.add("C"); // UnsupportedOperationException 발생
}
}
작성한 코드의 메인 메서드를 실행하면 아래와 같은 결과를 얻게 된다.
- 나는 코드에서 ArrayList를 unmodifiableList로 만들었다. 그렇기 때문에 get을 호출했을 때는 문제없이 데이터를 가지고 온다. 그러나 add를 호출하면 바로 UnsupportedOperationException이 발생하는 것을 확인할 수 있었다.
> Task :org.example.Example.main() FAILED
Unmodifiable List: [A, B]
// get에 대해서는 잘 동작했다.
First element: A
// add에 대해서는 예외가 발생한다.
2 actionable tasks: 2 executed
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1091)
at org.example.Example.main(Example.java:25)
3. Stream.collect(Collectors.toList()) 메서드의 내부 구현 이해하기
Stream.collect(Collectors.toList())
- 가변 리스트 반환: Stream.collect(Collectors.toList())는 가변 리스트를 반환합니다.
- 구현 방식: 일반적으로 ArrayList의 인스턴스를 반환하며, 리스트의 추가, 제거, 수정이 가능합니다.
List<String> mutableList = Stream.of("A", "B", "C").collect(Collectors.toList());
가장 먼저 Stream.collect() 메서드부터 이해해 보자
collect() 메서드는 무슨 역할을 하는가?
- collect 메서드는 스트림의 요소들을 변환하고 누적하여 원하는 형식으로 수집하는 데 사용된다.
- 스트림 요소들을 리스트, 맵, 집합 등 다양한 컬렉션으로 수집하거나 사용자 정의 연산을 수행할 수 있다.
/**
* 설명이 너무 길어서 생략했다.
* @param <R> the type of the result
* @param <A> the intermediate accumulation type of the {@code Collector}
* @param collector the {@code Collector} describing the reduction
* @return the result of the reduction
* @see #collect(Supplier, BiConsumer, BiConsumer)
* @see Collectors
*/
<R, A> R collect(Collector<? super T, A, R> collector);
제네릭 타입 파라미터: 맨 앞의 <R, A>
- <R>: 결과 타입: R은 collect() 메서드가 최종적으로 반환할 결과의 타입을 나타낸다. (List, Set, Map 등)
- <A>: 누적 타입 (Accumulator) : A는 스트림의 요소를 수집하는 과정에서 중간에 사용할 누적 컬렉션 또는 데이터 구조의 타입이다.
- 참고사항: 여기서 T에 대해서 설명하지 않는 이유는 T는 Stream 인터페이스의 제네릭 타입 파라미터로, collect 메서드의 제네릭 타입 파라미터가 아니다. 그래서 T에 대한 설명이 주석에 포함되지 않은 것이다.
메서드 파라미터 (매개변수): Collector<? super T, A, R> collector:
- Collector는 스트림의 요소를 수집하는 방법을 정의하는 인터페이스다.
- 이 파라미터는 스트림 요소를 수집할 때 사용할 전략(수집 방법)을 정의한다고 보면 된다. Collector 객체는 스트림의 요소를 어떻게 수집하고, 중간 누적 컬렉션을 어떤 방식으로 병합할지 등을 결정하게 된다.
- 예시를 통해 이해해 보자.
- Collectors.toList(): 스트림 요소를 리스트로 수집하는 Collector를 반환한다.
- Collectors.toSet(): 스트림 요소를 집합으로 수집하는 Collector를 반환한다.
- Collectors.groupingBy(...): 스트림 요소를 특정 기준으로 그룹화하여 Map으로 수집하는 Collector를 반환한다.
반환값 (R)
- R은 Collector에 의해 정의된 방식으로 수집된 최종 결과다.
- 스트림의 모든 요소가 Collector에 의해 수집된 후의 최종 결과로, 이 결과는 스트림의 요소들을 원하는 형태로 변환하거나 그룹화한 결과를 포함한다.
- 예시를 통해 이해해 보자.
- List<String>: 모든 스트림 요소가 리스트로 수집된 경우.
- Set<Integer>: 모든 스트림 요소가 집합으로 수집된 경우.
- Map<String, List<Person>>: 사람 객체들이 도시별로 그룹화되어 맵으로 수집된 경우.
이제 Stream.collect(Collectors.toList())를 이해하기 위해 Collectors 클래스 내부에 선언된 toList() 메서드를 살펴보자.
- javadoc을 번역해서 설명하도록 하겠다. toList() 메서드는 입력 요소들을 새로운 List로 수집하는 Collector를 반환한다.
- 수집된 리스트의 타입, 가변성(mutable or immutable), 직렬화 가능 여부(serializability), 또는 스레드 안전성(thread-safety)에 대한 보장은 없다.
- 더 많은 제어가 필요할 경우, toCollection(Supplier) 메서드를 사용하라고 권장하고 있다. toCollection(Supplier)를 사용하면 특정 타입의 List나 다른 Collection을 선택할 수 있다고 한다.
/**
* 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);
}
Collector<T, ?, List<T>>: Collector의 제네릭 타입 파라미터는 <T, A, R>이다.
- T: 입력 요소의 타입.
- A: 중간 누적자(누적 과정에서 사용되는 객체)의 타입. 여기에선?로 지정되어 있으며, 이는 CollectorImpl의 구현체에서 자동으로 결정됨
- R: 최종 결과의 타입, 이 경우 List<T>
그래서 이 메서드가 무슨 동작을 하는 건데?
- 여기서 사용되는 toList() 메서드는 스트림의 요소들을 List로 수집하는 Collector를 반환한다. (이건 Stream.toList()가 아니다.) 반환된 Collector는 기본적으로 새로운 ArrayList를 생성하여 요소들을 그 리스트에 추가하는 방식으로 동작한다.
CollectorImpl 클래스는 아래와 같이 선언되어 있다. (record를 사용: 불변객체)
- CollectorImpl 클래스는 Java 스트림 API에서 요소들을 수집하는 방법을 정의하기 위한 도구라고 생각하면 된다. 스트림에서 요소들을 어떻게 모아서 최종 결과로 변환할지를 결정한다. 예를 들어, 스트림의 요소들을 리스트나 맵으로 변환하는 등의 작업을 할 때 사용된다.
/**
* Simple implementation class for {@code Collector}.
*
* @param <T> the type of elements to be collected
* @param <R> the type of the result
*/
record CollectorImpl<T, A, R>(Supplier<A> supplier,
BiConsumer<A, T> accumulator,
BinaryOperator<A> combiner,
Function<A, R> finisher,
Set<Characteristics> characteristics
) implements Collector<T, A, R> {
CollectorImpl(Supplier<A> supplier,
BiConsumer<A, T> accumulator,
BinaryOperator<A> combiner,
Set<Characteristics> characteristics) {
this(supplier, accumulator, combiner, castingIdentity(), characteristics);
}
}
@SuppressWarnings("unchecked")
private static <I, R> Function<I, R> castingIdentity() {
return i -> (R) i;
}
CollectorImpl 클래스의 필드를 이해해 보자
- Supplier<A> supplier: 중간 저장소를 만드는 방법을 제공한다. 예를 들어, 새로운 리스트를 만들어야 할 때 사용된다.
- BiConsumer<A, T> accumulator: 요소들을 중간 저장소에 넣는 방법을 정의한다. 예를 들어, 리스트에 요소를 추가한다.
- BinaryOperator<A> combiner: 병렬 스트림에서 작업할 때 중간 저장소를 합치는 방법을 정의한다. 여러 리스트를 하나로 합치는 방식이다.
- Function<A, R> finisher: 중간 저장소를 최종 결과로 변환하는 방법을 정의한다. 만약 변환이 필요 없다면, 그냥 그대로 반환하는 함수를 사용할 수 있다.
- Set<Characteristics> characteristics: Collector의 속성을 정의한다. 예를 들어, 병렬 처리 지원 여부 같은 것을 나타낸다.
이 클래스에는 생성자가 2개 있다.
- 모든 필드를 받아서 초기화하는 생성자
- 변환 작업이 필요 없을 때 기본 값을 사용하는 생성자
변환 작업이 필요 없으면 castingIdentity() 메서드를 사용한다.
- 만약 변환이 필요 없는 경우, castingIdentity() 메서드는 입력을 그대로 반환한다.
설명만으로 바로 이해하기엔 쉽지 않다. 코드 예시를 통해 조금 더 쉽게 이해해 보자.
castingIdentity()가 사용되는 경우: 리스트로 수집
- 이 경우에는 finisher 함수(중간 결과를 최종 결과로 변환하는 함수)가 필요하지 않기 때문에 castingIdentity()가 사용된다.
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class Example1 {
public static void main(String[] args) {
// 스트림의 요소를 리스트로 수집
List<String> mutableList = Stream.of("A", "B", "C")
.collect(Collectors.toList());
System.out.println(mutableList); // 출력: [A, B, C]
}
}
castingIdentity()가 사용되지 않는 경우: 문자열 결합
- 이 경우에는 finisher 함수가 필요하다. 중간 결과를 최종 결과로 변환하기 위한 처리가 있기 때문이다.
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class Example2 {
public static void main(String[] args) {
// 스트림의 요소를 문자열로 결합
String result = Stream.of("A", "B", "C")
.collect(Collectors.joining(", "));
System.out.println(result); // 출력: A, B, C
}
}
코드를 통한 설명으로도 왜 CollectorImpl의 생성자가 2개인지 이해가지 않는 부분이 있을 것이다.
- 왜 toList에 대한 설명을 하다가 갑자기 joining 메서드가 나오는 거지? 이런 것들 말이다.
- 그 이유는 Collectors.toList()와 Collectors.joining()의 내부 로직을 살펴보면 둘 다 CollectorImpl 클래스를 생성해서 반환하기 때문이다. (이것이 CollectorImpl 클래스의 생성자가 2개 존재하는 이유다.)
- toList는 CollectorImpl 생성자에 4개의 매개변수를 넣어준다.
public static <T> Collector<T, ?, List<T>> toList() {
return new CollectorImpl<>(
ArrayList::new,
List::add,
(left, right) -> { left.addAll(right); return left; },
CH_ID);
}
- 반면 joining은 CollectorImpl 생성자에 5개의 매개변수를 넣어준다.
public static Collector<CharSequence, ?, String> joining() {
return new CollectorImpl<CharSequence, StringBuilder, String>(
StringBuilder::new,
StringBuilder::append,
(r1, r2) -> { r1.append(r2); return r1; },
StringBuilder::toString,
CH_NOID);
}
4. Stream.collect(Collectors.toList())는 (mutable list)를 반환한다.
자 이제 다시 Collectors 내부의 toList() 코드를 보도록 하자.
- 생성자의 첫 번째 매개변수에 ArrayList::new를 넣어준다.
public static <T> Collector<T, ?, List<T>> toList() {
return new CollectorImpl<>(
ArrayList::new,
List::add,
(left, right) -> { left.addAll(right); return left; },
CH_ID);
}
이 말이 무엇인가? ArrayList는 가변 리스트다. 즉, 우리가 원래 궁금했던 Collectors.toList()는 불변 리스트를 반환할지 가변 리스트를 반환할지에 대한 내용은 toList 메서드만 자세히 봐도 바로 알 수 있었다는 것이다. (일부로 내부 구현을 설명하고 싶어서 조금 시간을 끌었다.)
5. Stream.collect(Collectors.toList()) 직접 사용해 보기
Stream을 통해 Collectors.toList()를 사용해 봤다.
- 만약 내가 Stream.collect(Collectors.toList())를 사용한다면 지금까지 우리가 살펴본 대로 반환된 List<String>은 가변(mutable) 리스트여야만 한다.
package org.example;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
public class Example {
public static void main(String[] args) {
// 원본 리스트 생성
List<String> originalList = new ArrayList<>();
originalList.add("A");
originalList.add("B");
// 스트림을 사용하여 리스트 요소를 대문자로 변환 후 새로운 리스트로 수집
List<String> mutableList = originalList.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// 조회가 가능한지 확인 (가변 리스트)
System.out.println("\n");
System.out.println("=================================");
String getData = mutableList.get(0);// A
System.out.println("getData is: " + getData);
System.out.println("mutableList: " + mutableList);
System.out.println("=================================");
// add가 가능한지 확인 (가변 리스트)
mutableList.add("C"); // [A, B, C]
System.out.println("mutableList: " + mutableList);
System.out.println("=================================");
}
}
메인 메서드 호출 결과
- 가변 리스트로 생성되었기 때문에 get은 당연히 성공하고 add 또한 성공해서 C가 리스트에 추가된 것을 확인할 수 있다.
> Task :org.example.Example.main()
=================================
getData is: A // get 성공
mutableList: [A, B]
=================================
mutableList: [A, B, C] // add도 성공했다. (C가 추가됨)
=================================
이것을 통해 알 수 있는 것은 스트림을 사용하여 리스트로 변환할 때, 변환 후 리스트에 대한 작업을 계획하고 있어야 한다.
만약 불변 리스트가 필요한 상황에서는 Stream.toList()를 사용하면 되고 가변 리스트가 필요한 상황에서는 Collectors.toList()를 사용하여 오류를 방지해야 한다. 항상 사용하는 상황에 맞게 메서드를 선택하고, 이후의 코드에서 오류가 발생하지 않도록 주의하도록 하자.
6. Stream.toList()를 주의해서 사용해야 하는 이유
Stream.toList() 메서드를 이렇게 사용하는 건 괜찮다.
- Stream을 통해 생성된 불변 리스트를 새로운 Stream에 사용한다.
package org.example;
import java.util.ArrayList;
import java.util.List;
public class Example {
public static void main(String[] args) {
List<String> originalList = new ArrayList<>();
originalList.add("b");
originalList.add("a");
// 원본 리스트의 값을 대문자로 변경
List<String> upperCaseList = originalList.stream()
.map(String::toUpperCase)
.toList();
System.out.println("data List: " + upperCaseList);
// 대문자로 변경된 리스트의 내부 정렬 시도
List<String> sortedList = upperCaseList.stream()
.sorted(String::compareTo)
.toList();
System.out.println("sorted List: " + sortedList);
}
}
첫 번째 stream에서 toList() 사용
- originalList의 요소들을 모두 대문자로 변환한 다음, 결과를 Stream.toList()를 사용하여 불변 리스트로 수집한다. 여기서 반환되는 upperCaseList는 요소 "B"와 "A"를 가지는 불변 리스트다.
두 번째 stream에서는 sorted 사용
- 첫 번째 stream의 결과 리스트(불변 리스트)인 upperCaseList를 사용해서 스트림을 다시 생성하고, sorted(String::compareTo)를 호출하여 요소들을 정렬한다.
- 정렬된 스트림을 다시 toList()로 수집하여 새로운 불변 리스트를 생성한다. 이 과정은 새로운 리스트를 생성하는 것이므로, 불변 리스트의 요소를 수정하는 것이 아니며, 따라서 오류가 발생하지 않는다.
반면 주의해야 하는 상황은 다음과 같다.
- Stream으로 생성된 불변 객체를 새로운 Stream으로 사용하는 것이 아니라 바로 접근해서 forEach 같은 반복문으로 내용을 바꾸는 경우 (이런 경우는 개발 중에 쉽게 발생할 수 있다. 심지어 이 오류는 컴파일에서 잡히는 오류도 아니다.)
package org.example;
import java.util.ArrayList;
import java.util.List;
public class Example {
public static void main(String[] args) {
List<String> originalList = new ArrayList<>();
originalList.add("a");
originalList.add("b");
// 원본 리스트의 값을 대문자로 변경
List<String> upperCaseList = originalList.stream()
.map(String::toUpperCase)
.toList();
System.out.println("data List: " + upperCaseList);
// 정렬 시도 (오류 발생)
upperCaseList.sort(String::compareToIgnoreCase);
}
}
이 경우가 특히 발견하기 힘든 이유가 있다.
- Stream.toList()가 불변 객체를 반환한다는 것을 모른다면 무슨 오류인지 감을 잡기 힘들다. (자바의 코드 구조를 알아야 함)
- 컴파일 단계에서 오류가 잡히지 않고 런타임에 접하기 때문에 코드 내용을 하나하나 자세히 보지 않으면 찾아내기 힘들다.
- 심지어 코드를 작성할 때 변수명에 immutableList 이렇게 적지도 않기 때문에 불변 객체인지 바로 파악할 수 있는 방법도 사실상 없다. (현실적으로 리스트 이름에 immlutable이라고 적어두지는 않기 때문이다.)