안동민 개발노트

본문 시작

불변 값과 with 메서드

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

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

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

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


반환값 누락과 변경 손실

가변 객체의 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을 계속 사용하므로 더 오래 숨어 있을 수 있습니다.

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

반환값을 무시해도 원본 참조는 바뀌지 않는다

IgnoredImmutableReturnBug의 addViewCount는 조회 수60인 새 Post를 반환하지만 main은 그 참조를 저장하지 않는다. entry는 조회 수40인 원래 Post를 계속 가리킨다.

IgnoredImmutableReturnBug의 entry.addViewCount(20) 호출을 기준으로 봅니다. 제목은 모두 immutable입니다.

반환값을 무시한 호출 전후의 참조와 값
구분객체의 조회 수호출 뒤 참조의 사용
기존 객체40 유지entry가 계속 가리켜 actual=40을 출력합니다.
반환된 새 객체40 + 20 = 60호출문에 대입이 없어 반환 참조를 저장하지 않습니다.
기존 객체 · 조회 수 40
entry는 호출 뒤에도 이 객체를 가리킵니다. 원본을 수정하지 않았으므로 actual=40입니다.
반환된 새 객체 · 조회 수 60
40 + 20을 담은 별도 Post를 반환합니다. 대입이 없어 그 참조는 저장되지 않습니다.

expected=60은 기대값을 적은 출력문입니다. 새 객체를 읽어서 출력한 결과가 아닙니다.

결과를 사용할 때는 본문의 수정 호출처럼 entry = entry.addViewCount(20)으로 참조를 다시 대입하거나 revised에 받습니다. 원문 실패 예제는 이 대입을 실행하지 않습니다.


이전 값과 수정 값의 보존

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는 이미 공개된 객체라면 같은 인스턴스를 반환해 불필요한 생성을 피웁니다.

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

with와 publish가 보존하는 값과 인스턴스

ImmutableEntryWithMethods에서 original은40미공개, revised는60미공개, published는60공개인 서로 다른 Post다. 이 구현은 이미 공개된 객체의 publish 호출에서 this를 반환한다.

ImmutableEntryWithMethods의 세 대입을 모두 마친 뒤입니다. 세 Post의 제목은 immutable입니다.

세 변수의 조회 수·공개 여부·생성 경로
변수viewCountpublished이 객체를 얻은 경로
original40false처음 new Post(...)로 생성
revised60falseoriginal.withViewCount(60)이 새 객체 반환
published60true미공개인 revised.publish()가 새 객체 반환
  1. original

    new Post(...)로 만든 객체입니다. 조회 수 40, 공개 여부 false를 유지합니다.

  2. revised

    original.withViewCount(60)이 만든 별도 객체입니다. 조회 수 60, 공개 여부 false입니다.

  3. published

    revised.publish()가 만든 또 다른 객체입니다. 조회 수 60, 공개 여부 true입니다.

새 Post는 생성자 검증을 거칩니다. 새 값이 생겨도 original과 revised의 필드는 바뀌지 않습니다.

이 구현의 추가 호출 경계: 이미 공개된 published에 publish()를 다시 호출하면 this를 반환합니다. 따라서 새 객체가 아닙니다. 이 재호출은 원문 main에는 없으며, 모든 불변 API의 공통 정책도 아닙니다.


불변 구성 객체의 공유

여러 게시글이 같은 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]

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

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

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


메서드 이름과 반환 정책

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

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

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

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

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


연습 문제

불변 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을 건드리지 않고 제한 규칙이 적용된 새 값을 만듭니다.

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