CodeGym /행동 /JAVA 25 SELF /equals, hashCode, toString: 자동 생성

equals, hashCode, toString: 자동 생성

JAVA 25 SELF
레벨 22 , 레슨 2
사용 가능

1. equals, hashCode, toString 자동 생성

이 메서드들이 왜 필요한가?

Java에서 객체를 다루다 보면 늘 마주치는 과제들이 있습니다. 때로는 두 객체가 같은지 확인해야 합니다. 예를 들어 Set이나 Map 같은 컬렉션에 이미 있는지 판단해야 하죠. 다른 경우에는 객체를 HashMap의 키로 사용하기도 하는데, 이때는 특별한 비교 규칙이 필수입니다. 또 거의 언제나 로그나 화면에 객체를 출력할 때 MyClass@7b23ec81 같은 의미 없는 값이 아니라, 이해하기 쉬운 형태로 보고 싶습니다.

이런 상황을 위해 Java의 모든 클래스에는 다음의 세 가지 특별한 메서드가 있습니다:

  • equals(Object o)는 동등성 검사 담당.
  • hashCode()는 컬렉션(해시 테이블 등)에 필요한 숫자 “지문”을 제공합니다.
  • toString()은 디버깅과 출력에 유용한 문자열 표현을 반환합니다.

왜 일반 클래스에서는 번거로운가?

일반 클래스에서는 이 메서드들을 직접 작성해야 합니다. 여기서부터 지루함과 두통이 시작됩니다. 클래스만 지저분해지는 보일러플레이트 코드가 잔뜩 생깁니다. 필드 비교를 하나 빼먹거나 hashCode를 잘못 계산해 난해한 버그를 만나기 쉽습니다. 클래스에 새 필드를 추가하면 또다시 이 모든 메서드를 수정해야 합니다.

일반 클래스 예시


public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() { return x; }
    public int y() { return y; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }

    @Override
    public int hashCode() {
        return 31 * x + y;
    }

    @Override
    public String toString() {
        return "Point[x=" + x + ", y=" + y + "]";
    }
}

익숙해 보이나요? 그것도 필드가 두 개뿐일 때 이야기입니다! 만약 스무 개라면?

record는 어떻게 해결하나

record 클래스가 이 모든 일을 대신해 줍니다. 선언만 하면 됩니다:


public record Point(int x, int y) { }

그러면 Java가 자동으로 생성합니다:

  • 생성자
  • 게터(x(), y())
  • equals, hashCode, toString

자동 생성되는 메서드

  • equals는 record의 모든 컴포넌트를 값으로 비교합니다.
  • hashCode는 모든 컴포넌트를 기반으로 계산됩니다.
  • toStringPoint[x=1, y=2] 같은 형식의 문자열을 반환합니다.

직접 확인해 봅시다!


public record Point(int x, int y) {}

public class Demo {
    public static void main(String[] args) {
        Point p1 = new Point(1, 2);
        Point p2 = new Point(1, 2);

        System.out.println(p1.equals(p2)); // true
        System.out.println(p1.hashCode() == p2.hashCode()); // true
        System.out.println(p1); // Point[x=1, y=2]
    }
}

출력:

true
true
Point[x=1, y=2]

예상대로 한 줄의 불필요한 코드도 없이 잘 동작합니다!

2. 왜 중요한가: 컬렉션, 디버깅, 안정성

컬렉션에서의 올바른 동작

HashMap의 키나 HashSet의 원소로 객체를 사용한다고 가정해 보세요. equalshashCode가 잘못 구현되어 있으면 컬렉션이 이상하게 동작합니다. 방금 추가한 원소를 못 찾거나, 서로 다른 두 객체를 동일하다고 판단할 수 있습니다.

record 클래스에서는 비교와 해시가 항상 모든 컴포넌트(선언된 순서대로)를 고려하므로 안심할 수 있습니다.

예시: 키로 record 사용


import java.util.HashMap;
import java.util.Map;

public class Demo {
    public static void main(String[] args) {
        record Point(int x, int y) {}

        Map<Point, String> map = new HashMap<>();
        Point p1 = new Point(3, 4);
        map.put(p1, "Hello!");

        Point p2 = new Point(3, 4);
        System.out.println(map.get(p2)); // "Hello!" — 작동함!
    }
}

주의하세요: p1p2는 서로 다른 객체(서로 다른 참조)이지만, 필드 값이 동일하므로 동등하다고 간주됩니다. MapHashMap에 대해서는 26 레벨에서 더 자세히 다룹니다 :P

디버깅과 로깅의 편의성

일반 클래스의 기본 출력처럼 밋밋한 Point@1a2b3c4d 대신, record 클래스는 보기 좋고 정보가 풍부하게 출력됩니다:

Point[x=3, y=4]

디버깅과 로깅 시 시간을 크게 절약할 수 있습니다.

3. record 내부에서 equals, hashCode, toString이 어떻게 동작하는가

equals 메서드

record 클래스의 equals는 다음 조건을 만족할 때 두 객체를 동등하다고 봅니다:

  • 동일한 타입(같은 record 클래스)일 것
  • 모든 컴포넌트가 동일할 것(프리미티브는 ==, 객체는 equals())

비교 예시


Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
Point p3 = new Point(1, 3);

System.out.println(p1.equals(p2)); // true
System.out.println(p1.equals(p3)); // false

hashCode 메서드

