자바 개발자라면 객체의 동등성을 정의하기 위해 equals() 메서드를 올바르게 구현하는 것이 매우 중요합니다. 그런데 equals()를 오버라이드할 때 전달된 객체의 타입을 비교하는 방법으로 getClass()를 사용할지 instanceof를 사용할지 헷갈릴 때가 있습니다. 이 글에서는 두 방식의 차이를 구체적인 코드 예제와 함께 알아보고, 각 방식이 어떤 상황에 적합한지 실무적인 관점에서 장단점을 비교해보겠습니다. 아울러 equals()와 hashCode() 메서드의 계약(contract)도 간략히 짚어보고, 이 둘을 함께 올바르게 구현해야 하는 이유를 설명합니다.
자바의 Object.equals(Object) 메서드는 동치관계(equivalence relation)를 정의하도록 설계되어 있으며, 다음의 일반 규약을 따라야 합니다:
또한 equals()를 오버라이드할 때는 반드시 hashCode()도 일관되게 오버라이드해야 합니다. 자바의 일반 규약에 따르면 "두 객체가 equals()로 같다면 반드시 같은 hashCode()를 가져야 한다"고 명시되어 있습니다. 반대로 두 객체가 다르다고 해서 해시코드가 반드시 달라야 하는 것은 아니지만, 다를 경우 해시 충돌을 줄여 성능을 향상시킬 수 있습니다. 이 계약을 지키지 않으면 해시 기반 컬렉션(HashMap, HashSet 등)에서 예상치 못한 동작이 발생합니다.
예를 들어, equals()만 오버라이드하고 hashCode()를 구현하지 않은 클래스를 생각해봅시다. 두 객체가 equals() 상 동등하더라도 해시코드가 다르게 나오면, HashSet에 넣었을 때 별개의 객체로 인식되어 중복 저장되는 문제가 발생합니다. 실습으로 확인해보겠습니다:
class Employee {
int id;
String name;
Employee(int id, String name) { this.id = id; this.name = name; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Employee)) return false;
Employee other = (Employee) o;
return this.id == other.id;
}
// hashCode()를 오버라이드하지 않음 (Object.hashCode 사용)
}
Employee e1 = new Employee(1, "Alice");
Employee e2 = new Employee(1, "Alice");
System.out.println(e1.equals(e2)); // true, id가 같으므로 동등
Set<Employee> set = new HashSet<>();
set.add(e1);
set.add(e2);
System.out.println(set.size()); // 2, 동등한 객체가 두 개 저장됨!
위 코드에서 e1.equals(e2)는 true지만, 서로 다른 해시코드를 가져 HashSet에는 두 개의 별도 원소로 저장되어 size()가 2가 됩니다. 따라서 equals()와 hashCode()는 항상 함께 재정의해야 하며, 논리적으로 동등한 객체는 동일한 해시코드를 반환하도록 구현해야 합니다. 이는 자바 컬렉션이 객체를 비교하고 버켓(bucket)을 찾는 데 equals()와 hashCode()를 모두 사용하기 때문입니다. (예: HashMap에서 키 비교 등)
요약하면, equals()와 hashCode()를 올바르게 구현하는 것은 객체의 논리적 동등성을 보장하고 컬렉션 등에서 예상한 대로 동작하도록 하는 데 필수적입니다. 이제 equals() 구현 시 흔히 고민하는 getClass() vs instanceof 타입 비교 방법의 차이를 살펴보겠습니다.
equals()를 오버라이드할 때 가장 먼저 하는 일 중 하나는 넘겨받은 객체의 타입이 자신과 호환되는지 확인하는 것입니다. 이를 체크하는 일반적인 두 가지 방법은 getClass()를 사용하는 방법과 instanceof 연산자를 사용하는 방법입니다. 두 방식 모두 널(null) 검사를 포함한 방어적 코드를 작성해야 하며, 그 외 구현 절차(내부 필드 비교 등)는 유사하지만, 상속 관계에서 작동 방식이 크게 달라집니다. 아래에서 각각의 구현 예제를 살펴보겠습니다.
먼저, getClass()를 이용하여 타입을 확인하는 equals() 구현을 보겠습니다. 일반적으로 IDE(Eclipse 등)의 자동 생성 기능이 이 방식을 사용하는 경우가 많습니다. getClass() 방식은 두 객체의 실제 클래스가 정확히 동일한 경우에만 동등하다고 판단합니다.
class Person {
private String name;
Person(String name) { this.name = name; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person other = (Person) o;
return Objects.equals(this.name, other.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
}
// Person을 상속받는 하위 클래스
class Developer extends Person {
private String job;
Developer(String name, String job) {
super(name);
this.job = job;
}
// equals(), hashCode()를 따로 오버라이드하지 않음
}
Person p = new Person("민수");
Person p2 = new Person("민수");
Developer d = new Developer("민수", "BE");
System.out.println(p.equals(p2)); // true, 같은 클래스 Person이고 name 필드도 동일
System.out.println(p.equals(d)); // false, Developer는 Person과 클래스가 다름
System.out.println(d.equals(p)); // false, Developer 입장에서도 Person은 클래스 불일치
위 예제에서 Person.equals()는 getClass()로 클래스 타입이 동일한지 확인하기 때문에, 설사 Developer 객체가 name 필드 값이 같더라도 Person과 Developer는 서로 다른 클래스로 인식되어 equals() 결과가 false가 됩니다. 즉, getClass()를 사용하면 하위 클래스 객체는 상위 클래스의 equals()에서 동등하다고 간주되지 않습니다. 상속 관계에 있는 두 객체를 비교할 때, 클래스 타입이 정확히 일치하지 않으면 무조건 다르다고 보는 것이죠.
이 접근의 장점은 구현이 단순하며, 예기치 않은 클래스 간 비교를 막아준다는 것입니다. Person 예시에서 Developer는 Person과 이름이 같아도 동등하지 않다고 판단하여, 상위 클래스와 하위 클래스가 섞여서 비교될 일을 원천 봉쇄합니다. 이는 equals의 대칭성을 지키는 데에도 안전한 선택입니다. (위에서 p.equals(d)와 d.equals(p)가 모두 false로 일치함) 하지만 이 방식은 경우에 따라 부작용도 있습니다. 바로, 상속을 통한 확장성을 고려할 때 유연하지 않다는 점입니다.
다음으로, instanceof를 이용한 구현 방식을 살펴보겠습니다. 이 방법은 전달된 객체가 자신과 같은 클래스이거나 상속관계의 하위 클래스인 경우에도 동등성 비교를 진행합니다. instanceof 연산자는 자체적으로 null 체크를 포함하므로 (피연산자가 null이면 false 반환) 별도의 null 검사를 생략할 수도 있습니다.
class Point {
private final int x;
private final int y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false; // o가 Point 또는 그 하위 클래스인지 체크
Point other = (Point) o;
return this.x == other.x && this.y == other.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
}
// Point를 상속받는 클래스 (색상 정보 추가)
class ColorPoint extends Point {
private final String color;
ColorPoint(int x, int y, String color) {
super(x, y);
this.color = color;
}
// equals와 hashCode를 정의하지 않고 Point로부터 상속받음
}
Point pt = new Point(3, 5);
ColorPoint cp = new ColorPoint(3, 5, "red");
System.out.println(pt.equals(cp)); // true, cp도 Point의 하위 클래스이며 x,y 값 동일
System.out.println(cp.equals(pt)); // true, cp는 Point의 equals를 상속받아 동일 동작
위 코드에서 Point.equals()는 instanceof를 사용하고 있기 때문에 ColorPoint는 Point의 하위 클래스임에도 불구하고 x, y 좌표 값이 같다면 두 객체를 동등하다고 간주합니다. ColorPoint 클래스는 별도로 equals()를 오버라이드하지 않았으므로 Point의 동등성 규칙을 그대로 따르게 됩니다. 그 결과 pt.equals(cp)와 cp.equals(pt)가 모두 true로 평가되어 대칭성에도 문제가 없습니다.
이처럼 instanceof를 사용한 방식은 상위 타입과 하위 타입을 포괄적으로 비교할 수 있다는 장점이 있습니다. 상속을 통해 확장된 객체도 기본 클래스의 주요 필드들이 같으면 동등하게 취급될 수 있기 때문에, 객체지향의 리스코프 치환 원칙(Liskov Substitution Principle, LSP)에 부합하는 면이 있습니다. LSP를 간단히 설명하면, 하위 타입 객체는 상위 타입으로 충분히 교체(substitute)할 수 있어야 한다는 원칙인데, equals() 관점에서 보면 "상위 클래스에서 중요한 특성이라면 하위 클래스에서도 유지되어야 한다"는 의미로 해석할 수 있습니다. 예를 들어, Point의 경우 좌표 (x, y) 값이 동등성의 기준인데, 하위 클래스인 ColorPoint도 좌표 값만으로 동등성을 판단하도록 허용한 것입니다. 실제로 자바의 많은 라이브러리나 프레임워크(예: Hibernate의 프록시 객체, Mockito 등의 Mock 객체)는 런타임에 바이트코드로 동적으로 하위 클래스를 생성하여 원본 객체를 대체하기도 하는데, 이때 equals()가 getClass()만 사용한다면 프록시 객체와 원본 객체가 아무리 상태가 같아도 동등하다고 인식되지 않는 문제가 생길 수 있습니다. 따라서 이러한 경우 instanceof를 사용하는 쪽이 더 유리합니다.
그러나 instanceof 방식에도 주의할 점이 있습니다. 만약 하위 클래스가 상위 클래스에 없는 새로운 필드를 추가하고, 이를 고려하도록 equals()를 오버라이드한다면 자칫 잘못하여 대칭성이나 추이성 위반이 발생할 수 있습니다. 고전적인 예로, Point와 ColorPoint의 경우를 생각해보죠. 앞서 ColorPoint는 equals()를 오버라이드하지 않았기에 문제가 없었지만, 만약 ColorPoint.equals()를 오버라이드하여 color 값까지 비교하도록 수정하면 어떤 일이 벌어질까요?
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ColorPoint)) {
// ColorPoint가 아닌 객체와 비교 시, Point의 equals로 위임
return super.equals(o);
}
ColorPoint other = (ColorPoint) o;
return super.equals(o) && Objects.equals(this.color, other.color);
}
위와 같이 ColorPoint.equals()를 정의했다고 가정해봅시다. 이제 다음과 같은 객체가 있을 때:
Point p = new Point(3, 5);
ColorPoint red = new ColorPoint(3, 5, "red");
ColorPoint blue = new ColorPoint(3, 5, "blue");
이 상황을 살펴보면 p.equals(red)와 p.equals(blue)가 둘 다 true이고 red.equals(blue)는 false여서, 추이성이 깨지게 됩니다. 즉, A equals B, A equals C지만 B not equals C인 모순이 발생한 것이죠. 이런 경우 equals() 일반 규약에 어긋나며, 논리적으로도 혼란을 야기합니다. 이 예는 상속된 클래스에서 equals를 확장(overload)하려 할 때 겪는 대표적인 딜레마로, Effective Java 책에서도 소개된 내용입니다. 조슈아 블로크(Joshua Bloch)는 이를 해결하기 위해 equals()를 재정의할 때는 instanceof를 사용하고, 대신 클래스 자체를 상속할 수 없도록 final로 만들거나, equals 메서드를 final로 선언하여 하위 클래스에서 변경하지 못하게 하는 방법을 권장합니다.
클래스가 final이면 애초에 하위 클래스를 만들 수 없으므로 instanceof로 비교해도 문제가 없습니다. 반면, 하위 클래스를 열어두고 싶다면 차라리 getClass()를 사용하는 편이 상속 구조에서의 복잡한 문제를 회피하는 안전한 길일 수 있습니다.
정리하면, instanceof 방식은 보다 유연하고 객체지향적이지만, 상속 구조에서 추가된 필드까지 완벽히 포함하는 동등성 일관성을 유지하기 어려울 수 있습니다. 반면 getClass() 방식은 엄격하지만 동등성의 경계를 명확히 하고, 예기치 않은 상속 간 비교를 차단하여 규약 위반을 예방합니다.
앞서 살펴본 두 방식 getClass()와 instanceof에는 각기 장단점이 있으므로, 클래스의 용도와 설계 의도에 따라 적절한 방법을 선택하는 것이 중요합니다. 주요 차이점을 정리하고, 실무에서의 가이드라인을 정리해보겠습니다.
getClass() 사용 시: 동일한 구체 클래스끼리만 동등하다고 판단합니다. 상속받은 하위 클래스 객체는 상위 클래스와 절대 equal이 될 수 없습니다.
instanceof 사용 시: 현재 클래스의 하위 타입까지 포함하여 동등성을 비교합니다. 즉, 어느 정도의 다형성을 허용합니다.
getClass(): 상속 구조에서 상위 클래스와 하위 클래스의 동등성 개념이 다르거나, 하위 클래스에서 동등성 비교 방식을 변경할 여지가 있는 경우 유리합니다. 하위 클래스가 상위의 equals 규약을 따르기 어려운 상황이라면 아예 상위와 동등성을 분리해버리는 것이죠. 이로써 예기치 않은 대칭성/추이성 문제를 피할 수 있습니다.
instanceof: 클래스가 상속될 수 있지만 추가 상태가 동등성에 영향을 주지 않거나, 하위 클래스를 포함해 동일한 논리적 동등성을 공유하게 설계한 경우에 적합합니다. 예를 들어, JPA/Hibernate 엔티티처럼 프록시 객체와도 동등 비교가 필요하다면 자연스럽게 선택됩니다. 또한 클래스 자체를 final로 선언한 경우에는 하위 클래스가 생길 수 없으므로 instanceof를 안심하고 써도 됩니다.
getClass(): 구현이 약간 더 간결할 수 있습니다. instanceof와 큰 차이는 없지만, IDE 자동 생성의 기본값인 경우가 많아 팀 컨벤션에 따라 그대로 사용하기도 합니다. 다만, 이 방식으로 equals를 만들었다면 하위 클래스에서 equals를 오버라이드하지 않는 한, 하위 클래스는 절대 상위 클래스와 equal이 되지 않음을 기억해야 합니다.
instanceof: null 체크를 별도로 하지 않아도 되는 편의성이 있고, 한 번의 검사로 하위 타입까지 커버할 수 있습니다. 그러나 하위 클래스가 equals를 오버라이드할 경우 super.equals() 호출이나 비교 로직을 매우 신중히 작성해야 합니다. 필요한 경우 equals 구현을 상속 못 하게 final로 선언하는 것도 고려합니다.
IDE/도구 설정: 이클립스(Eclipse)는 기본적으로 getClass() 기반 equals/hashCode를 생성하고, 인텔리제이(IntelliJ) IDEA는 instanceof 기반으로 생성하는 등 도구마다 다릅니다. 팀원들과 미리 합의하여 일관된 방식을 사용하는 것이 좋습니다. 또한 Lombok의 @EqualsAndHashCode 등을 사용할 때도 기본 동작(하위 클래스 포함 여부)을 확인해야 합니다.
컬렉션 및 프레임워크: equals와 hashCode는 컬렉션의 키 비교, 포함 여부 확인 등에 영향을 주므로, 일관성이 깨지지 않도록 해야 합니다. 예를 들어, Hibernate를 사용한다면 엔티티의 equals에서 getClass()를 쓰면 프록시 객체와 원본 엔티티가 다르게 간주되어 LazyInitialization이나 캐싱 로직에 문제를 일으킬 수 있습니다. 이런 경우 instanceof 기반으로 구현하거나, 아예 ID만으로 동등성을 판단하는 별도의 전략을 쓰기도 합니다.
불변(Immutable) 및 final 클래스: 불변 객체나 상속이 필요 없는 값 객체(VO)라면 클래스를 final로 선언하고 instanceof로 equals를 구현하는 방식을 추천합니다. 클래스가 final이면 상속으로 인한 equals 문제를 걱정할 필요 없이 자유롭게 설계할 수 있고, instanceof로 충분히 구현이 가능합니다.
가독성과 유지보수: equals()와 hashCode()는 구현을 잘못하면 디버깅이 어려운 버그를 낳습니다. 가령, equals의 구현 의도가 명확히 드러나야 하고, instanceof vs getClass 중 무엇을 썼는지에 따라 동작이 완전히 달라지므로 코드 리뷰 시 주의 깊게 살펴야 합니다. 필요하다면 주석으로 해당 클래스의 equals 설계 의도를 설명해두는 것도 좋습니다. (예: “이 클래스는 하위 클래스와 동등성 비교를 허용하지 않기 위해 getClass 사용”).
마지막으로, equals()를 오버라이드할 때는 반드시 hashCode()도 같이 오버라이드해야 한다는 점을 다시 한 번 강조합니다. 둘 중 하나만 잘못 구현되어도 컬렉션에서 의도치 않은 동작이 발생할 수 있으며, 이는 찾기 어려운 버그로 이어집니다. 따라서 IDE를 통해 생성하든 손수 작성하든, 항상 두 메서드를 쌍으로 구현하세요. 그리고 구현 후에는 equals의 대칭성, 추이성, 일관성이 깨지는 시나리오가 없는지 여러 객체를 만들어 테스트해보는 것이 좋습니다.
요약하면, 자바에서 equals() 메서드를 구현할 때 getClass()와 instanceof는 객체 타입 비교를 위한 두 가지 선택지이며, 상황에 따라 적절한 방법을 선택해야 합니다. getClass()를 사용하면 클래스가 정확히 동일한 경우만 동등하다고 판단하여 보수적인 대신 안전한 접근을 제공하며, instanceof를 사용하면 상속 관계까지 포괄하여 유연한 동등성 비교가 가능하지만 그만큼 면밀한 설계가 필요합니다.
두 방식 중 어느 것이 “정답”이라고 단정할 수는 없습니다. 결국 중요한 것은 해당 클래스/계층의 의도한 동등성이 무엇인지입니다. 만약 하위 클래스까지 같은 논리로 비교되는 것이 자연스럽다면 instanceof를 고려하고, 하위 클래스가 별개의 동등성 정의를 가져야 한다면 getClass()로 엄격히 구분하는 편이 나을 것입니다. 어느 쪽이든 equals() 구현 시 위에서 설명한 일반 규약을 따르고, 반드시 hashCode()를 일관되게 구현해주는 것을 잊지 마세요. 이러한 기본기를 지켜야 컬렉션이나 프레임워크 사용 시 예기치 않은 버그 없이 안정적인 코드를 작성할 수 있습니다.
배운 내용을 토대로, 여러분의 클래스에 가장 알맞은 equals() 구현 방식을 선택하고 적용해 보세요. 🙂