컬렉션 내부 동시성 컬렉션 제네릭 심화 함수형 · 스트림 모던 자바 📝 문제풀이
◀ 심화 CH 06 📋 목차 심화 CH 08 ▶
🧰 심화 CHAPTER 07 · 5년차 트랙

컬렉션은 안에서 뭘 하고, 제네릭과 모던 문법은 왜 그렇게 생겼을까?

매일 쓰는 HashMap·ArrayList·스트림을 "내부 동작" 수준으로 다시 봅니다. 해시 충돌이 트리로 바뀌는 순간, 타입 소거의 함정, PECS 규칙, 병렬 스트림의 공용 풀, 그리고 record·sealed·패턴 매칭까지 — 코드 리뷰와 시니어 면접에서 실력이 갈리는 지점이에요.

🎯 이 장을 끝내면
🗄️
컬렉션 내부
HashMap은 버킷 안에서 무슨 일을 하나
해시 충돌이 트리로 바뀌는 순간까지 들여다봐요.

HashMap은 내부에 버킷 배열(Node[] table)을 두고, 키의 hashCode()를 한 번 더 섞어(스프레드) 버킷 인덱스를 정해요. 같은 버킷에 여러 키가 몰리면(충돌) 그 버킷은 링크드 리스트로 이어집니다.

🔑
key.hashCode()
스프레드로 상위 비트 섞기
🪣
버킷 인덱스
(n-1) & hash 로 위치 결정
🌳
충돌 처리
리스트 → 임계 넘으면 트리
🌳 Java 8+ Treeify — 리스트가 레드블랙 트리로 변신 한 버킷의 충돌 노드 수가 임계값 8에 도달하고 전체 테이블 용량이 64 이상이면, 그 버킷의 링크드 리스트가 레드블랙 트리로 전환돼요(treeify).
리스트 탐색은 최악 O(n)이지만 트리는 O(log n) — 악의적 해시 충돌(DoS)에도 성능이 덜 무너지죠. 노드 수가 6 이하로 줄면 다시 리스트로 되돌아가요(untreeify). 단, 용량이 64 미만이면 트리 대신 리사이즈(확장)를 먼저 시도해요. 트리화는 "충분히 큰데도 특정 버킷만 몰릴 때"의 방어책입니다.
🖼️ 그림으로 보기 — 버킷 배열 · 충돌 체이닝 · treeify
버킷 배열 table[] [0] [1] [2] [3] [4] 같은 버킷 충돌 → 링크드 리스트로 체이닝 k1 k2 k3 노드 8개↑ & 용량 64↑ root 레드블랙 트리 최악 탐색 O(n) → O(log n)
💡
부하율(load factor) 0.75와 리사이즈. 저장된 원소 수가 용량 × 0.75를 넘으면 버킷 배열을 2배로 키우고 모든 엔트리를 재배치(rehash)해요. 0.75는 공간 낭비와 충돌 확률의 절충값이에요. 넣을 개수를 안다면 new HashMap<>(expected/0.75 + 1)로 초기 용량을 줘 리사이즈를 피하세요.
⚠️
equals / hashCode 계약.a.equals(b)true둘의 hashCode도 반드시 같아야 해요. ② hashCode가 같다고 equals가 참일 필요는 없어요(충돌 허용). 이 계약을 어기면 넣은 키를 다시 못 찾습니다. 또 키는 불변(immutable)을 권장 — 맵에 넣은 뒤 키의 필드를 바꿔 hashCode가 달라지면 그 엔트리는 영영 미아가 돼요.
📊 ArrayList vs LinkedList — 이론과 현실 ArrayList — 배열 기반. 랜덤 접근 O(1), 캐시 지역성(연속 메모리)이 좋아 순회도 빨라요. 중간 삽입/삭제는 O(n)(시프트).
LinkedList — 이중 연결 리스트. 이론상 중간 삽입 O(1)이지만, 그 위치를 찾는 데 O(n)이고 노드가 흩어져 있어 캐시 미스가 잦아요. 그래서 현실에선 대부분 ArrayList가 빠릅니다. LinkedList는 Deque처럼 양 끝 삽입/삭제가 주된 경우가 아니면 잘 안 써요. "중간 삽입 많으니 LinkedList"는 대개 함정.
🧭
fail-fast 이터레이터. 대부분의 컬렉션은 순회 중 구조가 바뀌면(modCount 변화 감지) ConcurrentModificationException을 던져요. 이건 버그를 빨리 드러내려는 안전장치지 동시성 보장이 아니에요. 순회 중 삭제가 필요하면 Iterator.remove()removeIf()를 쓰세요.
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4));
for (Integer x : list) {
    if (x % 2 == 0) list.remove(x); // ❌ ConcurrentModificationException
}
list.removeIf(x -> x % 2 == 0);     // ✅ 안전한 조건 삭제
🔒
동시성 컬렉션
여러 스레드가 같은 컬렉션을 만질 때
"동기화 래퍼"와 "진짜 동시성 컬렉션"은 다릅니다.
선택지동작 방식특징
Collections.synchronizedList모든 메서드를 단일 락으로 감쌈간단하지만 순회는 수동 synchronized 필요, 경합 심함
CopyOnWriteArrayList쓰기마다 배열 전체 복사읽기 락 없음·읽기 압도적으로 많을 때. 쓰기 비쌈
ConcurrentHashMap버킷 단위 락 / CAS고동시성 읽기·쓰기. null 키·값 불가
⚡ ConcurrentHashMap이 빠른 이유 초기(Java 7)엔 세그먼트(Segment) 단위로 락을 쪼갰고, Java 8부터는 세그먼트를 버리고 버킷(노드) 단위 락 + CAS로 더 잘게 나눠요. 빈 버킷에 넣을 땐 CAS로 락 없이, 충돌 버킷만 synchronized로 잠급니다. 그래서 서로 다른 버킷은 동시에 갱신돼요. 읽기(get)는 대개 락 없이 동작해요(volatile 가시성 활용). 그 대신 크기(size())는 근사값일 수 있고, 순회는 약한 일관성(weakly consistent)이라 CME를 던지지 않아요.
⚠️
ConcurrentHashMap은 null을 허용하지 않아요. 키도 값도 null 불가입니다. 이유는 모호성map.get(k)null일 때 "값이 null인가, 키가 없는가"를 동시성 환경에서 구분할 수 없기 때문이에요(containsKey로도 원자적 판별 불가). HashMap은 단일 스레드라 이를 허용하지만 CHM은 금지합니다.
💡
불변 컬렉션 List.of / Map.of. Java 9+의 팩토리는 수정 불가 컬렉션을 만들어요(추가·삭제 시 UnsupportedOperationException). 불변이라 스레드 안전하고 방어적 복사가 필요 없어요. 단, List.of(...)null 원소는 불가합니다. 가변이 필요하면 new ArrayList<>(List.of(...))로 감싸세요.
🧬
제네릭 심화
타입 소거와 PECS
"런타임엔 타입이 없다"는 사실이 모든 제약의 근원이에요.

