equals 와 hashCode 재정의하기

유콩·2023년 11월 20일
0
post-thumbnail

equals 메서드란?

자바에는 ‘같다’ 라고 표현하는 기준이 두 가지가 있다.

실제 메모리 상의 주소값이 일치하여 물리적으로 동일한 경우와 비즈니스에 따라서 재정의한 방법 총 2가지이다.

물리적으로 동일한 것을 표현할 때에는 다른 언어와 마찬가지로 (==) 을 사용한다.

비교 대상 두 객체의 메모리 주소가 같은지 판단할 때 (==) 을 사용하며 ‘동일성(Identity)' 을 의미한다.

물리적으로 동일한 경우가 아닌 제공하는 서비스의 비즈니스에 따라서 동일함을 표현해야 하는 경우가 발생할 수 있다.

예를 들어, 로또 번호를 발급하는 서비스를 개발한다고 가정해보자.

로또의 6개의 번호는 일치하면 안되므로 중복 숫자를 선택했을 경우 새로운 숫자를 다시 선택해야 한다.

즉, 현재 서비스에서는 로또 인스턴스의 번호가 일치한 경우 논리적으로 같다고 표현할 수 있다.

이러한 경우에 Java 에서 제공하는 equals 메서드를 재정의해서 사용할 수 있다.

이를 동등(Equality)하다, 라고 표현한다.

두 인스턴스의 동등성을 판단할 때 다음과 같이 사용할 수 있다.

Lotto lotto = new Lotto(3);
Lotto other = new Lotto(3);

