본문 바로가기

컬렉션 정렬과 불변 변환

List·TreeSet의 정렬 결과와 배열 뷰·복사본의 변경 범위를 실행하고, 불변 반환과 동기화 래퍼의 계약을 구분합니다.

표준 컬렉션 유틸리티는 비슷한 모양의 List를 만들지만 변경 가능성, 원본 공유, null 허용이 다릅니다.

List.of, List.copyOf, Arrays.asList, new ArrayList를 구분하지 않으면 add에서 예외가 나거나 외부 배열 변경이 내부 상태에 번집니다.

정렬도 제자리 변경과 새 결과 생성을 먼저 나눠야 합니다.


Arrays.asList의 변경 범위

배열을 List 뷰로 만든 뒤 set은 가능하지만, 원소를 추가하거나 제거해 크기를 바꾸는 연산은 지원하지 않습니다.

원본 배열의 원소 변경과 List.set은 서로 반영됩니다.

lab/FixedSizeListAddFailure.java
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에서 실패합니다. 공유 반영과 크기 변경 거부의 실제 시간 경계가 핵심입니다.

배열 변경은 view에 보이지만 add는 실패한다

원본 배열의 첫 칸을 changed로 바꾸면 view에도 반영되어 출력됩니다. 고정 크기 뷰에 failure를 추가하는 다음 호출은 예외로 끝납니다.

ARRAYS.ASLIST · 고정 크기 뷰

배열 변경은 view에 보이지만 add는 실패한다

배열 원소 교체는 view에도 보입니다. 원소 수를 늘리는 add는 예외로 끝납니다.

공유한 배열 칸 변경과 금지된 크기 변경원 source 칸 교체를 view가 공유하여 view=[changed,list] 한 줄을 출력합니다. 그 뒤 add는 UnsupportedOperationException에서 끝나며 추가된 결과 목록은 없습니다. set 허용과 add 허용을 구분합니다.
같은 배열을 보는 viewsource=[array,list]view=[array,list]
연결 관계같은 배열을 보는 view · source=[array,list] · view=[array,list] → source[0] 칸 교체 · source=[changed,list] · view도 같은 칸을 관찰
source[0] 칸 교체source=[changed,list]view도 같은 칸을 관찰
연결 관계source[0] 칸 교체 · source=[changed,list] · view도 같은 칸을 관찰 → view를 println · view=[changed, list] · 이 한 줄은 출력됨
view를 printlnview=[changed, list]이 한 줄은 출력됨
연결 관계view를 println · view=[changed, list] · 이 한 줄은 출력됨 → view.add(failure) · 크기 변경은 지원 안 함 · UnsupportedOperation · Exception / 종료
view.add(failure)크기 변경은 지원 안 함UnsupportedOperationException / 종료

실선: 원 실행 순서추가 뒤 새 목록은 없음set 가능과 add 가능은 별개

  1. 1 · 뷰 생성

    배열과 view · 소스 추적
    source=[array, list]
    view=[array, list]
    실제 실행 결과
    출력 없음
  2. 2 · 배열 칸 교체 뒤 출력

    배열과 view · 소스 추적
    source=[changed, list]
    view=[changed, list]
    실제 실행 결과
    view=[changed, list]
  3. 3 · 추가 시도
    view.add("failure")

    배열과 view · 소스 추적
    source=[changed, list]
    view=[changed, list]
    실제 실행 결과
    UnsupportedOperationException

두 이름은 같은 배열의 원소 변경을 관찰합니다. 가운데 열은 소스 추적이고 실제 표준 출력은 view=[changed, list] 한 줄입니다. 그 뒤의 add가 실패하며, 원소를 추가한 결과 목록은 만들어지지 않습니다.

이 예의 조건

  • Arrays.asList 뷰는 set 가능과 add 가능이 다르다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
15Exception
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/view: array,list
  • 뷰의 내용은 원 배열 슬롯과 연결된다.
원 슬롯 교체

source[0]을 changed로 바꾸고 view를 출력합니다.

  • view 출력: [changed,list]
  • 크기: 2
  • 같은 슬롯을 읽어 교체 내용이 보인다.
크기 변경 실패

view.add(failure)는 예외로 끝납니다.

  • 예외: UnsupportedOperationException
  • 추가 결과: 없음
  • 공유 내용 교체 허용이 크기 증가 허용을 뜻하지 않는다.

