본문 바로가기
이펙티브 자바

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

by 에드박 2021. 5. 1.

equals() 메서드를 함부로 재정의 하면 함정에 걸려 끔찍한 결과가 생길 수 있습니다.

문제는 회피하는 가장 쉬운 길은 equals() 메서드를 아예 재정의 하지 않는것입니다.

 

재정의 하지 않으면 인스턴스는 오직 자기 자신과만 같게 됩니다.
즉, 같은 주소값을 가진 객체와만 같게 됩니다.

 

아래에서 열거한 상황 중 하나에 해당하지 않는다면 재정의 하지 않는것이 최선입니다.

  • 각 인스턴스가 본질적으로 고유하다.
    • 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 이에 해당합니다.
    • Thread 가 좋은 예로, Object의 equals메서드는 이러한 클래스에 딱 맞게 구현되었습니다.
  • 인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없습니다.
    • java.util.regex.Pattern 은 equals를 재정의해서 두 Pattern의 인스턴스가 같은 정규표현식을 나타내는지 검사하는 즉, 논리적 동치성을 검사하는 방법이 있습니다.
    • 즉, 객체의 주소가 아닌 가지고 있는 값을 비교해서 동일한 객체로 판단하는것을 논리적 동치성을 검사한다고 합니다.
  • 상위 클래스에서 재정의한 equals() 메서드가 하위 클래스에도 딱 알맞다.
  • 클래스가 private 이거나 package-private 이고 equals()메서드를 호출할 일이 없다.
  • 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스일 때

 


equals() 메서드를 재정의해야 할 때는 언제인가?

객체 식별성(object identity, 두 물체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데,

상위 클래스의 equals가 논치적 동치성을 비교하도록 재정의 되지 않았을 때입니다.

 

주로 값 클래스가 여기 해당합니다.

값 클래스란 Integer, String 처럼 값을 표현하는 클래스를 말합니다.

두 값 객체를 equals() 로 비교할 때 우리는 객체의 주소가 같은지가 아닌 값이 같은지를 알고싶을 겁니다.

이럴 때는 우리가 equal() 메서드에 값으로 비교하여 같은지를 확인하도록 재정의 하면 됩니다.

또한 값끼리 같은 객체를 동등하다고 적용하여 Map, Set 의 중복검사에도 같은 객체로 취급하게 됩니다.
Map, Set 은 equals() 그리고 hashCode() 를 사용해서 객체를 비교합니다.

 


equals() 메서드를 재정의할 때는 반드시 일반 규약을 따라야 한다

다음은 Object 명세에 적힌 규약입니다.

  • 반사성 (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를 반환한다.
  • null-아님 : null이 아닌 모든 참조 값 x에 대해 x.equals(null)은 false 입니다.

위의 규약을 어기면 프로그램이 이상하게 동작하거나 종료될 것이고, 원인이 되는 코드를 찾기도 굉장히 어려울 것입니다.

 

한 클래스의 인스턴스는 다른 곳으로 빈번히 전달됩니다.

그리고 컬렉션 클래스들을 포함해 수많은 클래스는 전달받은 객체가 equals 규약을 지킨다고 가정하고 동작합니다.


반사성

  • 반사성 : 객체는 자기자신과 같아야한다.
    • 반사성을 지키지 않으면 컬렉션에 넣었을 때 contains() 메서드에 방금 넣었던 객체를 찾으면 false가 반환됩니다.

대칭성

  • 대칭성 : 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻, 아래는 대칭성을 위배한 equals 메서드 입니다.
    • 아래 CaseInsensitiveString 클래스의 equals() 메서드를 보면 String 객체와도 equals가 호환되도록 했습니다.
    • 따라서 다음과 같은 객체를 생성했을 때
    • CaseInsensitiveString cs = new CaseInsensitiveString("asd");
    • String s = "ASD";
    • cs.equals(s); 의 결과는 true 입니다.
    • 하지만 s.equals(cs); 의 결과는 false입니다. String 의 equals() 메서드는 대소문자까지 구분을 하도록 되어있기 때문입니다.
    • 이 예제에서 대칭성을 유지하기 위해서는 CaseInsensitiveString 의 equals() 메서드에서 String 타입까지 호환하는 욕심을 버리는 것입니다.

 

import java.util.Objects;

public class CaseInsenstiveString {
    private final String s;

    public CaseInsenstiveString(String s) {
        this.s = Objects.requireNonNull(s);
    }

    //대칭성 위배
    @Override
    public boolean equals(Object o) {
        if (o instanceof CaseInsenstiveString) {
            return s.equalsIgnoreCase(((CaseInsenstiveString) o).s);
        }
        if (o instanceof String) { // 한방향 으로만 equals가 동작한다.
            return s.equalsIgnoreCase((String) o);
        }
        return false;
    }
}

 


추이성

  • 추이성 : 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체도 같아야 한다는 뜻
    • 첫 번째 객체 == 두 번째 객체 , 두 번째 객체 == 세번째 객체, 첫 번째 객체 == 세 번째 객체
    • 아래는 추이성을 위배한 코드입니다.
class Point {
    private final int x;
    private final int y;

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

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point)) {
            return false;
        }
        Point p = (Point) o;
        return p.x == x && p.y == y;
    }
}

