Java Collections Framework 완벽 가이드: 내부 구현부터 실전 활용까지
Java Collections Framework란 무엇인가
Java Collections Framework(JCF)는 자바에서 데이터를 저장하고 관리하기 위한 표준화된 방법을 제공하는 클래스와 인터페이스의 집합이다. JDK 1.2에서 처음 도입된 이후로 자바 프로그래밍에서 가장 핵심적인 부분 중 하나로 자리잡았다. 배열만으로는 처리하기 어려운 다양한 데이터 구조의 필요성을 해결하기 위해 설계되었으며, 개발자가 데이터 구조의 내부 구현에 신경 쓰지 않고도 효율적인 데이터 관리를 할 수 있도록 추상화된 인터페이스를 제공한다.
Collections Framework가 등장하기 전에는 개발자들이 Vector, Hashtable, Array 등을 사용했지만, 이들은 서로 일관성 없는 API를 가지고 있었고 확장성도 떨어졌다. JCF는 이러한 문제를 해결하기 위해 통일된 아키텍처를 제공하며, 모든 컬렉션 타입에 대해 일관된 방식으로 작업할 수 있게 해준다. 예를 들어, List든 Set이든 동일한 방식으로 순회하거나 요소를 추가할 수 있다.
프레임워크의 핵심 목표는 크게 네 가지로 요약할 수 있다. 첫째, 프로그래밍 노력을 줄여주는 것이다. 이미 구현된 데이터 구조를 사용함으로써 개발자는 비즈니스 로직에 집중할 수 있다. 둘째, 프로그램의 성능을 향상시킨다. 각 컬렉션은 특정 사용 패턴에 최적화되어 있으며, 고도로 최적화된 알고리즘을 사용한다. 셋째, 서로 관련 없는 API들 간의 상호 운용성을 제공한다. 공통 인터페이스를 통해 다양한 컬렉션 타입을 일관되게 다룰 수 있다. 넷째, 새로운 API를 배우는 데 필요한 노력을 줄여준다. 일단 프레임워크의 기본 구조를 이해하면 새로운 컬렉션 타입도 쉽게 사용할 수 있다.
Collection 인터페이스 계층 구조
JCF의 핵심은 인터페이스 계층 구조에 있다. 최상위에는 Iterable 인터페이스가 있으며, 그 아래에 Collection 인터페이스가 위치한다. Collection 인터페이스는 다시 List, Set, Queue 세 가지 주요 인터페이스로 분기한다. 이와 별도로 Map 인터페이스가 존재하는데, Map은 Collection을 상속하지 않지만 Collections Framework의 일부로 간주된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Collection 인터페이스 계층 구조
Iterable<E>
└── Collection<E>
├── List<E>
│ ├── ArrayList<E>
│ ├── LinkedList<E>
│ └── Vector<E>
│ └── Stack<E>
│
├── Set<E>
│ ├── HashSet<E>
│ ├── LinkedHashSet<E>
│ └── SortedSet<E>
│ └── TreeSet<E>
│
└── Queue<E>
├── PriorityQueue<E>
└── Deque<E>
└── ArrayDeque<E>
Map<K,V> (별도 계층)
├── HashMap<K,V>
├── LinkedHashMap<K,V>
├── Hashtable<K,V>
└── SortedMap<K,V>
└── TreeMap<K,V>
각 인터페이스는 고유한 특성을 가진다. List는 순서가 있고 중복을 허용하는 컬렉션이다. 인덱스를 통해 요소에 접근할 수 있으며, 같은 요소를 여러 번 추가할 수 있다. Set은 중복을 허용하지 않는 컬렉션이다. 수학적 집합 개념을 구현하며, 동일한 요소는 하나만 저장된다. Queue는 FIFO(First-In-First-Out) 방식으로 요소를 처리하는 컬렉션이다. 대기열이나 작업 큐를 구현할 때 사용된다. Map은 키-값 쌍으로 데이터를 저장하는 컬렉션이다. 각 키는 고유해야 하며, 하나의 키에는 하나의 값만 매핑된다.
List 인터페이스 심층 분석
List 인터페이스는 순서가 있는 컬렉션을 정의하며, 사용자가 요소가 삽입되는 위치를 정밀하게 제어할 수 있다. 인덱스를 통해 요소에 접근하고, 리스트 내에서 요소를 검색할 수 있다. 배열과 달리 크기가 동적으로 변할 수 있으며, 다양한 타입의 객체를 저장할 수 있다.
ArrayList: 동적 배열의 강자
ArrayList는 내부적으로 Object 배열을 사용하여 요소를 저장한다. 크기가 고정된 배열과 달리 필요에 따라 자동으로 크기가 증가한다. 이 클래스는 List 인터페이스의 가장 일반적인 구현체이며, 대부분의 상황에서 좋은 성능을 제공한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, Serializable {
// 기본 초기 용량
private static final int DEFAULT_CAPACITY = 10;
// 실제 데이터를 저장하는 배열
transient Object[] elementData;
// 현재 저장된 요소의 개수
private int size;
// 기본 생성자
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
// 초기 용량을 지정하는 생성자
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
}
}
}
ArrayList의 핵심 동작 원리를 이해하려면 배열 확장 메커니즘을 알아야 한다. 요소를 추가할 때 현재 배열의 용량이 부족하면 새로운 배열을 생성하고 기존 요소들을 복사한다. 이 과정에서 기존 용량의 1.5배 크기로 새 배열을 만든다. 이러한 방식은 메모리 할당 횟수를 줄여 전체적인 성능을 향상시킨다.
1
2
3
4
5
6
7
8
9
// ArrayList 내부의 grow 메서드 (단순화된 버전)
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
// 새 용량 = 기존 용량 + (기존 용량 / 2) = 1.5배
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
elementData = Arrays.copyOf(elementData, newCapacity);
}
ArrayList의 시간 복잡도를 살펴보면, 인덱스를 통한 접근(get, set)은 O(1)이다. 내부적으로 배열이므로 인덱스로 직접 접근할 수 있기 때문이다. 끝에 요소를 추가하는 작업(add)은 분할 상환 분석으로 O(1)이다. 배열 확장이 필요한 경우 O(n)이 들지만, 이런 일이 드물게 발생하므로 평균적으로는 상수 시간이다. 중간에 요소를 삽입하거나 삭제하는 작업은 O(n)이다. 해당 위치 이후의 모든 요소를 이동시켜야 하기 때문이다. 검색(contains, indexOf)은 O(n)이다. 순차적으로 요소를 비교해야 하기 때문이다.
LinkedList: 양방향 연결 리스트
LinkedList는 이중 연결 리스트로 구현되어 있다. 각 요소는 이전 요소와 다음 요소에 대한 참조를 가진 노드 형태로 저장된다. 이 구조는 요소의 삽입과 삭제가 빈번한 경우에 유리하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class LinkedList<E> extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, Serializable {
transient int size = 0;
transient Node<E> first; // 첫 번째 노드
transient Node<E> last; // 마지막 노드
// 노드 내부 클래스
private static class Node<E> {
E item; // 실제 데이터
Node<E> next; // 다음 노드 참조
Node<E> prev; // 이전 노드 참조
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
}
LinkedList의 시간 복잡도는 ArrayList와 상당히 다르다. 인덱스를 통한 접근은 O(n)이다. 원하는 위치까지 노드를 순회해야 하기 때문이다. 다만, 인덱스가 리스트 크기의 절반보다 크면 끝에서부터 역순으로 탐색하여 약간의 최적화가 이루어진다. 리스트의 시작이나 끝에서의 삽입/삭제는 O(1)이다. first나 last 참조만 변경하면 되기 때문이다. 중간에서의 삽입/삭제는 이론적으로 O(1)이지만, 해당 위치를 찾는 데 O(n)이 소요되므로 전체적으로는 O(n)이다.
ArrayList vs LinkedList: 언제 무엇을 사용할까
두 구현체의 선택은 사용 패턴에 따라 달라진다. ArrayList는 다음 상황에서 유리하다. 요소에 대한 랜덤 접근이 빈번한 경우, 주로 리스트 끝에 요소를 추가하는 경우, 메모리 사용량을 최소화해야 하는 경우(LinkedList는 노드당 추가 참조를 저장하므로 더 많은 메모리를 사용한다).
LinkedList는 다음 상황에서 유리하다. 리스트의 시작이나 중간에서 빈번한 삽입/삭제가 일어나는 경우, Queue나 Deque로 사용하는 경우(LinkedList는 Deque 인터페이스도 구현한다), Iterator를 사용하면서 요소를 삭제하는 경우.
1
2
3
4
5
6
7
8
9
10
11
12
13
// ArrayList가 유리한 경우: 랜덤 접근
List<Integer> arrayList = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
arrayList.add(i);
}
// O(1) 접근
int value = arrayList.get(50000);
// LinkedList가 유리한 경우: 앞에서 삽입/삭제
LinkedList<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < 1000; i++) {
linkedList.addFirst(i); // O(1)
}
Set 인터페이스 심층 분석
Set 인터페이스는 중복 요소를 허용하지 않는 컬렉션을 정의한다. 수학적 집합(Set) 개념을 추상화한 것으로, 합집합, 교집합, 차집합 등의 집합 연산을 수행할 수 있다. 요소의 존재 여부를 빠르게 확인할 수 있어 중복 검사에 자주 사용된다.
HashSet: 해시 테이블 기반 집합
HashSet은 내부적으로 HashMap을 사용하여 요소를 저장한다. 요소 자체가 HashMap의 키가 되고, 값은 더미 객체(PRESENT)가 된다. 해시 테이블의 특성을 그대로 이어받아 평균적으로 O(1) 시간에 추가, 삭제, 검색 작업을 수행할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class HashSet<E> extends AbstractSet<E>
implements Set<E>, Cloneable, Serializable {
private transient HashMap<E, Object> map;
// 모든 값에 매핑되는 더미 객체
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<>();
}
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
public boolean contains(Object o) {
return map.containsKey(o);
}
}
HashSet이 제대로 동작하려면 저장되는 객체의 hashCode()와 equals() 메서드가 올바르게 구현되어야 한다. 해시 코드는 객체를 버킷에 배치하는 데 사용되고, equals()는 같은 버킷 내에서 실제로 동일한 객체인지 확인하는 데 사용된다. 이 두 메서드가 일관성 있게 구현되지 않으면 예상치 못한 동작이 발생할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Person {
private String name;
private int age;
// equals와 hashCode를 올바르게 오버라이드
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && Objects.equals(name, person.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
// 사용 예시
Set<Person> people = new HashSet<>();
people.add(new Person("Kim", 25));
people.add(new Person("Kim", 25)); // 중복으로 추가되지 않음
System.out.println(people.size()); // 1
LinkedHashSet: 순서를 유지하는 HashSet
LinkedHashSet은 HashSet을 상속하면서 요소의 삽입 순서를 유지한다. 내부적으로 LinkedHashMap을 사용하여 이중 연결 리스트로 순서를 관리한다. HashSet의 성능을 유지하면서도 예측 가능한 순서로 요소를 순회할 수 있다.
1
2
3
4
5
6
7
8
9
Set<String> linkedHashSet = new LinkedHashSet<>();
linkedHashSet.add("C");
linkedHashSet.add("A");
linkedHashSet.add("B");
// 삽입 순서대로 출력: C, A, B
for (String s : linkedHashSet) {
System.out.println(s);
}
TreeSet: 정렬된 집합
TreeSet은 레드-블랙 트리를 기반으로 구현된 SortedSet이다. 요소들이 자연 순서(natural ordering) 또는 생성 시 제공된 Comparator에 따라 정렬된 상태로 유지된다. 정렬된 상태를 유지해야 하므로 추가, 삭제, 검색 작업에 O(log n) 시간이 소요된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 자연 순서 사용
Set<Integer> treeSet = new TreeSet<>();
treeSet.add(5);
treeSet.add(1);
treeSet.add(3);
// 정렬된 순서로 출력: 1, 3, 5
for (Integer i : treeSet) {
System.out.println(i);
}
// Comparator를 사용한 역순 정렬
Set<Integer> reverseSet = new TreeSet<>(Comparator.reverseOrder());
reverseSet.addAll(Arrays.asList(5, 1, 3));
// 역순으로 출력: 5, 3, 1
TreeSet은 SortedSet과 NavigableSet 인터페이스를 구현하여 범위 검색이나 가장 가까운 요소 찾기 등의 추가 기능을 제공한다.
1
2
3
4
5
6
7
8
9
10
TreeSet<Integer> numbers = new TreeSet<>(Arrays.asList(1, 3, 5, 7, 9));
// 부분 집합 조회
SortedSet<Integer> subset = numbers.subSet(3, 7); // [3, 5]
// 가장 가까운 요소 찾기
Integer floor = numbers.floor(4); // 4 이하 중 가장 큰 값: 3
Integer ceiling = numbers.ceiling(4); // 4 이상 중 가장 작은 값: 5
Integer lower = numbers.lower(5); // 5 미만 중 가장 큰 값: 3
Integer higher = numbers.higher(5); // 5 초과 중 가장 작은 값: 7
Map 인터페이스 심층 분석
Map은 키-값 쌍을 저장하는 컬렉션으로, Collection 인터페이스를 상속하지 않지만 Collections Framework의 핵심 구성요소다. 각 키는 고유해야 하며, 하나의 키에는 하나의 값만 매핑된다. 사전(dictionary)이나 연관 배열(associative array)과 유사한 개념이다.
HashMap: 해시 테이블 기반 맵
HashMap은 가장 널리 사용되는 Map 구현체로, 해시 테이블을 사용하여 키-값 쌍을 저장한다. 평균적으로 O(1) 시간에 키를 통해 값에 접근할 수 있어 매우 빠른 성능을 제공한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class HashMap<K, V> extends AbstractMap<K, V>
implements Map<K, V>, Cloneable, Serializable {
// 기본 초기 용량 (16)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 최대 용량
static final int MAXIMUM_CAPACITY = 1 << 30;
// 기본 로드 팩터 (0.75)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 버킷이 트리로 변환되는 임계값 (8)
static final int TREEIFY_THRESHOLD = 8;
// 해시 버킷 배열
transient Node<K,V>[] table;
// 저장된 키-값 쌍의 개수
transient int size;
// 노드 클래스
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
}
HashMap의 동작 원리는 다음과 같다. 키의 hashCode()를 계산하고, 이를 기반으로 버킷(배열 인덱스)을 결정한다. 같은 버킷에 여러 키가 매핑될 수 있는데(해시 충돌), 이 경우 연결 리스트나 트리 구조로 관리한다. Java 8부터는 같은 버킷에 8개 이상의 요소가 들어가면 연결 리스트를 레드-블랙 트리로 변환하여 최악의 경우에도 O(log n) 성능을 보장한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// HashMap 사용 예시
Map<String, Integer> scores = new HashMap<>();
// 값 추가
scores.put("Alice", 95);
scores.put("Bob", 87);
scores.put("Charlie", 92);
// 값 조회
int aliceScore = scores.get("Alice"); // 95
// 키 존재 여부 확인
boolean hasAlice = scores.containsKey("Alice"); // true
// 값 존재 여부 확인
boolean has95 = scores.containsValue(95); // true
// 기본값과 함께 조회
int davidScore = scores.getOrDefault("David", 0); // 0
// 순회
for (Map.Entry<String, Integer> entry : scores.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
로드 팩터(load factor)는 HashMap의 성능에 중요한 영향을 미친다. 로드 팩터는 버킷 배열이 얼마나 채워졌을 때 크기를 늘릴지를 결정한다. 기본값은 0.75로, 배열의 75%가 채워지면 크기를 두 배로 늘린다. 로드 팩터가 높으면 메모리는 절약되지만 해시 충돌이 많아져 성능이 저하된다. 반대로 로드 팩터가 낮으면 해시 충돌은 줄지만 메모리를 더 많이 사용한다.
LinkedHashMap: 순서를 유지하는 HashMap
LinkedHashMap은 HashMap을 상속하면서 삽입 순서 또는 접근 순서를 유지한다. 내부적으로 이중 연결 리스트를 사용하여 순서를 관리하며, 캐시 구현 등에 유용하게 사용된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 삽입 순서 유지 (기본)
Map<String, Integer> linkedMap = new LinkedHashMap<>();
linkedMap.put("C", 3);
linkedMap.put("A", 1);
linkedMap.put("B", 2);
// 삽입 순서대로 출력: C, A, B
// 접근 순서 유지 (accessOrder = true)
Map<String, Integer> accessOrderMap = new LinkedHashMap<>(16, 0.75f, true);
accessOrderMap.put("A", 1);
accessOrderMap.put("B", 2);
accessOrderMap.put("C", 3);
accessOrderMap.get("A"); // A에 접근
// 순회하면 B, C, A 순서 (A가 가장 최근 접근)
LinkedHashMap의 접근 순서 모드는 LRU(Least Recently Used) 캐시를 구현하는 데 활용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// LRU 캐시 구현
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LRUCache(int maxSize) {
super(16, 0.75f, true); // accessOrder = true
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize;
}
}
// 사용 예시
LRUCache<String, String> cache = new LRUCache<>(3);
cache.put("a", "1");
cache.put("b", "2");
cache.put("c", "3");
cache.get("a"); // a 접근
cache.put("d", "4"); // 가장 오래된 b가 제거됨
TreeMap: 정렬된 맵
TreeMap은 레드-블랙 트리를 기반으로 구현된 SortedMap이다. 키가 정렬된 상태로 유지되며, 모든 작업에 O(log n) 시간이 소요된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 자연 순서로 정렬
Map<String, Integer> treeMap = new TreeMap<>();
treeMap.put("banana", 2);
treeMap.put("apple", 1);
treeMap.put("cherry", 3);
// 키가 알파벳 순으로 정렬됨: apple, banana, cherry
// 범위 검색
TreeMap<Integer, String> numbers = new TreeMap<>();
numbers.put(1, "one");
numbers.put(3, "three");
numbers.put(5, "five");
numbers.put(7, "seven");
// 3 이상 6 미만의 엔트리
SortedMap<Integer, String> subMap = numbers.subMap(3, 6); // {3=three, 5=five}
// 5 이하의 엔트리
SortedMap<Integer, String> headMap = numbers.headMap(6); // {1=one, 3=three, 5=five}
// 5 이상의 엔트리
SortedMap<Integer, String> tailMap = numbers.tailMap(5); // {5=five, 7=seven}
동시성과 Collections
기본 컬렉션 클래스들은 스레드 세이프하지 않다. 멀티스레드 환경에서 안전하게 사용하려면 동기화된 래퍼를 사용하거나, java.util.concurrent 패키지의 동시성 컬렉션을 사용해야 한다.
동기화 래퍼
Collections 유틸리티 클래스는 기존 컬렉션을 동기화된 버전으로 감싸는 메서드를 제공한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 동기화된 List
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
// 동기화된 Set
Set<String> syncSet = Collections.synchronizedSet(new HashSet<>());
// 동기화된 Map
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
// 주의: 순회 시에는 명시적으로 동기화해야 함
synchronized (syncList) {
for (String item : syncList) {
System.out.println(item);
}
}
동시성 컬렉션
java.util.concurrent 패키지는 더 효율적인 동시성 컬렉션을 제공한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// ConcurrentHashMap - 분할 잠금으로 높은 동시성 제공
Map<String, Integer> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("key", 1);
concurrentMap.computeIfAbsent("newKey", k -> 100);
// CopyOnWriteArrayList - 읽기가 많고 쓰기가 적은 경우에 적합
List<String> cowList = new CopyOnWriteArrayList<>();
cowList.add("item");
// BlockingQueue - 생산자-소비자 패턴에 적합
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
queue.put("item"); // 큐가 가득 차면 대기
String item = queue.take(); // 큐가 비어있으면 대기
실전 활용 팁
적절한 초기 용량 설정
컬렉션의 예상 크기를 알고 있다면 초기 용량을 설정하여 불필요한 리사이징을 피할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 나쁜 예: 기본 용량으로 시작
List<String> list = new ArrayList<>(); // 기본 용량 10
for (int i = 0; i < 10000; i++) {
list.add("item" + i); // 여러 번 리사이징 발생
}
// 좋은 예: 예상 크기로 초기화
List<String> list = new ArrayList<>(10000);
for (int i = 0; i < 10000; i++) {
list.add("item" + i); // 리사이징 없음
}
// HashMap의 경우 로드 팩터 고려
// 10000개를 저장하려면 10000 / 0.75 ≈ 13334 이상의 용량 필요
Map<String, Integer> map = new HashMap<>(13334);
불변 컬렉션 활용
Java 9부터 불변 컬렉션을 쉽게 생성할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
// 불변 List
List<String> immutableList = List.of("a", "b", "c");
// 불변 Set
Set<Integer> immutableSet = Set.of(1, 2, 3);
// 불변 Map
Map<String, Integer> immutableMap = Map.of("a", 1, "b", 2);
// 기존 컬렉션의 불변 복사본
List<String> copy = List.copyOf(existingList);
스트림과의 통합
Java 8의 Stream API와 컬렉션을 함께 사용하면 선언적이고 효율적인 데이터 처리가 가능하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
List<Person> people = Arrays.asList(
new Person("Alice", 25),
new Person("Bob", 30),
new Person("Charlie", 20)
);
// 나이가 25 이상인 사람들의 이름을 정렬하여 리스트로
List<String> names = people.stream()
.filter(p -> p.getAge() >= 25)
.map(Person::getName)
.sorted()
.collect(Collectors.toList());
// 이름별로 그룹화
Map<String, List<Person>> byName = people.stream()
.collect(Collectors.groupingBy(Person::getName));
// 나이 합계
int totalAge = people.stream()
.mapToInt(Person::getAge)
.sum();
면접 대비 핵심 정리
Collections Framework 관련 면접 질문에서 자주 나오는 주제들을 정리하면 다음과 같다.
ArrayList와 LinkedList의 차이점은 내부 구현과 성능 특성이다. ArrayList는 동적 배열로 랜덤 접근이 O(1)이고, LinkedList는 이중 연결 리스트로 삽입/삭제가 유리하다.
HashSet과 TreeSet의 차이점은 순서와 성능이다. HashSet은 순서를 보장하지 않고 O(1) 성능을 제공하며, TreeSet은 정렬된 순서를 유지하고 O(log n) 성능을 제공한다.
HashMap과 Hashtable의 차이점은 동기화와 null 허용 여부다. HashMap은 동기화되지 않고 null 키/값을 허용하며, Hashtable은 동기화되고 null을 허용하지 않는다.
fail-fast와 fail-safe의 차이점은 순회 중 변경에 대한 대응 방식이다. fail-fast 이터레이터는 순회 중 컬렉션이 변경되면 ConcurrentModificationException을 던지고, fail-safe는 원본의 복사본을 순회하여 예외가 발생하지 않는다.
hashCode()와 equals()의 계약은 두 객체가 equals()로 같으면 hashCode()도 같아야 한다는 것이다. 이 계약이 지켜지지 않으면 HashSet이나 HashMap에서 예상치 못한 동작이 발생한다.
Java Collections Framework를 깊이 이해하면 적절한 자료구조를 선택하여 애플리케이션의 성능을 최적화할 수 있다. 각 컬렉션의 내부 구현과 시간 복잡도를 알고 있으면, 상황에 맞는 최선의 선택을 할 수 있다.