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

안동민 개발노트

본문 시작
15장 : 해시·HashSet·Set

equals·hashCode 계약

기본 동일성·hashCode만 구현·equals만 구현한 세 실패를 비교하고 불변 PostKey로 논리 중복을 차단합니다.

문자열은 값 동등성 규칙이 이미 구현되어 있지만 직접 만든 클래스는 Object의 equals와 hashCode를 물려받습니다.

내용이 같은 두 인스턴스도 기본 equals에서는 다른 참조이므로 HashSet에 둘 다 들어갑니다.

객체 키를 쓸 때는 “어떤 필드가 같은 객체를 뜻하는가”를 먼저 정하고 두 메서드를 같은 필드 집합으로 구현해야 합니다.


Object의 기본 동등성

lab/IdentityBasedKeyDuplicate.java
import java.util.HashSet;
import java.util.Set;

public final class IdentityBasedKeyDuplicate {
    public static void main(String[] args) {
        Set<MemberKey> keys = new HashSet<>();
        keys.add(new MemberKey("A-17"));
        keys.add(new MemberKey("A-17"));
        System.out.println("size=" + keys.size());
    }

    private static final class MemberKey {
        private final String id;

        private MemberKey(String id) {
            this.id = id;
        }
    }
}
잘못된 결과
size=2

id가 같아도 두 new 표현식은 다른 참조를 만듭니다.

기본 hashCode도 대개 인스턴스별 값이라 서로 다른 그룹으로 갈 수 있습니다.

HashSet은 우리가 의도한 id 동등성을 추측하지 않습니다.


두 메서드 중 하나만 구현해도 규칙이 깨지는 원인

equals만 id로 구현하고 hashCode를 상속하면 논리적으로 같은 키가 서로 다른 그룹에 들어갈 수 있습니다.

포함 여부는 조회 키의 그룹만 보기 때문에 기존 원소를 찾지 못합니다.

hashCode만 id로 구현하고 equals를 상속하면 후보는 같은 그룹에 모이지만 최종 비교가 false라 중복이 추가됩니다.

필수 방향은 a.equals(b)true이면 a.hashCode() == b.hashCode()true라는 것입니다.

반대는 요구되지 않습니다.

서로 다른 객체의 같은 hashCode는 정상 충돌이며 equals가 구분합니다.


생성자의 값 객체 식별 필드

PostKey는 카테고리와 제목을 함께 식별자로 사용합니다.

record는 두 컴포넌트에 기반한 equals·hashCode·toString을 자동으로 제공합니다.

생성 시 정규화까지 마치고 이후 바뀌지 않으므로 HashSet에 저장된 index가 안정적입니다.

src/PostKeyHashContract.java
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;

public final class PostKeyHashContract {
    public static void main(String[] args) {
        PostKey first = PostKey.of("Java", "Hash Set");
        PostKey same = PostKey.of(" java ", "hash set");
        PostKey other = PostKey.of("Spring", "hash set");
        Set<PostKey> keys = new HashSet<>();
        System.out.println("first=" + keys.add(first));
        System.out.println("same=" + keys.add(same));
        System.out.println("other=" + keys.add(other));
        System.out.println(
                "equal="
                        + first.equals(same)
                        + ", hash="
                        + (first.hashCode() == same.hashCode())
                        + ", size="
                        + keys.size());
    }

    private record PostKey(String category, String title) {
        static PostKey of(String category, String title) {
            return new PostKey(normalize(category), normalize(title));
        }

        private static String normalize(String value) {
            if (value == null) throw new IllegalArgumentException("null key part");
            String normalized = value.strip().toLowerCase(Locale.ROOT);
            if (normalized.isEmpty()) throw new IllegalArgumentException("blank key part");
            return normalized;
        }
    }
}

add와 다른 카테고리는 true, 정규화 뒤 같은 키는 false입니다.

equals가 보는 필드와 hashCode가 보는 필드를 record가 동일하게 유지합니다.

일부 필드만 식별에 쓰려면 일반 클래스로 두 메서드를 함께 재정의하거나 별도 Key record를 만드는 편이 명확합니다.


가변 키와 그룹 탐색 실패

가변 객체의 hashCode 재료를 바꾸면 포함 여부는 새 hashCode의 그룹을 찾지만 원소는 이전 그룹에 남아 있습니다.

같은 참조를 전달해도 false가 될 수 있고 remove도 실패합니다.

Set 내부가 자동으로 원소를 옮겨 주지 않습니다.

