[Effective Java] item 10. equals는 일반 규칙을 지켜 재정의하라

2023. 4. 15. 16:32Programming Languages/Effective Java

Java를 사용할 때면 객체를 비교하기 위해 equals를 재정의하는 경우가 종종 있다. equals 메서드는 재정의하기 쉬워 보이지만 잘못하면 끔찍한 결과를 초래한다. 이 문제를 회피하기 가장 쉬운 방법은 아예 재정의하지 않는 것이다. 그냥 두면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다. Object.equals를 그대로 상속받기 때문이다. 그러니 다음에서 열거한 상황 중 하나에 해당한다면 재정의하지 않는 것이 최선이다.

equals를 재정의할 필요가 없는 경우

  • 각 인스턴스가 본질적으로 고유하다.
    • 값을 표현하는 게 아니라 개체를 표현하는 클래스가 여기에 해당된다. Thread가 좋은 예로, Object의 equals는 이러한 클래스에 딱 맞게 구현되었다.
    • 값 클래스라 하더라도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스(싱글턴 클래스, Enum 등)는 재정의하지 않아도 된다. 어차피 인스턴스가 두 개 이상 만들어지지 않음이 보장되기 때문이다.
  • 인스턴스의 논리적 동치성을 검사할 일이 없다.
    • 예를 들어 name이라는 필드가 있는 Item 클래스가 있다고 하고 있다. 두 객체를 비교하려면 객체 주소가 아니라 name이 같은지를 검사해야 한다. 이런 경우를 논리적 동치성을 검사한다고 한다.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
  • 클래스가 private이거나 package-private이고 equals를 호출할 일이 없다.

만약 equals가 실수로라도 호출되는 걸 막고 싶다면 다음과 같이 재정의할 수 있다.

@Override
pubilc boolean equals(Object o) {
    throw new AssertionError(); // 호출하면 에러
}

equals를 재정의해야 하는 경우

  • 객체 간 논리적 동치성을 검사해야 하는데 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때

equals 재정의 일반 규칙

equals 메서드는 동치관계(equilvalaence relation)를 구현하며, 다음을 만족한다.

  • 반사성(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이다.

Java로 어느 정도 개발을 해보았다면 클래스가 혼자 동작하는 경우는 거의 없다는 것을 알 것이다. 객체 지향 프로그래밍에서는 수많은 객체가 서로 유기적으로 상호작용하면서 동작하기 때문이다. 그리고 이 과정에서 인스턴스는 다른 클래스로 자주 전달된다. 또한 컬렉션 클래스들을 포함해 수많은 클래스는 전달받은 객체가 equals 규약을 지킨다고 가정하고 동작한다. 따라서 equals의 재정의 규칙은 반드시 지켜야 한다. 이 규칙들은 보기엔 어려워 보여도 이해하면 그리 복잡하지 않다. 이해하고 나면 규칙을 지키는 것도 어렵지 않다.

그렇다면 동치관계라는 건 무슨 뜻일까? 쉽게 말해, 집합을 서로 같은 원소들로 이루어진 부분집합으로 나누는 연산이다. 이 부분집합을 동치류(동치 클래스)라고 한다. equals가 쓸모있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다. 이제 동치 관계를 만족시키기 위한 다섯 요건을 하나씩 알아보자.

⚠️갑자기 수학 얘기가 나와서 어지러운 분도 계시겠지만 그냥 지나치면 안 된다!! 도망쳐서 도착한 곳에 낙원은 없다.

반사성

단순히 말하면 객체는 자기 자신과 같아야 한다는 뜻이다. 이 요건은 일부러 어기지 않으면 만족시키지 못하기가 더 어려워 보인다. 이 요건을 어긴 인스턴스를 컬렉션에 넣은 다음 contains를 호출해보면 방금 넣은 인스턴스가 없다고 답할 것이다.

대칭성

두 객체는 서로에 대한 동치 여부에 대해 똑같이 답해야 한다는 뜻이다. 대소문자를 구별하지 않는 문자열을 구현한 다음 클래스를 예로 살펴보자.

public final class CaseInsensitiveString {
    private final String s;

    public CaseInsensitiveString(String s) {
        this.s = Object.requireNonNull(s);
    }

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

이 경우가 왜 문제가 되는지 알아보자.

CaseInsensitiveString cis = new CaseInsensitiveString("Hello");
String s = "hello";

여기서 cis.equals(s)는 true를 반환한다. 하지만 문제는 CaseInsensitiveString는 String을 알고 있지만 String은 CaseInsensitiveString를 알지 못하는 데 있다. 따라서 s.equals(cis)는 false를 반환하여 대칭성을 위반한다. 대칭성을 지킨 equals는 다음과 같이 구현할 수 있다.

@Override
public boolean equals(Object o) {
    return o instanceof CaseInsensitiveString &&
        ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

추이성

첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같으면, 첫 번째 객체와 세 번째 객체도 같아야 한다는 뜻이다. 이 요건도 자칫하면 어기기 쉽다. 상위 클래스에 없는 새로운 필드를 하위 클래스에 추가하는 상황을 생각해보자. equals 비교에 영향을 주는 정보를 추가한 것이다. 간단히 2차원에서의 점을 표현하는 클래스를 예로 들어보자.

public 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 == o.x && p.y == o.y;
    }
    // ...
}

이제 이 클래스를 확장해서 점의 색깔을 더해보자.

public class ColorPoint extends Point {
    private final Color color;
    
    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }
    // ...
}

