기본 콘텐츠로 건너뛰기

equals와 hashCode를 함께 구현해야 HashSet과 HashMap이 제대로 동작하는 이유

equals와 hashCode를 함께 구현해야 HashSet과 HashMap이 제대로 동작하는 이유

빠른 답

  • equals를 재정의했다면 hashCode도 같은 기준 필드로 재정의해야 합니다.
  • HashSetHashMap은 먼저 hashCode로 후보 위치를 좁힌 뒤 equals로 최종 비교합니다.
  • equalstrue인 두 객체는 항상 같은 hashCode를 반환해야 합니다.
  • 해시 계산에 쓰이는 필드는 컬렉션에 넣은 뒤 바뀌지 않는 값으로 잡는 편이 안전합니다.

흐름으로 보기

흐름 다이어그램
equals와 hashCode를 함께 구현해야 HashSet과 HashMap이 제대로 동작하는 이유 흐름 다이어그램

해시 기반 컬렉션은 객체를 넣거나 찾을 때 모든 원소를 처음부터 끝까지 equals로 비교하지 않습니다. 먼저 hashCode를 이용해 내부 저장 공간 중 확인할 위치를 좁힙니다. 그다음 같은 위치에 있는 후보들만 equals로 비교합니다.

이 순서 때문에 equals만 재정의한 객체는 일반 비교에서는 같아 보여도 HashSet, HashMap 안에서는 서로 다른 객체처럼 다뤄질 수 있습니다. equals가 호출되기 전에 hashCode가 다른 버킷을 가리켜 버리면, 같은 값인지 확인할 기회가 생기지 않기 때문입니다.

값, 상태, 오류 유형 구분하기

Java에서 객체 비교를 이야기할 때는 값, 상태, 오류 유형을 나누어 보면 덜 헷갈립니다.

값은 객체를 논리적으로 같다고 볼 때 사용하는 기준입니다. 예를 들어 구독자를 emailcategory로 구분한다면, 두 필드가 같은 두 객체는 같은 구독자라고 볼 수 있습니다.

상태는 객체가 현재 들고 있는 필드 값입니다. email이나 category가 바뀔 수 있는 필드라면 객체의 동등성도 실행 중에 달라질 수 있습니다. 해시 기반 컬렉션에 들어간 뒤 이 상태가 바뀌면 저장 위치와 조회 위치가 어긋날 수 있습니다.

오류 유형은 컴파일 에러나 예외가 아니라, 기대한 결과와 다른 값이 조용히 나오는 논리 오류에 가깝습니다. HashSet의 크기가 1이어야 할 것 같은데 2가 나오거나, HashMap에 넣은 값을 새 key로 조회했을 때 null이 나오는 식입니다.

동일성과 동등성

동일성은 두 참조가 같은 객체를 가리키는지 보는 비교입니다. Java에서는 ==가 이 비교를 합니다. new Subscribe(...)를 두 번 호출하면 필드 값이 같아도 서로 다른 객체가 만들어지므로 subscribe1 == subscribe2false입니다.

동등성은 업무나 도메인 관점에서 같은 값인지 보는 비교입니다. 이 기준은 equals를 재정의해 클래스가 직접 정할 수 있습니다. 예를 들어 이메일과 카테고리가 같으면 같은 구독자로 본다는 규칙을 세울 수 있습니다.

다만 equals만 바꾸면 Java의 모든 비교 구조가 저절로 그 규칙을 따르는 것은 아닙니다. 특히 HashSet, HashMap처럼 해시를 사용하는 컬렉션은 equalshashCode가 함께 맞아야 의도한 대로 동작합니다.

equals만 재정의했을 때 생기는 결과

아래 코드는 Subscribe의 동등성 기준을 email, category로 정했지만 hashCode는 재정의하지 않은 예시입니다. 같은 값으로 만든 두 객체를 HashSet에 넣고, 같은 값으로 만든 key로 HashMap 조회도 해 봅니다.

import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;

