개발

Java의 equals와 hashcode메서드 이해하기

owel.dev 2025. 3. 16. 23:45

 

Java 프로그래밍을 하다 보면 Object 클래스의 equals()와 hashCode() 메서드를 종종 마주칩니다.

이 두 메서드는 단순해 보이지만 올바르게 이해하고 사용하지 않으면 예상치 못한 버그를 발생시킬 수 있습니다.

오늘은 이 두 메서드의 목적과 관계에 대해 알아보겠습니다.

 

equals() 메서드 - 논리적 동등성의 기준

Java에서 객체 비교를 이야기할 때 두 가지 관점이 있습니다:

  1. 물리적 동등성(Reference Equality): '==' 연산자를 사용하여 두 객체가 메모리 상에서 동일한 위치(동일한 참조)를 가리키는지 확인
  2. 논리적 동등성(Logical Equality): 두 객체가 서로 다른 메모리 위치에 존재하더라도 내부 값이 같은지 확인

equals() 메서드는 바로 이 논리적 동등성을 판단하기 위해 존재합니다. 예를 들어, 두 개의 String 객체가 서로 다른 메모리 위치에 있더라도 같은 문자열 값을 가지고 있다면 논리적으로 동등하다고 볼 수 있습니다.

String str1 = new String("Hello");
String str2 = new String("Hello");

System.out.println(str1 == str2);       // false (서로 다른 객체 참조, 물리적으로 동등하지 않음)
System.out.println(str1.equals(str2));  // true (내용이 같음, 물리적으로 동등하지 않지만 논리적으로 동등)

hashCode() 메서드 - 해시 기반 자료구조의 핵심

hashCode() 메서드는 객체의 해시 코드 값을 반환합니다. 해시 코드 값이란 해시 기반 컬렉션(HashMap, HashSet 등)에서 객체의 인덱스를 결정하는 데 사용되는 값으로, 각 인덱스를 버킷이라 부릅니다.

해시 테이블은 O(1)의 시간 복잡도로 요소를 검색할 수 있는 효율적인 자료구조입니다. 이러한 효율성은 객체의 해시 코드를 사용하여 저장 위치(버킷)를 계산하는 방식으로 달성됩니다.

해시 테이블은 O(1)의 시간 복잡도로 요소를 검색할 수 있는 효율적인 자료구조로서
내부적으로 배열을 사용하며 요소의 값을 이용해 배열의 인덱스를 계산합니다.
그 과정에서 요소의 값을 인덱스로 매핑하는 과정이 필요한데 이를 해싱 이라고 하며 java에서는 hashcode 메서드가 해당 기능을 제공합니다.

해시테이블 저장공간을 최대한 작게 유지하여 공간 효율적으로 사용하기위해 요소의 값을 해싱을 통해 인덱스 값을 구한뒤 저장공간의 크기(배열의 크기)로 나눈 나머지 값을 사용하여 해당 값의 인덱스에 요소를 삽입하게 됩니다. 

배열의 각 인덱스 값은 연결리스트로 유지하여 해싱의 결과가 같은 값인 데이터들을 같은 연결리스트에 저장합니다.
(해싱을 통해 값을 인덱스를 위한 정수로 바꿨을때 충돌 가능성을 배제할 수 없으며, 해시테이블의 저장공간 크기를 효율적으로 유지하기 위해)
배열의 각 인덱스의 원소(연결리스트)를 버킷이라 하고, 해당 버킷(연결리스트)안에  각 노드를 엔트리라 하는데, 배열안에서 같은 버킷이냐의 여부를 hashcode를 사용해 판단하고, 한 버킷안에서 같은 엔트리인지 eqauls를 이용하여 결정합니다.
(그러므로 두 객체의 논리적 동등성을 구분할때 hashcode(버킷) 값은 같아도 되지만 equals(엔드리)값은 중복되어서는 안됩니다)

 

equals()와 hashCode()의 계약 관계

Java에서는 equals()와 hashCode() 사이에 중요한 계약이 존재합니다:

  1. 두 객체가 equals()로 비교했을 때 같다면(true), 두 객체의 hashCode() 값도 반드시 같아야 합니다.
  2. 두 객체의 hashCode() 값이 같다고 해서 equals()가 true를 반환할 필요는 없습니다.
성능을 위해서는 서로 다른 객체에 대해 가능한 한 다른 해시 코드를 반환하는 것이
(최대한 다양한 버킷에 고루 저장되게 하는것이) 좋습니다.

 

위 계약을 위반하면 HashMap, HashSet 등의 컬렉션에서 예상치 못한 동작이 발생할 수 있습니다.

public class Person {
    private String name;
    private int age;
    
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Person person = (Person) obj;
        return age == person.age && Objects.equals(name, person.name);
    }
    
    // hashCode()를 재정의하지 않음 - 계약 위반!
}

 

예를 들어, 위와 같은 클래스에서 equals()만 재정의하고 hashCode()를 재정의하지 않으면 아래와 같은 문제가 발생할 수 있습니다:

 

Map<Person, String> map = new HashMap<>();

// p1와 p2는 논리적으로 동등(equals로 비교시 같음)
Person p1 = new Person("John", 30);
Person p2 = new Person("John", 30);


map.put(p1, "Person 1");
// p1과 p2가 논리적으로 같기에 p2로 꺼냈을때 p1이 반환되어야 하지만 null이 출력됨
System.out.println(map.get(p2));

map 객체의 get 메서드는 내부적으로 equals()를 이용해 객체를 비교하여 반환합니다.

p1과 p2는 equals()로 비교했을 때는 같지만, hashcode()를 재정의하지 않았기에 서로 다른 해시 코드를 가지는 경우, HashMap에서 다른 버킷에 저장됩니다. 그 결과, p2로 값을 조회할 때 null이 반환되는 오류가 발생할 수 있습니다.

올바른 구현 방법

equals()와 hashCode()를 올바르게 구현하는 방법은 다음과 같습니다:

public class Person {
    private String name;
    private int age;
    
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Person person = (Person) obj;
        return age == person.age && Objects.equals(name, person.name);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

Java 7부터는 Objects.hash() 메서드를 사용하여 간편하게 해시 코드를 생성할 수 있습니다. 이 메서드는 내부적으로 Arrays.hashCode()를 사용하여 모든 인자의 해시 코드를 조합합니다.

결론

equals()와 hashCode() 메서드는 단순해 보이지만, Java 객체 모델의 중요한 부분을 차지합니다.

특히 HashMap 등의 해시 기반 컬렉션을 사용할 때는 이 두 메서드의 계약을 준수하는 것이 매우 중요합니다.

이 두 메서드의 관계를 제대로 이해하고 올바르게 구현함으로써, 더 견고하고 예측 가능한 Java 프로그램을 작성할 수 있습니다.