자바 제네릭은 컴파일 시점에만 타입을 검사하고, 컴파일 후엔 타입 정보를 지워요 — 이게 타입 소거(type erasure)예요. List<String>List<Integer>는 런타임엔 둘 다 그냥 List입니다(하위 호환을 위한 설계).

🖼️ 그림으로 보기 — 타입 소거: 컴파일 후엔 타입이 사라진다
컴파일 타임 (타입 검사)
List<String>
List<Integer>
컴파일 · 소거
런타임 (타입 없음)
List  (원소는 Object)
그래서 new T[]·instanceof List<String> 불가
⚠️
소거 때문에 못 하는 것들.new T[]·new ArrayList<T>[] — 배열은 런타임 타입이 필요한데 T가 지워져 생성 불가. ② obj instanceof List<String> — 런타임엔 String 정보가 없어 컴파일 에러(List<?>만 가능). ③ 같은 소거 시그니처로는 오버로딩 불가(f(List<String>)f(List<Integer>)는 충돌). ④ 제네릭 타입으로 catch 불가.
↔️ PECS — Producer Extends, Consumer Super ? extends T(상한) — 컬렉션에서 값을 꺼내(생산) 읽을 때. 읽으면 T로 안전하지만 넣을 수 없어요(무엇이 들어올지 모름).
? super T(하한) — 컬렉션에 값을 넣을(소비) 때. T나 그 하위를 넣을 수 있지만, 꺼내면 Object로만 받아요. 외우는 법: 데이터를 주는 쪽(Producer)이면 extends, 받는 쪽(Consumer)이면 super. Collections.copy(dest, src)에서 src? extends T, dest? super T인 이유예요.
// src에서 꺼내(생산) dest에 넣는다(소비)
static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (int i = 0; i < src.size(); i++)
        dest.set(i, src.get(i));
}
List<Number> dst = new ArrayList<>(List.of(0, 0, 0));
List<Integer> s  = List.of(1, 2, 3);
copy(dst, s); // Integer(src) → Number(dest) : PECS 성립
🧭
경계와 브리지 메서드. <T>는 무제한, <T extends Comparable<T>>상한 경계(T의 메서드를 쓸 수 있게 함). 한편 컴파일러는 소거로 깨지는 다형성을 메우려 브리지 메서드를 몰래 만들어요 — 예: Comparable<T>.compareTo(T)를 구현하면 소거된 compareTo(Object) 브리지가 생성돼 오버라이딩이 유지됩니다.
🌊
함수형 · 스트림
파이프라인은 최종 연산에서야 흐른다
지연 평가와 병렬 스트림의 함정을 짚어요.

스트림은 중간 연산(filter·map…)최종 연산(collect·forEach…)으로 나뉘어요. 핵심은 지연 평가(lazy) — 중간 연산은 정의만 쌓아두고, 최종 연산이 호출될 때 비로소 원소가 흐르며 한 번에 처리됩니다.

