3주차 Unit 2.5 — Map 5형제 (HashMap / LinkedHashMap / TreeMap / Hashtable / ConcurrentHashMap)

Psj·2026년 5월 19일

F-lab

목록 보기
82/215

Unit 2.5 — Map 5형제 (HashMap / LinkedHashMap / TreeMap / Hashtable / ConcurrentHashMap)

F-LAB JAVA · 3주차 · Phase 2 · 컬렉션 프레임워크 전체 지도


📌 학습 목표

이 Unit을 끝내면 다음을 답할 수 있어야 한다.

  • Map 인터페이스의 본질 과 키-값 매핑 의미는?
  • HashMap 의 내부 (해시 테이블 + 1주차 PPT 복습) 는?
  • LinkedHashMap 이 어떻게 삽입 순서 또는 접근 순서를 유지하나? (LRU 캐시 구현)
  • TreeMap 의 Red-Black Tree 와 NavigableMap 의 강력함은?
  • Hashtable 가 왜 Java 1.0 Legacy 인가?
  • ConcurrentHashMap 의 segment / lock-stripe 메커니즘은?
  • Hashtable vs ConcurrentHashMap 의 결정적 차이는?
  • TreeMap 의 정렬 순서 (숫자 → 대문자 → 소문자 → 한글) 와 범위 검색은?
  • 시나리오별로 어떤 Map 을 선택하나?

🎯 핵심 한 문장

Map 은 "키-값 매핑" 의 추상화이고, 5형제는 각자 다른 트레이드오프를 제공한다.
HashMap 은 빠른 O(1) 검색의 표준 (90% 정답),
LinkedHashMap 은 순서 유지 + LRU 캐시,
TreeMap 은 정렬 키 + 범위 검색,
Hashtable 은 Legacy (사용 X),
ConcurrentHashMap 은 멀티스레드 환경의 정답.
박승제씨가 1주차에 만든 HashMap PPT 의 모든 학습이 이 Unit 에 응집된다.

비유 — 도서관의 도서 검색 시스템

시스템비유
HashMap일반 카탈로그 — ISBN 으로 즉시 찾음
LinkedHashMap대출 기록 — 빌린 순서대로 정렬
TreeMap사전 순 카탈로그 — 알파벳 순 정렬 + 범위 검색
Hashtable옛 종이 카탈로그 — 매번 자물쇠 (느림)
ConcurrentHashMap현대적 다중 입구 카탈로그 — 동시 검색 가능

→ "키로 값 찾기" 는 공통, "추가 조건" 으로 5형제 갈림.


🧭 9개 섹션 로드맵

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. 면접 + 자기 점검

1️⃣ Map 인터페이스의 본질

1.1 Map 의 정의

Map<K, V> 인터페이스의 정의:

  "키 (K) 와 값 (V) 의 매핑"
  
규칙:
  - 키는 유일 (중복 키 불가)
  - 값은 중복 가능
  - 키로 값 조회
  - Collection 의 자식 아님 (Phase 2.1 참고)

1.2 Map 의 핵심 메서드

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+)

1.3 Map.Entry 의 역할

public interface Map.Entry<K, V> {
    K getKey();
    V getValue();
    V setValue(V value);
}

Map.Entry:

  • 키-값 쌍 표현
  • entrySet() 으로 순회 시 사용
  • Java 9+ Map.entry(k, v) 로 불변 Entry 생성

1.4 ILIC 에서 Map 의 자연스러운 사용

@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 의 모든 부분에 등장.

1.5 자기 점검 답변

Map 을 List 대신 써야 하는 신호 3가지는?

:
1. 키로 빠른 조회 가 필요할 때 (O(1) HashMap vs O(n) List.indexOf)
2. 키-값 관계 가 의미 있을 때 (인덱싱)
3. 중복 키 제거 가 필요할 때

→ ILIC 의 대부분 캐시, 인덱스, 그룹핑이 Map.


2️⃣ HashMap — 1주차 PPT 학습의 응집

2.1 HashMap 의 본질

박승제씨가 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;     // 체이닝 (단일 연결 리스트)
    }
}

핵심:

  • 버킷 배열 + 연결 리스트 (해시 충돌 시)
  • 초기 capacity: 16
  • LoadFactor: 0.75
  • 임계값 도달 시 2배 확장

2.2 해시 충돌 처리 — Java 8+

Java 7 까지:
  버킷 = 연결 리스트 (단순)
  최악의 경우 O(n)

