9 분 소요

1. 핵심 개념

1.1 설계 철학

Java Collections Framework(JCF)는 세 가지 핵심 원칙을 기반으로 설계되었습니다:

  1. 통일된 아키텍처: 모든 컬렉션이 일관된 인터페이스를 제공
  2. 성능 최적화: 각 상황에 최적화된 구현체 제공
  3. 타입 안전성: 제네릭을 통한 컴파일 타임 타입 검사

1.2 핵심 인터페이스

1
2
3
4
5
6
Collection<E>               // 최상위 인터페이스
├── List<E>                // 순서 있는 컬렉션, 중복 허용
├── Set<E>                 // 중복 없는 컬렉션
└── Queue<E>               // FIFO 처리를 위한 컬렉션

Map<K,V>                   // 키-값 쌍을 저장하는 별도 계층

1.3 설계 패턴: 인터페이스 우선

1
2
3
4
5
6
7
// ✅ 좋은 예: 인터페이스로 선언
List<String> names = new ArrayList<>();
Map<String, Integer> ages = new HashMap<>();

// ❌ 나쁜 예: 구현체로 선언
ArrayList<String> names = new ArrayList<>();
HashMap<String, Integer> ages = new HashMap<>();

2. 빠른 선택 가이드

2.1 상황별 권장 컬렉션

요구사항 권장 컬렉션 이유
순서 있는 리스트, 빠른 조회 ArrayList O(1) 인덱스 접근
순서 있는 리스트, 빈번한 삽입/삭제 LinkedList O(1) 양 끝 연산
중복 제거, 순서 무관 HashSet O(1) 평균 성능
중복 제거, 삽입 순서 유지 LinkedHashSet 순서 보장 + O(1) 성능
중복 제거, 정렬된 순서 TreeSet 자동 정렬
키-값 매핑, 빠른 조회 HashMap O(1) 평균 성능
키-값 매핑, 삽입 순서 유지 LinkedHashMap 순서 보장
키-값 매핑, 정렬된 키 TreeMap 키 자동 정렬
FIFO 큐/LIFO 스택 ArrayDeque 양 끝 O(1) 성능
우선순위 큐 PriorityQueue 힙 기반 우선순위

2.2 성능 요약

컬렉션 추가 삭제 조회 특징
ArrayList O(1)* O(n) O(1) 인덱스 접근
LinkedList O(1) O(1) O(n) 양 끝 연산
HashSet O(1) O(1) O(1) 해시 기반
TreeSet O(log n) O(log n) O(log n) 정렬 유지
HashMap O(1) O(1) O(1) 해시 기반
TreeMap O(log n) O(log n) O(log n) 정렬 유지

3. List 컬렉션

3.1 ArrayList - 동적 배열

언제 사용할까?

  • 인덱스 기반 조회가 빈번한 경우
  • 읽기 연산이 쓰기 연산보다 많은 경우
1
2
3
4
5
6
List<String> fruits = new ArrayList<>();
fruits.add("apple");        // O(1) - 끝에 추가
fruits.add("banana");
fruits.get(0);              // O(1) - 인덱스 조회
fruits.set(1, "orange");    // O(1) - 인덱스 수정
fruits.add(1, "grape");     // O(n) - 중간 삽입

성능 특성:

  • 조회/수정: O(1)
  • 끝에 추가: O(1) 상각
  • 중간 삽입/삭제: O(n)

3.2 LinkedList - 이중 연결 리스트

언제 사용할까?

  • 리스트의 양 끝에서 빈번한 삽입/삭제
  • Queue나 Stack으로 사용할 때
1
2
3
4
5
LinkedList<String> tasks = new LinkedList<>();
tasks.addFirst("urgent");    // O(1) - 맨 앞 추가
tasks.addLast("normal");     // O(1) - 맨 뒤 추가
tasks.removeFirst();         // O(1) - 맨 앞 제거
String first = tasks.peekFirst(); // O(1) - 맨 앞 조회

Queue/Deque 인터페이스 활용:

1
2
3
4
5
6
7
Queue<String> queue = new LinkedList<>();
queue.offer("task1");        // 큐에 추가
String next = queue.poll();  // 큐에서 제거

Deque<String> stack = new LinkedList<>();
stack.push("item1");         // 스택에 추가
String top = stack.pop();    // 스택에서 제거

