본문 바로가기

study/effective java

[item10] equals는 일반 규약을 지켜 재정의해라

equals 메소드

객체의 동등성을 비교하기 위한 메소드이며 Object 객체에 정의되어 있기에 모든 객체들이 가지고 있는 메소드이다.

동등성과 동일성

필자도 가끔 햇갈리는데 정리좀 하겠다.

  • 동등성: 참조하는 객체가 다르지만 값이 같다.
  • 동일성: 참조하는 객체가 같아 값과 주소가 같다.

동일성을 가진 객체들은 값을 수정하면 관련된 모든 객체레 적용되지만 동일성은 참조하는 주소가 다르기에 값을 수정하면 동일성을 깨지지만 동일성을 가졌던 다른 객체에 영향을 주지 않는다.

equals(Object obj) method

equals메소드는 두 객체가 같음을 확인하는 책임을 가진 메소드이다. 이 메소드는 최상위 객체인 Object객체가 정의하고 있기에 모든 객체들은 equals메소드를 가지고 있다.

equals는 오버라이딩하지 않으면 최상위 객체에 작성되어 있는 equals 메소드를 사용하게 된다. 이때 이 메소드는 동일성을 확인하도록 작성되어 있다. 최상위 객체인 Object에서는 다음과 같이 equals가 정의되어 있다.

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

아무것도 정의하지 않으면 equals는 주소값을 비교하기에 동일성을 비교함을 알 수 있다.

equals 오버라이딩을 지양해야 하는 경우

equals 오버라이딩은 반드시 필요한 것은 아니다. 다음의 경우에는 equals 오버라이딩은 지양해야 한다.

  1. 인스턴스가 고유하다.
    • 인스턴스가 어떤 상황에서도 같은 객체로 나올 경우가 없고 값으로 객체를 비교하는 것이 무의미한 상황인 경우이다.
    • 대표적인 경우가 Thread이다. Thread는 프로그램이 동작해야하는 내용을 처리해주는 책임을 가진다. Thread값을 비교한다고 해서 같읕 동작을 처리한다고는 알 수 있지만 같은 동작을 처리한다고 해서 같은 객체라는 것은 알 수 없다. 동작을 하기 위한 매개변수가 달라 서로 다른 결과를 제공할 수 있다. 즉 Thread를 equals로 비교하는 것이 무의미하다. 심지어 Thread는 생성될 때 id를 받기에 id를 이용한 동등성 검사는 수행할 수 없다.
  2. 논리적 동치성(logical equality)을 검사할 필요가 없다.
    • 논리적 동치성(logical equality): 객체의 주소가 아닌 값을 비교하여 두 객체의 값이 완전히 동일하면 true, 아니면 false로 판단하는 규칙.
    • 모든 객체가 동등성을 비교할 필요가 없다. java에서 main함수를 실행하는 클래스에 equals메소드를 작성하는 것이 의미가 있을까를 생각해보자. 이 객체는 왠만하면 1개로 유일하고 비교할만한 대상이 없는데 이 메소드가 필요할까?
  3. 부모 클래스가 정의한 equals메소드를 사용하여도 동등성 비교에 문제가 없다.
    • 부모 클래스로 equals를 이용한 동등성 비교가 가능한데 굳이 자식 클래스도 상속받아서 재작성한다면 상속을 한 의미가 없다.
  4. private접근제한자를 가지거나 package-private이고 equals 메소드를 호출할 필요가 없다.
    • 이 경우는 간단하다. 굳이 사용도 하지 않을거 적지도 말라는 의미이다.
    • 물론 private이므로 객체 내부에서 equals메소드를 호출할 수도 있다. 이럴 경우에는 에러를 던져서 막아보는 것도 고려할 필요가 있다.

equals를 작성해야하는 경우

위의 사항이 아니며 equals가 필요하다고 판단하면 equals를 오버라이딩하여 작성한다. 그럼 여기서 equals가 필요하다고 판단 하는 시점은 언제일까? 바로 logical equality를 비교할 때이다. 동일성이 아니라 동등성을 확인할 때 equals 메소드를 오버라이딩한다.

equals 오버라이딩 작성 규약

equals 메소드를 작성해야하는 판단이 오면 equals를 제대로 활용할 수 있도록 작성할 수 있는 방법이 있을까를 고민할 수 있다. 다행히도 equals메소드는 작성할 때 지키는 규약들이 있다. JavaDocs에도 이 내용이 설명되어 있으니 보는 것이 좋다.

equals 오버라이딩 작성 규약은 다음과 같다.

아래의 규약에서 작성하는 x,y,z는 모두 null이 아니다.

  1. 반사성(reflexivity): x.equals(x)==true 이다.
    • 자기자신을 비교하면 항상 참이다.
  2. 대칭성(symmetry): x.equals(y)==true이면 y.equals(x)==true이다.
  3. 추이성(transitivity): x.equals(y)==true이고 y.equals(z)==true이면 x.equals(z)==true이다.
    • 동등성으로 삼단 논법을 작성한 내용이다.
  4. 일관성(consistency): x,equals(y)==true는 값이 바뀌지 않는한 항상 동일하다.
  5. null아님: x.equals(null)==false이다.

1. 반사성(reflexivity)

x.equals(x)==true 이다.

자기 자신과 equals 메소드로 비교를 하였을 때 true가 나와야 한다는 규약이다. equals 메소드는 두 객체가 같음을 확인하는 책임을 가졌기에 수학식으로 비교하자면 = 과 역할이 같다. 수학식중 가장 간단한 1=1 라는 수학식처럼 자기 자신을 비교할 때는 true라는 동일한 결과가 나와야 한다.

