불변 값과 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을 계속 사용하므로 더 오래 숨어 있을 수 있습니다.
이 실패를 막으려면 불변 연산의 결과 참조를 호출 흐름에서 반드시 이어 받아야 합니다.
반환값을 무시해도 원본 참조는 바뀌지 않는다
addViewCount(20)이 60인 Post를 반환하지만 entry에 저장하지 않아 원본 40 참조가 유지됩니다. 호출 전·반환·무시 후 관측의 실제 차이가 반환값 누락을 설명합니다.
IgnoredImmutableReturnBug의 addViewCount는 조회 수60인 새 Post를 반환하지만 main은 그 참조를 저장하지 않는다. entry는 조회 수40인 원래 Post를 계속 가리킨다.
반환값을 받지 않으면 entry는 원본을 계속 가리킵니다
IgnoredImmutableReturnBug의 entry.addViewCount(20) 호출을 기준으로 봅니다. 제목은 모두 immutable입니다.
addViewCount(20): 새 값 반환실선: 저장된 참조 · 점선: 호출 결과 생성 · actual=40
참조 대입 없음actual=40
| 구분 | 객체의 조회 수 | 호출 뒤 참조의 사용 |
|---|---|---|
| 기존 객체 | 40 유지 | entry가 계속 가리키므로 main은 actual=40을 출력합니다. |
| 반환된 새 객체 | 40 + 20 = 60 | 호출문에 대입이 없어 반환 참조를 저장하지 않습니다. |
- 기존 객체 · 조회 수
40 entry는 호출 뒤에도 이 객체를 가리킵니다. 원본을 수정하지 않았으므로actual=40입니다.- 반환된 새 객체 · 조회 수
60 40 + 20을 담은 별도Post를 반환합니다. 대입이 없어 그 참조는 저장되지 않습니다.
expected=60은 기대값을 적은 출력문입니다. 새 객체를 읽어서 출력한 결과가 아닙니다.
결과를 사용할 때는 본문의 수정 호출처럼 entry = entry.addViewCount(20)으로 참조를 다시 대입하거나 revised에 받습니다. 원문 실패 예제는 이 대입을 실행하지 않습니다.
이 예의 조건
- 반환한 참조를 저장하지 않는 원 main이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | entry는 기존 immutable 40 Post를 계속 가리킨다. addViewCount 20이 새 60 Post를 반환해도 그 참조를 저장하지 않았으므로 actual 40이다. 점선은 호출로 생긴 결과이며 entry의 재대입이 아니다. |
| 구분 | 내용 |
|---|---|
| 설명 | entry는 기존 immutable 40 Post를 계속 가리킨다. addViewCount 20이 새 60 Post를 반환해도 그 참조를 저장하지 않았으므로 actual 40이다. 점선은 호출로 생긴 결과이며 entry의 재대입이 아니다. |
| 전체 읽기 정보 | 반환값을 무시해도 원본 참조는 바뀌지 않는다 반환값을 무시해도 원본 참조는 바뀌지 않는다 IgnoredImmutableReturnBug의 addViewCount는 조회 수 60인 새 Post를 반환하지만 main은 그 참조를 저장하지 않는다. entry는 조회 수 40인 원래 Post를 계속 가리킨다. 반환값을 받지 않으면 entry는 원본을 계속 가리킵니다 IgnoredImmutableReturnBug 의 entry.addViewCount(20) 호출을 기준으로 봅니다. 제목은 모두 immutable 입니다. 반환값을 무시한 호출 전후의 참조와 값 구분 객체의 조회 수 호출 뒤 참조의 사용 기존 객체 40 유지 entry 가 계속 가리키므로 main 은 actual=40 을 출력합니다. 반환된 새 객체 40 + 20 = 60 호출문에 대입이 없어 반환 참조를 저장하지 않습니다. 기존 객체 · 조회 수 40 entry 는 호출 뒤에도 이 객체를 가리킵니다. 원본을 수정하지 않았으므로 actual=40 입니다. 반환된 새 객체 · 조회 수 60 40 + 20 을 담은 별도 Post 를 반환합니다. 대입이 없어 그 참조는 저장되지 않습니다. expected=60 은 기대값을 적은 출력문입니다. 새 객체를 읽어서 출력한 결과가 아닙니다. 결과를 사용할 때는 본문의 수정 호출처럼 entry = entry.addViewCount(20) 으로 참조를 다시 대입하거나 revised 에 받습니다. 원문 실패 예제는 이 대입을 실행하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원래 참조 entry가 조회 수 40의 Post를 보관합니다. |
|
|
| 새 값 반환 addViewCount(20)이 60의 Post를 반환합니다. |
|
|
| 반환 무시 호출자는 반환 참조를 대입하지 않습니다. |
|
|
반환값을 무시해도 원본 참조는 바뀌지 않는다
addViewCount(20)이 60인 Post를 반환하지만 entry에 저장하지 않아 원본 40 참조가 유지됩니다. 호출 전·반환·무시 후 관측의 실제 차이가 반환값 누락을 설명합니다.
IgnoredImmutableReturnBug의 addViewCount는 조회 수60인 새 Post를 반환하지만 main은 그 참조를 저장하지 않는다. entry는 조회 수40인 원래 Post를 계속 가리킨다.
반환값을 받지 않으면 entry는 원본을 계속 가리킵니다
IgnoredImmutableReturnBug의 entry.addViewCount(20) 호출을 기준으로 봅니다. 제목은 모두 immutable입니다.
addViewCount(20): 새 값 반환실선: 저장된 참조 · 점선: 호출 결과 생성 · actual=40
참조 대입 없음actual=40
| 구분 | 객체의 조회 수 | 호출 뒤 참조의 사용 |
|---|---|---|
| 기존 객체 | 40 유지 | entry가 계속 가리키므로 main은 actual=40을 출력합니다. |
| 반환된 새 객체 | 40 + 20 = 60 | 호출문에 대입이 없어 반환 참조를 저장하지 않습니다. |
- 기존 객체 · 조회 수
40 entry는 호출 뒤에도 이 객체를 가리킵니다. 원본을 수정하지 않았으므로actual=40입니다.- 반환된 새 객체 · 조회 수
60 40 + 20을 담은 별도Post를 반환합니다. 대입이 없어 그 참조는 저장되지 않습니다.
expected=60은 기대값을 적은 출력문입니다. 새 객체를 읽어서 출력한 결과가 아닙니다.
결과를 사용할 때는 본문의 수정 호출처럼 entry = entry.addViewCount(20)으로 참조를 다시 대입하거나 revised에 받습니다. 원문 실패 예제는 이 대입을 실행하지 않습니다.
이 예의 조건
- 반환한 참조를 저장하지 않는 원 main이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | entry는 기존 immutable 40 Post를 계속 가리킨다. addViewCount 20이 새 60 Post를 반환해도 그 참조를 저장하지 않았으므로 actual 40이다. 점선은 호출로 생긴 결과이며 entry의 재대입이 아니다. |
| 구분 | 내용 |
|---|---|
| 설명 | entry는 기존 immutable 40 Post를 계속 가리킨다. addViewCount 20이 새 60 Post를 반환해도 그 참조를 저장하지 않았으므로 actual 40이다. 점선은 호출로 생긴 결과이며 entry의 재대입이 아니다. |
| 전체 읽기 정보 | 반환값을 무시해도 원본 참조는 바뀌지 않는다 반환값을 무시해도 원본 참조는 바뀌지 않는다 IgnoredImmutableReturnBug의 addViewCount는 조회 수 60인 새 Post를 반환하지만 main은 그 참조를 저장하지 않는다. entry는 조회 수 40인 원래 Post를 계속 가리킨다. 반환값을 받지 않으면 entry는 원본을 계속 가리킵니다 IgnoredImmutableReturnBug 의 entry.addViewCount(20) 호출을 기준으로 봅니다. 제목은 모두 immutable 입니다. 반환값을 무시한 호출 전후의 참조와 값 구분 객체의 조회 수 호출 뒤 참조의 사용 기존 객체 40 유지 entry 가 계속 가리키므로 main 은 actual=40 을 출력합니다. 반환된 새 객체 40 + 20 = 60 호출문에 대입이 없어 반환 참조를 저장하지 않습니다. 기존 객체 · 조회 수 40 entry 는 호출 뒤에도 이 객체를 가리킵니다. 원본을 수정하지 않았으므로 actual=40 입니다. 반환된 새 객체 · 조회 수 60 40 + 20 을 담은 별도 Post 를 반환합니다. 대입이 없어 그 참조는 저장되지 않습니다. expected=60 은 기대값을 적은 출력문입니다. 새 객체를 읽어서 출력한 결과가 아닙니다. 결과를 사용할 때는 본문의 수정 호출처럼 entry = entry.addViewCount(20) 으로 참조를 다시 대입하거나 revised 에 받습니다. 원문 실패 예제는 이 대입을 실행하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원래 참조 entry가 조회 수 40의 Post를 보관합니다. |
|
|
| 새 값 반환 addViewCount(20)이 60의 Post를 반환합니다. |
|
|
| 반환 무시 호출자는 반환 참조를 대입하지 않습니다. |
|
|
이전 값과 수정 값의 보존
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는 이미 공개된 객체라면 같은 인스턴스를 반환해 불필요한 생성을 피합니다.
값이 같다는 계약은 유지되므로 반드시 매번 새 객체를 만들어야 불변인 것은 아닙니다.
with와 publish가 보존하는 값과 인스턴스
original 40/미공개에서 revised 60/미공개와 published 60/공개라는 새 값을 얻으며 앞선 값을 보존합니다. 재호출 this 반환은 별도 추가 관찰로 구분하여 값의 전환을 보여 줄 수 있습니다.
ImmutableEntryWithMethods에서 original은40미공개, revised는60미공개, published는60공개인 서로 다른 Post다. 이 구현은 이미 공개된 객체의 publish 호출에서 this를 반환한다.
새 Post를 받아도 이전 값은 그대로 보존됩니다
ImmutableEntryWithMethods의 세 대입을 모두 마친 뒤입니다. 세 Post의 제목은 immutable입니다.
| 변수 | viewCount | published | 이 객체를 얻은 경로 |
|---|---|---|---|
original | 40 | false | 처음 new Post(...)로 생성 |
revised | 60 | false | original.withViewCount(60)이 새 객체 반환 |
published | 60 | true | 미공개인 revised.publish()가 새 객체 반환 |
originalnew Post(...)로 만든 객체입니다. 조회 수40, 공개 여부false를 유지합니다.revisedoriginal.withViewCount(60)이 만든 별도 객체입니다. 조회 수60, 공개 여부false입니다.publishedrevised.publish()가 만든 또 다른 객체입니다. 조회 수60, 공개 여부true입니다.
새 Post는 생성자 검증을 거칩니다. 새 값이 생겨도 original과 revised의 필드는 바뀌지 않습니다.
이 구현의 추가 호출 경계: 이미 공개된 published에 publish()를 다시 호출하면 this를 반환합니다. 따라서 새 객체가 아닙니다. 이 재호출은 원문 main에는 없으며, 모든 불변 API의 공통 정책도 아닙니다.
이 예의 조건
- published.publish 재호출은 원 main에 없는 별도 추가 관찰입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | original/revised/published의 각 참조는 40 false,60 false,60 true인 세 Post를 가리킨다. with 60과 publish는 새 값을 반환한다. 별도의 추가 관찰에서 이미 공개한 published.publish는 this로 같은 마지막 Post를 반환한다. |
| 구분 | 내용 |
|---|---|
| 설명 | original/revised/published의 각 참조는 40 false,60 false,60 true인 세 Post를 가리킨다. with 60과 publish는 새 값을 반환한다. 별도의 추가 관찰에서 이미 공개한 published.publish는 this로 같은 마지막 Post를 반환한다. |
| 전체 읽기 정보 | with와 publish가 보존하는 값과 인스턴스 with와 publish가 보존하는 값과 인스턴스 ImmutableEntryWithMethods에서 original은 40 미공개, revised는 60 미공개, published는 60 공개인 서로 다른 Post다. 이 구현은 이미 공개된 객체의 publish 호출에서 this를 반환한다. 새 Post를 받아도 이전 값은 그대로 보존됩니다 ImmutableEntryWithMethods 의 세 대입을 모두 마친 뒤입니다. 세 Post 의 제목은 immutable 입니다. 세 변수의 조회 수·공개 여부·생성 경로 변수 viewCount published 이 객체를 얻은 경로 original 40 false 처음 new Post(...) 로 생성 revised 60 false original.withViewCount(60) 이 새 객체 반환 published 60 true 미공개인 revised.publish() 가 새 객체 반환 original new Post(...) 로 만든 객체입니다. 조회 수 40 , 공개 여부 false 를 유지합니다. revised original.withViewCount(60) 이 만든 별도 객체입니다. 조회 수 60 , 공개 여부 false 입니다. published revised.publish() 가 만든 또 다른 객체입니다. 조회 수 60 , 공개 여부 true 입니다. 새 Post 는 생성자 검증을 거칩니다. 새 값이 생겨도 original 과 revised 의 필드는 바뀌지 않습니다. 이 구현의 추가 호출 경계: 이미 공개된 published 에 publish() 를 다시 호출하면 this 를 반환합니다. 따라서 새 객체가 아닙니다. 이 재호출은 원문 main 에는 없으며, 모든 불변 API의 공통 정책도 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원 값 original은 40,false입니다. |
|
|
| 수정값 반환 withViewCount 60이 revised를 만듭니다. |
|
|
| 공개값 반환 publish가 60,true인 결과를 만듭니다. |
|
|
with와 publish가 보존하는 값과 인스턴스
original 40/미공개에서 revised 60/미공개와 published 60/공개라는 새 값을 얻으며 앞선 값을 보존합니다. 재호출 this 반환은 별도 추가 관찰로 구분하여 값의 전환을 보여 줄 수 있습니다.
ImmutableEntryWithMethods에서 original은40미공개, revised는60미공개, published는60공개인 서로 다른 Post다. 이 구현은 이미 공개된 객체의 publish 호출에서 this를 반환한다.
새 Post를 받아도 이전 값은 그대로 보존됩니다
ImmutableEntryWithMethods의 세 대입을 모두 마친 뒤입니다. 세 Post의 제목은 immutable입니다.
| 변수 | viewCount | published | 이 객체를 얻은 경로 |
|---|---|---|---|
original | 40 | false | 처음 new Post(...)로 생성 |
revised | 60 | false | original.withViewCount(60)이 새 객체 반환 |
published | 60 | true | 미공개인 revised.publish()가 새 객체 반환 |
originalnew Post(...)로 만든 객체입니다. 조회 수40, 공개 여부false를 유지합니다.revisedoriginal.withViewCount(60)이 만든 별도 객체입니다. 조회 수60, 공개 여부false입니다.publishedrevised.publish()가 만든 또 다른 객체입니다. 조회 수60, 공개 여부true입니다.
새 Post는 생성자 검증을 거칩니다. 새 값이 생겨도 original과 revised의 필드는 바뀌지 않습니다.
이 구현의 추가 호출 경계: 이미 공개된 published에 publish()를 다시 호출하면 this를 반환합니다. 따라서 새 객체가 아닙니다. 이 재호출은 원문 main에는 없으며, 모든 불변 API의 공통 정책도 아닙니다.
이 예의 조건
- published.publish 재호출은 원 main에 없는 별도 추가 관찰입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | original/revised/published의 각 참조는 40 false,60 false,60 true인 세 Post를 가리킨다. with 60과 publish는 새 값을 반환한다. 별도의 추가 관찰에서 이미 공개한 published.publish는 this로 같은 마지막 Post를 반환한다. |
| 구분 | 내용 |
|---|---|
| 설명 | original/revised/published의 각 참조는 40 false,60 false,60 true인 세 Post를 가리킨다. with 60과 publish는 새 값을 반환한다. 별도의 추가 관찰에서 이미 공개한 published.publish는 this로 같은 마지막 Post를 반환한다. |
| 전체 읽기 정보 | with와 publish가 보존하는 값과 인스턴스 with와 publish가 보존하는 값과 인스턴스 ImmutableEntryWithMethods에서 original은 40 미공개, revised는 60 미공개, published는 60 공개인 서로 다른 Post다. 이 구현은 이미 공개된 객체의 publish 호출에서 this를 반환한다. 새 Post를 받아도 이전 값은 그대로 보존됩니다 ImmutableEntryWithMethods 의 세 대입을 모두 마친 뒤입니다. 세 Post 의 제목은 immutable 입니다. 세 변수의 조회 수·공개 여부·생성 경로 변수 viewCount published 이 객체를 얻은 경로 original 40 false 처음 new Post(...) 로 생성 revised 60 false original.withViewCount(60) 이 새 객체 반환 published 60 true 미공개인 revised.publish() 가 새 객체 반환 original new Post(...) 로 만든 객체입니다. 조회 수 40 , 공개 여부 false 를 유지합니다. revised original.withViewCount(60) 이 만든 별도 객체입니다. 조회 수 60 , 공개 여부 false 입니다. published revised.publish() 가 만든 또 다른 객체입니다. 조회 수 60 , 공개 여부 true 입니다. 새 Post 는 생성자 검증을 거칩니다. 새 값이 생겨도 original 과 revised 의 필드는 바뀌지 않습니다. 이 구현의 추가 호출 경계: 이미 공개된 published 에 publish() 를 다시 호출하면 this 를 반환합니다. 따라서 새 객체가 아닙니다. 이 재호출은 원문 main 에는 없으며, 모든 불변 API의 공통 정책도 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원 값 original은 40,false입니다. |
|
|
| 수정값 반환 withViewCount 60이 revised를 만듭니다. |
|
|
| 공개값 반환 publish가 60,true인 결과를 만듭니다. |
|
|
불변 구성 객체의 공유
여러 게시글이 같은 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에도 그대로 재사용됩니다.
이 실행은 null이 아닌 ViewPolicy를 전달합니다. Post의 생성자는 policy의 null 여부를 검사하지 않으며, reportThreshold는 저장만 하고 blocks 계산에는 쓰지 않습니다.
blocks는 양수 조회 수를 pageSize개씩 나눈 올림 개수입니다. 예를 들어 65는 25·25·15로 나뉘어 3블록입니다. 다만 원 식의 덧셈은 int이므로 조회 수 Integer.MAX_VALUE·pageSize 25에서는 -85899345라는 잘못된 음수가 나옵니다. 같은 Post 안에 아래 대안을 추가하면 덧셈부터 long으로 계산하여 85899346블록을 얻습니다.
int blocksSafe() {
return (int) (((long) viewCount + policy.pageSize() - 1) / policy.pageSize());
}viewCount와 pageSize가 양수이므로 블록 수는 1 이상 viewCount 이하입니다. 이 조건에서 최종 int 변환은 안전하며, 불변 정책을 공유하는 사실과 숫자 범위를 지키는 계산은 별개입니다.
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을 건드리지 않고 제한 규칙이 적용된 새 값을 만듭니다.
공개 수가 목표를 넘지 않는다는 불변식은 생성자 한 곳에서 계속 검증됩니다.
상한이 있어도 덧셈부터 확인하기
목표 2000000000·기존 공개 1800000000에 500000000을 더해 보세요. 규칙대로라면 2000000000으로 제한되어야 하지만 원 int 덧셈은 먼저 음수로 넘치고, Math.min은 그 음수를 골라 새 생성자에서 예외가 납니다. 상한 함수를 호출한 사실만으로 중간 계산이 안전해지지는 않습니다.
위 PublishQuota 안에 다음 대안을 추가합니다. 기존 제목·목표는 유지하고 공개 수만 새 값으로 계산합니다.
PublishQuota publishSafe(int count) {
if (count <= 0) throw new IllegalArgumentException("count");
long sum = (long) publishedCount + count;
int revised = (int) Math.min((long) targetCount, sum);
return new PublishQuota(title, targetCount, revised);
}같은 before에 publishSafe(500000000)을 호출하면 before는 1800000000, after는 2000000000이며 done은 true입니다. count가 양수이고 기존 공개 수가 목표 이하라서 제한한 값은 0 이상 목표 이하입니다. 원 100·70·50 예제도 before 70·after 100을 그대로 얻습니다.