포스트

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에 둘 다 들어갔습니다.

containsfalse입니다. 방금 넣은 것과 내용이 같은 객체로 물어봤는데 없다고 합니다.

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)

HashSetequals를 부르기 전에 hashCode로 자리를 먼저 정합니다. 자리가 다르면 equals까지 가지도 않습니다.

그래서 규칙이 이렇게 됩니다.

equalstrue인 두 객체는 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이 됐고, containstrue가 됐습니다. 바뀐 건 메서드 하나입니다.

이번에는 값이 실행할 때마다 달라지지 않습니다. Objects.hash("kim@example.com")문자열 내용만 가지고 계산하기 때문입니다.

hashCode 규약

정리하면 세 가지입니다.

규약 안 지키면
일관성 객체가 안 바뀌면 몇 번을 불러도 같은 값 넣어둔 걸 못 찾는다
equals → hashCode equalstruehashCode도 같아야 한다 확인 1의 상황
hashCode → equals는 아님 hashCode가 같아도 equalsfalse일 수 있다 문제 없음 (성능만 나빠짐)

세 번째가 자주 오해받는 부분입니다. 해시 값이 같은데 내용이 다른 것은 정상입니다.

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틀린 건 아닙니다. 규약은 다 지킵니다. 결과도 맞습니다. 대신 모든 것이 한 칸에 들어가서 HashMapList처럼 동작합니다. 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부터 정식으로 들어온 recordequals, 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

equalshashCode도 한 줄도 안 썼는데 확인 2와 같은 결과가 나옵니다. toStringMember[email=..., name=...] 형태로 만들어져 있고, 접근자 이름은 getEmail()이 아니라 email() 입니다.

hashCode()가 정확히 어떤 숫자를 내는지는 명세로 고정돼 있지 않습니다. 그래서 값 자체를 비교하지 말고 “두 값이 같은가”만 봅니다. 위에서 해시 값을 직접 찍지 않고 a.hashCode() == b.hashCode()로 출력한 이유입니다.

record가 해시 컬렉션의 키로 안전한 이유는 자동 생성 때문만이 아닙니다.

성질 결과
모든 필드가 final 넣어둔 뒤에 해시가 바뀔 수 없다
setter가 없다 확인 3의 상황이 구조적으로 안 생긴다
equals/hashCode모든 필드를 본다 필드를 추가하면 자동으로 반영된다

값을 담는 용도라면 record가 기본 선택입니다. DTO, 좌표, 금액처럼 “내용이 같으면 같은 것”인 대상에 맞습니다.

엔티티에는 그대로 쓰기 어렵다

JPA 엔티티는 사정이 다릅니다.

  • recordfinal 클래스라 프록시를 못 만듭니다. JPA가 지연 로딩에 쓰는 방식과 안 맞습니다
  • 기본 생성자가 없습니다
  • 필드가 전부 final이라 JPA의 더티 체킹(필드 값을 바꾸면 변경을 감지하는 방식)이 성립하지 않습니다

그래서 엔티티는 일반 클래스로 두되, equals/hashCode를 만들 때 한 가지를 조심합니다.

1
2
3
4
5
@Override
public boolean equals(Object o) {
    ...
    return Objects.equals(id, other.id);   // id 만 비교
}

id만 보는 구현은 저장 전 엔티티에서 문제가 됩니다. 아직 idnull인 엔티티 두 개를 Set에 넣으면 같은 것으로 취급됩니다. 반대로 저장 전에 Set에 넣어두고 저장하면 id가 채워지면서 해시가 바뀌어 확인 3과 똑같은 상황이 됩니다.

JPA의 1차 캐시 동일성은 이 문제를 한 겹 가려줍니다. 같은 트랜잭션 안에서 같은 id를 두 번 조회하면 영속성 컨텍스트가 같은 객체를 돌려주기 때문에 ==부터 true입니다. 그래서 equals를 재정의하지 않아도 잘 돌아가는 것처럼 보이는 구간이 있습니다.

문제는 트랜잭션이 다를 때입니다. 그때는 서로 다른 객체가 오고, 그때부터 equals 구현이 결과를 좌우합니다.

정리하면 이 순서

equalshashCode를 만들 때 판단 순서입니다.

1
2
3
4
5
6
7
8
9
10
11
12
   이 클래스는 "내용이 같으면 같은 것"인가?
        │
        ├─ 아니다 (신원 자체가 의미) ──▶ 재정의하지 않는다. Object 것을 그대로 둔다
        │
        └─ 그렇다
             │
             ├─ 값을 담는 용도인가? ──▶ record 를 쓴다
             │
             └─ 아니면 둘 다 직접 재정의한다
                  │
                  ├─ 같은 필드를 보게 한다 (equals 가 보는 필드 = hashCode 가 보는 필드)
                  └─ 그 필드는 바뀌지 않게 한다 (가능하면 final)

“둘 중 하나만”은 없습니다. IDE가 equalshashCode를 항상 같이 만들어주는 이유가 이것입니다.

내가 헷갈렸던 지점

정리

  • HashSetHashMaphashCode로 자리(버킷)를 먼저 정하고, 그 자리 안에서만 equals를 부른다. 자리가 다르면 equals는 아예 안 불린다
  • 그래서 equals만 재정의하면 set.size()가 2가 되고 containsfalse 가 된다. List는 전부 훑으면서 equals만 쓰기 때문에 이 버그가 안 드러난다
  • 규약은 equalstruehashCode도 같아야 한다” 한 방향이다. 반대는 성립하지 않아도 된다. "Aa""BB"는 해시가 같지만 다른 문자열이다(해시 충돌)
  • 해시 컬렉션에 넣어둔 객체의 필드를 바꾸면 못 찾는다. 객체는 예전 버킷에 있는데 새 해시로 다른 버킷을 보기 때문이다. contains뿐 아니라 remove도 실패해서 못 지우는 원소가 남는다
  • 그래서 키로 쓸 객체는 불변이어야 한다. 최소한 equals/hashCode가 보는 필드는 final로 둔다
  • record는 둘 다 자동으로 만들고 필드가 전부 final 이라 값 객체와 맵 키에 적합하다. 다만 final 클래스라 JPA 엔티티로는 쓰기 어렵다

3편에서 이 hashCode가 실제로 어떻게 자리를 정하는지, 그리고 모든 키의 해시를 1로 만들면 HashMap이 얼마나 느려지는지 측정합니다.

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