[Item11] - equals를 재정의하려거든 hashCode도 재정의하라.

Effective Java 3/E를 공부하며 작성한 글입니다.
혼자 공부하고 정리한 내용이며, 틀린 부분은 지적해주시면 감사드리겠습니다 😀

item10의 주의사항 중 아래와 같은 내용이 있었다.

equalshashCode는 함께 재정의해라.

만약 이를 어길 경우, hashCode 일반 규약을 어기게 되며, HashMap, HashSet과 같이 keyHashCode를 기준으로 사용하는 컬렉션의 원소로 사용할 때, 문제를 일으킬 것이다.

HashCode와 주소값

C언어 계열에서는 pointer를 통해서 특정 값의 실제 메모리 주소를 반환한다. 하지만 자바에서 객체의 값을 출력하면 아래와 같이 나온다.

// 출력 : ka.chapter3.item11.phone.Contact@51114e
System.out.println(contact);

가장 뒤에 나오는 51114e라는 hash 값은 실제 주소값과 관련이 있을까?

Clang

C언어 계열은 *p, malloc, free 등과 같은 메소드를 활용한다.

#include <stdio.h>

int main() {
    int x = 42;

    printf("x 주소값: %p\n", (void *)&x);

    return 0;
}
변수 x의 주소: 0x16ba33208

즉, 메모리를 직접 관리하기 때문에, 객체의 실제 메모리 주소를 직접 얻을 수 있다.

Java

Java에서 객체의 값을 출력하면 다음과 같이 나온다.

import org.openjdk.jol.vm.VM;

public class HashTest {
    
  @Test
  void postHashTest() {
    Post p = new Post(1);

    System.out.println("hashCode : " + p.hashCode());
    System.out.println("identity : " + System.identityHashCode(p));

    System.out.println("VM hash : " + VM.current().addressOf(p));
  }

}
hashCode : 1434041222
identity : 1434041222

하지만 OpenJDKJava Object Layout (JOL) 라이브러리를 사용하면 아래와 같은 값이 나온다.

VM hash : 30320045384

이와 같은 값을 내는 이유는 객체의 참조에 보통 많은 값을 담아 무겁기 때문에 압축 참조를 하기 때문이다.

Java objects are usually quite reference-heavy, and there is pressure for runtimes to employ the optimizations that make the references smaller. The most ubiquitous trick is to compress the references: make their representation smaller than the machine pointer width.

Java 객체는 일반적으로 참조가 상당히 크기 때문에 참조를 더 작게 만드는 최적화를 실행해야 하는 부담이 있습니다. 가장 보편적인 방법은 참조를 압축하는 것입니다. 참조를 기계 포인터 폭보다 작게 표현하는 것입니다.

즉, Java에서 볼 수 있는 hashCode 값들은 모두 실제 메모리 값을 압축한 값이다.

Java와 같은 경우에는 메모리 관리를 사용자가 하는 것이 아닌, GC가 직접 처리하기 때문에 hashCode를 이용해 실제 객체 메모리 값을 추상화하고, 압축한다.

C언어는 실제 메모리를 사용하기 때문에 안전성이 낮지만, Java의 경우 직접적인 메모리를 노출시키지 않아, 안전성을 높일 수 있다.

hashCode의 일반 규약

Object 클래스를 살펴보면 아래와 같은 주석을 확인할 수 있다.

public class Object {
    /**
     * - Whenever it is invoked on the same object more than once during an execution of a Java application,
     * the hashCode method must consistently return the same integer, 
     * provided no information used in equals comparisons on the object is modified.
     * This integer need not remain consistent from one execution of an application to another execution of the same application.
     * - If two objects are equal according to the equals method,
     * then calling the hashCode method on each of the two objects must produce the same integer result.
     * - It is not required that if two objects are unequal according to the equals method,
     * then calling the hashCode method on each of the two objects must produce distinct integer results.
     * However, the programmer should be aware that producing distinct integer results for unequal objects may improve the performance of hash tables.
     */
    @IntrinsicCandidate
    public native int hashCode();
}

