본문 바로가기

Object 클래스와 equals·hashCode·toString

모든 클래스가 물려받는 Object의 메서드를 살펴보고, toString·equals·hashCode를 올바르게 재정의하는 방법과 equals와 hashCode가 함께 지켜야 할 규칙을 배웁니다.

상속과 메서드 오버라이딩에서 extends를 적지 않은 클래스는 자동으로 java.lang.Object를 상속한다고 했습니다. 그래서 직접 만든 Post에 메서드를 하나도 적지 않아도 post.toString()이나 post.equals(other)를 호출할 수 있고, System.out.println(post)도 컴파일됩니다. 이 메서드들은 모두 Object에서 물려받은 것입니다.

그런데 물려받은 그대로 쓰면 기대와 다르게 동작할 때가 많습니다. 게시글을 출력하면 제목 대신 Post@2a139a55 같은 알아보기 힘든 문자열이 나오고, 제목과 조회 수가 똑같은 두 게시글을 equals로 비교해도 false가 나옵니다. 이 글에서는 먼저 Object 타입으로 무엇을 할 수 있고 무엇을 할 수 없는지 확인합니다. 그런 다음 가장 자주 재정의하는 세 메서드 toString, equals, hashCode를 올바르게 작성하는 방법을 배웁니다.

Object가 모든 객체에 물려주는 메서드

Object는 java.lang 패키지에 있습니다. String, System, Math와 같은 패키지라서 import 없이 바로 쓸 수 있습니다. 모든 클래스가 Object를 상속하므로 자바의 모든 객체는 Object에 선언된 메서드를 가지고 있습니다. 배열도 객체이므로 마찬가지입니다.

메서드하는 일다루는 곳
toString()객체를 나타내는 문자열을 돌려줍니다이 글
equals(Object)다른 객체와 같은 값인지 판단합니다이 글
hashCode()해시 기반 컬렉션이 쓰는 정수를 돌려줍니다이 글, 해시 테이블의 동작 원리
getClass()객체의 실제 클래스 정보를 돌려줍니다이 글, 리플렉션
clone()객체의 복사본을 만듭니다(이 교재에서는 배열에만 씁니다)값 복사와 참조 공유
wait(), notify(), notifyAll()스레드끼리 신호를 주고받습니다wait와 notify로 조건 기다리기

finalize()라는 메서드도 있지만 Java 9부터 사용 중단(deprecated) 상태이고 앞으로 제거될 예정이므로 새 코드에서는 쓰지 않습니다. 표의 메서드 가운데 getClass()와 wait·notify 계열은 final이라서 재정의할 수 없습니다. 반대로 toString, equals, hashCode는 클래스마다 알맞게 재정의하라고 열어 둔 메서드이고, 이 글의 대부분은 이 세 메서드를 다룹니다.

Object 타입 변수에는 어떤 객체든 담을 수 있습니다

모든 클래스가 Object의 자식이므로, 부모 타입 변수로 자식 객체 다루기에서 본 규칙에 따라 Object 타입 변수는 어떤 객체든 가리킬 수 있습니다. 다음 프로그램은 서로 관계없는 세 객체, 즉 게시글 Post와 문자열과 정수 객체를 Object 배열 하나에 담습니다. Post는 캡슐화로 회원 저장소 완성하기에서 본 record로 선언했습니다.

src/ObjectBoardInventory.java
public final class ObjectBoardInventory {
    public static void main(String[] args) {
        Object[] values = {
                new Post("object", 40),
                "memo",
                Integer.valueOf(3)
        };

        System.out.println("size=" + size(values));
        for (Object value : values) {
            System.out.println(value.getClass().getSimpleName() + "=" + value);
        }
    }

    private static int size(Object[] values) {
        return values.length;
    }

    private record Post(String title, int viewCount) { }
}
size=3
Post=Post[title=object, viewCount=40]
String=memo
Integer=3

size 메서드는 매개변수가 Object[]라서 어떤 객체 배열이든 받을 수 있고, 원소가 무엇인지 몰라도 개수는 셀 수 있습니다. 반복문의 value도 정적 타입이 Object이지만, getClass()와 문자열 결합은 Object에 있는 기능만 쓰므로 문제없이 컴파일됩니다. value.getClass()는 변수의 타입이 아니라 실제 객체의 클래스를 돌려주므로, getSimpleName()으로 이름을 꺼내면 Post, String, Integer가 차례로 나옵니다.

"=" + value처럼 문자열에 객체를 이어 붙이면 그 객체의 toString()이 호출됩니다. 이때도 변수의 타입과 상관없이 실제 객체가 재정의한 메서드가 실행됩니다. 그래서 record인 Post는 컴파일러가 만들어 준 toString()으로 Post[title=object, viewCount=40]을 돌려주고, String과 Integer는 자기 값을 문자열로 돌려줍니다.

int 같은 기본형 값은 객체가 아니어서 원래는 Object 자리에 들어갈 수 없습니다. 예제는 Integer.valueOf(3)으로 int 값 3을 Integer 객체로 감싸서 넣었습니다. 사실 그냥 3이라고 적어도 컴파일러가 같은 일을 대신해 주는데, 이를 오토박싱(autoboxing)이라고 합니다. 자세한 내용은 래퍼 클래스와 박싱·언박싱에서 다룹니다.

Object 변수로는 Object의 메서드만 호출할 수 있습니다

그렇다면 Object 변수에 담긴 Post의 제목을 꺼낼 수 있을까요? 다음 코드는 실제 객체가 title() 메서드를 가진 Post인데도 컴파일되지 않습니다.

lab/ObjectMethodLimitFailure.java
public final class ObjectMethodLimitFailure {
    public static void main(String[] args) {
        Object value = new Post("object", 40);
        System.out.println(value.title());
    }

    private record Post(String title, int viewCount) { }
}
컴파일 오류
lab/ObjectMethodLimitFailure.java:4: error: cannot find symbol
        System.out.println(value.title());
                                ^
  symbol:   method title()
  location: variable value of type Object
