안동민 개발노트

본문 시작

final 참조와 객체 변경

final 참조의 재대입 금지와 참조 대상 객체의 변경을 구분하고 방어적 복사와 불변 객체로 회원 저장소 경계를 강화합니다.

참조 변수에 final을 붙이면 다른 객체 참조로 재대입할 수 없습니다.

그러나 현재 참조가 가리키는 객체의 내부 상태까지 자동으로 고정되지는 않습니다.

“final 객체”라는 표현보다 참조의 고정과 객체의 가변성을 따로 판단해야 합니다.


final 참조의 재대입

lab/FinalReferenceReassignment.java
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 summary

summary 변수는 처음 StringBuilder 참조를 받은 뒤 다른 참조로 바뀔 수 없습니다.

같은 객체를 수정하는 것은 허용됩니다.

final StringBuilder summary = new StringBuilder("count=1");
summary.append(", total=40");

변수의 참조값은 같고 그 참조가 가리키는 StringBuilder 내부 문자 상태가 바뀝니다.


final 배열의 원소 변경

src/FinalArrayMutation.java
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와 변경 가능성

src/FinalMutableObject.java
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/100

progress 참조는 고정됐지만 register라는 변경 메서드가 있어 객체 상태는 변합니다.

targetCount 필드는 final이고 registeredCount는 변경 가능합니다.

객체 전체를 가변 또는 불변으로 분류할 때는 필드와 공개 행동을 함께 봅니다.

String은 변경 메서드가 새 String을 반환하는 불변 타입입니다.

StringBuilder는 같은 객체를 바꾸는 가변 타입입니다.

final 여부와 타입의 불변성은 별도 축입니다.

final은 참조 재대입과 객체 변경을 다르게 취급한다

서로 다른 네 예제에서 final 참조의 재대입은 컴파일 실패하지만 StringBuilder 내용, 배열 원소, SignupProgress 등록 수의 변경은 허용됩니다.

각 행은 별도 예제입니다. 한 프로그램에서 차례로 실행되는 단계가 아닙니다.

같은 final 참조라도 연산 대상이 다르다
연산바꾸려는 것판정과 관찰
다른 객체로 재대입summary 변수의 참조값컴파일 실패

FinalReferenceReassignment는 실행되지 않습니다.

문자열 빌더 수정같은 StringBuilder 객체의 문자 내용허용

별도 append 조각: count=1, total=40

배열 원소 대입같은 int[]의 0번 원소허용

FinalArrayMutation: [40, 50]에서 [60, 50]

회원 등록 수 누적같은 SignupProgress의 registeredCount허용

FinalMutableObject: 0 → 40 → 70, 출력 70/100

다른 객체로 재대입 · 컴파일 실패
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이 외부 별칭을 막지 않습니다.

내부 배열을 숨기거나 복사본을 반환해야 합니다.

src/DefensiveFinalArray.java
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인 것과 방어적 복사는 서로 다른 문제를 해결합니다.

반환된 복사본의 변경은 내부 배열로 전달되지 않는다

DefensiveFinalArray에서 leakedAttempt는 내부 values와 다른 배열을 가리켜 0번 원소를 999로 바꿔도 내부 값 40과 50은 유지됩니다.

DefensiveFinalArray · leakedAttempt[0] = 999; 직후의 두 배열입니다.

내부 배열과 반환된 복사본은 별개다 private final values는 내부 40,50 배열을, leakedAttempt는 999,50으로 수정한 반환 복사본을 참조하며 점선은 반환 때 새 배열이 만들어진 경로를 나타냅니다. values()의 복사 MemberAges.values private final int[] 내부 배열을 보관하는 필드 leakedAttempt values() 반환값을 받은 호출자의 지역 참조 내부 int[2] [40, 50] 원소 값 유지 반환된 int[2] 복사본 [999, 50] 복사 후 0번 원소만 변경
  • 실선: 현재 참조
  • 점선: 새 배열을 만든 복사 경로
내부 필드 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[]를 복사하며 임의 객체 그래프의 깊은 복사를 주장하지 않습니다.


불변 객체

src/ImmutableMember.java
public final class ImmutableMember {
    public static void main(String[] args) {
        Member original = new Member("kim@example.com", 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=kim@example.com=40
corrected=kim@example.com=55

record의 구성 요소는 final이고 값을 바꾸는 setter가 없습니다.

withAge는 기존 객체를 수정하지 않고 검증된 나이로 새 Member를 생성합니다.

두 참조를 동시에 보관해 변경 전후를 비교할 수 있고 공유 참조 때문에 과거 값이 바뀌지 않습니다.

다만 새 객체 생성 비용과 상태 전이 표현을 고려해야 합니다.

가변 객체도 캡슐화된 행동으로 안전하게 사용할 수 있습니다.

불변성은 모든 클래스를 무조건 바꾸는 규칙이 아니라 공유와 동시성, 값 의미가 중요한 곳에서 강한 장점이 있습니다.


연습 문제

targetCount와 registeredCount를 가진 불변 SignupQuota를 만들고 register(int count)가 새 SignupQuota를 반환하게 하세요.

기존 객체는 등록 수 0을 유지하고 새 객체는 등록 수 40을 가져야 합니다.

해설 보기
src/ImmutableSignupQuota.java
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/100

final 참조가 아니라 객체 설계 자체가 변경 대신 새 값을 만듭니다.

기존 상태와 새 상태가 독립적으로 유효합니다.