Java

[Java] equals()와 hashCode()를 같이 재정의 해야하는 이유

Lea Hwang 2023. 11. 20. 23:53

요즘 모의 면접 스터디를 진행하고 있습니다. 이번주 자바 관련 질문을 주고받을 때 객체의 동일성과 동등성에 대한 질문에 저는 이렇게 대답했습니다.

🤔 객체의 동일성과 동등성의 개념에 대해 아시나요?

"동일성은 두 객체가 정말 동일한 객체인지, 즉 같은 메모리 주소를 가졌는지의 여부를 확인합니다.

동등성은 두 객체의 값이 같은지 비교합니다. 

 

자바에서는 ==연산자를 통해서 두 객체의 동일성을 비교할 수 있고, equals()를 통해 두 객체의 동등성을 비교할 수 있습니다. 여기서 주의할 점은 equals()를 따로 오버라이드 하지 않으면 두 객체의 hashCode() 값을 비교하게 되므로 두 객체의 equals(), hashCode() 모두 오버라이드 해야 합니다."

 

 

그 당시에는 이렇게 답변을 하고 마무리를 지었지만 왜 equals()를 오버라이딩 할 때 hashCode()까지 오버라이딩을 해야 하는지 몰랐습니다. 

 

이번 포스팅을 통해 그 이유를 코드로 확인해보고자 합니다. 

⭐ hashcode()란?
객체의 주소값을 Hash function에 넣어 반환한 int형의 고유한 해시코드입니다.
hashcode 메서드의 목적은 해시 기반의 자료구조에서 빠르게 객체를 검색하거나 저장하기 위한 것입니다. 
따라서 동일한 내용을 가진 객체에 대해서는 동일한 해시 코드를 반환하도록 구현하는 것이 중요합니다.

 


핵심 정리 

equals를 재정의 할 때는 hashCode도 반드시 재정의 해야 합니다. 그 이유는 hash자료구조를 사용하는 컬렉션은 객체의 동등을 비교할 때 순서가 hashCode 메서드의 리턴 값이 일치하고 equals 메서드의 리턴 값 또한 true여야 논리적으로 같은 객체라고 판단하기 때문입니다.

 

 

위의 결론이 맞는지 경우를 나누어서 확인해보겠습니다.

1. equals(), hashCode() 둘 다 오버라이딩 안 할 경우

2. equals()만 오버라이딩 할 경우

3. hashCode()만 오버라이딩 할 경우 

4. equals(), hashCode() 둘 다 오버라이딩 할 경우

 

둘 다 오버라이딩 안 할 경우

import java.util.Objects;

public class Animal {
    private final String name;

    public Animal(String name) {
        this.name = name;
    }
    public static void main(String[] args) {
        Animal animal1 = new Animal("dog");
        Animal animal2 = new Animal("dog");

        // [출력] false
        System.out.println(animal1.equals(animal2));
    }
}

 

🧐 false가 나오는 이유는 무엇일까요?

equals 메서드가 명시적으로 오버라이드되지 않으면 Object 클래스에서 상속된 기본 동작이 수행됩니다.

이때는 두 객체가 같은 메모리 위치를 참조하는 경우에만 true를 반환합니다.객체의 내용이 동일하더라도 새로운 객체를 생성하여 참조가 다르므로 equalsfalse를 반환합니다.

 

 

하지만 우리가 원하는 것은 이것이 아닙니다.  두 객체의 필드를 비교하여 두 객체의 내용이 동일한지를 판단하고 싶습니다.

equals만 재정의 할 경우

import java.util.Objects;

public class AnimalEquals { 
    private final String name;

    public AnimalEquals(String name) {
        this.name = name;
    }
    
    // equals 재정의
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if(obj == null || getClass() != obj.getClass()) return false;
        AnimalEquals animal = (AnimalEquals) obj;
        return Objects.equals(name, animal.name);
    }

    public static void main(String[] args) {
        AnimalEquals animal1 = new AnimalEquals("dog");
        AnimalEquals animal2 = new AnimalEquals("dog");

        // [출력] true
        System.out.println(animal1.equals(animal2));
    }
}

 

