개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › Java › 자주 쓰는 클래스

equals와 hashCode

equals만 고치면 왜 HashMap에서 못 찾나

내용이 같은 두 객체를 같다고 취급하려면 equals를 재정의합니다. 그런데 HashMap과 HashSet은 equals를 부르기 전에 먼저 hashCode로 찾을 자리를 정합니다. 그래서 equals와 hashCode 가운데 하나만 재정의하면, 같다고 만든 객체를 HashMap과 HashSet에서 찾지 못합니다.

실행으로 대조 · JDK 21에서 열두 가지 조합을 실행해 대조

이 실습에 나오는 개념

equals와 hashCode 규약
equals()로 같다고 판단되는 두 객체는 반드시 같은 hashCode() 값을 반환해야 한다는 규칙이다. HashMap이나 HashSet은 먼저 해시 값으로 저장 위치를 찾고 그 안에서 equals로 비교하기 때문에, equals만 재정의하면 내용이 같은 객체를 찾지 못한다. 따라서 두 메서드는 항상 같은 필드를 기준으로 함께 재정의한다.
동일성과 동등성
동일성은 두 참조가 메모리상 같은 객체를 가리키는지를 뜻하며 == 연산자로 비교한다. 동등성은 두 객체가 논리적으로 같은 값을 가지는지를 뜻하며 equals() 메서드로 비교한다. 문자열이나 값 객체를 비교할 때 ==를 쓰면 내용이 같아도 false가 나올 수 있다.
해시 테이블과 HashMap
키를 해시 함수에 넣어 나온 값으로 저장 위치(버킷)를 정하는 자료구조다. 키만 알면 위치를 바로 계산할 수 있어 조회, 삽입, 삭제가 평균 O(1)이다. 자바의 HashMap이 이 구조이며, 순서를 보장하지 않고 스레드 안전하지 않다.
해시 충돌
서로 다른 키가 같은 버킷에 배정되는 현상이다. 버킷 수는 유한하므로 피할 수 없다. 자바의 HashMap은 같은 버킷의 엔트리들을 연결 리스트로 이어 두고, 한 버킷에 너무 많이 몰리면 레드-블랙 트리로 바꿔 탐색 시간을 줄인다. 충돌이 많을수록 성능이 떨어지므로 hashCode가 값을 고르게 분산해야 한다.
record
데이터를 담는 용도의 (얕은) 불변 클래스를 간결하게 선언하는 문법으로 Java 16에서 정식 도입되었다. 필드 목록만 적으면 생성자, 접근자 메서드, equals, hashCode, toString이 자동으로 만들어진다. 필드는 모두 final이지만, 필드가 가변 객체(List 등)를 가리키면 그 내용은 바뀔 수 있다. DTO나 값 객체를 정의할 때 쓴다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. Order 객체 두 개를 만듭니다. a의 id는 1, b의 id는 1입니다. 내용은 같지만 메모리에서는 서로 다른 객체입니다.
  2. a.equals(b): 재정의한 equals가 id를 비교합니다. 1과 1이므로 true입니다.
  3. hashCode 비교: hashCode를 재정의하지 않았으므로 Object의 hashCode가 쓰입니다. 이 값은 객체마다 따로 정해지므로, 서로 다른 객체인 a와 b의 값은 (거의 항상) 다릅니다. equals가 true인 두 객체의 hashCode가 다른 상태입니다. "equals가 같으면 hashCode도 같아야 한다"는 규약을 어겼습니다.
  4. map.put(a, "주문 내역"): HashMap은 먼저 a.hashCode()로 넣을 자리(버킷)를 정하고, 그 자리에 키 a와 값을 함께 넣어 둡니다.
  5. map.get(b): 먼저 b.hashCode()로 찾을 자리를 정합니다. hashCode가 a와 달라서 대개 다른 자리를 보고, 같은 자리여도 해시 값이 다른 키는 equals까지 가지 않습니다. equals는 불러 보지도 못하고 null을 돌려줍니다. equals로는 같은 객체인데도 꺼내지 못합니다.
  6. set.add(a), set.add(b): HashSet도 안에서 HashMap을 쓰므로 같은 순서로 판단합니다. b를 a와 다른 원소로 보아 둘 다 넣습니다. 크기는 2입니다. 내용이 같은 객체가 중복으로 들어갔습니다.

실행 결과

코드

class Order {
    final int id;
    Order(int id) { this.id = id; }

    @Override
    public boolean equals(Object o) {
        return o instanceof Order other && id == other.id;
    }
}

Order a = new Order(1);
Order b = new Order(1);

System.out.println(a.equals(b));
System.out.println(a.hashCode() == b.hashCode());

Map<Order, String> map = new HashMap<>();
map.put(a, "주문 내역");
System.out.println(map.get(b));

Set<Order> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println(set.size());

이 모형의 전제

열두 가지 조합의 결과(equals, hashCode가 같은지, map.get, set의 크기)를 이 PC의 JDK 21에서 같은 코드를 실행한 결과와 대조했습니다. hashCode를 재정의하지 않은 두 객체의 값이 "다르다"는 것은 거의 항상 그렇다는 뜻입니다. Object의 hashCode는 서로 다른 객체에 같은 값을 줄 수도 있으며(그래도 규약에 어긋나지 않습니다), 그 경우에는 equals의 결과에 따라 찾을 수도 있습니다. HashMap은 같은 자리에 있더라도 hashCode 값이 다른 키와는 equals를 부르지 않고 넘어갑니다. record의 hashCode가 내는 구체적인 값은 명세가 정하지 않지만, equals가 같은 두 record의 hashCode가 같다는 것은 보장됩니다.

앞 실습: ==와 equals · 다음 실습: String, StringBuilder

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요