1 error

컴파일러는 메서드를 호출할 수 있는지 판단할 때 실제 객체가 아니라 변수의 정적 타입만 봅니다. 오류 메시지의 마지막 줄 variable value of type Object가 컴파일러가 본 타입을 알려 줍니다. Object에는 title()이 선언되어 있지 않으므로, 실제로는 Post가 들어 있더라도 호출을 거부합니다. 컴파일러가 보기에 Object 변수에는 언제든 String 같은 다른 객체가 들어올 수 있기 때문입니다.

Post의 기능을 쓰려면 instanceof와 오버라이딩에서 배운 패턴 매칭으로 타입을 확인한 뒤 좁혀야 합니다. 다음 코드는 object를 출력합니다.

if (value instanceof Post post) {
    System.out.println(post.title());
}

하지만 이런 확인이 타입마다 늘어난다면 설계를 다시 볼 때입니다. 예를 들어 여러 종류의 게시글에서 조회 수를 모두 더해야 한다면, Object로 받은 뒤 instanceof로 하나씩 가려내기보다 viewCount()를 선언한 인터페이스를 만들어 그 타입으로 받는 편이 낫습니다. 인터페이스로 역할과 구현 분리하기에서 본 역할 타입입니다.

Object는 무엇이든 받을 수 있는 대신 할 수 있는 일이 가장 적은 타입입니다. 그래서 println처럼 객체의 의미를 몰라도 되는 코드나, 리플렉션 도구처럼 객체 자체를 다루는 코드에 어울립니다. 조회 수 합계처럼 업무 의미가 있는 동작이 필요하다면 그 동작을 선언한 타입을 써야 합니다. 아래 그림은 앞의 두 프로그램을 함께 정리한 것입니다. 배열의 세 칸은 모두 Object 타입이지만 서로 다른 객체를 가리키고, Object의 메서드는 쓸 수 있어도 title()은 쓸 수 없습니다.

Object로 읽을 수 있는 공통 정보와 호출할 수 없는 구체 메서드

ObjectBoardInventory는 Post, String, Integer를 Object 배열로 받아 크기와 실제 클래스 이름, 문자 표현을 출력한다. 별도 실패 예제에서 Object value의 title 호출은 컴파일되지 않는다.

Object 참조는 공통 메서드를 사용할 수 있습니다

ObjectBoardInventory의 Object[] values는 세 원소를 담습니다. main이 size(values)의 반환값을 size=3으로 출력하고, 반복문은 아래 순서로 실행됩니다.

Object 참조는 공통 메서드를 사용할 수 있습니다Object 배열의 세 참조가 실제 Post object/40, String memo, Integer3을 가리킨다. 정적 Object의 공통 메서드를 사용할 수 있지만 Post만의 title 메서드는 그 정적 계약에 없다.
values[0]정적 타입: Object
연결 관계values[0] · 정적 타입: Object → Post · title = object · viewCount = 40
Posttitle = objectviewCount = 40
values[1]정적 타입: Object
연결 관계values[1] · 정적 타입: Object → String · memo · 불변 문자 값
Stringmemo불변 문자 값
values[2]정적 타입: Object
연결 관계values[2] · 정적 타입: Object → Integer · 3 · 정수 래퍼
Integer3정수 래퍼

실선: 배열 칸의 참조 → 실제 객체Object의 getClass·toString은 사용 가능 · title()은 공통 계약에 없음

Object 참조는 공통 메서드를 사용할 수 있습니다 Object 배열의 세 참조가 실제 Post object/40, String memo, Integer3을 가리킨다. 정적 Object의 공통 메서드를 사용할 수 있지만 Post만의 title 메서드는 그 정적 계약에 없다. Object 타입의 배열 칸 values[0] 정적 타입: Object Post title = object viewCount = 40 Object 타입의 배열 칸 values[1] 정적 타입: Object String memo 불변 문자 값 Object 타입의 배열 칸 values[2] 정적 타입: Object Integer 3 정수 래퍼 Object의 공통 메서드 호출 title()은 이 타입에 없음
Object 배열의 실제 세 원소와 반복문 출력
배열 원소와 실제 타입반복문이 출력하는 한 줄
values[0] · PostPost=Post[title=object, viewCount=40]
values[1] · StringString=memo
values[2] · IntegerInteger=3
  1. values[0] · Post
    반복문 출력

    Post=Post[title=object, viewCount=40]

  2. values[1] · String
    반복문 출력

    String=memo

  3. values[2] · Integer
    반복문 출력

    Integer=3

반복 변수의 정적 타입은 모두 Object입니다. getClass()로 실제 타입을 읽고, 문자열 결합이 각 객체의 문자 표현을 요청합니다.

별도 프로그램 ObjectMethodLimitFailure의 value.title()은 컴파일 실패입니다. 실제 대상이 Post여도 Object에 없는 메서드는 그 참조로 직접 호출할 수 없습니다.

toString: 객체를 사람이 읽을 수 있는 문자열로

먼저 toString()을 재정의하지 않은 클래스를 출력해 보겠습니다. 두 번째 줄은 같은 객체의 hashCode()를 16진수로 바꿔 출력합니다.

src/DefaultToString.java
public final class DefaultToString {
    public static void main(String[] args) {
        Post entry = new Post("equality", 50);
        System.out.println(entry);
        System.out.println(Integer.toHexString(entry.hashCode()));
    }

    private static final class Post {
        private final String title;
        private final int viewCount;

        Post(String title, int viewCount) {
            this.title = title;
            this.viewCount = viewCount;
        }
    }
}
DefaultToString$Post@2a139a55
2a139a55

Object가 물려주는 toString()은 getClass().getName() + '@' + Integer.toHexString(hashCode())의 결과, 즉 클래스 이름과 @와 해시 값의 16진수를 이어 붙인 문자열을 돌려줍니다. 두 줄에 같은 16진수가 보이는 것이 그 때문입니다. 클래스 이름에 $가 들어간 것은 Post가 DefaultToString 안에 선언된 중첩 클래스이기 때문인데, 중첩 클래스는 11장에서 다룹니다. @ 뒤의 값은 실행 환경에 따라, 또는 실행할 때마다 달라질 수 있으므로 직접 실행했을 때 다른 숫자가 나와도 정상입니다.

