equals·hashCode 계약
기본 참조 동등성과 가변 키 실패를 관찰하고 equals·hashCode 계약과 불변 PostKey의 중복 판단을 확인합니다.
문자열은 값 동등성 규칙이 이미 구현되어 있지만 직접 만든 클래스는 Object의 equals와 hashCode를 물려받습니다.
내용이 같은 두 인스턴스도 기본 equals에서는 다른 참조이므로 HashSet에 둘 다 들어갑니다.
객체 키를 쓸 때는 “어떤 필드가 같은 객체를 뜻하는가”를 먼저 정하고, 논리적으로 같은 객체의 hashCode가 같도록 두 메서드를 맞춰 구현해야 합니다.
Object의 기본 동등성
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=2id가 같아도 두 new 표현식은 다른 참조를 만듭니다.
기본 hashCode도 대개 인스턴스별 값이라 서로 다른 그룹으로 갈 수 있습니다.
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 위치는 다시 계산될 수 있습니다.
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;
}
}
}first, same, other의 원래 두 문자열과 팩터리에서 정규화한 category·title, 실제 add 결과를 비교한다.
| 원문 변수 | of에 전달한 값 | 정규화된 category / title | 실제 add 출력 |
|---|---|---|---|
first | "Java""Hash Set" | "java""hash set" | first=true |
same | " java ""hash set" | "java""hash set" | same=false |
other | "Spring""hash set" | "spring""hash set" | other=true |
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입니다. 표의 정규화된 필드 값은 팩터리 코드를 추적한 값이며 별도 출력은 아닙니다.
첫 add와 다른 카테고리는 true, 정규화 뒤 같은 키는 false입니다.
equals가 보는 필드와 hashCode가 보는 필드를 record가 동일하게 유지합니다.
일부 필드만 식별에 쓰려면 일반 클래스로 두 메서드를 함께 재정의하거나 별도 Key record를 만드는 편이 명확합니다.
가변 키와 그룹 탐색 실패
가변 객체의 hashCode 재료를 바꾸면 포함 여부는 새 hashCode의 그룹을 찾지만 원소는 이전 그룹에 남아 있습니다.
같은 참조를 전달해도 false가 될 수 있고 remove도 실패합니다.
Set 내부가 자동으로 원소를 옮겨 주지 않습니다.
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);
}
}
}원문의 같은 MutableKey 참조에 저장된 title이 hash에서 set으로 바뀌고 contains와 remove가 false가 되는 과정을 구분한다.
| 원문 단계 | key.title | 상태 또는 실제 출력 |
|---|---|---|
keys.add(key) | "hash" | 빈 집합에 같은 key 참조를 저장한다. 이 줄의 반환값은 출력하지 않는다. |
key.title= "set" | "set" | 객체 참조는 그대로이며 해시 계산에 쓰는 필드만 바뀐다. 집합에 다시 넣는 코드는 없다. |
keys.contains(key) | "set" | contains-same-reference=false |
keys.remove(key) | "set" | remove=false, 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
위 두 행은 소스의 저장·대입 추적이고 아래 두 행은 실제 출력입니다. 이번 JDK는 저장된 해시와 현재 조회 해시를 먼저 비교하므로 같은 참조만으로 찾지 못합니다. 가변 키를 바꾼 뒤의 Set 동작을 모든 구현에서 보장하는 규칙으로 해석하지 않습니다.
이 실패는 equals/hashCode 구현이 서로 일치해도 키 안정성이 별도 조건임을 보여 줍니다.
변경 가능한 상세 정보는 Set 값과 분리하고, 식별 Key는 불변으로 유지합니다.
equals 구현의 세부 선택
this == other는 같은 참조를 빠르게 통과시킵니다.
null은 instanceof 패턴에서 자동으로 false입니다.
상속을 허용하는 값 계층은 instanceof와 getClass 중 대칭성 문제를 신중히 다뤄야 합니다.
식별 값 객체를 final class나 record로 만들면 이 선택이 단순해집니다.
equals에는 반사성, 대칭성, 추이성, 일관성, null과의 비교 false가 요구됩니다.
hashCode도 객체가 동등성 관련 상태를 바꾸지 않는 동안 일관되어야 합니다.
IDE 생성 기능을 써도 어떤 필드를 선택했는지 사람이 검토합니다.
게시글 키와 상세 정보 분리
게시글 조회수는 나중에 수정될 수 있지만 카테고리·게시일·순번으로 만든 PostPublicationKey는 바뀌지 않습니다.
Map 또는 Set은 키만 식별에 사용하고, 조회수 변경은 값 객체 교체로 처리합니다.
키 값이 바뀌지 않으므로 상세 정보의 갱신이 키의 동등성과 hashCode를 바꾸지 않습니다. 아래 CLI는 상세 정보 갱신 대신 키 등록과 중복 결과를 출력합니다.
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가 달라져 규칙을 깨므로 제외합니다.
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);
}
}
}연습의 두 Member가 같은 M-1 id와 다른 이름을 갖고, id만 사용하는 equals·hashCode로 집합 크기가 하나가 되는 이유를 비교한다.
| 입력 순서 | id | name | 집합 처리: 소스 추적 |
|---|---|---|---|
| 첫 번째 객체 | "M-1" | "Andongmin" | 빈 집합에 첫 객체를 저장한다. |
| 두 번째 객체 | "M-1" | "Changed Name" | id가 같으므로 중복으로 판단한다. 이름 차이는 식별에 쓰지 않는다. |
- 첫 번째 객체
- id:
"M-1"name:"Andongmin"집합 처리: 소스 추적: 빈 집합에 첫 객체를 저장한다. - 두 번째 객체
- id:
"M-1"name:"Changed Name"집합 처리: 소스 추적: id가 같으므로 중복으로 판단한다. 이름 차이는 식별에 쓰지 않는다.
이 연습의 Member는 기본 record 동등성을 그대로 쓰지 않고 equals와 hashCode를 함께 재정의합니다. 두 add 반환값은 출력하지 않으며, 실제 최종 출력은 size=1입니다.
결과는 size=1입니다.
record 기본 구현을 일부 변경했으므로 equals와 hashCode를 함께 재정의했습니다.
더 단순한 설계는 MemberId record를 별도 키로 사용하는 것입니다.
객체 해시 키의 최종 점검은 논리 동등성 필드, 그 동등성과 일치하는 hashCode, 저장 이후 식별 상태의 불변이라는 세 항목입니다.
하나라도 충족하지 못하면 HashSet 결과는 입력 순서나 우연한 그룹 배치에 따라 틀릴 수 있습니다.