배열 변경은 view에 보이지만 add는 실패한다

asList 뷰가 source 슬롯 변경을 관찰하고 출력한 뒤 add에서 실패합니다. 공유 반영과 크기 변경 거부의 실제 시간 경계가 핵심입니다.

배열 변경은 view에 보이지만 add는 실패한다

원본 배열의 첫 칸을 changed로 바꾸면 view에도 반영되어 출력됩니다. 고정 크기 뷰에 failure를 추가하는 다음 호출은 예외로 끝납니다.

ARRAYS.ASLIST · 고정 크기 뷰

배열 변경은 view에 보이지만 add는 실패한다

배열 원소 교체는 view에도 보입니다. 원소 수를 늘리는 add는 예외로 끝납니다.

공유한 배열 칸 변경과 금지된 크기 변경원 source 칸 교체를 view가 공유하여 view=[changed,list] 한 줄을 출력합니다. 그 뒤 add는 UnsupportedOperationException에서 끝나며 추가된 결과 목록은 없습니다. set 허용과 add 허용을 구분합니다.
같은 배열을 보는 viewsource=[array,list]view=[array,list]
연결 관계같은 배열을 보는 view · source=[array,list] · view=[array,list] → source[0] 칸 교체 · source=[changed,list] · view도 같은 칸을 관찰
source[0] 칸 교체source=[changed,list]view도 같은 칸을 관찰
연결 관계source[0] 칸 교체 · source=[changed,list] · view도 같은 칸을 관찰 → view를 println · view=[changed, list] · 이 한 줄은 출력됨
view를 printlnview=[changed, list]이 한 줄은 출력됨
연결 관계view를 println · view=[changed, list] · 이 한 줄은 출력됨 → view.add(failure) · 크기 변경은 지원 안 함 · UnsupportedOperation · Exception / 종료
view.add(failure)크기 변경은 지원 안 함UnsupportedOperationException / 종료

실선: 원 실행 순서추가 뒤 새 목록은 없음set 가능과 add 가능은 별개

  1. 1 · 뷰 생성

    배열과 view · 소스 추적
    source=[array, list]
    view=[array, list]
    실제 실행 결과
    출력 없음
  2. 2 · 배열 칸 교체 뒤 출력

    배열과 view · 소스 추적
    source=[changed, list]
    view=[changed, list]
    실제 실행 결과
    view=[changed, list]
  3. 3 · 추가 시도
    view.add("failure")

    배열과 view · 소스 추적
    source=[changed, list]
    view=[changed, list]
    실제 실행 결과
    UnsupportedOperationException

두 이름은 같은 배열의 원소 변경을 관찰합니다. 가운데 열은 소스 추적이고 실제 표준 출력은 view=[changed, list] 한 줄입니다. 그 뒤의 add가 실패하며, 원소를 추가한 결과 목록은 만들어지지 않습니다.

이 예의 조건

  • Arrays.asList 뷰는 set 가능과 add 가능이 다르다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
15Exception
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/view: array,list
  • 뷰의 내용은 원 배열 슬롯과 연결된다.
원 슬롯 교체

source[0]을 changed로 바꾸고 view를 출력합니다.

  • view 출력: [changed,list]
  • 크기: 2
  • 같은 슬롯을 읽어 교체 내용이 보인다.
크기 변경 실패

view.add(failure)는 예외로 끝납니다.

  • 예외: UnsupportedOperationException
  • 추가 결과: 없음
  • 공유 내용 교체 허용이 크기 증가 허용을 뜻하지 않는다.

출력 뒤 UnsupportedOperationException이 발생합니다.

독립된 가변 목록이 목적이면 new ArrayList<>(Arrays.asList(source)), 불변 스냅샷이면 List.copyOf(Arrays.asList(source))를 사용합니다.

수정 불가와 null 허용은 서로 다른 조건입니다. Arrays.asList는 null 원소를 보관할 수 있지만 List.copyOf와 List.of는 null 원소를 거부합니다. Stream.toList의 수정 불가 결과와도 이 경계를 구별해야 합니다.

예시

src/ListNullBoundary.java
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() 또는 가변 복사본을 사용합니다.

