본문으로 건너뛰기

안동민 개발노트

본문 시작

불변 객체와 안전한 공유

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

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

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

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

가변 객체와 불변 객체는 공유 후 가능한 행동이 다르다

여러 참조가 같은 객체를 보더라도 상태 변경 API가 열려 있는지에 따라 공유 위험이 달라집니다.

  1. 가변 객체

    setter·변경 명령 · 공유 후 관찰값 변동

  2. 불변 객체

    생성 후 상태 고정 · 복사 없이 안정된 스냅숏 공유


불변 객체의 수정 제한

불변 클래스는 상태를 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, 날짜·시간 값, 금액처럼 값 의미가 강한 타입이 불변으로 설계되는 이유입니다.

불변 객체는 생성 시점에 모든 규칙을 완성한다

성공한 생성자는 모든 필드를 검증·확정하고 이후에는 읽기 계약만 열어 유효 상태를 계속 유지합니다.

  1. 생성 인수

    title·viewCount·published

  2. 검증

    null·조회 범위

  3. final 필드

    한 번 초기화

  4. 읽기 메서드

    상태 관찰

  5. 공유

    수정 통로 없음


생성 시점의 유효성 완성

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

유효하지 않은 객체를 잠깐 만들었다가 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

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

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

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

배열의 방어적 복사는 입력과 출력 두 경계를 모두 닫는다

final 배열 필드도 같은 참조를 받거나 반환하면 외부 원소 대입이 내부 상태에 닿습니다.

  1. 호출자 배열

    계속 변경 가능

  2. 생성자 입력 복사

    titles.clone()

  3. 내부 배열

    BoardPlan 단독 소유

  4. 조회 복사

    다시 clone

  5. 외부 결과

    내부와 참조 분리


불변 객체의 적용 대상

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

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

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

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

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

불변 설계 적합성을 값 의미와 변경 빈도로 판단한다

한 시점 값과 안정적 해시가 중요한 대상은 불변에 잘 맞고 대형 편집 버퍼는 복사 비용을 따져야 합니다.

대상불변 적합성이유
날짜·금액·기록높음값의 한 시점
Map 키필수에 가까움해시 안정
대형 편집 버퍼낮을 수 있음복사 비용
가변 집계외부 뷰만 불변통제된 변경

연습 문제

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

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

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

해설 보기
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%

목표와 공개의 관계를 생성자에서 검증했으므로 진행률 메서드는 불가능한 음수나 100% 초과 상태를 고려하지 않아도 됩니다.