이를 그대로 번역하면 아래와 걑다.

  • equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메소드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다. 단, 애플리케이션을 다시 실행한다면, 이 값이 달라져도 상관없다.

  • equals(Object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다.

  • equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 단, 다른 객체에 대해서는 다른 값을 반환해야 해시 테이블의 성능이 좋아진다.

여기서 가장 주의해야할 것은 두 번째 조항인, 논리적으로 같은 객체는 같은 해시코드를 반환해야한다는 것이다.

코드를 통해 확인해보자!

public class Contact {
    private final int countryCode;
    private final int prefix, middle, suffix;
    private final Member member;

    public Contact(int countryCode, String phoneNumber, String name) {
        this.countryCode = countryCode;
        this.member = new Member(name);

        String[] split = phoneNumber.split("-");
        prefix = Integer.parseInt(split[0]);
        middle = Integer.parseInt(split[1]);
        suffix = Integer.parseInt(split[2]);
    }
}

HashMap을 만들어 연락처와 보낼 문자를 저장해보자!

public class ContectTest {
    @Test
    void equalsTest() {
        Map<Contact, String> sendMessageMap = new HashMap<>();

        String msg = "안녕 나 에스파 윈터야 100만원만 보내줘!";

        sendMessageMap.put(new Contact(82, "010-1234-1234", "준영"), msg);

        // 테스트 실패!
        assertTrue(sendMessageMap.get(new Contact(82, "010-1234-1234", "준영"))
                .equals(msg)
        );
    }
}

위 코드는 테스트를 실패하면서 아래와 같은 문구를 볼 수 있게 된다.

java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because the return value of "java.util.Map.get(Object)" is null

sendMessageMap.putsendMessageMap.get에서 각각 사용한 Contact 객체는 서로 다른 인스턴스이다.

아래와 같이 get을 하는 과정에서 보면 equals를 사용하는 것을 볼 수 있다.

public class HashMap { 
    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(key)) == null ? null : e.value;
    }
    
    final Node<K,V> getNode(Object key) {
        if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
            ...
        if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
            ...
    }
}

때문에 각자의 HashCode 값이 다르기 때문에 map에서 검색할 때, NPE가 발생한 것이다.

즉, hashCode를 재정의하지 않았기 때문에, 논리적 동치인 두 객체가 서로 다른 해시코드를 반환하여, 두 번째 규약을 지키지 못한 것이다.

논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.

hashCode 메소드는 어떻게 작성해야할까?

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

위 코드는 논리적으로 같은 객체는 같은 해시코드를 반환하게 했지만, 논리적으로 같지 않은 객체도 반환하게 된다.

Contact의 모든 인스턴스가 동일한 값만 내어주기 때문에 해시 테이블의 버킷 하나에 담겨 마치 연결 리스트처럼 동작하게 된다. 그 결과 평균 수행 시간이 O(1)인 해시 테이블이 O(n)으로 느려져서, 객체가 많아지면 도저히 쓸 수 없게 된다.

hashCode 작성 요령

위와 같은 문제를 해결하기 위해서는, 아래 요령에 따라 hashCode를 재정의해주면 된다.

equals 비교에 사용되지 않는 필드는 반드시 제외해야한다! 그렇지 않을 경우 hashCode 규약 두 번째를 위반할 위험이 있다.

1. 변수 선언

  • 정수형 변수 result를 선언한 뒤, 값을 c로 초기화 한다.
  • c는 뒤에서 나올 방식으로 계산한 해시코드이다.
public class Contact {
    @Override
    public int hashCode() {
        int result = c;
        ...
    }
}

2. 핵심 필드를 토대로 값 구하기

핵심 필드란, equals 비교에 사용될 필드를 말한다.

아래에 따라 값을 구한 뒤, c와 치환해준다.

기본 타입 필드일 경우