lab/MutableHashKeyLookupBug.java
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public final class MutableHashKeyLookupBug {
    public static void main(String[] args) {
        MutableKey key = new MutableKey("hash");
        Set<MutableKey> keys = new HashSet<>();
        keys.add(key);
        key.title = "set";
        System.out.println("contains-same-reference=" + keys.contains(key));
        System.out.println("remove=" + keys.remove(key) + ", size=" + keys.size());
    }

    private static final class MutableKey {
        private String title;

        private MutableKey(String title) {
            this.title = title;
        }

        public boolean equals(Object other) {
            return this == other
                    || other instanceof MutableKey key && Objects.equals(title, key.title);
        }

        public int hashCode() {
            return Objects.hash(title);
        }
    }
}

이 실패는 equals/hashCode 구현이 서로 일치해도 키 안정성이 별도 조건임을 보여 줍니다.

변경 가능한 상세 정보는 Set 값과 분리하고, 식별 Key는 불변으로 유지합니다.


equals 구현의 세부 선택

this == other는 같은 참조를 빠르게 통과시킵니다.

nullinstanceof 패턴에서 자동으로 false입니다.

상속을 허용하는 값 계층은 instanceof와 getClass 중 대칭성 문제를 신중히 다뤄야 합니다.

식별 값 객체를 final classrecord로 만들면 이 선택이 단순해집니다.

equals에는 반사성, 대칭성, 추이성, 일관성, null과의 비교 false가 요구됩니다.

hashCode도 객체가 동등성 관련 상태를 바꾸지 않는 동안 일관되어야 합니다.

IDE 생성 기능을 써도 어떤 필드를 선택했는지 사람이 검토합니다.


게시글 키와 상세 정보 분리

게시글 조회수는 나중에 수정될 수 있지만 카테고리·게시일·순번으로 만든 PostPublicationKey는 바뀌지 않습니다.

Map 또는 Set은 키만 식별에 사용하고, 조회수 변경은 값 객체 교체로 처리합니다.

키가 안정적이므로 업데이트 뒤에도 조회 위치가 같습니다.

app/PostPublicationKeyCli.java
import java.time.LocalDate;
import java.util.LinkedHashSet;
import java.util.Set;

public final class PostPublicationKeyCli {
    public static void main(String[] args) {
        Set<PostPublicationKey> seen = new LinkedHashSet<>();
        PostPublicationKey key = new PostPublicationKey("java", LocalDate.of(2026, 7, 14), 1);
        register(seen, key);
        register(seen, new PostPublicationKey("java", LocalDate.of(2026, 7, 14), 1));
        register(seen, new PostPublicationKey("java", LocalDate.of(2026, 7, 14), 2));
        System.out.println("unique=" + seen.size());
    }

    private static void register(Set<PostPublicationKey> seen, PostPublicationKey key) {
        System.out.println(key + "=" + (seen.add(key) ? "accepted" : "duplicate"));
    }

    private record PostPublicationKey(String category, LocalDate publishedOn, int sequence) {
        private PostPublicationKey {
            if (category == null || category.isBlank() || publishedOn == null || sequence <= 0) {
                throw new IllegalArgumentException("invalid key");
            }
        }
    }
}

LinkedHashSet을 쓴 이유는 CLI 출력에서 등록 순서를 보존하기 위해서입니다.

중복 판단은 record의 값 규칙이 담당하고 순서는 구현 선택이 담당합니다.

두 책임을 한 메서드 설명으로 뭉개지 않습니다.


연습 문제

이름은 바뀔 수 있지만 id가 같으면 같은 회원인 Member를 작성하세요.

같은 id와 다른 이름을 가진 두 객체를 HashSet에 넣었을 때 size가 1이어야 합니다.

정답과 해설

동등성 필드는 id 하나입니다.

이름을 hashCode에 넣으면 equalstrue인 두 객체의 hashCode가 달라져 규칙을 깨므로 제외합니다.

exercise/MemberIdentitySolution.java
import java.util.HashSet;
import java.util.Objects;

public final class MemberIdentitySolution {
    public static void main(String[] args) {
        var members = new HashSet<Member>();
        members.add(new Member("M-1", "Andongmin"));
        members.add(new Member("M-1", "Changed Name"));
        System.out.println("size=" + members.size());
    }

    private record Member(String id, String name) {
        private Member {
            if (id == null || id.isBlank()) throw new IllegalArgumentException("id");
        }

        public boolean equals(Object other) {
            return this == other || other instanceof Member member && id.equals(member.id);
        }

        public int hashCode() {
            return Objects.hash(id);
        }
    }
}

결과는 size=1입니다.

record 기본 구현을 일부 변경했으므로 equals와 hashCode를 함께 재정의했습니다.

더 단순한 설계는 MemberId record를 별도 키로 사용하는 것입니다.

객체 해시 키의 최종 점검은 논리 동등성 필드, 두 메서드의 동일 필드 사용, 저장 이후 불변이라는 세 항목입니다.

하나라도 충족하지 못하면 HashSet 결과는 입력 순서나 우연한 그룹 배치에 따라 틀릴 수 있습니다.