용도에 맞는 List 고르기
ArrayList, LinkedList, List.of, List.copyOf, Arrays.asList로 만든 목록이 변경을 허용하는지, 원본과 연결되어 있는지, null을 받는지를 구분하고, 게시글 저장소가 내부 목록을 안전하게 내보내도록 완성합니다.
이 장에서 직접 만든 MyArrayList와 MyLinkedList는 목록이 안에서 어떻게 움직이는지 이해하려고 만든 것입니다. 실제 프로그램에서는 오랫동안 검증되고 최적화된 표준 List 구현을 씁니다. 앞 글에서 ArrayList와 LinkedList의 연산 비용을 비교했으니 이제 둘 중 하나만 고르면 될 것 같지만, 표준 라이브러리에는 다른 방법으로 만든 List도 있습니다. List.of, List.copyOf, Arrays.asList가 돌려주는 목록입니다.
이 목록들은 타입은 모두 List이지만 할 수 있는 일이 서로 다릅니다. 어떤 것은 원소를 추가할 수 없고, 어떤 것은 원본 배열과 연결되어 있으며, 어떤 것은 null을 받지 않습니다. 그래서 목록을 고를 때는 조회 속도뿐 아니라 세 가지를 함께 물어야 합니다. 나중에 원소를 추가하거나 바꿀 수 있어야 하는가, 원본과 연결되어 있어도 되는가, null을 담아야 하는가입니다. 이 글에서는 이 차이를 하나씩 확인하고, 그 결과로 게시글 저장소가 내부 목록을 바깥에 안전하게 내보내도록 완성합니다.
같은 List인데 add가 실패한다
다음 프로그램은 Arrays.asList로 문자열 두 개를 담은 목록을 만든 뒤 하나를 더 추가합니다. 변수 타입이 List<String>이니 ArrayList처럼 원소를 늘릴 수 있을 것 같지만, 실제로 그렇게 되는지 보세요.
import java.util.Arrays;
import java.util.List;
public final class ArraysAsListMutationFailure {
public static void main(String[] args) {
List<String> titles = Arrays.asList("array", "linked");
titles.add("choice");
}
}Exception in thread "main" java.lang.UnsupportedOperationException컴파일은 문제없이 됩니다. titles의 타입은 List<String>이고 List에는 add 메서드가 있기 때문입니다. 그런데 실행하면 add에서 UnsupportedOperationException이 나며 프로그램이 멈춥니다(출력의 둘째 줄부터 이어지는 스택 트레이스는 생략했습니다). 이 예외는 '이 구현은 그 연산을 지원하지 않는다'는 뜻입니다.
Arrays.asList는 배열을 새 목록으로 복사하지 않고, 배열을 List처럼 볼 수 있게 감싼 뷰(view)를 돌려줍니다. 뷰는 자기만의 저장 공간 없이 원본을 다른 모습으로 보여 주는 객체입니다. 이 뷰의 칸은 곧 원본 배열의 칸이고, 배열은 한 번 만들면 길이를 바꿀 수 없습니다. 그래서 칸의 값을 바꾸는 set은 되지만, 크기를 바꾸는 add와 remove는 지원하지 않습니다. 이런 목록을 고정 크기 목록이라고 부릅니다.
원소를 더 넣어야 한다면 뷰의 내용을 새 ArrayList로 복사합니다. new ArrayList<>(Arrays.asList("array", "linked"))처럼 만들면 배열과 상관없는 가변 목록이 생기고, 여기에는 add도 remove도 됩니다.
이 일은 이 장에서 앞서 배운 원칙과도 관계가 있습니다. MyList 인터페이스로 목록 구현 바꾸기에서 인터페이스 타입에 의존하면 구현을 바꿔 끼울 수 있다고 했는데, 그러려면 구현들이 같은 동작을 해야 합니다. List는 이 점에서 조금 특별합니다. add나 remove 같은 변경 메서드는 지원하지 않을 수도 있는 선택적 연산(optional operation)이라고 문서에 적어 두고, 지원하지 않는 구현은 UnsupportedOperationException을 던지도록 정해 두었습니다. 그래서 List를 받는 코드는 그 목록을 변경해도 되는지 미리 알아야 하고, List를 돌려주는 메서드는 돌려준 목록을 변경할 수 있는지를 이름이나 문서로 알려야 합니다.
다섯 가지 목록의 규칙 비교
표준 라이브러리로 목록을 만드는 대표적인 방법 다섯 가지를 비교하면 다음과 같습니다. 원소를 추가하거나 지우는 일을 구조 변경, 칸의 원소를 다른 것으로 바꾸는 일을 교체라고 하겠습니다.
| 만드는 방법 | 구조 변경 (add, remove) | 교체 (set) | null 원소 | 원본과의 관계 |
|---|---|---|---|---|
new ArrayList<>() | 가능 | 가능 | 가능 | 원본 없음 |
new LinkedList<>() | 가능 | 가능 | 가능 | 원본 없음 |
Arrays.asList(array) | 불가 | 가능 | 가능 | 배열과 칸을 공유 |
List.of(...) | 불가 | 불가 | 불가 | 전달한 원소의 참조만 보관 |
List.copyOf(collection) | 불가 | 불가 | 불가 | 만든 시점에 복사, 이후 변경과 분리 |
ArrayList와 LinkedList는 모든 변경을 허용하는 가변 목록입니다. 둘 중 무엇을 고를지는 앞 글에서 본 작업 부하가 정합니다. 조회와 끝 추가, 순회가 대부분인 일반적인 목록이라면 ArrayList를 기본으로 씁니다. LinkedList는 List와 Deque를 함께 구현하고, 반복자가 있는 위치에서 삽입과 삭제가 잦을 때 쓸 만합니다.
List.of와 List.copyOf가 돌려주는 목록은 수정 불가 목록(unmodifiable list)입니다. 추가, 삭제, 교체가 모두 UnsupportedOperationException으로 거부됩니다. null 원소도 받지 않아서, List.of("a", null)처럼 만들려고 하면 그 자리에서 NullPointerException이 납니다. 바뀌면 안 되는 설정값이나 다 만들어진 계산 결과를 담기에 알맞고, 변경할 수 없다는 사실 자체가 '이 목록은 읽기만 하라'는 의도를 드러냅니다. 다만 수정 불가 목록이 막는 것은 목록의 칸을 바꾸는 일까지입니다. 칸이 가리키는 원소 객체의 내부까지 고정하지는 않는데, 이 차이는 뒤에서 예제로 확인합니다.
수정 불가 목록이 null을 거부하는 규칙은 조회에도 적용됩니다. List.of("a").contains(null)도 false를 돌려주지 않고 NullPointerException을 던집니다. 반면 Arrays.asList와 가변 목록은 null을 담을 수 있으므로, null이 섞인 데이터를 List.copyOf로 복사하려 하면 실패합니다.
아래 그림은 배열 뷰, 가변 복사본, 수정 불가 목록이 각각 무엇을 바꿀 수 있고 원본과 어떻게 연결되어 있는지를 나란히 보여 줍니다. 실선은 배열과 뷰가 칸을 공유하는 관계이고, 점선은 서로 다른 목록이 같은 원소 객체를 가리키는 관계입니다.
배열 뷰·가변 복사·수정 불가 목록의 변경 권한과 원본 관계를 구분하며 원소 객체는 깊은 복사되지 않는다.
API 계약 · 목록과 원소를 구분
목록 변경 권한과 원소 참조의 공유
목록의 추가·삭제·교체 권한과 원소 객체의 공유는 다른 문제입니다. 수정 불가 목록도 가변 원소의 내부 상태까지 고정하지 않습니다.
| 만드는 방법 | 목록의 변경 권한 | 원본과의 관계 |
|---|---|---|
Arrays.asList(array) | set 가능크기 변경 add/remove 불가 | 배열 슬롯과 목록 슬롯의 교체가 서로 반영됨 |
new ArrayList<>(view) | set/add/remove 가능null 원소 허용 | 목록 슬롯은 별도이며 원소 객체의 참조는 공유 |
List.of(...) | set/add/remove 불가null 원소 불가 | 전달한 원소 참조를 보관; 원소 객체를 복제하지 않음 |
List.copyOf(source) | set/add/remove 불가null 원소 불가 | 이후 원본 목록의 추가·삭제·교체는 미반영; 원소 참조는 공유 |
Arrays.asList(array)- 목록의 변경 권한
set가능크기 변경add/remove불가- 원본과의 관계
- 배열 슬롯과 목록 슬롯의 교체가 서로 반영됨
new ArrayList<>(view)- 목록의 변경 권한
set/add/remove가능null원소 허용- 원본과의 관계
- 목록 슬롯은 별도이며 원소 객체의 참조는 공유
List.of(...)- 목록의 변경 권한
set/add/remove불가null원소 불가- 원본과의 관계
- 전달한 원소 참조를 보관; 원소 객체를 복제하지 않음
List.copyOf(source)- 목록의 변경 권한
set/add/remove불가null원소 불가- 원본과의 관계
- 이후 원본 목록의 추가·삭제·교체는 미반영; 원소 참조는 공유
API 계약 비교입니다. 가변 원소의 내부 변경은 같은 객체를 보는 목록들에 보일 수 있습니다. 수정 불가 목록은 원소 객체까지 불변으로 만들지 않습니다.
Arrays.asList는 원본 배열과 연결되어 있다
Arrays.asList가 뷰라는 것은 원본 배열과 칸을 함께 쓴다는 뜻입니다. 다음 프로그램은 배열과 그 뷰, 그리고 뷰를 복사한 ArrayList를 만든 뒤 각각을 바꿔 봅니다.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public final class ArrayViewWriteThrough {
public static void main(String[] args) {
String[] titles = {"array", "linked"};
List<String> view = Arrays.asList(titles);
List<String> copy = new ArrayList<>(view);
titles[0] = "ARRAY";
view.set(1, "LINKED");
copy.add("choice");
System.out.println("array=" + Arrays.toString(titles));
System.out.println("view=" + view);
System.out.println("copy=" + copy);
}
}array=[ARRAY, LINKED]
view=[ARRAY, LINKED]
copy=[array, linked, choice]배열의 0번 칸을 바꾸자 뷰의 0번 원소도 ARRAY가 되었고, 뷰의 1번 원소를 set으로 바꾸자 배열의 1번 칸도 LINKED가 되었습니다. 두 변수가 같은 칸을 보고 있으므로 어느 쪽에서 바꿔도 다른 쪽에 보입니다. 반면 copy는 뷰를 만든 직후에 복사한 별도의 목록이라 그 뒤의 변경이 하나도 반영되지 않았고, 자기 칸을 따로 가지고 있으므로 add도 됩니다.
이 연결은 의도해서 쓰면 편리합니다. 배열을 받는 코드에 List 메서드를 쓰고 싶을 때 복사 없이 바로 감쌀 수 있고, Collections.sort(Arrays.asList(array))처럼 목록을 정렬하면 원본 배열이 정렬됩니다. 하지만 모르고 쓰면 버그가 됩니다. 메서드가 받은 배열을 Arrays.asList로 감싸 필드에 저장해 두면, 호출한 쪽이 나중에 배열을 고칠 때 객체의 상태가 몰래 바뀝니다. 배열과 상관없는 목록이 필요하다면 new ArrayList<>(Arrays.asList(values))나 List.copyOf(Arrays.asList(values))처럼 명시적으로 복사합니다.
게시글 저장소가 내부 목록을 그대로 내보내면
이제 이 장의 게시글 저장소를 표준 ArrayList로 다시 만듭니다. 내부 목록에 게시글을 담고, 전체 목록을 돌려주는 findAll을 제공합니다. 먼저 내부 목록을 그대로 돌려주는 흔한 실수부터 보겠습니다.
import java.util.ArrayList;
import java.util.List;
public final class LeakedListBug {
public static void main(String[] args) {
PostRepository repository = new PostRepository();
repository.add("standard-list", 46);
List<Entry> leaked = repository.findAll();
leaked.add(new Entry("", -5));
System.out.println(
"size=" + repository.findAll().size() + ", total=" + repository.totalViewCount());
}
private static final class PostRepository {
private final List<Entry> entries = new ArrayList<>();
void add(String title, int viewCount) {
if (title == null || title.isBlank()) throw new IllegalArgumentException("title");
if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
entries.add(new Entry(title, viewCount));
}
List<Entry> findAll() {
return entries; // 내부 목록을 그대로 돌려준다
}
int totalViewCount() {
int total = 0;
for (Entry entry : entries) {
total += entry.viewCount();
}
return total;
}
}
private record Entry(String title, int viewCount) {}
}size=2, total=41저장소의 add는 제목이 비었거나 조회 수가 0 이하인 게시글을 거부합니다. 그런데 findAll이 내부 목록 entries를 그대로 돌려주었기 때문에, 호출한 쪽은 받은 목록에 직접 add를 호출해 이 검사를 건너뛰었습니다. 그 결과 제목이 빈 문자열이고 조회 수가 -5인 게시글이 들어가서, 합계가 46 − 5 = 41이 되었습니다. entries 필드에 final을 붙였지만 소용이 없었습니다. final은 필드가 다른 목록을 가리키지 못하게 막을 뿐, 지금 가리키는 목록의 내용이 바뀌는 것은 막지 않기 때문입니다.
이 문제를 막으려면 내부 목록을 수정할 수 있는 길을 바깥에 열어 주지 말아야 합니다. findAll이 List.copyOf(entries)를 돌려주게 바꾸면, 호출한 쪽이 받은 목록에 add를 호출하는 순간 UnsupportedOperationException이 나서 잘못된 시도가 그 자리에서 드러납니다. 반환하는 목록이 수정 불가 복사본이므로 내부 목록은 add의 검사를 거쳐야만 바뀝니다.
List.copyOf로 만든 스냅숏
List.copyOf가 돌려주는 목록은 만든 시점의 원소를 담은 스냅숏(snapshot)입니다. 스냅숏은 어느 순간의 상태를 찍어 둔 사진처럼, 원본이 나중에 바뀌어도 그대로 남습니다. 다음 프로그램은 내부 저장소에 ArrayList를 쓰고, 바깥에는 List.copyOf로 만든 스냅숏을 돌려줍니다. 스냅숏을 받은 뒤 게시글을 하나 더 추가하고 두 목록의 크기를 비교합니다.
import java.util.ArrayList;
import java.util.List;
public final class PracticalBoardList {
public static void main(String[] args) {
PostRepository repository = new PostRepository();
repository.add("array", 40);
repository.add("linked", 50);
List<Entry> snapshot = repository.entries();
repository.add("choice", 30);
System.out.println("snapshot=" + snapshot.size() + ", current=" + repository.entries().size());
System.out.println("total=" + repository.total());
}
private static final class PostRepository {
private final List<Entry> entries = new ArrayList<>();
void add(String title, int viewCount) {
if (title == null || title.isBlank() || viewCount <= 0)
throw new IllegalArgumentException();
entries.add(new Entry(title, viewCount));
}
List<Entry> entries() {
return List.copyOf(entries);
}
int total() {
return entries.stream().mapToInt(Entry::viewCount).sum();
}
}
private record Entry(String title, int viewCount) {}
}snapshot=2, current=3
total=120snapshot은 게시글이 두 개일 때 받은 복사본이므로, 그 뒤에 choice가 추가되어도 크기가 2로 남습니다. repository.entries()를 다시 호출하면 그 시점의 내부 목록을 새로 복사하므로 크기 3이 나오고, 합계는 세 게시글의 조회 수를 더한 40 + 50 + 30 = 120입니다. 다음 단계별 그림이 이 과정을 따라갑니다.
저장소에 추가해도 이전 스냅숏은 두 항목
이전 snapshot은 두 항목을 보존하고 저장소에 choice를 추가한 뒤 현재 항목 수 3개와 합계 120을 얻습니다. 복사 시점과 이후 변경의 실제 상태 차이입니다.
snapshot 생성 뒤 choice:30을 추가하면 저장소는 3항목이 되며 이전 snapshot은 array:40과 linked:50을 유지한다.
저장소 상태 · 현재와 이전 스냅숏
저장소에 추가해도 이전 스냅숏은 두 항목
세 번째 항목을 저장소에 넣어도 이전 스냅숏에는 두 항목만 남습니다. 합계 120은 현재 저장소의 40 + 50 + 30입니다.
내부 ArrayList
- 두 Entry 뒤
snapshot = repository.entries() array:40, linked:50크기2repository.add("choice", 30)array:40, linked:50, choice:30현재 크기3
이전 snapshot
- 두 Entry 뒤
snapshot = repository.entries() - 같은 두 Entry 참조크기
2 repository.add("choice", 30)array:40, linked:50이전 크기2
소스 상태를 추적한 비교입니다. 원문의 출력은 snapshot=2, current=3과 total=120 두 줄입니다.
이 예의 조건
- 복사는 멤버십이며 앞선 두 Entry 참조는 공유한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원문 저장소의 array 40/linked 50 뒤 snapshot을 받고 choice 30을 추가합니다. 현재 목록 3항목·합계 120과 이전 snapshot 2항목이 분리됩니다. 실선은 각 목록 상태의 변화이며 이전 두 Entry 참조는 공유합니다. |
| 5 | array:40 |
| 6 | linked:50 |
| 14 | array:40 |
| 15 | linked:50 |
| 구분 | 내용 |
|---|---|
| 설명 | 원문 저장소의 array 40/linked 50 뒤 snapshot을 받고 choice 30을 추가합니다. 현재 목록 3항목·합계 120과 이전 snapshot 2항목이 분리됩니다. 실선은 각 목록 상태의 변화이며 이전 두 Entry 참조는 공유합니다. |
| 전체 읽기 정보 | 저장소에 추가해도 이전 스냅숏은 두 항목 저장소에 추가해도 이전 스냅숏은 두 항목 snapshot 생성 뒤 choice:30을 추가하면 저장소는 3항목이 되며 이전 snapshot은 array:40과 linked:50을 유지한다. 저장소 상태 · 현재와 이전 스냅숏 저장소에 추가해도 이전 스냅숏은 두 항목 세 번째 항목을 저장소에 넣어도 이전 스냅숏에는 두 항목만 남습니다. 합계 120은 현재 저장소의 40 + 50 + 30입니다. 내부 ArrayList 두 Entry 뒤 snapshot = repository.entries() array:40, linked:50 크기 2 repository.add("choice", 30) array:40, linked:50, choice:30 현재 크기 3 이전 snapshot 두 Entry 뒤 snapshot = repository.entries() 같은 두 Entry 참조 크기 2 repository.add("choice", 30) array:40, linked:50 이전 크기 2 소스 상태를 추적한 비교입니다. 원문의 출력은 snapshot=2, current=3과 total=120 두 줄입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기존 두 항목 저장소에 array 40/linked 50을 보관합니다. |
|
|
| 옛 snapshot 두 참조를 목록 사본으로 반환합니다. |
|
|
| 현재 저장소 추가 choice 30을 보관하고 현재/옛 목록을 읽습니다. |
|
|
저장소에 추가해도 이전 스냅숏은 두 항목
이전 snapshot은 두 항목을 보존하고 저장소에 choice를 추가한 뒤 현재 항목 수 3개와 합계 120을 얻습니다. 복사 시점과 이후 변경의 실제 상태 차이입니다.
snapshot 생성 뒤 choice:30을 추가하면 저장소는 3항목이 되며 이전 snapshot은 array:40과 linked:50을 유지한다.
저장소 상태 · 현재와 이전 스냅숏
저장소에 추가해도 이전 스냅숏은 두 항목
세 번째 항목을 저장소에 넣어도 이전 스냅숏에는 두 항목만 남습니다. 합계 120은 현재 저장소의 40 + 50 + 30입니다.
내부 ArrayList
- 두 Entry 뒤
snapshot = repository.entries() array:40, linked:50크기2repository.add("choice", 30)array:40, linked:50, choice:30현재 크기3
이전 snapshot
- 두 Entry 뒤
snapshot = repository.entries() - 같은 두 Entry 참조크기
2 repository.add("choice", 30)array:40, linked:50이전 크기2
소스 상태를 추적한 비교입니다. 원문의 출력은 snapshot=2, current=3과 total=120 두 줄입니다.
이 예의 조건
- 복사는 멤버십이며 앞선 두 Entry 참조는 공유한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원문 저장소의 array 40/linked 50 뒤 snapshot을 받고 choice 30을 추가합니다. 현재 목록 3항목·합계 120과 이전 snapshot 2항목이 분리됩니다. 실선은 각 목록 상태의 변화이며 이전 두 Entry 참조는 공유합니다. |
| 5 | array:40 |
| 6 | linked:50 |
| 14 | array:40 |
| 15 | linked:50 |
| 구분 | 내용 |
|---|---|
| 설명 | 원문 저장소의 array 40/linked 50 뒤 snapshot을 받고 choice 30을 추가합니다. 현재 목록 3항목·합계 120과 이전 snapshot 2항목이 분리됩니다. 실선은 각 목록 상태의 변화이며 이전 두 Entry 참조는 공유합니다. |
| 전체 읽기 정보 | 저장소에 추가해도 이전 스냅숏은 두 항목 저장소에 추가해도 이전 스냅숏은 두 항목 snapshot 생성 뒤 choice:30을 추가하면 저장소는 3항목이 되며 이전 snapshot은 array:40과 linked:50을 유지한다. 저장소 상태 · 현재와 이전 스냅숏 저장소에 추가해도 이전 스냅숏은 두 항목 세 번째 항목을 저장소에 넣어도 이전 스냅숏에는 두 항목만 남습니다. 합계 120은 현재 저장소의 40 + 50 + 30입니다. 내부 ArrayList 두 Entry 뒤 snapshot = repository.entries() array:40, linked:50 크기 2 repository.add("choice", 30) array:40, linked:50, choice:30 현재 크기 3 이전 snapshot 두 Entry 뒤 snapshot = repository.entries() 같은 두 Entry 참조 크기 2 repository.add("choice", 30) array:40, linked:50 이전 크기 2 소스 상태를 추적한 비교입니다. 원문의 출력은 snapshot=2, current=3과 total=120 두 줄입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기존 두 항목 저장소에 array 40/linked 50을 보관합니다. |
|
|
| 옛 snapshot 두 참조를 목록 사본으로 반환합니다. |
|
|
| 현재 저장소 추가 choice 30을 보관하고 현재/옛 목록을 읽습니다. |
|
|
여기서 복사된 것은 목록의 칸, 즉 원소를 가리키는 참조들입니다. Entry 객체 자체는 복사되지 않으므로, 스냅숏의 두 칸과 내부 목록의 앞 두 칸은 같은 Entry 객체를 가리킵니다. 이처럼 컨테이너만 새로 만들고 안의 원소는 공유하는 복사를 얕은 복사(shallow copy)라고 합니다. Entry는 필드를 바꿀 수 없는 레코드이므로 공유해도 문제가 없습니다.
이 설계는 내부 구현과 바깥에 보이는 규칙을 따로 고른 것입니다. 저장소 안에서는 게시글을 계속 추가해야 하므로 가변 목록인 ArrayList를 쓰고, 바깥에는 바꿀 수 없는 스냅숏을 내보냅니다. 호출한 쪽은 받은 목록을 마음 놓고 읽고 다른 곳에 넘길 수 있으며, 저장소의 상태는 저장소의 메서드로만 바뀝니다. 그 대가로 entries()를 부를 때마다 원소 수에 비례하는 복사가 일어나므로, 아주 큰 목록을 자주 내보내야 한다면 다음에 볼 읽기 전용 뷰와 비교해 고릅니다.
더 깊이: 읽기 전용 뷰와 스냅숏의 차이
수정을 막는 또 다른 방법으로 Collections.unmodifiableList(list)가 있습니다. 이 메서드는 원본을 복사하지 않고, 변경 메서드만 막은 뷰를 돌려줍니다. 그래서 만드는 비용이 거의 들지 않지만, 원본이 바뀌면 뷰에도 그대로 보입니다.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class UnmodifiableViewVsCopy {
public static void main(String[] args) {
List<String> source = new ArrayList<>(List.of("array", "linked"));
List<String> view = Collections.unmodifiableList(source);
List<String> copy = List.copyOf(source);
source.add("choice");
System.out.println("view=" + view);
System.out.println("copy=" + copy);
}
}view=[array, linked, choice]
copy=[array, linked]원본에 choice를 추가하자 뷰에는 보이고 복사본에는 보이지 않습니다. 뷰를 받은 쪽은 목록을 바꿀 수 없지만, 목록이 바뀌는 것을 보게 됩니다. 그래서 뷰를 순회하는 동안 원본이 바뀌면 ConcurrentModificationException이 날 수도 있습니다. 항상 최신 내용을 보여 주는 것이 목적이라면 뷰를, 받은 순간의 내용이 그대로 유지되어야 한다면 스냅숏을 고릅니다.
List.copyOf는 List.of나 List.copyOf로 만든 목록을 받으면 대개 복사하지 않고 그 목록을 그대로 돌려줍니다. 공식 문서도 구현 참고 사항(implementation note)에서 이렇게 될 수 있다고 적고 있으며, JDK 21에서 List.copyOf(List.of("a", "b"))는 인자로 넘긴 바로 그 객체를 돌려줍니다. 어차피 바꿀 수 없는 목록이라 복사해도 차이가 없기 때문입니다. 반면 Collections.unmodifiableList가 만든 뷰는 원본이 바뀔 수 있으므로 새로 복사합니다.
수정 불가 목록도 원소의 내용까지는 막지 못한다
앞에서 수정 불가 목록이 막는 것은 목록의 칸이라고 했습니다. 원소가 필드를 바꿀 수 있는 가변 객체라면, 목록을 수정할 수 없어도 그 객체를 통해 값은 바뀝니다. 다음 프로그램은 레코드 대신 제목을 바꿀 수 있는 MutableEntry를 원소로 써서 이 차이를 확인합니다. 저장소의 Entry는 그대로 레코드로 두고, 차이를 보여 주려고 만든 별도의 예제입니다.
import java.util.ArrayList;
import java.util.List;
public final class SharedElementSnapshot {
public static void main(String[] args) {
MutableEntry first = new MutableEntry("array");
List<MutableEntry> original = new ArrayList<>();
original.add(first);
List<MutableEntry> snapshot = List.copyOf(original);
original.add(new MutableEntry("linked"));
first.title = "updated";
System.out.println("sizes=" + original.size() + "/" + snapshot.size());
System.out.println("snapshot-title=" + snapshot.get(0).title);
System.out.println("same-entry=" + (original.get(0) == snapshot.get(0)));
}
private static final class MutableEntry {
private String title;
MutableEntry(String title) { this.title = title; }
}
}sizes=2/1
snapshot-title=updated
same-entry=true첫 줄을 보면 원본에 linked를 추가해도 스냅숏의 크기는 1 그대로이므로, 목록의 구조는 분리되어 있습니다. 하지만 둘째 줄에서 스냅숏의 첫 원소 제목은 updated로 바뀌어 있습니다. 셋째 줄이 그 이유를 보여 줍니다. 두 목록의 첫 칸이 같은 MutableEntry 객체를 가리키므로(==가 true), first.title을 바꾸면 두 목록 어디서 보든 바뀐 제목이 보입니다.
칸을 바꾸는 것은 다릅니다. first.title = "updated"; 대신 original.set(0, new MutableEntry("replacement"));를 실행하면 원본의 첫 칸만 새 객체를 가리키게 됩니다. 스냅숏의 첫 칸은 여전히 원래 객체를 가리키므로 출력은 sizes=2/1, snapshot-title=array, same-entry=false가 됩니다. 두 변경을 함께 실행한 결과가 아니라, 첫 번째 대입 대신 이 줄만 넣었을 때의 결과입니다.
목록을 완전히 바꿀 수 없게 만들려면 원소도 불변이어야 합니다. 저장소의 Entry를 레코드로 만든 것이 그래서입니다. 원소가 가변 객체여야 한다면, 바깥에 내보낼 때 원소까지 새로 복사하는 깊은 복사(deep copy)를 하거나 원소의 읽기 전용 정보만 담은 별도의 객체로 바꿔서 내보내야 합니다.
완성한 게시글 저장소
지금까지 정한 규칙을 모아 게시글 저장소를 완성합니다. 내부에는 입력 순서를 유지하고 끝 추가와 순회에 강한 ArrayList를 두고, findAll은 List.copyOf로 만든 스냅숏을 돌려줍니다.
import java.util.ArrayList;
import java.util.List;
public final class PostRepositoryCliCH149 {
public static void main(String[] args) {
PostRepository repository = new PostRepository();
repository.add("standard-list", 46);
repository.add("immutable-snapshot", 64);
for (Entry entry : repository.findAll()) {
System.out.println(entry.title() + "=" + entry.viewCount());
}
System.out.println("total=" + repository.totalViewCount());
}
private static final class PostRepository {
private final List<Entry> entries = new ArrayList<>();
void add(String title, int viewCount) {
if (title == null || title.isBlank()) throw new IllegalArgumentException("title");
if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
entries.add(new Entry(title, viewCount));
}
List<Entry> findAll() {
return List.copyOf(entries);
}
int totalViewCount() {
int total = 0;
for (Entry entry : entries) {
total += entry.viewCount();
}
return total;
}
}
private record Entry(String title, int viewCount) {}
}standard-list=46
immutable-snapshot=64
total=110두 게시글은 추가한 순서대로 출력되고, 합계는 46 + 64 = 110입니다. 이 CLI가 하는 일은 끝 추가와 전체 순회뿐이므로 앞 글에서 본 대로 ArrayList가 잘 맞습니다. 필드 타입은 List<Entry>로 적고 구현은 생성식에서만 정했기 때문에, 작업 부하가 달라져 다른 구현이 필요해지면 new ArrayList<>() 한 곳만 바꾸면 됩니다.
findAll이 수정 불가 스냅숏을 돌려주므로 앞에서 본 LeakedListBug 같은 우회는 통하지 않습니다. 호출한 쪽은 받은 목록을 읽고 순회할 수만 있고, 게시글을 추가하려면 검사가 있는 add를 거쳐야 합니다. 반환한 목록이 수정 불가라는 사실은 메서드 이름만으로는 알 수 없으므로, 실제 프로젝트에서는 문서 주석에 적어 둡니다. 그래야 호출한 쪽이 UnsupportedOperationException을 만나고 나서야 그 사실을 알게 되는 일을 막을 수 있습니다.
표준 List를 고르는 순서
목록을 고를 때는 구현 이름부터 떠올리지 말고, 그 목록에 필요한 규칙부터 정합니다. 먼저 원소의 순서를 유지해야 하는지, 중복을 허용하는지, null을 담아야 하는지, 만든 뒤에 바뀌어야 하는지, 원본과 연결되어야 하는지를 정합니다. 순서를 유지하고 중복을 허용한다면 List가 맞고, 중복을 막아야 한다면 다음 장에서 다루는 Set을 검토합니다. 규칙이 정해진 다음에야 성능 특성을 보고 구현을 좁힙니다.
| 질문 | 확인할 것 | 고를 것 |
|---|---|---|
| 계속 추가하고 바꾸는 일반 목록인가 | 조회, 끝 추가, 순회가 대부분인지 | ArrayList |
| 반복 중 삽입·삭제가 잦은가 | ListIterator 위치에서 add, remove를 하는지 | LinkedList 검토 |
| 양 끝에서만 넣고 빼는가 | 큐나 스택처럼 쓰는지 | ArrayDeque 우선 검토 |
| 만든 뒤 바뀌면 안 되는가 | 설정값, 완성된 결과, 바깥에 내보내는 목록인지 | List.of, List.copyOf |
| 배열을 목록으로 다뤄야 하는가 | 배열과 연결되어도 되는지, 크기는 고정인지 | Arrays.asList |
ArrayDeque는 양 끝 연산만 필요한 경우를 위한 구현으로, ArrayDeque로 스택과 큐 다루기에서 LinkedList와 비교합니다. 그리고 앞 글에서 본 것처럼 중간 삽입이 O(1)이라는 표 한 줄만 보고 LinkedList를 고르지는 않습니다. 위치를 찾는 비용까지 따지면 대부분의 경우 ArrayList가 더 빠릅니다.
정리
List의 변경 메서드는 선택적 연산이라, 구현에 따라UnsupportedOperationException을 던질 수 있습니다. 같은List타입이라도 무엇을 할 수 있는지는 만든 방법이 정합니다.ArrayList와LinkedList는 모든 변경을 허용하고null도 담습니다. 일반적인 목록은ArrayList를 기본으로 씁니다.Arrays.asList는 배열을 감싼 고정 크기 뷰입니다.set은 되고 배열과 서로 반영되지만,add와remove는 안 됩니다.List.of와List.copyOf는 추가, 삭제, 교체가 모두 안 되는 수정 불가 목록을 만들고,null을 받지 않습니다.List.copyOf는 만든 시점의 스냅숏이라 원본의 이후 변경이 반영되지 않습니다.- 수정 불가 목록이 막는 것은 목록의 칸까지입니다. 원소 객체가 가변이면 그 내용은 바뀔 수 있으므로, 원소도 레코드처럼 불변으로 만듭니다.
- 내부 가변 목록을 그대로 반환하면 호출한 쪽이 검사를 건너뛰어 내용을 바꿀 수 있습니다. 바깥에는
List.copyOf스냅숏을 돌려줍니다.
연습 문제
- 가변
ArrayList에 값 두 개를 넣고List.copyOf로 스냅숏을 만드세요. 원본에 세 번째 값을 추가한 뒤 두 목록의 크기를 출력하고, 스냅숏에add를 호출하면 무슨 일이 일어나는지 확인하세요.
정답과 해설
import java.util.ArrayList;
import java.util.List;
public final class ImmutableListSnapshotSolution {
public static void main(String[] args) {
ArrayList<String> mutable = new ArrayList<>(List.of("array", "list"));
List<String> snapshot = List.copyOf(mutable);
mutable.add("snapshot");
System.out.println("mutable=" + mutable.size() + ", snapshot=" + snapshot.size());
try {
snapshot.add("blocked");
} catch (UnsupportedOperationException exception) {
System.out.println("immutable=" + exception.getClass().getSimpleName());
}
}
}mutable=3, snapshot=2
immutable=UnsupportedOperationException스냅숏은 원소가 두 개일 때 만들었으므로, 원본에 "snapshot"을 추가해도 크기가 2로 남습니다. 그래서 첫 줄은 mutable=3, snapshot=2입니다. 이어서 스냅숏에 add를 호출하면 수정 불가 목록이라 UnsupportedOperationException이 나고, catch가 이를 받아 예외 클래스 이름을 출력합니다. 예외를 처리했으므로 프로그램은 정상적으로 끝나고, 스냅숏의 크기는 여전히 2입니다. 다음 단계별 그림이 이 과정을 보여 줍니다.
원본 추가와 스냅숏 추가의 다른 결과
snapshot을 만든 뒤 mutable에 추가하고 snapshot 추가는 거절되어 main이 계속됩니다. 두 목록의 고정 정체성과 크기/실패 전환을 추적할 수 있습니다.
원본은 3항목으로 늘고 2항목 snapshot은 추가를 거부하며 main이 예외를 처리해 출력한다.
스냅숏 · 두 추가 호출의 결과
원본 추가와 스냅숏 추가의 다른 결과
원본에 추가하면 원본만 3개가 됩니다. 이전 스냅숏의 추가는 거부되고 catch에서 처리되어 프로그램은 정상 종료합니다.
mutable.add("snapshot")
- 호출 뒤 목록 상태
[array, list, snapshot]원본 크기3- 관찰 결과
mutable=3, snapshot=2
snapshot.add("blocked")
- 호출 뒤 목록 상태
[array, list]snapshot 크기2유지- 관찰 결과
추가 거부; catch로 이동
immutable=UnsupportedOperationException
원본 추가가 이전 snapshot을 늘리지 않습니다. snapshot 추가 실패는 여기서 처리되므로 프로그램은 정상 종료합니다.
이 예의 조건
- snapshot.add 실패를 catch하여 main은 정상 종료한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원본 추가는 크기를 3으로 만들지만 기존 snapshot은 2입니다. snapshot.add의 blocked 추가는 UnsupportedOperationException으로 거부되고 catch가 관찰하므로 main은 정상 종료합니다. 실선은 각 호출과 결과의 관계입니다. |
| 5 | array |
| 6 | list |
| 13 | array |
| 14 | list |
| 15 | 크기 2 |
| 20 | Exception |
| 21 | 크기 2 |
| 구분 | 내용 |
|---|---|
| 설명 | 원본 추가는 크기를 3으로 만들지만 기존 snapshot은 2입니다. snapshot.add의 blocked 추가는 UnsupportedOperationException으로 거부되고 catch가 관찰하므로 main은 정상 종료합니다. 실선은 각 호출과 결과의 관계입니다. |
| 전체 읽기 정보 | 원본 추가와 스냅숏 추가의 다른 결과 원본 추가와 스냅숏 추가의 다른 결과 원본은 3항목으로 늘고 2항목 snapshot은 추가를 거부하며 main이 예외를 처리해 출력한다. 스냅숏 · 두 추가 호출의 결과 원본 추가와 스냅숏 추가의 다른 결과 원본에 추가하면 원본만 3개가 됩니다. 이전 스냅숏의 추가는 거부되고 catch에서 처리되어 프로그램은 정상 종료합니다. mutable.add("snapshot") 호출 뒤 목록 상태 [array, list, snapshot] 원본 크기 3 관찰 결과 mutable=3, snapshot=2 snapshot.add("blocked") 호출 뒤 목록 상태 [array, list] snapshot 크기 2 유지 관찰 결과 추가 거부; catch로 이동 immutable=UnsupportedOperationException 원본 추가가 이전 snapshot을 늘리지 않습니다. snapshot 추가 실패는 여기서 처리되므로 프로그램은 정상 종료합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 복사 시점 mutable의 array/list를 snapshot으로 받습니다. |
|
|
| 원본 추가 mutable에 snapshot 값을 추가합니다. |
|
|
| snapshot 쓰기 거절 blocked를 추가하려 하지만 실패합니다. |
|
|
원본 추가와 스냅숏 추가의 다른 결과
snapshot을 만든 뒤 mutable에 추가하고 snapshot 추가는 거절되어 main이 계속됩니다. 두 목록의 고정 정체성과 크기/실패 전환을 추적할 수 있습니다.
원본은 3항목으로 늘고 2항목 snapshot은 추가를 거부하며 main이 예외를 처리해 출력한다.
스냅숏 · 두 추가 호출의 결과
원본 추가와 스냅숏 추가의 다른 결과
원본에 추가하면 원본만 3개가 됩니다. 이전 스냅숏의 추가는 거부되고 catch에서 처리되어 프로그램은 정상 종료합니다.
mutable.add("snapshot")
- 호출 뒤 목록 상태
[array, list, snapshot]원본 크기3- 관찰 결과
mutable=3, snapshot=2
snapshot.add("blocked")
- 호출 뒤 목록 상태
[array, list]snapshot 크기2유지- 관찰 결과
추가 거부; catch로 이동
immutable=UnsupportedOperationException
원본 추가가 이전 snapshot을 늘리지 않습니다. snapshot 추가 실패는 여기서 처리되므로 프로그램은 정상 종료합니다.
이 예의 조건
- snapshot.add 실패를 catch하여 main은 정상 종료한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원본 추가는 크기를 3으로 만들지만 기존 snapshot은 2입니다. snapshot.add의 blocked 추가는 UnsupportedOperationException으로 거부되고 catch가 관찰하므로 main은 정상 종료합니다. 실선은 각 호출과 결과의 관계입니다. |
| 5 | array |
| 6 | list |
| 13 | array |
| 14 | list |
| 15 | 크기 2 |
| 20 | Exception |
| 21 | 크기 2 |
| 구분 | 내용 |
|---|---|
| 설명 | 원본 추가는 크기를 3으로 만들지만 기존 snapshot은 2입니다. snapshot.add의 blocked 추가는 UnsupportedOperationException으로 거부되고 catch가 관찰하므로 main은 정상 종료합니다. 실선은 각 호출과 결과의 관계입니다. |
| 전체 읽기 정보 | 원본 추가와 스냅숏 추가의 다른 결과 원본 추가와 스냅숏 추가의 다른 결과 원본은 3항목으로 늘고 2항목 snapshot은 추가를 거부하며 main이 예외를 처리해 출력한다. 스냅숏 · 두 추가 호출의 결과 원본 추가와 스냅숏 추가의 다른 결과 원본에 추가하면 원본만 3개가 됩니다. 이전 스냅숏의 추가는 거부되고 catch에서 처리되어 프로그램은 정상 종료합니다. mutable.add("snapshot") 호출 뒤 목록 상태 [array, list, snapshot] 원본 크기 3 관찰 결과 mutable=3, snapshot=2 snapshot.add("blocked") 호출 뒤 목록 상태 [array, list] snapshot 크기 2 유지 관찰 결과 추가 거부; catch로 이동 immutable=UnsupportedOperationException 원본 추가가 이전 snapshot을 늘리지 않습니다. snapshot 추가 실패는 여기서 처리되므로 프로그램은 정상 종료합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 복사 시점 mutable의 array/list를 snapshot으로 받습니다. |
|
|
| 원본 추가 mutable에 snapshot 값을 추가합니다. |
|
|
| snapshot 쓰기 거절 blocked를 추가하려 하지만 실패합니다. |
|
|
여기서 try와 catch를 쓴 것은 변경이 거부된다는 사실을 출력으로 확인하려는 것입니다. 실제 코드에서 이 예외를 잡아서 무시하면 안 됩니다. 이 예외가 났다면 수정할 수 없는 목록을 수정하려 한 코드를 고쳐야 합니다.
- 다음 코드 조각을 하나씩 따로 실행한다고 할 때, 정상적으로 끝나는 것과 예외가 나는 것을 고르세요. 예외가 난다면 어떤 예외인지 적으세요.
view는Arrays.asList("x", "y"),fixed는List.of("x", "y")로 만든 목록입니다.
view.set(0, "z"); // (1)
view.remove(0); // (2)
fixed.contains(null); // (3)
new ArrayList<>(view).add(null); // (4)
List.copyOf(Arrays.asList("x", null)); // (5)정답과 해설
(1)은 정상입니다. Arrays.asList의 뷰는 칸을 바꾸는 set을 지원하고, 그 변경은 원본 배열에도 반영됩니다. (2)는 UnsupportedOperationException입니다. 원소를 지우면 크기가 바뀌는데, 배열을 감싼 뷰는 크기를 바꿀 수 없습니다.
(3)은 NullPointerException입니다. 수정 불가 목록은 null 원소를 허용하지 않으므로 null로 조회하는 것도 거부합니다. (4)는 정상입니다. new ArrayList<>(view)는 뷰와 상관없는 가변 목록을 새로 만들고, ArrayList는 null도 담을 수 있습니다. (5)는 NullPointerException입니다. Arrays.asList는 null을 담을 수 있지만, List.copyOf는 null이 든 컬렉션을 복사하지 않습니다.
- 다음 프로그램이 출력하는 세 줄을 예상하고, 첫 줄이 그렇게 나오는 이유를 설명하세요.
import java.util.Arrays;
import java.util.List;
public final class AsListPrimitiveQuiz {
public static void main(String[] args) {
int[] numbers = {30, 40, 50};
Integer[] boxed = {30, 40, 50};
System.out.println(Arrays.asList(numbers).size());
System.out.println(Arrays.asList(boxed).size());
System.out.println(List.of(30, 40, 50).size());
}
}정답과 해설
1
3
3Arrays.asList는 가변 인자로 T...를 받는데, 제네릭 타입 T에는 기본형이 들어갈 수 없습니다. 그래서 int[]를 넘기면 원소 하나하나가 T가 되는 것이 아니라, 배열 전체가 하나의 객체로서 T가 됩니다. 결과는 원소가 int[] 하나뿐인 List<int[]>이고, 크기는 1입니다. Integer[]는 원소가 객체이므로 배열이 그대로 T[]로 쓰여 크기 3인 List<Integer>가 됩니다. List.of(30, 40, 50)은 세 int 값이 각각 Integer로 박싱되어 크기 3인 목록이 됩니다. int[]를 List<Integer>로 바꾸려면 Arrays.stream(numbers).boxed().toList()처럼 원소를 하나씩 박싱해야 합니다.
다음 글
이것으로 선형 자료구조를 직접 구현하는 14장을 마칩니다. 15장의 해시 테이블의 동작 원리에서는 목록과 다른 방법으로 원소를 찾는 해시 자료구조를 다룹니다. 목록에서 값으로 원소를 찾으려면 처음부터 하나씩 비교해야 했지만, 해시는 키를 배열 인덱스로 바꿔 후보를 바로 좁힙니다.