컬렉션 정렬과 불변 변환
List·TreeSet의 정렬 결과와 배열 뷰·복사본의 변경 범위를 실행하고, 불변 반환과 동기화 래퍼의 계약을 구분합니다.
표준 컬렉션 유틸리티는 비슷한 모양의 List를 만들지만 변경 가능성, 원본 공유, null 허용이 다릅니다.
List.of, List.copyOf, Arrays.asList, new ArrayList를 구분하지 않으면 add에서 예외가 나거나 외부 배열 변경이 내부 상태에 번집니다.
정렬도 제자리 변경과 새 결과 생성을 먼저 나눠야 합니다.
Arrays.asList의 변경 범위
배열을 List 뷰로 만든 뒤 set은 가능하지만, 원소를 추가하거나 제거해 크기를 바꾸는 연산은 지원하지 않습니다.
원본 배열의 원소 변경과 List.set은 서로 반영됩니다.
import java.util.Arrays;
import java.util.List;
public final class FixedSizeListAddFailure {
public static void main(String[] args) {
String[] source = {"array", "list"};
List<String> view = Arrays.asList(source);
source[0] = "changed";
System.out.println("view=" + view);
view.add("failure");
}
}배열 변경은 view에 보이지만 add는 실패한다
asList 뷰가 source 슬롯 변경을 관찰하고 출력한 뒤 add에서 실패합니다. 공유 반영과 크기 변경 거부의 실제 시간 경계가 핵심입니다.
원본 배열의 첫 칸을 changed로 바꾸면 view에도 반영되어 출력됩니다. 고정 크기 뷰에 failure를 추가하는 다음 호출은 예외로 끝납니다.
ARRAYS.ASLIST · 고정 크기 뷰
배열 변경은 view에 보이지만 add는 실패한다
배열 원소 교체는 view에도 보입니다. 원소 수를 늘리는 add는 예외로 끝납니다.
실선: 원 실행 순서추가 뒤 새 목록은 없음set 가능과 add 가능은 별개
1 · 뷰 생성
- 배열과 view · 소스 추적
source=[array, list]view=[array, list]- 실제 실행 결과
- 출력 없음
2 · 배열 칸 교체 뒤 출력
- 배열과 view · 소스 추적
source=[changed, list]view=[changed, list]- 실제 실행 결과
view=[changed, list]
- 3 · 추가 시도
view.add("failure")- 배열과 view · 소스 추적
source=[changed, list]view=[changed, list]- 실제 실행 결과
UnsupportedOperationException
두 이름은 같은 배열의 원소 변경을 관찰합니다. 가운데 열은 소스 추적이고 실제 표준 출력은 view=[changed, list] 한 줄입니다. 그 뒤의 add가 실패하며, 원소를 추가한 결과 목록은 만들어지지 않습니다.
이 예의 조건
- Arrays.asList 뷰는 set 가능과 add 가능이 다르다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 15 | Exception |
| 16 | 종료 |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 배열 변경은 view에 보이지만 add는 실패한다 배열 변경은 view에 보이지만 add는 실패한다 원본 배열의 첫 칸을 changed로 바꾸면 view에도 반영되어 출력됩니다. 고정 크기 뷰에 failure를 추가하는 다음 호출은 예외로 끝납니다. ARRAYS.ASLIST · 고정 크기 뷰 배열 변경은 view에 보이지만 add는 실패한다 배열 원소 교체는 view에도 보입니다. 원소 수를 늘리는 add는 예외로 끝납니다. 1 · 뷰 생성 배열과 view · 소스 추적 source=[array, list] view=[array, list] 실제 실행 결과 출력 없음 2 · 배열 칸 교체 뒤 출력 배열과 view · 소스 추적 source=[changed, list] view=[changed, list] 실제 실행 결과 view=[changed, list] 3 · 추가 시도 view.add("failure") 배열과 view · 소스 추적 source=[changed, list] view=[changed, list] 실제 실행 결과 UnsupportedOperationException 두 이름은 같은 배열의 원소 변경을 관찰합니다. 가운데 열은 소스 추적이고 실제 표준 출력은 view=[changed, list] 한 줄입니다. 그 뒤의 add 가 실패하며, 원소를 추가한 결과 목록은 만들어지지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 배열 뷰 source와 view가 같은 두 칸을 봅니다. |
|
|
| 원 슬롯 교체 source[0]을 changed로 바꾸고 view를 출력합니다. |
|
|
| 크기 변경 실패 view.add(failure)는 예외로 끝납니다. |
|
|
배열 변경은 view에 보이지만 add는 실패한다
asList 뷰가 source 슬롯 변경을 관찰하고 출력한 뒤 add에서 실패합니다. 공유 반영과 크기 변경 거부의 실제 시간 경계가 핵심입니다.
원본 배열의 첫 칸을 changed로 바꾸면 view에도 반영되어 출력됩니다. 고정 크기 뷰에 failure를 추가하는 다음 호출은 예외로 끝납니다.
ARRAYS.ASLIST · 고정 크기 뷰
배열 변경은 view에 보이지만 add는 실패한다
배열 원소 교체는 view에도 보입니다. 원소 수를 늘리는 add는 예외로 끝납니다.
실선: 원 실행 순서추가 뒤 새 목록은 없음set 가능과 add 가능은 별개
1 · 뷰 생성
- 배열과 view · 소스 추적
source=[array, list]view=[array, list]- 실제 실행 결과
- 출력 없음
2 · 배열 칸 교체 뒤 출력
- 배열과 view · 소스 추적
source=[changed, list]view=[changed, list]- 실제 실행 결과
view=[changed, list]
- 3 · 추가 시도
view.add("failure")- 배열과 view · 소스 추적
source=[changed, list]view=[changed, list]- 실제 실행 결과
UnsupportedOperationException
두 이름은 같은 배열의 원소 변경을 관찰합니다. 가운데 열은 소스 추적이고 실제 표준 출력은 view=[changed, list] 한 줄입니다. 그 뒤의 add가 실패하며, 원소를 추가한 결과 목록은 만들어지지 않습니다.
이 예의 조건
- Arrays.asList 뷰는 set 가능과 add 가능이 다르다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 15 | Exception |
| 16 | 종료 |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 배열 변경은 view에 보이지만 add는 실패한다 배열 변경은 view에 보이지만 add는 실패한다 원본 배열의 첫 칸을 changed로 바꾸면 view에도 반영되어 출력됩니다. 고정 크기 뷰에 failure를 추가하는 다음 호출은 예외로 끝납니다. ARRAYS.ASLIST · 고정 크기 뷰 배열 변경은 view에 보이지만 add는 실패한다 배열 원소 교체는 view에도 보입니다. 원소 수를 늘리는 add는 예외로 끝납니다. 1 · 뷰 생성 배열과 view · 소스 추적 source=[array, list] view=[array, list] 실제 실행 결과 출력 없음 2 · 배열 칸 교체 뒤 출력 배열과 view · 소스 추적 source=[changed, list] view=[changed, list] 실제 실행 결과 view=[changed, list] 3 · 추가 시도 view.add("failure") 배열과 view · 소스 추적 source=[changed, list] view=[changed, list] 실제 실행 결과 UnsupportedOperationException 두 이름은 같은 배열의 원소 변경을 관찰합니다. 가운데 열은 소스 추적이고 실제 표준 출력은 view=[changed, list] 한 줄입니다. 그 뒤의 add 가 실패하며, 원소를 추가한 결과 목록은 만들어지지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 배열 뷰 source와 view가 같은 두 칸을 봅니다. |
|
|
| 원 슬롯 교체 source[0]을 changed로 바꾸고 view를 출력합니다. |
|
|
| 크기 변경 실패 view.add(failure)는 예외로 끝납니다. |
|
|
출력 뒤 UnsupportedOperationException이 발생합니다.
독립된 가변 목록이 목적이면 new ArrayList<>(Arrays.asList(source)), 불변 스냅샷이면 List.copyOf(Arrays.asList(source))를 사용합니다.
수정 불가와 null 허용은 서로 다른 조건입니다. Arrays.asList는 null 원소를 보관할 수 있지만 List.copyOf와 List.of는 null 원소를 거부합니다. Stream.toList의 수정 불가 결과와도 이 경계를 구별해야 합니다.
예시
import java.util.Arrays;
import java.util.List;
public final class ListNullBoundary {
public static void main(String[] args) {
List<String> view = Arrays.asList("array", null);
List<String> streamResult = view.stream().toList();
System.out.println("null-kept=" + (streamResult.get(1) == null));
try { List.copyOf(view); }
catch (NullPointerException expected) { System.out.println("copy rejects null"); }
try { streamResult.set(0, "changed"); }
catch (UnsupportedOperationException expected) { System.out.println("stream result rejects set"); }
}
}출력은 null-kept=true, copy rejects null, stream result rejects set입니다. null을 보존하는 것과 set으로 내용을 바꿀 수 있는 것은 별도 규칙입니다. 반환 전에 List.copyOf를 사용한다면 입력에 null이 없는지도 API 조건으로 알려야 합니다.
빈 컬렉션의 값 의미
null List는 호출자마다 분기를 요구하고 “결과 없음”과 “계산 안 함”을 섞습니다.
결과가 0개라면 List.of(), Set.of(), Map.of() 같은 빈 불변 컬렉션을 반환합니다.
호출자는 안전하게 size와 for-each를 사용할 수 있습니다.
Collections.emptyList()도 불변 빈 List이며 제네릭 문맥에서 타입을 추론합니다.
매번 새 가변 ArrayList를 반환하면 호출자가 수정 가능한 규칙으로 오해할 수 있으므로 API 의도를 선택합니다.
제자리 정렬과 불변 정렬 결과 구분
가변 ArrayList의 정렬은 같은 객체의 순서를 바꿉니다.
List.of()로 만든 목록에 정렬을 호출하면 UnsupportedOperationException이 발생합니다.
원본을 보존하려면 스트림의 sorted() 또는 가변 복사본을 사용합니다.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public final class SortingAndCopyContracts {
public static void main(String[] args) {
List<Entry> source =
List.of(new Entry("thread", 60), new Entry("array", 40), new Entry("hash", 50));
Comparator<Entry> byViewCount = Comparator.comparingInt(Entry::viewCount);
List<Entry> immutableSorted = source.stream().sorted(byViewCount).toList();
List<Entry> mutableSorted = new ArrayList<>(source);
mutableSorted.sort(byViewCount.reversed());
System.out.println("source=" + source);
System.out.println("asc=" + immutableSorted);
System.out.println("desc=" + mutableSorted);
mutableSorted.add(new Entry("queue", 30));
System.out.println("mutable-size=" + mutableSorted.size());
}
private record Entry(String title, int viewCount) {}
}원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다
원본과 두 정렬 결과를 관찰한 뒤 mutableSorted에만 queue를 추가합니다. 마지막 추가·size 변화와 재정렬 없음의 시점을 구별해 수정 범위를 확인할 수 있습니다.
원본의 thread 60, array 40, hash 50 순서는 유지됩니다. 오름차순과 내림차순 결과는 서로 다르고 queue 30 추가 뒤에는 가변 목록의 크기 4만 출력됩니다.
정렬 결과 · 변경 범위
원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다
원본·오름차순·내림차순을 따로 보고, queue 추가 뒤에는 가변 목록의 크기 4만 출력합니다.
| 출력 이름·시점 | 출력의 값·원소 순서 | 확인할 변경 범위 |
|---|---|---|
source | thread · 60array · 40hash · 50 | 원본 순서 유지 |
asc | array · 40hash · 50thread · 60 | immutableSorted조회수 오름차순 |
desc | thread · 60hash · 50array · 40 | mutableSorted조회수 내림차순 |
mutable-size원소 추가 뒤 | 4 | queue · 30가변 복사본에 추가 |
source- 출력의 값·원소 순서
thread · 60array · 40hash · 50- 확인할 변경 범위
- 원본 순서 유지
asc- 출력의 값·원소 순서
array · 40hash · 50thread · 60- 확인할 변경 범위
immutableSorted조회수 오름차순
desc- 출력의 값·원소 순서
thread · 60hash · 50array · 40- 확인할 변경 범위
mutableSorted조회수 내림차순
mutable-size원소 추가 뒤- 출력의 값·원소 순서
4- 확인할 변경 범위
queue · 30가변 복사본에 추가
원소는 Entry의 title · viewCount로 줄여 적었습니다. desc는 queue 추가 전 출력입니다. Stream.toList() 결과는 수정 불가라는 API 계약이며, 이 main은 그 결과를 변경하려고 시도하지 않습니다.
이 예의 조건
- 세 목록은 별도 구조이며 queue 추가 뒤 재정렬하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 세 영역은 source,immutableSorted,mutableSorted입니다. 원 순서와 오름·내림 결과를 따로 보존하고 queue 30은 desc 출력 뒤 가변 목록에만 추가하여 size 4입니다. 추가 후 재정렬을 실행하지 않습니다. |
| 6 | array40 |
| 11 | 조회 수 오름차순 |
| 12 | array40 |
| 18 | 조회 수 내림차순 |
| 21 | array40 |
| 22 | 출력 시점 |
| 23 | 그 뒤 queue 30 추가 |
| 24 | 이 목록만 size 4 |
| 구분 | 내용 |
|---|---|
| 설명 | 세 영역은 source,immutableSorted,mutableSorted입니다. 원 순서와 오름·내림 결과를 따로 보존하고 queue 30은 desc 출력 뒤 가변 목록에만 추가하여 size 4입니다. 추가 후 재정렬을 실행하지 않습니다. |
| 전체 읽기 정보 | 원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다 원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다 원본의 thread 60, array 40, hash 50 순서는 유지됩니다. 오름차순과 내림차순 결과는 서로 다르고 queue 30 추가 뒤에는 가변 목록의 크기 4만 출력됩니다. 정렬 결과 · 변경 범위 원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다 원본·오름차순·내림차순을 따로 보고, queue 추가 뒤에는 가변 목록의 크기 4만 출력합니다. 원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다 출력 이름·시점 출력의 값·원소 순서 확인할 변경 범위 source thread · 60 array · 40 hash · 50 원본 순서 유지 asc array · 40 hash · 50 thread · 60 immutableSorted 조회 수 오름차순 desc thread · 60 hash · 50 array · 40 mutableSorted 조회 수 내림차순 mutable-size 원소 추가 뒤 4 queue · 30 가변 복사본에 추가 source 출력의 값·원소 순서 thread · 60 array · 40 hash · 50 확인할 변경 범위 원본 순서 유지 asc 출력의 값·원소 순서 array · 40 hash · 50 thread · 60 확인할 변경 범위 immutableSorted 조회 수 오름차순 desc 출력의 값·원소 순서 thread · 60 hash · 50 array · 40 확인할 변경 범위 mutableSorted 조회 수 내림차순 mutable-size 원소 추가 뒤 출력의 값·원소 순서 4 확인할 변경 범위 queue · 30 가변 복사본에 추가 원소는 Entry 의 title · viewCount 로 줄여 적었습니다. desc 는 queue 추가 전 출력입니다. Stream.toList() 결과는 수정 불가라는 API 계약이며, 이 main은 그 결과를 변경하려고 시도하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원본 source의 등록 순서를 보관합니다. |
|
|
| 두 정렬 결과 오름차순의 수정 불가 결과와 내림차순의 가변 결과를 얻습니다. |
|
|
| 가변 결과 추가 desc 출력 뒤 queue 30을 추가합니다. |
|
|
원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다
원본과 두 정렬 결과를 관찰한 뒤 mutableSorted에만 queue를 추가합니다. 마지막 추가·size 변화와 재정렬 없음의 시점을 구별해 수정 범위를 확인할 수 있습니다.
원본의 thread 60, array 40, hash 50 순서는 유지됩니다. 오름차순과 내림차순 결과는 서로 다르고 queue 30 추가 뒤에는 가변 목록의 크기 4만 출력됩니다.
정렬 결과 · 변경 범위
원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다
원본·오름차순·내림차순을 따로 보고, queue 추가 뒤에는 가변 목록의 크기 4만 출력합니다.
| 출력 이름·시점 | 출력의 값·원소 순서 | 확인할 변경 범위 |
|---|---|---|
source | thread · 60array · 40hash · 50 | 원본 순서 유지 |
asc | array · 40hash · 50thread · 60 | immutableSorted조회수 오름차순 |
desc | thread · 60hash · 50array · 40 | mutableSorted조회수 내림차순 |
mutable-size원소 추가 뒤 | 4 | queue · 30가변 복사본에 추가 |
source- 출력의 값·원소 순서
thread · 60array · 40hash · 50- 확인할 변경 범위
- 원본 순서 유지
asc- 출력의 값·원소 순서
array · 40hash · 50thread · 60- 확인할 변경 범위
immutableSorted조회수 오름차순
desc- 출력의 값·원소 순서
thread · 60hash · 50array · 40- 확인할 변경 범위
mutableSorted조회수 내림차순
mutable-size원소 추가 뒤- 출력의 값·원소 순서
4- 확인할 변경 범위
queue · 30가변 복사본에 추가
원소는 Entry의 title · viewCount로 줄여 적었습니다. desc는 queue 추가 전 출력입니다. Stream.toList() 결과는 수정 불가라는 API 계약이며, 이 main은 그 결과를 변경하려고 시도하지 않습니다.
이 예의 조건
- 세 목록은 별도 구조이며 queue 추가 뒤 재정렬하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 세 영역은 source,immutableSorted,mutableSorted입니다. 원 순서와 오름·내림 결과를 따로 보존하고 queue 30은 desc 출력 뒤 가변 목록에만 추가하여 size 4입니다. 추가 후 재정렬을 실행하지 않습니다. |
| 6 | array40 |
| 11 | 조회 수 오름차순 |
| 12 | array40 |
| 18 | 조회 수 내림차순 |
| 21 | array40 |
| 22 | 출력 시점 |
| 23 | 그 뒤 queue 30 추가 |
| 24 | 이 목록만 size 4 |
| 구분 | 내용 |
|---|---|
| 설명 | 세 영역은 source,immutableSorted,mutableSorted입니다. 원 순서와 오름·내림 결과를 따로 보존하고 queue 30은 desc 출력 뒤 가변 목록에만 추가하여 size 4입니다. 추가 후 재정렬을 실행하지 않습니다. |
| 전체 읽기 정보 | 원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다 원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다 원본의 thread 60, array 40, hash 50 순서는 유지됩니다. 오름차순과 내림차순 결과는 서로 다르고 queue 30 추가 뒤에는 가변 목록의 크기 4만 출력됩니다. 정렬 결과 · 변경 범위 원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다 원본·오름차순·내림차순을 따로 보고, queue 추가 뒤에는 가변 목록의 크기 4만 출력합니다. 원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다 출력 이름·시점 출력의 값·원소 순서 확인할 변경 범위 source thread · 60 array · 40 hash · 50 원본 순서 유지 asc array · 40 hash · 50 thread · 60 immutableSorted 조회 수 오름차순 desc thread · 60 hash · 50 array · 40 mutableSorted 조회 수 내림차순 mutable-size 원소 추가 뒤 4 queue · 30 가변 복사본에 추가 source 출력의 값·원소 순서 thread · 60 array · 40 hash · 50 확인할 변경 범위 원본 순서 유지 asc 출력의 값·원소 순서 array · 40 hash · 50 thread · 60 확인할 변경 범위 immutableSorted 조회 수 오름차순 desc 출력의 값·원소 순서 thread · 60 hash · 50 array · 40 확인할 변경 범위 mutableSorted 조회 수 내림차순 mutable-size 원소 추가 뒤 출력의 값·원소 순서 4 확인할 변경 범위 queue · 30 가변 복사본에 추가 원소는 Entry 의 title · viewCount 로 줄여 적었습니다. desc 는 queue 추가 전 출력입니다. Stream.toList() 결과는 수정 불가라는 API 계약이며, 이 main은 그 결과를 변경하려고 시도하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원본 source의 등록 순서를 보관합니다. |
|
|
| 두 정렬 결과 오름차순의 수정 불가 결과와 내림차순의 가변 결과를 얻습니다. |
|
|
| 가변 결과 추가 desc 출력 뒤 queue 30을 추가합니다. |
|
|
Stream.toList() 결과는 수정할 수 없는 List입니다. 이 main은 그 결과의 변경을 시도하지 않고 별도 ArrayList 복사본에만 원소를 추가합니다.
가변 결과가 필요하면 수집기 또는 ArrayList 생성자로 복사합니다.
List.copyOf는 입력이 이미 적합한 불변 List일 때 같은 인스턴스를 반환할 수 있으므로 “항상 새 객체”를 규칙으로 삼지 않습니다.
원소가 가변 객체라면 수정 불가 목록 안에서도 그 객체의 필드는 바뀔 수 있습니다. 아래에서는 목록의 칸 교체와 원소 필드 변경을 나눠 확인합니다.
예시
실행 버전: 다음 예제의 List.getFirst()는 Java 21에 추가된 API입니다. 이 예제를 그대로 실행하려면 JDK 21 이상을 사용하세요. 비어 있지 않은 List에서는 첫 원소를 읽는 get(0)과 같은 결과지만, 빈 목록이면 NoSuchElementException이 발생합니다.
import java.util.ArrayList;
import java.util.List;
public final class ShallowSnapshotBoundary {
public static void main(String[] args) {
Box first = new Box(40);
List<Box> source = new ArrayList<>(List.of(first));
List<Box> snapshot = List.copyOf(source);
first.count = 55;
source.set(0, new Box(70));
System.out.println("source=" + source.getFirst().count);
System.out.println("snapshot=" + snapshot.getFirst().count);
System.out.println("shared-first=" + (snapshot.getFirst() == first));
}
private static final class Box {
int count;
Box(int count) { this.count = count; }
}
}출력은 source=70, snapshot=55, shared-first=true입니다. source의 첫 칸을 새 Box로 바꿔도 snapshot은 옛 Box를 가리킵니다. 그러나 복사 전에 공유한 그 Box의 40→55 변경은 snapshot에서도 보입니다. 원문의 Entry는 불변 값 필드를 가진 record라 이 추가 예의 가변 Box와 조건이 다릅니다.
TreeSet·List의 중복 처리
List.sort()는 모든 원소를 보존하고 순서만 바꿉니다.
TreeSet은 비교 결과가 0인 원소를 하나만 남깁니다.
정렬된 보고서에 중복 게시글이 모두 필요하면 List를 정렬해야 합니다.
고유 정렬 키 집합과 범위 조회가 목적이면 TreeSet이 맞습니다.
Collections.min·max는 비교 규칙에 따라 전체 원소를 검사하므로 미리 정렬할 필요가 없습니다. binarySearch에는 검색에 사용할 비교 규칙으로 정렬된 List가 필요합니다.
binarySearch에 정렬되지 않은 List를 넣은 결과는 믿을 수 없습니다.
검색 결과의 뜻: 정렬과 검색에는 같은 순서를 만드는 비교 규칙을 사용해야 합니다. 비교자 객체가 같은 인스턴스일 필요는 없습니다. 성공하면 비교 결과가 0인 원소의 인덱스를 돌려주며, 동점이 여러 개라면 어느 인덱스인지는 보장하지 않습니다. 실패하면 반환값은 -(삽입 위치)-1입니다. 예를 들어 [80, 90, 90]에서 85의 삽입 위치는 0부터 센 1이므로 결과는 -2입니다. 점수만 비교하는 검색으로 특정 제목까지 찾았다고 판단하면 안 됩니다. binarySearch의 전제와 반환값을 함께 확인하세요.
정렬 비교자와 검색 비교자도 같아야 합니다.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
public final class ListAndTreeSortingDifference {
public static void main(String[] args) {
Comparator<Entry> byScore = Comparator.comparingInt(Entry::score).reversed();
List<Entry> list =
new ArrayList<>(
List.of(
new Entry("hash", 90),
new Entry("set", 90),
new Entry("queue", 80)));
list.sort(byScore);
Set<Entry> tree = new TreeSet<>(byScore);
tree.addAll(list);
System.out.println("list-size=" + list.size() + " " + list);
System.out.println("tree-size=" + tree.size() + " " + tree);
}
private record Entry(String title, int score) {}
}List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다
List 정렬은 동률 둘을 유지하고 같은 순서 addAll한 TreeSet은 두 번째 90을 거부합니다. 정렬 뒤 등록 과정을 단계로 강조하면 교체가 아닌 거부가 명확합니다.
같은 점수 내림차순 비교자로 List를 정렬하면 hash 90, set 90, queue 80이 모두 남습니다. 이 순서로 TreeSet에 넣으면 set은 기존 hash와 비교 결과가 0이어서 두 개만 남습니다.
LIST / TREESET · 동점의 의미
List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다
정렬 List는 동점 원소를 보존합니다. TreeSet은 비교 결과 0인 두 원소 중 먼저 넣은 hash를 보관합니다.
Listsort(byScore)
Listsort(byScore)- 최종 출력의 원소 순서
hash · 90set · 90queue · 80- 크기·동점 처리
list-size=3동점의 기존 상대 순서 유지
TreeSetaddAll(list)
TreeSetaddAll(list)- 최종 출력의 원소 순서
hash · 90queue · 80- 크기·동점 처리
tree-size=2먼저 전달된 hash 유지
원소는 Entry의 title · score로 줄여 적었습니다. hash와 set은 equals로는 다르지만 이 점수 비교 결과는 0입니다. 이 main은 정렬된 List에서 hash를 먼저 전달하므로, TreeSet의 대표 원소가 hash로 남습니다.
이 예의 조건
- TreeSet 비교 결과 0은 equals와 다른 조건이며 새 원소로 교체하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | List는 hash 90,set 90,queue 80의 세 원소와 동률의 상대 순서를 유지합니다. 이 순서로 TreeSet에 넣으면 set 90은 compare 결과 0으로 거부되어 hash 90,queue 80과 size 2가 남습니다. 새 원소가 기존 원소를 교체한 것이 아닙니다. |
| 5 | hash 90 |
| 6 | set 90 |
| 7 | queue 80 |
| 8 | 점수 내림차순 |
| 10 | hash 90 |
| 11 | set 90 |
| 12 | queue 80 |
| 13 | 동률의 기존 순서 |
| 14 | size3 |
| 17 | hash 90을 먼저 등록 |
| 18 | set 90은 compare 결과 0 |
| 20 | queue 80만 별도 |
| 21 | size 2 |
| 24 | set 추가는 거부 |
| 25 | 교체 아님 |
| 구분 | 내용 |
|---|---|
| 설명 | List는 hash 90,set 90,queue 80의 세 원소와 동률의 상대 순서를 유지합니다. 이 순서로 TreeSet에 넣으면 set 90은 compare 결과 0으로 거부되어 hash 90,queue 80과 size 2가 남습니다. 새 원소가 기존 원소를 교체한 것이 아닙니다. |
| 전체 읽기 정보 | List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다 List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다 같은 점수 내림차순 비교자로 List를 정렬하면 hash 90, set 90, queue 80이 모두 남습니다. 이 순서로 TreeSet에 넣으면 set은 기존 hash와 비교 결과가 0이어서 두 개만 남습니다. LIST / TREESET · 동점의 의미 List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다 정렬 List는 동점 원소를 보존합니다. TreeSet은 비교 결과 0인 두 원소 중 먼저 넣은 hash를 보관합니다. List sort(byScore) 최종 출력의 원소 순서 hash · 90 set · 90 queue · 80 크기·동점 처리 list-size=3 동점의 기존 상대 순서 유지 TreeSet addAll(list) 최종 출력의 원소 순서 hash · 90 queue · 80 크기·동점 처리 tree-size=2 먼저 전달된 hash 유지 원소는 Entry 의 title · score 로 줄여 적었습니다. hash 와 set 은 equals 로는 다르지만 이 점수 비교 결과는 0입니다. 이 main은 정렬된 List에서 hash 를 먼저 전달하므로, TreeSet의 대표 원소가 hash 로 남습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| List 안정 정렬 동률 hash 90/set 90과 queue 80을 모두 보관합니다. |
|
|
| 동률 Set 삽입 hash 90 먼저 등록 뒤 set 90은 compare 결과 0으로 거절합니다. |
|
|
| 다른 점수 삽입 queue 80을 추가합니다. |
|
|
List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다
List 정렬은 동률 둘을 유지하고 같은 순서 addAll한 TreeSet은 두 번째 90을 거부합니다. 정렬 뒤 등록 과정을 단계로 강조하면 교체가 아닌 거부가 명확합니다.
같은 점수 내림차순 비교자로 List를 정렬하면 hash 90, set 90, queue 80이 모두 남습니다. 이 순서로 TreeSet에 넣으면 set은 기존 hash와 비교 결과가 0이어서 두 개만 남습니다.
LIST / TREESET · 동점의 의미
List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다
정렬 List는 동점 원소를 보존합니다. TreeSet은 비교 결과 0인 두 원소 중 먼저 넣은 hash를 보관합니다.
Listsort(byScore)
Listsort(byScore)- 최종 출력의 원소 순서
hash · 90set · 90queue · 80- 크기·동점 처리
list-size=3동점의 기존 상대 순서 유지
TreeSetaddAll(list)
TreeSetaddAll(list)- 최종 출력의 원소 순서
hash · 90queue · 80- 크기·동점 처리
tree-size=2먼저 전달된 hash 유지
원소는 Entry의 title · score로 줄여 적었습니다. hash와 set은 equals로는 다르지만 이 점수 비교 결과는 0입니다. 이 main은 정렬된 List에서 hash를 먼저 전달하므로, TreeSet의 대표 원소가 hash로 남습니다.
이 예의 조건
- TreeSet 비교 결과 0은 equals와 다른 조건이며 새 원소로 교체하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | List는 hash 90,set 90,queue 80의 세 원소와 동률의 상대 순서를 유지합니다. 이 순서로 TreeSet에 넣으면 set 90은 compare 결과 0으로 거부되어 hash 90,queue 80과 size 2가 남습니다. 새 원소가 기존 원소를 교체한 것이 아닙니다. |
| 5 | hash 90 |
| 6 | set 90 |
| 7 | queue 80 |
| 8 | 점수 내림차순 |
| 10 | hash 90 |
| 11 | set 90 |
| 12 | queue 80 |
| 13 | 동률의 기존 순서 |
| 14 | size3 |
| 17 | hash 90을 먼저 등록 |
| 18 | set 90은 compare 결과 0 |
| 20 | queue 80만 별도 |
| 21 | size 2 |
| 24 | set 추가는 거부 |
| 25 | 교체 아님 |
| 구분 | 내용 |
|---|---|
| 설명 | List는 hash 90,set 90,queue 80의 세 원소와 동률의 상대 순서를 유지합니다. 이 순서로 TreeSet에 넣으면 set 90은 compare 결과 0으로 거부되어 hash 90,queue 80과 size 2가 남습니다. 새 원소가 기존 원소를 교체한 것이 아닙니다. |
| 전체 읽기 정보 | List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다 List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다 같은 점수 내림차순 비교자로 List를 정렬하면 hash 90, set 90, queue 80이 모두 남습니다. 이 순서로 TreeSet에 넣으면 set은 기존 hash와 비교 결과가 0이어서 두 개만 남습니다. LIST / TREESET · 동점의 의미 List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다 정렬 List는 동점 원소를 보존합니다. TreeSet은 비교 결과 0인 두 원소 중 먼저 넣은 hash를 보관합니다. List sort(byScore) 최종 출력의 원소 순서 hash · 90 set · 90 queue · 80 크기·동점 처리 list-size=3 동점의 기존 상대 순서 유지 TreeSet addAll(list) 최종 출력의 원소 순서 hash · 90 queue · 80 크기·동점 처리 tree-size=2 먼저 전달된 hash 유지 원소는 Entry 의 title · score 로 줄여 적었습니다. hash 와 set 은 equals 로는 다르지만 이 점수 비교 결과는 0입니다. 이 main은 정렬된 List에서 hash 를 먼저 전달하므로, TreeSet의 대표 원소가 hash 로 남습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| List 안정 정렬 동률 hash 90/set 90과 queue 80을 모두 보관합니다. |
|
|
| 동률 Set 삽입 hash 90 먼저 등록 뒤 set 90은 compare 결과 0으로 거절합니다. |
|
|
| 다른 점수 삽입 queue 80을 추가합니다. |
|
|
이 예제의 TreeSet에는 먼저 들어간 hash와 queue가 남아 size=2입니다. 같은 90점인 set은 hash와 비교 결과가 0이어서 추가되지 않습니다.
제목 tie-breaker를 추가하거나 List를 유지해야 합니다.
동점과 동일 객체를 구분하세요. 위 Entry의 equals는 제목과 점수를 모두 비교하지만, 점수만 보는 비교자는 다른 제목에도 0을 반환합니다. 이 TreeSet은 그런 비교자에서 어떻게 원소를 남기는지 보여 주는 예제이며, 일반 Set의 equals 기반 동일성 계약과는 일치하지 않습니다. 제목도 보존할 집합이라면 Comparator.comparingInt(Entry::score).thenComparing(Entry::title)처럼 두 필드를 함께 비교하세요. 이 예제의 제목은 null이 아니며 String의 자연 순서를 사용합니다. 같은 점수·같은 제목까지 모두 중복 보관하려면 집합 대신 List를 선택합니다. TreeSet의 비교와 동일성 계약을 기준으로 자료구조의 목적을 먼저 정하세요.
정렬의 안정성: List.sort는 비교 결과가 0인 원소끼리 기존 순서를 유지합니다. 따라서 위 목록에서 먼저 들어간 hash가 같은 90점인 set보다 앞섭니다. 순서가 있는 List.stream().sorted(...)도 이 성질을 보장하지만, 순서가 없는 스트림의 동점 순서까지 보장하지는 않습니다. 안정성은 원소를 제거하는 규칙이 아니므로 목록의 크기는 그대로 3입니다.
이 차이는 성능보다 먼저 확인할 기능 규칙입니다.
동기화 래퍼와 복합 원자성
Collections.synchronizedList는 개별 메서드 호출을 동기화합니다.
if (!list.contains(x)) list.add(x) 두 호출 전체는 하나의 잠금 구간이 아니므로 다른 스레드가 사이에 들어올 수 있습니다.
중복 없는 동시 저장이면 동시성 Set 등 목적에 맞는 구조를 사용합니다.
래퍼를 순회할 때는 문서대로 해당 컬렉션 객체를 synchronized 블록으로 잠가야 합니다.
Iterator 자체가 전체 순회를 잠그지 않습니다.
중복 검사 두 호출의 틈을 예로 들면, 빈 목록에서 A 스레드 contains(x)=false, B도 contains(x)=false, 그 뒤 A와 B가 각각 add(x)를 실행할 수 있습니다. 각 메서드가 보호되어도 목록은 [x, x]가 됩니다. 한 잠금으로 contains부터 add까지 묶는 별도 정책이 필요한 이유입니다. 이 순서는 가능한 실행 모델이며 여기서 실제 스레드 경합을 재현한 것은 아닙니다.
이 장에서는 규칙만 확인하고 실제 가시성과 경합은 19장에서 다룹니다.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public final class BoardSnapshotService {
public static void main(String[] args) {
Service service = new Service();
service.add("hash", 50);
service.add("array", 40);
List<Entry> first = service.ranking();
service.add("thread", 60);
System.out.println("first=" + first);
System.out.println("current=" + service.ranking());
}
private static final class Service {
private final List<Entry> entries = new ArrayList<>();
void add(String title, int viewCount) {
entries.add(new Entry(title, viewCount));
}
List<Entry> ranking() {
return entries.stream()
.sorted(Comparator.comparingInt(Entry::viewCount).reversed())
.toList();
}
}
private record Entry(String title, int viewCount) {}
}첫 ranking은 이후 add의 영향을 받지 않는 결과 List입니다.
내부 ArrayList 소유권을 노출하지 않고 표현 시점에 정렬합니다.
스냅샷과 동시 접근은 별개입니다. BoardSnapshotService는 한 스레드에서 추가하고 조회하는 예제입니다. ranking()이 독립적인 결과 목록을 반환해도 내부 ArrayList의 쓰기와 정렬용 순회를 여러 스레드에서 동시에 수행해도 안전해지는 것은 아닙니다. 그런 서비스에는 원본을 읽고 변경하는 동안의 접근 규칙이 추가로 필요합니다.
연습 문제
원본 String 배열을 바꾸어도 영향받지 않고 add와 정렬이 가능한 List를 만드세요.
목록 변경도 원본 배열에 반영되면 안 됩니다.
정답과 해설
Arrays.asList 뷰를 ArrayList 생성자에 전달하면 원소 참조를 새 내부 배열에 복사합니다.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public final class IndependentMutableListSolution {
public static void main(String[] args) {
String[] source = {"thread", "array"};
List<String> values = new ArrayList<>(Arrays.asList(source));
source[0] = "changed";
values.add("hash");
values.sort(String::compareTo);
System.out.println("source=" + Arrays.toString(source));
System.out.println("values=" + values);
}
}배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다
배열에서 목록 복사 뒤 source[0]만 changed로 바꾸고 별도 values에 hash 추가·정렬합니다. 두 컨테이너의 분리된 전후 상태를 추적합니다.
ArrayList 생성자가 배열 뷰의 원소 참조를 복사한 뒤 source 첫 칸을 changed로 교체합니다. values에는 원래 thread가 남고 hash를 더해 array, hash, thread 순서로 정렬됩니다.
배열 뷰와 복사본
배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다
ArrayList로 먼저 복사하면 배열 칸 교체와 목록의 추가·정렬이 서로 영향을 주지 않습니다.
source
- 실제 출력
source=[changed, array]- 그 컨테이너의 변경
source[0] = "changed"배열의 첫 칸 교체
values
- 실제 출력
values=[array, hash, thread]- 그 컨테이너의 변경
add("hash")sort(String::compareTo)
ArrayList 생성자는 Arrays.asList(source)의 원소 참조를 먼저 복사합니다. 뒤의 배열 칸 교체는 values의 thread를 바꾸지 않고, 목록의 추가·정렬도 원본 배열에 반영되지 않습니다. 원소 자체를 깊게 복사하는 예제는 아닙니다.
이 예의 조건
- String 원소의 참조 복사이며 깊은 복사가 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 6 | array |
| 8 | changed |
| 9 | array |
| 14 | array |
| 16 | array |
| 17 | hash |
| 22 | String은 불변 |
| 23 | 깊은 복사 아님 |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다 배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다 ArrayList 생성자가 배열 뷰의 원소 참조를 복사한 뒤 source 첫 칸을 changed로 교체합니다. values에는 원래 thread가 남고 hash를 더해 array, hash, thread 순서로 정렬됩니다. 배열 뷰와 복사본 배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다 ArrayList로 먼저 복사하면 배열 칸 교체와 목록의 추가·정렬이 서로 영향을 주지 않습니다. source 실제 출력 source=[changed, array] 그 컨테이너의 변경 source[0] = "changed" 배열의 첫 칸 교체 values 실제 출력 values=[array, hash, thread] 그 컨테이너의 변경 add("hash") sort(String::compareTo) ArrayList 생성자는 Arrays.asList(source) 의 원소 참조를 먼저 복사합니다. 뒤의 배열 칸 교체는 values 의 thread 를 바꾸지 않고, 목록의 추가·정렬도 원본 배열에 반영되지 않습니다. 원소 자체를 깊게 복사하는 예제는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 초기 복사 source의 thread/array를 values에 넣습니다. |
|
|
| 별도 변경 source[0]을 changed로 바꾸고 values에 hash를 추가합니다. |
|
|
| values 정렬 values만 array/hash/thread로 정렬합니다. |
|
|
배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다
배열에서 목록 복사 뒤 source[0]만 changed로 바꾸고 별도 values에 hash 추가·정렬합니다. 두 컨테이너의 분리된 전후 상태를 추적합니다.
ArrayList 생성자가 배열 뷰의 원소 참조를 복사한 뒤 source 첫 칸을 changed로 교체합니다. values에는 원래 thread가 남고 hash를 더해 array, hash, thread 순서로 정렬됩니다.
배열 뷰와 복사본
배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다
ArrayList로 먼저 복사하면 배열 칸 교체와 목록의 추가·정렬이 서로 영향을 주지 않습니다.
source
- 실제 출력
source=[changed, array]- 그 컨테이너의 변경
source[0] = "changed"배열의 첫 칸 교체
values
- 실제 출력
values=[array, hash, thread]- 그 컨테이너의 변경
add("hash")sort(String::compareTo)
ArrayList 생성자는 Arrays.asList(source)의 원소 참조를 먼저 복사합니다. 뒤의 배열 칸 교체는 values의 thread를 바꾸지 않고, 목록의 추가·정렬도 원본 배열에 반영되지 않습니다. 원소 자체를 깊게 복사하는 예제는 아닙니다.
이 예의 조건
- String 원소의 참조 복사이며 깊은 복사가 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 6 | array |
| 8 | changed |
| 9 | array |
| 14 | array |
| 16 | array |
| 17 | hash |
| 22 | String은 불변 |
| 23 | 깊은 복사 아님 |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다 배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다 ArrayList 생성자가 배열 뷰의 원소 참조를 복사한 뒤 source 첫 칸을 changed로 교체합니다. values에는 원래 thread가 남고 hash를 더해 array, hash, thread 순서로 정렬됩니다. 배열 뷰와 복사본 배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다 ArrayList로 먼저 복사하면 배열 칸 교체와 목록의 추가·정렬이 서로 영향을 주지 않습니다. source 실제 출력 source=[changed, array] 그 컨테이너의 변경 source[0] = "changed" 배열의 첫 칸 교체 values 실제 출력 values=[array, hash, thread] 그 컨테이너의 변경 add("hash") sort(String::compareTo) ArrayList 생성자는 Arrays.asList(source) 의 원소 참조를 먼저 복사합니다. 뒤의 배열 칸 교체는 values 의 thread 를 바꾸지 않고, 목록의 추가·정렬도 원본 배열에 반영되지 않습니다. 원소 자체를 깊게 복사하는 예제는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 초기 복사 source의 thread/array를 values에 넣습니다. |
|
|
| 별도 변경 source[0]을 changed로 바꾸고 values에 hash를 추가합니다. |
|
|
| values 정렬 values만 array/hash/thread로 정렬합니다. |
|
|
source 출력은 [changed, array], values 출력은 [array, hash, thread]입니다.
목록의 추가·정렬과 배열의 칸 교체는 서로 영향을 주지 않습니다. ArrayList 생성자는 원소 참조를 복사하므로 원소 자체의 깊은 복사는 아닙니다.
변환 API를 고를 때는 크기 변경, set 허용, 원본 공유, null 허용을 표로 적습니다.
정렬은 원본 변경 여부와 동률 보존을 더 확인해야 안전한 컬렉션 변환이 됩니다.
추가 연습: 동점 보존과 검색 실패를 따로 판정하기
입력은 hash=90, set=90, queue=80의 세 Entry이며 제목은 모두 서로 다른, null이 아닌 문자열입니다. 점수 오름차순으로 먼저 비교하고 동점일 때 제목 오름차순으로 비교하는 TreeSet의 제목 순서와 크기를 구하세요. 별도의 정렬된 점수 목록 [80, 90, 90]에서 85를 검색했을 때 반환값과 삽입 위치도 구하세요. 마지막으로 90을 검색했을 때 특정 인덱스 하나를 정답으로 고정해도 되는지 설명하세요.
정답 및 풀이 보기
집합은 [queue, hash, set]이며 크기는 3입니다. 80점이 먼저이고, 두 90점의 제목은 hash 다음 set입니다. 비교자가 두 record 필드를 모두 사용하므로 이 조건에서 다른 제목은 비교 결과 0이 되지 않습니다.
85의 반환값은 -2이며 -(-2)-1=1이 삽입 위치입니다. 90은 인덱스 1 또는 2에서 찾을 수 있으므로 둘 중 특정 인덱스를 반드시 반환한다고 가정하지 않습니다. 성공한 인덱스가 1 또는 2인지와 그 위치의 값이 90인지 확인합니다.
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.TreeSet;
public class SortTieAndSearchSolution {
record Entry(String title, int score) {}
public static void main(String[] args) {
var order = Comparator.comparingInt(Entry::score)
.thenComparing(Entry::title);
var entries = new TreeSet<Entry>(order);
entries.addAll(List.of(new Entry("hash", 90),
new Entry("set", 90), new Entry("queue", 80)));
System.out.println("titles=" + entries.stream().map(Entry::title).toList());
System.out.println("size=" + entries.size());
var scores = List.of(80, 90, 90);
int missing = Collections.binarySearch(scores, 85);
int found = Collections.binarySearch(scores, 90);
System.out.println("missing=" + missing);
System.out.println("insertion=" + (-missing - 1));
System.out.println("duplicate-index-valid=" + (found == 1 || found == 2));
System.out.println("duplicate-score=" + scores.get(found));
}
}출력은 차례로 titles=[queue, hash, set], size=3, missing=-2, insertion=1, duplicate-index-valid=true, duplicate-score=90입니다. 이 코드는 목록에 없는 값으로 실패한 검색을 확인한 뒤에만 음수에서 삽입 위치를 복원합니다.