본문 바로가기

불변 객체와 안전한 공유

변경 메서드를 제거한 불변 Post를 설계하고 생성 검증·final 필드·공유 안전성의 역할을 실제 코드로 확인합니다.

객체 공유를 막기 어렵다면 공유된 객체가 변하지 않게 만들 수 있습니다.

불변 객체는 생성이 끝난 뒤 관찰 가능한 상태가 바뀌지 않는 객체입니다.

여러 변수가 같은 인스턴스를 가리켜도 누구도 기존 값을 수정할 수 없으므로 한 경로의 작업이 다른 경로에 부수 효과를 만들지 않습니다.


불변 객체의 수정 제한

불변 클래스는 상태를 private final 필드에 보관하고 생성자에서 모두 초기화합니다.

다음 실패 예제는 호출자가 예전 가변 API처럼 setter를 사용하려 할 때 컴파일 단계에서 차단되는 모습을 보여 줍니다.

lab/ImmutableEntryMutationFailure.java
public final class ImmutableEntryMutationFailure {
    public static void main(String[] args) {
        Post entry = new Post("immutability", 40);
        entry.setViewCount(90);
    }

    private static final class Post {
        private final String title;
        private final int viewCount;

        Post(String title, int viewCount) {
            this.title = title;
            this.viewCount = viewCount;
        }
    }
}
컴파일 실패 관찰
error: cannot find symbol
entry.setViewCount(90);
     ^

setter를 private로 숨기는 정도가 아니라 아예 제공하지 않으면 객체 외부와 내부 모두 기존 필드를 다시 대입할 경로가 없습니다.

final은 필드가 생성 과정에서 한 번 정해진 뒤 재대입되지 않게 보조합니다.

불변성의 핵심은 final 키워드 하나가 아니라 모든 관찰 가능한 변경 통로를 닫는 설계입니다.


불변 게시글의 안전한 공유

DailyReport와 ReviewQueue가 한 Post를 함께 사용합니다.

어느 쪽에도 수정 API가 없으므로 두 기능은 안정된 스냅숏을 봅니다.

src/SharedImmutableEntry.java
public final class SharedImmutableEntry {
    public static void main(String[] args) {
        Post entry = new Post("immutability", 40);
        DailyReport report = new DailyReport(entry);
        ReviewQueue queue = new ReviewQueue(entry);

        System.out.println("report=" + report.summary());
        System.out.println("queue=" + queue.label());
        System.out.println("same=" + (report.entry() == queue.entry()));
    }

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

    private record DailyReport(Post entry) {
        String summary() { return entry.title() + "=" + entry.viewCount(); }
    }

    private record ReviewQueue(Post entry) {
        String label() { return "review:" + entry.title(); }
    }
}
report=immutability=40
queue=review:immutability
same=true

참조 동일성이 true여도 위험하지 않습니다.

상태를 바꿀 수 없기 때문에 복사 없이 공유할 수 있고, 메모리와 객체 생성 비용도 줄일 수 있습니다.

String, 날짜·시간 값, 금액처럼 값 의미가 강한 타입이 불변으로 설계되는 이유입니다.


생성 시점의 유효성 완성

불변 객체는 나중에 필드를 고칠 수 없으므로 생성자에서 모든 규칙을 검증해야 합니다.

유효하지 않은 객체를 잠깐 만들었다가 setter 순서로 완성하는 방식을 허용하지 않습니다.

src/ValidatedImmutablePost.java
public final class ValidatedImmutablePost {
    public static void main(String[] args) {
        PostMetric post = new PostMetric("string", 45, true);
        System.out.println(post.description());

        try {
            new PostMetric(" ", -1, false);
        } catch (IllegalArgumentException error) {
            System.out.println("rejected=" + error.getMessage());
        }
    }

    private static final class PostMetric {
        private final String title;
        private final int viewCount;
        private final boolean published;

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

        String description() {
            return title + ":" + viewCount + ":" + published;
        }
    }
}
string:45:true
rejected=title

생성 성공은 곧 클래스 불변식이 성립한다는 뜻입니다.

이후 상태가 바뀌지 않으므로 매 메서드마다 title과 viewCount를 재검증할 필요가 없습니다.

예외 메시지는 어떤 규칙이 깨졌는지 드러내고, 호출자는 객체가 생성되지 않았음을 확실히 알 수 있습니다.


final 참조와 내부 가변성

필드가 final이어도 그 필드가 가변 배열이나 목록을 가리키면 내부 요소는 바뀔 수 있습니다.

생성자 인수를 그대로 보관하거나 내부 배열을 그대로 반환하면 외부 참조가 상태를 수정합니다.