class ColorPoint extends Point {
    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof  Point)) {
            return false;
        }

        // o가 일반 Point 면 색상을 무시하고 비교
        if (!(o instanceof ColorPoint)) {
            return o.equals(this);
        }

        // o 가 ColorPoint면 색상까지 비교합니다.
        return super.equals(o) && ((ColorPoint) o).color == color;
    }
}

class Color {
    private String name;

    public Color(String name) {
        this.name = name;
    }
}

 

위 코드는 다음과 같은 객체로 비교했을 때 추이성을 위반하고 있습니다.

ColorPoint p1 = new ColorPoint(1, 2, Color.RED);

Point p2 = new Point(1, 2);

ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

 

p1.equals(p2) -> true  / 색상을 무시하고 비교

p2.equals(p3) -> true / 색상을 무시하고 비교

p1.equals(p3) -> false / 생상을 고려하여 비교

라는 결과가 나오므로 추이성을 위반하고 있습니다.

 

그렇다면 위에서 나타난 추이성 위반을 해결하는 방법은?

사실 이런 현상은 모든 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제입니다.

구체 클래스를 확장해서 새로운 값(상태)을 추가할 때 equals 규약을 만족시킬 방법은 존재하지 않습니다.

(객체 지향적 이점을 포기하지 않으면서)

 

위의 말을 보고 instanceof 로 검사하던 것을 getClass() 메서드로 검사하도록 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있다는 뜻으로 들립니다.

아래는 instanceof 로 검사하던 것을 getClass() 로 검사하도록 바꾼 예제입니다.

class Point2 {
    private final int x;
    private final int y;

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

    @Override
    public boolean equals(Object o) {
        if (o == null || o.getClass() != this.getClass()) {
            return false;
        }
        Point2 p = (Point2) o;
        return p.x == x && p.y == y;
    }
}

 

이제 equals 는 같은 구현 클래스의 객체와 비교할 때만 true를 반환합니다. 그럴듯해 보이지만 실제로 활용할 수는 없습니다.

이유는 Point 의 하위 클래스를 Point 로써 활용할 수 없다는 점입니다.

 

Poin의 하위 클래스는 정의상 Point 이므로 어디서든 Point로써 활용될 수 있어야합니다.

 

리스코브 치환 원칙 (Liskov substitution principle, LSP)에 따르면

"어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다. 따라서 그 타입의 모든 메서드가 하위 타입에서도 똑같이 동작(작동)해야한다."

즉, Point의 하위 클래스는 정의상 여전히 Point 이므로 어디서든 Point 로써 활용될 수 있어야합니다.

 


일관성

  • 일관성 : 두 객체가 같다면 (어느 하나 혹은 두 객체 모두 수정되지 않는 한) 앞으로도 계속 같아야 한다는 뜻
    • 가변 객체는 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있는 반면
    • 불변 객체는 한번 다르면 끝까지 달라야합니다.
    • 만약 불변 클래스로 만들기로 했다면 equals() 가 한번 같다고 한 객체는 영원히 같다고 해야하고, 한번 다르다고 한 객체는 영원히 다르다고 해야합니다.
    • 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안됩니다.
    • 한 예로 java.net.URL 의 equals는 주어진 URL 과 매핑된 호스트의 IP 주소를 이용해 비교합니다.
    • 호스트 이름을 IP주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없습니다.
    • 이런 문제를 피하려면 equals는 항시 메모리에 존재하는 객체만을 사용한 결정적 계산만을 수행해야 합니다.

