본문 바로가기
개발 공부/Java

이펙티브 자바 아이템 11 - equals 를 재정의하려거든 hashCode 도 재정의하라 - 핵심 정리

by 개발인생 2022. 10. 25.
반응형

아이템 11 - equals 를 재정의하려거든 hashCode 도 재정의하라 - 핵심 정리

이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.

equals 메서드를 구현할 때는 반드시 hashCode 도 구현해야한다.

equals 메서드와 hashCode 는 같이 정의되어 있지 않다면 잘못된 코드이다.

hashCode 를 구현할 때는 몇가지 규약 이 존재한다.

  • equals 메서드에서 사용하는 값이 변경되지 않았다면 hashCode 는 몇번이 실행 되더라도 동일한 값 을 리턴해야한다.
  • 두 객체를 equals 메서드로 비교했을 때 같은 객체 로 나온다면 두 객체의 hashCode 값도 같아야 한다.
  • 성능을 고려해 다른 객체라면 다른 hashCode 를 리턴하는 걸 추천한다. 다른 객체지만 hashCode 가 같아도 문제는 없지만 성능상 단점이 생긴다.

여기서 항상 같은 hashCode 를 리턴하게 되면 성능이 O(1) 에서 O(n) 으로 떨어지게 된다.

hashCode 구현시에는 equals 메서드 에서 사용하는 필드들을 모두 사용 해서 계산해야한다.

public final class PhoneNumber {
   private final short areaCode, prefix, lineNum;

   public PhoneNumber(int areaCode, int prefix, int lineNum) {
      this.areaCode = rangeCheck(areaCode, 999, "area code");
      this.prefix   = rangeCheck(prefix,   999, "prefix");
      this.lineNum  = rangeCheck(lineNum, 9999, "line num");
   }

   private static short rangeCheck(int val, int max, String arg) {
      if (val < 0 || val > max)
         throw new IllegalArgumentException(arg + ": " + val);
      return (short) val;
   }

   @Override public boolean equals(Object o) {
      if (o == this)
         return true;
      if (!(o instanceof PhoneNumber))
         return false;
      PhoneNumber pn = (PhoneNumber)o;
      return pn.lineNum == lineNum && pn.prefix == prefix
            && pn.areaCode == areaCode;
   }
}

PhoneNumber 클래스를 작성한다.

PhoneNumber 클래스는 equals 메서드는 구현되어 있지만 hashCode 는 구현되어 있지 않다.

public class HashMapTest {

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

      PhoneNumber number1 = new PhoneNumber(123, 456, 7890);
      PhoneNumber number2 = new PhoneNumber(123, 456, 7890);

//         TODO 같은 인스턴스인데 다른 hashCode
//         다른 인스턴스인데 같은 hashCode를 쓴다면?
      System.out.println(number1.equals(number2));
      System.out.println(number1.hashCode());
      System.out.println(number2.hashCode());

      map.put(number1, "keesun");
      map.put(number2, "whiteship");

      String s = map.get(number2);
      System.out.println(s);
   }
}

위의 코드에서 PhoneNumber 클래스에 같은 번호를 파라미터로 넘겨주어 객체를 생성한다.

위의 코드에서는 PhoneNumber 의 값은 같지만 hashCode 는 다르게 나온다.

얼핏 봤을 때는 코드가 잘 동작하는 것처럼 보인다.

PhoneNumber 클래스는 값 클래스이므로 map.get() 에 또다른 값 클래스를 넣어도 동일하게 동작해야 한다.

public class HashMapTest {

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

      PhoneNumber number1 = new PhoneNumber(123, 456, 7890);
      PhoneNumber number2 = new PhoneNumber(123, 456, 7890);

//         TODO 같은 인스턴스인데 다른 hashCode
//         다른 인스턴스인데 같은 hashCode를 쓴다면?
      System.out.println(number1.equals(number2));
      System.out.println(number1.hashCode());
      System.out.println(number2.hashCode());

      map.put(number1, "keesun");
      map.put(number2, "whiteship");

      String s = map.get(new PhoneNumber(123, 456, 7890)); // 제대로 동작하지 않는다.
      System.out.println(s);
   }
}

위의 코드는 제대로 동작하지 않는다.

