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는 모든 컴포넌트를 기반으로 계산됩니다.
- toString은 Point[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의 원소로 객체를 사용한다고 가정해 보세요. equals와 hashCode가 잘못 구현되어 있으면 컬렉션이 이상하게 동작합니다. 방금 추가한 원소를 못 찾거나, 서로 다른 두 객체를 동일하다고 판단할 수 있습니다.
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!" — 작동함!
}
}
주의하세요: p1과 p2는 서로 다른 객체(서로 다른 참조)이지만, 필드 값이 동일하므로 동등하다고 간주됩니다. Map과 HashMap에 대해서는 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
}
}
만약 equals와 hashCode가 제대로 구현되지 않았다면 조회가 실패했을 것입니다. Map과 HashMap에 대해서는 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를 위한 것입니다. 복잡한 로직, 가변 상태, 상속이 필요하다면 일반 클래스를 사용하세요.
GO TO FULL VERSION