본문 바로가기

불변 값과 with 메서드

불변 객체의 값을 바꿔야 할 때 원래 객체는 그대로 두고 바뀐 값을 담은 새 객체를 돌려주는 with 메서드를 만들고, 반환값을 버리는 실수와 변경 이력 보관까지 다룹니다.

앞 글에서 만든 불변 게시글은 한번 만들어지면 조회 수도 공개 여부도 바꿀 수 없습니다. 그런데 실제 게시글은 조회 수가 늘어나고, 초안이었다가 공개되기도 합니다. 값을 바꿀 수 없는 객체로 이런 변화를 어떻게 나타낼 수 있을까요?

답은 객체를 고치는 대신 바뀐 값을 담은 객체를 새로 만들어 돌려주는 것입니다. 원래 객체는 그대로 남으므로 바뀌기 전 값과 바뀐 뒤 값을 동시에 들고 있을 수 있습니다. 게다가 값이 바뀌는 곳이 entry = entry.withViewCount(60); 같은 대입문으로 호출하는 코드에 드러나므로, 어떤 변수가 새 값을 갖게 되었는지 읽기만 해도 알 수 있습니다. 이 글에서는 이런 메서드를 만드는 방법과, 이 방식에서 가장 흔한 실수를 함께 살펴봅니다.

바뀐 값을 담은 새 객체를 돌려주기

아래 Post 레코드에는 값을 바꾸는 메서드가 두 개 있습니다. withViewCount는 조회 수만 다른 새 Post를 만들어 돌려주고, publish는 공개 상태인 새 Post를 돌려줍니다. 이처럼 필드 하나만 바꾼 새 객체를 돌려주는 메서드를 흔히 with 메서드라고 부릅니다. main은 세 단계의 값을 각각 다른 변수에 받아 모두 출력합니다.

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]

출력 형식 Post[title=..., viewCount=..., published=...]는 레코드가 자동으로 만들어 주는 toString의 모양입니다. 세 줄을 비교하면 withViewCount(60)과 publish()를 거친 뒤에도 original은 처음 값인 조회 수 40, 미공개를 그대로 갖고 있습니다. revised는 조회 수만 60으로 바뀐 별도의 객체이고, published는 거기서 공개 여부만 바뀐 또 다른 객체입니다. 메모리에는 Post 객체 세 개가 있고, 세 변수가 하나씩 가리킵니다.

with 메서드가 만드는 새 객체도 new Post(...)로 만들어지므로 컴팩트 생성자의 검사를 똑같이 거칩니다. 예를 들어 original.withViewCount(0)을 호출하면 생성자에서 IllegalArgumentException이 던져지고 새 객체는 만들어지지 않습니다. 앞 글에서 '생성자에서 모든 규칙을 검사하면 잘못된 객체가 생길 수 없다'고 했는데, 값을 바꾸는 경로가 모두 생성자를 지나가므로 이 성질이 값이 바뀌는 동안에도 그대로 유지됩니다.

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

original 40/미공개에서 revised 60/미공개와 published 60/공개라는 새 값을 얻으며 앞선 값을 보존합니다. 재호출 this 반환은 별도 추가 관찰로 구분하여 값의 전환을 보여 줄 수 있습니다.

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

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

새 Post를 받아도 이전 값은 그대로 보존됩니다

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

