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

다람·2025년 2월 28일
0

Effective Java

목록 보기
10/13
post-thumbnail

1. equals를 재정의하지 않는 게 좋은 상황

꼭 필요한 경우가 아니라면 equals를 재정의하는 것이 좋지 않다.
equals를 재정의하지 않는 것이 좋은 상황은 대표적으로 아래 4가지 상황이 있다.
1. 각 인스턴스가 본질적으로 고유하다.

  • 값이 아닌 동작(행위) 중심 객체라면 동치성이 의미가 없다.
  • 예 : Thread, Random
    • Thread는 실행 흐름을 나타낸다.
    • Random은 seed값에 따라서 생성되는 난수도 다르고 동작이 다를 수 있다.
  1. 인스턴스의 논리적 동치성(logical equality)을 검사할 일이 없다.
    • 논리적 동치성 검사가 필요없다고 판단되는 경우 기본 Object equals만으로도 해결된다.
    • 예 : REST API 응답용으로 DTO 클래스(ResponseDto)를 만들었을 때도 이 경우 인 것 같다. 한 번 생성해서 클라이언트로 전달하면 끝이기 때문에 굳이 내부 상태를 비교할 일이 없는 것 같다.
  2. 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다
    • 예 : Set, Map 등의 일부 구현체가 해당된다. 상위 equals가 이미 논리적 동치성을 제공하기 때문이다.
  3. 클래스가 private이거나 package-private이며, equals 메서드를 호출할 일이 없다
    • equlas가 실수로라도 호출되지 않도록 에러를 던져서 예방한다.
    @Override
     public boolean equals(Object o) {
         throw new AssertionError(); // 호출 금지
     }

2. equals를 재정의해야 할 때

  • 값 클래스(예 : Integer, String)처럼 “논리적 동치성”을 비교해야 하는 클래스는 재정의가 필요하다.
  • 단, 인스턴스 통제(예 : 싱글턴, 열거 타입(Enum))나 값이 같은 인스턴스를 둘 이상 만들지 않는 경우는 재정의가 필요 없다.

3. Object 명세 규약 (동치관계)

Object의 equals동치관계(equivalence relation)를 구현해야 하며, 다섯 가지 요건을 만족한다.

  1. 반사성 (reflexivity) : x.equals(x)는 true
  2. 대칭성 (symmetry) : x.equals(y)가 true면, y.equals(x)도 true
  3. 추이성 (transitivity) : x,y,z가 각각 equals면 x,z도 equals
  4. 일관성 (consistency) : 두 객체가 변하지 않으면, 여러 번 equals 호출 시 결과가 변하면 안 됨
  5. null-아님 : null이 아닌 객체 x에 대해 x.equals(null)은 false

4. 잘못된 코드 예시

4-1. 대칭성 위배

public final class CaseInsensitiveString {
    private final String s;

    public CaseInsensitiveString(String s) {
        this.s = Objects.requiredNonNull(s);
    }

    // 잘못된 equals (대칭성 위배)
    @Override
    public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString) {
            // 같거나 다른 CaseInsensitiveString이면 대소문자 구분 없이 비교
            return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
        }
        if (o instanceof String) {
            // 만약 o가 일반 String이면, 이것도 case-insensitive로 비교
            return s.equalsIgnoreCase((String) o); 
        }
        return false;
    }

    // ...
}

코드 해석

  1. o instanceof CaseInsensitiveStringCaseInsensitiveString 객체라면,
    • this.s.equalsIgnoreCase(((CaseInsensitiveString) o).s)로 비교한다.
    • 두 문자열을 대소문자 구분 없이 같은지 확인한다.
  2. o instanceof Stringo가 일반 String이면,
    • this.s.equalsIgnoreCase((String) o)로 비교
    • CaseInsensitiveString vs String 비교도 대소문자 무시로 equals를 true/false 판단한다.
  3. 위의 두 경우 모두 아니면 false를 반환한다.

대칭성 위배인 이유

대칭성은 x.equals(y)가 true면, y.equals(x)도 true여야 한다.

CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String str = "";

System.out.println(cis.equals(str)); // A
System.out.println(str.equals(cis)); // B
  1. cis.equals(str) (A 라인)
    • o instanceof String 분기에 해당한다.
    • "Polish".equalsIgnoreCase("polish")true
    • 따라서 cis.equals(str)true
  2. str.equals(cis) (B 라인)
    • 여기서 str은 단순한 String 객체 → Stringequals는 같은 문자열 타입이고, 동일한 내용인지 검사한다.
    • cis는 타입이 CaseInsensitiveString이기 때문에 타입이 다르다.
    • str.equals(cis)false
    • "polish".equals( CaseInsensitiveString )false

A라인에서는 true를 반환, B라인에서는 false를 반환하기 때문에 대칭성이 위배된 것이다.

이 문제를 해결하려고 String과도 연동하려고 하면 안된다.
아래와 같이 해결하는 정도로 equals를 오버라이딩 해야한다.

@Override
public boolean equals(Object o) {
	return o instanceof CaseInsensitiveString &&
    	((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

이렇게 하면 상대가 CaseInsensitiveString인지 확인한 뒤에 CaseInsensitiveString로 캐스팅하고 대소문자를 무시하고 비교해서 대칭성을 지킬 수 있다.

4-2. 추이성 위배

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

public class ColorPoint extends Point {
    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }

    // 잘못된 equals (대칭성은 지키지만 추이성 위배)
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point)) return false;
       
        // 만약 o가 Point이지만 ColorPoint는 아니면,
        // o.equals(this)로 역방향 비교를 시도해서 색상을 무시하고 비교하게 된다.
        if (!(o instanceof ColorPoint)) {
            return o.equals(this);
        }
        
        // o가 ColorPoint면, 색상까지 비교
        ColorPoint cp = (ColorPoint) o;
        return super.equals(cp) && color.equals(cp.color);
    }
}

