equals는 일반 규약을 지켜 재정의하라 - Effective Java[10]

6 minute read

equals 메소드는 기본적으로 최상위 객체인 Object에서 제공하는 메소드로서 재정의를 염두에 두고 설계된 것이다. 때문에 재정의 시 지켜야 하는 일반 규약이 명확이 정의가 되어있다.

이러한 규약을 지키지 않고 재정의를 했다간 끔찍한 결과를 초래할 수 있다.

그렇다면 먼저 equals 재정의 규약을 살펴보기 전에 어느 상황에서는 재정의 하면 안되는지 부터 알아보자.


🔗 각 인스턴스가 본질적으로 고유하다.

값을 표현하는게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다. Thread가 좋은 예로, Object의 equals 메서드는 이러한 클래스에 딱 맞게 구현되었다.


🔗 인스턴스의 ‘논리적 동치성(logical equality)’를 검사할 일이 없다.

  • 논리적 동치: 두 명제 p, q에 대해 쌍방 조건이 항진 명제인 경우, 즉 p<=>q
  • 항진 명제: 논리식 혹은 합성명제에 있어 각 명제의 참·거짓의 모든 조합에 대하여 항상 참인 것
  • 즉, 쉽게 말하면 인스턴스들 끼리 equals() 메서드를 사용해서, 논리적으로 같은지 검사할 필요가 없는 경우에는 Object의 기본 equals 로만으로도 해결한다.

🔗 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.

  • HashSet, TreeSet, EnumSet 등 대부분의 Set 구현체 - AbstractSet 에서 정의된 equals 사용
  • ArrayList, Vector 등 대부분의 List 구현체 - AbstractList 에서 정의된 equals 사용
  • HashMap, TreeMap, EnumMap 등 대부분의 Map 구현체 - AbstractMap 에서 정의된 equals 사용

🔗 클래스가 private이거나 package-private이고 equals 메소드를 호출할 일이 없다.

이 경우에는 equals가 실수로라도 호출되는 걸 막고 싶다면 아래와 같이 하는것이 좋다고 한다.

1
2
3
4
@Override
public boolean equals(Object o) {
    throw new AssertionError();
}
cs

여기까지 equals를 재정의하면 안되는 상황들에 대해 알아보았다.


그렇다면 이제 도대체 언제 equals를 정의해야하는지에 대해 알아보자.

equals를 재정의해야 할 때는
객체 식별성(object identity : 두 객체가 물리적으로 같은가)이 아니라
논리적 동치성을 확인해야하는데,
상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의 되지 않았을 때이다.


주로 값 클래스들이 해당한다 [값 클래스란 IntegerString처럼 값을 표현하는 클래스]

두 값 객체를 equals로 비교하는 프로그래머는 객체가 같은지가 아닌 값이 같은지를 알고 싶어 할 것이다.

equals가 논리적 동치성을 비교하도록 재정의 해두면, 그 인스턴스는 값을 비교하길 원하는 프로그래머의 기대에 부응함은 물론 Map의 키와 Set의 원소로 사용할 수 있게 된다.


하지만, 값 클래스라 해도, 싱글턴 클래스[인스턴스 통제 클래스] 처럼 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 녀석에게는 equals를 재정의 할 이유가 없다.

지금까지 equals를 언제 재정의하는지도 알아보았다.

중요한게 남아있다. equals 메소드를 재정의할 때는 따라야하는 규약이 있다고 한다.

아래에서 살펴보자.

🔗 equals 메소드를 재정의 할 때 따라야하는 일반 규약

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

