[이펙티브 자바] 아이템10. equals는 일반 규약을 지켜 재정의하라

코린이서현이·2025년 2월 9일
0

이펙티브자바

목록 보기
3/6
post-thumbnail

들어가면서

이번 글에서는 쉽게만 생각했던 equals이 지켜야하는 규칙, 
그리고 개발자도 모르게 그 규칙이 깨지는 상황(코드)들,
그리고 올바른 equals의 재정의 단계에 대해서 알아보도록 하겠습니다.

equals 메서드란

equals 메서드는 무엇일까?

equals 메서드는 두 객체가 동등한 지를 비교하는 메서드이다.
두 객체의 내용이 같으면 true, 다른 경우에는 false를 리턴한다.

💡 동등성과 동일성의 차이

  • 동일성 : 저장된 메모리 주소값이 같은지 비교 ( == 를 이용한다. )
  • 동등성 : 논리적인 내용이 같은지 비교 ( equals 메서드를 이용)

equals 메서드는 언제 쓰일까?

어떤 객체가 실제로 저장된 주소는 다르지만, 동일한 객체로 판단해야하는 경우가 있다.

x 좌표와 y 좌표를 저장하는 클래스가 있다고 생각해보자.

좌표 a = new 좌표(5,4)
좌표 b = new 좌표(5,4)

//아래줄의 결과를 생각해보자 (단순 의사 코드임)
a == b

a,b는 다른 메모리 공간을 차지하는 객체이다. 따라서 false, 거짓의 결과값을 가진다.
그러나 두 개의 객체는 논리적으로 같은 객체, 동등성을 가진 객체로 true를 가져야 한다.

이때 연산자가 아닌 equals를 재정의하는 것으로 해결할 수 있다.

Object의 equals 메서드

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

재정의된 String의 equals 메서드

  • String 클래스 또한 주소값이 아니라 문자열 내용의 동등성을 비교해서 계산한다.
public final class String {

    public boolean equals(Object anObject) {
        if (this == anObject) {
        return true;
    }
        return (anObject instanceof String aString)
            && (!COMPACT_STRINGS || this.coder == aString.coder)
            && StringLatin1.equals(value, aString.value);
        }

그런데 중간에 ==가 보인다. 왜 그런 걸까??
img.png
당연히 타당한 이유가 있다! 왜 그런지는 이 글의 마지막쯤에서 나오니 이따 확인할 수 있다!

equals 메서드의 규약

규칙설명수식
반사성객체는 자기 자신과 같아야 함x.equals(x) = true
대칭성두 객체의 equals는 주체와 대상에 관계없이 동일해야함if x.equals(y) = true then y.equals(x) = true
추이성연속된 equals 비교는 일관된 결과를 가져야 함if x.equals(y) = true and y.equals(z) = true then x.equals(z) = true
일관성불변 객체의 equals는 항상 같은 결과를 반환해야 함x.equals(y) = true → x.equals(y) = true (항상)
Null 비교null과의 모든 비교는 false를 반환해야 함x.equals(null) = false

위 5가지를 모두 만족해야 제대로 재정의한 equale메서드라고 할 수 있다.
그러나 생각보다 위 5가지를 만족하기가 까다롭다.

이제 잘못 정의된 equals 코드를 공부해볼 것이다.

불변 객체와 가변 객체

불변(Immutable) 클래스

  • 한번 생성되면 그 객체의 상태가 절대 변하지 않음
  • 모든 필드가 final이며 수정자(setter) 메서드가 없음

가변(Mutable) 클래스

  • 객체 생성 후에도 상태를 변경할 수 있음
  • setter 메서드 등을 통해 내부 상태 수정 가능

올바르지 못한 equals 코드들

상황 1. 이것도 String의 일부라고 나는 생각한다.! ps. 대칭성 위배

  • 어떤 개발자가 커스텀한 String 클래스를 만들고, equals를 재정의 할 때, String과도 비교할 수 있도록 했다.
  • equals는 입력 객체가 String 타입일 때도 문자열 비교를 한 후 동등하면 true를 반환한다.

이때 이 코드의 문제점은 무엇일까 !!

class CustomString {
    private String string;

