JAVA/Effective Java

[아이템 10] equals는 일반 규약을 지켜 재정의하라

꾸준함. 2024. 1. 26. 05:31

모든 클래스는 암묵적으로 Object 클래스를 상속하며 이에 따라 아래의 메서드를 오버라이딩 가능합니다.

  • equals
  • hashcode
  • toString
  • clone
  • finalize

 

이번 게시글에서는 equals에 대해 알아보겠습니다.

 

equals를 재정의할 필요 없는 케이스

아래의 케이스에 대해서는 equals를 재정의할 필요가 없습니다.

  • 각 인스턴스가 본질적으로 고유
  • 인스턴스의 "논리적 동치성"을 검사할 필요 없음
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 적절
  • 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일 없음

 

1. 각 인스턴스가 본질적으로 고유

 

싱글톤으로 구현했거나 enum 클래스일 경우 본질적으로 고유한 인스턴스이기 때문에 비교를 하는 경우가 없습니다.

따라서 이런 경우 equals를 재정의할 필요 없습니다.

 

2. 인스턴스의 "논리적 동치성"을 검사할 필요 없음

 

논리적 동치성이란 value가 동일하다는 뜻으로 해석 가능합니다.

예를 들어 만원 짜리 지폐 두 개가 있을 때 각각의 지폐는 물질적으로 다르기 때문에 별도 인스턴스로 해석 가능하지만 "만원"이라는 값은 같기 때문에 논리적 동치성이 성립한다고 볼 수 있습니다.

만약 인스턴스의 value를 비교할 필요가 없다면 equals를 굳이 재정의하지 않아도 됩니다.

 

3. 상위 클래스에서 재정의한 equals가 하위 클래스에도 적절

 

상속 구조에서 상위 클래스의 equals 메서드를 SubClass에서도 그대로 사용해도 되는 경우 굳이 equals 메서드를 오버라이딩 안 해도 됩니다.

실제로 List 또한 AbstractList의 equals 메서드를 그대로 사용하며 Set 또한 AbstractSet의 equals 메서드를 그대로 사용합니다.

 

AbstractSet의 equals 메서드
HashSet 클래스는 equals 오버라이딩 X

 

4. 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일 없음

 

public 클래스의 경우 어디서든 참조될 수 있으므로 equals 메서드가 호출되지 않는다는 보장이 없지만 private이거나 package-private일 경우 equals 호출 여부를 파악할 수 있습니다.

따라서 이러한 클래스들 중 equals를 통한 비교를 안할 것이라고 판단되면 굳이 재정의할 필요가 없습니다.

 

equals 재정의 시 따라야하는 규약

equals 메서드 오버라이딩 시 따라야 하는 규약은 아래와 같습니다.

  • 반사성
  • 대칭성
  • 추이성
  • 일관성
  • NULL 아님

 

1. 반사성

 

반사성은 자기 자신이 같은지 판별합니다.

  • A.equals(A)

 

앞서 살펴본 AbstractSet 클래스에서 오버라이딩 된 equals 메서드를 보면 맨 처음 조건으로 같은 인스턴스임을 확인하는 것을 볼 수 있습니다.

 

 

2. 대칭성

 

말 그대로 대칭이 성립해야 합니다.

  • A.equals(B)와 B.equals(A)의 결과가 일치해야 함

 

예시 1

대소문자를 구분하지 않는 문자열 클래스 CaseInsensitiveString이 equals 메서드를 재정의할 때 아래 코드처 CaseInsensitiveString 뿐만 아니라 String에 대해서도 대소문자를 무시한 채 비교할 경우 대칭성이 깨지게 됩니다.

String에 정의된 equals 메서드는 대소문자를 구분하기 때문입니다.

 

 

 

대칭성이 성립하기 위해서는 equals 메서드에서 아래처럼 CaseInsensitiveString끼리만 비교해야 합니다.

 

 

예시 2

Point 클래스를 상속한 뒤 Color 컴포넌트를 추가한 ColorPoint 클래스가 있고 해당 클래스가 equals 메서드를 재정의하여 color 컴포넌트까지 비교할 경우 대칭성 위배가 발생됩니다.

