
자바 equals와 hashCode는 따로 배우면 금방 아는 것 같지만, 같이 생각하지 않으면 HashMap이나 HashSet에서 금방 이상한 동작을 만납니다. 이 주제의 핵심은 문법보다 객체 비교 계약이 컬렉션 동작에 직접 연결된다는 점입니다.
이번 글에서는 Object의 equals/hashCode 계약을 먼저 짚고, 왜 둘을 같이 구현해야 하는지, 실무에서 어떤 문제가 생기는지 예시 중심으로 설명하겠습니다.
기준이 되는 계약은 Oracle Java SE 21의 Object.equals / hashCode 문서가 가장 분명합니다. 실무에서는 이 계약을 HashMap, HashSet 동작과 연결해서 이해하는 편이 훨씬 잘 남습니다.
equals는 무엇을 약속할까
Oracle 문서에 따르면 equals는 반사성, 대칭성, 추이성, 일관성을 만족하는 equivalence relation으로 동작해야 합니다. 쉽게 말해 같은 객체를 비교할 때는 일관된 기준이 있어야 한다는 뜻입니다.
즉 equals는 “두 객체를 같은 것으로 볼 것인가”를 정하는 계약입니다.
hashCode는 왜 따로 필요할까
hashCode는 해시 기반 컬렉션이 객체를 빠르게 찾기 위해 쓰는 값입니다. HashMap과 HashSet은 먼저 hashCode로 대략적인 위치를 찾고, 그 다음 필요하면 equals로 최종 비교합니다.
그래서 equals만 맞고 hashCode가 어긋나면, 논리적으로는 같은 객체인데 컬렉션 안에서는 다른 칸에 들어가 버릴 수 있습니다.
왜 둘을 같이 구현해야 할까
Oracle 문서는 두 객체가 equals로 같다면 hashCode도 같은 값을 돌려야 한다고 분명히 말합니다. 이 규칙이 깨지면 해시 컬렉션의 전제가 무너집니다.
class User {
private final String email;
User(String email) {
this.email = email;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof User other)) return false;
return email.equals(other.email);
}
// hashCode를 구현하지 않으면 문제가 생길 수 있다
}이 상태에서 HashSet에 넣으면, email이 같은 두 User를 “같다”고 보면서도 해시 버킷은 다르게 처리될 수 있습니다.
HashMap에서 이상해지는 대표 장면
- put은 했는데 containsKey가 기대와 다르게 동작한다
- 중복이 없어야 할 Set에 논리적으로 같은 값이 들어간다
- 키를 수정한 뒤 다시 찾지 못하는 문제가 생긴다
특히 가변 필드를 기준으로 equals/hashCode를 만들고, 그 필드가 나중에 바뀌면 더 위험합니다. 이미 들어간 버킷 위치와 논리적 비교 기준이 어긋날 수 있기 때문입니다.
실무에서 자주 하는 실수
- equals만 재정의하고 hashCode는 그대로 두는 것
- 가변 필드를 기준으로 hashCode를 만드는 것
- IDE가 생성한 코드를 의미 없이 복붙하고 기준을 점검하지 않는 것
핵심은 코드 생성 여부가 아니라, 무엇을 같은 객체로 볼 것인지 기준을 먼저 정하는 일입니다.
기본 설명은 이미 발행한 equals/hashCode 글과도 연결되고, API 설계 감각은 Java Optional 글과도 결이 맞닿습니다.
마무리
자바 equals와 hashCode를 같이 생각해야 하는 이유는 아주 단순합니다. equals는 논리적 동일성을 정하고, hashCode는 해시 컬렉션이 그 동일성을 효율적으로 다루게 돕기 때문입니다.
둘 중 하나만 맞으면 충분한 것이 아니라, 둘이 같은 기준을 공유해야 컬렉션에서도 기대한 대로 동작합니다.