해당 타입에 대한 박싱 클래스의 hashCode를 호출해 값을 구한다.

public class Contact {
    private final int middle;
    ...
    @Override
    public int hashCode() {
        int result = Integer.hashCode(middle);
        ...
    }
}

참조 타입 필드 + equals 재귀 호출 경우

이 필드의 equals를 통해 계속해 비교할 경우 hashCode도 재귀적으로 호출해준다.

계산이 복잡해질 경우, 이 필드의 표준형을 만든 뒤 그 표준형의 hashCode를 호출한다. 필드의 값이 null이면, 0을 사용한다.

public class Contact {
    private final Member member;
    ...

    @Override
    public int hashCode() {
        int result = Integer.hashCode(middle);
        result = 31 * result + (member != null ? member.hashCode() : 0);
        return result;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Contact c)) return false;
        return countryCode == c.countryCode
                && prefix == c.prefix
                && middle == c.middle
                && suffix == c.suffix
                && Objects.equals(member, c.member);
    }
}

필드가 배열일 경우

원소 각각을 별도 필드처럼 다루며, 각 필드에 대한 해시코드를 구해 계산하고, result 변수를 갱신한다.

모든 원소가 핵심 원소라면, Arrays.hashCode를 사용한다.

public class Contact {
    private String[] nickname;
    ...

    @Override
    public int hashCode() {
        int result = Integer.hashCode(middle);
        result = 31 * result + (member != null ? member.hashCode() : 0);
        result = 31 * result + Arrays.hashCode(nickname);
        ...
    }
}

모든 핵심 필드에 대해서 위와 같은 과정을 반복해주면 된다.

3. 자문

위 과정을 토대로 hashCode를 다 구현했다면, 동치인 인스턴스에 대해 똑같은 해시 코드를 반환할지 자문해봐야한다.

각 핵심필드인 countryCode, middle, suffix, member에 대해서 작업을 수행하였고, equals 비교에 사용하지 않은 prefix도 제외했다.

public class Contact {
    private final int countryCode;
    private final int prefix, middle, suffix;
    private final Member member;

    @Override
    public int hashCode() {
        int result = Integer.hashCode(countryCode);
        result = 31 * result + Integer.hashCode(middle);
        result = 31 * result + Integer.hashCode(suffix);
        result = 31 * result + (member != null ? member.hashCode() : 0);
        return result;
    }

    @Override
    public boolean equals(Object o) {
        ...
        return countryCode == c.countryCode
                && middle == c.middle
                && suffix == c.suffix
                && Objects.equals(member, c.member);
    }
}

이렇게 곱셈을 구현하는 이유는 비슷한 필드가 여러 개일 때, 해시 효과를 크게 높여주기 때문이다.

실제로 DB에 중요 데이터를 저장할 때, 솔트, 페퍼를 뿌리는 느낌인 것 같다.

솔트란, 비밀번호와 같은 데이터를 암호화하는 과정에서 가장 앞에 특정 숫자 혹은 문자열을 추가해 암호화하는 것

image

만약, StringhashCode를 곱셈 없이 구현한다면, 모든 아나그램의 해시코드가 같아질 수 있다.

해시코드 팁

Guava 라이브러리

위에서 작성한 방법으로도 충분히 해싱 기능을 잘 수행하지만, 해시 충돌을 더욱 적은 방법을 사용하고 싶다면 com.google.common.hash.Hashing을 사용하면 된다.

public class Contact {
    @Override
    public int hashCode() {
        HashFunction hashFunction = Hashing.sha256();
        HashCode hashCode = hashFunction.newHasher()
                .putInt(countryCode)
                .putInt(middle)
                .putInt(middle)
                .putInt(suffix)
                .putObject(member)
                .hash();
        return hashCode.asInt();
    }
}

코드는 기존에 비해 더 길어졌지만, 비밀번호를 암호화할 때 사용하는 해싱 알고리즘 중 하나인, SHA256 방식으로 해시값을 만들어낸다.