새 Post를 받아도 이전 값은 그대로 보존됩니다 original/revised/published각참조는40false,60false,60true인세Post를가리킨다. with60과publish는새값을반환한다. 별도의추가관찰에서이미공개한published.publish는this로같은마지막Post를반환한다. original 참조 Post의 값 viewCount = 40 published = false revised 참조 Post의 값 viewCount = 60 published = false published 참조 Post의 값 viewCount = 60 published = true withViewCount(60) publish() 별도 재호출: this 앞선 두 값은 보존 · 재호출은 원 main에 없는 추가 관찰
새 Post를 받아도 이전 값은 그대로 보존됩니다 original/revised/published각참조는40false,60false,60true인세Post를가리킨다. with60과publish는새값을반환한다. 별도의추가관찰에서이미공개한published.publish는this로같은마지막Post를반환한다. original 참조 Post의 값 viewCount = 40 published = false revised 참조 Post의 값 viewCount = 60 published = false published 참조 Post의 값 viewCount = 60 published = true 점선: with60 → publish의 반환 마지막 재호출은 this 반환 재호출은 원 main 밖 비교
세 변수의 조회 수·공개 여부·생성 경로
변수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의 공통 정책도 아닙니다.

이 예의 조건

  • published.publish 재호출은 원 main에 없는 별도 추가 관찰입니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2original/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입니다.

  • viewCount: 40
  • published: false
  • 불변 값 변경 요청은 새 결과를 받아 보관합니다.
수정값 반환

withViewCount 60이 revised를 만듭니다.

  • original: 40,false 유지
  • revised: 60,false
  • 이전 참조가 가리키는 값을 바꾸지 않습니다.
공개값 반환

publish가 60,true인 결과를 만듭니다.

  • published: 60,true
  • 별도 재호출: 이미 공개되었으면 this 반환
  • 상태 동일 요청은 기존 인스턴스를 재사용할 수 있습니다.

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

original 40/미공개에서 revised 60/미공개와 published 60/공개라는 새 값을 얻으며 앞선 값을 보존합니다. 재호출 this 반환은 별도 추가 관찰로 구분하여 값의 전환을 보여 줄 수 있습니다.

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

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

새 Post를 받아도 이전 값은 그대로 보존됩니다

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

새 Post를 받아도 이전 값은 그대로 보존됩니다 original/revised/published각참조는40false,60false,60true인세Post를가리킨다. with60과publish는새값을반환한다. 별도의추가관찰에서이미공개한published.publish는this로같은마지막Post를반환한다. original 참조 Post의 값 viewCount = 40 published = false revised 참조 Post의 값 viewCount = 60 published = false published 참조 Post의 값 viewCount = 60 published = true withViewCount(60) publish() 별도 재호출: this 앞선 두 값은 보존 · 재호출은 원 main에 없는 추가 관찰
새 Post를 받아도 이전 값은 그대로 보존됩니다 original/revised/published각참조는40false,60false,60true인세Post를가리킨다. with60과publish는새값을반환한다. 별도의추가관찰에서이미공개한published.publish는this로같은마지막Post를반환한다. original 참조 Post의 값 viewCount = 40 published = false revised 참조 Post의 값 viewCount = 60 published = false published 참조 Post의 값 viewCount = 60 published = true 점선: with60 → publish의 반환 마지막 재호출은 this 반환 재호출은 원 main 밖 비교
세 변수의 조회 수·공개 여부·생성 경로
변수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의 공통 정책도 아닙니다.

이 예의 조건

  • published.publish 재호출은 원 main에 없는 별도 추가 관찰입니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2original/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입니다.

  • viewCount: 40
  • published: false
  • 불변 값 변경 요청은 새 결과를 받아 보관합니다.
수정값 반환

withViewCount 60이 revised를 만듭니다.

  • original: 40,false 유지
  • revised: 60,false
  • 이전 참조가 가리키는 값을 바꾸지 않습니다.
공개값 반환

publish가 60,true인 결과를 만듭니다.

  • published: 60,true
  • 별도 재호출: 이미 공개되었으면 this 반환
  • 상태 동일 요청은 기존 인스턴스를 재사용할 수 있습니다.

publish에는 작은 장치가 하나 더 있습니다. 이미 공개된 게시글에 publish()를 다시 호출하면 새 객체를 만들지 않고 this, 즉 자기 자신을 돌려줍니다. 위 그림의 마지막 단계에서 따로 표시한 '별도 재호출'이 이 경우이고, 실제로 published.publish() == published는 true입니다. 바꿀 것이 없는데 똑같은 값의 객체를 하나 더 만들 이유가 없기 때문입니다.

