[Java] hashCode와 equals의 중요성

Inung_92·2025년 8월 5일
1

JAVA

목록 보기
16/16
post-thumbnail

🚀 목적

객체 비교, Collection, Hash 기반 자료구조에서 핵심적인 역할을 하는 hashCode와 equals에 대해서 알아보고, 제대로 사용하지 못할 경우에 발생할 수 있는 문제와 어떤 방식을 통해 효과적이고, 올바르게 사용할 수 있는지 알아봅니다.


💻 hashCode와 equals

hashCode

해싱 기법에 사용되는 해시함수(hash function)를 구현한 것으로 해시 테이블 기반 자료구조에서 다량의 데이터를 저장하고 효율적으로 검색하는데 유용합니다.

해시 함수

해시 함수의 핵심 원리는 임의의 크기의 데이터를 고정된 크기의 값으로 매핑하는 것으로 Java에서 hashCode()는 32 bit 정수를 반환하고, 해시 테이블의 bucket index를 결정하는데 사용됩니다.
int index = Math.abs(object.hashCode()) % bucketSize;
좋은 해시 함수의 특성

  • 균등 분포(Uniform Distribution) : 해시 값이 가능한 범위에 고르게 분포
  • 결정적(Deterministic) : 같은 입력에 대해 항상 같은 출력
  • 효율성(Efficiency) : 빠른 계산 속도

특징

  • 해시코드가 같은 두 객체가 존재 가능
  • 객체의 주소값을 이용하여 해시코드를 생성
  • 64bit JVM에서 주소를 해시코드(32 bit)로 변환하면 중복 가능

equals

객체의 동등성을 판단하는 메서드로 Object 클래스에 정의된 기본 equals()는 참조 비교(fererence comparison)를 수행하는데, 이는 두 객체가 메모리상에서 같은 위치에 있는지를 확인합니다.

class Main {
    public static void main(String[] args) {
        String str1 = new String("abc");
        String str2 = new String("abc");
        String str3 = str1; // 생성된 인스턴스 참조

        System.out.println(str1.equals(str2)); // false
        System.out.println(str2.equals(str3)); // false
        System.out.println(str1.equals(str3)); // true
    }
}

위와 같이 참조하는 주소가 다른 변수에 대한 equals()를 수행하면 false를 반환하고, 생성된 인스턴스의 주소를 변수에 대입한 경우에는 동일한 참조로 판단하여 true를 반환합니다.

equals()를 올바르게 구현하기 위해서는 동치관계(equivalence relation)의 조건을 만족해야 합니다.

  • 반사성(Reflexive) : x.equals(x)는 항상 true
  • 대칭성(Symmetric) : x.equals(y)truey.equals(x)true
  • 이행성(Transitive) : x.equals(y)y.equals(z)가 모두 truex.equals(z)true
  • 일관성(COnsistent) : 객체가 변경되지 않았다면 여러 번 호출해도 같은 결과
  • null 처리 : 항상 false

두 메서드의 상호 의존성과 실행 흐름

Java의 해시계약에는 다음과 같은 조건이 있습니다.

  • 두 객체가 equals()로 같다면, hashCode()도 같아야 합니다.
  • 두 객체의 hashCode()가 같더라도 equals()가 반드시 true일 필요는 없습니다.(해시 충돌 허용)

대표적으로 HashMap에서의 동작 흐름을 살펴보겠습니다.

저장 과정

  • 키 객체의 hashCode() 호출
  • 해시 값을 이용해 버킷 인덱스 계산
  • 해당 버킷에 저장 (충돌 시 연결 리스트 또는 트리 구조로 관리)

검색 과정

  • 키 객체의 hashCode() 호출
  • 해시 값으로 버킷 찾기
  • 버킷 내에서 equals()를 이용해 정확한 키 검색
  • 해당 값 반환

위 과정들의 동작을 코드로 표현하면 다음과 같습니다.

// HashMap의 내부 동작 시뮬레이션
public V get(Object key) {
    int hash = key.hashCode();
    int index = hash % buckets.length;

    for (Entry<K,V> entry : buckets[index]) {
        if (entry.key.equals(key)) {
            return entry.value;
        }
    }
    return null;
}

올바른 구현

그럼 hashCode()equals()가 의도한대로 올바르게 동작하기 위해서는 어떤 방식으로 구현하는지 살펴보겠습니다.

equals() 구현 패턴

@Override
public boolean equals(Object obj) {
    // 1. 참조 비교 (최적화)
    if (this == obj) return true;

    // 2. null 체크
    if (obj == null) return false;

    // 3. 클래스 타입 체크
    if (getClass() != obj.getClass()) return false;

    // 4. 필드 비교
    User other = (User) obj;
    return Objects.equals(this.id, other.id) &&
           Objects.equals(this.name, other.name);
}

