본문 바로가기
Java

Effective Java #09 equals를 재정의할 때는 반드시 hashCode도 재정의하라

by NaHyungMin 2016. 4. 17.

equals 함수를 재정의하는 클래스는 반드시 hashCode 함수도 재정의 해야 한다.

그렇지 않으면 Object.hashCode의 일반 규약을 어기게 되므로, HashMap, HashSet, Hashtable같은 해시(hash) 기반 컬렉션과 함꼐 사용하면 오동작하게 된다.


Object 클래스 명에서 복사해 온 일반 규약은 다음과 같다.


응용프로그램 실행 중에 같은 객체의 hashCode를 여러 번 호출하는 경우 equals가 사용하는 정보들이 변경되지 않았다면 언제나 동일한 정수(integer)가

반환되야 한다.

다만 프로그램이 종료되었다가 다시 실행되어도 같은 값이 나올 필요는 없다.


equals(Object) 함수가 같다고 판정한 두 객체의 hashCode 값은 같아야 한다.


equals(Object) 함수가 다르다고 판정한 두 객체의 hashCode 값은 꼭 다를 필요는 없다. 그러나 서로 다른 hashCode 값이 나오면 해시 테이블(hashtable)의 성능이

향상될 수 있다는 점은 이해하고 있어야 한다.


hashCode를 재정의하지 않으면 위반되는 핵심 규약은 두 번째이다. 같은 객체는 같은 해시 코드 값을 가져야 한다는 규약이 위반되는 것이다.

equals 함수가 논리적으로 같다고 판단한 두 객체라 해도 Object의 hashCode 입장에서 보면 그다지 공통점이 없는 두 객체일 뿐이다.

따라서 Object의 hashCode 함수는 규약대로 같은 정수를 반환하는 대신, 무작위로 선택된 것처럼 보이는 두개의 정수를 반환한다.


예를 들어, 아래의 간단한 PhoneNumber 클래스를 보자. equals 함수는 규칙 8 지침대로 구현되어 있다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public final class PhoneNumber {
    private final short areaCode;
    private final short prefix;
    private final short lineNumber;
 
    public PhoneNumber(int areaCode, int prefix, int lineNumber){
        rangeCheck(areaCode, 999"area code");
        rangeCheck(prefix, 999"prefix");
        rangeCheck(lineNumber, 9999"line number");
        
        this.areaCode = (short)areaCode;
        this.prefix = (short)prefix;
        this.lineNumber = (short)lineNumber;
    }
 
    private static void rangeCheck(int arg, int max, String name){
        if(arg < 0 || arg > max){
            throw new IllegalArgumentException(name + ": " + arg);
        }
    }
 
    @Override public boolean equals(Object o){
        if(o == this){
            return true;
        }
 
        if(!(o instaceof PhoneNumber)){
            return false;
        }
 
        PhoneNumber pn = (PhoneNumber)o;
 
        return pn.lineNumber == lineNumber && pn.prefix == prefix && pn.areaCode == areaCode;
    }
 
    //hashCode 함수가 없으므로 문제가 발생한다!!
    
    //... 이하 ㅐㅇ략
}
cs

이 클래스를 hashMap과 함께 사용한다고 해 보자.

Map<PhoneNumber, String> m = new HashMap<PhoneNumber, String>();
m.put(new PhoneNumber(707, 867, 5309), "Jenny");

이렇게 해 놓고 m.get(new PhoneNumber(707, 867m 5309))를 호출하면 "Jenny"가 반환될 거라 기대하겠지만 정작 반환되는 것은 null이다.
하나는 HashMap에 삽일할 때 사용한 객체고, 다른 하나(앞 객체와 논리적으로 동일)는 HashMap에서 꺼낼 때 사용한 객체이다.
이 두객체는 서로 다른 해시 코드를 갖는다. hashCode 규약을 위반한 것이다.
따라서 get 함수는 put 함수가 객체에 저장한 것과 다른 해시 버킷(hash bucket)을 뒤지게 된다. 설사 운이 좋아서 같은 해시 버킷을 뒤지게 되더라도
get 함수는 거의 항상 null을 반환할 것이다.

HashMap은 성능 최적화를 위해 내부에 보관된 항목의 해시 코드를 캐시해 두고, 캐시된 해시 코드가 없는 객체는 동일성 검사조차 하지 않기 때문이다.
이 문제를 수정 하는 간단한 방법은 적절한 hashCode 함수를 구현하는 것이다.
그렇다면 어떻게 구현해야 하나?

1
2
3
4
//가장 끔찍한 형태의 해시 함수, 절대로 이렇게 구현하지 말 것.
@Override public int hashCode() {
    return 42;
}
cs

위에 함수에 문제가 없다고 한 것은 모든 같은 객체가 같은 해시 코드를 가지게 되니 끔찍하다.
전부 같은 버킷에 해시되므로, 해시 테이블은 결국 연결 리스트가 되어 버린다.
실행시간이 선형적이어야 하는 프로그램들의 복잡도가 제곱에 비례하게 바뀌면서 끔찍하게 느려진다.
해시 테이블에 저장되는 자료가 많을 경우에는 결과적으로 프로그램은 동작하지 않는 것처럼 되어버린다.

책에서는 해시 코드에 대한 자체 코드를 갖기 방법에 대해 한 페이지 분량으로 설명되어 있다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//초기화 지연 기법을 사용해 해시 코드 캐싱
 
private volatile int hashCode; //(규칙71 참조)
 
@Override public int hashCode() {
    int result = hashCode;
    
    if(result == 0) {
        result = 17;
        result = 31 * result + areaCode;
        result = 31 * result + prefix;
        result = 31 * result + lineNumber;
        hashCode = result;
    }
 
    return result;
}
cs


다음과 같이 되어 있는 클래스의 hashCode를 보면서 다음과 같은 생각을 해봤다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
private volatile int hashCode; 
 
@Override public int hashCode() {
    int result = hashCode.Enum.ClassName;
    int multiplication = hashCode.Enum.ClassMultiplication;
        
    result = multiplication * result + areaCode;
    result = multiplication * result + prefix;
    result = multiplication * result + lineNumber;
 
    this.hashCode = result;
 
    return result;
}
cs

해시코드를 관리하는 클래스에 열거형을 두 가지를 놓고, 이걸 기반으로 해시 코드를 생성하는게 어떨까 생각해본다.