Skip to main content

[Java] equals()와 hashCode() 알아보기

equals()와 hashCode()

두 가지 함수 모두 모든 클래스의 최상위 부모인 Object에 정의 되어있는 함수로, 인스턴스 간의 동등함과 관련된 함수들입니다.

equals() #

public boolean equals(Object obj)

인자로 주어지는 객체가 equals()를 호출한 객체와 동일한 객체인지 여부를 반환합니다.
null이 아닌 객체에 대해 equals()는 다음과 같은 Object 명세를 지키는 동등 관계를 구현합니다.
이를 어긴다면, 해당 클래스 인스턴스를 저장한 Collection에서 contatins()와 같은 함수를 호출 시, 인스턴스를 찾지 못할 것입니다.
이처럼, equals 규약을 어기면 이 객체를 사용하는 다른 객체들어떻게 반응할지 알 수 없습니다.

동등 관계 규약

반사성(reflexivity)

null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.
즉, 객체는 자기 자신과 같아야 합니다.

대칭성(symmetry)

null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true다.
즉, 두 객체는 서로에 대한 동치 여부에 동일하게 답해야 합니다.

추이성(transitivity)

null이 아닌 모든 참조 값 x, y,z에 대해, x.equals(y)가 true고 y.equals(z)도 true면, x.equals(z)도 true다.
즉, 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 합니다.

일관성(consistency)

null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)를 반복해 호출하면, 일관되게 true를 반환하거나 false를 반환한다.
두 객체가 같다면, 이후로 둘 중 한 객체에서도 수정이 발생하지 않는 한 앞으로도 영원히 같아야 합니다.

hashCode() #

public int hashCode()

함수를 호출한 객체의 해시 코드 값반환합니다.
이 함수는 HashMap과 같은 클래스에서 제공하는 해시 테이블의 이점을 위해 지원됩니다. hashCode() 역시 원활한 사용을 위해 지켜야하는 규약들이 명시되어 있습니다.
만약 이를 어긴다면, Hash 기반 컬렉션에서 get 메서드를 통해 논리적 동치 인 객체로 꺼내려 해도, hashCode가 다르기 때문에 꺼낼 수 없습니다. (내부에서 최적화 때문에 hashCode가 동일하지 않으면, 동치인지 비교조차 하지 않도록 되어있습니다.)
일반적으로 객체의 내부 주소를 Integer로 변환해 반환하는 방식으로 구현되지만, 이러한 형태는 Java 언어에서 공식적으로 요구하는 기법은 아닙니다.

hashCode 일반 규약

  • equals 비교시 사용되는 정보가 변하지 않았다면, 애플리케이션의 실행시간 동안항상 같은 값을 반환해야 한다. 단, 애플리케이션이 재실행된다면 값이 달라져도 상관없다.
  • equals(obj)가 두 객체를 같다고 판단한 경우, 두 객체의 hashCode똑같은 값을 반환해야 한다.
  • equals(obj)가 두 객체를 다르다 판단해도, hashCode 값이 꼭 달라야 할 필요는 없다.
    • 다만, 다른 값을 반환하는 것이 해시테이블 성능에 좋다.

hashCode() 를 재정의할 때, 염두해야 할 점

모든 객체에서 다른 값을 반환할 수 있는가?

최대한 모든 객체에서 다른 값을 반환하도록 구현해야 합니다.
만약 모든 객체가 같은 값을 반환하게 된다면, 모든 객체가 해시 테이블의 한 버킷에 담기게되어, 마치 연결 리스트처럼 동작하게 됩니다. 따라서 평균 수행시간이 $O(N)$으로 느려지므로 성능에 좋지 않습니다.

좋은 hashCode() 작성하기

Effective Java의 item 11에 나와있는 방식은 다음과 같습니다.
참고로 아래에 나오는 핵심 필드equals 비교시 사용되는 필드를 의미합니다.

  1. int 변수인 result를 선언하고, 해당 객체의 첫 핵심 필드를 를 2.a 방식으로 계산한 값으로 초기화 합니다.
  2. 해당 객체의 나머지 핵심 필드 f 각각에 다음 작업을 수행합니다.
    • a. 해당 필드의 해시 코드 C를 계산한다.
      1. 원시 타입인 경우: Type(래퍼 클래스).hashCode(f)를 수행한다.
      2. 객체인 경우: 해당 객체의 hashCode()를 호출(null이면 0 사용)한다.
      3. 배열인 경우: 각각을 별도의 필드로 다뤄, 2.a.1 또는 2.a.2 규칙대로 계산한다.
    • b. 2.a에서 계산한 hashCode로 result를 갱신한다.
    result = 31 * result + c;
    
  3. result를 반환한다.

주의 사항

  • hashCode를 계산할 때, equals에 사용되지 않는 필드는 계산에서 반드시 제외해야 합니다.
  • 31을 사용하는 이유는, 홀수이면서 소수이기 때문에 짝수를 사용할 경우 발생하는 ‘오버플로 발생시 정보 소실’을 방지할 수 있습니다.

hashCode 계산 비용이 큰 가?

매 번 계산하기 보다, 캐싱 하는 방법을 고려해야 합니다.
다만, 객체 내부의 값이 바뀔 수 있는 경우 이러한 방법은 적용하기 힘들고, 불변 클래스일 경우에만 가능합니다.
인스턴스 생성시, 해시코드를 계산해 저장해두고 추후에는 이를 불러오도록 하면 성능이 향상될 수 있습니다.
추가로 지연 초기화(Lazy-loading)를 적용할 수도 있지만, thread-safe한 지 신경써야 합니다.