3.3 ArrayList vs LinkedList 선택 기준

1
2
3
4
5
6
7
8
9
10
11
// 조회가 많은 경우 - ArrayList
List<String> readHeavy = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
    String item = readHeavy.get(i); // ArrayList가 빠름
}

// 삽입/삭제가 많은 경우 - LinkedList
List<String> writeHeavy = new LinkedList<>();
for (int i = 0; i < 1000; i++) {
    writeHeavy.add(0, "new item"); // LinkedList가 빠름
}

4. Set 컬렉션

4.1 HashSet - 해시 기반 집합

언제 사용할까?

  • 단순히 중복 제거가 목적
  • 순서가 중요하지 않은 경우
1
2
3
4
5
6
Set<String> uniqueWords = new HashSet<>();
uniqueWords.add("hello");    // O(1)
uniqueWords.add("world");
uniqueWords.add("hello");    // 중복, 무시됨

boolean exists = uniqueWords.contains("hello"); // O(1)

4.2 LinkedHashSet - 순서 보장 집합

언제 사용할까?

  • 중복 제거 + 삽입 순서 유지가 필요한 경우
1
2
3
4
5
6
7
8
9
Set<String> orderedSet = new LinkedHashSet<>();
orderedSet.add("first");
orderedSet.add("second");
orderedSet.add("third");

// 삽입 순서대로 반복됨
for (String item : orderedSet) {
    System.out.println(item); // first, second, third 순서
}

4.3 TreeSet - 정렬된 집합

언제 사용할까?

  • 중복 제거 + 자동 정렬이 필요한 경우
1
2
3
4
5
6
7
8
9
10
11
Set<Integer> sortedNumbers = new TreeSet<>();
sortedNumbers.add(3);
sortedNumbers.add(1);
sortedNumbers.add(2);

// 자동으로 정렬됨: 1, 2, 3
System.out.println(sortedNumbers); // [1, 2, 3]

// 범위 연산 가능
NavigableSet<Integer> subset = ((TreeSet<Integer>) sortedNumbers)
    .subSet(1, 3); // 1 이상 3 미만

4.4 Set 컬렉션 비교

1
2
3
4
5
6
7
8
9
// 성능 테스트
Set<String> hashSet = new HashSet<>();         // 가장 빠름
Set<String> linkedHashSet = new LinkedHashSet<>(); // 중간
Set<String> treeSet = new TreeSet<>();         // 가장 느림

// null 처리
hashSet.add(null);         // ✅ 허용 (1개만)
linkedHashSet.add(null);   // ✅ 허용 (1개만)
// treeSet.add(null);      // ❌ NullPointerException

5. Map 컬렉션

5.1 HashMap - 해시 기반 맵

언제 사용할까?

  • 일반적인 키-값 저장소가 필요한 경우
  • 성능이 가장 중요한 경우
1
2
3
4
5
6
7
Map<String, Integer> ageMap = new HashMap<>();
ageMap.put("Alice", 25);     // O(1)
ageMap.put("Bob", 30);
ageMap.put("Alice", 26);     // 덮어쓰기

Integer age = ageMap.get("Alice");      // O(1) - 26
boolean hasKey = ageMap.containsKey("Charlie"); // O(1) - false

5.2 LinkedHashMap - 순서 보장 맵

언제 사용할까?

  • 키-값 매핑 + 삽입 순서 유지가 필요한 경우
  • LRU 캐시 구현 시
1
2
3
4
5
6
7
8
9
10
11
12
13
// 일반적인 사용
Map<String, String> orderedMap = new LinkedHashMap<>();
orderedMap.put("first", "1");
orderedMap.put("second", "2");
orderedMap.put("third", "3");

// LRU 캐시 구현
Map<String, String> lruCache = new LinkedHashMap<String, String>(16, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
        return size() > 100; // 최대 100개 유지
    }
};

5.3 TreeMap - 정렬된 맵

언제 사용할까?

  • 키가 정렬된 순서로 저장되어야 하는 경우
  • 범위 기반 검색이 필요한 경우
1
2
3
4
5
6
7
8
9
10
11
12
NavigableMap<String, Integer> sortedMap = new TreeMap<>();
sortedMap.put("charlie", 3);
sortedMap.put("alice", 1);
sortedMap.put("bob", 2);

