equals( )와 hashCode( )가 무엇이고, 하나만 재정의하면 어떻게 될까?

Seyeong·2023년 1월 13일
0

자바

목록 보기
2/2

자바에서는 두 객체를 비교할 때 equals( ) 와 hashCode( ) 를 이용하여 비교하게 됩니다. ( 이들을 이해하기 위해선 동일성과 동등성에 대한 사전 지식이 필요하므로 혹시나 모르신다면 동일성(Identity)과 동등성(Equality) 을 참고해주세요 )

equals( ) 와 hashCode( ) 를 설명하기 위한 간단한 객체를 하나 만들어봅시다.

User 클래스

사용자 정보를 담는 클래스를 선언해봅시다.

public class User {
    private String name; // 이름
    private int age; // 나이
    
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

이 User 클래스를 이용하여 equals( ) 와 hashCode( ) 를 알아봅시다.

equals( )?

equals( ) 메서드는 객체 간의 '동등성' 을 비교할 때 사용하는 함수입니다.

아래와 같이 객체를 선언해봅시다.

User user1 = new User("홍길동", 20);
User user2 = new User("홍길동", 20);

이 두 객체는 서로 동등합니다. 그렇다면 아래와 같은 코드는 어떻게 될까요?

System.out.println(user1.equals(user2));
이 식에 관한 출력 결과는 true와 false중 어떤 것인가요?

결론부터 말하면 false 입니다.

User 클래스의 equals( ) 메서드를 봐봅시다. 근데 뭔가 이상하지 않나요?
저희는 위의 User 클래스에서 equals( ) 메서드를 만들지 않았습니다.
그럼에도 불구하고 에러 하나 없이 코드가 잘 실행됩니다.

그 이유는 모든 객체는 Object 클래스를 상속받고 있기 때문입니다.
여기에 비밀이 있는데요. 저희가 호출한 equals( ) 메서드는 User 클래스가 아닌, 부모 클래스인 Object 클래스의 equals( ) 메서드를 호출한 것입니다. 그럼 다시 돌아와 Object 클래스의 equals( ) 메서드를 봐볼까요?

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

이 함수를 저희 코드에 연결해보면, user1 == user2 를 물어보고 있습니다. 여기서 == 연산은 메모리 주소가 같은지를 물어보는 연산입니다. 즉, user1과 user2의 메모리 주소는 new 키워드를 통해 새로운 User 객체를 생성한 것이므로 다른게 정상입니다. 따라서 equals( ) 의 결과로 false가 나온 것입니다.

뭔가 이상합니다. 메모리 주소를 비교하는 것은 동일성 체크인데 동등성에서 메모리 주소를 비교하고 있습니다. 따라서 equals( ) 메서드를 동등성 비교를 하도록 User 클래스 내에서 재정의 해주어야 합니다.

public class User {
    private String name;
    private int age;
    
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        
        // 여기가 동등성을 비교하는 부분
        User user = (User) o;
        return age == user.age && Objects.equals(name, user.name);
    }

복잡해보이지만 쉽게 말하면 필드 값이 서로 같은 객체면 equals( ) 함수로 true를 반환 하도록 재정의 해주었습니다.

이렇게 equals( ) 메서드를 재정의해준 뒤, 다시 비교해보면
System.out.println(user1.equals(user2));
이번엔 이 둘의 필드 값이 서로 같기 때문에 true가 반환될 것입니다.

이렇듯 클래스에서 equals( ) 메서드를 재정의하면 객체 간의 동등성을 체크할 수 있습니다.

하지만 중요한 사실을 간과해서는 안됩니다.
이 둘은 서로 동등 하다는 것이지 동일 하다고 확신할 수 없습니다.

따라서 이 둘의 메모리 주소를 출력해보면 서로 다르다는 걸 확인할 수 있습니다.

// 동등성 체크
System.out.println(user1.equals(user2));

// 동등성 출력
true

// 객체 메모리 주소 체크
System.out.println(user1);
System.out.println(user2);

// 객체 메모리 주소 출력 ('동등'하지만 '동일'하진 않다.)
user1 = User@7bb58ca3
user2 = User@c540f5a

hashCode( )?

hashCode( ) 메서드는 객체 간의 '동일성' 을 비교할 때 사용하는 함수입니다.

모든 객체는 자기만의 해시 코드를 가지고 있습니다.

위의 user1과 user2의 해시 코드를 출력해볼까요?

// 객체 생성
User user1 = new User("홍길동", 20);
User user2 = new User("홍길동", 20);

// 해시 코드 체크
System.out.println(user1.hashCode());
System.out.println(user2.hashCode());

// 해시 코드 출력
user1 = 2075495587
user2 = 206835546

이 둘은 서로 해시 코드가 다릅니다. 즉, 이 둘은 서로 동일하지 않습니다.

두 객체가 서로 동일하려면 같은 해시 코드를 가지고 있어야 합니다. 일반적으로 해시코드는 객체의 메모리 주소에 따라 결정됩니다. 따라서 메모리 주소가 서로 다른 두 객체는 해시 코드가 다르게 나오지만, 간혹 드물게 다른 객체들끼리 같은 해시 코드를 반환하기도 합니다. 이 부분에 대해서는 '해시 충돌' 이라는 키워드로 검색해보면 더 깊게 공부할 수 있습니다.

(아래의 hashCode( ) 만 재정의하면 어떻게 되는데? 부분에서 메모리 주소와 해시 코드 간의 관계에 대해 다시 설명합니다.)

이쯤에서 Object 명세에 적힌 hashCode( ) 규약을 살펴볼까요?

The general contract of hashCode is:

