본문 바로가기
비난 스터디

Java - equals와 hashCode란? 왜 둘을 함께 재정의해야 할까?

by 정말반가워요 2023. 7. 5.

사전지식 - HashTable, 동등성과 동일성

 

동일성 - Book 객체들의 메모리 주소가 같은가? (즉, 완벽하게 일치하며 메모리상에 하나만 존재하는가?)

동등성 - Book 객체들이 '사실상' 같은가?

 

(여기서 '사실상'은 개발자가 정의하기 나름이다.

예를 들어 출판사, 도서명, 저자가 같을 경우 초판과 개정판이 '사실상' 같다고 판단할 수도 있다.

이 때 초판과 개정판은 동일(identical)하진 않지만 동등(equal)하다고 볼 수 있다.)

 

그래서 동일할 경우 동등하지만, 동등하다고 해서 동일할 순 없다.

 

HashTable - 저보다 니코아저씨가 설명 더 간략하게 잘 해 주십니다.
영상의 HashTable은 추상자료형(ADT)이며, 아래의 주제가 될 자바의 HashMap 클래스는
추상개념인 HashTable을 자바 언어에서 구현한 자료구조입니다.

 


Equals And HashCode

자바의 모든 클래스들은 Object를 암묵적으로 확장(extends)한다.

 

그래서 모든 객체들이 equals()와 hashCode()를 호출할수 있으며,

따로 재정의하지 않았을 시 Object 클래스의 equals()와 hashCode()가 실행된다.

 

Object.equals(Object obj) // 동일성 (==) 비교.
Object.hashCode() // 메모리값을 대상으로 정수 반환(이것도 기준은 동일성(메모리주소)).

 

기본적으로 두 메서드는 모두 동일성을 따진다.

"o1.hashCode() == o2.hashCode()"

위 메서드는 사실상 o1.equals(o2)랑 같기 때문이다.

객체의 내부 값이 아니라 메모리 주소를 따진다는 것이다.

 

그러나 어떤 객체가 메모리 주소가 아닌 멤버변수의 값에 의해 비교되게끔 하고 싶을 때가 있다.

그럴 때 우리는 equals()를 재정의할 수 있다.

@Getter @Setter
@AllArgsConstructor
class Human{
    private final String name;

	// 같은 이름이면 같다고 판단할 것이다(동등성 비교).
    @Override
    public boolean equals(Object o) {
        if (this == o) return true; // 동일할 경우 무조건 동등하다.
        if (o == null || getClass() != o.getClass()) return false; // 타입이 다르거나 null인지 체크

        Human human = (Human) o;
        return getName().equals(human.getName()); // 파라미터로 들어온 객체의 name과 this.name이 같은지?
    }
}

 

equals()를 재정의한다는 것은,

"앞으로 이 객체를 비교할 때는 메모리 주소를 보지 말고

내가 정한 기준(주로 멤버변수의 값)을 보고 비교해주세요"

하고 선언하는 것이다.

 

그런데 equals()를 재정의해서 'String name' 을 기준으로 비교하기로 했다고 치자.

여기서 하나의 문제가 생긴다.

eqauls만 재정의할 경우

  • eqauls -> name이 같을 경우 같은 객체라고 나옴
  • hashCode -> name이 같아도 주소가 다르면 다른 정수를 반환함

원래 Object의 equals와 hashCode는 둘 다 동일성을 따졌는데.
eqauls만 오버라이딩하면 eqauls는 동등성을, hashCode는 동일성을 따지는 것이다.

equals()와 hashCode()는 함께 재정의하거나 함께 재정의하지 말아야 하는 이유가 이것이다.

 

이는 HashSet처럼 내부적으로 equals()와 hashCode()를 모두 사용하는 객체를 사용할 때

예기치 않은 문제를 일으킬 수 있기 때문인데,

오늘은 그 사례에 대해서 직접 HashSet을 사용해보며 결과를 보도록 할 것이다.

 

HashSet을 사용할 때 equals와 hashCode 중 하나만 재정의할 경우 어떻게 일관성이 흐트러지는지 보자.


