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()는 세 가지 규칙을 따릅니다:
- 변하지 않는 같은 객체에서 여러 번 호출하면 같은 값을 반환해야 합니다.
a.equals(b)이면a.hashCode() == b.hashCode()입니다. 이것이 가장 중요한 규칙입니다.- 서로 다른 두 객체는 같은 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를 사용한 테스트를 추가하여 도구가 계약을 확인하도록 하세요.