HashMap 에 값을 넣을때와 hashCode() 메서드를 실행해 어느 버킷에 넣을지 정하게 된다.

값을 꺼낼 때도 key 에 대한 hashCode 값 을 통해서 버킷에서 객체를 꺼내오게 된다.

PhoneNumber 클래스에 hashCode 를 정의하지 않았기 때문에

map.get(new PhoneNumber(123, 456, 7890)) 부분에서 버킷을 찾지 못하는 것이다.

hash 를 기반으로 만들어진 Map 이기 때문에 HashMap 이라고 불린다.

이러한 이유로 인해 equals 가 같다면 같은 HashCode 를 리턴해야한다.

만약 다른 인스턴스인데 같은 hashCode 값을 가지면 어떻게 될까?

public final class PhoneNumber {
   private final short areaCode, prefix, lineNum;

   public PhoneNumber(int areaCode, int prefix, int lineNum) {
      this.areaCode = rangeCheck(areaCode, 999, "area code");
      this.prefix   = rangeCheck(prefix,   999, "prefix");
      this.lineNum  = rangeCheck(lineNum, 9999, "line num");
   }

   private static short rangeCheck(int val, int max, String arg) {
      if (val < 0 || val > max)
         throw new IllegalArgumentException(arg + ": " + val);
      return (short) val;
   }

   @Override public boolean equals(Object o) {
      if (o == this)
         return true;
      if (!(o instanceof PhoneNumber))
         return false;
      PhoneNumber pn = (PhoneNumber)o;
      return pn.lineNum == lineNum && pn.prefix == prefix
            && pn.areaCode == areaCode;
   }

   @Override
    public int hashCode() {
        return 42;
    }
}

PhoneNumber 클래스가 항상 같은 hashCode 를 리턴하게 수정한다.

public class HashMapTest {

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

      PhoneNumber number1 = new PhoneNumber(123, 456, 7890);
      PhoneNumber number2 = new PhoneNumber(456, 789, 1111);


      // 다른 인스턴스인데 같은 hashCode를 쓴다면?
      System.out.println(number1.equals(number2));
      System.out.println(number1.hashCode());
      System.out.println(number2.hashCode());

      map.put(number1, "keesun");
      map.put(number2, "whiteship");

      String s = map.get(number1); // "keesun"
      System.out.println(s);
   }
}

위의 코드에서 number1, number2 인스턴스는 equals 값이 다르지만 같은 hashCode 값을 가진다.

코드는 잘 동작한다.

number1, number2 인스턴스는 다른 객체임에도 불구하고 hashCode 값이 같으므로 해시 충돌(Hash Collision) 이 발생한다.

HashMap 에서 해시 충돌 이 발생하면 버킷에 들어있는 오브젝트를 링크드 리스트 로 만든다.

hashCode 가 같다면 모두 같은 버킷 안으로 들어가게 된다.

버킷 안에 링크드 리스트 안에 들어가게된다.

값을 가져올 때는 버킷안에 있는 링크드 리스트 를 꺼내 equals 를 비교한다.

즉, 해시 충돌 이 발생하면 HashMap 을 사용하는 장점이 사라지는 셈이다.


hashCode 를 구현하는 적절한 방법을 알아보도록 하자.

// equals를 재정의하면 hashCode로 재정의해야 함을 보여준다. (70-71쪽)
public final class PhoneNumber {
   private final short areaCode, prefix, lineNum;

   public PhoneNumber(int areaCode, int prefix, int lineNum) {
      this.areaCode = rangeCheck(areaCode, 999, "area code");
      this.prefix   = rangeCheck(prefix,   999, "prefix");
      this.lineNum  = rangeCheck(lineNum, 9999, "line num");
   }

   private static short rangeCheck(int val, int max, String arg) {
      if (val < 0 || val > max)
         throw new IllegalArgumentException(arg + ": " + val);
      return (short) val;
   }

   @Override public boolean equals(Object o) {
      if (o == this)
         return true;
      if (!(o instanceof PhoneNumber))
         return false;
      PhoneNumber pn = (PhoneNumber)o;
      return pn.lineNum == lineNum && pn.prefix == prefix
            && pn.areaCode == areaCode;
   }

