본문 바로가기

equals·hashCode 계약

기본 참조 동등성과 가변 키 실패를 관찰하고 equals·hashCode 계약과 불변 PostKey의 중복 판단을 확인합니다.

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

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

객체 키를 쓸 때는 “어떤 필드가 같은 객체를 뜻하는가”를 먼저 정하고, 논리적으로 같은 객체의 hashCode가 같도록 두 메서드를 맞춰 구현해야 합니다.


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도 대개 인스턴스별 값이라 서로 다른 그룹으로 갈 수 있습니다. 서로 다른 참조의 hashCode가 반드시 다르다는 보장은 없으므로, size=2의 근거는 기본 equals의 참조 동등성입니다. Object의 동등성·해시 계약은 이 두 조건을 구분합니다.

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


한 메서드만 바꾸면 논리 중복 정책을 놓칠 수 있다

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

포함 여부는 조회 키의 해시를 기준으로 후보를 찾기 때문에 기존 원소를 찾지 못할 수 있습니다.

hashCode만 id로 구현하고 equals를 상속하면 후보는 같은 그룹에 모이지만 최종 비교가 false라 중복이 추가됩니다. 이 두 경우는 별도 main의 실행 결과가 아니라 구현 규칙 설명입니다.

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

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

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


팩터리 정규화와 record 식별 필드

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

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

이 예제는 PostKey.of에서 정규화한 값을 record 생성자에 전달합니다. 두 문자열은 바뀌지 않아 동등성과 hashCode가 안정적이며, 내부 배열이 확장되면 bucket 위치는 다시 계산될 수 있습니다.

정규화는 record 자체의 자동 기능이 아니라 이 팩터리의 규칙입니다. 같은 바깥 클래스에서 new PostKey("Java", "Hash Set")로 직접 생성하면 of를 거치지 않습니다. 모든 생성 경로에 같은 규칙이 필요할 때는 canonical 생성자에서도 검증·정규화를 적용합니다. 원 예제는 팩터리 호출 경로를 관찰하므로 그대로 둡니다.

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;
        }
    }
}

PostKey.of로 정규화한 세 키의 추가 결과

first/same/other record 키를 정규화하고 차례로 add하여 size 1→1→2가 됩니다. 두 식별 필드와 중복 보존의 상태를 추적할 수 있습니다.

PostKey.of로 정규화한 세 키의 추가 결과

first, same, other의 원래 두 문자열과 팩터리에서 정규화한 category·title, 실제 add 결과를 비교한다.

값 키 · 생성 팩터리에서 정규화

PostKey.of로 정규화한 세 키의 추가 결과

PostKey.of는 category와 title을 함께 정규화합니다. first와 same은 같은 값이 되어 중복이고 other는 category가 달라 새 원소입니다.

팩터리 정규화 이후 두 필드의 값 동등성 영역별first/same/other의팩터리입력과정규화값을연결합니다. first/same은java,hash set으로동등하고other는spring카테고리라달라집니다. 세add순서first/same/other결과true/false/true이며size2입니다. first of로 전달 Java / Hash Set 정규화한 식별자 java / hash set 빈 집합에 추가 first=true / size=1 same of로 전달 공백 java / hash set 정규화한 식별자 java / hash set first와 동등 same=false / size=1 other of로 전달 Spring / hash set 정규화한 식별자 spring / hash set 카테고리가 다름 other=true / size=2 실선: 각 키의 생성·add 관계 세 add는 first→same→other 두 record 컴포넌트로 식별
팩터리 정규화 이후 두 필드의 값 동등성 영역별first/same/other의팩터리입력과정규화값을연결합니다. first/same은java,hash set으로동등하고other는spring카테고리라달라집니다. 세add순서first/same/other결과true/false/true이며size2입니다. first same other of로 전달 Java / Hash Set 정규화한 식별자 java / hash set 빈 집합에 추가 first=true / size=1 of로 전달 공백 java / hash set 정규화한 식별자 java / hash set first와 동등 same=false / size=1 of로 전달 Spring / hash set 정규화한 식별자 spring / hash set 카테고리가 다름 other=true / size=2 실선: 각 키의 생성·add 관계 세 add는 first→same→ other 두 record 컴포넌트로 식별

first

of에 전달한 값
"Java"
"Hash Set"
정규화된 category / title
"java"
"hash set"
실제 add 출력
first=true

same

of에 전달한 값
" java "
"hash set"
정규화된 category / title
"java"
"hash set"
실제 add 출력
same=false

other

of에 전달한 값
"Spring"
"hash set"
정규화된 category / title
"spring"
"hash set"
실제 add 출력
other=true

정규화는 PostKey.of에서 수행합니다. 첫 두 키의 비교는 실제로 equal=true, hash=true이고 최종 집합은 size=2입니다. 표의 정규화된 필드 값은 팩터리 코드를 추적한 값이며 별도 출력은 아닙니다.

이 예의 조건

  • record 두 컴포넌트로 식별한다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2영역별 first/same/other의 팩터리 입력과 정규화 값을 연결합니다. first/same은 java,hash set으로 동등하고 other는 spring 카테고리라 달라집니다. 세 add 순서 first/same/other의 결과는 true/false/true이며 size는 2입니다.
