예를 들어, 아래와 같이 Menu 클래스가 있다고 하자.

public class Menu {
    private final String name;
    private final int price;

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

이 때 name과 price 값이 똑같은 두 Menu 객체를 비교하면 어떻게 될까?.

@Test
@DisplayName("같은 객체를 equals 비교")
void equals() {
    //given
    Menu friedChicken = new Menu("후라이드치킨", 16_000);
    Menu friedChicken2 = new Menu("후라이드치킨", 16_000);
    //when & then
    assertThat(friedChicken).isEqualTo(friedChicken2);
}

두 개의 Menu객체(friedChicken, friedChicken2)는 name과 price가 서로 같은 객체 지닌다. 위 테스트 코드를 실행한 결과는 다음과 같다.

테스트가 실패했다. 그 이유는 두 객체의 주소값이 다르기 때문이다.

즉, equals 메서드는 주소값이 다른 객체는 서로 다른 객체로 판단한다.

두 객체를 같도록 하려면 Menu 클래스에 equals 메서드를 재정의 해야 한다.

public class Menu {
    private final String name;
    private final int price;

    public Menu(final String name, final int price) {
        this.name = name;
        this.price = price;
    }
	
    // equals 재정의
    @Override
    public boolean equals(Object o) {
        if (this == o)
            return true;
        if (!(o instanceof Menu))
            return false;
        Menu menu = (Menu)o;
        return price == menu.price &&
            Objects.equals(name, menu.name);
    }
}

equals 메서드를 재정의 한 후 테스트 코드를 실행하면 테스트가 통과한다.

equals 메서드

@Override로 재정의 하지 않으면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다.

Java equals()

Object 클래스에 정의된 equals()는 다음과 같다.

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

즉, 오직 자기 자신과만 같다고 인식한다.

언제 equals를 재정의 해야 할까?


  • 논리적 동치성을 확인해야 하는데 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의 되어있지 않을때
  • 주로 값 클래스 : Integer, String처럼 값을 표현하는 클래스
  • 두 값 객체를 equals로 비교한다는 것은 객체가 같은지가 아니라 값이 같은지 알고 싶은 것이다.
  • equals가 논리적 동치성을 확인하도록 재정의 해두면, 그 인스턴스 값의 비교가 가능하고 Map의 key와 Set의 원소로 사용할 수 있다.

값 클래스 중 equals를 재정의하지 않아도 되는 경우

인스턴스가 둘 이상 만들어지지 않음을 보장하는 클래스

  • ex) Enum
  • 이런 클래스에서는 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않는다.
  • 따라서 논리적 동치성과 객체 식별성이 사실상 똑같은 의미가 된다.
  • 이런 경우 Object의 equals가 논리적 동치성까지 확인해준다고 볼 수 있다.

Equals 메서드 규약

(null이 아닌 모든 참조값 x,y,z에 대해)

  • 반사성(reflexivity) : x.equals(x)는 true
  • 대칭성(symmetry) : x.equals(y)가 true이면 y.equals(x)도 true
  • 추이성(transitivity) : x.equals(y)는 true이고 y.equals(z)는 true이면 x.equals(z)는 true
  • 일관성(consistency) : x.equals(y)를 반복해서 호출해도 항상 true 또는 false를 반환
  • null-아님 : x.equals(null)는 false

equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.

리스코프 치환 원칙(Liskov substitution principle)

  • 어떤 타입에 있어 중요한 속성이면 그 하위 타입에서도 중요하다.
  • 그 타입의 모든 메서드가 하위 타입에도 똑같이 잘 작동해야 한다.
  • "Point의 하위 클래스는 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다."

양질의 equals 메서드 구현 방법

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
  3. 입력을 올바른 타입으로 형변환한다.(2번에서 instanceof 검사를 했으니 100% 성공한다.)
  4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
  5. equals를 재정의할 땐 hashCode도 반드시 재정의한다

꼭 필요한 경우가 아니면 equals를 재정의하지 않는다. 대부분의 경우 Object의 equals 메서드가 원하는 비교를 정확히 수행한다.

재정의 해야할 경우 그 클래스의 핵심 필드를 빠짐없이 다섯 가지 규약을 지키며 비교한다.

hashCode 메서드

equals를 재정의한 클래스에는 hashCode도 반드시 재정의한다.

Override either both of them or neither of them.

hashCode 규약

  • equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 객체의 hashCode 메서드는 몇 번을 호출해도 항상 같은 값을 반환한다. (단, 애플리케이션을 다시 실행하면 값은 바뀔 수 있다.)
  • equals(Object)가 두 객체를 같다고 판단했으면, 두 객체의 hashCode 값은 항상 같다.
  • 하지만 equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객채의 hashCode 값은 같을 수 있다. (해시 충돌)
    • 해시 테이블에서 해시 충돌이 발생하면 LinkedList 형태로 객체를 추가한다.
    • 단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.
    • 좋은 해시 함수라면 서로 다른 인스턴스에 대해 다른 해시코드를 반환한다.

hashCode를 재정의 하지 않았을 경우 생기는 문제점

  • 같은 값을 가진 객체가 서로 다른 해시값을 갖게 될 수 있다.
  • 특히 HashMap의 key 값으로 해당 객체를 사용할 경우 문제가 발생한다.

