|

자바 equals hashCode 규약: 왜 같이 구현해야 컬렉션 버그가 줄어들까

자바 equals hashCode 규약: 왜 같이 구현해야 컬렉션 버그가 줄어들까
equals와 hashCode는 객체가 같은지 판단하는 기준과 해시 컬렉션의 위치 찾기 기준을 함께 맞추는 계약입니다.

자바 equals hashCode 문제는 단순히 메서드 두 개를 같이 생성하라는 암기 주제가 아닙니다. HashSet에 중복이 들어가거나 HashMap에서 분명 넣은 key를 찾지 못하는 버그가 여기서 시작될 수 있습니다.

핵심은 equals가 같은 객체라고 말한다면 hashCode도 같은 위치를 가리켜야 한다는 계약입니다. 이 글은 Java Object, HashMap, HashSet 공식 문서를 기준으로 왜 두 메서드를 함께 봐야 하는지 정리합니다.

자바 equals hashCode 구현 기준 요약 카드
equals가 같다고 판단하는 두 객체는 같은 hashCode를 반환해야 합니다.

자바 equals hashCode를 한 문장으로 정리하면

`equals`는 두 객체를 같은 값으로 볼지 정하는 메서드입니다. `hashCode`는 hash 기반 자료구조가 객체를 어느 위치에서 찾을지 정할 때 쓰는 정수 값입니다.

둘은 역할이 다르지만 따로 움직이면 안 됩니다. `equals`가 같다고 판단한 두 객체가 서로 다른 hashCode를 반환하면 HashMap과 HashSet은 같은 객체를 다른 칸에서 찾으려 할 수 있습니다.

class User {
    private final String email;

    User(String email) {
        this.email = email;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User other)) return false;
        return email.equals(other.email);
    }

    // hashCode를 구현하지 않으면 Object의 기본 hashCode가 남는다
}

HashSet에서 중복이 들어가는 이유

HashSet은 먼저 hashCode로 후보 위치를 찾고, 그 안에서 equals로 같은 객체인지 확인합니다. 그런데 equals만 재정의하고 hashCode를 그대로 두면 값은 같아도 서로 다른 hash bucket으로 갈 수 있습니다.

Set<User> users = new HashSet<>();
users.add(new User("a@b.com"));
users.add(new User("a@b.com"));

System.out.println(users.size()); // 기대와 다르게 2가 될 수 있다

개발자는 이메일이 같으니 같은 사용자라고 생각하지만, HashSet은 hashCode 기준 위치가 다르면 같은 후보군으로 보지 못합니다. 이때 중복 제거가 실패합니다.

HashMap에서 put한 key를 못 찾는 이유

HashMap도 같은 원리입니다. key를 넣을 때의 hashCode와 찾을 때의 hashCode가 다르면, Map은 다른 위치를 뒤지게 됩니다.

Map<User, String> map = new HashMap<>();
map.put(new User("a@b.com"), "admin");

System.out.println(map.get(new User("a@b.com"))); // null이 될 수 있다

이 버그는 테스트에서 바로 드러나지 않을 수 있습니다. 같은 인스턴스로 조회하면 동작하지만, 같은 값을 가진 새 인스턴스로 조회할 때 문제가 드러나기 때문입니다.

올바른 구현은 같은 필드를 같이 쓴다

equals가 email을 기준으로 비교한다면 hashCode도 email을 기준으로 만들어야 합니다. 비교 기준이 되는 필드와 hash 기준이 되는 필드를 맞추는 것이 핵심입니다.

@Override
public int hashCode() {
    return Objects.hash(email);
}

여러 필드를 비교 기준으로 삼는다면 hashCode도 같은 필드 묶음을 사용해야 합니다. IDE의 generate 기능이나 record를 쓰면 단순 실수를 줄일 수 있습니다.

record를 쓰면 왜 편할까

Java record는 값 운반 객체를 만들 때 구성 요소를 기준으로 equals, hashCode, toString을 자동으로 제공합니다. 단순 DTO나 key 객체에서는 record가 자연스러운 선택이 될 수 있습니다.

public record UserKey(String email) {
}

Map<UserKey, String> roles = new HashMap<>();
roles.put(new UserKey("a@b.com"), "admin");
System.out.println(roles.get(new UserKey("a@b.com"))); // admin

다만 record가 모든 도메인 객체의 정답은 아닙니다. 값 비교가 자연스러운 작은 객체인지, 불변성이 맞는지, 프레임워크 제약이 없는지 함께 봐야 합니다.

mutable key는 특히 조심해야 한다

HashMap key로 쓴 객체의 비교 기준 필드를 나중에 바꾸면 문제가 커집니다. 넣을 때 hashCode와 찾을 때 hashCode가 달라질 수 있기 때문입니다.

User user = new User("a@b.com");
map.put(user, "admin");

user.changeEmail("changed@b.com");
System.out.println(map.get(user)); // 찾지 못할 수 있다

HashMap key나 HashSet 원소의 비교 기준 필드는 가능한 한 불변으로 두는 편이 안전합니다.

실무 체크리스트

  1. equals를 재정의했다면 hashCode도 함께 재정의했는지 확인한다
  2. 두 메서드가 같은 의미의 필드 묶음을 쓰는지 확인한다
  3. HashMap key로 쓸 객체는 가능한 한 불변으로 만든다
  4. 값 객체라면 record나 IDE 생성 코드를 우선 검토한다
  5. 테스트는 같은 인스턴스가 아니라 같은 값을 가진 다른 인스턴스로 조회해 본다

정리

equals hashCode를 이해할 때는 문법 이름보다 문제를 일으키는 지점을 먼저 봐야 합니다. 오늘 글의 핵심은 겉으로 비슷해 보이는 코드가 어떤 기준으로 다르게 동작하는지 분리해 보는 것입니다.

함께 보면 좋은 내부 글은 자바 record와 DTO 차이, HashSet vs TreeSet 차이 정리, 자바 record와 class 차이입니다. 외부 기준은 Java SE Docs – Object.equals, Java SE Docs – Object.hashCode, Java SE Docs – HashMap를 확인했습니다.

함께보면 좋은 글