1. 개요
equals 만 오버라이드하고 hashCode 는 그대로 둔 채 HashSet 에 넣으면 분명 같은 값인데 못 찾는 일이 생긴다. 처음 겪으면 equals 를 잘못 짠 줄 알고 한참 들여다보게 된다. 원인은 다른 데 있다. 이 글은 equals 와 hashCode 를 왜 항상 같이 재정의해야 하는지 예제로 짚는다.
2. 핵심 내용
2-1. 기본 동작과 계약
Object 의 기본 equals 는 == 와 같다(참조 비교). 기본 hashCode 는 객체의 메모리 주소 기반 값이다. 자바 명세가 정한 계약은 이거다: a.equals(b) 가 true 면 a.hashCode() == b.hashCode() 도 반드시 true 여야 한다. 역은 성립하지 않아도 된다(다른 객체가 같은 해시값을 가질 수 있다).
2-2. 계약을 어기면 HashSet 이 이렇게 깨진다
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
// hashCode 는 재정의 안 함
}
Set<Point> set = new HashSet<>();
set.add(new Point(1, 2));
System.out.println(set.contains(new Point(1, 2))); // false!equals 로만 보면 두 Point(1, 2) 는 같다. 하지만 HashSet 은 먼저 hashCode() 로 버킷을 찾고, 그 버킷 안에서만 equals 로 비교한다. hashCode 를 재정의하지 않았으니 두 객체는 기본 해시값(메모리 주소 기반)이 다르고, 애초에 다른 버킷을 찾아가서 equals 비교까지 가지도 못한다.
2-3. 고치기
@Override
public int hashCode() {
return Objects.hash(x, y);
}이렇게 equals 에서 비교에 쓰는 필드를 그대로 hashCode 계산에도 써야 한다. IDE 의 자동 생성 기능이나 Lombok 의 @EqualsAndHashCode 를 쓰면 이 둘을 항상 짝으로 만들어줘서 실수를 줄인다. Java 16 부터의 record 는 아예 필드 기반으로 둘 다 자동 생성한다.
record Point(int x, int y) {} // equals, hashCode 자동 생성코틀린은 아예 언어 차원에서 한 발 더 나가서, data class 로 선언만 하면 equals/hashCode/toString/copy 를 컴파일러가 통째로 만들어준다. 자동 생성이 뭘 대신 해주는지, 그리고 거기서도 여전히 남는 함정이 뭔지는 data class 편에 정리했다.
2-4. == 와 equals 는 별개다
여기서 헷갈리기 쉬운 게 == 다. == 는 항상 참조 비교이고, equals 를 아무리 잘 오버라이드해도 == 의 동작은 안 바뀐다. String 처럼 리터럴 풀을 쓰는 특수한 경우 때문에 == 가 가끔 true 를 주기도 하는데, 그 얘기는 String 편 에서 다룬다.
3. 마무리
요약
a.equals(b)가 true 면a.hashCode() == b.hashCode()도 반드시 true 여야 한다.HashSet/HashMap은 먼저hashCode로 버킷을 찾고 그 안에서만equals로 비교하므로,hashCode를 빼먹으면 논리적으로 같은 객체를 못 찾는다.equals를 재정의하면hashCode도 같은 필드 기준으로 반드시 같이 재정의한다.record나 Lombok@EqualsAndHashCode를 쓰면 이 실수 자체를 막을 수 있다.