Java

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

jwKim96 2022. 12. 28. 01:16

예제 코드는 여기서 보실 수 있습니다.

0. 들어가며

equals 메서드는 두 개체가 같은지 확인하는데 사용됩니다.

Java 에서 두 개체가 같은지 확인하는 기준은 동일성동등성 두가지로 나누어집니다.

0.1 동일성

동일성 : 두 개 이상의 사상(事象)이나 사물이 서로 같은 성질.

image

동일성이란 두 개체가 완전히 같은지를 나타내는 성질입니다.

위 그림의 refVar2 와 refVar3 는 지역변수로서는 개별적으로 존재하지만, 같은 객체2를 가리킵니다.

즉, refVar2 == refVar3 이 성립하며 이는 두 변수 refVar2, refVar3 은 동일하다고 표현할 수 있습니다.

0.2 동등성

동등성 : 가치, 등급 따위가 서로 똑같음.

동등성이란 두 개체가 논리적으로 같은지를 나타내는 성질입니다.

String nickName1 = new String("jinan159");
String nickName2 = new String("jinan159");

System.out.println(nickName1 == nickName2);      // false (동일성)
System.out.println(nickName1.equals(nickName2)); // true  (동등성)

위 예제 코드에 두 개의 String 인스턴스 nickName1 과 nickName2 가 있습니다.

nickName1 과 nickName2 는 서로 다른 인스턴스지만 값은 같습니다.

이 경우 동일하진 않지만, 동등하다는 것을 알 수 있습니다.

이렇게 되는 이유는 String 의 equals 는 인스턴스의 값이 같은지 확인하도록 재정의 되어있기 때문입니다.

그러면 Java 의 상위 클래스인 Object 클래스의 equals 메서드는 어떻게 되어있을까요?

public class Object {
    ...

    public boolean equals(Object obj) {
        return (this == obj);
    }

    ...
}

Object 클래스의 equals 메서드는 == 연산자를 사용하여 두 인스턴스의 동일성 을 비교합니다.

즉, Java 에서는 기본적으로 equals 메소드가 두 인스턴스의 동일성 을 비교하고 있다고 생각할 수 있습니다.

그런데 위의 String 의 경우처럼, 개발하면서 동등성을 비교해야할 경우가 생기게 될 수 있습니다.

이런 경우에는 equals 메서드를 재정의해서 사용하면되는데요.

하지만 논리적으로 같은지 확인하는 로직을 잘못 구현한다면 예기치 않은 결과를 초래할 수 있습니다.

이 때문에 이펙티브자바에서는 재정의하지 않아도 되는 경우에 대해 먼저 언급합니다.

1. 재정의 하지 않아도 되는 경우

  • 인스턴스가 본질적으로 고유한 경우
  • 인스턴스가 논리적으로 같은지 검사할 일이 없는 경우
  • 상위 클래스의 equasl 가 하위 클래스에도 딱 맞는 경우
  • 클래스가 private, package-private 이고, equals 를 호출할 일이 없는 경우

각각의 경우에 대해 조금 더 자세히 살펴보겠습니다.

1.1 인스턴스가 본질적으로 고유한 경우

equals 메서드를 재정의하지 않았다면, 두 확인하는 로직이 기본입니다.

예를 들어 OS 관점에서 Thread 자체는 논리적, 물리적으로 독립적인 개체입니다.

그래서 이를 추상화하여 구현한 Java 의 Thread 또한 본질적으로 고유한 성질을 가져야 합니다.

즉, 해당 요소나 개념등이 본질적으로 고유한 성질을 가지고 있다면, 재정의할 필요가 없다고 생각하면 됩니다.

1.2 인스턴스가 논리적으로 같은지 검사할 일이 없는 경우

1.1 의 Thread 예시에서 이어서 생각해보겠습니다.

Thread 는 본질적으로 고유하니, 당연히 논리적으로 같은지 검사할 필요가 없습니다.

반대로 주민번호, 이름을 가진 Person 이라는 클래스가 있다고 생각해 봅시다.

