업데이트:

얼마전 equalshashCode에 대해 팀원 분과 대화를 나누다가 저도 자세히 알아보고자 이번 포스트를 작성하게 되었습니다.

equals


사실 equals를 모르시는 분들은 없을 거라 생각하지만 그래도 한번 알아보도록 하겠습니다.

equals는 자바 클래스 중 가장 위에 있는 클래스, Object의 메소드인데 다음처럼 작성되있습니다.

public boolean equals(Object obj) {
    return (this == obj);
}

== 연산자로 객체를 비교하는 것은 결국 객체의 주소값을 비교하는 것이기 때문에 정말 똑같은 객체인지를 물어보는 것입니다.

하지만 보통 equals는 재정의하여 동등성(내부 값이 같은지)을 확인합니다.

대표적인 예가 String이죠. Stringequals를 보면 다음과 같습니다.

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


오해

제가 학부 시절부터 오해하고 있던 사실이 있었습니다.(물론 그동안 공부안한 탓이지만…)

JavahashCode는 객체의 내부 메모리 주소를 반환한다는 것으로 알고 사용법 역시 항상 그러리란 고정관념을 갖고 있었습니다.

당연히 이는 잘못된 생각이었고, 이에 따라 제대로 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 같은 이점을 위해 지원한다고 적혀 있는데요.

실제로 HashMapget메소드에 들어가보시면 KeyhashCode를 사용하는 것을 확인할 수 있습니다.

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임 됨을 확인할 수 있습니다.

test

결과를 보면 정상적으로 테스트가 성공했음을 알 수 있고, 분면 다른 인스턴스임에도 불구하고 지정한 멤버 변수를 기준으로 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.

해석을 해보자면

  1. Application이 실행 중일 때 동일한 객체에 대해서 동일한 hashCode 메소드는 항상 같은 값을 반환해야 합니다. 이 값은 Application이 다른 실행일 경우에는 값을 유지할 필요가 없습니다. (다시 실행하면 값이 달라도 됩니다.)
  2. equals에서 동일한 객체임이 판별됐다면, 두 객체의 hashCode는 동일해야 합니다.
  3. equals에서 다른 객체임이 판별됐다고 해서 두 객체의 hashCode가 무조건 달라야 하진 않습니다. 단, 다른 객체의 경우 다른 hashCode를 반환해야 HashTable의 성능이 좋아집니다.

먼저 3번에 대해서 알아보겠습니다.

hashCode의 값은 결국 어떤 해시 함수를 사용하느냐에 따라 다르겠지만 100% 다른 값이 나올꺼란 보장은 없습니다.

따라서 equals에서 다른 객체라고해서 반드시 같은 hashCode가 나올것이란 보장을 할 수 없는 것입니다.

하지만 다른 객체의 경우 다른 hashCode가 나와야 성능이 좋아지는데, 동일한 hashCode가 나올 경우 HashTable 내부에서

Separate Chaning(분리 체이닝), Open Addressing(공개 주소) 등을 이용해서 충돌을 피하고 있지만 이는 성능이 낮아지기 때문입니다.

그리고 equals를 재정의할 시 hashCode도 재정의해야 하는 이유는 2번에 있는데, 생각해보면 그리 어려운 문제는 아닙니다.

equals로 어떤 객체의 값을 비교하여 동일한 객체로 판단할 경우 hashCode 역시 동일한 값을 반환해야만 HashMap에 사용해야 하기 때문에 재정의가 필요한 것입니다.

재정의를 하지 않는 다면 equals에서 동일한 객체로 판단이 되었음에도 불구하고 HashMap에 사용할 수 없겠죠?

마무리


이번에 제대로 equalshashCode를 제대로 알아봤는데요.

equals는 아직 어디서 사용하게 될지 감이 잘 오지 않습니다. 많은 라이브러리에서 equals를 사용하여 값의 비교를 사용하고 있기 때문에 함부로 override하여 사용하기 쉽지 않더군요.

하지만 hashCode는 많은 데이터를 분류할때 정말 유용하게 쓰일 것 같습니다. 위의 예시를 예전 같았다면 ListElement를 하나 씩 꺼내서 값을 비교해보면서 분류를 했을 텐데 hashCode를 기준으로 분류하니 코드의 수도 줄어들고 성능도 좋아졌음을 알 수 있습니다. (참고로 HashMap, HashTable의 시간복잡도는 O(1) 입니다.)

물론 이는 결국 equals 역시 사용해야 한다는 의미지만요 🤣

다만, 사용하는 곳에 대해선 이동욱님의 의견따르고 싶습니다.

이동욱님의 블로그를 보시면 다음과 같은 글이 있습니다.

단, equalshashCode는 모두 VO(Value Object)에서만 사용하는 것을 권장합니다. 값을 나타내는 것 외에 기능을 갖고 있는 인스턴스에서는 문제가 발생할 여지가 많아 웬만해선 사용하지 않는 것을 권장합니다.

위에서 언급했다싶이 다양한 라이브러리에서 hashCode, equals를 가지고 메소드 내부에서 사용하는 곳이 많은데, 잘못했다간 예상하던 기능이 동작안하게 될 소지가 높습니다.

어떤 기능을 갖고 있는 인스턴스에 그런 문제가 생긴다면 영향도는 더 커지겠죠??

이번 포스트를 보시고 많은 분들이 도움되셨으면 좋겠습니다.

오늘도 제 긴 글을 봐주셔서 감사합니다. 😀

📌참고


권남

equals와 hashCode 사용하기 ( +lombok)

댓글남기기