Point 클래스에 정의된 equals의 경우 좌표만 비교하기 때문에 Point와 ColorPoint의 좌표가 동일할 경우 결과가 참이지만 ColorPoint의 경우 좌표와 함께 색깔까지 비교하기 때문입니다.

 

 

 

별도 필드를 추가하는 경우 상속보다는 composition

자바에서 컴포지션(Composition)은 객체 지향 프로그래밍(OOP)에서 사용되는 하나의 디자인 패턴입니다.

컴포지션은 상속을 사용하지 않고 클래스들을 조합하여 새로운 클래스를 만드는 방법이며 주로 "HAS-A" 관계로 나타냅니다.

예를 들어, 자동차 클래스가 엔진 클래스를 포함하고 있다면, 자동차 "HAS-A" 엔진입니다.

 

앞서 ColorPoint 클래스도 Point 클래스를 상속하는 대신 Color와 Point를 필드로 갖는 컴포지션으로 구성하여 equals 메서드에서 Point는 Point끼리, Color는 Color끼리 비교한다면 대칭성을 위반할 일은 없을 것입니다.

 

 

3. 추이성 (Transivity)

 

만약 A.equals(B)true이고, B.equals(C)true이면, A.equals(C)도 반드시 true를 반환해야 합니다.

즉, 동등성이 체인 형태로 전파되어야 합니다.

앞서 예시를 들어본 ColorPoint 클래스에서 equals를 재정의할 때 매개변수로 전달 받은 Object가 ColorPoint일 때 색깔까지 비교를 할 경우 추이성이 깨지는 것을 확인할 수 있습니다.

매개변수로 전달 받은 Object가 Point라면 좌표끼리만 비교하지만 ColorPoint일 경우 좌표뿐만 아니라 색깔까지 비교하기 때문입니다.

 

 

또한 위 예제 코드처럼 서브 클래스에서 오버라이딩한 equals 메서드에서 매개변수로 전달받은 Object가 동일 클래스가 아닐 때 전달받은 Object의 equals() 메서드를 호출하도록 구현했고 넘겨받은 클래스가 ColorPoint와 동일 레벨인 서브 클래스일 경우 서로 순환 참조를 하여 StackOverflow 에러가 발생할 수 있습니다.

간단하게 예시를 들자면 Point를 상속받는 ColorPoint(위 코드 참고)와 SmellPoint가 있고 서로가 equals 메서드를 동일하게 작성할 경우 아래와 같은 순환 참조가 발생합니다.

  • ColorPoint.equals() 메서드가 SmellPoint.equals() 메서드 호출
  • SmellPoint.equals() 메서드가 ColorPoint.equals() 메서드 호출
  • ColorPoint.equals() 메서드가 SmellPoint.equals() 메서드 호출
  • SmellPoint.equals() 메서드가 ColorPoint.equals() 메서드 호출
  • ...
  • 스택이 무한정 쌓여 결국에는 StackOverFlow 발생


StackOverFlow 발생

 

정리하자면 상속 구조에서 새로운 필드를 추가할 경우 객체 지향적 추상화의 이점을 포기하지 않으면서 equals 규약을 만족시킬 방법은 존재하지 않습니다.

혹자는 "상위 클래스인 Point 클래스 내 equals 메서드에서 instanceOf를 getClass 검사로 바꾸면 규약도 지키고 새로운 필드도 추가하면서 구체 클래스를 상속할 수 있는 것 아니냐"라고 질문할 수 있습니다.

이렇게 구현할 경우 equals는 같은 구현 클래스의 객체와 비교할 때만 참을 반환하기 때문에 괜찮아 보이지만 실제로는 SOLID 원칙의 리스코프 치환 원칙에 위배되어 실제로 활용할 수는 없습니다.

리스코프 치환 원칙에 따르면 어떤 '하위 클래스의 객체'가 '사위 클래스 객체'를 대체하더라도 소프트웨어의 기능을 깨트리지 말아야 하는데 아래 예시를 보면 좌표가 동일한데도 불구하고 CounterPoint와 Point의 equals 결과가 다른 것을 확인할 수 있습니다.


 

따라서 앞서 정의한대로 상위 클래스에서는 getClass 검사가 아닌 instanceOf로 타입 비교를 권장합니다.


 