Java 8+ ★:
  버킷 = 연결 리스트 (요소 적음)
  연결 리스트 길이 > 8 → 트리 (Red-Black Tree) 로 변환
  트리 크기 < 6 → 다시 연결 리스트
  
  → 최악의 경우 O(log n) 보장

2.3 HashMap 의 내부 메모리

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)

2.4 put 동작 추적

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)

2.5 LoadFactor 0.75 의 의미

LoadFactor = size / capacity 의 임계값
0.75 의 의미:
  - 75% 차면 확장 시작
  - 메모리 사용 vs 충돌 빈도의 균형점
  - 너무 높으면 (예: 0.95) → 충돌 많음 → 성능 ↓
  - 너무 낮으면 (예: 0.5) → 메모리 낭비

박승제씨가 1주차 PPT 에서 본 내용.

2.6 HashMap 의 시간 복잡도

시간 복잡도 (평균):
  - put:     O(1)
  - get:     O(1)
  - remove:  O(1)
  - containsKey: O(1)

최악 (모든 키 같은 버킷):
  - Java 7:  O(n)
  - Java 8+: O(log n) (트리 변환)

순회:
  - O(n + capacity)

2.7 ILIC 사용 패턴 (자주)

// 패턴 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));

2.8 HashMap 의 초기 크기 지정

// 큰 Map 만들 때
Map<Long, Shipment> map = new HashMap<>(1024);
// initialCapacity 16 대신 1024 로 시작
// → 매번 확장 회피

// 더 정확하게
Map<Long, Shipment> map = new HashMap<>((int) (expectedSize / 0.75f) + 1);
// LoadFactor 고려한 정확한 크기

박승제씨가 큰 Map 만들 때 적극 활용.

2.9 자기 점검 답변

HashMap 의 LoadFactor 가 0.75 인 이유는?

:
1. 메모리 사용 vs 충돌 빈도의 균형점
2. 0.75 이상이면 충돌 빈번 → 성능 저하
3. 0.75 미만이면 메모리 낭비
4. 통계적 분석 + 실험적 검증
5. Java 표준 라이브러리의 합의된 값

→ 박승제씨가 1주차 PPT 학습으로 외운 핵심.


3️⃣ LinkedHashMap — 순서 유지 + LRU 캐시

3.1 LinkedHashMap 의 내부

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: 접근 순서
}

핵심:

  • HashMap 의 모든 기능 + 이중 연결 리스트
  • accessOrder 플래그로 순서 결정:
    • false (기본): 삽입 순서
    • true: 접근 순서 (LRU 구현 가능)

3.2 LinkedHashMap 의 메모리 구조

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).

3.3 삽입 순서 모드 (기본)

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 과 같은 원리.

3.4 접근 순서 모드 (LRU 캐시 핵심)

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) 알고리즘 의 자료구조.

3.5 간단한 LRU 캐시 구현

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 완성
  • 별도 라이브러리 (Caffeine, Guava) 없이도 구현 가능

3.6 LinkedHashMap 의 시간 복잡도

시간 복잡도:
  - put:     O(1) 평균 (HashMap 과 동일 + 포인터 갱신)
  - get:     O(1) 평균
  - remove:  O(1) 평균
  - 순회:    O(n) (정확히 n, HashMap 은 O(n + capacity))

→ HashMap 과 비슷한 성능 + 순서 유지.

3.7 메모리 비용

HashMap 의 Node:   ~ 32 bytes
LinkedHashMap의 Entry: ~ 48 bytes (before/after 추가)

→ HashMap 의 약 50% 더 메모리

3.8 ILIC 사용 사례

// 사례 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 생성
}

3.9 자기 점검 답변

LinkedHashMap 의 accessOrder=true 가 어떻게 LRU 를 구현하나?

:
1. accessOrder=trueget() 도 노드 위치 변경
2. 접근한 노드를 이중 연결 리스트의 끝 으로 이동
3. 결과적으로:

  • 가장 최근 접근 → tail 쪽
  • 가장 오래된 접근 → head 쪽
  1. removeEldestEntry 오버라이드로 head 삭제 → LRU 완성

→ 박승제씨가 직접 LRU 캐시 구현 가능.


4️⃣ TreeMap — Red-Black Tree + NavigableMap

4.1 TreeMap 의 내부

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):

  • 박승제씨가 Unit 2.2 (TreeSet) 에서 본 동일 구조
  • TreeSet 도 내부는 TreeMap