equals 메소드는 동치관계(equivalence 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이다.

하나하나씩 자세히 살펴보도록 하자.

💎 반사성

단순히 말하면 객체는 자기 자신과 같아야 한다는 말이라는데 ? 뭔 당연한 소릴 하는걸까? 라고 생각할 것 같다.

이 요건은 일부러 여기는 경우가 아니라면 만족시키지 못하기가 더 어렵다고 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Car {
    
    public static void main(String[] args) {
    
        Set<Car> carSet = new Set<>();
        Car car = new Car();
        carSet.add(car);
        
        //반사성을 어기면 해당 값이 false가 나온다고 한다.
        System.out.println(carSet.contains(car));
        
    }
}
 
cs

💎 대칭성

두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다.

다음의 예시를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package Item10;
 
import java.util.Objects;
 
public class CaseInsensitiveString {
 
    private final String s;
 
    public CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(s);
    }
 
    public static void main(String[] args) {
        CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
        String s = "polish";
        System.out.println(cis.equals(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;
    }
}
 
cs

위 코드의 문제점을 무엇일까?

CaseInsensitiveStringequals는 일반 문자열과 비교를 시도하며 결과는 true를 반환한다.

문제는 CaseInsensitiveString 의 재정의된 equals는 일반 String 을 알고 있지만 Stringequals는 재정의 되있지 않기 때문에 CaseInsensitiveString의 존재를 모른다.

따라서 역으로 s.equals(cis)는 false를 반환하여, 대칭성을 명백히 위반한다.


이 문제를 해결하려면 CaseInsensitiveStringequalsString과도 연동한다는 허황된 꿈을 버려야 하며 아래와 같이 해결해야 한다.

결론 - 같은 놈 끼리만 비교해라;;

1
2
3
4
5
@Override
public boolean equals(Object o) {
    return o instanceof CaseInsensitiveString &&
        ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
cs

💎 추이성

추이성은 첫번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다는 뜻이다. 겉보기에는 이 요건도 간단해보이지만 자칫하면 어기기 쉽다.

상위 클래스에는 없는 새로운 필드를 하위클래스에 추가하는 상황을 생각해보자.

equals 비교에 영향을 주는 정보를 추가하는 것이다.

아래 예시를 통해 자세히 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 == x && p.y == y;
    }
}
cs

이제 이 클래스를 확장해서 상위 클래스에는 없는 Color 필드를 하위 클래스에 집어넣어보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public 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 ColorPoint) ) {
            return false;
        }    
        return super.equals(o) && ((ColorPoint)o).color == color;
    }
}
cs

이제 위 클래스의 인스턴스를 하나씩 만들어 실제로 동작하는 모습을 확인해보자.

Point p = new Point(1,2);
ColorPoint cp = new ColorPoint(1,2, Color.BLUE);
Systen.out.println(p.equals(cp));  //true
System.out.println(cp.equals(p));  //false

위 코드를 보면 일반 Point를 ColorPoint에 비교한 결과와 그 둘을 바꿔 비교한 결과는 다르다. [대칭성 위배]

Point의 equals는 색상을 무시하고, ColorPoint의 equals는 입력 매개변수의 클래스 종류가 다르다며 매번 false만 반환 할 것이다. (ColorPoint는 Point이지만, Point는 ColorPoint가 아니기 때문)


그렇다면 ColorPoint.equals가 Point와 비교할 때는 색상을 무시하도록 하면 해결될까?

다음의 추이성을 위배하는 예시를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public 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;
        }    
        
        if ( !(o instanceof ColorPoint) ) {
            return o.equals(this);
        } 
 
        return super.equals(o) && ((ColorPoint)o).color == color;
    }
}
cs

위 코드가 실제 동작하는 모습을 살펴보자

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)와 p2.equals(p3)는 true를 반환하는데, p1.equals(p3)는 false을 반환한다. 추이성에 명백히 위배된다.


그럼 도대체 어떻게 해야 추이성을 위배하지 않고 위 문제를 해결 할 수 있을까?

사실 위 현상은 모든 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제로서, 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.


하지만, 구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 괜찮은 우회 방법이 하나 있다.

그것은 바로 상속 대신 컴포지션을 사용하는 방법이다.

Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 view 메소드를 public으로 추가하는 식이다.

아래의 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ColorPoint {
    private final Point point;
    private final Color color;
    
    public ColorPoint(Point point, Color color) {
        this.point = point;
        this.color = color;
    }
 
    public Point getPoint() {
        return this.point;    
    }
 
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint)) {
            return false;
        }
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
}
cs

추상 클래스의 하위 클래스에서라면 equals 규약을 지키면서도 값을 추가할 수 있다.

