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.
@OneToManyfield는 Hibernate가 관리할 수 있도록 수정 가능한 컬렉션이어야 합니다. 내부에는ArrayList를 유지하고 getter에서만 뷰를 반환하세요. - 매우 큰 컬렉션이 자주 업데이트되는 경우. 매번 “수정”이 전체 복사를 의미하면 O(n) 비용이 듭니다.
10. 결론
세 가지 주요 API는 각각 다른 목적을 제공합니다:
List.of는null을 포함하지 않는 고정 크기 리스트를 빠르게 만듭니다.List.copyOf는 독립적인 스냅샷을 만듭니다. 생성자나record의 defensive copy에 기본 선택입니다.Collections.unmodifiableList는 아직 변경 중인 데이터의 읽기 전용 뷰입니다. 싸지만 불변이 아닙니다.
또한 JDK의 모든 불변 컬렉션은 얕은 불변성만 제공함을 기억하세요. record 같은 불변 요소와 결합하면 스레드 간 안전하게 공유할 수 있는 진정한 불변 객체를 만들 수 있습니다.