우리나라는 주민번호가 같으면 같은 사람으로 취급합니다.

따라서, 이 경우에는 주민번호가 같은 사람은 논리적으로 같은 사람으로 취급할 수 있습니다.

즉, Person 인스턴스의 equals 는 주민번호가 같으면 동일한 인스턴스라는 논리적 의미를 구현하게 됩니다.

class Person {
    private int 주민번호;
    private String 이름;

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Person)) {
        return false;
      }
    Person person = (Person) o;
    return 주민번호 == person.주민번호;
    }
}

1.3 상위 클래스의 equasl 가 하위 클래스에도 딱 맞는 경우

상위 클래스에서 구현한 equals 가 하위 클래스에도 동일하게 적용될 수 있다면, 굳이 재정의하지 않아도 됩니다.

아래는 HashSet, LinkedHashSet, TreeSet 등의 상위 클래스인 AbstractSet 클래스 입니다.

Set 내부의 구성요소가 모두 동일하면 논리적으로 같은 Set 이라고 취급됩니다.

public abstract class AbstractSet<E> extends AbstractCollection<E> implements Set<E> {
  ...
    public boolean equals(Object o) {
        if (o == this) return true;

        if (!(o instanceof Set)) return false;
        Collection<?> c = (Collection<?>) o;
        if (c.size() != size()) return false;
        try {
            return containsAll(c);
        } catch (ClassCastException | NullPointerException unused) {
            return false;
        }
    }
  ...
}

// -------------------------------------------------------------------------------------------

HashSet<Integer> set1 = new HashSet<>();
LinkedHashSet<Integer> set2 = new LinkedHashSet<>();
TreeSet<Integer> set3 = new TreeSet<>();

int num = 1;
set1.add(num); set2.add(num); set3.add(num);

System.out.println(set1.equals(set2)); // true
System.out.println(set2.equals(set3)); // true
System.out.println(set1.equals(set3)); // true

Set 은 ‘중복되지 않는 요소들의 집합’ 입니다.

이를 확인하는데에는 AbstractSet 에 있는 equals 메서드만으로 충분하기 때문에 굳이 재정의 하지 않아도 됩니다.

그래서 HashSet, LinkedHashSet, TreeSet 이 구현체들은 실제로 모두 equals 를 재정의 하지 않았습니다.

1.4 클래스가 private, package-private 이고, equals 를 호출할 일이 없는 경우

클래스가 private 혹은 package-private 이라는 것은, 특정 클래스나 패키지의 내부에서만 사용할 클래스라는 의미죠.

그리고 해당 클래스는 equals 를 호출할 일이 없는데 혹시라도 불리는 것을 방지하기 위해서는 아래와 같은 방법이 있다고 합니다.

@Override
public boolean equals(Object o) {
    throw new AssertionError(); // 호출 금지!
}

이 경우는 흔치않은 경우일 것 같습니다만, 이런 경우도 고려하더라 정도로만 생각하고 넘어가면 될 것 같습니다.

2. 정의해야하는 경우

그럼 반대로 정의해야하는 경우는 언제일까요?

  • 두 인스턴스의 논리적 동등성을 확인해야 할 경우

대표적인 예로 String 을 살펴보겠습니다.

// String 클래스의 equals 메서드
public boolean equals(Object anObject) {
  if (this == anObject) {
    return true;
  }
  if (anObject instanceof String) {
    String aString = (String)anObject;
    if (coder() == aString.coder()) {
      return isLatin1() ? StringLatin1.equals(value, aString.value)
                        : StringUTF16.equals(value, aString.value);
    }
  }
  return false;
}

단순히 인스턴스의 동등성을 비교하는것이 아니라, 그 안에있는 값이 같은지를 확인합니다.

하지만 앞서 언급한 것 처럼 equals 메소드는 Java 언어 내부적으로도 호출하여 사용하는 중요한 메서드입니다.

그래서 Java 에서는 equals 메소드를 구현할 때 지켜야할 규약을 정의해놓았습니다.

3. Object 클래스의 equals 규약

image

