"못 바꾼다"는 말에는 세 단계가 있고, 섞어 쓰면 사고가 난다.
-
① 수정 불가 뷰 — Collections.unmodifiableList(origin)
- 원본이 바뀌면 뷰도 바뀐다 — 껍데기만 막았다
-
② 진짜 불변 — List.of(a, b, c) / List.copyOf(origin)
- 내부 배열을 자기가 들고 있어 원본과 무관하다
-
③ 깊은 불변 — 원소까지 불변이어야 성립한다
- List.of(mutableUser) 의 user.setName() 은 여전히 통한다
①의 함정
List<String> origin = new ArrayList<>(List.of("a"));
List<String> view = Collections.unmodifiableList(origin);
origin.add("b");
view.size(); // 2 — 뷰는 원본을 그대로 비춘다
게터에서 이 뷰를 돌려주면 "불변을 반환했다"고 착각하지만, 원본 참조를 가진 쪽이 언제든 바꾼다. 진짜 보호하려면 List.copyOf로 복사본을 돌려준다.
List.of 의 추가 성질
· null 원소를 허용하지 않는다 (NullPointerException)
· 중복은 List 는 허용, Set.of·Map.of 는 IllegalArgumentException
· 반환 타입이 Arrays.asList 와 다르다
Arrays.asList 고정 크기지만 set() 은 된다 (배열 뷰)
List.of set() 도 UnsupportedOperationException
왜 불변이 동시성에 유리한가
불변 객체는 안전 공개(safe publication) 만 되면 동기화 없이 여러 스레드가 공유해도 된다
생성자에서 대입된 final 필드는 생성 완료 시점이 보장되므로 다른 스레드가 '반쯤 만들어진' 상태를 보지 않는다
- 방어적 복사·락을 모두 생략할 수 있다
스트림 수집 결과의 가변성
Collectors.toList() // 가변인지 불변인지 명시되지 않음 (현재는 ArrayList)
Collectors.toUnmodifiableList() // 명시적으로 수정 불가
Stream.toList() // Java 16+, 수정 불가 (단 null 허용)
toList() 의 결과를 수정하는 코드는 '지금은' 동작한다 명세가 보장하지 않으므로 JDK 버전이 바뀌면 깨질 수 있다
- 의도를 코드로 드러내는 쪽을 고른다
방어적 복사는 어디에 넣나
public final class Order {
private final List<Item> items;
public Order(List<Item> items) {
this.items = List.copyOf(items); // ① 들어올 때 복사
}
public List<Item> getItems() {
return items; // ② 이미 불변이므로 그대로
}
}
-
① 을 빠뜨리면 호출자가 원본을 계속 들고 바꾼다
-
② 에서 내부 리스트를 그대로 주면 호출자가 add() 로 뚫는다
-
record 도 마찬가지다 — 컴팩트 생성자에서 복사해야 진짜 불변이 된다
- record Order(List<Item> items) {
- Order { items = List.copyOf(items); }
- }
- record Order(List<Item> items) {
면접 함정
- ❌ "unmodifiableList는 불변이다" → 원본을 통해 바뀐다. 뷰일 뿐이다.
- ❌ "List.of면 안에 든 객체도 안전하다" → 얕은 불변이다.