null-아님

  • null-아님(공식 이름도 아님, 공식 이름이 없습니다) : 모든 객체가 null과는 같지 않아야 한다는 말입니다.

 


올바른 equals 메서드를 구현하는 방법

  1.  == 연산자를 사용해 입력이 자기 자신의 참조인지 확인합니다. 자기 자신이면 true를 반환.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인하고 올바른 타입이 아니면 false 를 반환
    (이 때 올바른 타입이란 equals가 정의된 클래스인 것이 보통이지만, 가끔은 그 클래스가 구현한 특정 인터페이스가 될 수도 있습니다.)
    때로는 비교할 타입 대상이 그 클래스가 구현한 인터페이스가 될 수도 있습니다.
  3. 입력을 올바른 타입으로 형변환 합니다. (앞에서 instanceof 로 검사했기 때문에 100% 성공합니다.)
  4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
    모든 필드가 일치하면 true 를 반환, 하나라도 틀리면 false를 반환
    만약 2단계에서 인터페이스를 사용했다면 입력의 필드 값을 가져올 때 인터페이스의 메서드를 사용해서 접근하면 됩니다.

 

*float 과 double 을 제외한 기본 타입 (Primitive Type) 필드는 == 연산자로 비교하고,

참조 타입 필드는 각각의 equals 메서드로,

float와 double 필드는 각각 정적 메서드인 Float.compare(float, float) 과 Double.compare(double, double)로 비교합니다.

float 과 double 을 특별 취급 하는 이유는 Float.NaN, -0.0f 특수한 부동소수 값 등을 다뤄야 하기 때문입니다.

