컬렉션 프레임워크 — List, Set, Map 제대로 쓰기
배열을 쓰다 보면 한 가지 답답한 순간이 온다. "크기를 미리 정해야 한다고?" 데이터가 몇 개 들어올지도 모르는데, 왜 처음부터 칸 수를 정해놔야 하는 걸까? 이 질문에서 출발하면 컬렉션 프레임워크가 왜 필요한지 자연스럽게 이해된다.
배열의 한계, 컬렉션이 필요한 이유
배열은 Java에서 가장 기본적인 자료구조다. 하지만 실무에서 쓰다 보면 금방 한계에 부딪힌다.
// 배열: 크기를 미리 정해야 한다
String[] names = new String[3];
names[0] = "Alice";
names[1] = "Bob";
names[2] = "Charlie";
// names[3] = "Dave"; // ArrayIndexOutOfBoundsException!
배열의 문제점:
- 고정 크기: 한번 생성하면 늘릴 수 없다. 더 큰 배열을 만들어서 복사해야 한다.
- 삽입/삭제 불편: 중간에 요소를 넣거나 빼려면 직접 밀고 당겨야 한다.
- 기능 부족: "이 값이 있는지 확인해줘"같은 기본 기능도 직접 반복문을 돌려야 한다.
이런 불편을 해결하기 위해 Java는 **컬렉션 프레임워크(Collection Framework)**를 제공한다. 크기가 자동으로 늘어나고, 삽입·삭제·검색 같은 기능이 메서드로 정리되어 있다.
컬렉션 프레임워크 구조
컬렉션 프레임워크는 인터페이스 계층으로 설계되어 있다. 크게 세 갈래로 나뉜다.
Iterable
└── Collection
├── List — 순서 있음, 중복 허용
├── Set — 순서 없음, 중복 불허
└── Queue — FIFO 구조
Map (별도 계층) — 키-값 쌍, 키 중복 불허
핵심 구현체를 정리하면 이렇다.
| 인터페이스 | 주요 구현체 | 특징 |
|---|---|---|
List | ArrayList, LinkedList | 인덱스로 접근, 순서 보장 |
Set | HashSet, TreeSet, LinkedHashSet | 중복 제거 |
Map | HashMap, TreeMap, LinkedHashMap | 키로 값 조회 |
공부하다 보니 "왜 인터페이스로 분리해놓았을까?"라는 의문이 들었는데, 이유는 간단하다. 용도에 맞는 구현체를 바꿔 끼울 수 있게 하기 위해서다.
// 인터페이스 타입으로 선언 → 구현체를 바꿔도 나머지 코드는 안 바뀐다
List<String> names = new ArrayList<>();
// 나중에 LinkedList로 바꿔야 한다면?
// List<String> names = new LinkedList<>();
List — ArrayList
ArrayList는 가장 많이 쓰는 컬렉션이다. 내부적으로 배열을 사용하지만, 크기가 자동으로 늘어난다.
기본 사용법
List<String> fruits = new ArrayList<>();
fruits.add("사과");
fruits.add("바나나");
fruits.add("체리");
fruits.get(0); // "사과" — 인덱스로 접근
fruits.size(); // 3
fruits.set(1, "블루베리"); // 수정
fruits.remove(2); // 인덱스로 삭제
fruits.remove("사과"); // 값으로 삭제
fruits.contains("블루베리"); // true
내부 구조: 동적 배열
ArrayList의 핵심은 내부 배열(Object[])이 꽉 차면 더 큰 배열을 새로 만들어서 복사하는 것이다.
초기 상태 (기본 용량 10):
[사과][바나나][체리][ ][ ][ ][ ][ ][ ][ ]
↑ 여유 공간
꽉 찼을 때 → 새 배열(기존의 1.5배) 생성 후 복사:
[사과][바나나][체리][딸기][...][포도][ ][ ][ ][ ][ ]
↑ 새 여유 공간
이걸 이해하면 시간복잡도가 왜 그렇게 되는지 바로 납득이 된다.
ArrayList 시간복잡도
| 연산 | 시간복잡도 | 이유 |
|---|---|---|
get(index) | O(1) | 배열이니까 인덱스로 바로 접근 |
add(element) — 끝에 추가 | O(1) 평균 | 가끔 배열 복사가 일어나면 O(n) |
add(index, element) — 중간 삽입 | O(n) | 뒤의 요소를 전부 밀어야 함 |
remove(index) | O(n) | 뒤의 요소를 전부 당겨야 함 |
contains(element) | O(n) | 처음부터 끝까지 순회 |
핵심 포인트: ArrayList는 읽기가 빠르고, 중간 삽입/삭제가 느리다.
List — LinkedList
LinkedList는 노드(Node)가 다음 노드를 가리키는 체인 구조다. ArrayList와 정반대의 성능 특성을 가진다.
내부 구조: 이중 연결 리스트
[null|사과|→] ←→ [←|바나나|→] ←→ [←|체리|null]
prev data next
각 노드가 이전(prev)과 다음(next) 노드의 참조를 가지고 있다. 그래서 중간에 끼워넣거나 빼는 건 참조만 바꾸면 된다.
LinkedList<String> names = new LinkedList<>();
names.add("Alice");
names.add("Bob");
names.add("Charlie");
// 맨 앞에 추가 — ArrayList보다 빠르다
names.addFirst("Zara");
System.out.println(names); // [Zara, Alice, Bob, Charlie]
ArrayList vs LinkedList 비교
| 연산 | ArrayList | LinkedList |
|---|---|---|
인덱스 접근 get(i) | O(1) | O(n) |
끝에 추가 add(e) | O(1) 평균 | O(1) |
중간 삽입 add(i, e) | O(n) | O(n)* |
중간 삭제 remove(i) | O(n) | O(n)* |
| 메모리 | 연속 배열 (캐시 친화적) | 노드마다 prev/next 참조 (오버헤드) |
*LinkedList의 중간 삽입/삭제가 O(n)인 이유: 삽입 자체는 O(1)이지만, 해당 위치를 찾아가는 데 O(n)이 걸린다.
그래서 결론이 뭐냐면, 대부분의 경우 ArrayList를 쓰면 된다. LinkedList가 더 나은 상황은 생각보다 드물다. 데이터를 순차적으로 읽는 작업이 많다면 ArrayList의 캐시 친화성이 훨씬 유리하다.
Set — HashSet
Set은 중복을 허용하지 않는 컬렉션이다. 가장 많이 쓰는 구현체는 HashSet이다.
기본 사용법
Set<String> languages = new HashSet<>();
languages.add("Java");
languages.add("Python");
languages.add("Java"); // 중복 → 무시됨
languages.size(); // 2
languages.contains("Java"); // true — O(1)
중복 판단 기준: hashCode() + equals()
HashSet이 중복을 판단하는 과정은 두 단계다.
1단계: hashCode()로 버킷(저장 위치) 결정
↓
2단계: 같은 버킷에 이미 요소가 있으면 equals()로 비교
↓
같으면 → 중복이므로 추가 안 함
다르면 → 같은 버킷에 추가 (해시 충돌)
이게 왜 중요하냐면, 커스텀 클래스를 HashSet에 넣을 때 hashCode()와 equals()를 반드시 오버라이드해야 하기 때문이다.
class Student {
String name;
int id;
// equals()와 hashCode()를 둘 다 오버라이드해야 한다
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Student s)) return false;
return id == s.id && Objects.equals(name, s.name);
}
@Override
public int hashCode() {
return Objects.hash(name, id);
}
}
Set<Student> students = new HashSet<>();
students.add(new Student("Alice", 1));
students.add(new Student("Alice", 1)); // 중복으로 판단됨
students.size(); // 1
equals()만 오버라이드하고 hashCode()를 안 하면? 같은 내용이어도 다른 버킷에 들어가서 중복으로 판단되지 않는다. 이건 실수하기 정말 쉬운 부분이라 꼭 기억해두자.
HashSet 시간복잡도
| 연산 | 시간복잡도 |
|---|---|
add(element) | O(1) 평균 |
remove(element) | O(1) 평균 |
contains(element) | O(1) 평균 |
해시 충돌이 심하면 O(n)까지 나빠질 수 있지만, 보통은 O(1)로 생각하면 된다.
Set — TreeSet, LinkedHashSet
HashSet 외에 두 가지 Set 구현체가 더 있다. 간단히 알아두자.
TreeSet — Red-Black 트리 기반, 요소를 자동 정렬한다. 삽입/삭제/검색 모두 O(log n).
Set<Integer> numbers = new TreeSet<>();
numbers.add(30); numbers.add(10); numbers.add(20);
System.out.println(numbers); // [10, 20, 30] — 자동 정렬!
LinkedHashSet — HashSet + 연결 리스트로 삽입 순서를 유지한다. 성능은 HashSet과 거의 동일.
Set<String> colors = new LinkedHashSet<>();
colors.add("빨강"); colors.add("파랑"); colors.add("초록");
System.out.println(colors); // [빨강, 파랑, 초록] — 넣은 순서 그대로
Map — HashMap
Map은 키(key)와 값(value)의 쌍으로 데이터를 저장한다. 가장 많이 쓰이는 구현체가 HashMap이다.
기본 사용법
Map<String, Integer> scores = new HashMap<>();
scores.put("Alice", 95);
scores.put("Bob", 87);
scores.put("Charlie", 92);
scores.get("Alice"); // 95
scores.get("Dave"); // null — 없는 키
scores.getOrDefault("Dave", 0); // 0 — 기본값 지정
scores.containsKey("Bob"); // true
scores.remove("Charlie"); // 삭제
// 전체 순회
for (Map.Entry<String, Integer> entry : scores.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
put/get의 내부 동작
HashMap도 HashSet과 마찬가지로 해시 기반이다. 사실 HashSet의 내부가 HashMap으로 구현되어 있다.
put("Alice", 95) 호출 시:
1. "Alice".hashCode() → 해시값 계산
2. 해시값으로 버킷 인덱스 결정 (해시값 % 배열 크기)
3. 해당 버킷이 비어있으면 → 바로 저장
4. 이미 차있으면 → equals()로 키 비교
- 같은 키 → 값 덮어쓰기
- 다른 키 → 체이닝(연결 리스트 or 트리)
null 허용 여부
Map<String, Integer> map = new HashMap<>();
// key에 null 가능 (하나만)
map.put(null, 100);
System.out.println(map.get(null)); // 100
// value에 null 가능 (여러 개)
map.put("A", null);
map.put("B", null);
System.out.println(map); // {null=100, A=null, B=null}
HashMap은 key에 null 하나, value에 null 여러 개를 허용한다. 반면 Hashtable이나 ConcurrentHashMap은 null을 아예 허용하지 않는다. 이건 의외로 모르는 사람이 많다.
HashMap 시간복잡도
| 연산 | 시간복잡도 |
|---|---|
put(key, value) | O(1) 평균 |
get(key) | O(1) 평균 |
remove(key) | O(1) 평균 |
containsKey(key) | O(1) 평균 |
containsValue(value) | O(n) — 전체 순회 필요 |
Map — TreeMap, LinkedHashMap
TreeMap — 키 기준 자동 정렬. Red-Black 트리 기반, 모든 연산 O(log n).
Map<String, Integer> treeMap = new TreeMap<>();
treeMap.put("Charlie", 92); treeMap.put("Alice", 95); treeMap.put("Bob", 87);
System.out.println(treeMap); // {Alice=95, Bob=87, Charlie=92}
LinkedHashMap — 삽입 순서를 유지한다. LRU 캐시를 간단히 구현할 때 유용하다.
Map<String, Integer> linkedMap = new LinkedHashMap<>();
linkedMap.put("세 번째", 3); linkedMap.put("첫 번째", 1); linkedMap.put("두 번째", 2);
System.out.println(linkedMap); // {세 번째=3, 첫 번째=1, 두 번째=2}
Iterator와 순회
컬렉션의 요소를 하나씩 꺼내보는 방법은 여러 가지다.
for-each (향상된 for문)
가장 간단하고 가장 많이 쓰는 방법이다.
List<String> names = List.of("Alice", "Bob", "Charlie");
for (String name : names) {
System.out.println(name);
}
Iterator
직접 반복자를 사용하는 방법이다. 순회 중 안전하게 삭제할 때 필요하다.
List<String> names = new ArrayList<>(List.of("Alice", "Bob", "Charlie"));
Iterator<String> it = names.iterator();
while (it.hasNext()) {
String name = it.next();
if (name.equals("Bob")) {
it.remove(); // Iterator의 remove()는 안전
}
}
System.out.println(names); // [Alice, Charlie]
ConcurrentModificationException
for-each 돌리면서 원본 컬렉션을 수정하면 이 예외가 터진다. 처음 마주치면 꽤 당황스럽다.
List<String> names = new ArrayList<>(List.of("Alice", "Bob", "Charlie"));
// 이렇게 하면 안 된다!
for (String name : names) {
if (name.equals("Bob")) {
names.remove(name); // ConcurrentModificationException 발생!
}
}
해결 방법은 3가지:
- Iterator.remove() 사용 (위에서 본 방법)
- removeIf() 사용 (Java 8+, 가장 깔끔)
- 새 컬렉션에 담기
// removeIf() — 가장 간결하다
names.removeIf(name -> name.equals("Bob"));
Collections 유틸리티
java.util.Collections 클래스는 컬렉션을 다루는 편리한 정적 메서드를 제공한다.
정렬
List<Integer> numbers = new ArrayList<>(List.of(30, 10, 20, 50, 40));
Collections.sort(numbers); // [10, 20, 30, 40, 50]
Collections.sort(numbers, Collections.reverseOrder()); // [50, 40, 30, 20, 10]
수정 불가 컬렉션
List<String> original = new ArrayList<>(List.of("A", "B", "C"));
// 수정 불가 래퍼로 감싸기
List<String> readOnly = Collections.unmodifiableList(original);
// readOnly.add("D"); // UnsupportedOperationException!
// 주의: original을 수정하면 readOnly에도 반영된다
original.add("D");
System.out.println(readOnly); // [A, B, C, D] — 진짜 불변이 아니다!
그 외에도 Collections.emptyList(), Collections.singletonList(), Collections.max(), Collections.min(), Collections.shuffle() 등 자주 쓰이는 메서드가 있다.
불변 컬렉션 (Java 9+)
Java 9부터 List.of(), Set.of(), Map.of()로 진짜 불변 컬렉션을 만들 수 있다.
// List.of — 수정 불가
List<String> fruits = List.of("사과", "바나나", "체리");
// fruits.add("딸기"); // UnsupportedOperationException!
// Set.of — 중복 넣으면 예외
Set<String> colors = Set.of("빨강", "파랑", "초록");
// Set.of("빨강", "빨강"); // IllegalArgumentException!
// Map.of — 10개 이하의 키-값 쌍
Map<String, Integer> scores = Map.of(
"Alice", 95,
"Bob", 87,
"Charlie", 92
);
// Map.ofEntries — 10개 초과 시
Map<String, Integer> moreScores = Map.ofEntries(
Map.entry("Alice", 95),
Map.entry("Bob", 87)
);
Collections.unmodifiableList()는 래퍼일 뿐이라 원본을 수정하면 같이 바뀌지만, List.of()는 진짜 불변이다. 또한 List.of()는 null을 허용하지 않는다.
실전: 어떤 컬렉션을 언제 쓸까?
마지막으로 실전 선택 가이드를 정리해본다. 공부하다 보니 이 부분이 제일 헷갈렸는데, 트리 구조로 생각하면 쉽다.
순서가 필요하다 → 인덱스 접근 多 → ArrayList ✅ / 앞뒤 삽입삭제 多 → LinkedList
중복 제거가 필요하다 → 순서 무관 → HashSet ✅ / 삽입 순서 → LinkedHashSet / 정렬 → TreeSet
키-값 쌍이 필요하다 → 순서 무관 → HashMap ✅ / 삽입 순서 → LinkedHashMap / 키 정렬 → TreeMap
한눈에 보는 요약표
| 상황 | 추천 컬렉션 |
|---|---|
| 일반적인 리스트 | ArrayList |
| 중복 제거 | HashSet |
| 키-값 저장 | HashMap |
| 정렬된 집합 | TreeSet |
| 정렬된 맵 | TreeMap |
| 순서 유지 + 중복 제거 | LinkedHashSet |
| 순서 유지 + 키-값 | LinkedHashMap |
| 불변 리스트 | List.of() |
| 스레드 안전 맵 | ConcurrentHashMap |
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
정리
- 배열의 한계(고정 크기, 기능 부족)를 해결하기 위해 컬렉션 프레임워크가 존재한다.
- ArrayList는 내부 배열 기반이라 인덱스 접근이 O(1)로 빠르고, 대부분의 상황에서 기본 선택이다.
- LinkedList는 노드 기반이라 이론상 삽입/삭제가 유리하지만, 실제로는 ArrayList가 더 나은 경우가 많다.
- HashSet은 hashCode() + equals()로 중복을 판단한다. 커스텀 클래스를 넣으려면 두 메서드를 반드시 오버라이드해야 한다.
- HashMap은 key에 null 하나, value에 null 여러 개를 허용한다.
- 순회 중 삭제는 Iterator.remove() 또는 **removeIf()**를 사용해야 ConcurrentModificationException을 피할 수 있다.
- Java 9+에서는
List.of(),Set.of(),Map.of()로 진짜 불변 컬렉션을 만들 수 있다.
다음 글에서는 제네릭을 다룬다. 타입 소거가 뭔지, 와일드카드는 어떻게 쓰는지, PECS 원칙은 왜 필요한지 — 컬렉션을 제대로 쓰려면 반드시 알아야 할 내용이다.