본문 바로가기

Map 키와 갱신 연산

배열을 Map으로 변환하고 공통 키·단어 빈도·회원 저장소·게시글 반응 집계를 서로 다른 키 중복 정책으로 해결합니다.

Map 문제는 문법보다 키를 무엇으로 정하는지가 어렵습니다.

이름을 키로 고르면 동명이인을 합칠 수 있고, 상품 객체 전체를 키로 쓰면 가격 변경이 조회를 깨뜨릴 수 있습니다.

문제 문장에서 고유성·갱신·부재의 의미를 먼저 표시한 뒤 put, putIfAbsent, merge, computeIfAbsent 중 맞는 연산을 고릅니다.


단독 덧셈의 컴파일 실패와 갱신 누락

다음 원문은 반복 단어를 갱신하려 하지만, 분기 안의 덧셈을 단독 문장으로 적었습니다.

Java는 이 문장을 허용하지 않으므로 컴파일 단계에서 실패하고 반복문은 실행되지 않습니다.

lab/WordCountUpdateBug.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는 이 전제에 맞는 입력입니다.

src/MapProblemWorkbook.java
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);
    }
}
네 가지 Map 문제에서 남는 원소와 값

실제 toMap, common, countWords, findByValue 호출의 입력과 결과 원소를 비교한다. 표의 나열은 copyOf 결과의 반복 순서 보장이 아니다.

MAP · INPUT / RESULT

네 가지 Map 문제에서 남는 원소와 값

변환·교집합·빈도·역조회는 서로 다른 결과를 만듭니다. 결과의 구성과 반복 순서는 구분합니다.

네 입력이 다른 Map 결과로 향하는 관계 네영역은별도API입력과결과입니다. ID11Ada12Linus/공통keyhash/단어map3hash2queue1/값50의keysA,C를보존합니다. 실선은API변환이지호출간데이터연쇄가아닙니다. copyOf반복순서미보장. toMap 두 배열 입력 ids11/12 namesAda/Linus 같은 index끼리 묶음 11→Ada / 12→Linus common 두 Map의 키 left array/hash right hash/map 공통 key만 남음 hash 하나 / 값차이 아님 countWords 여섯 단어 map hash map queue hash map 단어별 누적 map3/hash2/queue1 findByValue 같은 값 두 key A50 / B70 / C50 target=50 키의 집합 반환 A와 C 둘 다 실선:각 별도 API 입력→결과 표시는 원소구성이지 반복순서 아님 copyOf는 이전 정렬순서를 보장 안 함
네 입력이 다른 Map 결과로 향하는 관계 네영역은별도API입력과결과입니다. ID11Ada12Linus/공통keyhash/단어map3hash2queue1/값50의keysA,C를보존합니다. 실선은API변환이지호출간데이터연쇄가아닙니다. copyOf반복순서미보장. toMap 두 배열 입력 ids11/12 namesAda/Linus 같은 index끼리 묶음 11→Ada / 12→Linus common 두 Map의 키 left array/hash right hash/map 공통 key만 남음 hash 하나 / 값차이 아님 countWords 여섯 단어 map hash map queue hash map 단어별 누적 map3/hash2/queue1 findByValue 같은 값 두 key A50 / B70 / C50 target=50 키의 집합 반환 A와 C 둘 다 실선:각 API 입력→결과 표시는 원소 구성 반복순서 보장은 아님 copyOf는 이전 정렬순서 보장을 옮기지 않음
네 가지 Map 문제에서 남는 원소와 값
원문 호출실제 입력결과의 원소·값
toMap
id: 11, 12
name: "Ada", "Linus"
11 → "Ada"
12 → "Linus"
common
left: array=40, hash=50
right: hash=45, map=30
공통 key는 hash 하나
countWordsmap hash map queue hash map
map → 3
hash → 2
queue → 1
findByValue
A=50, B=70, C=50
target: 50
일치하는 key는 A, C
toMap
실제 입력
id: 11, 12
name: "Ada", "Linus"
결과의 원소·값
11 → "Ada"
12 → "Linus"
common
실제 입력
left: array=40, hash=50
right: hash=45, map=30
결과의 원소·값
공통 key는 hash 하나
countWords
실제 입력
map hash map queue hash map
결과의 원소·값
map → 3
hash → 2
queue → 1
findByValue
실제 입력
A=50, B=70, C=50
target: 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개로 처리하고 앞뒤 공백을 제외하려면 다음처럼 정규화 후 빈 상태를 먼저 판정합니다.