src/DeepImmutableBoardPlan.java
public final class DeepImmutableBoardPlan {
    public static void main(String[] args) {
        String[] source = {"object", "immutable"};
        BoardPlan plan = new BoardPlan("mid-java", source);
        source[0] = "changed-source";

        String[] returned = plan.titles();
        returned[1] = "changed-result";

        String[] safe = plan.titles();
        System.out.println(safe[0] + "," + safe[1]);
    }

    private static final class BoardPlan {
        private final String name;
        private final String[] titles;

        BoardPlan(String name, String[] titles) {
            if (name == null || name.isBlank()) throw new IllegalArgumentException("name");
            if (titles == null || titles.length == 0) throw new IllegalArgumentException("titles");
            this.name = name;
            this.titles = titles.clone();
        }

        String[] titles() {
            return titles.clone();
        }
    }
}
object,immutable

생성 시 입력 배열을 복사하고 조회 시에도 새 배열을 반환하므로 바깥의 원소 대입이 내부 배열에 도달하지 않습니다.

그러나 배열 요소가 가변 객체라면 요소 상태는 여전히 바뀔 수 있습니다.

깊은 불변성을 원하면 요소도 불변 타입이어야 하거나 생성 시 각각 복사해야 합니다.

입력과 반환 배열의 복사 경계

source와 returned의 서로 다른 슬롯을 바꿔도 내부 object/immutable과 새 safe 조회가 보존됩니다. 입력 복사·반환 복사·외부 변경의 인과를 String 불변 공유 조건과 함께 설명할 수 있습니다.

입력과 반환 배열의 복사 경계

DeepImmutableBoardPlan에서 생성자와 두 titles 호출은 각각 새 배열을 만든다. source와 returned를 수정해도 내부 배열과 safe의 값은 object,immutable이다.

배열 네 개의 슬롯은 각각 독립적으로 바뀝니다

DeepImmutableBoardPlan에서 safe = plan.titles()까지 실행한 뒤의 네 배열입니다. 각 배열의 현재 슬롯 값과 배열을 만들었던 복사 관계를 정리했습니다.

배열 네 개의 슬롯은 각각 독립적으로 바뀝니다 DeepImmutableBoardPlan의입력clone과두조회clone은서로다른네배열을만든다. sourcechanged-source/immutable,internalobject/immutable,returnedobject/changed-result,safeobject/immutable이다. 복사경로는그당시의슬롯복사이며String요소깊은복사는아니다. 클래스 내부 source [0] changed-source [1] immutable 내부 titles [0] object [1] immutable returned [0] object [1] changed-result safe [0] object [1] immutable 생성 시 입력 복사 첫 조회: 복사 둘째 조회: 복사 점선: 새 배열을 만든 복사 슬롯의 String은 불변 공유
서로 다른 네 배열과 입력·반환 복사 경로 생성자가 수정 전 source를 복사해 내부 titles를 만든다. 첫 titles 호출은 returned, 두 번째 호출은 safe라는 별도 배열을 반환한다. source 0번은 changed-source, returned 1번은 changed-result지만 내부와 safe는 object,immutable을 유지한다. 수정 전 입력 복사 첫 titles() 호출 · 복사 둘째 titles() 호출 · 복사 source · 입력 배열 [0] "changed-source" [1] "immutable" plan 내부 titles · 입력 사본 [0] "object" [1] "immutable" returned · 첫 반환 배열 [0] "object" [1] "changed-result" safe · 둘째 반환 배열 [0] "object" [1] "immutable"
source · 입력 배열
생성자는 수정 전 배열을 복사했습니다. 이후 source[0]에 대입하여 현재 값은 ["changed-source", "immutable"]입니다.
plan 내부 titles · 입력 사본
["object", "immutable"]을 유지합니다. source와 다른 배열입니다.
returned · 첫 반환 배열
첫 titles()가 내부 배열을 복사했습니다. 이후 returned[1]에 대입하여 ["object", "changed-result"]가 됐습니다.
safe · 둘째 반환 배열
다시 titles()가 내부 배열을 복사했습니다. 앞의 세 배열과 다른 배열이며 값은 ["object", "immutable"]입니다.

두 외부 대입은 내부 배열에 닿지 않아 출력은 object,immutable입니다. 이 복사 관계는 같은 배열을 공유한다는 뜻이 아닙니다.

슬롯에는 문자열의 내용을 표시했습니다. clone()은 배열을 새로 만들지만 요소 객체까지 깊게 복사하지는 않습니다. 여기서는 요소가 불변인 String입니다. final만으로 배열 슬롯의 변경을 막는 것은 아닙니다.

