Programming/Java

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

VSFe 2021. 2. 10. 18:03
equals는 일반 규약을 지켜 재정의하라!

다른 언어를 쓰다가 Java로 넘어가게 되면 가장 많이 헷갈려하는 것이 String을 비교할 때다.

String str1 = "Hello";
String str2 = "Hello";
System.out.println(str1 == str2);

보통 저렇게 쓰고 "어?? 왜 안되는 걸까???" 이러고 앉아 있다. 당연히 이 글을 읽을 수준이 된다면 왜 안되는지 너무나도 쉽게 알 것이다. equals 가 그 답이다.

일반적으로 객체를 생성할 때는 Object에서 final로 정의하지 않는 것들을 오버라이딩 해서 사용하는게 일반적이다. (equals, hashCode, toString, clone, finalize) 그런데 대부분 equals을 오버라이딩 할 때 IDE에서 자동으로 만들어주는 기능을 사용하기 때문에 그냥 넘어가곤 하는데, 이 장에서 equals를 재정의 할 때 유의해야 할 점들을 알아보자.

꼭 정의해야 할까?

가끔 객체를 만들면 일단 오버라이딩 부터 하는 사람들이 있는데, 꼭 그럴 필요는 없다. 특히 이런 경우라면 더더욱!

  • 각 인스턴스들은 모두 고유함: 다른 객체는 절대 같을 수가 없다. (ex. Thread)
  • 논리적 동치성을 검사할 일이 없음: String에서의 equals는 내용이 같으면 (즉, 논리적으로 같으면) true를 리턴하는데, 굳이 이런식으로 비교할 필요가 없는 경우 (즉, 인스턴스 자체가 동일한지를 확인하고 싶다면)
  • 상위 클래스에서 오버라이딩 한 equals가 하위 클래스에도 맞을 때: 예시로, List/Set/Map의 경우 AbstractList/AbstractSet/AbstractMap의 equals를 그대로 가져다 쓴다.
  • private나 package-private이고 equals를 사용할 일이 없을 때: 혹시나 아예 금지시키고 싶다면, AssertionError()를 띄워버리면 된다.

물론 반드시 정의를 해야할 일도 있을 것이다. 위에서 논리적 동치성 이야기를 했는데, 논리적 동치성을 검증하고 싶은데 상위 클래스의 equals 메소드가 그것을 보장하지 않는다면 반드시 오버라이딩 해야 한다.

꼭 값을 나타내는 클래스라 해도 같은 것이 두개 이상 만들어지지 않는 경우 (특히 Enum!!!)은 만들 필요가 없을 것이다.

지켜야 할 일반 규약

  • 반사성 (Reflexivity): null이 아닌 모든 참조 값 x에 대해 x.equals(x) == true
  • 대칭성 (Symmerty): 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) 는 항상 동일한 값을 출력해야 한다.
  • null이 아님: null이 아닌 모든 참조 값 x에 대해, x.equals(null) == false 이다.

아래 두개를 제외한 세 요소를 모두 만족하는 것을 수학에선 동치관계 (Equivalence Relation) 이라고 하는데, 대표적으로 도형의 합동, 등호 그 자체가 있다.

각각에 대해서 조금 자세히 알아보자.

반사성

사실 진짜 억지로 만들지 않는 이상 불가능하긴 하다. 말도 안되는 예시지만 강제로 틀린 예시를 만들어보자.

public final class NonReflexClass {
    @Override
    public boolean equals(Object o) {
        if(o == this) return false;
        return true;
    }
}

Collection을 떠올려보자. 각각의 컬렉션엔 contains 메소드를 갖고 있는데, 이는 컬렉션의 원소들에 대해 equals를 실행해 true가 될 때 까지 탐색을 한다. 당연히 저런식으로 코드를 짜면 컬렉션에 인스턴스를 넣었음에도 없다고 뜨는 이상한 결론이 나올 것이다.

대칭성

반사성과 달리 대칭성의 경우 지켜지지 않을 가능성이 있다. 책의 예시가 워낙 괜찮아서 책의 예시를 그대로 가지고 왔다.

public 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가 있다고 하자. cis.equals(string)은 문제가 없겠지만, string.equals(cis)에서 문제가 발생한다. 문자열과 문자열 클래스가 아닌 두 인스턴스를 비교하게 되면, toString을 가져와서 비교하게 될텐데, 이 과정에선 equalsIgnoreCase()를 호출하지 않으므로 문제가 발생한다!!

사실 String.equals를 재정의하는 짓을 할 것이 아니라면, 결국 String과의 직접적인 비교는 피해야 한다는 결론이 나온다. 이런식으로 기본 클래스와의 직접 비교는 의도치 않은 대칭성 위반이 발생할 수 있으니 유의하도록 하자.

추이성

추이성도 대충 보면 쉽게 지켜질 것 같지만, 상위 클래스에 없는 필드를 하위 클래스에서 재정의 하면서 서로의 equals가 달라지는 경우 발생하기 쉽다!

피자집을 운영해보자. 각각의 메뉴를 관리하는 클래스를 만들어보자.