hashCode() 구현 패턴

@Override
public int hashCode() {
    // 필요한 필드에 대해서만 hash() 호출
    return Objects.hash(id, name);
}

hash()

Java 7부터 제공되는 hash()31을 곱하는 알고리즘을 사용합니다.

public static int hash(Object... values) {
    return Arrays.hashCode(values);
}
public static int hashCode(Object a[]) {
    int result = 1;
    for (Object element : a)
        result = 31 * result + (element == null ? 0 : element.hashCode());
    return result;
}

31을 사용하는 이유는 홀수 소수이기 때문에 비트 연산으로 최적화가 가능합니다.
31 * i == (i << 5) - i

주의사항과 해결 방안

가변 객체를 키로 사용하는 경우

Map<User, String> userMap = new HashMap<>();
User user = new User(1, "김감자");
userMap.put(user, "백엔드 개발자");

user.setName("박감자"); // 객체 변경
String job = userMap.get(user); // null 반환! (찾을 수 없음)
  • 불변 객체(Immutable Object) 사용
  • equals/hashCode 오버라이딩 시 불변 필드만 사용
  • 컬렉션에 저장된 객체는 수정 x

상속 관계에서의 equals 구현

// 잘못된 예시 - 리스코프 치환 원칙 위반
public class ColorPoint extends Point {
    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof ColorPoint)) return false;
        return super.equals(obj) && ((ColorPoint) obj).color == color;
    }
}
  • getClass()를 사용하여 정확한 타입 매칭
  • 컴포지션(Composition) 사용으로 상속보다 객체를 포함하여 해결
  • 상속이 필요한 경우 추상 클래스 활용

성능 최적화

@Override
public boolean equals(Object obj) {
    if (this == obj) return true; // 빠른 경로
    if (obj == null || getClass() != obj.getClass()) return false;

    User user = (User) obj;

    return id == user.id && Objects.equals(name, user.name);
}
  • 참조 비교를 통해 빠른 경로로 결과 반환
  • 필드 비교는 비용이 낮은 필드부터 먼저 비교

JPA 엔티티에서의 주의점

JPA 엔티티에서의 hashCode()equals() 사용은 더 특별한 주의가 필요합니다. 주요 문제에 대해서 알아보겠습니다.

엔티티의 생명 주기

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 잘못된 구현 - id 기반
    @Override
    public boolean equals(Object obj) {
        User user = (User) obj;
        return Objects.equals(this.id, user.id);
    }
}

// 문제 발생
Set<User> users = new HashSet<>();
User newUser = new User("김감자", "kim@example.com");
users.add(newUser); // null

entityManager.persist(newUser);
entityManager.flush(); // 1로 설정

boolean contains = users.contains(newUser);
  • 최초 생성 시 id가 null인 상태로 리스트에 저장
  • flush()를 통해 id가 부여되면 hashCode 변경
  • 리스트 내 동일한 hashCode가 없기 때문에 false 반환

Proxy 객체(getClass() 문제)

// 지연 로딩으로 가져온 프록시 객체
User proxyUser = entityManager.getReference(User.class, 1L);
User realUser = entityManager.find(User.class, 1L);

// getClass() 사용 시 문제
proxyUser.getClass();
realUser.getClass();
  • 지연 로딩으로 조회한 객체는 프록시 객체를 getClass() 했을 경우 반환값 User$HibernateProxy$xxx
  • 지연 로딩을 적용하지 않은 객체를 getClass() 했을 경우 반환값 User
  • 같은 엔티티이지만 equals()에서 false 반환

해결 방안

  • email 등의 business key 사용
  • UUID 사용 (권장)
  • id가 필수인 경우 비교 객체가 모두 영속화된 경우만 비교

그 외에도 연관관계 필드가 equals()hashCode()에 포함되면 안되는 등의 문제가 존재합니다.


🫡 마무리

equalshashCode는 Java의 객체 동등성과 해시 기반 컬렉션의 핵심입니다. 올바르게 구현하지 않으면 예상치 못한 버그가 발생할 수 있으므로, 항상 계약 조건을 준수하고 함께 구현하는 것이 중요합니다.
특히 백엔드 개발에서는 JPA 엔티티, 캐시 키, API 응답 객체 등에서 이 메서드들이 중요한 역할을 하므로, 충분한 이해와 올바른 구현이 필수입니다.

profile
개발감자🥔

0개의 댓글