본문 바로가기

Map 활용: 중복 키 처리와 merge·compute

같은 키가 다시 들어올 때의 처리 방법을 정하고, put·putIfAbsent·merge·computeIfAbsent·compute로 단어 빈도 세기, 회원 저장소, 반응 수 집계, 역방향 그룹 묶기를 구현합니다.

16장 첫 글에서 put으로 저장하고 get으로 꺼내는 Map의 기본 사용법을 익혔습니다. 메서드 자체는 단순하지만, 실제 문제를 풀다 보면 문법보다 먼저 정해야 할 것이 있습니다. 무엇을 키로 삼을지, 같은 키가 다시 들어오면 어떻게 할지, 없는 키를 찾으면 무엇을 돌려줄지입니다. 예를 들어 회원 이름을 키로 삼으면 이름이 같은 두 사람이 한 항목으로 합쳐집니다. 게시글 객체 전체를 키로 삼으면 제목을 고치는 순간 같은 글이 다른 키가 되어 버립니다.

이 글에서는 단어 빈도 세기, 배열을 Map으로 바꾸기, 회원 저장소, 게시글 반응 수 집계 같은 작은 문제를 풀면서 이 질문들에 답합니다. 답이 정해지면 put, putIfAbsent, merge, computeIfAbsent, compute 가운데 어떤 메서드를 쓸지도 자연스럽게 정해집니다.

개수를 세다가 저장을 빠뜨리는 실수

문장에 나온 단어가 각각 몇 번 나왔는지 세는 프로그램을 생각해 봅시다. 단어를 키로, 나온 횟수를 값으로 두면 됩니다. 처음 나온 단어는 1을 저장하고, 이미 있는 단어는 저장된 값에 1을 더하면 될 것 같습니다. 다음 코드는 이 생각을 그대로 옮기려다 한 줄을 잘못 적었습니다.

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);
    }
}

이 코드는 실행까지 가지 못합니다. 컴파일하면 다음 오류가 납니다.

javac 오류
WordCountUpdateBug.java:9: error: not a statement
                counts.get(word) + 1;
                                 ^
1 error

not a statement는 '문장이 될 수 없다'는 뜻입니다. 자바는 대입, ++와 --, 메서드 호출, 객체 생성처럼 무언가를 바꾸거나 실행하는 식만 그 자체로 문장이 될 수 있게 합니다. counts.get(word) + 1은 값을 계산할 뿐 그 값을 아무 데도 쓰지 않으니, 컴파일러는 이것을 실수로 보고 거부합니다.

그렇다면 계산 결과를 변수에 받으면 될까요? int next = counts.get(word) + 1;로 고치면 컴파일은 되지만, 실행 결과는 {map=1, hash=1}입니다. map이 두 번 나왔는데도 1입니다. counts.get(word)가 돌려준 것은 Map에 저장된 Integer 객체이고, 여기에 1을 더하면 새 정수가 하나 만들어질 뿐입니다. Integer는 불변 객체라서 꺼낸 객체의 값을 직접 바꿀 방법도 없습니다. 그래서 Map의 값을 바꾸려면 계산한 결과를 put으로 다시 저장해야 합니다.

if (counts.containsKey(word)) {
    counts.put(word, counts.get(word) + 1);
} else {
    counts.put(word, 1);
}

이제 결과는 {map=2, hash=1}로 맞습니다. 하지만 '있는지 확인하고, 꺼내고, 계산하고, 다시 넣는' 네 단계를 매번 손으로 적어야 하고, 그중 하나만 빠뜨려도 앞에서처럼 틀립니다. 16장 첫 글에서 조회 수를 누적할 때 쓴 merge가 바로 이 네 단계를 한 번의 호출로 묶어 주는 메서드입니다. 위의 if/else 전체를 다음 한 줄로 바꿀 수 있습니다.

counts.merge(word, 1, Integer::sum);

다시 정리하면, merge(key, value, function)은 키가 없으면 주어진 값을 그대로 저장하고, 키가 있으면 기존 값과 주어진 값을 함수에 넣어 나온 결과를 저장합니다. 세 번째 인수 Integer::sum은 두 정수를 더하는 Integer.sum(a, b)를 함수처럼 넘기는 메서드 참조(method reference)로, 람다식 (oldCount, one) -> oldCount + one과 같은 뜻입니다. 람다와 메서드 참조는 30장에서 자세히 배우므로, 지금은 '기존 값과 새 값을 더하는 함수'라고 읽으면 됩니다. map, hash, map 순서로 merge를 호출하면 상태가 다음과 같이 바뀝니다.