  • equals( ) 로 비교했던 객체의 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 해당 객체의 hashCode( ) 는 몇 번을 호출해도 항상 같은 값을 반환해야 한다. 단, 애플리케이션을 재실행한다면 달라져도 상관없다.
  • equals( ) 로 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 같은 값을 반환해야 한다. (★)
  • equals( ) 로 두 객체가 다르다고 판단했더라도, 두 객체의 hashCode는 같을 수 있다. 단, 다른 객체에 대해서 다른 해시 값을 반환하면 성능이 좋아진다.

여기서 중요한 것은 두 번째 규약입니다.
객체 간의 정보 즉, 내부 필드값들이 서로 모두 일치한다면 이 객체들은 반드시 같은 해시 코드를 반환해야합니다.

하지만 위의 코드를 보면 객체간의 정보가 서로 같음(equals( ))에도 불구하고 해시 코드가 다른 것을 볼 수 있습니다.

그 이유는 Object의 기본 hashCode( ) 메서드는 객체의 필드값이 아닌 객체의 메모리 주소값을 이용하여 해시 코드를 반환하기 때문입니다

따라서 hashCode( ) 메소드를 재정의하여 두 번째 규약을 만족시켜 봅시다

public class User {
	
    ...
 
 	@Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

이러면 두 객체의 해시코드 값이 동일하게 나오게 됩니다.

// 객체 생성
User user1 = new User("홍길동", 20);
User user2 = new User("홍길동", 20);

// 해시 코드 체크
System.out.println(user1.hashCode());
System.out.println(user2.hashCode());

// 해시 코드 출력
1678652903
1678652903

이게 어떻게 가능한지 내부 함수를 살펴볼까요?

Objects.hash( ) 에서 한번 더 들어가면 Arrays가 나옵니다.
코드를 봐보면 아래와 같습니다.

public class Arrays {
	
    ...

	public static int hashCode(double a[]) {
        // null 값이라면 해시코드 0 반환
        if (a == null) 
            return 0;

		// 그게 아니라면 모든 필드의 해시값을 연산하여 반환
        int result = 1;
        for (double element : a) {
            long bits = Double.doubleToLongBits(element);
            result = 31 * result + (int)(bits ^ (bits >>> 32));
        }
        return result;
    }

	...

}

쉽게 말하면 만들었던 User 객체가 가진 필드들. 즉, ("홍길동", 20) 이라는 필드 각각의 해시값을 연산하여 반환한다. 한마디로 hashCode( ) 함수 재정의로 인해 두 객체가 메모리 주소가 달라도 동일한 필드를 가지고 있다면 서로 동일 하다는 것 이다.

그럼 좀 의문이 든다. equals( ) 와 hashCode( ) 함수들은 둘 다 서로 다른 객체에 대해 동등성 을 체크하고 있는 것 같다. equals( ) 는 그렇다치고 hashCode( ) 함수조차 필드값들로부터 해시함수를 추출하기 때문에 둘 다 동등성을 체크하는 것 같지 않은가?

그렇다면 동등성 체크 중복인 것 같은데 둘 중에 하나만 재정의해도 문제 없지 않을까?

equals( ) 만 재정의하면 어떻게 되는데?

equals( ) 만 재정의한 User 클래스를 봐보자.

public class User {
    private String name;
    private int age;
    
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    @Override
    public boolean equals(Object o) { // equals( ) 만 재정의
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        User user = (User) o;
        return age == user.age && Objects.equals(name, user.name);
    }
    
