본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
9장 : 고급 타입 모델링

Object 메서드의 계약

모든 Java 클래스는 명시적이든 암묵적이든 Object를 상속합니다.

따라서 equals, hashCode, toString, getClass 같은 기본 계약이 컬렉션과 진단 동작에 영향을 줍니다.

equals만 바꾸고 hashCode를 그대로 두면 HashMap과 HashSet에서 같은 값이 다른 버킷에 들어갈 수 있습니다.

toString은 객체를 읽기 위한 표현이지 안정된 저장 포맷은 아닙니다.

이 절에서는 객체 동일성과 값 동등성을 구분합니다.

equals는 반사성·대칭성·추이성·일관성과 null 비교 규칙을 함께 지켜야 합니다. hashCode는 그 계약을 해시 버킷 선택에 연결합니다. 둘 중 하나만 맞으면 작은 예제에서는 우연히 동작해도 컬렉션의 크기나 조회 순서가 달라질 때 문제가 드러납니다.


기본 equals는 참조 동일성을 본다

Object 구현은 사실상 ==와 같은 객체인지 비교합니다.

값 객체라면 구성 필드로 동등성을 재정의할지 결정합니다.


같은 객체는 같은 hashCode를 가져야 한다

equals가 true인 두 객체의 hashCode는 반드시 같아야 합니다.

반대는 필수가 아니므로 해시 충돌은 컬렉션이 equals로 다시 확인합니다.

ObjectContracts.java는 별도 객체 두 개를 값으로 같게 만들고 HashSet이 그 계약을 어떻게 사용하는지 보여 줍니다.

ObjectContracts.java
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public class ObjectContracts {
    static final class Product {
        private final String code;
        Product(String code) { this.code = code; }
        @Override public boolean equals(Object other) {
            return this == other
                    || other instanceof Product product && code.equals(product.code);
        }
        @Override public int hashCode() { return Objects.hash(code); }
        @Override public String toString() { return "Product[code=" + code + "]"; }
    }

    public static void main(String[] args) {
        Product first = new Product("A-1");
        Product second = new Product("A-1");
        Set<Product> set = new HashSet<>();
        set.add(first);
        set.add(second);
        System.out.println(first.equals(second));
        System.out.println(set.size());
        System.out.println(first);
    }
}

Product는 서로 다른 참조지만 code가 같으므로 값으로는 동등합니다. HashSet은 먼저 hashCode로 후보 버킷을 찾고 그 안에서 equals를 확인하므로 최종 크기가 1이 됩니다.

true
1
Product[code=A-1]

동일한 code를 가진 두 객체가 같은 hashCode와 equals 결과를 제공해 Set에 하나만 남습니다.

값 동등성을 구현할 때는 먼저 같은 참조를 빠르게 인정하고, 비교 가능한 타입인지 확인한 뒤, 정체성을 이루는 필드를 비교합니다. hashCode도 바로 그 필드 집합에서 만들어야 합니다. 필드 선택이 서로 다르면 equals와 해시 버킷 탐색이 서로 다른 객체 모델을 보게 됩니다.


가변 키는 해시 컬렉션을 깨뜨릴 수 있다

키를 넣은 뒤 equals와 hashCode에 쓰는 필드를 바꾸면 원래 버킷에서 찾지 못할 수 있습니다.

불변 키를 사용합니다.

아래 잘못된 키 구현은 equals만 고쳤을 때의 문제를, 두 번째 조각은 진단 문자열에 넣지 말아야 할 상태를 보여 줍니다.

비교 1
final class BrokenKey {
    private final String code;
    BrokenKey(String code) { this.code = code; }
    @Override public boolean equals(Object other) {
        return other instanceof BrokenKey key && code.equals(key.code);
    }
    // hashCode를 재정의하지 않아 해시 컬렉션 계약 위반
}
비교 2
// 민감한 토큰이나 비밀번호는 toString에 포함하지 않는다.

getClass와 instanceof 선택은 계층 계약에 달려 있다

정확히 같은 클래스만 값 비교할지 하위 타입도 허용할지 결정해야 합니다.

상속된 값 동등성은 대칭성을 깨뜨리기 쉬워 신중해야 합니다.


equals는 대칭성과 추이성을 지킨다

a가 b와 같다면 b도 a와 같아야 하고 연쇄 비교 결과도 모순이 없어야 합니다.

상속 계층에서 값 동등성을 섞을 때 특히 주의합니다.


toString은 진단용 표현을 제공한다

로그와 디버거에서 객체를 식별할 핵심 상태를 담되 비밀번호나 토큰 같은 민감 정보는 포함하지 않습니다.

파싱 가능한 영구 포맷으로 의존하지 않습니다.

두 번째 상품의 코드를 "B-1"로 바꾸면 equals는 false, Set 크기는 2가 됩니다. 그다음 hashCode 재정의를 잠시 제거해 크기가 다시 2로 남는 현상을 관찰해 보세요. 값은 같아도 서로 다른 버킷에서 만나지 못하는 것이 계약 위반의 핵심입니다.

IDE가 생성한 메서드를 사용하더라도 비교 필드 선택은 개발자의 몫입니다. 데이터베이스가 나중에 부여하는 id나 시간이 지나며 바뀌는 표시 이름을 키 동등성에 넣으면 객체의 수명 중 계약이 달라질 수 있습니다.


Object 메서드는 자동으로 존재하지만 값 타입을 만들 때는 동등성, 해시, 진단 표현을 하나의 계약으로 설계해야 합니다.