10. equals는 일반 규약을 지켜 재정의하라

Effective Java Item 10

equals는 일반 규약을 지켜 재정의하라

이 문서는 Effective Java의 아이템 10, "equals는 일반 규약을 지켜 재정의하라"를 주제로 객체의 동등성을 올바르게 비교하는 방법에 대해 다룹니다.

객체 지향 프로그래밍에서 equals 메서드는 객체의 논리적 동등성을 판단하는 중요한 역할을 합니다. 하지만 equals 메서드를 잘못 재정의하면 예상치 못한 동작을 초래하고, 코드의 유지보수성을 저하시킬 수 있습니다. 따라서 equals 메서드를 재정의할 때는 반드시 일반 규약을 준수해야 합니다.

equals를 재정의하지 말아야 할 경우

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

Thread thread1 = new Thread();
Thread thread2 = new Thread();
System.out.println(thread1.equals(thread2)); // false (객체 식별성 비교)
  • 값이 아닌 동작하는 개체 표현 (Thread, Activity, Service)

  • Object의 기본 equals가 정확한 동작 수행

인스턴스의 논리적 동치성을 검사할 일이 없다

class Logger { // 기본 equals 사용
    private String name; // 클라이언트가 같은 이름의 Logger를 원하지 않음
}
  • 설계자가 의도적으로 논리적 비교를 배제한 경우

  • Singleton, Enum ...

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

Set<String> set1 = new HashSet<>(List.of("A", "B"));
Set<String> set2 = new LinkedHashSet<>(List.of("B", "A"));
System.out.println(set1.equals(set2)); // true (AbstractSet의 equals 사용)
  • 대부분의 Set/List 구현체는 AbstractSet/AbstractList의 equals 상속

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

@Override
public boolean equals(Object o) {
      throw new AssertionError(); // 호출 금지!
}
  • 실수로 호출되는 걸 막고 싶다면 구현해두자

equals()를 재정의해야 하는 경우

논리적 동치성을 확인해야 하는데, equals가 그렇게 재정의되지 않았을 때

  • 논리적 동등성을 비교해야 하는 값 객체 (ex. Integer, String)

  • 컬렉션의 키(Key)로 사용될 객체 (ex. HashMap, HashSet의 요소)

  • 동등성 비교가 클래스의 주요 기능일 때

equals() 규약 위반 시 발생할 수 있는 문제점

  • 컬렉션 프레임워크(HashSet, HashMap)에서 예기치 않은 동작 발생

  • 동등성을 기반으로 동작하는 로직에서 오류 발생

  • 대칭성, 추이성, 일관성 원칙을 어겨 디버깅이 어려워짐

equals() 메서드 재정의 시 Object 명세 규약

  • x, y, z 는 null이 아닌 모든 참조 값

반사성 (Reflexivity) : x.equals(x)는 항상 true를 반환해야 함

Money money = new Money(1000, "KRW");
assert money.equals(money); // 실패 시 컬렉션 저장 후 contains 검사 불가

대칭성 (Symmetry): x.equals(y)가 true이면 y.equals(x)도 true여야 함

// 잘못된 구현
class CaseInsensitiveString {
    public boolean equals(Object o) {
        if (o instanceof String)  // String과 비교 허용
            return equalsIgnoreCase((String) o);
        //...
    }
}

CaseInsensitiveString cis = new CaseInsensitiveString("Hello");
String str = "hello";
cis.equals(str); // true
str.equals(cis); // false → 대칭성 위반!

추이성 (Transitivity): x.equals(y)가 true이고, y.equals(z)도 true이면 x.equals(z)도 true여야 함

class Point {
    private int x, y;
    // equals 구현
}

class ColorPoint extends Point {
    private Color color;

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point)) return false;
        if (!(o instanceof ColorPoint))
            return o.equals(this); // Point와 비교 불가능하게 변경
        return super.equals(o) && ((ColorPoint)o).color == color;
    }
}

Point p = new Point(1, 2);
ColorPoint cp1 = new ColorPoint(1, 2, RED);
ColorPoint cp2 = new ColorPoint(1, 2, BLUE);
cp1.equals(p); // true (Point의 equals 사용)
p.equals(cp2); // true (Point의 equals 사용)
cp1.equals(cp2); // false → 대칭성 위반!
  • 상속보다 컴포지션 사용 권장 (아이템18)

  • -> Point를 상속하는 대신 private 필드로 둔다

