안동민 개발노트

본문 시작

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는 읽기·계산·쓰기를 한 호출로 묶어 이 누락을 줄입니다.


네 가지 Map 문제의 입력과 결과

회원 ID 배열과 이름 배열을 Map으로 묶을 때는 두 배열의 길이가 같은지 검사해야 합니다.

같은 ID가 두 번 나오면 마지막 값으로 교체할지, 첫 값을 지킬지, 오류로 중단할지 정책을 정해야 합니다.

무조건 put하면 마지막 값이 이기는 정책이 암묵적으로 생깁니다.

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 문제에서 남는 원소와 값
원문 호출실제 입력결과의 원소·값
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의 반복 순서를 보장하지 않습니다.

값은 고유하지 않을 수 있으므로 역조회 결과를 키 하나로 반환하지 않습니다.

대상 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 Andongmin을 저장한 뒤 Dongmin으로 교체하고, M1의 Member와 M2의 Optional.empty를 출력한다. 제거 호출은 없다.

같은 M1 값을 교체한 뒤 존재·부재를 조회한다
main의 동작M1 회원의 name: 소스 추적실제 출력
insert
M1 / Andongmin
name="Andongmin"출력 없음
updateName
M1 / Dongmin
같은 key의 새 Member
name="Dongmin"
출력 없음
find("M1")
.orElseThrow()
name="Dongmin"Member[id=M1, name=Dongmin]
find("M2")name="Dongmin"missing=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에는 회원을 제거하는 동작이 없습니다.

Member를 불변 record로 두고 이름 변경은 같은 id의 새 값을 put합니다.

키로 쓰는 id는 바뀌지 않습니다.

저장소 내부 Map은 외부에 노출하지 않습니다.


게시글 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로 시작한다

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

P1 반응 수는 3으로 누적되고 P2는 1로 시작한다
main의 입력compute에서 수행하는 분기호출 뒤 상태: 소스 추적
P1 / +1
제목: Java 컬렉션
기존 값 없음
new Line(post, amount)
P1 → 1
P1 / +2
제목: Java 컬렉션
기존 line.post() 유지
반응 수 1 + 2 = 3
P1 → 3
P2 / +1
제목: 스레드 기초
기존 값 없음
new Line(post, amount)
P1 → 3
P2 → 1
P1 / +1
제목: Java 컬렉션
compute에서 수행하는 분기:
기존 값 없음
new Line(post, amount)
호출 뒤 상태: 소스 추적: P1 → 1
P1 / +2
제목: Java 컬렉션
compute에서 수행하는 분기:
기존 line.post() 유지
반응 수 1 + 2 = 3
호출 뒤 상태: 소스 추적: P1 → 3
P2 / +1
제목: 스레드 기초
compute에서 수행하는 분기:
기존 값 없음
new Line(post, amount)
호출 뒤 상태: 소스 추적:
P1 → 3
P2 → 1

표는 add의 호출 순서를 따릅니다. 실제 lines() 출력에는 P1의 반응 수 3과 P2의 반응 수 1이 있고, 다음 줄은 total=4입니다. 스냅숏을 만드는 Map.copyOf(lines)의 반복 순서는 보장되지 않습니다.


연습 문제

게시글 제목→범주 Map을 범주→제목 Set Map으로 뒤집으세요.

같은 범주의 모든 제목을 보존하고 결과 제목은 입력 순서를 유지합니다.

정답과 해설

computeIfAbsent는 범주가 처음 나올 때만 LinkedHashSet을 만들고 현재 제목을 추가합니다.

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를 함께 모은다

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

같은 collection key에 array와 hash를 함께 모은다
원본 Map의 관계범주별 집합 선택추가 뒤 grouped: 소스 추적
array → collection
처음 보는 범주
새 LinkedHashSet 생성
collection=[array]
hash → collection
기존 collection 집합 사용
hash 추가
collection=[array, hash]
thread → concurrency
처음 보는 범주
새 LinkedHashSet 생성
collection=[array, hash]
concurrency=[thread]
array → collection
범주별 집합 선택:
처음 보는 범주
새 LinkedHashSet 생성
추가 뒤 grouped: 소스 추적: collection=[array]
hash → collection
범주별 집합 선택:
기존 collection 집합 사용
hash 추가
추가 뒤 grouped: 소스 추적: collection=[array, hash]
thread → concurrency
범주별 집합 선택:
처음 보는 범주
새 LinkedHashSet 생성
추가 뒤 grouped: 소스 추적:
collection=[array, hash]
concurrency=[thread]

computeIfAbsent는 범주가 처음 나올 때 집합을 만들고, 이어지는 add가 제목을 넣습니다. 원문의 LinkedHashMap과 LinkedHashSet이 순서를 유지하여 실제 최종 출력은 {collection=[array, hash], concurrency=[thread]}입니다.

collection에는 array와 hash가 이 순서로 함께 남고, concurrency에는 thread가 남습니다.

값이 중복될 수 있으므로 역방향 자료형은 Set 하나가 아니라 범주마다 Set을 가진 Map입니다.

Map 문제의 답은 API 선택 전에 키의 고유성, 중복 put 의미, 없는 키 처리, 값의 다중성을 적는 데서 시작합니다.

이 네 질문이 맞으면 merge와 compute 계열도 목적에 맞게 선택할 수 있습니다.