불변 날짜 값 API
공유된 게시 날짜가 함께 변하는 결함을 불변 MyDate로 해결하고 값 객체의 검증·동등성·with 연산을 종합합니다.
불변성은 단순한 주소 예제보다 날짜, 기간, 금액 같은 값 타입에서 힘을 발휘합니다.
게시글의 날짜가 여러 일정에 공유될 때 가변 setter는 과거 기록까지 바꿀 수 있습니다.
이번 절은 작은 MyDate를 직접 만들어 불변 값 객체가 갖춰야 할 계약을 끝까지 확인합니다.
공유 날짜의 동시 변경
두 객체가 같은 MutableMyDate를 보관하면 한쪽의 연도 변경이 다른 쪽에서도 관찰됩니다.
일정을 다음 해로 복사하려던 작업이 과거 게시 이력을 훼손합니다.
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-10BoardRecord는 생성 당시 날짜를 보존해야 하지만 공유된 가변 객체 때문에 결과가 달라졌습니다.
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로 정하고 setter를 제거합니다.
withYear는 기존 월·일을 유지한 새 날짜를 생성합니다.
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=false원래 날짜와 다음 해 날짜가 동시에 존재합니다.
withYear도 생성자를 거치므로 허용되지 않는 연도는 거부됩니다.
이 예제의 단순 검증은 2월 30일을 막지 못하므로 실제 날짜 계산에는 뒤에서 배우는 LocalDate를 사용해야 합니다.
직접 만든 값 타입의 목적은 불변 API 구조를 확인하는 데 있습니다.
ImmutableMyDate는2025-1-10인 original을 보존하고2026-1-10인 새 nextYear를 반환한다. same은false다. 검증은 연1이상 월1부터12 일1부터31이며 실제 달력의 날짜 조합은 검사하지 않는다.
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일 같은 조합은 막지 못하며, 이 예제는 완전한 달력 검증기가 아닙니다.
날짜 값의 동등성과 문자 표현
불변 값은 필드가 같으면 동등하다는 규칙이 자연스럽습니다.
두 값을 직접 비교하거나 이후 해시 기반 조회에 사용하려면 equals와 hashCode가 같은 필드에서 안정적으로 계산되어야 합니다.
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-01두 인스턴스는 메모리상 다르지만 같은 날짜 값입니다.
동등한 두 객체는 같은 해시 값을 반환해야 한다는 계약도 확인했습니다.
필드가 변하지 않으므로 객체 수명 동안 해시 값도 안정적입니다.
문자 표현은 월과 일을 두 자리로 맞춰 게시 이력 정렬과 읽기를 돕습니다.
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는 별도 프로그램입니다. 이 생성자는 날짜 범위를 검사하지 않습니다. 불변 필드로 값 계약을 구현한 사실과 달력 유효성 검증을 구분합니다.
명시적인 날짜 상태 전이
BoardRecord도 불변으로 만들고 날짜를 옮긴 결과를 새 기록으로 반환하면 이력을 안전하게 비교할 수 있습니다.
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);
}
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 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-3-1]
rescheduled=BoardRecord[title=immutability, viewCount=50, date=2025-3-2]moveTo는 어떤 필드가 바뀌는지 호출부에 드러냅니다.
이전 기록을 제거할지 이력으로 보관할지는 Board가 결정하고, 값 객체는 두 상태를 모두 안전하게 표현합니다.
불변 값 객체 점검표
구현을 마친 뒤 다음 경로로 상태가 새어 나가지 않는지 확인합니다.
- 생성자가 모든 필드를 검증하고 초기화하는가?
- 필드가 private이고 재대입되지 않는가?
- setter나 내부 컬렉션 변경 메서드가 노출되지 않는가?
- 배열·목록 입력과 반환에 방어 정책이 있는가?
- 변경 연산이 새 값을 반환하며 결과를 무시할 위험을 설명하는가?
- equals와 hashCode가 같은 불변 필드를 사용하는가?
- toString에 민감 정보나 불안정한 내부 식별자가 없는가?
record는 얕은 불변 값을 간결하게 표현하지만 구성 요소가 가변 객체라면 자동으로 깊은 불변이 되지 않습니다.
클래스를 record로 바꾸는 작업보다 객체 그래프 전체의 변경 통로를 추적하는 작업이 먼저입니다.
연습 문제
MyDate에 withMonth, withDay를 추가하고 원본 2025-3-1에서 2025-12-25를 단계적으로 만드세요.
각 메서드는 생성자 검증을 재사용해야 합니다.
해설 보기
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);
}
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 year + "-" + month + "-" + day; }
}
}spring=2025-3-1
winter=2025-12-25연쇄 호출의 중간 값도 유효한 MyDate입니다.
실제 달력 규칙이 필요해지는 순간 표준 날짜 타입으로 교체하는 선택이 적절합니다.