Java의 불변 컬렉션: List.of, copyOf, unmodifiable

1. 개요

Java에는 “수정할 수 없는” 컬렉션을 만드는 여러 가지 방법이 있습니다: List.of, List.copyOf, Collections.unmodifiableList, Stream.toList()… 겉보기에는 비슷해 보이지만 세 가지 측면에서 다릅니다: 데이터를 복사하는지 여부, null을 허용하는지 여부, 그리고 원본 컬렉션의 변경을 반영하는지 여부.

이 글에서는 각 방법을 구체적인 예제와 함께 살펴봅니다. 읽은 후에는 올바른 API를 선택할 수 있고, 예기치 않은 NullPointerException을 피할 수 있으며, 정확한 defensive copy를 작성할 수 있을 것입니다.

2. 불변 컬렉션이 필요한 이유

다음과 같은 Order 클래스를 살펴보세요:

import java.util.List;

public class Order {
    private final List<String> items;

    public Order(List<String> items) {
        this.items = items;
    }

    public List<String> getItems() {
        return items;
    }
}

final 키워드는 참조만 고정합니다. 내용은 고정되지 않습니다. 외부에서 여전히 수정할 수 있습니다:

List<String> items = new ArrayList<>(List.of("Laptop"));
Order order = new Order(items);

items.add("Mouse");              // 원본 list 수정
order.getItems().add("Keyboard"); // getter를 통해 수정

System.out.println(order.getItems());
[Laptop, Mouse, Keyboard]

Order는 자신의 상태를 더 이상 제어할 수 없습니다. 객체가 여러 스레드 간에 공유될 때 이런 종류의 버그는 찾기가 더 어렵습니다. 불변 컬렉션이 이 문제를 해결합니다: 아무도 수정할 수 없으므로 동기화 없이 안전하게 공유할 수 있습니다.

3. List.of, Set.of와 Map.of

Java 9부터는 factory 메서드 of로 한 줄에 수정할 수 없는 컬렉션을 만들 수 있습니다:

List<String> statuses = List.of("NEW", "PAID", "SHIPPED");
Set<String> currencies = Set.of("VND", "USD", "EUR");
Map<String, Integer> stock = Map.of("Laptop", 5, "Mouse", 20);

statuses.add("CANCELLED");
Exception in thread "main" java.lang.UnsupportedOperationException

add, remove, set, clear, sort, put 같은 데이터를 변경하는 모든 메서드는 UnsupportedOperationException을 던집니다. 이 예외는 항상 던져집니다. 실제로 데이터를 변경하지 않는 경우에도 말입니다. 예를 들어 존재하지 않는 요소를 remove할 때도요.

Map.of는 10개 쌍까지만 overload 메서드가 있습니다. 더 많이 필요하면 Map.ofEntries를 사용하세요:

Map<String, Integer> prices = Map.ofEntries(
        Map.entry("Laptop", 25_000_000),
        Map.entry("Mouse", 300_000)
);

3.1. null을 허용하지 않음

of로 생성한 컬렉션은 초기화 시점에 null을 거부합니다:

List<String> names = List.of("An", null);
Exception in thread "main" java.lang.NullPointerException

더 주의할 점은: 조회 조차도 null을 거부합니다.

List<String> names = List.of("An", "Bình");
System.out.println(names.contains(null));
Exception in thread "main" java.lang.NullPointerException

ArrayList를 사용하면 위 호출은 false를 반환합니다. 기존 코드에서 list.contains(input)을 사용하고 input이 null일 수 있다면, List.of로 전환할 때 이 코드는 runtime에 깨질 것입니다.

3.2. Set과 Map: 중복 및 순서

Set.of와 Map.of는 중복 요소를 조용히 무시하지 않고 오류를 던집니다:

Set<String> tags = Set.of("java", "spring", "java");
Exception in thread "main" java.lang.IllegalArgumentException: duplicate element: java

Set.of와 Map.of의 순회 순서는 보장되지 않습니다. OpenJDK에서는 이 순서가 JVM을 실행할 때마다 변합니다. 따라서 Set.of의 toString()을 비교하는 테스트는 때로는 통과하고 때로는 실패할 수 있습니다. 순서를 유지해야 하면 LinkedHashSet이나 LinkedHashMap을 사용한 후 감싸야 합니다. 섹션 5를 참고하세요.

4. List.copyOf: 수정할 수 없는 복사본

List.of는 요소를 미리 알고 있을 때 좋습니다. 이미 있는 컬렉션이 있을 때, 예를 들어 생성자의 매개변수처럼, List.copyOf를 사용합니다 (Java 10부터):

List<String> source = new ArrayList<>(List.of("Laptop", "Mouse"));
List<String> copy = List.copyOf(source);

source.add("Keyboard");