단어호출 전 그 단어의 값merge가 한 일호출 뒤 counts
map없음1을 그대로 저장{map=1}
hash없음1을 그대로 저장{map=1, hash=1}
map1Integer.sum(1, 1)의 결과 2를 저장{map=2, hash=1}

if/else를 이 한 줄로 바꾼 프로그램은 표의 마지막 줄과 같은 {map=2, hash=1}을 출력합니다. 다만 HashMap은 키의 순서를 약속하지 않으므로, 출력에서 map과 hash가 나오는 순서는 JDK에 따라 다를 수 있습니다.

같은 키가 다시 들어올 때 정할 것

단어 빈도에서는 같은 키가 다시 오면 값을 합쳤습니다. 하지만 언제나 그래야 하는 것은 아닙니다. 회원 ID 배열 {11, 12, 11}과 이름 배열 {"Ada", "Linus", "Grace"}를 같은 위치끼리 묶어 Map으로 만든다고 해 봅시다. ID 11이 두 번 나오는데, 이것을 어떻게 처리할지는 Map이 아니라 풀고 있는 문제가 정해야 합니다. 고를 수 있는 처리 방법과 그에 맞는 메서드는 다음과 같습니다.

같은 키가 다시 오면쓰는 메서드위 입력의 결과
나중 값으로 바꾼다put{11=Grace, 12=Linus}
처음 값을 지킨다putIfAbsent{11=Ada, 12=Linus}
잘못된 입력으로 보고 멈춘다putIfAbsent의 반환값 검사IllegalArgumentException: duplicate id=11
두 값을 합친다merge값이 개수나 합계일 때 사용

아무 생각 없이 put만 쓰면 첫 번째 방법, 즉 나중 값이 이기는 방법을 고른 셈이 됩니다. 앞의 Ada는 아무 경고 없이 사라집니다. 오류가 나지 않으니 이것이 의도한 동작인지 실수인지 코드만 봐서는 알 수 없습니다. 그래서 같은 키가 다시 올 수 있는 문제라면 어떤 방법을 쓸지 먼저 정하고, 그것이 드러나는 메서드를 고르는 것이 좋습니다.

세 번째 방법은 메서드의 반환값을 이용합니다. put은 바꾸기 전의 값을, 키가 없었다면 null을 돌려줍니다. putIfAbsent는 키가 없을 때만 저장하고 null을 돌려주며, 키가 이미 있으면 아무것도 바꾸지 않고 기존 값을 돌려줍니다. 그러니 putIfAbsent의 반환값이 null이 아니면 '이미 있던 키'라는 뜻이고, 그 자리에서 예외를 던지면 됩니다.

단, 이 판단은 Map에 null 값을 저장하지 않을 때만 정확합니다. putIfAbsent는 키가 없을 때뿐 아니라 키가 null에 연결돼 있을 때도 새 값을 저장하고 null을 돌려주기 때문입니다. 예를 들어 ID 배열이 {11, 11, 12}이고 이름 배열이 {null, "Grace", "Linus"}라면, 첫 번째 11은 null로 저장되고 두 번째 11은 그 null을 Grace로 바꾸면서 null을 돌려주므로 중복이 걸러지지 않습니다. 다음 절의 예제는 이름이 null이 아니라고 전제합니다. 외부에서 입력을 받는 코드라면 반복문 전에 Objects.requireNonNull 같은 검사로 null부터 막아야 합니다.

네 가지 문제를 Map으로 풀기

이제 작은 문제 네 개를 한 프로그램으로 풀어 보겠습니다. toMap은 두 배열을 Map으로 묶고, common은 두 Map에 모두 있는 키를 찾습니다. countWords는 문장의 단어 빈도를 세고, findByValue는 값이 같은 키를 모두 찾습니다. 네 메서드는 서로 데이터를 주고받지 않는 별개의 문제입니다.

src/MapProblemWorkbook.java
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;

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 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 TreeSet<>();
        for (var entry : source.entrySet()) {
            if (entry.getValue() == target) keys.add(entry.getKey());
        }
        return Set.copyOf(keys);
    }
}

실행하면 네 줄이 출력됩니다. 다음은 실행 결과의 한 예입니다.

실행 결과(한 예)
{12=Linus, 11=Ada}
[hash]
{queue=1, map=3, hash=2}
[A, C]