5Java
6Hash Set
8java
9hash set
15공백 java
16hash set
18java
19hash set
25Spring
26hash set
28spring
29hash set
원 도표의 설명과 주석
구분내용
설명영역별 first/same/other의 팩터리 입력과 정규화 값을 연결합니다. first/same은 java,hash set으로 동등하고 other는 spring 카테고리라 달라집니다. 세 add 순서 first/same/other의 결과는 true/false/true이며 size는 2입니다.
전체 읽기 정보PostKey.of로 정규화한 세 키의 추가 결과 PostKey.of로 정규화한 세 키의 추가 결과 first, same, other의 원래 두 문자열과 팩터리에서 정규화한 category·title, 실제 add 결과를 비교한다. 값 키 · 생성 팩터리에서 정규화 PostKey.of로 정규화한 세 키의 추가 결과 PostKey.of는 category와 title을 함께 정규화합니다. first와 same은 같은 값이 되어 중복이고 other는 category가 달라 새 원소입니다. first of에 전달한 값 "Java" "Hash Set" 정규화된 category / title "java" "hash set" 실제 add 출력 first=true same of에 전달한 값 " java " "hash set" 정규화된 category / title "java" "hash set" 실제 add 출력 same=false other of에 전달한 값 "Spring" "hash set" 정규화된 category / title "spring" "hash set" 실제 add 출력 other=true 정규화는 PostKey.of 에서 수행합니다. 첫 두 키의 비교는 실제로 equal=true , hash=true 이고 최종 집합은 size=2 입니다. 표의 정규화된 필드 값은 팩터리 코드를 추적한 값이며 별도 출력은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
first 생성/추가

Java/Hash Set를 정규화하여 등록합니다.

  • 키: java,hash set
  • add: true
  • size: 1
  • 팩터리가 외부 형식을 같은 식별 형태로 바꾼다.
same 생성/추가

공백 java/hash set은 동일 키가 됩니다.

  • 키: java,hash set
  • add: false
  • size: 1
  • 정규화와 값 동등성이 중복을 막는다.
other 생성/추가

Spring의 category는 다른 키로 남습니다.

  • 키: spring,hash set
  • add: true
  • size: 2
  • 제목이 같아도 다른 컴포넌트가 식별을 구분한다.

PostKey.of로 정규화한 세 키의 추가 결과

first/same/other record 키를 정규화하고 차례로 add하여 size 1→1→2가 됩니다. 두 식별 필드와 중복 보존의 상태를 추적할 수 있습니다.

PostKey.of로 정규화한 세 키의 추가 결과

first, same, other의 원래 두 문자열과 팩터리에서 정규화한 category·title, 실제 add 결과를 비교한다.

값 키 · 생성 팩터리에서 정규화

PostKey.of로 정규화한 세 키의 추가 결과

PostKey.of는 category와 title을 함께 정규화합니다. first와 same은 같은 값이 되어 중복이고 other는 category가 달라 새 원소입니다.

팩터리 정규화 이후 두 필드의 값 동등성 영역별first/same/other의팩터리입력과정규화값을연결합니다. first/same은java,hash set으로동등하고other는spring카테고리라달라집니다. 세add순서first/same/other결과true/false/true이며size2입니다. first of로 전달 Java / Hash Set 정규화한 식별자 java / hash set 빈 집합에 추가 first=true / size=1 same of로 전달 공백 java / hash set 정규화한 식별자 java / hash set first와 동등 same=false / size=1 other of로 전달 Spring / hash set 정규화한 식별자 spring / hash set 카테고리가 다름 other=true / size=2 실선: 각 키의 생성·add 관계 세 add는 first→same→other 두 record 컴포넌트로 식별
팩터리 정규화 이후 두 필드의 값 동등성 영역별first/same/other의팩터리입력과정규화값을연결합니다. first/same은java,hash set으로동등하고other는spring카테고리라달라집니다. 세add순서first/same/other결과true/false/true이며size2입니다. first same other of로 전달 Java / Hash Set 정규화한 식별자 java / hash set 빈 집합에 추가 first=true / size=1 of로 전달 공백 java / hash set 정규화한 식별자 java / hash set first와 동등 same=false / size=1 of로 전달 Spring / hash set 정규화한 식별자 spring / hash set 카테고리가 다름 other=true / size=2 실선: 각 키의 생성·add 관계 세 add는 first→same→ other 두 record 컴포넌트로 식별

first

of에 전달한 값
"Java"
"Hash Set"
정규화된 category / title
"java"
"hash set"
실제 add 출력
first=true

same

of에 전달한 값
" java "
"hash set"
정규화된 category / title
"java"
"hash set"
실제 add 출력
same=false

other

of에 전달한 값
"Spring"
"hash set"
정규화된 category / title
"spring"
"hash set"
실제 add 출력
other=true

정규화는 PostKey.of에서 수행합니다. 첫 두 키의 비교는 실제로 equal=true, hash=true이고 최종 집합은 size=2입니다. 표의 정규화된 필드 값은 팩터리 코드를 추적한 값이며 별도 출력은 아닙니다.