src/SortingAndCopyContracts.java
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,immutableSorted,mutableSorted입니다. 원 순서와 오름·내림 결과를 따로 보존하고 queue30은 desc 출력 뒤 가변 목록에만 추가하여 size4입니다. 추가 후 재정렬을 실행하지 않습니다. source 원본 초기 세 값 thread60 / array40 hash50 원본 출력 같은 등록 순서 유지 immutableSorted 조회수 오름차순 array40 / hash50 thread60 수정 불가 결과 원 main은 변경 시도 안 함 mutableSorted 조회수 내림차순 thread60 / hash50 array40 / 출력 시점 그 뒤 queue30 추가 이 목록만 size4 mutable-size=4 출력 영역은 서로 다른 목록 실선: 각 결과 안의 시점 queue를 더한 뒤 재정렬 안 함
원본과 두 정렬 결과의 분리된 변경 범위 세 영역은 source,immutableSorted,mutableSorted입니다. 원 순서와 오름·내림 결과를 따로 보존하고 queue30은 desc 출력 뒤 가변 목록에만 추가하여 size4입니다. 추가 후 재정렬을 실행하지 않습니다. source 원본 초기 세 값 thread60 / array40 hash50 원본 출력 같은 등록 순서 유지 immutableSorted 조회수 오름차순 array40 / hash50 thread60 수정 불가 결과 원 main은 변경 시도 안 함 mutableSorted 조회수 내림차순 thread60 / hash50 array40 / 출력 시점 그 뒤 queue30 추가 이 목록만 size4 mutable-size=4 출력 영역은 서로 다른 목록 실선: 각 결과 안의 시점 queue를 더한 뒤 재정렬 안 함
원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다
출력 이름·시점출력의 값·원소 순서확인할 변경 범위
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은 그 결과를 변경하려고 시도하지 않습니다.

이 예의 조건

  • 세 목록은 별도 구조이며 queue 추가 뒤 재정렬하지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2세 영역은 source,immutableSorted,mutableSorted입니다. 원 순서와 오름·내림 결과를 따로 보존하고 queue 30은 desc 출력 뒤 가변 목록에만 추가하여 size 4입니다. 추가 후 재정렬을 실행하지 않습니다.
6array40
11조회 수 오름차순
12array40
18조회 수 내림차순
21array40
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의 등록 순서를 보관합니다.

  • source: thread60,array40,hash50
  • 정렬 결과와 독립인 입력 순서가 있다.
두 정렬 결과

오름차순의 수정 불가 결과와 내림차순의 가변 결과를 얻습니다.

  • immutable: array40,hash50,thread60
  • mutable: thread60,hash50,array40
  • 정렬 방향과 결과 수정 계약이 별개다.
가변 결과 추가

desc 출력 뒤 queue 30을 추가합니다.

  • mutable-size: 4
  • source: 기존 3개
  • 재정렬: 없음
  • 정렬 당시 순서와 나중 수정된 결과를 구분한다.

원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다

원본과 두 정렬 결과를 관찰한 뒤 mutableSorted에만 queue를 추가합니다. 마지막 추가·size 변화와 재정렬 없음의 시점을 구별해 수정 범위를 확인할 수 있습니다.

원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다

원본의 thread 60, array 40, hash 50 순서는 유지됩니다. 오름차순과 내림차순 결과는 서로 다르고 queue 30 추가 뒤에는 가변 목록의 크기 4만 출력됩니다.

정렬 결과 · 변경 범위

원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다

원본·오름차순·내림차순을 따로 보고, queue 추가 뒤에는 가변 목록의 크기 4만 출력합니다.

원본과 두 정렬 결과의 분리된 변경 범위 세 영역은 source,immutableSorted,mutableSorted입니다. 원 순서와 오름·내림 결과를 따로 보존하고 queue30은 desc 출력 뒤 가변 목록에만 추가하여 size4입니다. 추가 후 재정렬을 실행하지 않습니다. source 원본 초기 세 값 thread60 / array40 hash50 원본 출력 같은 등록 순서 유지 immutableSorted 조회수 오름차순 array40 / hash50 thread60 수정 불가 결과 원 main은 변경 시도 안 함 mutableSorted 조회수 내림차순 thread60 / hash50 array40 / 출력 시점 그 뒤 queue30 추가 이 목록만 size4 mutable-size=4 출력 영역은 서로 다른 목록 실선: 각 결과 안의 시점 queue를 더한 뒤 재정렬 안 함
원본과 두 정렬 결과의 분리된 변경 범위 세 영역은 source,immutableSorted,mutableSorted입니다. 원 순서와 오름·내림 결과를 따로 보존하고 queue30은 desc 출력 뒤 가변 목록에만 추가하여 size4입니다. 추가 후 재정렬을 실행하지 않습니다. source 원본 초기 세 값 thread60 / array40 hash50 원본 출력 같은 등록 순서 유지 immutableSorted 조회수 오름차순 array40 / hash50 thread60 수정 불가 결과 원 main은 변경 시도 안 함 mutableSorted 조회수 내림차순 thread60 / hash50 array40 / 출력 시점 그 뒤 queue30 추가 이 목록만 size4 mutable-size=4 출력 영역은 서로 다른 목록 실선: 각 결과 안의 시점 queue를 더한 뒤 재정렬 안 함
원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다
출력 이름·시점출력의 값·원소 순서확인할 변경 범위
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은 그 결과를 변경하려고 시도하지 않습니다.

