컬렉션과 예외 — 무엇에 담고, 어떻게 실패할 것인가
데이터를 담을 그릇을 고르는 일은 사실 성능을 고르는 일이다. 자료구조가 메모리에서 어떤 모습인지부터, 일이 틀어졌을 때 다루는 예외·자원 관리까지 — 매일 쓰는 것들의 바닥을 본다.
1. 어떤 그릇을 고를까
용도가 그릇을 정한다. 순서대로면 List, 중복 없이면 Set, 키-값이면 Map, 양끝이 중요하면 Deque.
| 인터페이스 | 대표 구현 | 성격 |
|---|---|---|
List | ArrayList / LinkedList | 순서·인덱스 / 양끝 삽입 |
Set | HashSet / LinkedHashSet / TreeSet | 무순 / 삽입순 / 정렬 |
Map | HashMap / LinkedHashMap / TreeMap | 무순 / 삽입·접근순(LRU) / 키 정렬 |
Queue/Deque | ArrayDeque / PriorityQueue | 스택·큐 / 우선순위 |
그런데 "ArrayList냐 LinkedList냐" 같은 선택은 메모리에서의 모습을 알아야 제대로 고를 수 있다.
메모리에서 보면 (CS 연결)
ArrayList는 연속된 배열이다. 원소들이 메모리에 나란히 있어, 06·07에서 본 *캐시 라인(64B)*에 여러 개가 함께 실린다 → 순회가 빠르고 get(i)는 주소 계산 한 번(O(1)). 반면 LinkedList는 흩어진 노드라 순회할 때마다 포인터를 따라 메모리를 점프(캐시 미스)한다. 그래서 같은 O(n)이라도 실측은 ArrayList가 거의 항상 빠르다 — 중간 삽입조차 arraycopy가 캐시 친화적이라 이기는 경우가 많다.
HashMap은 버킷 배열 + 노드다. 키의 해시로 버킷을 정하는데, 인덱스를 (n-1) & hash로 구한다 — n이 2의 거듭제곱이라 이 비트 AND가 곧 % n(느린 나눗셈을 피한다). 충돌이 한 버킷에 8개 이상 쌓이고 테이블이 64 이상이면 연결 리스트를 레드-블랙 트리로 바꿔(O(n)→O(log n)) 최악을 방어한다.
왜 부하율 0.75인가? 75%가 차면 테이블을 2배로 키운다. 더 높이면 충돌이 늘어 조회가 느려지고, 더 낮추면 빈 칸 낭비가 는다 — 그 사이의 경험적 절충이다.
실무에서는 — 큰 맵은 들어올 크기를 알면 초기 용량을 지정한다. 왜? 부하율 0.75를 넘을 때마다 리해시가 일어나는데, 이는 모든 키를 새(2배) 테이블에 다시 해싱해 재배치하는 O(n) 작업이다. 맵이 자라며 16→32→64…로 여러 번 반복되면 그 누적 복사 비용이 크다 → 미리 용량을 잡으면 반복 재배치를 없앤다. 빈도 카운팅은
map.merge(k, 1, Integer::sum), 멀티맵은map.computeIfAbsent(k, x -> new ArrayList<>()).add(v)가 관용구다.
2. 순회 중에 바꾸면 터진다 — fail-fast
컬렉션을 돌면서 동시에 바꾸면 대부분 ConcurrentModificationException을 던진다(fail-fast). 망가진 상태로 계속 도느니 바로 멈춰 알리는 안전장치다.
for (Integer x : list) if (x % 2 == 0) list.remove(x); // 💥 CME
list.removeIf(x -> x % 2 == 0); // ✓ 안전·간결
인덱스로 지우는 for (i...) if(...) list.remove(i)도 제거하면 뒤 원소가 당겨져 일부를 건너뛰는 버그다. 안전한 삭제는 removeIf나 Iterator.remove().
3. "못 바꾸는" 데에도 종류가 있다
불변처럼 보이는 것들이 사실 다르게 동작한다 — 여기서 사고가 난다.
List.of(1, 2, 3); // 진짜 불변 (add/set 모두 💥)
Arrays.asList(1, 2, 3); // 고정 크기 뷰: set은 OK, add는 💥 (배열을 감쌈)
Collections.unmodifiableList(src); // 읽기 전용 "뷰" — 원본이 바뀌면 같이 바뀜
List.copyOf(src); // 독립 "스냅샷" — 이후 원본과 무관
실무에서는 — "이 리스트를 밖에 넘기는데 못 바꾸게 하고 싶다"면 진짜 불변인
List.copyOf를 써라.unmodifiableList는 뷰라 원본이 바뀌면 함께 바뀌어, 호출자가 불변이라 믿다가 데이는 일이 잦다.
4. 일이 틀어졌을 때 — 예외와 자원
이제 실패 쪽이다. 자바 예외는 세 갈래다.
- Checked(
IOException…) — 호출자가 복구할 수 있는 상황. 컴파일러가 처리를 강제. - Unchecked(
NPE·IllegalArgument…) — 프로그래밍 오류. 강제 없음. - Error(
OutOfMemoryError…) — 복구 불가. 잡지 않는다.
실무에서는 — 예외를 삼키지 마라.
catch (Exception e) {}로 비워 두면 스택트레이스·원인이 사라져 장애가 은폐된다. 저수준 예외는 의미 있는 상위 예외로 번역하되 원인(cause)을 반드시 보존한다(throw new ServiceException(msg, e), Item 73). 복구 가능하면 checked, 프로그래밍 오류면 unchecked로 던진다.
자원(파일·소켓·커넥션)은 try-with-resources로 닫는다.
try (var in = new BufferedReader(new FileReader(path))) {
return in.readLine();
} // 블록을 어떻게 빠져나가든 자동 close (예외가 나도)
이게 옛 finally 방식보다 나은 이유는 예외 처리에 있다. 본문과 close()가 둘 다 예외를 던지면, 본문 예외를 주 예외로 전파하고 close 예외는 버리지 않고 suppressed로 첨부한다(getSuppressed()). 옛날엔 close 예외가 본문 예외를 덮어써 진짜 원인을 잃었다.
함정 하나 더 —
finally안에서return하면try의return(과 예외)을 덮어쓴다.finally에선 절대return·throw를 두지 마라 — 정상 결과와 예외를 통째로 삼킨다.
한눈에 정리
- 그릇 선택 = 성능 선택. ArrayList(연속·캐시친화) ≫ LinkedList(포인터추적), 정렬은 Tree, LRU는 LinkedHashMap.
- HashMap:
(n-1)&hash인덱싱, 충돌 8↑·테이블 64↑면 트리화, 부하율 0.75. 큰 맵은 초기 용량 지정. - fail-fast: 순회 중 변경은 CME →
removeIf/Iterator.remove. - 불변 종류:
List.of(불변)·Arrays.asList(고정뷰)·unmodifiable(뷰)·copyOf(스냅샷). 진짜 불변은 copyOf. - 예외: 삼키지 말고 cause 보존·번역. 자원은 try-with-resources(suppressed).
finally에 return 금지.