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

안동민 개발노트

본문 시작
11장 : 컬렉션

Map과 안전한 순회

Map은 키 하나를 값 하나에 연결하고 같은 키를 다시 넣으면 기존 값이 교체됩니다.

키의 equals와 hashCode가 저장 위치와 조회 결과를 결정합니다.

값이 없다는 사실과 null 값이 저장됐다는 사실은 API에 따라 구분하기 어려울 수 있습니다.

순회 중 구조를 직접 변경하면 fail-fast 반복자가 예외를 낼 수 있습니다.

이 절에서는 조회, 기본값, 누적, 안전한 제거 패턴을 다룹니다.

Map에서 “키가 없다”와 “키가 있고 값이 null이다”를 함께 허용하면 get 결과만으로 둘을 구분할 수 없습니다. null을 값으로 저장하지 않는 계약을 정하거나 containsKey를 함께 써야 합니다. 구현에 따라 null 허용 여부도 다르므로 공개 API에서 정책을 먼저 고정합니다.


키는 안정된 동등성을 가져야 한다

저장 뒤 키 상태가 바뀌면 같은 객체로도 값을 찾지 못할 수 있습니다.

String, record 같은 불변 값을 키로 선호합니다.


조회 정책에 맞는 메서드를 고른다

get, getOrDefault, computeIfAbsent, merge는 값 없음 처리와 계산 시점이 다릅니다.

null 허용 여부도 구현과 계약에서 확인합니다.

단어를 만날 때마다 merge가 새 키에는 1을 넣고 기존 키에는 합계를 계산합니다. 순회는 키 목록을 다시 조회하지 않고 entry에서 키와 값을 한 번에 읽습니다.

MapPatterns.java
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class MapPatterns {
    public static void main(String[] args) {
        Map<String, Integer> counts = new LinkedHashMap<>();
        for (String word : List.of("java", "jdk", "java")) {
            counts.merge(word, 1, Integer::sum);
        }
        for (Map.Entry<String, Integer> entry : counts.entrySet()) {
            System.out.println(entry.getKey() + "=" + entry.getValue());
        }
        System.out.println(counts.getOrDefault("jvm", 0));
        counts.entrySet().removeIf(entry -> entry.getValue() < 2);
        System.out.println(counts);
    }
}

java=2, jdk=1은 누적 결과이고, 없는 jvm은 맵을 변경하지 않은 채 기본값 0으로 읽힙니다. 마지막 removeIf는 반복자가 제공하는 제거 경로를 사용해 값이 2 미만인 항목만 없앱니다.

java=2
jdk=1
0
{java=2}

merge는 값 없음과 기존 값 누적을 하나의 연산으로 표현하고 removeIf는 반복자 경로로 안전하게 제거합니다.


entrySet은 키와 값을 함께 순회한다

키로 다시 get을 호출하지 않고 Map.Entry에서 둘을 읽습니다.

순서가 필요하면 LinkedHashMap이나 정렬된 Map을 선택합니다.

그룹 누적과 항목 제거는 Map을 순회하면서 자주 필요한 두 패턴입니다. 직접 구조를 바꾸는 반복과 컬렉션이 제공하는 제거 경로를 비교합니다.

비교 1
Map<String, List<Integer>> groups = new java.util.HashMap<>();
groups.computeIfAbsent("odd", key -> new java.util.ArrayList<>()).add(1);
groups.computeIfAbsent("odd", key -> new java.util.ArrayList<>()).add(3);
비교 2
// for (String key : counts.keySet()) counts.remove(key);
// 실행 중 ConcurrentModificationException 가능
비교 3
counts.keySet().removeIf(key -> key.startsWith("temp"));

computeIfAbsent는 키에 값이 없을 때만 새 목록을 만들어 그룹 누적에 적합합니다. 반면 getOrDefault가 돌려준 기본 목록에 값을 추가해도 그 목록이 맵에 자동 저장되지는 않습니다. “조회용 기본값”과 “없으면 생성해 저장”을 구분하세요.

fail-fast 반복자는 버그를 빨리 드러내는 보조 장치이지 동시성 안전 보장이 아닙니다. 다른 스레드가 수정할 수 있다면 ConcurrentHashMap 같은 구현과 compute, merge의 원자적 갱신 계약을 검토해야 합니다.

연습으로 입력에 "jvm"을 두 번 추가하고 제거 기준을 3으로 올려 보세요. 삽입 순서는 LinkedHashMap이 유지하고, 제거 여부는 최종 누적값으로 결정되어야 합니다. 이 두 결과를 구분해 설명할 수 있으면 순회 순서와 값 정책을 따로 이해한 것입니다.


Map 코드는 키 동등성, 값 없음 정책, 순회 중 변경이라는 세 경계를 명시하면 예측하기 쉬워집니다.