equals는 어떻게 해야할까? 만약 그대로 둔다면 Point의 구현이 상속되어 색깔 정보는 무시하고 비교를 수행한다. equals 규칙을 어긴 건 아니지만 중요한 정보를 놓치게 되니 난감한 상황이다. 다음 코드처럼 비교 대상이 또 다른 ColorPoint이고 위치와 색상이 같을 때만 true를 반환하는 equals를 생각해보자.

@Override
public boolean equals(Object o) {
    if (!(o instanceof ColorPoint)) return false;
    return super.equals(o) && ((ColorPoint) o).color == color;
}

이 메서드는 Point와 ColorPoint를 비교한 결과와 그 둘을 바꿔 비교한 결과가 다를 수 있다. Point는 ColorPoint의 색깔을 무시하고, ColorPoint의 equals는 ColorPoint 타입이 아니라고 매번 false만 반환할 것이다.

Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp)는 true를, cp.equals(p)는 false를 항상 반환할 것이다. ColorPoint.equals가 Point와 비교할 때는 색깔을 무시하도록 하면 될까?

@Override
public boolean equals(Object o) {
    if (!(o instanceof Point)) return false;
    if (!(o instanceof ColorPoint)) return o.equals(this);
    
    return super.equals(o) && ((ColorPoint) o).color == color;
}

이 방식은 대칭성은 지켜주지만, 추이성을 깨버린다.

ColorPoint cp1 = new ColorPoint(1, 2, Color.RED);
Point p1 = new Point(1, 2);
ColorPoint cp2 = new ColorPoint(1, 2, Color.BLUE);

cp1.equals(p1)은 true를, p1.equals(cp2)도 true를 반환하지만 cp1.equals(cp2)는 false를 반환하므로 추이성을 명백히 위반한다. cp1과 cp2의 비교에는 색깔이 포함되기 때문이다.

그렇다면 해법은 무엇일까? 사실 이 현상은 모든 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제다. 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규칙을 만족시킬 방법은 존재하지 않는다. 객체 지향적 추상화의 이점을 포기하지 않는 한은 말이다.

이 말은 얼핏 equals 안의 instanceof 검사를 getClass 검사로 바꾸면 규칙도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있다는 뜻으로 들린다.

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

이는 리스코프 치환 원칙을 위반한다. 리스코프 치환 원칙에 따르면, 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 중요하다. 따라서 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야 한다. 다음과 같은 상황에서 어떻게 동작하는지 생각해보자.