이 예의 조건

  • record 두 컴포넌트로 식별한다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2영역별 first/same/other의 팩터리 입력과 정규화 값을 연결합니다. first/same은 java,hash set으로 동등하고 other는 spring 카테고리라 달라집니다. 세 add 순서 first/same/other의 결과는 true/false/true이며 size는 2입니다.
5Java
6Hash Set
8java
9hash set
15공백 java
16hash set
18java
19hash set
25Spring
26hash set
28spring
29hash set
원 도표의 설명과 주석
구분내용
설명영역별 first/same/other의 팩터리 입력과 정규화 값을 연결합니다. first/same은 java,hash set으로 동등하고 other는 spring 카테고리라 달라집니다. 세 add 순서 first/same/other의 결과는 true/false/true이며 size는 2입니다.
전체 읽기 정보PostKey.of로 정규화한 세 키의 추가 결과 PostKey.of로 정규화한 세 키의 추가 결과 first, same, other의 원래 두 문자열과 팩터리에서 정규화한 category·title, 실제 add 결과를 비교한다. 값 키 · 생성 팩터리에서 정규화 PostKey.of로 정규화한 세 키의 추가 결과 PostKey.of는 category와 title을 함께 정규화합니다. first와 same은 같은 값이 되어 중복이고 other는 category가 달라 새 원소입니다. first of에 전달한 값 "Java" "Hash Set" 정규화된 category / title "java" "hash set" 실제 add 출력 first=true same of에 전달한 값 " java " "hash set" 정규화된 category / title "java" "hash set" 실제 add 출력 same=false other of에 전달한 값 "Spring" "hash set" 정규화된 category / title "spring" "hash set" 실제 add 출력 other=true 정규화는 PostKey.of 에서 수행합니다. 첫 두 키의 비교는 실제로 equal=true , hash=true 이고 최종 집합은 size=2 입니다. 표의 정규화된 필드 값은 팩터리 코드를 추적한 값이며 별도 출력은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
first 생성/추가

Java/Hash Set를 정규화하여 등록합니다.

  • 키: java,hash set
  • add: true
  • size: 1
  • 팩터리가 외부 형식을 같은 식별 형태로 바꾼다.
same 생성/추가

공백 java/hash set은 동일 키가 됩니다.

  • 키: java,hash set
  • add: false
  • size: 1
  • 정규화와 값 동등성이 중복을 막는다.
other 생성/추가

Spring의 category는 다른 키로 남습니다.

  • 키: spring,hash set
  • add: true
  • size: 2
  • 제목이 같아도 다른 컴포넌트가 식별을 구분한다.

첫 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);
        }
    }
}

같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다

등록한 MutableKey.title을 바꾼 뒤 contains/remove 실패와 size 1 유지가 관찰됩니다. 등록·변경·조회 시점은 유익하되 변경된 Set key 행동을 명세 보장으로 일반화하지 않습니다.

같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다

원문의 같은 MutableKey 참조에 저장된 title이 hash에서 set으로 바뀌고 contains와 remove가 false가 되는 과정을 구분한다.

가변 키 · 저장 뒤 식별 상태 변경

같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다

저장한 key의 title을 hash에서 set으로 바꿉니다. 같은 객체를 다시 조회해도 본문 예시에서는 찾기와 제거가 실패하고 size는 1로 남습니다.

저장 당시 키 상태와 후속 조회 상태의 차이 저장한같은MutableKey의title을hash에서set으로바꾸고다시등록하지않습니다. 두영역은저장정보와후속조회조건을구분하며원JDK실행containsfalse/removefalse/size1입니다. 특정그룹번호를추측하지않으며변경키의Set계약은지정되지않습니다. 집합이 가진 저장 상태 처음 add한 키 같은 MutableKey 참조 title=hash 저장 당시 탐색 정보 hash 재료는 옛 title 필드는 그대로 보유 새 title로 재등록 안 함 size=1 같은 키의 후속 조회 title만 set으로 변경 새 객체 생성은 아님 현재 title로 해시 계산 새 탐색 정보로 찾기 원 실행의 관찰 contains=false remove=false / size=1 실선: 각 상태·조회 관계 변경된 키의 Set 동작은 계약상 지정되지 않음
저장 당시 키 상태와 후속 조회 상태의 차이 저장한같은MutableKey의title을hash에서set으로바꾸고다시등록하지않습니다. 두영역은저장정보와후속조회조건을구분하며원JDK실행containsfalse/removefalse/size1입니다. 특정그룹번호를추측하지않으며변경키의Set계약은지정되지않습니다. 집합이 가진 저장 상태 같은 키의 후속 조회 처음 add한 키 같은 MutableKey 참조 title=hash 저장 당시 탐색 정보 hash 재료는 옛 title 필드는 그대로 보유 새 title로 재등록 안 함 size=1 title만 set으로 변경 새 객체 생성은 아님 현재 title로 해시 계산 새 탐색 정보로 찾기 원 실행의 관찰 contains=false remove=false / size=1 실선: 각 상태·조회 관계 변경된 키의 Set 동작은 계약상 지정되지 않음
  1. keys.add(key)

    key.title
    "hash"
    상태 또는 실제 출력
    빈 집합에 같은 key 참조를 저장한다. 이 줄의 반환값은 출력하지 않는다.
  2. key.title
    = "set"

    key.title
    "set"
    상태 또는 실제 출력
    객체 참조는 그대로이며 해시 계산에 쓰는 필드만 바뀐다. 집합에 다시 넣는 코드는 없다.
  3. keys.contains(key)

    key.title
    "set"
    상태 또는 실제 출력
    contains-same-reference=false
  4. keys.remove(key)

    key.title
    "set"
    상태 또는 실제 출력
    remove=false, size=1