public enum Size {
    L, M, S;
}

public class Pizza {
    private final String name;
    private final Size size;

    public Pizza(String name, Size size) {
        this.name = name;
        this.size = size;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Pizza))
            return false;
        Pizza p = (Pizza)o;
        return this.name == p.name && this.size == p.size;
    }
}

일단 이것만 보면 문제가 없다. equals는 잘 동작한다!

그런데 치즈 매니아들이 더 많은 치즈를 요구하기 시작했고, 결국 여러분은 추가 치즈토핑과 치즈크러스트 옵션을 추가했다.

public class PizzaWithAdditionalCheese extends Pizza{
    private final boolean isCheeseTopping;
    private final boolean isCheeseCrust;

    public PizzaWithAdditionalCheese(String name, Size size, boolean tp, boolean crust) {
        super(name, size);
        isCheeseTopping = tp;
        isCheeseCrust = crust;
    }
}

음... 일단 적당히 만들었는데, equals가 문제다. 그대로 쓰자니 원칙이 위반되는건 아닌데 치즈토핑/크러스트 여부가 깡그리 무시되는 꼴이다.

일단 위에서 사용했던 방식으로 equals를 만들어보자.

@Override
public boolean equals(Object o) {
    if (!(o instanceof PizzaWithAdditionalCheese))
        return false;
    return super.equals(o) && ((PizzaWithAdditionalCheese) o).isCheeseTopping == isCheeseTopping
            && ((PizzaWithAdditionalCheese) o).isCheeseCrust == isCheeseCrust;
}

위에랑 생긴건 똑같은데 대칭성이 위배된다! 띠용? 갑자기 무슨 말인가요?

그냥 Pizza와 PizzaWithAdditionalCheese를 비교하면 문제가 발생한다. Pizza의 equals는 토핑/크러스트를 전혀 확인하지 않기 때문에 이름과 가격만 같으면 무조건 true를 반환하는데, PizzaWithAdditionalCheese의 경우 Pizza와 비교하게 되면 클래스 종류가 다르니 무조건 false를 반환하게 된다.

흠... 그러면 입력 받은 클래스가 Pizza면 이름과 가격만 같은지 비교하는건 어떨까?

@Override
public boolean equals(Object o) {
    if (!(o instanceof Pizza))
        return false;
    
    if (!(o instanceof PizzaWithAdditionalCheese))
        return o.equals(this);
    
    return super.equals(o) && ((PizzaWithAdditionalCheese) o).isCheeseTopping == isCheeseTopping
            && ((PizzaWithAdditionalCheese) o).isCheeseCrust == isCheeseCrust;
}

Pizza 클래스라면 Pizza의 equals를 실행시키도록 했다. 찝찝하지만 대칭성은 만족하는 것 같다.

그러나 이 코드는 추이성을 무시한다.

PizzaWithAdditionalCheese potatoWithCheese = new PizzaWithAdditionalCheese("Potato", Size.L, true, true);
PizzaWithAdditionalCheese potato = new PizzaWithAdditionalCheese("Potato", Size.L, true, true);
PizzaWithAdditionalCheese trap = new PizzaWithAdditionalCheese("Potato", Size.L, true, false);

potatoWithCheese.equals(potato)는 분명 true를 리턴할 것이고, potato.equals(trap)도 true를 리턴하겠지만, potatoWithCheese.equals(trap)은 어떤 결과를 리턴할까? 당연 false다.

추이성을 만족하지 못하는 것만이 아니라, 재귀가 폭발할 수 있다! 치즈토핑 여부에 사이드 메뉴까지 추가하는 새로운 클래스를 만들었는데, 상속을 받았음에도 equals를 오버라이딩을 하지 않는다면, 위 코드의 return o.equals(this);에 걸리게 되어 무한 재귀에 걸리게 되고, 자연스럽게 StackOverflowError를 반환할 것 이다.

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

위의 문제를 해결하지 못했다. 결국 열 받은 나머지 equals에서 instanceof를 사용하지 않고 getClass를 사용해 상위 클래스와 하위 클래스의 접근을 차단해버렸다!

public boolean equals(Object o) {
    if (o == null || o.getClass() != getClass())
        return false;
    PizzaWithAdditionalCheese pac = (PizzaWithAdditionalCheese) o;
    return super.equals(pac) && pac.isCheeseCrust == this.isCheeseCrust && pac.isCheeseTopping == this.isCheeseTopping;
}

딱 봐도 좀 그렇게 생겼다...

사실 이렇게 만드는건 해당 객체 자체를 쓰레기로 만드는 매우 잘못된 행동이다...

아무 생각 없이 저렇게 만들고 하위 클래스를 여러개 만든다고 가정해보자. 피자를 또 따져보면, 치즈 바이트가 붙거나, 샐러드를 추가 주문하거나 등등... 그리고 주문 내역을 담을 Set을 하나 생성하고 인스턴스를 담는다. 당연히 각각의 서로 다른 클래스를 구분하기 힘드니까 상위 클래스를 기반으로 한 Set을 생성해서 데이터를 담을텐데, 여기서 문제가 발생한다.

