바쁜 분은 이것만
- HashSet·HashMap은 hashCode로 칸을 찾고, equals로 같은지 확인합니다. 둘 중 하나만 오버라이딩하면 중복이 그대로 들어가요
equals는Objects.equals(),hashCode는Objects.hash(필드...)로 같은 필드를 써서 같이 만들기- 키로 넣은 객체의 필드를 나중에 바꾸면
get()이 null. 키는 바뀌지 않는 값으로(record가 편함)
같은 이름·나이로 만든 회원 객체를 HashSet에 두 번 넣었는데 size()가 2로 나오거나, HashMap에 넣어 둔 키를 똑같은 값으로 새로 만들어 get() 했는데 null이 나오는 경우가 있습니다. 원인은 equals()와 hashCode()예요. OpenJDK 27에서 하나씩 빼 보면서 재현해 봤습니다.
1. 아무것도 안 했을 때: 같은 값인데 2개
import java.util.HashSet;
import java.util.Set;
public class H1 {
static class Member {
String name;
int age;
Member(String name, int age) { this.name = name; this.age = age; }
public String toString() { return name + "(" + age + ")"; }
}
public static void main(String[] args) {
Set<Member> set = new HashSet<>();
set.add(new Member("민수", 20));
set.add(new Member("민수", 20));
System.out.println("size = " + set.size());
System.out.println(set.contains(new Member("민수", 20)));
}
}
size = 2
false
오버라이딩하지 않으면 Object의 기본 equals()를 씁니다. 기본 equals는 ==와 같아서 "같은 객체(같은 주소)인가"만 봐요. new로 두 번 만들었으니 다른 객체이고, 그래서 둘 다 들어갑니다. 문자열에서 ==와 equals()가 다른 것과 같은 이야기예요(자바 문자열 비교 == 와 equals() 차이).
2. equals만 오버라이딩하면?
import java.util.HashSet;
import java.util.Set;
public class H2 {
static class Member {
String name;
int age;
Member(String name, int age) { this.name = name; this.age = age; }
public String toString() { return name + "(" + age + ")"; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Member m)) return false;
return age == m.age && name.equals(m.name);
}
}
public static void main(String[] args) {
Member a = new Member("민수", 20);
Member b = new Member("민수", 20);
System.out.println("a.equals(b) = " + a.equals(b));
System.out.println("hashCode a = " + a.hashCode() + ", b = " + b.hashCode());
Set<Member> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println("size = " + set.size() + " " + set);
}
}
a.equals(b) = true
hashCode a = 1436901839, b = 999522307
size = 2 [민수(20), 민수(20)]
equals()는 true인데도 2개가 들어갔습니다. hashCode 숫자가 서로 다르기 때문이에요(이 숫자는 실행할 때마다 바뀝니다). HashSet은 먼저 hashCode로 "어느 칸에 넣을지"를 정하고, 같은 칸에 있는 것끼리만 equals로 비교해요. 칸이 다르면 equals는 불려 보지도 못하고 다른 값으로 취급됩니다.
3. hashCode만 오버라이딩하면?
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public class H3 {
static class Member {
String name;
int age;
Member(String name, int age) { this.name = name; this.age = age; }
public String toString() { return name + "(" + age + ")"; }
@Override
public int hashCode() { return Objects.hash(name, age); }
}
public static void main(String[] args) {
Member a = new Member("민수", 20);
Member b = new Member("민수", 20);
System.out.println("hashCode 같음 = " + (a.hashCode() == b.hashCode()));
System.out.println("a.equals(b) = " + a.equals(b));
Set<Member> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println("size = " + set.size() + " " + set);
}
}
hashCode 같음 = true
a.equals(b) = false
size = 2 [민수(20), 민수(20)]
이번엔 같은 칸으로 가지만, 칸 안에서 비교하는 equals()가 기본(주소 비교)이라 false. 역시 2개입니다. 둘 다 있어야 한다는 게 이 두 실험으로 보여요.
4. 둘 다 오버라이딩하면 고쳐짐 (Objects.equals / Objects.hash)
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public class H4 {
static class Member {
String name;
int age;
Member(String name, int age) { this.name = name; this.age = age; }
public String toString() { return name + "(" + age + ")"; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Member m)) return false;
return age == m.age && Objects.equals(name, m.name);
}
@Override
public int hashCode() { return Objects.hash(name, age); }
}
public static void main(String[] args) {
Set<Member> set = new HashSet<>();
set.add(new Member("민수", 20));
set.add(new Member("민수", 20));
set.add(new Member(null, 30));
set.add(new Member(null, 30));
System.out.println("size = " + set.size() + " " + set);
Map<Member, String> map = new HashMap<>();
map.put(new Member("민수", 20), "A반");
System.out.println(map.get(new Member("민수", 20)));
}
}
size = 2 [민수(20), null(30)]
A반
중복이 빠져서 2개만 남고, 새로 만든 객체로 HashMap에서도 값을 찾습니다. 규칙은 하나예요. equals에서 비교한 필드를 hashCode에도 똑같이 넣는다. equals가 true인 두 객체는 hashCode가 반드시 같아야 하거든요(반대로 hashCode가 같다고 equals가 꼭 true일 필요는 없습니다).
이름이 null인 회원도 문제없이 들어간 건 name.equals(...) 대신 Objects.equals(name, m.name)을 썼기 때문이에요. 차이를 따로 보면:
import java.util.Objects;
public class H5 {
public static void main(String[] args) {
String a = null;
String b = null;
System.out.println(Objects.equals(a, b));
System.out.println(Objects.equals(a, "민수"));
System.out.println(Objects.hash("민수", 20) == Objects.hash("민수", 20));
System.out.println(Objects.hash((Object) null));
System.out.println(a.equals(b));
}
}
true
false
true
31
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because "<local1>" is null
at H5.main(H5.java:11)
Objects.equals()는 둘 다 null이면 true, 한쪽만 null이면 false를 돌려주고 예외를 내지 않습니다. Objects.hash()도 null을 받아도 괜찮아요. 반면 null에서 바로 .equals()를 부르면 NullPointerException이 납니다(<local1>은 변수 a를 가리켜요).
5. 키로 넣은 뒤 필드를 바꾸면 못 찾는다
equals·hashCode를 제대로 만들어도 생기는 문제입니다. 키 객체의 필드를 넣은 뒤에 바꿔 보면:
// Member 클래스는 4번(H4)과 같음
public class H6 {
public static void main(String[] args) {
Member key = new Member("민수", 20);
Map<Member, String> map = new HashMap<>();
map.put(key, "A반");
key.age = 21;
System.out.println("map = " + map);
System.out.println("get(key) = " + map.get(key));
System.out.println("get(민수,21) = " + map.get(new Member("민수", 21)));
System.out.println("get(민수,20) = " + map.get(new Member("민수", 20)));
System.out.println("containsKey(key) = " + map.containsKey(key));
System.out.println("size = " + map.size());
}
}
map = {민수(21)=A반}
get(key) = null
get(민수,21) = null
get(민수,20) = null
containsKey(key) = false
size = 1
출력해 보면 분명 민수(21)=A반이 들어 있는데 어떤 방법으로도 못 꺼냅니다. 넣을 때는 나이 20 기준의 hashCode 칸에 저장됐는데, 지금 키로 찾으면 나이 21 기준 칸을 뒤지거든요. new Member("민수", 20)으로 찾으면 칸은 맞지만 equals에서 나이가 달라 false가 됩니다. 꼭 바꿔야 한다면 빼고 → 바꾸고 → 다시 넣어요.
// Member 클래스는 4번(H4)과 같음
public class H9 {
public static void main(String[] args) {
Member key = new Member("민수", 20);
Map<Member, String> map = new HashMap<>();
map.put(key, "A반");
String value = map.remove(key);
key.age = 21;
map.put(key, value);
System.out.println("map = " + map);
System.out.println("get(민수,21) = " + map.get(new Member("민수", 21)));
}
}
map = {민수(21)=A반}
get(민수,21) = A반
더 좋은 방법은 애초에 키 필드를 final로 만들어 바꿀 수 없게 하는 거예요. HashSet에 넣은 객체도 똑같아서, 넣은 뒤 필드를 바꾸면 contains()·remove()가 안 먹힙니다.
6. record로 만들면 equals·hashCode가 자동
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public class H7 {
record Member(String name, int age) {}
public static void main(String[] args) {
Set<Member> set = new HashSet<>();
set.add(new Member("민수", 20));
set.add(new Member("민수", 20));
System.out.println("size = " + set.size() + " " + set);
Map<Member, String> map = new HashMap<>();
map.put(new Member("민수", 20), "A반");
System.out.println(map.get(new Member("민수", 20)));
}
}
size = 1 [Member[name=민수, age=20]]
A반
Java 16 이상이라면 record로 선언하는 게 가장 간단합니다. 모든 필드를 기준으로 equals()·hashCode()·toString()을 만들어 주고, 필드가 final이라 5번 같은 문제도 생기지 않아요.
7. 덤: ArrayList.contains는 equals만 본다
import java.util.ArrayList;
import java.util.List;
public class H8 {
static class Member {
String name;
Member(String name) { this.name = name; }
}
public static void main(String[] args) {
List<Member> list = new ArrayList<>();
list.add(new Member("민수"));
System.out.println(list.contains(new Member("민수")));
System.out.println(list.indexOf(new Member("민수")));
}
}
false
-1
ArrayList의 contains(), indexOf(), remove(Object)는 hashCode 없이 equals로 하나씩 비교합니다. equals를 안 만들면 여기서도 못 찾아요. HashSet·HashMap이 아니어도 "값이 같으면 같은 객체"로 다루고 싶은 클래스라면 equals와 hashCode를 같이 만들어 두는 게 안전합니다.
정리
| 상황 | HashSet 중복 / 원인과 고치는 법 |
|---|---|
| 둘 다 안 함 | 2개 들어감 — 기본 equals는 주소 비교 |
| equals만 | 2개 들어감 — hashCode가 달라 다른 칸 |
| hashCode만 | 2개 들어감 — 같은 칸이지만 equals가 false |
| 둘 다 (같은 필드로) | 1개 — Objects.equals + Objects.hash |
| 키 필드를 넣은 뒤 변경 | get() null → 키는 final로, 바꿀 땐 remove 후 다시 put |
| record | equals·hashCode 자동, 필드 final |
참고: Oracle Java SE API 문서 — Object.equals / Object.hashCode(일반 계약), Objects.equals / Objects.hash, HashSet, HashMap, Map(가변 키 주의), List.contains / java.lang.Record / JLS 8.10 Record Classes