2. 대칭성(symmetry)

x.equals(y)==true이면 y.equals(x)==true이다.

equals 가 수학식의 = 과 동일한 역할을 한다. 그럼 이를 수학식으로 치환하자면 다음과 같이 바뀐다.

x=y 이면 y=x이다.

즉 등호의 순서를 바뀌어도 서로 같아야 한다는 규약이다.

규약 내용만 들으면 '이걸 실수할 수 있을까' 라는 생각을 할 수 있다. 그러나 이 규약은 서로 타입을 가진 두 객체를 비교하는 경우에 위반이 발생할 수 있다.

아래의 코드는 대칭성 규약을 위반한 코드이다.

public class Car {
    private String model;

    public Car(String model) {
        this.model = model;
    }


    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof Car car)) {
            return false;
        }
        return model.equals(car.model);
    }
}
public class RaceCar extends Car{
    private int topSpeed;

    public RaceCar(String model, int topSpeed) {
        super(model);
        this.topSpeed = topSpeed;
    }

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof RaceCar raceCar)) {
            return false;
        }
        return super.equals(obj) && raceCar.topSpeed == topSpeed;
    }
}

이 코드는 대칭성 규약을 위반한다. 같은 모델을 가지도록 만들어도 Car객체를 통해 비교하면 true이지만 RaceCar객체로 비교하면 false를 나온다. RaceCarCar를 비교할 때 RaceCar타입이 아니므로 false를 반환해버린다. 실행코드로 결과를 확인해보면 다음과 같다.

public class Main {

    public static void main(String[] args) {
        Car car1 = new Car("ABC123");
        RaceCar raceCar1 = new RaceCar("ABC123", 270);

        System.out.println(car1.equals(raceCar1)); // true
        System.out.println(raceCar1.equals(car1)); // false
    }
}

CarRaceCar를 비교할 땐 true로 나오나 반대로 비교할 때는 false가 나온다. 이처럼 서로 다른 두 타입의 객체를 비교할 때 동등성이 같음에도 비교할 수 없는 것을 방지해야 한다.

3. 추이성(transitivity)

x.equals(y)==true이고 y.equals(z)==true이면 x.equals(z)==true이다.

3단논법이 떠오르는 규약이다. 수학식으로 바꾸면 다음과 같다.

x=y이고 y=z이면 x=z이다.

굉장히 간단한 규약이지만 2번과 마찬가지로 서로 다른 두 타입들을 비교할 때 문제가 발생한다. 상속관계에 있는 타입들이 늘어날 때마다 equals를 오버라이딩하게 되고 잘못 작성하면 순환 참조 문제가 발생할 수 있다. 예를 들어 Car객체를 상속받는 SuperCar라는 객체를 작성하고 Car, SuperCar, RaceCar가 동등성을 만족하도록 작성할 시 서로 equals를 호출하면서 순환 참조가 발생할 수 있다.

하위 객체들을 작성하면서 이들을 모두 equals작성 규약을 맞추면서 작성하는 것은 사실상 존재하지 않는다.

4. 일관성(consistency)

x,equals(y)==true는 값이 바뀌지 않는한 항상 동일하다.

같은 객체라면 항상 비교는 true로 바뀌어야 한다. 여기서 중요한 것은 값이 바뀌지 않는한 이란 조건이다. 불변이든 가변이든 값이 자주 바뀌어 equals메소드에 영향을 주는 신뢰할 수 없는 필드를 이용해서 비교하지 말라는 의미이다.

객체지향 5대 원칙인 SOLID원칙중 LSP(리스코프 치환 원칙)은 하위 타입은 상위 타입으로 대체가 가능해야 한다는 의미이다. 객체 타입을 getClass() 를 이용해 객체 타입을 완전일치하는 방식으로 equals 메소드를 작성할 경우 LSP원칙을 위배하게 된다. 하위 타입인 RaceCar는 상위 타입인 Car로 대체가 가능하여 비교가 가능함에도 불과하고 이를 고려하지 않았기 때문이다.

5. null아님: x.equals(null)==false이다

null값은 누구와도 같은 값이 될 수 없다. 그러나 이 규약은 NullPointerException을 던지지 말고 false라는 boolean값을 반환하라는 의미이다. Null은 반드시 같을 수 없기 때문에 예외를 던지지 말라는 의미다.

주의사항

equals 일반 규약을 준수하면서 작성할 때는 아래의 주의사항을 고려하는 것이 좋다.

  1. equals와 hashcode 는 같이 작성해라.
  2. 복잡하게 생각하지 말자
    • File를 비교할 때 symbol link가 걸린 파일과 안걸린 파일을 비교할지를 고려하기 보단 파일의 해시값을 추출해서 비교하는 등의 간단한 방법으로 비교해라.
  3. 매개변수는 반드시 Object 타입이어야 한다.
    • Object타입이 아닌 equals메소드는 오버라이딩이 아니라 오버로딩이다.

결론

동등성을 비교하면 어디까지가 "같다" 라는 정의가 필요하다는 생각이 든다. Car와 RaceCar는 모델이 같다는 조건은 모델이다. 모델이름이 같으면 타입이 다르더라도 두 객체는 "같다" 라고 인식한다. 그렇기에 equals를 작성할 때는 "같다" 라는 조건을 어떻게 정의하고 이를 equals 메소드 작성에 적용하느냐가 equals 메소드를 정확히 오버라이딩할 수 있는 핵심이라고 생각한다.


Reference