Object 클래스의 equals 메서드 주석을 보면 위와 같이 규약이 명시되어있습니다.

이를 간단히 풀어서 정리해보면 다음과 같습니다.

  • 반사성 : 자기 자신과 비교하면 항상 true 여야 한다
  • 대칭성 : 역으로 비교해도 항상 같은 결과가 나와야 한다
  • 추이성 : 첫 번째와 두번째가 같고, 두 번째와 세 번째가 같으면, 첫 번째와 세번째도 같아야 함
  • 일관성 : 항상 같은 결과를 반환해야 함
  • null-아님 : 비교 대상이 null 아님

이 항목들을 하나씩 살펴보겠습니다.

3.1 반사성

반사성 : 자기 자신과 비교하면 항상 true 여야 한다.

  • x ≠ null && x.equals(x) == true

객체는 자기 자신과 같아야 한다는 아주 기본적인 규약입니다.

앞서 1.3 에서 살펴봤던 AbstractSet 의 equals 메서드에서는 containsAll 이라는 메서드가 사용됩니다.

이는 AbstractCollection 에 정의되어 있는데요.

public abstract class AbstractCollection<E> implements Collection<E> {
        ...

        public boolean containsAll(Collection<?> c) {
        for (Object e : c)
            if (!contains(e)) // 여기서 각 요소에 대해 contains 를 호출
                return false;
        return true;
    }

        public boolean contains(Object o) {
        Iterator<E> it = iterator();
        if (o==null) {
            while (it.hasNext())
                if (it.next()==null)
                    return true;
        } else {
            while (it.hasNext())
                if (o.equals(it.next())) // 여기서 equals 가 사용된다.
                    return true;
        }
        return false;
    }

        ...
}

AbstractCollection 에서는 대상이 있는지 확인하는 로직에서 결국 equals 가 사용됩니다.

하지만 이를 잘못 구현해놓으면 중복이 안되어야하는 Set 에 중복이 발생할 수 있습니다.

3.2 대칭성

대칭성 : 역으로 비교해도 항상 같은 결과가 나와야 한다

  • x ≠ null && y ≠ null
  • x.equals(y) == true 라면, x.equals(y) == true 도 성립해야함

equals 를 재정의 하며 로직이 복잡해지면 대칭성은 자칫하면 어길 수 있는데요.

책에 나오는 예제는 다음과 같습니다.

public final class CaseInsensitiveString {
    private final String s;

    public CaseInsensitiveString(String s) {
        this.s = Objects.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 str = new String("hello");

System.out.println(cis.equals(str)); // true
System.out.println(str.equals(cis)); // false

CaseInsensitiveString 는 equals 에서 String 타입에 맞게 처리를 하여 true 를 반환합니다.

하지만 String 은 CaseInsensitiveString 에 맞게 처리하지 않아 false 를 반환합니다

이렇게 대칭성을 어기게될 수 있는 것이죠.

3.3 추이성

추이성 : 첫 번째와 두번째가 같고, 두 번째와 세 번째가 같으면, 첫 번째와 세번째도 같아야 함

위 설명 그대로 이해하면 되며, 책에서는 Point 객체를 예제로 사용합니다.

(가독성을 위해 코드가 일부 수정되었습니다.)

class Point {

    private final int x;
    private final int y;

    ...

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

class ColorPoint extends Point {

    private final Color color;

    ...

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

        // 좌표 비교
        if (!(o instanceof ColorPoint)) return o.equals(this);

        // 좌표, 색상 비교
        ColorPoint cp = (ColorPoint) o;
        return super.equals(cp) && this.color == cp.color;
    }
}

// --------------------------------------------------------------------

ColorPoint p1 = new ColorPoint(1,2, Color.RED);
Point p2 = new Point(1,2);
ColorPoint p3 = new ColorPoint(1,2, Color.BLUE);

System.out.println(p1.equals(p2)); // true (좌표 같음)
System.out.println(p2.equals(p3)); // true (좌표 같음)
System.out.println(p1.equals(p3)); // false (좌표 같음, 색상 다름)

// --------------------------------------------------------------------
// 추이성 위반을 해결하기 위해서 Point view 를 갖는 방법이 있다고 합니다.
class ColorPoint {

    private final Point point;
    private final Color color;

    ...

    public Point asPoint() {
        return this.point;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint)) false;

        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(this.point) && && this.color == cp.color;
    }
}