// 키 기준 자동 정렬: alice, bob, charlie
System.out.println(sortedMap.keySet()); // [alice, bob, charlie]

// 범위 연산
Map<String, Integer> subMap = sortedMap.subMap("alice", "charlie");
String firstKey = sortedMap.firstKey();  // "alice"
String lastKey = sortedMap.lastKey();    // "charlie"

5.4 Map 활용 패턴

빈도 계산:

1
2
3
4
5
6
7
Map<String, Integer> wordCount = new HashMap<>();
String[] words = {"apple", "banana", "apple", "cherry"};

for (String word : words) {
    wordCount.merge(word, 1, Integer::sum);
}
// {apple=2, banana=1, cherry=1}

그룹핑:

1
2
3
4
5
Map<String, List<Person>> peopleByCity = new HashMap<>();
for (Person person : people) {
    peopleByCity.computeIfAbsent(person.getCity(), k -> new ArrayList<>())
               .add(person);
}

6. Queue 컬렉션

6.1 ArrayDeque - 고성능 큐/스택

언제 사용할까?

  • Stack 클래스를 대체할 때
  • 빠른 FIFO/LIFO 연산이 필요한 경우
1
2
3
4
5
6
7
8
9
10
11
// FIFO 큐로 사용
Queue<String> queue = new ArrayDeque<>();
queue.offer("first");     // 큐에 추가
queue.offer("second");
String head = queue.poll(); // "first" 제거 및 반환

// LIFO 스택으로 사용
Deque<String> stack = new ArrayDeque<>();
stack.push("bottom");     // 스택에 추가
stack.push("top");
String top = stack.pop(); // "top" 제거 및 반환

6.2 PriorityQueue - 우선순위 큐

언제 사용할까?

  • 작업 스케줄링
  • 우선순위 기반 처리가 필요한 경우
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 자연 순서 (작은 값이 높은 우선순위)
Queue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(3);
minHeap.offer(1);
minHeap.offer(2);
System.out.println(minHeap.poll()); // 1 (가장 작은 값)

// 사용자 정의 우선순위
Queue<Task> taskQueue = new PriorityQueue<>(
    Comparator.comparing(Task::getPriority).reversed()
);

// 복잡한 객체 우선순위
record Task(String name, int priority) {}
Queue<Task> tasks = new PriorityQueue<>(
    Comparator.comparing(Task::priority)
);

7. 동시성 컬렉션

7.1 ConcurrentHashMap

Vector/Hashtable의 현대적 대안:

1
2
3
4
5
6
7
8
9
10
// ❌ 구식 방법
Map<String, Integer> oldMap = Collections.synchronizedMap(new HashMap<>());

// ✅ 현대적 방법
Map<String, Integer> concurrentMap = new ConcurrentHashMap<>();

// 원자적 연산
concurrentMap.putIfAbsent("key", 1);
concurrentMap.compute("key", (k, v) -> v == null ? 1 : v + 1);
concurrentMap.merge("key", 1, Integer::sum);

7.2 CopyOnWriteArrayList

읽기가 많고 쓰기가 적은 경우:

1
2
3
4
5
6
7
8
9
List<EventListener> listeners = new CopyOnWriteArrayList<>();

// 안전한 반복 (쓰기 중에도 안전)
for (EventListener listener : listeners) {
    listener.onEvent(event); // 스냅샷에서 반복
}

// 백그라운드에서 안전한 수정
listeners.add(newListener);

7.3 BlockingQueue

생산자-소비자 패턴:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
BlockingQueue<Task> taskQueue = new ArrayBlockingQueue<>(100);