HashSet으로 테스트해보자

HashSet에 여러 객체를 넣어보면서 실험을 진행해보자.

참고로 HashSet은 내부에서 HashMap을 사용하므로 HashMap을 사용해도 된다.

HashMap은 Key에 대해서 equals()와 hashCode()를 사용하므로, HashMap 사용시 코드는 아래와 같아진다.

HashMap<Human, 쓸데없는값> map;
HashSet<Human> set;

위 둘 중에 HashSet이 더 사용하기 편하므로 HashSet을 사용한다.

'HashSet, HashMap' 이 두 표현이 아래에서 혼용될 수 있지만

HashSet이 내부적으로 HashMap을 사용하기 때문에, 사실상 HashMap의 구현에 관해서 말한다고 봐도 된다.

 


일단 실험용 클래스에 equals-hashCode를 오버라이딩하지 않고,

'서로 동등하나 동일하지 않은' 객체를 생성한 뒤 HashSet에 넣어 보겠다.

참고로 Set은 중복을 불허하는 내장 자료구조이다.

public class MainMain {
    public static void main(String[] args) {
        Human human1 = new Human("상남자");
        Human human2 = new Human("상남자"); // 서로 다른 객체 두 개 생성

        HashSet<Human> humanSet = new HashSet<>();
        humanSet.add(human1);
        humanSet.add(human2); // 객체 두 개 삽입

        System.out.println(humanSet.size()); // size => 2
    }
}
@Getter @Setter
@AllArgsConstructor
class Human{
    private final String name;
}

size가 2인 이유는 무엇일까?

Human에 equals()나 hashCode()를 따로 정의하지 않아 Human 객체는 메모리 주소가 판단기준이 되기 때문이다.

둘이 똑같이 "상남자"라는 이름을 가졌지만 서로 다른 객체이므로 Set에 중복 저장해도 된다고 판단하고,

human1과 human2를 모두 저장한다.

 

그렇다면 같은 이름을 가진 Human이 중복 저장되지 않게 하려면?

당연히 equals() hashCode()를 재정의하면 된다.

public class MainMain {
    public static void main(String[] args) {
        Human human1 = new Human("상남자"); // main()메서드 구현은 직전과 같다
        Human human2 = new Human("상남자");

        HashSet<Human> humanSet = new HashSet<>();
        humanSet.add(human1);
        humanSet.add(human2); // 중복 데이터 삽입을 감지하고 무시한다.

        System.out.println(humanSet.size()); // size => 1. human1만이 들어있다.
    }
}
@Getter @Setter
@AllArgsConstructor
@EqualsAndHashCode // equals() hashCode() 추가. 코드가 길어져서 Lombok 사용
class Human{
    private final String name;
}

 

humanSet.size()는 1이 출력된다.

이제 필드인 name의 값이 기준이 되었기 때문이다.

 

 

 

 

 

그런데, equals()만 구현해도 중복여부 판단이 알아서 조절되지 않을까?

왜 hashCode()까지 구현하라는 것일까?

직접 실험해보기 전에, HashMap.put()의 동작원리에 대해서 알아보자.

HashSet.add()에 의해 간접 호출되는 HashMap.put()

 

HashMap 공식문서상의 소스코드 구현내용을 모두 읽고 파악하기에는 무리가 있어

put()의 동작원리를 찾아봤다.

HashMap 클래스는 기본적으로 HashTable이라는 추상자료형(ADT)을 구현했으므로

HashTable의 동작과 크게 다르지 않으나 상세 구현은 조금 다를 수 있을 것이다.

HashTable의 기본적인 동작을 잘 알고 있다면 아래 내용은 가볍게 읽어도 괜찮을 것.

......

HashMap은 Key의 유일함을 보장하기 위해,