public class EqualsOnlyExample {
    public static void main(String[] args) {
        Subscribe subscribe1 = new Subscribe("team.maeilmail@gmail.com", "backend");
        Subscribe subscribe2 = new Subscribe("team.maeilmail@gmail.com", "backend");

        HashSet<Subscribe> subscribes = new HashSet<>(List.of(subscribe1, subscribe2));

        Map<Subscribe, Long> subscriberIds = new HashMap<>();
        subscriberIds.put(subscribe1, 100L);

        System.out.println("equals: " + subscribe1.equals(subscribe2));
        System.out.println("hashCode 1: " + subscribe1.hashCode());
        System.out.println("hashCode 2: " + subscribe2.hashCode());
        System.out.println("set size: " + subscribes.size());
        System.out.println("map get: " + subscriberIds.get(subscribe2));
    }

    static class Subscribe {
        private final String email;
        private final String category;

        Subscribe(String email, String category) {
            this.email = email;
            this.category = category;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Subscribe other)) return false;
            return Objects.equals(email, other.email)
                && Objects.equals(category, other.category);
        }
    }
}

실행 결과의 숫자는 환경마다 달라질 수 있습니다. 중요한 부분은 equalstrue인데도 hashCode가 다르고, 그 결과 컬렉션 동작이 기대와 어긋난다는 점입니다.

equals: true
hashCode 1: 617901222
hashCode 2: 1159190947
set size: 2
map get: null

HashSet 입장에서는 두 객체가 다른 버킷에 들어갈 수 있으므로 중복이라고 판단하지 못합니다. HashMap도 마찬가지입니다. 값을 넣을 때 사용한 key와 조회할 때 사용한 key가 값으로는 같아도, 서로 다른 해시 위치를 찾으면 기존 엔트리까지 도달하지 못할 수 있습니다.

Object의 기본 hashCode를 메모리 주소라고 설명하는 경우가 많지만, Java 명세 관점에서 주소 자체를 반환한다고 단정하기는 어렵습니다. 기본 동작은 객체 정체성에 기반한 해시값을 제공하는 쪽에 가깝고, 서로 다른 객체라면 값이 같더라도 다른 해시값이 나올 수 있습니다.

equals와 hashCode를 함께 구현하기

해결 방법은 equals에서 사용한 기준을 hashCode에서도 동일하게 사용하는 것입니다. emailcategory로 같은 구독자인지 판단한다면, 해시값도 같은 두 필드로 계산해야 합니다.

import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;

public class EqualsAndHashCodeExample {
    public static void main(String[] args) {
        Subscribe subscribe1 = new Subscribe("team.maeilmail@gmail.com", "backend");
        Subscribe subscribe2 = new Subscribe("team.maeilmail@gmail.com", "backend");

        HashSet<Subscribe> subscribes = new HashSet<>(List.of(subscribe1, subscribe2));

        Map<Subscribe, Long> subscriberIds = new HashMap<>();
        subscriberIds.put(subscribe1, 100L);

        System.out.println("equals: " + subscribe1.equals(subscribe2));
        System.out.println("hashCode 1: " + subscribe1.hashCode());
        System.out.println("hashCode 2: " + subscribe2.hashCode());
        System.out.println("set size: " + subscribes.size());
        System.out.println("map get: " + subscriberIds.get(subscribe2));
    }

    static class Subscribe {
        private final String email;
        private final String category;

        Subscribe(String email, String category) {
            this.email = email;
            this.category = category;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Subscribe other)) return false;
            return Objects.equals(email, other.email)
                && Objects.equals(category, other.category);
        }

        @Override
        public int hashCode() {
            return Objects.hash(email, category);
        }
    }
}

이제 같은 값으로 만든 두 객체는 같은 해시값을 갖습니다. HashSet은 같은 버킷 안에서 equals 비교까지 진행할 수 있고, HashMap도 같은 값의 key로 저장된 값을 찾을 수 있습니다.

equals: true
hashCode 1: -1898125601
hashCode 2: -1898125601
set size: 1
map get: 100

여기서 해시값 숫자 자체는 중요하지 않습니다. 관찰해야 할 부분은 두 객체의 hashCode가 같고, HashSet 크기가 1이 되며, HashMap 조회가 100을 반환한다는 점입니다.

해시 충돌과 흔한 오해

equalshashCode의 관계에서 자주 생기는 오해가 두 가지 있습니다.

첫 번째는 equalstrue이면 컬렉션도 알아서 같은 객체로 처리할 것이라는 생각입니다. 해시 기반 컬렉션에서는 hashCode로 후보 위치를 먼저 고르기 때문에, equals만으로는 충분하지 않습니다.