import java.util.Set;

class Main {
    public static void main(String[] args) {
        ColorPoint cp = new ColorPoint(0, 1, Color.RED);
        System.out.print(Point.onUnitCircle(cp)); // ???
    }
    
    static class Point {
        private final int x;
        private final int y;
        // 미리 4개의 점을 넣어놓는다.
        private static Set<Point> set = Set.of(new Point(1, 0), new Point(0, 1),
                                               new Point(-1, 0), new Point(0, -1));
        
        public Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
        
        public static boolean onUnitCircle(Point p) {
            return set.contains(p);
        }
        
        @Override
        public boolean equals(Object o) {
            if (o == null || o.getClass() != getClass()) return false;
            Point p = (Point) o;
            return p.x == x && p.y == y;
        }
    }
    
    static class ColorPoint extends Point {
        private final Color color;
        
        public ColorPoint(int x, int y, Color color) {
            super(x, y);
            this.color = color;
        }
    }
    
    enum Color { RED, BLUE }
}

결과는 false이다. 왜냐하면 equals에서 getClass 검사를 할 때 Point가 아니면 항상 false를 반환하기 때문에 Set 컬렉션은 두 Point가 같지 않다고 판단한 것이다. 대부분의 컬렉션은 contains와 같은 작업에 equals 메서드를 이용하는데 ColorPoint의 인스턴스는 어떤 Point 인스턴스와도 같을 수 없기 때문이다. 하지만 getClass를 instanceof로 구현했다면 이 기능은 ColorPoint를 넘겨줘도 잘 동작할 것이다.

그럼 도대체 어쩌라고?

구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 괜찮은 우회 방법이 하나 있다. 상속 대신 컴포지션을 사용하면 된다. Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 메서드를 public으로 추가하는 방법이다.

public class ColorPoint {
    private final Point point;
    private final Color color;
    
    public ColorPoint(int x, int y, Color color) {
        point = new Point(x, y);
        this.color = color;
    }
    
    public Point asPoint() {
        return point;
    }
    