“태그 달린 클래스보다는 클래스 계층구조를 활용하라”는 조언을 따르는 클래스 계층구조에서는 아주 중요한 사실이다. 예를들어 값을 갖지않는 추상클래스인 Shape를 위에 두고, 이를 확장하여 radius를 필드를 추가한 Circle 클래스와, length와 width 필드를 추가한 Rectangle 클래스를 만들 수 있다. 상위 클래스를 직접 인스턴스로 만드는게 불가능하다면 지금까지 이야기한 문제는 일어나지 않는다.


💎 일관성

일관성은 두 객체가 같다면 (어느 하나 두 객체 모두가 수정 되지 않는 한) 앞으로도 영원히 같아야 한다는 뜻이다.

클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.

이 제약을 어기면 일관성 조건을 만족시키기가 매우 어렵다.


예컨대 java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP주소를 이용해 비교한다.

그런데 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 보장 할 수 없다.

이는 URL의 equals가 일반 규약을 어기게 하고, 실무에서도 종종 문제를 일으킨다.

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


💎 NULL-아님

ㅋㅋ 이름이 좀 별거 없어보이는데, 공식이름이 없어서 임의로 설정 한 것이라고 한다. 그냥 not null이라 하지..

null-아님은 이름처럼 모든 객체가 null과 같지 않아야 한다는 것이다.

의도하지 않았음에도 o.equals(null)이 true를 반환하는 상황은 상상하기 힘들지만, 실수로 NullPointerException을 던지는 코드는 흔할 것이다.


수 많은 클래스가 입력이 null인지를 확인해 자신을 보호하는데, equals 메소드에서는 그러한 검사는 필요없다.

동치성을 검사하기 위해서는 건네받은 객체를 적절히 형변환한 후 피루수 필드들의 값을 알아내야하는데, 이때 instanceof 연산자로 입력 매개변수가 올바른지 판단하기 때문이다.


지금까지의 내용을 종합해서 양질의 equals 메소드 구현방법을 단계별로 아래와 같이 정리해본다.

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.

  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.

  3. 입력을 올바른 타입으로 형변환한다.

  4. 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.


🔗 equals의 성능을 잡아라!!

어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 한다.

최상의 성능을 바란다면 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하자.

동기화용 락(lock)필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안된다.


🔗 equals 재정의 시 마지막 주의 사항

  • equals를 재정의할 땐 hashCode도 반드시 재정의하자.

  • 너무 복잡하게 해결하려 들지말자.

    • 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다. 오히려 너무 공격적으로 파고들다가 문제를 일으키기도 한다. 일반적으로 별칭(alias)은 비교하지 않는게 좋다. 예컨대 File 클래스라면, 심볼릭 링크를 비교해 같은 파일을 가리키는지를 확인하려 들면 안된다.
  • Object 외의 타입을 매개변수로 받는 equals 메소드는 선언하지말자. 많은 프로그래머가 다음과 같이 equals를 작성해놓고 문제의 원인을 찾아 해맨다.

    • public boolean equals(Myclass o) {
      	...
      }
      

​ 이 메서드는 Object.equals를 재정의[오버라이딩]이 아니라, 오버로딩한 것이다. 이러한 실수는 @Override 어노테이션을 사용하면 예방 할 수 있다.


🔗 equals 작성과 테스트를 알아서 해준다고!? 누가!?

equals를 작성하고 테스트하는 일은 지루하고 이를 테스트하는 코드는 항상 뻔하다. 다행히 이 작업을 대신해줄 오픈소스가 있으니, 구글이 만든 AutoValue 프레임워크이다

이 프레임워크는 클래스에 애너테이션 하나만 추가하면 알아서 메서드들을 작성해준다.


꼭 필요한 경우가 아니면 equals를 재정의하지 말자. 많은 경우에 Object의 equals가 우리가 원하는 비교를 정확히 수행해준다. 재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지 규약을 확실히 지켜가며 비교해야한다.

참조 - 이펙티브 자바 3/E - 조슈아 블로크

Categories:

Updated:

Comments