이렇게 같은 객체를 돌려줘도 안전한 것은 Post가 불변이기 때문입니다. 호출한 쪽이 돌려받은 객체를 고칠 방법이 없으니, 그것이 새 객체든 원래 객체든 호출한 쪽에서는 차이가 없습니다. 만약 Post가 가변이었다면 '새 값'이라고 받은 객체를 고치는 순간 원래 객체까지 함께 바뀌는, 공유 참조와 소유권에서 본 문제가 다시 생겼을 것입니다. 그러니 불변 객체라고 해서 값을 바꾸는 메서드가 반드시 새 객체를 만들어야 하는 것은 아닙니다. 지켜야 할 것은 원래 객체를 바꾸지 않는다는 점 하나입니다.

반환값을 버리면 아무 일도 일어나지 않습니다

가변 객체에 익숙하면 이런 실수를 하기 쉽습니다. 아래 addViewCount도 조회 수를 더한 새 Post를 돌려주는 메서드인데, main은 가변 객체의 메서드를 부르듯이 호출만 하고 결과를 어디에도 저장하지 않습니다.

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

첫 줄은 기대하는 값을 그냥 문자열로 찍은 것이고, 둘째 줄이 실제 조회 수입니다. addViewCount(20)은 조회 수 60인 새 Post를 정상적으로 만들어 돌려줬습니다. 하지만 그 참조를 받아 둔 변수가 없으므로 새 객체는 아무도 쓰지 못한 채 버려지고, entry는 여전히 조회 수 40인 원래 객체를 가리킵니다.

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

addViewCount(20)이 60인 Post를 반환하지만 entry에 저장하지 않아 원본 40 참조가 유지됩니다. 호출 전·반환·무시 후 관측의 실제 차이가 반환값 누락을 설명합니다.

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

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

반환값을 받지 않으면 entry는 원본을 계속 가리킵니다

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

반환값을 받지 않으면 entry는 원본을 계속 가리킵니다entry는기존immutable40Post를계속가리킨다. addViewCount20이새60Post를반환해도그참조를저장하지않았으므로actual40이다. 점선은호출로생긴결과이며entry의재대입이아니다.
entry 변수기존 참조 유지
연결 관계entry 변수 · 기존 참조 유지 → 원래 Post · immutable / 40
원래 Postimmutable / 40
연결 관계원래 Post · immutable / 40 → 반환된 Post · immutable / 60 · 저장 안 함
반환된 Postimmutable / 60저장 안 함

addViewCount(20): 새 값 반환실선: 저장된 참조 · 점선: 호출 결과 생성 · actual=40

반환값을 받지 않으면 entry는 원본을 계속 가리킵니다entry는기존immutable40Post를계속가리킨다. addViewCount20이새60Post를반환해도그참조를저장하지않았으므로actual40이다. 점선은호출로생긴결과이며entry의재대입이아니다.
entry 변수기존 참조 유지
연결 관계entry 변수 · 기존 참조 유지 → 원래 Post · immutable / 40
원래 Postimmutable / 40
addViewCount(20)addViewCount(20)원래 Post · immutable / 40 → 반환된 Post · immutable / 60 · 호출문에서 저장 안 함
반환된 Postimmutable / 60호출문에서 저장 안 함

참조 대입 없음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이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2entry는 기존 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를 보관합니다.

  • entry 값: 40
  • 불변 값 연산의 출발점이다.
새 값 반환

addViewCount(20)이 60의 Post를 반환합니다.

  • 반환값: 60
  • entry 값: 40 유지
  • 메서드는 기존 객체를 변경하지 않는다.
반환 무시