4.2 NavigableMap 의 강력한 API

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 의 가치는 정렬 + 범위 검색 + 근사 검색.

4.3 정렬 순서 (자연 순서)

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) 와 동일.

4.4 Comparator 로 정렬 기준 변경

// 역순 정렬
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) 와 연결.

4.5 TreeMap 의 시간 복잡도

시간 복잡도:
  - 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)) 보다 느리지만 정렬 + 범위 검색 가능.

4.6 ILIC 사용 사례

사례 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();
    }
}

사례 2 — 정렬된 캐시

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 의 강점.

사례 3 — 운임 구간

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 효과적.

4.7 자기 점검 답변

TreeMap 의 floorEntry(key) 가 무엇을 반환하나?

:

  • key 이하의 가장 큰 키 의 Entry 반환
  • 예: TreeMap 에 [10, 20, 30, 40] 있을 때
    • floorEntry(25) → Entry(20, ...)
    • floorEntry(30) → Entry(30, ...)
    • floorEntry(5) → null

구간 검색의 강력한 도구.


5️⃣ Hashtable — Java 1.0 Legacy

5.1 Hashtable 의 역사

Java 1.0 (1996):
  - HashMap 없음
  - Hashtable 만 있음
  - 모든 메서드 synchronized

Java 1.2 (1998):
  - HashMap 등장
  - Hashtable 은 Map 인터페이스 구현 (retrofit)
  - 여전히 synchronized 유지
  - → Legacy 가 됨

5.2 Hashtable 의 특징

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 과의 차이:

  • 모든 메서드 synchronized
  • null 키/값 거부 (NullPointerException)
  • 초기 capacity: 11 (HashMap 은 16)
  • 확장 시 (2n + 1) — 홀수 유지

5.3 Hashtable vs HashMap 비교

항목HashtableHashMap
동기화synchronized없음
null 키✓ 1개
null 값
초기 capacity1116
확장2n + 12n
성능느림빠름
사용 시점Legacy 만현재 표준

5.4 Hashtable 의 멀티스레드 안전성 — 불충분

Hashtable<String, Integer> table = new Hashtable<>();

// 스레드 1
if (!table.containsKey("X")) {
    table.put("X", 1);
}

// 스레드 2
if (!table.containsKey("X")) {
    table.put("X", 1);
}

문제:

  • 각 메서드는 synchronized
  • 하지만 containsKey + put 사이는 비원자적
  • 두 스레드 모두 containsKey 통과 → 둘 다 put → 중복 시도
Vector 와 같은 문제 (Unit 2.3 참고).
→ 진짜 thread-safe 가 아님.

5.5 Hashtable 안 쓰는 이유 7가지

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

5.6 Properties 클래스 — Hashtable 의 자식

public class Properties extends Hashtable<Object, Object> { ... }

Properties:

  • application.properties 파일 처리
  • key/value 둘 다 String
  • 여전히 사용 중 (Spring 등)
  • Hashtable 상속이지만 실용적

→ 박승제씨가 Spring 의 @Value("${...}") 와 관련된 부분.

5.7 Hashtable → ConcurrentHashMap 마이그레이션

// Legacy
Hashtable<String, Shipment> table = new Hashtable<>();

// 현대적 대안
ConcurrentMap<String, Shipment> map = new ConcurrentHashMap<>();

// 또는 (락 명시적)
Map<String, Shipment> map = Collections.synchronizedMap(new HashMap<>());

5.8 자기 점검 답변

Hashtable 을 절대 사용하지 말아야 하는 가장 큰 이유는?

:
1. 불필요한 synchronized 비용 (단일 스레드도)
2. 복합 작업 안전성 부족 (진짜 thread-safe 아님)
3. null 키/값 거부
4. Legacy (Java 1.0)
5. 더 나은 대안:

  • 단일 스레드: HashMap
  • 멀티스레드: ConcurrentHashMap

→ Vector 와 같은 운명 (Unit 2.3).


6️⃣ ConcurrentHashMap — 멀티스레드의 정답

6.1 ConcurrentHashMap 의 등장 배경

Hashtable 의 문제:
  - 모든 메서드 synchronized
  - 한 스레드가 작업 중이면 다른 스레드 모두 대기
  - 확장성 ↓

Collections.synchronizedMap(new HashMap<>()):
  - HashMap 전체에 락
  - Hashtable 과 비슷한 문제

