equals()와 hashCode(): 계약과 흔한 실수

1. 개요

equals()와 hashCode()는 간단해 보이는 두 메서드지만, 잘못 구현하면 HashSet에 중복 요소가 포함되고 HashMap이 key를 “잃어버리게” 됩니다. 이런 오류들은 보통 예외를 던지지 않습니다. 프로그램은 계속 실행되지만 잘못 실행됩니다.

이 글에서는 Java가 두 메서드에 정한 계약(contract)을 설명합니다. 그다음 흔히 발생하는 오류들을 거쳐 가며, 각 오류마다 재현할 수 있는 예제를 포함합니다. 이 글을 읽고 나면 두 메서드를 올바르게 구현하고, 언제 record를 사용할지 알며, 검사 테스트를 작성하는 방법을 알게 될 것입니다.

2. equals()의 계약

기본적으로 Object.equals()는 참조를 비교합니다. 즉, this == obj입니다. 재정의할 때는 모든 null이 아닌 참조에 대해 다음 다섯 가지 특성을 유지해야 합니다:

  • 반사적(reflexive): x.equals(x)는 항상 true입니다.
  • 대칭적(symmetric): x.equals(y)가 true일 때와 y.equals(x)가 true일 때만 일치합니다.
  • 이행적(transitive): x.equals(y)이고 y.equals(z)이면 x.equals(z)입니다.
  • 일관성(consistent): 객체가 변하지 않으면 여러 번 호출해도 같은 결과를 줘야 합니다.
  • null과의 비교: x.equals(null)은 항상 false입니다.

Customer 클래스에 대한 표준적인 구현은 다음과 같습니다:

import java.util.Objects;

public final class Customer {
    private final String email;
    private final String name;

    public Customer(String email, String name) {
        this.email = email;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer other)) return false;
        return Objects.equals(email, other.email)
                && Objects.equals(name, other.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(email, name);
    }
}

각 부분을 설명하면:

  • this == o는 두 참조가 같은 객체를 가리킬 때의 단축키입니다.
  • instanceof Customer other는 pattern matching(Java 16+)을 사용합니다. 이 표현식은 o가 null일 때 false를 반환하므로, null 비교 특성이 별도 검사 없이 보장됩니다.
  • Objects.equals는 필드가 null일 수 있어도 안전합니다.
  • Objects.hash는 equals()에서 사용한 필드에서 hash를 계산합니다.

클래스는 final로 선언됩니다. 이유는 섹션 6에서 명확해질 것입니다.

3. hashCode()의 계약

hashCode()는 세 가지 규칙을 따릅니다:

  1. 변하지 않는 같은 객체에서 여러 번 호출하면 같은 값을 반환해야 합니다.
  2. a.equals(b)이면 a.hashCode() == b.hashCode()입니다. 이것이 가장 중요한 규칙입니다.
  3. 서로 다른 두 객체는 같은 hash code를 가질 수 있습니다(collision). 하지만 해시가 잘 분산되면 컬렉션이 더 빠르게 작동합니다.

규칙 2의 역은 참이 아닙니다. 같은 hash code라고 해서 같은 객체는 아닙니다.

규칙 2가 왜 중요한지 보려면 HashMap이 key를 어떻게 찾는지 봅시다. 먼저 hashCode()를 사용하여 bucket을 선택합니다. 그다음 그 bucket 내 요소들을 비교하기 위해 equals()를 사용합니다. 두 객체가 “같지만” hash가 다르면 서로 다른 bucket에 있게 되고, equals()는 절대 호출되지 않습니다.

4. 오류 1: equals()는 재정의하고 hashCode()는 잊음

이것은 고전적인 실수입니다. 섹션 2의 클래스에서 hashCode()를 빼면 어떻게 될까요:

public class Customer {
    private final String email;

    public Customer(String email) { this.email = email; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer other)) return false;
        return email.equals(other.email);
    }
    // hashCode() 누락
}
Set<Customer> customers = new HashSet<>();
customers.add(new Customer("an@example.com"));
customers.add(new Customer("an@example.com"));

System.out.println(customers.size());
System.out.println(customers.contains(new Customer("an@example.com")));
2
false

두 객체는 equals()에 따라 같습니다. 하지만 hashCode()는 여전히 Object의 기본값이며, 보통 각 인스턴스마다 다릅니다. 결과적으로 HashSet에는 “중복된” 두 요소가 포함되고, contains()는 false를 반환합니다. 이 결과는 거의 확실하게 발생합니다. 두 기본 hash가 일치하는 경우는 드물기 때문에 이 오류는 안정적으로 재현하기 어렵습니다.

주의: IDE와 SonarQube 같은 도구들이 이 오류를 경고합니다. 그 경고를 끄지 마세요.

5. 오류 2: 재정의 대신 오버로드

이 오류는 훨씬 보기 어렵습니다:

public class Order {
    private final long id;

    public Order(long id) { this.id = id; }

    public boolean equals(Order other) {   // 잘못됨: 매개변수가 Object가 아니라 Order입니다
        return other != null && id == other.id;
    }

