본문 바로가기
빌드노트 검색

빌드노트

Stream.toList()로 만든 목록에 add가 실패하는 이유

Stream.toList()는 변경 불가 목록을 반환한다. 같은 원소를 세 방식으로 수집해 add 결과를 비교하고, 후속 수정에 필요한 명시적 ArrayList 수집과 이미 받은 결과의 복사를 설명한다. Collectors.toList()의 실행 결과와 API 보장도 구분한다.

Stream.toList()로 받은 목록에 add()나 remove()를 호출하면 UnsupportedOperationException이 발생한다. 변수 타입이 List여도 수정할 수 있다는 뜻은 아니다. 수집 뒤 추가·삭제해야 한다면 collect(Collectors.toCollection(ArrayList::new))로 변경 가능한 결과를 만든다. 이미 받은 목록을 수정해서 써야 한다면 new ArrayList<>(result)로 복사한다.

예외가 난 줄에서 목록 변수의 생성 지점까지 따라가 보자. stream.toList()로 만들었거나 그 결과를 다른 메서드에서 반환받았다면, 추가·삭제를 거부하는 것이 이 API의 계약이다. Java 17의 Stream.toList() 문서 (새 탭에서 열림)는 반환 목록의 변경 메서드가 예외를 던진다고 명시한다. 목록의 크기를 유지하는 set()도 허용하는 계약이 아니다.

같은 원소를 모아도 add 결과는 다르다

세 수집 방식의 차이를 두 문자열로 비교한다. 외부 라이브러리는 필요 없다. JDK의 java와 jshell을 사용할 수 있는 빈 폴더에서 아래 명령으로 버전을 확인하고 JShell을 시작한다. Stream.toList()는 Java 16부터 제공되며, 여기서는 OpenJDK와 JShell 17.0.2를 사용했다.

java --version
jshell --class-path . --no-startup --feedback concise

다음 코드를 JShell에 순서대로 입력한다. 같은 source에서 매번 새 Stream을 만들고, 수집한 각 목록에 green을 추가한다. 첫 번째 예외는 잡아서 출력하므로 나머지 비교도 이어 실행된다.

import java.util.*;
import java.util.stream.*;
var source = List.of("red", "blue");
var result = source.stream().toList();
var collected = source.stream().collect(Collectors.toList());
var editable = source.stream().collect(Collectors.toCollection(ArrayList::new));
try {
    result.add("green");
} catch (UnsupportedOperationException e) {
    System.out.println("toList.add=" + e.getClass().getSimpleName());
}
System.out.println("Collectors.toList.add=" + collected.add("green") + ": " + collected);
System.out.println("toCollection.add=" + editable.add("green") + ": " + editable);

Windows 11, OpenJDK 17.0.2에서 코드가 직접 출력한 세 줄은 다음과 같다. JShell의 변수 등록·표현식 평가 메시지는 제외했다.

toList.add=UnsupportedOperationException
Collectors.toList.add=true: [red, blue, green]
toCollection.add=true: [red, blue, green]

toList()로 만든 result는 추가를 거부했고, 다른 두 목록에는 green이 들어갔다. 같은 실행에서 result.remove("red")도 시도했지만 UnsupportedOperationException이었다. 실제로 들어 있는 값을 골라도 삭제할 수 없었다.

여기서 Collectors.toList()를 언제나 변경 가능한 목록의 해법으로 기억하면 곤란하다. 이번 JDK에서 추가에 성공한 것과 API가 변경 가능성을 보장하는 것은 다르다. Collectors.toList() 문서 (새 탭에서 열림)는 반환 목록의 구현 타입과 변경 가능 여부 등을 보장하지 않는다. collect(Collectors.toList())를 짧은 toList()로 바꿀 때도 뒤에 목록을 수정하는 코드가 있는지 확인해야 한다.

후속 수정이 필요하면 ArrayList로 수집한다

위 코드의 editable처럼 collect(Collectors.toCollection(ArrayList::new))를 사용하면 결과를 담을 새 ArrayList를 직접 지정한다. toCollection() 문서 (새 탭에서 열림)는 전달한 팩터리로 결과 컬렉션을 만든다고 설명한다. 이 예제에서는 팩터리가 ArrayList::new이므로 원소 추가·삭제를 지원하는 목록을 얻는다.

실제 수정도 editable에 해야 한다. 같은 세션에서 editable.remove("red")도 확인했을 때 반환값은 true였고 내용은 [blue, green]이 됐다. 수집한 결과를 읽기만 하면 기존 toList()를 유지할 수 있다. 추가·삭제를 해야 하는 결과에서 수집 방식을 명시하면 된다.

이미 받은 목록은 복사본에서 수정한다

목록을 반환하는 메서드는 그대로 두고, 호출한 쪽에서만 원소를 바꿔야 할 때도 있다. 앞의 JShell 세션에서 다음 코드를 이어 입력하면 result를 유지하면서 수정할 목록을 따로 만들 수 있다.

var copy = new ArrayList<>(result);
copy.add("green");
copy.remove("red");
System.out.println("result=" + result);
System.out.println("copy=" + copy);

앞서 result.add()가 실패했으므로 복사 직전의 내용은 여전히 [red, blue]다. 실행 결과에서도 기존 결과와 복사본의 원소 구성이 분리됐다.

result=[red, blue]
copy=[blue, green]

ArrayList의 컬렉션 생성자 (새 탭에서 열림)는 전달받은 컬렉션의 원소를 담은 목록을 만든다. 복사본에서 green을 추가하고 red를 지워도 result는 바뀌지 않았다. 복사한다고 기존 목록의 수정 제한이 풀리는 것은 아니다. 수정 후에도 같은 예외가 난다면 add()나 remove()의 대상이 새 복사본인지, 여전히 기존 결과를 쓰는지 확인한다. JShell을 마칠 때는 /exit를 입력한다.

직접 실행한 범위는 OpenJDK 17.0.2의 순차 Stream과 null이 없는 두 문자열이다. Java 21의 Stream.toList() 문서 (새 탭에서 열림)에서도 변경 불가 계약을 확인했지만 Java 21 런타임 실행은 하지 않았다. null 처리, 가변 원소 객체의 복제, 병렬 실행과 성능은 이 비교로 검증하지 않았다. 목록을 수정할 수 있는지와 그 밖의 계약은 따로 확인해야 한다.

← 전체 글 보기