F-lab Java 1주차 / Phase 6 / Unit 6.5 본격 학습 자료 — Phase 6 마무리!
9-섹션 마스터 프롬프트 형식으로 깊이 파헤친다.선수 지식: Phase 4-5, Unit 6.1-6.4 (특히 HashMap, ArrayList/LinkedList)
다음 학습: Phase 7 — I/O & NIO (예정)이 Unit의 의미: 순서가 있는 Map 의 두 가지 답.
TreeMap = 정렬 순서 (범위 쿼리). LinkedHashMap = 삽입/접근 순서 (LRU 캐시).
면접 ("TreeMap 언제 쓰나?") + 실무 (LRU 캐시 6줄 구현) 두 마리.
세 가지 Map 을 일상 비유로 풀어보겠습니다.
[A]
Apple — 사과
Apricot — 살구
[B]
Banana — 바나나
Berry — 베리
[C]
Cat — 고양이
Cherry — 체리
...
특징:
핵심: 정렬된 상태 유지 + 범위 검색.
2024-12-01 14:30 Alice
2024-12-02 09:15 Bob
2024-12-02 11:45 Charlie
2024-12-03 16:20 David
특징:
핵심: 삽입 순서 또는 접근 순서 유지.
"HashMap 은 순서 없음 (빠름), TreeMap 은 정렬 순서 (범위), LinkedHashMap 은 삽입/접근 순서 (LRU)."
비유 정리:
| 비유 | 자바 클래스 | 순서 | 특징 |
|---|---|---|---|
| 사물함 | HashMap | 없음 | 가장 빠름 |
| 영어 사전 | TreeMap | 정렬 | 범위 쿼리 |
| 방명록 | LinkedHashMap | 삽입/접근 | LRU 캐시 |
HashMap = 임의 배치 책꽂이:
TreeMap = 분류 정리된 책꽂이:
LinkedHashMap = 도착 순서 책꽂이:
HashMap 은 빠르지만 순서 정보가 없음.
Map<Integer, Customer> customers = new HashMap<>();
customers.put(20, alice);
customers.put(35, bob);
customers.put(40, charlie);
customers.put(55, david);
// 30~50대 고객만 가져오기?
// HashMap 으로는 — 전체 순회 + 필터링 → O(n)
→ 순서가 있다면 O(log n + k) 로 가능.
// LRU 캐시 시나리오
// 가장 오래 안 쓴 항목 제거
Map<String, Cache> cache = new HashMap<>();
cache.put("A", a); // 1번째
cache.put("B", b); // 2번째
cache.put("C", c); // 3번째
// "가장 오래된 항목" → 어떻게?
// HashMap 은 순서 없음 → 추적 불가
→ 삽입 순서가 유지된다면 즉시 처리 가능.
Map<String, Integer> wordCount = new HashMap<>();
wordCount.put("apple", 5);
wordCount.put("banana", 3);
wordCount.put("cherry", 8);
// 알파벳 순서로 출력?
// HashMap 으로는 — keys → 정렬 → 매번 O(n log n)
→ 항상 정렬되어 있다면 O(n) 으로 끝.
// Java 1.2 (1998)
public class TreeMap<K, V> implements NavigableMap<K, V> {
private final Comparator<? super K> comparator;
private transient Entry<K,V> root; // Red-Black Tree
private transient int size;
}
핵심:
subMap, headMap, tailMap, floorKey, ceilingKey)// Java 1.4 (2002)
public class LinkedHashMap<K, V> extends HashMap<K, V> {
transient LinkedHashMap.Entry<K,V> head; // 첫 요소
transient LinkedHashMap.Entry<K,V> tail; // 마지막 요소
final boolean accessOrder; // false = 삽입 순서, true = 접근 순서
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after; // 양방향 연결
}
}
핵심:
accessOrder=false (기본): 삽입 순서 유지accessOrder=true: 접근 순서 유지 (LRU 구현 가능)| 연산 | HashMap | TreeMap | LinkedHashMap |
|---|---|---|---|
get | O(1) ⭐ | O(log n) | O(1) ⭐ |
put | O(1) ⭐ | O(log n) | O(1) ⭐ |
remove | O(1) ⭐ | O(log n) | O(1) ⭐ |
containsKey | O(1) ⭐ | O(log n) | O(1) ⭐ |
| firstKey/lastKey | X | O(log n) ⭐ | O(1) (첫/마지막) |
| subMap (범위) | X | O(log n + k) ⭐ | X |
| 순회 (순서) | 보장 X | 정렬 순서 ⭐ | 삽입/접근 순서 ⭐ |
→ TreeMap 은 정렬 메서드, LinkedHashMap 은 순서 + 같은 성능.
| 항목 | HashMap | TreeMap | LinkedHashMap |
|---|---|---|---|
| Node 크기 | 32 바이트 | 40 바이트 (parent 추가) | 48 바이트 (before/after 추가) |
| 메모리/요소 | 1배 | 1.25배 | 1.5배 |
→ LinkedHashMap 가 메모리 가장 큼, 그러나 순서 유지의 가치 가 있을 때만.
"같은 Map 인터페이스, 세 가지 다른 trade-off."
속도만 필요하면 HashMap, 정렬과 범위가 필요하면 TreeMap, 삽입 순서나 LRU 가 필요하면 LinkedHashMap. 상황에 맞는 선택 이 시니어 자바 개발자의 역량.
@Service
public class CargoSearchService {
// ❌ HashMap 으로 가격대 검색
private Map<Integer, List<Cargo>> cargosByFare = new HashMap<>();
public List<Cargo> findByFareRange(int minFare, int maxFare) {
List<Cargo> result = new ArrayList<>();
for (Map.Entry<Integer, List<Cargo>> entry : cargosByFare.entrySet()) {
// ⚠️ 모든 가격대 순회 → O(n)
if (entry.getKey() >= minFare && entry.getKey() <= maxFare) {
result.addAll(entry.getValue());
}
}
return result;
}
}
문제:
해결: TreeMap
private NavigableMap<Integer, List<Cargo>> cargosByFare = new TreeMap<>();
public List<Cargo> findByFareRange(int minFare, int maxFare) {
// O(log n + k) — 정렬된 트리에서 범위 검색
return cargosByFare.subMap(minFare, true, maxFare, true)
.values().stream()
.flatMap(List::stream)
.collect(Collectors.toList());
}
// ❌ HashMap + List 로 LRU 구현 시도
public class BadLRUCache<K, V> {
private Map<K, V> map = new HashMap<>();
private List<K> accessOrder = new ArrayList<>();
private int capacity;
public V get(K key) {
V value = map.get(key);
if (value != null) {
accessOrder.remove(key); // ⚠️ O(n)
accessOrder.add(key);
}
return value;
}
public void put(K key, V value) {
if (map.size() >= capacity && !map.containsKey(key)) {
K oldest = accessOrder.remove(0); // ⚠️ O(n)
map.remove(oldest);
}
map.put(key, value);
accessOrder.remove(key); // O(n)
accessOrder.add(key);
}
}
문제:
해결: LinkedHashMap
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true); // accessOrder = true!
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
// 6 줄로 LRU 캐시 완성! 모든 작업 O(1)
// ❌ HashMap — 순서 보존 X
Map<LocalDateTime, Activity> activities = new HashMap<>();
activities.put(time1, a1);
activities.put(time2, a2);
activities.put(time3, a3);
// 시간순 출력?
for (Activity a : activities.values()) {
System.out.println(a); // 순서 보장 X!
}
해결 1: TreeMap (시간순 정렬)
NavigableMap<LocalDateTime, Activity> activities = new TreeMap<>();
// 자동으로 시간순
for (Activity a : activities.values()) { ... } // 시간순 출력
해결 2: LinkedHashMap (삽입 순서)
Map<LocalDateTime, Activity> activities = new LinkedHashMap<>();
// 삽입 순서 유지 (시간순 삽입했다면 시간순)
Q: "TreeMap 은 언제 쓰나요?"
A: "음... HashMap 보다 정렬되는 거에요" ❌ 표면적
시니어 답변:
1. 범위 쿼리 가 필요할 때 (subMap, headMap, tailMap)
2. floorKey/ceilingKey 같은 근접 키 검색
3. 정렬된 출력 이 자주 필요할 때
4. NavigableMap 의 강력한 메서드들
5. trade-off: O(log n) 비용
// ❌ 매번 정렬
Map<Integer, Integer> hourlyCount = new HashMap<>();
// ... 데이터 추가 ...
// 시간순 출력 시
List<Integer> sortedHours = new ArrayList<>(hourlyCount.keySet());
Collections.sort(sortedHours); // 매번 O(n log n)
for (int hour : sortedHours) {
System.out.println(hour + ": " + hourlyCount.get(hour));
}
해결: TreeMap
Map<Integer, Integer> hourlyCount = new TreeMap<>();
// 자동 정렬
for (Map.Entry<Integer, Integer> e : hourlyCount.entrySet()) {
System.out.println(e.getKey() + ": " + e.getValue());
}
// JSON 응답에서 키 순서가 중요한 경우
@RestController
public class ReportController {
@GetMapping("/report")
public Map<String, Object> getReport() {
Map<String, Object> result = new HashMap<>();
result.put("totalCount", 1000);
result.put("avgFare", 50000);
result.put("maxWeight", 5000);
return result;
// JSON 순서가 무작위!
// {"avgFare":50000, "maxWeight":5000, "totalCount":1000}
}
// ✓ LinkedHashMap — 삽입 순서 유지
@GetMapping("/report-ordered")
public Map<String, Object> getReportOrdered() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("totalCount", 1000);
result.put("avgFare", 50000);
result.put("maxWeight", 5000);
return result;
// JSON: {"totalCount":1000, "avgFare":50000, "maxWeight":5000}
}
}
| 시나리오 | 잘못된 선택 | 올바른 선택 |
|---|---|---|
| 가격대 범위 검색 | HashMap O(n) | TreeMap O(log n + k) |
| LRU 캐시 | 직접 구현 O(n) | LinkedHashMap O(1) |
| 시간순 출력 | HashMap + 정렬 매번 | TreeMap 또는 LinkedHashMap |
| 면접 답변 | 표면적 | 시니어 |
| API 응답 순서 | HashMap (무작위) | LinkedHashMap |
→ 적절한 Map 선택은 시니어의 핵심 역량.
TreeMap<String, Integer> map = new TreeMap<>();
map.put("Charlie", 3);
map.put("Alice", 1);
map.put("Bob", 2);
// 순회 → 정렬된 순서!
for (Map.Entry<String, Integer> e : map.entrySet()) {
System.out.println(e.getKey() + ": " + e.getValue());
}
// 출력:
// Alice: 1
// Bob: 2
// Charlie: 3
NavigableMap<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.subMap(20, 40); // {20=B, 30=C} (40 미포함)
map.subMap(20, true, 40, true); // {20=B, 30=C, 40=D}
map.headMap(30); // {10=A, 20=B}
map.tailMap(30); // {30=C, 40=D, 50=E}
// === 근접 키 검색 ===
map.floorKey(25); // 25 이하 중 가장 큰 키 → 20
map.ceilingKey(25); // 25 이상 중 가장 작은 키 → 30
map.lowerKey(20); // 20 미만 중 가장 큰 키 → 10
map.higherKey(20); // 20 초과 중 가장 작은 키 → 30
// === 첫 / 마지막 ===
map.firstKey(); // 10
map.lastKey(); // 50
map.firstEntry(); // (10, A)
map.lastEntry(); // (50, E)
map.pollFirstEntry(); // (10, A) 반환 + 제거
map.pollLastEntry(); // (50, E) 반환 + 제거
// === 역순 ===
map.descendingMap(); // 역순 Map
map.descendingKeySet(); // 역순 키
// 1. 자연 순서 (Comparable)
TreeMap<String, Integer> ascMap = new TreeMap<>(); // A, B, C...
// 2. 역순
TreeMap<String, Integer> descMap = new TreeMap<>(Comparator.reverseOrder());
// 3. 커스텀 Comparator
TreeMap<Customer, Order> byAge = new TreeMap<>(
Comparator.comparingInt(Customer::getAge)
);
// 4. 다중 정렬
TreeMap<Customer, Order> byGradeThenName = new TreeMap<>(
Comparator.comparing(Customer::getGrade)
.thenComparing(Customer::getName)
);
Map<String, Integer> map = new LinkedHashMap<>();
map.put("Charlie", 3);
map.put("Alice", 1);
map.put("Bob", 2);
// 순회 → 삽입 순서!
for (Map.Entry<String, Integer> e : map.entrySet()) {
System.out.println(e.getKey());
}
// 출력:
// Charlie (먼저 삽입)
// Alice
// Bob
// accessOrder = true
LinkedHashMap<String, Integer> map = new LinkedHashMap<>(16, 0.75f, true);
map.put("A", 1);
map.put("B", 2);
map.put("C", 3);
// 현재 순서: A → B → C (삽입 순서)
map.get("A"); // A 접근
// 순서 변경: B → C → A
// (A 가 가장 최근 접근 → 맨 뒤로 이동)
핵심: get() 호출 시 그 항목이 맨 뒤로 이동.
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true); // accessOrder = true
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity; // capacity 초과 시 가장 오래된 제거
}
}
// 사용
LRUCache<String, Customer> cache = new LRUCache<>(100);
cache.put("alice", aliceCustomer);
cache.get("alice"); // alice 가 가장 최근
// 100 개 초과 시 자동으로 가장 오래 안 쓴 항목 제거
핵심 메커니즘:
accessOrder = true → get 시 맨 뒤로 이동removeEldestEntry 오버라이드 → 자동 제거 정책| 항목 | HashMap | TreeMap | LinkedHashMap |
|---|---|---|---|
| 순서 | 없음 | 정렬 | 삽입/접근 |
| 시간 복잡도 | O(1) | O(log n) | O(1) |
| null key | 1개 | ❌ | 1개 |
| 메모리/요소 | 1배 | 1.25배 | 1.5배 |
| 범위 쿼리 | X | ✓ ⭐ | X |
| firstKey/lastKey | X | O(log n) | O(1) |
| LRU 구현 | 어려움 | 어려움 | 6줄 ⭐ |
| 사용 시기 | 기본 | 정렬/범위 | 순서/LRU |
Map 이 필요하다
↓
순서가 필요한가?
├── 무관 → HashMap (또는 ConcurrentHashMap)
├── 정렬 순서 / 범위 쿼리 → TreeMap ⭐
└── 삽입 / 접근 순서
├── 삽입 순서만 → LinkedHashMap (기본)
└── LRU 캐시 → LinkedHashMap(accessOrder=true) ⭐
// 단어 빈도 → 알파벳순 출력
TreeMap<String, Integer> wordCount = new TreeMap<>();
words.forEach(w -> wordCount.merge(w, 1, Integer::sum));
wordCount.forEach((word, count) ->
System.out.println(word + ": " + count)
);
// 자동으로 알파벳순!
// 가격대별 화물
NavigableMap<Integer, List<Cargo>> byFare = new TreeMap<>();
// 5,000원~10,000원 범위
Map<Integer, List<Cargo>> mid = byFare.subMap(5000, 10001);
Map<String, Data> cache = new LinkedHashMap<>(100, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Data> eldest) {
return size() > 100;
}
};
Map<String, Object> response = new LinkedHashMap<>();
response.put("status", "success");
response.put("data", ...);
response.put("timestamp", ...);
// JSON 출력 순서 보장
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;
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
}
}
Red-Black Tree 규칙:
1. 노드는 Red 또는 Black
2. Root 는 Black
3. Red 노드의 자식은 Black
4. 모든 leaf 까지의 Black 노드 수는 같음
5. → 균형 보장: 최악 깊이 ≤ 2 × log(n+1)
public V put(K key, V value) {
Entry<K,V> t = root;
// 1. 루트가 없으면 root 로
if (t == null) {
compare(key, key); // null/type check
root = new Entry<>(key, value, null);
size = 1;
return null;
}
// 2. 비교하며 위치 찾기
Comparator<? super K> cpr = comparator;
int cmp;
Entry<K,V> parent;
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0) t = t.left;
else if (cmp > 0) t = t.right;
else return t.setValue(value); // 같은 키 → 값 교체
} while (t != null);
// 3. 새 노드 추가
Entry<K,V> e = new Entry<>(key, value, parent);
if (cmp < 0) parent.left = e;
else parent.right = e;
// 4. Red-Black 균형 조정
fixAfterInsertion(e);
size++;
return null;
}
O(log n): 트리 깊이만큼 탐색.
// map.subMap(20, 40)
// → 20 ≤ key < 40 인 모든 entry
알고리즘:
1. 시작 키 (20) 찾기: O(log n)
2. 끝 키 (40) 찾기: O(log n)
3. 사이의 모든 entry 순회: O(k)
4. 총: O(log n + k)
중요: subMap 은 view — 원본 변경 시 반영됨.
| 작업 | 시간 |
|---|---|
| 삽입 | O(log n) |
| 검색 | O(log n) |
| 삭제 | O(log n) |
| 최소/최대 | O(log n) |
| 범위 검색 | O(log n + k) |
자바 8+ HashMap 의 Treeification 도 같은 Red-Black Tree.
public class LinkedHashMap<K,V> extends HashMap<K,V> {
transient Entry<K,V> head; // 첫 번째 (가장 오래된)
transient Entry<K,V> tail; // 마지막 (가장 최근)
final boolean accessOrder; // false=삽입순서, true=접근순서
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after; // 양방향 연결
}
}
핵심:
HashMap 부분 (table 배열):
table[0]: ...
table[3]: Node(B) → Node(D)
table[5]: Node(A)
table[7]: Node(C)
...
LinkedHashMap 의 양방향 리스트:
head → A ↔ B ↔ C ↔ D ← tail
↑ ↑
가장 오래된 가장 최근
각 Node 는 next (HashMap) 와 before/after (LinkedHashMap) 모두 보유
// LinkedHashMap.newNode()
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p = new LinkedHashMap.Entry<>(hash, key, value, e);
linkNodeLast(p); // ★ 양방향 리스트의 끝에 추가
return p;
}
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
}
효과:
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)
afterNodeAccess(e); // ★ 맨 뒤로 이동
return e.value;
}
void afterNodeAccess(Node<K,V> e) {
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
// e 를 현재 위치에서 빼고 tail 로 이동
LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e;
LinkedHashMap.Entry<K,V> b = p.before, a = p.after;
p.after = null;
if (b == null) head = a;
else b.after = a;
if (a != null) a.before = b;
else last = b;
if (last == null) head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
}
}
핵심:
// LinkedHashMap 의 메서드
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false; // 기본: 제거 안 함
}
// HashMap.afterNodeInsertion 에서 호출
void afterNodeInsertion(boolean evict) {
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
LRU 캐시의 동작:
1. put 호출 → 새 entry 추가
2. afterNodeInsertion 호출
3. removeEldestEntry 오버라이드 가 true 반환 시
4. head (가장 오래된 항목) 제거
모드 비교:
// 1. 삽입 순서 (accessOrder = false, 기본)
Map<String, Integer> insertion = new LinkedHashMap<>();
insertion.put("A", 1);
insertion.put("B", 2);
insertion.put("C", 3);
insertion.get("A"); // 순서 변경 X
// 순회: A, B, C (삽입 순서)
// 2. 접근 순서 (accessOrder = true)
Map<String, Integer> access = new LinkedHashMap<>(16, 0.75f, true);
access.put("A", 1);
access.put("B", 2);
access.put("C", 3);
access.get("A"); // A 가 맨 뒤로!
// 순회: B, C, A (접근 순서)
// LinkedHashSet 도 같은 원리
Set<String> set = new LinkedHashSet<>();
set.add("C");
set.add("A");
set.add("B");
for (String s : set) {
System.out.println(s); // C, A, B (삽입 순서)
}
→ HashSet + 양방향 리스트.
@Service
public class CargoFareService {
private final NavigableMap<Integer, List<Cargo>> cargosByFare = new TreeMap<>();
public void registerCargo(Cargo cargo) {
cargosByFare
.computeIfAbsent(cargo.getFare(), k -> new ArrayList<>())
.add(cargo);
}
public List<Cargo> findByFareRange(int minFare, int maxFare) {
// O(log n + k) — 정렬 트리에서 범위 검색
return cargosByFare.subMap(minFare, true, maxFare, true)
.values().stream()
.flatMap(List::stream)
.collect(Collectors.toList());
}
public Cargo findCheapestAbove(int minFare) {
// 가장 저렴한 화물 중 minFare 이상
Map.Entry<Integer, List<Cargo>> entry = cargosByFare.ceilingEntry(minFare);
return entry == null ? null : entry.getValue().get(0);
}
public Cargo findMostExpensive() {
Map.Entry<Integer, List<Cargo>> entry = cargosByFare.lastEntry();
return entry == null ? null : entry.getValue().get(0);
}
}
@Service
public class FareCalculationCache {
private final LRUCache<String, BigDecimal> cache = new LRUCache<>(1000);
public BigDecimal getFare(String routeKey) {
BigDecimal cached = cache.get(routeKey);
if (cached != null) return cached;
// 비싼 계산
BigDecimal computed = fareCalculator.calculate(routeKey);
cache.put(routeKey, computed);
return computed;
}
}
// LRU 캐시 — 6 줄!
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
효과:
@Service
public class DailySalesService {
// 일자 -> 매출
private final NavigableMap<LocalDate, Long> dailySales = new TreeMap<>();
public void recordSale(LocalDate date, long amount) {
dailySales.merge(date, amount, Long::sum);
}
public Map<LocalDate, Long> getRecentDays(int days) {
LocalDate today = LocalDate.now();
LocalDate from = today.minusDays(days);
// O(log n + k)
return dailySales.subMap(from, true, today, true);
}
public Long getMaxSale() {
return dailySales.values().stream()
.max(Long::compareTo)
.orElse(0L);
}
public LocalDate getMaxSaleDate() {
return dailySales.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse(null);
}
public void printChronological() {
// 자동으로 날짜순!
dailySales.forEach((date, amount) ->
System.out.println(date + ": " + amount + "원"));
}
}
public class TopNAnalysis {
public List<Map.Entry<String, Integer>> getTopCustomers(
Map<String, Integer> ordersByCustomer, int n) {
// 주문 수 → 고객 (정렬 트리)
NavigableMap<Integer, List<String>> byCount = new TreeMap<>();
ordersByCustomer.forEach((customer, count) ->
byCount.computeIfAbsent(count, k -> new ArrayList<>()).add(customer));
// 상위 N 개
List<Map.Entry<String, Integer>> top = new ArrayList<>();
for (Map.Entry<Integer, List<String>> e : byCount.descendingMap().entrySet()) {
for (String customer : e.getValue()) {
if (top.size() >= n) return top;
top.add(Map.entry(customer, e.getKey()));
}
}
return top;
}
}
@RestController
public class CargoController {
@GetMapping("/api/cargo/{id}")
public Map<String, Object> getCargoDetails(@PathVariable Long id) {
Cargo cargo = cargoService.findById(id);
// 순서가 중요한 응답
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", cargo.getId());
response.put("name", cargo.getName());
response.put("weight", cargo.getWeight());
response.put("fare", cargo.getFare());
response.put("originPort", cargo.getOriginPort());
response.put("destinationPort", cargo.getDestinationPort());
response.put("createdAt", cargo.getCreatedAt());
return response;
// JSON 출력 순서 보장!
}
}
public class CustomerSortExample {
public NavigableMap<Customer, Order> sortByGradeAndName(
Map<Customer, Order> orders) {
// 등급 (높은 등급 우선) → 이름 (가나다순)
Comparator<Customer> sorter = Comparator
.comparing(Customer::getGrade, Comparator.reverseOrder())
.thenComparing(Customer::getName);
TreeMap<Customer, Order> sorted = new TreeMap<>(sorter);
sorted.putAll(orders);
return sorted;
}
public NavigableMap<Customer, Order> sortByFare(
Map<Customer, Order> orders) {
// 운임 (높은 순)
Comparator<Customer> byFare = (a, b) -> {
long fareA = orders.get(a).getFare();
long fareB = orders.get(b).getFare();
return Long.compare(fareB, fareA); // 내림차순
};
TreeMap<Customer, Order> sorted = new TreeMap<>(byFare);
sorted.putAll(orders);
return sorted;
}
}
public class CacheWithStats<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
private long hits = 0;
private long misses = 0;
public CacheWithStats(int capacity) {
super(capacity, 0.75f, true);
this.capacity = capacity;
}
@Override
public V get(Object key) {
V value = super.get(key);
if (value != null) hits++;
else misses++;
return value;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
public double getHitRate() {
long total = hits + misses;
return total == 0 ? 0 : (double) hits / total;
}
public void printStats() {
System.out.printf("Hits: %d, Misses: %d, HitRate: %.2f%%%n",
hits, misses, getHitRate() * 100);
}
}
public class OrderBook {
// 매수 주문 (가격 높은 순으로 정렬)
private final NavigableMap<BigDecimal, Long> bids = new TreeMap<>(Comparator.reverseOrder());
// 매도 주문 (가격 낮은 순으로 정렬)
private final NavigableMap<BigDecimal, Long> asks = new TreeMap<>();
public void addBid(BigDecimal price, long quantity) {
bids.merge(price, quantity, Long::sum);
}
public void addAsk(BigDecimal price, long quantity) {
asks.merge(price, quantity, Long::sum);
}
public BigDecimal getBestBid() {
return bids.isEmpty() ? null : bids.firstKey(); // 가장 높은 매수 가격
}
public BigDecimal getBestAsk() {
return asks.isEmpty() ? null : asks.firstKey(); // 가장 낮은 매도 가격
}
public BigDecimal getSpread() {
BigDecimal bestBid = getBestBid();
BigDecimal bestAsk = getBestAsk();
if (bestBid == null || bestAsk == null) return null;
return bestAsk.subtract(bestBid);
}
public Map<BigDecimal, Long> getTop5Bids() {
return bids.entrySet().stream()
.limit(5)
.collect(Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue,
(a, b) -> a, LinkedHashMap::new));
// LinkedHashMap → 순서 보존
}
}
TreeMap<String, Integer> map = new TreeMap<>();
map.put(null, 1); // ❌ NullPointerException
이유: 정렬을 위해 키 비교 → null 비교 불가.
해결: HashMap 또는 LinkedHashMap 사용 (null 키 1개 허용).
class Customer {
String customerId;
}
TreeMap<Customer, String> map = new TreeMap<>();
map.put(new Customer(), "value"); // ❌ ClassCastException
이유: Customer 가 Comparable 미구현 → 정렬 불가.
해결 1: Comparable 구현
class Customer implements Comparable<Customer> {
@Override
public int compareTo(Customer other) {
return this.customerId.compareTo(other.customerId);
}
}
해결 2: Comparator 전달
TreeMap<Customer, String> map = new TreeMap<>(
Comparator.comparing(Customer::getCustomerId)
);
NavigableMap<Integer, String> map = new TreeMap<>();
map.put(10, "A"); map.put(20, "B"); map.put(30, "C");
Map<Integer, String> sub = map.subMap(15, 25); // {20=B}
map.put(25, "X"); // 원본 수정
System.out.println(sub); // {20=B, 25=X} — view 가 갱신됨!
// 수정 시 원본도 변경
sub.put(22, "Y");
System.out.println(map); // {10=A, 20=B, 22=Y, 25=X, 30=C}
원칙: subMap 은 view. 독립 사본 필요 시 복사.
Map<Integer, String> copy = new TreeMap<>(map.subMap(15, 25));
// LRU 의도
Map<String, Integer> cache = new LinkedHashMap<>(100); // ❌ 기본 = 삽입 순서
cache.put("A", 1);
cache.get("A"); // 순서 변경 안 됨!
해결: 생성자 명시
new LinkedHashMap<>(100, 0.75f, true); // accessOrder = true
// LRU 캐시 시도
Map<String, Data> cache = new LinkedHashMap<>(100, 0.75f, true);
// ❌ 자동 제거 안 됨!
// removeEldestEntry 가 항상 false 반환 (기본값)
해결: 익명 클래스 또는 상속
Map<String, Data> cache = new LinkedHashMap<>(100, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Data> eldest) {
return size() > 100;
}
};
// ❌ 빠른 lookup 이 필요한데 TreeMap 사용
Map<String, Customer> lookup = new TreeMap<>();
for (int i = 0; i < 1_000_000; i++) {
lookup.get(keys[i]); // O(log n) × 100만 ≈ 2천만
}
해결: 정렬/범위 필요 없으면 HashMap.
Map<String, Customer> lookup = new HashMap<>(); // O(1) × 100만 = 100만
// 100만 데이터 + 순서 불필요
Map<String, Customer> map = new LinkedHashMap<>(1_000_000); // ❌ 1.5배 메모리
해결: 순서 필요 없으면 HashMap.
Map<String, Customer> map = new HashMap<>(1_000_000); // 메모리 효율
class Cargo {
private double weight;
// ❌ Comparable 위반
public int compareTo(Cargo other) {
if (this.weight < other.weight) return -1;
return 1; // ⚠️ 같을 때도 1 반환 → 일관성 X
}
}
원칙: compareTo == 0 이면 equals == true 여야 함 (정렬 안정성).
해결:
public int compareTo(Cargo other) {
return Double.compare(this.weight, other.weight);
}
[Unit 6.1: String + Constant Pool] ✓
↓
[Unit 6.2: StringBuilder vs StringBuffer] ✓
↓
[Unit 6.3: ArrayList vs LinkedList] ✓
↓
[Unit 6.4: HashMap 내부 구조] ✓ ★★★
↓
[Unit 6.5: TreeMap, LinkedHashMap] ← 지금 (Phase 6 완료!)
↓
[Phase 7: I/O & NIO] (예정)
List ─── ArrayList ⭐ (Unit 6.3)
├── LinkedList
└── Vector
Set ─── HashSet
├── LinkedHashSet (LinkedHashMap 의 Set 버전)
└── TreeSet (TreeMap 의 Set 버전)
Map ─── HashMap ⭐ (Unit 6.4)
├── LinkedHashMap ⭐ (Unit 6.5)
├── TreeMap ⭐ (Unit 6.5)
├── ConcurrentHashMap
└── Hashtable (legacy)
Queue ── ArrayDeque ⭐
├── LinkedList (Deque 도 구현)
└── PriorityQueue (TreeMap 과 유사 — 정렬 유지)
| 사용처 | 설명 |
|---|---|
| TreeMap | Map 의 기본 구현 |
| TreeSet | Set 의 정렬 구현 |
| HashMap (Java 8+) | 충돌 8개 초과 시 트리화 |
| LinkedHashMap (Java 8+) | 같은 트리화 |
| ConcurrentHashMap (Java 8+) | 같은 트리화 |
→ Red-Black Tree 는 자바 컬렉션의 핵심.
| 언어 | 정렬 Map | 순서 Map |
|---|---|---|
| Java | TreeMap | LinkedHashMap |
| Python | (sortedcontainers) | OrderedDict |
| JavaScript | (없음) | Map (삽입 순서) |
| C++ | std::map | (없음) |
| C# | SortedDictionary | (없음) |
| Rust | BTreeMap | IndexMap (외부) |
→ 자바는 두 가지 모두 표준 제공 — 강점.
| 질문 | 이 Unit 에서의 답 |
|---|---|
| TreeMap 언제? | 정렬/범위 쿼리 |
| LinkedHashMap 언제? | 순서 보존/LRU |
| LRU 캐시 구현? | LinkedHashMap + accessOrder + removeEldestEntry |
| TreeMap 내부? | Red-Black Tree |
| HashMap vs TreeMap? | O(1) vs O(log n) |
1️⃣ TreeMap = Red-Black Tree, LinkedHashMap = HashMap + 양방향 리스트.
TreeMap 은 정렬된 상태 유지 — 모든 작업 O(log n), 그러나 범위 쿼리/근접 키 검색 이 강점 (subMap, floorKey, ceilingKey). LinkedHashMap 은 HashMap 의 모든 기능 (O(1)) + 양방향 연결 리스트 로 삽입 순서 또는 접근 순서 유지. 메모리는 HashMap 의 1.5배.
2️⃣ LinkedHashMap 의 accessOrder=true + removeEldestEntry = LRU 캐시 6줄.
accessOrder=true면 get 시 그 항목이 맨 뒤로 이동 → head 는 항상 가장 오래 안 쓴 항목. removeEldestEntry 오버라이드 로 capacity 초과 시 head 자동 제거. 모든 작업 O(1). 자바 표준 라이브러리의 숨겨진 보석.3️⃣ 선택 원칙 — 정렬/범위 → TreeMap, 순서/LRU → LinkedHashMap, 그 외 → HashMap.
TreeMap 의 강점:
subMap,headMap,tailMap,floorKey,ceilingKey같은 NavigableMap 메서드. LinkedHashMap 의 강점: 삽입 순서 보존 (API 응답 JSON 순서), LRU 캐시. HashMap 은 순서 무관 시 기본. ILIC 의 가격대별 화물 = TreeMap, 운임 캐시 = LinkedHashMap, 일반 매핑 = HashMap. Phase 6 완료 — 자바 컬렉션 정복!
Q1: LRU 캐시를 LinkedHashMap 으로 어떻게 구현하는가?
한 줄 답: accessOrder=true 로 생성 후 removeEldestEntry 오버라이드. 6줄 구현, 모든 작업 O(1).
상세 설명:
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true); // ★ accessOrder = true
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity; // ★ 초과 시 가장 오래된 제거
}
}
6줄로 LRU 완성!
상태 1: 캐시 capacity = 3, 비어있음
head → null ← tail
상태 2: put("A", 1)
head → A ← tail
상태 3: put("B", 2), put("C", 3)
head → A ↔ B ↔ C ← tail
상태 4: get("A")
A 가 접근됨 → 맨 뒤로 이동
head → B ↔ C ↔ A ← tail
상태 5: put("D", 4) — capacity 초과!
1. afterNodeInsertion(true) 호출
2. removeEldestEntry(head=B) 호출 → size()=4 > 3 → true
3. head (B) 제거
head → C ↔ A ↔ D ← tail
→ 가장 오래 안 쓴 항목 (B) 자동 제거.
모든 작업: O(1)
get: HashMap 의 O(1) + 양방향 리스트 이동 O(1)put: HashMap 의 O(1) + 리스트 끝 추가 O(1)remove (LRU): head 제거 O(1)@Service
public class CustomerCache {
private final Map<String, Customer> cache =
Collections.synchronizedMap(new LRUCache<>(10000));
// 10,000 개까지 캐시, 그 이상은 자동 제거
public Customer getCustomer(String id) {
return cache.computeIfAbsent(id, this::loadFromDb);
}
private Customer loadFromDb(String id) {
return customerRepository.findById(id).orElse(null);
}
}
LinkedHashMap 은 Thread-Safe X.
해결 1: synchronizedMap
Map<K, V> cache = Collections.synchronizedMap(new LRUCache<>(100));
해결 2: Caffeine 라이브러리 (권장)
Cache<K, V> cache = Caffeine.newBuilder()
.maximumSize(100)
.build();
해결 3: ConcurrentHashMap + 별도 LRU 로직 (복잡)
"LinkedHashMap 의 accessOrder=true + removeEldestEntry 오버라이드 = 완벽한 LRU.
6줄로 모든 작업 O(1), 메모리 일정.
자바 표준 라이브러리의 숨겨진 보석 — 알면 시니어, 모르면 직접 구현해 O(n).
멀티스레드 환경에서는 synchronizedMap 또는 Caffeine 라이브러리 활용."
Q2: TreeMap 의 subMap 과 HashMap 으로 같은 작업 구현 시 시간 차이는?
한 줄 답: TreeMap 의 subMap = O(log n + k), HashMap 으로 같은 작업 = O(n). 데이터 크면 수천 배 차이.
상세 설명:
// 100만 개 화물, 5,000~10,000원 범위 검색
NavigableMap<Integer, List<Cargo>> tree = new TreeMap<>();
Map<Integer, List<Cargo>> hash = new HashMap<>();
// 데이터 채우기 ...
// TreeMap
List<Cargo> result1 = tree.subMap(5000, 10001).values().stream()
.flatMap(List::stream).collect(Collectors.toList());
// HashMap
List<Cargo> result2 = hash.entrySet().stream()
.filter(e -> e.getKey() >= 5000 && e.getKey() <= 10000)
.flatMap(e -> e.getValue().stream())
.collect(Collectors.toList());
TreeMap.subMap:
1. 시작 키 (5000) 위치 찾기: O(log n) ≈ 20번 비교 (100만)
2. 끝 키 (10001) 위치 찾기: O(log n) ≈ 20번 비교
3. 사이의 entry 순회: O(k) (예: 100개)
4. 합계: O(log n + k) ≈ 140번 작업
HashMap 필터링:
1. 모든 entry 순회: O(n) = 100만 번
2. 각각 범위 검사: 100만 번 비교
3. 합계: O(n) = 100만 번
차이: 약 7,000배.
public class RangeQueryBenchmark {
public static void main(String[] args) {
int N = 1_000_000;
NavigableMap<Integer, String> tree = new TreeMap<>();
Map<Integer, String> hash = new HashMap<>();
for (int i = 0; i < N; i++) {
tree.put(i, "value-" + i);
hash.put(i, "value-" + i);
}
// TreeMap subMap
long start = System.nanoTime();
Map<Integer, String> sub = tree.subMap(500000, 500100);
int size1 = sub.size();
long treeTime = System.nanoTime() - start;
// HashMap 필터링
start = System.nanoTime();
long count = hash.entrySet().stream()
.filter(e -> e.getKey() >= 500000 && e.getKey() < 500100)
.count();
long hashTime = System.nanoTime() - start;
System.out.println("TreeMap subMap: " + treeTime + " ns");
// 약 10,000 ns = 10 μs
System.out.println("HashMap filter: " + hashTime + " ns");
// 약 30,000,000 ns = 30 ms (3,000배)
}
}
| 데이터 수 | TreeMap subMap | HashMap filter | 차이 |
|---|---|---|---|
| 1,000 | 1 μs | 50 μs | 50배 |
| 10,000 | 2 μs | 500 μs | 250배 |
| 100,000 | 5 μs | 5 ms | 1,000배 |
| 1,000,000 | 10 μs | 30 ms | 3,000배 |
| 10,000,000 | 15 μs | 300 ms | 20,000배 |
→ 데이터 클수록 차이 폭증.
"범위 쿼리는 자료구조 선택의 결정적 차이.
TreeMap 은 정렬된 구조 덕분에 O(log n + k) — 데이터가 100만 개여도 거의 즉시.
HashMap 은 정렬 X → O(n) 필터링 강제 — 매번 전체 순회.
ILIC 의 가격대별 화물, 시간대별 조회, 등급별 검색 등 범위 쿼리가 빈번하면 TreeMap 정답.
시니어 차별화: subMap 의 view 특성, NavigableMap 의 강력한 메서드 활용."