    @Override
    public int hashCode() { return Long.hashCode(id); }
}
Order a = new Order(1);
Order b = new Order(1);

System.out.println(a.equals(b));                  // equals(Order) 호출
System.out.println(List.of(a).contains(b));       // equals(Object) 호출
true
false

equals(Order)는 새로운 메서드(overload)입니다. equals(Object)를 재정의하지 않습니다. 컬렉션은 항상 equals(Object)를 호출하므로 여전히 Object의 참조 비교를 사용합니다.

방지하는 방법은 매우 간단합니다: 항상 @Override를 붙이세요. 위의 메서드에 @Override를 추가하면 컴파일러가 즉시 오류를 보고합니다.

6. 오류 3: 상속으로 인한 대칭성 파괴

Point 클래스와 color 필드를 추가하는 ColorPoint 서브클래스를 생각해봅시다:

public class Point {
    protected final int x, y;
    public Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point p)) return false;
        return x == p.x && y == p.y;
    }

    @Override
    public int hashCode() { return Objects.hash(x, y); }
}

public class ColorPoint extends Point {
    private final String color;
    public ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint cp)) return false;
        return super.equals(cp) && color.equals(cp.color);
    }

    @Override
    public int hashCode() { return Objects.hash(x, y, color); }
}
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "RED");

System.out.println(p.equals(cp));
System.out.println(cp.equals(p));
true
false

대칭성이 깨졌습니다. 또한 p.equals(cp)는 true이지만 두 객체의 hash가 다르므로 섹션 3의 규칙 2도 위반됩니다.

서브클래스에 의미 있는 필드를 추가하면서 instanceof와의 계약을 완전히 유지할 방법은 없습니다. 세 가지 선택지가 있습니다:

  • 클래스를 final로 선언하거나 record를 사용하세요. 이것이 가장 간단한 방법입니다.
  • instanceof 대신 getClass() != o.getClass()를 사용하세요. 이 경우 Point와 ColorPoint는 절대 같지 않습니다. 이 방법은 계약을 유지하지만, 필드를 추가하지 않은 서브클래스(예: proxy)도 부모 클래스와 같지 않게 됩니다.
  • composition을 선호하세요: ColorPoint가 Point를 상속하는 대신 포함하게 하세요.

7. 오류 4: key에 변경 가능한 필드

hashCode()는 객체가 HashSet에 추가될 때 계산됩니다. 그 이후 hash 계산에 포함된 필드가 변경되면, 객체는 여전히 이전 bucket에 있습니다:

public class Product {
    private String sku;
    public Product(String sku) { this.sku = sku; }
    public void setSku(String sku) { this.sku = sku; }

    @Override
    public boolean equals(Object o) {
        return o instanceof Product p && Objects.equals(sku, p.sku);
    }

    @Override
    public int hashCode() { return Objects.hashCode(sku); }
}
Product product = new Product("SKU-1");
Set<Product> catalog = new HashSet<>();
catalog.add(product);

product.setSku("SKU-2");

System.out.println(catalog.contains(product));
System.out.println(catalog.remove(product));
System.out.println(catalog.size());
false
false
1

객체는 여전히 set에 있지만, 찾을 수도 없고 제거할 수도 없습니다. 결과적으로 작은 메모리 누수가 발생하고 로직이 잘못 실행됩니다.

주의: equals()/hashCode()에서는 불변(immutable) 필드만 사용하세요. 변경 가능한 필드를 사용해야 한다면, HashMap의 key이거나 HashSet의 요소인 동안 객체를 수정하지 마세요. 올바른 방법은 객체를 제거한 다음 수정한 후 다시 추가하는 것입니다.

8. 오류 5: BigDecimal과 배열의 함정

JDK의 일부 타입은 예상과 다르게 equals()를 구현합니다.

8.1. BigDecimal은 scale도 비교합니다

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

System.out.println(a.equals(b));
System.out.println(a.compareTo(b) == 0);
false
true

BigDecimal.equals()는 scale이 다르기 때문에 1.0과 1.00을 다른 것으로 봅니다. Money 클래스가 equals()에서 BigDecimal을 사용한다면 먼저 scale을 정규화하세요. 예를 들어, 생성자에서 amount.setScale(2, RoundingMode.HALF_UP)을 호출하세요. stripTrailingZeros()도 정규화할 수 있지만 음의 scale을 만들 수 있습니다(예: 100).

8.2. 배열은 참조로 비교합니다

배열은 equals()를 재정의하지 않습니다. 따라서 new int[]{1, 2}.equals(new int[]{1, 2})는 false를 반환합니다. 필드가 배열일 때는 Arrays.equals()와 Arrays.hashCode()를 사용하세요. 이 함정은 record에도 적용되며, 다음 섹션에서 볼 것입니다.

9. record를 사용하여 대부분의 오류 방지

Java 16부터 record는 모든 component를 기반으로 equals(), hashCode(), toString()을 자동으로 생성합니다:

public record OrderLine(String productSku, int quantity) {}
var line1 = new OrderLine("SKU-1", 2);
var line2 = new OrderLine("SKU-1", 2);

