final 참조와 객체 변경
final 참조의 재대입 금지와 참조 대상 객체의 변경을 구분하고 방어적 복사와 불변 객체로 회원 저장소 경계를 강화합니다.
참조 변수에 final을 붙이면 다른 객체 참조로 재대입할 수 없습니다.
그러나 현재 참조가 가리키는 객체의 내부 상태까지 자동으로 고정되지는 않습니다.
“final 객체”라는 표현보다 참조의 고정과 객체의 가변성을 따로 판단해야 합니다.
final 참조의 재대입
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);
}
}error: cannot assign a value to final variable summarysummary 변수는 처음 StringBuilder 참조를 받은 뒤 다른 참조로 바뀔 수 없습니다.
같은 객체를 수정하는 것은 허용됩니다.
final StringBuilder summary = new StringBuilder("count=1");
summary.append(", total=40");변수의 참조값은 같고 그 참조가 가리키는 StringBuilder 내부 문자 상태가 바뀝니다.
final 배열의 원소 변경
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]final은 age 변수가 다른 배열을 가리키는 것을 막지만 index 0 원소 대입을 막지 않습니다.
MemberRegistry의 private final Member[] members도 배열 참조를 생성 뒤 교체하지 않겠다는 뜻입니다.
add와 remove는 배열 원소를 계속 변경합니다.
객체 API와 변경 가능성
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 참조는 고정됐지만 register라는 변경 메서드가 있어 객체 상태는 변합니다.
targetCount 필드는 final이고 registeredCount는 변경 가능합니다.
객체 전체를 가변 또는 불변으로 분류할 때는 필드와 공개 행동을 함께 봅니다.
String은 변경 메서드가 새 String을 반환하는 불변 타입입니다.
StringBuilder는 같은 객체를 바꾸는 가변 타입입니다.
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 참조 자체가 대상 객체를 불변으로 만들지는 않습니다.
final과 캡슐화
private final int[] age;
int[] age() {
return age;
}필드 참조는 final이지만 반환된 배열을 호출자가 수정할 수 있습니다.
final이 외부 별칭을 막지 않습니다.
내부 배열을 숨기거나 복사본을 반환해야 합니다.
import java.util.Arrays;
public final class DefensiveFinalArray {
public static void main(String[] args) {
MemberAges memberData = new MemberAges(new int[] {40, 50});
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]생성자에서 입력 배열을 복사해 호출자가 나중에 원본을 바꾸는 경로를 차단하고, 반환할 때도 복사해 내부 참조가 나가지 않게 합니다.
배열 참조가 final인 것과 방어적 복사는 서로 다른 문제를 해결합니다.
예시
위 main에서 입력 배열을 지역 변수 int[] source = {40, 50};로 보관한 뒤 new MemberAges(source)에 전달해 보세요. 생성 후 source[0] = 777;로 원본을 바꾸고, memberData.values()에서 받은 배열도 999로 바꿉니다. 다시 values()를 호출해 출력하면 여전히 [40, 50]입니다. 생성자 복사가 입력 경로를, 반환 메서드 복사가 출력 경로를 각각 막기 때문입니다.
여기서는 기본형 int 값 두 개를 복사합니다. Member[] 같은 참조형 배열을 복사하면 배열은 새로 생겨도 각 칸의 Member 참조값은 복사되므로 같은 회원 객체를 공유할 수 있습니다. 배열 복사만으로 그 객체의 내부 필드까지 독립 복사한 것은 아닙니다.
반환된 복사본의 변경은 내부 배열로 전달되지 않는다
내부 [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로 바꿉니다. |
|
|
불변 객체
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;
}
}
}record의 구성 요소는 final이고 값을 바꾸는 setter가 없습니다.
이 Member는 나이가 기본형 int이고 이메일이 불변 String이므로, 구성 요소를 통해 내부 가변 상태를 바꿀 경로가 없습니다. 반대로 record에 가변 배열이나 StringBuilder 참조를 담으면 그 참조를 재대입하지 못해도 대상은 변경될 수 있습니다. record라는 표기만으로 모든 객체 그래프가 불변이 되는 것은 아닙니다.
withAge는 기존 객체를 수정하지 않고 검증된 나이로 새 Member를 생성합니다.
두 참조를 동시에 보관해 변경 전후를 비교할 수 있고 공유 참조 때문에 과거 값이 바뀌지 않습니다.
다만 새 객체 생성 비용과 상태 전이 표현을 고려해야 합니다.
가변 객체도 캡슐화된 행동으로 안전하게 사용할 수 있습니다.
모든 클래스를 무조건 불변으로 바꿀 필요는 없습니다. 불변성은 공유와 동시성, 값 의미가 중요한 곳에서 특히 유용합니다.
연습 문제
targetCount와 registeredCount를 가진 불변 SignupQuota를 만들고 register(int count)가 새 SignupQuota를 반환하게 하세요.
기존 객체는 등록 수 0을 유지하고 새 객체는 등록 수 40을 가져야 합니다.
해설을 보기 전에 before.register(40);만 호출하고 반환값을 보관하지 않았을 때도 before가 40이 될지 예측하세요. 다음으로 after.register(30)의 반환값을 third에 담으면 before·after·third가 각각 어떤 등록 수를 갖는지 기록합니다. 목표 초과 등록은 이 문제에서 금지하지 않으며, 양수 등록과 int 합계 범위만 지킵니다.
해설 보기
public final class ImmutableSignupQuota {
public static void main(String[] args) {
SignupQuota before = new SignupQuota(100, 0);
SignupQuota after = before.register(40);
System.out.println("before=" + before.describe());
System.out.println("after=" + after.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/100final 참조가 아니라 객체 설계 자체가 변경 대신 새 값을 만듭니다.
기존 상태와 새 상태가 독립적으로 유효합니다.
반환값을 보관하지 않은 호출은 before의 0을 바꾸지 않습니다. after에서 30을 더한 새 third를 받으면 등록 수는 각각 0·40·70이며 목표는 모두 100입니다. Math.addExact가 넘침을 발견하면 새 SignupQuota 생성까지 진행하지 않으므로, 예외 뒤에도 기존 객체의 등록 수는 그대로입니다. “실패하면 옛 상태를 유지한다”는 이유도 재대입 금지라는 말보다 실제 생성·반환 순서로 설명할 수 있습니다.