Map 키와 갱신 연산
배열을 Map으로 변환하고 공통 키·단어 빈도·회원 저장소·게시글 반응 집계를 서로 다른 키 중복 정책으로 해결합니다.
Map 문제는 문법보다 키를 무엇으로 정하는지가 어렵습니다.
이름을 키로 고르면 동명이인을 합칠 수 있고, 상품 객체 전체를 키로 쓰면 가격 변경이 조회를 깨뜨릴 수 있습니다.
문제 문장에서 고유성·갱신·부재의 의미를 먼저 표시한 뒤 put, putIfAbsent, merge, computeIfAbsent 중 맞는 연산을 고릅니다.
단독 덧셈의 컴파일 실패와 갱신 누락
다음 원문은 반복 단어를 갱신하려 하지만, 분기 안의 덧셈을 단독 문장으로 적었습니다.
Java는 이 문장을 허용하지 않으므로 컴파일 단계에서 실패하고 반복문은 실행되지 않습니다.
import java.util.HashMap;
import java.util.Map;
public final class WordCountUpdateBug {
public static void main(String[] args) {
Map<String, Integer> counts = new HashMap<>();
for (String word : new String[] {"map", "hash", "map"}) {
if (counts.containsKey(word)) {
counts.get(word) + 1;
} else {
counts.put(word, 1);
}
}
System.out.println(counts);
}
}이 코드는 Java에서 단독 덧셈 표현식이 문장으로 허용되지 않아 컴파일 실패합니다.
int next = counts.get(word) + 1로 바꿔도 put을 빠뜨리면 논리 버그가 남습니다.
merge는 읽기·계산·쓰기를 한 호출로 묶어 이 누락을 줄입니다.
네 가지 Map 문제의 입력과 결과
회원 ID 배열과 이름 배열을 Map으로 묶을 때는 두 배열의 길이가 같은지 검사해야 합니다.
같은 ID가 두 번 나오면 마지막 값으로 교체할지, 첫 값을 지킬지, 오류로 중단할지 정책을 정해야 합니다.
무조건 put하면 마지막 값이 이기는 정책이 암묵적으로 생깁니다.
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
public final class MapProblemWorkbook {
public static void main(String[] args) {
System.out.println(toMap(new int[] {11, 12}, new String[] {"Ada", "Linus"}));
System.out.println(common(Map.of("array", 40, "hash", 50), Map.of("hash", 45, "map", 30)));
System.out.println(countWords("map hash map queue hash map"));
System.out.println(findByValue(Map.of("A", 50, "B", 70, "C", 50), 50));
}
static Map<Integer, String> toMap(int[] ids, String[] names) {
if (ids.length != names.length) throw new IllegalArgumentException("length mismatch");
Map<Integer, String> result = new LinkedHashMap<>();
for (int i = 0; i < ids.length; i++)
if (result.putIfAbsent(ids[i], names[i]) != null)
throw new IllegalArgumentException("duplicate id=" + ids[i]);
return Map.copyOf(result);
}
static Set<String> common(Map<String, Integer> left, Map<String, Integer> right) {
Set<String> keys = new java.util.HashSet<>(left.keySet());
keys.retainAll(right.keySet());
return Set.copyOf(keys);
}
static Map<String, Integer> countWords(String line) {
Map<String, Integer> counts = new TreeMap<>();
for (String word : line.split("\\s+")) {
counts.merge(word, 1, Integer::sum);
}
return Map.copyOf(counts);
}
static Set<String> findByValue(Map<String, Integer> source, int target) {
Set<String> keys = new java.util.TreeSet<>();
for (var entry : source.entrySet())
if (entry.getValue() == target) keys.add(entry.getKey());
return Set.copyOf(keys);
}
}실제 toMap, common, countWords, findByValue 호출의 입력과 결과 원소를 비교한다. 표의 나열은 copyOf 결과의 반복 순서 보장이 아니다.
| 원문 호출 | 실제 입력 | 결과의 원소·값 |
|---|---|---|
toMap | id: 11, 12name: "Ada", "Linus" | 11 → "Ada"12 → "Linus" |
common | left: array=40, hash=50right: hash=45, map=30 | 공통 key는 hash 하나 |
countWords | map hash map queue hash map | map → 3hash → 2queue → 1 |
findByValue | A=50, B=70, C=50target: 50 | 일치하는 key는 A, C |
toMap- 실제 입력:id:
11, 12name:"Ada", "Linus"결과의 원소·값:11 → "Ada"12 → "Linus" common- 실제 입력:
left: array=40, hash=50right: hash=45, map=30결과의 원소·값: 공통 key는hash하나 countWords- 실제 입력:
map hash map queue hash map결과의 원소·값:map → 3hash → 2queue → 1 findByValue- 실제 입력:
A=50, B=70, C=50target:50결과의 원소·값: 일치하는 key는A,C
결과 열은 실제 실행으로 확인한 원소와 값의 목록입니다. Map.copyOf와 Set.copyOf는 원소 구성을 보존하지만, 복사 전 LinkedHashMap·TreeMap·TreeSet의 반복 순서를 보장하지 않습니다.
값은 고유하지 않을 수 있으므로 역조회 결과를 키 하나로 반환하지 않습니다.
대상 50에는 A와 C 둘 다 해당합니다.
값 조회가 빈번하다면 매번 전체 entrySet을 걷는 대신 Map<Value, Set<Key>> 보조 인덱스를 유지할 수 있지만 갱신 일관성 비용이 생깁니다.
사전과 회원 저장소의 부재 표현
사전 조회에서 없는 단어는 null, 예외, Optional, 추천 목록 중 하나로 표현할 수 있습니다.
UI 자동 완성이라면 빈 결과가 정상이고, 내부 필수 설정이라면 예외가 결함을 빨리 드러냅니다.
Map.get의 null을 그대로 모든 계층에 흘리지 않습니다.
이 예제의 회원 저장소는 id 중복 저장을 거부하고 find에서 Optional을 반환합니다.
갱신은 존재하지 않는 id를 새로 만들지, 실패할지 별도 메서드로 나눕니다.
저장 하나가 insert와 갱신을 모두 뜻하면 호출자의 실수를 숨깁니다.
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
public final class MemberRepositoryMap {
public static void main(String[] args) {
Repository repository = new Repository();
repository.insert(new Member("M1", "Andongmin"));
repository.updateName("M1", "Dongmin");
System.out.println(repository.find("M1").orElseThrow());
System.out.println("missing=" + repository.find("M2"));
}
private static final class Repository {
private final Map<String, Member> members = new LinkedHashMap<>();
void insert(Member member) {
if (members.putIfAbsent(member.id(), member) != null)
throw new IllegalArgumentException("duplicate");
}
void updateName(String id, String name) {
Member before = members.get(id);
if (before == null) throw new java.util.NoSuchElementException(id);
members.put(id, new Member(id, name));
}
Optional<Member> find(String id) {
return Optional.ofNullable(members.get(id));
}
}
private record Member(String id, String name) {}
}원문은 M1 Andongmin을 저장한 뒤 Dongmin으로 교체하고, M1의 Member와 M2의 Optional.empty를 출력한다. 제거 호출은 없다.
| main의 동작 | M1 회원의 name: 소스 추적 | 실제 출력 |
|---|---|---|
insertM1 / Andongmin | name="Andongmin" | 출력 없음 |
updateNameM1 / Dongmin | 같은 key의 새 Member name="Dongmin" | 출력 없음 |
find("M1").orElseThrow() | name="Dongmin" | Member[id=M1, name=Dongmin] |
find("M2") | name="Dongmin" | missing=Optional.empty |
insertM1 / Andongmin- M1 회원의 name: 소스 추적:
name="Andongmin"실제 출력: 출력 없음 updateNameM1 / Dongmin- M1 회원의 name: 소스 추적:같은 key의 새 Member
name="Dongmin"실제 출력: 출력 없음 find("M1").orElseThrow()- M1 회원의 name: 소스 추적:
name="Dongmin"실제 출력:Member[id=M1, name=Dongmin] find("M2")- M1 회원의 name: 소스 추적:
name="Dongmin"실제 출력:missing=Optional.empty
표의 id·name은 insert에 전달한 Member의 필드와 updateName의 두 인수를 나타냅니다. 첫 두 호출은 저장 값을 바꾸고, 뒤의 두 조회는 그 값을 유지합니다. 이 main에는 회원을 제거하는 동작이 없습니다.
Member를 불변 record로 두고 이름 변경은 같은 id의 새 값을 put합니다.
키로 쓰는 id는 바뀌지 않습니다.
저장소 내부 Map은 외부에 노출하지 않습니다.
게시글 ID와 반응 수 모델
Map<Post, Integer>는 Post의 equals/hashCode가 게시글 식별 의미와 정확히 맞아야 합니다.
더 단순하게 postId를 키로 쓰고 제목은 게시글 저장소에서 조회할 수 있습니다.
이 예제의 Line은 게시글 정보와 반응 수를 함께 보관하며, 같은 id를 다시 처리할 때는 기존 line.post()를 유지합니다.
아래 코드는 compute에서 해당 id의 기존 Line이 있는지 확인해, 처음이면 새 값을 만들고 기존 값이 있으면 반응 수를 더합니다.
반응 수가 0인 항목을 Map에 남길지 제거할지도 불변식으로 정합니다.
import java.util.LinkedHashMap;
import java.util.Map;
public final class BoardReactionCounter {
public static void main(String[] args) {
Counter counter = new Counter();
counter.add(new Post("P1", "Java 컬렉션"), 1);
counter.add(new Post("P1", "Java 컬렉션"), 2);
counter.add(new Post("P2", "스레드 기초"), 1);
System.out.println(counter.lines());
System.out.println("total=" + counter.totalReactions());
}
private static final class Counter {
private final Map<String, Line> lines = new LinkedHashMap<>();
void add(Post post, int amount) {
if (amount <= 0) throw new IllegalArgumentException("amount");
lines.compute(
post.id(),
(id, line) ->
line == null
? new Line(post, amount)
: new Line(line.post(), line.count() + amount));
}
int totalReactions() {
return lines.values().stream().mapToInt(Line::count).sum();
}
Map<String, Line> lines() {
return Map.copyOf(lines);
}
}
private record Post(String id, String title) {}
private record Line(Post post, int count) {}
}원문의 세 add 호출을 따라 P1에 1과 2를 더하고 P2에 1을 저장하여 최종 총합 4를 만든다. 중간 상태는 소스 추적이다.
| main의 입력 | compute에서 수행하는 분기 | 호출 뒤 상태: 소스 추적 |
|---|---|---|
P1 / +1제목: Java 컬렉션 | 기존 값 없음 new Line(post, amount) | P1 → 1 |
P1 / +2제목: Java 컬렉션 | 기존 line.post() 유지반응 수 1 + 2 = 3 | P1 → 3 |
P2 / +1제목: 스레드 기초 | 기존 값 없음 new Line(post, amount) | P1 → 3P2 → 1 |
P1 / +1제목:Java 컬렉션- compute에서 수행하는 분기:기존 값 없음
new Line(post, amount)호출 뒤 상태: 소스 추적:P1 → 1 P1 / +2제목:Java 컬렉션- compute에서 수행하는 분기:기존
line.post()유지반응 수1 + 2 = 3호출 뒤 상태: 소스 추적:P1 → 3 P2 / +1제목:스레드 기초- compute에서 수행하는 분기:기존 값 없음
new Line(post, amount)호출 뒤 상태: 소스 추적:P1 → 3P2 → 1
표는 add의 호출 순서를 따릅니다. 실제 lines() 출력에는 P1의 반응 수 3과 P2의 반응 수 1이 있고, 다음 줄은 total=4입니다. 스냅숏을 만드는 Map.copyOf(lines)의 반복 순서는 보장되지 않습니다.
연습 문제
게시글 제목→범주 Map을 범주→제목 Set Map으로 뒤집으세요.
같은 범주의 모든 제목을 보존하고 결과 제목은 입력 순서를 유지합니다.
정답과 해설
computeIfAbsent는 범주가 처음 나올 때만 LinkedHashSet을 만들고 현재 제목을 추가합니다.
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
public final class ReverseGroupingMapSolution {
public static void main(String[] args) {
Map<String, String> source = new LinkedHashMap<>();
source.put("array", "collection");
source.put("hash", "collection");
source.put("thread", "concurrency");
Map<String, Set<String>> grouped = new LinkedHashMap<>();
for (var e : source.entrySet())
grouped.computeIfAbsent(e.getValue(), k -> new LinkedHashSet<>()).add(e.getKey());
System.out.println(grouped);
}
}원문 LinkedHashMap의 제목·범주 세 관계를 읽어 범주별 LinkedHashSet으로 모은다. 두 번째 collection 입력은 기존 집합에 제목을 추가한다.
| 원본 Map의 관계 | 범주별 집합 선택 | 추가 뒤 grouped: 소스 추적 |
|---|---|---|
array → collection | 처음 보는 범주 새 LinkedHashSet 생성 | collection=[array] |
hash → collection | 기존 collection 집합 사용hash 추가 | collection=[array, hash] |
thread → concurrency | 처음 보는 범주 새 LinkedHashSet 생성 | collection=[array, hash]concurrency=[thread] |
array→collection- 범주별 집합 선택:처음 보는 범주새
LinkedHashSet생성추가 뒤 grouped: 소스 추적:collection=[array] hash→collection- 범주별 집합 선택:기존
collection집합 사용hash추가추가 뒤 grouped: 소스 추적:collection=[array, hash] thread→concurrency- 범주별 집합 선택:처음 보는 범주새
LinkedHashSet생성추가 뒤 grouped: 소스 추적:collection=[array, hash]concurrency=[thread]
computeIfAbsent는 범주가 처음 나올 때 집합을 만들고, 이어지는 add가 제목을 넣습니다. 원문의 LinkedHashMap과 LinkedHashSet이 순서를 유지하여 실제 최종 출력은 {collection=[array, hash], concurrency=[thread]}입니다.
collection에는 array와 hash가 이 순서로 함께 남고, concurrency에는 thread가 남습니다.
값이 중복될 수 있으므로 역방향 자료형은 Set 하나가 아니라 범주마다 Set을 가진 Map입니다.
Map 문제의 답은 API 선택 전에 키의 고유성, 중복 put 의미, 없는 키 처리, 값의 다중성을 적는 데서 시작합니다.
이 네 질문이 맞으면 merge와 compute 계열도 목적에 맞게 선택할 수 있습니다.