새로운 데이터가 put되면

  1. hashCode()를 기반으로 버킷 주소를 결정한다.
  2. 버킷이 비어있으면 저장하고 작업을 종료한다.
  3. 비어있지 않을 시(해시 충돌 발생),
    해당 버킷 주소의 Key를 대상으로 버킷키.eqauls(새로운 키)를 실행한다.
  4. true일 경우 replace한다. false일 경우 새로운 Key-Value 쌍을 저장한다.
    (여기서 'false일 경우 새로운 Key-Value 쌍을 저장하는 방식은
    트리 구조를 활용한 linear probing' 이라고 한다.)

    (주의: HashSet과 HashMap의 작은 차이점
    HashSet은 해시충돌 후 equals가 true일 경우 삽입 무시하고 기존 데이터 유지,
    HashMap은 해시충돌 후 equals가 true일 경우 replace한다.)

 

그렇다면, main() 메서드의 구현이 그대로(같은 name, 다른 객체)라고 가정하고

equals()를 재정의하고 hashCode()를 재정의하지 않으면 어떻게 될까?

위의 구현 시나리오를 다시 보고 결과를 짐작해보자.

  1. 동일한 객체가 아닐 경우 hashCode()가 다른 값을 리턴하므로, 매번 다른 버킷 주소가 결정된다
    (낮은 확률로 해시 충돌이 가능하지만 매번 다를 것이라 가정합니다)
  2. 새로운 버킷 주소를 할당받았기 때문에,
    버킷이 비어있다고 판단하게 되고 HashSet 안에 데이터를 매번 새로 저장한다.

정말 결과가 그럴지 코드를 작성해서 돌려보자.

public class MainMain {
    public static void main(String[] args) {
        Human human1 = new Human("상남자");
        Human human2 = new Human("상남자");

        HashSet<Human> humanSet = new HashSet<>();
        humanSet.add(human1);
        humanSet.add(human2);

        // size => 2. 
        // equals를 재정의했지만 같은 name의 중복을 허용했다.
        System.out.println(humanSet.size());
    }
}
@Getter @Setter
@AllArgsConstructor
class Human{
    private final String name;

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

        Human human = (Human) o;

        return getName().equals(human.getName());
    }

//    @Override
//    public int hashCode() {
//        return getName().hashCode();
//    }
}

분명 equals()는 이름을 기준으로 비교하는데,

hashCode()를 구현하지 않아서

 

human1.equals(human2) 는 TRUE지만

HashSet 안에 두 객체를 넣으면 2개 모두가 저장된다.

eqauls()는 true지만 HashSet에선 값이 중복 저장되는 환장의 일관성을 보여주는 것이다.

자바 인터프리터야, 이상한 코드를 읽게 해서 미안해!


흐름을 알았으니.
그럼 hashCode만 재정의하고 eqauls를 재정의하지 않을 때는 어떻게 될지 추측해볼 수 있다.

name이 똑같이 "상남자" 이기 때문에 같은 버킷 인덱스를 찾아가지만,

equals()에서 무조건 false가 나오기 때문에

'하나의 인덱스에 여러 key-value 쌍' 이 저장되고, size는 2가 나올 것이다.

 

여기서 둘 중 하나의 이름을 "하남자"로 바꾼다면?

서로 다른 인덱스에 객체가 하나씩 저장되고, size는 2가 나올 것이다.

 

마지막으로, 재미삼아 equals()는 무조건 true로, hashCode()는 무조건 30을 반환하게 해 보자.

결과는 어떻게 나올까?

public class MainMain {
    public static void main(String[] args) {
        Human human1 = new Human("상남자");
        Human human2 = new Human("하남자");

        HashSet<Human> humanSet = new HashSet<>();
        humanSet.add(human1);
        // 같은 hashCode이므로 같은 버킷 인덱스를 찾고
        // equals()가 true이므로 human2 추가는 무시된다.
        humanSet.add(human2); 

        System.out.println(humanSet.size()); // 그러므로 size => 1
    }
}
@Getter @Setter
@AllArgsConstructor
class Human{
    private final String name;
    
    @Override
    public boolean equals(Object o) {
        return true;
    }
    
    @Override
    public int hashCode() {
        return 30;
    }
}