    @Override
    public boolean equals(Object object) {
        //입력객체가 CustomString인 경우 -> 내용 동일시 true
        if (object instanceof CustomString) {
            CustomString objectCustomString = (CustomString) object;
            return this.string.equals(objectCustomString.string);
        }

        //입력객체가 String인 경우 -> 내용 동일시 true
        if (object instanceof String) {
            String objectString = (String) object;
            return this.string.equals(objectString);
        }
    }

}

CustomString객체.equals(String객체)String객체.equals(CustomString객체)의 결과값이 다르다.
즉, 대칭성을 위배한다.

올바른 방법

String의 equals메서드는 재정의할 수 없다.
애초에 잘못된 요구사항이다.

Stiring과 비교하는 코드 자체를 삭제해야한다.

상황2. instanceof를 사용하면?

좌표클래스와, 하위 클래스인 색깔 좌표 클래스가 있다고 해보자.
좌표 클래스는 xy 를 비교하고, 색깔 좌표 클래스는 x, y ,색정보까지 모두 비교하면 될 것 같다.

그럴 듯 하게 들리고, 타당해보인다. 그러나 규약을 위배하게 된다. 코드가 많이 나오니 집중해보자

Point 클래스 와 ColorPoint 클래스

public 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;
        // x 와 y 를 비교한다. 
        return p.x == x && p.y == y; 
    }
}

... 

public class ColorPoint extends Point {
    private final Color color;

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            // 입력 객체가 ColorPoint가 아닌 경우 false
            return false; 
        ColorPoint cp = (ColorPoint) o;
        // x,y,색정보까지 모두 비교
        return super.equals(o) && cp.color == color;
    }
  • 위 코드는 일단, 대칭성을 만족하지 못하는 문제점이 있다.
  • x,y 좌표가 동일한 각각의 객체에 따라서 서로의 비교가 다른 값을 내놓는다.
    ColorPoint의 비교에서는 ColorPoint 타입이 아닌 경우는 무조건 false를 반환하기 때문이다.
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp)  -> true 
cp.equals(p) -> false
  • 추가로 이 구현은 리스코프 치환 규칙 (LSP)를 위배한다는 또 다른 문제점을 가진다.

    리스코프 치환 원칙(LSP, Liskov Substitution Principle)이란

    "상위 타입의 객체를 하위 타입의 객체로 치환해도 프로그램의 정확성을 깨뜨리지 않아야 한다"는 것을 뜻한다.
    상위 객체를 사용하는 곳에서 하위객체를 넣어도 정상 작동해야하는 것을 말한다.

List<Point> points = new ArrayList<>();
points.add(new Point(1, 2));
points.add(new ColorPoint(1, 2, Color.RED));

Point p = new Point(1, 2);

// LSP 위배 예시
for (Point point : points) {
    if (point.equals(p)) { // 일관되지 않은 결과
    // point가 Point일 때는 true
    // point가 ColorPoint일 때는 false
    }
}

Point 타입을 사용하는 컬렉션에 ColorPoint를 넣었을 때, equals() 동작이 일관되지 않아 예측할 수 없는 결과가 발생합니다. 이는 하위 클래스가 상위 클래스를 대체할 수 없다는 것을 의미하므로 LSP를 위배합니다.

상황2-2. 상위 객체일 때에도 비교를 진행해보자.

위의 코드는 하위객체에서 상위객체를 고려하지못해서 대칭성이 위배되었다.
그렇다면 하위객체에서도 상위객체를 고려하는 코드를 추가하면 되지 않을까??

public class ColorPoint extends Point {
    
    @Override
    public boolean equals(Object o) {

        //입력값이 상위객체인 경우는 상위에서만 비교
        if (!(o instanceof Point))
            return super.equals(o);  
        
        if (!(o instanceof ColorPoint))
            return false; 
        ColorPoint cp = (ColorPoint) o;
        return super.equals(o) && cp.color == color;
    }

이제 위 테스트 코드가 대칭성을 위배하면서 돌아갈 것 같다..!!

img_2.png

하지만 !! 이것은 말도안되는 코드 잖아요~ !

img_3.png

Point p = new Point(1, 2);
ColorPoint cp1 = new ColorPoint(1, 2, Color.RED);
ColorPoint cp2 = new ColorPoint(1, 2, Color.BLUE);

//대칭성은 만족한다. 
p.equals(cp1)       // true: cp는 Point의 instanceof이므로
cp1.equals(p)       // true
        

//추이성을 위배한다.        
p.equals(cp3)       // true
cp1.equals(cp3)     // false

equals 구현은 추이성을 위배한다.

애초에 ColorPoint 클래스의 색깔 정보는 중요한 핵심 정보인데, 이 정보를 제대로 활용하지 못하는 것 자체가 문제점이기도 하다.

상황3. getClass를 사용하면?

그러면 아예 정확한 클래스를 통해서 비교하는 getClass를 대신 쓰면 어떨까??
이 의문에 대한 답은 1-1과 동일하다.

하위 클래스는 상위 클래스로 치환 가능한 LSP를 지켜야하기 때문이다.

과연 equals메서드가 그런 상황에 처하게 될 지 궁금한 개발자도 있을 것이다.
그러나 Set과 같은 자료구조에 경우 1-1, 2 번의 경우를 넣었다고 생각해보자.

contains()와 같은 메서드들은 의도한 대로 동작하지 않을 것이고, 이것이 다른 사람이 짠 코드와도 연결된다면 문제의 원인은 더 찾기 어려워 질 것이다.

getClassinstanceof의 차이