Float.equals, Double.equals 같은 메서드를 사용할 수도 있지만, 이 메서드들은 오토박싱을 수반할 수 있으니 성능상 좋지 않습니다.
(float 또는 double 기본값을 == 으로 비교하면 그대로 비교하면 되지만 Float , Double 을 사용하면 오토박싱이 필요

 

가끔 null도 정상 값으로 취급하는 참조 타입 필드도 있습니다. 이런 필드는 정적 메서드인 Objects.equals(Object, Object)로 비교해서 NullPointerException 발생을 예방하도록 합니다.

 


비교하기 복잡한 필드라면 그 필드의 표준형을 저장해두자

이펙티브 자바 책에 써져있는 필드의 표준형 이라는 단어가 헷갈려서 몇가지 찾아보게 됐습니다.

 

먼저 위의 CaseInsensitiveString 의 제대로 된 코드는 아래와 같습니다.

class CaseInsenstiveStringEx1 {
    private final String value;

    public CaseInsenstiveStringEx1(String value) {
        this.value = Objects.requireNonNull(value);
    }

    @Override
    public boolean equals(Object o) {
        if (o instanceof CaseInsenstiveStringEx1) {
            return value.equalsIgnoreCase(((CaseInsenstiveStringEx1) o).value);
        }
        return false;
    }
}

 

현재는 equals 내부에서 value 문자열의 equalsIgnoreCase 를 사용해서 값 비교를 하고있습니다.

equalsIgnoreCase() 메서드는 내부적으로 toUpperCase 또는 toLowerCase 를 사용하기 때문에 아무래도 비교할때마다 비용이 발생합니다. 따라서 value 라는 필드의 표준형을 만들어서 초기화 해두는 것입니다.

 

class CaseInsenstiveStringEx2 {
    private final String value;
    private final String canonicalFormValue;

    public CaseInsenstiveStringEx2(String value) {
        this.value = Objects.requireNonNull(value);
        this.canonicalFormValue = value.toUpperCase();
    }

    @Override
    public boolean equals(Object o) {
        if (o instanceof CaseInsenstiveStringEx2) {
            return canonicalFormValue.equals(((CaseInsenstiveStringEx2) o).canonicalFormValue);
        }
        return false;
    }
}

 

canonicalFormValue 라는 표준형 필드를 만들었습니다.

canonicalFormValue 필드는 생성자에서 value 에 toUpperCase() 라는 메서드를 사용해서 초기화합니다.

이제 equals() 메서드에서  canonicalFormValue로 비교를 합니다.

이렇게 하면 equalsIgnoreCase() 메서드에서 매번 발생하는 비용을 줄일 수 있습니다.

 

이 기법은 위와같이 불변 클래스(아이템 17)에 딱 맞는 방법이지만

만약 가변 객체라면 표준형의 대상 필드가 변경될때마다 표준형 필드도 함께 최신화 해줘야 합니다.

 


다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하자

 

최상의 성능을 원한다면 비교했을 때 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하는것이 좋습니다.

 

다를 가능성이 더 큰 필드를 먼저 비교하는 이유는 아래의 순서로 비교했을때 어떤 문제가 있을지 생각해보면 좋습니다.

 

먼저 아래와 같은 필드가 있습니다.

  • 회사 이름
  • 간부 수
  • 사원 수 

 

위의 필드를 다음과 같은 순서로 비교합니다.

  • 회사이름 -> 간부 수 -> 사원 수

회사 이름은 대부분의 경우에 변경이 없습니다. 따라서 매번 통과를 할것이고 다음 간부 수를 비교하여 같은지 다른지 비교할것입니다.

만약 간부 수 또는 사원 수 가 다르더라도 회사 이름을 비교하는 비용은 계속해서 발생할것입니다.

 

하지만 다음과 같이 순서를 바꾼다면 비교 비용이 달라집니다.

  • 사원 수 -> 간부 수 -> 회사이름

사원 수가 다르면 비교는 즉시 끝날것이고 회사 이름을 비교하는 비용은 더 이상 처음에 발생하지 않습니다.

즉 앞에서 다르다는 결과가 나오면 뒤의 값들에 비교하는 비용은 발생하지 않는것입니다.

그래서 비교 했을때 다를 확률이 가장 높은 값을 우선적으로 배치하는 것입니다.

 

비용에 대한 이야기도 비슷합니다.

만약 비용이 비싼 값의 비교를 통과해도 뒤의 비교 비용이 싼 값의 결과가 다르다 라면 앞의 비싼 비용을 치른 비교가 물거품이 됩니다.

따라서 값싼 비용의 필드를 먼저 비교해서 비교의 비용을 최대한 효율적으로 만드는 것입니다.

 


  • equals를 재정의할 땐 hashCode 도 반드시 재정의 하자(아이템 11)
  • 너무 복잡하게 해결하려 들지 말자
    • 필드의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있습니다.
    • 오히려 너무 공격적으로 파고들다가 문제를 일으키기도 합니다.
  • Object외의 타입을 매개변수로 받는 equals 메서드는 선언하지 않아야 합니다.
// 잘못된 예 - 입력타입은 반드시 Object 여야 합니다.
public boolean equals(MyClass o) {
    ...
}

 

  • @Override 애너테이션을 붙이도록 하자
    • 이는 긍정 오류(false positive)를 내게하고 보안 측면에서도 잘못된 정보를 줍니다.
    • 이를 잘 활용하면 실수들을 방지할 수 있습니다(아이템 40)

AutoValue (구글이 만든 프레임워크)

equals(그리고 hashCode)를 작성하고 테스트 하는 일은 지루하고 테스트하는 코드도 항상 반복됩니다.

 

이런 작업을 대신해줄 오픈 소스로 구글이 만든 AutoValue 프레임워크가 있습니다.

클래스에 애너테이션 하나만 추가하면 AutoValue가 이 메서드들을 알아서 작성해줍니다.

 

대다수의 IDE도 같은 기능을 제공하지만 생성된 코드가 AutoValue만큼 깔끔하거나 읽기 좋지는 않습니다.

 

내 의견 : 외부 라이브러리에 의존하여 편해지는것은 좋지만 너무 의존하는 것은 나중에 외부 라이브러리가 프로그램에 어떤 영향을 끼칠지 알 수 없습니다. 따라서 잘 생각해서 AutoValue 같은 라이브러리를 사용하는 것이 좋다고 생각합니다.

댓글