Map 계약과 구현
배열 병렬 저장의 불일치를 Map으로 고치고 HashMap·LinkedHashMap·TreeMap의 키 순서와 조회 비용을 구분합니다.
Set이 값의 존재 여부를 묻는다면 Map은 고유 키와 연결된 값을 찾습니다.
회원 id로 Member를 조회하거나 제목별 조회 수를 누적할 때 키를 전체 목록에서 반복 검색하지 않고 관계 자체를 저장합니다.
같은 키에 put하면 원소가 하나 더 생기는 것이 아니라 기존 값이 교체됩니다.
개별 배열 정렬에 따른 키·값 불일치
키와 값을 같은 index의 병렬 배열에 저장하면 모든 변경을 함께 수행해야 합니다.
다음 코드는 제목만 정렬해 조회수와의 관계를 깨뜨립니다.
출력 타입은 맞아서 컴파일러가 발견하지 못하는 데이터 오류입니다.
import java.util.Arrays;
public final class ParallelArrayMapBug {
public static void main(String[] args) {
String[] titles = {"thread", "array", "hash"};
int[] viewCount = {70, 30, 50};
Arrays.sort(titles);
for (int i = 0; i < titles.length; i++) {
System.out.println(titles[i] + "=" + viewCount[i]);
}
}
}배열=70이라는 잘못된 관계가 출력됩니다.
Entry 하나로 키와 값을 묶거나 Map에 저장하면 정렬과 조회가 관계 단위로 움직입니다.
Map의 entrySet은 두 값을 함께 순회하기 위한 뷰입니다.
put의 반환값과 중복 키 정책
put(key, value)는 이전 값을 반환하고 없었다면 null을 반환합니다.
null 값을 허용하는 HashMap에서는 반환 null만으로 “없었음”과 “이전 값 null”을 구분할 수 없으므로 containsKey가 필요합니다.
중복 입력을 오류로 볼 때는 putIfAbsent와 반환값을 사용합니다.
get은 없는 키에서 null을 반환합니다.
null을 정상 값으로 허용하지 않는 도메인이라면 get 결과 null이 부재를 뜻합니다.
더 명시적으로 Optional을 감싸거나 getOrDefault, computeIfAbsent, merge를 목적에 맞게 사용합니다.
import java.util.HashMap;
import java.util.Map;
public final class TitleViewCountMap {
public static void main(String[] args) {
Map<String, Integer> viewCount = new HashMap<>();
add(viewCount, "array", 30);
add(viewCount, "hash", 50);
add(viewCount, "array", 20);
System.out.println("array=" + viewCount.get("array"));
System.out.println("missing=" + viewCount.getOrDefault("queue", 0));
for (Map.Entry<String, Integer> entry : viewCount.entrySet()) {
System.out.println(entry.getKey() + ":" + entry.getValue());
}
}
private static void add(Map<String, Integer> viewCount, String title, int amount) {
if (title == null || title.isBlank() || amount <= 0) throw new IllegalArgumentException();
viewCount.merge(title, amount, Integer::sum);
}
}merge는 키가 없으면 amount를 저장하고 있으면 기존 값과 합칩니다.
containsKey와 get·put을 나누어 호출하는 코드보다 누적 의도가 직접 보입니다.
단일 스레드 예제이며 동시 갱신에는 ConcurrentHashMap 같은 별도 규칙이 필요합니다.
Map 뷰 선택
키만 검사하면 keySet, 값만 합산하면 values, 관계를 함께 출력하면 entrySet이 적합합니다.
entrySet 순회 중 entry.setValue는 Map 값을 바꿀 수 있지만 구조 변경은 반복자 규칙을 따라야 합니다.
키를 가져와 다시 get하는 이중 조회보다 entrySet이 단순합니다.
Map의 키는 내부적으로 Set처럼 고유합니다.
HashMap은 hashCode로 그룹을 고르고 equals로 키를 확인하므로 15장의 불변 키 규칙이 그대로 적용됩니다.
가변 키를 저장 뒤 바꾸면 조회에서 원소를 잃을 수 있습니다.
Map 구현별 키 순서
HashMap은 일반 조회의 기본 선택이며 반복 순서를 약속하지 않습니다.
LinkedHashMap은 삽입 순서를 유지하고 accessOrder 옵션으로 LRU 같은 접근 순서를 만들 수 있습니다.
TreeMap은 자연 순서 또는 Comparator로 키를 정렬하고 범위 질의를 제공합니다.
세 구현의 get·put 결과 의미는 같지만 복잡도와 반복 순서가 다릅니다.
HashMap과 LinkedHashMap은 좋은 분산에서 평균 O(1), TreeMap은 O(log n)입니다.
보고서 한 번 정렬하려고 TreeMap을 쓰지 않고 출력에서 스트림 정렬할 수도 있습니다.
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
public final class MapImplementationOrder {
public static void main(String[] args) {
compare("hash", new HashMap<>());
compare("linked", new LinkedHashMap<>());
compare("tree", new TreeMap<>());
}
private static void compare(String name, Map<String, Integer> map) {
map.put("thread", 70);
map.put("array", 30);
map.put("hash", 50);
map.put("array", 40);
System.out.println(name + " size=" + map.size() + " keys=" + map.keySet());
System.out.println(name + " array=" + map.get("array"));
}
}모두 size=3이고 배열=40입니다.
키 반복 순서만 구현 요구사항에 따라 달라집니다.
HashMap의 현재 출력 모양은 테스트 기대값으로 고정하지 않습니다.
게시글 저장소의 Map 캡슐화
저장소가 내부 Map을 그대로 반환하면 호출자가 검증 없이 put·remove할 수 있습니다.
findById, save, snapshot 같은 도메인 메서드로 소유권을 닫고 스냅샷은 Map.copyOf로 반환합니다.
import java.util.LinkedHashMap;
import java.util.Map;
public final class PostRepository {
public static void main(String[] args) {
Repository repository = new Repository();
repository.save(new Entry(1, "map", 45));
repository.save(new Entry(2, "queue", 55));
System.out.println(repository.find(2));
System.out.println("snapshot=" + repository.snapshot());
}
private static final class Repository {
private final Map<Integer, Entry> entries = new LinkedHashMap<>();
void save(Entry entry) {
if (entries.putIfAbsent(entry.id(), entry) != null)
throw new IllegalArgumentException("duplicate id");
}
Entry find(int id) {
Entry found = entries.get(id);
if (found == null) throw new java.util.NoSuchElementException("id=" + id);
return found;
}
Map<Integer, Entry> snapshot() {
return Map.copyOf(entries);
}
}
private record Entry(int id, String title, int viewCount) {}
}연습 문제
두 Map에서 공통 제목만 찾아 조회수 차이를 출력하세요.
한쪽에만 있는 키는 결과에 넣지 않고 입력 Map은 바꾸지 않습니다.
정답과 해설
더 작은 keySet을 순회하면 containsKey 검사 횟수를 줄일 수 있습니다.
예제는 의미를 선명하게 하기 위해 left entrySet을 순회합니다.
import java.util.LinkedHashMap;
import java.util.Map;
public final class CommonMapDifferenceSolution {
public static void main(String[] args) {
Map<String, Integer> left = Map.of("array", 40, "hash", 60, "set", 30);
Map<String, Integer> right = Map.of("hash", 45, "set", 50, "queue", 20);
Map<String, Integer> difference = new LinkedHashMap<>();
for (var entry : left.entrySet())
if (right.containsKey(entry.getKey())) {
difference.put(entry.getKey(), entry.getValue() - right.get(entry.getKey()));
}
System.out.println(difference);
System.out.println("inputs=" + left.size() + "/" + right.size());
}
}결과에는 해시와 set만 있고 입력 크기는 3/3으로 유지됩니다.
값이 null일 수 있는 Map이라면 containsKey와 get의 의미를 더 세밀하게 나눠야 합니다.
Map 선택은 키 고유성·부재 표현·순서 요구·범위 질의에서 시작합니다.
keySet과 Set이 닮았다는 내부 원리를 이해하되 값 교체와 Entry 순회라는 Map 고유 규칙을 놓치지 않습니다.