이번 글에서는 쉽게만 생각했던 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
를 재정의하는 것으로 해결할 수 있다.
public class Object {
public boolean equals(Object obj) {
return (this == obj);
}
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);
}
그런데 중간에
==
가 보인다. 왜 그런 걸까??
당연히 타당한 이유가 있다! 왜 그런지는 이 글의 마지막쯤에서 나오니 이따 확인할 수 있다!
규칙 | 설명 | 수식 |
---|---|---|
반사성 | 객체는 자기 자신과 같아야 함 | 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 코드들
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과 비교하는 코드 자체를 삭제해야한다.
좌표클래스와, 하위 클래스인 색깔 좌표 클래스가 있다고 해보자.
좌표 클래스는 x 와 y 를 비교하고, 색깔 좌표 클래스는 x, y ,색정보까지 모두 비교하면 될 것 같다.
그럴 듯 하게 들리고, 타당해보인다. 그러나 규약을 위배하게 된다. 코드가 많이 나오니 집중해보자
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;
}
false
를 반환하기 때문이다. Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
p.equals(cp) -> true
cp.equals(p) -> false
리스코프 치환 원칙(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를 위배합니다.
위의 코드는 하위객체에서 상위객체를 고려하지못해서 대칭성이 위배되었다.
그렇다면 하위객체에서도 상위객체를 고려하는 코드를 추가하면 되지 않을까??
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;
}
이제 위 테스트 코드가 대칭성을 위배하면서 돌아갈 것 같다..!!
하지만 !! 이것은 말도안되는 코드 잖아요~ !
![]()
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
클래스의 색깔 정보는 중요한 핵심 정보인데, 이 정보를 제대로 활용하지 못하는 것 자체가 문제점이기도 하다.
그러면 아예 정확한 클래스를 통해서 비교하는 getClass
를 대신 쓰면 어떨까??
이 의문에 대한 답은 1-1과 동일하다.
하위 클래스는 상위 클래스로 치환 가능한 LSP를 지켜야하기 때문이다.
과연 equals
메서드가 그런 상황에 처하게 될 지 궁금한 개발자도 있을 것이다.
그러나 Set
과 같은 자료구조에 경우 1-1, 2 번의 경우를 넣었다고 생각해보자.
contains()
와 같은 메서드들은 의도한 대로 동작하지 않을 것이고, 이것이 다른 사람이 짠 코드와도 연결된다면 문제의 원인은 더 찾기 어려워 질 것이다.
getClass
와instanceof
의 차이
- getClass: 정확히 같은 클래스인지 검사 (하위 클래스도 다른 클래스로 봄)
- instanceof: 해당 클래스이거나 하위 클래스인지 검사 (상속 관계 인정)
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
를 설명했지만, 굳이 필요한 경우가 아니라면 재정의하지 않는게 더 적절할 수 있다.
대부분의 경우에서 Object의 equals
가 정상동작을 할 수 있다.
또한 만약 재정의한다면 의도와는 다르게 동작해 오류를 만들지 않도록 위 규약을 정확히 지켜야한다.
- 고유한 인스턴스: 각 인스턴스가 본질적으로 고유한 경우
- 동등성 검사 불필요: 인스턴스의 동등성을 검사할 일이 없는 경우
- 상위 클래스 equals 충분: 상위 클래스의 equals가 하위 클래스에도 적합한 경우
- private 클래스: 클래스가 private이고 equals 호출이 필요하지 않은 경우
여러분들은 테스트코드를 성실히 짜시나요?
혹시 재정의한 equals를 테스트해보신적이 있나요?
저는 아쉽게도 둘다 아니요입니다...
여러 글을 읽으면서 느끼는 점은 실무에 가면 내가 모르는 코드를 짜는 일이 태반이겠지?입니다.
그렇기 때문에 잘 짜여진 코드가 중요한것이구나 싶네요!
해당 글은 카카오테크스터디 중에 작성한 글입니다 🙇♀️
제가 정리하지 않은 나머지 글은 고마운 팀원분들이 정리해주시고 계십니다 🫶더 많은 글을 읽고 싶다면 아래 깃허브를 참고해주세요!