// 생산자 스레드
new Thread(() -> {
    try {
        taskQueue.put(new Task()); // 큐가 가득 차면 대기
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();

// 소비자 스레드
new Thread(() -> {
    try {
        Task task = taskQueue.take(); // 큐가 비어 있으면 대기
        process(task);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();

8. 성능 최적화

8.1 초기 용량 설정

1
2
3
4
5
6
7
// ❌ 기본 용량 (16, 로드팩터 0.75)
Map<String, String> map1 = new HashMap<>();

// ✅ 예상 크기에 맞는 초기 용량
int expectedSize = 1000;
int initialCapacity = (int) (expectedSize / 0.75) + 1;
Map<String, String> map2 = new HashMap<>(initialCapacity);

8.2 적절한 컬렉션 선택

1
2
3
4
5
6
7
8
// 크기가 작고 변경되지 않는 컬렉션
List<String> small = List.of("a", "b", "c");  // 불변, 메모리 효율적

// 크기를 미리 알 수 있는 경우
List<String> known = new ArrayList<>(expectedSize);

// 빈번한 contains() 연산
Set<String> fastLookup = new HashSet<>(items); // O(1) contains()

8.3 스트림 API와의 조합

1
2
3
4
5
6
7
8
9
10
11
12
// 컬렉션에서 스트림으로
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<String> upperNames = names.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toList());

// 특정 컬렉션 타입으로 수집
Set<String> uniqueNames = names.stream()
    .collect(Collectors.toSet());

Map<Integer, String> lengthMap = names.stream()
    .collect(Collectors.toMap(String::length, s -> s));

9. 모범 사례

9.1 타입 안전성

1
2
3
4
5
// ✅ 제네릭 사용
List<String> names = new ArrayList<>();

// ❌ Raw 타입 사용
List names = new ArrayList(); // 경고 발생

9.2 불변 컬렉션 활용

1
2
3
4
5
6
7
8
9
// Java 9+ 팩토리 메서드
List<String> immutableList = List.of("a", "b", "c");
Set<String> immutableSet = Set.of("x", "y", "z");
Map<String, Integer> immutableMap = Map.of("key", 1);

// 방어적 복사
public List<String> getItems() {
    return Collections.unmodifiableList(items);
}

9.3 안전한 반복

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ✅ Iterator의 remove() 사용
Iterator<String> iter = list.iterator();
while (iter.hasNext()) {
    String item = iter.next();
    if (shouldRemove(item)) {
        iter.remove(); // 안전
    }
}

// ✅ removeIf() 사용 (Java 8+)
list.removeIf(item -> shouldRemove(item));

// ❌ 반복 중 직접 수정
for (String item : list) {
    if (shouldRemove(item)) {
        list.remove(item); // ConcurrentModificationException
    }
}

10. 자주 하는 실수

10.1 Arrays.asList()의 함정

1
2
3
4
5
6
7
// ❌ 고정 크기 리스트
List<String> list = Arrays.asList("a", "b", "c");
// list.add("d"); // UnsupportedOperationException

// ✅ 가변 리스트
List<String> mutableList = new ArrayList<>(Arrays.asList("a", "b", "c"));
mutableList.add("d"); // 정상 동작

10.2 null 처리 불일치

1
2
3
4
5
6
7
8
9
10
11
12
13
// null 허용하는 컬렉션
Map<String, String> hashMap = new HashMap<>();
hashMap.put(null, "value");  // ✅ 허용

Set<String> hashSet = new HashSet<>();
hashSet.add(null);           // ✅ 허용

// null 허용하지 않는 컬렉션
// TreeSet<String> treeSet = new TreeSet<>();
// treeSet.add(null);        // ❌ NullPointerException

// ArrayDeque<String> deque = new ArrayDeque<>();
// deque.add(null);          // ❌ NullPointerException

10.3 동시성 문제

1
2
3
4
5
6
7
8
9
// ❌ 비동기화 컬렉션의 동시 접근
Map<String, Integer> map = new HashMap<>();
// 여러 스레드에서 동시 수정 시 문제 발생

// ✅ 동시성 컬렉션 사용
Map<String, Integer> safeMap = new ConcurrentHashMap<>();

// ✅ 동기화 래퍼 사용 (성능은 떨어짐)
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());

10.4 메모리 누수

1
2
3
4
5
6
7
8
9
10
11
// ❌ 불필요한 참조 유지
List<LargeObject> cache = new ArrayList<>();
// cache가 계속 커져서 메모리 누수

// ✅ 적절한 크기 제한
Map<String, String> cache = new LinkedHashMap<String, String>(16, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
        return size() > MAX_CACHE_SIZE;
    }
};

참고 자료

공식 문서

유용한 도구

  • JProfiler - 메모리 사용량 분석
  • JMH (Java Microbenchmark Harness) - 성능 벤치마킹
  • VisualVM - JVM 모니터링

댓글남기기