호출자는 반환 참조를 대입하지 않습니다.

  • 관찰 actual: 40
  • 60 결과: 사용되지 않음
  • 결과를 보관하지 않아 이후 읽기는 원래 값으로 남는다.

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

addViewCount(20)이 60인 Post를 반환하지만 entry에 저장하지 않아 원본 40 참조가 유지됩니다. 호출 전·반환·무시 후 관측의 실제 차이가 반환값 누락을 설명합니다.

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

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

반환값을 받지 않으면 entry는 원본을 계속 가리킵니다

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

반환값을 받지 않으면 entry는 원본을 계속 가리킵니다entry는기존immutable40Post를계속가리킨다. addViewCount20이새60Post를반환해도그참조를저장하지않았으므로actual40이다. 점선은호출로생긴결과이며entry의재대입이아니다.
entry 변수기존 참조 유지
연결 관계entry 변수 · 기존 참조 유지 → 원래 Post · immutable / 40
원래 Postimmutable / 40
연결 관계원래 Post · immutable / 40 → 반환된 Post · immutable / 60 · 저장 안 함
반환된 Postimmutable / 60저장 안 함

addViewCount(20): 새 값 반환실선: 저장된 참조 · 점선: 호출 결과 생성 · actual=40

반환값을 받지 않으면 entry는 원본을 계속 가리킵니다entry는기존immutable40Post를계속가리킨다. addViewCount20이새60Post를반환해도그참조를저장하지않았으므로actual40이다. 점선은호출로생긴결과이며entry의재대입이아니다.
entry 변수기존 참조 유지
연결 관계entry 변수 · 기존 참조 유지 → 원래 Post · immutable / 40
원래 Postimmutable / 40
addViewCount(20)addViewCount(20)원래 Post · immutable / 40 → 반환된 Post · immutable / 60 · 호출문에서 저장 안 함
반환된 Postimmutable / 60호출문에서 저장 안 함

참조 대입 없음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이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2entry는 기존 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를 보관합니다.

  • entry 값: 40
  • 불변 값 연산의 출발점이다.
새 값 반환

addViewCount(20)이 60의 Post를 반환합니다.

  • 반환값: 60
  • entry 값: 40 유지
  • 메서드는 기존 객체를 변경하지 않는다.
반환 무시

호출자는 반환 참조를 대입하지 않습니다.

  • 관찰 actual: 40
  • 60 결과: 사용되지 않음
  • 결과를 보관하지 않아 이후 읽기는 원래 값으로 남는다.

고치는 방법은 돌려받은 참조를 변수에 넣는 것입니다. 같은 변수가 새 값을 가리키게 하려면 entry = entry.addViewCount(20);이라고 쓰고, 원래 값도 남겨 두고 싶으면 Post revised = entry.addViewCount(20);처럼 새 변수에 받습니다. 앞의 코드를 첫 번째 방식으로 고치면 둘째 줄이 actual=60이 됩니다.

이 버그가 까다로운 이유는 아무 신호도 없다는 데 있습니다. 컴파일 오류도, 예외도 나지 않고 javac -Xlint:all로 컴파일해도 경고가 나오지 않습니다. 프로그램은 조회 수 40을 계속 쓰면서 조용히 돌아가므로, 결과가 이상하다는 것을 누군가 알아챌 때까지 오래 숨어 있을 수 있습니다. 표준 라이브러리의 불변 클래스에서도 똑같은 실수가 자주 나옵니다. BigDecimal의 add도 새 객체를 돌려주므로 total.add(new BigDecimal("50"));만 쓰면 total은 그대로입니다. 문자열에서 생기는 같은 실수는 StringBuilder를 다루는 글에서 다시 봅니다.