바로 우리가 원하는 true가 나왔습니다! 

 

그렇다면... hashcode는 따로 재정의 하지 않아도 될 것 같은데요?

하지만 컬렉션을 사용할 때 문제가 발생할 수 있습니다. 

 

List

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public class AnimalList {
    private final String name;

    public AnimalList(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if(obj == null || getClass() != obj.getClass()) return false;
        AnimalList animal = (AnimalList) obj;
        return Objects.equals(name, animal.name);
    }
    public static void main(String[] args) {
        List<AnimalList> animalList = new ArrayList<>();
        animalList.add(new AnimalList("dog"));
        animalList.add(new AnimalList("dog"));

        // [출력] 2
        System.out.println(animalList.size());
    }
}

 

List는 중복 데이터를 허용합니다. List의 size가 2로 올바르게 출력이 되었습니다.

 

그렇다면 중복을 허용하지 않는 Set이라면 결과가 어떻게 될까요?

Set

package example.java.equals;

import java.util.*;

public class AnimalSet {
    private final String name;

    public AnimalSet(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if(obj == null || getClass() != obj.getClass()) return false;
        AnimalSet animal = (AnimalSet) obj;
        return Objects.equals(name, animal.name);
    }
    public static void main(String[] args) {
        Set<AnimalSet> animalSet = new HashSet<>();
        animalSet.add(new AnimalSet("dog"));
        animalSet.add(new AnimalSet("dog"));

        // [출력] 2
        System.out.println(animalSet.size());
    }
}

 

중복을 허용하지 않으니 Set의 사이즈가 1이 나올 것이라 예상했는데 2가 나왔습니다. 왜일까요?

hash 자료구조를 사용할 경우, equals() 메서드로 비교 전에 hashcode() 메서드에서 먼저 다른 객체로 인식했기때문입니다.

 

hash자료구조를 사용하는 컬렉션은 객체의 동등을 비교할 때 다음의 순서대로 비교합니다.

hashCode 메서드의 리턴 값이 일치하고 equals 메서드의 리턴 값 또한 true여야 논리적으로 같은 객체라고 판단하는 것입니다.

 

 Animal 클래스에는 hashCode 메서드가 재정의 되어있지 않아서 Object 클래스의 hashCode 메서드가 사용되었고, 이때 객체의 고유한 해시코드를 반환하여 다른 객체로 인식한 것입니다. 

 

hashcode만 재정의 할 경우

package example.java.equals;

import java.util.HashMap;
import java.util.Objects;

public class AnimalHash {
    private final String name;

    public AnimalHash(String name) {
        this.name = name;
    }

    // hashCode() 재정의
    @Override
    public int hashCode() {
        return Objects.hash(name);
    }

    public static void main(String[] args) {
        HashMap<AnimalHash, Integer> hashMap = new HashMap<>();
        hashMap.put(new AnimalHash("dog"),1);
        hashMap.put(new AnimalHash("dog"),2);
        hashMap.put(new AnimalHash("cat"),3);

        // [출력] 3
        System.out.println(hashMap.size());
        // [출력] null
        System.out.println(hashMap.get(new AnimalHash("dog")));
        // [출력] null
        System.out.println(hashMap.get(new AnimalHash("cat")));

    }
}

 

위 코드를 통해 확인해 볼 사항은 두 가지입니다.

1. Objects.hash()로 hashCode() 재정의

2. hashMap.get이 null이 나온 이유

 

1.

intelliJ Generate 기능의 도움을 받아 Objects.hash()를 리턴하는 로직으로 hashCode()를 재정의하였습니다. 

Objects.hash()는 hashCode 메서드를 재정의하기 위해 간편히 사용할 수 있는 메서드이지만 속도가 느리다는 단점이 있습니다. 인자를 담기 위한 배열이 만들어지고 인자 중 기본 타입이 있다면 박싱과 언박싱도 거쳐야 하기 때문입니다. 다행히도 성능에 아주 민감하지 않은 대부분의 프로그램은 간편하게 Objects.hash()를 사용해서 hashCode 메서드를 재정의해도 문제없지만 민감한 경우에는 상황에 따라 직접 재정의 해주는 것을 권장합니다. 