핵심 필드가 포함되어 있는가?

성능이 떨어진다고 hashCode 계산시 핵심 필드를 생략한다면, 해시 품질이 떨어지게 되고 해시 테이블의 성능 저하를 야기하게 됩니다.
특히, 해시코드를 퍼뜨려주는 효과를 가진 필드를 생략한다면, 몇 개의 해시코드로 인스턴스들이 집중되기 때문에 속도가 더욱 느려집니다.

  • 실제로 Java 2버전 이전의 String은, 성능 문제로 문자열의 16자만 가지고 해시코드를 계산했고 위와 같은 문제가 드러났었습니다.

hashCode 생성 규칙을 API에 공표해야 하는가?

정말 불가피한 경우가 아니라면, API에 어떤 방식으로 hashCode가 생성되는지 공표하지 않는 것이 좋습니다.
그래야 클라이언트가 hashCode 값에 의지하는 것을 방지할 수 있고, 추후 더 나은 방식을 도입해 해싱 속도나 정확도를 개선할 수 있기 때문입니다.

equals() 를 재정의 해야 할 때, 염두해야 할 점

equals()를 재정의할 땐, 다음과 같은 사항들을 신중히 고려해 재정의해야 합니다.

꼭 재정의가 필요한가?

equals()를 잘못 재정의하게 되면 오작동 할 우려가 큽니다.
따라서 꼭 재정의하지 않아도 된다면 하지 않는것이 최선일 수 있습니다.
재정의하지 않아도 되는 경우는 다음과 같습니다.

각 인스턴스가 본질적으로 고유한 경우

값을 표현하는게 아닌, 동작하는 개체를 표현하는 클래스가 이에 해당합니다.

인스턴스의 논리적 동치성을 검사할 일이 없는 경우

상위 클래스에서 이미 재정의한 equals가 하위 클래스에서도 잘 동작하는 경우

예시로 대부분의 Set, List, Map 구현체들은 그들의 상위인 AbstractSet, AbstractList, AbstractMap 으로 부터 상속받은 equals를 사용합니다.

클래스가 private 또는 package-private이고, equals 메서드를 호출할 일이 없는 경우

반대로, 꼭 재정의해야하는 경우?

논리적 동치성 을 확인해야 하는데, 상위 클래스의 equals()가 이러한 논리적 동치성을 비교하도록 재정의되어있지 않은 경우 꼭 재정의해주어야 합니다.
주로 값을 표현하는 클래스 가 이러한 경우에 해당됩니다.

hashCode()도 재정의했는가?

그렇지 않으면 hashCode() 일반 규약을 어기게 되어, HashMap, HashSet과 같이 Hash 값을 사용하는 컬렉션의 원소로 사용할 때 문제를 일으키게 됩니다.

Object 외의 타입을 인자로 받는 equals를 선언하진 않았는가?

Class ABC {
	...
	public boolean equals(ABC o) {
		...
	}
}

위와 같이 구현하게 되면, Objectequals를 재정의 하는게 아닌, 다중정의(Overloading)을 하게 되는 것이다.

구체 클래스에 값을 추가해 확장했는가?

이러한 경우에는 상기된 equals()에 대한 규약을 만족시킬 방법이 존재하지 않습니다.
이는 모든 객체지향 언어의 동치 관계에서 나타나는 근본적인 문제로, 객체지향적 추상화의 이점을 포기하지 않는한 지킬 수 없습니다.
따라서, 구체 클래스에 새로운 값을 추가해 확장하고 싶다면, 상속 대신 컴포지션의 방식으로 우회해 구현하게 된다면 equals() 규약을 지킬 수 있습니다.

  • 예시로 Java의 API 중에서도 Date에 값을 추가하여 확장한 TimeStamp 클래스가 있는데, 이 둘 역시 대칭성을 위배해 API 설명에 이 둘을 섞어 쓰지 않도록 권장합니다.

equals를 통한 비교 과정에 신뢰할 수 없는 자원이 포함되어있는가?

예시로 java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 비교에 이용하는데, 호스트 이름을 IP 주소로 바꾸었을 때 그 결과가 항상 같음을 보장할 수 없습니다.
이 때문에 실무에서 종종 문제를 일으키기도 합니다.

같은 타입의 객체를 비교하고 있는가?

이를 확인하기 위해 equals() 내부에서 instanceof 연산자를 통해 올바른 타입의 객체가 입력으로 주어졌는지 확인하는 과정이 필요합니다.
이러한 과정을 거치면 추가로 묵시적인 null 검사도 진행할 수 있고, 이후 올바른 타입으로 형변환 할때도 오류가 발생하지 않습니다.

어떤 필드를 비교하는가?

기본적으로 핵심 필드들이 모두 일치하는지 비교해야하며, 필드의 비교는 종류별로 다음과 같은 방법을 통해 진행해야 합니다.

  • 원시 타입 필드(float, double 제외) : == 연산자로 비교합니다.
  • float, double : 부동소수 값의 비교는 신중해야 하기 때문에, Float.compare(float, float)Double.compare(double, double)을 이용해 비교해야 합니다.
  • 참조 타입 필드: 각각의 equals() 메서드로 비교합니다.
    • 만약 null 값도 정상 값으로 취급하는 경우, Object.equals(obj, obj)로 비교합니다.

또한, 어떤 필드를 먼저 비교하느냐에 따라 성능이 갈릴 수 있기 때문에, 다를 가능성이 크거나 비교 비용이 싼 필드를 먼저 비교해야 합니다.

참고 문서

cloudsoswift