    /*@Override
    public int hashCode() {
        return Objects.hash(name, age);
    }*/
}

이제 객체들을 비교해보면

// 객체 생성
User user1 = new User("홍길동", 20);
User user2 = new User("홍길동", 20);

// 객체 메모리 주소 체크
System.out.println(user1);
System.out.println(user2);

// 객체 메모리 주소 출력 (주소가 다르다)
user1 = User@7bb58ca3
user2 = User@c540f5a

// 동등성 체크
System.out.println(user1.equals(user2));

// 동등성 출력 (동등하다)
true

equals( ) 메서드를 재정의하면 두 객체가 물리적으로는 달라도 논리적으로 같은 객체면 같다(equals)고 할 수 있습니다.

보통은 두 객체가 같은지를 비교할 때 논리적인 동등성을 따지는 경우가 많기 때문에 equals( ) 만 재정의 해주어도 충분히 사용 가능한 객체가 될 것 같습니다.

그럼 이제 이 객체들을 컬렉션에 담아볼까요?

// 객체 생성
User user1 = new User("홍길동", 20);
User user2 = new User("홍길동", 20);

// Set 자료구조에 user 객체들을 넣음
Set<User> userSet = new HashSet<>(List.of(user1, user2));

userSet 은 이름과 나이가 중복되지 않는 유저들만 담겠다고 가정해봅시다.

그럼 위의 코드에서 userSet의 size( ) 는 1이 되기를 기대하겠죠.
하지만 userSet의 사이즈는 2가 나옵니다.

user1과 user2 이 둘은 서로 같은 객체인데 어째서 Set 자료구조에 둘 다 들어갈 수 있는 걸까요?

이는 Set 인터페이스의 구현체인 HashSet은 객체를 담을 때 그 객체의 동등성 뿐만이 아니라 동일성(hashCode) 도 비교하기 때문입니다.

따라서 user1과 user2는 서로 메모리 주소가 다르고, 그에 따라 해시 코드가 다르기 때문에 같은 이름과 나이 정보를 가지고 있음에도 서로 다른 객체라고 판단하여 둘 다 담아버린 것입니다.

잠깐 Hash 구현체에서 같은 객체가 존재하는지 판단하는 아주 일부분의 코드만 봐봅시다.

if (e.hash == hash && // 해시 값을 먼저 비교하고 나서
    ((k = e.key) == key || (key != null && key.equals(k)))) // 동등성을 비교
    return e;

실제로 Hash 구현체들은 객체들을 비교할 때 동일성을 먼저 비교하고 이게 통과되면 동등성 을 비교합니다.

그래서 해시 함수를 재정의하여 메모리 값이 아닌, 내부 필드값을 이용해 해시 코드를 만들어야 이러한 HashSet, HashMap 등의 Hash 구현체 자료구조에서 문제없이 사용할 수 있습니다.

hashCode( ) 만 재정의하면 어떻게 되는데?

많은 글에서는 equals( ) 메서드를 기본으로 재정의하고, hashCode( ) 를 왜 같이 재정의 하는가에 대한 이유가 나와있는데 hashCode( ) 만을 재정의한 글은 거의 못본 것 같아 갑자기 궁금해져서 hashCode( ) 만을 재정의 해보았습니다.

지금부터 하는 예제는 equals( ) 는 재정의하지 않은 채 hashCode( ) 만 재정의하였습니다.

hashCode( ) 재정의 전

// 객체 생성
User user1 = new User("홍길동", 20);
User user2 = new User("홍길동", 20);

// 객체 메모리 주소 체크 (같지 않다)
System.out.println(user1);
System.out.println(user2);
user1 = User@7bb58ca3
user2 = User@c540f5a

// 객체 해시 코드 체크 (같지 않다)
System.out.println(user1.hashCode());
System.out.println(user2.hashCode());
user1 = 2075495587
user2 = 206835546

// 객체 equals( ) 체크 (같지 않다)
System.out.println(user1.equals(user2));
false

hashCode( ) 를 재정의 하지 않았으므로 해시코드, 메모리 주소가 모두 다릅니다. 또한, equals( ) 메서드도 재정의하지 않았으므로 두 객체를 비교해도 같지 않다고 나옵니다. 그럼 이제 hashCode( ) 를 재정의하면 어떻게 달라질까요?

hashCode( ) 재정의 후

// 객체 생성
User user1 = new User("홍길동", 20);
User user2 = new User("홍길동", 20);

// 객체 메모리 주소 체크 (같다??)
System.out.println(user1);
System.out.println(user2);
user1 = User@640e35e7
user2 = User@640e35e7

// 객체 해시 코드 체크 (같다)
System.out.println(user1.hashCode());
System.out.println(user2.hashCode());
user1 = 1678652903
user2 = 1678652903

// 객체 equals( ) 체크 (같지 않다??)
System.out.println(user1.equals(user2));
false

hashCode( ) 함수를 재정의해서 해시 코드가 같아진 건 알겠다.
그런데 메모리 주소는 갑자기 왜 같아지며, equals( ) 메서드는 왜 false 인가?

특히 equals( ) 메서드를 봐보면 재정의 하지 않았으므로 Object의 equals( ) 가 호출될 것이다. 여기 코드를 봐보면 더 이해가 안된다.

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

}

두 객체 간의 == 연산을 통해 메모리 주소를 비교하고 있다. 그런데 위에서 확인했듯이, 두 객체의 메모리 주소가 같다. 정확히는 갑자기 같아졌다(?)

그런데 equals( ) 메서드에서 비교를 하면 같지 않다고 나온다. 이게 무슨 말일까?

도저히 이해도 안되고 궁금해져서 찾아보았고, 아래는 그로부터 정리한 것이다.

객체의 메모리 주소가 같은 것이 아니다.

user1과 user2의 메모리 주소가 User@640e35e7 로 동일한 것을 봤다. 그러나, 이는 메모리 주소가 아닌 해시 코드였다.

우리는 User 객체를 아래와 같이 출력했었다.
System.out.println(user1);
이는 사실 아래의 코드에서 생략된 것이다.
System.out.println(user1.toString());

즉, toString( ) 은 Object 의 메서드를 사용하여 객체를 출력하는 것이며 Object의 toString( ) 은 아래와 같다.

public class Object {
	...
    