이 예의 조건

  • 세 목록은 별도 구조이며 queue 추가 뒤 재정렬하지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2세 영역은 source,immutableSorted,mutableSorted입니다. 원 순서와 오름·내림 결과를 따로 보존하고 queue 30은 desc 출력 뒤 가변 목록에만 추가하여 size 4입니다. 추가 후 재정렬을 실행하지 않습니다.
6array40
11조회 수 오름차순
12array40
18조회 수 내림차순
21array40
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의 등록 순서를 보관합니다.

  • source: thread60,array40,hash50
  • 정렬 결과와 독립인 입력 순서가 있다.
두 정렬 결과

오름차순의 수정 불가 결과와 내림차순의 가변 결과를 얻습니다.

  • immutable: array40,hash50,thread60
  • mutable: thread60,hash50,array40
  • 정렬 방향과 결과 수정 계약이 별개다.
가변 결과 추가

desc 출력 뒤 queue 30을 추가합니다.

  • mutable-size: 4
  • source: 기존 3개
  • 재정렬: 없음
  • 정렬 당시 순서와 나중 수정된 결과를 구분한다.

Stream.toList() 결과는 수정할 수 없는 List입니다. 이 main은 그 결과의 변경을 시도하지 않고 별도 ArrayList 복사본에만 원소를 추가합니다.

가변 결과가 필요하면 수집기 또는 ArrayList 생성자로 복사합니다.

List.copyOf는 입력이 이미 적합한 불변 List일 때 같은 인스턴스를 반환할 수 있으므로 “항상 새 객체”를 규칙으로 삼지 않습니다.

원소가 가변 객체라면 수정 불가 목록 안에서도 그 객체의 필드는 바뀔 수 있습니다. 아래에서는 목록의 칸 교체와 원소 필드 변경을 나눠 확인합니다.

예시

실행 버전: 다음 예제의 List.getFirst()는 Java 21에 추가된 API입니다. 이 예제를 그대로 실행하려면 JDK 21 이상을 사용하세요. 비어 있지 않은 List에서는 첫 원소를 읽는 get(0)과 같은 결과지만, 빈 목록이면 NoSuchElementException이 발생합니다.

src/ShallowSnapshotBoundary.java
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의 전제와 반환값을 함께 확인하세요.

정렬 비교자와 검색 비교자도 같아야 합니다.

src/ListAndTreeSortingDifference.java
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는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다

같은 점수 내림차순 비교자로 List를 정렬하면 hash 90, set 90, queue 80이 모두 남습니다. 이 순서로 TreeSet에 넣으면 set은 기존 hash와 비교 결과가 0이어서 두 개만 남습니다.

LIST / TREESET · 동점의 의미

List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다

정렬 List는 동점 원소를 보존합니다. TreeSet은 비교 결과 0인 두 원소 중 먼저 넣은 hash를 보관합니다.

