Java (2) - equals & hashCode: 같다는 것
equals만 재정의하고 HashSet에 넣으면 중복이 들어가는 것을 재현하고 두 메서드를 함께 재정의해야 하는 이유를 다룹니다.
자바 기초 시리즈의 2편입니다. 전체 목차는 0편에 있습니다.
1편에서 ==는 같은 자리인지를 묻고 equals는 내용이 같은지를 묻는다고 했습니다. 그리고 재정의하지 않으면 equals도 ==와 똑같이 동작한다고 했습니다.
그래서 재정의합니다. IDE가 만들어주는 그대로 씁니다. 여기서 한 가지를 빠뜨리면 무슨 일이 나는지가 이번 편입니다.
equals만 재정의한다
이메일이 같으면 같은 회원으로 보고 싶습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static class Member {
private final String email;
Member(String email) { this.email = email; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Member m = (Member) o;
return Objects.equals(email, m.email);
}
@Override
public String toString() { return "Member(" + email + ")"; }
}
equals의 모양은 관례가 정해져 있습니다.
| 줄 | 하는 일 |
|---|---|
if (this == o) return true; |
같은 객체면 바로 끝. 빠른 길 |
if (o == null \|\| getClass() != o.getClass()) |
null이거나 다른 타입이면 false |
(Member) o |
여기까지 왔으면 캐스팅이 안전하다 |
Objects.equals(...) |
필드가 null이어도 안 터지는 비교 |
여기까지는 문제가 없습니다. hashCode를 안 만들었을 뿐입니다.
확인 1: HashSet에 넣어본다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.util.*;
public class Eq {
// 위의 Member 클래스
public static void main(String[] args) {
Member a = new Member("kim@example.com");
Member b = new Member("kim@example.com");
System.out.println("a.equals(b) = " + a.equals(b));
System.out.println("a.hashCode() = " + a.hashCode());
System.out.println("b.hashCode() = " + b.hashCode());
Set<Member> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println("set.size() = " + set.size());
System.out.println("set.contains(new Member(\"kim@example.com\")) = "
+ set.contains(new Member("kim@example.com")));
List<Member> list = new ArrayList<>();
list.add(a);
System.out.println("list.contains(b) = " + list.contains(b));
Map<Member, String> map = new HashMap<>();
map.put(a, "A");
map.put(b, "B");
System.out.println("map.size() = " + map.size());
}
}
1
2
3
4
5
6
7
a.equals(b) = true
a.hashCode() = 257513673
b.hashCode() = 590845366
set.size() = 2
set.contains(new Member("kim@example.com")) = false
list.contains(b) = true
map.size() = 2
읽을 곳이 네 군데입니다.
a.equals(b)는 true인데 set.size()는 2입니다. 같다고 해놓고 Set에 둘 다 들어갔습니다.
contains가 false입니다. 방금 넣은 것과 내용이 같은 객체로 물어봤는데 없다고 합니다.
list.contains(b)는 true입니다. 똑같이 물어봤는데 List에서는 찾습니다.
map.size()도 2입니다. 같은 회원인데 키가 두 개 생겼습니다.
hashCode() 두 줄을 보면 답이 나옵니다. 값이 다릅니다.
위의
257513673,590845366같은 숫자는 실행할 때마다 달라집니다. 재정의하지 않은Object.hashCode()는 객체마다 사실상 고유한 값을 만들어내기 때문입니다. 봐야 할 건 값이 아니라 두 줄이 서로 다르다는 것입니다.
왜 List는 찾고 Set은 못 찾나
두 컬렉션이 “들어 있나”를 확인하는 방법이 다릅니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[ ArrayList.contains(b) ]
처음부터 끝까지 한 칸씩 돌면서 equals 를 부른다
[0] ●──▶ Member(kim@...) b.equals(...) → true ✔ 찾음
[1] ●──▶ Member(lee@...)
[2] ●──▶ Member(park@...)
→ hashCode 를 아예 안 쓴다. 그래서 잘 찾는다. 대신 O(n)
[ HashSet.contains(b) ]
① b.hashCode() 로 어느 칸을 볼지 먼저 정한다
② 그 칸에 있는 것들하고만 equals 를 부른다
버킷 0 │
버킷 1 │ ● ──▶ Member(kim@...) ← a 가 여기 들어갔다
버킷 2 │
... │
버킷 9 │ ● ──▶ (비어 있음) ← b 는 여기를 본다
버킷 15 │
→ b 의 hashCode 가 다르니 다른 칸을 본다.
그 칸이 비었으면 equals 는 한 번도 안 불린다. O(1)
HashSet은 equals를 부르기 전에 hashCode로 자리를 먼저 정합니다. 자리가 다르면 equals까지 가지도 않습니다.
그래서 규칙이 이렇게 됩니다.
equals가true인 두 객체는hashCode도 반드시 같아야 한다.
이걸 안 지키면 해시 기반 컬렉션(HashSet, HashMap, LinkedHashMap, HashTable)이 전부 오동작합니다. List에서는 안 드러나서 더 늦게 발견됩니다.
hashCode를 같이 재정의한다
1
2
3
4
@Override
public int hashCode() {
return Objects.hash(email);
}
한 줄입니다. Objects.hash()는 필드 값들을 섞어서 int 하나로 만들어줍니다.
확인 2: 같은 코드를 다시 돌린다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.util.*;
public class Eq2 {
static class Member {
private final String email;
Member(String email) { this.email = email; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
return Objects.equals(email, ((Member) o).email);
}
@Override public int hashCode() { return Objects.hash(email); }
@Override public String toString() { return "Member(" + email + ")"; }
}
public static void main(String[] args) {
Member a = new Member("kim@example.com");
Member b = new Member("kim@example.com");
System.out.println("a.hashCode() = " + a.hashCode());
System.out.println("b.hashCode() = " + b.hashCode());
Set<Member> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println("set.size() = " + set.size());
System.out.println("contains = " + set.contains(new Member("kim@example.com")));
}
}
1
2
3
4
a.hashCode() = 956362539
b.hashCode() = 956362539
set.size() = 1
contains = true
해시 값이 같아졌고, 크기가 1이 됐고, contains가 true가 됐습니다. 바뀐 건 메서드 하나입니다.
이번에는 값이 실행할 때마다 달라지지 않습니다. Objects.hash("kim@example.com")은 문자열 내용만 가지고 계산하기 때문입니다.
hashCode 규약
정리하면 세 가지입니다.
| 규약 | 뜻 | 안 지키면 |
|---|---|---|
| 일관성 | 객체가 안 바뀌면 몇 번을 불러도 같은 값 | 넣어둔 걸 못 찾는다 |
| equals → hashCode | equals가 true면 hashCode도 같아야 한다 |
확인 1의 상황 |
| hashCode → equals는 아님 | hashCode가 같아도 equals는 false일 수 있다 |
문제 없음 (성능만 나빠짐) |
세 번째가 자주 오해받는 부분입니다. 해시 값이 같은데 내용이 다른 것은 정상입니다.
int가 표현할 수 있는 값은 약 43억 개인데 만들 수 있는 문자열은 무한합니다. 겹치는 게 당연합니다.
1
2
3
System.out.println("\"Aa\".hashCode() = " + "Aa".hashCode());
System.out.println("\"BB\".hashCode() = " + "BB".hashCode());
System.out.println("\"Aa\".equals(\"BB\") = " + "Aa".equals("BB"));
1
2
3
"Aa".hashCode() = 2112
"BB".hashCode() = 2112
"Aa".equals("BB") = false
두 글자짜리 문자열에서도 이미 충돌합니다. 이걸 해시 충돌이라고 하고, 컬렉션은 같은 버킷 안에서 equals로 최종 판정을 해서 처리합니다.
1
2
버킷 0 │ ● ──▶ "Aa" ──▶ "BB" ← 같은 칸에 둘 다 들어간다
버킷 1 │ 꺼낼 때 equals 로 구분
그래서 hashCode는 “자리를 정하는 힌트”고 최종 판정은 언제나 equals 입니다. 충돌이 나도 결과는 맞습니다. 다만 같은 칸에 너무 많이 쌓이면 느려집니다. 그 이야기는 3편에서 합니다.
그래서
return 1;같은hashCode도 틀린 건 아닙니다. 규약은 다 지킵니다. 결과도 맞습니다. 대신 모든 것이 한 칸에 들어가서HashMap이List처럼 동작합니다. 3편에서 이걸 실제로 측정합니다.
넣어두고 필드를 바꾸면
규약의 첫 번째, 일관성이 깨지는 경우입니다.
확인 3: 키로 쓴 객체의 필드를 바꾼다
1
2
3
4
5
6
static class Member {
private String email; // final 을 떼고 setter 를 열었다
Member(String email) { this.email = email; }
void setEmail(String e) { this.email = e; }
// equals, hashCode 는 확인 2와 같다
}
1
2
3
4
5
6
7
8
9
10
11
12
13
Set<Member> set2 = new HashSet<>();
Member m = new Member("old@example.com");
set2.add(m);
System.out.println("before: contains = " + set2.contains(m) + ", hash = " + m.hashCode());
m.setEmail("new@example.com");
System.out.println("after : contains = " + set2.contains(m) + ", hash = " + m.hashCode());
System.out.println("set2 = " + set2 + ", size = " + set2.size());
set2.remove(m);
System.out.println("remove 후 size = " + set2.size());
1
2
3
4
before: contains = true, hash = -479413629
after : contains = false, hash = 272255100
set2 = [Member(new@example.com)], size = 1
remove 후 size = 1
넣어둔 그 객체 자체로 물어봤는데 없다고 합니다.
1
2
3
4
5
6
7
8
9
[ set2.add(m) 시점 ] hash = -479413629 → 버킷 3
버킷 3 │ ● ──▶ Member(old@example.com)
[ m.setEmail("new@...") 이후 ] hash = 272255100 → 버킷 12
버킷 3 │ ● ──▶ Member(new@example.com) ← 객체는 아직 여기 있다
버킷 12│ (비어 있음) ← contains 는 여기를 본다
객체는 넣을 때의 버킷에 그대로 있는데, 찾을 때는 새 해시로 다른 버킷을 봅니다. 아무도 그 버킷으로 옮겨주지 않습니다.
더 나쁜 건 마지막 줄입니다. remove(m)도 실패해서 크기가 1로 남습니다. 꺼낼 수도 지울 수도 없는 원소가 컬렉션 안에 남습니다. set2를 출력하면 보이기는 합니다. 보이는데 못 찾습니다.
해시 컬렉션의 키로 쓸 객체는 불변이어야 합니다. 최소한
equals/hashCode가 보는 필드만은 안 바뀌어야 합니다. 필드를final로 두면 컴파일러가 도와줍니다.
이건 실무에서 이렇게 나옵니다.
1
2
3
4
Set<Order> orders = new HashSet<>();
orders.add(order); // order.status = 접수
order.setStatus(배송중); // equals/hashCode 가 status 를 본다면
orders.remove(order); // 안 지워진다
record는 둘 다 만들어준다
Java 16부터 정식으로 들어온 record는 equals, hashCode, toString, 접근자를 전부 자동으로 만듭니다.
확인 4: record로 같은 실험
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.*;
public class Rec {
record Member(String email, String name) {}
public static void main(String[] args) {
Member a = new Member("kim@example.com", "김");
Member b = new Member("kim@example.com", "김");
System.out.println("a = " + a);
System.out.println("a.equals(b) = " + a.equals(b));
System.out.println("해시가 같은가 = " + (a.hashCode() == b.hashCode()));
Set<Member> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println("set.size() = " + set.size());
System.out.println("a.email() = " + a.email());
}
}
1
2
3
4
5
a = Member[email=kim@example.com, name=김]
a.equals(b) = true
해시가 같은가 = true
set.size() = 1
a.email() = kim@example.com
equals도 hashCode도 한 줄도 안 썼는데 확인 2와 같은 결과가 나옵니다. toString도 Member[email=..., name=...] 형태로 만들어져 있고, 접근자 이름은 getEmail()이 아니라 email() 입니다.
hashCode()가 정확히 어떤 숫자를 내는지는 명세로 고정돼 있지 않습니다. 그래서 값 자체를 비교하지 말고 “두 값이 같은가”만 봅니다. 위에서 해시 값을 직접 찍지 않고a.hashCode() == b.hashCode()로 출력한 이유입니다.
record가 해시 컬렉션의 키로 안전한 이유는 자동 생성 때문만이 아닙니다.
| 성질 | 결과 |
|---|---|
모든 필드가 final |
넣어둔 뒤에 해시가 바뀔 수 없다 |
| setter가 없다 | 확인 3의 상황이 구조적으로 안 생긴다 |
equals/hashCode가 모든 필드를 본다 |
필드를 추가하면 자동으로 반영된다 |
값을 담는 용도라면 record가 기본 선택입니다. DTO, 좌표, 금액처럼 “내용이 같으면 같은 것”인 대상에 맞습니다.
엔티티에는 그대로 쓰기 어렵다
JPA 엔티티는 사정이 다릅니다.
record는 final 클래스라 프록시를 못 만듭니다. JPA가 지연 로딩에 쓰는 방식과 안 맞습니다- 기본 생성자가 없습니다
- 필드가 전부
final이라 JPA의 더티 체킹(필드 값을 바꾸면 변경을 감지하는 방식)이 성립하지 않습니다
그래서 엔티티는 일반 클래스로 두되, equals/hashCode를 만들 때 한 가지를 조심합니다.
1
2
3
4
5
@Override
public boolean equals(Object o) {
...
return Objects.equals(id, other.id); // id 만 비교
}
id만 보는 구현은 저장 전 엔티티에서 문제가 됩니다. 아직 id가 null인 엔티티 두 개를 Set에 넣으면 같은 것으로 취급됩니다. 반대로 저장 전에 Set에 넣어두고 저장하면 id가 채워지면서 해시가 바뀌어 확인 3과 똑같은 상황이 됩니다.
JPA의 1차 캐시 동일성은 이 문제를 한 겹 가려줍니다. 같은 트랜잭션 안에서 같은 id를 두 번 조회하면 영속성 컨텍스트가 같은 객체를 돌려주기 때문에 ==부터 true입니다. 그래서 equals를 재정의하지 않아도 잘 돌아가는 것처럼 보이는 구간이 있습니다.
문제는 트랜잭션이 다를 때입니다. 그때는 서로 다른 객체가 오고, 그때부터 equals 구현이 결과를 좌우합니다.
정리하면 이 순서
equals와 hashCode를 만들 때 판단 순서입니다.
1
2
3
4
5
6
7
8
9
10
11
12
이 클래스는 "내용이 같으면 같은 것"인가?
│
├─ 아니다 (신원 자체가 의미) ──▶ 재정의하지 않는다. Object 것을 그대로 둔다
│
└─ 그렇다
│
├─ 값을 담는 용도인가? ──▶ record 를 쓴다
│
└─ 아니면 둘 다 직접 재정의한다
│
├─ 같은 필드를 보게 한다 (equals 가 보는 필드 = hashCode 가 보는 필드)
└─ 그 필드는 바뀌지 않게 한다 (가능하면 final)
“둘 중 하나만”은 없습니다. IDE가 equals와 hashCode를 항상 같이 만들어주는 이유가 이것입니다.
내가 헷갈렸던 지점
정리
HashSet과HashMap은hashCode로 자리(버킷)를 먼저 정하고, 그 자리 안에서만equals를 부른다. 자리가 다르면equals는 아예 안 불린다- 그래서
equals만 재정의하면set.size()가 2가 되고contains가false가 된다.List는 전부 훑으면서equals만 쓰기 때문에 이 버그가 안 드러난다 - 규약은 “
equals가true면hashCode도 같아야 한다” 한 방향이다. 반대는 성립하지 않아도 된다."Aa"와"BB"는 해시가 같지만 다른 문자열이다(해시 충돌) - 해시 컬렉션에 넣어둔 객체의 필드를 바꾸면 못 찾는다. 객체는 예전 버킷에 있는데 새 해시로 다른 버킷을 보기 때문이다.
contains뿐 아니라remove도 실패해서 못 지우는 원소가 남는다 - 그래서 키로 쓸 객체는 불변이어야 한다. 최소한
equals/hashCode가 보는 필드는final로 둔다 record는 둘 다 자동으로 만들고 필드가 전부final이라 값 객체와 맵 키에 적합하다. 다만 final 클래스라 JPA 엔티티로는 쓰기 어렵다
3편에서 이 hashCode가 실제로 어떻게 자리를 정하는지, 그리고 모든 키의 해시를 1로 만들면 HashMap이 얼마나 느려지는지 측정합니다.