위 두 단계는 저장·변경 과정의 소스 추적이고 아래 두 단계는 본문이 제시한 출력입니다. equals에 영향을 주는 원소 상태를 저장 뒤 바꾸면 Set의 동작은 계약상 지정되지 않습니다.

이 예의 조건

  • 변경된 Set key의 동작은 계약상 지정되지 않는다.
  • 특정 그룹번호를 추측하지 않는 원 실행 관찰이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2저장한 같은 MutableKey의 title을 hash에서 set으로 바꾸고 다시 등록하지 않습니다. 두 영역은 저장 정보와 후속 조회 조건을 구분하며 원래 JDK 실행의 contains는 false/remove는 false/size는 1입니다. 특정 그룹 번호를 추측하지 않으며 변경된 키의 Set 동작은 계약상 지정되지 않습니다.
19remove=false
원 도표의 설명과 주석
구분내용
설명저장한 같은 MutableKey의 title을 hash에서 set으로 바꾸고 다시 등록하지 않습니다. 두 영역은 저장 정보와 후속 조회 조건을 구분하며 원래 JDK 실행의 contains는 false/remove는 false/size는 1입니다. 특정 그룹 번호를 추측하지 않으며 변경된 키의 Set 동작은 계약상 지정되지 않습니다.
전체 읽기 정보같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다 같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다 원문의 같은 MutableKey 참조에 저장된 title이 hash에서 set으로 바뀌고 contains와 remove가 false가 되는 과정을 구분한다. 가변 키 · 저장 뒤 식별 상태 변경 같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다 저장한 key의 title을 hash에서 set으로 바꿉니다. 같은 객체를 다시 조회해도 본문 예시에서는 찾기와 제거가 실패하고 size는 1로 남습니다. keys.add(key) key.title "hash" 상태 또는 실제 출력 빈 집합에 같은 key 참조를 저장한다. 이 줄의 반환값은 출력하지 않는다. key.title = "set" key.title "set" 상태 또는 실제 출력 객체 참조는 그대로이며 해시 계산에 쓰는 필드만 바뀐다. 집합에 다시 넣는 코드는 없다. keys.contains(key) key.title "set" 상태 또는 실제 출력 contains-same-reference=false keys.remove(key) key.title "set" 상태 또는 실제 출력 remove=false, size=1 위 두 단계는 저장·변경 과정의 소스 추적이고 아래 두 단계는 본문이 제시한 출력입니다. equals에 영향을 주는 원소 상태를 저장 뒤 바꾸면 Set의 동작은 계약상 지정되지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
등록

title hash의 MutableKey를 넣습니다.

  • 저장 참조: 같은 key
  • size: 1
  • 탐색 재료: 옛 title
  • 등록 당시 식별 정보로 저장 경로가 만들어진다.
필드 변경

새 객체나 재등록 없이 title을 set으로 바꿉니다.

  • title: set
  • 저장: 기존 참조 유지
  • 현재 hash 재료와 등록 당시 정보가 달라진다.
원 실행 조회

contains/remove가 실패하고 원소 수는 남습니다.

  • contains: false
  • remove: false
  • size: 1
  • 이 관찰을 모든 변경 key의 보장 결과로 일반화하지 않는다.

같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다

등록한 MutableKey.title을 바꾼 뒤 contains/remove 실패와 size 1 유지가 관찰됩니다. 등록·변경·조회 시점은 유익하되 변경된 Set key 행동을 명세 보장으로 일반화하지 않습니다.

같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다

원문의 같은 MutableKey 참조에 저장된 title이 hash에서 set으로 바뀌고 contains와 remove가 false가 되는 과정을 구분한다.

가변 키 · 저장 뒤 식별 상태 변경

같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다

저장한 key의 title을 hash에서 set으로 바꿉니다. 같은 객체를 다시 조회해도 본문 예시에서는 찾기와 제거가 실패하고 size는 1로 남습니다.