예시

src/WordCountInputBoundary.java
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와 갱신을 모두 뜻하면 호출자의 실수를 숨깁니다.

src/MemberRepositoryMap.java
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 값을 교체한 뒤 존재·부재를 조회한다

원문은 M1 Andongmin을 저장한 뒤 Dongmin으로 교체하고, M1의 Member와 M2의 Optional.empty를 출력한다. 제거 호출은 없다.

MAP · REPLACE / LOOKUP

같은 M1 값을 교체한 뒤 존재·부재를 조회한다

M1은 같은 id로 이름만 교체됩니다. M2 조회의 Optional.empty는 삭제 결과가 아닙니다.

같은 id의 값 교체와 없는 id의 조회M1Andongmininsert뒤M1Dongmin으로새Member값교체합니다. M1조회는DongminMember를출력하고M2조회는Optional.empty입니다. 실선은순서이며처음두갱신stdout없음과삭제없음을보존합니다.
M1을 처음 저장Member M1 / Andongmin출력 없음
연결 관계M1을 처음 저장 · Member M1 / Andongmin · 출력 없음 → 같은 M1 값 교체 · 새 Member M1 / Dongmin · 기존 id 유지 / 출력 없음
같은 M1 값 교체새 Member M1 / Dongmin기존 id 유지 / 출력 없음
연결 관계같은 M1 값 교체 · 새 Member M1 / Dongmin · 기존 id 유지 / 출력 없음 → M1을 조회 · name=Dongmin · orElseThrow로 Member 출력
M1을 조회name=DongminorElseThrow로 Member 출력
연결 관계M1을 조회 · name=Dongmin · orElseThrow로 Member 출력 → M2를 조회 · missing=Optional.empty · M1 값은 그대로 유지
M2를 조회missing=Optional.emptyM1 값은 그대로 유지

실선:원 호출의 저장·조회회원 제거 호출은 없음

  1. insert
    M1 / Andongmin

    M1 회원의 name: 소스 추적
    name="Andongmin"
    실제 출력
    출력 없음
  2. updateName
    M1 / Dongmin

    M1 회원의 name: 소스 추적
    같은 key의 새 Member
    name="Dongmin"
    실제 출력
    출력 없음
  3. find("M1")
    .orElseThrow()

    M1 회원의 name: 소스 추적
    name="Dongmin"
    실제 출력
    Member[id=M1, name=Dongmin]
  4. find("M2")

    M1 회원의 name: 소스 추적
    name="Dongmin"
    실제 출력
    missing=Optional.empty

표의 id·name은 insert에 전달한 Member의 필드와 updateName의 두 인수를 나타냅니다. 첫 두 호출은 저장 값을 바꾸고, 뒤의 두 조회는 그 값을 유지합니다. 이 main에는 회원을 제거하는 동작이 없습니다.

이 예의 조건

  • 등록 두 호출은 stdout이 없으며 삭제를 수행하지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2M1 Andongmin insert 뒤 M1 Dongmin으로 새 Member 값을 교체합니다. M1 조회는 Dongmin Member를 출력하고 M2 조회는 Optional.empty입니다. 실선은 순서이며 처음 두 갱신의 stdout 없음과 삭제 없음을 보존합니다.
4Member M1
5Andongmin
8새 Member M1
9Dongmin
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을 저장합니다.

  • M1: Andongmin
  • stdout: 없음
  • 새 id가 저장소에 등록된다.
같은 id 교체

M1에 새 Dongmin Member를 넣습니다.

  • M1: Dongmin
  • id: 유지
  • stdout: 없음
  • 값 객체 교체가 id의 멤버십을 늘리지 않는다.
