Java에서 equals()와 hashCode()는 가장 많이 사용되지만 동시에 가장 많이 오해되는 메서드이다.
HashMap, HashSet 등 자료구조 동작부터 JPA 엔티티 비교까지 거의 모든 영역에서 핵심적인 역할을 맡는다.
이 문서는 단순 문법을 넘어서 JDK 내부 동작, Contract, 자료구조별 비교 전략, Collection에서의 equals/hashCode 동작까지
심화 수준으로 완성 정리하였다.
Object의 기본 equals 구현은 다음과 같다.
public boolean equals(Object obj) {
return (this == obj);
}
즉 기본 비교는 동일성(identity) 비교이다. 즉, 주소 비교를 수행한다. 그러나 대부분의 도메인 객체에서는 논리적 동등성(logical equality) 비교가 필요하기 때문에 equals를 재정의한다. 여기서 헷갈리는 부분이 String 등의 Wrapper 클래스 == 연산의 경우 false가 발생한다. Java에서 모든 객체는 Object를 상속받으므로 내부 연산이 == 로 되어 있을 것이라고 착각할 수 있다.
// String에 정의된 equals @Override public boolean equals(Object anObject) { // 1) 동일성(identity) 체크: 같은 객체면 바로 true 반환 (빠른 경로) if (this == anObject) { return true; }// 2) 타입 확인: anObject가 String인지 확인 if (anObject instanceof String aString) { // 3) compact string 여부와 인코딩(coder) 비교 if (!COMPACT_STRINGS || this.coder == aString.coder) { // 4) 실제 문자 내용 비교 return StringLatin1.equals(this.value, aString.value); } } // String이 아니거나 내용이 다르면 false return false;
}
// StringLatin1에 정의된 equals
@IntrinsicCandidate
public static boolean equals(byte[] value, byte[] other) {
// 길이가 다르면 바로 false
if (value.length != other.length) {
return false;
}
// 길이가 같으면 각 문자(byte) 비교
for (int i = 0; i < value.length; i++) {
if (value[i] != other[i]) {
return false;
}
}
// 모든 문자가 동일하면 true
return true;
}
문자 비교를 통하여 String의 equals가 정의되어 있음을 확인할 수 있다.
equals는 다음 5가지 계약을 반드시 만족해야 한다.
x.equals(x)는 항상 truex.equals(y)라면 y.equals(x)도 truex.equals(null)은 false이 계약을 어기면 HashSet, HashMap, TreeSet 등에서 예측 불가능한 버그가 발생한다.
hashCode()는 객체의 해시 버킷 위치를 결정하는 정수 값을 반환한다.
Hash 기반 컬렉션의 검색 흐름은 다음과 같다:
hashCode()로 버킷 선택equals()로 최종 객체 비교즉 equals가 호출되기 위해서는 우선 hashCode가 동일해야 한다.
다음 예제를 보자.
Set<Person> set = new HashSet<>();
set.add(new Person("Kim", 30));
System.out.println(set.contains(new Person("Kim", 30)));
결과 → false
즉 equals만 재정의하고 hashCode를 재정의하지 않으면 HashSet/HashMap은 완전히 비정상적으로 동작한다.
@Override
public boolean equals(Object o) {
Person p = (Person) o;
return name.equals(p.name) && age == p.age;
}
// hashCode는 기본값 (Object)
→ HashSet, HashMap에서 key로 동작하지 않음 → contains(), remove()가 항상 실패
class Person {
String name;
int age; // setter 있음
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
HashSet에 넣은 후 age가 바뀌면? hashCode가 바뀌어 원래 버킷을 찾을 수 없어 컬렉션이 사실상 깨진다.
class CaseInsensitiveString {
private String s;
@Override
public boolean equals(Object o) {
if (o instanceof String) {
return s.equalsIgnoreCase((String) o);
}
if (o instanceof CaseInsensitiveString) {
return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
}
return false;
}
}
문제 예시:
new CaseInsensitiveString("abc").equals("ABC") → true
"ABC".equals(new CaseInsensitiveString("abc")) → false
→ 대칭성 위반으로 컬렉션에서 혼란 발생
@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);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
return id != null && id.equals(((Entity)o).id);
}
@Override
public int hashCode() {
return 31;
}
식별자 기반 비교 + hashCode 안정성 확보
| 자료구조 | hashCode 사용? | equals 사용? | 설명 |
|---|---|---|---|
| HashSet | ✔ | ✔ | 중복 판단 핵심 |
| HashMap (key) | ✔ | ✔ | 검색/삭제 핵심 |
| TreeSet | ✘ | 간접적 | compareTo 기반 |
| TreeMap | ✘ | 간접적 | Key 정렬 기반 |
| ArrayList | ✘ | ✔ | equals로 contains 수행 |
== 비교equals()와 hashCode()는 단순한 두 메서드가 아니라
Java 객체 모델의 철학과 자료구조의 핵심이다.