불변 객체와 안전한 공유
변경 메서드를 제거한 불변 Post를 설계하고 생성 검증·final 필드·공유 안전성의 역할을 실제 코드로 확인합니다.
객체 공유를 막기 어렵다면 공유된 객체가 변하지 않게 만들 수 있습니다.
불변 객체는 생성이 끝난 뒤 관찰 가능한 상태가 바뀌지 않는 객체입니다.
여러 변수가 같은 인스턴스를 가리켜도 누구도 기존 값을 수정할 수 없으므로 한 경로의 작업이 다른 경로에 부수 효과를 만들지 않습니다.
불변 객체의 수정 제한
불변 클래스는 상태를 private final 필드에 보관하고 생성자에서 모두 초기화합니다.
다음 실패 예제는 호출자가 예전 가변 API처럼 setter를 사용하려 할 때 컴파일 단계에서 차단되는 모습을 보여 줍니다.
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가 없으므로 두 기능은 안정된 스냅숏을 봅니다.
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 순서로 완성하는 방식을 허용하지 않습니다.
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이어도 그 필드가 가변 배열이나 목록을 가리키면 내부 요소는 바뀔 수 있습니다.
생성자 인수를 그대로 보관하거나 내부 배열을 그대로 반환하면 외부 참조가 상태를 수정합니다.
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생성 시 입력 배열을 복사하고 조회 시에도 새 배열을 반환하므로 바깥의 원소 대입이 내부 배열에 도달하지 않습니다.
그러나 배열 요소가 가변 객체라면 요소 상태는 여전히 바뀔 수 있습니다.
깊은 불변성을 원하면 요소도 불변 타입이어야 하거나 생성 시 각각 복사해야 합니다.
불변 객체의 적용 대상
모든 객체를 불변으로 만들 수는 있지만 항상 최선은 아닙니다.
값의 한 시점을 표현하고 여러 곳에서 안전하게 공유해야 하는 객체에 특히 잘 맞습니다.
- 게시글 항목, 기간, 날짜, 설정처럼 값 자체가 의미인 타입
- 동등성과 해시가 안정되어야 하는 조회 키 타입
- 여러 스레드나 서비스가 읽지만 수정 조정은 필요 없는 데이터
- 캐시해 두고 반복 재사용할 값
- 명령 처리 전후 상태를 비교해야 하는 스냅숏
반대로 매우 큰 행렬을 매 연산마다 통째로 복사하거나, 동일 실체의 상태가 지속해서 변하는 집계 루트라면 가변 내부 상태와 통제된 변경 메서드가 현실적일 수 있습니다.
외부에는 불변 조회 모델을 제공하면서 내부만 가변으로 유지하는 혼합도 가능합니다.
연습 문제
title, targetCount, publishedCount를 가진 PublishQuota를 만드세요.
세 값은 생성 후 바뀌지 않으며 공개 수는 0 이상 목표 이하이어야 합니다.
진행률을 정수 백분율로 계산합니다.
해설 보기
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% 초과 상태를 고려하지 않아도 됩니다.