본문으로 건너뛰기

안동민 개발노트

본문 시작

Map 키와 갱신 연산

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

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

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

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

Map 문제는 key의 유일성과 중복 정책부터 설계한다

무엇으로 찾을지 정한 뒤 같은 key가 다시 왔을 때 거부·교체·누적 중 하나를 선택합니다.

  1. 조회 질문

    무엇으로 찾는가

  2. key

    불변·유일 식별

  3. value

    연결할 데이터

  4. 중복 정책

    거부·교체·누적

  5. 부재 표현

    null·Optional·default


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하면 마지막 값이 이기는 정책이 암묵적으로 생깁니다.

단어 수 분기와 merge는 같은 갱신을 다른 모양으로 표현한다

분기 코드는 부재 확인과 저장이 떨어져 있고 merge는 초기값과 누적 함수를 한곳에 둡니다.

  1. containsKey 분기

    조회→분기→put · 중복 코드

  2. merge(word,1,Integer::sum)

    없음 1 · 있음 +1

  3. 결과

    word → count

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

회원 id M1은 insert와 update에서 같은 key를 유지한다

Map의 size는 회원 수이며 같은 id를 다시 put하면 value만 교체됩니다.

  1. 빈 저장소

    {} · size 0

  2. insert M1

    M1→회원A · size 1

  3. update M1

    M1→회원A′ · size 1

  4. remove M1

    {} · size 0

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

Map 갱신 메서드는 중복 key의 업무 정책에 맞춘다

메서드 이름보다 기존 값이 있을 때와 없을 때 각각 어떤 상태가 되어야 하는지 적습니다.

요구메서드기존 key일 때
무조건 교체put새 value
최초만 등록putIfAbsent기존 유지
누적merge결합 함수
있을 때 계산computeIfPresent재계산
없을 때 생성computeIfAbsent지연 생성
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 계열도 목적에 맞게 선택할 수 있습니다.