1. 소개
객체를 어떻게 비교할까
Java에서 객체는 단순한 데이터가 아니라 각자 메모리상의 고유한 주소를 가집니다. 연산자 ==는 “같은 상자냐?”라는 질문에 답하며, 즉 참조(주소)를 비교하지 내용물을 비교하지는 않습니다. 메서드 equals는 내용물 비교를 위해 존재합니다. 기본적으로 재정의하지 않으면 equals는 ==처럼 동작합니다.
Person p1 = new Person("이반", 20);
Person p2 = new Person("이반", 20);
System.out.println(p1 == p2); // false — 서로 다른 메모리에 있는 객체!
두 서로 다른 객체를 데이터 기준으로(예: 모든 의미 있는 필드가 일치) 같다고 보려면 equals를 재정의해야 합니다. 그리고 해시 기반 컬렉션을 사용할 계획이라면 반드시 함께 hashCode도 올바르게 재정의해야 합니다.
class Person {
String name;
int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true; // 같은 객체인지 확인
if (o == null || getClass() != o.getClass()) return false; // 클래스 확인
Person person = (Person) o; // 형변환
return age == person.age && name.equals(person.name); // 필드 비교
}
@Override
public int hashCode() {
return Objects.hash(name, age); // HashSet/HashMap에서 올바르게 동작하도록
}
}
이러한 재정의는 HashSet/HashMap과 올바르게 함께 사용하기 위해 매우 중요합니다. equals와 hashCode가 없으면, 데이터가 같아도 컬렉션은 서로 다른 객체로 간주합니다.
equals에서의 동치 관계는 요구사항에 따라 달라질 수 있습니다. 모든 필드로 비교할 수도 있고 일부 필드만(예: User의 email) 비교할 수도 있습니다 — 중요한 것은 일관성과 규약 준수입니다.
어디에서 특히 중요할까요?
- 해시 테이블 기반 컬렉션: HashSet, HashMap, LinkedHashSet 등
- 컬렉션에서의 검색과 삭제: 올바른 equals가 없으면 원하는 객체가 “찾아지지 않을” 수 있습니다.
- 비즈니스 로직: 예를 들어 email이 같은 두 User는 하나의 사용자로 간주되어야 합니다.
hashCode — 왜 필요한가?
해시 기반 컬렉션(예: HashSet, HashMap)은 해시 테이블을 사용합니다. 메서드 hashCode는 객체가 들어갈 “버킷 주소”에 해당하는 정수를 계산합니다. 두 객체가 equals로 같다면, 그들의 hashCode도 반드시 같아야 합니다. 이 규칙을 어기면 컬렉션이 예측 불가능하게 동작합니다.
2. equals와 hashCode 규약
equals 규약
equals의 기본 요구사항:
- 반사성: a.equals(a)는 항상 true.
- 대칭성: a.equals(b)가 true이면 b.equals(a)도 true.
- 추이성: a.equals(b)이고 b.equals(c)이면, a.equals(c)도 true.
- 일관성: 객체가 변하지 않는 한 결과는 안정적이어야 함.
- null과의 비교: 어떤 객체도 null과 같지 않음.
hashCode 규약
- 두 객체가 equals로 같다면, 그들의 hashCode는 같다.
- 같지 않은 객체의 해시 코드는 일치할 수도 있다(충돌은 허용되지만 바람직하지 않음).
- 객체가 논리적으로 변하지 않는 한 hashCode는 일정해야 한다.
즉, 동일한 hashCode는 동등성의 필요조건일 뿐 충분조건은 아닙니다. 같은 해시가 나와도 equals로 같다고 보장되지는 않습니다.
3. equals와 hashCode 구현: 예시
Person 클래스에서 동등성을 name과 age 필드로 정의해 보겠습니다.
public class Person {
private String name;
private int age;
// 생성자, getter, setter...
@Override
public boolean equals(Object o) {
if (this == o) return true; // 참조 비교
if (o == null || getClass() != o.getClass()) return false; // 클래스 확인
Person person = (Person) o; // 형변환
// 필드 비교
return age == person.age &&
(name != null ? name.equals(person.name) : person.name == null);
}
@Override
public int hashCode() {
int result = name != null ? name.hashCode() : 0;
result = 31 * result + age; // 31 — 흔히 쓰이는 소수 선택값
return result;
}
}
- 먼저 빠른 검사: 참조와 클래스.
- 그다음 의미 있는 필드 비교.
- hashCode에서는 충돌을 줄이기 위해 소수 31을 사용합니다.
Objects.equals와 Objects.hash의 사용
Java 7부터 Objects 클래스가 코드를 단순화하고 null에 안전하도록 도와줍니다:
import java.util.Objects;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age &&
Objects.equals(name, person.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
4. equals, hashCode, compareTo: 어떻게 연관되는가
equals와 compareTo는 어떻게 연결되는가?
인터페이스 Comparable은 “작다/같다/크다”를 위해 음수/0/양수를 반환하는 compareTo를 정의합니다. 가능하면 a.compareTo(b) == 0이면 a.equals(b)도 성립하도록 하는 것이 바람직합니다. 그 역은 필수는 아닙니다.
정합성이 깨지면(예: compareTo는 나이만 비교하고 equals는 이름과 나이를 비교하는 경우) TreeSet/TreeMap 같은 정렬 컬렉션이 예상 밖으로 동작할 수 있습니다. 순서 관점에서는 “같은” 객체로 보이지만 내용 측면에서는 같지 않게 됩니다.
컬렉션에서의 equals와 hashCode
- HashSet과 HashMap에서 추가/검색/삭제는 올바른 equals와 hashCode 구현에 의존합니다.
- 재정의가 없으면 데이터가 같아도 이들 컬렉션은 객체를 “서로 다르다”고 판단합니다.
5. 예제: 컬렉션에서 어떻게 동작하는가
HashSet: 유일 객체 저장
Set<Person> people = new HashSet<>();
people.add(new Person("이반", 20));
people.add(new Person("이반", 20)); // 중복
System.out.println(people.size()); // equals/hashCode가 올바르게 구현되었다면 1
올바른 equals/hashCode가 없으면 두 객체 모두 집합에 저장됩니다.
HashMap: 키로 검색
Map<Person, String> map = new HashMap<>();
Person p1 = new Person("안나", 25);
Person p2 = new Person("안나", 25);
map.put(p1, "사용자 1");
System.out.println(map.get(p2)); // "사용자 1" — equals/hashCode가 올바르게 구현되었다면
규약을 지키지 않으면 컬렉션은 null을 반환합니다 — 컬렉션 입장에서는 “서로 다른” 키이기 때문입니다.
6. Best practices: 구현 가이드
- equals/hashCode에는 객체의 “정체성”을 결정하는 모든 필드를 포함하세요.
- hashCode 계산에 컬렉션에 추가된 후 변경될 수 있는 가변 필드를 사용하지 마세요.
- IDE에 메서드 생성을 맡기면 오타 가능성이 줄어듭니다.
- equals에서는 먼저 this == o를 확인하고, 그다음 클래스, 그다음 필드를 비교하세요.
- 객체 필드 비교에는 Objects.equals를 사용하세요.
- 해시 코드는 Objects.hash 또는 검증된 31 배수 패턴을 사용하세요.
7. 유용한 뉘앙스
왜 hashCode만 사용하면 안 될까?
충돌은 피할 수 없습니다. 서로 다른 객체가 같은 hashCode를 가질 수 있습니다. 해시는 버킷을 빠르게 좁히기 위한 힌트일 뿐이며, 최종 동등성 판정은 equals가 담당합니다.
equals와 hashCode를 재정의하지 않아도 될까?
객체가 내용 기준으로 비교되지 않으며, 컬렉션의 키나 유일 원소로도 절대 쓰이지 않는다는 확신이 있을 때만 가능합니다. 실무에서는 드문 경우입니다.
==, equals, compareTo의 차이
| 연산자/메서드 | 무엇을 비교하나? | 무엇을 위해 쓰나? |
|---|---|---|
|
참조(메모리 주소) | “같은 객체인가?” 확인 |
|
객체의 내용 | 비즈니스 로직 기준 동등성 |
|
순서(작다/같다/크다) | 정렬, 순서화 |
8. equals와 hashCode 구현 시 흔한 실수
오류 №1: equals는 재정의했는데 hashCode를 잊음. 객체는 같다고 판단되지만 해시 테이블의 서로 다른 버킷에 들어가 검색과 삭제가 깨집니다.
오류 №2: hashCode에 가변 필드를 사용함. 컬렉션에 넣은 뒤 필드가 바뀌면 객체가 “사라집니다”. 해시는 바뀌지만 버킷은 바뀌지 않기 때문입니다.
오류 №3: equals의 대칭성/추이성이 깨짐. a.equals(b)는 true인데 b.equals(a)는 false이거나, 추이성이 깨지면 컬렉션이 예측 불가능하게 동작합니다.
오류 №4: equals에서 클래스를 확인하지 않음. 서로 다른 클래스의 객체를 비교해 잘못된 결과나 예외가 발생합니다.
오류 №5: 문자열과 객체를 ==로 비교함. 연산자 ==는 참조를 비교하므로, 내용 비교에는 equals를 사용해야 합니다.
오류 №6: compareTo와 equals의 정합성 부족. a.compareTo(b) == 0인데 !a.equals(b)이면, TreeSet/TreeMap은 순서 관점에서 같은 요소로 보지만 동등성 관점에서는 다르게 보아 “유령”과 중복의 원인이 됩니다.
GO TO FULL VERSION