[Java] 효율적인 equals와 hashCode

6 minute read

java에서는 모든 클래스가 Object 클래스를 상속한다.

그리고 이 Object 클래스에는 여러가지 기본적인 메소드들이 있는데, 오늘은 그 중 equals 와 hashCode 메소드에 대하여 공부했던 점에 대해 정리해 보고자 한다.

Object 클래스

자바에서 모든 클래스는 Object클래스를 상속하며, 따로 extends 키워드를 사용하여 상속하지 않더라도 컴파일러가 자동으로 Object를 상속하도록 만든다.

이는 모든 클래스가 공통적으로 가져야 하는 메소드들을 지원하기 위함이다.

Object 클래스에는 다음과 같은 11개의 메소드들이 정의되어 있다.

Modifier and Type Method and Description
protected Object clone() Creates and returns a copy of this object.
boolean equals(Object obj) Indicates whether some other object is “equal to” this one.
protected void finalize() Called by the garbage collector on an object when garbage collection determines that there are no more references to the object.
Class<?> getClass() Returns the runtime class of this Object.
int hashCode() Returns a hash code value for the object.
void notify() Wakes up a single thread that is waiting on this object’s monitor.
void notifyAll() Wakes up all threads that are waiting on this object’s monitor.
String toString() Returns a string representation of the object.
void wait() Causes the current thread to wait until another thread invokes the notify() method or the notifyAll() method for this object.
void wait(long timeout) Causes the current thread to wait until either another thread invokes the notify() method or the notifyAll() method for this object, or a specified amount of time has elapsed.
void wait(long timeout, int nanos) Causes the current thread to wait until another thread invokes the notify() method or the notifyAll() method for this object, or some other thread interrupts the current thread, or a certain amount of real time has elapsed.

이들 메소드들은 OOP의 효율적인 지원을 위해 꼭 필요한 메소드들이며, 많은 중요한 메소드가 있지만, 오늘은 equals와 hashCode에 대해서 정리해본다.

equals 메소드

equals는 논리적 동치성을 검사하기 위해 존재한다고 볼수 있다. 논리적 동치성이란 그야말로 그 객체가 가지는 값이 같은가를 의미한다.

우리가 잘 알고 있는 java.lang.String 클래스를 예로 들어 보자.

		String str1 = "123";
		String str2 = "456";
		String str3 = new String("123");
		
		System.out.println(str1 == str2); // false
		System.out.println(str1 == str3); // false

위 코드의 출력은 모두 false이다.

이는 == 연산자가 단순히 객체의 메모리 주소를 비교하기 때문이며, 변수가 가리키는 인스턴스가 다르다면 false를 반환한다.

결국 str1과 str3 은 같은 문자열을 가진 것처럼 보이지만, str3이 new 키워드를 통해 새로운 메모리 공간을 할당받아, Heap memory에 인스턴화 되어 존재하게 되었으므로, str1과 str3는 다른 인스턴스가 된다.

우리는 두 String이 똑같은 문자열 값을 가지는지 알고싶다. 그렇다면 어떻게 비교해야 할까?

		String str1 = "123";
		String str2 = "456";
		String str3 = new String("123");
		
		System.out.println(str1.equals(str2)); // false
		System.out.println(str1.equals(str3)); // true

위와 같이 equals 메소드를 이용하여 비교하게 되면, 두 String 객체가 같은 문자열값을 가지는지 비교할 수 있다.

이는 java.lang.String 클래스에서 equals 메소드를 아래와 같이 Override 하고 있기에 가능한 일이다.

    public boolean equals(Object anObject) {
        if (this == anObject) { // 같은 인스턴스인경우 true 리턴
            return true;
        }
        if (anObject instanceof String) { // String 타입의 인스턴스인 경우
            String anotherString = (String)anObject;
            int n = value.length;
            if (n == anotherString.value.length) { // 문자열 값의 길이가 같은 경우, 값 비교 시작
                char v1[] = value;
                char v2[] = anotherString.value;
                int i = 0;
                while (n-- != 0) {
                    if (v1[i] != v2[i])
                        return false; // 중간에 다른 값이 있는 경우 false 리턴
                    i++;
                }
                return true; // 모든 값이 같음, true 리턴
            }
        }
        return false;
    }

이 equals 메소드는 전략적으로 잘 짜여진 equals이다.

이펙티브 자바에서는 효율적인 equals로 재정의하기 위해서는 다음의 순서대로 구현하는것이 좋다고 언급되어 있다.

  1. == 연산자를 이용하여 입력이 자기 자신의 참조인지 확인한다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다. 이때 null 체크까지 해결되므로, null인지 체크할 필요가 없어진다.
  3. 입력을 명시적 형변환 한다.
  4. 입력 객체와 자기 자신의 대응되는 논리적 핵심 필드들이 모두 일치하는지 하나하나 검사한다. 이 때, 하나라도 다르다면 false를 리턴하고, 모두 같다면 true를 리턴한다.

이를 위의 String.equals와 비교해보면 완벽히 순서가 일치하는것을 알 수 있다.

추가적으로 덧붙이자면, float 와 double을 제외한 Primitive type은 == 연산자를 이용하여 비교하고, Reference 타입 필드는 각각의 equals 메소드를 이용하여 비교한다.

float와 double을 비교할 때 == 을 사용하지 않는 이유는, float과 double은 부동소수점을 표현하는데에 사용되기도 하기 때문이다.

부동소수점 표현 방식은 오차를 가질 수 있으므로, 정확한 값 비교 연산자인 ==를 사용하는것은 바람직하지 않다.

