본문 바로가기
개발

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

by owel.dev 2025. 3. 16.

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를 이용하여 결정합니다.
(그러므로 같은 버킷의 엔트리들은 절대 중복되어서는 안됩니다)

 

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

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

  1. 두 객체가 equals()로 비교했을 때 같다면(true), 두 객체의 hashCode() 값도 반드시 같아야 합니다.
  2. 두 객체의 hashCode() 값이 같다고 해서 equals()가 true를 반환할 필요는 없습니다(해시 충돌 가능).
  3. 그러나 성능을 위해서는 서로 다른 객체에 대해 가능한 한 다른 해시 코드를 반환하는 것이 좋습니다.

이 계약을 위반하면 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<>();
Person p1 = new Person("John", 30);
Person p2 = new Person("John", 30);

map.put(p1, "Person 1");
System.out.println(map.get(p2)); // null이 출력됨 (예상과 다름)

p1과 p2는 equals()로 비교했을 때 동등하지만, 서로 다른 해시 코드를 가지므로 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 객체 모델의 중요한 부분을 차지합니다. 특히 해시 기반 컬렉션을 사용할 때는 이 두 메서드의 계약을 준수하는 것이 매우 중요합니다.

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