이 문자열로는 어떤 게시글인지 알 수 없습니다. 그래서 도메인 클래스는 보통 핵심 필드를 보여 주도록 toString()을 재정의합니다. 다음 예제는 제목과 조회 수를 보여 주도록 재정의한 뒤 두 가지 방법으로 출력합니다.

src/PostToString.java
public final class PostToString {
    public static void main(String[] args) {
        Post entry = new Post("equality", 50);
        print(entry);
        System.out.println("direct=" + entry);
    }

    private static void print(Object value) {
        System.out.println("audit=" + value.toString());
    }

    private static final class Post {
        private final String title;
        private final int viewCount;

        Post(String title, int viewCount) {
            this.title = title;
            this.viewCount = viewCount;
        }

        @Override
        public String toString() {
            return "Post{title='" + title + "', viewCount=" + viewCount + "}";
        }
    }
}
audit=Post{title='equality', viewCount=50}
direct=Post{title='equality', viewCount=50}

첫 줄은 print 메서드가 Object 타입 매개변수로 받은 객체의 toString()을 직접 호출한 결과입니다. 매개변수 타입이 Object여도 실행되는 것은 Post가 재정의한 메서드입니다. 두 번째 줄은 "direct=" + entry라는 문자열 결합의 결과입니다. 자바는 문자열에 객체를 이어 붙일 때 그 객체의 toString()을 호출해 문자열로 바꾸고, System.out.println(entry)처럼 객체를 바로 출력할 때도 toString()의 결과를 출력합니다.

메서드 위의 @Override는 상속과 메서드 오버라이딩에서 본 것처럼, 이 메서드가 정말 부모 메서드를 재정의하는지 컴파일러가 확인하게 하는 표시입니다. 뒤에서 보겠지만 equals를 작성할 때는 이 표시가 특히 중요합니다.

변수가 null일 때는 호출 방법에 따라 결과가 다릅니다. 문자열 결합과 println은 null을 만나면 toString()을 호출하지 않고 null이라는 글자를 대신 씁니다. 반면 value.toString()처럼 직접 호출하면 메서드를 실행할 객체가 없으므로 NullPointerException이 발생합니다. 그래서 null일 수 있는 값을 문자열로 바꿀 때는 toString()을 직접 부르기보다 문자열 결합이나 String.valueOf(value)를 쓰는 편이 안전합니다.

toString()의 결과는 사람이 로그나 디버거에서 읽기 위한 것입니다. 로그는 생각보다 많은 사람과 시스템에 전달되므로 비밀번호나 인증 토큰 같은 민감한 정보는 넣지 않습니다. 또 toString()의 결과를 파일에 저장하거나 다른 프로그램과 주고받는 형식으로 쓰지 않습니다. 공식 문서도 toString()의 출력은 시간이 지나거나 JVM을 다시 실행하면 달라질 수 있다고 적고 있습니다. 게다가 저장 형식으로 쓰기 시작하면 디버깅을 위해 출력 모양을 조금만 바꿔도 저장해 둔 데이터를 읽지 못하게 됩니다. CSV나 JSON처럼 저장과 교환에 쓸 형식은 그 일만 하는 메서드를 따로 만듭니다.

==는 같은 객체인지, equals는 같은 값인지 묻습니다

기본형과 참조형 복사에서 참조형 변수의 ==는 두 변수가 같은 객체를 가리키는지를 비교한다고 배웠습니다. 이를 동일성(identity)이라고 합니다. 반면 '두 객체가 같은 값을 나타내는가'는 동등성(equality)이라고 하며, equals 메서드로 판단합니다. String이 대표적인 예입니다. new String("java")를 두 번 실행해 만든 두 문자열은 서로 다른 객체라서 ==로는 false입니다. 하지만 String은 equals를 문자 내용을 비교하도록 재정의해 두었으므로 equals로는 true입니다.

직접 만든 클래스는 사정이 다릅니다. Object가 물려주는 equals는 ==와 똑같이 같은 객체인지만 비교합니다. 그러니 값으로 비교하고 싶다면 클래스가 직접 equals를 재정의해야 합니다. 이때 가장 먼저 정할 것은 '어떤 필드가 같으면 같은 객체로 볼 것인가'입니다.

이 글에서는 제목과 조회 수가 모두 같으면 같은 게시글 값으로 보겠습니다. 따로 만든 두 객체라도 두 필드가 같으면 같은 값입니다. 반면 데이터베이스에 저장되는 회원처럼 이름이 바뀌어도 같은 회원으로 봐야 하는 객체라면, 고유 식별자(id)만 비교하는 방법을 쓸 수 있습니다. 어떤 필드를 고를지는 클래스가 무엇을 나타내는지에 따라 달라집니다. 다만 뒤에서 보듯이 객체가 사는 동안 바뀌지 않는 값을 골라야 컬렉션 안에서도 문제가 생기지 않습니다.

src/PostEquality.java
import java.util.Objects;

public final class PostEquality {
    public static void main(String[] args) {
        Post first = new Post("object", 40);
        Post second = new Post("object", 40);
        Post alias = first;

        System.out.println("identity=" + (first == second));
        System.out.println("alias=" + (first == alias));
        System.out.println("equality=" + first.equals(second));
        System.out.println("hash=" + (first.hashCode() == second.hashCode()));
    }

    private static final class Post {
        private final String title;
        private final int viewCount;

        Post(String title, int viewCount) {
            this.title = title;
            this.viewCount = viewCount;
        }

        @Override
        public boolean equals(Object other) {
            if (this == other) return true;
            if (other == null || getClass() != other.getClass()) return false;
            Post that = (Post) other;
            return viewCount == that.viewCount && Objects.equals(title, that.title);
        }