이 예의 조건

  • 배열 슬롯 복사이며 불변 String 요소를 깊은 복사하지 않습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2DeepImmutableBoardPlan의 입력 clone과 두 조회 clone은 서로 다른 네 배열을 만든다. source는 changed-source/immutable, internal은 object/immutable, returned는 object/changed-result, safe는 object/immutable이다. 복사 경로는 그 당시의 슬롯 복사이며 String 요소의 깊은 복사는 아니다.
원 도표의 설명과 주석
구분내용
설명DeepImmutableBoardPlan의 입력 clone과 두 조회 clone은 서로 다른 네 배열을 만든다. source는 changed-source/immutable, internal은 object/immutable, returned는 object/changed-result, safe는 object/immutable이다. 복사 경로는 그 당시의 슬롯 복사이며 String 요소의 깊은 복사는 아니다.
전체 읽기 정보입력과 반환 배열의 복사 경계 입력과 반환 배열의 복사 경계 DeepImmutableBoardPlan에서 생성자와 두 titles 호출은 각각 새 배열을 만든다. source와 returned를 수정해도 내부 배열과 safe의 값은 object,immutable이다. 배열 네 개의 슬롯은 각각 독립적으로 바뀝니다 DeepImmutableBoardPlan 에서 safe = plan.titles() 까지 실행한 뒤의 네 배열입니다. 각 배열의 현재 슬롯 값과 배열을 만들었던 복사 관계를 정리했습니다. source · 입력 배열 생성자는 수정 전 배열을 복사했습니다. 이후 source[0] 에 대입하여 현재 값은 ["changed-source", "immutable"] 입니다. plan 내부 titles · 입력 사본 ["object", "immutable"] 을 유지합니다. source 와 다른 배열입니다. returned · 첫 반환 배열 첫 titles() 가 내부 배열을 복사했습니다. 이후 returned[1] 에 대입하여 ["object", "changed-result"] 가 됐습니다. safe · 둘째 반환 배열 다시 titles() 가 내부 배열을 복사했습니다. 앞의 세 배열과 다른 배열이며 값은 ["object", "immutable"] 입니다. 두 외부 대입은 내부 배열에 닿지 않아 출력은 object,immutable 입니다. 이 복사 관계는 같은 배열을 공유한다는 뜻이 아닙니다. 슬롯에는 문자열의 내용을 표시했습니다. clone() 은 배열을 새로 만들지만 요소 객체까지 깊게 복사하지는 않습니다. 여기서는 요소가 불변인 String 입니다. final 만으로 배열 슬롯의 변경을 막는 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
입력 복사

입력 배열을 내부 titles로 clone한 뒤 source[0]="changed-source"를 대입합니다.

  • 내부: object,immutable
  • source 변경 후: changed-source,immutable
  • source의 0번 슬롯만 changed-source로 바뀌며 별도 내부 배열은 object,immutable을 유지합니다.
첫 조회 복사

returned는 새 배열이며 1번을 changed-result로 바꿉니다.

  • returned: object,changed-result
  • 내부: object,immutable 유지
  • 출력 배열의 쓰기 대상도 내부 슬롯과 다릅니다.
다음 조회

safe가 새 배열로 내부 값을 다시 받습니다.

  • safe: object,immutable
  • 전체 배열 수: 4
  • 앞 반환값 수정이 다음 조회 사본에 반영되지 않습니다.

입력과 반환 배열의 복사 경계

source와 returned의 서로 다른 슬롯을 바꿔도 내부 object/immutable과 새 safe 조회가 보존됩니다. 입력 복사·반환 복사·외부 변경의 인과를 String 불변 공유 조건과 함께 설명할 수 있습니다.

입력과 반환 배열의 복사 경계

DeepImmutableBoardPlan에서 생성자와 두 titles 호출은 각각 새 배열을 만든다. source와 returned를 수정해도 내부 배열과 safe의 값은 object,immutable이다.

배열 네 개의 슬롯은 각각 독립적으로 바뀝니다

DeepImmutableBoardPlan에서 safe = plan.titles()까지 실행한 뒤의 네 배열입니다. 각 배열의 현재 슬롯 값과 배열을 만들었던 복사 관계를 정리했습니다.