주석에서 볼 수 있듯이

hashCode()가 고정되어있으므로 버킷 인덱스는 같고, 

같은 인덱스에 두 번째 객체를 add() 시도할 때, equals()가 무조건 true이므로

인덱스 내부의 자료는 교체된다.

 

 

글에는 분량상 여러 경우의 수를 다 담진 못했다.

equals만 재정의한 경우,

hashCode만 재정의한 경우,

equals나 hashCode를 이상하게 정의한 경우,

멤버변수가 서로 같은 경우, 서로 다른 경우에 대해

 

예상 시나리오를 가정해보고, 코드를 작성해서 검증해보면 재미있을 것이다.

 


해결하지 못한 의문

public class MainMain {
    public static void main(String[] args) {
        Human human1 = new Human("상남자");
        Human human2 = new Human("상남자");

        HashMap<Human, String> humanMap = new HashMap<>();
        humanMap.put(human1, "val1");
        humanMap.put(human2, "val2");

        System.out.println(humanMap.size()); // 2
        
        System.out.println(humanMap.get(human1)); // val1 반환. 왜 null이 아닌지? 
    }
}
@Getter @Setter
@AllArgsConstructor
class Human {
    private final String name;

    @Override
    public boolean equals(Object o) {
        return false;
    }

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

이번엔 HashMap으로 테스트를 해 보았다.

이번에도 '이상한 equals와 이상한 hashCode' 조합이긴 한데,

 

put()을 두 번 실행하면 같은 버킷 인덱스에 사이즈가 2인 키-값 리스트가 생길 것이라 가정했고,

이후 get(Object obj)을 실행하면

obj의 hashCode값으로 버킷 인덱스를 찾은 후, 

하나의 인덱스에 여러 키-값 쌍이 있으므로,

for (Map.Entry<Human, String> entry : mapInBucket.entrySet()) {
	if(entry.getKey().equals(keyHuman))
    		return entry.getValue();
}

대충 이런 식으로 get()의 Key와 

버킷 인덱스 내부의 연결리스트에 있는 Key가 같은지를

equals()로 리스트 순회를 하고, true가 나올 시 그 value를 반환하게 되는데

 

내 케이스에서는 equals()가 무조건 false를 반환하므로,

get()메서드는 무조건 null을 반환하리라 예상했지만

예시 코드에서의 humanMap.get(human1) 은

"val1"이라는 value를 너무 잘도 반환했다.

 

 

 

HashMap의 동작을 실험하기 위해 의도적으로 equals()와 hashCode()를 이상하게 구현한

케이스이지만, 풀지 못한 의문점이 있어 아쉽다.

하지만 적어도 풀지 못하는 의문점이 나올 때까지는 파고든 것 같아서 나름의 보람은 있었다.

 

지금 이 극히 예외적인 케이스에서의 문제점을 끝까지 파헤치는 건 시간 낭비 같아서 여기까지만 알아보려 한다.

나중에 공부를 더 하고 이 글을 봤을 때, get()의 동작방식이 보였으면 좋겠다.

 


글을 작성한 뒤, 평소 보던 유튜버의 영상 중에 해시테이블 관련 영상이 나와서

내가 아는 것을 다시 점검해보았다.

https://youtu.be/ZBu_slSH5Sk

https://youtu.be/IkImFugfFQk

(쉬운코드님 영상 좋아요)

 

내가 공부하고 있는 것이 구현체인지(Java의 HashMap 클래스),

자료구조에서 일반적으로 통용되는 ADT(개념으로써의 HashTable)인지를 구분해야겠다는 생각이 들었다.

 

ADT를 신경써서 배워 두면 프로그래밍 언어에서 구현한 구현체들을 이해하는 데에 도움이 된다는 것을

대학교에서 자료구조를 수강하면서는 알지 못했다.

나중에 프로그래밍을 교육하는 사람이 되면 이러한 개념적 이해의 중요성과 실제 활용에 대해서

수강생들에게 꼭 가르쳐주고 싶다.