4. 일관성


x.equals(y)가 참인 결과를 반환한다면 몇 번을 재호출해도 결과는 항상 참이어야 합니다.

위 규약은 불변 객체일 경우 항상 보장이 되지만 가변 객체의 경우 내부 필드 값이 변경될 수 있기 때문에 항상 결과가 같다고 보장할 수 없습니다.

 

5. NULL 아님

 

null이 아닌 모든 참조 값 A에 대해 A.equals(null) 결과가 false임을 보장해야 합니다.

null과 비교했을 때 실수로 NullPointerException을 던지지 않도록 주의해야 합니다.

 

equals 구현 방법

지금까지의 내용을 종합하면 equals 메서드를 구현 방법은 아래와 같습니다.

  • == 연산자를 사용해 자기 자신의 참조인지 확인
  • instanceof 연산자로 올바른 타입이지 확인
  • 입력된 값을 올바른 타입으로 형변환
  • 입력 객체와 자기 자신의 대응되는 핵심 필드가 일치하는지 확인
    • 예를 들어 lock 자체는 어떤 클래스가 가지고 있는 고유한 필드가 아니므로 핵심 필드가 아님

 

앞서 예시로 소개한 Point 클래스가 위 원칙을 모두 지키며 equals를 구현했습니다.


 

하지만 현실적으로 위 원칙을 모두 지키며 equals를 구현하기 쉽지 않고 많은 경우 Object의 equals 메서드로 원하는 비교를 정확히 수행하기 때문에 해당 메서드를 직접 재정의하는 것은 지양해야 합니다.

하지만 다행히도 아래의 라이브러리 혹은 IDE의 도움을 받으면 손쉽게 equals를 원칙에 맞게 구현할 수 있습니다.

  • 구글의 AutoValue
  • Lombok

 

또한, java 13+부터 소개된 Record 클래스를 사용할 경우 DDD의 Value Object와 같이 동작하여 불변 객체이기 때문에 별도 equals 및 hashCode 메서드를 구현하지 않아도 됩니다.

 

equals 구현 시 주의 사항

불가피하게 equals 메서드를 재정의해야하는 경우 아래 사항을 주의해야 합니다.

  • equals 메서드를 재정의할 때 hashCode도 반드시 재정의 필요
  • 너무 복잡하게 해결 X
  • Object가 아닌 타입의 매개변수를 받는 equals 메서드는 선언하지 말자

 

1. equals 메서드를 재정의할 때 hashCode도 반드시 재정의 필요

 

해당 내용은 다음 아이템인 아이템 11에서 다루겠습니다.

 

2. 너무 복잡하게 해결 X

 

필드들의 동치성만 검사하더라도 equals 규약을 모두 지키며 재정의할 수 있는 케이스가 대다수입니다.

오히려 너무 깊게 파고들어 alias까지 비교하여 가령 File 클래스의 심볼릭 링크를 비교해 같은 파일을 가리키는지 확인하도록 구현하는 경우가 있는데 이는 권장하지 않는 방법입니다.

 

3. Object가 아닌 타입의 매개변수를 받는 equals 메서드는 선언하지 말자

 

간혹 오버라이딩과 오버로딩을 헷갈리는 사람이 있습니다.

반환 타입과 매개변수 타입과 순서까지 일치할 경우 오버라이딩이지만 아닐 경우 메서드명이 같더라도 오버로딩입니다.

따라서 아래 코드처럼 Object가 아닌 Point 매개변수와 비교하는 equals를 구현했다면 실질적으로는 Object의 equals로 비교하는 꼴이 됩니다.


 

정리

모든 원칙과 주의사항을 지키며 equals를 재정의하기 쉽지 않기 때문에 웬만해서는 Object의 equals를 사용하는 것을 권장합니다.

굳이 재정의해야한다면 라이브러리의 도움 혹은 IDE의 도움을 빌리는 것을 추천합니다.

Java 13+ 버전을 사용하는 경우 값 비교를 할 때 Record 클래스를 사용하는 것을 추천합니다.

 

참고

이펙티브 자바
이펙티브 자바 완벽 공략 1부 - 백기선 강사님

반응형