Set을 포함한 대부분의 컬렉션은 값의 포함 여부를 확인하기 위해 equals 메소드를 사용한다. 그러나 위에처럼 만들어 놓으면 안에 들어있는 모든 하위 클래스의 인스턴스는 false를 반환하므로 컬렉션이 정상적으로 동작하지 않는다.

객체지향의 원칙 중 하나인 리스코프 치환 원칙 (Liskov Substitution Principle)에 따르면, 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.

즉, 우리의 PizzaWithAdditionalCheese 클래스를 떠올리면, 하위 클래스 또한 엄밀히 말하면 해당 클래스이므로 어디서든 활용될 수 있어야 한다는 말이지만, 위 예시는 그것을 완전히 어기고 있다.

사실 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규역을 만족시킬 방법은 존재하지 않는다. 이후 배울 컴포지션을 활용하면 이 문제를 우회할 수 있는데, 아예 별도의 클래스로 만들어버리고, 내부에 private로 원래 상속해야 할 클래스를 넣어서 따로 따로 비교하는 방식이다.

일관성

클래스는 불변일 수도 있고, 가변일 수도 있다. 가변이라면 이전에 달랐던 인스턴스가 같아질 수도 있지만, 불변이라면 계속 쭉~~~~~ 같거나 달라야 할 것이다.

다만, 신뢰할 수 없는 자원을 equals 구현에 넣어버리면 조금 문제가 생길 수 있다.

java.net.URL이 여기에 속하는데, equals 구현에 URL과 IP주소를 활용하는데, 내가 고정 IP가 아니라 유동 IP를 사용한다면? 굉장히 곤란한 문제에 빠지게 되는데 이 친구는 레거시 지원 때문에 구현을 바꾸지도 못한다...

신뢰할 수 없는 자원이라는 말이 애매하다면, 메모리에 존재하는 객체와 자원만을 활용한 계산만 수행해야 하고, 외부의 몫이 조금이라도 포함되는 자원간의 비교는 최대한 지양한다고 생각하자!

Null Check

위에서 적은 코드들을 봤을 때, 우리는 널 체크를 따로 하지 않았다. 과연 괜찮은 걸까? 정답은 YES다. instanceof를 첫번째 피연산자가 null이면 무조건 false를 리턴한다. 그래서 괜찮다!

물론 타입 확인을 안하면 조금 문제가 생긴다. 타입을 확인하지 않았는데 잘못된 타입이 들어오게 된다면 ClassCastException이 호출되버리기 때문이다. 그러니 instanceof를 하는 습관을 들이자!

그래서 어떤식으로 짜야 하는가?

빠르게 정리해보자.

  • == 연산자를 사용해 입력이 자기 자신인지 확인해보자: 귀찮으면 안 해도 되겠지만, 성능 최적화용으로 나쁘지 않다.
  • instanceof 연산자로 입력이 올바른 타입인지 확인하자: 올바른 타입의 기준은 짜는 사람 맘이겠지만... 굳이 클래스가 아니더라도 클래스가 구현한 인터페이스여도 괜찮을 것이다.
  • 입력을 올바른 타입으로 변환한다: 앞에서 instaceof로 체크했기 때문에 ClassCastException이 발생하지 않는다.
  • 핵심 필드가 모두 일치하는지 하나씩 검사한다: 만약 인터페이스로 체크한 것이라면 필드 값을 가져올 때 인터페이스의 메소드를 사용해야 하는 것 잊지말자!

추가 유의사항

  • float과 double은 각각의 Wrapper 클래스에 있는 compare 메소드를 활용해서 비교한다. 왜냐하면 Float.NaN이나 -0.0f, 또는 특수한 부동소수 값 등을 다뤄야 하기 때문이다. 물론 저 둘도 equals가 있지만, 오토박싱이 발생할 수 있으니 성능 측면에서 곤란할 수 있다.
  • 만약 null을 정상적으로 받아들여야 한다면, Object.equals()을 활용해 NullPointerException을 회피하자.
  • 비교 과정에서의 비용이 비싼 것과 싼 필드가 각각 있다면, 싼 필드부터 먼저 비교하자. 비싼 필드를 먼저 비교하게 하면 쓸데 없이 시간이 늘어질 수 있다.
  • 일단 정의했으면 대칭성, 추이성을 만족하는지 확인해보고 단위 테스트도 작성해서 돌려보자.
  • equals를 재정의하면 반드시 hashCode도 재정의 하자. (다음 글에서 다룰 예정!!)
  • 필드의 동치성만 검사해도 큰 문제는 없다. 위에서 강조한 것을 하나하나 꼼꼼히 분석한다고 너무 고민하지 말자.
  • 입력 값은 반드시 Object여야 한다! 그게 아닌 경우 오버라이딩이 아니라 오버로딩이라 꼬일 수 있다... @Override 어노테이션을 붙여버리면 컴파일 에러가 뜰 것이고, 안 붙이면 이상한 결과가 나올 수 있다.