분명히 HashMap에 put했는데 똑같은 키로 get했더니 null이 나오는 황당한 경험, 자바 좀 만져보신 분이라면 한 번쯤은 겪어보셨을 겁니다. 저도 신입 시절에 새로 만든 도메인 클래스를 HashMap 키로 썼다가 같은 증상으로 한 시간을 헤맸어요. 원인은 equals()만 오버라이드하고 hashCode()는 빼먹은 거였습니다. 오늘은 이 둘이 왜 항상 같이 다녀야 하는지, 안 그러면 뭐가 깨지는지 들여다보겠습니다.
개념 / 원리
equals()는 "두 객체가 의미상 같은가?"를 판단하고, hashCode()는 "이 객체를 해시 기반 자료구조에 넣을 때 어느 버킷으로 보낼 것인가?"를 결정합니다. HashMap, HashSet 같은 컬렉션은 다음 순서로 동작해요.
hashCode()로 버킷 위치를 찾는다.- 그 버킷 안에서
equals()로 진짜 같은 객체인지 비교한다.
여기서 자바가 강제하는 계약(Contract) 이 있습니다.
equals()로 같다고 판단된 두 객체는 반드시 같은hashCode()를 반환해야 한다.
반대(hashCode()가 같다고 equals()가 같을 필요)는 없습니다. 해시 충돌은 자연스러운 일이거든요. 하지만 위 방향은 어기는 순간 컬렉션이 망가집니다. equals로는 같다고 하는데 hashCode가 다르면, 1단계에서 아예 다른 버킷을 뒤지니까 영원히 못 찾는 거죠.
실전 예제
equals만 오버라이드한 망가진 케이스부터 보겠습니다.
public class User {
private final Long id;
private final String email;
public User(Long id, String email) {
this.id = id;
this.email = email;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User u = (User) o;
return id.equals(u.id);
}
// hashCode() 오버라이드 안 함!
}
이 클래스를 HashMap 키로 쓰면 이렇게 됩니다.
Map<User, String> map = new HashMap<>();
User key1 = new User(1L, "jaemin@example.com");
User key2 = new User(1L, "jaemin@example.com");
map.put(key1, "관리자");
System.out.println(key1.equals(key2)); // true
System.out.println(map.get(key2)); // null ← 망가짐
System.out.println(map.containsKey(key2)); // false
equals는 true를 돌려주는데 hashCode는 Object 기본 구현(객체 주소 기반)을 그대로 쓰기 때문에 두 객체의 해시값이 다릅니다. 그래서 다른 버킷을 뒤지고 못 찾는 거예요.
제대로 짠 버전은 이렇습니다.
import java.util.Objects;
public class User {
private final Long id;
private final String email;
public User(Long id, String email) {
this.id = id;
this.email = email;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User u = (User) o;
return Objects.equals(id, u.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
Objects.hash(...)는 가변 인자로 받은 필드들의 해시를 적당히 섞어서 int를 만들어줍니다. equals에 쓴 필드와 hashCode에 쓰는 필드를 똑같이 맞추는 게 핵심이에요.
Java 14부터는 record 클래스를 쓰면 이 모든 걸 자동으로 만들어 줍니다.
public record User(Long id, String email) {}
위 한 줄로 생성자, 게터, equals, hashCode, toString이 다 만들어집니다. 모든 필드를 비교에 포함시키죠. 불변 객체에는 record가 훨씬 깔끔합니다.
Lombok을 쓰는 프로젝트라면 @EqualsAndHashCode로 자동 생성할 수 있고, 특정 필드만 포함시키려면 @EqualsAndHashCode.Include 어노테이션을 붙이면 됩니다.
주의할 점
가장 흔한 실수는 위에서 본 hashCode 빼먹기이지만, 그 외에도 함정이 몇 가지 있습니다.
첫째, 가변 필드를 해시에 포함하면 안 됩니다. HashMap에 넣은 뒤에 그 필드 값을 바꾸면 해시값이 변해서 다시는 못 찾게 됩니다. 키로 쓸 객체는 가능한 한 불변으로 만들거나, 적어도 키에 쓰는 필드는 final로 두세요.
둘째, 상속 관계에서 instanceof로 비교할지 getClass()로 비교할지 결정해야 합니다. instanceof를 쓰면 자식 클래스 객체도 부모 객체와 같다고 판단될 수 있어 대칭성(symmetry)이 깨지기 쉽습니다. 안전하게 가려면 getClass() != o.getClass() 체크를 넣거나, 아예 클래스를 final로 만드는 게 좋습니다.
셋째, IDE 자동 생성을 너무 믿지 말기입니다. IntelliJ나 Eclipse 둘 다 Generate equals() and hashCode() 기능을 제공하는데요, 어떤 필드를 포함할지 묻는 단계가 있습니다. 거기서 자동으로 모든 필드를 다 체크해 두는데, 비즈니스적으로 식별자가 아닌 필드(예: createdAt, lastLoginAt)까지 들어가면 의도하지 않은 비교가 일어납니다. 식별 필드만 골라서 체크하세요.
저는 예전에 User 객체에 lastLoginAt을 equals에 포함시켜 놨다가, 같은 사용자인데 로그인 시각이 다르면 다른 사용자로 취급되어서 캐시가 폭주한 적이 있습니다. 식별성과 동등성을 헷갈리지 마세요.
마무리
정리하면 세 줄입니다.
equals를 오버라이드하면hashCode도 반드시 같이. 안 그러면 HashMap이 망가진다.- equals에 쓴 필드와 hashCode에 쓰는 필드는 똑같이 맞춰야 한다.
- 불변 데이터엔
record나 Lombok@EqualsAndHashCode로 자동 생성이 가장 안전하다.
'IT·리눅스 > Java' 카테고리의 다른 글
| Java Stream API 실전 - map, filter, collect 제대로 쓰기 (0) | 2026.07.10 |
|---|---|
| java.lang.OutOfMemoryError: Java heap space - 원인 진단부터 해결까지 (0) | 2026.07.05 |
| @Transactional의 함정 - 프록시와 self-invocation (0) | 2026.06.02 |
| [Java] JVM 메모리 누수 - 원인과 진단 도구 한 번에 정리 (0) | 2026.05.12 |
| [Java] G1 GC 내부 동작 - Region과 Garbage First의 정체 (0) | 2026.05.11 |