저장 당시 키 상태와 후속 조회 상태의 차이 저장한같은MutableKey의title을hash에서set으로바꾸고다시등록하지않습니다. 두영역은저장정보와후속조회조건을구분하며원JDK실행containsfalse/removefalse/size1입니다. 특정그룹번호를추측하지않으며변경키의Set계약은지정되지않습니다. 집합이 가진 저장 상태 처음 add한 키 같은 MutableKey 참조 title=hash 저장 당시 탐색 정보 hash 재료는 옛 title 필드는 그대로 보유 새 title로 재등록 안 함 size=1 같은 키의 후속 조회 title만 set으로 변경 새 객체 생성은 아님 현재 title로 해시 계산 새 탐색 정보로 찾기 원 실행의 관찰 contains=false remove=false / size=1 실선: 각 상태·조회 관계 변경된 키의 Set 동작은 계약상 지정되지 않음
저장 당시 키 상태와 후속 조회 상태의 차이 저장한같은MutableKey의title을hash에서set으로바꾸고다시등록하지않습니다. 두영역은저장정보와후속조회조건을구분하며원JDK실행containsfalse/removefalse/size1입니다. 특정그룹번호를추측하지않으며변경키의Set계약은지정되지않습니다. 집합이 가진 저장 상태 같은 키의 후속 조회 처음 add한 키 같은 MutableKey 참조 title=hash 저장 당시 탐색 정보 hash 재료는 옛 title 필드는 그대로 보유 새 title로 재등록 안 함 size=1 title만 set으로 변경 새 객체 생성은 아님 현재 title로 해시 계산 새 탐색 정보로 찾기 원 실행의 관찰 contains=false remove=false / size=1 실선: 각 상태·조회 관계 변경된 키의 Set 동작은 계약상 지정되지 않음
  1. keys.add(key)

    key.title
    "hash"
    상태 또는 실제 출력
    빈 집합에 같은 key 참조를 저장한다. 이 줄의 반환값은 출력하지 않는다.
  2. key.title
    = "set"

    key.title
    "set"
    상태 또는 실제 출력
    객체 참조는 그대로이며 해시 계산에 쓰는 필드만 바뀐다. 집합에 다시 넣는 코드는 없다.
  3. keys.contains(key)

    key.title
    "set"
    상태 또는 실제 출력
    contains-same-reference=false
  4. keys.remove(key)

    key.title
    "set"
    상태 또는 실제 출력
    remove=false, size=1

위 두 단계는 저장·변경 과정의 소스 추적이고 아래 두 단계는 본문이 제시한 출력입니다. equals에 영향을 주는 원소 상태를 저장 뒤 바꾸면 Set의 동작은 계약상 지정되지 않습니다.

이 예의 조건

  • 변경된 Set key의 동작은 계약상 지정되지 않는다.
  • 특정 그룹번호를 추측하지 않는 원 실행 관찰이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2저장한 같은 MutableKey의 title을 hash에서 set으로 바꾸고 다시 등록하지 않습니다. 두 영역은 저장 정보와 후속 조회 조건을 구분하며 원래 JDK 실행의 contains는 false/remove는 false/size는 1입니다. 특정 그룹 번호를 추측하지 않으며 변경된 키의 Set 동작은 계약상 지정되지 않습니다.
19remove=false
원 도표의 설명과 주석
구분내용
설명저장한 같은 MutableKey의 title을 hash에서 set으로 바꾸고 다시 등록하지 않습니다. 두 영역은 저장 정보와 후속 조회 조건을 구분하며 원래 JDK 실행의 contains는 false/remove는 false/size는 1입니다. 특정 그룹 번호를 추측하지 않으며 변경된 키의 Set 동작은 계약상 지정되지 않습니다.
전체 읽기 정보같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다 같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다 원문의 같은 MutableKey 참조에 저장된 title이 hash에서 set으로 바뀌고 contains와 remove가 false가 되는 과정을 구분한다. 가변 키 · 저장 뒤 식별 상태 변경 같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다 저장한 key의 title을 hash에서 set으로 바꿉니다. 같은 객체를 다시 조회해도 본문 예시에서는 찾기와 제거가 실패하고 size는 1로 남습니다. keys.add(key) key.title "hash" 상태 또는 실제 출력 빈 집합에 같은 key 참조를 저장한다. 이 줄의 반환값은 출력하지 않는다. key.title = "set" key.title "set" 상태 또는 실제 출력 객체 참조는 그대로이며 해시 계산에 쓰는 필드만 바뀐다. 집합에 다시 넣는 코드는 없다. keys.contains(key) key.title "set" 상태 또는 실제 출력 contains-same-reference=false keys.remove(key) key.title "set" 상태 또는 실제 출력 remove=false, size=1 위 두 단계는 저장·변경 과정의 소스 추적이고 아래 두 단계는 본문이 제시한 출력입니다. equals에 영향을 주는 원소 상태를 저장 뒤 바꾸면 Set의 동작은 계약상 지정되지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
등록

title hash의 MutableKey를 넣습니다.

  • 저장 참조: 같은 key
  • size: 1
  • 탐색 재료: 옛 title
  • 등록 당시 식별 정보로 저장 경로가 만들어진다.
필드 변경

새 객체나 재등록 없이 title을 set으로 바꿉니다.

  • title: set
  • 저장: 기존 참조 유지
  • 현재 hash 재료와 등록 당시 정보가 달라진다.
원 실행 조회

contains/remove가 실패하고 원소 수는 남습니다.

  • contains: false
  • remove: false
  • size: 1
  • 이 관찰을 모든 변경 key의 보장 결과로 일반화하지 않는다.

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