배열 네 개의 슬롯은 각각 독립적으로 바뀝니다 DeepImmutableBoardPlan의입력clone과두조회clone은서로다른네배열을만든다. sourcechanged-source/immutable,internalobject/immutable,returnedobject/changed-result,safeobject/immutable이다. 복사경로는그당시의슬롯복사이며String요소깊은복사는아니다. 클래스 내부 source [0] changed-source [1] immutable 내부 titles [0] object [1] immutable returned [0] object [1] changed-result safe [0] object [1] immutable 생성 시 입력 복사 첫 조회: 복사 둘째 조회: 복사 점선: 새 배열을 만든 복사 슬롯의 String은 불변 공유
서로 다른 네 배열과 입력·반환 복사 경로 생성자가 수정 전 source를 복사해 내부 titles를 만든다. 첫 titles 호출은 returned, 두 번째 호출은 safe라는 별도 배열을 반환한다. source 0번은 changed-source, returned 1번은 changed-result지만 내부와 safe는 object,immutable을 유지한다. 수정 전 입력 복사 첫 titles() 호출 · 복사 둘째 titles() 호출 · 복사 source · 입력 배열 [0] "changed-source" [1] "immutable" plan 내부 titles · 입력 사본 [0] "object" [1] "immutable" returned · 첫 반환 배열 [0] "object" [1] "changed-result" safe · 둘째 반환 배열 [0] "object" [1] "immutable"
source · 입력 배열
생성자는 수정 전 배열을 복사했습니다. 이후 source[0]에 대입하여 현재 값은 ["changed-source", "immutable"]입니다.
plan 내부 titles · 입력 사본
["object", "immutable"]을 유지합니다. source와 다른 배열입니다.
returned · 첫 반환 배열
첫 titles()가 내부 배열을 복사했습니다. 이후 returned[1]에 대입하여 ["object", "changed-result"]가 됐습니다.
safe · 둘째 반환 배열
다시 titles()가 내부 배열을 복사했습니다. 앞의 세 배열과 다른 배열이며 값은 ["object", "immutable"]입니다.

두 외부 대입은 내부 배열에 닿지 않아 출력은 object,immutable입니다. 이 복사 관계는 같은 배열을 공유한다는 뜻이 아닙니다.

슬롯에는 문자열의 내용을 표시했습니다. clone()은 배열을 새로 만들지만 요소 객체까지 깊게 복사하지는 않습니다. 여기서는 요소가 불변인 String입니다. final만으로 배열 슬롯의 변경을 막는 것은 아닙니다.

이 예의 조건

  • 배열 슬롯 복사이며 불변 String 요소를 깊은 복사하지 않습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2DeepImmutableBoardPlan의 입력 clone과 두 조회 clone은 서로 다른 네 배열을 만든다. source는 changed-source/immutable, internal은 object/immutable, returned는 object/changed-result, safe는 object/immutable이다. 복사 경로는 그 당시의 슬롯 복사이며 String 요소의 깊은 복사는 아니다.
원 도표의 설명과 주석
구분내용
설명DeepImmutableBoardPlan의 입력 clone과 두 조회 clone은 서로 다른 네 배열을 만든다. source는 changed-source/immutable, internal은 object/immutable, returned는 object/changed-result, safe는 object/immutable이다. 복사 경로는 그 당시의 슬롯 복사이며 String 요소의 깊은 복사는 아니다.
전체 읽기 정보입력과 반환 배열의 복사 경계 입력과 반환 배열의 복사 경계 DeepImmutableBoardPlan에서 생성자와 두 titles 호출은 각각 새 배열을 만든다. source와 returned를 수정해도 내부 배열과 safe의 값은 object,immutable이다. 배열 네 개의 슬롯은 각각 독립적으로 바뀝니다 DeepImmutableBoardPlan 에서 safe = plan.titles() 까지 실행한 뒤의 네 배열입니다. 각 배열의 현재 슬롯 값과 배열을 만들었던 복사 관계를 정리했습니다. source · 입력 배열 생성자는 수정 전 배열을 복사했습니다. 이후 source[0] 에 대입하여 현재 값은 ["changed-source", "immutable"] 입니다. plan 내부 titles · 입력 사본 ["object", "immutable"] 을 유지합니다. source 와 다른 배열입니다. returned · 첫 반환 배열 첫 titles() 가 내부 배열을 복사했습니다. 이후 returned[1] 에 대입하여 ["object", "changed-result"] 가 됐습니다. safe · 둘째 반환 배열 다시 titles() 가 내부 배열을 복사했습니다. 앞의 세 배열과 다른 배열이며 값은 ["object", "immutable"] 입니다. 두 외부 대입은 내부 배열에 닿지 않아 출력은 object,immutable 입니다. 이 복사 관계는 같은 배열을 공유한다는 뜻이 아닙니다. 슬롯에는 문자열의 내용을 표시했습니다. clone() 은 배열을 새로 만들지만 요소 객체까지 깊게 복사하지는 않습니다. 여기서는 요소가 불변인 String 입니다. final 만으로 배열 슬롯의 변경을 막는 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
입력 복사