System.out.println(line1.equals(line2));
System.out.println(line1.hashCode() == line2.hashCode());
true
true

record는 final 클래스이고, 필드도 final이며, 두 메서드가 계약을 올바르게 만족하며 생성됩니다. 섹션 4, 5, 6의 오류들이 사라집니다. 섹션 7의 오류도 거의 사라지지만, component가 변경 가능한 객체(예: 가변 List)인 경우는 예외입니다.

record를 사용할 때 기억해야 할 두 가지 점:

  • Component가 배열이면 여전히 참조로 비교됩니다. 배열 대신 List를 사용하세요.
  • Component가 BigDecimal이면 여전히 scale 함정이 있습니다. compact constructor에서 정규화하세요:
public record Money(BigDecimal amount, String currency) {
    public Money {
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }
}

이 선언으로 new Money(new BigDecimal("1.0"), "VND")는 new Money(new BigDecimal("1.00"), "VND")와 같습니다. 둘 다 scale 2로 정규화되기 때문입니다.

10. equals()와 hashCode() 테스트하기

주요 동작에 대한 테스트를 작성해야 합니다. 다음은 섹션 2의 Customer 클래스에 대한 JUnit 5와 AssertJ를 사용한 테스트입니다:

import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;

class CustomerTest {

    @Test
    void equalCustomers_shouldHaveSameHashCode_andDeduplicateInSet() {
        var a = new Customer("an@example.com", "An");
        var b = new Customer("an@example.com", "An");

        assertThat(a).isEqualTo(b);
        assertThat(b).isEqualTo(a);
        assertThat(a.hashCode()).isEqualTo(b.hashCode());

        Set<Customer> set = new HashSet<>(Set.of(a));
        assertThat(set).contains(b);
    }

    @Test
    void customer_shouldNotEqualNullOrOtherType() {
        var a = new Customer("an@example.com", "An");

        assertThat(a).isNotEqualTo(null);
        assertThat(a).isNotEqualTo("an@example.com");
    }
}

첫 번째 테스트는 대칭성, “같으면 같은 hash” 규칙, 그리고 HashSet에서의 실제 동작을 확인합니다. 두 번째 테스트는 null인 경우와 다른 타입인 경우를 확인합니다.

모든 경우를 수동으로 작성하는 것은 꽤 번거롭습니다. EqualsVerifier 라이브러리는 한 줄로 전체 계약을 확인합니다:

<dependency>
    <groupId>nl.jqno.equalsverifier</groupId>
    <artifactId>equalsverifier</artifactId>
    <version>3.16.1</version>
    <scope>test</scope>
</dependency>
@Test
void equalsContract() {
    EqualsVerifier.forClass(Customer.class).verify();
}

hashCode()에서 필드를 빠뜨리거나, final이 아닌 필드를 사용하거나, final이 아닌 클래스에서 instanceof를 사용하면 어떤 규칙이 위반되었는지 명확히 알려주는 실패 메시지와 함께 테스트가 실패합니다.

11. 언제 재정의하면 안 될까

모든 클래스가 equals()/hashCode()를 재정의할 필요는 없습니다:

  • 인스턴스에 따라 자연스러운 정의를 갖는 클래스는 Thread, PaymentService 또는 Spring bean 같은 것입니다. 두 service는 두 개의 다른 객체이며, 참조 비교가 올바릅니다.
  • JPA entity: 모든 필드를 사용하는 것을 피하세요. 또한 @Id를 무심코 사용하지 마세요. persist 전에는 id가 null이므로 저장 후 hash가 변경됩니다. 일반적인 방법은 불변 business key로 비교하는 것입니다. 또 다른 방법은 id가 null이 아닐 때 id로 비교하고, hashCode()가 getClass().hashCode() 같은 클래스별 상수를 반환하도록 하는 것입니다.
  • entity 또는 양방향 관계가 있는 클래스의 Lombok @Data: 이 annotation은 모든 필드를 사용하는 equals()/hashCode()를 생성합니다. 이것은 두 entity가 서로를 참조할 때 StackOverflowError를 유발하거나 의도하지 않게 lazy loading을 활성화할 수 있습니다.

12. 결론

equals()와 hashCode() 간의 계약은 한 가지 핵심 규칙을 중심으로 합니다: 두 객체가 같으면 같은 hash code를 가져야 합니다. 대부분의 실제 오류는 다섯 가지 원인에서 비롯됩니다:

  • hashCode()를 재정의하는 것을 잊음
  • equals(Object)를 재정의하는 대신 equals(Order)를 overload함
  • 상속이 대칭성을 파괴함
  • key에 변경 가능한 필드를 사용함
  • BigDecimal과 배열의 함정

새 코드에서는 value object로 record를 우선적으로 사용하세요. 일반 클래스의 경우 final로 선언하고, 항상 @Override를 붙이고, 불변 필드만 사용하세요. 마지막으로 EqualsVerifier를 사용한 테스트를 추가하여 도구가 계약을 확인하도록 하세요.

javaequalshashcodecollectionsrecord