🖼️ 그림으로 보기 — 스트림 파이프라인은 최종 연산에서야 흐른다
중간 연산 — 정의만 쌓임 (아직 실행 X) source of(a,b,c) filter 중간 map 중간 collect 최종 연산 이때 원소가 흐르며 실행!
Stream<String> s = Stream.of("a", "b", "c")
    .filter(x -> { System.out.println("filter " + x); return true; });
// 여기까지 아무것도 출력 안 됨 (지연)
long n = s.count(); // 최종 연산 → 이제서야 filter 실행
💡
지연 덕에 최적화가 된다. stream.filter(...).findFirst()조건 맞는 첫 원소를 찾으면 즉시 멈춰요(short-circuit). 전체를 걸러낸 뒤 첫 개를 고르는 게 아니에요. 무한 스트림(Stream.iterate)에 limit을 걸 수 있는 것도 지연 평가 덕분입니다.
🧺 Collectors — groupingBy와 toMap groupingBy(classifier) — 키 함수로 Map<K, List<V>>를 만들어요. 다운스트림(counting()·mapping())으로 값을 가공할 수 있어요.
toMap(k, v) — 키가 중복되면 예외(IllegalStateException)! 중복 가능성이 있으면 병합 함수 인자를 반드시 주세요: toMap(k, v, (a,b)->a). "어? 스트림에서 갑자기 예외가?" 절반은 toMap 키 중복이에요.
var words = List.of("apple", "ant", "bee", "bear");
Map<Character, Long> countByInitial = words.stream()
    .collect(Collectors.groupingBy(w -> w.charAt(0), Collectors.counting()));
// {a=2, b=2}
⚠️
병렬 스트림(.parallel())의 3대 함정. ① 기본적으로 공용 ForkJoinPool.commonPool을 써요 — 한 곳에서 남용하면 다른 병렬 작업까지 굶어요. ② 람다가 공유 가변 상태를 만지면 데이터 레이스(반드시 무상태·부수효과 없이). ③ forEach는 순서를 보장하지 않아요(순서가 필요하면 forEachOrdered). 데이터가 작거나 작업이 가벼우면 병렬이 오히려 느립니다.
🧭
Optional는 "없을 수 있는 반환값"에. Optional은 메서드 반환 타입에 쓰라고 만든 도구예요. 필드·메서드 파라미터·컬렉션 원소로 쓰는 건 안티패턴(직렬화·오버헤드·중첩). .get()을 바로 부르지 말고 orElse·orElseThrow·map으로 다뤄요. 메서드 참조(String::length)와 잘 어울립니다.
모던 자바
record · sealed · 패턴 매칭 (Java 17/21)
보일러플레이트를 줄이고 표현력을 높이는 문법들.
문법무엇핵심
record불변 데이터 캐리어equals/hashCode/toString·접근자 자동 생성. 필드는 final
sealed상속 허용 대상 제한permits로 하위 타입 봉인 → 컴파일러가 망라(exhaustive) 검사
switch 패턴 매칭타입·구조 분해 분기타입 검사+캐스팅+분기를 한 번에. when 가드
var지역 변수 타입 추론지역에서만. 타입 안전은 그대로(동적 아님)
텍스트 블록""" 여러 줄 문자열JSON·SQL·HTML 가독성 ↑
// record: 한 줄로 불변 데이터 + equals/hashCode/toString 자동
record Point(int x, int y) {}

// sealed: 허용된 하위 타입만
sealed interface Shape permits Circle, Rect {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}

// switch 패턴 매칭 + record 디컨스트럭션 (Java 21)
static double area(Shape s) {
    return switch (s) {
        case Circle c -> Math.PI * c.r() * c.r();
        case Rect(double w, double h) -> w * h; // 구조 분해
    }; // sealed라 default 없이도 망라 성립
}
🔗 record + sealed + 패턴 매칭은 한 세트 record가 데이터를 불변으로 담고, sealed가 "가능한 종류"를 못 박고, switch 패턴 매칭이 그 종류를 안전하게 분해해요. sealed 덕에 컴파일러가 모든 경우를 다뤘는지 검사하니 default 없이도 안전하고, 새 하위 타입을 추가하면 컴파일 에러로 누락을 알려줘요. 이 조합이 자바식 대수적 데이터 타입(ADT)이에요 — Kotlin·Scala의 sealed와 같은 결.
💡
record는 값 기반. 같은 컴포넌트 값이면 equalstrue라, 맵의 키나 Set 원소로 안성맞춤이에요(불변 + 계약 준수). 필요하면 컴팩트 생성자로 검증을 넣을 수 있어요: record Range(int lo, int hi){ Range{ if(lo>hi) throw...; } }.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·코드리뷰에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.
심화 CH 08 🔌 자바 I/O와 NIO — 스트림·버퍼·채널·Selector·직렬화