본문으로 건너뛰기

안동민 개발노트

본문 시작

불변 값과 with 메서드

기존 값을 보존하며 새 Post를 반환하는 연산을 설계하고 반환값 누락·공유 구성·변경 이력의 선택 기준을 다룹니다.

불변 객체도 값 변화라는 업무 요구를 표현할 수 있습니다.

기존 인스턴스의 필드를 고치는 대신, 계산 결과를 담은 새 인스턴스를 반환합니다.

호출자는 이전 값과 이후 값을 동시에 보관할 수 있고 어느 참조가 바뀌었는지 대입문에서 확인할 수 있습니다.

HTML 다이어그램: /docs/java/ch9/ch9-5/1.html

반환값 누락과 변경 손실

가변 객체의 addViewCount는 자기 상태를 바꾸지만 불변 객체의 addViewCount는 새 값을 반환합니다.

예전 호출 습관으로 반환을 무시하면 원래 객체는 그대로입니다.

lab/IgnoredImmutableReturnBug.java
public final class IgnoredImmutableReturnBug {
    public static void main(String[] args) {
        Post entry = new Post("immutable", 40);

        entry.addViewCount(20);

        System.out.println("expected=60");
        System.out.println("actual=" + entry.viewCount());
    }

    private record Post(String title, int viewCount) {
        Post addViewCount(int extra) {
            return new Post(title, viewCount + extra);
        }
    }
}
잘못된 결과
expected=60
actual=40

메서드는 새 Post를 만들었지만 어떤 변수도 그 참조를 저장하지 않아 사용되지 않습니다.

불변 값 연산은 entry = entry.addViewCount(20) 또는 새 이름 revised로 결과를 받아야 합니다.

API 이름과 문서가 새 객체 반환을 분명히 하고 IDE의 반환값 무시 경고도 활용합니다.

반환값 누락 버그는 예외 없이 잘못된 조회 수 40을 계속 사용하므로 더 오래 숨어 있을 수 있습니다.

이 실패를 막으려면 불변 연산의 결과 참조를 호출 흐름에서 반드시 이어 받아야 합니다.


이전 값과 수정 값의 보존

with 메서드는 특정 속성만 달라진 새 객체를 만듭니다.

생성자 검증을 다시 통과하므로 변경 결과도 클래스 불변식을 만족합니다.

src/ImmutableEntryWithMethods.java
public final class ImmutableEntryWithMethods {
    public static void main(String[] args) {
        Post original = new Post("immutable", 40, false);
        Post revised = original.withViewCount(60);
        Post published = revised.publish();

        System.out.println("original=" + original);
        System.out.println("revised=" + revised);
        System.out.println("published=" + published);
    }

    private record Post(String title, int viewCount, boolean published) {
        Post {
            if (title == null || title.isBlank()) throw new IllegalArgumentException("title");
            if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
        }

        Post withViewCount(int newViewCount) {
            return new Post(title, newViewCount, published);
        }

        Post publish() {
            return published ? this : new Post(title, viewCount, true);
        }
    }
}
original=Post[title=immutable, viewCount=40, published=false]
revised=Post[title=immutable, viewCount=60, published=false]
published=Post[title=immutable, viewCount=60, published=true]

original은 조회 40회 미공개 상태를 유지합니다.

publish는 이미 공개된 객체라면 같은 인스턴스를 반환해 불필요한 생성을 피웁니다.

값이 같다는 계약은 유지되므로 반드시 매번 새 객체를 만들어야 불변인 것은 아닙니다.

HTML 다이어그램: /docs/java/ch9/ch9-5/2.html

불변 구성 객체의 공유

여러 게시글이 같은 ViewPolicy를 사용할 수 있습니다.

정책이 불변이면 한 게시글의 갱신이 공통 정책을 오염시키지 않습니다.

src/SharedImmutablePolicy.java
public final class SharedImmutablePolicy {
    public static void main(String[] args) {
        ViewPolicy policy = new ViewPolicy(25, 5);
        Post first = new Post("object", 40, policy);
        Post second = new Post("string", 50, policy);

        Post revised = first.withViewCount(65);

        System.out.println("first-blocks=" + first.blocks());
        System.out.println("revised-blocks=" + revised.blocks());
        System.out.println("second-blocks=" + second.blocks());
        System.out.println("shared-policy=" + (first.policy() == second.policy()));
    }

    private record ViewPolicy(int pageSize, int reportThreshold) {
        ViewPolicy {
            if (pageSize <= 0 || reportThreshold < 0) throw new IllegalArgumentException("policy");
        }
    }

    private record Post(String title, int viewCount, ViewPolicy policy) {
        Post {
            if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
        }

