자바를 공부하다 보면 "equals를 재정의하면 hashCode도 반드시 재정의해야 한다"는 말을 듣는다. 이유는 대충 "HashMap이 이상해진다"고들 하는데, 정확히 뭐가 어떻게 이상해지는지는 잘 와닿지 않았다. 그래서 직접 코드를 짜서 단계별로 확인해봤다.
hashCode() : 객체를 정수 하나로 요약한 값이다. 아무것도 안 건드리면 주소를 기반으로 만들어진다.equals() : 두 객체가 "같은지" 판단한다. 아무것도 안 건드리면 주소가 같을 때만 true다.HashSet, HashMap처럼 이름에 Hash가 붙은 자료구조는 이 둘을 순서대로 쓴다.
hashCode()로 어느 칸(버킷)에 넣을지 정하고equals()로 진짜 같은지 확인한다도서관에 비유하면 hashCode는 "제목 첫 글자로 서가 고르기", equals는 "그 서가에서 제목 전체 비교하기"다. 이 시스템이 제대로 돌아가려면 같은 책은 반드시 같은 서가에 있어야 한다는 전제가 필요하다.
이름과 나이를 가진 Person 클래스를 만들고, 내용이 똑같은 객체 두 개를 HashSet에 넣어봤다.
Person a = new Person("민수", 20);
Person b = new Person("민수", 20);
Set<Person> set = new HashSet<>();
set.add(a);
set.add(b);
4가지 경우로 나눠서 돌렸다.
a.hashCode() = 2018699554
b.hashCode() = 1846274136
a.equals(b) = false
HashSet 크기 = 2
contains(new Person("민수", 20)) = false
예상대로다. 자바 기본 equals는 주소 비교라서 내용이 같아도 다른 객체다. 해시값도 다르고, Set에도 2개가 들어간다.
이름과 나이가 같으면 같은 사람으로 보게 equals를 고쳤다.
@Override
public boolean equals(Object o) {
if (this == o) return true; // 자기 자신이면 true
if (!(o instanceof Person)) return false; // Person 아니면 false (null 포함)
Person p = (Person) o; // Person으로 형변환
return age == p.age && name.equals(p.name); // 필드 비교
}
처음엔 이 코드도 잘 안 읽혔는데, 위에서부터 "자기 자신인가 → 타입이 맞나 → 캐스팅 → 필드 비교" 순서라고 이해하니까 괜찮았다. 앞의 두 줄이 없으면 null이나 다른 타입이 들어왔을 때 예외가 터진다.
이제 실행하면:
a.hashCode() = 2018699554
b.hashCode() = 1846274136
a.equals(b) = true
HashSet 크기 = 2
contains(new Person("민수", 20)) = false
map.get(new Person("민수", 20)) = null
a.equals(b)는 분명 true인데 Set 크기는 여전히 2다. 심지어 contains로 찾으면 없다고 하고, HashMap에 넣은 값도 get으로 못 꺼낸다.
이유는 해시값을 보면 알 수 있다. hashCode는 안 고쳤으니까 여전히 주소 기반이고, 두 객체의 해시가 다르다. HashSet 입장에서는 a와 b가 서로 다른 칸에 들어간 거다. equals 비교는 같은 칸 안에서만 하니까, 애초에 둘을 비교할 기회조차 없었다.
반대로 hashCode만 고쳐봤다. equals는 기본 그대로 뒀다.
@Override
public int hashCode() {
return Objects.hash(name, age);
}
a.hashCode() = 47788473
b.hashCode() = 47788473
a.equals(b) = false
HashSet 크기 = 2
contains(new Person("민수", 20)) = false
이번엔 해시가 같아졌다. 같은 칸까지는 갔다는 뜻이다. 그런데 칸 안에서 equals가 "다르다"고 하니까 결국 둘 다 저장된다. 원하는 결과가 아니다.
a.hashCode() = 47788473
b.hashCode() = 47788473
a.equals(b) = true
HashSet 크기 = 1
contains(new Person("민수", 20)) = true
map.get(new Person("민수", 20)) = 학생
드디어 의도대로 동작한다. 같은 칸으로 가고, 칸 안에서 같다고 판정되고, 그래서 중복 없이 1개만 저장된다. contains도 map.get도 정상이다.
| hashCode 같음 | equals | Set 크기 | 찾기 | |
|---|---|---|---|---|
| 아무것도 안 함 | X | false | 2 | 실패 |
| equals만 | X | true | 2 | 실패 |
| hashCode만 | O | false | 2 | 실패 |
| 둘 다 | O | true | 1 | 성공 |
자바가 정해둔 규칙은 이거다.
equals가 true인 두 객체는 hashCode도 반드시 같아야 한다.
2단계가 딱 이 규칙을 어긴 상태다. equals는 true인데 hashCode가 다르니까 Hash 자료구조가 전부 오작동한다.
반대 방향(hashCode가 같으면 equals도 true여야 한다)은 강제가 아니다. 3단계처럼 해시가 같아도 equals가 false인 건 규칙 위반이 아니고, 그냥 같은 칸에 여러 개가 들어가서 조금 느려질 뿐이다. 그래도 hashCode를 재정의하는 이유가 결국 "내용이 같으면 같은 객체로 취급하고 싶어서"니까, 실질적으로 둘은 항상 세트로 움직인다고 보면 된다. IDE나 롬복(@EqualsAndHashCode)이 둘을 한 번에 만들어주는 것도 그래서다.
"둘 다 재정의해라"는 말은 외우고 있었지만, 왜인지는 2단계 출력 결과를 눈으로 보고 나서야 납득이 됐다. equals는 true인데 contains는 false인 화면을 보면 어디서 끊기는지가 바로 보인다.