HashSet에 넣을 객체의 equals와 hashCode
직접 만든 클래스를 HashSet이나 HashMap의 키로 쓸 때 같음의 기준이 될 필드를 정하고, equals와 hashCode를 함께 구현하며, 저장한 키가 바뀌지 않게 만드는 방법을 배웁니다.
지금까지 해시 집합에 넣은 값은 정수와 문자열이었습니다. Integer와 String은 값을 기준으로 equals와 hashCode를 이미 재정의해 두었기 때문에, 내용이 같은 두 객체를 넣으면 HashSet이 알아서 하나만 남깁니다. 그런데 실제 프로그램에서는 회원 id나 '카테고리와 제목'처럼 직접 만든 클래스를 키로 쓰는 일이 많습니다. 이런 클래스가 아무것도 재정의하지 않으면 Object에서 물려받은 메서드가 그대로 쓰이고, HashSet은 내용이 같은 두 키를 서로 다른 원소로 봅니다.
Object 클래스를 다룬 글에서 equals와 hashCode가 지켜야 할 규칙을 배웠습니다. 이 글에서는 같은 규칙을 해시 집합 쪽에서 다시 살펴봅니다. HashSet이 두 메서드를 어떤 순서로 쓰는지 확인하고, 한쪽만 재정의하거나 저장한 키를 바꾸면 무엇이 틀어지는지 실행해 봅니다. 그런 다음 record로 안전한 키를 만드는 방법과 그때 주의할 점을 정리합니다.
내용이 같은 키가 둘 다 들어가는 이유
다음 프로그램은 id가 같은 MemberKey 두 개를 HashSet에 넣고 크기를 출력합니다. MemberKey는 id 필드 하나만 가진 클래스이고, equals와 hashCode는 재정의하지 않았습니다.
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가 같으면 같은 회원이라고 생각하지만, HashSet은 그 의도를 알 방법이 없습니다. HashSet이 두 원소가 같은지 판단할 때 쓰는 것은 원소 객체의 equals와 hashCode뿐이고, MemberKey는 두 메서드를 Object에서 물려받았습니다. Object의 equals는 ==와 같아서 같은 객체일 때만 true를 돌려주는데, new를 두 번 실행했으니 두 키는 서로 다른 객체입니다. 그래서 두 번째 add도 새 원소로 받아들여져 크기가 2가 됩니다.
Object의 hashCode도 보통 객체마다 다른 값을 돌려주므로 두 키는 대개 서로 다른 버킷으로 갑니다. 하지만 공식 문서(Javadoc)는 서로 다른 객체의 기본 해시 값이 반드시 다르다고 약속하지 않습니다. 설령 두 값이 같아 같은 버킷에 들어가더라도 마지막 equals 비교가 false이므로 결과는 똑같이 2입니다. 이 결과를 최종적으로 정하는 것은 equals입니다.
HashSet이 같은 원소를 찾는 두 단계
해시 테이블의 동작 원리와 정수 해시 집합 MyHashSetV1 만들기에서 만든 해시 집합은 원소를 두 단계로 찾았습니다. 표준 HashSet도 같은 방식으로 동작합니다.
- 찾는 객체의
hashCode()로 버킷 번호를 계산해, 비교할 후보를 그 버킷에 있는 원소로 좁힙니다. - 그 버킷의 후보들과
equals로 하나씩 비교해,true가 나오면 같은 원소로 봅니다.
add는 같은 원소를 찾으면 아무것도 넣지 않고 false를 돌려주고, 찾지 못하면 그 버킷에 새로 넣고 true를 돌려줍니다. contains와 remove도 같은 방법으로 원소를 찾습니다. 첫 단계는 후보를 빨리 좁히는 일이고, 정말 같은지는 두 번째 단계의 equals가 정합니다.
이 구조에서 두 메서드가 함께 지켜야 할 규칙이 나옵니다. a.equals(b)가 true이면 a.hashCode() == b.hashCode()도 true여야 합니다. 같은 원소인데 해시 값이 다르면 첫 단계에서 엉뚱한 버킷을 보게 되어, 두 번째 단계에서 비교할 기회조차 얻지 못하기 때문입니다. 또 equals 비교에 쓰는 정보가 바뀌지 않는 한, 같은 객체의 hashCode()는 프로그램이 실행되는 동안 언제나 같은 값을 돌려줘야 합니다. Object의 Javadoc은 이런 조건들을 hashCode의 일반 계약(general contract)이라고 부르는데, 모든 클래스의 hashCode가 지켜야 하는 규칙이라는 뜻입니다.
반대 방향은 필요하지 않습니다. 해시 값이 같다고 같은 원소일 필요는 없고, 문자열의 hashCode를 다룬 글에서 본 "Aa"와 "BB"처럼 서로 다른 객체가 같은 해시 값을 갖는 충돌은 언제든 생깁니다. 충돌한 객체들은 두 번째 단계의 equals가 구별하므로 결과가 틀리지는 않고, 비교할 후보가 늘어 느려질 뿐입니다.
극단적인 경우를 실행해 보겠습니다. 다음 ZeroHashKey는 모든 객체가 해시 값 0을 돌려주도록 hashCode만 재정의한 record입니다. equals는 record가 만들어 준 것을 그대로 쓰므로 id를 비교합니다.
import java.util.HashSet;
import java.util.Set;
public final class ConstantHashCodeCheck {
public static void main(String[] args) {
Set<ZeroHashKey> keys = new HashSet<>();
for (int id = 0; id < 5; id++) {
keys.add(new ZeroHashKey(id));
}
System.out.println("duplicate=" + keys.add(new ZeroHashKey(3)));
System.out.println("size=" + keys.size());
System.out.println("contains(4)=" + keys.contains(new ZeroHashKey(4))
+ ", contains(9)=" + keys.contains(new ZeroHashKey(9)));
}
private record ZeroHashKey(int id) {
@Override
public int hashCode() {
return 0;
}
}
}duplicate=false
size=5
contains(4)=true, contains(9)=false모든 키의 해시 값이 같은데도 결과는 모두 맞습니다. 중복된 3은 걸러졌고, 들어 있는 4는 찾았고, 없는 9는 없다고 답합니다. 같은 원소는 해시 값도 같아야 한다는 규칙은 지켰기 때문입니다. 하지만 다섯 원소가 모두 한 버킷에 모여 있으므로, 이 집합의 contains는 최악의 경우 원소 전부와 equals를 비교합니다. HashMap의 Javadoc도 해시 값이 같은 키를 많이 쓰는 것은 어떤 해시 테이블이든 느리게 만드는 확실한 방법이라고 경고합니다. 정리하면 규칙을 지키는 것은 정확성의 문제이고, 해시 값을 고르게 퍼뜨리는 것은 성능의 문제입니다.
equals와 hashCode 중 하나만 재정의하면
두 단계를 알고 나면 두 메서드를 왜 함께 재정의해야 하는지 분명해집니다. 다음 프로그램은 키 클래스 세 가지를 비교합니다. EqualsOnlyKey는 equals만, HashOnlyKey는 hashCode만, MemberKey는 둘 다 id를 기준으로 재정의했습니다. 클래스마다 id가 A-17인 키 두 개를 넣은 뒤, 새로 만든 같은 id의 키로 들어 있는지 물어봅니다.
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public final class OverrideBothOrNeither {
public static void main(String[] args) {
Set<EqualsOnlyKey> equalsOnly = new HashSet<>();
equalsOnly.add(new EqualsOnlyKey("A-17"));
equalsOnly.add(new EqualsOnlyKey("A-17"));
System.out.println("equals-only: size=" + equalsOnly.size()
+ ", contains=" + equalsOnly.contains(new EqualsOnlyKey("A-17")));
Set<HashOnlyKey> hashOnly = new HashSet<>();
hashOnly.add(new HashOnlyKey("A-17"));
hashOnly.add(new HashOnlyKey("A-17"));
System.out.println("hashCode-only: size=" + hashOnly.size()
+ ", contains=" + hashOnly.contains(new HashOnlyKey("A-17")));
Set<MemberKey> both = new HashSet<>();
both.add(new MemberKey("A-17"));
both.add(new MemberKey("A-17"));
System.out.println("both: size=" + both.size()
+ ", contains=" + both.contains(new MemberKey("A-17")));
}
private static final class EqualsOnlyKey {
private final String id;
private EqualsOnlyKey(String id) {
this.id = id;
}
@Override
public boolean equals(Object other) {
return other instanceof EqualsOnlyKey key && id.equals(key.id);
}
}
private static final class HashOnlyKey {
private final String id;
private HashOnlyKey(String id) {
this.id = id;
}
@Override
public int hashCode() {
return id.hashCode();
}
}
private static final class MemberKey {
private final String id;
private MemberKey(String id) {
this.id = Objects.requireNonNull(id, "id");
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
return other instanceof MemberKey key && id.equals(key.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}
}equals-only: size=2, contains=false
hashCode-only: size=2, contains=false
both: size=1, contains=true첫 줄의 EqualsOnlyKey는 두 번째 단계만 고친 경우입니다. equals는 id를 비교하지만 hashCode는 Object의 것이라 두 키의 해시 값이 대개 다르고, 그래서 두 키는 대개 처음부터 서로 다른 버킷을 봅니다. equals를 호출할 기회가 없으니 두 번째 add도 성공하고, 세 번째 키로 찾을 때도 또 다른 버킷을 보므로 false입니다. 해시 값이 다른 두 키가 우연히 같은 버킷에 들어가더라도, 아래 '더 깊이'에서 보듯 OpenJDK는 해시 값부터 비교하므로 equals를 호출하지 않고 결과도 같습니다.
둘째 줄의 HashOnlyKey는 반대로 첫 단계만 고친 경우입니다. 해시 값이 같아 두 키가 같은 버킷에서 만나지만, 거기서 호출하는 equals가 Object의 것이라 같은 객체인지만 묻습니다. 두 키는 다른 객체이므로 false가 나오고, 결국 중복이 그대로 들어갑니다. 마지막 줄처럼 두 메서드를 같은 필드로 함께 재정의해야 두 단계가 모두 id를 기준으로 판단합니다.
equals만 재정의하는 실수는 컴파일러의 도움을 받아 찾을 수 있습니다. 위 파일을 javac -Xlint:overrides로 컴파일하면 다음 경고가 나옵니다. 기본 설정에서는 이 경고가 나오지 않고, hashCode만 재정의한 HashOnlyKey에 대해서는 이 옵션을 켜도 아무 경고가 없습니다.
lab/OverrideBothOrNeither.java:26: warning: [overrides] Class EqualsOnlyKey overrides equals, but neither it nor any superclass overrides hashCode method
private static final class EqualsOnlyKey {
^
1 warning두 메서드를 재정의할 때는 @Override도 빠뜨리지 않습니다. equals(MemberKey other)처럼 매개변수 타입을 자기 클래스로 적으면 재정의가 아니라 오버로딩이 되어, HashSet은 여전히 Object의 equals를 호출합니다. @Override를 붙여 두면 이 실수가 컴파일 오류로 바로 드러납니다. 이 함정은 Object 클래스를 다룬 글에서 자세히 다루었습니다.
같음의 기준이 될 필드 고르기
키 클래스를 만들 때 가장 먼저 정할 것은 '어떤 필드가 같으면 같은 키인가'입니다. 회원이라면 이름은 바뀔 수 있어도 회원 id는 그대로이니, id만 같으면 같은 회원으로 볼 수 있습니다. 게시글이라면 카테고리와 제목을 묶어서 식별자로 삼을 수 있습니다. 이 글에서는 이렇게 고른 필드를 식별 필드라고 부르겠습니다. 조회 수처럼 식별과 상관없이 계속 바뀌는 값은 식별 필드에 넣지 않습니다.
식별 필드를 정하면 두 메서드가 할 일도 정해집니다. equals는 식별 필드를 모두 비교하고, hashCode는 식별 필드만으로 계산합니다. 이때 hashCode에 식별 필드가 아닌 값을 섞으면 안 됩니다. 예를 들어 equals는 id만 비교하는데 hashCode에 이름까지 넣으면, id가 같고 이름이 다른 두 회원은 equals로는 같은데 해시 값이 달라 앞의 규칙이 깨집니다. 반대로 식별 필드 가운데 일부만으로 해시 값을 계산하는 것은 규칙 위반은 아니지만 충돌이 늘어납니다.
record로 키를 만들고 입력을 정규화하기
record는 모든 구성 요소로 equals와 hashCode를 만들어 주므로, 구성 요소를 식별 필드로만 채우면 두 메서드가 같은 필드를 쓰도록 저절로 맞춰집니다. 다음 PostKey는 카테고리와 제목을 구성 요소로 갖습니다. 다만 "Java"와 " java "처럼 표기만 다른 입력을 같은 키로 보려면, record에 넣기 전에 값을 한 가지 형태로 맞춰야 합니다. 이 작업을 정규화(normalization)라고 하며, 여기서는 정적 팩터리 메서드 of가 앞뒤 공백을 지우고 소문자로 바꿉니다. Locale.ROOT를 쓰는 이유는 문자열의 hashCode를 다룬 글의 태그 정규화와 같습니다.
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=true
same=false
other=true
equal=true, hash=true, size=2PostKey.of로 정규화한 세 키의 추가 결과
first/same/other record 키를 정규화하고 차례로 add하여 size 1→1→2가 됩니다. 두 식별 필드와 중복 보존의 상태를 추적할 수 있습니다.
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입니다. 표의 정규화된 필드 값은 팩터리 코드를 추적한 값이며 별도 출력은 아닙니다.
이 예의 조건
- record 두 컴포넌트로 식별한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 영역별 first/same/other의 팩터리 입력과 정규화 값을 연결합니다. first/same은 java,hash set으로 동등하고 other는 spring 카테고리라 달라집니다. 세 add 순서 first/same/other의 결과는 true/false/true이며 size는 2입니다. |
| 5 | Java |
| 6 | Hash Set |
| 8 | java |
| 9 | hash set |
| 15 | 공백 java |
| 16 | hash set |
| 18 | java |
| 19 | hash set |
| 25 | Spring |
| 26 | hash set |
| 28 | spring |
| 29 | hash 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를 정규화하여 등록합니다. |
|
|
| same 생성/추가 공백 java/hash set은 동일 키가 됩니다. |
|
|
| other 생성/추가 Spring의 category는 다른 키로 남습니다. |
|
|
PostKey.of로 정규화한 세 키의 추가 결과
first/same/other record 키를 정규화하고 차례로 add하여 size 1→1→2가 됩니다. 두 식별 필드와 중복 보존의 상태를 추적할 수 있습니다.
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입니다. 표의 정규화된 필드 값은 팩터리 코드를 추적한 값이며 별도 출력은 아닙니다.
이 예의 조건
- record 두 컴포넌트로 식별한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 영역별 first/same/other의 팩터리 입력과 정규화 값을 연결합니다. first/same은 java,hash set으로 동등하고 other는 spring 카테고리라 달라집니다. 세 add 순서 first/same/other의 결과는 true/false/true이며 size는 2입니다. |
| 5 | Java |
| 6 | Hash Set |
| 8 | java |
| 9 | hash set |
| 15 | 공백 java |
| 16 | hash set |
| 18 | java |
| 19 | hash set |
| 25 | Spring |
| 26 | hash set |
| 28 | spring |
| 29 | hash 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를 정규화하여 등록합니다. |
|
|
| same 생성/추가 공백 java/hash set은 동일 키가 됩니다. |
|
|
| other 생성/추가 Spring의 category는 다른 키로 남습니다. |
|
|
first는 java와 hash set으로 정규화되어 빈 집합에 들어가므로 true입니다. same은 공백과 대소문자가 달랐지만 정규화하면 first와 똑같은 두 문자열이 되므로, record의 equals가 true를 돌려주고 add는 false입니다. other는 제목이 같아도 카테고리가 spring이라 다른 키이므로 들어갑니다. 마지막 줄은 equals가 true인 두 키의 해시 값이 실제로 같다는 것을 확인하고, 크기는 서로 다른 키 두 개를 담은 2입니다.
정규화는 record가 해 주는 일이 아니라 of 메서드의 규칙이라는 점에 주의해야 합니다. 같은 바깥 클래스 안에서 new PostKey("Java", "Hash Set")을 직접 호출하면 of를 거치지 않으므로, 대문자와 공백이 그대로 남은 키가 만들어집니다. 모든 생성 경로에 같은 규칙을 적용하려면 정규화를 컴팩트 생성자(compact constructor)로 옮깁니다. 컴팩트 생성자는 매개변수 목록을 쓰지 않는 record 전용 생성자로, 본문에서 매개변수에 새 값을 대입하면 그 값이 필드에 저장됩니다.
private record PostKey(String category, String title) {
PostKey {
category = normalize(category);
title = normalize(title);
}
// normalize 메서드는 앞의 코드와 같습니다.
}이렇게 바꾸면 of가 없어도 new PostKey("Java", "Hash Set")이 PostKey[category=java, title=hash set]이 됩니다. 반대로 구성 요소 가운데 일부만 식별에 써야 한다면 record가 만들어 주는 메서드가 맞지 않습니다. 그럴 때는 두 메서드를 식별 필드 기준으로 함께 재정의하거나, 식별 필드만 담은 키 record를 따로 만듭니다. 연습 문제 1에서 이 경우를 다룹니다.
equals 규칙이 깨지면 넣는 순서에 따라 결과가 달라진다
Object 클래스를 다룬 글에서는 필드를 더한 하위 클래스가 instanceof로 자기 타입만 받아들이면 equals의 대칭성이 깨지는 예를 보았습니다. post.equals(pinned)는 true인데 pinned.equals(post)는 false인 경우입니다. 이런 클래스를 HashSet에 넣으면 무슨 일이 생기는지 확인해 보겠습니다. 아래 Post와 PinnedPost는 그 글의 예제와 같고, 집합 두 개에 같은 두 객체를 순서만 바꿔 넣습니다.
import java.util.HashSet;
import java.util.Set;
public final class SymmetryBreakInHashSet {
public static void main(String[] args) {
Post post = new Post("object", 40);
PinnedPost pinned = new PinnedPost("object", 40, 1);
Set<Post> postFirst = new HashSet<>();
postFirst.add(post);
postFirst.add(pinned);
Set<Post> pinnedFirst = new HashSet<>();
pinnedFirst.add(pinned);
pinnedFirst.add(post);
System.out.println("post-first=" + postFirst.size() + ", pinned-first=" + pinnedFirst.size());
}
private static class Post {
final String title;
final int viewCount;
Post(String title, int viewCount) {
this.title = title;
this.viewCount = viewCount;
}
@Override
public boolean equals(Object other) {
if (!(other instanceof Post that)) return false;
return viewCount == that.viewCount && title.equals(that.title);
}
@Override
public int hashCode() {
return 31 * title.hashCode() + viewCount;
}
}
private static final class PinnedPost extends Post {
final int rank;
PinnedPost(String title, int viewCount, int rank) {
super(title, viewCount);
this.rank = rank;
}
@Override
public boolean equals(Object other) {
if (!(other instanceof PinnedPost that)) return false;
return super.equals(that) && rank == that.rank;
}
}
}post-first=2, pinned-first=1같은 두 객체를 넣었는데 넣은 순서에 따라 크기가 2이기도 하고 1이기도 합니다. PinnedPost는 hashCode를 물려받아 rank를 계산에 넣지 않으므로 두 객체의 해시 값은 같고, 따라서 결과는 같은 버킷 안의 equals 비교로 정해집니다. 이 실행에서 HashSet은 새로 넣는 객체의 equals에 이미 들어 있는 원소를 넘겨 비교했습니다. post가 먼저 들어간 집합에서는 pinned.equals(post)가 false라 pinned가 새 원소로 들어가고, 순서를 바꾼 집합에서는 post.equals(pinned)가 true라 post가 중복으로 거부됩니다.
어느 쪽 객체의 equals를 호출할지는 Javadoc이 정하지 않은 구현의 세부 사항입니다. equals가 대칭성을 지키면 어느 쪽을 호출하든 결과가 같으므로 이런 문제가 생기지 않습니다. 해시 키로 쓸 클래스는 final 클래스나 record로 만들어 하위 클래스가 비교 기준을 바꾸지 못하게 하는 것이 가장 간단한 해결책입니다.
더 깊이: OpenJDK의 HashSet은 equals보다 해시 값을 먼저 비교합니다
이 단락은 Javadoc이 약속하는 동작이 아니라 OpenJDK 21의 구현 방식을 설명합니다. HashSet은 원소를 내부 HashMap의 키로 저장하고, HashMap은 항목마다 키의 해시 값을 함께 저장해 둡니다. 원소를 찾을 때는 버킷 안의 항목마다 먼저 저장된 해시 값이 찾는 객체의 해시 값과 같은지 보고, 같을 때만 ==로 같은 객체인지 확인한 다음 마지막으로 equals를 호출합니다. 정수 비교로 대부분의 후보를 먼저 걸러 내므로, equals가 비싼 객체일수록 이득이 큽니다.
이 순서를 알면 몇 가지 결과가 설명됩니다. equals가 무조건 true를 돌려주는 클래스라도 해시 값이 다르면 같은 원소로 보지 않습니다. 예를 들어 해시 값이 1과 17인 두 객체는 버킷 16개인 기본 크기의 HashSet에서 같은 버킷에 들어가지만, contains는 false를 돌려줍니다. 또 비교할 때 호출하는 것은 찾는 객체 쪽의 equals이고, 인수가 이미 저장된 원소입니다. 앞의 대칭성 예제에서 넣는 순서에 따라 결과가 갈린 것이 이 때문입니다.
저장한 뒤 키를 바꾸면 찾을 수 없다
equals와 hashCode를 같은 필드로 정확히 작성해도 문제가 하나 더 남습니다. 다음 MutableKey는 title을 기준으로 두 메서드를 올바르게 재정의했지만, title 필드가 final이 아니어서 집합에 넣은 뒤에도 값을 바꿀 수 있습니다. 프로그램은 키를 넣은 뒤 title을 "hash"에서 "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;
}
@Override
public boolean equals(Object other) {
return this == other
|| other instanceof MutableKey key && Objects.equals(title, key.title);
}
@Override
public int hashCode() {
return Objects.hash(title);
}
}
}contains-same-reference=false
remove=false, size=1같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다
등록한 MutableKey.title을 바꾼 뒤 contains/remove 실패와 size 1 유지가 관찰됩니다. 등록·변경·조회 시점은 유익하되 변경된 Set key 행동을 명세 보장으로 일반화하지 않습니다.
원문의 같은 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의 동작은 계약상 지정되지 않습니다.
이 예의 조건
- 변경된 Set key의 동작은 계약상 지정되지 않는다.
- 특정 그룹번호를 추측하지 않는 원 실행 관찰이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 저장한 같은 MutableKey의 title을 hash에서 set으로 바꾸고 다시 등록하지 않습니다. 두 영역은 저장 정보와 후속 조회 조건을 구분하며 원래 JDK 실행의 contains는 false/remove는 false/size는 1입니다. 특정 그룹 번호를 추측하지 않으며 변경된 키의 Set 동작은 계약상 지정되지 않습니다. |
| 19 | remove=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를 넣습니다. |
|
|
| 필드 변경 새 객체나 재등록 없이 title을 set으로 바꿉니다. |
|
|
| 원 실행 조회 contains/remove가 실패하고 원소 수는 남습니다. |
|
|
같은 참조의 title을 바꾼 뒤 조회와 제거가 실패한다
등록한 MutableKey.title을 바꾼 뒤 contains/remove 실패와 size 1 유지가 관찰됩니다. 등록·변경·조회 시점은 유익하되 변경된 Set key 행동을 명세 보장으로 일반화하지 않습니다.
원문의 같은 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의 동작은 계약상 지정되지 않습니다.
이 예의 조건
- 변경된 Set key의 동작은 계약상 지정되지 않는다.
- 특정 그룹번호를 추측하지 않는 원 실행 관찰이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 저장한 같은 MutableKey의 title을 hash에서 set으로 바꾸고 다시 등록하지 않습니다. 두 영역은 저장 정보와 후속 조회 조건을 구분하며 원래 JDK 실행의 contains는 false/remove는 false/size는 1입니다. 특정 그룹 번호를 추측하지 않으며 변경된 키의 Set 동작은 계약상 지정되지 않습니다. |
| 19 | remove=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를 넣습니다. |
|
|
| 필드 변경 새 객체나 재등록 없이 title을 set으로 바꿉니다. |
|
|
| 원 실행 조회 contains/remove가 실패하고 원소 수는 남습니다. |
|
|
집합에 들어 있는 바로 그 객체를 넘겼는데도 찾지 못했습니다. 무슨 일이 있었는지 시점별로 따라가 보겠습니다.
| 시점 | key.title | 일어나는 일 |
|---|---|---|
keys.add(key) | hash | "hash"로 계산한 해시 값에 따라 버킷을 정해 저장합니다. 크기는 1입니다. |
key.title = "set" | set | 객체의 필드만 바뀝니다. 집합은 이 변화를 알지 못하고, 원소를 옮기지도 않습니다. |
keys.contains(key) | set | "set"으로 계산한 새 해시 값으로 찾으므로, 처음 저장한 위치의 원소와 맞지 않아 false입니다. |
keys.remove(key) | set | 같은 이유로 지울 원소를 찾지 못해 false이고, 크기는 1 그대로입니다. |
원소는 사라진 것이 아니라 처음 저장된 자리에 그대로 있습니다. 실제로 이 상태에서 title을 다시 "hash"로 되돌리면 contains와 remove가 다시 true를 돌려줍니다. 집합은 원소를 넣을 때의 해시 값을 기준으로 자리를 정하는데, 그 뒤에 해시 값이 바뀌면 그 자리를 다시 찾아갈 방법이 없는 것입니다.
Set의 Javadoc은 가변 객체를 원소로 쓸 때 각별히 주의하라고 하면서, 원소가 집합 안에 있는 동안 equals 비교에 영향을 주도록 값이 바뀌면 집합의 동작은 정해져 있지 않다고 적고 있습니다. Map의 Javadoc도 키에 대해 같은 경고를 합니다. 그러니 위의 false와 size=1은 OpenJDK 21에서 실행한 결과일 뿐, 어떤 구현에서나 똑같이 나온다고 약속된 결과가 아닙니다. 확실한 것은 이런 상태를 만들지 말아야 한다는 것뿐입니다.
이 문제를 피하는 가장 좋은 방법은 키의 식별 필드를 바꿀 수 없게 만드는 것입니다. 필드를 final로 선언하고, String이나 LocalDate처럼 그 자체로 바뀌지 않는 값만 담으면 됩니다. 식별 정보를 정말 바꿔야 한다면 키를 고치지 말고, 바뀌기 전의 키로 원소를 먼저 지운 뒤 새 키를 넣습니다.
바뀌는 값은 키에서 빼서 따로 보관하기
조회 수처럼 계속 바뀌어야 하는 값이 있다면 키에 넣지 말고 따로 보관합니다. 이럴 때 자주 쓰는 것이 HashMap입니다. HashMap은 키 하나에 값 하나를 짝지어 저장하는 해시 자료구조이고, 키를 찾는 방법은 HashSet과 같습니다. Map은 16장에서 자세히 다룹니다. 여기서는 put(키, 값)이 같은 키가 이미 있으면 값만 바꾸고 이전 값을 돌려주며, 처음 넣는 키라면 null을 돌려준다는 것만 알면 됩니다.
다음 예제는 카테고리와 순번으로 만든 불변 키에 조회 수를 짝지어 저장합니다. 처음 넣은 키 객체와 다른 객체로 조회 수를 갱신한 뒤, 처음 키로 다시 읽어 봅니다.
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=55same은 stored와 다른 객체이지만, record의 equals가 true이고 해시 값도 같으므로 put은 기존 항목을 찾아냅니다. 그래서 항목을 새로 만들지 않고 값 40을 55로 바꾼 뒤 이전 값 40을 돌려주고, 크기는 1로 남습니다. get(stored)가 55를 돌려주는 것도 같은 항목을 찾았기 때문입니다. 조회 수가 바뀌는 동안 키의 카테고리와 순번은 한 번도 바뀌지 않았으므로, 키의 equals와 hashCode도 처음 그대로입니다.
키를 불변으로 만들 때는 입력 검사도 한곳에 모을 수 있습니다. 다음 PostPublicationKey는 카테고리, 게시일, 순번으로 게시글 하나를 식별하고, 컴팩트 생성자에서 잘못된 값을 거부합니다. LocalDate는 불변 날짜 클래스와 equals·hashCode에서 본 것처럼 불변이고 날짜 값으로 equals를 비교하므로 식별 필드로 쓰기에 알맞습니다.
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");
}
}
}
}PostPublicationKey[category=java, publishedOn=2026-07-14, sequence=1]=accepted
PostPublicationKey[category=java, publishedOn=2026-07-14, sequence=1]=duplicate
PostPublicationKey[category=java, publishedOn=2026-07-14, sequence=2]=accepted
unique=2두 번째 키는 첫 키와 다른 객체이지만 세 구성 요소가 모두 같으므로 duplicate가 되고, 순번만 다른 세 번째 키는 새 원소로 들어갑니다. 중복 판단은 record가 만든 equals와 hashCode가 하는 일입니다. 집합을 LinkedHashSet으로 만든 것은 나중에 순회할 때 등록한 순서대로 꺼내기 위해서인데, 이 프로그램은 register를 호출할 때마다 바로 출력하므로 출력 순서는 그와 상관없이 호출 순서입니다. LinkedHashSet의 순서 규칙은 HashSet·LinkedHashSet·TreeSet 비교와 선택에서 다룹니다.
record의 구성 요소가 가변 객체라면
record의 필드는 final입니다. 하지만 final은 필드가 다른 객체를 가리키도록 바꿀 수 없다는 뜻이지, 필드가 가리키는 객체의 내용까지 바꿀 수 없다는 뜻이 아닙니다. Record의 Javadoc이 record를 '얕은 불변(shallowly immutable)'이라고 표현하는 이유입니다. 구성 요소가 String, LocalDate, int처럼 바뀌지 않는 값이면 record 전체가 사실상 불변이지만, ArrayList 같은 가변 객체를 담으면 사정이 달라집니다.
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=trueTagKey는 넘겨받은 목록을 복사하지 않고 그 참조를 그대로 저장합니다. 그래서 key.tags()와 바깥의 tags 변수는 같은 목록을 가리키고, 바깥에서 "set"을 추가하자 key가 담은 목록의 내용도 바뀌었습니다. record의 hashCode는 구성 요소인 목록의 hashCode로 계산되므로 해시 값도 달라졌습니다. 만약 이 key가 HashSet에 들어 있었다면, setter가 하나도 없는데도 앞의 MutableKey와 같은 문제가 생겼을 것입니다.
해결 방법은 컴팩트 생성자에서 목록을 복사해 두는 것입니다. TagKey를 다음처럼 바꾸고, main 끝에 System.out.println("tags=" + key.tags());를 한 줄 추가해 실행해 보세요.
private record TagKey(List<String> tags) {
TagKey {
tags = List.copyOf(tags);
}
}same-list=false
hash-changed=false
tags=[java]List.copyOf는 원소를 담은 수정할 수 없는 목록을 돌려줍니다. 그래서 key는 바깥 목록과 연결이 끊긴 자기만의 목록을 갖게 되고, 바깥에서 무엇을 추가해도 key의 내용과 해시 값은 그대로입니다. 복사본은 수정할 수도 없어서 key.tags().add("x")는 UnsupportedOperationException을 던집니다. 또 List.copyOf는 null 원소가 들어 있으면 NullPointerException을 던지므로, 원소에 null을 허용할지도 함께 정해야 합니다.
다만 이 복사는 목록만 새로 만드는 얕은 복사입니다. 원소 객체까지 복사하지는 않으므로, 원소가 가변 객체라면 그 원소의 상태 변화는 막지 못합니다. 이 예제의 원소는 불변인 String이므로 문제가 없습니다.
정리
HashSet은hashCode()로 버킷을 골라 후보를 좁히고, 그 안에서equals로 같은 원소인지 확인합니다. 그래서equals로 같은 두 객체는 반드시 같은 해시 값을 가져야 합니다. 해시 값이 같은 서로 다른 객체(충돌)는 허용되지만 느려집니다.equals와hashCode는 같은 식별 필드로 함께 재정의합니다. 한쪽만 재정의하면 두 단계 중 하나가 여전히 같은 객체인지만 묻게 되어 중복이 들어갑니다.- record는 구성 요소로 두 메서드를 맞춰 만들어 줍니다. 입력 정규화나 검사는 record가 해 주지 않으므로 컴팩트 생성자에 둡니다.
- 집합에 넣은 키의 식별 필드를 바꾸면 그 원소를 찾을 수도 지울 수도 없게 됩니다. Javadoc은 이때의 동작을 정하지 않으므로, 키는 불변으로 만들고 바뀌는 값은
Map의 값처럼 키 밖에 둡니다. - record는 얕은 불변입니다. 가변 객체를 구성 요소로 받으면
List.copyOf처럼 컴팩트 생성자에서 복사합니다.
연습 문제
- 이름은 바뀔 수 있지만 id가 같으면 같은 회원인
Member를 작성하세요. 같은 id와 다른 이름을 가진 두 객체를HashSet에 넣었을 때 크기가 1이어야 합니다. - 다음
Tag의hashCode는 이름의 길이를 돌려줍니다. 이 구현이equals와hashCode의 규칙을 어기는지 판단하고, 프로그램의 출력을 예측하세요.
import java.util.HashSet;
import java.util.Set;
public final class TagLengthHash {
public static void main(String[] args) {
Set<Tag> tags = new HashSet<>();
System.out.println(tags.add(new Tag("hash")));
System.out.println(tags.add(new Tag("tree")));
System.out.println(tags.add(new Tag("hash")));
System.out.println("size=" + tags.size());
}
private static final class Tag {
private final String name;
Tag(String name) {
this.name = name;
}
@Override
public boolean equals(Object other) {
return other instanceof Tag tag && name.equals(tag.name);
}
@Override
public int hashCode() {
return name.length();
}
}
}- 다음
Post는 제목과 조회 수를 모두equals와hashCode에 씁니다. 게시글을 집합에 넣은 뒤 조회 수를 1 올리면 어떤 출력이 나올지 예측하고, 왜 그런지 설명하세요. 어떻게 고치면 될까요?
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public final class MutablePostKey {
public static void main(String[] args) {
Post post = new Post("hash", 10);
Set<Post> posts = new HashSet<>();
posts.add(post);
post.viewCount++;
System.out.println("contains=" + posts.contains(post));
System.out.println("add-again=" + posts.add(post));
System.out.println("size=" + posts.size());
}
private static final class Post {
private final String title;
private int viewCount;
Post(String title, int viewCount) {
this.title = title;
this.viewCount = viewCount;
}
@Override
public boolean equals(Object other) {
return other instanceof Post that
&& viewCount == that.viewCount
&& title.equals(that.title);
}
@Override
public int hashCode() {
return Objects.hash(title, viewCount);
}
}
}해설 보기
1번. 식별 필드는 id 하나입니다. 이름을 hashCode에 넣으면 equals가 true인 두 회원의 해시 값이 달라질 수 있으므로 계산에서 뺍니다. 아래 풀이는 record에 두 구성 요소를 두되, equals와 hashCode를 id 기준으로 함께 재정의합니다.
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");
}
@Override
public boolean equals(Object other) {
return this == other || other instanceof Member member && id.equals(member.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
}size=1이름이 달라도 같은 회원 id는 한 원소로 남는다
같은 id의 두 번째 Member는 name이 달라도 같다고 판단되어 거절되고 첫 객체만 남습니다. 식별 필드와 비식별 데이터의 차이를 실제 두 add로 보여 줍니다.
연습의 두 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입니다.
이 예의 조건
- 원 main은 add 반환값을 출력하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 별도 객체 둘의 id는 M-1이고 name은 Andongmin/Changed Name입니다. equals와 hashCode가 id만 사용해 두 번째 추가를 거부하고 size는 1입니다. 실선은 식별·등록 관계이며 원래 add 반환값은 출력하지 않습니다. |
| 6 | M-1 |
| 7 | Andongmin |
| 15 | M-1 |
| 16 | Changed 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/Changed Name의 id를 비교합니다. |
|
|
| 등록 거절 첫 객체를 보존하며 크기 1로 남습니다. |
|
|
이름이 달라도 같은 회원 id는 한 원소로 남는다
같은 id의 두 번째 Member는 name이 달라도 같다고 판단되어 거절되고 첫 객체만 남습니다. 식별 필드와 비식별 데이터의 차이를 실제 두 add로 보여 줍니다.
연습의 두 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입니다.
이 예의 조건
- 원 main은 add 반환값을 출력하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 별도 객체 둘의 id는 M-1이고 name은 Andongmin/Changed Name입니다. equals와 hashCode가 id만 사용해 두 번째 추가를 거부하고 size는 1입니다. 실선은 식별·등록 관계이며 원래 add 반환값은 출력하지 않습니다. |
| 6 | M-1 |
| 7 | Andongmin |
| 15 | M-1 |
| 16 | Changed 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/Changed Name의 id를 비교합니다. |
|
|
| 등록 거절 첫 객체를 보존하며 크기 1로 남습니다. |
|
|
두 번째 회원은 이름이 달라도 id가 M-1로 같으므로 equals가 true이고 해시 값도 같습니다. 그래서 두 번째 add는 거부되고, 집합에는 처음 넣은 Andongmin 객체가 그대로 남습니다. 중복 add는 기존 원소를 새 객체로 바꾸지 않는다는 점도 기억해 두세요. record의 기본 구현을 바꾸었으므로 두 메서드를 반드시 함께 재정의했습니다. 더 단순한 설계는 id만 담은 MemberId record를 따로 만들어 키로 쓰는 것입니다. 그러면 직접 재정의할 메서드가 하나도 없습니다.
2번. 규칙을 어기지 않습니다. equals로 같은 두 Tag는 이름이 같으니 길이도 같고, 따라서 해시 값도 같습니다. 출력은 다음과 같습니다.
true
true
false
size=2"hash"와 "tree"는 길이가 4로 같아 해시 값이 충돌하지만, 같은 버킷 안에서 equals가 이름을 비교해 서로 다른 원소로 구별합니다. 세 번째 add는 같은 이름이라 false입니다. 결과는 맞지만 길이가 같은 태그가 모두 한 버킷에 모이므로 태그가 많아질수록 느려집니다. name.hashCode()를 쓰거나 Tag를 record Tag(String name)으로 만들면 해시 값이 훨씬 고르게 퍼집니다.
3번. OpenJDK 21에서 실행하면 다음과 같이 나옵니다.
contains=false
add-again=true
size=2조회 수가 바뀌면서 해시 값이 달라졌으므로, contains는 새 해시 값으로 찾다가 처음 저장한 원소를 만나지 못하고 false를 돌려줍니다. add도 같은 방법으로 중복을 확인하므로 원소가 없다고 판단하고, 같은 객체를 한 번 더 넣습니다. 그 결과 집합 하나에 같은 객체가 두 번 들어가 크기가 2가 되었습니다. 앞에서 보았듯이 Javadoc은 이런 상황의 동작을 정하지 않으므로, 다른 구현에서는 결과가 다를 수 있습니다. 고치려면 바뀌는 조회 수를 식별 필드에서 빼고 제목처럼 바뀌지 않는 값으로만 equals와 hashCode를 만들거나, 조회 수를 Map<PostKey, Integer>의 값으로 따로 보관합니다.
다음 글
제네릭 해시 집합과 재해시 구현하기에서는 int만 담던 MyHashSetV1을 어떤 타입이든 담는 제네릭 집합으로 바꿉니다. 이 글에서 정리한 규칙대로 equals와 hashCode를 구현한 객체라면 무엇이든 넣을 수 있고, 원소가 늘어나면 버킷 배열을 키우는 재해시도 함께 구현합니다.