if (lotto.equals(other)) {
	System.out.println("로또의 숫자가 동일함"); // -> 해당 내용 출력
else {
	System.out.println("로또의 숫자가 다름");
}

equals 메서드를 재정의할 때에는 비즈니스 요구사항에 맞춰서 구현하면 된다.

public class Lotto {
    
    private final int number;
    
    public Lotto(int number) {
        this.number = number;
    }
    
    @Override
    public boolean equals(final Object other) {
        final Lotto lotto = (Lotto) other;
        return number == lotto.number;
    }
}

모든 클래스의 최상위 클래스인 Object 에 equals 메서드가 정의되어 있으므로,

별도의 클래스를 상속하거나 구현받지 않아도 된다.

매개변수의 타입은 Object 에서 정의되어 있는 Object 타입으로 받아온다.

비즈니스 요구만을 반영하여 Lotto 클래스의 number 필드값을 비교했다.

하지만 위와 같은 방법은 문제가 발생할 가능성이 있다.

equals 메서드 재정의하기

equals 메서드를 재정의하려고 할 때 intelliJ 자동 생성 기능을 이용하면 간단하게 재정의할 수 있다.

그런데 생성하려고 보면 다양한 방법으로 이처럼 재정의 하는 법을 제공한다.

Apache, Guava 관련은 외부 라이브러리가 필요해서 관련 설명은 제외하였다.

그러면 자동 생성 기능을 이용할 수 있는 것은 IntelliJ Default 와 Objects.equals() 를 사용하는 두 가지 방법이 있다.

실제로 선택해서 자동 생성하면 동일한 코드를 제공한다.

public class Lotto {

    private final int number;

    public Lotto(int number) {
        this.number = number;
    }

    @Override
    public boolean equals(final Object o) {
	    // 1. 인스턴스의 물리적 주소 비교
        if (this == o)
            return true;
        // 2. 인스턴스의 타입 비교
        if (o == null || getClass() != o.getClass())
            return false;
		// 3. 비즈니스 요구사항 적용
        final Lotto lotto = (Lotto)o;
        return number == lotto.number;
    }
}

위에서 직접 정의한 방법과 두 가지 차이가 있다.

  1. 인스턴스의 물리적 주소 비교

논리적 비교를 위해 equals 메서드를 재정의하고 있지만 결국 물리적 주소가 같으면 같은 인스턴스이다.

참조하고 있는 값 자체가 동일하면 이후의 과정을 할 필요가 없어 바로 true 를 반환한다.

  1. 인스턴스의 타입 비교

3번 파트에 매개변수로 받아온 Object 를 현재 클래스와 동일한 타입으로 변환하는 과정이 있다.

만약 매개변수로 들어온 값이 Lotto 와 완전히 다른 클래스였을 경우 에러가 발생한다.

이를 대비하기 위해 null 이거나 클래스 타입을 비교하여 다를 경우 바로 false 를 반환한다.

이러한 안전 장치를 모두 적용한 후 실제 비즈니스 요구사항을 구현한다.

hashCode 메서드란?

equals 메서드하면 hashCode 메서드도 항상 같이 언급된다.

hashCode 메서드는 자바에서 사용하는 주소값을 나타낸다.

하지만 hashCode 값이 실제 메모리 주소값을 의미하지는 않는다.

자바는 개발자가 아닌 GC 가 자바 어플리케이션의 메모리를 관리해준다.

GC 의 동작 과정을 간단하게 설명하면 JVM Heap Area 에 단계에 따라 구역이 나누어져 있으며,

참조되는 횟수에 따라 여러 구역으로 이동되며 관리된다.

즉, 변수가 참조하는 인스턴스의 메모리 주소값이 매번 변경되며 관리된다.

따라서 자바에서 실제 메모리 주소의 값은 큰 의미를 가지지 않는다.

이를 대체하기 위해 메모리 주소값이 아닌 인스턴스를 식별하기 위한 정보를 hashCode 로 따로 관리한다.

자바에서 hashCode 는 메모리 주소의 역할을 수행한다.

만약 equals 메서드를 재정의했다면 이는 서비스 상에서 동일한 인스턴스로 판단하는 기준이 된다.

동일한 인스턴스라면 동일한 주소값을 반환하는 것을 기대하기 때문에 equals 와 hashCode 는 같이 재정의해야 한다.

이후에 설명하는 HashSet 등의 자료구조에서 중복 인스턴스를 판단할 때

equals 와 hashCode 두 메서드를 모두 보고 판단한다.

hashCode 메서드 재정의하기

hashCode 메서드는 equals 메서드에 재정의한 기준대로 동일한 결과를 반환할 수 있도록 구현하면 된다.

IntelliJ Default

public class Lotto {

    private final int number;

    public Lotto(int number) {
        this.number = number;
    }
		
	@Override
    public boolean equals(final Object o) {
        if (this == o)
            return true;
        if (o == null || getClass() != o.getClass())
            return false;

        final Lotto lotto = (Lotto)o;

        return number == lotto.number;
    }

    @Override
    public int hashCode() {
        return number;
    }
}

IntelliJ Default 설정으로 자동 생성하면 다음과 같이 number 값 그대로 반환한다.

하지만 number 필드의 접근 제어자는 private 으로 외부에 노출되면 안되는 값이다.

따라서 다음과 같이 Objects 를 이용하여 hashCode 값을 직접 생성할 수 있다.

java.util.Objects.equals() and hashCode()

import java.util.Objects;

public class Lotto {

    private final int number;

    public Lotto(int number) {
        this.number = number;
    }

	@Override
    public boolean equals(final Object o) {
        if (this == o)
            return true;
        if (o == null || getClass() != o.getClass())
            return false;
        final Lotto lotto = (Lotto)o;
        return number == lotto.number;
    }

    @Override
    public int hashCode() {
        return Objects.hash(number);
    }
}

hashCode 값을 쉽게 생성하기 위해 Objects 클래스에서 hash 메서드를 제공한다.

Objects 클래스는 Object 클래스 관련 편의 기능을 제공하는 클래스이다.

Lotto 객체는 number 필드를 기준으로 동일 인스턴스임을 판단하므로

number 를 이용하여 hashCode 를 생성하였다.

기본 클래스의 equals 와 hashCode

Object 클래스는 모든 클래스의 최상위 클래스이기 때문에 모든 클래스는 equals 와 hashCode 를 상속받았다.

그런데 Integer 나 String 과 같이 자바 기본 내장 클래스를 사용할 때에는

equals 와 hashCode 를 재정의하지 않아도 equals 로 동등성을 판단하였다.

Integer 와 String 은 각각의 클래스 비즈니스 요구사항에 맞는 기준으로 미리 재정의 되어있기 때문이다.

Integer 의 equals 와 hashCode

public final class Integer extends Number
        implements Comparable<Integer>, Constable, ConstantDesc {

		...

	public boolean equals(Object obj) {
        if (obj instanceof Integer) {
            return value == ((Integer)obj).intValue();
        }
    	return false;
    }

	@Override
    public int hashCode() {
        return Integer.hashCode(value);
    }

    public static int hashCode(int value) {
        return value;
    }

		...
}

Integer 클래스의 equals 메서드는 Integer 클래스의 value 값을 비교하며,

hashCode 메서드는 Integer 클래스에 저장되어 있는 value 을 그대로 반환한다.

public class Main {

    public static void main(String[] args) {

        Integer num1 = 128;
        Integer num2 = 128;

        System.out.println(num1 == num2); // false
        System.out.println(num1.equals(num2)); // true
        System.out.println(num1.hashCode()); // 128
        System.out.println(num2.hashCode()); // 128

        Integer num3 = 127;
        Integer num4 = 127;

        System.out.println(num3 == num4); // true
    }
}

두 참조 변수의 물리적 주소값이 다르기 때문에 == 비교 시에는 false 가 출력되었다.

다만 Integer 의 equals 와 hashCode 는 Integer 의 value 가 동일하면 동일 인스턴스로 취급하기 때문에

true 와 value 값인 128 이 출력되었다.

또한, 테스트 시 Integer 클래스 내부에서 캐싱되어 있지 않은 값을 사용하기 위해 128 숫자를 사용하였다.

Integer 의 경우 내부적으로 자주 사용하는 숫자(-128~127)을 미리 캐싱해두고 사용한다.

String 의 equals 와 hashCode

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence,
               Constable, ConstantDesc {

		...

		public boolean equals(Object anObject) {
        	if (this == anObject) {
            	return true;
        	}
        	return (anObject instanceof String aString)
                && (!COMPACT_STRINGS || this.coder == aString.coder)
                && StringLatin1.equals(value, aString.value);
    	}

		public int hashCode() {
        	int h = hash;
        	if (h == 0 && !hashIsZero) {
            	h = isLatin1() ? StringLatin1.hashCode(value)
                           	: StringUTF16.hashCode(value);
            	if (h == 0) {
                	hashIsZero = true;
            	} else {
                	hash = h;
            	}
        	}
        	return h;
    	}

		...
}

String 클래스의 equals 메서드는 또한 String 클래스의 value 값을 비교하며,

hashCode 메서드는 String 클래스에 저장되어 있는 value 을 기반으로 hashCode 를 생성하여 반환한다.

public class Main {

    public static void main(String[] args) {

        String abc1 = new String("abc");
        String abc2 = new String("abc");

        System.out.println(abc1 == abc2); // false

        System.out.println(abc1.equals(abc2)); // true
        System.out.println(abc1.hashCode()); // 96354
        System.out.println(abc2.hashCode()); // 96354

        System.out.println("abc" == "abc"); // true
    }
}

두 참조 변수의 물리적 주소값이 다르기 때문에 == 비교 시에는 false 가 출력되었다.

다만 String 의 equals 와 hashCode 는 String 의 value 가 동일하면 동일 인스턴스로 취급하기 때문에

true 와 value 값으로 생성한 hashCode 인 96354 가 출력되었다.

물리적 주소가 다른 객체로 값을 비교하기 위해 new String(); 생성자를 이용하였다.

String 은 JVM 의 상수 풀에서 관리하기 때문에 생성자가 아닌 큰따옴표로 생성하면 상수 풀에 접근하여 조회한다.

== 사용 시 결과값이 다르나, equals 나 hashCode 의 결과가 일치한다.

물리적 주소는 다르지만 논리적 판단은 동일하도록 재정의했기 때문이다.

Hash 관련 자료구조의 내부 동작

앞에서 hashSet 의 경우 중복 저장에 대한 기준을 판단하기 위해 equals 와 hashCode 를 사용한다고 언급했다.

hashSet 은 내부적으로 hashMap 을 사용하여 hashMap 의 값 저장 메서드 코드를 가져왔다.

public V put(K key, V value) {
		// 1
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        if (p.hash == hash && // 2
            ((k = p.key) == key || (key != null && key.equals(k)))) // 3
            e = p;
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

1번에 보면 putVal 메서드 호출 시 key 의 hash 값을 전달한다.

2번에 key 의 hashCode 값을 비교하여 주소값의 동일성을 판단하였다.

3번에 key 객체의 equals 메서드를 호출하여 동등성을 판단하였다.

따라서 비즈니스 요구사항에 따라 equals 를 재정의할 때 hashCode 메서드 또한 재정의해야 한다.

참고 사이트

0개의 댓글