public class Person {
private final String name;
private final int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && Objects.equals(name, person.name);
}
}
위 클래스는 equals 는 재정의 했지만, hashCode 는 재정의 하지 않았다.
// hashCode 를 재정의 하지 않으면 실패
@Test
void 해시코드_검증() throws Exception {
// given
Map<Person, String> hashMap = new HashMap<>();
Person person1 = new Person("John", 25);
String expected = "Developer";
hashMap.put(person1, expected);
// when / then
assertThat(hashMap.get(person2)).isEqualTo(expected); // null
}
문제 원인 :
hashCode 를 재정의하지 않았으므로, HashMap은 person1 과 person2 를 서로 다른 키로 인식한다.
기본 Object 의 hashCode 는 메모리 주소를 기반으로 하기 때문에, 두 객체는 동일한 값(equals 가 true) 이어도 서로 다른 해시값을 가질 수 있다.
따라서, person2 로 값을 검색할 수 없다.
2. hashCode 의 규약
hashCode를 재정의할 때 반드시 다음 규약을 지켜야 한다.
equals가 true인 두 객체는 반드시 같은 hashCode를 반환 해야 한다.
이는 해시 기반 컬렉션의 동작을 보장하기 위해 필수적이다.
equals가 false인 두 객체가 같은 hashCode를 가질 수 있다.
해시 충돌은 허용됩니다. 단, 충돌이 많아지면 성능이 저하될 수 있으므로 가능한 한 피해야 한다.
객체 상태가 변경되지 않는 한, 동일한 객체에서 호출된 hashCode는 항상 같은 값을 반환해야 한다.
예를 들어, 불변 객체(immutable object)에서 쉽게 달성할 수 있다.
3. hashCode 를 구현하는 방법
방법 1. Objects.hash 사용 (권장)
java.util.Objects 클래스의 hash 메서드를 사용하면 간단하게 hashCode 를 구현 할 수 있다.
@Override
public int hashCode() {
return Objects.hash(name, age); // 주요 필드를 나열
}
방법 2. 직접 구현
// hashCode 를 직접 구현한 예제
@Override
public int hashCode() {
int result = 17; // 초기값으로 임의의 비숫자 상수 사용
result = 31 * result + name.hashCode(); // 중요한 필드 추가
result = 31 * result + Integer.hashCode(age); // 기본 타입 처리
return result;
}
31 을 사용하는 이유 :
소수(Prime number)로, 곱셈 과정에서 해시 값을 고르게 분포시켜 해시 충돌 가능성을 줄인다.
31은 곱셈 연산이 비트 연산으로 최적화 되기 쉽다. (31 * x = (x << 5) - x)
방법 3. 캐싱된 해시 값 사용
불변 객체(Immutable object) 에서는 해시 값을 한 번 계산하여 캐싱해두는 방식이 성능 최적화에 유리 할 수 있다.
public class Person {
private final String name;
private final int age;
private int cachedHashCode;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public int hashCode() {
if (cachedHashCode == 0) {
cachedHashCode = Objects.hash(name, age);
}
return cachedHashCode;
}
}
4. 올바르게 정의한 예제
import java.util.Objects;
public class Person {
private final String name;
private final int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && name.equals(person.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
테스트 코드
@Test
void 해시코드_검증() throws Exception {
// given
Map<Person, String> hashMap = new HashMap<>();
Person person1 = new Person("John", 25);
String expected = "Developer";
hashMap.put(person1, expected);
// when / then
assertThat(hashMap.get(person2)).isEqualTo(expected); // true
}
5. 주의사항
hashCode의 주요 필드:
equals에서 비교에 사용되는 모든 필드를 포함해야 합니다.
가변 객체에서의 주의점:
hashCode와 equals가 사용하는 필드가 변경되면 컬렉션에서의 동작이 불안정해질 수 있습니다. 가변 객체는 해시 기반 컬렉션에서 사용을 피하거나 불변 객체를 사용하는 것이 좋습니다.
테스트 필요:
hashCode와 equals가 올바르게 구현되었는지 단위 테스트를 작성하는 것이 좋습니다.
6. 결론
equals 와 hashCode 는 기본적으로 재정의 해야 한다.
7. 느낀점
관성적으로, 기계적으로 붙이던 equals 와 hashCode 의 동작 매커니즘을 이해하니, 논리적 동등성에 대해 한번 더 생각할 수 있는 기회가 된 것 같다.
앞으로는 equals 와 hashCode 를 재정의 할 때 유의미한 필드들만 엄선해서 정의해야겠다.