    // 코드 11-2 전형적인 hashCode 메서드 (70쪽)
    @Override public int hashCode() {
        int result = Short.hashCode(areaCode); // 1
        result = 31 * result + Short.hashCode(prefix); // 2
        result = 31 * result + Short.hashCode(lineNum); // 3
        return result;
    }
}

위에서 정의한 hashCode 가 가장 전형적인 방법이다.

    @Override public int hashCode() {
        int result = Short.hashCode(areaCode); // 1
        result = 31 * result + Short.hashCode(prefix); // 2
        result = 31 * result + Short.hashCode(lineNum); // 3
        return result;
    }

클래스의 필드 중 가장 핵심적인 필드들을 골라 hashCode 값을 구한다.

프리미티브 타입이라면 해당 타입의 wrapper 타입의 hashCode 메서드를 사용하여 hashCode 값을 구한다.

레퍼런스 타입이라면 해당 레퍼런스 타입이 가지고 있는 hashCode 메서드를 호출해 구한다.

Array 라면 Arrays.hashCode() 메서드를 사용하여 hashCode 값을 구한다.

그 다음 필드부터 31 * result + 다음필드의 hashCode값 으로 계산해 나간다.

31 인 이유는

  • 홀수를 사용해야한다. (짝수를 사용하면 값이 왼쪽으로 밀리면서 값이 날아갈 수 있다.)
  • 해시 충돌 이 가장 적게 나는 숫자이다.

큰 문제가 없다면 다른 숫자를 사용해도 된다.

hashCode 메서드의 핵심은 리턴값이 골고루 나와야 한다는 것이다.

같은 객체라면 같은 값이 나오지만 다른 객체라면 다른 값이 골고루 분포되어 나와야한다.

    @Override public int hashCode() {
        return Objects.hash(lineNum, prefix, areaCode);
    }

Objects.hash 메서드를 사용해 hashCode 를 정의할 수도 있다.

IDE 에서 HashCode 를 정의하면 Objects.hash 를 사용하게 된다.

만약 hashCode 계산을 자주해야하고, 그 클래스가 불변 클래스 라면

// equals를 재정의하면 hashCode로 재정의해야 함을 보여준다. (70-71쪽)
public final class PhoneNumber {
   private final short areaCode, prefix, lineNum;

   public PhoneNumber(int areaCode, int prefix, int lineNum) {
      this.areaCode = rangeCheck(areaCode, 999, "area code");
      this.prefix   = rangeCheck(prefix,   999, "prefix");
      this.lineNum  = rangeCheck(lineNum, 9999, "line num");
   }

   private static short rangeCheck(int val, int max, String arg) {
      if (val < 0 || val > max)
         throw new IllegalArgumentException(arg + ": " + val);
      return (short) val;
   }

   @Override public boolean equals(Object o) {
      if (o == this)
         return true;
      if (!(o instanceof PhoneNumber))
         return false;
      PhoneNumber pn = (PhoneNumber)o;
      return pn.lineNum == lineNum && pn.prefix == prefix
            && pn.areaCode == areaCode;
   }

   // 해시코드를 지연 초기화하는 hashCode 메서드 - 스레드 안정성까지 고려해야 한다. (71쪽)
   private int hashCode; // 자동으로 0으로 초기화된다.

   @Override public int hashCode() {
      int result = hashCode;
      if (result == 0) {
         result = Short.hashCode(areaCode);
         result = 31 * result + Short.hashCode(prefix);
         result = 31 * result + Short.hashCode(lineNum);
         this.hashCode = result;
      }
      return result;
   }
}

위처럼 hashCode 를 필드로 만들어 사용할수도 있다.

hashCode 가 필요할 때 계산을 하기 때문에 지연 초기화 를 했다고도 한다.

지연 초기화 기법의 주의사항으로는 스레드 안정성 을 고려해야한다.

멀티 스레드 환경에서 같은 객체이지만 다른 hashCode 값이 나올 수 있기 때문이다.

주의할 점은 해쉬코드 계산이 길어질 것 같아 equals 에서 사용하는 필드를

hashCode 계산에서 제외시키면 안된다.

hashCode 를 계산하는 알고리즘을 외부에 노출할 필요가 없다.

즉, 외부에서 hashCode 값을 토대로 다르게 동작하는 로직을 작성하면 안된다.

반응형

댓글