ConcurrentHashMap (Java 5):
  - Segment 기반 락 (lock striping)
  - 부분 락 → 동시성 ↑
  - Java 8+ 에서는 CAS + synchronized 노드 락

6.2 Java 7 의 Segment 기반

ConcurrentHashMap (Java 7):
  - 16개의 Segment 로 분할 (기본)
  - 각 Segment 가 별도 락 보유
  - 다른 Segment 의 작업은 동시 수행
  
  ┌─────────────────────────────────────┐
  │ Segment[0]: 락 0                     │
  │   table[]: [..., ..., ...]            │
  ├─────────────────────────────────────┤
  │ Segment[1]: 락 1                     │
  │   table[]: [..., ..., ...]            │
  ├─────────────────────────────────────┤
  │ ...                                   │
  ├─────────────────────────────────────┤
  │ Segment[15]: 락 15                   │
  │   table[]: [..., ..., ...]            │
  └─────────────────────────────────────┘

→ 16개 락 → 최대 16개 스레드 동시 작업.

6.3 Java 8+ 의 변경

Java 8+ ConcurrentHashMap:
  - Segment 제거
  - 단일 table[] (HashMap 과 유사)
  - 각 버킷마다 synchronized 또는 CAS
  - 락 단위 더 작아짐 → 동시성 더 ↑
  
  ┌─────────────────────────────────────┐
  │ table[]:                              │
  │  [0]  → Node (락 단위)                │
  │  [1]  → null                          │
  │  [2]  → Node → Node (체이닝)          │
  │  ...                                   │
  │  [N]  → Tree (트리 변환 시)           │
  └─────────────────────────────────────┘
  
  put 동작:
    1. CAS 로 첫 노드 시도 (락 없이)
    2. 실패 시 synchronized (해당 노드만)

→ 더 세밀한 락.

6.4 ConcurrentHashMap 의 메서드

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);

핵심:

  • 원자적 메서드 — 멀티스레드 환경에서 안전
  • 병렬 메서드 — 스레드 수 지정 가능

6.5 ConcurrentHashMap vs Hashtable

항목ConcurrentHashMapHashtable
락 단위노드 (Java 8+)전체
동시성매우 높음낮음
null 키
null 값
원자적 메서드✓ (compute 등)
병렬 처리
성능빠름느림
사용 시점현재 표준Legacy

6.6 ConcurrentHashMap 가 null 거부하는 이유

ConcurrentMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("X", null);   // ❌ NullPointerException

이유:

Integer v = map.get("X");
// v 가 null 이면?
//   1. "X" 키가 없음
//   2. 또는 값이 null 인 키 존재
// 멀티스레드 환경에서 구분 불가 → 위험

해결:

  • HashMap 은 단일 스레드라 containsKey 로 구분 가능
  • ConcurrentHashMap 은 containsKeyget 사이에 다른 스레드 변경 가능
  • 그래서 null 자체를 거부

6.7 ConcurrentHashMap 의 size 정확성

ConcurrentHashMap<Long, Shipment> map = new ConcurrentHashMap<>();

int size = map.size();

size():

  • O(1) 이지만 근사값
  • 멀티스레드 변경 중일 수 있음
  • 정확한 카운트가 필요하면 별도 처리
// 정확한 카운트
LongAdder counter = new LongAdder();
counter.increment();   // 멀티스레드 안전
counter.sum();         // 정확한 합

6.8 ILIC 사용 사례

사례 1 — 멀티스레드 캐시

@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();
    }
}

사례 2 — 빈도 카운터

@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()
            ));
    }
}

사례 3 — 이벤트 리스너 등록

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));
        }
    }
}

6.9 자기 점검 답변

ConcurrentHashMap 이 Hashtable 보다 빠른 이유는?

:
1. 세밀한 락 단위:

  • Hashtable: 전체 락
  • ConcurrentHashMap (Java 7): Segment 단위 (16개 락)
  • ConcurrentHashMap (Java 8+): 노드 단위 + CAS
  1. CAS (Compare-And-Swap) 활용:

    • 락 없이 원자적 작업 시도
    • 실패 시에만 synchronized
  2. 읽기 락 최소화:

    • 일반 get 은 락 없이 가능 (volatile)
    • 멀티 스레드 읽기 매우 빠름
  3. 원자적 메서드 제공:

    • computeIfAbsent, merge 등
    • 별도 락 없이 안전

→ Hashtable 보다 5-10배 빠름.


