[Effective Java] equals를 재정의하려거든 hashCode도 재정의하라

들어가며

equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다. 그렇지 않다면 HashMap이나 HashSet같은 컬렉션의 원소로 인스턴스를 사용할 때 문제를 일으키게 된다. 다음은 Object 명세서에서 발최한 규약이다.

equals 비교에 사용되는 정보가 변경되지 않았다면 어플리케이션이 실행되는 동안 그 객체의 hashCode 메소드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다. 단, 어플리케이션을 다시 실행한다면 이 값이 달라져도 상관 없다.
equals(Object)가 두 객체를 같다고 판단했다면 두 객체의 hashCode는 똑같은 값을 반환해야 한다.
equals(Object)가 두 객체를 다르다고 판단했더라도 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 단 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

hashCode 재정의를 잘못하게 되면 크게 문제가 되는 조항은 두번째이다. 즉 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.

1
2
3
4
@Override
public int hashCode() {
return 42;
}

위와 같은 해시코드는 동치인 모든 객체에서 같인 해시코드를 반환하나 좋지 않다. 모든 객체에서 같은 해시코드를 반환하니까 말이다. 결국 이는 해시테이블 버킷 하나에만 담겨 연결 리스트처럼 동작한다.

다음은 좋은 hashCode를 작성하는 요령이다.

  • A. int 변수 result를 선언한 후 값 c로 초기화한다. 이때 c는 해당 객체의 첫번째 핵심 필드를 단계 B-1 방식으로 계산한 해시코드다. (여기서 핵심 필드란 equals 비교에 사용되는 필드를 말한다.)
  • B. 해당 객체의 나머지 핵심 필드 f 각각에 대해 다음 작업을 수행한다.
    • B-1. 해당 필드의 해시코드 c를 계산한다.
      • B-1-1. 기본 타입 필드라면 Type.hashCode(f)를 수행한다.
      • B-1-2. 참조 타입 필드이면서 이 클래스의 equals 메소드가 이 필드의 equals를 재귀적으로 호출해 비교한다면 이 필드의 hashCode를 재귀적으로 호출한다. 계산이 더 복잡해질 것 같으면 이 필드의 표준형을 만들어 표준형의 hashCode를 호출한다. 필드의 값이 null이면 0을 사용한다.(관례)
      • B-1-3. 필드가 배열이라면 핵심 원소 각각을 별도 필드처럼 다룬다. 이상의 규칙을 재귀적으로 적용해 각 핵심 원소의 해시코드를 계산한 다음 B-2의 방식으로 갱신한다. 배열에 핵심 원소가 하나도 없다면 0을 사용하고 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
    • B-2. 단계 B-1에서 계산한 해시코드 c로 result를 갱신한다. result = 31 * result + c;
  • C. result를 반환한다.

파생 필드는 해시코드 계산에서 제외해도 된다. 즉 다른 필드로부터 계산해낼 수 있는 필드는 모두 무시해도 된다. 또한 equals 비교에 사용되지 않은 필드는 반드시 제외해야 한다.

B-2의 31 * result는 필드를 곱하는 순서에 따라 result 값이 달라지게 한다. 그 결과 비슷한 필드가 여러 개일 때 해시 성능을 크게 높여준다. 31인 이유는 소수이면서 홀수이기 때문이다. 만약 이 숫자가 짝수이고 오버플로가 발생한다면 정보를 잃게 된다.

결과적으로 Short 필드 3개를 가지고 있는 클래스의 해시코드는 다음과 같이 작성할 수 있다.

1
2
3
4
5
6
7
@Override
public int hashCode() {
int result = Short.hashCode(field1);
result = 31 * result + Short.hashCode(field2);
result = 31 * result + Short.hashCode(field3);
return result;
}

만약 해시 충돌이 더욱 적은 방법을 사용하고 싶다면 구아바의 com.google.common.hash.Hashing을 참고하자.

Objects클래스의 hash 메소드를 통해 다음과 같은 방법도 사용할 수 있다. 하지만 파라미터를 위한 배열 생성과 박싱, 언박싱 과정이 이루어지니 성능에 문제가 생긴다.

1
2
3
4
@Override
public int hashCode() {
return Objects.hash(field1, field2, field3);
}

캐시를 사용하는 방법도 있다. 단 스레드 안전성까지 고려해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
private int hashCode;	// 자동 0으로 초기화

@Override
public int hashCode() {
int result = hashCode;
if(result == 0) {
result = Short.hashCode(field1);
result = 31 * result + Short.hashCode(field2);
result = 31 * result + Short.hashCode(field3);
hashCode = result;
}
return result;
}

성능을 높힌다고 해시코드를 계산할 때 핵심 필드를 생략해서는 안된다. 속도야 빨라지지만 해시 품질이 나빠진다. 또한 hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자. 그래야 클라이언트가 이 값에 의지하지 않게 된다. AutoValue 프레임워크는 hashCode도 자동으로 만들어준다.

Author: Song Hayoung
Link: https://songhayoung.github.io/2020/08/06/Languages/Effective%20JAVA/item11/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.