조회

M1을 출력하고 M2는 empty를 얻습니다.

  • M1 결과: Dongmin Member
  • M2 결과: Optional.empty
  • M1 저장: 유지
  • 없는 id 조회가 기존 회원을 제거하지 않는다.

같은 M1 값을 교체한 뒤 존재·부재를 조회한다

같은 M1 값을 새 record로 교체한 뒤 M1 조회와 없는 M2 조회를 합니다. 고정 키의 값 교체와 Optional.empty를 단계로 추적할 수 있습니다.

같은 M1 값을 교체한 뒤 존재·부재를 조회한다

원문은 M1 Andongmin을 저장한 뒤 Dongmin으로 교체하고, M1의 Member와 M2의 Optional.empty를 출력한다. 제거 호출은 없다.

MAP · REPLACE / LOOKUP

같은 M1 값을 교체한 뒤 존재·부재를 조회한다

M1은 같은 id로 이름만 교체됩니다. M2 조회의 Optional.empty는 삭제 결과가 아닙니다.

같은 id의 값 교체와 없는 id의 조회M1Andongmininsert뒤M1Dongmin으로새Member값교체합니다. M1조회는DongminMember를출력하고M2조회는Optional.empty입니다. 실선은순서이며처음두갱신stdout없음과삭제없음을보존합니다.
M1을 처음 저장Member M1 / Andongmin출력 없음
연결 관계M1을 처음 저장 · Member M1 / Andongmin · 출력 없음 → 같은 M1 값 교체 · 새 Member M1 / Dongmin · 기존 id 유지 / 출력 없음
같은 M1 값 교체새 Member M1 / Dongmin기존 id 유지 / 출력 없음
연결 관계같은 M1 값 교체 · 새 Member M1 / Dongmin · 기존 id 유지 / 출력 없음 → M1을 조회 · name=Dongmin · orElseThrow로 Member 출력
M1을 조회name=DongminorElseThrow로 Member 출력
연결 관계M1을 조회 · name=Dongmin · orElseThrow로 Member 출력 → M2를 조회 · missing=Optional.empty · M1 값은 그대로 유지
M2를 조회missing=Optional.emptyM1 값은 그대로 유지

실선:원 호출의 저장·조회회원 제거 호출은 없음

  1. insert
    M1 / Andongmin

    M1 회원의 name: 소스 추적
    name="Andongmin"
    실제 출력
    출력 없음
  2. updateName
    M1 / Dongmin

    M1 회원의 name: 소스 추적
    같은 key의 새 Member
    name="Dongmin"
    실제 출력
    출력 없음
  3. find("M1")
    .orElseThrow()

    M1 회원의 name: 소스 추적
    name="Dongmin"
    실제 출력
    Member[id=M1, name=Dongmin]
  4. find("M2")

    M1 회원의 name: 소스 추적
    name="Dongmin"
    실제 출력
    missing=Optional.empty

표의 id·name은 insert에 전달한 Member의 필드와 updateName의 두 인수를 나타냅니다. 첫 두 호출은 저장 값을 바꾸고, 뒤의 두 조회는 그 값을 유지합니다. 이 main에는 회원을 제거하는 동작이 없습니다.

이 예의 조건

  • 등록 두 호출은 stdout이 없으며 삭제를 수행하지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2M1 Andongmin insert 뒤 M1 Dongmin으로 새 Member 값을 교체합니다. M1 조회는 Dongmin Member를 출력하고 M2 조회는 Optional.empty입니다. 실선은 순서이며 처음 두 갱신의 stdout 없음과 삭제 없음을 보존합니다.
4Member M1
5Andongmin
8새 Member M1
9Dongmin
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을 저장합니다.

  • M1: Andongmin
  • stdout: 없음
  • 새 id가 저장소에 등록된다.
같은 id 교체

M1에 새 Dongmin Member를 넣습니다.

  • M1: Dongmin
  • id: 유지
  • stdout: 없음
  • 값 객체 교체가 id의 멤버십을 늘리지 않는다.
조회