컴파일러가 잡아 주지 않는 만큼 다른 도구의 도움을 받을 수 있습니다. Error Prone 같은 정적 분석 도구는 @CheckReturnValue 표시가 붙은 메서드의 반환값을 버리면 오류로 알려 주고, IntelliJ IDEA 같은 IDE에도 반환값을 쓰지 않는 호출을 찾아 주는 검사가 있습니다. 메서드 이름도 중요합니다. addViewCount라는 이름은 '이 객체의 조회 수를 늘린다'로 읽히기 쉬워서 이 실수를 부추겼습니다. 이름을 고르는 방법은 이 글 뒤쪽에서 다룹니다.

바뀌지 않은 부분은 새 객체와 함께 씁니다

with 메서드는 새 객체를 만들지만, 그렇다고 모든 것을 새로 만들지는 않습니다. 아래 예제에서 게시글 두 개는 목록을 몇 개씩 나눠 보여 줄지 정하는 정책 객체 ViewPolicy 하나를 함께 씁니다. blocks()는 조회 수를 정책의 pageSize씩 묶었을 때 몇 묶음이 되는지를 올림해서 셉니다.

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

(viewCount + pageSize - 1) / pageSize는 정수 나눗셈으로 올림을 하는 흔한 식입니다. 나머지가 조금이라도 있으면 몫이 하나 늘어나도록 미리 pageSize - 1을 더해 두는 것입니다. 조회 수 40은 25와 15로 나뉘어 2묶음이고, 65는 25, 25, 15로 나뉘어 3묶음이며, 50은 정확히 25 두 개라서 2묶음입니다. 이 예제의 reportThreshold는 정책에 저장만 되고 계산에는 쓰이지 않습니다.

눈여겨볼 곳은 withViewCount입니다. 새 Post를 만들면서 정책은 원래 객체가 갖고 있던 참조를 그대로 넘깁니다. 그래서 first, revised, second가 모두 같은 ViewPolicy 객체 하나를 가리키고, 마지막 줄의 shared-policy=true가 이를 확인해 줍니다. 제목 title도 마찬가지로 같은 String 객체를 공유합니다. 새로 만들어진 것은 조회 수가 다른 Post 껍데기 하나뿐입니다.

이렇게 공유해도 되는 것은 ViewPolicy와 String이 모두 불변이기 때문입니다. 어느 게시글을 통해서도 정책을 고칠 수 없으니, 한 게시글이 새 값으로 바뀌어도 다른 게시글이 쓰는 정책은 영향을 받지 않습니다. 정책이 가변이었다면 게시글마다 정책을 깊은 복사(deep copy)로 따로 들고 있어야 안심할 수 있었을 것입니다. 불변 객체를 조합하면 이처럼 바뀐 부분만 새로 만들고 나머지는 그대로 함께 쓸 수 있습니다.

한 가지 빈틈도 짚어 두겠습니다. 이 Post의 생성자는 policy가 null인지 검사하지 않습니다. 그래서 new Post("object", 40, null)은 문제없이 만들어지고, 나중에 blocks()를 호출할 때에야 NullPointerException이 납니다. 생성자에서 검사하지 않은 조건은 이렇게 엉뚱한 곳에서 문제를 일으킵니다. 컴팩트 생성자에 Objects.requireNonNull(policy, "policy");를 넣어 두면 잘못된 객체를 만드는 순간에 바로 알 수 있습니다.

더 깊이: 올림 나눗셈이 넘칠 때

blocks()의 덧셈은 int로 계산되므로 조회 수가 아주 크면 넘칩니다. 조회 수가 Integer.MAX_VALUE이고 pageSize가 25이면, 덧셈 결과가 int 범위를 넘어 음수가 되고 blocks()는 -85899345라는 엉뚱한 값을 돌려줍니다. 덧셈 전에 long으로 바꾸면 이 문제를 피할 수 있습니다.

Post 안에 넣는 큰 조회 수용 올림 계산
int blocksSafe() {
    return (int) (((long) viewCount + policy.pageSize() - 1) / policy.pageSize());
}