변경 가능한 상세 정보는 Set 값과 분리하고, 식별 Key는 불변으로 유지합니다. 저장 중 동등성에 영향을 주는 상태를 바꾸면 Set 계약상 동작이 지정되지 않습니다. 위 contains=false·remove=false는 이 실행에서 관찰하는 실패이며 모든 가변 키에서 같은 결과가 나오는 API 보장은 아닙니다. 변경이 필요하면 변경 전 식별 값으로 원소를 제거하고 새 불변 키를 등록하는 식으로 수명 경계를 정합니다.

불변 키로 만든 Map의 값을 갱신하면 무엇이 유지되는지 별도로 확인할 수 있습니다. 아래는 category와 sequence가 게시글 식별자이고 조회수는 상세 값인 작은 예입니다. 원문의 PostPublicationKey CLI는 그대로 두며 이 예제에서 날짜는 식별자에 사용하지 않습니다.

예시

src/StableKeyDetailUpdate.java
import java.util.HashMap;
import java.util.Map;

public final class StableKeyDetailUpdate {
    public static void main(String[] args) {
        Map<Key, Integer> views = new HashMap<>();
        Key stored = new Key("java", 1);
        views.put(stored, 40);
        Key same = new Key("java", 1);
        System.out.println("old=" + views.put(same, 55));
        System.out.println("size=" + views.size());
        System.out.println("current=" + views.get(stored));
    }

    private record Key(String category, int sequence) {}
}

출력은 old=40, size=1, current=55입니다. 같은 값 키의 기존 Map 항목을 교체했으므로 두 번째 키 객체를 만들었어도 항목 수는 늘지 않습니다. 조회수 40에서 55로 바뀌는 동안 키의 category·sequence는 그대로입니다. 실제 입력 검증은 원문의 게시글 키처럼 별도로 필요하며 이 예제는 갱신 관계에만 집중합니다.

record의 final 필드와 내부 값은 다르다

Record의 얕은 불변성은 컴포넌트 참조가 final이라는 뜻입니다. 참조가 가리키는 가변 List까지 동결하지 않습니다. PostKey의 String과 PostPublicationKey의 String·LocalDate·int는 식별 값이 안정적이지만, 같은 결론을 모든 record에 적용할 수는 없습니다.

lab/RecordComponentAlias.java
import java.util.ArrayList;
import java.util.List;

public final class RecordComponentAlias {
    public static void main(String[] args) {
        List<String> tags = new ArrayList<>(List.of("java"));
        TagKey key = new TagKey(tags);
        int before = key.hashCode();
        tags.add("set");
        System.out.println("same-list=" + (key.tags() == tags));
        System.out.println("hash-changed=" + (before != key.hashCode()));
    }

    private record TagKey(List<String> tags) {}
}

이 실행은 same-list=true, hash-changed=true입니다. 집합에 넣기 전에 외부 별칭과 해시 변화만 확인했으며, 저장 후 가변 키의 조회 결과를 보장하는 예제가 아닙니다.

외부 별칭을 끊는 연습

TagKey 생성 시 tags를 List.copyOf(tags)로 받아 두면 원 리스트에 set을 추가한 뒤 key의 내용은 무엇인가요? 리스트 안의 원소가 가변 객체라면 그 객체까지 복제되나요?

복사 범위와 키 안정성 확인

canonical 생성자에서 tags = List.copyOf(tags)를 적용하면 key의 tags는 java 하나를 가진 수정 불가능한 목록으로 유지됩니다. 이 예제의 String 원소도 불변이므로 해당 식별 값은 안정적입니다. 목록 복사는 원소 객체의 깊은 복사가 아니어서 가변 원소의 동등성 상태까지 보호하지는 않습니다. List.copyOf 계약은 null 목록·null 원소를 거부하므로 입력 정책도 함께 정합니다.


equals 구현의 세부 선택

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

null은 instanceof 패턴에서 자동으로 false입니다.

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

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

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

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

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


게시글 키와 상세 정보 분리

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

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

키 값이 바뀌지 않으므로 상세 정보의 갱신이 키의 동등성과 hashCode를 바꾸지 않습니다. 아래 CLI는 상세 정보 갱신 대신 키 등록과 중복 결과를 출력합니다.

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");
            }
        }
    }
}

register가 호출될 때마다 바로 출력하므로 이 CLI의 출력 순서는 메서드 호출 순서입니다. 이 원문은 LinkedHashSet을 순회하지 않습니다.

중복 판단은 record의 값 규칙이 담당합니다. 집합을 순회할 때의 삽입 순서 보존은 LinkedHashSet의 별도 계약입니다.

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


연습 문제

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

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

정답과 해설

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

이름을 hashCode에 넣으면 equals가 true인 두 객체의 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);
        }
    }
}

이름이 달라도 같은 회원 id는 한 원소로 남는다

같은 id의 두 번째 Member는 name이 달라도 같다고 판단되어 거절되고 첫 객체만 남습니다. 식별 필드와 비식별 데이터의 차이를 실제 두 add로 보여 줍니다.

이름이 달라도 같은 회원 id는 한 원소로 남는다

