안동민 개발노트

본문 시작

컬렉션 정렬과 불변 변환

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는 실패한다

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

배열 변경은 view에 보이지만 add는 실패한다
원문의 실행 순서배열과 view · 소스 추적실제 실행 결과
1 · 뷰 생성
source=[array, list]
view=[array, list]
출력 없음
2 · 배열 칸 교체 뒤 출력
source=[changed, list]
view=[changed, list]
view=[changed, list]
3 · 추가 시도
view.add("failure")
source=[changed, list]
view=[changed, list]
UnsupportedOperationException
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가 실패하며, 원소를 추가한 결과 목록은 만들어지지 않습니다.

출력 뒤 UnsupportedOperationException이 발생합니다.

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


빈 컬렉션의 값 의미

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) {}
}
원본과 두 정렬 결과를 나누고 가변 복사본에만 추가한다

원본의 thread 60, array 40, hash 50 순서는 유지됩니다. 오름차순과 내림차순 결과는 서로 다르고 queue 30 추가 뒤에는 가변 목록의 크기 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은 그 결과를 변경하려고 시도하지 않습니다.

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

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

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


TreeSet·List의 중복 처리

List.sort()는 모든 원소를 보존하고 순서만 바꿉니다.

TreeSet은 비교 결과가 0인 원소를 하나만 남깁니다.

정렬된 보고서에 중복 게시글이 모두 필요하면 List를 정렬해야 합니다.

고유 정렬 키 집합과 범위 조회가 목적이면 TreeSet이 맞습니다.

Collections.min·max는 비교 규칙에 따라 전체 원소를 검사하므로 미리 정렬할 필요가 없습니다. binarySearch에는 검색에 사용할 비교 규칙으로 정렬된 List가 필요합니다.

binarySearch에 정렬되지 않은 List를 넣은 결과는 믿을 수 없습니다.

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

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를 정렬하면 hash 90, set 90, queue 80이 모두 남습니다. 이 순서로 TreeSet에 넣으면 set은 기존 hash와 비교 결과가 0이어서 두 개만 남습니다.

List는 동점 두 개를 보존하고 TreeSet에는 hash가 남는다
원문의 컨테이너최종 출력의 원소 순서크기·동점 처리
List
sort(byScore)
hash · 90
set · 90
queue · 80
list-size=3
동점의 기존 상대 순서 유지
TreeSet
addAll(list)
hash · 90
queue · 80
tree-size=2
먼저 전달된 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로 남습니다.

이 예제의 TreeSet에는 먼저 들어간 hash와 queue가 남아 size=2입니다. 같은 90점인 set은 hash와 비교 결과가 0이어서 추가되지 않습니다.

제목 tie-breaker를 추가하거나 List를 유지해야 합니다.

이 차이는 성능보다 먼저 확인할 기능 규칙입니다.


동기화 래퍼와 복합 원자성

Collections.synchronizedList는 개별 메서드 호출을 동기화합니다.

if (!list.contains(x)) list.add(x) 두 호출 전체는 하나의 잠금 구간이 아니므로 다른 스레드가 사이에 들어올 수 있습니다.

중복 없는 동시 저장이면 동시성 Set 등 목적에 맞는 구조를 사용합니다.

래퍼를 순회할 때는 문서대로 해당 컬렉션 객체를 synchronized 블록으로 잠가야 합니다.

Iterator 자체가 전체 순회를 잠그지 않습니다.

이 장에서는 규칙만 확인하고 실제 가시성과 경합은 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 소유권을 노출하지 않고 표현 시점에 정렬합니다.


연습 문제

원본 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로 바꾸고 복사 목록은 따로 추가·정렬한다

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

배열 칸은 changed로 바꾸고 복사 목록은 따로 추가·정렬한다
컨테이너실제 출력그 컨테이너의 변경
sourcesource=[changed, array]
source[0] = "changed"
배열의 첫 칸 교체
valuesvalues=[array, hash, thread]
add("hash")
sort(String::compareTo)
source
실제 출력: source=[changed, array]
그 컨테이너의 변경:
source[0] = "changed"
배열의 첫 칸 교체
values
실제 출력: values=[array, hash, thread]
그 컨테이너의 변경:
add("hash")
sort(String::compareTo)

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

source 출력은 [changed, array], values 출력은 [array, hash, thread]입니다.

목록의 추가·정렬과 배열의 칸 교체는 서로 영향을 주지 않습니다. ArrayList 생성자는 원소 참조를 복사하므로 원소 자체의 깊은 복사는 아닙니다.

변환 API를 고를 때는 크기 변경, set 허용, 원본 공유, null 허용을 표로 적습니다.

정렬은 원본 변경 여부와 동률 보존을 더 확인해야 안전한 컬렉션 구분이 됩니다.