M1을 출력하고 M2는 empty를 얻습니다.

  • M1 결과: Dongmin Member
  • M2 결과: Optional.empty
  • M1 저장: 유지
  • 없는 id 조회가 기존 회원을 제거하지 않는다.

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에 남길지 제거할지도 불변식으로 정합니다.

app/BoardReactionCounter.java
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 제목 유지와 카운트 변경을 함께 추적합니다.

P1 반응 수는 3으로 누적되고 P2는 1로 시작한다

원문의 세 add 호출을 따라 P1에 1과 2를 더하고 P2에 1을 저장하여 최종 총합 4를 만든다. 중간 상태는 소스 추적이다.

MAP · COMPUTE BRANCH

P1 반응 수는 3으로 누적되고 P2는 1로 시작한다

같은 게시글 id는 기존 정보를 유지하며 수만 더합니다. 최종 반응 수의 합은 4입니다.

postId별 갱신과 기존 제목 유지원세addP1+1/P1+2/P2+1뒤P1count3/P2count1/total4입니다. 같은id는기존post참조메타정보를유지하고count만교체합니다. 실선은원갱신조회순서이며큰수경계는본문별도검산입니다.
P1에 +1없음→새 Line / count1Post제목 Java 컬렉션
연결 관계P1에 +1 · 없음→새 Line / count1 · Post제목 Java 컬렉션 → 같은 P1에 +2 · 기존 line.post 유지 · 1+2=3
같은 P1에 +2기존 line.post 유지1+2=3
연결 관계같은 P1에 +2 · 기존 line.post 유지 · 1+2=3 → 새 P2에 +1 · P1=3 / P2=1 · 제목 스레드 기초
새 P2에 +1P1=3 / P2=1제목 스레드 기초
연결 관계새 P2에 +1 · P1=3 / P2=1 · 제목 스레드 기초 → 합계 조회 · total=4 · 두 Line은 그대로 유지
합계 조회total=4두 Line은 그대로 유지

실선:원 compute·조회 순서copyOf 결과 나열순서 미보장본문 큰수경계는 별도 검산

  1. P1 / +1
    제목: Java 컬렉션

    compute에서 수행하는 분기
    기존 값 없음
    new Line(post, amount)
    호출 뒤 상태: 소스 추적
    P1 → 1
  2. P1 / +2
    제목: Java 컬렉션

    compute에서 수행하는 분기
    기존 line.post() 유지
    반응 수 1 + 2 = 3
    호출 뒤 상태: 소스 추적
    P1 → 3
  3. P2 / +1
    제목: 스레드 기초

    compute에서 수행하는 분기
    기존 값 없음
    new Line(post, amount)
    호출 뒤 상태: 소스 추적
    P1 → 3
    P2 → 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
5count 1
6Post 제목 Java 컬렉션
11P1=3
12P2=1
18copyOf 결과 나열 순서 미보장
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 count: 1
  • 없던 id의 최초 메타정보를 보관한다.
P1 누적

같은 id에 +2를 적용합니다.

  • P1 count: 3
  • line.post: 기존 참조 유지
  • count만 바꾸고 저장된 제목을 교체하지 않는다.
P2와 합

새 P2에 +1 후 합계를 읽습니다.

  • P2: 스레드 기초 / count 1
  • total: 4
  • 독립 id의 카운트가 별도로 누적된다.

P1 반응 수는 3으로 누적되고 P2는 1로 시작한다

P1의 1→3 누적, P2 새 등록, total 4 조회의 실제 compute 순서입니다. 기존 post 제목 유지와 카운트 변경을 함께 추적합니다.

P1 반응 수는 3으로 누적되고 P2는 1로 시작한다

원문의 세 add 호출을 따라 P1에 1과 2를 더하고 P2에 1을 저장하여 최종 총합 4를 만든다. 중간 상태는 소스 추적이다.

MAP · COMPUTE BRANCH

P1 반응 수는 3으로 누적되고 P2는 1로 시작한다

같은 게시글 id는 기존 정보를 유지하며 수만 더합니다. 최종 반응 수의 합은 4입니다.

