불변 날짜 클래스와 equals·hashCode
여러 곳에서 공유한 날짜가 함께 바뀌는 문제를 불변 날짜 클래스로 고치고, 값이 같은 두 날짜를 같은 값으로 다루도록 equals와 hashCode, toString을 규칙에 맞게 구현하는 방법을 배웁니다.
날짜는 불변 객체가 가장 잘 어울리는 타입입니다. 게시글이 올라간 날짜는 게시 이력에도 쓰이고, 다음 일정을 잡을 때도 쓰이고, 통계를 낼 때도 쓰입니다. 이렇게 여러 곳에서 함께 쓰는 날짜를 누군가 고칠 수 있다면, 한 기능에서 바꾼 날짜가 지나간 기록까지 바꿔 버릴 수 있습니다.
이 글에서는 작은 날짜 클래스 MyDate를 직접 만들면서 불변 객체와 with 메서드에서 배운 내용을 한데 모읍니다. 그리고 날짜처럼 값 자체가 의미인 객체에 꼭 필요한 세 메서드, equals, hashCode, toString을 구현합니다. 값이 같은 두 날짜를 같다고 판단하게 만들려면 몇 가지 규칙을 지켜야 하는데, 그 규칙이 무엇이고 어기면 어떤 일이 생기는지도 실행해서 확인합니다.
공유한 날짜를 고치면 지난 기록도 바뀝니다
먼저 날짜를 가변 객체로 만들면 무슨 일이 생기는지 보겠습니다. 아래 MutableMyDate는 연도를 바꾸는 setYear를 가진 가변 날짜입니다. 게시 이력 BoardRecord와 다음 일정 BoardSchedule이 같은 날짜 객체를 받고, 일정 쪽에서 날짜를 다음 해로 옮깁니다.
public final class MutableDateHistoryBug {
public static void main(String[] args) {
MutableMyDate shared = new MutableMyDate(2025, 1, 10);
BoardRecord history = new BoardRecord("object", shared);
BoardSchedule next = new BoardSchedule("review", shared);
next.moveToYear(2026);
System.out.println("history=" + history.date());
System.out.println("next=" + next.date());
}
private static final class MutableMyDate {
private int year;
private final int month;
private final int day;
MutableMyDate(int year, int month, int day) {
this.year = year;
this.month = month;
this.day = day;
}
void setYear(int year) { this.year = year; }
@Override public String toString() { return year + "-" + month + "-" + day; }
}
private record BoardRecord(String title, MutableMyDate date) { }
private record BoardSchedule(String title, MutableMyDate date) {
void moveToYear(int year) { date.setYear(year); }
}
}history=2026-1-10
next=2026-1-10일정만 다음 해로 옮기려 했는데, 2025년 1월 10일에 올라간 글의 이력까지 2026년으로 바뀌었습니다. history와 next가 날짜 객체를 각자 하나씩 가진 것이 아니라, shared가 가리키는 같은 객체 하나를 함께 가리키고 있기 때문입니다. moveToYear(2026)은 그 객체의 year 필드를 바꾸므로, 같은 객체를 읽는 두 출력이 모두 2026년이 됩니다.
공유된 날짜 하나를 바꾸면 두 조회 결과가 바뀐다
history와 next의 final 참조가 같은 가변 날짜를 공유하여 year 2025→2026 변경을 둘 다 읽습니다. 참조는 고정이고 공유 필드만 바뀌는 실제 상태 전환입니다.
MutableDateHistoryBug에서 history와 next의 final date 참조는 shared와 같은 MutableMyDate를 가리킨다. next.moveToYear(2026)는 이 객체의 year를 바꾸어 두 출력 모두2026-1-10이 된다.
공유 날짜를 바꾸면 이력과 일정의 날짜가 함께 바뀝니다
MutableDateHistoryBug에서 next.moveToYear(2026) 실행 뒤입니다. date 참조를 교체한 것이 아니라, 함께 가리키던 객체의 year를 바꿨습니다.
history· BoardRecord- 제목은
object입니다.date는shared와 같은 날짜 객체의 참조입니다. next· BoardSchedule- 제목은
review입니다.date도 같은 객체를 가리킵니다.moveToYear(2026)가 그 객체의setYear를 호출합니다. - 하나의 MutableMyDate
year가2025에서2026으로 바뀝니다.month = 1,day = 10은 유지됩니다.
두 출력은 history=2026-1-10, next=2026-1-10입니다. 기록용 날짜만 따로 복사하거나 새 날짜를 만든 동작은 없습니다.
BoardRecord와 BoardSchedule이 record여도 구성 요소인 날짜는 가변입니다. final 참조는 참조 대상의 year 변경까지 막지 않습니다.
이 예의 조건
- 두 final 필드가 가변 날짜 객체를 공유한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | history와 next의 date 참조 및 shared는 같은 MutableMyDate를 가리킨다. next의 변경이 year 2025→2026만 수정하므로 두 독자는 2026-1-10을 본다. record/final 참조가 날짜 객체를 불변으로 만들지는 않는다. |
| 13 | month 1 |
| 14 | day 10 |
| 구분 | 내용 |
|---|---|
| 설명 | history와 next의 date 참조 및 shared는 같은 MutableMyDate를 가리킨다. next의 변경이 year 2025→2026만 수정하므로 두 독자는 2026-1-10을 본다. record/final 참조가 날짜 객체를 불변으로 만들지는 않는다. |
| 전체 읽기 정보 | 공유된 날짜 하나를 바꾸면 두 조회 결과가 바뀐다 공유된 날짜 하나를 바꾸면 두 조회 결과가 바뀐다 MutableDateHistoryBug에서 history와 next의 final date 참조는 shared와 같은 MutableMyDate를 가리킨다. next.moveToYear(2026)는 이 객체의 year를 바꾸어 두 출력 모두 2026-1-10이 된다. 공유 날짜를 바꾸면 이력과 일정의 날짜가 함께 바뀝니다 MutableDateHistoryBug 에서 next.moveToYear(2026) 실행 뒤입니다. date 참조를 교체한 것이 아니라, 함께 가리키던 객체의 year 를 바꿨습니다. history · BoardRecord 제목은 object 입니다. date 는 shared 와 같은 날짜 객체의 참조입니다. next · BoardSchedule 제목은 review 입니다. date 도 같은 객체를 가리킵니다. moveToYear(2026) 가 그 객체의 setYear 를 호출합니다. 하나의 MutableMyDate year 가 2025 에서 2026 으로 바뀝니다. month = 1 , day = 10 은 유지됩니다. 두 출력은 history=2026-1-10 , next=2026-1-10 입니다. 기록용 날짜만 따로 복사하거나 새 날짜를 만든 동작은 없습니다. BoardRecord 와 BoardSchedule 이 record여도 구성 요소인 날짜는 가변입니다. final 참조는 참조 대상의 year 변경까지 막지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 공유 날짜 history와 next가 같은 2025/1/10을 보관합니다. |
|
|
| 연도 변경 shared 날짜의 year를 2026으로 바꿉니다. |
|
|
| 두 독자 관찰 기록과 일정에서 변경값을 읽습니다. |
|
|
공유된 날짜 하나를 바꾸면 두 조회 결과가 바뀐다
history와 next의 final 참조가 같은 가변 날짜를 공유하여 year 2025→2026 변경을 둘 다 읽습니다. 참조는 고정이고 공유 필드만 바뀌는 실제 상태 전환입니다.
MutableDateHistoryBug에서 history와 next의 final date 참조는 shared와 같은 MutableMyDate를 가리킨다. next.moveToYear(2026)는 이 객체의 year를 바꾸어 두 출력 모두2026-1-10이 된다.
공유 날짜를 바꾸면 이력과 일정의 날짜가 함께 바뀝니다
MutableDateHistoryBug에서 next.moveToYear(2026) 실행 뒤입니다. date 참조를 교체한 것이 아니라, 함께 가리키던 객체의 year를 바꿨습니다.
history· BoardRecord- 제목은
object입니다.date는shared와 같은 날짜 객체의 참조입니다. next· BoardSchedule- 제목은
review입니다.date도 같은 객체를 가리킵니다.moveToYear(2026)가 그 객체의setYear를 호출합니다. - 하나의 MutableMyDate
year가2025에서2026으로 바뀝니다.month = 1,day = 10은 유지됩니다.
두 출력은 history=2026-1-10, next=2026-1-10입니다. 기록용 날짜만 따로 복사하거나 새 날짜를 만든 동작은 없습니다.
BoardRecord와 BoardSchedule이 record여도 구성 요소인 날짜는 가변입니다. final 참조는 참조 대상의 year 변경까지 막지 않습니다.
이 예의 조건
- 두 final 필드가 가변 날짜 객체를 공유한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | history와 next의 date 참조 및 shared는 같은 MutableMyDate를 가리킨다. next의 변경이 year 2025→2026만 수정하므로 두 독자는 2026-1-10을 본다. record/final 참조가 날짜 객체를 불변으로 만들지는 않는다. |
| 13 | month 1 |
| 14 | day 10 |
| 구분 | 내용 |
|---|---|
| 설명 | history와 next의 date 참조 및 shared는 같은 MutableMyDate를 가리킨다. next의 변경이 year 2025→2026만 수정하므로 두 독자는 2026-1-10을 본다. record/final 참조가 날짜 객체를 불변으로 만들지는 않는다. |
| 전체 읽기 정보 | 공유된 날짜 하나를 바꾸면 두 조회 결과가 바뀐다 공유된 날짜 하나를 바꾸면 두 조회 결과가 바뀐다 MutableDateHistoryBug에서 history와 next의 final date 참조는 shared와 같은 MutableMyDate를 가리킨다. next.moveToYear(2026)는 이 객체의 year를 바꾸어 두 출력 모두 2026-1-10이 된다. 공유 날짜를 바꾸면 이력과 일정의 날짜가 함께 바뀝니다 MutableDateHistoryBug 에서 next.moveToYear(2026) 실행 뒤입니다. date 참조를 교체한 것이 아니라, 함께 가리키던 객체의 year 를 바꿨습니다. history · BoardRecord 제목은 object 입니다. date 는 shared 와 같은 날짜 객체의 참조입니다. next · BoardSchedule 제목은 review 입니다. date 도 같은 객체를 가리킵니다. moveToYear(2026) 가 그 객체의 setYear 를 호출합니다. 하나의 MutableMyDate year 가 2025 에서 2026 으로 바뀝니다. month = 1 , day = 10 은 유지됩니다. 두 출력은 history=2026-1-10 , next=2026-1-10 입니다. 기록용 날짜만 따로 복사하거나 새 날짜를 만든 동작은 없습니다. BoardRecord 와 BoardSchedule 이 record여도 구성 요소인 날짜는 가변입니다. final 참조는 참조 대상의 year 변경까지 막지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 공유 날짜 history와 next가 같은 2025/1/10을 보관합니다. |
|
|
| 연도 변경 shared 날짜의 year를 2026으로 바꿉니다. |
|
|
| 두 독자 관찰 기록과 일정에서 변경값을 읽습니다. |
|
|
BoardRecord가 레코드라서 date 필드가 final이라는 점도 도움이 되지 않았습니다. final은 date가 다른 날짜 객체를 가리키지 못하게 할 뿐, 가리키는 객체의 year가 바뀌는 것은 막지 않습니다. 불변 객체를 처음 만든 글에서 본 배열 필드와 같은 상황입니다.
고치는 방법은 두 가지입니다. 하나는 BoardRecord가 날짜를 받을 때마다 복사본을 만들어 보관하는 것입니다. 하지만 이 방법은 날짜를 받는 모든 클래스가 복사를 잊지 않아야 해서, 공유 참조와 소유권에서 본 것처럼 한 곳만 빠뜨려도 문제가 다시 생깁니다. 다른 하나는 날짜 타입 자체를 불변으로 만드는 것입니다. 이렇게 하면 날짜를 어디서 몇 번 공유하든 누구도 값을 바꿀 수 없으니, 한 번의 설계로 모든 곳의 문제가 사라집니다.
연도만 바꾼 새 날짜를 돌려주는 withYear
불변 날짜 클래스는 앞의 두 글에서 본 순서대로 만듭니다. 필드를 모두 private final로 두고, setter 대신 바뀐 값을 담은 새 객체를 돌려주는 메서드를 둡니다. 아래 withYear는 월과 일은 그대로 두고 연도만 바꾼 새 MyDate를 만듭니다.
public final class ImmutableMyDate {
public static void main(String[] args) {
MyDate original = new MyDate(2025, 1, 10);
MyDate nextYear = original.withYear(2026);
System.out.println("original=" + original);
System.out.println("next=" + nextYear);
System.out.println("same=" + (original == nextYear));
}
private static final class MyDate {
private final int year;
private final int month;
private final int day;
MyDate(int year, int month, int day) {
validate(year, month, day);
this.year = year;
this.month = month;
this.day = day;
}
MyDate withYear(int newYear) {
return new MyDate(newYear, month, day);
}
private static void validate(int year, int month, int day) {
if (year < 1) throw new IllegalArgumentException("year");
if (month < 1 || month > 12) throw new IllegalArgumentException("month");
if (day < 1 || day > 31) throw new IllegalArgumentException("day");
}
@Override public String toString() { return year + "-" + month + "-" + day; }
}
}original=2025-1-10
next=2026-1-10
same=falsewithYear(2026)을 호출한 뒤에도 original은 2025년 1월 10일 그대로이고, nextYear는 2026년 1월 10일을 담은 다른 객체입니다. 마지막 줄의 same=false가 두 변수가 서로 다른 객체를 가리킨다는 것을 보여 줍니다. 앞의 버그 예제에서 일정이 이 방식으로 날짜를 옮겼다면, 이력은 원래 날짜 객체를 그대로 가리키므로 2025년으로 남았을 것입니다.
withYear도 결국 new MyDate(...)를 호출하므로 생성자의 검사를 거칩니다. 그래서 original.withYear(0)을 호출하면 validate에서 IllegalArgumentException: year가 던져지고 새 날짜는 만들어지지 않습니다. 검사는 매개변수만 보고 하면 되므로 인스턴스 필드가 필요 없는 static 메서드로 분리했고, 생성자는 필드를 채우기 전에 이 메서드를 먼저 호출합니다.
withYear의 새 날짜와 단순 검증 범위
withYear(2026)은 원래 2025/1/10을 보존하고 새 2026/1/10을 반환합니다. 값 반환의 전후를 보여 주되 MyDate의 범위 검사와 실제 달력 조합 검증을 같은 보장으로 읽지 않습니다.
ImmutableMyDate는2025-1-10인 original을 보존하고2026-1-10인 새 nextYear를 반환한다. same은false다. 검증은 연1이상 월1부터12 일1부터31이며 실제 달력의 날짜 조합은 검사하지 않는다.
withYear는 월·일을 이어받는 새 날짜를 반환합니다
ImmutableMyDate의 original.withYear(2026)을 nextYear에 대입한 뒤입니다.
| 변수 | 날짜 출력 | 객체와 값의 관계 |
|---|---|---|
original | 2025-1-10 | 기존 객체와 기존 연·월·일을 유지합니다. |
nextYear | 2026-1-10 | new MyDate(...)로 생성한 별도 객체입니다. 연도만 새 값, 월·일은 1·10입니다. |
original·2025-1-10- 기존 객체의 연·월·일을 그대로 보존합니다.
nextYear·2026-1-10new MyDate(...)로 만든 별도 객체입니다. 연도만 바뀌고 월·일1·10을 이어받습니다.
original == nextYear의 출력은 same=false입니다. 이 프로그램의 문자 표현은 월·일 앞에 0을 붙이지 않습니다.
withYear도 생성자를 거칩니다. 코드의 허용 범위는 연 1 이상, 월 1..12, 일 1..31입니다. 원문이 설명하듯 2월 30일 같은 조합은 막지 못하며, 이 예제는 완전한 달력 검증기가 아닙니다.
이 예의 조건
- 단순 필드 범위 검증만 있고 실제 달력 조합 검사는 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | original은 2025/1/10의 기존 MyDate, nextYear는 withYear 2026이 만든 2026/1/10의 새 MyDate를 참조한다. same은 false이고 단순 필드 범위 검증은 달력 조합을 검사하지 않는다. |
| 8 | 2025 |
| 12 | 2026 |
| 16 | 연 1 이상 · 월 1..12 · 일 1..31만 검사 · 실제 달력 조합까지 보장하지 않음 |
| 구분 | 내용 |
|---|---|
| 설명 | original은 2025/1/10의 기존 MyDate, nextYear는 withYear 2026이 만든 2026/1/10의 새 MyDate를 참조한다. same은 false이고 단순 필드 범위 검증은 달력 조합을 검사하지 않는다. |
| 전체 읽기 정보 | withYear의 새 날짜와 단순 검증 범위 withYear의 새 날짜와 단순 검증 범위 ImmutableMyDate는 2025-1-10인 original을 보존하고 2026-1-10인 새 nextYear를 반환한다. same은 false다. 검증은 연 1 이상 월 1부터 12, 일 1부터 31이며 실제 달력의 날짜 조합은 검사하지 않는다. withYear는 월·일을 이어받는 새 날짜를 반환합니다 ImmutableMyDate 의 original.withYear(2026) 을 nextYear 에 대입한 뒤입니다. withYear 호출 뒤 두 날짜의 값과 참조 변수 날짜 출력 객체와 값의 관계 original 2025-1-10 기존 객체와 기존 연·월·일을 유지합니다. nextYear 2026-1-10 new MyDate(...) 로 생성한 별도 객체입니다. 연도만 새 값, 월·일은 1 · 10 입니다. original · 2025-1-10 기존 객체의 연·월·일을 그대로 보존합니다. nextYear · 2026-1-10 new MyDate(...) 로 만든 별도 객체입니다. 연도만 바뀌고 월·일 1 · 10 을 이어받습니다. original == nextYear 의 출력은 same=false 입니다. 이 프로그램의 문자 표현은 월·일 앞에 0 을 붙이지 않습니다. withYear 도 생성자를 거칩니다. 코드의 허용 범위는 연 1 이상, 월 1..12 , 일 1..31 입니다. 원문이 설명하듯 2월 30일 같은 조합은 막지 못하며, 이 예제는 완전한 달력 검증기가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기존 날짜 original이 2025/1/10을 참조합니다. |
|
|
| 새 날짜 계산 withYear 2026이 월·일을 이어받습니다. |
|
|
| 반환 참조 nextYear가 새 MyDate를 받습니다. |
|
|
withYear의 새 날짜와 단순 검증 범위
withYear(2026)은 원래 2025/1/10을 보존하고 새 2026/1/10을 반환합니다. 값 반환의 전후를 보여 주되 MyDate의 범위 검사와 실제 달력 조합 검증을 같은 보장으로 읽지 않습니다.
ImmutableMyDate는2025-1-10인 original을 보존하고2026-1-10인 새 nextYear를 반환한다. same은false다. 검증은 연1이상 월1부터12 일1부터31이며 실제 달력의 날짜 조합은 검사하지 않는다.
withYear는 월·일을 이어받는 새 날짜를 반환합니다
ImmutableMyDate의 original.withYear(2026)을 nextYear에 대입한 뒤입니다.
| 변수 | 날짜 출력 | 객체와 값의 관계 |
|---|---|---|
original | 2025-1-10 | 기존 객체와 기존 연·월·일을 유지합니다. |
nextYear | 2026-1-10 | new MyDate(...)로 생성한 별도 객체입니다. 연도만 새 값, 월·일은 1·10입니다. |
original·2025-1-10- 기존 객체의 연·월·일을 그대로 보존합니다.
nextYear·2026-1-10new MyDate(...)로 만든 별도 객체입니다. 연도만 바뀌고 월·일1·10을 이어받습니다.
original == nextYear의 출력은 same=false입니다. 이 프로그램의 문자 표현은 월·일 앞에 0을 붙이지 않습니다.
withYear도 생성자를 거칩니다. 코드의 허용 범위는 연 1 이상, 월 1..12, 일 1..31입니다. 원문이 설명하듯 2월 30일 같은 조합은 막지 못하며, 이 예제는 완전한 달력 검증기가 아닙니다.
이 예의 조건
- 단순 필드 범위 검증만 있고 실제 달력 조합 검사는 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | original은 2025/1/10의 기존 MyDate, nextYear는 withYear 2026이 만든 2026/1/10의 새 MyDate를 참조한다. same은 false이고 단순 필드 범위 검증은 달력 조합을 검사하지 않는다. |
| 8 | 2025 |
| 12 | 2026 |
| 16 | 연 1 이상 · 월 1..12 · 일 1..31만 검사 · 실제 달력 조합까지 보장하지 않음 |
| 구분 | 내용 |
|---|---|
| 설명 | original은 2025/1/10의 기존 MyDate, nextYear는 withYear 2026이 만든 2026/1/10의 새 MyDate를 참조한다. same은 false이고 단순 필드 범위 검증은 달력 조합을 검사하지 않는다. |
| 전체 읽기 정보 | withYear의 새 날짜와 단순 검증 범위 withYear의 새 날짜와 단순 검증 범위 ImmutableMyDate는 2025-1-10인 original을 보존하고 2026-1-10인 새 nextYear를 반환한다. same은 false다. 검증은 연 1 이상 월 1부터 12, 일 1부터 31이며 실제 달력의 날짜 조합은 검사하지 않는다. withYear는 월·일을 이어받는 새 날짜를 반환합니다 ImmutableMyDate 의 original.withYear(2026) 을 nextYear 에 대입한 뒤입니다. withYear 호출 뒤 두 날짜의 값과 참조 변수 날짜 출력 객체와 값의 관계 original 2025-1-10 기존 객체와 기존 연·월·일을 유지합니다. nextYear 2026-1-10 new MyDate(...) 로 생성한 별도 객체입니다. 연도만 새 값, 월·일은 1 · 10 입니다. original · 2025-1-10 기존 객체의 연·월·일을 그대로 보존합니다. nextYear · 2026-1-10 new MyDate(...) 로 만든 별도 객체입니다. 연도만 바뀌고 월·일 1 · 10 을 이어받습니다. original == nextYear 의 출력은 same=false 입니다. 이 프로그램의 문자 표현은 월·일 앞에 0 을 붙이지 않습니다. withYear 도 생성자를 거칩니다. 코드의 허용 범위는 연 1 이상, 월 1..12 , 일 1..31 입니다. 원문이 설명하듯 2월 30일 같은 조합은 막지 못하며, 이 예제는 완전한 달력 검증기가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기존 날짜 original이 2025/1/10을 참조합니다. |
|
|
| 새 날짜 계산 withYear 2026이 월·일을 이어받습니다. |
|
|
| 반환 참조 nextYear가 새 MyDate를 받습니다. |
|
|
그림의 마지막 단계가 짚는 것처럼, 이 검사에는 한계가 있습니다. 연도는 1 이상, 월은 1부터 12까지, 일은 1부터 31까지인지를 따로따로 볼 뿐, 그 조합이 실제 달력에 있는 날짜인지는 확인하지 않습니다. 그래서 new MyDate(2025, 2, 30)도 문제없이 만들어지고 2025-2-30으로 출력됩니다. 달마다 다른 날수와 윤년까지 제대로 검사하려면 꽤 많은 코드가 필요합니다. MyDate는 불변 클래스의 구조를 연습하려고 만든 것이고, 실제 프로그램에서 날짜를 다룰 때는 자바가 제공하는 LocalDate를 씁니다.
실제 달력 규칙은 LocalDate가 검사합니다
java.time 패키지의 LocalDate는 연, 월, 일로 이루어진 날짜를 나타내는 표준 불변 클래스입니다. MyDate에서 해 본 것과 같은 구조를 갖추면서 달력 규칙까지 검사합니다. 아래 프로그램은 없는 날짜를 만들려고 할 때와, 윤년의 2월 29일에서 연도를 바꿀 때 LocalDate가 어떻게 동작하는지 보여 줍니다.
import java.time.DateTimeException;
import java.time.LocalDate;
public class CalendarValidationBridge {
public static void main(String[] args) {
try { LocalDate.of(2025, 2, 30); }
catch (DateTimeException expected) { System.out.println("invalid-date"); }
LocalDate leap = LocalDate.of(2024, 2, 29);
LocalDate next = leap.withYear(2025);
System.out.println("original=" + leap);
System.out.println("next=" + next);
}
}invalid-date
original=2024-02-29
next=2025-02-28첫 줄은 LocalDate.of(2025, 2, 30)이 2월 30일이라는 날짜를 거부하고 DateTimeException을 던졌다는 뜻입니다. 예외 메시지는 Invalid date 'FEBRUARY 30'입니다. MyDate라면 그대로 만들어졌을 날짜를, LocalDate는 객체를 만드는 순간에 막습니다.
나머지 두 줄은 withYear의 동작을 보여 줍니다. 2024년은 윤년이라 2월 29일이 있지만 2025년에는 없습니다. 이때 LocalDate의 withYear는 예외를 던지는 대신, 그 달의 마지막 유효한 날인 2월 28일로 날짜를 맞춥니다. 이 규칙은 LocalDate의 API 문서에 적혀 있습니다. 그리고 withYear가 새 객체를 돌려주므로 원래의 leap는 2024년 2월 29일 그대로입니다. 출력 형식이 2024-02-29처럼 월과 일을 두 자리로 맞춘다는 점도 눈여겨보세요. 이 형식은 뒤에서 MyDate의 toString을 고칠 때 다시 나옵니다.
같은 이름의 메서드라도 정책은 다를 수 있다는 점에 주의해야 합니다. MyDate의 withYear는 월과 일을 그대로 넘기기만 하므로, new MyDate(2024, 2, 29).withYear(2025)는 2025년 2월 29일이라는 없는 날짜를 만듭니다. LocalDate의 withYear는 날짜를 맞춰 줍니다. 메서드 이름이 같다고 동작까지 같으리라고 짐작하지 말고, 문서에서 확인해야 합니다. LocalDate를 비롯한 날짜와 시간 API는 10장에서 본격적으로 다룹니다.
값이 같은 두 날짜를 같다고 판단하기
이제 날짜를 비교하는 문제로 넘어가겠습니다. new MyDate(2025, 3, 1)을 두 번 실행하면 객체가 두 개 생깁니다. 둘 다 2025년 3월 1일이니 같은 날짜라고 답해야 할 것 같지만, ==는 두 변수가 같은 객체를 가리키는지를 비교하므로 false입니다. Object 클래스를 다룬 글에서 본 것처럼 Object가 물려주는 기본 equals도 ==와 똑같이 같은 객체인지만 비교하므로, equals를 재정의하지 않은 MyDate에서는 equals도 false를 돌려줍니다.
날짜, 금액, 기간처럼 어떤 객체인지보다 어떤 값을 담고 있는지가 중요한 객체를 값 객체(value object)라고 합니다. 값 객체는 담은 값이 같으면 같은 것으로 다뤄야 하므로, 값을 비교하는 equals를 직접 구현해야 합니다. 그리고 equals를 재정의할 때는 hashCode도 함께 재정의해야 하는데, 그 이유는 잠시 뒤에 실행 결과로 확인합니다. 아래 MyDate는 두 메서드와 함께 toString도 구현합니다. 비교에 집중하려고 생성자의 검사는 뺐습니다.
import java.util.Objects;
public final class MyDateValueContract {
public static void main(String[] args) {
MyDate first = new MyDate(2025, 3, 1);
MyDate second = new MyDate(2025, 3, 1);
System.out.println("identity=" + (first == second));
System.out.println("equality=" + first.equals(second));
System.out.println("same-hash=" + (first.hashCode() == second.hashCode()));
System.out.println("date=" + first);
}
private static final class MyDate {
private final int year;
private final int month;
private final int day;
MyDate(int year, int month, int day) {
this.year = year;
this.month = month;
this.day = day;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (other == null || getClass() != other.getClass()) return false;
MyDate date = (MyDate) other;
return year == date.year && month == date.month && day == date.day;
}
@Override public int hashCode() { return Objects.hash(year, month, day); }
@Override public String toString() { return "%04d-%02d-%02d".formatted(year, month, day); }
}
}identity=false
equality=true
same-hash=true
date=2025-03-01equals는 세 단계로 비교합니다. 첫째 줄은 같은 객체와 비교하는 경우로, 필드를 볼 필요 없이 바로 true를 돌려줍니다. 둘째 줄은 null이나 클래스가 다른 객체를 걸러 냅니다. getClass()로 비교하면 정확히 같은 클래스의 객체끼리만 같다고 보는데, instanceof를 쓰는 방법과의 차이는 Object 클래스를 다룬 글에서 다뤘습니다. 이 검사를 통과하면 other가 MyDate임이 확실하므로, 셋째 줄에서 MyDate로 변환해 연, 월, 일을 하나씩 비교합니다. 세 필드가 모두 int라서 ==로 비교하면 됩니다.
출력을 보면 first와 second는 서로 다른 객체라서 identity=false이지만, equals는 세 필드가 모두 같으므로 equality=true입니다. hashCode도 같은 필드로 계산하므로 두 객체의 해시 값이 같아 same-hash=true가 나옵니다. 마지막 줄은 toString이 만든 문자열입니다.
MyDateValueContract는 같은2025년3월1일을 두 번 생성한다. first와second는 다른 객체지만 equals는true이고 hashCode 결과가 같으며 toString은2025-03-01이다.
같은 날짜 값이어도 서로 다른 객체일 수 있습니다
MyDateValueContract는 new MyDate(2025, 3, 1)을 두 번 실행하여 first와 second를 만듭니다.
| 관찰 | 실제 출력 | 이 코드에서 비교하는 것 |
|---|---|---|
| 참조 동일성 | identity=false | first == second: 서로 다른 두 객체 |
| 값 동등성 | equality=true | equals: 같은 클래스의 year·month·day가 모두 같음 |
| 해시 결과 | same-hash=true | Objects.hash(year, month, day)가 같은 필드로 계산됨 |
| 문자 표현 | date=2025-03-01 | toString()이 월·일을 두 자리로 출력 |
- 참조 동일성 ·
identity=false first == second는 거짓입니다. 별도로 생성한 두 객체입니다.- 값 동등성 ·
equality=true equals는 같은 클래스의 연·월·일을 비교합니다. 세 필드가 모두 같습니다.- 해시 결과 ·
same-hash=true Objects.hash(year, month, day)에 같은 필드 값을 넣어 결과가 같습니다.- 문자 표현 ·
date=2025-03-01 toString()이 월·일을 두 자리로 출력합니다.
값 동등성을 구현한 이 예제와 앞 절의 ImmutableMyDate는 별도 프로그램입니다. 이 생성자는 날짜 범위를 검사하지 않습니다. 불변 필드로 값 계약을 구현한 사실과 달력 유효성 검증을 구분합니다.
equals와 hashCode가 지켜야 할 규칙
equals는 아무렇게나 구현해도 되는 메서드가 아닙니다. HashSet 같은 컬렉션과 표준 라이브러리의 여러 코드가 equals가 몇 가지 성질을 갖췄다고 믿고 동작하기 때문입니다. Object의 API 문서는 equals가 갖춰야 할 성질 다섯 가지를 나열하고, hashCode가 지켜야 할 조건은 hashCode의 일반 계약(general contract)이라는 이름으로 정리해 두었습니다. 일반 계약이란 그 메서드를 재정의하는 모든 클래스가 지켜야 하는 약속이라는 뜻인데, 이 글에서는 두 메서드의 조건을 모두 간단히 규칙이라고 부르겠습니다. null이 아닌 참조 x, y, z에 대해 equals의 규칙은 다섯 가지입니다.
| 규칙 | 뜻 | MyDate에서는 |
|---|---|---|
| 반사성 | x.equals(x)는 true | 첫 줄의 this == other 검사가 바로 true를 돌려줍니다 |
| 대칭성 | x.equals(y)가 true이면 y.equals(x)도 true | 양쪽 모두 같은 세 필드를 같은 방법으로 비교합니다 |
| 추이성 | x.equals(y)와 y.equals(z)가 true이면 x.equals(z)도 true | 세 필드가 같다는 관계는 이어서 성립합니다 |
| 일관성 | 비교에 쓰는 값이 바뀌지 않는 한 몇 번 호출해도 같은 결과 | 필드가 final이라 결과가 바뀔 일이 없습니다 |
null과의 비교 | x.equals(null)은 false | 둘째 줄의 other == null 검사가 맡습니다 |
반사성과 null 규칙은 당연해 보이지만, 대칭성과 추이성은 상속이 끼어들면 깨지기 쉽습니다. 예를 들어 부모 클래스는 instanceof로 비교하고 자식 클래스는 필드를 하나 더 비교하면, 부모 쪽에서 물으면 같고 자식 쪽에서 물으면 다른 상황이 생깁니다. MyDate는 final 클래스이고 getClass()로 같은 클래스끼리만 비교하므로 이런 문제가 생기지 않습니다. 일관성은 불변 클래스에서 특히 쉽게 지켜집니다. 비교에 쓰는 필드가 바뀔 수 없으니, 오늘 같았던 두 날짜는 객체가 사라질 때까지 같습니다.
hashCode의 규칙은 세 가지입니다. equals의 API 문서도 equals를 재정의할 때는 hashCode도 함께 재정의해야 한다고 적고 있는데, 아래 2번 규칙을 지키기 위해서입니다.
- 한 프로그램이 실행되는 동안 같은 객체에 여러 번 호출하면,
equals에 쓰는 값이 바뀌지 않는 한 같은 정수를 돌려줘야 합니다. 프로그램을 다시 실행했을 때까지 같을 필요는 없습니다. equals로 같은 두 객체는 반드시 같은hashCode를 돌려줘야 합니다.equals로 다른 두 객체가 같은hashCode를 돌려줘도 규칙 위반은 아닙니다. 다만 서로 다른 값을 돌려주면 해시 컬렉션의 성능이 좋아질 수 있습니다.
가장 중요한 것은 2번입니다. 방향에 주의하세요. '같은 객체라면 해시 값도 같다'이지, '해시 값이 같으면 같은 객체다'가 아닙니다. MyDate는 equals가 비교하는 세 필드를 그대로 Objects.hash(year, month, day)에 넘기므로, equals로 같은 두 날짜는 언제나 같은 해시 값을 얻습니다. Objects.hash는 받은 값들을 섞어 정수 하나로 만드는 표준 메서드입니다.
equals만 재정의하면 HashSet이 같은 날짜를 찾지 못합니다
2번 규칙을 어기면 어떻게 되는지 직접 보겠습니다. 아래 MyDate는 equals는 앞과 똑같이 구현했지만 hashCode를 빠뜨렸습니다. HashSet은 같은 값을 한 번만 담는 컬렉션인데, 날짜 하나를 넣은 뒤 값이 같은 다른 객체로 들어 있는지 물어봅니다. HashSet은 15장에서 자세히 배우므로, 여기서는 add로 넣고 contains로 들어 있는지 묻는다는 정도만 알면 충분합니다.
import java.util.HashSet;
import java.util.Set;
public final class MissingHashCode {
public static void main(String[] args) {
MyDate stored = new MyDate(2025, 3, 1);
MyDate same = new MyDate(2025, 3, 1);
Set<MyDate> dates = new HashSet<>();
dates.add(stored);
System.out.println("equals=" + stored.equals(same));
System.out.println("contains=" + dates.contains(same));
}
private static final class MyDate {
private final int year;
private final int month;
private final int day;
MyDate(int year, int month, int day) {
this.year = year;
this.month = month;
this.day = day;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (other == null || getClass() != other.getClass()) return false;
MyDate date = (MyDate) other;
return year == date.year && month == date.month && day == date.day;
}
}
}equals=true
contains=falseequals로 물으면 두 날짜는 같은데, HashSet은 같은 날짜가 들어 있지 않다고 답합니다. 원인은 HashSet이 값을 찾는 방법에 있습니다. HashSet은 객체를 넣을 때 hashCode로 얻은 해시 값에 따라 담을 칸을 정합니다. 찾을 때도 먼저 해시 값으로 칸을 정하고, 그 칸에서 해시 값까지 같은 객체에 대해서만 equals를 호출합니다. 그런데 hashCode를 재정의하지 않으면 Object의 기본 hashCode가 쓰이고, 이 값은 객체마다 대개 다릅니다. 그래서 HashSet은 same과 stored의 해시 값이 다르다는 것만 보고 둘을 다른 객체로 판단하며, equals는 호출조차 하지 않습니다. 같은 이유로 이 HashSet에 same을 add하면 같은 날짜가 두 개 들어갑니다.
이 실수는 컴파일 오류가 나지 않지만, javac -Xlint:all로 컴파일하면 경고로 알려 줍니다.
lab/MissingHashCode.java:16: warning: [overrides] Class MyDate overrides equals, but neither it nor any superclass overrides hashCode method
private static final class MyDate {
^
1 warning반대 방향의 실수도 있습니다. hashCode에 equals가 비교하지 않는 필드를 넣는 경우입니다. 예를 들어 equals는 연과 월만 비교하는데 hashCode는 Objects.hash(year, month, day)로 일까지 섞으면, equals로 같은 2025년 3월 1일과 3월 2일이 서로 다른 해시 값을 얻어 2번 규칙이 깨집니다. 그래서 hashCode에는 equals가 비교하는 필드만 써야 합니다. 가장 안전한 방법은 이 글의 예제처럼 두 메서드가 똑같은 필드 목록을 쓰는 것입니다.
해시 값이 바뀌지 않아야 한다는 1번 규칙도 불변성과 이어집니다. 가변 객체를 HashSet에 넣은 뒤 해시 계산에 쓰는 필드를 바꾸면, 그 객체는 이미 옛 해시 값에 해당하는 칸에 들어가 있으므로 새 해시 값으로는 찾을 수 없게 됩니다. 이 문제도 15장에서 다시 다룹니다. 필드가 바뀌지 않는 MyDate에서는 해시 값이 객체가 사라질 때까지 그대로이므로 이런 일이 생기지 않습니다.
equals의 매개변수는 Object여야 합니다
equals를 처음 구현할 때 흔히 하는 실수가 하나 더 있습니다. 매개변수 타입을 MyDate로 쓰는 것입니다.
public boolean equals(MyDate other) {
return other != null && year == other.year && month == other.month && day == other.day;
}이 메서드는 Object의 equals(Object)를 재정의하지 않습니다. 매개변수 타입이 다르므로 같은 이름의 메서드를 하나 더 만든 오버로딩이 됩니다. 그래서 first.equals(second)처럼 MyDate 타입 변수를 넘기면 이 메서드가 호출되어 true가 나오지만, second를 Object 타입 변수에 담아 넘기면 Object에서 물려받은 기본 equals(Object)가 호출되어 false가 나옵니다. HashSet을 비롯한 컬렉션은 모두 equals(Object)를 호출하므로, 이 메서드는 컬렉션 안에서 한 번도 쓰이지 않습니다.
이 실수는 메서드 위에 @Override를 붙이면 바로 잡힙니다. 위 메서드에 @Override를 붙이면 컴파일러가 method does not override or implement a method from a supertype 오류를 냅니다. 상속을 다룬 7장에서 본 그 검사입니다. equals, hashCode, toString을 재정의할 때는 언제나 @Override를 붙이는 습관을 들이세요.
toString은 읽기 좋은 형식으로
toString은 객체를 문자열로 나타내는 메서드입니다. 문자열과 +로 이어 붙이거나 println에 넘기면 자동으로 호출됩니다. 재정의하지 않으면 Object의 기본 구현이 쓰이는데, 이 구현은 클래스 이름 뒤에 @와 hashCode()의 결과를 16진수로 붙인 문자열을 돌려줍니다. 앞의 MyDateValueContract에서 toString만 지우고 실행하면 마지막 줄이 date=MyDateValueContract$MyDate@1e2666이 됩니다. 중첩 클래스라서 이름에 바깥 클래스 이름과 $가 붙었고, 1e2666은 재정의한 hashCode가 돌려준 1975910을 16진수로 쓴 것입니다. 어느 쪽이든 이 문자열로는 날짜를 읽을 수 없습니다.
앞 예제의 toString은 "%04d-%02d-%02d".formatted(year, month, day)로 문자열을 만듭니다. formatted는 서식 문자열의 자리에 값을 차례로 채우는 String 메서드로, %04d는 정수를 네 자리로 쓰되 모자라는 앞자리를 0으로 채우고, %02d는 두 자리로 채웁니다. 그래서 2025년 3월 1일이 2025-03-01이 됩니다. 서식 문자열은 StringBuilder를 다루는 글에서 자세히 봅니다.
월과 일을 두 자리로 맞추는 데는 이유가 있습니다. 앞에서 본 것처럼 LocalDate도 같은 형식을 쓰고, 이 형식은 국제 표준(ISO 8601)의 날짜 표기이기도 합니다. 또 이렇게 쓰면 문자열 순서와 날짜 순서가 같아집니다. 2025-3-1과 2025-12-25를 문자열로 비교하면 여섯 번째 글자 3과 1을 비교하게 되어 3월 1일이 뒤로 가지만, 2025-03-01과 2025-12-25는 0과 1을 비교하므로 날짜 순서대로 놓입니다. 게시 이력을 문자열로 정렬하거나 로그에서 찾아 읽을 때 이 차이가 드러납니다.
toString의 결과는 로그나 오류 메시지에 그대로 찍히는 경우가 많습니다. 그러니 비밀번호나 개인 정보처럼 남으면 안 되는 값은 toString에 넣지 않아야 합니다. 레코드가 자동으로 만드는 toString은 모든 구성 요소를 출력하므로, 그런 값을 담은 레코드라면 toString을 재정의해야 합니다.
날짜를 담은 기록도 불변으로 만들기
이제 첫 절의 문제를 불변 날짜로 다시 풀어 보겠습니다. 이번에는 MyDate를 레코드로 만들었습니다. 불변 객체를 처음 만든 글에서 본 것처럼 레코드는 구성 요소를 private final 필드로 만들고, 여기에 더해 모든 구성 요소를 비교하는 equals와 hashCode도 자동으로 만들어 줍니다. 앞 절에서 손으로 쓴 두 메서드를 컴파일러가 대신 써 주는 셈입니다. toString은 앞 절과 같은 형식으로 재정의했습니다. 게시 이력 BoardRecord는 날짜를 옮긴 결과를 새 기록으로 돌려주는 moveTo를 가집니다.
public final class DatedBoardRecord {
public static void main(String[] args) {
BoardRecord recorded = new BoardRecord("immutability", 50, new MyDate(2025, 3, 1));
BoardRecord rescheduled = recorded.moveTo(new MyDate(2025, 3, 2));
System.out.println("recorded=" + recorded);
System.out.println("rescheduled=" + rescheduled);
System.out.println("same-date=" + recorded.date().equals(new MyDate(2025, 3, 1)));
}
private record MyDate(int year, int month, int day) {
MyDate {
if (year < 1 || month < 1 || month > 12 || day < 1 || day > 31) {
throw new IllegalArgumentException("date");
}
}
@Override
public String toString() {
return "%04d-%02d-%02d".formatted(year, month, day);
}
}
private record BoardRecord(String title, int viewCount, MyDate date) {
BoardRecord {
if (title == null || title.isBlank()) throw new IllegalArgumentException("title");
if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
}
BoardRecord moveTo(MyDate newDate) {
return new BoardRecord(title, viewCount, newDate);
}
}
}recorded=BoardRecord[title=immutability, viewCount=50, date=2025-03-01]
rescheduled=BoardRecord[title=immutability, viewCount=50, date=2025-03-02]
same-date=truemoveTo를 호출한 뒤에도 recorded의 날짜는 3월 1일 그대로이고, 3월 2일은 새 기록 rescheduled에만 있습니다. BoardRecord가 자동으로 만든 toString이 date 자리에 MyDate의 toString 결과를 넣었다는 것도 출력에서 볼 수 있습니다. 마지막 줄은 레코드가 만든 equals가 값을 비교한다는 것을 보여 줍니다. 새로 만든 new MyDate(2025, 3, 1)은 recorded가 가진 날짜와 다른 객체지만 세 구성 요소가 같으므로 true입니다.
첫 절의 버그 예제와 비교해 보세요. 그때도 BoardRecord는 레코드였지만 구성 요소인 날짜가 가변이라서 이력이 바뀌었습니다. 지금은 BoardRecord의 구성 요소가 불변 String, 기본형 int, 불변 MyDate뿐이므로, BoardRecord가 가리키는 모든 객체가 바뀔 수 없습니다. 날짜를 옮긴다는 의도는 moveTo라는 이름과 새 기록을 받는 대입문으로 호출하는 코드에 드러나고, 옛 기록을 지울지 이력으로 남길지는 기록을 관리하는 쪽이 정할 수 있습니다. BoardRecord는 두 상태를 모두 안전하게 나타내기만 하면 됩니다.
더 깊이: Objects.hash와 레코드의 hashCode
Objects.hash(year, month, day)는 받은 값들을 배열에 담아 Arrays.hashCode에 넘깁니다. 이 메서드는 결과를 1에서 시작해, 값마다 지금까지의 결과에 31을 곱하고 그 값의 해시 값을 더합니다. 2025년 3월 1일이라면 ((31 + 2025) * 31 + 3) * 31 + 1을 계산해 1975910이 됩니다. 값마다 31을 곱해 가며 더하므로 같은 값이라도 놓인 자리에 따라 결과가 달라집니다. 세 값을 그냥 더하면 2025년 3월 1일과 월과 일이 뒤바뀐 2025년 1월 3일이 똑같이 2029가 되지만, Objects.hash로는 1975910과 1975850으로 갈립니다.
Objects.hash는 편리하지만 호출할 때마다 가변 인자용 배열을 만들고, int 값을 Integer 객체로 감싸는 박싱(boxing)도 일어납니다. 해시 값을 아주 자주 계산하는 곳이라면 31 * (31 * year + month) + day처럼 직접 계산해 그 비용을 없앨 수 있습니다. 결과 숫자는 Objects.hash와 다르지만, equals와 같은 필드로 계산하므로 규칙은 똑같이 지켜집니다.
레코드가 자동으로 만드는 hashCode도 구성 요소의 해시 값을 섞어 계산하지만, Record의 API 문서는 구체적인 계산 방법을 정해 두지 않았고 바뀔 수 있다고 적어 두었습니다. 실제로 JDK 21에서 레코드 MyDate(2025, 3, 1)의 해시 값은 Objects.hash의 결과와 다른 1946119입니다. 또 hashCode의 1번 규칙이 말하듯 해시 값은 프로그램을 다시 실행하면 달라질 수 있습니다. 그래서 해시 값은 실행 중에 컬렉션이 쓰는 값으로만 다루고, 파일에 저장하거나 출력 결과에 의존하지 않아야 합니다.
불변 값 클래스를 만들 때 확인할 것
이 장에서 만든 불변 클래스들을 돌아보면, 다음 질문에 모두 '예'라고 답할 수 있어야 합니다.
- 생성자가 모든 필드를 검사한 뒤에 채우는가?
- 필드가 모두
private final인가? - setter나 내부 배열과 컬렉션을 바꾸는 메서드가 없는가?
- 배열이나 목록을 받을 때와 내보낼 때 복사하는가?
- 값을 바꾸는 메서드가 새 객체를 돌려주고, 이름이 그것을 드러내는가?
- 클래스가
final이거나 레코드여서 상속으로 가변 하위 클래스를 만들 수 없는가? equals와hashCode가 같은 필드를 쓰는가?toString에 남으면 안 되는 정보가 들어 있지 않은가?
레코드를 쓰면 이 가운데 여러 항목이 저절로 해결됩니다. 하지만 레코드가 보장하는 것은 구성 요소 필드를 다시 대입할 수 없다는 것까지입니다. 첫 절의 BoardRecord처럼 구성 요소가 가변 객체이면 그 객체는 여전히 바뀔 수 있습니다. 그래서 클래스를 레코드로 바꾸기 전에, 그 객체에서 닿을 수 있는 모든 객체를 따라가며 상태를 바꿀 수 있는 길이 남아 있지 않은지 먼저 확인해야 합니다. 레코드의 구성 요소를 방어적으로 복사하는 방법은 record를 깊이 다루는 36장에서 배웁니다.
정리
- 여러 곳에서 공유하는 날짜를 가변으로 만들면 한 곳의 변경이 지난 기록까지 바꿉니다. 날짜 타입을 불변으로 만들면 공유하는 곳이 아무리 많아도 이 문제가 생기지 않습니다.
- 불변 날짜는
withYear처럼 바뀐 값을 담은 새 객체를 돌려주고, 새 객체도 생성자의 검사를 거칩니다. 실제 달력 규칙까지 검사하려면LocalDate를 씁니다. - 값 객체는
equals로 값을 비교해야 합니다.equals는 반사성, 대칭성, 추이성, 일관성,null과의 비교라는 다섯 규칙을 지켜야 합니다. equals로 같은 두 객체는 같은hashCode를 돌려줘야 합니다.hashCode를 빠뜨리면HashSet이 같은 값을 찾지 못합니다. 두 메서드는 같은 필드로 구현하고,@Override를 붙입니다.toString은 사람이 읽기 좋은 형식으로 만들고, 날짜라면 월과 일을 두 자리로 맞춥니다.
연습 문제
- 레코드
MyDate에 월만 바꾸는withMonth와 일만 바꾸는withDay를 추가하고, 2025-03-01에서 출발해 2025-12-25를 만드세요. 두 메서드 모두 생성자의 검사를 다시 거쳐야 합니다. 이어서new MyDate(2025, 1, 31).withMonth(2)의 결과를 예상해 보세요. LocalDate.of(2024, 2, 29).withYear(2028)의 결과는 무엇일까요?withYear(2025)일 때와 왜 다른지 설명하세요.equals는 연, 월, 일을 모두 비교한다고 할 때, 다음 두hashCode가운데 규칙을 어기는 것은 어느 쪽이고 다른 하나에는 어떤 단점이 있을까요? (가)return year;(나)return Objects.hash(year, month, day, title);(여기서title은equals가 비교하지 않는, 날짜에 붙인 메모 필드입니다.)
해설 보기
1번. 두 메서드는 바꿀 값 하나만 새 값으로 넣고 나머지는 그대로 넘겨 새 MyDate를 만듭니다.
public final class MyDateWithExercise {
public static void main(String[] args) {
MyDate spring = new MyDate(2025, 3, 1);
MyDate winter = spring.withMonth(12).withDay(25);
System.out.println("spring=" + spring);
System.out.println("winter=" + winter);
System.out.println("jan31-to-feb=" + new MyDate(2025, 1, 31).withMonth(2));
}
private record MyDate(int year, int month, int day) {
MyDate {
if (year < 1) throw new IllegalArgumentException("year");
if (month < 1 || month > 12) throw new IllegalArgumentException("month");
if (day < 1 || day > 31) throw new IllegalArgumentException("day");
}
MyDate withMonth(int value) { return new MyDate(year, value, day); }
MyDate withDay(int value) { return new MyDate(year, month, value); }
@Override
public String toString() {
return "%04d-%02d-%02d".formatted(year, month, day);
}
}
}spring=2025-03-01
winter=2025-12-25
jan31-to-feb=2025-02-31spring.withMonth(12)가 중간 값 2025-12-01을 만들고, 그 값에 withDay(25)를 호출해 2025-12-25를 얻습니다. 중간 값도 생성자를 거친 MyDate이고, spring은 끝까지 2025-03-01입니다. 마지막 줄은 이 클래스의 한계를 보여 줍니다. 1월 31일에서 월만 2로 바꾸면 2월 31일이 되는데, 월과 일을 따로따로 검사하는 생성자는 이것을 막지 못합니다. 같은 일을 LocalDate.of(2025, 1, 31).withMonth(2)로 하면 그 달의 마지막 날인 2025-02-28로 맞춰집니다. 실제 달력 규칙이 필요해지는 순간이 바로 MyDate를 LocalDate로 바꿀 때입니다.
2번. 결과는 2028-02-29입니다. 2028년은 4로 나누어떨어지고 100으로는 나누어떨어지지 않으므로 윤년이고, 2월 29일이 있습니다. withYear는 바꾼 연도에서 날짜가 유효하지 않을 때만 그 달의 마지막 유효한 날로 맞추는데, 2028년 2월 29일은 유효하므로 그대로 둡니다. 2025년은 윤년이 아니라서 2월 29일이 없으므로 2월 28일로 맞춰졌던 것입니다.
3번. 규칙을 어기는 것은 (나)입니다. equals는 메모를 비교하지 않으므로, 날짜가 같고 메모만 다른 두 객체는 equals로 같습니다. 그런데 (나)는 메모까지 섞어 해시 값을 계산하므로 두 객체의 해시 값이 달라지고, '같은 객체는 같은 해시 값'이라는 2번 규칙이 깨집니다. 그러면 HashSet이 같은 날짜를 찾지 못하거나 두 번 담게 됩니다.
(가)는 규칙을 지킵니다. equals로 같은 두 날짜는 연도도 같으므로 해시 값이 언제나 같습니다. 하지만 같은 해의 날짜가 모두 같은 해시 값을 얻으므로, HashSet에 한 해의 날짜를 많이 넣으면 모두 같은 칸에 몰립니다. 그러면 칸 하나에서 equals로 하나씩 비교해야 해서 찾는 속도가 크게 느려집니다. 결과는 맞지만 느린 것입니다. 3번 규칙이 말하듯 다른 객체에는 되도록 다른 해시 값을 주는 것이 좋으므로, equals가 비교하는 세 필드를 모두 쓰는 Objects.hash(year, month, day)가 알맞습니다.
다음 글
다음 글에서는 자바에서 가장 많이 쓰는 불변 클래스인 String을 다룹니다. 문자열을 ==로 비교하면 내용이 같은데도 false가 나올 수 있는 이유를 알아보고, 이 글에서 구현해 본 equals가 문자열 비교에서 어떻게 쓰이는지 확인합니다.