        @Override
        public int hashCode() {
            return Objects.hash(title, viewCount);
        }
    }
}
identity=false
alias=true
equality=true
hash=true

first와 second는 new를 따로 실행해 만든 두 객체이므로 first == second는 false입니다. alias는 first의 참조값을 복사한 변수라서 같은 객체를 가리키고, first == alias는 true입니다. 세 번째 줄에서는 재정의한 equals가 두 객체의 필드를 비교하므로 true가 나옵니다. 네 번째 줄은 두 객체의 hashCode()가 같다는 뜻인데, 이것이 왜 필요한지는 조금 뒤에 설명합니다.

서로 다른 두 Post는 동등할 수 있고 alias는 같은 객체를 본다

PostEquality에서 first와 alias는 같은 Post를, second는 별도로 생성한 Post를 가리킨다. 두 Post의 제목 object와 조회 수 40은 같아 equals와 hashCode 비교가 true지만 first와 second의 동일성은 false다.

같은 값을 가진 두 객체와 하나의 객체를 공유하는 별칭

PostEquality는 new Post("object", 40)를 두 번 실행하고 alias = first를 대입합니다. 변수의 참조 대상과 비교 결과를 구분합니다.

같은 값을 가진 두 객체와 하나의 객체를 공유하는 별칭 first와 alias는 같은 Post, second는 같은 필드를 가진 다른 Post를 가리킨다. first==alias true, first==second false이며 값 equals와 그 필드 hashCode는 같다. first 참조 alias 참조 같은 Post title = object viewCount = 40 second 참조 다른 Post title = object viewCount = 40 first == alias: true first == second: false equals와 hashCode는 같은 값
세 변수와 두 Post 객체 first와 alias는 같은 첫 Post를 가리키고 second는 별도 Post를 가리킨다. 두 Post 모두 title이 object이고 viewCount가 40이다. first alias 첫 번째 Post 객체 title = "object" viewCount = 40 second 별도로 만든 Post 객체 title = "object" viewCount = 40
  • first와 alias는 같은 첫 번째 Post 객체를 가리킵니다. title="object", viewCount=40입니다.
  • second는 별도로 만든 Post 객체를 가리킵니다. 이 객체도 title="object", viewCount=40입니다.
  • first == second · identity=false
  • first == alias · alias=true
  • first.equals(second) · equality=true
  • first.hashCode() == second.hashCode() · hash=true

이 final Post의 equals는 같은 런타임 클래스의 title·viewCount를 비교하고, hashCode도 같은 두 필드를 사용합니다. 동등하면 해시값도 같아야 하지만, 해시값이 같다는 사실만으로 동등하다고 판정하지는 않습니다. 실제 주소나 숫자 해시값은 표시하지 않습니다.

equals의 본문은 위에서부터 차례로 걸러 내는 구조입니다. 첫 줄은 자기 자신과 비교하는 경우로, 필드를 볼 필요 없이 바로 true를 돌려줍니다. 둘째 줄은 other가 null이거나 실제 클래스가 다르면 false를 돌려줍니다. getClass()는 실제 객체의 클래스를 돌려주므로 정확히 같은 클래스의 객체만 다음 줄로 넘어갑니다. ||는 앞의 조건이 참이면 뒤를 계산하지 않으므로, other가 null일 때 other.getClass()가 호출되어 예외가 나는 일도 없습니다.

여기까지 통과했다면 other가 Post임이 확실하므로, 셋째 줄에서 Post로 캐스팅합니다. 마지막 줄에서는 필드를 비교합니다. int 같은 기본형은 ==로 비교하고, String 같은 참조형은 Objects.equals로 비교합니다. Objects.equals(a, b)는 두 인수가 같은 참조이거나 둘 다 null이면 true, a만 null이면 false를 돌려주고, 그 밖의 경우에만 a.equals(b)를 호출합니다. 그래서 필드가 null이어도 예외가 나지 않습니다. hashCode는 Objects.hash(title, viewCount)로, equals에서 비교한 두 필드를 써서 계산합니다.

equals의 매개변수 타입은 Object여야 합니다

equals를 처음 작성할 때 흔히 하는 실수는 매개변수 타입을 자기 클래스로 적는 것입니다. Post끼리 비교할 테니 equals(Post other)가 더 자연스러워 보이고 캐스팅도 필요 없습니다. 다음 프로그램은 그렇게 작성한 equals를 세 가지 방법으로 호출합니다.

lab/EqualsOverloadTrap.java
import java.util.Objects;

public final class EqualsOverloadTrap {
    public static void main(String[] args) {
        Post first = new Post("object", 40);
        Post second = new Post("object", 40);
        Object other = second;

        System.out.println("post=" + first.equals(second));
        System.out.println("object=" + first.equals(other));
        System.out.println("objects=" + Objects.equals(first, second));
    }

    private static final class Post {
        private final String title;
        private final int viewCount;

        Post(String title, int viewCount) {
            this.title = title;
            this.viewCount = viewCount;
        }

        public boolean equals(Post other) {
            return other != null && viewCount == other.viewCount && title.equals(other.title);
        }
    }
}
post=true
object=false
objects=false

같은 두 객체를 비교했는데 결과가 갈립니다. equals(Post other)는 Object의 equals(Object)와 매개변수 타입이 다르므로, 재정의가 아니라 이름만 같은 새 메서드를 추가한 오버로딩입니다. 그래서 Post에는 equals가 두 개 있고, 어느 쪽을 호출할지는 컴파일러가 인수의 정적 타입을 보고 정합니다. 첫 줄은 인수가 Post 타입이라 새로 만든 메서드가 선택되어 필드를 비교합니다. 둘째 줄은 같은 객체를 Object 타입 변수에 담아 넘겼으므로 물려받은 equals(Object)가 선택되고, 이 메서드는 같은 객체인지만 보므로 false입니다. 셋째 줄의 Objects.equals도 안에서 equals(Object)를 호출합니다.