System.out.println(source);
System.out.println(copy);
[Laptop, Mouse, Keyboard]
[Laptop, Mouse]

copy는 독립적인 복사본입니다. source의 변경은 영향을 주지 않습니다. List.of처럼 copyOf도 컬렉션이 null을 포함하면 NullPointerException을 던집니다.

흥미로운 세부 사항 하나: 입력이 이미 List.of나 List.copyOf로 만든 불변 list라면, OpenJDK는 그 인스턴스를 그대로 반환하고 다시 복사하지 않습니다:

List<String> a = List.of("x", "y");
System.out.println(List.copyOf(a) == a);
true

따라서 copyOf를 “확인차” 호출하는 것은 거의 비용이 들지 않습니다. 다만 이것은 구현 세부 사항이므로 == 비교에 로직을 짜지 마세요.

마찬가지로 Set.copyOf와 Map.copyOf도 있습니다. 특히 Set.copyOf는 소스에 중복 요소가 있어도 오류를 던지지 않고 하나만 유지합니다. 소스가 List일 수도 있기 때문입니다.

5. Collections.unmodifiableList: 단지 뷰일 뿐

Collections.unmodifiableList는 Java 1.2부터 있으며 완전히 다르게 작동합니다. 복사하지 않고 쓰기를 차단하는 래퍼로 원본 컬렉션을 감쌉니다:

List<String> source = new ArrayList<>(List.of("Laptop", "Mouse"));
List<String> view = Collections.unmodifiableList(source);

source.add("Keyboard");

System.out.println(view);
view.add("Monitor");
[Laptop, Mouse, Keyboard]
Exception in thread "main" java.lang.UnsupportedOperationException

view를 통해서는 수정할 수 없지만, source 참조를 가진 사람은 여전히 수정할 수 있고, 변경 사항은 view에 즉시 반영됩니다. 따라서 “unmodifiable”은 “이 참조를 통해서는 수정할 수 없다”는 의미이지 “불변이다”라는 의미가 아닙니다.

이 뷰 동작은 호출자가 최신 데이터를 보기를 원하지만 수정은 못하게 할 때 유용합니다:

public class ShoppingCart {
    private final List<String> items = new ArrayList<>();

    public void addItem(String item) {
        items.add(item);
    }

    public List<String> getItems() {
        return Collections.unmodifiableList(items);
    }
}

getter는 복사 없이 뷰를 반환하므로 비용은 O(1)입니다. 호출자는 읽기만 가능하고, ShoppingCart는 내부적으로 완전히 수정할 수 있습니다.

마지막 차이점: 이 뷰는 null을 허용합니다. contains(null) 호출은 오류를 던지지 않고 false를 반환합니다.

6. 다른 선택지와 비교표

혼동하기 쉬운 API가 더 있습니다:

// Java 16+: 결과는 수정 불가, null 허용
List<String> a = Stream.of("A", null).toList();

// Java 10+: 결과는 수정 불가, null 거부
List<String> b = Stream.of("A", "B").collect(Collectors.toUnmodifiableList());

// 크기는 고정이지만 set() 가능, 배열에 직접 쓰임
String[] arr = {"A", "B"};
List<String> c = Arrays.asList(arr);
c.set(0, "Z");
System.out.println(arr[0]); // Z

요약 표:

API 복사? null 소스 변경 반영? 수정 가능?
List.of(...) 소스 없음 NPE 던짐 적용 안 함 불가
List.copyOf(c) 예 NPE 던짐 불가 불가
Collections.unmodifiableList(l) 아니오 (뷰) 허용 예 뷰 통해 불가
stream.toList() 예 허용 불가 불가
Collectors.toUnmodifiableList() 예 NPE 던짐 불가 불가
Arrays.asList(arr) 아니오 (배열 뷰) 허용 예 set만 가능

7. 얕은 불변성 (shallow immutability)

위 모든 API는 컬렉션의 구조만 잠급니다. 내부 요소는 잠그지 않습니다:

public class OrderLine {
    private int quantity;

    public OrderLine(int quantity) { this.quantity = quantity; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }
}

List<OrderLine> lines = List.of(new OrderLine(1));
lines.get(0).setQuantity(99);
System.out.println(lines.get(0).getQuantity());
99

요소를 추가하거나 제거할 수는 없지만, 각 요소의 내용은 여전히 수정할 수 있습니다. 진정한 불변성을 원한다면 요소 자체도 불변이어야 합니다. 가장 간단한 방법은 record를 사용하는 것입니다:

public record OrderLine(String product, int quantity) {}

모두 합치면, 진정한 불변 aggregate의 표준 defensive copy 패턴입니다:

import java.util.List;

public record Order(String customer, List<OrderLine> lines) {
    public Order {
        lines = List.copyOf(lines);
    }
}