위 처럼 p1, p2 는 동등하고 p2, p3 도 동등한데 p1, p3 은 동등하지 않습니다.

이 경우가 추이성을 어긴 경우입니다.

그래서 이펙티브자바에서는 이를 해결하기 위한 방법으로 내부에 view 를 갖는것을 제안합니다.

Point 를 상속을 하지않고, 연관 관계로 풀어내는 것이죠.

그리고 더 좋은 방법은 Point 클래스를 추상클래스로 만들어버린다면 이 문제가 해결된다고 합니다.

추이성 예제에서 발생한 문제들은 Point 와 ColorPoint 두 종류의 인스턴스를 비교하면서 발생했는데요.

Point 를 추상클래스로 만든다면 Point 를 인스턴스화 할 수 없으니 이러한 문제는 사라지기 때문입니다.

그리고 번외로 또 다른 예시를 한번 살펴보겠습니다.

// new Person(주민번호, 이름);
Person personA = new Person(1, "A Name");
Person personB = new Person(2, "B Name");
Person personAA = new Person(1, "AA Name");

System.out.println(personA.equals(personB));  // false
System.out.println(personB.equals(personAA)); // false
System.out.println(personA.equals(personAA)); // true

//이 예시가 추이성을 위반한 것이 아니냐고 할 수 있는데, 추이성은 '같을때' 만 해당된다.

1.2 에서 살펴봤던 Person 클래스를 다시 가져와봤습니다.

personA, personB 는 서로 다르고, personB, personAA 도 서로 다릅니다.

여기서 추이성을 착각하면 personA 와 personAA 도 달라야 하는것 아닌가 오해할 수 있는데요.

추이성은 같은때만 해당된다는 것을 유념해야 합니다.

3.4 일관성

일관성 : 항상 같은 결과를 반환해야 함

이와 관련하여 java.net.URL 클래스가 이 규약을 위반하는 대표적인 사례라고 소개합니다.

URL 클래스의 equals 는 매핑된 호스트의 IP 주소를 비교하는데, 이는 네트워크 상태에 영향을 받기 때문입니다.

즉, equals 는 JVM 메모리상의 정보로만 동등성을 비교해야합니다.

3.5 null-아님

null-아님 : 비교 대상이 null 아님

비교 대상이 null 이 아니어야 한다는 뜻입니다.

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

위 코드는 위에서 살펴본 Point 클래스의 equals 메소드입니다.

첫 번째줄의 instanceof 연산자는 첫번째 피연산자(o)가 null 이라면 false 를 반환한다고 합니다.

그래서 이러한 묵시적 null 검사를 활용하는것이 좋다고 합니다.

4. 정리

  1. == 연산자를 사용해 동일성 을 먼저 비교
    • 성능 최적화용, 비교 작업이 복잡할 경우에 추천합니다.
  2. instanceof 연산자로 null 과 타입을 체크
  3. 입력을 올바른 타입으로 변환
  4. 입력 객체와 자신이 대응되는 ‘핵심’ 필드들이 모두 일치하는지 검사
    • 이는 비즈니스에 따라 PK 가 되는 필드들이 해당될 것 같습니다.
    • 인터페이스를 구현했다면, getter 메서드를 사용하는것이 좋습니다.
    • 비교 비용이 싼 필드를 먼저 비교하는것을 추천
    • 핵심필드로 계산할 수 있는 파생 필드는 비교 비추천
      • ex) 주문 클래스(가격, 개수)에서 총주문금액 필드의 경우

4.1 번외

  • float, double 의 래퍼타입인 Float, Double 의 경우에는 compare 메서드를 추천
    • 특수한 부동소수값을 다룰 때, equals 를 사용하면 오토박싱이 일어날 경우 성능이 좋지 않습니다.