postId별 갱신과 기존 제목 유지원세addP1+1/P1+2/P2+1뒤P1count3/P2count1/total4입니다. 같은id는기존post참조메타정보를유지하고count만교체합니다. 실선은원갱신조회순서이며큰수경계는본문별도검산입니다.
P1에 +1없음→새 Line / count1Post제목 Java 컬렉션
연결 관계P1에 +1 · 없음→새 Line / count1 · Post제목 Java 컬렉션 → 같은 P1에 +2 · 기존 line.post 유지 · 1+2=3
같은 P1에 +2기존 line.post 유지1+2=3
연결 관계같은 P1에 +2 · 기존 line.post 유지 · 1+2=3 → 새 P2에 +1 · P1=3 / P2=1 · 제목 스레드 기초
새 P2에 +1P1=3 / P2=1제목 스레드 기초
연결 관계새 P2에 +1 · P1=3 / P2=1 · 제목 스레드 기초 → 합계 조회 · total=4 · 두 Line은 그대로 유지
합계 조회total=4두 Line은 그대로 유지

실선:원 compute·조회 순서copyOf 결과 나열순서 미보장본문 큰수경계는 별도 검산

  1. P1 / +1
    제목: Java 컬렉션

    compute에서 수행하는 분기
    기존 값 없음
    new Line(post, amount)
    호출 뒤 상태: 소스 추적
    P1 → 1
  2. P1 / +2
    제목: Java 컬렉션

    compute에서 수행하는 분기
    기존 line.post() 유지
    반응 수 1 + 2 = 3
    호출 뒤 상태: 소스 추적
    P1 → 3
  3. P2 / +1
    제목: 스레드 기초

    compute에서 수행하는 분기
    기존 값 없음
    new Line(post, amount)
    호출 뒤 상태: 소스 추적
    P1 → 3
    P2 → 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
5count 1
6Post 제목 Java 컬렉션
11P1=3
12P2=1
18copyOf 결과 나열 순서 미보장
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 count: 1
  • 없던 id의 최초 메타정보를 보관한다.
P1 누적

같은 id에 +2를 적용합니다.

  • P1 count: 3
  • line.post: 기존 참조 유지
  • count만 바꾸고 저장된 제목을 교체하지 않는다.
P2와 합

새 P2에 +1 후 합계를 읽습니다.

  • P2: 스레드 기초 / count 1
  • total: 4
  • 독립 id의 카운트가 별도로 누적된다.

같은 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을 만드는 함수 안에서만 제목을 추가하는 흐름과 구별해야 합니다.

exercise/ReverseGroupingMapSolution.java
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 때 새 범주 생성입니다. 세 입력의 그룹 멤버십 누적을 단계로 읽으며 집합의 누적 상태를 확인할 수 있습니다.

같은 collection key에 array와 hash를 함께 모은다

원문 LinkedHashMap의 제목·범주 세 관계를 읽어 범주별 LinkedHashSet으로 모은다. 두 번째 collection 입력은 기존 집합에 제목을 추가한다.

MAP · GROUP BY VALUE

같은 collection key에 array와 hash를 함께 모은다

범주를 key로 삼고 제목을 Set에 추가합니다. 같은 범주의 두 제목을 모두 보존합니다.

세 제목이 두 범주별 집합으로 모이는 관계 세입력array/hash/thread는collection/collection/concurrency로연결됩니다. 실선은제목이소속될Set이고collection은첫array때생성후hash때재사용하여두제목을순서대로보존합니다. 원출력전체관계와동일합니다. array 범주 collection hash 범주 collection thread 범주 concurrency collection array → hash 제목 첫삽입순서 concurrency thread 하나 실선: 제목이 들어가는 범주별 Set array 때 생성 / hash 때 기존 collection Set 재사용
세 제목이 두 범주별 집합으로 모이는 관계 세입력array/hash/thread는collection/collection/concurrency로연결됩니다. 실선은제목이소속될Set이고collection은첫array때생성후hash때재사용하여두제목을순서대로보존합니다. 원출력전체관계와동일합니다. array 범주 collection hash 범주 collection thread 범주 concurrency collection array → hash 제목 첫삽입순서 concurrency thread 하나 실선:제목→범주별집합 같은collection은Set재사용 서로 다른제목 둘다 보존
  1. array → collection

    범주별 집합 선택
    처음 보는 범주
    새 LinkedHashSet 생성
    추가 뒤 grouped: 소스 추적
    collection=[array]
  2. hash → collection

    범주별 집합 선택
    기존 collection 집합 사용
    hash 추가
    추가 뒤 grouped: 소스 추적
    collection=[array, hash]
  3. 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제목 첫 삽입 순서
