불변 객체와 안전한 공유
setter를 없애고 필드를 final로 두는 것에서 시작해 생성자 검증과 배열 복사까지, 여러 곳에서 공유해도 안전한 불변 객체를 만드는 방법을 배웁니다.
앞 글에서는 두 객체가 가변 게시글 하나를 함께 들고 있을 때, 한쪽에서 조회 수를 고치자 다른 쪽이 읽는 값까지 바뀌는 문제를 보았습니다. 해결 방법은 세 가지였습니다. 복사해서 넘기거나, 변경을 한 객체에만 맡기거나, 객체를 아예 바꿀 수 없게 만드는 것입니다. 이 글은 세 번째 방법을 다룹니다.
불변 객체(immutable object)는 생성이 끝난 뒤에는 상태가 바뀌지 않는 객체입니다. 같은 객체를 변수 여러 개가 가리키더라도 누구도 그 값을 고칠 수 없으니, 한쪽에서 한 일이 다른 쪽에 영향을 줄 일도 없습니다. 공유를 막는 대신 공유해도 문제가 없게 만드는 셈입니다. 이 글에서는 게시글 클래스 Post를 불변으로 만들면서 필요한 장치를 하나씩 갖춥니다.
setter를 없애면 밖에서 바꿀 방법이 사라집니다
불변 클래스를 만드는 첫 단계는 상태를 바꾸는 메서드를 두지 않는 것입니다. 아래 Post는 제목과 조회 수를 private final 필드에 담고 생성자에서 한 번만 채웁니다. 값을 바꾸는 setViewCount 같은 메서드는 없습니다. main은 가변 객체를 쓰던 습관대로 setViewCount를 호출해 봅니다.
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;
}
}
}이 파일은 컴파일되지 않습니다. javac lab/ImmutableEntryMutationFailure.java를 실행하면 다음 오류가 납니다.
lab/ImmutableEntryMutationFailure.java:4: error: cannot find symbol
entry.setViewCount(90);
^
symbol: method setViewCount(int)
location: variable entry of type Post
1 errorcannot find symbol은 그런 이름을 찾지 못했다는 뜻이고, 아래 두 줄은 Post 타입 변수 entry에서 setViewCount(int) 메서드를 찾지 못했다고 알려 줍니다. setter를 private으로 숨긴 것이 아니라 아예 만들지 않았기 때문에, 클래스 밖의 코드에는 필드를 바꿀 길이 처음부터 없습니다. 실수로 호출하더라도 프로그램을 실행하기 전에 컴파일러가 막아 줍니다.
final은 클래스 안의 실수까지 막습니다
setter가 없어도 클래스 안의 메서드는 여전히 private 필드에 손댈 수 있습니다. 나중에 누군가 Post에 조회 수를 1 늘리는 메서드를 추가하면, 그 메서드가 필드를 바꿔 버릴 수 있습니다. 이것을 막는 것이 필드에 붙인 final입니다. 앞 예제에서 entry.setViewCount(90); 줄을 지우고, Post의 생성자 아래에 다음 메서드를 넣은 뒤 다시 컴파일해 보세요.
void addView() {
viewCount++;
}lab/ImmutableEntryMutationFailure.java:16: error: cannot assign a value to final variable viewCount
viewCount++;
^
1 errorfinal 필드는 생성자에서 한 번 값을 받은 뒤로는 다시 대입할 수 없습니다. setter를 두지 않는 것이 클래스 밖에서 오는 변경을 막는다면, final은 클래스 안에서 생기는 변경을 막습니다. 반대로 생성자가 끝날 때까지 final 필드에 값을 넣지 않아도 컴파일 오류가 나므로, 값이 빠진 채로 객체가 만들어질 걱정도 없습니다.
다만 final이 고정하는 것은 필드에 든 값뿐입니다. 필드가 배열처럼 내용을 바꿀 수 있는 객체를 가리키면 이야기가 달라지는데, 이 문제는 뒤에서 따로 다룹니다. 그래서 불변성은 final 키워드 하나로 얻는 성질이 아니라, 상태를 바꿀 수 있는 길을 모두 닫는 설계로 얻는 성질입니다.
불변 객체는 복사하지 않고 공유해도 됩니다
이제 불변 Post 하나를 두 기능이 함께 쓰게 해 보겠습니다. 일일 보고서 DailyReport와 검토 대기열 ReviewQueue가 같은 게시글 객체를 받습니다. 이번에는 클래스 대신 레코드(record)로 썼습니다. 레코드의 구성 요소는 private final 필드가 되고 setter는 만들어지지 않으므로, 이런 작은 불변 객체를 짧게 쓰기에 알맞습니다.
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=truePost 레코드 안의 Post { ... }는 컴팩트 생성자(compact constructor)입니다. 매개변수 목록을 다시 쓰지 않는 생성자로, 본문이 끝난 뒤에 필드 대입이 자동으로 일어납니다. 그래서 여기에 둔 검사를 통과해야만 필드에 값이 들어가고 객체가 완성됩니다.
출력의 첫 두 줄은 보고서와 대기열이 같은 게시글을 각자의 방식으로 읽은 결과이고, 세 번째 줄은 두 기능이 정말로 같은 인스턴스를 들고 있음을 보여 줍니다. 앞 글의 가변 게시글이었다면 same=true는 위험 신호였습니다. 한쪽이 조회 수를 바꾸면 다른 쪽 결과도 함께 바뀌기 때문입니다. 하지만 이 Post에는 값을 바꾸는 메서드가 없으므로, 같은 객체를 공유해도 두 기능은 언제나 처음 만들어진 값을 봅니다.
복사할 필요가 없다는 것은 실제 이득입니다. 넘길 때마다 새 객체를 만들지 않으니 메모리와 생성 비용이 줄고, '이 객체를 그냥 넘겨도 될까, 복사해서 넘겨야 할까'를 따질 일도 사라집니다. 자바 표준 라이브러리의 String, 날짜를 나타내는 LocalDate, 금액 계산에 쓰는 BigDecimal이 모두 불변으로 설계된 것도 이 때문입니다. 이런 값은 프로그램 곳곳에서 주고받는데, 주고받을 때마다 복사하거나 누가 바꾸지 않을지 걱정해야 한다면 쓰기가 무척 번거로울 것입니다.
생성자에서 모든 규칙을 검사합니다
불변 객체는 만든 뒤에 고칠 수 없습니다. 잘못된 값으로 만들어지면 그 상태가 끝까지 남는다는 뜻이므로, 값이 올바른지는 생성자에서 전부 확인해야 합니다. 아래 PostMetric은 제목이 비어 있지 않은지와 조회 수가 음수가 아닌지를 먼저 검사하고, 둘 다 통과한 뒤에야 필드를 채웁니다.
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첫 번째 객체는 두 검사를 모두 통과해 정상적으로 만들어졌고, description()이 string:45:true를 돌려줍니다. 두 번째 호출은 제목이 공백 하나뿐이라 첫 번째 검사에서 IllegalArgumentException이 던져집니다. 조회 수 -1도 잘못된 값이지만, 예외가 던져지는 순간 생성자 실행이 멈추므로 조회 수 검사까지는 가지 않습니다. 그래서 메시지가 viewCount가 아니라 title입니다. 예외로 끝난 생성자 호출은 객체를 돌려주지 않으므로, 잘못된 PostMetric을 가리키는 변수는 어디에도 생기지 않습니다.
'제목은 비어 있지 않다'처럼 객체가 살아 있는 동안 늘 참이어야 하는 조건을 클래스 불변식(class invariant)이라고 합니다. 이름이 비슷하지만 불변식은 '언제나 성립하는 조건'이고 불변 객체는 '상태가 바뀌지 않는 객체'로, 서로 다른 개념입니다. 그런데 둘이 만나면 좋은 일이 생깁니다. 객체가 만들어진 순간 불변식이 성립하고 그 뒤로 상태가 바뀌지 않으니, 불변식은 객체가 사라질 때까지 깨질 수 없습니다. description() 같은 메서드가 쓸 때마다 제목과 조회 수를 다시 검사할 필요가 없는 이유입니다.
setter로 값을 채우는 가변 객체와 비교하면 차이가 분명해집니다. new Post()로 빈 객체를 만든 뒤 setTitle, setViewCount를 차례로 호출하는 방식에서는 호출을 마치기 전까지 제목 없는 객체가 잠시 존재하고, 누군가 호출 하나를 빠뜨리면 그 상태 그대로 쓰입니다. 불변 객체는 모든 값을 생성자에서 한꺼번에 받으므로 이런 '덜 만들어진' 순간이 없습니다.
final 필드가 배열을 가리킬 때
final이 고정하는 것은 필드에 든 참조값이지, 그 참조가 가리키는 배열의 칸이 아닙니다. final 참조와 객체 변경에서 본 내용입니다. 그래서 불변 클래스가 배열 필드를 가지면 바깥 코드가 그 배열에 손댈 수 있는 길이 두 개 생깁니다. 하나는 생성자에 넘긴 배열을 호출자가 계속 들고 있다가 고치는 길이고, 다른 하나는 조회 메서드가 돌려준 배열을 받은 쪽이 고치는 길입니다.
아래 BoardPlan은 두 길을 모두 막습니다. 생성자는 받은 배열을 clone()으로 복사해 보관하고, titles()도 내부 배열 대신 복사본을 돌려줍니다. main은 두 길로 값을 바꿔 본 뒤 다시 조회합니다.
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,immutablesource[0]으로 바꾼 것은 호출자의 배열이고, returned[1]로 바꾼 것은 첫 조회에서 받은 복사본입니다. 둘 다 plan 안의 배열과는 다른 배열이므로, 다시 조회한 safe에는 처음 값 object,immutable이 그대로 남아 있습니다. 비교를 위해 생성자와 titles()에서 clone()을 빼고 실행하면 출력이 changed-source,changed-result로 바뀝니다. 그때는 source, 내부 필드, returned, safe가 모두 같은 배열 하나를 가리키므로 두 번의 변경이 모두 내부 배열에 들어가기 때문입니다.
아래 단계별 그림은 이 프로그램이 만드는 배열 네 개와, 각 대입이 어느 배열을 바꾸는지 보여 줍니다.
입력과 반환 배열의 복사 경계
source와 returned의 서로 다른 슬롯을 바꿔도 내부 object/immutable과 새 safe 조회가 보존됩니다. 입력 복사·반환 복사·외부 변경의 인과를 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만으로 배열 슬롯의 변경을 막는 것은 아닙니다.
이 예의 조건
- 배열 슬롯 복사이며 불변 String 요소를 깊은 복사하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | DeepImmutableBoardPlan의 입력 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"를 대입합니다. |
|
|
| 첫 조회 복사 returned는 새 배열이며 1번을 changed-result로 바꿉니다. |
|
|
| 다음 조회 safe가 새 배열로 내부 값을 다시 받습니다. |
|
|
입력과 반환 배열의 복사 경계
source와 returned의 서로 다른 슬롯을 바꿔도 내부 object/immutable과 새 safe 조회가 보존됩니다. 입력 복사·반환 복사·외부 변경의 인과를 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만으로 배열 슬롯의 변경을 막는 것은 아닙니다.
이 예의 조건
- 배열 슬롯 복사이며 불변 String 요소를 깊은 복사하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | DeepImmutableBoardPlan의 입력 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"를 대입합니다. |
|
|
| 첫 조회 복사 returned는 새 배열이며 1번을 changed-result로 바꿉니다. |
|
|
| 다음 조회 safe가 새 배열로 내부 값을 다시 받습니다. |
|
|
배열의 clone()은 칸에 든 참조값만 복사하는 얕은 복사(shallow copy)라는 점도 기억해 두세요. 이 예제에서는 칸에 든 것이 불변인 String이라서 복사본끼리 같은 문자열 객체를 공유해도 문제가 없습니다. 하지만 칸에 가변 객체가 들어 있다면, 배열을 복사해도 그 객체는 여전히 공유되고 누군가 그 상태를 바꿀 수 있습니다. 담고 있는 객체까지 모두 바뀌지 않게 하려면 요소를 불변 타입으로 고르거나, 생성할 때 요소까지 하나씩 복사해야 합니다.
불변 클래스를 만드는 규칙
지금까지 본 장치를 모으면 불변 클래스를 만드는 규칙이 됩니다.
- 상태를 바꾸는 메서드(setter 등)를 두지 않습니다.
- 모든 필드를
private final로 선언합니다. - 생성자에서 모든 값을 검사한 뒤 필드를 채웁니다.
- 배열처럼 가변인 객체는 받을 때와 내보낼 때 복사합니다.
- 클래스를
final로 선언해 상속을 막습니다.
마지막 규칙은 아직 설명하지 않았습니다. 상속이 열려 있으면 누군가 Post를 상속해 가변 필드와 setter를 추가한 하위 클래스를 만들 수 있습니다. 그러면 Post 타입 변수가 가리키는 객체가 실제로는 바뀌는 객체일 수 있어서, Post는 불변이라는 믿음이 깨집니다. 이 글의 클래스 예제가 모두 final class로 선언된 이유입니다. 레코드는 원래 상속할 수 없으므로 final을 따로 붙이지 않아도 됩니다. 표준 라이브러리에도 이 규칙을 지키지 않은 예가 있습니다. BigDecimal과 BigInteger는 불변으로 설계되었지만 final 클래스가 아니라서, 그 타입으로 받은 객체가 실제로는 값을 바꿀 수 있는 하위 클래스일 가능성을 완전히 배제할 수 없습니다.
어떤 객체를 불변으로 만들까
모든 클래스를 불변으로 만들 수는 있지만, 그것이 언제나 최선은 아닙니다. 불변 객체가 특히 잘 맞는 것은 어느 한 시점의 값을 나타내면서 여러 곳에서 함께 읽는 객체입니다.
- 게시글 스냅샷, 기간, 날짜, 설정처럼 값 자체가 의미인 타입
- 동등성과 해시 값이 바뀌면 안 되는 조회용 키
- 여러 스레드나 여러 기능이 읽기만 하는 데이터
- 한 번 만들어 두고 반복해서 쓰는 값
- 명령을 처리하기 전과 후를 비교해야 하는 상태
반대로 상태가 자주, 크게 바뀌는 객체는 불변으로 만들면 오히려 불편합니다. 아주 큰 행렬을 연산할 때마다 통째로 새로 만들면 복사 비용이 커집니다. 회원이나 주문처럼 하나의 실체가 수명 내내 상태를 바꿔 가는 객체도, 바뀔 때마다 새 객체로 갈아 끼우기보다 상태를 직접 바꾸되 변경 메서드를 한곳으로 모아 통제하는 편이 자연스럽습니다. 두 방식을 섞을 수도 있습니다. 앞 글에서 본 것처럼 안에서는 가변 객체로 상태를 관리하고, 밖으로는 불변 조회 결과만 내보내는 방식입니다.
더 깊이: 불변 객체가 스레드 사이에서 안전한 이유
여러 스레드가 같은 가변 객체를 읽고 쓰면, 한 스레드가 바꾼 값을 다른 스레드가 언제 보게 되는지가 문제가 됩니다. 이 문제는 19장에서 자세히 다룹니다. 불변 객체에는 쓰기 자체가 없으므로 이런 문제의 상당 부분이 처음부터 생기지 않습니다. 여기에 더해 Java 언어 명세(JLS 17.5)는 final 필드에 특별한 규칙을 둡니다. 생성자가 끝난 뒤에야 객체의 참조를 얻은 스레드는, 동기화를 하지 않아도 그 객체의 final 필드가 올바르게 초기화된 값을 본다는 것입니다.
이 규칙에는 전제가 있습니다. 생성자가 실행되는 도중에 this를 다른 스레드가 볼 수 있는 곳에 넘기면, 그 스레드가 덜 만들어진 객체를 볼 수 있으므로 위 규칙이 적용되지 않습니다. 그래서 여러 스레드가 함께 쓸 불변 클래스는 필드를 모두 final로 두고, 생성자 안에서 this를 밖으로 내보내지 않아야 합니다. 필드를 final로 두는 것은 실수를 막는 장치이면서 동시에 이런 보장을 얻는 조건이기도 합니다.
연습 문제
- 제목
title, 목표 수targetCount, 공개 수publishedCount를 가진 불변 클래스PublishQuota를 만드세요. 세 값은 생성 후 바뀌지 않아야 하고, 목표 수는 1 이상, 공개 수는 0 이상 목표 수 이하여야 합니다. 진행률을 소수 부분을 버린 정수 백분율로 돌려주는progressPercent()도 만드세요. 목표 100과 공개 40으로 확인한 뒤, 목표 1500000000과 공개 1000000000에서도 계산해 보세요. 두 입력 모두 생성자 검사를 통과하는데, 작은 입력에서 맞던 식이 큰 입력에서는 왜 틀린 값을 낼까요? - 다음 클래스는 필드가 모두
private final이고 setter도 없습니다. 그런데도 불변이 아닌 이유를 설명하고, 불변이 되도록 고치세요.
public final class Scoreboard {
private final String name;
private final int[] scores;
public Scoreboard(String name, int[] scores) {
this.name = name;
this.scores = scores;
}
public String name() { return name; }
public int[] scores() { return scores; }
}해설 보기
1번. 작은 입력에서 떠올리기 쉬운 publishedCount * 100 / targetCount와, 곱하기 전에 long으로 바꾸는 식을 나란히 두고 비교해 봅니다.
public final class ImmutablePublishQuotaExercise {
public static void main(String[] args) {
PublishQuota small = new PublishQuota("small", 100, 40);
PublishQuota large = new PublishQuota("large", 1500000000, 1000000000);
System.out.println(small.title() + ": int=" + small.progressPercentInt()
+ "%, long=" + small.progressPercent() + "%");
System.out.println(large.title() + ": int=" + large.progressPercentInt()
+ "%, long=" + large.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 progressPercentInt() {
return publishedCount * 100 / targetCount;
}
int progressPercent() {
return (int) ((long) publishedCount * 100 / targetCount);
}
}
}small: int=40%, long=40%
large: int=0%, long=66%작은 입력에서는 두 식의 결과가 같습니다. 큰 입력에서 int 식이 0을 내는 이유는 곱셈에 있습니다. 1000000000에 100을 곱한 1000억은 int의 최댓값 2147483647을 넘으므로 값이 넘쳐 1215752192가 되고, 이것을 1500000000으로 나누면 0입니다. (long) publishedCount처럼 곱하기 전에 한쪽을 long으로 바꾸면 곱셈 전체가 long으로 계산되어 약 66.67%의 정수 부분인 66을 얻습니다. (long) (publishedCount * 100)처럼 곱한 뒤에 바꾸면 이미 넘친 값을 바꾸는 것이라 소용이 없습니다.
마지막의 (int) 변환이 안전한 이유는 생성자 검사에 있습니다. 공개 수가 0 이상 목표 수 이하라는 불변식 덕분에 결과는 언제나 0 이상 100 이하이므로 int에 그대로 들어갑니다. 목표 수와 공개 수를 모두 Integer.MAX_VALUE로 두어도 결과는 100입니다. 정수 나눗셈은 반올림하지 않고 소수 부분을 버린다는 점도 기억해 두세요.
2번. final은 scores 필드가 다른 배열을 가리키지 못하게 할 뿐, 배열의 칸을 바꾸는 것은 막지 않습니다. 이 클래스에는 바깥에서 배열을 바꿀 길이 두 개 있습니다. 생성자에 넘긴 배열을 호출자가 나중에 고치는 길과, scores()가 돌려준 내부 배열을 받은 쪽이 고치는 길입니다. 생성자에서 받은 배열을 복사해 보관하고, scores()도 복사본을 돌려주면 두 길이 모두 막힙니다.
public final class ScoreboardExercise {
public static void main(String[] args) {
int[] source = {90, 80};
Scoreboard board = new Scoreboard("quiz", source);
source[0] = 0;
board.scores()[1] = 0;
int[] scores = board.scores();
System.out.println(board.name() + "=" + scores[0] + "," + scores[1]);
}
private static final class Scoreboard {
private final String name;
private final int[] scores;
Scoreboard(String name, int[] scores) {
this.name = name;
this.scores = scores.clone();
}
String name() { return name; }
int[] scores() { return scores.clone(); }
}
}quiz=90,80두 clone()을 빼고 실행하면 두 변경이 모두 내부 배열에 들어가 quiz=0,0이 출력됩니다. int는 기본형이라서 이 배열은 칸까지 복사하면 완전히 분리됩니다.
다음 글
불변 객체도 값이 달라져야 할 때가 있습니다. 조회 수는 늘어나고 게시글은 공개됩니다. 다음 글에서는 기존 객체를 고치는 대신, 바뀐 값을 담은 새 객체를 돌려주는 메서드로 이런 변화를 표현하는 방법을 배웁니다.