매일 쓰는 HashMap·ArrayList·스트림을 "내부 동작" 수준으로 다시 봅니다. 해시 충돌이 트리로 바뀌는 순간, 타입 소거의 함정, PECS 규칙, 병렬 스트림의 공용 풀, 그리고 record·sealed·패턴 매칭까지 — 코드 리뷰와 시니어 면접에서 실력이 갈리는 지점이에요.
HashMap의 버킷·해시·treeify(트리 전환)·리사이즈와 equals/hashCode 계약을 설명할 수 있어요.ArrayList와 LinkedList의 실제 성능 차이와 fail-fast 이터레이터를 이해해요.ConcurrentHashMap·CopyOnWriteArrayList와 synchronizedList의 차이를 구분해요.record·sealed·패턴 매칭을 활용해요.HashMap은 내부에 버킷 배열(Node[] table)을 두고, 키의 hashCode()를 한 번 더 섞어(스프레드) 버킷 인덱스를 정해요. 같은 버킷에 여러 키가 몰리면(충돌) 그 버킷은 링크드 리스트로 이어집니다.
용량 × 0.75를 넘으면 버킷 배열을 2배로 키우고 모든 엔트리를 재배치(rehash)해요. 0.75는 공간 낭비와 충돌 확률의 절충값이에요. 넣을 개수를 안다면 new HashMap<>(expected/0.75 + 1)로 초기 용량을 줘 리사이즈를 피하세요.a.equals(b)가 true면 둘의 hashCode도 반드시 같아야 해요. ② hashCode가 같다고 equals가 참일 필요는 없어요(충돌 허용). 이 계약을 어기면 넣은 키를 다시 못 찾습니다. 또 키는 불변(immutable)을 권장 — 맵에 넣은 뒤 키의 필드를 바꿔 hashCode가 달라지면 그 엔트리는 영영 미아가 돼요.Deque처럼 양 끝 삽입/삭제가 주된 경우가 아니면 잘 안 써요. "중간 삽입 많으니 LinkedList"는 대개 함정.
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 키·값 불가 |
synchronized로 잠급니다. 그래서 서로 다른 버킷은 동시에 갱신돼요.
읽기(get)는 대개 락 없이 동작해요(volatile 가시성 활용). 그 대신 크기(size())는 근사값일 수 있고, 순회는 약한 일관성(weakly consistent)이라 CME를 던지지 않아요.
null 불가입니다. 이유는 모호성 — map.get(k)가 null일 때 "값이 null인가, 키가 없는가"를 동시성 환경에서 구분할 수 없기 때문이에요(containsKey로도 원자적 판별 불가). HashMap은 단일 스레드라 이를 허용하지만 CHM은 금지합니다.List.of / Map.of. Java 9+의 팩토리는 수정 불가 컬렉션을 만들어요(추가·삭제 시 UnsupportedOperationException). 불변이라 스레드 안전하고 방어적 복사가 필요 없어요. 단, List.of(...)도 null 원소는 불가합니다. 가변이 필요하면 new ArrayList<>(List.of(...))로 감싸세요.자바 제네릭은 컴파일 시점에만 타입을 검사하고, 컴파일 후엔 타입 정보를 지워요 — 이게 타입 소거(type erasure)예요. List<String>과 List<Integer>는 런타임엔 둘 다 그냥 List입니다(하위 호환을 위한 설계).
new T[]·instanceof List<String> 불가new T[]·new ArrayList<T>[] — 배열은 런타임 타입이 필요한데 T가 지워져 생성 불가. ② obj instanceof List<String> — 런타임엔 String 정보가 없어 컴파일 에러(List<?>만 가능). ③ 같은 소거 시그니처로는 오버로딩 불가(f(List<String>)와 f(List<Integer>)는 충돌). ④ 제네릭 타입으로 catch 불가.? 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) — 중간 연산은 정의만 쌓아두고, 최종 연산이 호출될 때 비로소 원소가 흐르며 한 번에 처리됩니다.
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을 걸 수 있는 것도 지연 평가 덕분입니다.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은 메서드 반환 타입에 쓰라고 만든 도구예요. 필드·메서드 파라미터·컬렉션 원소로 쓰는 건 안티패턴(직렬화·오버헤드·중첩). .get()을 바로 부르지 말고 orElse·orElseThrow·map으로 다뤄요. 메서드 참조(String::length)와 잘 어울립니다.| 문법 | 무엇 | 핵심 |
|---|---|---|
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 없이도 망라 성립
}
default 없이도 안전하고, 새 하위 타입을 추가하면 컴파일 에러로 누락을 알려줘요.
이 조합이 자바식 대수적 데이터 타입(ADT)이에요 — Kotlin·Scala의 sealed와 같은 결.
equals가 true라, 맵의 키나 Set 원소로 안성맞춤이에요(불변 + 계약 준수). 필요하면 컴팩트 생성자로 검증을 넣을 수 있어요: record Range(int lo, int hi){ Range{ if(lo>hi) throw...; } }.HashMap: 버킷+해시, 충돌 리스트가 임계 8·용량 64+면 레드블랙 트리로 treeify, 부하율 0.75에서 2배 리사이즈. equals⇔hashCode 일관성·불변 키.ArrayList는 캐시 지역성·랜덤 접근으로 대개 LinkedList보다 빠름. 순회 중 구조 변경은 fail-fast(CME).ConcurrentHashMap=버킷 락/CAS·null 불가, CopyOnWriteArrayList=읽기 우세, synchronizedList=단일 락 래퍼. List.of=불변.new T[]·instanceof List<String> 불가. PECS: Producer=extends, Consumer=super.Optional은 반환값용.var·텍스트 블록으로 표현력 ↑.