직접 실행하면 줄 안의 순서가 이와 다를 수 있고, 같은 컴퓨터에서 다시 실행해도 {11=Ada, 12=Linus}나 [C, A]처럼 바뀌어 나오기도 합니다. 네 메서드가 모두 마지막에 결과를 Map.copyOf나 Set.copyOf로 복사해 돌려주기 때문입니다. 16장 첫 글에서 본 것처럼 이 두 메서드는 수정할 수 없는 복사본을 만드는데, 복사본의 반복 순서는 정해져 있지 않습니다. 원래 Map이 LinkedHashMap이나 TreeMap이어서 순서가 있었더라도 그 순서는 복사본으로 옮겨 가지 않습니다. 그러니 이 출력은 어떤 원소와 값이 들어 있는지로 읽어야 하고, 순서까지 비교하면 안 됩니다.

네 가지 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의 반복 순서를 보장하지 않습니다.

두 배열을 묶는 toMap

toMap은 먼저 두 배열의 길이를 비교합니다. 길이가 다르면 짝이 없는 원소가 생기므로 length mismatch 예외로 멈춥니다. 그다음 같은 인덱스의 ID와 이름을 putIfAbsent로 넣고, 반환값이 null이 아니면 앞 절의 세 번째 방법대로 예외를 던집니다. 실제로 ID 배열을 {11, 12, 11}로 바꿔 호출하면 IllegalArgumentException: duplicate id=11이 발생합니다. 마지막 줄의 Map.copyOf는 null 키와 값을 허용하지 않으므로, 결과에 null 이름이 남아 있으면 여기서 NullPointerException이 납니다. 하지만 앞 절의 예처럼 null이 뒤의 이름으로 덮어써지면 이 검사도 통과해 버립니다. 그래서 null은 반복문에 들어가기 전에 막아야 합니다.

두 Map의 공통 키를 찾는 common

common은 왼쪽 Map의 키를 새 HashSet에 복사한 뒤, retainAll로 오른쪽 Map에도 있는 키만 남깁니다. 키만 비교하므로 hash의 값이 왼쪽에서는 50, 오른쪽에서는 45로 달라도 결과에 남습니다. 값까지 같은 항목을 찾으려면 남은 키마다 두 값을 따로 비교해야 합니다.

복사부터 하는 데에는 이유가 있습니다. keySet()이 돌려주는 집합은 Map과 연결된 뷰(view)라서, 이 집합에서 원소를 지우면 Map에서도 그 항목이 지워집니다. 복사하지 않고 left.keySet().retainAll(...)을 호출하면, left가 HashMap일 때는 공통이 아닌 array 항목이 left에서 사라집니다. 이 예제처럼 Map.of로 만든 수정할 수 없는 Map이라면 UnsupportedOperationException이 납니다. 입력으로 받은 Map을 몰래 바꾸지 않으려면 복사본에서 계산해야 합니다.

단어 빈도를 세는 countWords

countWords는 문장을 공백으로 나눈 뒤, 앞에서 본 merge(word, 1, Integer::sum)으로 개수를 셉니다. split에 넘긴 정규 표현식 \s+는 공백 문자가 하나 이상 이어진 부분을 뜻하므로(자바 문자열 안에서는 역슬래시를 두 번 써서 "\\s+"로 적습니다), 단어 사이에 공백이 여러 칸 있어도 한 번에 나뉩니다. counts를 TreeMap으로 만들었으니 반환하기 전까지는 키가 사전 순으로 정렬돼 있지만, Map.copyOf를 거치면서 그 순서는 사라집니다.

값으로 키를 찾는 findByValue

Map은 키로 값을 찾도록 만든 구조입니다. 어떤 값이 들어 있는지만 알려 주는 containsValue는 있지만, 값으로 키를 찾아 주는 메서드는 없습니다. 그래서 findByValue는 entrySet()으로 키와 값의 쌍을 하나씩 꺼내 값이 50인 항목의 키를 모읍니다. 키는 고유하지만 값은 그렇지 않습니다. A와 C가 둘 다 50이므로 결과를 키 하나가 아니라 Set으로 돌려줍니다.

entry.getValue() == target이 제대로 동작하는 것은 target이 int이기 때문입니다. 한쪽이 기본형이면 Integer가 int로 언박싱되어 값끼리 비교합니다. 반면 두 쪽이 모두 Integer이면 ==는 같은 객체인지를 비교하므로, 값이 1000으로 같아도 false가 나올 수 있습니다. 이 함정은 래퍼 클래스와 박싱·언박싱에서 다뤘습니다. 비교할 두 값이 모두 래퍼 타입이라면 equals를 쓰세요.