같은 입력에서 이 메서드는 85899346을 돌려줍니다. 조회 수와 pageSize가 모두 양수이면 결과는 1 이상 조회 수 이하이므로 마지막 (int) 변환에서 값이 잘리지 않습니다. Java 18부터는 Math.ceilDiv(viewCount, policy.pageSize())처럼 올림 나눗셈을 해 주는 표준 메서드를 쓸 수도 있고, 이 메서드도 같은 입력에서 85899346을 돌려줍니다. 정책 객체를 안전하게 공유하는 것과 계산 결과가 숫자 범위 안에 있는지는 서로 다른 문제라서, 두 가지를 따로 확인해야 합니다.

지난 값을 그대로 남겨 두기

값이 바뀔 때마다 새 객체가 생긴다는 것은, 지난 값이 저절로 보존된다는 뜻이기도 합니다. 이 성질은 되돌리기(undo), 언제 무엇이 바뀌었는지 남기는 감사 로그(audit log), 처리 전후 비교에 그대로 쓸 수 있습니다. 아래 프로그램은 게시글이 바뀔 때마다 그 시점의 값을 배열에 차례로 넣습니다.

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 {
            if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
        }

        Post withViewCount(int value) {
            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]

current는 단계마다 새 객체를 가리키도록 다시 대입되지만, 배열의 각 칸은 그 시점에 current가 가리키던 객체를 계속 가리킵니다. 그 객체들은 바뀔 수 없으므로 0번 칸은 끝까지 처음 값을, 1번 칸은 조회 수를 고친 직후의 값을 보여 줍니다. 되돌리기가 필요하면 history[size - 2]를 다시 current로 삼으면 됩니다. 검사를 컴팩트 생성자에 두었으므로 with 메서드로 만든 새 값도 같은 검사를 거친다는 점도 앞 예제와 같습니다.

같은 일을 가변 게시글 하나로 하면 결과가 전혀 다릅니다. 게시글 객체 하나의 조회 수와 공개 여부를 setter로 바꾸면서 매번 배열에 넣으면, 배열의 세 칸에는 같은 객체의 참조가 세 번 들어갑니다. 그래서 반복문이 출력하는 세 줄은 모두 마지막 상태인 조회 수 45, 공개됨이 됩니다. 칸마다 다른 시점을 보존하려면 넣을 때마다 복사본을 만들어야 하는데, 불변 객체는 이 복사를 할 필요가 없습니다.

다만 이력이 아주 길거나 값 하나가 크면 시점마다 전체 값을 보관하는 비용이 커집니다. 그럴 때는 값 전체 대신 '조회 수를 45로 바꿈' 같은 변경 내용만 기록하고, 필요할 때 처음 값에 차례로 적용해 원하는 시점의 값을 다시 만드는 방식을 생각해 볼 수 있습니다.

새 값을 돌려준다는 것을 이름으로 알리기

앞에서 본 것처럼 반환값을 버리는 실수는 메서드 이름에서 시작되는 경우가 많습니다. setViewCount나 addViewCount는 보통 객체 자신을 바꾸는 메서드에 붙는 이름이라서, 새 객체를 돌려주는 메서드에 붙이면 호출하는 쪽이 반환값을 무시하기 쉽습니다. 자바의 날짜 API가 withYear, plusDays처럼 새 값을 돌려준다는 것이 드러나는 이름을 쓰는 것도 이 때문입니다. 불변 객체의 메서드 이름은 대개 다음 관례를 따릅니다.

  • 필드 하나를 주어진 값으로 바꾼 객체를 돌려주면 withViewCount처럼 with + 필드 이름
  • 지금 값에 더하거나 뺀 객체를 돌려주면 plusViewCount, minusViewCount처럼 plus나 minus + 필드 이름
  • 상태가 넘어가는 것을 나타내면 publish, complete, cancel 같은 업무 동사