해시 코드는 보통 표준 메서드 Objects.hash(...)를 사용하여 record의 모든 컴포넌트로부터 계산됩니다.


System.out.println(p1.hashCode()); // 예: 994
System.out.println(p2.hashCode()); // 역시 994
System.out.println(p3.hashCode()); // 다른 숫자

toString 메서드

문자열 표현은 항상 다음 형식입니다:

ClassName[field1=value1, field2=value2, ...]

System.out.println(p1); // Point[x=1, y=2]

4. equals, hashCode, toString 재정의: 언제, 어떻게?

가끔(드물지만) 기본 동작을 바꿔야 할 때가 있습니다. 예를 들어 toString을 다른 형식으로 출력하고 싶거나, 일부 필드만으로 비교하고 싶을 수 있습니다.

주의: equals/hashCode를 재정의할 때는 매우 신중해야 합니다! 이들의 “계약”을 어기면 잡기 어려운 버그로 이어질 수 있습니다.

메서드를 재정의하는 방법

record 클래스 본문에 메서드를 선언하면 됩니다:


public record Point(int x, int y) {
    @Override
    public String toString() {
        return "(" + x + "; " + y + ")";
    }
}

Point p = new Point(3, 5);
System.out.println(p); // (3; 5)

equals/hashCode도 재정의할 수 있을까?

가능하지만, 확신이 없다면 권장하지 않습니다. 예를 들어 비교를 x 필드만으로 하려는 경우(다소 이상하지만)입니다:


public record Point(int x, int y) {
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point other)) return false;
        return x == other.x;
    }

    @Override
    public int hashCode() {
        return Integer.hashCode(x);
    }
}

Point p1 = new Point(1, 2);
Point p2 = new Point(1, 999);

System.out.println(p1.equals(p2)); // true (!)

단, 주의하세요: equals를 재정의한다면 반드시 hashCode도 함께 재정의해야 합니다. 그렇지 않으면 컬렉션이 올바르게 동작하지 않습니다.

모범 사례

  • 재정의할 명확한 이유가 없다면 재정의하지 마세요.
  • toString은 원하는 형식으로 바꿔도 비교적 안전합니다.
  • equals/hashCode는 명확한 필요와 결과에 대한 이해가 있을 때만 바꾸세요.

5. 실습: 객체 비교와 컬렉션에서 record 사용

예시: 두 record 객체 비교


public record User(String name, int age) {}

public class Demo {
    public static void main(String[] args) {
        User u1 = new User("Alice", 20);
        User u2 = new User("Alice", 20);
        User u3 = new User("Bob", 25);

        System.out.println(u1.equals(u2)); // true
        System.out.println(u1.equals(u3)); // false

        System.out.println(u1.hashCode() == u2.hashCode()); // true
        System.out.println(u1); // User[name=Alice, age=20]
    }
}

예시: HashMap 키로 record 사용

사용자 이름과 나이로 방문 횟수를 저장하는 애플리케이션이 있다고 가정해 봅시다(예컨대 클럽에 “Ivan, 20세”가 두 명 있을 수도 있으니까요).


import java.util.HashMap;
import java.util.Map;

public class Demo {
    public static void main(String[] args) {
        record User(String name, int age) {}

        Map<User, Integer> visits = new HashMap<>();
        User ivan20 = new User("Ivan", 20);
        User ivan22 = new User("Ivan", 22);

        visits.put(ivan20, 5);
        visits.put(ivan22, 2);

        // 값 기반 조회가 올바르게 동작하는지 확인
        System.out.println(visits.get(new User("Ivan", 20))); // 5
        System.out.println(visits.get(new User("Ivan", 22))); // 2
    }
}

만약 equalshashCode가 제대로 구현되지 않았다면 조회가 실패했을 것입니다. MapHashMap에 대해서는 26 레벨 강의에서 더 자세히 다룹니다 :P

6. record에서 equals, hashCode, toString을 다룰 때 흔한 실수

오류 1: 생성 후 필드를 변경할 수 있다고 기대함.
record의 필드는 항상 final이며, 비교는 생성자에서 설정된 값들로 이루어집니다. 만약 필드 안에 있는 가변 객체를 통해 내부 상태를 “바꿔” 버리면, 비교와 해시가 일관성을 잃을 수 있습니다.

오류 2: equals만 재정의하고 hashCode를 잊음.
두 메서드 중 하나를 재정의하면 — 다른 하나도 반드시 재정의해야 합니다! 그렇지 않으면 HashSet, HashMap 같은 컬렉션이 예측 불가능하게 동작합니다.

오류 3: toString이 다른 형식일 것이라 기대함.
특별한 문자열 형식이 필요하다면 toString을 직접 재정의하세요. 기본 형식은 항상 ClassName[field1=value1, field2=value2]입니다.

오류 4: 변경 가능한 필드를 가진 복잡한 클래스에 record를 사용함.
record의 필드는 불변이어야 합니다. 예를 들어 ArrayList를 필드로 두고 그 내용이 바뀐다면, 비교와 해시 코드가 “망가질” 수 있습니다. record에는 불변 타입만 사용하는 것이 좋습니다.

오류 5: value-object가 아닌 성격의 클래스를 record로 만듦.
record는 “구문이 짧은 작은 클래스”가 아닙니다. 값의 집합을 담는 value-object를 위한 것입니다. 복잡한 로직, 가변 상태, 상속이 필요하다면 일반 클래스를 사용하세요.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION