Effective Java : 아이템 11. equals를 재정의하면, hashCode도 재정의하라.

    들어가기 전

    이 글은 인프런 백기선님의 강의를 복습하며 작성한 글입니다. 


    핵심 정리 : HashCode 규약

    • equals 비교에서 사용하는 정보(필드)가 변경되지 않았다면, hashCode는 매번 같은 값을 리턴해야함.
      • 필드가 변경되거나 어플리케이션을 다시 실행했다면 달라질 수 있음.
    • equals와 hashCode
      • equals가 같으면 → HashCode는 같아야 함. 
      • equals가 다르면 → HashCode는 같을 수도 있음. (해시 충돌)
      • 해시 충돌이 많이 발생하면 성능이 떨어질 수 있기 때문에 가급적이면 다른 값을 리턴하도록 하는 것이 좋음. 
    • 코드의 구현은 언제든 바뀔 수 있는 것이고, 위 규약을 지키는 hashCode() 메서드를 구현해야 한다. 

    hashCode()는 equals()와 함께 구현되어야 한다. 따라서 equals() 내부에서 사용되는 모든 정보를 hashCode()에서 사용하도록 해야한다. 그렇게 작성해야 equals()에 사용되는 정보가 hashCode()에 그대로 반영되기 때문이다. 만약 제대로 반영되지 않았다면, 어떤 필드에서는 같은 값임에도 불구하고 다른 해시코드가 나올 수도 있다. 

     


    두 객체에 대한 equals가 같다면, hashCode의 값도 같아야 한다.

    아래의 HashMapTest 클래스 코드를 살펴보자. PhoneNumber 클래스에 대해서 equals(), hashCode() 관련 호출을 하고 있다. 일단은 PhoneNumber 클래스에 해시코드가 구현되지 않은 경우를 생각해보자. 이 상태에서 HashMapTest 클래스의 코드를 실행하면 어떤 결과가 나올까? 

    • equals()의 결과는 True가 나온다. (값 객체로 구현되어 있음.)
    • 각각의 hashCode()는 서로 다른 결과가 나옴. 
    • HashMap에 값은 잘 저장하고, 필요한 값을 잘 꺼내온다. 
    public class HashMapTest {
    
        public static void main(String[] args) {
            Map<PhoneNumber, String> map = new HashMap<>();
    
            PhoneNumber number1 = new PhoneNumber(123, 456, 7890);
            PhoneNumber number2 = new PhoneNumber(456, 789, 1111);
    
    //         TODO 같은 인스턴스인데 다른 hashCode
    //         다른 인스턴스인데 같은 hashCode를 쓴다면?
            System.out.println(number1.equals(number2));
            System.out.println(number1.hashCode());
            System.out.println(number2.hashCode());
    
            map.put(number1, "keesun");
            map.put(number2, "whiteship");
    
            String s = map.get(number2);
            System.out.println(s);
        }
    }

    잘 동작하는 것 같은데 어떤 문제가 있는걸까? hashCode()가 equals()를 기준으로 구현되어 있지 않기 때문에 다음 문제가 발생한다.

    • equals()로는 값은 객체로 판단됨. 
    • 같은 객체로 판단되는 객체지만, hashCode()는 다른 값이기 때문에 HashMap에서는 서로 다른 객체로 동작함. 

    이런 상황이기 때문에 문제가 발생한다. 예를 들어서 아래 코드를 동작시켜보면, 동작하지 않는다. 왜냐하면 number1 / number2는 서로 다른 해시코드를 가지고 있기 때문이다. HashMap은 인스턴스를 넣고 뺄 때, Key 값에 대응되는 객체의 HashCode를 이용해서 값을 저장한다. 즉, HashCode에 해당되는 주소의 버킷에 Value 값을 저장한다. 

            Map<PhoneNumber, String> map = new HashMap<>();
    
            PhoneNumber number1 = new PhoneNumber(123, 456, 7890);
            PhoneNumber number2 = new PhoneNumber(123, 456, 7890);
    
            map.put(number1, "keesun");
    
            String s = map.get(number2);
            System.out.println(s);

    다른 인스턴스인데 같은 HashCode를 반환한다면? 

    hashCode() 메서드를 오버라이드 해서 항상 42라는 값을 리턴하도록 하자. 앞으로 이 해시코드를 쓰는 클래스의 모든 인스턴스는 42라는 해시코드를 가지게 될 것이다. 이렇게 hashCode() 메서드가 구현되면, 해시 테이블에 값을 넣을 때 마다 항상 해시 충돌이 발생한다.

    이렇게 작성해도 HashMap에 값을 넣고 빼는데는 아무 문제가 없다. 다만 값을 넣을 때 마다 해시 충돌이 발생하기 때문에 값을 조회하는 시간이 항상 O(N)이 된다. 

    @Override public int hashCode() {
        return 42;
    }

    핵심정리 : hashCode() 구현 방법

    • hashCode() 메서드 구현 방법
      1. 핵심 필드 하나의 해시값을 계산해서 result 값을 초기화한다.
      2. 계산하는 방법은 hashCode() 메서드를 이용해야 함.
        • 기본 타입은 Interger.hashCode() 같은 것을 사용.
        • 참조 타입은 해당 필드의 hashCode()를 사용. 
        • 배열은 모든 원소를 재귀적으로 위의 로직을 적용하거나, Arrays.hashCode()를 사용.
        • result = 31 * result + 해당 필드의 hashCode 계산값을 다시 초기화 함. 
      3. 모든 필드에 대해 완료되면 계산 결과를 리턴.
    • equals()에서 사용하고 있는 필드가 있으면 hashCode()에서도 사용해야 함.
      • 비용이 비싸다고 빼면 안된다. 해시 충돌이 더 발생해서 더 많은 비용이 들 수 있기 때문임. 
    • 좋은 방법
      • 직접 구현해야 한다면 Object.hash(), Arrays.hash()를 사용.
      • IDE, Lombok을 사용해서 구현. 

    아래의 PhoneNumber 클래스를 살펴보자. 먼저 equals() 메서드를 살펴보면 다음 필드를 논리적 동치성을 비교하는데 사용한 것을 알 수 있다. 따라서 아래 필드들이 모두 포함된 hashCode() 메서드를 구현해야만 한다.

    • areaCode
    • prefix
    • lineNum
    public final class PhoneNumber {
        private final short areaCode, prefix, lineNum;
    
        @Override public boolean equals(Object o) {
            ...
            PhoneNumber pn = (PhoneNumber)o;
            return pn.lineNum == lineNum && pn.prefix == prefix
                    && pn.areaCode == areaCode;
        }
    
        // 코드 11-3 한 줄짜리 hashCode 메서드 - 성능이 살짝 아쉽다. (71쪽)
    //    @Override public int hashCode() {
    //        return Objects.hash(lineNum, prefix, areaCode);
    //    }
    
    
        @Override public int hashCode() {
            int result = hashCode;
            if (result == 0) {
                result = Short.hashCode(areaCode);
                result = 31 * result + Short.hashCode(prefix);
                result = 31 * result + Short.hashCode(lineNum);
                this.hashCode = result;
            }
        }
    	
    }

    사람이 수작업으로 구현할 때는 가장 아래쪽에 있는 hashCode() 메서드처럼 구현해 볼 수 있다. 아래쪽에 있는 메서드는 다음과 같이 구성되어 있다.

    • 각 필드에 대한 해시 값을 구함
    • 31을 곱한 후 다음 필드에 대한 해시 값을 구한 후 더함 

    이런 형식으로 해시코드를 구한다. 31을 곱하고 더하는 이유는 이런 식으로 했을 때 해시 충돌이 발생하는 것이 가장 적었기 때문이라고 한다. 만약 이런 방식으로 구하는게 귀찮다면 Objects.hash() 메서드를 이용해서 해시코드를 구할 수도 있다. Object.hash()를 호출하면 Arrays.hashCode()에 delegate하는데, Arrays.hashCode()는 우리가 구현한 hashCode()와 거의 동일하게 구현되어 있다. 

    // Objects.hash() → Arrays.hashCode()
    // 아래는 Arrays.hashCode()
    public static int hashCode(Object a[]) {
        if (a == null)
            return 0;
    
        int result = 1;
    
        for (Object element : a)
            result = 31 * result + (element == null ? 0 : element.hashCode());
    
        return result;
    }

    hashCode() 구현 시, 좋은 방법

    Lombok을 사용하자. 어차피 우리가 작성한 equals(), hashCode()의 코드 블록은 모두 테스트 대상이 된다. 따라서 구현한 equals(), hashCode()를 테스트 하는 테스트 코드를 직접 작성해야 한다. 반면 Lombok에서 만들어진 코드는 이미 테스트가 된 코드이기 때문에 우리가 작성해야 할 부분이 줄어든다. 


    해시 코드 계산 비용이 비싸다면? → 캐싱 사용

    만약 해시 코드를 계속 호출할 때 마다 계산하는 것도 아깝다라고 하면, 처음에 해시 코드를 한번 호출했을 때 값을 캐싱해서 재사용할 수 있다. 따라서 해시코드가 최초로 필요로 하는 시점에 해시코드를 계산하고, 필드에 값을 저장해서 사용하는 것이다. 즉, '지연 초기화'를 이용해 캐싱하는 것이다. 

    이 때 주의해야 할 사항은 멀티 쓰레드 환경에서 '스레드 안전성'을 신경써야만 한다. 만약 hashCode() 메서드 안에 여러 스레드가 동시에 들어오는 경우가 있는데, 이 때 동일한 값을 가지고 있는 불변 객체인데 해시코드가 다른 상황이 될 수 있다. 캐싱을 하지 않을 때는 이런 것을 신경쓰지 않아도 된다. 쓰레드 안정성을 고려한 hashCode의 캐싱은 아래와 같다. 

    private volatile int hashCode; // 자동으로 0으로 초기화된다.
    
    @Override public int hashCode() {
        if (this.hashCode != 0) {
            return hashCode;
        }
    
        synchronized (this) {
            int result = hashCode;
            if (result == 0) {
                result = Short.hashCode(areaCode);
                result = 31 * result + Short.hashCode(prefix);
                result = 31 * result + Short.hashCode(lineNum);
                this.hashCode = result;
            }
            return result;
        }
    }

    댓글

    Designed by JB FACTORY