이 방법은 찾을 때마다 Map 전체를 훑으므로 항목 수에 비례하는 시간이 듭니다. 값으로 찾는 일이 잦다면 값을 키로, 그 값을 가진 키들의 집합을 값으로 하는 Map<Integer, Set<String>>을 따로 유지할 수 있습니다. 다만 원래 Map이 바뀔 때마다 이 보조 Map도 함께 고쳐야 하는 부담이 생깁니다. 이런 역방향 Map을 만드는 방법은 연습 문제 2에서 다룹니다.

더 깊이: copyOf 결과의 순서가 실행마다 바뀌는 이유

Javadoc은 Map.of나 Map.copyOf가 만드는 수정할 수 없는 Map에 대해 '반복 순서는 정해져 있지 않으며 바뀔 수 있다(The iteration order of mappings is unspecified and is subject to change)'고만 적습니다. 명세가 약속하는 것은 여기까지이고, 실행할 때마다 순서가 달라지는 것은 OpenJDK 구현의 선택입니다. JDK 21의 java.util.ImmutableCollections는 클래스가 초기화될 때 System.nanoTime()을 바탕으로 무작위 값(SALT32L)을 하나 만들고, 이 값으로 내부 배열을 순회할 때 시작할 위치와 방향을 정합니다. 원소가 배열의 어느 칸에 놓이는지는 해시 코드로 정해지지만, 어디서부터 어느 쪽으로 읽을지가 JVM을 새로 띄울 때마다 달라지므로 출력 순서도 달라집니다.

일부러 이렇게 만든 이유는 정해지지 않은 순서에 기대는 코드를 빨리 드러내기 위해서입니다. 매번 같은 순서로 나온다면 그 순서를 가정한 테스트가 우연히 통과하다가, JDK를 올리는 날 갑자기 깨질 것입니다. 순서가 필요하다면 복사본 대신 Collections.unmodifiableMap(new TreeMap<>(counts))처럼 순서 있는 Map을 새로 만들고, 그것을 수정할 수 없는 뷰로 감싸 돌려주면 됩니다. HashMap도 같은 JDK에서 같은 순서로 넣으면 대개 같은 순서로 나오지만, 이것 역시 명세가 약속한 동작은 아닙니다.

빈 문자열과 앞쪽 공백이 만드는 빈 단어

countWords에는 입력에 따라 엉뚱한 단어가 세어지는 문제가 있습니다. 빈 문자열 ""을 넣으면 결과가 비어 있지 않고 {=1}이 됩니다. 키가 빈 문자열이고 개수가 1인 항목입니다. 앞에 공백이 있는 " map "을 넣어도 map 말고 빈 문자열 키가 하나 더 생깁니다.

원인은 split의 규칙에 있습니다. 나눌 곳을 하나도 찾지 못하면 split은 원래 문자열 하나만 담은 배열을 돌려주므로, "".split("\\s+")의 결과는 빈 배열이 아니라 [""]입니다. 또 문자열이 공백으로 시작하면 그 공백 앞의 빈 부분이 첫 원소로 들어갑니다. 반대로 끝에 붙은 빈 부분은 split이 버리므로, "map hash "처럼 뒤에만 공백이 있을 때는 문제가 생기지 않습니다.

빈 입력을 '단어 0개'로 처리하려면 나누기 전에 앞뒤 공백을 지우고, 남은 것이 없으면 바로 빈 Map을 돌려주면 됩니다.