If you are planning to use a class as Hash table key, then you must override both equals() and hashCode() methods.

Map<Menu,Integer> menus = new HashMap<>();
menus.put(new Menu("치킨", 16_000), 10);
menus.put(new Menu("감자튀김", 8_000), 2);
menus.put(new Menu("콜라", 2_000), 7);
 
Menu menu = new Menu("치킨", 16_000);
int count = menus.get(menu);

위 코드를 실행했을 때 count가 10일 것을 기대하지만 결과는 그렇지 않다.

@Test
@DisplayName("같은 값 객체는 해시값이 같은지 체크")
void hashcode_menu() {
    //given
    Map<Menu, Integer> menus = new HashMap<>();
    menus.put(new Menu("치킨", 16_000), 10);
    menus.put(new Menu("감자튀김", 8_000), 2);
    menus.put(new Menu("콜라", 2_000), 7);
    //when
    Menu menu = new Menu("치킨", 16_000);
    int count = menus.get(menu);
    //then
    assertThat(count).isEqualTo(10);
}

위 테스트 코드를 실행하면 아래와 같이 NullPointerException 이 발생한다.

그 이유는 menu 객체에 대한 해시값 menus에서 찾을 수 없기 때문이다. HashMap의 key값으로 Menu 클래스를 사용하기 위해서는 Menu 클래스에 hashCode() 메서드를 재정의 해줘야한다. 그래야 같은 값을 가진 객체가 항상 같은 해시값을 갖게된다.

public class Menu {
    private final String name;
    private final int price;

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

    @Override
    public boolean equals(Object o) {
        if (this == o)
            return true;
        if (!(o instanceof Menu))
            return false;
        Menu menu = (Menu)o;
        return price == menu.price &&
            Objects.equals(name, menu.name);
    }
    
    // hashcode 재정의
    @Override
    public int hashCode() {
        return Objects.hash(name, price);
    }
}

hashcode를 재정의 해주었으므로 같은 값을 가지는 객체는 같은 해시값을 갖는다. 따라서 테스트 코드는 통과한다.

Java HashTable

equals와 hashcode 메서드를 이해하기 위해서 자바에서 HashTable이 작동하는 원리를 간단히 살펴보자.

HashTable은 <key,value> 형태로 데이터를 저장한다. 이 때 해시 함수(Hash Function)을 이용하여 key값을 기준으로 고유한 식별값인 해시값을 만든다. (hashcode가 해시값을 만드는 역할을 한다.) 이 해시값을 버킷(Bucket)에 저장한다.

하지만 HashTable 크기는 한정적이기 때문에 같은 서로 다른 객체라 하더라도 같은 해시값을 갖게 될 수도 있다. 이것을 해시 충돌(Hash Collisions)이라고 한다. 이런 경우 아래와 같이 해당 버킷(Bucket)에 LinkedList 형태로 객체를 추가한다.


이미지 출처: https://www.geeksforgeeks.org/implementing-our-own-hash-table-with-separate-chaining-in-java/

이처럼 같은 해시값의 버킷 안에 다른 객체가 있는 경우 equals 메서드가 사용된다.

HashTable에 put 메서드로 객체를 추가하는 경우

  • 값이 같은 객체가 이미 있다면(equals()가 true) 기존 객체를 덮어쓴다.
  • 값이 같은 객체가 없다면(equals()가 false) 해당 entry를 LinkedList에 추가한다.

HashTable에 get 메서드로 객체를 조회하는 경우

  • 값이 같은 객체가 있다면 (equals()가 true) 그 객체를 리턴한다.
  • 값이 같은 객체가 없다면(equals()가 false) null을 리턴한다.

위 그림에서 세 객체 (Entry<K1,V1>, Entry<K2,V2>, Entry<K3,V3>)는 서로 같은 해시값을 같는다. 따라서 hashcode() 메서드는 같은 값을 리턴한다. 하지만 서로 값이 다른 객체이기 때문에 equals() 메서드는 false를 리턴한다.

equals()와 hashcode()를 같이 재정의해야 하는 이유

만약 equals()와 hashcode() 중 하나만 재정의 하면 어떻게 될까? 위 예에서도 봤듯이 hashcode()를 재정의 하지 않으면 같은 값 객체라도 해시값이 다를 수 있다. 따라서 HashTable에서 해당 객체가 저장된 버킷을 찾을 수 없다.

반대로 equals()를 재정의하지 않으면 hashcode()가 만든 해시값을 이용해 객체가 저장된 버킷을 찾을 수는 있지만 해당 객체가 자신과 같은 객체인지 값을 비교할 수 없기 때문에 null을 리턴하게 된다. 따라서 역시 원하는 객체를 찾을 수 없다.

결론

  • equals()와 hashcode()는 항상 같이 재정의 한다.
  • Always override hashCode() if we override equals()
  • 값객체(Value Object)에는 equals()와 hashCode()를 재정의하자
  • Override equals() and hashCode() for value objects(VO)

Reference

https://velog.io/@sonypark/Java-equals-hascode-%EB%A9%94%EC%84%9C%EB%93%9C%EB%8A%94-%EC%96%B8%EC%A0%9C-%EC%9E%AC%EC%A0%95%EC%9D%98%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C

 

  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기