7️⃣ ILIC 실무 — Map 선택 가이드

7.1 시나리오별 선택

시나리오 1 — 일반 캐시

// 단일 스레드
private final Map<Long, Shipment> cache = new HashMap<>();

// 멀티 스레드
private final ConcurrentMap<Long, Shipment> cache = new ConcurrentHashMap<>();

→ 90% 케이스.

시나리오 2 — LRU 캐시

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 라이브러리.

시나리오 3 — 정렬된 키

TreeMap<LocalDateTime, ShipmentEvent> timeline = new TreeMap<>();

// 범위 검색
timeline.subMap(from, to);

→ TreeMap.

시나리오 4 — 응답 JSON 의 순서

Map<String, Object> response = new LinkedHashMap<>();
response.put("id", ...);
response.put("blNo", ...);
// 순서대로 JSON 직렬화

→ LinkedHashMap.

시나리오 5 — 불변 상수

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+).

시나리오 6 — 멀티스레드 빈도 카운트

private final ConcurrentMap<String, AtomicLong> counts = new ConcurrentHashMap<>();

public void record(String key) {
    counts.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
}

→ ConcurrentHashMap + AtomicLong.

시나리오 7 — Enum 키

EnumMap<ShipmentStatus, List<Shipment>> byStatus = new EnumMap<>(ShipmentStatus.class);

EnumMap (Enum 전용, 매우 빠름).

7.2 Map 선택 의사결정 표

시나리오선택이유
일반 캐시 (단일)HashMap90% 정답, O(1)
일반 캐시 (멀티)ConcurrentHashMap안전 + 빠름
LRU 캐시LinkedHashMap (accessOrder=true)자동 LRU
정렬 키TreeMapNavigableMap
시간순 이벤트TreeMap범위 검색
응답 순서LinkedHashMap삽입 순서
Prefix 검색TreeMapsubMap
불변 상수Map.of가벼움
Enum 키EnumMap비트벡터
빈도 카운트 (멀티)ConcurrentHashMap + AtomicLong원자적

7.3 ILIC 의 90:9:1 법칙

박승제씨가 ILIC 에서 Map 사용:

90%: HashMap / ConcurrentHashMap
  - 캐시
  - 인덱스
  - 그룹핑
  - 일반 키-값 매핑

9%: 특수 Map
  - LinkedHashMap (LRU, 순서)
  - TreeMap (정렬, 범위)
  - EnumMap (Enum 키)
  - Map.of (불변 상수)

1%: 기타
  - WeakHashMap (캐시 자동 제거)
  - IdentityHashMap (참조 동일성)

0%: Legacy
  - Hashtable

7.4 ILIC 표준 패턴

// 표준 패턴 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);

7.5 ILIC 코드 리뷰 체크리스트

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 로 불변 반환?

7.6 자기 점검 답변

ILIC 에서 Map 선택 90% 답은?

:

  • 단일 스레드: HashMap
  • 멀티스레드: ConcurrentHashMap
  • 모두 O(1) 평균
  • 캐시, 인덱스, 그룹핑이 대부분

특수 케이스:

  • LRU 필요 → LinkedHashMap
  • 정렬 필요 → TreeMap
  • Enum 키 → EnumMap

8️⃣ 흔한 실수 + 안티패턴

8.1 실수 1 — Hashtable 사용

// ❌ Legacy
Hashtable<String, Shipment> map = new Hashtable<>();

// ✓ 단일 스레드
Map<String, Shipment> map = new HashMap<>();

// ✓ 멀티 스레드
ConcurrentMap<String, Shipment> map = new ConcurrentHashMap<>();

8.2 실수 2 — 멀티스레드에 HashMap

// ❌ 위험
@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<>();

문제:

  • 멀티스레드 put 시 내부 구조 깨짐
  • 무한 루프 가능 (Java 7 의 알려진 버그)
  • Java 8+ 에서도 일관성 깨짐

8.3 실수 3 — Map 키로 mutable 객체

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 — 못 찾음

해결:

  • Map 키는 불변 객체 권장
  • 또는 변경 안 되는 필드 기반 hashCode

8.4 실수 4 — null 값 가정

ConcurrentMap<Long, Shipment> map = new ConcurrentHashMap<>();
map.put(1L, null);   // ❌ NullPointerException

ConcurrentHashMap 은 null 값 거부.
→ Optional 또는 명시적 처리.

8.5 실수 5 — get 후 put 비원자적