Compact constructor가 lines 매개변수를 field에 할당하기 전에 수정 불가능한 복사본으로 대체합니다. Accessor lines()는 그 복사본 자체를 반환하므로 getter에서 다시 복사할 필요가 없습니다. lines가 null이거나 null을 포함하면 객체는 생성 시점에 실패합니다. 더 먼 곳에서 오류가 나는 것보다 낫습니다.

8. 동작 테스트

pom.xml에 JUnit 5와 AssertJ를 추가합니다. Spring Boot 3.x를 사용하면 spring-boot-starter-test에 둘 다 포함되어 있습니다:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>

다음 테스트는 Order와 뷰의 세 가지 주요 동작을 확인합니다:

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class OrderTest {

    @Test
    void shouldNotReflectChangesToSourceList() {
        List<OrderLine> source = new ArrayList<>();
        source.add(new OrderLine("Laptop", 1));

        Order order = new Order("An", source);
        source.add(new OrderLine("Mouse", 2));

        assertThat(order.lines()).hasSize(1);
    }

    @Test
    void shouldRejectModificationThroughAccessor() {
        Order order = new Order("An", List.of(new OrderLine("Laptop", 1)));

        assertThatThrownBy(() -> order.lines().add(new OrderLine("Mouse", 2)))
                .isInstanceOf(UnsupportedOperationException.class);
    }

    @Test
    void unmodifiableViewShouldReflectSourceChanges() {
        List<String> source = new ArrayList<>(List.of("A"));
        List<String> view = Collections.unmodifiableList(source);

        source.add("B");

        assertThat(view).containsExactly("A", "B");
    }
}

첫 번째 테스트는 compact constructor의 List.copyOf가 Order를 원본 list에서 분리했음을 확인합니다. 두 번째 테스트는 accessor가 데이터 수정 경로를 열어주지 않는지 확인합니다. 마지막 테스트는 unmodifiableList 뷰 동작을 문서화합니다. 아무도 이것을 복사본으로 착각하지 않도록요.

9. 흔한 오류와 사용하지 말아야 할 때

주의: List.of(...).contains(null)과 indexOf(null)은 NullPointerException을 던집니다. null을 먼저 확인하거나 list를 순회할 때 Objects.equals를 사용하세요.

다른 흔한 오류들:

  • unmodifiableList가 불변이라고 생각하기. 단지 뷰일 뿐입니다. 소스가 어디선가 계속 참조되면 데이터가 변할 수 있습니다. 스냅샷이 필요하면 List.copyOf를 사용하세요.
  • collect(Collectors.toList())를 무작정 toList()로 바꾸기. Collectors.toList()는 수정 가능한 ArrayList를 반환합니다. 뒤에 결과에 add나 sort를 호출하는 코드가 있으면 runtime에 UnsupportedOperationException을 던질 것입니다.
  • 제자리에서 sort 호출하기. List.of(...).sort(...)는 항상 오류를 던집니다. 먼저 ArrayList로 복사하거나 stream().sorted().toList()를 사용해야 합니다.
  • Set.of 또는 Map.of의 순서에 의존하기. 로그, JSON, 테스트에서 말이입니다.
  • null을 포함할 수 있는 외부 소스의 데이터를 (JSON, database 결과처럼) List.copyOf에 직접 전달하기. 먼저 필터링하거나, null이 유효한 값이면 stream().toList()를 사용하세요.

사용하지 말아야 할 경우도 몇 가지 있습니다:

  • 컬렉션이 한 메서드 내 짧은 생명 주기 동안 자주 변경되는 경우, 예를 들어 결과를 모으는 버퍼. ArrayList가 간단하고 빠릅니다.
  • 컬렉션 수정이 필요한 라이브러리, 예를 들어 Hibernate와의 JPA entity. @OneToMany field는 Hibernate가 관리할 수 있도록 수정 가능한 컬렉션이어야 합니다. 내부에는 ArrayList를 유지하고 getter에서만 뷰를 반환하세요.
  • 매우 큰 컬렉션이 자주 업데이트되는 경우. 매번 “수정”이 전체 복사를 의미하면 O(n) 비용이 듭니다.

10. 결론

세 가지 주요 API는 각각 다른 목적을 제공합니다:

  • List.of는 null을 포함하지 않는 고정 크기 리스트를 빠르게 만듭니다.
  • List.copyOf는 독립적인 스냅샷을 만듭니다. 생성자나 record의 defensive copy에 기본 선택입니다.
  • Collections.unmodifiableList는 아직 변경 중인 데이터의 읽기 전용 뷰입니다. 싸지만 불변이 아닙니다.

또한 JDK의 모든 불변 컬렉션은 얕은 불변성만 제공함을 기억하세요. record 같은 불변 요소와 결합하면 스레드 간 안전하게 공유할 수 있는 진정한 불변 객체를 만들 수 있습니다.

javacollectionsimmutablejava-21clean-code