    @Override
    publilc boolean equals(Object o) {
        if (!(o instanceof ColorPoint)) return false;
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
}

일관성

두 객체가 같다면(객체가 수정되지 않는 한) 앞으로도 영원히 같아야 한다는 뜻이다. 가변 객체는 비교 시점에 따라 서로 다를 수도 있지만, 불변 객체는 한 번 다르면 끝까지 달라야 한다. 클래스를 작성할 때 불변 클래스로 만드는 게 나을지 심사숙고하고, 불변으로 만들기로 결정했다면 equals가 한 번 같다고 한 객체와는 영원히 같다고 답하고 아니라면 영원히 다르다고 답하도록 만들어야 한다.

클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다. 이 규칙을 어기면 일관성 조건을 만족시키기가 아주 어렵다. 예들 들어 java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없다.

이런 문제를 피하려면 항상 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다.

null-아님

모든 객체가 null과 같지 않아야 한다는 뜻이다. 의도하지 않았음에도 o.equals(null)이 true를 반환하는 상황은 상상하기 어렵지만, 실수로 NPE를 던지는 코드는 흔할 것이다. 이 규칙은 이런 경우도 허용하지 않는다. 수많은 클래스가 다음 코드처럼 입력이 null인지를 확인해 자신을 보호한다.

@Override
public boolean equals(Object o) {
    if (o == null) return false;
}

이러한 검사는 필요하지 않다. 동치성을 검사하려면 equals는 건네받은 객체를 적절히 형변환한 후 필수 필드들의 값을 알아내야 한다. 그러려면 형변환에 앞서 instanceof 연산자로 입력 매개변수가 올바른 타입인지 검사해야 한다.

@Override
public boolean equals(Object o) {
    if (!(o instanceof MyType)) return false;
    MyType mt = (MyType) o;
    // ...
}

equals가 타입을 확인하지 않으면 잘못된 타입이 인수로 주어졌을 때 ClassCaseException을 던져서 규칙을 위반하게 된다. 그런데 instanaceof는 두 번째 피연산자와 무관하게 첫 번째 연산자가 null이면 false를 반환한다.

 

지금까지 내용을 종합해서 양질의 equals 메서드 구현 방법을 단계별로 정리하면 다음과 같다.

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다. 자기 자신이면 true를 반환한다. 이는 단순한 성능 최적화용으로, 비교 작업이 복잡한 상황일 때 값어치를 할 것이다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다. 그렇지 않다면 false를 반환한다.
  3. 입력을 올바른 타입으로 형변환한다. 2번 과정을 거쳤기 때문에 100% 성공한다.
  4. 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다. 모든 필드가 일치하면 true를, 하나라도 다르면 false를 반환한다.
    • float과 double을 제외한 기본 타입 필드는 == 연산자로 비교하고, 참조 타입 필드는 각각의 equals로, float과 double은 각각 정적 메서드인 Float.compare(float, float)와 Double.compare(double, double)로 비교한다. 이는 부동 소수점 문제 때문이다. Float.equals나 Double.eqauls를 대신 사용할 수 있겠지만 이는 오토박싱을 수반할 수 있으므로 성능상 좋지 않다.
    • 때론 null도 정상 값으로 취급하는 참조 타입 필드도 있다. 이런 필드는 정적 메서드인 Objects.equals(Object, Object)로 비교해 NPE 발생을 예방하자.
    • 비교하기 아주 복잡한 필드를 가진 클래스라면 표준형 필드를 저장해두고 표준형끼리 비교하면 훨씬 경제적이다. 이 기법은 불변 클래스에 제격이다. 가변 객체라면 값이 바뀔 때마다 표준형을 최신 상태로 갱신해줘야 한다.
    • 어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 한다. 최상의 성능을 바란다면 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하자. 동기화용 락 필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안 된다.
    • 핵심 필드로부터 계산해낼 수 있는 파생 필드는 굳이 비교할 필요는 없지만 때로 파생 필드를 비교하는 쪽이더 빠를 수도 있다. 예를 들어 자신의 상태를 캐시해놓은 파생 필드가 있다면 다른 필드 비교 전에 캐시한 파생 필드를 비교한다면 훨씬 빠른 비교를 할 수 있다.

이러한 규칙을 모두 고려하여 equals를 재정의했다면 세 가지만 자문해보자. 대칭적인가? 추이성이 있는가? 일관적인가? 단순히 자문에서 끝내지 말고 단위 테스트를 작성해 돌려보자. 세 요건 중 하나라도 실패한다면 원인을 찾아서 고치자. 물론 나머지 요건인 반사성과 null-아님도 만족해야 하지만, 이 둘이 문제되는 경우는 별로 없다.

마지막 주의사항

  • equals를 재정의할 땐 hashCode도 반드시 재정의하자
  • 너무 복잡하게 해결하려 들지 말자
    • 필드들의 동치성만 검사해도 equals 규칙을 쉽게 지킬 수 있다. 오히려 너무 파고들다가 문제가 발생하기도 한다.
  • Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자.
    • 매개변수 타입이 달라지면 오버라이드가 아니라 오버로딩한 것이다. 이는 의도치 않은 동작을 유발한다.

equals(hashCode도 마찬가지)를 작성하고 테스트하는 일은 지루하고, 테스트 내용도 뻔하다. 이를 대신해줄 오픈소스가 있으니 그 친구는 바로 구글이 만든 AutoValue이다. 클래스에 애너테이션 하나만 추가하면 AutoValue가 이 메서드들을 알아서 작성해주며 직접 작성하는 것과 근본적으로 똑같은 코드를 만들어 줄 것이다. 또는 IDE에서 제공해주는 경우도 있지만 AutoValue만큼 깔끔하거나 읽기 좋지는 않다. 또한 IDE는 나중에 클래스가 수정된 걸 자동으로 알아채지는 못하니 테스트 코드를 작성해둬야 한다.