본문으로 바로가기

1. equals를 재정의하지 않는 조건

다음 상황 중 하나에 해당한다면 equals를 재정의하지 않는 것이 최선이다.

 

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

    • Thread는 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스이다.
  • 인스턴스의 논리적 동치성을 검사할 일이 없는 경우.
  • 상위 클래스에서 정의한 equals가 하위 클래스에도 딱 맞는 경우.

    • Set, List, Map 등의 구현체는 상위 AbstractXXX의 equals를 상속받아 사용한다.
  • 클래스가 private이거나 package-private이고 equals를 호출할 일이 없는 경우.

    • 확실하게 호출을 금지하고 싶으면 equals 호출시 예외를 던지도록 퇴화시키는 코드를 작성한다.

2. equals를 재정의하는 조건

객체 식별성(물리적으로 같은지)이 아닌 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 정의되어 있지 않은 경우이다.

 

String이나 Integer 등의 값 클래스는 객체 레퍼런스가 아닌 값을 비교하는 것이 중요하다. 값 클래스라고 하더라도 값이 같은 인스턴스가 둘 이상 만들어지지 않음이 보장되는 클래스의 경우 equals 재정의가 필요없다.

3. equals 재정의 일반 규약

3.1. 반사성(Reflexivity)

null이 아닌 모든 참조 값 x에 대해x.equals(x)는 true다.

 

3.2. 대칭성(Symmetry)

null이 아닌 모든 참조 값 x, y에 대해x.equals(y)가 true면y.equals(x)도 true다.

 

MyString.java

※ 잘못된 코드 - 대칭성 위배

public class MyString {

    private final String s;

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

    @Override
    public boolean equals(Object o) {
        if (o instanceof MyString) {
            return s.equals(((MyString) o).s);
        }
        if (o instanceof String) { // 한 방향으로만 작동한다!
            return s.equals(((String) o));
        }
        return false;
    }
}

 

 

MyString 값 클래스는 일반 String의 존재를 인식하지만, 반대로 String 클래스는 MyString 값 클래스를 인식하지 못한다.

 

Main.java

MyString myString = new MyString("test");
String s = "test";
myString.equals(s); //true
s.equals(myString); //false

equals 규약을 어기면 그 객체를 사용하는 다른 객체들(예 : 컬렉션)이 어떻게 반응할지 알 수 없다.

 

3.3. 추이성(Transitivity)

null이 아닌 모든 참조 값 x, y, z에 대해x.equals(y)가 true고y.equals(z)가 true면x.equals(z)도 true다.

 

Point.java

public class Point {

    private final int x;
    private final int y;

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

 

ColorPoint.java

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;
    }
}

 

Main.java

Point point = new Point(1, 2);
ColorPoint colorPoint = new ColorPoint(1, 2, Color.RED);
point.equals(colorPoint); //true
colorPoint.equals(point); //false

ColorPoint는 equals의 인자로 받은 Point의 클래스가 ColorPoint가 아니기 때문에 false를 반환한다. 이로 인해 대칭성이 깨진다.

  • A extends B일 때,A instanceof B는 true지만B instanceof A는 false다.

 

ColorPoint.java

@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가 equals로 Point를 인자로 받으면 Color를 무시하고 좌표만 비교하도록 코드를 변경했다.

 

Main.java

ColorPoint cp1 = new ColorPoint(1, 2, Color.RED);
Point point = new Point(1, 2);
ColorPoint cp2 = new ColorPoint(1, 2, Color.BLUE);
cp1.equals(point); //true
point.equals(cp2); //true
cp1.equals(cp2); //false

이 방식은 대칭성이 지켜지지만 추이성이 깨지게 되며, 무한 재귀에 빠질 위험이 존재한다.

 

  • Point의 또 다른 하위 클래스 X가 ColorPoint와 동일한 equals를 재정의하고,ColorPoint.equals(X)를 하면o.equals(this)부분에서 무한 루프가 발생한다.

OOP 언어의 동치 관계에서 발생하는 근본적인 문제이다. 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.

 

리스코프 치환 원칙(Liskov Substitution Principle)

Point.java

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

instanceof가 아닌getClass()를 사용하면 같은 구현 클래스의 객체를 비교할 때만 true를 반환한다.

 

Main.java

List<Point> points = Arrays.asList(Point.of(1, 1), Point.of(1, 2), Point.of(1, 3));
ColorPoint colorPoint = new ColorPoint(1, 1, Color.RED);
points.contains(colorPoint); //false

특정 좌표가 리스트에 존재하는 경우contains()메서드를 통해 true를 반환하려고 한다.

 

컬렉션 프레임워크는 내부적으로equals()메서드를 통해 비교 작업을 수행한다.

 

문제는 Point가getClass()를 사용하여 equals를 수행하기 때문에 Point와 ColorPoint는 값에 상관없이 항상 같을 수 없어서 false를 반환한다.

 

반면 instanceof를 사용하면 의도한대로 true가 반환될 것이다.

 

리스코프 치환 원칙에 따르면 어떤 타입에 있어서 중요한 속성은 하위 타입에서도 마찬가지로 중요하며, 해당 타입의 모든 메서드는 하위 타입에서도 똑같이 잘 작동해야 한다.

 

즉,상위 클래스의 인스턴스는 하위 클래스의 인스턴스로 대체해 사용할 수 있어야 한다.

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

이를 해결하는 간단한 방법은 상속대신 조합(Composition)을 사용하는 것이다. 또한 추상 클래스의 하위 클래스라면 equals 규약을 지키며 값을 추가할 수 있다. 상위 클래스를 직접 인스턴스로 만드는게 불가능하기 때문이다.

 

3.4. 일관성(Consistency)

null이 아닌 모든 참조 값 x, y에 대해x.equals(y)를 반복 호출하면 항상 true 혹은 항상 false를 리턴한다. 가변 객체라면 비교 시점에 따라 다를 수 있으나, 불변 객체의 경우 일관성이 필수로 지켜져야 한다.

또한 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들면 안 된다.

  • java.net.URL의 equals는 호스트 이름과 IP 주소 맵핑 결과가 항상 같지 않아 문제가 있다.

3.5. null-아님

null이 아닌 모든 참조 값 x에 대해x.equals(null)은 false다. instanceof는 자동으로 null을 체크해준다.

4. 요약

  • 먼저 == 연산자를 통해 입력 객체가 자기 자신의 참조인지 먼저 확인한다.
    • 비교 작업이 복잡한 상황인데 자기 자신을 비교하는 경우 성능을 최적화할 수 있다.
  • instanceof로 타입을 확인한다.
    • 인터페이스를 구현한 서로 다른 클래스끼리도 비교할 수 있다.
  • 입력 객체를 올바른 타입으로 형변환한다.
  • 입력 객체와 자기 자신의 대응되는 핵심 필드들을 비교한다.

5. 기타

equals()메서드의 매개 변수로 Object 타입만을 받도록 하며, 재정의할 때hashCode()도 함께 재정의해야 한다.

float(double) 필드는Float(Double).compare()메서드로 비교한다.

  • 특수한 부동소수 값 등을 다루어야 하기 때문이다.
  • Float(Double).equals()는 오토박싱을 수반할 수 있어 성능상 좋지 않다.

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

  • 다를 가능성이 크거나 비교 비용이 싼 필드를 먼저 비교한다.
  • 동기화용 Lock 필드같이 논리적 상태와 상관없는 필드는 비교하지 않는다.