본문으로 건너뛰기

안동민 개발노트

본문 시작

컬렉션 정렬과 불변 변환

List 정렬과 TreeSet 정렬을 구분하고 List.of·Arrays.asList·copyOf·synchronizedList의 변경·공유 규칙을 실행합니다.

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

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

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

Arrays.asList view와 ArrayList 복사본은 소유권이 다르다

배열 view는 원소 변경을 원본과 공유하지만 크기를 바꿀 수 없고, 복사본은 독립적으로 성장합니다.

  1. Arrays.asList(array)

    고정 크기 · set은 원본 배열 반영

  2. new ArrayList<>(view)

    독립 가변 복사 · add/remove 가능

  3. List.copyOf(view)

    불변 snapshot · 이후 원본 변경 미반영


Arrays.asList의 변경 범위

배열을 List 뷰로 만든 뒤 set은 가능하지만 add·remove는 배열 길이를 바꿀 수 없어 실패합니다.

원본 배열의 원소 변경과 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");
    }
}

출력 뒤 UnsupportedOperationException이 발생합니다.

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


빈 컬렉션의 값 의미

null List는 호출자마다 분기를 요구하고 “결과 없음”과 “계산 안 함”을 섞습니다.

결과가 0개라면 List.of(), Set.of(), Map.of() 같은 빈 불변 컬렉션을 반환합니다.

호출자는 안전하게 sizefor-each를 사용할 수 있습니다.

Collections.emptyList()도 불변 빈 List이며 제네릭 문맥에서 타입을 추론합니다.

매번 새 가변 ArrayList를 반환하면 호출자가 수정 가능한 규칙으로 오해할 수 있으므로 API 의도를 선택합니다.

정렬 API마다 source와 결과의 소유권 변화가 다르다

제자리 정렬은 같은 목록을 바꾸고 복사 정렬은 원본을 유지한 새 결과를 만듭니다.

  1. source

    [3,1,2]

  2. list.sort

    source=[1,2,3]

  3. stream.sorted

    source 유지

  4. toList

    새 불변 결과 [1,2,3]


제자리 정렬과 불변 정렬 결과 구분

가변 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) {}
}

Stream.toList 결과는 수정할 수 있다고 가정하지 않습니다.

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

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


TreeSet·List의 중복 처리

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

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

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

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

Collections.min·max·binarySearch는 정렬 규칙을 전제로 합니다.

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

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

List 정렬은 동률을 보존하고 TreeSet은 동률 원소를 합칠 수 있다

정렬과 집합의 comparator 0은 같은 계산이지만 원소 수에 미치는 의미는 다릅니다.

  1. List.sort

    score 동률 두 원소 모두 유지

  2. TreeSet(score)

    compare 0 → 둘째 원소 거부

  3. TreeSet(score,id)

    식별 key로 동률 해소

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) {}
}

TreeSet size가 2라면 해시 또는 set 하나를 잃었습니다.

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

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


동기화 래퍼와 복합 원자성

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

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

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

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

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

이 장에서는 규칙만 확인하고 실제 가시성과 경합은 19장에서 다룹니다.

컬렉션 변환 API는 크기·공유·가변·null 계약이 다르다

메서드 이름보다 결과가 원본과 무엇을 공유하고 어떤 변경을 허용하는지 확인합니다.

API원본 공유변경 계약
Arrays.asList배열 원소set 가능·크기 고정
new ArrayList없음가변 복사
List.copyOf없음불변·null 거부
Collections.unmodifiableList내부 목록view 변경 불가
synchronizedList내부 목록호출 단위 동기화
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,배열이고 값은 배열,해시,스레드입니다.

두 컨테이너 구조와 원소 위치가 독립입니다.

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

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