자바 언어·플랫폼 학습 노트 목차

컬렉션과 예외 — 무엇에 담고, 어떻게 실패할 것인가

데이터를 담을 그릇을 고르는 일은 사실 성능을 고르는 일이다. 자료구조가 메모리에서 어떤 모습인지부터, 일이 틀어졌을 때 다루는 예외·자원 관리까지 — 매일 쓰는 것들의 바닥을 본다.


1. 어떤 그릇을 고를까

용도가 그릇을 정한다. 순서대로면 List, 중복 없이면 Set, 키-값이면 Map, 양끝이 중요하면 Deque.

인터페이스대표 구현성격
ListArrayList / LinkedList순서·인덱스 / 양끝 삽입
SetHashSet / LinkedHashSet / TreeSet무순 / 삽입순 / 정렬
MapHashMap / LinkedHashMap / TreeMap무순 / 삽입·접근순(LRU) / 키 정렬
Queue/DequeArrayDeque / 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)제거하면 뒤 원소가 당겨져 일부를 건너뛰는 버그다. 안전한 삭제는 removeIfIterator.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하면 tryreturn(과 예외)을 덮어쓴다. 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 금지.
함수형 프로그래밍·Stream동시성