|

자바 equals와 hashCode: 왜 같이 구현해야 할까

자바 equals와 hashCode, HashSet과 HashMap 동작을 설명하는 대표 이미지
동등성 규칙과 해시 기준이 어긋나면 컬렉션 동작이 꼬인다

자바 equals와 hashCode는 따로 외우는 문법 항목처럼 보이지만, 실제로는 HashSet 중복 제거, HashMap 조회, contains 동작에서 같이 드러나는 규칙입니다. 이 글에서는 깨지는 예시부터 보여준 뒤, 왜 둘을 같은 기준으로 구현해야 하는지 실전 감각으로 정리하겠습니다.


먼저 깨지는 장면부터 보자

아래 코드는 이메일이 같으면 같은 사용자라고 보고 싶어서 equals()만 재정의한 예시입니다. 많은 사람이 HashSet 크기가 1이 되길 기대하지만, 실제로는 2가 나올 수 있습니다. 즉 같다고 정의했는데도 중복 제거가 실패하는 장면입니다.

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

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)) return false;
        User user = (User) o;
        return Objects.equals(email, user.email);
    }
}

public class Main {
    public static void main(String[] args) {
        Set<User> users = new HashSet<>();
        users.add(new User("a@test.com"));
        users.add(new User("a@test.com"));

        System.out.println(users.size());
    }
}

왜 이런 일이 생길까요. HashSet은 먼저 해시 버킷을 찾고, 그다음 같은 버킷 안에서 동등성 비교를 합니다. equals()만 바꾸고 hashCode()를 맞추지 않으면 같은 값처럼 보이는 객체가 서로 다른 버킷으로 흩어져 만나지 못할 수 있습니다.


HashMap도 똑같이 꼬인다

이번에는 HashMap입니다. 키를 넣을 때와 찾을 때 같은 이메일을 썼는데도 get()이 null을 돌려주거나 containsKey()가 false가 될 수 있습니다. 실무에서는 이 장면이 더 아프게 느껴집니다.

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

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)) return false;
        User user = (User) o;
        return Objects.equals(email, user.email);
    }
}

public class Main {
    public static void main(String[] args) {
        Map<User, String> map = new HashMap<>();
        map.put(new User("a@test.com"), "admin");

        System.out.println(map.get(new User("a@test.com")));
        System.out.println(map.containsKey(new User("a@test.com")));
    }
}
  • equals()는 두 객체를 같은 것으로 볼지 정한다
  • hashCode()는 해시 기반 컬렉션이 어디서 찾기 시작할지 정한다
  • 둘의 기준이 어긋나면 HashSet과 HashMap은 기대와 다르게 동작할 수 있다

같다고 판단하는 기준이 바뀌었다면 해시 코드도 같은 기준으로 같이 바뀌어야 한다는 말을 여기서 기억하면 됩니다.


contract 핵심만 보자

Oracle Object 문서 기준으로 equals()는 reflexive, symmetric, transitive, consistent를 만족해야 하고, null과 비교하면 false여야 합니다. 그리고 equals()를 재정의하면 일반적으로 hashCode()도 함께 재정의해야 합니다.

  • 같은 객체 상태라면 같은 실행 중 hashCode() 결과는 일관돼야 한다
  • equals()가 true인 두 객체는 반드시 같은 hashCode()를 가져야 한다
  • 반대로 해시 코드가 같다고 equals()도 true일 필요는 없다

즉, 같으면 해시도 같아야 한다는 방향만 확실히 기억하면 큰 실수는 많이 줄어듭니다.


안전한 기본 구현

값 객체라면 보통 아래처럼 같은 필드를 기준으로 equals()와 hashCode()를 함께 구현하는 것이 가장 무난합니다. Objects.equals()는 null-safe 비교를, Objects.hash()는 여러 필드를 이용한 기본 해시 구현을 도와줍니다.

import java.util.Objects;

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)) return false;
        User user = (User) o;
        return Objects.equals(email, user.email);
    }

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

value object와 entity는 다르게 봐야 한다

이제 구현보다 더 중요한 질문이 남습니다. 무엇을 같은 객체로 볼 것인가입니다. 돈, 좌표, 기간, 이메일 주소처럼 값 자체가 의미인 객체는 보통 필드 값이 같으면 같은 객체로 보는 것이 자연스럽습니다. 이런 타입은 set 중복 제거와 map 키 비교도 값 기준이 잘 맞습니다.

반면 회원, 주문, 게시글처럼 생명주기와 식별자가 중요한 entity는 다릅니다. 이름이 같다고 같은 회원은 아니고, 총액이 같다고 같은 주문도 아닙니다. 그래서 entity는 값이 아니라 식별자와 생명주기 기준을 먼저 생각해야 합니다.

value object는 값이 곧 정체성인 경우가 많고, entity는 식별자와 생명주기가 정체성인 경우가 많다는 직관을 먼저 잡아 두면 설계가 훨씬 덜 흔들립니다.


mutable 필드는 더 위험하다

equals()와 hashCode()를 잘 구현해도, 그 기준이 되는 필드가 나중에 바뀌면 또 문제가 생깁니다. set이나 map에 넣어 둔 뒤 해시 기준 필드가 변하면 컬렉션 내부 위치와 현재 해시 코드가 어긋날 수 있기 때문입니다.

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

class User {
    private String email;

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

    public void changeEmail(String email) {
        this.email = email;
    }

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

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

public class Main {
    public static void main(String[] args) {
        Set<User> users = new HashSet<>();
        User user = new User("a@test.com");
        users.add(user);

        user.changeEmail("b@test.com");

        System.out.println(users.contains(user));
    }
}
  • 동등성 기준 필드는 가능하면 불변에 가깝게 두기
  • set/map에 넣은 뒤 그 기준 필드를 바꾸지 않기
  • key로 쓸 타입은 안정적인 식별자를 쓰기

자주 깨지는 구현

  • equals()만 재정의하고 hashCode()는 안 바꾸기
  • equals()와 hashCode()가 서로 다른 필드를 보기
  • mutable 필드를 동등성 기준에 넣기
  • entity를 값 객체처럼 단순 비교하기

이 네 가지는 대부분의 컬렉션 버그를 설명해 줍니다. 특히 contains(), containsKey(), 중복 제거가 이상하면 이 목록부터 확인하면 됩니다.


실전 기준 정리

  • 이메일 주소, 좌표, 금액+통화, 기간 범위 같은 value object는 값 기준 equals/hashCode가 잘 맞는다
  • 회원, 주문, 게시글 같은 entity는 식별자와 생명주기를 먼저 생각해야 한다
  • 동등성 기준을 정했다면 equals()와 hashCode()는 반드시 같은 기준으로 맞춘다

한 줄로 줄이면 이것입니다. 값으로 같다고 말하고 싶다면 equals와 hashCode도 같은 기준으로 같이 움직여야 합니다.

관련해서 비교 연산 자체가 헷갈린다면 자바 equals와 == 차이 글도 같이 보면 좋습니다. 그리고 해시 기반 컬렉션 선택 감각을 더 넓히고 싶다면 자바 HashMap과 TreeMap 차이 글, 정렬 기준이 객체 설계와 어떻게 연결되는지 보고 싶다면 자바 Comparable과 Comparator 차이 글도 이어서 읽어보면 도움이 됩니다.

공식 문서는 Oracle Java SE 21 Object API, HashSet API, HashMap API, Objects API를 참고했습니다.

함께보면 좋은 글