Java - equals와 hashCode
업데이트:
얼마전 equals
와 hashCode
에 대해 팀원 분과 대화를 나누다가 저도 자세히 알아보고자 이번 포스트를 작성하게 되었습니다.
equals
사실 equals
를 모르시는 분들은 없을 거라 생각하지만 그래도 한번 알아보도록 하겠습니다.
equals
는 자바 클래스 중 가장 위에 있는 클래스, Object
의 메소드인데 다음처럼 작성되있습니다.
public boolean equals(Object obj) {
return (this == obj);
}
==
연산자로 객체를 비교하는 것은 결국 객체의 주소값을 비교하는 것이기 때문에 정말 똑같은 객체인지를 물어보는 것입니다.
하지만 보통 equals
는 재정의하여 동등성(내부 값이 같은지)을 확인합니다.
대표적인 예가 String
이죠. 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;
}
/* StringLatin1.equals */
public static boolean equals(byte[] value, byte[] other) {
if (value.length == other.length) {
for (int i = 0; i < value.length; i++) {
if (value[i] != other[i]) {
return false;
}
}
return true;
}
return false;
}
/* StringUTF16.equals */
public static boolean equals(byte[] value, byte[] other) {
if (value.length == other.length) {
int len = value.length >> 1;
for (int i = 0; i < len; i++) {
if (getChar(value, i) != getChar(other, i)) {
return false;
}
}
return true;
}
return false;
}
안에 보면 String
의 각 value
의 길이 및 값을 비교하는 것을 확인할 수 있습니다.
hashCode
오해
제가 학부 시절부터 오해하고 있던 사실이 있었습니다.(물론 그동안 공부안한 탓이지만…)
Java의 hashCode
는 객체의 내부 메모리 주소를 반환한다는 것으로 알고 사용법 역시 항상 그러리란 고정관념을 갖고 있었습니다.
당연히 이는 잘못된 생각이었고, 이에 따라 제대로 hashCode
를 사용해본적이 없습니다. (사실 아예 없…)
사용 용도
물론 기본적으로 hashCode
는 객체 내부의 메모리 주소를 반환하는 것이 맞습니다. 하지만 사용 용도는 아닙니다.
Object
에서 hashCode
메소드의 설명을 보면 다음과 같은 내용이 있습니다.
This is typically implemented by converting the internal address of the object into an integer,
but this implementation technique is not required by the JavaTM programming language.
일반적으로 객체 내부의 주소를 반환하지만, Java 프로그래밍에서는 필요하지 않다고 합니다.
저는 이 말을 보고든 생각이, “실제로 내가 객체 주소를 알아야 할 일이 있을까?”였습니다.
여러가지 이유가 있겠지만, 제가 프로그래밍 하면서 객체의 주소를 비교한일이 없을 뿐더러 불변 객체 생성 및 사용을 지향하는 요즘은 더욱 더 사용될 일이 없을꺼라 생각이 듭니다.
그럼 진짜 사용용도는 무엇일까요?
이 역시도 설명을 보면 답이 나와있습니다.
This method is supported for the benefit of hash tables such as those provided by HashMap.
HashMap
같은 이점을 위해 지원한다고 적혀 있는데요.
실제로 HashMap
의 get
메소드에 들어가보시면 Key
의 hashCode
를 사용하는 것을 확인할 수 있습니다.
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
이는 결국 키의 hashCode
를 기준으로 동일한 객체인지를 판단하겠다는 것입니다.
예제
💡아래 이동욱님의 블로그글을 보시면 좀 더 좋은 예제를 보실 수 있으십니다. 저는 저부터 난이도를 최대한 낮춰 쉬운 예제를 가져왔습니다.
특정 값(Tag, Category)를 기준으로 hashCode
를 만들어 동일한 hashCode
를 가진 게시글(Post
)가 몇개가 있는지 알아보는 테스트 코드입니다.
Post 클래스
먼저 게시글을 나타내는 Post
클래스입니다.
보시면 Lombok의 @EqualsAndHashCode(exclude = {"title", "content", "author"})
를 사용해서 tag
, category
필드만 가지고 hashCode
를 생성하도록 합니다.
forTestConstructor
메소드는 i
값을 기준으로 [SHOPPING, COMMUNITY], [RESTOCKING, QNA], [GAME, FREE] 총 3개 타입의 Post
를 반환하는 메소드입니다.
@EqualsAndHashCode(exclude = {"title", "content", "author"})
public class Post {
private String title; // 제목
private Tag tag; // 태그
private Category category; // 카테고리
private String content; // 본문
private String author; // 작성자
public static Post forTestConstructor(int i) {
final String title = "게시글" + i;
final String content = "게시글 내용" + i;
final String author = "게시 작성자" + i;
if (i % 3 == 0) {
return new Post(title, Tag.SHOPPING, Category.COMMUNITY, content, author);
}
if (i % 2 == 0) {
return new Post(title, Tag.RESTOCKING, Category.QNA, content, author);
}
return new Post(title, Tag.GAME, Category.FREE, content, author);
}
}
Lombok은 실제론 아래와 같은 코드가 만들어졌다고 생각하시면 됩니다.
@Override
public int hashCode() {
int result = tag != null ? tag.hashCode() : 0;
result = 31 * result + (category != null ? category.hashCode() : 0);
return result;
}
테스트 코드
다음은 테스트 코드 입니다.
100개의 Post
를 생성하여 List
에 담아 [SHOPPING, COMMUNITY], [RESTOCKING, QNA], [GAME, FREE] 총 3개 타입의 Post
를 키로 사용하여 각각 몇개가 생성되었는지 확인해보겠습니다.
public class BoardServiceTest {
private List<Post> list = new ArrayList<>();
@BeforeEach
void setUp() {
for (int i = 0; i < 100; i++) {
list.add(Post.forTestConstructor(i));
}
}
@Test
void HashCode_Test() {
// given
Post shoppingAndCommunity = Post.forHashCode(Tag.SHOPPING, Category.COMMUNITY);
Post restockingAndQna = Post.forHashCode(Tag.RESTOCKING, Category.QNA);
Post gameAndFree = Post.forHashCode(Tag.GAME, Category.FREE);
Map<Post, Integer> map = new HashMap<>();
for (Post post: list) {
map.put(post, map.getOrDefault(post, 0) + 1);
}
// when
Integer result1 = map.get(shoppingAndCommunity);
Integer result2 = map.get(restockingAndQna);
Integer result3 = map.get(gameAndFree);
// then
assertThat(result1).isEqualTo(34);
assertThat(result2).isEqualTo(33);
assertThat(result3).isEqualTo(33);
assertThat(result1 + result2 + result3).isEqualTo(100);
}
}
3개의 shoppingAndCommunity
, restockingAndQna
, gameAndFree
객체를 가지고 map
에서 value
를 꺼내면 각각 34, 33, 33, 총 합은 100임 됨을 확인할 수 있습니다.
결과를 보면 정상적으로 테스트가 성공했음을 알 수 있고, 분면 다른 인스턴스임에도 불구하고 지정한 멤버 변수를 기준으로 hashCode
가 생성, 사용되어 map
에서 put
, get
이 성공적으로 동작했음을 알 수 있습니다.
Effective Java의 hashCode
이펙티브 자바에선 equals
를 재정의할 시 hashCode
역시 재정의하라고 합니다.
이유가 뭘까요?
먼저 Object
클래스의 hashCode
메소드의 설명을 보면 다음과 같습니다.
The general contract of hashCode is:
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(Object) 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(java.lang.Object) 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.
해석을 해보자면
- Application이 실행 중일 때 동일한 객체에 대해서 동일한
hashCode
메소드는 항상 같은 값을 반환해야 합니다. 이 값은 Application이 다른 실행일 경우에는 값을 유지할 필요가 없습니다. (다시 실행하면 값이 달라도 됩니다.) equals
에서 동일한 객체임이 판별됐다면, 두 객체의hashCode
는 동일해야 합니다.equals
에서 다른 객체임이 판별됐다고 해서 두 객체의hashCode
가 무조건 달라야 하진 않습니다. 단, 다른 객체의 경우 다른hashCode
를 반환해야HashTable
의 성능이 좋아집니다.
먼저 3번에 대해서 알아보겠습니다.
hashCode
의 값은 결국 어떤 해시 함수를 사용하느냐에 따라 다르겠지만 100% 다른 값이 나올꺼란 보장은 없습니다.
따라서 equals
에서 다른 객체라고해서 반드시 같은 hashCode
가 나올것이란 보장을 할 수 없는 것입니다.
하지만 다른 객체의 경우 다른 hashCode
가 나와야 성능이 좋아지는데, 동일한 hashCode
가 나올 경우 HashTable
내부에서
Separate Chaning(분리 체이닝), Open Addressing(공개 주소) 등을 이용해서 충돌을 피하고 있지만 이는 성능이 낮아지기 때문입니다.
그리고 equals
를 재정의할 시 hashCode
도 재정의해야 하는 이유는 2번에 있는데, 생각해보면 그리 어려운 문제는 아닙니다.
equals
로 어떤 객체의 값을 비교하여 동일한 객체로 판단할 경우 hashCode
역시 동일한 값을 반환해야만 HashMap
에 사용해야 하기 때문에 재정의가 필요한 것입니다.
재정의를 하지 않는 다면 equals
에서 동일한 객체로 판단이 되었음에도 불구하고 HashMap
에 사용할 수 없겠죠?
마무리
이번에 제대로 equals
와 hashCode
를 제대로 알아봤는데요.
equals
는 아직 어디서 사용하게 될지 감이 잘 오지 않습니다. 많은 라이브러리에서 equals
를 사용하여 값의 비교를 사용하고 있기 때문에 함부로 override
하여 사용하기 쉽지 않더군요.
하지만 hashCode
는 많은 데이터를 분류할때 정말 유용하게 쓰일 것 같습니다. 위의 예시를 예전 같았다면 List
의 Element
를 하나 씩 꺼내서 값을 비교해보면서 분류를 했을 텐데 hashCode
를 기준으로 분류하니 코드의 수도 줄어들고 성능도 좋아졌음을 알 수 있습니다. (참고로 HashMap
, HashTable
의 시간복잡도는 O(1) 입니다.)
물론 이는 결국 equals
역시 사용해야 한다는 의미지만요 🤣
다만, 사용하는 곳에 대해선 이동욱님의 의견따르고 싶습니다.
이동욱님의 블로그를 보시면 다음과 같은 글이 있습니다.
단,
equals
와hashCode
는 모두VO(Value Object)
에서만 사용하는 것을 권장합니다. 값을 나타내는 것 외에 기능을 갖고 있는 인스턴스에서는 문제가 발생할 여지가 많아 웬만해선 사용하지 않는 것을 권장합니다.
위에서 언급했다싶이 다양한 라이브러리에서 hashCode
, equals
를 가지고 메소드 내부에서 사용하는 곳이 많은데, 잘못했다간 예상하던 기능이 동작안하게 될 소지가 높습니다.
어떤 기능을 갖고 있는 인스턴스에 그런 문제가 생긴다면 영향도는 더 커지겠죠??
이번 포스트를 보시고 많은 분들이 도움되셨으면 좋겠습니다.
오늘도 제 긴 글을 봐주셔서 감사합니다. 😀
댓글남기기