포스트

Java (2) - equals & hashCode: 같다는 것

equals만 재정의하고 HashSet에 넣으면 중복이 들어가는 것을 재현하고 두 메서드를 함께 재정의해야 하는 이유를 다룹니다.

Java (2) - equals & hashCode: 같다는 것

자바 기초 시리즈의 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이 얼마나 느려지는지 측정합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.