셋째 줄이 가장 위험합니다. 14장과 15장에서 배울 ArrayList나 HashSet 같은 컬렉션은 원소를 Object로 다루므로, contains나 remove도 언제나 equals(Object)를 호출합니다. 결국 이 Post는 직접 비교할 때만 값으로 비교되고, 컬렉션 안에서는 같은 객체인지만 비교됩니다.

이 실수는 @Override를 붙이면 컴파일할 때 바로 드러납니다. equals(Post other) 위에 @Override를 붙이면 재정의할 부모 메서드가 없으므로 다음 오류가 납니다. equals, hashCode, toString을 재정의할 때 @Override를 빠뜨리지 않는 습관이 이런 실수를 막아 줍니다.

컴파일 오류
lab/EqualsOverloadTrap.java:23: error: method does not override or implement a method from a supertype
        @Override
        ^
1 error

equals가 지켜야 할 다섯 가지 규칙

equals는 아무렇게나 작성해도 컴파일되지만, 컬렉션과 라이브러리는 equals가 몇 가지 규칙을 지킨다고 믿고 동작합니다. Object의 공식 문서(Javadoc)는 equals가 동치 관계(equivalence relation)여야 한다고 정하고, 그 조건을 다음 다섯 가지로 적어 둡니다. 표의 x, y, z는 null이 아닌 참조입니다.

규칙뜻게시글로 말하면
반사성(reflexive)x.equals(x)는 true어떤 게시글도 자기 자신과 같습니다
대칭성(symmetric)x.equals(y)가 true이면 y.equals(x)도 true비교하는 순서를 바꿔도 결과가 같습니다
추이성(transitive)x.equals(y)와 y.equals(z)가 true이면 x.equals(z)도 trueA와 B가 같고 B와 C가 같으면 A와 C도 같습니다
일관성(consistent)비교에 쓰는 정보가 바뀌지 않는 한 몇 번을 호출해도 결과가 같음같은 두 게시글을 다시 비교해도 답이 바뀌지 않습니다
null과 비교x.equals(null)은 false예외를 던지지 않고 false를 돌려줍니다

앞의 PostEquality는 다섯 규칙을 모두 지킵니다. 필드 값만으로 판단하니 자기 자신과는 언제나 같고, 두 필드를 ==와 Objects.equals로 비교하므로 순서를 바꾸거나 셋을 이어 비교해도 결과가 어긋나지 않습니다. 필드가 final이라 바뀌지 않으니 일관성도 지켜지고, null은 둘째 줄에서 false가 됩니다. 이 규칙들은 상속 관계에 있는 클래스끼리 비교할 때 특히 깨지기 쉽습니다.

getClass와 instanceof 중 무엇으로 타입을 확인할까

equals에서 상대의 타입을 확인하는 방법은 두 가지입니다. 앞에서 쓴 getClass() != other.getClass()는 실제 클래스가 정확히 같아야 통과시키고, other instanceof Post는 Post의 하위 클래스 객체도 통과시킵니다. 하위 클래스가 없다면 두 방법의 결과는 같지만, 하위 클래스가 비교 기준을 바꾸면 차이가 생깁니다. 다음 예제에서 Post는 instanceof로 타입을 확인하고, 고정 순위 rank를 더한 하위 클래스 PinnedPost도 instanceof로 자기 타입을 확인합니다.

lab/SymmetryBreak.java
public final class SymmetryBreak {
    public static void main(String[] args) {
        Post post = new Post("object", 40);
        PinnedPost pinned = new PinnedPost("object", 40, 1);

        System.out.println("post.equals(pinned)=" + post.equals(pinned));
        System.out.println("pinned.equals(post)=" + pinned.equals(post));
    }

    private static class Post {
        final String title;
        final int viewCount;

        Post(String title, int viewCount) {
            this.title = title;
            this.viewCount = viewCount;
        }

        @Override
        public boolean equals(Object other) {
            if (!(other instanceof Post that)) return false;
            return viewCount == that.viewCount && title.equals(that.title);
        }

        @Override
        public int hashCode() {
            return 31 * title.hashCode() + viewCount;
        }
    }

    private static final class PinnedPost extends Post {
        final int rank;

        PinnedPost(String title, int viewCount, int rank) {
            super(title, viewCount);
            this.rank = rank;
        }

        @Override
        public boolean equals(Object other) {
            if (!(other instanceof PinnedPost that)) return false;
            return super.equals(that) && rank == that.rank;
        }
    }
}
post.equals(pinned)=true
pinned.equals(post)=false

같은 두 객체를 비교했는데 순서에 따라 결과가 다르니 대칭성이 깨졌습니다. post.equals(pinned)에서는 Post의 equals가 실행됩니다. pinned도 Post의 하위 클래스 객체이므로 instanceof Post를 통과하고, 제목과 조회 수만 비교해 true가 됩니다. 반대로 pinned.equals(post)에서는 PinnedPost의 equals가 실행되는데, post는 PinnedPost가 아니므로 첫 줄에서 false가 됩니다. 이런 equals를 쓰면 ArrayList의 contains처럼 내부에서 equals를 부르는 메서드의 결과가, 어느 객체를 목록에 넣고 어느 객체로 찾느냐에 따라 달라집니다.

가장 단순한 해결책은 값을 나타내는 클래스를 final로 선언해 하위 클래스를 만들 수 없게 하는 것입니다. 이 글의 다른 예제에서 Post를 final 클래스나 record로 선언한 것도 그 때문입니다. 하위 클래스가 없으면 getClass()와 instanceof 중 무엇을 써도 결과가 같으므로, instanceof와 오버라이딩에서 배운 패턴 매칭으로 equals를 더 짧게 쓸 수도 있습니다.

@Override
public boolean equals(Object other) {
    return other instanceof Post that
            && viewCount == that.viewCount
            && Objects.equals(title, that.title);
}

null instanceof Post는 언제나 false이므로 null 검사를 따로 하지 않아도 됩니다. 상속 계층 전체가 같은 동등성 기준을 공유해야 한다면, 하위 클래스가 비교 기준을 바꾸지 않도록 설계 단계에서 정해 두어야 합니다.