  • getClass: 정확히 같은 클래스인지 검사 (하위 클래스도 다른 클래스로 봄)
  • instanceof: 해당 클래스이거나 하위 클래스인지 검사 (상속 관계 인정)

상황2, 3에서 더 적절하게 equals를 재정의하는 방법 ps. 컴포지션을 이용하자.

  • 상속이 아닌 컴포지션으로 이용할 경우, 별개의 클래스기 때문에 규약 위반 걱정이 없디/
  • 필요한 경우 내부 필드인 point를 제공하는 메서드를 두면 된다.
public class ColorPoint {
    private final Point point; //상속이 아닌 컴포지션을 이용했다. 
    private final Color color;

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            return false;
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color == color;
    }
}

equals를 더 올바르게 재정의하는 가이드

1. == 연산자를 이용헤 입력이 자가자신인지 확인한다.
동일한 경우 복잡한 비교시에 성늘 최적화를 할 수 있다.

2. null 검사 대신 instanceof를 이용한다.
instanceof는 첫번째 피 연산자가 null인 경우에 false를 무조건 반환하므로 null 검사코드를 줄일 수 있다.

3. 입력을 올바른 타입으로 형변환한다.
이는 2번으로 인해 안전하게 형변환할 수 있다.

4. 핵심 필드들이 일치하는 지 검사한다.

5. 이때 어떤 필드를 먼저 검사할지는 성능을 고려해 선택할 수 있다.
비교하는 비용이 싼 필드, 틀링 가능성이 더 큰 필드를 먼저 비교하면 성능 최적화를 할 수 있다.

꼭 equals를 재정의해야 할까?

열심히 equals를 설명했지만, 굳이 필요한 경우가 아니라면 재정의하지 않는게 더 적절할 수 있다.
대부분의 경우에서 Object의 equals가 정상동작을 할 수 있다.

또한 만약 재정의한다면 의도와는 다르게 동작해 오류를 만들지 않도록 위 규약을 정확히 지켜야한다.

equals 재정의가 필요하지 않은 경우

  1. 고유한 인스턴스: 각 인스턴스가 본질적으로 고유한 경우
  2. 동등성 검사 불필요: 인스턴스의 동등성을 검사할 일이 없는 경우
  3. 상위 클래스 equals 충분: 상위 클래스의 equals가 하위 클래스에도 적합한 경우
  4. private 클래스: 클래스가 private이고 equals 호출이 필요하지 않은 경우

마무리하면서

여러분들은 테스트코드를 성실히 짜시나요?
혹시 재정의한 equals를 테스트해보신적이 있나요?

저는 아쉽게도 둘다 아니요입니다... 

여러 글을 읽으면서 느끼는 점은 실무에 가면 내가 모르는 코드를 짜는 일이 태반이겠지?입니다.
그렇기 때문에 잘 짜여진 코드가 중요한것이구나 싶네요!

해당 글은 카카오테크스터디 중에 작성한 글입니다 🙇‍♀️
제가 정리하지 않은 나머지 글은 고마운 팀원분들이 정리해주시고 계십니다 🫶

더 많은 글을 읽고 싶다면 아래 깃허브를 참고해주세요!

🔗 카카오테크 스터디의 이펙티브 자바

profile
24년도까지 프로젝트 두개를 마치고 25년에는 개발 팀장을 할 수 있는 실력이 되자!

0개의 댓글

관련 채용 정보