src/WordCountInputBoundary.java
import java.util.Map;
import java.util.Objects;
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) {
        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

count("")는 strip()으로 앞뒤 공백을 지운 결과가 빈 문자열이므로, split까지 가지 않고 Map.of()를 돌려줍니다. 그래서 첫 줄은 empty=0입니다. " map hash map "은 strip()을 거쳐 "map hash map"이 되고, 이것을 나누면 [map, hash, map]이므로 빈 문자열 키가 생기지 않습니다. 둘째 줄의 empty-key=false와 셋째 줄의 개수가 그 결과입니다.

이 예제는 공백으로만 단어를 나눕니다. 정규 표현식의 \s는 기본적으로 스페이스, 탭, 줄바꿈 같은 ASCII 공백 문자만 뜻합니다. 대소문자를 합치거나 구두점을 떼어 내지도 않으므로 Map이나 map,은 map과 다른 단어로 세어집니다. 무엇을 같은 단어로 볼지는 문제마다 다르니, 실제 텍스트를 다룬다면 그 규칙부터 정해야 합니다.

없는 키를 찾을 때: 회원 저장소와 Optional

get은 없는 키에 대해 null을 돌려줍니다. 이 null을 받은 쪽이 확인 없이 메서드를 호출하면 NullPointerException이 나는데, 그 지점은 대개 get을 호출한 곳에서 한참 떨어져 있어서 원인을 찾기 어렵습니다. 그래서 '없음'을 어떻게 알릴지는 상황에 맞게 정해야 합니다. 자동 완성처럼 결과가 없는 것이 정상인 조회라면 빈 결과를 돌려주고, 반드시 있어야 하는 설정 값이 없다면 예외를 던져 문제를 빨리 드러내는 편이 낫습니다.

다음 회원 저장소는 메서드마다 이를 다르게 처리합니다. 조회 메서드 find는 Optional<Member>를 돌려줍니다. Optional은 값이 하나 있거나 없는 상자로, '없을 수도 있다'는 사실을 반환 타입에 드러냅니다. Optional.ofNullable(x)는 x가 null이면 빈 상자를, 아니면 x를 담은 상자를 만듭니다. orElseThrow()는 상자에서 값을 꺼내되 비어 있으면 NoSuchElementException을 던집니다. Optional은 33장에서 자세히 배웁니다.

src/MemberRepositoryMap.java
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.NoSuchElementException;
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 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) {}
}
실행 결과
Member[id=M1, name=Dongmin]
missing=Optional.empty

같은 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 조회가 기존 회원을 제거하지 않는다.

실행 순서대로 따라가 보겠습니다. insert는 M1에 Andongmin을 저장하는데, putIfAbsent가 null을 돌려줬으니 중복이 아닙니다. updateName은 먼저 M1이 있는지 확인한 뒤, 같은 키 M1에 이름이 Dongmin인 새 Member를 put해서 값을 통째로 바꿉니다. 그래서 find("M1").orElseThrow()는 바뀐 회원을 꺼내 첫 줄을 출력합니다. M2는 저장한 적이 없으므로 find("M2")는 빈 Optional을 돌려주고, 이것을 출력하면 Optional.empty가 됩니다. 조회만으로 새 항목이 생기지는 않습니다.

저장과 수정을 별도 메서드로 나눈 것도 의도한 설계입니다. 하나의 save가 put으로 둘 다 처리하면, 수정하려던 ID에 오타가 있을 때 오류 없이 새 회원이 생겨 버립니다. insert는 이미 있는 ID를, updateName은 없는 ID를 예외로 거부하므로 이런 실수가 바로 드러납니다. 참고로 updateName의 확인과 교체는 Map.replace(key, value) 한 번으로도 쓸 수 있습니다. replace는 키가 있을 때만 값을 바꾸고 이전 값을 돌려주며, 키가 없으면 아무것도 하지 않고 null을 돌려줍니다.

Member는 불변 record이고 이름이 바뀌면 새 객체로 교체하므로, 키로 쓰는 ID는 처음 저장한 뒤 바뀌지 않습니다. 15장에서 본 것처럼 Map에 넣은 뒤 키가 바뀌면 그 항목을 다시 찾지 못할 수 있으니, 키는 이렇게 바뀌지 않는 값으로 고르는 것이 안전합니다. 또 get이 돌려준 null을 '없음'으로 읽을 수 있는 것은 이 저장소가 null 값을 저장하지 않기 때문입니다. null을 값으로 허용한다면 16장 첫 글에서 본 것처럼 containsKey로 따로 확인해야 합니다.

게시글별 반응 수 모으기: compute

이번에는 게시글마다 '좋아요' 같은 반응이 몇 개 달렸는지 모으는 기능입니다. 먼저 키를 정해야 합니다. 게시글을 record Post(String id, String title)로 나타낸다면 Map<Post, Integer>처럼 게시글 객체를 키로 쓰고 싶어질 수 있습니다. 그런데 record의 equals는 모든 구성 요소를 비교하므로, P1의 제목을 'Java 컬렉션'에서 '자바 컬렉션'으로 고친 객체는 다른 키가 됩니다. 실제로 두 객체로 반응을 더하면 Map에 P1 항목이 두 개 생깁니다. 같은 글인지 판단하는 기준은 ID이므로 키도 ID 문자열로 정합니다.