이름 말고도 정해서 문서에 적어 둘 것이 두 가지 있습니다. 첫째, publish처럼 바뀌는 것이 없을 때 같은 객체를 돌려줄 수 있는지입니다. 그럴 수 있다면 호출하는 쪽은 ==로 '값이 바뀌었는가'를 판단하면 안 되고, 값을 비교해야 합니다. 값 비교에 쓰는 equals는 다음 글에서 직접 구현합니다. 둘째, with 메서드에 null을 넘길 수 있는지입니다. 이것은 생성자가 null을 허용하는지와 같아야 하고, 생성자를 거쳐 새 객체를 만들면 자연히 같아집니다.

새 값을 돌려받아 다시 대입하는 일이 너무 잦고 번거롭다면 설계를 다시 볼 때입니다. 수명 내내 상태가 계속 바뀌는 객체라면, 앞 글에서 말한 것처럼 가변으로 두고 변경을 한곳에서 통제하는 편이 더 자연스러울 수 있습니다.

더 깊이: 새 객체를 계속 만들어도 괜찮을까

with 메서드를 쓰면 값이 바뀔 때마다 객체가 하나씩 생깁니다. 처음에는 낭비처럼 보이지만, 이 글의 Post 같은 작은 객체라면 대개 걱정할 만한 비용이 아닙니다. HotSpot JVM은 작은 객체를 스레드마다 미리 떼어 둔 메모리 영역에서 포인터를 옮기는 방식으로 할당하므로 할당 자체가 빠릅니다. 또 금방 쓰지 않게 되는 객체는 젊은 세대(young generation)를 정리하는 가비지 수집에서 한꺼번에 정리되는데, 이 수집은 대개 살아남은 객체를 옮기는 데 시간을 쓰므로 버려진 객체가 많아도 비용이 크게 늘지 않습니다.

비용이 문제가 되는 것은 복사하는 양이 클 때입니다. 원소가 수만 개인 배열을 값 하나 바꿀 때마다 통째로 복사한다면 할당보다 복사가 비싸집니다. 이 글에서 본 두 가지 기법이 그 비용을 줄입니다. 바뀌는 것이 없으면 publish처럼 같은 객체를 돌려주고, 바뀌지 않은 부분은 ViewPolicy처럼 새 객체와 함께 씁니다. 그래도 복사가 부담스러운 크기라면 앞 절의 변경 내용 기록이나, 앞 글에서 본 '안은 가변, 밖은 불변 조회' 방식을 고려합니다.

정리

  • 불변 객체의 값을 바꿀 때는 원래 객체를 그대로 두고 바뀐 값을 담은 새 객체를 돌려줍니다. 새 객체도 생성자를 거치므로 생성자의 검사가 늘 적용됩니다.
  • 돌려받은 값을 entry = entry.withViewCount(60);처럼 변수에 받지 않으면 아무것도 바뀌지 않습니다. 컴파일러는 이 실수를 알려 주지 않습니다.
  • 불변 객체는 공유해도 안전하므로, 바뀌는 것이 없으면 같은 객체를 돌려주고 바뀌지 않은 부분은 새 객체와 함께 쓸 수 있습니다.
  • 지난 값이 저절로 보존되므로 변경 이력, 되돌리기, 전후 비교를 복사 없이 구현할 수 있습니다.
  • withX, plusX, 업무 동사처럼 새 값을 돌려준다는 것이 드러나는 이름을 씁니다.

연습 문제

  1. PostHistory의 main에서 current = current.withViewCount(45);를 current.withViewCount(45);로 바꾸면 세 줄이 각각 어떻게 출력될지 예상해 보세요.
  2. 앞 글 연습 문제의 PublishQuota(목표 수, 공개 수)를 레코드로 만들고, 공개 수에 count를 더한 새 값을 돌려주는 publish(int count)를 추가하세요. 더한 결과가 목표 수를 넘으면 목표 수로 맞춥니다. 목표 100, 공개 70에 50을 더한 결과와, 목표 2000000000, 공개 1800000000에 500000000을 더한 결과를 모두 출력하세요.
  3. publish()가 이미 공개된 게시글에 대해 this를 돌려주는 것이 왜 안전한지 설명하세요. Post가 setter를 가진 가변 클래스였다면 같은 구현에서 어떤 문제가 생길까요?
