본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
6장 : 메모리·정적 멤버·상수

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]

finalage 변수가 다른 배열을 가리키는 것을 막지만 index 0 원소 대입을 막지 않습니다.

MemberRegistry의 private final Member[] members도 배열 참조를 생성 뒤 교체하지 않겠다는 뜻입니다.

addremove는 배열 원소를 계속 변경합니다.


객체 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과 캡슐화

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


불변 객체

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 참조가 아니라 객체 설계 자체가 변경 대신 새 값을 만듭니다.

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