        Post withViewCount(int value) {
            return new Post(title, value, policy);
        }

        int blocks() {
            return (viewCount + policy.pageSize() - 1) / policy.pageSize();
        }
    }
}
first-blocks=2
revised-blocks=3
second-blocks=2
shared-policy=true

ViewPolicy 참조는 새 Post에도 그대로 재사용됩니다.

policy가 불변이므로 깊은 복사를 할 필요가 없습니다.

불변 값들을 조합하면 큰 객체 그래프에서도 변하지 않은 부분을 공유하며 수정된 경로만 새로 만들 수 있습니다.


값의 연속으로 남기는 변경 이력

불변 상태는 undo, 감사 로그, 동시 비교에 유리합니다.

각 명령이 새 값을 반환하므로 처리 전후를 배열의 서로 다른 위치에 보관할 수 있습니다.

src/PostHistory.java
public final class PostHistory {
    public static void main(String[] args) {
        Post[] history = new Post[3];
        int size = 0;
        Post current = new Post("string", 30, false);
        history[size++] = current;

        current = current.withViewCount(45);
        history[size++] = current;
        current = current.publish();
        history[size++] = current;

        for (int index = 0; index < size; index++) {
            System.out.println(index + "=" + history[index]);
        }
    }

    private record Post(String title, int viewCount, boolean published) {
        Post withViewCount(int value) {
            if (value <= 0) throw new IllegalArgumentException("viewCount");
            return new Post(title, value, published);
        }

        Post publish() {
            return new Post(title, viewCount, true);
        }
    }
}
0=Post[title=string, viewCount=30, published=false]
1=Post[title=string, viewCount=45, published=false]
2=Post[title=string, viewCount=45, published=true]

가변 객체 하나의 같은 참조를 배열에 세 번 넣었다면 모든 원소가 마지막 상태를 가리켰을 수 있습니다.

불변 값은 각 시점의 의미를 보존합니다.

이력이 매우 길거나 값이 크면 전체 스냅숏 대신 변경 명령만 별도로 기록하는 설계를 검토합니다.

HTML 다이어그램: /docs/java/ch9/ch9-5/3.html

메서드 이름과 반환 정책

setViewCount는 보통 현재 객체 변경을 연상시킵니다.

불변 객체에서는 withViewCount, plusViewCount, publish처럼 결과 값의 의미를 드러내는 이름이 읽기 쉽습니다.

  • 한 필드를 새 값으로 대체하면 withX
  • 현재 수치에 더하면 plusX 또는 addX
  • 상태 전이를 표현하면 complete, cancel 같은 도메인 동사
  • 값 변화가 없을 때 같은 인스턴스를 반환해도 되는지 계약에 명시
  • null을 새 상태로 허용할지 생성자 규칙과 일치

호출부는 원본을 유지할지 갱신 변수에 대입할지 선택합니다.

새 값을 반드시 사용해야 하는 연산이라면 반환 타입과 정적 분석을 통해 무시 가능성을 줄이고, 명령형 API가 더 자연스러운 가변 집계인지도 재검토합니다.

HTML 다이어그램: /docs/java/ch9/ch9-5/4.html

연습 문제

불변 PublishQuota에 publish(int count)를 추가하세요.

기존 공개 수에 count를 더한 새 목표를 반환하고 목표 수를 넘으면 목표 수로 제한합니다.

원본과 갱신 값을 모두 출력합니다.

해설 보기
src/PublishQuotaWithExercise.java
public final class PublishQuotaWithExercise {
    public static void main(String[] args) {
        PublishQuota before = new PublishQuota("generics", 100, 70);
        PublishQuota after = before.publish(50);

        System.out.println("before=" + before.publishedCount());
        System.out.println("after=" + after.publishedCount());
        System.out.println("done=" + after.done());
    }

    private record PublishQuota(String title, int targetCount, int publishedCount) {
        PublishQuota {
            if (targetCount <= 0) throw new IllegalArgumentException("target");
            if (publishedCount < 0 || publishedCount > targetCount) {
                throw new IllegalArgumentException("published");
            }
        }

        PublishQuota publish(int count) {
            if (count <= 0) throw new IllegalArgumentException("count");
            int revised = Math.min(targetCount, publishedCount + count);
            return new PublishQuota(title, targetCount, revised);
        }

        boolean done() { return publishedCount == targetCount; }
    }
}
before=70
after=100
done=true

publish는 원본 70을 건드리지 않고 제한 규칙이 적용된 새 값을 만듭니다.

목표 공개라는 불변식은 생성자 한 곳에서 계속 검증됩니다.