맞는데 왜 틀릴까..?

Java/Effective Java

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

안도일 2023. 3. 8. 20:00

자바에서 hashCode()는 객체의 해시 코드를 반환하는 메서드다.

 

해시 코드란 객체를 구분하기 위해 생성된 고유한 정수값으로, 자바의 HashMap, HashSet 등과 같은 자료구조에서 객체를 식별하는 데 사용된다.

 

hashCode() 메서드는 Object 클래스에서 정의되어 있으며, 모든 자바 객체가 이를 상속하는데

Object 클래스에서 구현된 기본 hashCode()는 객체의 메모리 주소에 기반하여 해시 코드를 생성한다.

 

Object 클래스에 정의된 hashCode의 규약을 살펴보자.

 

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

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

3. equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 

 


 

hashCode() 메서드의 반환 값은 int형이며, 해시 코드 값이 같은 객체들은 equals() 메서드의 비교를 통해 동등한 객체로 판단된다.

따라서 일반적으로 equals() 메서드를 오버라이딩하면, hashCode() 메서드도 함께 오버라이딩해야 한다.

equals() 메서드가 같은 객체로 판단하는 두 객체는 반드시 같은 hashCode()를 반환해야 한다.

 

 

 

equals는 물리적으로 다른 두 객체를 논리적으로는 같다고 할 수 있다. 하지만 Object의 기본 hashCode 메서드는 이 둘이 전혀 다르다고 판단하여,  규약과 달리 서로 다른 값을 반환한다.

 

앞서 Item10에서 작성한 PhoneNumber 클래스의 인스턴스를 HashMap의 원소로 사용한다고 해보자.

 

 

실행결과 "제니"가 출력되어야 할 것 같지만 실제로는 null을 반환한다.

 

 

 

여기서는 2개의 PhoneNumber 인스턴스가 사용되었는데, 하나는 HashMap에 "제니"를 넣을 때 사용했고 두 번째는 이를 꺼내려할 때 사용됐다. 

PhoneNumber 클래스는 hashCode를 오버라이딩하지 않았기 때문에 논리적 동치인 두 객체가 서로 다른 해시코드를 반환하여 앞서 살펴본 두 번째 규약을 지키지 못한다. 그 결과 get 메서드는 엉뚱한 해시 버킷에 가서 객체를 찾으려 한 것이다.

만약 우연으로 두 인스턴스가 같은 버킷에 있더라도 HashMap은 해시 코드가 다른 엔트리끼리는 동치성 비교를 시도조차 하지 않도록 최적화되어 있다.

 

버킷

더보기

자바에서 버킷(bucket)은 해시 테이블(hash table)에서 각각의 인덱스를 가리키는 단위이다.

해시 테이블은 키-값(key-value) 쌍의 데이터를 저장하는 자료구조로, 키를 해시 함수(hash function)를 사용하여 해시 코드(hash code)로 변환하고, 이를 버킷 인덱스로 사용하여 데이터를 저장하고 검색한다.

해시 테이블은 배열과 같은 구조를 가지며, 각각의 버킷에는 하나 이상의 키-값 쌍이 저장될 수 있다. 여러 개의 키-값 쌍이 같은 버킷에 저장될 경우, 이들은 연결 리스트(linked list) 형태로 연결되어 저장된다.

 

 

이 문제는 PhoneNumber 클래스에 적절한 hashCode만 작성해주만 해결된다. 다음으로 여러 가지 해결법을 살펴보자.

 

 

1. 동치인 모든 객체에서 똑같은 해시코드 반환

 

 

적법하지만 최악의 hashCode 구현이다.

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

 

이상적인 해시 함수는 주어진 인스턴스들을 32비트 정수 범위에 균일하게 분배해야 한다.

 

 

 

2. 전형적인 hashCode 구현

 

 

1. int 변수 result를 선언하고 해당 객체의 첫 번째 핵심 필드를 Type.hashCode()를 수행해 초기화한다. 여기서 Type은 해당 기본 타입의 박싱 클래스다.

 

2. 해당 객체의 나머지 핵심 필드 f 각각에 대해 다음 작업을 수행한다.

 

2-a. 해당 필드의 해시코드 c를 계산한다.

 

기본 타입 필드 : Type.hashCode(f)

참조 타입 필드 : 이 필드의 hashCode를 재귀적으로 호출

배열 : 핵심 원소 각각을 별도 필드처럼 다룬다. 모든 원소가 핵심 원소라면 Arrays.hashCode를, 핵심 원소가 하나도 없다면 상수 0을 사용한다.

 

2-b. 위의 단계에서 계산한 해시 코드 c로 result를 갱신한다.

result = 31 * result + c;

 

3. result 리턴 

 

 

코드로 나타내면 다음과 같다.

 

 

2-b 단계에서 31 * result는 필드를 곱하는 순서에 따라 result 값이 달라지게 한다. 그 결과 클래스에 비슷한 필드가 여러 개일 때 해시 효과를 크게 높여주는데, 만약 String의 hashCode를 곱셈 없이 구현한다면 모든 아나그림의 해시코드가 같아질 것이다.

 

 

3. Objects.hash 활용

 

Objects 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해 주는 정적 메서드 hash를 제공한다.

입력 인수를 담기 위한 배열이 만들어지고, 입력 중 기본 타입이 있다면 박싱과 언박싱도 거쳐야 하기 때문에 느리지만 앞서 구현한 코드와 비슷한 수준이며 단 한 줄로 작성할 수 있는 장점이 있다.

 

 

 

성능에 민감하지 않은 상황에서만 사용하자.

 

 

4. hashCode 지연 초기화 

 

해시의 키로 사용되지 않는 경우라면 hashCode가 처음 불릴 때 계산하는 지연 초기화 전략을 쓸 수 있다.

스레드 안정성을 고려하고 hashCode 필드의 초깃값은 흔히 생성되는 객체의 해시코드와는 달라야 함에 유념하자.

 

 


주의해야 할 점

 

 

성능을 높이자고 해시 코드를 계산할 때 핵심 필드를 생략해서는 안된다. 속도는 빨라지겠지만 해시 품질이 나빠져 해시테이블의 성능을 심각하게 떨어 뜨릴 수도 있다.

 

hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자. 그래야 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수도 있다.

 

 


 

요약

 

hashCode() 메서드를 오버라이딩하는 이유는, 객체가 해시 테이블과 같은 자료구조에서 사용될 때, 객체를 고유하게 식별하기 위한 해시 코드를 제공하기 위해서이다.

 

HashMap, HashSet과 같은 자료구조에서는 객체의 해시 코드(hash code)를 이용하여 저장된 위치를 계산하고, 객체를 검색하므로, hashCode() 메서드의 반환 값이 서로 다른 객체들은 서로 다른 버킷(bucket)에 저장된다. 따라서 해시 테이블에서 객체를 검색할 때, hashCode() 메서드의 반환 값이 다른 객체들은 검색 대상에서 제외된다.

 

만약 hashCode() 메서드를 오버라이딩하지 않으면, Object 클래스에서 상속한 기본 hashCode() 메서드는 객체의 메모리 주소를 이용하여 해시 코드를 생성하므로, 같은 내용을 가진 객체들도 다른 해시 코드를 갖게 된다. 이 경우 같은 내용을 가진 객체들이라 하더라도 다른 버킷에 저장되게 되어, 검색 시에 원하는 결과를 얻을 수 없다.