입력 배열을 내부 titles로 clone한 뒤 source[0]="changed-source"를 대입합니다.

  • 내부: object,immutable
  • source 변경 후: changed-source,immutable
  • source의 0번 슬롯만 changed-source로 바뀌며 별도 내부 배열은 object,immutable을 유지합니다.
첫 조회 복사

returned는 새 배열이며 1번을 changed-result로 바꿉니다.

  • returned: object,changed-result
  • 내부: object,immutable 유지
  • 출력 배열의 쓰기 대상도 내부 슬롯과 다릅니다.
다음 조회

safe가 새 배열로 내부 값을 다시 받습니다.

  • safe: object,immutable
  • 전체 배열 수: 4
  • 앞 반환값 수정이 다음 조회 사본에 반영되지 않습니다.

불변 객체의 적용 대상

모든 객체를 불변으로 만들 수는 있지만 항상 최선은 아닙니다.

값의 한 시점을 표현하고 여러 곳에서 안전하게 공유해야 하는 객체에 특히 잘 맞습니다.

  • 게시글 항목, 기간, 날짜, 설정처럼 값 자체가 의미인 타입
  • 동등성과 해시가 안정되어야 하는 조회 키 타입
  • 여러 스레드나 서비스가 읽지만 수정 조정은 필요 없는 데이터
  • 캐시해 두고 반복 재사용할 값
  • 명령 처리 전후 상태를 비교해야 하는 스냅숏

반대로 매우 큰 행렬을 매 연산마다 통째로 복사하거나, 동일 실체의 상태가 지속해서 변하는 집계 루트라면 가변 내부 상태와 통제된 변경 메서드가 현실적일 수 있습니다.

외부에는 불변 조회 모델을 제공하면서 내부만 가변으로 유지하는 혼합도 가능합니다.


연습 문제

title, targetCount, publishedCount를 가진 PublishQuota를 만드세요.

세 값은 생성 후 바뀌지 않으며 공개 수는 0 이상 목표 이하이어야 합니다.

진행률을 정수 백분율로 계산합니다.

작은 입력 100·40으로 시작하되, 목표 1500000000·공개 1000000000에서도 계산해 보세요. 생성자 조건을 통과하는 두 값인데 작은 예제의 곱셈식을 그대로 쓰면 왜 다른 결과가 나올까요? 곱셈 전에 자료형을 바꾸어 0..100 범위의 정수 백분율을 구하세요.

해설 보기
src/ImmutablePublishQuotaExercise.java
public final class ImmutablePublishQuotaExercise {
    public static void main(String[] args) {
        PublishQuota goal = new PublishQuota("object", 100, 40);
        System.out.println(goal.title() + "=" + goal.progressPercent() + "%");
    }

    private static final class PublishQuota {
        private final String title;
        private final int targetCount;
        private final int publishedCount;

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

        String title() { return title; }
        int progressPercent() { return publishedCount * 100 / targetCount; }
    }
}
object=40%

생성자는 공개 수가 0 이상 목표 이하인지 검증합니다. 다만 진행률의 publishedCount * 100은 int 곱셈이므로 큰 입력에서는 오버플로할 수 있습니다. 이 예제의 입력 100과 40에서는 40%를 출력합니다.

예시

목표 1500000000·공개 1000000000이면 약 66.67%이고, 소수 부분을 버린 정수 결과는 66입니다. 원래 int 식은 곱셈에서 이미 넘쳐 0을 반환합니다. 결과를 나중에 long으로 바꾸어도 앞서 잃은 값은 복구되지 않습니다. 위 PublishQuota 클래스 안에 다음 메서드를 추가하면 곱셈부터 long으로 계산합니다.

PublishQuota 내부의 큰 입력 계산
int progressPercentSafe() {
    return (int) ((long) publishedCount * 100 / targetCount);
}

main에서 같은 goal의 progressPercentSafe를 호출하면 100·40은 여전히 40이고, 큰 입력은 66입니다. 목표와 공개 수를 모두 Integer.MAX_VALUE로 두면 안전한 결과는 100입니다. 공개 수가 목표 이하라는 생성자 조건 덕분에 계산 결과는 0..100이므로 마지막 int 변환도 그 범위 안에 있습니다. 이 방법은 반올림이 아니라 양수 정수 나눗셈의 소수 부분 버림입니다.