더 깊이: 필드를 더한 하위 클래스는 규칙을 모두 지키기 어렵습니다

PinnedPost의 대칭성을 고치려고, 상대가 일반 Post이면 제목과 조회 수만 비교하도록 바꿔 볼 수 있습니다. 그러면 대칭성은 돌아오지만 이번에는 추이성이 깨집니다. 순위가 1인 PinnedPost와 일반 Post가 같고, 그 Post와 순위가 2인 PinnedPost도 같은데, 정작 두 PinnedPost끼리는 순위가 달라 같지 않기 때문입니다. 조슈아 블로크(Joshua Bloch)의 《이펙티브 자바》는 이 문제를 자세히 다루면서, 객체를 만들 수 있는 클래스를 상속해 비교할 필드를 더하면 객체지향의 장점을 일부 포기하지 않는 한 규칙을 모두 지킬 방법이 없다고 설명합니다.

getClass() 방식이 바로 그 장점을 포기하는 방법입니다. 이 방식은 다섯 규칙을 모두 지키지만, 필드를 하나도 더하지 않은 하위 클래스의 객체조차 부모 객체와 같지 않다고 판단합니다. 하위 클래스 객체를 부모 타입 자리에 대신 쓸 수 있다는 다형성의 전제와 어긋나는 것입니다. 그래서 블로크는 필드를 더하고 싶다면 상속 대신 Post를 필드로 갖는 새 클래스를 만드는 구성(composition)을 권합니다. 어느 쪽을 골라도 대가가 있으므로, 값을 나타내는 클래스는 final로 두는 것이 가장 단순합니다.

equals를 재정의하면 hashCode도 재정의해야 합니다

hashCode()는 객체를 대표하는 int 값을 돌려주는 메서드입니다. HashSet이나 HashMap 같은 해시 기반 컬렉션은 이 값을 이용해 객체를 빨리 찾습니다. 공식 문서는 hashCode가 지켜야 할 규칙을 이 메서드의 '일반 계약(general contract)'이라고 부릅니다. 이 메서드를 재정의하는 모든 클래스가 지켜야 하는 약속이라는 뜻이고, 내용은 다음 세 가지입니다.

  • 프로그램이 한 번 실행되는 동안, equals 비교에 쓰는 정보가 바뀌지 않는 한 같은 객체의 hashCode()는 언제나 같은 값을 돌려줘야 합니다. 프로그램을 다시 실행했을 때까지 같을 필요는 없습니다.
  • equals로 같은 두 객체는 반드시 같은 hashCode() 값을 돌려줘야 합니다.
  • equals로 다른 두 객체가 반드시 다른 값을 돌려줄 필요는 없습니다. 다만 서로 다를수록 해시 컬렉션의 성능이 좋아집니다.

Object가 물려주는 hashCode()는 객체마다 되도록 다른 값을 돌려줍니다. 그래서 equals만 재정의하고 hashCode를 그대로 두면, equals로 같은 두 객체가 서로 다른 해시 값을 가져 두 번째 규칙이 깨집니다. 다음 프로그램은 앞의 PostEquality에서 hashCode 메서드만 지운 Post를 HashSet에 넣어 봅니다. HashSet은 같은 원소를 두 번 담지 않는 모음이고, Set<Post>는 'Post를 담는 Set'이라는 뜻입니다. <Post> 같은 표기는 13장에서, HashSet의 내부 구조는 15장에서 자세히 배웁니다.

lab/EqualsWithoutHashCode.java
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public final class EqualsWithoutHashCode {
    public static void main(String[] args) {
        Post first = new Post("object", 40);
        Post second = new Post("object", 40);

        Set<Post> posts = new HashSet<>();
        posts.add(first);

        System.out.println("equals=" + first.equals(second));
        System.out.println("contains=" + posts.contains(second));
        posts.add(second);
        System.out.println("size=" + posts.size());
    }

    private static final class Post {
        private final String title;
        private final int viewCount;

        Post(String title, int viewCount) {
            this.title = title;
            this.viewCount = viewCount;
        }

        @Override
        public boolean equals(Object other) {
            if (this == other) return true;
            if (other == null || getClass() != other.getClass()) return false;
            Post that = (Post) other;
            return viewCount == that.viewCount && Objects.equals(title, that.title);
        }
    }
}
equals=true
contains=false
size=2

equals는 두 게시글이 같다고 답하는데, HashSet은 second가 들어 있지 않다고 답하고 같은 값의 게시글을 하나 더 받아 크기가 2가 됩니다. HashSet은 원소를 찾을 때 모든 원소와 equals로 비교하지 않기 때문입니다. 먼저 찾는 객체의 hashCode()로 원소가 있을 만한 자리를 정하고, 그 자리에 있는 원소 가운데 해시 값이 같은 것만 equals로 비교합니다. first와 second의 해시 값은 Object의 hashCode()가 객체마다 따로 정한 값이라 서로 다르고, 그래서 HashSet은 second를 찾으면서 first를 비교 대상에 올리지도 않습니다. 두 값이 우연히 같을 수도 있지만, 그런 우연에 기대는 코드는 올바른 코드가 아닙니다.

PostEquality처럼 hashCode() 메서드를 다시 넣고 실행하면 결과가 다음과 같이 바뀝니다.

equals=true
contains=true
size=1

Objects.hash(title, viewCount)는 두 필드로 해시 값을 계산하므로, 필드가 같은 두 게시글은 언제나 같은 해시 값을 갖습니다. 그래서 HashSet이 first를 비교 대상에 올리고, equals로 같다는 것을 확인해 contains가 true가 되며 중복 추가도 막습니다.

반대 방향은 성립하지 않아도 됩니다. 예를 들어 문자열 "Aa"와 "BB"는 내용이 다른데도 hashCode()가 둘 다 2112입니다. 이렇게 서로 다른 객체의 해시 값이 같은 것을 충돌(collision)이라고 합니다. 해시 컬렉션은 해시 값이 같아도 equals로 한 번 더 확인하므로 충돌이 있어도 결과가 틀리지는 않고, 비교할 후보가 늘어 느려질 뿐입니다.