해설 보기

1번. 출력은 다음과 같습니다.

0=Post[title=string, viewCount=30, published=false]
1=Post[title=string, viewCount=30, published=false]
2=Post[title=string, viewCount=30, published=true]

withViewCount(45)가 만든 조회 수 45인 객체는 아무 변수에도 저장되지 않고 버려집니다. 그래서 1번 칸에는 여전히 처음 객체가 다시 들어가고, 0번과 1번 칸은 같은 객체를 가리키게 됩니다. 이어지는 current = current.publish();는 결과를 제대로 받지만, 그 출발점이 조회 수 30인 처음 객체이므로 2번 칸도 조회 수가 30입니다. 반환값을 한 번 버린 실수가 그 뒤의 모든 값에 그대로 이어집니다.

2번. 먼저 떠오르는 구현은 Math.min(targetCount, publishedCount + count)입니다. 작은 입력에서는 이 식으로 충분하지만 큰 입력에서는 실패합니다. 1800000000 + 500000000은 int의 최댓값 2147483647을 넘으므로 덧셈 결과가 -1994967296으로 넘칩니다. Math.min은 더 작은 값인 이 음수를 고르고, 음수 공개 수를 받은 생성자는 IllegalArgumentException: published를 던집니다. 상한을 정하는 Math.min을 썼더라도 그 앞의 덧셈이 이미 넘쳤다면 소용이 없습니다. 그래서 아래 해답은 덧셈을 long으로 계산합니다.

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());

        PublishQuota large = new PublishQuota("large", 2000000000, 1800000000);
        System.out.println("large-after=" + large.publish(500000000).publishedCount());
    }

    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");
            long sum = (long) publishedCount + count;
            int revised = (int) Math.min((long) targetCount, sum);
            return new PublishQuota(title, targetCount, revised);
        }

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

publish는 원래 객체의 공개 수 70을 건드리지 않고, 상한을 적용한 새 값을 만듭니다. count가 양수이고 원래 공개 수가 목표 수 이하이므로, Math.min으로 고른 값은 언제나 0 이상 목표 수 이하이고 (int)로 바꿔도 잘리지 않습니다. 공개 수가 목표 수를 넘지 않는다는 조건은 생성자 한곳에서만 검사하지만, 새 값이 모두 생성자를 지나가므로 어떤 경로로 만든 PublishQuota에도 적용됩니다.

3번. 불변 객체는 누구도 고칠 수 없으므로, 호출한 쪽은 돌려받은 객체가 새 객체인지 원래 객체인지 구별할 필요가 없습니다. ==로 비교하지 않는 한 둘은 똑같이 동작합니다. 그러나 Post가 가변이었다면 이야기가 다릅니다. 호출한 쪽은 '공개된 새 게시글'을 받았다고 생각하고 setViewCount로 조회 수를 고칠 수 있는데, 실제로는 원래 객체를 돌려받았으므로 그 수정이 원래 게시글에도 그대로 나타납니다. 같은 객체를 두 곳에서 참조하면서 한쪽의 변경이 다른 쪽에 퍼지는, 9장 앞부분에서 본 공유 참조 문제가 다시 생기는 것입니다.

다음 글

다음 글에서는 지금까지 배운 것을 모아 날짜를 나타내는 불변 클래스를 처음부터 만듭니다. 공유된 날짜가 함께 바뀌는 문제를 불변 클래스로 고치고, 값이 같은 두 날짜를 같다고 판단하도록 equals와 hashCode, toString을 구현합니다.