다음 Counter는 ID를 키로, 게시글 정보와 반응 수를 묶은 Line을 값으로 둡니다. 반응을 더하는 add는 compute를 사용합니다. compute(key, function)은 키와 현재 값을 함수에 넘기고, 함수가 돌려준 값을 새 값으로 저장합니다. 키가 없으면 현재 값 자리에 null이 들어가므로, 함수 안에서 처음인지 누적인지를 나눠 처리할 수 있습니다.

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=Line[post=Post[id=P1, title=Java 컬렉션], count=3], P2=Line[post=Post[id=P2, title=스레드 기초], count=1]}
total=4

lines()도 Map.copyOf로 복사본을 돌려주므로, 앞 절과 마찬가지로 첫 줄에서 P1과 P2의 순서는 실행마다 바뀔 수 있습니다. 세 번의 add가 Map을 어떻게 바꾸는지는 다음 도표와 표로 따라가 보겠습니다.

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의 카운트가 별도로 누적된다.
호출함수에 들어온 line함수가 돌려준 값호출 뒤 상태
P1에 +1nullnew Line(post, 1)P1 → 1
P1에 +2P1의 Line(개수 1)new Line(line.post(), 1 + 2)P1 → 3
P2에 +1nullnew Line(post, 1)P1 → 3, P2 → 1

마지막 줄의 total=4는 totalReactions가 lines.values()의 Line마다 count를 꺼내 모두 더한 값, 즉 3 + 1입니다. stream().mapToInt(Line::count).sum()은 '각 원소에서 정수 하나씩을 꺼내 모두 더하라'는 뜻으로, for문으로 합계를 구하는 것과 같은 일을 합니다. 스트림은 32장에서 배웁니다.

두 번째 호출에서 새 Line을 만들 때 인수로 받은 post가 아니라 기존의 line.post()를 쓴다는 점을 보세요. 그래서 같은 P1이 다른 제목으로 다시 들어와도 처음 저장한 제목 'Java 컬렉션'이 유지됩니다. 제목 수정까지 반영해야 하는 기능이라면 이 부분을 post로 바꿔야 하는데, 어느 쪽이 맞는지는 요구 사항이 정할 일입니다.

compute에는 함수가 null을 돌려주면 그 키의 항목을 지운다는 규칙도 있습니다. 예를 들어 반응을 취소하는 기능을 만든다면, 개수가 0이 되는 순간 함수가 null을 돌려주게 해서 반응이 없는 게시글을 Map에서 없앨 수 있습니다. 반응 수가 0인 항목을 남겨 둘지 지울지는 처음부터 정해 두어야 size()나 출력 결과를 일관되게 해석할 수 있습니다.

이 add는 merge로도 쓸 수 있습니다. 하지만 merge는 키가 있든 없든 저장할 후보 값 new Line(post, amount)를 먼저 만들어 넘겨야 하고, 기존 값과 합치는 함수를 따로 줘야 합니다. 처음일 때와 누적할 때 만드는 값의 모양이 다르다면, 두 경우를 compute 함수 하나에 나란히 적는 편이 읽기 쉽습니다.

더 깊이: 반응 수가 int 범위를 넘을 때

add는 amount가 양수인지 검사하지만, 더한 결과가 int 범위 안에 있는지는 검사하지 않습니다. P1의 반응 수가 이미 Integer.MAX_VALUE인 2,147,483,647일 때 1을 더하면 -2,147,483,648이 됩니다. 게시글 하나하나의 수가 범위 안이어도 합계는 넘칠 수 있어서, P1이 2,147,483,647이고 P2가 1이면 mapToInt(Line::count).sum()의 결과도 -2,147,483,648입니다.

개별 수를 int로 제한하려면 line.count() + amount 대신 Math.addExact(line.count(), amount)를 쓰면 됩니다. 넘치는 순간 ArithmeticException: integer overflow가 발생하고, 예외가 함수 밖으로 나가면 compute는 기존 값을 바꾸지 않습니다. 합계는 반환형과 더하는 과정을 함께 long으로 바꿔야 합니다.

별도 전체 합 메서드
long totalReactions() {
    return lines.values().stream().mapToLong(Line::count).sum();
}

int로 다 더한 결과를 나중에 long으로 바꾸는 것은 소용이 없습니다. 이미 넘쳐서 틀어진 값을 바꿀 뿐이기 때문입니다. 위처럼 처음부터 long으로 더하면 앞의 두 게시글의 합은 2,147,483,648로 바르게 나옵니다. 물론 long에도 한계가 있으니, 얼마까지 허용하고 넘으면 어떻게 거부할지는 도메인에서 따로 정해야 합니다.