안정 정렬의 두 동률과 TreeSet의 기존 대표 List는 hash90,set90,queue80의 세 원소와 동률의 상대 순서를 유지합니다. 이 순서로 TreeSet에 넣으면 set90은 compare0으로 거부되어 hash90,queue80과 size2가 남습니다. 새 원소가 기존 원소를 교체한 것이 아닙니다. List.sort 세 원소를 입력 hash90 / set90 queue80 / 점수 내림차순 세 원소 모두 유지 hash90 / set90 / queue80 동률의 기존 순서 / size3 TreeSet.addAll 앞 List 순서로 전달 hash90을 먼저 등록 set90은 compare0 기존 hash 유지 queue80만 별도 / size2 실선: 각 연산의 입력·결과 TreeSet의 기존 hash 유지 set 추가는 거부 / 교체 아님 equals와 비교0의 조건은 다름
안정 정렬의 두 동률과 TreeSet의 기존 대표 List는 hash90,set90,queue80의 세 원소와 동률의 상대 순서를 유지합니다. 이 순서로 TreeSet에 넣으면 set90은 compare0으로 거부되어 hash90,queue80과 size2가 남습니다. 새 원소가 기존 원소를 교체한 것이 아닙니다. List.sort 세 원소를 입력 hash90 / set90 queue80 / 점수 내림차순 세 원소 모두 유지 hash90 / set90 / queue80 동률의 기존 순서 / size3 TreeSet.addAll 앞 List 순서로 전달 hash90을 먼저 등록 set90은 compare0 기존 hash 유지 queue80만 별도 / size2 실선: 각 연산의 입력·결과 TreeSet의 기존 hash 유지 set 추가는 거부 / 교체 아님 equals와 비교0의 조건은 다름

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로 남습니다.

이 예의 조건

  • TreeSet 비교 결과 0은 equals와 다른 조건이며 새 원소로 교체하지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2List는 hash 90,set 90,queue 80의 세 원소와 동률의 상대 순서를 유지합니다. 이 순서로 TreeSet에 넣으면 set 90은 compare 결과 0으로 거부되어 hash 90,queue 80과 size 2가 남습니다. 새 원소가 기존 원소를 교체한 것이 아닙니다.
5hash 90
6set 90
7queue 80
8점수 내림차순
10hash 90
11set 90
12queue 80
13동률의 기존 순서
14size3
17hash 90을 먼저 등록
18set 90은 compare 결과 0
20queue 80만 별도
21size 2
24set 추가는 거부
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을 모두 보관합니다.

  • List: hash 90,set 90,queue 80
  • size: 3
  • 동률의 원래 상대 순서가 유지된다.
동률 Set 삽입

hash 90 먼저 등록 뒤 set 90은 compare 결과 0으로 거절합니다.

  • 대표: hash 90
  • set 90: 거절
  • 비교상 동등한 새 원소가 기존 대표를 교체하지 않는다.
다른 점수 삽입

queue 80을 추가합니다.

  • TreeSet: hash 90,queue 80
  • size: 2
  • List의 세 멤버와 Set의 두 대표는 다른 계약이다.

List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다

List 정렬은 동률 둘을 유지하고 같은 순서 addAll한 TreeSet은 두 번째 90을 거부합니다. 정렬 뒤 등록 과정을 단계로 강조하면 교체가 아닌 거부가 명확합니다.

List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다

같은 점수 내림차순 비교자로 List를 정렬하면 hash 90, set 90, queue 80이 모두 남습니다. 이 순서로 TreeSet에 넣으면 set은 기존 hash와 비교 결과가 0이어서 두 개만 남습니다.

LIST / TREESET · 동점의 의미

List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다

정렬 List는 동점 원소를 보존합니다. TreeSet은 비교 결과 0인 두 원소 중 먼저 넣은 hash를 보관합니다.

안정 정렬의 두 동률과 TreeSet의 기존 대표 List는 hash90,set90,queue80의 세 원소와 동률의 상대 순서를 유지합니다. 이 순서로 TreeSet에 넣으면 set90은 compare0으로 거부되어 hash90,queue80과 size2가 남습니다. 새 원소가 기존 원소를 교체한 것이 아닙니다. List.sort 세 원소를 입력 hash90 / set90 queue80 / 점수 내림차순 세 원소 모두 유지 hash90 / set90 / queue80 동률의 기존 순서 / size3 TreeSet.addAll 앞 List 순서로 전달 hash90을 먼저 등록 set90은 compare0 기존 hash 유지 queue80만 별도 / size2 실선: 각 연산의 입력·결과 TreeSet의 기존 hash 유지 set 추가는 거부 / 교체 아님 equals와 비교0의 조건은 다름
안정 정렬의 두 동률과 TreeSet의 기존 대표 List는 hash90,set90,queue80의 세 원소와 동률의 상대 순서를 유지합니다. 이 순서로 TreeSet에 넣으면 set90은 compare0으로 거부되어 hash90,queue80과 size2가 남습니다. 새 원소가 기존 원소를 교체한 것이 아닙니다. List.sort 세 원소를 입력 hash90 / set90 queue80 / 점수 내림차순 세 원소 모두 유지 hash90 / set90 / queue80 동률의 기존 순서 / size3 TreeSet.addAll 앞 List 순서로 전달 hash90을 먼저 등록 set90은 compare0 기존 hash 유지 queue80만 별도 / size2 실선: 각 연산의 입력·결과 TreeSet의 기존 hash 유지 set 추가는 거부 / 교체 아님 equals와 비교0의 조건은 다름

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로 남습니다.