15array 때 생성
16hash 때 기존 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에 넣습니다.

  • collection: [array]
  • concurrency: 없음
  • 첫 입력에서 범주의 Set을 만든다.
기존 범주 재사용

hash를 같은 collection에 추가합니다.

  • collection: [array,hash]
  • 같은 key의 기존 Set을 덮지 않고 재사용한다.
다른 범주 생성

thread를 concurrency에 넣습니다.

  • collection: [array,hash]
  • concurrency: [thread]
  • 범주별 소속을 분리하여 두 제목을 모두 유지한다.

같은 collection key에 array와 hash를 함께 모은다

array 때 범주 Set 생성, hash 때 같은 Set 재사용, thread 때 새 범주 생성입니다. 세 입력의 그룹 멤버십 누적을 단계로 읽으며 집합의 누적 상태를 확인할 수 있습니다.

같은 collection key에 array와 hash를 함께 모은다

원문 LinkedHashMap의 제목·범주 세 관계를 읽어 범주별 LinkedHashSet으로 모은다. 두 번째 collection 입력은 기존 집합에 제목을 추가한다.

MAP · GROUP BY VALUE

같은 collection key에 array와 hash를 함께 모은다

범주를 key로 삼고 제목을 Set에 추가합니다. 같은 범주의 두 제목을 모두 보존합니다.

세 제목이 두 범주별 집합으로 모이는 관계 세입력array/hash/thread는collection/collection/concurrency로연결됩니다. 실선은제목이소속될Set이고collection은첫array때생성후hash때재사용하여두제목을순서대로보존합니다. 원출력전체관계와동일합니다. array 범주 collection hash 범주 collection thread 범주 concurrency collection array → hash 제목 첫삽입순서 concurrency thread 하나 실선: 제목이 들어가는 범주별 Set array 때 생성 / hash 때 기존 collection Set 재사용
세 제목이 두 범주별 집합으로 모이는 관계 세입력array/hash/thread는collection/collection/concurrency로연결됩니다. 실선은제목이소속될Set이고collection은첫array때생성후hash때재사용하여두제목을순서대로보존합니다. 원출력전체관계와동일합니다. array 범주 collection hash 범주 collection thread 범주 concurrency collection array → hash 제목 첫삽입순서 concurrency thread 하나 실선:제목→범주별집합 같은collection은Set재사용 서로 다른제목 둘다 보존
  1. array → collection

    범주별 집합 선택
    처음 보는 범주
    새 LinkedHashSet 생성
    추가 뒤 grouped: 소스 추적
    collection=[array]
  2. hash → collection

    범주별 집합 선택
    기존 collection 집합 사용
    hash 추가
    추가 뒤 grouped: 소스 추적
    collection=[array, hash]
  3. 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제목 첫 삽입 순서
15array 때 생성
16hash 때 기존 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에 넣습니다.

  • collection: [array]
  • concurrency: 없음
  • 첫 입력에서 범주의 Set을 만든다.
기존 범주 재사용

hash를 같은 collection에 추가합니다.

  • collection: [array,hash]
  • 같은 key의 기존 Set을 덮지 않고 재사용한다.
다른 범주 생성

thread를 concurrency에 넣습니다.

  • collection: [array,hash]
  • concurrency: [thread]
  • 범주별 소속을 분리하여 두 제목을 모두 유지한다.

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 계열도 목적에 맞게 선택할 수 있습니다.