갱신 메서드 한눈에 비교

지금까지 쓴 메서드를 정리하면 다음과 같습니다. '키가 없을 때' 열은 키가 아예 없는 경우와 키가 null에 연결된 경우를 함께 뜻합니다.

메서드키가 없을 때키가 있을 때돌려주는 값
put(k, v)v를 저장v로 교체이전 값, 없었으면 null
putIfAbsent(k, v)v를 저장그대로 둠기존 값, 새로 저장했으면 null
merge(k, v, f)v를 저장f(기존 값, v)의 결과로 교체새 값
computeIfAbsent(k, f)f(k)의 결과를 저장그대로 둠기존 값 또는 새로 저장한 값
compute(k, f)f(k, null)의 결과를 저장f(k, 기존 값)의 결과로 교체새 값

함수를 받는 세 메서드에서 함수가 null을 돌려주면 항목을 남기지 않습니다. merge와 compute는 기존 항목을 지우고, computeIfAbsent는 아무것도 저장하지 않으며, 이때 세 메서드 모두 null을 돌려줍니다. 또 merge에 넘기는 값 v 자체는 null이면 안 되며, null을 넘기면 NullPointerException이 납니다. 각 메서드의 정확한 규칙은 Map Javadoc에서 확인할 수 있습니다.

메서드를 고를 때는 앞에서 정한 처리 방법을 그대로 옮기면 됩니다. 나중 값이 이기면 put, 처음 값을 지키거나 중복을 막으려면 putIfAbsent, 숫자를 누적하면 merge를 씁니다. 키마다 컬렉션을 하나씩 두고 원소를 추가하려면 computeIfAbsent가 맞고, 처음과 누적을 다르게 처리하거나 결과에 따라 항목을 지워야 하면 compute가 맞습니다.

더 깊이: 여러 스레드에서 쓸 때와 함수 안에서 Map을 바꿀 때

Map 인터페이스의 기본 구현은 이 메서드들이 원자적(atomic)으로, 즉 중간에 다른 스레드가 끼어들 수 없게 실행된다고 약속하지 않습니다. Javadoc에도 '동기화나 원자성에 대해 아무것도 보장하지 않는다(makes no guarantees about synchronization or atomicity properties)'고 적혀 있습니다. HashMap은 이 메서드들을 더 효율적으로 다시 구현했지만 애초에 여러 스레드가 함께 쓰도록 만든 클래스가 아니므로, merge 한 줄로 바꿨다고 해서 동시 갱신이 안전해지지는 않습니다. 여러 스레드가 함께 개수를 센다면 compute와 merge 호출 전체를 원자적으로 실행하는 ConcurrentHashMap을 써야 하며, 이것은 21장에서 다룹니다.

또 compute나 computeIfAbsent에 넘긴 함수 안에서 같은 Map을 수정하면 안 됩니다. Javadoc은 계산하는 동안 Map을 바꾸지 말라고 적고 있고, HashMap은 이런 수정을 발견하면 가능한 범위에서(best-effort) ConcurrentModificationException을 던집니다. 실제로 memo.computeIfAbsent(1, k -> { memo.put(2, 2); return 1; })를 실행하면 이 예외가 납니다. 재귀로 값을 계산하며 캐시를 채우는 코드에서 자주 만나는 실수입니다.

정리

  • 같은 키가 다시 들어올 때 바꿀지, 처음 값을 지킬지, 거부할지, 합칠지를 먼저 정하고 그에 맞는 메서드를 고릅니다. put만 쓰면 나중 값이 이기는 방법을 말없이 고른 셈입니다.
  • Integer 값은 불변이라서 꺼내서 더하는 것만으로는 Map이 바뀌지 않습니다. merge는 확인, 계산, 저장을 한 번에 처리합니다.
  • keySet() 같은 뷰는 원래 Map과 연결돼 있으므로, 입력 Map을 바꾸지 않으려면 복사본에서 계산합니다.
  • Map.copyOf와 Set.copyOf의 반복 순서는 정해져 있지 않고, OpenJDK에서는 실행할 때마다 바뀝니다.
  • 키는 ID처럼 바뀌지 않는 값으로 고르고, 없는 키를 조회한 결과는 null, 예외, Optional 가운데 상황에 맞게 알립니다.