이 예의 조건

  • TreeSet 비교 결과 0은 equals와 다른 조건이며 새 원소로 교체하지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2List는 hash 90,set 90,queue 80의 세 원소와 동률의 상대 순서를 유지합니다. 이 순서로 TreeSet에 넣으면 set 90은 compare 결과 0으로 거부되어 hash 90,queue 80과 size 2가 남습니다. 새 원소가 기존 원소를 교체한 것이 아닙니다.
5hash 90
6set 90
7queue 80
8점수 내림차순
10hash 90
11set 90
12queue 80
13동률의 기존 순서
14size3
17hash 90을 먼저 등록
18set 90은 compare 결과 0
20queue 80만 별도
21size 2
24set 추가는 거부
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을 모두 보관합니다.

  • List: hash 90,set 90,queue 80
  • size: 3
  • 동률의 원래 상대 순서가 유지된다.
동률 Set 삽입

hash 90 먼저 등록 뒤 set 90은 compare 결과 0으로 거절합니다.

  • 대표: hash 90
  • set 90: 거절
  • 비교상 동등한 새 원소가 기존 대표를 교체하지 않는다.
다른 점수 삽입

queue 80을 추가합니다.

  • TreeSet: hash 90,queue 80
  • size: 2
  • List의 세 멤버와 Set의 두 대표는 다른 계약이다.

이 예제의 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장에서 다룹니다.

app/BoardSnapshotService.java
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 생성자에 전달하면 원소 참조를 새 내부 배열에 복사합니다.

exercise/IndependentMutableListSolution.java
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 추가·정렬합니다. 두 컨테이너의 분리된 전후 상태를 추적합니다.

배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다

ArrayList 생성자가 배열 뷰의 원소 참조를 복사한 뒤 source 첫 칸을 changed로 교체합니다. values에는 원래 thread가 남고 hash를 더해 array, hash, thread 순서로 정렬됩니다.

배열 뷰와 복사본

배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다

ArrayList로 먼저 복사하면 배열 칸 교체와 목록의 추가·정렬이 서로 영향을 주지 않습니다.

배열 칸 교체와 복사 목록의 정렬 두 영역은 원 source 배열과 먼저 만든 values입니다. source는 changed,array가 되고 values는 원 thread를 유지한 채 hash 추가·정렬로 array,hash,thread가 됩니다. String 원소의 참조 복사이며 깊은 복사라는 뜻은 아닙니다. source 배열 복사 직전 thread / array source[0] 교체 changed / array source 실제 출력 values 독립 목록 원소 참조를 복사 thread / array hash 추가 후 정렬 array / hash / thread values 실제 출력 영역은 별도 컨테이너 실선: 그 컨테이너의 변화 String은 불변 / 깊은 복사 아님
배열 칸 교체와 복사 목록의 정렬 두 영역은 원 source 배열과 먼저 만든 values입니다. source는 changed,array가 되고 values는 원 thread를 유지한 채 hash 추가·정렬로 array,hash,thread가 됩니다. String 원소의 참조 복사이며 깊은 복사라는 뜻은 아닙니다. source 배열 복사 직전 thread / array source[0] 교체 changed / array source 실제 출력 values 독립 목록 원소 참조를 복사 thread / array hash 추가 후 정렬 array / hash / thread values 실제 출력 영역은 별도 컨테이너 실선: 그 컨테이너의 변화 String은 불변 / 깊은 복사 아님

source

실제 출력
source=[changed, array]
그 컨테이너의 변경
source[0] = "changed"
배열의 첫 칸 교체

values

실제 출력
values=[array, hash, thread]
그 컨테이너의 변경
add("hash")
sort(String::compareTo)

