불변 값과 with 메서드
기존 값을 보존하며 새 Post를 반환하는 연산을 설계하고 반환값 누락·공유 구성·변경 이력의 선택 기준을 다룹니다.
불변 객체도 값 변화라는 업무 요구를 표현할 수 있습니다.
기존 인스턴스의 필드를 고치는 대신, 계산 결과를 담은 새 인스턴스를 반환합니다.
호출자는 이전 값과 이후 값을 동시에 보관할 수 있고 어느 참조가 바뀌었는지 대입문에서 확인할 수 있습니다.
반환값 누락과 변경 손실
가변 객체의 addViewCount는 자기 상태를 바꾸지만 불변 객체의 addViewCount는 새 값을 반환합니다.
예전 호출 습관으로 반환을 무시하면 원래 객체는 그대로입니다.
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 메서드는 특정 속성만 달라진 새 객체를 만듭니다.
생성자 검증을 다시 통과하므로 변경 결과도 클래스 불변식을 만족합니다.
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는 이미 공개된 객체라면 같은 인스턴스를 반환해 불필요한 생성을 피웁니다.
값이 같다는 계약은 유지되므로 반드시 매번 새 객체를 만들어야 불변인 것은 아닙니다.
불변 구성 객체의 공유
여러 게시글이 같은 ViewPolicy를 사용할 수 있습니다.
정책이 불변이면 한 게시글의 갱신이 공통 정책을 오염시키지 않습니다.
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=trueViewPolicy 참조는 새 Post에도 그대로 재사용됩니다.
policy가 불변이므로 깊은 복사를 할 필요가 없습니다.
불변 값들을 조합하면 큰 객체 그래프에서도 변하지 않은 부분을 공유하며 수정된 경로만 새로 만들 수 있습니다.
값의 연속으로 남기는 변경 이력
불변 상태는 undo, 감사 로그, 동시 비교에 유리합니다.
각 명령이 새 값을 반환하므로 처리 전후를 배열의 서로 다른 위치에 보관할 수 있습니다.
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를 더한 새 목표를 반환하고 목표 수를 넘으면 목표 수로 제한합니다.
원본과 갱신 값을 모두 출력합니다.
해설 보기
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=truepublish는 원본 70을 건드리지 않고 제한 규칙이 적용된 새 값을 만듭니다.
목표 공개라는 불변식은 생성자 한 곳에서 계속 검증됩니다.