이 글에서 다루는 내용

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 자료구조의 동작 순서는 다음과 같다.

  1. hashCode()로 버킷 위치 결정
  2. 해당 버킷에서 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());

nullhashCode()를 호출할 수 없으니 고정값이 필요하다. 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 자체를 쓰지 않는 게 권장되기도 한다.

핵심은 "부모 클래스의 필드가 동등성 판단에 의미가 있느냐" 로 결정하면 된다.