[Java] Effective Java - Equals And HashCode 제대로 알고 쓰자 !

Kim Dae Hyun·2022년 2월 1일
0

Effective Java

목록 보기
1/1
post-thumbnail

Java 개발자라면 한번쯤 들어봤고 사용해봤을 두 메서드가 있다.

  • eqauls
  • hashCode

두 메서드가 어떤 역할을 하는지 알고는 있었지만 나보다 똑똑한 IDE의 도움을 받아 필요할 때 자동생성하는 식으로 구현하는 바람에 어떤 상황에서 커스텀 할 수 있는 능력과 최적화 포인트 등을 전혀 몰랐다.

Object가 제공하는 몇 안 되는 메서드이기 때문에 이번 기회에 잘 알아보고자 한다.


📌 eqauls 먼저

eqauls는 두 객체의 논리적 동치성을 비교하기 위해 사용한다.

여기서 논리적 동치성이란 분명 다른 클래스 인스턴스이지만 핵심 필드를 비교했을 때 동일한 인스턴스로 판별하는 것을 말한다.

만약 equals를 재정의하지 않는다면 상위 클래스의 eqauls를 사용하게 되고 끝까지 올라간다면 Objectequals를 사용하게 된다.

Objectequals의 경우 단순히 자기 자신과 같은 지를 비교한다.

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

이제 직접 테스트 클래스를 작성해보고 equals를 구현해보자.

static class Point {
    public final int x;
    public final int y;

  public Point(int x, int y) {
      this.x = x;
      this.y = y;
  }
}

xy를 필드로 갖는 클래스이다.
아직 equals 메서드를 재정의하지 않아서 x와 y의 값이 같은 인스턴스더라도 equals의 결과는 false가 나올 것이다.

    public static void main(String[] args) {
        Point p1 = new Point(1, 2);
        Point p2 = new Point(1, 2);

        System.out.println(p1.equals(p2)); // false
        System.out.println(p2.equals(p1)); // false
    }

물리적으로 다른 인스턴스이지만 논리적 동치성을 확인하기 위해 equals를 재정의한다. equals 재정의 시 무조건 모든 필드의 동치를 비교할 필요는 없다. 도메인의 요구사항에 따라 핵심필드를 뽑아 비교한다.

테스트를 위한 클래스인 Point의 경우 두 개 필드 뿐이고 두 필드 모두 핵심필드라고 판단하고 equals 메서드를 재정의한다.

static class Point {
    public final int x;
    public final int y;

      public Point(int x, int y) {
          this.x = x;
          this.y = y;
      }

      @Override
      public boolean equals(Object o) {
          if (o == this) return true; // 1
          if (!(o instanceof Point)) return false; // 2
          Point p = (Point) o; // 3

          return p.x == this.x && p.y == this.y;
      }
}

각 핵심필드의 동치를 비교하기 전에 3가지 작업을 필요로 한다.

  1. 비교 대상이 자기 자신인 경우 굳이 비교로직을 수행할 필요가 없다.
    성능 최적화를 위해 자기 자신과의 비교는 비교로직을 수행하지 않고 true를 반환한다.
if (o == this) return true;
  1. 비교 대상의 타입이 비교 가능한 타입인지 확인한다.
    이 때 instanceof를 사용해서 타입을 확인하면 묵시적으로 null에 대한 체크도 수행한다. instanceof의 앞 쪽에 위치한 객체가 null 이라면 false를 반환한다.
if (!(o instanceof Point)) return false;
  1. 모든 핵심필드를 비교하기 위해 비교대상의 형변환을 수행한다.
Point p = (Point) p;

이제 물리적으로 다른 인스턴스에 대해 논리적 동치성을 판별할 수 있게 되었다.

equals 메서드를 재정의 할 때 다음 3가지 원칙을 고려해야 한다.

  1. 대칭성
    null이 아닌 모든 참조 값 x,y에 대해 x.equals(y)가 true라면 y.equals(x)도 true 이다.

  2. 추이성 (3단 논법 느낌)
    null이 아닌 모든 참조 값 x,y,z에 대해 x.equals(y)가 true이고, y.equals(z)라면 x.equals(z)는 true 이다.

  3. 일관성
    null이 아닌 모든 참조 값 x,y에 대해 x.equals(y)를 반복해도 항상 같은 결과를 반환한다.


