빌드노트
unmodifiableList로 감쌌는데 원본 변경이 보인다
unmodifiableList는 원본 변경이 보이는 읽기 전용 뷰다. 짧은 JShell 예제로 원본 add·set과 뷰의 수정 거부를 비교하고, List.copyOf가 보존하는 원소 구성과 공유되는 원소 상태를 구별한다.
Collections.unmodifiableList(original)로 돌려준 목록이 add를 거부하는데도, 다른 코드가 원본을 수정하면 읽히는 내용이 달라질 수 있다. 이 메서드는 원본의 현재 내용을 읽는 읽기 전용 뷰를 반환한다. 뷰를 받은 쪽의 목록 수정은 막지만 원본 참조를 가진 코드의 수정까지 막지는 않는다. Java 17의 unmodifiableList 문서 (새 탭에서 열림)에 명시된 동작이다.
반환 시점의 원소 구성을 유지하려면 List.copyOf(original)을 쓴다. 이후 원본 목록에 원소를 추가하거나 다른 원소로 교체해도 그 결과에 반영되지 않는다. 다만 목록을 복사해도 원소 객체의 상태까지 복제되지는 않는다. 원소 구성과 원소 내부 상태는 따로 판단해야 한다.
add를 거부하는 것과 원본 변경을 안 보는 것은 다르다
두 동작을 비교하려고 만든 작은 예제다. JDK 17의 java와 jshell을 사용할 수 있는 빈 폴더에서 다음 명령을 순서대로 실행한다. 첫 명령에서 버전을 확인한 뒤 JShell을 시작한다. 명령을 찾을 수 없다면 JDK 설치와 실행 경로부터 확인한다. 외부 라이브러리는 필요 없다.
java --version
jshell --class-path . --no-startup --feedback concise
아래 코드를 JShell에 순서대로 입력한다. 원본은 [A, B]이고, 원본을 수정하기 전에 뷰와 복사본을 모두 만든다. JShell (새 탭에서 열림)은 클래스나 main 메서드 없이 이런 선언과 문장을 평가할 수 있다.
import java.util.*;
var original = new ArrayList<>(List.of("A", "B"));
var view = Collections.unmodifiableList(original);
var copy = List.copyOf(original);
original.add("C");
System.out.println("add: view=" + view + ", copy=" + copy);
original.set(0, "X");
System.out.println("set: view=" + view + ", copy=" + copy);
try { view.add("D"); } catch (UnsupportedOperationException e) { System.out.println("view.add: " + e.getClass().getSimpleName()); }
Windows 11의 OpenJDK·JShell 17.0.2에서 다음 출력을 얻었다. 프롬프트와 변수 등록·표현식 평가 메시지를 제외하고, 코드가 직접 출력한 줄만 옮겼다. 마지막 줄은 예외를 잡아 출력한 것이므로 세션은 계속 사용할 수 있다.
add: view=[A, B, C], copy=[A, B]
set: view=[X, B, C], copy=[A, B]
view.add: UnsupportedOperationException
원본에 C를 추가하자 뷰에도 보였다. 첫 원소를 X로 교체했을 때도 뷰가 따라 바뀌었다. 원소 수를 바꾸지 않는 set도 원본에서 일어나면 뷰에 반영된다. 반면 복사본은 두 번 모두 [A, B]를 유지했다.
view.add의 실패와 뷰의 내용 변화는 함께 일어날 수 있다. 수정이 들어간 경로가 다르기 때문이다. 자기 코드에서 이 증상이 보이면 반환문이 unmodifiableList(original)인지, 원본 참조를 다른 코드도 보유하는지 확인한다. 반환 직후의 원소 구성이 필요했다면 그 지점에서 List.copyOf(original)로 바꾼다. List.copyOf 계약 (새 탭에서 열림)은 이후 원본 컬렉션의 변경을 반영하지 않는다고 설명한다. 반환된 목록 자체의 추가·삭제·교체도 허용하지 않는다.
복사본에서 꺼낸 객체의 필드는 바꿀 수 있다
같은 JShell 세션에서 다음 코드를 이어 입력한다. 이번 원소는 name 필드를 바꿀 수 있는 Item이다. 복사본에서 꺼낸 원소의 필드에 새 이름을 대입한다.
class Item { String name = "old"; }
var items = new ArrayList<>(List.of(new Item()));
var itemView = Collections.unmodifiableList(items);
var itemCopy = List.copyOf(items);
itemCopy.get(0).name = "new";
System.out.println("same element: " + (items.get(0) == itemCopy.get(0)));
System.out.println(items.get(0).name + " / " + itemView.get(0).name + " / " + itemCopy.get(0).name);
직접 출력한 결과는 다음과 같다. 둘째 줄의 순서는 원본·뷰·복사본에서 읽은 이름이다. 마친 뒤 /exit를 입력하면 JShell을 종료한다.
same element: true
new / new / new
same element: true는 원본과 복사본이 같은 Item 객체를 가리킨다는 관찰이다. 필드 대입은 목록의 set을 호출하지 않는다. 목록 안의 참조를 교체하지 않고, 그 참조가 가리키는 객체를 수정했기 때문에 세 목록에서 모두 new가 읽혔다. 이처럼 원소 객체를 함께 참조하는 것이 얕은 복사의 경계다. 변경 불가 목록의 가변 원소에 관한 문서 (새 탭에서 열림)도 원소가 가변이면 읽히는 내용이 달라질 수 있다고 설명한다.
이 Item의 당시 상태까지 보관하려면 name의 당시 값을 별도의 불변 값 객체에 담는 식으로 원소 복사 규칙을 정해야 한다. 원소 안에 가변 목록이나 다른 가변 객체가 있다면 그 필드도 어디까지 분리할지 결정한다. 겉 객체 하나를 새로 만드는 것만으로 중첩된 객체까지 깊이 복사되지는 않는다.
반환값에 필요한 수명을 고른다
- 원본의 최신 원소 구성을 계속 보여 주면서 받는 쪽의 목록 수정만 막으려면
Collections.unmodifiableList(original)을 쓴다. - 반환 시점의 원소 구성을 유지하려면
List.copyOf(original)을 쓴다. - 원소의 당시 상태까지 유지하려면 필요한 값을 별도로 옮기거나 원소 객체를 복사한다. 목록 복사만으로는 충분하지 않다.
교체 전 입력 조건도 확인한다. List.copyOf는 Java 10부터 제공하며 입력 컬렉션이나 그 안의 원소가 null이면 NullPointerException을 낸다. 또 호출마다 새 목록 인스턴스를 할당한다고 보장하지 않으므로, 목록끼리의 ==로 스냅샷 여부를 판단하지 않는다. 이 조건들은 Java 17의 List 문서 (새 탭에서 열림)에서 확인한 계약이다.
직접 실행한 범위는 OpenJDK 17.0.2에서 합성 목록을 한 스레드로 차례로 수정한 경우다. 복사 중 다른 스레드가 원본을 수정하는 상황의 일관성이나 임의의 객체 그래프를 깊이 복사하는 방법은 검증하지 않았다. 반환값이 달라진다면 먼저 목록의 추가·교체가 반영되는지, 같은 원소 객체의 필드가 변하는지를 나눠 확인한다.