final 참조와 객체 변경
final 참조 변수는 다른 객체를 가리킬 수 없을 뿐 가리키는 객체의 내용은 바뀔 수 있다는 점을 이해하고, 방어적 복사와 불변 객체로 객체의 내부 상태를 지키는 방법을 배웁니다.
앞 글의 마지막 예제에서 MemberRegistry의 members 필드는 final이었습니다. 그런데도 add를 호출할 때마다 배열의 칸이 null에서 회원으로 바뀌었습니다. final을 '값이 바뀌지 않는다'로만 기억하면 이 결과가 이상해 보입니다. 하지만 final이 고정하는 것은 변수에 든 값이고, 참조형 변수에 든 값은 객체 자체가 아니라 객체를 가리키는 참조값입니다.
그래서 참조형 변수에 final을 붙이면 그 변수가 다른 객체를 가리키게 할 수는 없지만, 지금 가리키는 객체의 내용이 바뀌는 것은 막지 못합니다. 흔히 'final 객체'라는 말을 쓰지만 이 표현은 두 질문을 하나로 섞어 버립니다. 변수가 다른 객체를 가리키게 될 수 있는지와, 객체의 내용이 바뀔 수 있는지입니다. 이 글에서는 두 질문을 차례로 확인한 뒤, private final 필드만으로는 객체 내부를 지킬 수 없는 경우를 방어적 복사와 불변 객체로 해결합니다.
final 참조 변수에 다른 객체를 대입하면
먼저 final 참조 변수에 새 객체를 대입해 보겠습니다. 다음 코드는 StringBuilder 객체를 가리키는 final 변수 summary에, 새로 만든 다른 StringBuilder를 대입하려고 합니다. StringBuilder는 만든 뒤에도 문자열을 덧붙이거나 고칠 수 있는 객체이고, 자세한 사용법은 문자열 서식과 StringBuilder에서 다룹니다.
public final class FinalReferenceReassignment {
public static void main(String[] args) {
final StringBuilder summary = new StringBuilder("count=1");
summary = new StringBuilder("count=2");
System.out.println(summary);
}
}lab/FinalReferenceReassignment.java:4: error: cannot assign a value to final variable summary
summary = new StringBuilder("count=2");
^
1 error앞 글에서 final int 변수에 다시 대입했을 때와 같은 오류입니다. 대입은 변수에 든 값을 바꾸는 일이고, 참조형 변수에 든 값은 참조값입니다. 그러니 다른 객체를 대입하는 것은 summary에 든 참조값을 다른 참조값으로 바꾸려는 시도이고, final은 기본형 변수에서와 똑같이 이 두 번째 대입을 거부합니다. 매개변수나 향상된 for 문의 반복 변수에 붙인 final도 같은 방식으로, 다른 값을 대입하는 것만 막습니다.
가리키는 객체의 내용은 바꿀 수 있다
이번에는 대입하는 대신 summary가 가리키는 객체의 메서드를 호출해 보겠습니다. append는 StringBuilder 객체의 끝에 문자열을 덧붙이는 메서드입니다.
final StringBuilder summary = new StringBuilder("count=1");
summary.append(", total=40");
System.out.println(summary);count=1, total=40이 코드는 컴파일도 되고 실행도 됩니다. summary는 처음부터 끝까지 같은 StringBuilder 객체를 가리키고, 바뀐 것은 그 객체 안의 문자들뿐이기 때문입니다.
JVM 메모리 구조: 스택, 힙, 메서드 영역의 모델로 보면 두 코드의 차이가 분명해집니다. 지역 변수 summary는 main 호출의 스택 프레임 안에 있고, 거기에 든 것은 객체를 가리키는 참조값입니다. StringBuilder 객체 자체는 new로 만들어져 힙에 있습니다. final은 프레임 안의 summary 칸에 붙은 규칙이므로 그 칸에 다른 참조값을 넣는 것만 막습니다. 반면 append는 참조값을 따라가 힙에 있는 객체를 바꿀 뿐, summary 칸은 건드리지 않습니다.
| 대상 | 있는 곳 | final summary가 막는가 |
|---|---|---|
summary 변수에 든 참조값 | main의 스택 프레임 | 막는다. 다른 참조값을 대입할 수 없다 |
StringBuilder 객체의 문자 내용 | 힙 | 막지 않는다 |
필드도 마찬가지입니다. 앞 글의 private final Member[] members에서 members 칸은 힙에 있는 MemberRegistry 객체 안에 있고, 그 칸이 가리키는 배열은 힙에 있는 또 하나의 객체입니다. 필드의 final이 고정하는 것은 MemberRegistry 객체 안의 그 칸이지, 배열 객체가 아닙니다.
이 설명은 JVM 명세가 정한 개념 모델을 따른 것입니다. 명세는 스레드마다 스택이 있고 그 안에 메서드 호출마다 프레임이 생기며, 모든 객체와 배열은 힙에 할당된다고 설명합니다. 반면 HotSpot 같은 실제 JVM은 실행 속도를 높이려고 지역 변수를 CPU 레지스터에 두거나, 메서드 밖으로 나가지 않는 객체는 힙에 만들지 않도록 최적화하기도 합니다. 이런 최적화는 HotSpot의 구현 방식이고 프로그램의 실행 결과를 바꾸지 않으므로, final이 무엇을 막는지 판단할 때는 명세의 모델만 생각하면 됩니다.
final 배열의 원소 바꾸기
배열도 객체이므로 같은 규칙이 적용됩니다. 다음 프로그램은 final 배열 변수 age가 가리키는 배열의 0번 칸을 바꿉니다.
import java.util.Arrays;
public final class FinalArrayMutation {
public static void main(String[] args) {
final int[] age = {40, 50};
age[0] = 60;
System.out.println(Arrays.toString(age));
}
}[60, 50]age[0] = 60은 age 변수에 대입하는 것이 아니라, age가 가리키는 배열의 0번 칸에 대입하는 것입니다. 그래서 final과 관계없이 실행되고, 출력은 바뀐 배열 [60, 50]입니다. 반대로 age = new int[] {60, 50};처럼 변수 자체에 새 배열을 대입하면 cannot assign a value to final variable age 오류가 납니다.
앞 글의 MemberRegistry도 같은 원리로 동작합니다. private final Member[] members는 '이 저장소는 생성자에서 만든 배열 하나만 계속 쓴다'는 선언일 뿐이어서, add가 배열의 칸을 채우는 것을 막지 않습니다. 캡슐화로 회원 저장소 완성하기의 removeAt이 칸의 참조를 앞으로 당기고 마지막 칸을 비우는 것도 마찬가지입니다. 저장소가 동작하려면 칸은 바뀌어야 하지만 배열을 통째로 바꿀 일은 없으니, 필드의 final은 정확히 그 의도를 나타냅니다.
객체가 바뀌는지는 객체의 설계가 정한다
StringBuilder나 배열처럼 만든 뒤에도 내용을 바꿀 수 있는 객체를 가변(mutable) 객체라고 합니다. 직접 만든 클래스도 상태를 바꾸는 메서드가 있으면 가변 객체가 됩니다. 다음 SignupProgress는 목표 인원과 누적 등록 수를 가지며, register를 호출할 때마다 등록 수가 늘어납니다.
public final class FinalMutableObject {
public static void main(String[] args) {
final SignupProgress progress = new SignupProgress(100);
progress.register(40);
progress.register(30);
System.out.println(progress.describe());
}
private static final class SignupProgress {
private final int targetCount;
private int registeredCount;
SignupProgress(int targetCount) {
this.targetCount = targetCount;
}
void register(int count) {
registeredCount += count;
}
String describe() {
return registeredCount + "/" + targetCount;
}
}
}70/100progress 변수는 final이지만, register(40)과 register(30)이 같은 객체의 registeredCount를 0에서 40으로, 다시 70으로 바꿉니다. 객체 안을 보면 targetCount는 final 필드라서 100으로 고정되어 있고, registeredCount는 일반 필드라서 바뀔 수 있습니다. 이처럼 객체가 바뀌는지는 그 객체를 가리키는 변수에 final을 붙였는지로 정해지지 않습니다. 객체의 필드와 메서드가 상태를 바꿀 수 있게 만들어져 있는지로 정해집니다.
반대로 만든 뒤에는 내용을 바꿀 방법이 없는 객체를 불변(immutable) 객체라고 합니다. 대표적인 예가 String입니다. String의 toUpperCase 같은 메서드는 원래 문자열을 고치지 않고, 결과를 담은 새 String을 만들어 돌려줍니다.
String name = "kim";
name.toUpperCase();
System.out.println(name);
name = name.toUpperCase();
System.out.println(name);kim
KIM둘째 줄은 대문자로 바꾼 새 문자열을 만들지만 그 결과를 아무 데도 저장하지 않으므로, 첫 출력은 여전히 kim입니다. 넷째 줄은 새 문자열의 참조값을 name에 대입하므로 두 번째 출력은 KIM입니다. 이때도 원래의 "kim" 객체가 바뀐 것은 아니고, name이 다른 객체를 가리키게 되었을 뿐입니다. 만약 name을 final로 선언했다면 넷째 줄에서 컴파일 오류가 납니다.
결국 '변수에 다른 객체를 대입할 수 있는가'와 '객체의 내용을 바꿀 수 있는가'는 서로 독립된 질문입니다. 앞의 것은 변수의 final이 정하고, 뒤의 것은 객체의 설계가 정합니다. 두 질문을 조합하면 다음 네 경우가 나옵니다.
| 변수 선언 | 가변 객체 (StringBuilder) | 불변 객체 (String) |
|---|---|---|
final 변수 | 다른 객체 대입 불가, 내용 변경 가능 | 다른 객체 대입 불가, 내용 변경 불가 |
| 일반 변수 | 다른 객체 대입 가능, 내용 변경 가능 | 다른 객체 대입 가능, 내용 변경 불가 |
아래 그림은 지금까지 본 네 예제를 한곳에 모은 것입니다. 각 줄은 한 프로그램에서 차례로 실행되는 단계가 아니라 서로 독립된 예제입니다. 다른 객체를 대입하는 첫 줄만 컴파일에 실패하고, 같은 객체의 내용을 바꾸는 나머지 세 줄은 모두 허용됩니다.
서로 다른 네 예제에서 final 참조의 재대입은 컴파일 실패하지만 StringBuilder 내용, 배열 원소, SignupProgress 등록 수의 변경은 허용됩니다.
참조 재대입과 객체 내부 값 변경을 구분합니다
각 행은 별도 예제입니다. 한 프로그램에서 차례로 실행되는 단계가 아닙니다.
| 연산 | 바꾸려는 것 | 판정과 관찰 |
|---|---|---|
| 다른 객체로 재대입 | summary 변수의 참조값 | 컴파일 실패
|
| 문자열 빌더 수정 | 같은 StringBuilder 객체의 문자 내용 | 허용 별도 |
| 배열 원소 대입 | 같은 int[]의 0번 원소 | 허용
|
| 회원 등록 수 누적 | 같은 SignupProgress의 registeredCount | 허용
|
- 다른 객체로 재대입 · 컴파일 실패
FinalReferenceReassignment는summary의 참조값을 다시 바꾸려 하므로 실행되지 않습니다.- 문자열 빌더 수정 · 허용
- 별도
append조각은 같은 객체의 내용을count=1, total=40으로 바꿉니다. 참조 재대입이 아닙니다. - 배열 원소 대입 · 허용
FinalArrayMutation은 같은 배열의 0번 칸을 바꿔[40, 50]이[60, 50]이 됩니다.- 회원 등록 수 누적 · 허용
FinalMutableObject의 같은 객체에서registeredCount가0 → 40 → 70으로 변하며 결과는70/100입니다.
마지막 예제의 targetCount = 100은 객체별 final 필드로 유지됩니다. final 참조 자체가 대상 객체를 불변으로 만들지는 않습니다.
private final 배열이 바깥에서 바뀌는 경우
final이 객체의 내용을 지켜 주지 않는다는 사실은 캡슐화에서 문제가 됩니다. 다음 MemberAges는 회원 나이 배열을 private final 필드로 가집니다. 필드가 private이니 다른 클래스는 이 배열을 건드릴 수 없을 것 같고, final이니 내용도 바뀌지 않을 것 같습니다. main은 두 가지를 시도합니다. 생성자에 넘긴 원본 배열 source의 1번 칸을 바꾸고, values()가 돌려준 배열의 0번 칸을 바꿉니다.
import java.util.Arrays;
public final class LeakyFinalArray {
public static void main(String[] args) {
int[] source = {40, 50};
MemberAges memberData = new MemberAges(source);
source[1] = 77;
System.out.println(Arrays.toString(memberData.values()));
int[] leakedAttempt = memberData.values();
leakedAttempt[0] = 999;
System.out.println(Arrays.toString(memberData.values()));
}
private static final class MemberAges {
private final int[] values;
MemberAges(int[] values) {
this.values = values;
}
int[] values() {
return values;
}
}
}[40, 77]
[999, 77]두 시도가 모두 MemberAges 안의 배열을 바꿨습니다. 원인은 이 클래스가 배열을 복사하지 않고 참조값만 주고받는다는 데 있습니다. 생성자는 source가 가리키는 배열의 참조값을 그대로 필드에 저장하므로, source와 필드 values는 같은 배열을 가리키는 별칭(alias)이 됩니다. 그래서 source[1] = 77은 곧 내부 배열의 1번 칸을 바꾸는 일입니다. values()도 내부 배열의 참조값을 그대로 돌려주므로 leakedAttempt 역시 같은 배열의 별칭이 되고, leakedAttempt[0] = 999가 내부 배열에 그대로 들어갑니다.
private은 다른 클래스의 코드가 values라는 이름으로 필드에 접근하는 것을 막을 뿐이고, final은 필드에 다른 배열을 대입하는 것을 막을 뿐입니다. 바깥에 같은 배열을 가리키는 참조가 이미 있다면, 둘 중 어느 것도 그 참조를 통한 변경을 막지 못합니다. 이 예제의 main이 필드 이름 values를 한 번도 쓰지 않았다는 점에 주목하세요. main은 생성자에 배열을 넘기고 values() 메서드를 호출했을 뿐인데도 내부 배열을 바꿨습니다. 그러니 MemberAges를 자기 파일로 옮겨 private이 실제로 main의 접근을 막는 상황이 되어도 결과는 똑같습니다. 이 예제에서 내부 배열의 참조가 바깥으로 새어 나간 길은 두 가지입니다. 객체를 만들 때 받은 참조를 그대로 저장한 입력 쪽 길과, 내부 참조를 그대로 돌려준 출력 쪽 길입니다.
받을 때와 내줄 때 복사하기
두 길을 막는 방법은 간단합니다. 받을 때 복사해서 저장하고, 내줄 때도 복사해서 내주면 됩니다. 이렇게 객체가 자기 내부 상태를 지키려고 가변 객체를 복사해 두는 것을 방어적 복사(defensive copy)라고 합니다. 다음 DefensiveFinalArray는 앞 예제와 main이 같고, MemberAges의 생성자와 values()만 배열과 인덱스에서 쓴 Arrays.copyOf로 복사하도록 바꿨습니다.
import java.util.Arrays;
public final class DefensiveFinalArray {
public static void main(String[] args) {
int[] source = {40, 50};
MemberAges memberData = new MemberAges(source);
source[1] = 77;
System.out.println(Arrays.toString(memberData.values()));
int[] leakedAttempt = memberData.values();
leakedAttempt[0] = 999;
System.out.println(Arrays.toString(memberData.values()));
}
private static final class MemberAges {
private final int[] values;
MemberAges(int[] values) {
this.values = Arrays.copyOf(values, values.length);
}
int[] values() {
return Arrays.copyOf(values, values.length);
}
}
}[40, 50]
[40, 50]이번에는 두 줄 모두 [40, 50]입니다. 생성자가 source의 내용을 새 배열에 복사해 저장했으므로, source[1] = 77은 main의 원본 배열만 바꾸고 내부 배열에는 닿지 않습니다. values()는 호출될 때마다 내부 배열의 복사본을 새로 만들어 돌려주므로, leakedAttempt[0] = 999도 그 복사본만 바꿉니다.
아래 단계별 그림은 출력 쪽 복사를 따라갑니다. values()가 만든 복사본은 처음에는 내부 배열과 같은 값 [40, 50]을 갖지만 서로 다른 배열입니다. 그래서 복사본의 0번 칸을 999로 바꿔도 내부 배열은 그대로이고, 다음 values() 호출은 다시 [40, 50]을 담은 새 복사본을 돌려줍니다.
반환된 복사본의 변경은 내부 배열로 전달되지 않는다
내부 [40,50]에서 values 복사본을 얻어 index 0을 999로 바꿔도 내부는 그대로입니다. 반환 전·복사 후·외부 변경 후의 참조와 값 상태를 추적할 수 있습니다.
DefensiveFinalArray에서 leakedAttempt는 내부 values와 다른 배열을 가리켜 0번 원소를 999로 바꿔도 내부 값 40과 50은 유지됩니다.
반환된 복사본을 바꿔도 내부 배열은 유지됩니다
DefensiveFinalArray · leakedAttempt[0] = 999; 직후의 두 배열입니다.
- 실선: 현재 참조
- 점선: 새 배열을 만든 복사 경로
- 내부 필드 values
private final int[]필드는 내부 배열[40, 50]을 가리킵니다.- values()가 별도 배열을 반환
Arrays.copyOf로 처음에는 같은 원소 값[40, 50]을 가진 다른 배열을 만듭니다. 내부 참조를 그대로 반환하지 않습니다.- leakedAttempt로 복사본 변경
leakedAttempt[0] = 999;는 반환된 배열만[999, 50]으로 바꿉니다. 내부 배열은[40, 50]을 유지합니다.
점선은 같은 배열을 공유한다는 뜻이 아닙니다. 뒤의 memberData.values() 호출도 새 복사본을 만들며 출력은 [40, 50]입니다.
생성자도 입력 배열을 한 번 복사합니다. 입력 때의 복사와 반환 때의 복사는 각각 외부의 원소 변경 경로를 차단하며, final은 내부 필드의 재대입을 막습니다. 여기서는 int[]를 복사하며 임의 객체 그래프의 깊은 복사를 주장하지 않습니다.
이 예의 조건
- values()는 새 기본형 배열을 반환합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 반환된 복사본의 변경은 내부 배열로 전달되지 않는다 반환된 복사본의 변경은 내부 배열로 전달되지 않는다 DefensiveFinalArray에서 leakedAttempt는 내부 values와 다른 배열을 가리켜 0번 원소를 999로 바꿔도 내부 값 40과 50은 유지됩니다. 반환된 복사본을 바꿔도 내부 배열은 유지됩니다 DefensiveFinalArray · leakedAttempt[0] = 999; 직후의 두 배열입니다. 실선: 현재 참조 점선: 새 배열을 만든 복사 경로 내부 필드 values private final int[] 필드는 내부 배열 [40, 50] 을 가리킵니다. values()가 별도 배열을 반환 Arrays.copyOf 로 처음에는 같은 원소 값 [40, 50] 을 가진 다른 배열을 만듭니다. 내부 참조를 그대로 반환하지 않습니다. leakedAttempt로 복사본 변경 leakedAttempt[0] = 999; 는 반환된 배열만 [999, 50] 으로 바꿉니다. 내부 배열은 [40, 50] 을 유지합니다. 점선은 같은 배열을 공유한다는 뜻이 아닙니다. 뒤의 memberData.values() 호출도 새 복사본을 만들며 출력은 [40, 50] 입니다. 생성자도 입력 배열을 한 번 복사합니다. 입력 때의 복사와 반환 때의 복사는 각각 외부의 원소 변경 경로를 차단하며, final 은 내부 필드의 재대입을 막습니다. 여기서는 int[] 를 복사하며 임의 객체 그래프의 깊은 복사를 주장하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 내부 배열 private final values가 40,50을 보관합니다. |
|
|
| 반환 복사 values()가 새 int[2]를 만듭니다. |
|
|
| 외부 수정 반환 배열의 0번을 999로 바꿉니다. |
|
|
반환된 복사본의 변경은 내부 배열로 전달되지 않는다
내부 [40,50]에서 values 복사본을 얻어 index 0을 999로 바꿔도 내부는 그대로입니다. 반환 전·복사 후·외부 변경 후의 참조와 값 상태를 추적할 수 있습니다.
DefensiveFinalArray에서 leakedAttempt는 내부 values와 다른 배열을 가리켜 0번 원소를 999로 바꿔도 내부 값 40과 50은 유지됩니다.
반환된 복사본을 바꿔도 내부 배열은 유지됩니다
DefensiveFinalArray · leakedAttempt[0] = 999; 직후의 두 배열입니다.
- 실선: 현재 참조
- 점선: 새 배열을 만든 복사 경로
- 내부 필드 values
private final int[]필드는 내부 배열[40, 50]을 가리킵니다.- values()가 별도 배열을 반환
Arrays.copyOf로 처음에는 같은 원소 값[40, 50]을 가진 다른 배열을 만듭니다. 내부 참조를 그대로 반환하지 않습니다.- leakedAttempt로 복사본 변경
leakedAttempt[0] = 999;는 반환된 배열만[999, 50]으로 바꿉니다. 내부 배열은[40, 50]을 유지합니다.
점선은 같은 배열을 공유한다는 뜻이 아닙니다. 뒤의 memberData.values() 호출도 새 복사본을 만들며 출력은 [40, 50]입니다.
생성자도 입력 배열을 한 번 복사합니다. 입력 때의 복사와 반환 때의 복사는 각각 외부의 원소 변경 경로를 차단하며, final은 내부 필드의 재대입을 막습니다. 여기서는 int[]를 복사하며 임의 객체 그래프의 깊은 복사를 주장하지 않습니다.
이 예의 조건
- values()는 새 기본형 배열을 반환합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 반환된 복사본의 변경은 내부 배열로 전달되지 않는다 반환된 복사본의 변경은 내부 배열로 전달되지 않는다 DefensiveFinalArray에서 leakedAttempt는 내부 values와 다른 배열을 가리켜 0번 원소를 999로 바꿔도 내부 값 40과 50은 유지됩니다. 반환된 복사본을 바꿔도 내부 배열은 유지됩니다 DefensiveFinalArray · leakedAttempt[0] = 999; 직후의 두 배열입니다. 실선: 현재 참조 점선: 새 배열을 만든 복사 경로 내부 필드 values private final int[] 필드는 내부 배열 [40, 50] 을 가리킵니다. values()가 별도 배열을 반환 Arrays.copyOf 로 처음에는 같은 원소 값 [40, 50] 을 가진 다른 배열을 만듭니다. 내부 참조를 그대로 반환하지 않습니다. leakedAttempt로 복사본 변경 leakedAttempt[0] = 999; 는 반환된 배열만 [999, 50] 으로 바꿉니다. 내부 배열은 [40, 50] 을 유지합니다. 점선은 같은 배열을 공유한다는 뜻이 아닙니다. 뒤의 memberData.values() 호출도 새 복사본을 만들며 출력은 [40, 50] 입니다. 생성자도 입력 배열을 한 번 복사합니다. 입력 때의 복사와 반환 때의 복사는 각각 외부의 원소 변경 경로를 차단하며, final 은 내부 필드의 재대입을 막습니다. 여기서는 int[] 를 복사하며 임의 객체 그래프의 깊은 복사를 주장하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 내부 배열 private final values가 40,50을 보관합니다. |
|
|
| 반환 복사 values()가 새 int[2]를 만듭니다. |
|
|
| 외부 수정 반환 배열의 0번을 999로 바꿉니다. |
|
|
두 복사는 각자 다른 길을 막으므로 어느 하나만으로는 부족합니다. 출력 쪽 복사를 빼면 어떻게 되는지는 연습 문제 2에서 확인합니다. 복사에는 배열의 clone() 메서드를 써도 됩니다. values.clone()도 같은 길이의 새 배열을 만들어 원소를 복사해 줍니다. Java에는 배열을 읽기 전용으로 만드는 방법이 없으므로, 배열을 바깥에 내줘야 한다면 이렇게 복사본을 내주거나 배열 대신 필요한 결과만 돌려줘야 합니다.
생성자에서 입력을 검사해야 한다면 순서에도 주의합니다. 먼저 복사하고, 검사는 원본이 아니라 복사본에 대해 합니다. 원본을 검사한 뒤에 복사하면, 검사와 복사 사이에 다른 스레드가 원본을 바꿔 검사를 통과하지 못할 값이 들어올 수 있기 때문입니다. 스레드는 18장에서 배우지만, 복사한 다음 검사하는 습관은 지금부터 들여 두는 것이 좋습니다.
방어적 복사에도 한계가 있습니다. 이 예제에서 복사한 것은 기본형 int 값이라 복사본과 원본이 완전히 따로 움직입니다. 하지만 Member[] 같은 참조형 배열을 Arrays.copyOf로 복사하면 새로 생기는 것은 배열뿐이고, 각 칸에는 원본과 같은 Member 객체의 참조값이 들어갑니다. 캡슐화로 회원 저장소 완성하기에서 본 얕은 복사입니다. 원소가 Member record처럼 바꿀 수 없는 객체라면 공유해도 문제가 없지만, 원소가 가변 객체라면 원소까지 하나하나 복사하거나 원소를 불변 객체로 만들어야 합니다.
바꾸는 대신 새 객체를 만드는 불변 객체
복사로 내부를 지키는 대신 처음부터 바뀔 수 없는 객체를 만들면, 복사하지 않고 공유해도 안전합니다. 불변 객체는 상태가 바뀌어야 할 때 자기 필드를 고치지 않고, 바뀐 값을 담은 새 객체를 만들어 돌려줍니다. 다음 Member record의 withAge가 그런 메서드입니다.
public final class ImmutableMember {
public static void main(String[] args) {
Member original = new Member("[email protected]", 40);
Member corrected = original.withAge(55);
System.out.println("original=" + original.describe());
System.out.println("corrected=" + corrected.describe());
}
private record Member(String email, int age) {
Member {
if (email == null || email.isBlank() || age < 14 || age > 120) {
throw new IllegalArgumentException("invalid member");
}
}
Member withAge(int correctedAge) {
return new Member(email, correctedAge);
}
String describe() {
return email + "=" + age;
}
}
}original.withAge(55)는 original의 나이를 바꾸지 않고, 같은 이메일과 새 나이 55로 Member를 하나 더 만듭니다. 그래서 출력에서 original은 여전히 40이고 corrected만 55입니다. 두 객체를 함께 가지고 있으면 수정 전과 후를 비교할 수 있고, original을 다른 곳에 넘겨 두었더라도 그쪽에서 보는 값이 바뀔 걱정이 없습니다.
새 Member도 생성자를 거쳐 만들어지므로 컴팩트 생성자의 검사를 똑같이 통과해야 합니다. 예를 들어 original.withAge(130)을 호출하면 새 객체를 만드는 단계에서 IllegalArgumentException이 나고, original은 아무 영향 없이 40으로 남습니다. 불변 객체는 만들어지는 순간에 한 번 검사하면 그 뒤로는 어떤 메서드도 상태를 바꾸지 않습니다. 그래서 검사를 통과해 만들어진 객체가 나중에 잘못된 상태로 바뀌는 일이 없습니다.
이 Member가 불변인 이유를 따져 보면 불변 객체를 만드는 조건이 보입니다.
- 상태를 바꾸는 메서드가 없습니다. record는 값을 바꾸는 메서드(setter)를 만들지 않고,
withAge도 새 객체를 돌려줍니다. - 모든 필드가
private final입니다. record의 구성 요소는 자동으로private final필드가 됩니다. - 필드가 가리키는 객체도 바뀌지 않습니다.
int는 기본형이고String은 불변 객체입니다. - 다른 클래스가 상속해서 상태를 바꾸는 메서드를 덧붙일 수 없습니다. record는 자동으로
final클래스입니다.
세 번째 조건이 가장 놓치기 쉽습니다. record로 선언했다고 해서 안에 든 것까지 불변이 되는 것은 아닙니다. 구성 요소로 배열을 넣으면 record는 그 배열을 가리키는 참조만 고정합니다.
record Team(String name, int[] scores) {}
Team team = new Team("java", new int[] {90, 80});
team.scores()[0] = 0;마지막 줄은 컴파일되고, 실행하면 team 안의 배열이 [0, 80]으로 바뀝니다. 읽기 메서드 scores()가 내부 배열의 참조값을 그대로 돌려주기 때문입니다. 이런 record를 불변으로 만들려면 앞 절의 방어적 복사를 컴팩트 생성자와 읽기 메서드에 적용해야 하는데, 그 방법은 record 심화와 불변 스냅샷에서 다룹니다.
더 깊이: final 필드와 여러 스레드
앞 글에서 본 것처럼 지역 변수의 final은 컴파일러가 확인하고 끝나지만, 필드의 final에는 실행 중의 의미가 하나 더 있습니다. 여러 스레드가 객체를 함께 쓸 때, 한 스레드가 필드에 쓴 값이 다른 스레드에 언제 보이는지는 가시성·원자성·임계 영역에서 배울 자바 메모리 모델이 정합니다. 동기화 없이 객체를 다른 스레드에 넘기면, 받은 스레드가 생성자에서 대입한 일반 필드의 값을 보지 못하고 기본값 0을 읽는 일도 메모리 모델상 허용됩니다.
JLS 17.5는 final 필드에 대해 예외를 둡니다. 생성자가 끝난 뒤에 객체의 참조를 얻은 스레드는, 동기화를 하지 않아도 그 객체의 final 필드를 생성자에서 대입한 값으로 본다고 보장합니다. final 필드를 거쳐 닿는 배열이나 객체도 적어도 생성자가 끝났을 때의 내용으로 보입니다. 다만 생성자가 끝나기 전에 this를 static 필드처럼 다른 스레드가 볼 수 있는 곳에 저장하면 이 보장이 사라지고, 생성자가 끝난 뒤에 배열의 내용을 바꾼 것도 보장 대상이 아닙니다. 모든 필드가 final이고 생성 뒤에 아무것도 바뀌지 않는 불변 객체를 동기화 없이 여러 스레드에 넘겨도 안전한 것은 이 규칙 덕분입니다.
가변 객체와 불변 객체 중 무엇을 고를까
불변 객체에도 비용은 있습니다. 값이 바뀔 때마다 새 객체를 만들어야 하므로, 크기가 큰 상태가 자주 바뀌면 객체를 만들고 복사하는 비용이 쌓입니다. 또 같은 대상의 상태가 계속 바뀌는 것이 자연스러운 경우도 있습니다. 회원이 계속 추가되고 삭제되는 MemberRegistry가 그렇습니다. 이런 객체는 가변으로 두되 내부 상태를 private으로 숨기고, 정해진 메서드로만 바꾸게 하면 충분히 안전하게 쓸 수 있습니다.
그래서 모든 클래스를 불변으로 만들 필요는 없습니다. 불변 객체가 특히 잘 맞는 곳은 회원 정보나 날짜처럼 값 자체가 의미인 타입, 여러 곳에서 같은 객체를 공유하는 경우, 여러 스레드가 함께 읽는 데이터입니다. 불변 객체를 설계하는 방법은 불변 객체와 안전한 공유에서 더 자세히 다룹니다.
정리
final참조 변수에는 다른 객체를 대입할 수 없지만, 가리키는 객체의 내용은 바꿀 수 있습니다.final이 고정하는 것은 변수에 든 참조값입니다.- 객체의 내용이 바뀌는지는 변수의
final이 아니라 객체의 설계가 정합니다.StringBuilder와 배열은 가변 객체이고,String은 불변 객체입니다. private final필드라도 가변 객체의 참조를 그대로 받거나 그대로 내주면, 바깥 코드가 그 참조로 내부 상태를 바꿀 수 있습니다. 받을 때와 내줄 때 모두 복사해야 두 길이 막힙니다.- 참조형 배열을 복사하면 배열만 새로 생기고 원소 객체는 공유됩니다. 원소가 가변 객체라면 원소까지 복사하거나 불변 객체로 만듭니다.
- 불변 객체는 상태를 바꾸는 대신 새 객체를 돌려줍니다. 만들 때 한 번 검사하면 그 뒤로 상태가 바뀌지 않으므로 여러 곳에서 공유해도 안전합니다.
연습 문제
문제 1: final과 불변 구분하기
다음 프로그램을 실행하면 무엇이 출력될지 예상하세요. 그다음 main의 끝에 scores = new int[] {90, 80};을 추가하면, 또는 name = name.toUpperCase();를 추가하면 각각 어떻게 되는지 설명하세요.
import java.util.Arrays;
public final class FinalAndImmutable {
public static void main(String[] args) {
final int[] scores = {70, 80};
final String name = "kim";
scores[0] = 90;
name.toUpperCase();
System.out.println(Arrays.toString(scores) + " " + name);
}
}해설 보기
[90, 80] kimscores[0] = 90은 final 변수 scores에 대입하는 것이 아니라 그 변수가 가리키는 배열의 칸에 대입하므로 허용되고, 배열은 [90, 80]이 됩니다. name.toUpperCase()는 "KIM"을 담은 새 문자열을 만들지만 결과를 저장하지 않았고, String은 불변이라 원래 문자열도 그대로입니다. 그래서 name은 여전히 kim입니다.
추가하는 두 줄은 모두 컴파일되지 않습니다. 둘 다 final 변수에 다른 참조값을 대입하려 하므로 cannot assign a value to final variable scores, cannot assign a value to final variable name 오류가 납니다. scores는 내용을 바꿀 수 있는 배열을 가리키고 name은 내용을 바꿀 수 없는 문자열을 가리키지만, 다른 객체를 대입할 수 없다는 점은 똑같습니다. 앞의 네 칸짜리 표에서 final 변수 줄의 두 칸에 해당합니다.
문제 2: 한쪽만 복사하면
DefensiveFinalArray에서 생성자의 복사는 그대로 두고, values()만 다음처럼 내부 배열을 그대로 돌려주도록 바꿨습니다. 두 줄의 출력이 어떻게 될지 예상하고, 각 줄이 그렇게 나오는 이유를 설명하세요.
int[] values() {
return values;
}해설 보기
[40, 50]
[999, 50]첫 줄은 바꾸기 전과 같은 [40, 50]입니다. 생성자가 여전히 source를 복사해 저장하므로 source[1] = 77은 내부 배열에 닿지 않습니다. 두 번째 줄은 [999, 50]이 됩니다. values()가 내부 배열의 참조값을 그대로 돌려주므로 leakedAttempt가 내부 배열의 별칭이 되고, leakedAttempt[0] = 999가 내부 배열의 0번 칸을 바꿉니다. 1번 칸이 77이 아니라 50으로 남은 것은 입력 쪽 길이 여전히 막혀 있기 때문입니다. 두 복사가 서로 다른 길을 하나씩 막는다는 것을 이 출력이 보여 줍니다.
문제 3: 불변 SignupQuota 만들기
앞 글의 연습 문제 3에서 만든 SignupQuota는 register가 자기 필드를 바꾸는 가변 객체였습니다. 이번에는 목표 인원 targetCount와 등록 수 registeredCount를 가진 불변 record로 만들고, register(int count)가 등록 수를 더한 새 SignupQuota를 돌려주게 하세요. 등록 수는 양수만 받고, 합계가 int 범위를 넘으면 예외를 던집니다. 목표를 넘는 등록은 막지 않아도 됩니다. 완성했다면 다음 코드를 실행했을 때 before, after, third의 등록 수가 각각 얼마일지 먼저 예상해 보세요.
SignupQuota before = new SignupQuota(100, 0);
before.register(40);
SignupQuota after = before.register(40);
SignupQuota third = after.register(30);해설 보기
public final class ImmutableSignupQuota {
public static void main(String[] args) {
SignupQuota before = new SignupQuota(100, 0);
before.register(40);
SignupQuota after = before.register(40);
SignupQuota third = after.register(30);
System.out.println("before=" + before.describe());
System.out.println("after=" + after.describe());
System.out.println("third=" + third.describe());
}
private record SignupQuota(int targetCount, int registeredCount) {
SignupQuota {
if (targetCount <= 0 || registeredCount < 0) {
throw new IllegalArgumentException("invalid goal");
}
}
SignupQuota register(int count) {
if (count <= 0) throw new IllegalArgumentException("count");
return new SignupQuota(targetCount, Math.addExact(registeredCount, count));
}
String describe() {
return registeredCount + "/" + targetCount;
}
}
}before=0/100
after=40/100
third=70/100register는 자기 필드를 바꾸지 않고 등록 수를 더한 새 SignupQuota를 돌려줍니다. 그래서 반환값을 버린 둘째 줄의 before.register(40)은 새 객체를 만들었다가 버릴 뿐이고, before는 계속 0입니다. after는 before의 0에 40을 더한 새 객체이고, third는 after의 40에 30을 더한 또 다른 객체입니다. 세 객체가 각자 0, 40, 70을 가지며 목표는 모두 100입니다.
이번 설계에서 before가 바뀌지 않는 이유는 변수의 final이 아니라 객체의 설계에 있습니다. register가 새 객체를 돌려주도록 만들었기 때문에 기존 객체는 처음 상태를 그대로 유지합니다. 합계가 int 범위를 넘을 때도 같은 원리가 적용됩니다. Math.addExact가 ArithmeticException을 던지면 새 SignupQuota를 만드는 단계까지 가지 않으므로, 예외가 난 뒤에도 기존 객체의 등록 수는 그대로입니다.
다음 글
7장에서는 공통된 상태와 행동을 부모 클래스에 두고 자식 클래스가 물려받는 상속을 배웁니다. 상속을 알고 나면 이 교재의 예제 클래스에 붙어 있던 final class가 무엇을 막는지도 이해할 수 있습니다.