본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
9장 : Object와 불변성

불변 날짜 값 API

공유된 게시 날짜가 함께 변하는 결함을 불변 MyDate로 해결하고 값 객체의 검증·동등성·with 연산을 종합합니다.

불변성은 단순한 주소 예제보다 날짜, 기간, 금액 같은 값 타입에서 힘을 발휘합니다.

게시글의 날짜가 여러 일정에 공유될 때 가변 setter는 과거 기록까지 바꿀 수 있습니다.

이번 절은 작은 MyDate를 직접 만들어 불변 값 객체가 갖춰야 할 계약을 끝까지 확인합니다.


공유 날짜의 동시 변경

두 객체가 같은 MutableMyDate를 보관하면 한쪽의 연도 변경이 다른 쪽에서도 관찰됩니다.

일정을 다음 해로 복사하려던 작업이 과거 게시 이력을 훼손합니다.

lab/MutableDateHistoryBug.java
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

BoardRecord는 생성 당시 날짜를 보존해야 하지만 공유된 가변 객체 때문에 결과가 달라졌습니다.

날짜 복사를 매번 강제하기보다 날짜 타입 자체를 불변으로 만들면 기록과 일정 어디서 공유하더라도 기존 값이 유지됩니다.


새 값을 반환하는 날짜 객체

필드를 final로 정하고 setter를 제거합니다.

withYear는 기존 월·일을 유지한 새 날짜를 생성합니다.

src/ImmutableMyDate.java
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 구조를 확인하는 데 있습니다.


날짜 값의 동등성과 문자 표현

불변 값은 필드가 같으면 동등하다는 규칙이 자연스럽습니다.

두 값을 직접 비교하거나 이후 해시 기반 조회에 사용하려면 equalshashCode가 같은 필드에서 안정적으로 계산되어야 합니다.

src/MyDateValueContract.java
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

두 인스턴스는 메모리상 다르지만 같은 날짜 값입니다.

동등한 두 객체는 같은 해시 값을 반환해야 한다는 계약도 확인했습니다.

필드가 변하지 않으므로 객체 수명 동안 해시 값도 안정적입니다.

문자 표현은 월과 일을 두 자리로 맞춰 게시 이력 정렬과 읽기를 돕습니다.


명시적인 날짜 상태 전이

BoardRecord도 불변으로 만들고 날짜를 옮긴 결과를 새 기록으로 반환하면 이력을 안전하게 비교할 수 있습니다.

src/DatedBoardRecord.java
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를 단계적으로 만드세요.

각 메서드는 생성자 검증을 재사용해야 합니다.

해설 보기
src/MyDateWithExercise.java
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입니다.

실제 달력 규칙이 필요해지는 순간 표준 날짜 타입으로 교체하는 선택이 적절합니다.