ArrayList 생성자는 Arrays.asList(source)의 원소 참조를 먼저 복사합니다. 뒤의 배열 칸 교체는 values의 thread를 바꾸지 않고, 목록의 추가·정렬도 원본 배열에 반영되지 않습니다. 원소 자체를 깊게 복사하는 예제는 아닙니다.

이 예의 조건

  • String 원소의 참조 복사이며 깊은 복사가 아니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
6array
8changed
9array
14array
16array
17hash
22String은 불변
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: thread,array
  • values: thread,array
  • 목록의 멤버십을 배열 슬롯과 분리한다.
별도 변경

source[0]을 changed로 바꾸고 values에 hash를 추가합니다.

  • source: changed,array
  • values: thread,array,hash
  • 슬롯 변경과 목록 추가는 다른 컨테이너만 바꾼다.
values 정렬

values만 array/hash/thread로 정렬합니다.

  • source: changed,array 유지
  • values: array,hash,thread
  • 독립 구조의 정렬이 원 배열에 영향을 주지 않는다.

배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다

배열에서 목록 복사 뒤 source[0]만 changed로 바꾸고 별도 values에 hash 추가·정렬합니다. 두 컨테이너의 분리된 전후 상태를 추적합니다.

배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다

ArrayList 생성자가 배열 뷰의 원소 참조를 복사한 뒤 source 첫 칸을 changed로 교체합니다. values에는 원래 thread가 남고 hash를 더해 array, hash, thread 순서로 정렬됩니다.

배열 뷰와 복사본

배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다

ArrayList로 먼저 복사하면 배열 칸 교체와 목록의 추가·정렬이 서로 영향을 주지 않습니다.

배열 칸 교체와 복사 목록의 정렬 두 영역은 원 source 배열과 먼저 만든 values입니다. source는 changed,array가 되고 values는 원 thread를 유지한 채 hash 추가·정렬로 array,hash,thread가 됩니다. String 원소의 참조 복사이며 깊은 복사라는 뜻은 아닙니다. source 배열 복사 직전 thread / array source[0] 교체 changed / array source 실제 출력 values 독립 목록 원소 참조를 복사 thread / array hash 추가 후 정렬 array / hash / thread values 실제 출력 영역은 별도 컨테이너 실선: 그 컨테이너의 변화 String은 불변 / 깊은 복사 아님
배열 칸 교체와 복사 목록의 정렬 두 영역은 원 source 배열과 먼저 만든 values입니다. source는 changed,array가 되고 values는 원 thread를 유지한 채 hash 추가·정렬로 array,hash,thread가 됩니다. String 원소의 참조 복사이며 깊은 복사라는 뜻은 아닙니다. source 배열 복사 직전 thread / array source[0] 교체 changed / array source 실제 출력 values 독립 목록 원소 참조를 복사 thread / array hash 추가 후 정렬 array / hash / thread values 실제 출력 영역은 별도 컨테이너 실선: 그 컨테이너의 변화 String은 불변 / 깊은 복사 아님

source

실제 출력
source=[changed, array]
그 컨테이너의 변경
source[0] = "changed"
배열의 첫 칸 교체

values

실제 출력
values=[array, hash, thread]
그 컨테이너의 변경
add("hash")
sort(String::compareTo)

ArrayList 생성자는 Arrays.asList(source)의 원소 참조를 먼저 복사합니다. 뒤의 배열 칸 교체는 values의 thread를 바꾸지 않고, 목록의 추가·정렬도 원본 배열에 반영되지 않습니다. 원소 자체를 깊게 복사하는 예제는 아닙니다.

이 예의 조건

  • String 원소의 참조 복사이며 깊은 복사가 아니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
6array
8changed
9array
14array
16array
17hash
22String은 불변
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: thread,array
  • values: thread,array
  • 목록의 멤버십을 배열 슬롯과 분리한다.
별도 변경

source[0]을 changed로 바꾸고 values에 hash를 추가합니다.

  • source: changed,array
  • values: thread,array,hash
  • 슬롯 변경과 목록 추가는 다른 컨테이너만 바꾼다.
values 정렬

values만 array/hash/thread로 정렬합니다.

  • source: changed,array 유지
  • 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인지 확인합니다.

exercise/SortTieAndSearchSolution.java
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입니다. 이 코드는 목록에 없는 값으로 실패한 검색을 확인한 뒤에만 음수에서 삽입 위치를 복원합니다.