equals와 hashCode를 함께 재정의하는 이유

한 줄 정의

equals()는 두 객체가 의미상 같은지 판단하는 메서드이고, hashCode()HashMap, HashSet 같은 hash 기반 컬렉션에서 객체를 찾기 위해 사용하는 값이다.

실무에서 왜 중요한가

백엔드에서는 객체를 자주 key로 쓰고, 중복 제거하고, 캐시하고, 테스트에서 비교한다.

동등성 기준이 틀리면 같은 값인데도 HashSet에서 중복 제거가 안 되거나, HashMap에 넣은 값을 다시 찾지 못한다. JPA Entity에서는 id 할당 시점, 프록시, 연관관계 때문에 더 쉽게 문제가 생긴다.

반드시 기억할 규칙

규칙의미
equals()가 true이면 hashCode()도 같아야 한다hash 컬렉션에서 같은 bucket을 찾기 위해 필요
hashCode()가 같아도 equals()가 true일 필요는 없다hash 충돌은 정상
hash 계산에 쓰는 값은 변경되지 않아야 한다저장 후 key를 찾지 못하는 문제를 막기 위해 필요

HashMapHashSet은 먼저 hashCode()로 위치를 찾고, 그 안에서 equals()로 최종 비교한다.

기본 동작

equals()를 재정의하지 않으면 보통 같은 인스턴스인지 비교한다.

User a = new User(1L, "kim");
User b = new User(1L, "kim");
 
System.out.println(a == b);      // false
System.out.println(a.equals(b)); // equals를 재정의하지 않으면 false

실무에서는 서로 다른 객체여도 같은 userId를 가진 사용자를 같은 사용자로 볼 수 있다. 이 기준을 코드로 표현하는 것이 equals()다.

좋은 값 객체 예시

값 객체나 key 객체는 비교 기준을 작고 안정적으로 잡는다.

import java.util.Objects;
 
public class UserKey {
    private final Long userId;
 
    public UserKey(Long userId) {
        this.userId = userId;
    }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof UserKey other)) {
            return false;
        }
        return Objects.equals(userId, other.userId);
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(userId);
    }
}

Java 16 이상에서 값 전달이 목적이라면 record가 더 간단하다.

public record UserKey(Long userId) {
}

단, record는 모든 컴포넌트를 기준으로 equals()hashCode()를 만든다. 일부 필드를 제외해야 한다면 직접 구현하거나 record가 맞는지 다시 확인한다.

가장 위험한 실수

hash 기반 컬렉션에 넣은 뒤, hashCode() 계산에 쓰이는 값을 바꾸면 안 된다.

public class MutableUserKey {
    private Long userId;
 
    public MutableUserKey(Long userId) {
        this.userId = userId;
    }
 
    public void changeUserId(Long userId) {
        this.userId = userId;
    }
 
    // equals/hashCode가 userId 기준이라고 가정
}
 
Set<MutableUserKey> keys = new HashSet<>();
 
MutableUserKey key = new MutableUserKey(1L);
keys.add(key);
 
key.changeUserId(2L);
 
keys.contains(key); // false가 될 수 있다.

객체는 원래 bucket에 저장되어 있는데, 변경된 값으로는 다른 bucket을 찾기 때문이다.

JPA Entity에서의 주의점

JPA Entity는 값 객체처럼 모든 필드로 equals()hashCode()를 만들면 위험하다.

주의해야 할 이유:

  • 저장 전에는 id가 없을 수 있다.
  • 같은 row를 가리키는 다른 객체나 프록시가 생길 수 있다.
  • 연관관계를 포함하면 lazy loading, 순환 참조, 성능 문제가 생길 수 있다.
  • Lombok @Data는 모든 필드 기준 메서드를 만들 수 있어 Entity에 부적합한 경우가 많다.

실무에서는 팀 규칙을 정해 id 기반 또는 비즈니스 key 기반으로 제한한다. 중요한 것은 “이 Entity를 무엇으로 같은 객체라고 볼 것인가”를 명확히 정하는 것이다.

실무 판단 기준

객체 종류권장 기준
값 객체변경 불가능한 핵심 필드 기준
HashMap keyString, Long, enum, record처럼 안정적인 key 우선
DTO꼭 필요할 때만 주요 필드 기준 비교
JPA Entity모든 필드 자동 생성 금지, id 또는 비즈니스 key 기준 검토
테스트 객체 비교필요하면 AssertJ recursive comparison 고려

자주 나는 실수

  • equals()만 재정의하고 hashCode()를 재정의하지 않는다.
  • mutable 필드를 equals()hashCode()에 포함한다.
  • HashSet에 넣은 뒤 비교 기준 필드를 변경한다.
  • 객체 내용을 ==로 비교한다.
  • Long, Integer 같은 wrapper type을 ==로 비교한다.
  • JPA Entity에 Lombok @Data를 붙인다.

확인 방법

  • 같은 값의 새 객체로 HashSet.contains()가 true인지 확인한다.
  • 같은 값의 새 key로 HashMap.get()이 되는지 확인한다.
  • 비교 기준 필드를 변경할 수 있는지 확인한다.
  • Entity라면 저장 전/후, 프록시, 연관관계 포함 여부를 확인한다.
  • Lombok을 쓰면 @EqualsAndHashCode에 포함되는 필드를 확인한다.

핵심 요약

equals()hashCode()는 객체의 동등성 기준을 정하는 메서드다. 특히 HashMap, HashSet, 캐시 key, 중복 제거에서 반드시 함께 봐야 한다.

값 객체는 변경 불가능한 핵심 필드 기준으로 구현한다. hash 기반 컬렉션에 넣은 뒤 비교 기준 값이 바뀌면 조회와 삭제가 깨질 수 있다.

JPA Entity는 id 할당 시점, 프록시, 연관관계 때문에 모든 필드 기준 자동 생성이 위험하다. Entity 동등성은 팀 규칙에 맞게 id 또는 비즈니스 key 기준으로 제한한다.

꼬리 질문

점검 퀴즈

아래 문항은 개념을 실제로 설명할 수 있는지 점검하기 위한 것이다. 선택지를 누르면 정답 여부와 이유가 표시된다.

OXequals가 true인 두 객체는 hashCode도 반드시 같아야 한다.

객관식mutable 객체를 HashMap key로 쓰면 위험한 이유는?

객관식JPA Entity에 Lombok @Data를 무심코 붙이면 생길 수 있는 문제는?

관련 문서