float와 double을 비교해야만 하는 경우에는 Float.compare 메소드와 Double.compare메소드를 이용하도록 하자.

따라서, equals를 구현할 때에는 논리적인 값을 표현하는 필드에는 어떤 것 이 있는지 생각해보고, 해당되는 필드들에 대하여 위와 같은 순서로 구현하는 것이 좋다.

hashCode 메소드

자바 Object 명세에서는 equals 메소드를 재정의 하였다면 hashCode 메소드 또한 재 정의 하도록 규정하고 있다.

hashCode 메소드는 자바의 Collection 프레임 워크에 존재하는 HashMap과 HashSet등을 지원하기 위하여 만들어 졌으며, 이 메소드는 해당 객체의 hash값을 반환해야한다.

또한, 이 객체와 다른 한 객체의 equals 결과가 true라면, 그 둘의 hashCode 값은 반드시 같아야 한다.

(반대로, hashCode 값이 같다고 해서 equals의 결과가 true일 필요는 없다.)

hashCode의 용도를 간단하게 축약해서 말하자면, “다양한 데이터 타입을 배열에 넣기 위한 인덱스” 정도로 말 할수 있을 것 같다.

다음과 같이, char 타입의 알파벳들을 배열에 저장하는 상황을 떠올려 보자.

만약 ‘E’가 어디에 있는지 찾으려면, 단순 비교 검색으로는 5번의 비교연산이 발생한다.

하지만 hashCode를 이용하여 배열에 저장한다면 더 쉽게 찾을 수 있을 것이다.

위와 같이 각 알파벳의 hashCode를 얻어, 배열의 길이로 나눈 나머지를 인덱스로 정한다면, 각 알파벳이 어디있는지는 hashCode만 계산해내면 알 수 있게 된다.

물론 위의 예는 간략한 예이고, 실제로는 해시 충돌 등 여러 문제를 해결해야 안정적이고 빠른 해시맵(셋)의 성능을 기대할 수 있다.

해시 충돌은 서로 다른 객체에서 동일한 해시코드를 얻는 현상을 일컫으며, 좋은 해시 함수일수록 해시 충돌이 적다.

해시 충돌을 위의 예제에서 찾아보자. 알파벳 ‘E’의 hashCode는 5였다. 하지만 알파벳 ‘M’의 hashCode 또한 5(77 % 8 = 5)이다. 이 둘은 다른 값을 가진 객체지만, 같은 해시코드 값을 가지게 되었다. 이것이 바로 해시 충돌이다.

하지만 안타깝게도 완벽한 hashCode 함수를 만드는것은 불가능에 가깝다. 다만 다행히도 hashCode 함수 또한 효율적으로 동작하도록 작성하는 방법이 있는데, 그것은 다음의 단계를 거친다.

  1. int 변수 result를 선언하고, 값 c로 초기화 한다. 이 때 c는 단계 2.1로 계산한 값이다.

  2. 해당 객체의 논리적 핵심 필드 f들 각각에 대하여 다음을 수행한다.

    1. 해당 필드의 해시코드 c를 계산한다. 각 필드의 타입에 따라서 아래 사항을 따른다.

      1. Primitive 타입 필드라면, Type.hashCode(f)를 수행한다. int로 예를 들면, Integer.hashCode()이다.

      2. 참조타입 필드면서 이 클래스의 equals 메서드 안에서 이 필드의 equals를 사용한다면, 이 필드의 hashCode메서드 또한 이 단계에서 사용한다. 필드의 값이 null이면 0을 사용한다.

        만약 참조타입 필드면서 이 클래스의 equals 메서드 안에서 이 필드의 equals를 사용하지 않는다면, 해당 필드는 계산에서 제외 해야만 한다.

      3. 필드가 배열이라면, 각각을 별도 필드처럼 다루며, 즉 최상위 1번 단계부터 끝단계 까지를 배열의 원소마다 계산해낸다. 배열의 모든 원소가 논리적 핵심 필드라면 Arrays.hashCode를 사용한다.

    2. 2.1번 단계에서 계산한 해시코드 c를 이용하여 result 변수를 다음과 같이 계산한다.

    result = 31 * result + c;

  3. result를 리턴한다.

위와 같은 과정을 통해 이상적인 hashCode 메소드를 구현할 수 있다.

String.hashCode도 위와 비슷한 과정을 따른다.

    public int hashCode() {
        int h = hash; // h를 기본 hash값으로 초기화. 기본 hash변수는 0으로 초기화 되어 있다.
        if (h == 0 && value.length > 0) {
            char val[] = value; // value는 String클래스의 전역변수로, 실제 문자열 값을 char의 배열형태로 가지고 있다.
    
            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i]; //각각 char타입 변수에 대해 해시코드를 계산한다.
            }
            hash = h; // 기본 hash변수를 초기화 해준다.
        }
        return h; // h(result)를 리턴한다.
    }

hash의 경우 해시 충돌을 극복하는 방법 등 공부해야 할 게 많지만.. 시간이 된다면 그에 대해서도 좀 더 파서 글을 작성해야겠다.

이상 equals와 hashCode 메소드를 효율적으로 작성하는 방법에 대해 알아 보았다.

이 모든 구현이 귀찮거나 어렵다면, AutoValue 라이브러리로 생성하는것도 좋은 방법이다. AutoValue라이브러리는 위에서 진행한 모든 과정을 자동으로 대신해준다.

AutoValue에 대해서는 다음 기회에…

Leave a comment