Java equals()와 hashCode()를 이해하기 feat. Lombok @EqualsAndHashCode
이 글에서 다루는 내용
Lombok의 @EqualsAndHashCode이 생성하는
equals()와hashCode()가 왜 필요한지, Lombok이 내부적으로 어떻게 코드를 생성하는지 살펴본다. 나아가 HashMap의 실제 내부 구조와 해시 충돌 동작 방식,hashCode()를 구현하지 않았을 때 발생하는 문제까지 다룬다.
@EqualsAndHashCode(callSuper = false)
Lombok의 @EqualsAndHashCode 어노테이션은 equals()와 hashCode() 메서드를 자동으로 생성해준다.
callSuper = false 옵션은 부모 클래스의 필드를 제외하고 현재 클래스의 필드만으로 두 메서드를 생성하라는 의미다.
@EqualsAndHashCode(callSuper = false)
public class Child extends Parent {
private String name;
private int age;
// name, age 필드만으로 equals/hashCode 생성
// Parent의 필드는 무시됨
}
| 옵션 | 동작 |
|---|---|
callSuper = false (기본값) |
현재 클래스 필드만 비교 |
callSuper = true |
부모 클래스의 equals()/hashCode()도 호출해서 함께 비교 |
JPA Entity에서 BaseEntity 같은 공통 부모가 있을 때 createdAt, updatedAt 같은 부모 필드를 동등성 비교에서 제외하려고 callSuper = false를 명시적으로 쓰는 경우가 많다.
@Entity
@EqualsAndHashCode(callSuper = false) // BaseEntity의 createdAt 등 제외
public class User extends BaseEntity {
private String email;
}
equals()와 hashCode()가 필요한 이유
기본 동작 이해
Java의 모든 객체는 Object 클래스를 상속받는데, 기본 equals()는 주소값(참조) 비교를 한다.
User a = new User("kim", 20);
User b = new User("kim", 20);
a == b // false (주소가 다름)
a.equals(b) // false (기본 equals도 주소 비교)
같은 데이터인데 다르다고 판단하는 문제가 생긴다.
equals()가 필요한 이유
"값이 같으면 같은 객체" 로 취급하고 싶을 때 필요하다.
@EqualsAndHashCode
public class User {
private String email;
private int age;
}
User a = new User("kim@test.com", 20);
User b = new User("kim@test.com", 20);
a.equals(b) // true ✅
hashCode()가 필요한 이유
HashMap, HashSet 같은 Hash 기반 자료구조에서 객체를 올바르게 찾기 위해 필요하다.
Hash 자료구조의 동작 순서는 다음과 같다.
hashCode()로 버킷 위치 결정- 해당 버킷에서
equals()로 최종 비교
Set<User> set = new HashSet<>();
set.add(new User("kim@test.com", 20));
// hashCode 안 오버라이딩 시
set.contains(new User("kim@test.com", 20)); // false ❌ (버킷을 못 찾음)
// hashCode 오버라이딩 시
set.contains(new User("kim@test.com", 20)); // true ✅
둘을 항상 같이 오버라이딩해야 하는 이유
Java 스펙 규칙이다.
equals()가 true면,hashCode()도 반드시 같아야 한다.
// ❌ 잘못된 케이스: equals만 오버라이딩
a.equals(b) // true
a.hashCode() // 12345 (주소 기반)
b.hashCode() // 67890 (주소 기반) → HashMap에서 못 찾음!
| 상황 | equals | hashCode |
|---|---|---|
| 둘 다 오버라이딩 ✅ | 값 비교 | 값 기반 해시 |
| equals만 ❌ | 값 비교 | 주소 기반 → Hash 자료구조 오동작 |
| hashCode만 ❌ | 주소 비교 → 의미 없음 | 값 기반 해시 |
Lombok은 어떻게 자동으로 구현하는가
동작 원리
Lombok은 런타임이 아닌 컴파일 타임에 동작한다. Java 컴파일러의 Annotation Processing 기능을 활용해 AST(추상 구문 트리)를 직접 조작한다.
소스코드 (.java)
↓
javac 실행
↓
Annotation Processor 개입 (Lombok)
↓
AST(추상 구문 트리) 직접 조작
↓
바이트코드 생성 (.class)
일반 라이브러리는 바이트코드에 코드를 추가하지만, Lombok은 컴파일 전 AST 단계에서 개입하기 때문에 마치 직접 작성한 코드처럼 자연스럽게 동작한다.
실제로 생성되는 코드
@EqualsAndHashCode
public class User {
private String email;
private int age;
}
컴파일 후 실제로는 이렇게 변환된다.
public class User {
private String email;
private int age;
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof User)) return false;
final User other = (User) o;
if (!other.canEqual((Object) this)) return false;
final Object this$email = this.email;
final Object other$email = other.email;
if (this$email == null ? other$email != null : !this$email.equals(other$email))
return false;
if (this.age != other.age)
return false;
return true;
}
@Override
public int hashCode() {
final int PRIME = 59;
int result = 1;
final Object $email = this.email;
result = result * PRIME + ($email == null ? 43 : $email.hashCode());
result = result * PRIME + this.age;
return result;
}
protected boolean canEqual(Object other) {
return other instanceof User;
}
}
직접 확인하는 법
IntelliJ에서 Delombok 기능을 쓰면 Lombok이 생성할 코드를 미리 볼 수 있다.
우클릭 → Refactor → Delombok → @EqualsAndHashCode
hashCode에서 59와 43은 왜 쓰는가
59 - 곱셈에 쓰는 소수 (multiplier)
result = result * 59 + nextValue;
해시를 계산할 때 각 필드를 섞는 역할이다. 짝수(2)를 쓰면 비트 시프트와 같아서 하위 비트가 0으로 채워지는 정보 손실이 생기지만, 소수(59)를 쓰면 비트가 골고루 퍼진다.
Java String.hashCode()는 31을 사용한다. Lombok은 59를 선택했는데, 둘 다 소수면 충돌 최소화 효과는 비슷하다.
43 - null일 때 대체값
result = result * 59 + ($email == null ? 43 : $email.hashCode());
null은 hashCode()를 호출할 수 없으니 고정값이 필요하다. 0을 쓰면 null 필드 조합끼리 충돌 가능성이 높아지기 때문에, 0이 아닌 소수 43을 써서 구분이 잘 되게 한다.
| 값 | 용도 | 이유 |
|---|---|---|
| 59 | 필드 섞는 곱셈 multiplier | 소수라서 비트 충돌 최소화 |
| 43 | null 필드 대체값 | 0 대신 소수 써서 null 조합 간 충돌 방지 |
정확한 값 자체가 중요한 건 아니다. 소수면 다 비슷한 효과를 낸다.
HashMap은 Key-Value가 1:1 구조가 아니다
많은 사람들이 HashMap을 이렇게 생각한다.
버킷[0] → (key, value)
버킷[1] → (key, value)
버킷[2] → (key, value)
실제로는 이렇다.
버킷[0] → null
버킷[1] → (key1, value1) → (key2, value2) → (key3, value3)
버킷[2] → (key4, value4)
버킷[3] → null
HashMap은 Node 배열 + 체이닝 구조다.
// HashMap 내부 코드 (실제)
transient Node<K,V>[] table; // 버킷 배열
static class Node<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next; // ← 다음 노드 포인터 (연결리스트!)
}
get() 동작은 다음과 같다.
map.get("Aa")
// 1. "Aa".hashCode() = 2112 → 버킷 위치 계산
// 2. 버킷의 연결리스트 순회
// (key1, val1) → equals("Aa")? No
// (key2, val2) → equals("Aa")? Yes → 반환!
결국 hashCode = 버킷 찾기 / equals = 정확한 항목 찾기 이 두 개가 항상 같이 필요한 이유가 여기에 있다.
해시 충돌은 반드시 발생한다
수학적으로 피할 수 없다.
hashCode() 반환 타입: int → 약 42억 가지 경우의 수
실제 객체 수: 무한대
비둘기집 원리: 42억 개 이상의 객체가 있으면 반드시 충돌
충돌 시 HashMap 동작
Java 7 이하 - 연결 리스트
버킷[7]: aaa → bbb → ccc → ddd → ...
↑
충돌 많을수록 탐색이 O(n)으로 느려짐
Java 8 이상 - 트리로 전환
같은 버킷에 8개 이상 쌓이면 자동으로 Red-Black Tree로 전환해 O(log n)을 보장한다.
실제 충돌 케이스
"Aa".hashCode() // 2112
"BB".hashCode() // 2112 ← 같음!
"AaAa".hashCode() // 2031744
"BBBB".hashCode() // 2031744 ← 같음!
"AaBB".hashCode() // 2031744 ← 같음!
이런 충돌 키를 의도적으로 대량 삽입하면 HashDoS 공격이 가능하다.
충돌이 발생해도 정확성은 보장된다
충돌 발생 시 → 정확성 문제 ❌, 성능 문제 ✅
hashCode로 버킷을 찾고, 버킷 안에서 equals로 최종 확인하기 때문에 데이터를 잘못 반환하지는 않는다.
| 상황 | 결과 |
|---|---|
| 충돌 없음 | O(1) 탐색 |
| 충돌 조금 | O(n) 탐색 (연결리스트) |
| 충돌 많음 (Java 8+) | O(log n) 탐색 (트리) |
| 충돌 폭발 (공격) | 서버 성능 마비 |
그래서 좋은 hashCode = 값이 고르게 분산되는 것이 목표다.
equals()만 구현하고 hashCode()를 구현하지 않으면
public class User {
private String email;
private int age;
@Override
public boolean equals(Object o) {
if (!(o instanceof User)) return false;
User other = (User) o;
return email.equals(other.email) && age == other.age;
}
// hashCode 오버라이딩 안 함 → Object 기본 hashCode 사용 (주소 기반)
}
HashMap에서 get()이 null을 반환한다
User a = new User("kim@test.com", 20);
User b = new User("kim@test.com", 20);
Map<User, String> map = new HashMap<>();
map.put(a, "user1");
map.get(b); // null ❌
내부 동작을 보면:
map.put(a)
→ a.hashCode() = 12345 → 버킷[3]에 저장
map.get(b)
→ b.hashCode() = 67890 → 버킷[9] 탐색
→ 버킷[9]는 비어있음 → null 반환
→ equals() 호출조차 안 됨!
HashSet에서 중복 제거가 실패한다
Set<User> set = new HashSet<>();
set.add(new User("kim@test.com", 20));
set.add(new User("kim@test.com", 20));
set.add(new User("kim@test.com", 20));
set.size(); // 3 ❌ (1이어야 하는데!)
HashSet은 hashCode로 버킷을 먼저 찾고, equals로 중복을 확인한다. hashCode가 다르면 버킷 자체가 달라서 equals 호출 없이 그냥 다른 항목으로 저장해버린다.
| 자료구조 | 증상 |
|---|---|
HashMap |
get()이 항상 null 반환 |
HashSet |
중복 제거 안 됨 |
Hashtable |
동일 증상 |
LinkedHashMap |
동일 증상 |
가장 무서운 건 컴파일 에러도, 런타임 에러도 안 나고 그냥 조용히 잘못 동작한다는 점이다. 그래서 Lombok의 @EqualsAndHashCode가 둘을 묶어서 처리하는 것이 실수 방지에 효과적이다.
callSuper = true가 필요한 이유
같은 자식 필드, 다른 부모 필드인 경우
public class BaseEntity {
private Long id; // DB PK
}
public class User extends BaseEntity {
private String name;
}
User a = new User();
a.setId(1L);
a.setName("kim");
User b = new User();
b.setId(2L); // ← id가 다름!
b.setName("kim");
callSuper = false이면:
a.equals(b) → true ❌
// name만 비교하니까 같다고 판단
// id가 1이든 2든 다른 사람인데 같은 사람 취급
callSuper = true이면:
a.equals(b) → false ✅
// 부모의 id까지 비교하니까 다르다고 판단
결국 도메인 의미의 차이
| 상황 | 옵션 |
|---|---|
| DB Entity - id가 다르면 다른 객체 | callSuper = true |
| DTO / VO - 비즈니스 값만 같으면 같은 객체 | callSuper = false |
| BaseEntity의 createdAt 등은 비교 불필요 | callSuper = false |
JPA Entity에서 주의할 점
아이러니하게도 JPA Entity는 보통 callSuper = false를 쓴다.
// BaseEntity에 id가 있는 경우
// 새로 생성된 Entity는 id가 null → 비교 자체가 의미 없음
User newUser = new User("kim");
newUser.getId(); // null (아직 저장 안 됨)
JPA Entity의 동등성은 id로만 비교하는 별도 로직을 쓰거나, 아예 @EqualsAndHashCode 자체를 쓰지 않는 게 권장되기도 한다.
핵심은 "부모 클래스의 필드가 동등성 판단에 의미가 있느냐" 로 결정하면 된다.