개요
지난번 포스팅에서 ==와 equals()에 대해 알아봤다. Object.equals()의 java document를 보면 아래와 같은 내용이 있다.
Note that it is generally necessary to override the hashCode method whenever this method is overridden, so as to maintain the general contract for the hashCode method, which states that equal objects must have equal hash codes.
equals()를 오버라이딩 할 때마다 hashCode()도 함께 재정의하는 것을 권장하고 있다. equals()는 언제 오버라이딩 되어야 하는지, hashCode()는 뭔지, 그리고 왜 객체를 비교할 때 해시 코드에도 신경을 써야하는지까지 알아보자.
equals()
equals()는 말 그대로 특정 객체와 다른 객체가 '같은' 객체인지를 판별해주는 메서드이다.
( + 이 메서드에 대한 자세하게 알아보고 싶다면 여기 포스팅을 참고하세요!)
public boolean equals(Object obj) {
return (this == obj);
}
==을 한 번 더 감싼 것 뿐 아닌가요 ?🤔 맞다. Object.equals() 자체는 ==와 다를바가 없다. 하지만 클래스가 equals()를 오버라이딩하는 순간 이 메서드는 큰 의미를 가지게 된다. Object 클래스는 모든 클래스의 조상이며, 따라서 자바의 모든 클래스는 equals()를 기본으로 제공한다. 하지만 우리가 일반적으로 사용하는 String, Integer 등의 equals() 메서드를 열어보면 Object.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() 메서드이다. 코드를 살펴보면 단순히 주소값을 비교하는 equals()와는 다르게, 주소가 다를 경우 String의 value를 직접 비교하여 두 객체가 같은지 다른지를 판단하고 있다.
왜 이렇게 구현했을까? 그건 참조형이 실제 메모리에 들고 있는 값과, 해당 메모리 주소를 나타내는 주소값 두 가지를 가지고 있기 때문이다. "test"와 "test" 두 개의 변수가 있다고 가정할 때, 우리는 이 두 문자열을 같다고 판단할 것이다. 하지만 java의 String은 참조형이고, new을 이용해 변수를 생성했다면 내용물이 같아도 서로 다른 메모리에 저장이 되어 주소가 다르게 찍힌다. 이때 Object.equals()나 ==을 이용해 비교해 두 변수가 다르다는 결과가 나올 것이고, 그럼 프로그램은 우리의 의도와 다르게 동작할 것이다. 따라서 문자열의 특성을 고려해, 주소가 달라도 값을 한 번 더 비교하게 equals()를 재정의했다고 이해할 수 있다.
이렇듯, 자바에 존재하는 대부분의 참조형 클래스는 equals()를 재정의하여 자신의 특성에 맞게 객체를 비교하고 있다.
객체 동등성과 동일성
equals()에 대해 알아보면서, 우리는 객체를 비교하는 방식이 크게 두 가지로 나뉜다는 것을 알게 되었다. 바로
- 객체의 주소값을 직접 비교하는 방식과 (==이나 Object.equals())
- 주소가 아닌 다른 어떤 값을 이용해 비교하는 방식 (equals()를 오버라이딩해서 사용)
이다. 이 친구들은 각각 동일성과 동등성이라는 개념과 연결된다. 두 개념에 대해 좀 더 풀어서 살펴보자.
1️⃣ 동일성 (Identity)
두 개 이상의 사상(事象)이나 사물이 서로 같은 성질. (어학사전)
비교 대상들이 고냥 똑같은 친구라면 우리는 대상이 동일하다고 표현할 수 있다. 객체의 주소값을 직접 비교하는 경우가 바로 객체의 동일성을 판단하는 케이스! 왜냐하면 자바의 객체는 생성되는 동시에 메모리에 할당되기에, 서로 다른 변수는 서로 다른 메모리 주소값을 갖기 때문이다. 따라서 객체와 주소는 대부분의 경우 1:1로 매칭되기에, 주소가 같다면 동일하다고 판단할 수 있다.
(물론 문자열 리터럴이나 Integer Cache같이 매번 새로 만드는게 아니라 이미 존재하는 주소를 참조하게 하는 경우도 존재한다. 이 친구들은 자주 사용되는 값, 또는 불변값의 특성을 반영한 케이스로, 만약 값이 같아도 서로 다른 주소값을 가지게 하고 싶다면 꼭 new를 이용해 객체를 생성해야 한다는 점도 함께 알아두자.)
2️⃣ 동등성 (Equality)
가치, 등급 따위가 서로 똑같음. (어학사전)
하지만 살다보면, 서로 다른 값이긴 하지만 맥락상 같은 의미라고 판단하는 경우가 꽤 많다.
예를 들어보자. 수능 국어 1등급 컷이 94점이라고 치자. 그럼 100점을 맞았든, 98점을 맞았든, 94점을 맞았든 수능 성적표에는 전부 똑같은 1등급으로 찍힌다. 퍼센티지로 잘라서 줄세웠더니 94점까지는 1등급이다 라는 맥락이 있으니 점수 자체는 달라도 의미상으로는 같은 1등급으로 분류되는 것!
아까 언급한 String.equals()를 다시 떠올려보자. String a = new String("test")와 String b = new String("test")는 서로 다른 주소값을 가지기에 동일하지 않다. 하지만 문자열을 다루는 상황에서는 주소가 중요한게 아니라 해당 문자열이 나타내는 값이 중요하기에 두 변수는 의미상으로 같다고 봐야한다.
이렇듯 실제로는 서로 다른 값이라도, 의미상으로 같다고 판별되는 경우를 동등하다고 한다.
정리해보자. 프로그램을 구현하면서 서로 다른 값을 맥락상 같다고 판단해야 하는 경우가 존재하며, 이를 객체의 동등성을 비교한다고 표현한다. 그리고 객체 동등성을 판단하는 기준은 객체마다 다르기에 equals() 메서드를 오버라이딩하여 사용한다.
hashCode()
이쯤에서 이 포스팅의 제목을 다시 확인해보자. "equals()와 hashCode()를 털어보자". 지금까지 equals()에 대해 알아봤으니, 이제 hashCode()를 털어볼 차례다 🔨 마찬가지로 hashCode()의 코드부터 열어보자. (OpenJDK 17을 사용했습니다)
@HotSpotIntrinsicCandidate
public native int hashCode();
일단 hashCode()는 객체의 해시 코드 값을 반환해주는 메서드이다. 하지만 Object 클래스의 hashCode() 메서드는 이렇게만 정의되어있고 내부 구현을 확인할 수 없다. 이건 hashCode()가 native 메서드이기 때문이다.
java의 native 키워드는 해당 메서드가 JNI (Java Native Interface)로 구현되었음을 나타내는 키워드로, 자바가 아닌 언어(보통 C, C++, 어셈블리어)로 구현된 OS의 메서드를 자바에서 호출할 때 사용된다.
또, 메서드에 붙은 @HotSpotIntrinsicCandidate 어노테이션은 해당 메서드가 HotSpot JVM에 의해 인라인 처리될 가능성이 있음을 의미한다. 해당 어노테이션에 대한 Javadoc을 열어보자.
"The @HotSpotIntrinsicCandidate annotation is specific to the HotSpot Virtual Machine. It indicates that an annotated method may be (but is not guaranteed to be) intrinsified by the HotSpot VM. A method is intrinsified if the HotSpot VM replaces the annotated method with hand-written assembly and/or hand-written compiler IR -- a compiler intrinsic -- to improve performance. The @HotSpotIntrinsicCandidate annotation is internal to the Java libraries and is therefore not supposed to have any relevance for application code."
요약하자면 이 어노테이션이 붙어있으면 HotSpot VM이 해당 메서드를 직접 작성한 어셈블리 또는 컴파일러 IR에 내재화하는 방법으로 메서드 호출을 최적화할 수도 있다는 의미다.
네이티브 메서드인 hashCode()는 JNI를 통해 호출되기에 기존 Java 메서드 호출보다 호출 성능이 떨어지니, @HotSpotIntrinsicCandidate를 이용해 최적화한게 아닐까 추측한다 🤔
정리하면 hashCode()는 native 메서드로 JDK마다 구현이 다르기에 어떤 알고리즘으로 구현되었는지는 딱 정의할 수 없다. 어떻게 구현되었든 목적은 객체의 해시코드를 반환하는 것이라는 점만 염두해두자!
왜 equals()를 재정의할 때 hashCode()도 재정의해야 할까?
hashCode()가 뭔지는 충분히 살펴봤으니, 이제 equals()과의 관계를 알아보자. 이는 hashCode()의 java doc을 보면 알 수 있다.
Returns a hash code value for the object. This method is supported for the benefit of hash tables such as those provided by java.util.HashMap.
즉, hashCode()는 독립적으로 사용되기보다는, HashMap와 같은 hash table을 지원하기 위해 만들어진 메서드이다. 여기서 hash table은 java.util.Hashtable 클래스가 아니라, 해시함수를 통해 얻은 해시값을 사용해 데이터를 key-value 쌍으로 저장하는 자료구조를 의미한다. 해시 맵(= 해시 테이블)의 동작 원리는 간단하다. 해시 맵에 값을 저장할 때는 key-value의 쌍을 만들어 저장하며, 이후 value를 검색할 때는 key를 통해 접근한다.
그리고 Java에서 해시테이블 자료구조의 key는 모두 hashCode()를 통해 만들어진다. 이게 바로 핵심이다.
간단한 예시를 들어보자. 쇼핑몰에 포인트 제도를 도입하려고 한다. 고객은 상품을 구매할 때마다 포인트를 적립받으며, 이후 포인트를 현금처럼 사용할 수 있다고 가정한다. 개발자 A씨는 고객의 이름에 포인트를 맵핑하여 사용하여 해당 기능을 구현한다.
// key로 고객의 이름을, value로 해당 고객의 포인트를 관리한다.
Map<String, Integer> userPoints = new HashMap<>();
하지만 이 코드는 아주 큰 문제가 있다. 바로 동명이인 고객을 고려하지 않았다는 점이다. 똑같은 이름의 고객이 포인트를 공유하는 문제가 발생하고, A씨는 후다닥 코드를 수정한다.
public class User {
private String name;
private Integer residentNumber;
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o instanceof User user) {
return Objects.equals(residentNumber, user.residentNumber);
}
return false;
}
}
Map<User, Integer> userPoints = new HashMap<>();
고객의 이름과 주민등록번호를 클래스로 묶고, 고객을 이름이 아니라 주민등록번호로 구분하기로 결정한다. (User 클래스에 바로 포인트를 넣으면 되지 않냐는 생각이 드시겠지만 예시니까 흐린눈 부탁드립니다ㅎ.. 적당한게 생각나지 않았어요) 이름이 같아도 주민번호가 다르면 equals()의 결과가 false로 찍히는걸 확인한 A씨는 흐뭇한 마음으로 변경사항을 배포한 후 퇴근한다.
하지만 곧 새로운 문제가 발생한다. 고객이 상품을 구매했는데도 포인트가 적립이 안되고, 동시에 포인트를 조회할 때마다 다른 값이 나오는 것이다. A씨는 당황하여 map의 내용물을 전부 출력하고, 이내 문제를 깨닫는다.
static Map<User, Integer> userPoints = new HashMap<>();
public static void main(String[] args) {
User user = new User("우디", 1234);
User user2 = new User("우디", 1234);
System.out.println(user.equals(user2));
accumulate(user, 10);
accumulate(user2, 100);
for (Entry<User, Integer> entry: userPoints.entrySet()) {
System.out.println("key : " + entry.getKey() + ", value : " + entry.getValue());
}
}
public static void accumulate(User key, Integer point) {
if (userPoints.containsKey(key)) {
int existingValue = userPoints.get(key);
userPoints.put(key, existingValue + point);
} else {
userPoints.put(key, point);
}
}
>> true
>> key : dp.User@2ff4acd0, value : 10
>> key : dp.User@54bedef2, value : 100
주민번호가 같아 equals()가 true로 나와도, HashMap에 저장될 때는 서로 다른 키로 분류되고 있던 것이다.
왜 이런 문제가 생긴걸까? 그건 바로 객체를 비교하는 논리적 기준이 바뀌었기 때문이다. User라는 객체를 쓸 때는 멤버 변수 중 주민번호를 이용해 같고 다름을 판단하게 되었다. 따라서 동일한 주민번호를 가진 User는 서로 같은 객체로 취급되고 있는데, hashCode()는 주민번호를 이용하도록 재정의되지 않았기 때문에 주민번호가 같아도 서로 다른 해시값을 뱉어내는 것이다.
문제를 파악한 A씨는 User 클래스 내부에 주민번호를 이용해 해시코드를 얻는 hashCode()를 재정의했다.
@Override
public int hashCode() {
return Objects.hash(residentNumber);
}
해당 코드가 배포된 후, 드디어 포인트 기능은 정상적으로 작동하게 되었다.
이제 우리는 equals()를 재정의할 때 왜 hashCode()도 재정의해야 하는지 알 수 있다. equals()를 재정의한다는 것은 곧 객체의 같고 다름을 비교하는 어떤 논리적 기준이 생긴다는 뜻이며, 이 기준에 맞추어 객체의 해시코드를 생성해야 해시테이블 자료구조를 사용할 때 의도에 맞게 key에 접근할 수 있기에 hashCode()도 그에 맞춰 재정의해야 하는 것이다 🤩 고마워요 A씨!
마무리
IntelliJ의 generate 기능을 보면 equals()와 hashCode()가 같은 탭으로 묶여있다. 갓텔리제이는 역시 항상 뜻이 있음을 깨닫게된다 😎
이제 ==, equals(), hashCode()는 완벽히 설명할 수 있다 ^____^
레퍼런스
- Difference between "native" keyword and @HotSpotIntrinsicCandidate annotation : https://stackoverflow.com/questions/66842504/difference-between-native-keyword-and-hotspotintrinsiccandidate-annotation
'Develop > Java' 카테고리의 다른 글
[Java/Spring] DIP 활용해서 Testability 높이기 (0) | 2024.07.14 |
---|---|
[Java] 참조 타입에 ==을 쓰면 안되는 이유 (feat. String Pool, Integer cache) (0) | 2023.11.25 |
[Java] 맥북에서 자바 버전 여러 개 돌려쓰기 (1) | 2023.05.01 |