hashCode를 계산할 때는 equals에서 비교하는 필드만 사용합니다. equals가 비교하지 않는 필드를 해시 계산에 넣으면, equals로 같은 두 객체가 그 필드 때문에 서로 다른 해시 값을 가질 수 있기 때문입니다. 반대로 equals가 비교하는 필드 가운데 일부만 써서 해시를 계산하는 것은 규칙 위반은 아니지만 충돌이 늘어납니다.

equals에 쓰는 필드가 바뀌면 HashSet이 객체를 찾지 못합니다

앞에서 동등성에 쓸 필드는 바뀌지 않는 값으로 고르는 것이 좋다고 했습니다. 그 이유를 확인해 보겠습니다. PostEquality의 Post에서 viewCount의 final을 지우고 조회 수를 바꾸는 setViewCount 메서드를 추가한 뒤, main의 본문을 다음처럼 바꿉니다. 맨 위에는 앞 예제처럼 HashSet과 Set의 import가 필요합니다.

Post post = new Post("object", 40);
Set<Post> posts = new HashSet<>();
posts.add(post);

post.setViewCount(41);

System.out.println("contains=" + posts.contains(post));
System.out.println("size=" + posts.size());
contains=false
size=1

posts 안에는 분명히 post 객체가 하나 들어 있는데, 바로 그 객체로 찾아도 없다고 나옵니다. add할 때 HashSet은 조회 수가 40일 때의 해시 값으로 저장할 자리를 정했습니다. 조회 수가 41로 바뀐 뒤에는 hashCode()가 다른 값을 돌려주므로, contains는 바뀐 해시 값으로 찾다가 저장된 원소를 만나지 못하고 없다고 답합니다. 같은 이유로 remove(post)도 실패합니다. 객체는 집합 안에 남아 있지만 찾을 수도 지울 수도 없게 된 것입니다.

이 문제는 equals와 hashCode를 아무리 정확하게 작성해도 생깁니다. 그래서 해시 컬렉션에 넣을 객체는 동등성에 쓰는 필드를 바꿀 수 없게 만드는 것이 가장 안전합니다. 조회 수처럼 계속 바뀌어야 하는 값은 동등성 비교에서 빼거나, 객체를 아예 불변으로 만듭니다. 불변 객체는 불변 객체와 안전한 공유에서 다룹니다.

record는 equals·hashCode·toString을 만들어 줍니다

record를 쓰면 이 세 메서드를 컴파일러가 만들어 줍니다. 앞의 PostEquality와 같은 비교를 record로 해 보겠습니다.

src/RecordEquality.java
public final class RecordEquality {
    public static void main(String[] args) {
        Post first = new Post("object", 40);
        Post second = new Post("object", 40);

        System.out.println(first);
        System.out.println("same=" + (first == second));
        System.out.println("equals=" + first.equals(second));
        System.out.println("hash=" + (first.hashCode() == second.hashCode()));
    }

    private record Post(String title, int viewCount) { }
}
Post[title=object, viewCount=40]
same=false
equals=true
hash=true

record의 equals는 모든 구성 요소를 비교합니다. 기본형 구성 요소는 값을 비교하고 참조형 구성 요소는 Objects.equals로 비교하므로, 앞에서 직접 작성한 equals와 같은 결과가 나옵니다. hashCode도 모든 구성 요소로 계산하므로 equals와 짝이 맞고, toString은 구성 요소의 이름과 값을 대괄호 안에 나열합니다. 게다가 record는 상속할 수 없는 클래스(암묵적으로 final)라서 앞에서 본 하위 클래스 문제도 생기지 않습니다. 값을 담는 것이 주된 목적인 클래스라면 record로 선언하는 것이 가장 실수가 적습니다.

다만 record가 만든 메서드도 구성 요소의 equals에 기댄다는 점은 같습니다. 구성 요소가 배열이면 배열의 equals는 같은 배열인지만 비교하므로, 내용이 같은 배열을 담은 두 record도 같지 않다고 판단합니다. 또 공식 문서는 record가 만드는 toString의 정확한 형식이 바뀔 수 있다고 밝히고 있으므로, 이 문자열을 파싱해 값을 꺼내는 코드는 작성하지 않습니다. record를 더 깊이 활용하는 방법은 record 심화와 불변 스냅샷에서 다룹니다.

더 깊이: Objects.hash가 계산하는 값

Objects.hash(title, viewCount)는 인수들을 배열에 담아 Arrays.hashCode로 계산한 것과 같은 값을 돌려줍니다. 계산식은 31 * (31 * 1 + title.hashCode()) + Integer.hashCode(viewCount)이고, "object"와 40이면 -1659647862가 나옵니다. 31을 곱해 가며 더하는 방식이라 같은 값이라도 순서가 바뀌면 결과가 달라집니다. 호출할 때마다 가변 인수 배열을 만들고 int를 Integer로 감싸는 비용이 들지만, 대부분의 프로그램에서는 문제가 되지 않습니다. 해시 값을 아주 자주 계산하는 코드라면 같은 식을 직접 작성할 수 있습니다.

필드가 하나일 때는 주의할 점이 있습니다. 계산식의 초깃값 1 때문에 Objects.hash(title)은 31 + title.hashCode()가 되어 title.hashCode()와 다른 값입니다. 규칙 위반은 아니지만, 필드 하나의 해시 값을 그대로 쓰고 싶다면 null도 처리해 주는 Objects.hashCode(title)을 씁니다.