Objects.hash()
매개변수로 전달된 값들을 기반으로 해시 코드를 생성합니다. 만약 매개변수로 전달된 값이 같다면,
Objects.hash() 는 동일한 해시 코드를 반환합니다.

 

2.

hashCode가 같다면 버킷(LinkedList) 안에서 equals로 비교를 할 텐데, 이때 동일한 객체를 찾을 수 없기에 null을 리턴하였습니다. ( equals() 재정의 안 해주었기 때문에 )

 

둘 다 오버라이딩 할 경우

import java.util.HashMap;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public class AnimalEqualsHashcode {
    private final String name;

    public AnimalEqualsHashcode(String name) {
        this.name = name;
    }
    // equals 재정의
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if(obj == null || getClass() != obj.getClass()) return false;
        AnimalEqualsHashcode animal = (AnimalEqualsHashcode) obj;
        return Objects.equals(name, animal.name);
    }
    // hashCode 재정의
    @Override
    public int hashCode() {
        return Objects.hash(name);
    }

    public static void main(String[] args) {
        AnimalEqualsHashcode animal1 = new AnimalEqualsHashcode("dog");
        AnimalEqualsHashcode animal2 = new AnimalEqualsHashcode("dog");
        // [출력] true
        System.out.println(animal1.equals(animal2));

        // HashSet
        Set<AnimalEqualsHashcode> animalSet = new HashSet<>();
        animalSet.add(new AnimalEqualsHashcode("dog"));
        animalSet.add(new AnimalEqualsHashcode("dog"));
        // [출력] 1
        System.out.println(animalSet.size());

        // HashMap
        HashMap<AnimalEqualsHashcode, Integer> hashMap = new HashMap<>();
        hashMap.put(new AnimalEqualsHashcode("dog"),1);
        hashMap.put(new AnimalEqualsHashcode("dog"),2);
        hashMap.put(new AnimalEqualsHashcode("cat"),3);
        // [출력] 2
        System.out.println(hashMap.size());
        // [출력] 2
        System.out.println(hashMap.get(new AnimalEqualsHashcode("dog")));
        // [출력] 3
        System.out.println(hashMap.get(new AnimalEqualsHashcode("cat")));
    }
}

 

둘 다 오버라이딩 한 결과 우리가 원하는 결과들을 얻게 되었습니다. 

1. 두 객체의 필드 값이 같다면 true를 리턴

2. hashSet은 중복 추가를 막아줌

3. hashMap은 중복 추가도 막아주고, 동일한 객체의 데이터도 꺼내오고 있음. 

 

결론적으로 equals()와 hashcode() 둘 다 오버라이딩 해줘야 전부 올바르게 동작한다는 것을 확인하였습니다.

 

 

그럼에도 불구하고... 둘 다 오버라이딩을 해야 할까?

위의 예에서는 hash 값을 사용하는 Collection( HashMap, HashSet, Hashtable )을 사용할 때를 가정했습니다. 그렇다면 처음에 equals()만 재정의하고 나중에 hash 값을 사용하는 Collection을 사용할 때 둘 다 재정의 하면 되지 않을까하는 생각이 들 수도 있습니다.

 

하지만 현업에서는 여러 개발자가 코드를 생성하고 수정하는 것을 반복합니다. 동료 개발자가 Collection을 사용하지 않을 것이라는 보장을 할 수도 없고 해당 코드를 수정할 때 당연히 둘 다 재정의 되어있겠지 하는 믿음으로 추가 작업할 수도 있습니다.  따라서 클래스를 생성할 때  equals()와 hashcode() 둘 다 오버라이딩 해주는 것이 좋습니다.

 

 

 

 

 


참고: 이펙티브 자바 3 - 아이템11 equals()를 재정의하려거든 hashCode도 재정의하라