빌드노트
HashMap에 키가 있는데 get은 null을 돌려준다
삽입 뒤 키의 equals·hashCode에 쓰는 필드가 바뀌면 같은 객체로도 조회가 실패할 수 있다. 실제 해시·get·containsKey·entrySet 결과로 확인할 지점을 짚고, 불변 ID 키와 변경 전 제거 순서를 설명한다.
HashMap에 넣은 키를 그대로 get에 넘겼는데 null이 나오고, entrySet()에는 그 키가 보인다면 삽입 뒤 키의 필드가 바뀌었는지 확인한다. 특히 equals와 hashCode가 읽는 필드가 대상이다. 같은 객체를 들고 있어도 조회가 실패할 수 있다. 변하지 않는 식별자를 키로 두고, 이름이나 상태처럼 바뀌는 데이터는 값으로 옮기면 이 문제를 피할 수 있다.
Map API (새 탭에서 열림)는 키로 들어 있는 동안 equals 비교에 영향을 주는 상태가 바뀌면 Map의 동작을 보장하지 않는다. 아래의 null과 false는 합성 키로 직접 관찰한 Windows OpenJDK 17.0.2의 HashMap 결과다. 모든 Map에서 반드시 같은 결과가 나온다는 뜻은 아니다.
같은 참조인데 해시가 달라졌다
실험의 Key는 정수 code로 동등성을 판단한다. 다음은 생성자나 출력 코드를 제외한, 판단에 필요한 필드와 메서드다.
int code;
@Override public int hashCode() { return code; }
@Override public boolean equals(Object other) {
return other instanceof Key k && code == k.code;
}
빈 HashMap<Key, String>에 code=1인 키와 값 "memo"를 넣었다. 그 키의 code를 2로 바꾸고 같은 객체로 조회했다. 아래에서 contains는 containsKey(key), entries는 entrySet()이다. Key(숫자)는 키의 현재 code를 표시하도록 만든 출력이다.
before hash=1 get=memo contains=true size=1 entries=[Key(1)=memo]
after hash=2 get=null contains=false size=1 entries=[Key(2)=memo]
sameKey=true
값을 제거하지 않았는데도 get은 null이 됐다. 크기는 여전히 1이었고, 순회한 엔트리의 키와 원래 키를 ==로 비교한 sameKey도 true였다.
이 차이는 OpenJDK 17.0.2의 HashMap 구현 (새 탭에서 열림)으로 설명할 수 있다. 엔트리는 삽입 때 계산한 해시와 키 참조를 저장한다. 조회는 현재 키로 해시를 다시 계산하고, 저장된 해시가 일치해야 참조나 equals를 비교한다. 키의 필드를 바꿔도 저장된 해시는 함께 바뀌지 않는다. 따라서 entrySet()의 Key(2)는 참조한 객체의 현재 모습이지, 새 해시에 맞춰 저장 위치가 정리됐다는 증거가 아니다.
put과 조회 사이에서 무엇이 바뀌었는지 본다
자기 코드에서는 키가 null이 아닌 지점에서 다음 진단을 put 직후와 문제가 생긴 get 직전에 넣어 비교할 수 있다. key와 map은 실제 조회에 쓰는 변수로 바꾼다.
System.out.printf("hash=%d get=%s contains=%s size=%d entries=%s%n",
key.hashCode(), map.get(key), map.containsKey(key),
map.size(), map.entrySet());
해시가 달라졌다면 두 시점 사이에서 equals·hashCode가 읽는 필드를 누가 바꿨는지 찾는다. 자동 생성한 메서드도 포함 필드를 확인한다. 해시가 같다고 안전한 것은 아니다. Object.hashCode 계약 (새 탭에서 열림)은 서로 다른 값의 해시 충돌을 허용하므로, 동등성에 쓰는 상태도 함께 대조해야 한다. 별도 대조군에서 두 메서드가 읽지 않는 label만 바꾸자 해시는 1로 유지됐고 같은 키와 새 Key(1) 모두 "memo"를 조회했다.
get의 null이 값 자체일 수도 있다. HashMap.get 문서 (새 탭에서 열림)의 구별 방법대로, 변경하지 않은 정수 키에 null 값을 넣어 비교했다. 이때는 get(1)=null, containsKey(1)=true였다. 앞의 변경된 키에서는 containsKey까지 false였지만, 이미 계약의 보장 범위를 벗어난 상태이므로 그것만 보고 엔트리가 사라졌다고 판단할 수 없다.
조회 결과가 예제와 다르면 실제 JDK 버전, 두 메서드의 구현, 필드 변경 시점을 먼저 대조한다. 이번 출력과 다르다는 이유로 가변 키가 안전하다고 결론 내리지는 않는다.
식별자는 키에, 바뀌는 데이터는 값에 둔다
표시명 변경이 키의 동등성까지 바꿀 필요는 없다. 다음은 JDK 17의 Java 메서드 안에서 실행할 수 있는 수정 예다. 사용자 번호는 키에, 표시명은 값에 둔다.
record UserId(int value) {}
var names = new java.util.HashMap<UserId, String>();
names.put(new UserId(7), "before");
names.put(new UserId(7), "after");
System.out.println(names.get(new UserId(7)));
출력은 after였다. 별도 측정에서도 해시는 7에서 7로 유지됐고, 새 동등 키의 조회와 containsKey가 성공했으며 항목 수는 1이었다. 해시 숫자 7 자체가 record의 고정 공식이라는 뜻은 아니다. 이 키는 변하지 않는 int 하나로 동등성을 정한다. Record API (새 탭에서 열림)가 설명하는 불변성은 얕은 불변성이므로, record 안에 가변 목록을 넣으면 그 목록의 내용까지 고정되지는 않는다.
가변 키를 당장 없애기 어렵다면 변경하기 전에 제거하고, Map 밖에서 바꾼 뒤 다시 넣는다. 실험에서는 code=1인 키와 null이 아닌 "memo" 한 건만 든 새 Map에서 다음 순서를 비교했다.
String saved = map.remove(key);
key.code = 2;
map.put(key, saved);
제거는 "memo"를 반환했고 크기는 0이 됐다. 다시 넣은 뒤에는 get(key)=memo, containsKey(key)=true, 크기 1이었다. 이 순서는 제거 전까지 키가 바뀌지 않았다는 전제다. 값이 null일 수 있다면 제거 전 존재 여부를 따로 판단하고, 새 키와 동등한 기존 항목이 있다면 덮어쓸지 거절할지도 정해야 한다.
반대로 먼저 code를 바꾼 새 Map에서는 remove가 null을 반환했고 크기는 1로 남았다. 이어 같은 객체를 put하자 크기가 2가 되고 두 엔트리의 키가 모두 같은 참조였다. 이미 바뀐 키를 다시 넣는 동작을 복구 방법으로 삼을 수 없는 관찰이다.
여기서 실행한 범위는 OpenJDK 17.0.2, 한 스레드와 작은 합성 HashMap이다. 다른 JDK·Map 구현, 동시 접근과 이미 변경된 Map의 일반 복구 절차는 검증하지 않았다. 설계에 적용할 기준은 키로 들어 있는 동안 동등성과 해시 계산에 쓰이는 상태를 유지하는 것이다.