본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
16장 : 맵·스택·큐·덱

Map 키와 갱신 연산

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

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

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

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


containsKey 갱신 누락

분기 안에서 계산만 하고 put을 하지 않으면 두 번째 등장도 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);
    }
}

이 코드는 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);
    }
}

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

대상 50에는 A와 C 둘 다 해당합니다.

값 조회가 빈번하다면 매번 전체 entrySet을 걷는 대신 Map<Value, Set<Key>> 보조 인덱스를 유지할 수 있지만 갱신 일관성 비용이 생깁니다.


사전과 회원 저장소의 부재 표현

사전 조회에서 없는 단어는 null, 예외, Optional, 추천 목록 중 하나로 표현할 수 있습니다.

UI 자동 완성이라면 빈 결과가 정상이고, 내부 필수 설정이라면 예외가 결함을 빨리 드러냅니다.

Map.getnull을 그대로 모든 계층에 흘리지 않습니다.

회원 저장소는 id 중복 저장을 거부하고 findById에서 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) {}
}

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

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

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


게시글 ID와 반응 수 모델

Map<Post, Integer>는 Post의 equals/hashCode가 게시글 식별 의미와 정확히 맞아야 합니다.

더 단순하게 postId를 키로 쓰고 제목은 게시글 저장소에서 조회할 수 있습니다.

집계 시점 제목을 고정해야 한다면 ReactionLine 값에 게시글 정보와 반응 수를 함께 보관합니다.

반응 수 추가는 merge로 표현할 수 있지만 취소와 상한 같은 규칙이 생기면 명시적 메서드가 읽기 쉽습니다.

반응 수가 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) {}
}

연습 문제

게시글 제목→범주 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);
    }
}

컬렉션에는 배열과 해시가 함께 남습니다.

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

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

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