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 를 쓰면 이 실수 자체를 막을 수 있다.