JAVA

[JAVA] equals()를 재정의 하면 hashCode()도 재정의 해야 할까?

inuma 2021. 1. 21. 22:35

equals() 메소드를 Override 하는 이유

  • 서로 다른 객체의 동등성을 보장하기 위해 재정의 한다.

 

동일성 vs 동등성

동일성(Identity)

  • 두 객체가 완전히 같음을 뜻한다.
  • 자바에서의 동일성이란, 두 변수가 같은 Instance의 참조를 바라보고 있음을 뜻한다.
TestValue value1 = new TestValue("value1");
TestValue value2 = value1;

//value1 == value2; true이므로 value1과 value2는 동일하다.

TestValue value1 = new TestValue("value1");
TestValue value2 = new TestValue("value2");

//value1 == value2; false이므로 value1과 value2는 동일하지 않다.

 

동등성(Equality)

  • 두 객체의 참조는 다르더라도 내부의 값이 같음을 뜻한다.
  • 자바에서는 equals() 메소드를 재정의하여 동등성을 보장하도록 한다.
public class TestClass{
  private String value1;
  
  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    Value value = (Value) o;
    return intValue1 == value.intValue1 &&
        Objects.equals(stringValue1, value.stringValue1);
  }
  
  ...
}

TestClass value1 = new TestClass("value1");
TestClass value2 = new TestClass("value1");

//value1 == value2; false이므로 value1과 value2는 동일하지 않다.
//value1.equals(value2); true이므로 value1과 value2는 동등하다.

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() 메소드를 재정의 하면 hashCode()도 재정의 해야 할까?

위의 내용은 java.lang.Object에서 equals 메소드 위의 주석 내용중 일부 내용이다.
위 내용에 따르면 equals method를 override 하게 되는 경우,
공통의 제약(동등한 객체의 경우 반드시 동등한 hash code를 가져야 한다.)을 지키기 위해서
hashCode를 재정의 하라고 가이드 되어 있다.

 

그렇다면 왜 hashCode도 재정의 해야할까?

일반적으로 equals() 메소드는 동일하지만 hashCode()를 사용하는 컬렉션에서 문제가 발생할 수 있다.

아래에서 해당 문제에 대해서 자세히 다뤄보도록 하겠다.

public class Value {
  private String stringValue1;
  private int intValue1;

  public Value(String stringValue1, int intValue1) {
    this.stringValue1 = stringValue1;
    this.intValue1 = intValue1;
  }

  public String getStringValue1() {
    return stringValue1;
  }

  public int getIntValue1() {
    return intValue1;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    Value value = (Value) o;
    return intValue1 == value.intValue1 &&
        Objects.equals(stringValue1, value.stringValue1);
  }

  @Override
  public int hashCode() {
    return 2;
  }
  
  ...
}
HashMap<Value, Integer> map = new HashMap<>();

map.put(value1, 1);
map.put(value2, 2);

System.out.println(map);

System.out.println("value1 : " + map.get(value1));
System.out.println("value2 : " + map.get(value2));

 

위의 Value class는 의도적으로 equals() 메소드는

동등성을 보장하도록 구현되었지만 hashCode는 2로만 리턴하도록 작성되었다.

하지만 결과상으로는 의도적으로 동작하고 아무 문제 없어보인다. 진짜로 문제가 없는 걸까?

진짜로 문제가 없는지 HashMap의 내부 구현 코드를 분석해보도록 하겠다.

 

HashMap의 동작 원리

 

위 put 또는 get 함수의 구현을 보면 내부적으로 hash 함수를 사용하며,

해당 hash 함수는 파라미터 key의 hashCode를 사용한다는 것을 알 수 있다.

그렇다면 해시 충돌 때문에 같은 key에 대해서 value가 덮어 씌어지거나 같은 value가 조회되는 것으로 오해할 수 있다.

하지만 실제로 값을 저장/조회하는 로직인 putVal, getNode 함수를 살펴보면

해시 충돌이 발생하더라도 어떻게 서로 다른 value를 저장/조회할 수 있는지 알 수 있다.

 630번째 라인을 살펴보면, hash 값을 실제로 key-value를 관리하는 Node array의 index로 사용하는 것을 알 수 있다.

이 Node array에 동일한 hash 값으로 이미 객체가 있는 경우(632번째 라인),
동일성, 동등성 비교를 하는 것을 알 수 있고

640번째 라인에서부터 Node에 value를 LinkedList 또는 Tree 구조로 저장한다는 것을 알 수 있다.

  • (HashMap의 경우, Node에 연결된 value의 갯수가
    8 (TREEFY_THRESHOLD)개가 넘어가는 시점에서 Tree 구조로 변경하여 관리한다.
    그 외의 경우는 LinkedList의 형태로 관리한다.)

 

그렇다면 hashCode()는 아무렇게나 작성해도 문제가 되지 않는게 아닐까?

이에 대한 답변으로는 문제가 발생할 수도 있고, 성능상에서 불이익이 존재할 수 있다.

 

문제 발생 시나리오

  • 만약 hashCode()가 호출 될때마다 새로운 값을 리턴하도록 코드를 수정해본다면 어떻게 될까?
public class Value {
  private String stringValue1;
  private int intValue1;

  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    Value value = (Value) o;
    return intValue1 == value.intValue1 &&
        Objects.equals(stringValue1, value.stringValue1);
  }

  @Override
  public int hashCode() {
    return (int) System.currentTimeMillis();
  }
  
  ...
}
HashMap<Value, Integer> map = new HashMap<>();

map.put(value1, 1);
map.put(value2, 2);

System.out.println(map);

System.out.println("value1 : " + map.get(value1));
System.out.println("value2 : " + map.get(value2));

Java의 HashMap의 경우, hash 코드를 기반으로 먼저 조회를 하기 때문에

동일한 객체를 key로 조회를 하더라도 조회할 수 없다는 것을 확인할 수 있다.

 

성능상의 불이익

  • 해시 충돌이 자주 발생하여 동일한 Node에 계속해서 연결을 하게 되는 경우,
    동일성, 동등성이 보장된 key를 찾을 때까지 연결된 Node들을 계속해서 찾게 되기 때문에 조회 성능이 떨어지게 된다.
    (TreeNode로 관리가 변경되는 경우에도 동일하다.)

 

결론

equals 메소드를 Override 하면 동등한 객체에 대해서 같은 hashCode 값을 가질 수 있도록 Override 하자!!!