두 번째는 hashCode가 같으면 같은 객체라는 생각입니다. 이것도 정확하지 않습니다. 서로 다른 객체가 같은 해시값을 가질 수 있습니다. 이를 해시 충돌이라고 부릅니다. 그래서 해시 기반 컬렉션은 hashCode로 후보를 좁힌 뒤, 마지막에는 equals로 실제 동등성을 확인합니다.

정리하면 계약은 한 방향으로 이해하는 편이 좋습니다. equalstrue인 두 객체는 같은 hashCode를 반환해야 합니다. 그러나 hashCode가 같다고 해서 equals가 반드시 true일 필요는 없습니다.

변경 가능한 필드를 기준으로 삼을 때의 위험

해시 기반 컬렉션에 들어가는 객체는 동등성 기준 필드가 바뀌지 않는 편이 좋습니다. HashSet에 객체를 넣을 때 계산된 해시 위치와, 나중에 찾을 때 계산된 해시 위치가 달라지면 컬렉션 안에 객체가 있어도 찾지 못하는 상황이 생길 수 있습니다.

예를 들어 emailcategory를 기준으로 equals, hashCode를 구현했는데, 객체를 HashSet에 넣은 뒤 email이 바뀐다면 조회 결과가 어긋날 수 있습니다. 컬렉션은 예전 해시값을 기준으로 저장된 위치를 기억하고 있는데, 조회할 때는 바뀐 필드로 새 해시값을 계산하기 때문입니다.

그래서 해시 기반 컬렉션의 key나 원소로 사용할 객체는 가능하면 불변 객체로 설계하는 편이 낫습니다. 위 예시처럼 필드를 final로 두거나, 값 객체라면 record를 검토할 수 있습니다. record는 컴포넌트를 기준으로 equals, hashCode, toString을 자동으로 제공합니다. 단순한 값 객체라면 public record Subscribe(String email, String category) {}처럼 표현할 수 있습니다.

반대로 식별자 기반 엔티티, 변경 가능한 상태를 가진 객체, 프록시가 개입하는 모델에서는 어떤 필드를 동등성 기준으로 삼을지 별도로 검토해야 합니다. 모든 필드를 기계적으로 넣는 방식은 상태 변화와 컬렉션 동작을 함께 불안정하게 만들 수 있습니다.

자동 점검과 구성 예시

반복적으로 작성하는 코드이기 때문에 IDE의 생성 기능이나 정적 분석 도구를 함께 쓰면 누락을 줄일 수 있습니다. IntelliJ IDEA, Eclipse 같은 IDE는 equalshashCode를 함께 생성하는 기능을 제공합니다. 이때 먼저 정해야 할 것은 생성 방식이 아니라 동등성의 기준이 되는 필드입니다.

팀 단위에서는 Checkstyle 같은 도구로 equals만 오버라이드하고 hashCode를 빠뜨린 클래스를 점검할 수도 있습니다.

<module name="Checker">
    <module name="TreeWalker">
        <module name="EqualsHashCode"/>
    </module>
</module>

이 설정은 equals를 오버라이드한 클래스가 hashCode도 함께 오버라이드하는지 확인하는 데 도움이 됩니다. 도구가 동등성 기준을 대신 정해 주지는 않지만, 계약 위반을 리뷰 전에 발견하는 안전망으로 사용할 수 있습니다.

디버깅할 때 볼 출력

이 문제가 의심될 때는 객체의 주요 필드, equals 결과, hashCode 결과, 컬렉션 크기, 조회 결과를 함께 남기면 원인을 좁히기 쉽습니다.

email=team.maeilmail@gmail.com, category=backend
equals=true
left.hashCode=617901222
right.hashCode=1159190947
hashSet.size=2
hashMap.get(right)=null

이런 출력이 보이면 equals 기준과 hashCode 기준이 같은 필드를 보고 있는지 먼저 확인해 볼 수 있습니다. equalstrue인데 hashCode가 다르면 해시 기반 컬렉션에서 같은 객체로 취급되지 않을 가능성이 큽니다.

반대로 hashCode가 같지만 equalsfalse인 경우는 해시 충돌일 수 있습니다. 이때는 컬렉션이 같은 버킷 안에서 equals로 다시 비교하므로, 최종 동등성 판단은 여전히 equals가 맡습니다.

원문 참고

https://www.maeil-mail.kr/question/70

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...