[Java] Equals and HashCode

2021. 10. 19. 15:09Programming Languages/Java

equals()hashcode()는 서로 다른 객체가 같은지를 판별할 때 사용되는 메서드이다. Object 클래스에 구현되어 있고 따라서 모든 객체가 상속받는다.

Objectequals()는 객체의 참조값을 비교하기 때문에 비교하고자 하는 클래스에서 hashCode()와 함께 오버라이딩해야 한다. hashCode()는 객체의 멤버 변수를 통해 해싱을 통한 해시값을 만들어낸다. 따라서 멤버 변수가 달라지면 hashCode()의 결과도 달라진다. 참고로 ObjecthashCode()는 객체의 주소값으로 해시코드를 만들어내기 때문에 프로그램을 실행할 때마다 변경될 수 있다.

JDK1.8부터 java.util.Objects 클래스에 hash() 메서드가 추가됐다. hashCode()를 구현할 때 유용하게 사용할 수 있다.

 

오버라이딩된 hashCode()는 다음 조건을 만족해야 한다.

  1. 실행 중인 애플리케이션 내의 동일한 객체에 대해 equals()의 구현에 사용된 멤버 변수의 값이 바뀌지 않았다면 hashCode()의 호출 결과는 항상 같아야 한다.
  2. equals()를 이용한 비교에 의해서 true를 얻은 두 객체에 대해 각각 hashCode()를 호출해서 얻은 결과는 반드시 같아야 한다.
  3. equals()를 호출했을 때 false를 반환하는 두 객체는 hashCode() 호출에 대해 같은 int값을 반환하는 경우가 있어도 괜찮지만, 해싱을 사용하는 컬렉션의 성능을 향상시키기 위해서는 다른 int값을 반환하는 것이 좋다.

아래 예시를 통해 살펴보도록 하자.

import java.util.*;

class EqualsAndHashCodeEx {
    public static void main(String[] args) {
        HashSet set = new HashSet();

        set.add("abc");
        set.add("abc");
        set.add(new Book("자바", 30000));
        set.add(new Book("자바", 30000));

        System.out.println("set = " + set);
    }

    static class Book {
        private String name;
        private int price;

        public Book(String name, int price) {
            this.name = name;
            this.price = price;
        }

        public String toString() {
            return name + ", " + price;
        }
    }
}

결과는 어떻게 나올까?

set = [abc, "자바, 30000", "자바, 30000"]

이런 결과가 나온 이유는 앞서 설명한 바와 같이 set에 저장된 두 Book이 같은지 판별하는 방법을 HashSet은 equals()hashCode()를 사용했기 때문이다(Hash가 들어간 자료구조는 같은 방법을 사용한다). 두 메서드를 오버라이딩하지 않았기 때문에 두 인스턴스의 참조값으로 equals() 메서드로 비교를 진행했고 두 인스턴스가 모두 들어있게 됐다.

이 문제를 해결하기 위해서는 다음과 같은 방법으로 두 메서드를 오버라이딩하면 된다.

import java.util.*;

class EqualsAndHashCodeEx {
    public static void main(String[] args) {
        HashSet set = new HashSet();

        set.add("abc");
        set.add("abc");
        set.add(new Book("자바", 30000));
        set.add(new Book("자바", 30000));

        System.out.println("set = " + set);
    }

    static class Book {
        private String name;
        private int price;

        public Book(String name, int price) {
            this.name = name;
            this.price = price;
        }

        @Override
        public boolean equals(Object obj) {
            if(obj instanceof Book) {
                Book tmp = (Book) obj;
                return name.equals(tmp.name) && price == tmp.price;
            }
        }

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

        public String toString() {
            return name + ", " + price;
        }
    }
}

이렇게 작성하고 다시 실행하면 2개였던 Book이 1개로 출력된다.

HashSet, HashMap과 같이 해싱을 통해 데이터를 저장하는 자료구조들은 내부적으로 키과 값으로 저장된다(HashSetHashMap으로 구현됐다). 여기서 해싱이란 배열과 연결 리스트를 통해 구현된 해시 테이블에 저장하고 검색하는 기법을 말한다. 그리고 키에 해당하는 값이 바로 해시값이다. 그리고 값에 해당하는 곳에 우리가 저장할 데이터가 들어간다. 같은 해시값이 나온다면 같은 키에 여러 값이 들어가게 된다. 이는 안그래도 검색에 느린 연결 리스트에 데이터를 추가하게 되는 것이다. 따라서 핵심은 해시값을 만들어내는 해시 알고리즘이고, 중복된 해시값을 최대한 피해야 하는 이유이다.

실제로 HashMap과 같이 해싱을 구현한 컬렉션에서는 Object 클래스에 정의된 hashCode()를 해시함수로 사용한다. 위에 설명했다시피 객체의 주소값으로 해시값을 만들어내기 때문에 같은 인스턴스가 아니라면 반드시 다르므로 아주 훌륭한 방법이다.

String 클래스의 경우 hashCode()를 문자열의 내용으로 해시값을 반환하도록 오버라이딩했다. 그래서 서로 다른 String 인스턴스라도 같은 내용이라면 같은 해시코드값을 가지게 된다.

HashSet, HashMap에 저장되는 객체들은 두 객체간에 equals()의 결과가 true인 동시에 hashCode()의 값이 같아야 같은 객체로 인식한다. 그리고 이미 존재하는 키에 대해 값을 지정하면 기존의 값을 새로운 값이 덮어써버린다.

그래서 사이드 이펙트를 막기 위해 equals()를 오버라이딩해야 한다면 hashCode()도 같이 오버라이딩해주어야 한다. 그렇지 않으면 HashMap과 같이 해싱을 구현한 컬렉션 클래스에서는 equals()의 호출 결과가 true지만 hashCode()의 호출 결과가 달라 두 객체를 다른 것이라 인식하고 따로 저장할 것이다.

equals()로 비교한 결과가 false이고 해시코드가 같은 경우는 같은 링크트 리스트에 저장된 서로 다른 두 데이터가 된다.