정리

  • 모든 클래스는 Object를 상속하므로 toString, equals, hashCode, getClass 같은 메서드를 갖습니다. Object 타입 변수는 어떤 객체든 가리킬 수 있지만, 그 변수로는 Object에 선언된 메서드만 호출할 수 있습니다.
  • toString은 사람이 읽을 문자열을 돌려줍니다. 핵심 필드를 보여 주도록 재정의하고, 민감한 정보를 넣거나 저장 형식으로 쓰지 않습니다.
  • ==는 같은 객체인지(동일성), equals는 같은 값인지(동등성)를 묻습니다. Object의 equals는 ==와 같으므로 값으로 비교하려면 재정의해야 하고, 매개변수 타입은 반드시 Object여야 합니다.
  • equals는 반사성, 대칭성, 추이성, 일관성, null과 비교하면 false라는 다섯 규칙을 지켜야 합니다. 값을 나타내는 클래스를 final로 두면 상속 때문에 규칙이 깨지는 일을 막을 수 있습니다.
  • equals로 같은 객체는 hashCode도 같아야 합니다. equals를 재정의하면 같은 필드로 hashCode도 재정의하고, 해시 컬렉션에 넣은 뒤에는 그 필드를 바꾸지 않습니다.
  • record는 모든 구성 요소로 equals, hashCode, toString을 만들어 줍니다.

연습 문제

  1. 폭과 높이가 같은 Rectangle 두 개가 동등하도록 equals와 hashCode를 구현하세요. 폭과 높이가 양수인지 생성자에서 검사하고, toString은 Rectangle{100x20} 형식으로 만드세요.
  2. 동료가 Rectangle의 equals를 '넓이가 같으면 같다'로 바꾸고, hashCode는 Objects.hash(width, height)를 그대로 두었습니다. 100×20 사각형과 20×100 사각형을 비교하면 어떤 문제가 생기는지 규칙에 비추어 설명하세요.
  3. equals를 public boolean equals(Rectangle other)로 선언한 Rectangle이 있습니다. 두 사각형이 모두 100×20일 때 first.equals(second)와 Objects.equals(first, second)는 각각 무엇을 돌려줄까요?
힌트 보기
  1. 비교할 필드가 모두 int이므로 ==로 비교하면 됩니다.
  2. 넓이가 같은 두 사각형의 hashCode()를 계산해 보세요. Objects.hash(a, b)는 31 * (31 + a) + b입니다.
  3. 어느 equals가 호출될지는 인수의 정적 타입으로 정해집니다. Objects.equals는 안에서 equals(Object)를 호출합니다.
해설 보기
  1. 다음과 같이 구현할 수 있습니다.
src/RectangleValueExercise.java
import java.util.Objects;

public final class RectangleValueExercise {
    public static void main(String[] args) {
        Rectangle first = new Rectangle(100, 20);
        Rectangle second = new Rectangle(100, 20);
        Rectangle third = new Rectangle(20, 100);

        System.out.println(first);
        System.out.println(first.equals(second));
        System.out.println(first.equals(third));
        System.out.println(first.hashCode() == second.hashCode());
    }

    private static final class Rectangle {
        private final int width;
        private final int height;

        Rectangle(int width, int height) {
            if (width <= 0 || height <= 0) throw new IllegalArgumentException("size");
            this.width = width;
            this.height = height;
        }

        @Override
        public boolean equals(Object other) {
            if (this == other) return true;
            if (other == null || getClass() != other.getClass()) return false;
            Rectangle that = (Rectangle) other;
            return width == that.width && height == that.height;
        }

        @Override
        public int hashCode() {
            return Objects.hash(width, height);
        }

        @Override
        public String toString() {
            return "Rectangle{" + width + "x" + height + "}";
        }
    }
}
Rectangle{100x20}
true
false
true

생성자는 폭이나 높이가 0 이하이면 예외를 던지므로, 만들어진 Rectangle은 언제나 양수 크기를 가집니다. equals는 앞의 Post와 같은 순서로 같은 객체인지, null이거나 클래스가 다른지, 필드가 같은지를 차례로 확인합니다. hashCode는 equals에서 비교한 두 필드로 계산합니다. 그래서 first와 second는 같고 해시 값도 같지만, 폭과 높이가 뒤바뀐 third는 같지 않습니다.

  1. 넓이만 비교하면 100×20과 20×100은 넓이가 모두 2000이므로 equals가 true를 돌려줍니다. 그런데 hashCode는 폭과 높이를 순서대로 써서 계산하므로 각각 31 * (31 + 100) + 20인 4081과 31 * (31 + 20) + 100인 1681로 다릅니다. equals로 같은 두 객체의 해시 값이 다르니 hashCode의 두 번째 규칙이 깨진 것입니다. 실제로 HashSet에 100×20 사각형을 넣고 20×100 사각형으로 contains를 호출하면 false가 나옵니다.

hashCode도 넓이로 계산하면 규칙은 지킬 수 있습니다. 하지만 그 전에 넓이가 같으면 같은 사각형으로 볼 것인지부터 다시 생각해야 합니다. 가로로 긴 사각형과 세로로 긴 사각형은 모양이 다르므로, 사각형이라는 값에는 폭과 높이를 모두 비교하는 쪽이 맞습니다. 동등성에 쓸 필드는 그 객체가 나타내는 개념을 기준으로 정합니다.

  1. first.equals(second)는 true, Objects.equals(first, second)는 false입니다. 첫 호출은 인수의 정적 타입이 Rectangle이라 새로 선언한 equals(Rectangle)이 선택되고 필드를 비교합니다. 반면 Objects.equals의 매개변수는 Object 타입이므로, 안에서 equals를 부를 때 Object에서 물려받은 equals(Object)가 선택됩니다. 이 메서드는 같은 객체인지만 비교하므로 false입니다. 메서드 위에 @Override를 붙였다면 컴파일러가 이 실수를 바로 알려 주었을 것입니다.

다음 글

값 복사와 참조 공유에서는 대입과 메서드 호출이 언제나 변수의 값을 복사한다는 규칙을 다시 짚습니다. 같은 객체를 가리키는 변수가 둘 생겼을 때 한쪽의 변경이 다른 쪽에 어떻게 보이는지를, 이 글에서 본 ==와 별칭(alias)에서 출발해 따라갑니다.