JAVA

Java Collections 유틸 활용 팁 — 정렬부터 불변 컬렉션까지 한 번에 정리

IT Lab 2026. 2. 19. 22:00

Java 17 기준으로 Collections 정렬 유틸과 불변 컬렉션(List.of/Map.of), unmodifiable vs copyOf 차이를 실무 관점에서 정리합니다.

 

리스트를 정렬했는데 “원본이 바뀌어 버려서” 다른 로직이 깨진 경험이 있으신가요? 또는 List.of()로 만든 리스트에 add()를 했다가 런타임 예외를 만나 당황하신 적도 있을 거예요. 컬렉션은 자주 쓰는 만큼, “의도한 변경 가능성(mutability)”을 코드로 정확히 표현하는 게 생각보다 중요합니다.

핵심 개념 (Java Collections 유틸과 불변 컬렉션이 중요한 이유)

unmodifiableList는 원본을 감싼 뷰이고 copyOf는 불변 스냅샷이라는 비교 다이어그램

Java에서 컬렉션을 다룰 때 실무 사고의 대부분은 두 가지에서 나옵니다.

  1. 정렬은 “어디를” 바꾸는가?
    Collections.sort(list)list.sort(...)리스트 자체를 제자리(in-place) 정렬합니다. 반면 “정렬된 결과만 필요”한 상황에서 원본을 바꿔버리면, 이후 로직(캐시 키, 페이징, 감사 로그 등)에서 미묘한 버그가 납니다. 정렬은 계산이 아니라 상태 변경이라는 점을 항상 의식해야 합니다.
  2. 불변(immutable)과 수정 불가(unmodifiable)는 다르다
  • List.of(...), Map.of(...)불변 컬렉션(정확히는 “구조 변경 불가, 요소 교체도 불가”)을 만듭니다. add/remove/set 같은 연산은 UnsupportedOperationException이 납니다.
  • Collections.unmodifiableList(list)는 “원본을 감싼 읽기 전용 뷰(view)”입니다. 즉, 원본이 바뀌면 뷰에도 그대로 반영됩니다. “뚜껑만 닫아둔 도시락” 같은 느낌이라, 도시락 내용을 다른 사람이 바꾸면 그대로 바뀝니다.
  • List.copyOf(list)스냅샷을 떠서 불변 컬렉션으로 반환합니다. 원본이 이후에 바뀌어도 copy 결과는 바뀌지 않습니다. “사진 찍어 보관”에 가깝습니다.

이 차이를 모르면, API에서 컬렉션을 반환할 때 “수정 못 하게 막았다”고 생각했는데 실제로는 외부 변경이 전파되어 장애로 이어질 수 있어요.

한눈에 보는 선택 가이드 (unmodifiable vs copyOf vs of)

아래 표만 기억해도 실무에서 선택이 훨씬 쉬워집니다.

방법 변경 시도(add/remove/set) 원본 변경이 반영되나? null 허용 주 사용처
List.of(...), Map.of(...) 불가 (예외) 해당 없음(원본 없음) 불가 상수/고정 데이터, 테스트 픽스처
Collections.unmodifiableList(list) 불가 (예외) 반영됨 원본에 따름 내부 컬렉션을 “읽기 전용 뷰”로 노출
List.copyOf(list) / Map.copyOf(map) 불가 (예외) 반영 안 됨 불가 외부에 안전하게 스냅샷 반환, 방어적 복사

참고: List.copyOf()는 원본이 이미 불변 컬렉션이고 조건이 맞으면 같은 인스턴스를 반환할 수도 있습니다(구현 세부). 하지만 “원본 변경 전파 없음”이라는 의미는 동일하게 기대하시면 됩니다.