hashCode 생성 기능 사용 자제

IntelliJ에서 hashCode를 생성하면 아래와 같이 나온다.

@Override
public int hashCode() {
    return Objects.hash(countryCode, prefix, middle, suffix, member);
}

이러한 방식은 우리가 작성한 hashCode에 비해 성능이 좋지 않다.

public final class Objects {
    public static int hash(Object... values) {
        return Arrays.hashCode(values);
    }
}

public class Arrays {
    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;
    }
}

위와 같이 Object 타입으로 인자를 받기 때문에 박싱과 언박싱이 계속되며, 배열로 변환되어 우리가 작성한 hashCode와 같은 형태로 계산된다.

앞서 Item6에서 봤던 박싱 및 언박싱의 시간 차이를 보면 겁이 날 수 밖에 없다.

캐싱 방식 고려

클래스가 불변이고, 해시 코드를 계산하는 비용이 크다면, 매번 새로 계산하는 것이 아닌 필드에 캐싱하는 방식을 고려해야 한다.

만약, 해당 타입의 객체가 주로 해시의 키로 사용될 것 같다면 인스턴스가 만들어질 때 해시코드를 계산해둬야 한다.

public class Contact {
    private int hashCode;
    private final int countryCode;
    private final int prefix, middle, suffix;
    private final Member member;

    public Contact(int countryCode, String phoneNumber, String name) {
        this.countryCode = countryCode;
        this.member = new Member(name);

        String[] split = phoneNumber.split("-");
        prefix = Integer.parseInt(split[0]);
        middle = Integer.parseInt(split[1]);
        suffix = Integer.parseInt(split[2]);

        int result = Integer.hashCode(countryCode);
        result = 31 * result + Integer.hashCode(middle);
        result = 31 * result + Integer.hashCode(suffix);
        result = 31 * result + (member != null ? member.hashCode() : 0);
        hashCode = result;
    }
    
    @Override
    public int hashCode() {
        int result = hashCode;
        if (result == 0) {
            result = Integer.hashCode(countryCode);
            result = 31 * result + Integer.hashCode(middle);
            result = 31 * result + Integer.hashCode(suffix);
            result = 31 * result + (member != null ? member.hashCode() : 0);
        }
        return result;
    }
}

위와 같은 경우가 아니라면 지연 초기화(Lazy Initialization) 전략은 어떨까? 필드를 지연 초기화하려면 그 클래스를 Thread-Safe하게 만들도록 신경 써야 한다.

즉, 스레드 안전성까지 고려해야하는 것이다.

private int hashCode; // 기본값인 0으로 초기화

@Override
public int hashCode() {
    int result = hashCode;
    if (result == 0) {
        result = Integer.hashCode(countryCode);
        result = 31 * result + Integer.hashCode(middle);
        result = 31 * result + Integer.hashCode(suffix);
        result = 31 * result + (member != null ? member.hashCode() : 0);
    }
    return result;
}

여러 스레드에서 동시에 접근할 경우, 동시에 계산하여 처음 의도와 다르게 여러번 계산하는 상황이 발생할 수 있다. 간단하게 synchronized를 붙여 한 쓰레드씩 접근할 수 있도록 하는 것이 좋을 것 같다.

정리

  • 성능을 위해 해시코드를 계산할 때 핵심 필드를 생략하면 안 된다.
    • 해시 품질이 나빠져 해시 테이블의 성능을 심각하게 떨어뜨릴 수 있다.
  • hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자.
    • 그래야 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수 있다.
  • equals를 재정의할 때는 hashCode도 반드시 재정의하자.
    • 그렇지 않을 경우 프로그램의 동작이 이상해질 수 있다.
  • hashCode는 API 문서에 기술된 일반 규약을 따라야한다.
    • 서로 다른 인스턴스라면 되도록 해시코드도 서로 다르게 구현해야 한다.

댓글남기기