컬렉션과 예외 — 무엇에 담고, 어떻게 실패할 것인가
데이터를 담을 그릇을 고르는 일은 사실 성능을 고르는 일이다. 자료구조가 메모리에서 어떤 모습인지부터, 일이 틀어졌을 때 다루는 예외·자원 관리까지 — 매일 쓰는 것들의 바닥을 본다.
1. 어떤 그릇을 고를까
용도가 그릇을 정한다. 순서대로면 List, 중복 없이면 Set, 키-값이면 Map, 양끝이 중요하면 Deque.
| 인터페이스 | 대표 구현 | 성격 |
|---|---|---|
List | ArrayList / LinkedList | 순서·인덱스 / 양끝 삽입 |
Set | HashSet / LinkedHashSet / TreeSet | 무순 / 삽입순 / 정렬 |
Map | HashMap / LinkedHashMap / TreeMap | 무순 / 삽입·접근순(LRU) / 키 정렬 |
Queue/Deque | ArrayDeque / PriorityQueue | 스택·큐 / 우선순위 |
여러 스레드가 같은 맵을 함께 쓰면 HashMap 대신 ConcurrentHashMap 을 쓴다(05편에서 다룬다). 그런데 "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. 조용히 배신하는 다섯 장면
앞의 것들은 예외를 던져 준다. 여기 있는 것들은 아무 소리 없이 틀린 답을 준다 — 그래서 더 오래 산다.
① 가변 키를 넣으면 원소를 잃는다
해시 컬렉션은 넣을 때 계산한 해시로 자리를 정한다. 그 뒤에 키가 바뀌면 자리는 그대로인데 찾는 곳만 달라진다.
class Member { // equals·hashCode 가 name 기반
String name;
Member(String n) { name = n; }
}
Set<Member> set = new HashSet<>();
Member m = new Member("철수");
set.add(m);
m.name = "영희"; // 해시가 바뀌었다
set.contains(m); // false — 원래 버킷에 있는데 새 버킷을 뒤진다
set.remove(m); // 지워지지 않는다
set.size(); // 1 — 꺼낼 수도 지울 수도 없는 원소가 남았다
iterator()로 돌면 그 원소가 보이기는 한다. 보이는데 못 찾는 상태라, 디버깅할 때 사람을 가장 헷갈리게 만든다.
규칙 하나로 끝난다 — 해시 컬렉션의 키는 불변이어야 한다.
record나final필드로 만들고, 부득이 가변이면 키로 쓰지 말고 식별자(ID)를 키로 쓴다.
② remove(int) 와 remove(Object) 는 다른 메서드다
List<Integer>에서만 터지는 고전이다.
List<Integer> list = new ArrayList<>(List.of(10, 20, 30));
list.remove(1); // 인덱스 1 제거 → [10, 30]
list.remove(Integer.valueOf(1)); // 값 1 제거 (없으므로 아무 일 없음)
둘 다 정상 컴파일된다. 오버로드 해석은 실행 시점이 아니라 컴파일 시점에 정적 타입으로 결정되고, 리터럴 1의 정적 타입은 int라서 remove(int)가 뽑힌다.
list.remove(30); // ArrayIndexOutOfBounds? 아니다 —
// IndexOutOfBoundsException: Index 30 out of bounds
값을 지우려던 코드가 인덱스 예외를 던지는 순간 원인을 못 찾는다. 값 삭제 의도라면 Integer.valueOf(...)로 감싸거나 removeIf(x -> x == 30)을 쓴다.
③ subList는 복사가 아니라 뷰다
List<String> all = new ArrayList<>(List.of("a","b","c","d","e"));
List<String> part = all.subList(1, 3); // [b, c] — 새 리스트가 아니다
part.set(0, "B");
all; // [a, B, c, d, e] 원본이 바뀐다
all.add("f"); // 원본을 구조적으로 변경
part.get(0); // 💥 ConcurrentModificationException
subList 가 돌려주는 것은 원본을 들여다보는 창이라, 원본이 구조적으로 바뀌면 창이 깨진다. 잘라서 따로 들고 다닐 생각이라면 복사해야 한다.
List<String> copy = new ArrayList<>(all.subList(1, 3)); // 독립
같은 이유로 subList를 오래 보관하면 원본 전체가 GC 되지 않는다. 작은 조각을 들고 있다고 생각했는데 큰 배열이 통째로 남는다.
④ 배열을 키로 쓰면 절대 못 찾는다
Map<int[], String> map = new HashMap<>();
map.put(new int[]{1, 2}, "값");
map.get(new int[]{1, 2}); // null
배열은 equals·hashCode를 재정의하지 않는다. Object의 것을 그대로 써서 내용이 아니라 주소로 비교한다. 내용이 같아도 다른 객체이므로 영원히 못 찾는다.
int[] a = {1, 2}, b = {1, 2};
a.equals(b); // false — 주소 비교
Arrays.equals(a, b); // true — 내용 비교
Arrays.hashCode(a) == Arrays.hashCode(b); // true
내용으로 비교하려면 Arrays.equals 와 Arrays.hashCode 를 써야 한다. 키가 필요하면 List<Integer>나 record 로 감싼다 — 둘 다 내용 기반 equals·hashCode를 갖는다.
record Point(int x, int y) { }
Map<Point, String> ok = new HashMap<>(); // 이건 잘 동작한다
⑤ Integer 캐시와 ==
Integer a = 127, b = 127;
a == b; // true — 캐시된 같은 객체
Integer c = 128, d = 128;
c == d; // false — 새 객체 둘
Integer.valueOf는 −128 ~ 127 범위를 미리 만들어 두고 재사용한다. 이 구간의 캐시는 자바 언어 명세가 요구하는 동작이라 어디서나 같다.
그 위는 다르다 — 상한은 구현·옵션에 달려 있어(-XX:AutoBoxCacheMax) 128 이상도 캐시될 수 있다. 즉 == 결과가 실행 환경에 따라 달라진다. 항상 틀리는 것보다 나쁘다. 테스트 환경에서 통과하고 운영에서 실패하는 유형이기 때문이다.
Integer 캐시가 만드는 이 함정을 컬렉션은 어느 정도 막아 준다 — contains·indexOf·remove(Object)는 내부에서 equals를 쓰기 때문에 값 비교가 된다. 문제는 사람이 직접 ==로 비교할 때다.
if (list.get(0) == list.get(1)) // 값이 같아도 128 이상이면 false
if (list.get(0).equals(list.get(1))) // 이렇게 쓴다
다섯 장면을 관통하는 세 가지 원리
① 해시 자료구조는 '넣을 때의 해시' 에 기대어 자리를 정한다 → 키가 변하면 계약이 깨진다
③ 뷰는 참조를 공유한다 → 원본과 운명을 함께한다
② ⑤ 오버로드와 == 는 정적 타입·참조로 결정된다 → 값 의미를 기대하면 어긋난다
세 원리가 전부 "자바가 값(value)이 아니라 참조(reference)를 다루는 언어"라는 사실에서 나온다. 개별 사례를 외우는 것보다 이 뿌리를 쥐는 편이 오래간다.
5. 일이 틀어졌을 때 — 예외와 자원
이제 실패 쪽이다. 자바 예외는 세 갈래다.
- 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. - 조용한 사고 다섯: 가변 키(원소 분실) ·
remove(int)/remove(Object)오버로드 ·subList뷰 · 배열 키(주소 비교) ·Integer캐시와==. - 예외: 삼키지 말고 cause 보존·번역. 자원은 try-with-resources(suppressed).
finally에 return 금지.