flowchart TD
  A[""원본 컬렉션(list)"""] --> B[""Collections.unmodifiableList"""]
  A --> C[""List.copyOf"""]
  B --> D[""읽기 전용 뷰(원본 변경이 보임)"""]
  C --> E[""불변 스냅샷(원본 변경이 안 보임)"""]

unmodifiable은 원본을 “감싼 뷰”, copyOf는 “복사된 불변 스냅샷”이라는 흐름을 보여줍니다.

 

코드 예제 (정렬 + 불변 컬렉션 + unmodifiable vs copyOf)

아래 코드는 그대로 복사해서 실행할 수 있고, 정렬의 부작용과 불변 컬렉션 선택 차이를 한 번에 확인할 수 있습니다.

import java.util.*;

public class CollectionsTipsDemo {

    public static void main(String[] args) {
        sortingDemo();
        immutableFactoriesDemo();
        unmodifiableVsCopyOfDemo();
    }

    private static void sortingDemo() {
        System.out.println("=== 1) Sorting Demo ===");
        List<Integer> numbers = new ArrayList<>(List.of(5, 1, 3, 2, 4));

        // in-place 정렬: 원본 리스트가 바뀝니다.
        numbers.sort(Comparator.naturalOrder());
        System.out.println("in-place sorted numbers = " + numbers);

        // 원본을 보존하고 싶으면 복사 후 정렬하세요.
        List<Integer> original = new ArrayList<>(List.of(5, 1, 3, 2, 4));
        List<Integer> sortedCopy = new ArrayList<>(original);
        Collections.sort(sortedCopy); // in-place지만 복사본을 정렬
        System.out.println("original = " + original);
        System.out.println("sortedCopy = " + sortedCopy);

        // 역순/커스텀 정렬도 Comparator로 명확히 표현합니다.
        List<String> names = new ArrayList<>(List.of("kim", "Lee", "park", "Choi"));
        names.sort(String.CASE_INSENSITIVE_ORDER.thenComparing(Comparator.naturalOrder()));
        System.out.println("sorted names (case-insensitive) = " + names);
        System.out.println();
    }

    private static void immutableFactoriesDemo() {
        System.out.println("=== 2) Immutable Factories Demo (List.of / Map.of) ===");

        List<String> fixed = List.of("A", "B", "C");
        Map<String, Integer> scores = Map.of("alice", 10, "bob", 20);

        System.out.println("fixed = " + fixed);
        System.out.println("scores = " + scores);

        try {
            fixed.add("D"); // 불변: 구조 변경 불가
        } catch (UnsupportedOperationException e) {
            System.out.println("List.of is immutable: add() throws " + e.getClass().getSimpleName());
        }

        try {
            // null도 허용하지 않습니다.
            List.of("A", null);
        } catch (NullPointerException e) {
            System.out.println("List.of does not allow null: throws " + e.getClass().getSimpleName());
        }

        System.out.println();
    }

    private static void unmodifiableVsCopyOfDemo() {
        System.out.println("=== 3) unmodifiableList vs copyOf Demo ===");

        List<String> mutable = new ArrayList<>();
        mutable.add("alpha");
        mutable.add("beta");

        List<String> view = Collections.unmodifiableList(mutable); // 읽기 전용 "뷰"
        List<String> snapshot = List.copyOf(mutable);              // 불변 "스냅샷"

        System.out.println("mutable   = " + mutable);
        System.out.println("view      = " + view);
        System.out.println("snapshot  = " + snapshot);

        // 원본 변경: view에는 반영되지만 snapshot은 그대로입니다.
        mutable.add("gamma");
        System.out.println("-- after mutable.add(\"gamma\") --");
        System.out.println("mutable   = " + mutable);
        System.out.println("view      = " + view + "  (changed!)");
        System.out.println("snapshot  = " + snapshot + "  (unchanged)");

        // view/snapshot은 수정 시도 시 예외
        try {
            view.remove(0);
        } catch (UnsupportedOperationException e) {
            System.out.println("unmodifiable view: remove() throws " + e.getClass().getSimpleName());
        }

        try {
            snapshot.set(0, "ALPHA");
        } catch (UnsupportedOperationException e) {
            System.out.println("copyOf snapshot: set() throws " + e.getClass().getSimpleName());
        }

        // copyOf는 null을 허용하지 않습니다.
        List<String> hasNull = new ArrayList<>(Arrays.asList("x", null));
        try {
            List.copyOf(hasNull);
        } catch (NullPointerException e) {
            System.out.println("List.copyOf does not allow null: throws " + e.getClass().getSimpleName());
        }

        System.out.println();
    }
}

실무 팁

💡 실무에서는
API(메서드/컨트롤러/서비스)에서 컬렉션을 반환할 때 “외부 변경 전파”를 원천 차단하려면 List.copyOf(...) 같은 방어적 복사 + 불변 반환을 기본값으로 두는 게 안전합니다. Collections.unmodifiableList(...)는 “내부 상태를 그대로 보여주되 수정만 막기”라서, 원본이 바뀌는 순간 호출자 화면/캐시/로그 값이 같이 흔들릴 수 있어요.

💡 실무에서는
정렬은 가능한 한 “정렬 기준(Comparator)”을 변수로 이름 붙여 의도를 드러내 보세요. 예를 들어 Comparator<User> BY_JOINED_AT_DESC = ...처럼요. 나중에 요구사항이 바뀌어도 “어디서 어떤 기준으로 정렬하는지” 추적이 쉬워지고, 테스트도 훨씬 명확해집니다.


정리하면, 정렬은 원본 변경 여부를 먼저 결정하고, 불변 컬렉션은 of(상수) / copyOf(스냅샷) / unmodifiable(뷰) 중 의도에 맞게 고르시면 됩니다.
unmodifiable은 안전해 보이지만 “원본 변경이 보이는 뷰”라는 점이 핵심 함정입니다.
Java 17에서도 이 조합만 잘 써도 컬렉션 관련 버그가 눈에 띄게 줄어듭니다.

다음 글: #16 제네릭 기초 — 타입 안전성의 시작