Theme:

배열을 쓰다 보면 한 가지 답답한 순간이 온다. "크기를 미리 정해야 한다고?" 데이터가 몇 개 들어올지도 모르는데, 왜 처음부터 칸 수를 정해놔야 하는 걸까? 이 질문에서 출발하면 컬렉션 프레임워크가 왜 필요한지 자연스럽게 이해된다.

배열의 한계, 컬렉션이 필요한 이유

배열은 Java에서 가장 기본적인 자료구조다. 하지만 실무에서 쓰다 보면 금방 한계에 부딪힌다.

JAVA
// 배열: 크기를 미리 정해야 한다
String[] names = new String[3];
names[0] = "Alice";
names[1] = "Bob";
names[2] = "Charlie";
// names[3] = "Dave"; // ArrayIndexOutOfBoundsException!

배열의 문제점:

  • 고정 크기: 한번 생성하면 늘릴 수 없다. 더 큰 배열을 만들어서 복사해야 한다.
  • 삽입/삭제 불편: 중간에 요소를 넣거나 빼려면 직접 밀고 당겨야 한다.
  • 기능 부족: "이 값이 있는지 확인해줘"같은 기본 기능도 직접 반복문을 돌려야 한다.

이런 불편을 해결하기 위해 Java는 **컬렉션 프레임워크(Collection Framework)**를 제공한다. 크기가 자동으로 늘어나고, 삽입·삭제·검색 같은 기능이 메서드로 정리되어 있다.

컬렉션 프레임워크 구조

컬렉션 프레임워크는 인터페이스 계층으로 설계되어 있다. 크게 세 갈래로 나뉜다.

PLAINTEXT
Iterable
  └── Collection
        ├── List   — 순서 있음, 중복 허용
        ├── Set    — 순서 없음, 중복 불허
        └── Queue  — FIFO 구조

Map (별도 계층) — 키-값 쌍, 키 중복 불허

핵심 구현체를 정리하면 이렇다.

인터페이스주요 구현체특징
ListArrayList, LinkedList인덱스로 접근, 순서 보장
SetHashSet, TreeSet, LinkedHashSet중복 제거
MapHashMap, TreeMap, LinkedHashMap키로 값 조회

공부하다 보니 "왜 인터페이스로 분리해놓았을까?"라는 의문이 들었는데, 이유는 간단하다. 용도에 맞는 구현체를 바꿔 끼울 수 있게 하기 위해서다.

JAVA
// 인터페이스 타입으로 선언 → 구현체를 바꿔도 나머지 코드는 안 바뀐다
List<String> names = new ArrayList<>();
// 나중에 LinkedList로 바꿔야 한다면?
// List<String> names = new LinkedList<>();

List — ArrayList

ArrayList는 가장 많이 쓰는 컬렉션이다. 내부적으로 배열을 사용하지만, 크기가 자동으로 늘어난다.

기본 사용법

JAVA
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[])이 꽉 차면 더 큰 배열을 새로 만들어서 복사하는 것이다.

PLAINTEXT
초기 상태 (기본 용량 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와 정반대의 성능 특성을 가진다.

내부 구조: 이중 연결 리스트

PLAINTEXT
[null|사과|→] ←→ [←|바나나|→] ←→ [←|체리|null]
 prev  data next

각 노드가 이전(prev)과 다음(next) 노드의 참조를 가지고 있다. 그래서 중간에 끼워넣거나 빼는 건 참조만 바꾸면 된다.

JAVA
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 비교

연산ArrayListLinkedList
인덱스 접근 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이다.

기본 사용법

JAVA
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이 중복을 판단하는 과정은 두 단계다.

PLAINTEXT
1단계: hashCode()로 버킷(저장 위치) 결정

2단계: 같은 버킷에 이미 요소가 있으면 equals()로 비교

  같으면 → 중복이므로 추가 안 함
  다르면 → 같은 버킷에 추가 (해시 충돌)

이게 왜 중요하냐면, 커스텀 클래스를 HashSet에 넣을 때 hashCode()와 equals()를 반드시 오버라이드해야 하기 때문이다.

JAVA
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).

JAVA
Set<Integer> numbers = new TreeSet<>();
numbers.add(30); numbers.add(10); numbers.add(20);
System.out.println(numbers); // [10, 20, 30] — 자동 정렬!

LinkedHashSet — HashSet + 연결 리스트로 삽입 순서를 유지한다. 성능은 HashSet과 거의 동일.

JAVA
Set<String> colors = new LinkedHashSet<>();
colors.add("빨강"); colors.add("파랑"); colors.add("초록");
System.out.println(colors); // [빨강, 파랑, 초록] — 넣은 순서 그대로

Map — HashMap

Map은 키(key)와 값(value)의 쌍으로 데이터를 저장한다. 가장 많이 쓰이는 구현체가 HashMap이다.

기본 사용법

JAVA
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으로 구현되어 있다.

PLAINTEXT
put("Alice", 95) 호출 시:

1. "Alice".hashCode() → 해시값 계산
2. 해시값으로 버킷 인덱스 결정 (해시값 % 배열 크기)
3. 해당 버킷이 비어있으면 → 바로 저장
4. 이미 차있으면 → equals()로 키 비교
   - 같은 키 → 값 덮어쓰기
   - 다른 키 → 체이닝(연결 리스트 or 트리)

null 허용 여부

JAVA
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).

JAVA
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 캐시를 간단히 구현할 때 유용하다.

JAVA
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문)

가장 간단하고 가장 많이 쓰는 방법이다.

JAVA
List<String> names = List.of("Alice", "Bob", "Charlie");

for (String name : names) {
    System.out.println(name);
}

Iterator

직접 반복자를 사용하는 방법이다. 순회 중 안전하게 삭제할 때 필요하다.

JAVA
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 돌리면서 원본 컬렉션을 수정하면 이 예외가 터진다. 처음 마주치면 꽤 당황스럽다.

JAVA
List<String> names = new ArrayList<>(List.of("Alice", "Bob", "Charlie"));

// 이렇게 하면 안 된다!
for (String name : names) {
    if (name.equals("Bob")) {
        names.remove(name); // ConcurrentModificationException 발생!
    }
}

해결 방법은 3가지:

  1. Iterator.remove() 사용 (위에서 본 방법)
  2. removeIf() 사용 (Java 8+, 가장 깔끔)
  3. 새 컬렉션에 담기
JAVA
// removeIf() — 가장 간결하다
names.removeIf(name -> name.equals("Bob"));

Collections 유틸리티

java.util.Collections 클래스는 컬렉션을 다루는 편리한 정적 메서드를 제공한다.

정렬

JAVA
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]

수정 불가 컬렉션

JAVA
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()진짜 불변 컬렉션을 만들 수 있다.

JAVA
// 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을 허용하지 않는다.

실전: 어떤 컬렉션을 언제 쓸까?

마지막으로 실전 선택 가이드를 정리해본다. 공부하다 보니 이 부분이 제일 헷갈렸는데, 트리 구조로 생각하면 쉽다.

PLAINTEXT
순서가 필요하다 → 인덱스 접근 多 → 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 원칙은 왜 필요한지 — 컬렉션을 제대로 쓰려면 반드시 알아야 할 내용이다.

댓글 로딩 중...