연습의 두 Member가 같은 M-1 id와 다른 이름을 갖고, id만 사용하는 equals·hashCode로 집합 크기가 하나가 되는 이유를 비교한다.

회원 식별 · id만 동등성에 사용

이름이 달라도 같은 회원 id는 한 원소로 남는다

이름이 달라도 회원 id가 M-1로 같으면 중복입니다. 이 연습은 equals와 hashCode를 함께 재정의해 id로 식별합니다.

id만 같은 두 Member와 추가 거부 별도객체둘의id는M-1이고name은Andongmin/Changed Name입니다. equals와hashCode가id만사용해두번째추가를거부하고size1입니다. 실선은식별·등록관계이며원add반환값은출력하지않습니다. 첫 Member id / name M-1 / Andongmin 동등성에 쓰는 필드 id만 M-1 처음 등록 첫 객체를 보관 둘째 Member id / name M-1 / Changed Name id 동등성 비교 이름은 비교하지 않음 중복으로 추가 거부 최종 size=1 실선: id 기반 식별·등록 원 add 반환값은 미출력 record 두 메서드를 함께 재정의
id만 같은 두 Member와 추가 거부 별도객체둘의id는M-1이고name은Andongmin/Changed Name입니다. equals와hashCode가id만사용해두번째추가를거부하고size1입니다. 실선은식별·등록관계이며원add반환값은출력하지않습니다. 첫 Member 둘째 Member id / name M-1 / Andongmin 동등성에 쓰는 필드 id만 M-1 처음 등록 첫 객체를 보관 id / name M-1 / Changed Name id 동등성 비교 이름은 비교하지 않음 중복으로 추가 거부 최종 size=1 실선: id 기반 식별·등록 원 add 반환값은 미출력 record 두 메서드를 함께 재정의

첫 번째 객체

id
"M-1"
name
"Andongmin"
집합 처리: 소스 추적
빈 집합에 첫 객체를 저장한다.

두 번째 객체

id
"M-1"
name
"Changed Name"
집합 처리: 소스 추적
id가 같으므로 중복으로 판단한다. 이름 차이는 식별에 쓰지 않는다.

이 연습의 Member는 기본 record 동등성을 그대로 쓰지 않고 equals와 hashCode를 함께 재정의합니다. 두 add 반환값은 출력하지 않으며, 실제 최종 출력은 size=1입니다.

이 예의 조건

  • 원 main은 add 반환값을 출력하지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2별도 객체 둘의 id는 M-1이고 name은 Andongmin/Changed Name입니다. equals와 hashCode가 id만 사용해 두 번째 추가를 거부하고 size는 1입니다. 실선은 식별·등록 관계이며 원래 add 반환값은 출력하지 않습니다.
6M-1
7Andongmin
15M-1
16Changed Name
원 도표의 설명과 주석
구분내용
설명별도 객체 둘의 id는 M-1이고 name은 Andongmin/Changed Name입니다. equals와 hashCode가 id만 사용해 두 번째 추가를 거부하고 size는 1입니다. 실선은 식별·등록 관계이며 원래 add 반환값은 출력하지 않습니다.
전체 읽기 정보이름이 달라도 같은 회원 id는 한 원소로 남는다 이름이 달라도 같은 회원 id는 한 원소로 남는다 연습의 두 Member가 같은 M-1 id와 다른 이름을 갖고, id만 사용하는 equals·hashCode로 집합 크기가 하나가 되는 이유를 비교한다. 회원 식별 · id만 동등성에 사용 이름이 달라도 같은 회원 id는 한 원소로 남는다 이름이 달라도 회원 id가 M-1로 같으면 중복입니다. 이 연습은 equals와 hashCode를 함께 재정의해 id로 식별합니다. 첫 번째 객체 id "M-1" name "Andongmin" 집합 처리: 소스 추적 빈 집합에 첫 객체를 저장한다. 두 번째 객체 id "M-1" name "Changed Name" 집합 처리: 소스 추적 id가 같으므로 중복으로 판단한다. 이름 차이는 식별에 쓰지 않는다. 이 연습의 Member 는 기본 record 동등성을 그대로 쓰지 않고 equals 와 hashCode 를 함께 재정의합니다. 두 add 반환값은 출력하지 않으며, 실제 최종 출력은 size=1 입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 회원

M-1/Andongmin을 등록합니다.

  • 집합: 첫 객체
  • 식별자: M-1
  • id 기반 equals/hashCode로 등록한다.
두 번째 회원

M-1/Changed Name의 id를 비교합니다.

  • id 비교: 같음
  • name 비교: 사용하지 않음
  • 비식별 필드 변경은 새 회원으로 취급되지 않는다.
등록 거절

첫 객체를 보존하며 크기 1로 남습니다.

  • size: 1
  • 보관 name: Andongmin
  • 중복 추가가 기존 객체의 다른 필드를 갱신하지 않는다.

이름이 달라도 같은 회원 id는 한 원소로 남는다

같은 id의 두 번째 Member는 name이 달라도 같다고 판단되어 거절되고 첫 객체만 남습니다. 식별 필드와 비식별 데이터의 차이를 실제 두 add로 보여 줍니다.

이름이 달라도 같은 회원 id는 한 원소로 남는다