    public String toString() {
       return getClass().getName() + "@" + Integer.toHexString(hashCode());
    }
    
	...
}

여기서 주의하여 볼 점은 "@" 뒤에 붙은 값이다. 바로 hashCode( ) 값을 16진수로 변환하여 붙여준 것이지, 메모리 주소를 의미한 것이 아니라는 것이다.

이로써 왜 hashCode( )를 재정의했더니 메모리 주소(?)가 갑자기 같아지는지에 대한 의문이 풀렸습니다.

equals( ) 는 왜 false 였나?

이제 남은건 equals( ) 메서드가 false로 나온 이유인데요. 이걸 알기 위해선 객체의 메모리 주소를 알아야 합니다. == 연산이 메모리 주소를 비교하기 때문이죠.
그럼 메모리 주소는 어떻게 알까요?

찾아본 바로는 정확한 자바 가상 머신내에 존재하는 객체의 메모리 주소를 알기란 어려워보였습니다. 하지만 약간 우회해서 아는 방법이 있었는데요. 아래 코드를 봐보죠

// 객체 생성
User user1 = new User("홍길동", 20);
User user2 = new User("홍길동", 20);

// 해시 코드 체크
System.out.println(user1.hashCode());
System.out.println(user2.hashCode());
user1 = 1678652903
user2 = 1678652903

// 고유 해시 코드 체크
System.out.println(System.identityHashCode(user1));
System.out.println(System.identityHashCode(user2));
user1 = 2075495587
user2 = 2141179775

차이점을 아시겠나요? hashCode( ) 는 필드 값에 따라 임의로 재정의한 해시 코드입니다. 그런데 identityHashCode( ) 는 원래 객체가 처음부터 가지고 있던 고유한 해시 코드를 의미합니다. 이 말은 돌려 말하면 저희가 User 클래스에서 hashCode( ) 를 재정의 하였는데, 재정의 하지 않은 채로 hashCode( ) 를 출력하면 이 값은 identityHashCode( ) 값과 같다는 것을 의미합니다.

원래는 identityHashCode( ) 값이었던 해시 코드를 재정의해서 hashCode( ) 값으로 만들었다는 거죠.

즉, 두 객체가 각자의 필드를 이용해 새로 만든 해시코드는 같을지 몰라도 사실 이 두 객체의 고유 해시 코드는 같지 않으며, 이는 메모리 주소도 다른 것을 의미합니다. 그렇다고 오해해서는 안됩니다. 해시 코드가 메모리 주소에 의해 결정된다는 말이 아닙니다. 단지 같은 메모리인 객체는 서로 해시 코드가 같다는 말이죠.

두 객체가 메모리 주소가 다르기 때문에 equals( ) 메서드도 false가 나온 것입니다.

이로써 모든 의문이 풀렸습니다.

마무리

내용이 너무 많고 헤맨 부분도 많아서 글이 두서없이 적어졌을까 우려스럽지만 그 과정에서 새로 배운점도 많아서 의미 있는 시간이었습니다.

저도 공부해보며 정리하였는데 혹시나 잘못된 부분이 있다면 알려주시면 감사하겠습니다.



0개의 댓글