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는 읽기·계산·쓰기를 한 호출로 묶어 이 누락을 줄입니다.
counts.get(word)로 얻은 Integer에 1을 더하면 새 숫자를 계산할 뿐, Map에 저장된 값은 바뀌지 않습니다. 기존 값이 1일 때 next는 2가 되지만, counts.put(word, next)를 호출하기 전까지 저장된 값은 여전히 1입니다.
counts.merge(word, 1, Integer::sum)은 키가 없으면 1을 저장하고, 값이 있으면 기존 값과 새 값 1을 더해 다시 저장합니다. Integer::sum은 이때 (oldCount, incomingCount) -> oldCount + incomingCount 역할을 합니다. map, hash, map을 순서대로 처리하면 map=1 → hash=1 → map=2가 됩니다. 이 절의 예제는 한 스레드에서 실행하며, HashMap의 merge 한 호출만으로 여러 스레드의 갱신까지 안전해지는 것은 아닙니다.
네 가지 Map 문제의 입력과 결과
회원 ID 배열과 이름 배열을 Map으로 묶을 때는 두 배열의 길이가 같은지 검사해야 합니다.
같은 ID가 두 번 나오면 마지막 값으로 교체할지, 첫 값을 지킬지, 오류로 중단할지 정책을 정해야 합니다.
무조건 put하면 마지막 값이 이기는 정책이 암묵적으로 생깁니다.
아래 toMap의 입력 전제는 두 배열 자체와 각 name이 null이 아니며, 배열 길이가 같다는 것입니다. putIfAbsent는 기존 값이 null이면 새 값으로 바꿀 수 있으므로, null 이름을 허용한 채 반환값만 검사하면 중복 ID 거부가 성립하지 않을 수 있습니다. 또한 Map.copyOf는 null 키·값을 거부합니다. 실제 입력을 받는 함수라면 반복문 전에 name마다 null 여부를 검사해야 이 중복 정책을 유지할 수 있습니다. 예제의 Ada·Linus는 이 전제에 맞는 입력입니다.
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 결과의 반복 순서 보장이 아니다.
MAP · INPUT / RESULT
네 가지 Map 문제에서 남는 원소와 값
변환·교집합·빈도·역조회는 서로 다른 결과를 만듭니다. 결과의 구성과 반복 순서는 구분합니다.
| 원문 호출 | 실제 입력 | 결과의 원소·값 |
|---|---|---|
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의 반복 순서를 보장하지 않습니다.
네 결과는 각각 {11=Ada, 12=Linus}, 공통 키 {hash}, 빈도 {map=3, hash=2, queue=1}, 값 50의 키 {A, C}입니다. 공통 키를 구할 때 두 hash의 값 50과 45가 달라도 키가 같으므로 결과에 남습니다. entrySet()은 키와 값을 함께 읽어야 하는 역조회에, keySet()은 키만 비교하는 교집합에 맞습니다.
common이 left.keySet()을 새 HashSet에 복사하는 데에도 이유가 있습니다. keySet()은 원본 Map과 연결된 뷰이므로 그 뷰에 직접 retainAll을 호출하면 원본에서 공통 키가 아닌 항목을 삭제할 수 있습니다. 복사본에서 교집합을 구하면 두 입력을 그대로 유지합니다.
위 결과 표기는 원소 구성을 설명합니다. 중간에 LinkedHashMap, TreeMap, TreeSet을 사용했더라도 마지막 Map.copyOf와 Set.copyOf가 그 반복 순서까지 보장하지는 않습니다. 따라서 실행 결과의 출력 순서가 위 표기와 달라도 키·값과 원소가 같으면 같은 답입니다. 아래 역그룹 연습에서는 순서를 요구하므로 LinkedHashMap과 LinkedHashSet 자체를 유지합니다.
빈 문장과 앞쪽 공백은 별도 입력 경계입니다. 원 countWords에 빈 문자열을 넣으면 split 결과에 빈 문자열 하나가 남아 빈 key의 개수 1을 저장합니다. 앞에 공백이 있는 " map "도 빈 key를 만들 수 있습니다. 빈 입력을 단어 0개로 처리하고 앞뒤 공백을 제외하려면 다음처럼 정규화 후 빈 상태를 먼저 판정합니다.
예시
import java.util.Map;
import java.util.TreeMap;
public final class WordCountInputBoundary {
public static void main(String[] args) {
Map<String, Integer> empty = count("");
Map<String, Integer> words = count(" map hash map ");
System.out.println("empty=" + empty.size());
System.out.println("empty-key=" + words.containsKey(""));
System.out.println("map=" + words.get("map") + ", hash=" + words.get("hash"));
}
private static Map<String, Integer> count(String line) {
java.util.Objects.requireNonNull(line, "line");
String normalized = line.strip();
if (normalized.isEmpty()) return Map.of();
Map<String, Integer> counts = new TreeMap<>();
for (String word : normalized.split("\\s+"))
counts.merge(word, 1, Integer::sum);
return Map.copyOf(counts);
}
}출력은 empty=0, empty-key=false, map=2, hash=1입니다. 원 MapProblemWorkbook 예제를 바꾼 것이 아니라 입력 경계의 처리 순서를 따로 확인한 코드입니다. 이 예의 구분자는 ASCII 공백이며 대소문자를 합치거나 구두점을 제거하지 않습니다. 텍스트 분석의 단어 정의가 달라지면 분리 규칙도 명시해야 합니다.
값은 고유하지 않을 수 있으므로 역조회 결과를 키 하나로 반환하지 않습니다.
대상 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 값을 교체한 뒤 존재·부재를 조회한다
같은 M1 값을 새 record로 교체한 뒤 M1 조회와 없는 M2 조회를 합니다. 고정 키의 값 교체와 Optional.empty를 단계로 추적할 수 있습니다.
원문은 M1 Andongmin을 저장한 뒤 Dongmin으로 교체하고, M1의 Member와 M2의 Optional.empty를 출력한다. 제거 호출은 없다.
MAP · REPLACE / LOOKUP
같은 M1 값을 교체한 뒤 존재·부재를 조회한다
M1은 같은 id로 이름만 교체됩니다. M2 조회의 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에는 회원을 제거하는 동작이 없습니다.
이 예의 조건
- 등록 두 호출은 stdout이 없으며 삭제를 수행하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | M1 Andongmin insert 뒤 M1 Dongmin으로 새 Member 값을 교체합니다. M1 조회는 Dongmin Member를 출력하고 M2 조회는 Optional.empty입니다. 실선은 순서이며 처음 두 갱신의 stdout 없음과 삭제 없음을 보존합니다. |
| 4 | Member M1 |
| 5 | Andongmin |
| 8 | 새 Member M1 |
| 9 | Dongmin |
| 10 | 기존 id 유지 |
| 구분 | 내용 |
|---|---|
| 설명 | M1 Andongmin insert 뒤 M1 Dongmin으로 새 Member 값을 교체합니다. M1 조회는 Dongmin Member를 출력하고 M2 조회는 Optional.empty입니다. 실선은 순서이며 처음 두 갱신의 stdout 없음과 삭제 없음을 보존합니다. |
| 전체 읽기 정보 | 같은 M1 값을 교체한 뒤 존재·부재를 조회한다 같은 M1 값을 교체한 뒤 존재·부재를 조회한다 원문은 M1 Andongmin을 저장한 뒤 Dongmin으로 교체하고, M1의 Member와 M2의 Optional.empty를 출력한다. 제거 호출은 없다. MAP · REPLACE / LOOKUP 같은 M1 값을 교체한 뒤 존재·부재를 조회한다 M1은 같은 id로 이름만 교체됩니다. M2 조회의 Optional.empty는 삭제 결과가 아닙니다. insert M1 / Andongmin M1 회원의 name: 소스 추적 name="Andongmin" 실제 출력 출력 없음 updateName M1 / 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에는 회원을 제거하는 동작이 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 값 M1/Andongmin을 저장합니다. |
|
|
| 같은 id 교체 M1에 새 Dongmin Member를 넣습니다. |
|
|
| 조회 M1을 출력하고 M2는 empty를 얻습니다. |
|
|
같은 M1 값을 교체한 뒤 존재·부재를 조회한다
같은 M1 값을 새 record로 교체한 뒤 M1 조회와 없는 M2 조회를 합니다. 고정 키의 값 교체와 Optional.empty를 단계로 추적할 수 있습니다.
원문은 M1 Andongmin을 저장한 뒤 Dongmin으로 교체하고, M1의 Member와 M2의 Optional.empty를 출력한다. 제거 호출은 없다.
MAP · REPLACE / LOOKUP
같은 M1 값을 교체한 뒤 존재·부재를 조회한다
M1은 같은 id로 이름만 교체됩니다. M2 조회의 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에는 회원을 제거하는 동작이 없습니다.
이 예의 조건
- 등록 두 호출은 stdout이 없으며 삭제를 수행하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | M1 Andongmin insert 뒤 M1 Dongmin으로 새 Member 값을 교체합니다. M1 조회는 Dongmin Member를 출력하고 M2 조회는 Optional.empty입니다. 실선은 순서이며 처음 두 갱신의 stdout 없음과 삭제 없음을 보존합니다. |
| 4 | Member M1 |
| 5 | Andongmin |
| 8 | 새 Member M1 |
| 9 | Dongmin |
| 10 | 기존 id 유지 |
| 구분 | 내용 |
|---|---|
| 설명 | M1 Andongmin insert 뒤 M1 Dongmin으로 새 Member 값을 교체합니다. M1 조회는 Dongmin Member를 출력하고 M2 조회는 Optional.empty입니다. 실선은 순서이며 처음 두 갱신의 stdout 없음과 삭제 없음을 보존합니다. |
| 전체 읽기 정보 | 같은 M1 값을 교체한 뒤 존재·부재를 조회한다 같은 M1 값을 교체한 뒤 존재·부재를 조회한다 원문은 M1 Andongmin을 저장한 뒤 Dongmin으로 교체하고, M1의 Member와 M2의 Optional.empty를 출력한다. 제거 호출은 없다. MAP · REPLACE / LOOKUP 같은 M1 값을 교체한 뒤 존재·부재를 조회한다 M1은 같은 id로 이름만 교체됩니다. M2 조회의 Optional.empty는 삭제 결과가 아닙니다. insert M1 / Andongmin M1 회원의 name: 소스 추적 name="Andongmin" 실제 출력 출력 없음 updateName M1 / 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에는 회원을 제거하는 동작이 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 값 M1/Andongmin을 저장합니다. |
|
|
| 같은 id 교체 M1에 새 Dongmin Member를 넣습니다. |
|
|
| 조회 M1을 출력하고 M2는 empty를 얻습니다. |
|
|
Member를 불변 record로 두고 이름 변경은 같은 id의 새 값을 put합니다.
키로 쓰는 id는 바뀌지 않습니다.
저장소 내부 Map은 외부에 노출하지 않습니다.
실행 순서대로 보면 M1에는 Andongmin이 저장됐다가 같은 키의 Dongmin으로 교체됩니다. find("M1").orElseThrow()는 그 회원을 꺼내므로 Member[id=M1, name=Dongmin]을 출력합니다. M2는 저장한 적이 없어 Optional.empty가 되고, 조회만으로 새 회원이 생기지는 않습니다. 여기서는 Member 값 자체를 null로 저장하지 않으므로 get의 null을 부재로 해석할 수 있습니다.
게시글 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) {}
}P1 반응 수는 3으로 누적되고 P2는 1로 시작한다
P1의 1→3 누적, P2 새 등록, total 4 조회의 실제 compute 순서입니다. 기존 post 제목 유지와 카운트 변경을 함께 추적합니다.
원문의 세 add 호출을 따라 P1에 1과 2를 더하고 P2에 1을 저장하여 최종 총합 4를 만든다. 중간 상태는 소스 추적이다.
MAP · COMPUTE BRANCH
P1 반응 수는 3으로 누적되고 P2는 1로 시작한다
같은 게시글 id는 기존 정보를 유지하며 수만 더합니다. 최종 반응 수의 합은 4입니다.
실선:원 compute·조회 순서copyOf 결과 나열순서 미보장본문 큰수경계는 별도 검산
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)의 반복 순서는 보장되지 않습니다.
이 예의 조건
- copyOf 나열 순서와 큰 수 경계는 원 main의 세 갱신과 별개다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원래 세 add P1+1/P1+2/P2+1 뒤 P1 count 3/P2 count 1/total 4입니다. 같은 id는 기존 post 참조 메타정보를 유지하고 count만 교체합니다. 실선은 원래 갱신·조회 순서이며 큰 수 경계는 본문의 별도 검산입니다. |
| 4 | 없음→새 Line |
| 5 | count 1 |
| 6 | Post 제목 Java 컬렉션 |
| 11 | P1=3 |
| 12 | P2=1 |
| 18 | copyOf 결과 나열 순서 미보장 |
| 19 | 본문 큰 수 경계는 별도 검산 |
| 구분 | 내용 |
|---|---|
| 설명 | 원래 세 add P1+1/P1+2/P2+1 뒤 P1 count 3/P2 count 1/total 4입니다. 같은 id는 기존 post 참조 메타정보를 유지하고 count만 교체합니다. 실선은 원래 갱신·조회 순서이며 큰 수 경계는 본문의 별도 검산입니다. |
| 전체 읽기 정보 | P1 반응 수는 3으로 누적되고 P2는 1로 시작한다 P1 반응 수는 3으로 누적되고 P2는 1로 시작한다 원문의 세 add 호출을 따라 P1에 1과 2를 더하고 P2에 1을 저장하여 최종 총합 4를 만든다. 중간 상태는 소스 추적이다. MAP · COMPUTE BRANCH P1 반응 수는 3으로 누적되고 P2는 1로 시작한다 같은 게시글 id는 기존 정보를 유지하며 수만 더합니다. 최종 반응 수의 합은 4입니다. 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 → 3 P2 → 1 표는 add 의 호출 순서를 따릅니다. 실제 lines() 출력에는 P1의 반응 수 3과 P2의 반응 수 1이 있고, 다음 줄은 total=4 입니다. 스냅숏을 만드는 Map.copyOf(lines) 의 반복 순서는 보장되지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| P1 최초 +1로 새 Line을 만듭니다. |
|
|
| P1 누적 같은 id에 +2를 적용합니다. |
|
|
| P2와 합 새 P2에 +1 후 합계를 읽습니다. |
|
|
P1 반응 수는 3으로 누적되고 P2는 1로 시작한다
P1의 1→3 누적, P2 새 등록, total 4 조회의 실제 compute 순서입니다. 기존 post 제목 유지와 카운트 변경을 함께 추적합니다.
원문의 세 add 호출을 따라 P1에 1과 2를 더하고 P2에 1을 저장하여 최종 총합 4를 만든다. 중간 상태는 소스 추적이다.
MAP · COMPUTE BRANCH
P1 반응 수는 3으로 누적되고 P2는 1로 시작한다
같은 게시글 id는 기존 정보를 유지하며 수만 더합니다. 최종 반응 수의 합은 4입니다.
실선:원 compute·조회 순서copyOf 결과 나열순서 미보장본문 큰수경계는 별도 검산
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)의 반복 순서는 보장되지 않습니다.
이 예의 조건
- copyOf 나열 순서와 큰 수 경계는 원 main의 세 갱신과 별개다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원래 세 add P1+1/P1+2/P2+1 뒤 P1 count 3/P2 count 1/total 4입니다. 같은 id는 기존 post 참조 메타정보를 유지하고 count만 교체합니다. 실선은 원래 갱신·조회 순서이며 큰 수 경계는 본문의 별도 검산입니다. |
| 4 | 없음→새 Line |
| 5 | count 1 |
| 6 | Post 제목 Java 컬렉션 |
| 11 | P1=3 |
| 12 | P2=1 |
| 18 | copyOf 결과 나열 순서 미보장 |
| 19 | 본문 큰 수 경계는 별도 검산 |
| 구분 | 내용 |
|---|---|
| 설명 | 원래 세 add P1+1/P1+2/P2+1 뒤 P1 count 3/P2 count 1/total 4입니다. 같은 id는 기존 post 참조 메타정보를 유지하고 count만 교체합니다. 실선은 원래 갱신·조회 순서이며 큰 수 경계는 본문의 별도 검산입니다. |
| 전체 읽기 정보 | P1 반응 수는 3으로 누적되고 P2는 1로 시작한다 P1 반응 수는 3으로 누적되고 P2는 1로 시작한다 원문의 세 add 호출을 따라 P1에 1과 2를 더하고 P2에 1을 저장하여 최종 총합 4를 만든다. 중간 상태는 소스 추적이다. MAP · COMPUTE BRANCH P1 반응 수는 3으로 누적되고 P2는 1로 시작한다 같은 게시글 id는 기존 정보를 유지하며 수만 더합니다. 최종 반응 수의 합은 4입니다. 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 → 3 P2 → 1 표는 add 의 호출 순서를 따릅니다. 실제 lines() 출력에는 P1의 반응 수 3과 P2의 반응 수 1이 있고, 다음 줄은 total=4 입니다. 스냅숏을 만드는 Map.copyOf(lines) 의 반복 순서는 보장되지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| P1 최초 +1로 새 Line을 만듭니다. |
|
|
| P1 누적 같은 id에 +2를 적용합니다. |
|
|
| P2와 합 새 P2에 +1 후 합계를 읽습니다. |
|
|
같은 P1을 다른 제목으로 다시 전달해도 기존 line.post()가 남으므로 원 제목 Java 컬렉션이 유지됩니다. 제목 갱신까지 하려는 요구라면 이 규칙을 별도로 바꿔야 합니다. 양수 amount만으로 누적 count와 전체 합의 int 범위까지 보장되지는 않습니다. P1의 기존 count가 2,147,483,647일 때 +1은 넘치고, P1=2,147,483,647·P2=1은 개별 count가 모두 양수여도 int 전체 합이 넘칩니다.
개별 반응 수를 int로 제한한다면 기존 line.count() + amount 대신 Math.addExact(line.count(), amount)로 상한을 검사합니다. 전체 합은 다음처럼 반환형과 합산형을 함께 long으로 바꿉니다. stream의 sum이 끝난 int 결과를 나중에 long으로 캐스팅하면 이미 발생한 넘침을 되돌릴 수 없습니다.
long totalReactions() {
return lines.values().stream().mapToLong(Line::count).sum();
}P1 최댓값과 P2=1의 long 합은 2,147,483,648입니다. 이 두 키 조건은 long 범위에 충분히 들어갑니다. 모든 데이터량의 총합을 무조건 허용한다는 뜻은 아니며, 거부 정책과 허용 상한은 도메인에서 따로 정해야 합니다.
연습 문제
게시글 제목→범주 Map을 범주→제목 Set Map으로 뒤집으세요.
같은 범주의 모든 제목을 보존하고 결과 제목은 입력 순서를 유지합니다.
입력은 LinkedHashMap에 array→collection, hash→collection, thread→concurrency를 이 순서로 넣은 상태입니다. 해설을 열기 전에 범주 key가 새로 생기는 호출과 기존 Set에 추가하는 호출을 구별하세요. 전체 입력 순서가 보장돼야 결과 제목 순서를 요구대로 유지할 수 있습니다.
추가로, 제목 하나를 처리할 때마다 그 제목만 담은 새 LinkedHashSet을 범주 키에 put한다면 collection에 무엇이 남을지도 예측하세요. 같은 범주를 다시 만났을 때 기존 Set을 재사용해야 하는 이유를 설명하는 문제입니다.
정답과 해설
computeIfAbsent는 범주가 처음 나올 때 새 LinkedHashSet을 저장해 반환하고, 이미 있으면 그 Set을 반환합니다. 이어지는 .add(e.getKey())는 두 경우 모두 실행해 현재 제목을 추가합니다. 새 Set을 만드는 함수 안에서만 제목을 추가하는 흐름과 구별해야 합니다.
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);
}
}같은 collection key에 array와 hash를 함께 모은다
array 때 범주 Set 생성, hash 때 같은 Set 재사용, thread 때 새 범주 생성입니다. 세 입력의 그룹 멤버십 누적을 단계로 읽으며 집합의 누적 상태를 확인할 수 있습니다.
원문 LinkedHashMap의 제목·범주 세 관계를 읽어 범주별 LinkedHashSet으로 모은다. 두 번째 collection 입력은 기존 집합에 제목을 추가한다.
MAP · GROUP BY VALUE
같은 collection key에 array와 hash를 함께 모은다
범주를 key로 삼고 제목을 Set에 추가합니다. 같은 범주의 두 제목을 모두 보존합니다.
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]}입니다.
이 예의 조건
- 범주별 Set은 첫 등장 순서로 제목을 보관한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 세 입력 array/hash/thread는 collection/collection/concurrency로 연결됩니다. 실선은 제목이 소속될 Set이고 collection은 첫 array 때 생성 후 hash 때 재사용하여 두 제목을 순서대로 보존합니다. 원래 출력의 전체 관계와 동일합니다. |
| 11 | 제목 첫 삽입 순서 |
| 15 | array 때 생성 |
| 16 | hash 때 기존 collection Set 재사용 |
| 구분 | 내용 |
|---|---|
| 설명 | 세 입력 array/hash/thread는 collection/collection/concurrency로 연결됩니다. 실선은 제목이 소속될 Set이고 collection은 첫 array 때 생성 후 hash 때 재사용하여 두 제목을 순서대로 보존합니다. 원래 출력의 전체 관계와 동일합니다. |
| 전체 읽기 정보 | 같은 collection key에 array와 hash를 함께 모은다 같은 collection key에 array와 hash를 함께 모은다 원문 LinkedHashMap의 제목·범주 세 관계를 읽어 범주별 LinkedHashSet으로 모은다. 두 번째 collection 입력은 기존 집합에 제목을 추가한다. MAP · GROUP BY VALUE 같은 collection key에 array와 hash를 함께 모은다 범주를 key로 삼고 제목을 Set에 추가합니다. 같은 범주의 두 제목을 모두 보존합니다. 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]} 입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 범주 생성 array를 collection에 넣습니다. |
|
|
| 기존 범주 재사용 hash를 같은 collection에 추가합니다. |
|
|
| 다른 범주 생성 thread를 concurrency에 넣습니다. |
|
|
같은 collection key에 array와 hash를 함께 모은다
array 때 범주 Set 생성, hash 때 같은 Set 재사용, thread 때 새 범주 생성입니다. 세 입력의 그룹 멤버십 누적을 단계로 읽으며 집합의 누적 상태를 확인할 수 있습니다.
원문 LinkedHashMap의 제목·범주 세 관계를 읽어 범주별 LinkedHashSet으로 모은다. 두 번째 collection 입력은 기존 집합에 제목을 추가한다.
MAP · GROUP BY VALUE
같은 collection key에 array와 hash를 함께 모은다
범주를 key로 삼고 제목을 Set에 추가합니다. 같은 범주의 두 제목을 모두 보존합니다.
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]}입니다.
이 예의 조건
- 범주별 Set은 첫 등장 순서로 제목을 보관한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 세 입력 array/hash/thread는 collection/collection/concurrency로 연결됩니다. 실선은 제목이 소속될 Set이고 collection은 첫 array 때 생성 후 hash 때 재사용하여 두 제목을 순서대로 보존합니다. 원래 출력의 전체 관계와 동일합니다. |
| 11 | 제목 첫 삽입 순서 |
| 15 | array 때 생성 |
| 16 | hash 때 기존 collection Set 재사용 |
| 구분 | 내용 |
|---|---|
| 설명 | 세 입력 array/hash/thread는 collection/collection/concurrency로 연결됩니다. 실선은 제목이 소속될 Set이고 collection은 첫 array 때 생성 후 hash 때 재사용하여 두 제목을 순서대로 보존합니다. 원래 출력의 전체 관계와 동일합니다. |
| 전체 읽기 정보 | 같은 collection key에 array와 hash를 함께 모은다 같은 collection key에 array와 hash를 함께 모은다 원문 LinkedHashMap의 제목·범주 세 관계를 읽어 범주별 LinkedHashSet으로 모은다. 두 번째 collection 입력은 기존 집합에 제목을 추가한다. MAP · GROUP BY VALUE 같은 collection key에 array와 hash를 함께 모은다 범주를 key로 삼고 제목을 Set에 추가합니다. 같은 범주의 두 제목을 모두 보존합니다. 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]} 입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 범주 생성 array를 collection에 넣습니다. |
|
|
| 기존 범주 재사용 hash를 같은 collection에 추가합니다. |
|
|
| 다른 범주 생성 thread를 concurrency에 넣습니다. |
|
|
collection에는 array와 hash가 이 순서로 함께 남고, concurrency에는 thread가 남습니다.
값이 중복될 수 있으므로 역방향 자료형은 Set 하나가 아니라 범주마다 Set을 가진 Map입니다.
첫 array에서는 collection의 새 Set을 만들고 array를 넣습니다. 다음 hash에서는 같은 Set을 받아 hash를 추가합니다. 마지막 thread에서만 다른 키 concurrency의 새 Set을 만듭니다.
반대로 매번 새 Set을 put하면 두 번째 입력이 collection의 첫 Set을 교체하므로 hash만 남고 array를 잃습니다. 이는 입력 순서 문제가 아니라 같은 키의 값을 교체하는 갱신 정책 문제입니다.
Map 문제의 답은 API 선택 전에 키의 고유성, 중복 put 의미, 없는 키 처리, 값의 다중성을 적는 데서 시작합니다.
이 네 질문이 맞으면 merge와 compute 계열도 목적에 맞게 선택할 수 있습니다.