[Java] equals() & hashCode()
equals()와 hashCode() 는 객체 간의 비교를 위한 Object 클래스의 함수이다. 이 함수들은 Object를 구현한 객체 즉, 자바의 모든 클래스에서 상속하고 있다.
equals()
equals() 함수는 기본적으로 == 연산자를 사용하여 비교를 한다. 이는 객체 내부의 값을 비교하는게 아니라 객체가 저장된 메모리 위치를 비교하는 것과 같다.
/** Indicates whether some other object is "equal to" this one.
...
*/
public class Object {
...
public boolean equals(Object obj) {
return (this == obj);
}
...
}
단순 객체의 주소를 비교하려면 == 연산자를 사용하면 되지만 equals 라는 이름으로 함수를 따로 만들어둔 것은 필요에 따라 객체 간의 비교를 위해 이 함수를 재정의하여 사용하라는 여지를 준다. 우리가 빈번하게 사용하는 String도 문자열을 비교할 때 equals를 재정의하여 내부에서 charactor 값을 저장하고 있는 byte 배열을 비교한다.
//java 11 기준 String의 equals 함수
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String aString = (String)anObject;
if (coder() == aString.coder()) {
return isLatin1() ? StringLatin1.equals(value, aString.value)
: StringUTF16.equals(value, aString.value);
}
}
return false;
}
위의 String equals() 함수와 같이 어떠한 클래스의 인스턴스들의 값 비교를 위해서 직접 이 함수를 재정의하여 사용해야한다.
Company라는 간단한 클래스를 만들고 Intellij IDE의 auto generate를 사용하여 equals() 함수를 재정의해보았다.
public class Company {
private String name;
private String location;
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Company company = (Company) o;
return Objects.equals(name, company.name) && Objects.equals(location, company.location);
}
@Override
public int hashCode() {
return Objects.hash(name, location);
}
}
함수 내부를 살펴보면 같은 객체일 경우 true를 return하고, 비교하는 객체가 null이거나 같은 클래스가 아닐 경우 false를 return한다. 그리고 그 외의 경우에는 필드값 비교를 통해 같은 내용의 인스턴스인지를 판단한다.
예시 코드에서 필드값끼리 비교할 때도 Objects.equals()를 사용한다. 그러면 만약에 Company 클래스의 필드로 Employee라는 객체를 가지고 있으면 비교를 어떻게 할까라는 궁금증이 생겼다. Employee라는 객체를 추가한 뒤 클래스를 만들어 equals()를 다시 auto generate 해보았다.
return Objects.equals(name, company.name) && Objects.equals(location, company.location)
&& Objects.equals(employee, company.employee);// 객체 비교시 다시 equals()를 사용
위와 같이 Employee 객체도 그냥 equals()로 비교를 한다. Employee 클래스에 equals()가 재정의되어 있지 않은 경우 값 비교가 아닌 employee의 주소를 비교할 것이고, 내가 원하는 값을 얻지 못할 수도 있기 때문에 IDE의 도움을 받더라도 내 의도에 따라 코드를 한 번 더 확인하는 과정이 필요할 것 같다.
hashCode()
hashCode()는 native 함수로 객체의 메모리 주소를 기반하여 hash function을 거쳐 나온 int 값을 리턴한다. hashCode 값은 한번 프로그램이 실행되면 값이 변하지 않지만, 종료 후 새롭게 실행되면 값이 변경될 수 있다. 한 번의 실행에서 같은 값을 return하는 것은 보장한다는 뜻이다.
/** Returns a hash code value for the object.
* This method is supported for the benefit of hash tables such as those provided by HashMap.
...
*/
public class Object {
...
@HotSpotIntrinsicCandidate
public native int hashCode();
...
}
Java doc을 살펴보면 hash table을 사용함에 있어서 이점을 가져다 준다고 하며 HashMap, HashTable 등 hash 값을 사용하는 구조에서 key값으로 사용한다. 쉽게 생각하면 객체가 hash 구조의 key로 사용될 때 특정 function을 거쳐 나온 값으로 임시 id를 부여하는 것과 비슷하다. 객체의 전체를 비교하기 위해서는 여러개의 값을 비교해야하지만 hashcode 값은 하나의 int값이기 때문에 비교할 때 훨씬 더 효율적이다. 앞에서 임시 id를 부여하는 것과 같다고 했지만 이 id는 사실 unique하지 않다. 다른 객체라도 계산상 같은 결과가 나올 수도 있고, 중복이 없이 생성이 된다고 하더라도 int 가 4byte이 크기를 가지고 있기 때문에 이 이상의 객체를 표현할 수 있는 숫자도 부족하다.
java의 HashMap 같은 경우는 이러한 문제를 해결하기 위해 hash값이 같은 경우 같은 개수에 따라 linkedList 혹은 tree 구조를 사용하여 하나의 bucket에 저장을 한다.
그렇다면 hash구조에서 우연히 같은 hashCode 값을 가진 객체로 데이터를 조회할 때 해당 bucket을 찾아간 뒤 linkedList 혹은 tree 구조에 있는 여러 데이터 중 찾으려는 데이터가 어떤 것인지 어떻게 알 수 있을까? 이때 equals() 함수를 사용한다. Intellij의 auto generation에서 equals()와 hashCode()를 같이 override 하도록 만들어진 것도, 같은 일을 하는 lombok의 @EqualsAndHashCode 어노테이션도 2개를 함께 재정의하도록 만들어진 이유가 있는 것이다. 둘 중 하나만 재정의 하는 경우에 문제가 생길 수 있는 여지가 있다.
equals() & hashCode()
두개를 동시에 재정의하지 않으면 생길 수 있는 문제를 비교해보자.
첫번째, hashCode()만 재정의하는 경우. 변수들이 가진 값은 같으나 주소값이 다른 객체 2개는 hashCode는 같은 값을 return하고, equals 비교는 false를 return한다.
@Test
void hashCodeOverrideOnly() {
Cafe cafe1 = new Cafe("classico", "ori");
Cafe cafe2 = new Cafe("classico", "ori");
System.out.println("cafe1.equals(cafe2) is " + cafe1.equals(cafe2));
System.out.println("cafe1.hashCode() == cafe2.hashCode() is " + (cafe1.hashCode() == cafe2.hashCode()));
}
//result
cafe1.equals(cafe2) is false
cafe1.hashCode() == cafe2.hashCode() is true
이 두 인스턴스를 hash 구조에 넣으면 hashCode 값이 같기 때문에 같은 bucket에 들어가게 되고 주소값은 같지 않기 때문에 linkedList에 각각 저장되게 된다. 우리가 기대한 것은 같은 값을 가진 객체는 hash 구조에서 같은 객체로 취급되는 것이지만 사실은 다른 값으로 저장이 된 것이다. 만약 HashSet에 두 객체를 add 한다면 HashSet의 사이즈는 1이 아닌 2가 될 것이고 같은 값을 가진 객체를 새로 생성해서 HashSet에서 찾게 된다면 기존의 두 객체가 저장되어 있는 bucket에는 접근이 가능하지만 equals()에서 true를 return 하는 값이 없기 때문에 해당하는 값이 없다고 판단하고 null을 return 할 것이다.
두번째, equals()만 재정의하는 경우. 변수들이 가진 값은 같으나 주소값이 다른 객체 2개는 hashCode는 다른 값을 return하고, equals 비교는 true를 return 한다.
//result
cafe1.equals(cafe2) is true
cafe1.hashCode() == cafe2.hashCode() is false
두 인스턴스를 hash 구조에 넣으면 첫번째 예시와는 다르게 hashCode 값이 다르기 때문에 처음부터 다른 bucket에 들어가게 된다. HashSet에 add 했을 때 HashSet의 사이즈는 2가 될 것이고, 같은 값을 가진 객체를 새로 만들어 HashSet에서 찾으면 그 hashCode를 key로 가진 bucket부터 찾을 수 없기 때문에 null을 return한다.
위의 두가지 상황을 보면 결국 equals()와 hashCode()는 객체의 주소 즉, 참조값 비교가 아닌 변수들의 값 비교를 하기 위해 사용되는데 하나만 재정의할 경우 '이런 상황에서는 값으로 비교하지만 다른 상황에서는 주소값으로 비교를 하겠다'이다. 사실 이 글을 쓰기 전까지만해도 equals() 함수는 값만 비교를 하고 hash 구조에서는 주소값 비교를 하겠다면 equals()만 재정의해도 문제가 없다라고 생각을 했었다. 하지만 문득 든 생각이 hashCode 값은 중복이 될 수 있는데 우연히 필드 값도 같다면 다른 주소값을 가지더라고 hash 구조에서는 같은 객체로 취급될 수도 있지 않을까라는 생각이 들었다. 그래서 equals()만 재정의한 객체 100만개를 HashSet에 넣고 개수를 출력해보았다.
@Test
void equalsOverrideOnly() {
HashSet<Cafe> cafeSet = new HashSet<>();
for (int i = 0; i < 1000000; i++) {
cafeSet.add(new Cafe("classico", "ori"));
}
System.out.println("cafeSet.size() = " + cafeSet.size());
}
//result
cafeSet.size() = 999761
cafeSet.size() = 999775
cafeSet.size() = 999753
cafeSet.size() = 999761
여러번 실행해보았을 때 100만개 중 대략 240개 정도가 같은 HashCode 값을 return하며 HashSet에 저장되지 못했다. 낮은 확률이긴 하지만 데이터를 다루는 측면에서 데이터가 유실될 수 있는 큰 문제가 생길 수 있는 것이다. 이 객체에서 equals 재정의한 부분을 제거한다면 우리가 기대한 100만개가 그대로 저장이 된다.
//result
cafeSet.size() = 1000000
equals()와 hashCode()를 같이 재정의해야되는 선택이 아니다. 일관성을 지킬 필요가 있다. equals()나 hashCode()를 재정의 할 경우 최소한 이 객체가 프로그램 전체에서 같은 변수값을 가졌으면 같은 객체로 취급을 해야하고, 재정의하는 방법에 따라 특정한 변수만 재정의할 수 있으니 다른 사람이 작성한 코드라면 equals()나 hashCode() 함수를 사용하기 전에 최소한 어떤 변수들의 값을 비교하는지 왜 재정의하는 것이 필요한지 확인을 하고 사용하는 것이 좋을 것 같다.