equals와 hashCode를 함께 구현해야 HashSet과 HashMap이 제대로 동작하는 이유
빠른 답
equals를 재정의했다면hashCode도 같은 기준 필드로 재정의해야 합니다.HashSet과HashMap은 먼저hashCode로 후보 위치를 좁힌 뒤equals로 최종 비교합니다.equals가true인 두 객체는 항상 같은hashCode를 반환해야 합니다.- 해시 계산에 쓰이는 필드는 컬렉션에 넣은 뒤 바뀌지 않는 값으로 잡는 편이 안전합니다.
목차
흐름으로 보기
해시 기반 컬렉션은 객체를 넣거나 찾을 때 모든 원소를 처음부터 끝까지 equals로 비교하지 않습니다. 먼저 hashCode를 이용해 내부 저장 공간 중 확인할 위치를 좁힙니다. 그다음 같은 위치에 있는 후보들만 equals로 비교합니다.
이 순서 때문에 equals만 재정의한 객체는 일반 비교에서는 같아 보여도 HashSet, HashMap 안에서는 서로 다른 객체처럼 다뤄질 수 있습니다. equals가 호출되기 전에 hashCode가 다른 버킷을 가리켜 버리면, 같은 값인지 확인할 기회가 생기지 않기 때문입니다.
값, 상태, 오류 유형 구분하기
Java에서 객체 비교를 이야기할 때는 값, 상태, 오류 유형을 나누어 보면 덜 헷갈립니다.
값은 객체를 논리적으로 같다고 볼 때 사용하는 기준입니다. 예를 들어 구독자를 email과 category로 구분한다면, 두 필드가 같은 두 객체는 같은 구독자라고 볼 수 있습니다.
상태는 객체가 현재 들고 있는 필드 값입니다. email이나 category가 바뀔 수 있는 필드라면 객체의 동등성도 실행 중에 달라질 수 있습니다. 해시 기반 컬렉션에 들어간 뒤 이 상태가 바뀌면 저장 위치와 조회 위치가 어긋날 수 있습니다.
오류 유형은 컴파일 에러나 예외가 아니라, 기대한 결과와 다른 값이 조용히 나오는 논리 오류에 가깝습니다. HashSet의 크기가 1이어야 할 것 같은데 2가 나오거나, HashMap에 넣은 값을 새 key로 조회했을 때 null이 나오는 식입니다.
동일성과 동등성
동일성은 두 참조가 같은 객체를 가리키는지 보는 비교입니다. Java에서는 ==가 이 비교를 합니다. new Subscribe(...)를 두 번 호출하면 필드 값이 같아도 서로 다른 객체가 만들어지므로 subscribe1 == subscribe2는 false입니다.
동등성은 업무나 도메인 관점에서 같은 값인지 보는 비교입니다. 이 기준은 equals를 재정의해 클래스가 직접 정할 수 있습니다. 예를 들어 이메일과 카테고리가 같으면 같은 구독자로 본다는 규칙을 세울 수 있습니다.
다만 equals만 바꾸면 Java의 모든 비교 구조가 저절로 그 규칙을 따르는 것은 아닙니다. 특히 HashSet, HashMap처럼 해시를 사용하는 컬렉션은 equals와 hashCode가 함께 맞아야 의도한 대로 동작합니다.
equals만 재정의했을 때 생기는 결과
아래 코드는 Subscribe의 동등성 기준을 email, category로 정했지만 hashCode는 재정의하지 않은 예시입니다. 같은 값으로 만든 두 객체를 HashSet에 넣고, 같은 값으로 만든 key로 HashMap 조회도 해 봅니다.
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public class EqualsOnlyExample {
public static void main(String[] args) {
Subscribe subscribe1 = new Subscribe("team.maeilmail@gmail.com", "backend");
Subscribe subscribe2 = new Subscribe("team.maeilmail@gmail.com", "backend");
HashSet<Subscribe> subscribes = new HashSet<>(List.of(subscribe1, subscribe2));
Map<Subscribe, Long> subscriberIds = new HashMap<>();
subscriberIds.put(subscribe1, 100L);
System.out.println("equals: " + subscribe1.equals(subscribe2));
System.out.println("hashCode 1: " + subscribe1.hashCode());
System.out.println("hashCode 2: " + subscribe2.hashCode());
System.out.println("set size: " + subscribes.size());
System.out.println("map get: " + subscriberIds.get(subscribe2));
}
static class Subscribe {
private final String email;
private final String category;
Subscribe(String email, String category) {
this.email = email;
this.category = category;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Subscribe other)) return false;
return Objects.equals(email, other.email)
&& Objects.equals(category, other.category);
}
}
}
실행 결과의 숫자는 환경마다 달라질 수 있습니다. 중요한 부분은 equals가 true인데도 hashCode가 다르고, 그 결과 컬렉션 동작이 기대와 어긋난다는 점입니다.
equals: true
hashCode 1: 617901222
hashCode 2: 1159190947
set size: 2
map get: null
HashSet 입장에서는 두 객체가 다른 버킷에 들어갈 수 있으므로 중복이라고 판단하지 못합니다. HashMap도 마찬가지입니다. 값을 넣을 때 사용한 key와 조회할 때 사용한 key가 값으로는 같아도, 서로 다른 해시 위치를 찾으면 기존 엔트리까지 도달하지 못할 수 있습니다.
Object의 기본 hashCode를 메모리 주소라고 설명하는 경우가 많지만, Java 명세 관점에서 주소 자체를 반환한다고 단정하기는 어렵습니다. 기본 동작은 객체 정체성에 기반한 해시값을 제공하는 쪽에 가깝고, 서로 다른 객체라면 값이 같더라도 다른 해시값이 나올 수 있습니다.
equals와 hashCode를 함께 구현하기
해결 방법은 equals에서 사용한 기준을 hashCode에서도 동일하게 사용하는 것입니다. email과 category로 같은 구독자인지 판단한다면, 해시값도 같은 두 필드로 계산해야 합니다.
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public class EqualsAndHashCodeExample {
public static void main(String[] args) {
Subscribe subscribe1 = new Subscribe("team.maeilmail@gmail.com", "backend");
Subscribe subscribe2 = new Subscribe("team.maeilmail@gmail.com", "backend");
HashSet<Subscribe> subscribes = new HashSet<>(List.of(subscribe1, subscribe2));
Map<Subscribe, Long> subscriberIds = new HashMap<>();
subscriberIds.put(subscribe1, 100L);
System.out.println("equals: " + subscribe1.equals(subscribe2));
System.out.println("hashCode 1: " + subscribe1.hashCode());
System.out.println("hashCode 2: " + subscribe2.hashCode());
System.out.println("set size: " + subscribes.size());
System.out.println("map get: " + subscriberIds.get(subscribe2));
}
static class Subscribe {
private final String email;
private final String category;
Subscribe(String email, String category) {
this.email = email;
this.category = category;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Subscribe other)) return false;
return Objects.equals(email, other.email)
&& Objects.equals(category, other.category);
}
@Override
public int hashCode() {
return Objects.hash(email, category);
}
}
}
이제 같은 값으로 만든 두 객체는 같은 해시값을 갖습니다. HashSet은 같은 버킷 안에서 equals 비교까지 진행할 수 있고, HashMap도 같은 값의 key로 저장된 값을 찾을 수 있습니다.
equals: true
hashCode 1: -1898125601
hashCode 2: -1898125601
set size: 1
map get: 100
여기서 해시값 숫자 자체는 중요하지 않습니다. 관찰해야 할 부분은 두 객체의 hashCode가 같고, HashSet 크기가 1이 되며, HashMap 조회가 100을 반환한다는 점입니다.
해시 충돌과 흔한 오해
equals와 hashCode의 관계에서 자주 생기는 오해가 두 가지 있습니다.
첫 번째는 equals가 true이면 컬렉션도 알아서 같은 객체로 처리할 것이라는 생각입니다. 해시 기반 컬렉션에서는 hashCode로 후보 위치를 먼저 고르기 때문에, equals만으로는 충분하지 않습니다.
두 번째는 hashCode가 같으면 같은 객체라는 생각입니다. 이것도 정확하지 않습니다. 서로 다른 객체가 같은 해시값을 가질 수 있습니다. 이를 해시 충돌이라고 부릅니다. 그래서 해시 기반 컬렉션은 hashCode로 후보를 좁힌 뒤, 마지막에는 equals로 실제 동등성을 확인합니다.
정리하면 계약은 한 방향으로 이해하는 편이 좋습니다. equals가 true인 두 객체는 같은 hashCode를 반환해야 합니다. 그러나 hashCode가 같다고 해서 equals가 반드시 true일 필요는 없습니다.
변경 가능한 필드를 기준으로 삼을 때의 위험
해시 기반 컬렉션에 들어가는 객체는 동등성 기준 필드가 바뀌지 않는 편이 좋습니다. HashSet에 객체를 넣을 때 계산된 해시 위치와, 나중에 찾을 때 계산된 해시 위치가 달라지면 컬렉션 안에 객체가 있어도 찾지 못하는 상황이 생길 수 있습니다.
예를 들어 email과 category를 기준으로 equals, hashCode를 구현했는데, 객체를 HashSet에 넣은 뒤 email이 바뀐다면 조회 결과가 어긋날 수 있습니다. 컬렉션은 예전 해시값을 기준으로 저장된 위치를 기억하고 있는데, 조회할 때는 바뀐 필드로 새 해시값을 계산하기 때문입니다.
그래서 해시 기반 컬렉션의 key나 원소로 사용할 객체는 가능하면 불변 객체로 설계하는 편이 낫습니다. 위 예시처럼 필드를 final로 두거나, 값 객체라면 record를 검토할 수 있습니다. record는 컴포넌트를 기준으로 equals, hashCode, toString을 자동으로 제공합니다. 단순한 값 객체라면 public record Subscribe(String email, String category) {}처럼 표현할 수 있습니다.
반대로 식별자 기반 엔티티, 변경 가능한 상태를 가진 객체, 프록시가 개입하는 모델에서는 어떤 필드를 동등성 기준으로 삼을지 별도로 검토해야 합니다. 모든 필드를 기계적으로 넣는 방식은 상태 변화와 컬렉션 동작을 함께 불안정하게 만들 수 있습니다.
자동 점검과 구성 예시
반복적으로 작성하는 코드이기 때문에 IDE의 생성 기능이나 정적 분석 도구를 함께 쓰면 누락을 줄일 수 있습니다. IntelliJ IDEA, Eclipse 같은 IDE는 equals와 hashCode를 함께 생성하는 기능을 제공합니다. 이때 먼저 정해야 할 것은 생성 방식이 아니라 동등성의 기준이 되는 필드입니다.
팀 단위에서는 Checkstyle 같은 도구로 equals만 오버라이드하고 hashCode를 빠뜨린 클래스를 점검할 수도 있습니다.
<module name="Checker">
<module name="TreeWalker">
<module name="EqualsHashCode"/>
</module>
</module>
이 설정은 equals를 오버라이드한 클래스가 hashCode도 함께 오버라이드하는지 확인하는 데 도움이 됩니다. 도구가 동등성 기준을 대신 정해 주지는 않지만, 계약 위반을 리뷰 전에 발견하는 안전망으로 사용할 수 있습니다.
디버깅할 때 볼 출력
이 문제가 의심될 때는 객체의 주요 필드, equals 결과, hashCode 결과, 컬렉션 크기, 조회 결과를 함께 남기면 원인을 좁히기 쉽습니다.
email=team.maeilmail@gmail.com, category=backend
equals=true
left.hashCode=617901222
right.hashCode=1159190947
hashSet.size=2
hashMap.get(right)=null
이런 출력이 보이면 equals 기준과 hashCode 기준이 같은 필드를 보고 있는지 먼저 확인해 볼 수 있습니다. equals는 true인데 hashCode가 다르면 해시 기반 컬렉션에서 같은 객체로 취급되지 않을 가능성이 큽니다.
반대로 hashCode가 같지만 equals가 false인 경우는 해시 충돌일 수 있습니다. 이때는 컬렉션이 같은 버킷 안에서 equals로 다시 비교하므로, 최종 동등성 판단은 여전히 equals가 맡습니다.
원문 참고
https://www.maeil-mail.kr/question/70
댓글
댓글 쓰기