일관성 (Consistency): equals 메서드의 결과는 객체가 변경되지 않는 한 일관되어야 함

URL url1 = new URL("https://example.com");
URL url2 = new URL("https://example.com");
// 네트워크 상태에 따라 달라질 수 있음 → equals 사용 금지
//URL 대신 java.net.URI를 사용하는 것이 권장됩니다. URI는 equals()와 hashCode()에서 네트워크 I/O를 수행하지 않고, 문자열 기반으로 비교를 수행합니다.  따라서 URI는 URL의 문제점을 해결하고, 예측 가능하고 일관된 동작을 보장
  1. 호스트 이름 비교

    • 먼저 두 URL의 호스트 이름(예: example.com)을 비교합니다.

  2. IP 주소 확인 (문제 발생 지점)

    • 호스트 이름이 같으면, URL은 각 호스트 이름에 해당하는 IP 주소를 확인하기 위해 네트워크 I/O를 수행합니다. 즉, DNS (Domain Name System) 서버에 쿼리를 보내 IP 주소를 가져옵니다.

  3. IP 주소 비교

    • 가져온 IP 주소가 같으면 두 URL은 같다고 판단합니다.

null - 아님: x.equals(null)은 항상 false를 반환해야 함

// 명시적 null 검사 (불필요)
@Override
public boolean equals(Object o) {
    if (o == null) return false; // 필요 없음!
}

// 암묵적 null 검사 (권장)
@Override
public boolean equals(Object o) {
    if (!(o instanceof MyType)) return false; // instanceof는 null 체크 포함
}

양질의 equals() 메서드 구현 단계별 정리

  1. == 연산자를 사용하여 자기 참조 확인 (빠른 반환)

    if (this == o) return true;
  2. instanceof를 사용하여 타입 체크 수행

    if (!(o instanceof PhoneNumber)) return false;
  3. 비교 대상 객체를 적절한 타입으로 형변환

    PhoneNumber pn = (PhoneNumber) o;
  4. 중요한 필드들의 값을 비교하여 논리적 동등성 확인

    // 기본 타입
    return areaCode == pn.areaCode;
    
    // 참조 타입
    return name.equals(pn.name);
    
    // float/double
    return Double.compare(weight, pn.weight) == 0;
    
    // 배열
    return Arrays.equals(factors, pn.factors);
    
  5. 완성된 equals 예시

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof PhoneNumber)) return false;
        PhoneNumber pn = (PhoneNumber) o;
        return pn.areaCode == areaCode
            && pn.prefix == prefix
            && pn.lineNum == lineNum
            && Double.compare(weight, pn.weight) == 0;
    }


핵심 구현 팁

1. 최적화 순서

return shortCalculation() && expensiveCalculation();
  • 계산 비용이 저렴한 필드 먼저 비교

  • 다를 가능성이 큰 필드

2. 불변 클래스의 캐싱 기법

class Complex {
    private final double re;
    private final double im;
    private volatile int hashCode; // 캐시

    @Override public boolean equals(Object o) {
        // 표준형 비교 로직
    }
}

3. equals를 재정의할 때는 hashCode도 반드시 함께 재정의해야 한다 (Item 11).

  • NEXT WEEK

4. 너무 복잡하게 해결하려 들지 않는다.

  • 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있습니다.

5. 매개변수 타입은 Object 유지

  • @Override 애너테이션을 일관되게 사용하면 실수를 예방 할 수 있습니다.

6. AutoValue 프레임워크 사용

@AutoValue
abstract class Person {
    static Person create(String name, int age) {
        return new AutoValue_Person(name, age);
    }

    abstract String name();
    abstract int age();
}
// equals/hashCode 자동 생성
  • equals, 테스트 코드를 작성해주는 오픈소스

  • google이 만든 프레임 워크


결론

  • equals필요한 경우에만 신중하게 재정의해야 함.

  • 5가지 규약을 반드시 지켜야 하며, 이를 어기면 예측 불가능한 버그 발생 가능.

  • 가변 객체에서는 equals 재정의를 피하는 것이 좋음.

  • AutoValue 프레임워크 쓰자

"equals를 재정의할 때는 마치 시를 쓰는 마음으로, 모든 규약을 준수하는 동시에 객체의 본질을 정확히 반영해야 합니다." - 조슈아 블로크

Last updated