equals를 제대로 재정의 했다면 반드시 hashCode를 재정의 해야 한다.
이유는 hashCode를 재정의 하면서 알아보자.


📌 hashCode

equals 메서드를 통해 객체의 핵심필드를 비교할 수 있게 되었다.
아직 충분하지 않다.

충분하지 않은 상황은 다음과 같다.

    public static void main(String[] args) {
        Map<Point, String> map = new HashMap<>();

        Point p1 = new Point(1, 2);
        Point p2 = new Point(1, 2);
        System.out.println(p1.equals(p2));

        map.put(p1, "test");
        System.out.println(map.get(p2)); // null
    }

Point 클래스는 이전 equals를 재정의 했을 때와 동일하다.

논리적 동치를 보장받은 두 인스턴스이지만 Map의 key로 두 인스턴스를 사용했을 때 서로 다르다는 결과가 나온다.

두 인스턴스의 equals 결과가 true라면 "test"가 map에서 조회됐어야 했다.

위 코드를 수행했을 때 "test"가 조회되기 위해 hashCode를 재정의하는 것이다.

equals에 사용된 핵심필드의 해시 값을 계산해서 반환하는 것이 hashCode의 역할이다.

Point 클래스에 hashCode를 재정의해보자.

    static class Point {
        public final int x;
        public final int y;

          public Point(int x, int y) {
              this.x = x;
              this.y = y;
          }

          @Override
          public boolean equals(Object o) {
              if (o == this) return true;
              if (!(o instanceof Point)) return false;
              Point p = (Point) o;

              return p.x == this.x && p.y == this.y;
          }

          @Override
          public int hashCode() {
              int result = Integer.hashCode(this.x);
              result += 31 * result + Integer.hashCode(this.y);

              return result;
          }
    }

hashCode 를 재정의하는 일반적인 과정은 다음과 같다.

  1. equals에 사용된 핵심필드 중 첫번째 필드의 hash 값을 int 타입 변수 result에 초기화 한다.
  2. 나머지 핵심필드에 대해 hash 값을 계산하고 계산된 결과를 result에 누적한다.
    2-1 누적시 31을 곱하는데 이것은 해시충돌을 피해서 해시성능을 높이기 위함이다.
  3. 누적된 결과를 반환한다.

위 과정을 보다 간결하게 구현하기 Objectshash() 메서드를 사용할 수 있다.

@Override
public int hashCode() {
      return Objects.hash(x, y);
}

hashCode를 구현하고 map을 조회하면 동일한 key값으로 판정되어 값을 받아올 수 있다.

📌 hashCode 캐싱

클래스가 불변이고, 핵심필드가 너무 많아 해시코드 계산에 너무 많은 시간이 소요된다면 캐싱을 고려할 수 있다.

static class Point {
    public final int x;
    public final int y;
    public int hashCode;

      public Point(int x, int y) {
          this.x = x;
          this.y = y;
      }

      @Override
      public boolean equals(Object o) {

          if (o == this) return true;
          if (!(o instanceof Point)) return false;
          Point p = (Point) o;

          return p.x == this.x && p.y == this.y;
      }

        @Override
        public int hashCode() {
              int result = this.hashCode;
              if (result == 0) {
                  result = Integer.hashCode(this.x);
                  result += 31 * result + Integer.hashCode(this.y);
              }
              return result;
          }
}

캐싱을 위한 필드를 추가하고 해당 필드에 캐싱된 결가가 있다면 캐싱된 패시코드를 반환하는 방식이다.

(캐싱을 위한 필드는 쓰레드 세이프하게 만들어야 한다. ThreadLocal 같은 ??)


equals를 재정의 할 때 반드시 hashCode를 재정의하고 hashCode에는 equals에 사용된 핵심필드만을 사용해서 해시 값을 계산해야 한다.



📌 참고

이펙티브 자바

profile
좀 더 천천히 까먹기 위해 기록합니다. 🧐

0개의 댓글