// ❌ 비원자적
if (!map.containsKey(key)) {
    map.put(key, value);   // 두 스레드가 동시에 → 중복 시도
}

// ✓ 원자적
map.putIfAbsent(key, value);

// ✓ 또는
map.computeIfAbsent(key, k -> value);

ConcurrentHashMap 의 원자적 메서드 활용.

8.6 실수 6 — Map.of 변경 시도

Map<String, Integer> immutable = Map.of("A", 1);
immutable.put("B", 2);   // ❌ UnsupportedOperationException

Map.of:

  • Java 9+ 불변 Map
  • 변경 메서드 호출 시 예외

해결:

Map<String, Integer> mutable = new HashMap<>(Map.of("A", 1));
mutable.put("B", 2);   // OK

8.7 실수 7 — 큰 Map 초기 크기 미지정

// ❌ 매번 확장
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);

8.8 실수 8 — ConcurrentHashMap.size 정확성 가정

ConcurrentMap<Long, Shipment> map = new ConcurrentHashMap<>();

int size = map.size();
// 멀티스레드 환경에서 정확하지 않을 수 있음

해결:

  • 정확한 카운트 필요하면 LongAdder 사용
  • 또는 작업 중간엔 size 신뢰 X

8.9 ILIC Map 안티패턴 체크리스트

박승제씨가 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

9️⃣ 면접 + 자기 점검

9.1 면접 단골 질문 매핑

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

9.2 자기 점검 체크리스트

기본 이해

  • Map 인터페이스의 핵심 메서드를 안다
  • HashMap 의 해시 테이블 구조를 안다 (1주차 PPT 복습)
  • LinkedHashMap 의 이중 연결 + accessOrder 를 안다
  • TreeMap 의 Red-Black Tree + NavigableMap 을 안다
  • Hashtable 의 Legacy 이유를 안다
  • ConcurrentHashMap 의 락 단위 (Java 7 vs 8) 를 안다

시간 복잡도

  • HashMap: O(1) 평균, O(log n) 최악
  • LinkedHashMap: O(1) + 순서 유지
  • TreeMap: O(log n) 모든 작업
  • ConcurrentHashMap: O(1) 평균, 동시성

실전 적용

  • 시나리오에 맞는 Map 선택
  • Hashtable 안 씀
  • 멀티스레드 HashMap → ConcurrentHashMap
  • LRU 캐시 직접 구현 가능
  • computeIfAbsent 활용
  • Enum 키 → EnumMap

면접 대비

  • 5형제 비교 설명
  • HashMap 의 내부 동작
  • LRU 캐시 구현 코드
  • ConcurrentHashMap 의 진화
  • ILIC 사용 사례 5가지

🎯 핵심 요약 — 3줄 정리

1. Map 5형제의 본질

  • HashMap = 해시 테이블 + 체이닝/트리 (90% 정답)
  • LinkedHashMap = HashMap + 이중 연결 (순서, LRU)
  • TreeMap = Red-Black Tree (정렬, 범위)
  • Hashtable = Java 1.0 Legacy (사용 X)
  • ConcurrentHashMap = 멀티스레드 표준 (Java 8+ 노드 락)

2. 1주차 HashMap PPT 의 응집

  • 해시 함수, LoadFactor 0.75, 체이닝, Java 8 트리 변환
  • 박승제씨의 학습이 이 Unit 에 응집
  • 모든 구현체의 토대

3. ILIC 실무 90:9:1

  • 90% HashMap / ConcurrentHashMap
  • 9% LinkedHashMap, TreeMap, EnumMap, Map.of
  • 1% 특수 (WeakHashMap, IdentityHashMap)
  • 0% Hashtable

📚 다음으로...

Unit 2.6 — 컬렉션 전체 지도 정리 (Phase 2 완주)

이번 Unit에서 Map 5형제를 봤다면, 다음은 Phase 2 의 종합 정리.

  • 4가지 자료구조 + 모든 구현체 종합 표
  • 시간 복잡도 종합
  • 박승제씨의 ILIC 90:9:1 패턴
  • Phase 2 졸업 시험
  • Phase 3 (해시의 원리) 진입 준비

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 완주)

3주차 누적 진행

✅ Phase 1 — Pass by Value (1.1 ~ 1.3 완주)
🚀 Phase 2 — 컬렉션 프레임워크 (5/6 진행)

총: 8/43 Unit 작성 (약 19%)

profile
Software Developer

0개의 댓글