연습 문제

  1. Map<String, Integer> m = new HashMap<>();에 m.put("a", 1);을 한 뒤 아래 다섯 줄을 차례로 실행합니다. 각 줄이 돌려주는 값과 마지막에 남는 m을 예측하세요.

    m.putIfAbsent("a", 2);
    m.merge("a", 10, Integer::sum);
    m.merge("b", 5, Integer::sum);
    m.computeIfAbsent("b", k -> 100);
    m.compute("a", (k, v) -> v > 10 ? null : v);
  2. 게시글 제목을 범주에 연결한 Map을, 범주마다 제목 집합을 연결한 Map<String, Set<String>>으로 뒤집으세요. 입력은 LinkedHashMap에 array→collection, hash→collection, thread→concurrency를 이 순서로 넣은 것입니다. 같은 범주의 제목은 모두 보존하고, 범주 안의 제목은 입력 순서를 유지해야 합니다. 또 제목을 처리할 때마다 그 제목 하나만 담은 새 LinkedHashSet을 범주 키에 put한다면 collection에 무엇이 남을지도 예측하세요.

  3. Map<Post, Integer>에 merge(new Post("P1", "Java 컬렉션"), 1, Integer::sum)과 merge(new Post("P1", "자바 컬렉션"), 2, Integer::sum)을 차례로 호출하면 size()는 얼마일까요? Post는 본문의 record Post(String id, String title)입니다. 이유를 설명하고, 같은 게시글의 반응이 한곳에 모이게 하려면 무엇을 바꿔야 하는지 쓰세요.

1번 풀이

돌려주는 값은 차례로 1, 11, 5, 5, null이고, 마지막 m은 {b=5}입니다.

putIfAbsent("a", 2)는 a가 이미 1에 연결돼 있으므로 아무것도 바꾸지 않고 기존 값 1을 돌려줍니다. merge("a", 10, Integer::sum)은 기존 값 1과 10을 더한 11을 저장하고 그 새 값을 돌려줍니다. merge("b", 5, Integer::sum)은 b가 없으므로 5를 그대로 저장하고 5를 돌려줍니다. computeIfAbsent("b", k -> 100)은 b가 이미 있으므로 함수를 호출하지 않고 기존 값 5를 돌려줍니다. 마지막 compute는 a의 현재 값 11이 10보다 크므로 함수가 null을 돌려주고, 그래서 a 항목이 지워지면서 null을 돌려줍니다.

2번 풀이

computeIfAbsent는 범주가 처음 나올 때 새 LinkedHashSet을 만들어 저장한 뒤 그 집합을 돌려주고, 이미 있으면 저장된 집합을 돌려줍니다. 이어지는 .add(e.getKey())는 두 경우 모두 실행되어 현재 제목을 집합에 넣습니다.

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=[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 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]
  • 범주별 소속을 분리하여 두 제목을 모두 유지한다.

첫 array에서는 collection의 새 집합을 만들고 array를 넣습니다. 다음 hash에서는 같은 집합을 받아 hash를 추가하고, 마지막 thread에서만 다른 키 concurrency의 새 집합을 만듭니다. 값이 겹칠 수 있으므로 뒤집은 결과는 Set 하나가 아니라 범주마다 Set을 가진 Map이어야 합니다. 원본과 결과가 모두 LinkedHashMap이고 집합도 LinkedHashSet이라서 입력 순서가 그대로 출력에 남습니다.

반대로 매번 새 집합을 put하면 결과는 {collection=[hash], concurrency=[thread]}입니다. 두 번째 입력이 collection의 첫 집합을 통째로 교체하므로 array를 잃습니다. 입력 순서의 문제가 아니라 같은 키의 값을 교체해 버린 것이 원인입니다. 비슷한 실수로, 제목을 넣는 일을 computeIfAbsent에 넘긴 함수 안에서만 하면 결과가 {collection=[array], concurrency=[thread]}가 됩니다. 함수는 범주가 처음 나올 때만 호출되므로, 이미 있는 범주에 들어오는 hash는 추가되지 않습니다.

3번 풀이

size()는 2입니다. record의 equals와 hashCode는 모든 구성 요소를 비교하므로, ID가 같아도 제목이 다른 두 Post는 서로 다른 키입니다. 그래서 두 번째 merge는 기존 항목에 2를 더하지 않고 새 항목을 만듭니다. 같은 게시글인지는 ID로 판단해야 하므로, 본문처럼 post.id()를 키로 쓰는 Map<String, ...>으로 바꾸면 반응이 한곳에 모입니다.

다음 글

다음 글에서는 앞 글에서 배운 스택과 큐로 브라우저의 뒤로 가기·앞으로 가기와 인쇄 작업 대기열을 만듭니다.