F-LAB JAVA · 3주차 · Phase 2 · 컬렉션 프레임워크 전체 지도
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
Map 은 "키-값 매핑" 의 추상화이고, 5형제는 각자 다른 트레이드오프를 제공한다.
HashMap 은 빠른 O(1) 검색의 표준 (90% 정답),
LinkedHashMap 은 순서 유지 + LRU 캐시,
TreeMap 은 정렬 키 + 범위 검색,
Hashtable 은 Legacy (사용 X),
ConcurrentHashMap 은 멀티스레드 환경의 정답.
박승제씨가 1주차에 만든 HashMap PPT 의 모든 학습이 이 Unit 에 응집된다.
| 시스템 | 비유 |
|---|---|
| HashMap | 일반 카탈로그 — ISBN 으로 즉시 찾음 |
| LinkedHashMap | 대출 기록 — 빌린 순서대로 정렬 |
| TreeMap | 사전 순 카탈로그 — 알파벳 순 정렬 + 범위 검색 |
| Hashtable | 옛 종이 카탈로그 — 매번 자물쇠 (느림) |
| ConcurrentHashMap | 현대적 다중 입구 카탈로그 — 동시 검색 가능 |
→ "키로 값 찾기" 는 공통, "추가 조건" 으로 5형제 갈림.
1. Map 인터페이스의 본질
2. HashMap — 1주차 PPT 학습의 응집
3. LinkedHashMap — 순서 유지 + LRU 캐시
4. TreeMap — Red-Black Tree + NavigableMap
5. Hashtable — Java 1.0 Legacy
6. ConcurrentHashMap — 멀티스레드의 정답
7. ILIC 실무 — Map 선택 가이드
8. 흔한 실수 + 안티패턴
9. 면접 + 자기 점검
Map<K, V> 인터페이스의 정의:
"키 (K) 와 값 (V) 의 매핑"
규칙:
- 키는 유일 (중복 키 불가)
- 값은 중복 가능
- 키로 값 조회
- Collection 의 자식 아님 (Phase 2.1 참고)
public interface Map<K, V> {
// 기본 작업
V put(K key, V value); // 추가/덮어쓰기
V get(Object key); // 조회
V remove(Object key); // 제거
boolean containsKey(Object key); // 키 존재 여부
boolean containsValue(Object value); // 값 존재 여부
int size();
boolean isEmpty();
// 컬렉션 view
Set<K> keySet();
Collection<V> values();
Set<Map.Entry<K, V>> entrySet();
// Java 8+ default 메서드
default V getOrDefault(Object key, V defaultValue);
default V putIfAbsent(K key, V value);
default V computeIfAbsent(K key, Function<? super K, ? extends V> mapper);
default V computeIfPresent(K key, BiFunction<? super K, ? super V, ? extends V> remapper);
default V compute(K key, BiFunction<? super K, ? super V, ? extends V> remapper);
default V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remapper);
default void forEach(BiConsumer<? super K, ? super V> action);
default void replaceAll(BiFunction<? super K, ? super V, ? extends V> function);
}
핵심:
put: 추가 또는 덮어쓰기 (기존 키면 새 값으로)get: O(1) 검색 (HashMap 기준)containsKey: 빠른 키 존재 검사computeIfAbsent: 없으면 계산해서 추가 (Java 8+)public interface Map.Entry<K, V> {
K getKey();
V getValue();
V setValue(V value);
}
Map.Entry:
entrySet() 으로 순회 시 사용Map.entry(k, v) 로 불변 Entry 생성@Service
public class ShipmentService {
// 1. 캐시
private final Map<Long, Shipment> cache = new HashMap<>();
// 2. 그룹핑 결과
public Map<String, List<Shipment>> groupByRoute(List<Shipment> shipments) {
return shipments.stream()
.collect(Collectors.groupingBy(Shipment::getRoute));
}
// 3. 카운팅
public Map<String, Long> countByStatus(List<Shipment> shipments) {
return shipments.stream()
.collect(Collectors.groupingBy(
s -> s.getStatus().name(),
Collectors.counting()
));
}
// 4. 인덱싱 (List → Map 변환)
public Map<Long, Shipment> indexById(List<Shipment> shipments) {
return shipments.stream()
.collect(Collectors.toMap(Shipment::getId, s -> s));
}
// 5. 설정값
private final Map<String, BigDecimal> routeFareRates = Map.of(
"BUSAN-LA", new BigDecimal("0.50"),
"BUSAN-NY", new BigDecimal("0.55"),
"INCHEON-LA", new BigDecimal("0.48")
);
}
→ Map 이 ILIC 의 모든 부분에 등장.
Map 을 List 대신 써야 하는 신호 3가지는?
답:
1. 키로 빠른 조회 가 필요할 때 (O(1) HashMap vs O(n) List.indexOf)
2. 키-값 관계 가 의미 있을 때 (인덱싱)
3. 중복 키 제거 가 필요할 때
→ ILIC 의 대부분 캐시, 인덱스, 그룹핑이 Map.
박승제씨가 1주차 PPT 에서 만든 학습 자료의 모든 내용이 이 섹션에 응집:
public class HashMap<K, V> implements Map<K, V>, Cloneable, Serializable {
transient Node<K, V>[] table; // 버킷 배열
transient int size; // 요소 수
int threshold; // 확장 임계값
final float loadFactor; // 보통 0.75
static class Node<K, V> implements Map.Entry<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next; // 체이닝 (단일 연결 리스트)
}
}
핵심:
Java 7 까지:
버킷 = 연결 리스트 (단순)
최악의 경우 O(n)
Java 8+ ★:
버킷 = 연결 리스트 (요소 적음)
연결 리스트 길이 > 8 → 트리 (Red-Black Tree) 로 변환
트리 크기 < 6 → 다시 연결 리스트
→ 최악의 경우 O(log n) 보장
HashMap<String, Shipment> map = new HashMap<>();
map.put("BL-001", s1);
map.put("BL-002", s2);
map.put("BL-005", s5); // 가정: "BL-001" 과 같은 해시
HashMap 객체:
┌─────────────────────────────────────┐
│ table[] (capacity 16) │
│ [0] → null │
│ [1] → null │
│ [2] → Node("BL-001", s1) │
│ ↓ │
│ Node("BL-005", s5) ← 체이닝 │
│ [3] → null │
│ [4] → null │
│ ... │
│ [7] → Node("BL-002", s2) │
│ ... │
│ [15] → null │
└─────────────────────────────────────┘
size = 3
threshold = 12 (= 16 × 0.75)
map.put("BL-001", shipment1);
내부 단계:
Step 1: "BL-001".hashCode() 계산
→ 정수 hash 값
Step 2: hash & (table.length - 1) → 버킷 인덱스
→ 예: hash 가 18 이면 18 & 15 = 2
→ table[2] 에 저장
Step 3: 버킷 확인
Case A: 버킷이 null → 새 노드 생성, 저장
Case B: 버킷에 같은 key → value 덮어쓰기
Case C: 버킷에 다른 key (해시 충돌) → 체이닝 (또는 트리)
Step 4: size++
Step 5: size > threshold → 2배 확장 (resize)
LoadFactor = size / capacity 의 임계값
0.75 의 의미:
- 75% 차면 확장 시작
- 메모리 사용 vs 충돌 빈도의 균형점
- 너무 높으면 (예: 0.95) → 충돌 많음 → 성능 ↓
- 너무 낮으면 (예: 0.5) → 메모리 낭비
박승제씨가 1주차 PPT 에서 본 내용.
시간 복잡도 (평균):
- put: O(1)
- get: O(1)
- remove: O(1)
- containsKey: O(1)
최악 (모든 키 같은 버킷):
- Java 7: O(n)
- Java 8+: O(log n) (트리 변환)
순회:
- O(n + capacity)
// 패턴 1: 캐시
private final Map<Long, Shipment> cache = new HashMap<>();
public Shipment getShipment(Long id) {
return cache.computeIfAbsent(id, this::loadFromDb);
}
// 패턴 2: 빈도 카운트
Map<String, Integer> routeCounts = new HashMap<>();
for (Shipment s : shipments) {
routeCounts.merge(s.getRoute(), 1, Integer::sum);
}
// 패턴 3: 빠른 조회 인덱스
Map<Long, Shipment> byId = shipments.stream()
.collect(Collectors.toMap(Shipment::getId, s -> s));
// 큰 Map 만들 때
Map<Long, Shipment> map = new HashMap<>(1024);
// initialCapacity 16 대신 1024 로 시작
// → 매번 확장 회피
// 더 정확하게
Map<Long, Shipment> map = new HashMap<>((int) (expectedSize / 0.75f) + 1);
// LoadFactor 고려한 정확한 크기
박승제씨가 큰 Map 만들 때 적극 활용.
HashMap 의 LoadFactor 가 0.75 인 이유는?
답:
1. 메모리 사용 vs 충돌 빈도의 균형점
2. 0.75 이상이면 충돌 빈번 → 성능 저하
3. 0.75 미만이면 메모리 낭비
4. 통계적 분석 + 실험적 검증
5. Java 표준 라이브러리의 합의된 값
→ 박승제씨가 1주차 PPT 학습으로 외운 핵심.
public class LinkedHashMap<K, V> extends HashMap<K, V> implements Map<K, V> {
static class Entry<K, V> extends HashMap.Node<K, V> {
Entry<K, V> before, after; // 이중 연결!
}
transient LinkedHashMap.Entry<K, V> head; // 첫 노드
transient LinkedHashMap.Entry<K, V> tail; // 마지막 노드
final boolean accessOrder; // ★ false: 삽입 순서, true: 접근 순서
}
핵심:
accessOrder 플래그로 순서 결정:false (기본): 삽입 순서true: 접근 순서 (LRU 구현 가능)LinkedHashMap<String, Shipment>:
내부 HashMap.table[]:
[0] → Entry("BL-001")
[1] → null
[2] → Entry("BL-002")
...
추가로 이중 연결 리스트:
head → Entry("BL-001") ↔ Entry("BL-002") ↔ Entry("BL-003") ← tail
(삽입 순서 또는 접근 순서)
→ HashMap 의 노드 + 추가 포인터 2개 (before/after).
Map<String, Integer> map = new LinkedHashMap<>();
map.put("C", 3);
map.put("A", 1);
map.put("B", 2);
map.forEach((k, v) -> System.out.println(k + "=" + v));
// 출력:
// C=3
// A=1
// B=2
// → 삽입 순서!
→ Set 의 LinkedHashSet 과 같은 원리.
LinkedHashMap<String, Integer> lru = new LinkedHashMap<>(16, 0.75f, true);
// ^^^^
// accessOrder = true
lru.put("A", 1);
lru.put("B", 2);
lru.put("C", 3);
lru.get("A"); // A 접근 → 끝으로 이동
lru.get("B"); // B 접근 → 끝으로 이동
lru.forEach((k, v) -> System.out.println(k + "=" + v));
// 출력:
// C=3 (제일 안 쓴 것)
// A=1
// B=2 (가장 최근에 쓴 것)
→ LRU (Least Recently Used) 알고리즘 의 자료구조.
public class SimpleLRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public SimpleLRUCache(int maxSize) {
super(maxSize, 0.75f, true); // accessOrder = true
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize;
// size 초과 시 가장 오래된 항목 자동 제거
}
}
// 사용
SimpleLRUCache<Long, Shipment> cache = new SimpleLRUCache<>(1000);
cache.put(1L, shipment1);
// ... 1001번째 추가 시 가장 오래된 것 자동 제거
박승제씨의 ILIC LRU 캐시:
removeEldestEntry 오버라이드만으로 LRU 완성시간 복잡도:
- put: O(1) 평균 (HashMap 과 동일 + 포인터 갱신)
- get: O(1) 평균
- remove: O(1) 평균
- 순회: O(n) (정확히 n, HashMap 은 O(n + capacity))
→ HashMap 과 비슷한 성능 + 순서 유지.
HashMap 의 Node: ~ 32 bytes
LinkedHashMap의 Entry: ~ 48 bytes (before/after 추가)
→ HashMap 의 약 50% 더 메모리
// 사례 1: 최근 본 운송장 목록 (순서 유지)
public class RecentShipmentTracker {
private static final int MAX = 10;
private final Map<Long, Shipment> recent = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Long, Shipment> eldest) {
return size() > MAX;
}
};
public void view(Shipment s) {
recent.put(s.getId(), s);
}
public List<Shipment> getRecent() {
List<Shipment> result = new ArrayList<>(recent.values());
Collections.reverse(result); // 최신 순
return result;
}
}
// 사례 2: 응답 JSON 의 순서 보장
public Map<String, Object> buildResponse(Shipment s) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", s.getId());
response.put("blNo", s.getBlNo());
response.put("status", s.getStatus());
response.put("createdAt", s.getCreatedAt());
return response;
// 직렬화 시 이 순서대로 JSON 생성
}
LinkedHashMap 의
accessOrder=true가 어떻게 LRU 를 구현하나?
답:
1. accessOrder=true 면 get() 도 노드 위치 변경
2. 접근한 노드를 이중 연결 리스트의 끝 으로 이동
3. 결과적으로:
removeEldestEntry 오버라이드로 head 삭제 → LRU 완성→ 박승제씨가 직접 LRU 캐시 구현 가능.
public class TreeMap<K, V> implements NavigableMap<K, V>, ... {
private final Comparator<? super K> comparator;
private transient Entry<K, V> root;
private transient int size = 0;
static final class Entry<K, V> implements Map.Entry<K, V> {
K key;
V value;
Entry<K, V> left;
Entry<K, V> right;
Entry<K, V> parent;
boolean color = BLACK; // Red-Black Tree
}
}
Red-Black Tree (자가 균형 BST):
TreeMap<Integer, String> map = new TreeMap<>();
map.put(10, "A");
map.put(20, "B");
map.put(30, "C");
map.put(40, "D");
map.put(50, "E");
// 첫/마지막
map.firstKey(); // 10
map.lastKey(); // 50
map.firstEntry(); // Entry(10, "A")
// 근사 키
map.floorKey(25); // 20 (25 이하 최댓값)
map.ceilingKey(25); // 30 (25 이상 최솟값)
map.lowerKey(30); // 20 (30 미만)
map.higherKey(30); // 40 (30 초과)
// 부분 Map
map.headMap(30); // {10=A, 20=B}
map.tailMap(30); // {30=C, 40=D, 50=E}
map.subMap(20, 40); // {20=B, 30=C}
// 역순
map.descendingMap(); // 역순 view
// 삭제 + 반환
map.pollFirstEntry(); // Entry(10, "A") + 제거
map.pollLastEntry(); // Entry(50, "E") + 제거
→ TreeMap 의 가치는 정렬 + 범위 검색 + 근사 검색.
TreeMap<String, Integer> map = new TreeMap<>();
map.put("apple", 1);
map.put("Banana", 2);
map.put("3rd", 3);
map.put("사과", 4);
map.put("Apple", 5);
map.forEach((k, v) -> System.out.println(k + "=" + v));
// 출력 순서:
// 3rd=3 (숫자)
// Apple=5 (대문자)
// Banana=2
// apple=1 (소문자)
// 사과=4 (한글)
정렬 규칙:
1. 숫자 0-9 (ASCII 48-57)
2. 대문자 A-Z (ASCII 65-90)
3. 소문자 a-z (ASCII 97-122)
4. 한글 (Unicode 0xAC00 ~ 0xD7A3)
→ Unit 2.2 (TreeSet) 와 동일.
// 역순 정렬
TreeMap<Integer, String> desc = new TreeMap<>(Comparator.reverseOrder());
// 객체 키
TreeMap<Shipment, BigDecimal> byCreatedAt = new TreeMap<>(
Comparator.comparing(Shipment::getCreatedAt)
);
// 복합 정렬
TreeMap<Order, Status> complex = new TreeMap<>(
Comparator.comparing(Order::getPriority)
.thenComparing(Order::getCreatedAt)
);
→ Phase 6 (Comparator) 와 연결.
시간 복잡도:
- put: O(log n)
- get: O(log n)
- remove: O(log n)
- containsKey: O(log n)
- firstKey/lastKey: O(log n)
- subMap/headMap/tailMap: O(log n)
- 순회: O(n)
→ HashMap (O(1)) 보다 느리지만 정렬 + 범위 검색 가능.
public class ShipmentEventTimeline {
private final TreeMap<LocalDateTime, ShipmentEvent> timeline = new TreeMap<>();
public void record(ShipmentEvent event) {
timeline.put(event.getTimestamp(), event);
}
// 최근 1시간 이벤트
public Collection<ShipmentEvent> recentEvents() {
LocalDateTime hourAgo = LocalDateTime.now().minusHours(1);
return timeline.tailMap(hourAgo).values();
}
// 특정 기간 이벤트
public Collection<ShipmentEvent> eventsBetween(LocalDateTime from, LocalDateTime to) {
return timeline.subMap(from, to).values();
}
// 첫 이벤트
public ShipmentEvent firstEvent() {
return timeline.firstEntry().getValue();
}
}
public class SortedShipmentCache {
private final TreeMap<String, Shipment> cache = new TreeMap<>();
public void put(Shipment s) {
cache.put(s.getBlNo(), s);
}
// BL 번호 prefix 검색
public NavigableMap<String, Shipment> findByPrefix(String prefix) {
return cache.subMap(
prefix, // 시작
prefix + Character.MAX_VALUE // 끝
);
}
// 예: "BL-2024" 로 시작하는 모든 운송장
findByPrefix("BL-2024");
}
→ prefix 검색은 TreeMap 의 강점.
public class FareCalculator {
// 무게 → 운임 단가
private final TreeMap<Integer, BigDecimal> fareRanges = new TreeMap<>();
public FareCalculator() {
fareRanges.put(0, new BigDecimal("0.10"));
fareRanges.put(100, new BigDecimal("0.08"));
fareRanges.put(500, new BigDecimal("0.06"));
fareRanges.put(1000, new BigDecimal("0.05"));
}
public BigDecimal getFareRate(int weight) {
return fareRanges.floorEntry(weight).getValue();
// floorEntry: weight 이하 최댓값
// 750kg → 500 구간의 0.06
}
}
→ 구간별 적용 규칙에 TreeMap 효과적.
TreeMap 의
floorEntry(key)가 무엇을 반환하나?
답:
key 이하의 가장 큰 키 의 Entry 반환floorEntry(25) → Entry(20, ...)floorEntry(30) → Entry(30, ...)floorEntry(5) → null→ 구간 검색의 강력한 도구.
Java 1.0 (1996):
- HashMap 없음
- Hashtable 만 있음
- 모든 메서드 synchronized
Java 1.2 (1998):
- HashMap 등장
- Hashtable 은 Map 인터페이스 구현 (retrofit)
- 여전히 synchronized 유지
- → Legacy 가 됨
public class Hashtable<K, V> implements Map<K, V>, ... {
private transient Entry<?, ?>[] table;
private transient int count;
public synchronized V put(K key, V value) { ... }
public synchronized V get(Object key) { ... }
public synchronized V remove(Object key) { ... }
// 모든 메서드가 synchronized
}
HashMap 과의 차이:
| 항목 | Hashtable | HashMap |
|---|---|---|
| 동기화 | synchronized | 없음 |
| null 키 | ❌ | ✓ 1개 |
| null 값 | ❌ | ✓ |
| 초기 capacity | 11 | 16 |
| 확장 | 2n + 1 | 2n |
| 성능 | 느림 | 빠름 |
| 사용 시점 | Legacy 만 | 현재 표준 |
Hashtable<String, Integer> table = new Hashtable<>();
// 스레드 1
if (!table.containsKey("X")) {
table.put("X", 1);
}
// 스레드 2
if (!table.containsKey("X")) {
table.put("X", 1);
}
문제:
Vector 와 같은 문제 (Unit 2.3 참고).
→ 진짜 thread-safe 가 아님.
1. Legacy (Java 1.0)
- 1998년 이후 HashMap 권장
2. 단일 스레드도 synchronized 비용
- 90% 케이스 단일 스레드 → 불필요한 오버헤드
3. 복합 작업 안전성 부족
- 진짜 멀티스레드 안전 보장 X
4. null 키/값 거부
- HashMap 은 1개의 null 키 + 다수 null 값 허용
- 일부 코드에서 필요
5. 더 나은 대안 존재
- 단일 스레드: HashMap
- 멀티스레드: ConcurrentHashMap
6. 확장 정책 비효율
- 2n + 1 의 홀수 유지가 의미 부족
7. API 일관성 부족
- elements, keys 같은 Enumeration 기반 old API
- Map 인터페이스에 retrofit
public class Properties extends Hashtable<Object, Object> { ... }
Properties:
→ 박승제씨가 Spring 의 @Value("${...}") 와 관련된 부분.
// Legacy
Hashtable<String, Shipment> table = new Hashtable<>();
// 현대적 대안
ConcurrentMap<String, Shipment> map = new ConcurrentHashMap<>();
// 또는 (락 명시적)
Map<String, Shipment> map = Collections.synchronizedMap(new HashMap<>());
Hashtable 을 절대 사용하지 말아야 하는 가장 큰 이유는?
답:
1. 불필요한 synchronized 비용 (단일 스레드도)
2. 복합 작업 안전성 부족 (진짜 thread-safe 아님)
3. null 키/값 거부
4. Legacy (Java 1.0)
5. 더 나은 대안:
→ Vector 와 같은 운명 (Unit 2.3).
Hashtable 의 문제:
- 모든 메서드 synchronized
- 한 스레드가 작업 중이면 다른 스레드 모두 대기
- 확장성 ↓
Collections.synchronizedMap(new HashMap<>()):
- HashMap 전체에 락
- Hashtable 과 비슷한 문제
ConcurrentHashMap (Java 5):
- Segment 기반 락 (lock striping)
- 부분 락 → 동시성 ↑
- Java 8+ 에서는 CAS + synchronized 노드 락
ConcurrentHashMap (Java 7):
- 16개의 Segment 로 분할 (기본)
- 각 Segment 가 별도 락 보유
- 다른 Segment 의 작업은 동시 수행
┌─────────────────────────────────────┐
│ Segment[0]: 락 0 │
│ table[]: [..., ..., ...] │
├─────────────────────────────────────┤
│ Segment[1]: 락 1 │
│ table[]: [..., ..., ...] │
├─────────────────────────────────────┤
│ ... │
├─────────────────────────────────────┤
│ Segment[15]: 락 15 │
│ table[]: [..., ..., ...] │
└─────────────────────────────────────┘
→ 16개 락 → 최대 16개 스레드 동시 작업.
Java 8+ ConcurrentHashMap:
- Segment 제거
- 단일 table[] (HashMap 과 유사)
- 각 버킷마다 synchronized 또는 CAS
- 락 단위 더 작아짐 → 동시성 더 ↑
┌─────────────────────────────────────┐
│ table[]: │
│ [0] → Node (락 단위) │
│ [1] → null │
│ [2] → Node → Node (체이닝) │
│ ... │
│ [N] → Tree (트리 변환 시) │
└─────────────────────────────────────┘
put 동작:
1. CAS 로 첫 노드 시도 (락 없이)
2. 실패 시 synchronized (해당 노드만)
→ 더 세밀한 락.
ConcurrentHashMap<Long, Shipment> map = new ConcurrentHashMap<>();
// 일반 Map 메서드 (멀티스레드 안전)
map.put(1L, s);
map.get(1L);
map.remove(1L);
// 원자적 메서드 (Java 8+)
map.putIfAbsent(1L, s); // 없을 때만 추가
map.computeIfAbsent(1L, k -> load(k)); // 없으면 계산
map.compute(1L, (k, v) -> v.update()); // 원자적 갱신
map.merge(1L, s, Shipment::combine); // 합치기
// 동시성 메서드
map.forEach(8, (k, v) -> process(k, v)); // 8개 스레드로 병렬 순회
map.search(8, (k, v) -> v.matches() ? v : null);
map.reduce(8, (k, v) -> v.getCount(), 0L, Long::sum);
핵심:
| 항목 | ConcurrentHashMap | Hashtable |
|---|---|---|
| 락 단위 | 노드 (Java 8+) | 전체 |
| 동시성 | 매우 높음 | 낮음 |
| null 키 | ❌ | ❌ |
| null 값 | ❌ | ❌ |
| 원자적 메서드 | ✓ (compute 등) | ❌ |
| 병렬 처리 | ✓ | ❌ |
| 성능 | 빠름 | 느림 |
| 사용 시점 | 현재 표준 | Legacy |
ConcurrentMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("X", null); // ❌ NullPointerException
이유:
Integer v = map.get("X");
// v 가 null 이면?
// 1. "X" 키가 없음
// 2. 또는 값이 null 인 키 존재
// 멀티스레드 환경에서 구분 불가 → 위험
해결:
containsKey 로 구분 가능containsKey 와 get 사이에 다른 스레드 변경 가능ConcurrentHashMap<Long, Shipment> map = new ConcurrentHashMap<>();
int size = map.size();
size():
// 정확한 카운트
LongAdder counter = new LongAdder();
counter.increment(); // 멀티스레드 안전
counter.sum(); // 정확한 합
@Service
public class ShipmentCache {
private final ConcurrentMap<Long, Shipment> cache = new ConcurrentHashMap<>();
public Shipment get(Long id) {
return cache.computeIfAbsent(id, this::loadFromDb);
// computeIfAbsent: 원자적 (안전)
}
public void invalidate(Long id) {
cache.remove(id);
}
public void invalidateAll() {
cache.clear();
}
}
@Service
public class RouteStatistics {
private final ConcurrentMap<String, AtomicLong> routeCounts = new ConcurrentHashMap<>();
public void recordShipment(Shipment s) {
routeCounts.computeIfAbsent(s.getRoute(), k -> new AtomicLong())
.incrementAndGet();
}
public Map<String, Long> getCounts() {
return routeCounts.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue().get()
));
}
}
public class ShipmentEventDispatcher {
private final ConcurrentMap<String, List<EventListener>> listeners =
new ConcurrentHashMap<>();
public void register(String eventType, EventListener listener) {
listeners.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>())
.add(listener);
}
public void dispatch(String eventType, Event event) {
List<EventListener> targets = listeners.get(eventType);
if (targets != null) {
targets.forEach(l -> l.onEvent(event));
}
}
}
ConcurrentHashMap 이 Hashtable 보다 빠른 이유는?
답:
1. 세밀한 락 단위:
CAS (Compare-And-Swap) 활용:
읽기 락 최소화:
원자적 메서드 제공:
→ Hashtable 보다 5-10배 빠름.
// 단일 스레드
private final Map<Long, Shipment> cache = new HashMap<>();
// 멀티 스레드
private final ConcurrentMap<Long, Shipment> cache = new ConcurrentHashMap<>();
→ 90% 케이스.
private final Map<Long, Shipment> lruCache = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Long, Shipment> eldest) {
return size() > 1000;
}
};
→ 또는 Caffeine 라이브러리.
TreeMap<LocalDateTime, ShipmentEvent> timeline = new TreeMap<>();
// 범위 검색
timeline.subMap(from, to);
→ TreeMap.
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", ...);
response.put("blNo", ...);
// 순서대로 JSON 직렬화
→ LinkedHashMap.
private static final Map<String, BigDecimal> PORT_FARES = Map.of(
"BUSAN-LA", new BigDecimal("0.50"),
"BUSAN-NY", new BigDecimal("0.55")
);
→ Map.of (Java 9+).
private final ConcurrentMap<String, AtomicLong> counts = new ConcurrentHashMap<>();
public void record(String key) {
counts.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
}
→ ConcurrentHashMap + AtomicLong.
EnumMap<ShipmentStatus, List<Shipment>> byStatus = new EnumMap<>(ShipmentStatus.class);
→ EnumMap (Enum 전용, 매우 빠름).
| 시나리오 | 선택 | 이유 |
|---|---|---|
| 일반 캐시 (단일) | HashMap | 90% 정답, O(1) |
| 일반 캐시 (멀티) | ConcurrentHashMap | 안전 + 빠름 |
| LRU 캐시 | LinkedHashMap (accessOrder=true) | 자동 LRU |
| 정렬 키 | TreeMap | NavigableMap |
| 시간순 이벤트 | TreeMap | 범위 검색 |
| 응답 순서 | LinkedHashMap | 삽입 순서 |
| Prefix 검색 | TreeMap | subMap |
| 불변 상수 | Map.of | 가벼움 |
| Enum 키 | EnumMap | 비트벡터 |
| 빈도 카운트 (멀티) | ConcurrentHashMap + AtomicLong | 원자적 |
박승제씨가 ILIC 에서 Map 사용:
90%: HashMap / ConcurrentHashMap
- 캐시
- 인덱스
- 그룹핑
- 일반 키-값 매핑
9%: 특수 Map
- LinkedHashMap (LRU, 순서)
- TreeMap (정렬, 범위)
- EnumMap (Enum 키)
- Map.of (불변 상수)
1%: 기타
- WeakHashMap (캐시 자동 제거)
- IdentityHashMap (참조 동일성)
0%: Legacy
- Hashtable
// 표준 패턴 1: 일반 Map
Map<Long, Shipment> map = new HashMap<>();
// 표준 패턴 2: 멀티스레드 Map
ConcurrentMap<Long, Shipment> cache = new ConcurrentHashMap<>();
// 표준 패턴 3: 초기 크기 지정 (큰 Map)
Map<Long, Shipment> bigMap = new HashMap<>((int) (expectedSize / 0.75f) + 1);
// 표준 패턴 4: 컬렉션 → Map 변환
Map<Long, Shipment> byId = shipments.stream()
.collect(Collectors.toMap(Shipment::getId, s -> s));
// 표준 패턴 5: 그룹핑
Map<String, List<Shipment>> byRoute = shipments.stream()
.collect(Collectors.groupingBy(Shipment::getRoute));
// 표준 패턴 6: 불변 Map
Map<String, BigDecimal> CONSTANTS = Map.of("KEY", VALUE);
// 표준 패턴 7: computeIfAbsent 캐싱
Shipment s = cache.computeIfAbsent(id, this::loadFromDb);
// 표준 패턴 8: merge 카운팅
counts.merge(key, 1L, Long::sum);
Map 사용 검토:
타입 선택:
- Hashtable → ConcurrentHashMap
- HashMap 멀티스레드 → ConcurrentHashMap
- 큰 Map 초기 크기 미지정 → 지정
- Enum 키인데 HashMap → EnumMap
- 순서 필요한데 HashMap → LinkedHashMap
- 정렬 필요한데 HashMap → TreeMap
equals + hashCode:
- 키로 사용하는 객체의 equals/hashCode 오버라이드?
- 가변 객체 키 사용 안 함?
- JPA Entity 키면 id 기반?
멀티스레드:
- ConcurrentHashMap.size 의 비정확성?
- computeIfAbsent 의 원자성 활용?
- 컬렉션 값에 ConcurrentXxx?
안전:
- 매개변수 Map 변경 (side effect)?
- null 키/값 가능성?
- Map.copyOf 로 불변 반환?
ILIC 에서 Map 선택 90% 답은?
답:
특수 케이스:
// ❌ Legacy
Hashtable<String, Shipment> map = new Hashtable<>();
// ✓ 단일 스레드
Map<String, Shipment> map = new HashMap<>();
// ✓ 멀티 스레드
ConcurrentMap<String, Shipment> map = new ConcurrentHashMap<>();
// ❌ 위험
@Service
public class ShipmentCache {
private final Map<Long, Shipment> cache = new HashMap<>();
public void put(Long id, Shipment s) {
cache.put(id, s); // 멀티스레드에서 데이터 깨짐
}
}
// ✓ ConcurrentHashMap
private final ConcurrentMap<Long, Shipment> cache = new ConcurrentHashMap<>();
문제:
public class Cargo {
private String code; // mutable
@Override
public int hashCode() {
return code.hashCode();
}
}
Map<Cargo, Integer> map = new HashMap<>();
Cargo c = new Cargo("A");
map.put(c, 1);
c.setCode("B"); // hashCode 변경!
map.get(c); // null — 못 찾음
해결:
ConcurrentMap<Long, Shipment> map = new ConcurrentHashMap<>();
map.put(1L, null); // ❌ NullPointerException
ConcurrentHashMap 은 null 값 거부.
→ Optional 또는 명시적 처리.
// ❌ 비원자적
if (!map.containsKey(key)) {
map.put(key, value); // 두 스레드가 동시에 → 중복 시도
}
// ✓ 원자적
map.putIfAbsent(key, value);
// ✓ 또는
map.computeIfAbsent(key, k -> value);
ConcurrentHashMap 의 원자적 메서드 활용.
Map<String, Integer> immutable = Map.of("A", 1);
immutable.put("B", 2); // ❌ UnsupportedOperationException
Map.of:
해결:
Map<String, Integer> mutable = new HashMap<>(Map.of("A", 1));
mutable.put("B", 2); // OK
// ❌ 매번 확장
Map<Long, Shipment> map = new HashMap<>();
for (int i = 0; i < 1_000_000; i++) {
map.put((long) i, ...);
}
// ✓ 초기 크기 지정
Map<Long, Shipment> map = new HashMap<>(2_000_000);
// 또는 정확히
Map<Long, Shipment> map = new HashMap<>((int)(1_000_000 / 0.75f) + 1);
ConcurrentMap<Long, Shipment> map = new ConcurrentHashMap<>();
int size = map.size();
// 멀티스레드 환경에서 정확하지 않을 수 있음
해결:
LongAdder 사용박승제씨가 ILIC 에서 피할 것:
레거시:
- Hashtable → ConcurrentHashMap
- Properties 는 OK (Spring 등에서 사용)
멀티스레드:
- 공유 HashMap → ConcurrentHashMap
- get + put 패턴 → computeIfAbsent
- null 값 → Optional 또는 거부
키:
- Mutable 객체 키 → 불변 객체
- JPA Entity 키 → id 기반 hashCode
성능:
- 큰 Map 초기 크기 미지정 → 지정
- List.contains 빈번 → Map.containsKey
함정:
- Map.of 변경 시도 → 가변 사본
- ConcurrentHashMap.size 정확성 가정 → LongAdder
| Q | 핵심 답변 |
|---|---|
| Map 의 정의? | 키-값 매핑, 키 유일 |
| HashMap 내부 구조? | 해시 테이블 + 체이닝 + Java 8 트리 |
| LoadFactor 0.75? | 메모리/충돌 균형점 |
| LinkedHashMap 의 accessOrder? | LRU 캐시 구현 |
| TreeMap 내부? | Red-Black Tree |
| NavigableMap 메서드? | floor/ceiling/headMap/tailMap/subMap |
| Hashtable 안 쓰는 이유? | Legacy + synchronized + 더 나은 대안 |
| ConcurrentHashMap vs Hashtable? | 세밀한 락 vs 전체 락 |
| ConcurrentHashMap 의 null 거부? | get 결과의 모호성 회피 |
| computeIfAbsent 의 원자성? | 멀티스레드 안전 캐싱 패턴 |
| EnumMap 의 이점? | 비트벡터, 매우 빠름 |
| TreeMap floorEntry? | 키 이하 최댓값 Entry |
1. Map 5형제의 본질
2. 1주차 HashMap PPT 의 응집
3. ILIC 실무 90:9:1
이번 Unit에서 Map 5형제를 봤다면, 다음은 Phase 2 의 종합 정리.
🚀 Phase 2 — 컬렉션 프레임워크 전체 지도
✅ Unit 2.1 배열의 한계와 컬렉션의 등장
✅ Unit 2.2 Set 3형제
✅ Unit 2.3 List 3형제
✅ Unit 2.4 Queue
✅ Unit 2.5 Map 5형제 ← 여기
⏭ Unit 2.6 컬렉션 전체 지도 정리 (Phase 2 완주)
✅ Phase 1 — Pass by Value (1.1 ~ 1.3 완주)
🚀 Phase 2 — 컬렉션 프레임워크 (5/6 진행)
총: 8/43 Unit 작성 (약 19%)