연습의 두 Member가 같은 M-1 id와 다른 이름을 갖고, id만 사용하는 equals·hashCode로 집합 크기가 하나가 되는 이유를 비교한다.

회원 식별 · id만 동등성에 사용

이름이 달라도 같은 회원 id는 한 원소로 남는다

이름이 달라도 회원 id가 M-1로 같으면 중복입니다. 이 연습은 equals와 hashCode를 함께 재정의해 id로 식별합니다.

id만 같은 두 Member와 추가 거부 별도객체둘의id는M-1이고name은Andongmin/Changed Name입니다. equals와hashCode가id만사용해두번째추가를거부하고size1입니다. 실선은식별·등록관계이며원add반환값은출력하지않습니다. 첫 Member id / name M-1 / Andongmin 동등성에 쓰는 필드 id만 M-1 처음 등록 첫 객체를 보관 둘째 Member id / name M-1 / Changed Name id 동등성 비교 이름은 비교하지 않음 중복으로 추가 거부 최종 size=1 실선: id 기반 식별·등록 원 add 반환값은 미출력 record 두 메서드를 함께 재정의
id만 같은 두 Member와 추가 거부 별도객체둘의id는M-1이고name은Andongmin/Changed Name입니다. equals와hashCode가id만사용해두번째추가를거부하고size1입니다. 실선은식별·등록관계이며원add반환값은출력하지않습니다. 첫 Member 둘째 Member id / name M-1 / Andongmin 동등성에 쓰는 필드 id만 M-1 처음 등록 첫 객체를 보관 id / name M-1 / Changed Name id 동등성 비교 이름은 비교하지 않음 중복으로 추가 거부 최종 size=1 실선: id 기반 식별·등록 원 add 반환값은 미출력 record 두 메서드를 함께 재정의

첫 번째 객체

id
"M-1"
name
"Andongmin"
집합 처리: 소스 추적
빈 집합에 첫 객체를 저장한다.

두 번째 객체

id
"M-1"
name
"Changed Name"
집합 처리: 소스 추적
id가 같으므로 중복으로 판단한다. 이름 차이는 식별에 쓰지 않는다.

이 연습의 Member는 기본 record 동등성을 그대로 쓰지 않고 equals와 hashCode를 함께 재정의합니다. 두 add 반환값은 출력하지 않으며, 실제 최종 출력은 size=1입니다.

이 예의 조건

  • 원 main은 add 반환값을 출력하지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2별도 객체 둘의 id는 M-1이고 name은 Andongmin/Changed Name입니다. equals와 hashCode가 id만 사용해 두 번째 추가를 거부하고 size는 1입니다. 실선은 식별·등록 관계이며 원래 add 반환값은 출력하지 않습니다.
6M-1
7Andongmin
15M-1
16Changed Name
원 도표의 설명과 주석
구분내용
설명별도 객체 둘의 id는 M-1이고 name은 Andongmin/Changed Name입니다. equals와 hashCode가 id만 사용해 두 번째 추가를 거부하고 size는 1입니다. 실선은 식별·등록 관계이며 원래 add 반환값은 출력하지 않습니다.
전체 읽기 정보이름이 달라도 같은 회원 id는 한 원소로 남는다 이름이 달라도 같은 회원 id는 한 원소로 남는다 연습의 두 Member가 같은 M-1 id와 다른 이름을 갖고, id만 사용하는 equals·hashCode로 집합 크기가 하나가 되는 이유를 비교한다. 회원 식별 · id만 동등성에 사용 이름이 달라도 같은 회원 id는 한 원소로 남는다 이름이 달라도 회원 id가 M-1로 같으면 중복입니다. 이 연습은 equals와 hashCode를 함께 재정의해 id로 식별합니다. 첫 번째 객체 id "M-1" name "Andongmin" 집합 처리: 소스 추적 빈 집합에 첫 객체를 저장한다. 두 번째 객체 id "M-1" name "Changed Name" 집합 처리: 소스 추적 id가 같으므로 중복으로 판단한다. 이름 차이는 식별에 쓰지 않는다. 이 연습의 Member 는 기본 record 동등성을 그대로 쓰지 않고 equals 와 hashCode 를 함께 재정의합니다. 두 add 반환값은 출력하지 않으며, 실제 최종 출력은 size=1 입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 회원

M-1/Andongmin을 등록합니다.

  • 집합: 첫 객체
  • 식별자: M-1
  • id 기반 equals/hashCode로 등록한다.
두 번째 회원

M-1/Changed Name의 id를 비교합니다.

  • id 비교: 같음
  • name 비교: 사용하지 않음
  • 비식별 필드 변경은 새 회원으로 취급되지 않는다.
등록 거절

첫 객체를 보존하며 크기 1로 남습니다.

  • size: 1
  • 보관 name: Andongmin
  • 중복 추가가 기존 객체의 다른 필드를 갱신하지 않는다.

결과는 size=1입니다.

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

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

객체 해시 키의 최종 점검은 논리 동등성 필드, 그 동등성과 일치하는 hashCode, 저장 이후 식별 상태의 불변이라는 세 항목입니다.

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