추이성 위배인 이유

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

p1.equals(p2); // A
p2.equals(p3); // B
p1.equals(p3); // C
  1. p1.equals(p2) (A라인)
    • p1.equals(p2) -> true
    • ColorPoint 내부에서 oPointo.equals(this) 호출한다.
    • p2.equals(p1)x,y만 비교
  2. p2.equals(p3) (B라인)
    • p2.equals(p3) -> true
    • 같은 Point 객체
  3. p1.equals(p3) (C라인)
    • p1.equals(p3) -> false
    • 같은 ColorPoint인 경우에는 색상까지 비교하는데 p1RED, p3BLUE로 색상이 다르다.

4-3. 리스코프 치환 원칙 위배

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

리스코프 치환 원칙 위배인 이유

  1. getClass() != getClass()
  • 상대 객체 o의 실제 클래스가 나의 클래스와 정확히 일치해야만 equalstrue로 나오게 된다.
  • PointColorPoint(하위 클래스)가 절대 같다고 나오지 않게 된다.
  1. 리스코프 치환 원칙(LSP)이 깨진다.
  • LSP는 하위 클래스는 상위 클래스가 쓰이는 모든 곳에서 호환되어야 한다는 원칙이다.
  • 여기서 Point를 상속한 ColorPointx,y만 같으면 같다고 생각하고 싶어도 클래스가 다르다는 이유로 equalsfalse가 돼버리게 된다.
  • 하위 클래스는 상위 클래스와 동등해질 수 없게되어서 하위 클래스를 상위 타입으로 쓰는 호환성이 깨지는 결과가 생기는 것이다.

해결 방법(우회방법)

ColorPoint가 Point를 확장하므로 상위/하위 클래스 간에 equals가 안되게 되는 것이므로 합성(컴포지션) 방법으로 우회하는 방법이다.

public class ColorPoint {
    private final Point point; // 합성
    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        this.point = new Point(x, y); // private 필드로 갖고있음
        this.color = Objects.requireNonNull(color);
    }

    // 만약 Point의 x,y로 뭔가 계산이 필요하면 point 필드를 사용

    // 뷰 메서드
    public Point asPoint() {
        return this.point; // 일반 Point 반환
    }

    @Override
    public boolean equals(Object o) {
    	if (!(o instanceof ColorPoint)
        	return false;
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
    // ...
}
  1. 뷰 메서드(public Point asPoint())를 사용.
    • ColorPoint가 내부적으로 Point를 속성(필드)으로만 두고
    • 필요할 때 asPoint()로 일반 Point를 뷰로써 반환하게 된다.
    • 이렇게 하면 상속 관계가 없어져서 'ColorPoint는 Point이다'가 아니게되고 'ColorPoint는 Point'를 가지고 있다 형태가 된다.
  2. LSP 문제(equals 충돌)가 사라진다.
    • ColorPointPoint를 상속하지 않기 때문에
    • Point인지 아닌지로 인한 equals 충돌이 없어진다.
    • 하위 클래스 관련 고민도 필요 없게된다.
    • 상위/하위 관계가 아니기 때문에 PointColorPoint를 동일 타입으로 다룰 일이 없게된다.
  3. 그리고 동치성 결정은 ColorPoint만의 로직으로 가능해진다.
    • ColorPoint는 색상까지 포함한 비교와 같이 어떤 식으로 equals를 구현할지 스스로 결정하게된다.
    • Point는 이 과정에 대해 아무것도 모른다(별개의 클래스).

5. equals 구현 시 단계

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인
    • if (this == o) return true;
  2. instanceof 연산자로 입력이 올바른 타입인지 확인
    • if (!(o instanceof MyClass)) return false;
  3. 입력을 올바른 타입으로 형변환
    • MyClass other = (MyClass) o;
  4. 핵심 필드 비교
    • 모든 핵심 필드가 같아야 true

주의사항

  • Objects.equals(a, b)을 통해 NullPointException 예방
  • equals 재정의할 때 hashCode()도 재정의!
  • 입력 타입은 반드시 Object -> @Override하면 실수 예방 가능

6. 성능 관련 팁

  • 비교 비용이 싼 필드, 서로 다를 확률이 큰 필드를 우선 비교하면 빠르게 false를 반환해 효율적이다.
  • 동기화용 lock 필드(동시 접근 제어를 위해서 존재하기 때문에 무관함), 파생된(다른 필드 조합으로 계산된) 필드 등은 꼭 논리적 상태가 아니면 비교에서 제외한다.

Tip. 구글 AutoValue 활용

(https://mvnrepository.com/artifact/com.google.auto.value/auto-value/1.10.1)

  • AutoValue 라이브러리를 쓰면 equals, hashCode, toString 등을 자동으로 만들어준다고 한다.
  • 컴파일 시점에 AutoValue_MyClass 클래스가 생성되어, 올바른 equals가 구현된다.

7. 결론

  1. 값 객체(논리적 동치성 검사 필요)일 때만 equals 재정의해야 한다.
  2. 동치관계(반사성,대칭성,추이성,일관성,null-아님)을 만족해야한다.
  3. 상속 구조에서 equals는 매우 조심해야 한다. ColorPoint 예시룰 확인해 보자.
  4. equals를 재정의 할 때 hashCode는 반드시 재정의해야한다.
  5. 구글 AutoValue 프레임워크를 사용하면 편리하게 작성이 가능하다.
profile
개발하는 다람쥐

0개의 댓글