본문 바로가기

final 변수·필드·상수

final 지역 변수와 필드의 재대입 금지 계약을 확인하고 인스턴스 설정과 클래스 상수를 초기화 시점에 맞게 구분합니다.

final은 변수에 값을 한 번만 대입할 수 있게 합니다.

값이 절대 변하지 않는다는 막연한 표시보다, 어떤 이름이 초기화 뒤 다른 값을 가리키지 않는다는 컴파일 계약입니다.

지역 변수, 인스턴스 필드, static 필드에서 초기화 시점과 공유 범위가 다릅니다.


final 지역 변수

lab/FinalLocalReassignment.java
public final class FinalLocalReassignment {
    public static void main(String[] args) {
        final int targetCount = 120;
        targetCount = 180;
        System.out.println(targetCount);
    }
}
컴파일 실패 관찰
error: cannot assign a value to final variable targetCount

final 변수는 선언과 동시에 초기화하거나, 모든 경로에서 정확히 한 번 대입할 수 있습니다.

final int target;
if (args.length == 0) {
    target = 60;
} else {
    target = Integer.parseInt(args[0]);
}
System.out.println(target);

어느 경로에서도 두 번 대입되지 않고 사용 전에 값이 정해지므로 컴파일됩니다.

final은 실행 중 값을 비교하는 검증이 아니라 컴파일러가 대입 횟수를 추적하는 규칙입니다.

예를 들어 인수 -1은 parseInt가 성공하므로 final target에 대입할 수 있습니다. “한 번 대입한다”와 “목표가 양수다”는 서로 다른 계약입니다. 정상적으로 값을 사용하는 경로에서는 먼저 대입되어야 하지만, 파싱이 예외로 끝나는 경로는 아래 출력까지 도달하지 않습니다.


final 필드 초기화

src/FinalInstanceFields.java
public final class FinalInstanceFields {
    public static void main(String[] args) {
        MemberRegistry personal = new MemberRegistry("personal", 3);
        MemberRegistry work = new MemberRegistry("work", 10);

        System.out.println(personal.describe());
        System.out.println(work.describe());
    }

    private static final class MemberRegistry {
        private final String name;
        private final int capacity;

        MemberRegistry(String name, int capacity) {
            if (name.isBlank() || capacity <= 0) throw new IllegalArgumentException();
            this.name = name;
            this.capacity = capacity;
        }

        String describe() {
            return name + ":" + capacity;
        }
    }
}
personal:3
work:10

name과 capacity는 객체마다 다른 값이지만 각 객체에서는 생성 뒤 바뀌지 않습니다.

final 인스턴스 필드는 필드 선언식 또는 모든 생성자 경로에서 초기화해야 합니다.

생성자 오버로딩이 있다면 this() 위임으로 초기화 경로를 모으면 누락을 줄일 수 있습니다.

final 필드를 생성자에서 받으면 객체의 필수 설정이 생성 계약에 드러납니다.

변경이 필요하다면 같은 객체의 필드를 바꾸는 대신 새 설정으로 새 객체를 만드는 설계를 고려합니다.

위 생성자는 name.isBlank()를 먼저 호출하므로 null 이름에는 NullPointerException이 발생합니다. 빈 이름이나 0 이하 용량의 IllegalArgumentException과 구분하세요. 아래 FinalMemberRegistryPolicy는 name == null도 먼저 검사하는 별도 예제입니다. final 선언이 빠진 입력 검증을 대신하지는 않습니다.


클래스 상수

src/SignupPolicyConstants.java
public final class SignupPolicyConstants {
    public static void main(String[] args) {
        System.out.println(SignupPolicy.MIN_MEMBER_AGE);
        System.out.println(SignupPolicy.MAX_MEMBER_AGE);
        System.out.println(SignupPolicy.isAllowed(40));
        System.out.println(SignupPolicy.isAllowed(130));
    }

    private static final class SignupPolicy {
        static final int MIN_MEMBER_AGE = 14;
        static final int MAX_MEMBER_AGE = 120;

        private SignupPolicy() {}

        static boolean isAllowed(int age) {
            return age >= MIN_MEMBER_AGE && age <= MAX_MEMBER_AGE;
        }
    }
}
14
120
true
false

모든 객체에 동일하고 바뀌지 않는 정책 값은 static final로 클래스에 하나만 둡니다.

상수 이름은 대문자와 밑줄을 사용하는 관례가 있습니다.

단지 final이라는 이유로 모든 지역 변수 이름을 대문자로 만들지 않습니다.

대문자 관례는 클래스 수준 상수에 적용합니다.

상수로 만들 값은 변하지 않는다는 기술적 조건뿐 아니라 모든 인스턴스가 공유하는 개념이어야 합니다.

가입 캠페인별 목표 인원은 final이어도 static이 아니라 인스턴스 필드입니다.

private final int targetCount;

사용자마다 값이 다르지만 한 SignupQuota 안에서는 바뀌지 않는 설정입니다.

final의 한 번 대입과 객체별·클래스별 값의 범위

final 지역 변수는 실행 경로별 한 번 대입하고, 생성자에서 정한 final 필드는 객체마다 다른 값을 가질 수 있다. 예제의 나이 기준 두 static final int 필드는 클래스에 공유되며 상수식으로 초기화한 상수 변수이다.

final의 대입 제약과 static의 공유 범위는 별개입니다

앞의 서로 다른 예제를 비교합니다. final은 대입 제약이고, static 유무는 필드가 클래스에 속하는지 객체별로 존재하는지를 구분합니다.

지역 변수, 객체별 필드, 클래스 공유 필드의 final 적용 범위
구분실제 예제와 값의 범위
final 지역 변수

FinalLocalReassignment: targetCount = 120으로 초기화한 뒤 180을 재대입하면 컴파일 오류입니다.

별도 분기 예제의 target은 각 실행 경로에서 한 번 대입합니다. 인수가 없으면 60, 있으면 Integer.parseInt(args[0])입니다.

final 인스턴스 필드

FinalInstanceFields의 name과 capacity는 생성자에서 정합니다. personal은 "personal", 3, work는 "work", 10을 보관합니다.

두 객체가 같은 값을 공유하는 것이 아니라 각 객체의 설정이 고정됩니다.

static final 클래스 필드

SignupPolicyConstants의 SignupPolicy는 MIN_MEMBER_AGE = 14, MAX_MEMBER_AGE = 120을 클래스에 둡니다.

이 두 필드는 객체마다 다른 설정이 아니라 클래스가 공유하는 나이 기준입니다.

final의 대입 제약과 static의 공유 범위는 별개입니다 별도지역final120→180재대입컴파일실패;별도personal/3,work/10인스턴스final설정;별도SignupPolicy공유14/120→40true/130false. 상수변수조건기존본문유지. 지역 변수 · 컴파일 실패 예제 final targetCount=120 180 재대입 시도 컴파일 실패 프로그램 실행 없음 객체별 final 설정 · 서로 다른 두 객체 personal 객체 name=personal capacity=3 work 객체 name=work capacity=10 클래스 공유 정책 · SignupPolicy static final 필드 MIN_MEMBER_AGE=14 MAX_MEMBER_AGE=120 isAllowed(age) 40 → true 130 → false final은 대입 제약, static은 공유 범위 · 세 영역은 독립 예제
final의 대입 제약과 static의 공유 범위는 별개입니다 별도지역final120→180재대입컴파일실패;별도personal/3,work/10인스턴스final설정;별도SignupPolicy공유14/120→40true/130false. 상수변수조건기존본문유지. 지역 변수 · 별도 오류 예제 final targetCount 120으로 한 번 대입 180 재대입 시도 컴파일 실패 이 프로그램 실행 없음 분기 target 예제는 별도 객체별 final 설정 personal 객체 name: personal capacity: 3 work 객체 name: work capacity: 10 각 생성자에서 자기 필드 대입 클래스 공유 정책 SignupPolicy MIN_MEMBER_AGE=14 MAX_MEMBER_AGE=120 isAllowed(age) 공유 기준으로 판정 40 → true 130 → false final 대입 제약 ≠ static 공유 각 영역은 독립 예제
final 지역 변수

FinalLocalReassignment: targetCount = 120으로 초기화한 뒤 180을 재대입하면 컴파일 오류입니다.

별도 분기 예제의 target은 각 실행 경로에서 한 번 대입합니다. 인수가 없으면 60, 있으면 Integer.parseInt(args[0])입니다.

final 인스턴스 필드

FinalInstanceFields의 name과 capacity는 생성자에서 정합니다. personal은 "personal", 3, work는 "work", 10을 보관합니다.

두 객체가 같은 값을 공유하는 것이 아니라 각 객체의 설정이 고정됩니다.

static final 클래스 필드

SignupPolicyConstants의 SignupPolicy는 MIN_MEMBER_AGE = 14, MAX_MEMBER_AGE = 120을 클래스에 둡니다.

이 두 필드는 객체마다 다른 설정이 아니라 클래스가 공유하는 나이 기준입니다.

위 나이 기준 두 필드는 final int를 상수식으로 초기화했으므로 JLS의 상수 변수이기도 합니다. 이 분류는 final인 기본형·String과 상수식 초기화가 조건이며, static final이라는 표기만으로 정해지지는 않습니다.


매직 숫자와 의미

if (age > 120) { ... }

숫자만 보면 120이 최대 회원 나이인지 다른 업무 한도인지 알 수 없습니다.

if (age > MAX_MEMBER_AGE) { ... }

이름이 규칙의 의미와 단위를 보여 줍니다.

같은 값 60이라도 회원 나이 기준과 게시판 페이지 크기는 다른 상수입니다.

static final int SENIOR_MEMBER_AGE = 60;
static final int DEFAULT_PAGE_SIZE = 60;

숫자가 같다는 이유로 하나의 상수를 재사용하면 회원 분류와 게시판 조회 규칙이 우연히 결합합니다.

이후 고령 회원 기준만 65로 바꿀 때 게시판 페이지 크기까지 바뀌는 결함이 생깁니다.

상수는 값 중복이 아니라 개념 중복을 제거합니다.

모든 숫자를 상수로 뽑을 필요는 없습니다.

배열의 첫 인덱스 0, 한 단계 증가 1처럼 문맥에서 구조적 의미가 명확한 값은 그대로 읽기 쉬울 수 있습니다.


정책 상수와 인스턴스 설정

src/FinalMemberRegistryPolicy.java
public final class FinalMemberRegistryPolicy {
    public static void main(String[] args) {
        MemberRegistry registry = new MemberRegistry("daily", 2);

        System.out.println(registry.add("[email protected]", 40));
        System.out.println(registry.add("[email protected]", 130));
        System.out.println(registry.add("[email protected]", 50));
        System.out.println(registry.summary());
    }

    private static final class MemberRegistry {
        private static final int MAX_MEMBER_AGE = 120;
        private final String name;
        private final Member[] members;
        private int size;

        MemberRegistry(String name, int capacity) {
            if (name == null || name.isBlank() || capacity <= 0) {
                throw new IllegalArgumentException("configuration");
            }
            this.name = name;
            this.members = new Member[capacity];
        }

        boolean add(String email, int age) {
            if (size == members.length || age < 14 || age > MAX_MEMBER_AGE) {
                return false;
            }
            members[size++] = new Member(email, age);
            return true;
        }

        String summary() {
            int total = 0;
            for (int index = 0; index < size; index++) {
                total += members[index].age();
            }
            return name + ":count=" + size + ", total=" + total;
        }
    }

    private record Member(String email, int age) {}
}
true
false
true
daily:count=2, total=90

MAX_MEMBER_AGE는 모든 MemberRegistry가 공유하는 클래스 정책입니다.

name과 members 참조는 객체별로 다르지만 생성 뒤 재대입하지 않는 final 필드입니다.

size는 회원 추가와 삭제에 따라 변해야 하므로 final이 아닙니다.

final 배열 참조를 유지하면서 바뀌는 슬롯과 회원 수

동일 final 배열에서 생성 직후·Kim 성공·130세 거절·Lee 성공의 슬롯과 size가 변합니다. 참조는 고정이어도 내부 상태가 바뀌는 실제 네 시점입니다.

final 배열 참조를 유지하면서 바뀌는 슬롯과 회원 수

FinalMemberRegistryPolicy에서 members의 final 참조는 동일한 길이2 배열을 계속 가리키지만 슬롯은 null에서 회원 참조로 바뀌고 size는0에서2가 된다. 나이130 요청은 상태를 바꾸지 않고 false를 반환한다.

배열 참조는 고정되어도 슬롯과 회원 수는 바뀝니다

FinalMemberRegistryPolicy의 같은 registry를 비교합니다. 생성 인수는 "daily", 2이며 표의 마지막 상태는 세 add 호출을 모두 마친 뒤입니다.

생성 직후와 세 add 뒤의 동일 배열 참조, 두 슬롯, size
대상생성 직후세 add 뒤
members길이 2인 배열의 참조같은 배열의 참조

final이므로 다른 배열로 재대입하지 못함

members[0]null[email protected]

age = 40인 Member의 참조

members[1]null[email protected]

age = 50인 Member의 참조

size02

성공한 추가마다 증가하는 가변 필드

배열 참조는 고정되어도 슬롯과 회원 수는 바뀝니다 finalmembers동일길이2배열ref고정;초기null/null0→Kim40성공1→130거절동일1→Lee50성공2,합계90. 네시점은네배열생성이아님;Kim40/Lee50본문회원약칭. registry.members · final 같은 길이 2 배열의 네 시점 생성 직후 [0] null / [1] null size = 0 Kim 추가 → true [0] Kim40 / [1] null size = 1 130세 거절 → false [0] Kim40 / [1] null size = 1 유지 Lee 추가 → true [0] Kim40 / [1] Lee50 size = 2 130세는 검사에서 거절 네 시점의 members 참조는 동일 배열 · 슬롯과 size만 변화 Kim40 / Lee50은 본문 두 회원 · 최종 daily:count=2, total=90
배열 참조는 고정되어도 슬롯과 회원 수는 바뀝니다 finalmembers동일길이2배열ref고정;초기null/null0→Kim40성공1→130거절동일1→Lee50성공2,합계90. 네시점은네배열생성이아님;Kim40/Lee50본문회원약칭. registry.members final 참조 고정 같은 길이 2 배열 생성 직후 [0] null / [1] null size = 0 Kim 추가 → true [0] Kim40 / [1] null size = 1 130세 거절 → false [0] Kim40 / [1] null size = 1 유지 Lee 추가 → true [0] Kim40 / [1] Lee50 size = 2 네 시점의 배열은 같은 객체 Kim40 / Lee50은 본문 회원 daily:count=2, total=90
members: 같은 배열 참조
생성 직후에도 세 add 뒤에도 길이 2인 같은 배열을 가리킵니다. final 필드이므로 다른 배열의 참조로 재대입하지 못합니다.
members[0]: 슬롯 변경
처음의 null 대신 이메일 [email protected], age = 40인 Member의 참조를 저장합니다.
members[1]: 슬롯 변경
처음의 null 대신 이메일 [email protected], age = 50인 Member의 참조를 저장합니다.
size: 회원 수 변경
가변 필드이므로 성공한 추가마다 증가하여 0 → 2가 됩니다.

첫 요청 [email protected], 40은 true, 둘째 [email protected], 130은 나이 검사에서 false를 반환하고 슬롯·size를 바꾸지 않습니다. 셋째 [email protected], 50은 true입니다.

요약 출력은 daily:count=2, total=90입니다. final인 배열 참조가 고정되어도 배열 슬롯까지 불변이 되는 것은 아닙니다.

이 예의 조건

  • members의 final 참조는 모든 시점에 같은 길이 2 배열이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2final members는 동일한 길이 2 배열의 ref로 고정된다. 초기 null/null 0→Kim 40 성공 1→130 거절 동일 1→Lee 50 성공 2, 합계 90이다. 네 시점은 네 배열을 생성한 것이 아니며 Kim 40/Lee 50은 본문 회원의 약칭이다.
6[0] null
7[1] null
10[0] Kim 40
11[1] null
14[0] Kim 40
15[1] null
18[0] Kim 40
19[1] Lee 50
23Kim 40
24Lee 50은 본문 두 회원 · 최종 daily:count=2, total=90
원 도표의 설명과 주석
구분내용
설명final members는 동일한 길이 2 배열의 ref로 고정된다. 초기 null/null 0→Kim 40 성공 1→130 거절 동일 1→Lee 50 성공 2, 합계 90이다. 네 시점은 네 배열을 생성한 것이 아니며 Kim 40/Lee 50은 본문 회원의 약칭이다.
전체 읽기 정보final 배열 참조를 유지하면서 바뀌는 슬롯과 회원 수 final 배열 참조를 유지하면서 바뀌는 슬롯과 회원 수 FinalMemberRegistryPolicy에서 members의 final 참조는 동일한 길이 2 배열을 계속 가리키지만 슬롯은 null에서 회원 참조로 바뀌고 size는 0에서 2가 된다. 나이 130 요청은 상태를 바꾸지 않고 false를 반환한다. 배열 참조는 고정되어도 슬롯과 회원 수는 바뀝니다 FinalMemberRegistryPolicy 의 같은 registry 를 비교합니다. 생성 인수는 "daily", 2 이며 표의 마지막 상태는 세 add 호출을 모두 마친 뒤입니다. 생성 직후와 세 add 뒤의 동일 배열 참조, 두 슬롯, size 대상 생성 직후 세 add 뒤 members 길이 2 인 배열의 참조 같은 배열의 참조 final 이므로 다른 배열로 재대입하지 못함 members[0] null [email protected] age = 40 인 Member 의 참조 members[1] null [email protected] age = 50 인 Member 의 참조 size 0 2 성공한 추가마다 증가하는 가변 필드 members : 같은 배열 참조 생성 직후에도 세 add 뒤에도 길이 2 인 같은 배열을 가리킵니다. final 필드이므로 다른 배열의 참조로 재대입하지 못합니다. members[0] : 슬롯 변경 처음의 null 대신 이메일 [email protected] , age = 40 인 Member 의 참조를 저장합니다. members[1] : 슬롯 변경 처음의 null 대신 이메일 [email protected] , age = 50 인 Member 의 참조를 저장합니다. size : 회원 수 변경 가변 필드이므로 성공한 추가마다 증가하여 0 → 2 가 됩니다. 첫 요청 [email protected], 40 은 true , 둘째 [email protected], 130 은 나이 검사에서 false 를 반환하고 슬롯· size 를 바꾸지 않습니다. 셋째 [email protected], 50 은 true 입니다. 요약 출력은 daily:count=2, total=90 입니다. final 인 배열 참조가 고정되어도 배열 슬롯까지 불변이 되는 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
빈 저장소

두 null 칸과 size 0을 생성합니다.

  • 배열: [null,null]
  • size: 0
  • 참조를 초기화해도 슬롯 값은 나중에 변경할 수 있다.
정상 첫 회원

Kim 40을 추가합니다.

  • 배열: [Kim 40,null]
  • size: 1
  • 결과: true
  • 성공한 저장은 슬롯과 사용 개수를 바꾼다.
130세 거절

잘못된 나이는 저장하지 않습니다.

  • 배열: [Kim 40,null] 유지
  • size: 1 유지
  • 결과: false
  • final과 별개로 검증 정책이 상태 변경을 막는다.
정상 둘째 회원

Lee 50을 추가합니다.

  • 배열: [Kim 40,Lee 50]
  • size: 2
  • total: 90
  • 참조 재대입 없이 같은 배열의 빈 슬롯을 채운다.

final 배열 참조를 유지하면서 바뀌는 슬롯과 회원 수

동일 final 배열에서 생성 직후·Kim 성공·130세 거절·Lee 성공의 슬롯과 size가 변합니다. 참조는 고정이어도 내부 상태가 바뀌는 실제 네 시점입니다.

final 배열 참조를 유지하면서 바뀌는 슬롯과 회원 수

FinalMemberRegistryPolicy에서 members의 final 참조는 동일한 길이2 배열을 계속 가리키지만 슬롯은 null에서 회원 참조로 바뀌고 size는0에서2가 된다. 나이130 요청은 상태를 바꾸지 않고 false를 반환한다.

배열 참조는 고정되어도 슬롯과 회원 수는 바뀝니다

FinalMemberRegistryPolicy의 같은 registry를 비교합니다. 생성 인수는 "daily", 2이며 표의 마지막 상태는 세 add 호출을 모두 마친 뒤입니다.

생성 직후와 세 add 뒤의 동일 배열 참조, 두 슬롯, size
대상생성 직후세 add 뒤
members길이 2인 배열의 참조같은 배열의 참조

final이므로 다른 배열로 재대입하지 못함

members[0]null[email protected]

age = 40인 Member의 참조

members[1]null[email protected]

age = 50인 Member의 참조

size02

성공한 추가마다 증가하는 가변 필드

배열 참조는 고정되어도 슬롯과 회원 수는 바뀝니다 finalmembers동일길이2배열ref고정;초기null/null0→Kim40성공1→130거절동일1→Lee50성공2,합계90. 네시점은네배열생성이아님;Kim40/Lee50본문회원약칭. registry.members · final 같은 길이 2 배열의 네 시점 생성 직후 [0] null / [1] null size = 0 Kim 추가 → true [0] Kim40 / [1] null size = 1 130세 거절 → false [0] Kim40 / [1] null size = 1 유지 Lee 추가 → true [0] Kim40 / [1] Lee50 size = 2 130세는 검사에서 거절 네 시점의 members 참조는 동일 배열 · 슬롯과 size만 변화 Kim40 / Lee50은 본문 두 회원 · 최종 daily:count=2, total=90
배열 참조는 고정되어도 슬롯과 회원 수는 바뀝니다 finalmembers동일길이2배열ref고정;초기null/null0→Kim40성공1→130거절동일1→Lee50성공2,합계90. 네시점은네배열생성이아님;Kim40/Lee50본문회원약칭. registry.members final 참조 고정 같은 길이 2 배열 생성 직후 [0] null / [1] null size = 0 Kim 추가 → true [0] Kim40 / [1] null size = 1 130세 거절 → false [0] Kim40 / [1] null size = 1 유지 Lee 추가 → true [0] Kim40 / [1] Lee50 size = 2 네 시점의 배열은 같은 객체 Kim40 / Lee50은 본문 회원 daily:count=2, total=90
members: 같은 배열 참조
생성 직후에도 세 add 뒤에도 길이 2인 같은 배열을 가리킵니다. final 필드이므로 다른 배열의 참조로 재대입하지 못합니다.
members[0]: 슬롯 변경
처음의 null 대신 이메일 [email protected], age = 40인 Member의 참조를 저장합니다.
members[1]: 슬롯 변경
처음의 null 대신 이메일 [email protected], age = 50인 Member의 참조를 저장합니다.
size: 회원 수 변경
가변 필드이므로 성공한 추가마다 증가하여 0 → 2가 됩니다.

첫 요청 [email protected], 40은 true, 둘째 [email protected], 130은 나이 검사에서 false를 반환하고 슬롯·size를 바꾸지 않습니다. 셋째 [email protected], 50은 true입니다.

요약 출력은 daily:count=2, total=90입니다. final인 배열 참조가 고정되어도 배열 슬롯까지 불변이 되는 것은 아닙니다.

이 예의 조건

  • members의 final 참조는 모든 시점에 같은 길이 2 배열이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2final members는 동일한 길이 2 배열의 ref로 고정된다. 초기 null/null 0→Kim 40 성공 1→130 거절 동일 1→Lee 50 성공 2, 합계 90이다. 네 시점은 네 배열을 생성한 것이 아니며 Kim 40/Lee 50은 본문 회원의 약칭이다.
6[0] null
7[1] null
10[0] Kim 40
11[1] null
14[0] Kim 40
15[1] null
18[0] Kim 40
19[1] Lee 50
23Kim 40
24Lee 50은 본문 두 회원 · 최종 daily:count=2, total=90
원 도표의 설명과 주석
구분내용
설명final members는 동일한 길이 2 배열의 ref로 고정된다. 초기 null/null 0→Kim 40 성공 1→130 거절 동일 1→Lee 50 성공 2, 합계 90이다. 네 시점은 네 배열을 생성한 것이 아니며 Kim 40/Lee 50은 본문 회원의 약칭이다.
전체 읽기 정보final 배열 참조를 유지하면서 바뀌는 슬롯과 회원 수 final 배열 참조를 유지하면서 바뀌는 슬롯과 회원 수 FinalMemberRegistryPolicy에서 members의 final 참조는 동일한 길이 2 배열을 계속 가리키지만 슬롯은 null에서 회원 참조로 바뀌고 size는 0에서 2가 된다. 나이 130 요청은 상태를 바꾸지 않고 false를 반환한다. 배열 참조는 고정되어도 슬롯과 회원 수는 바뀝니다 FinalMemberRegistryPolicy 의 같은 registry 를 비교합니다. 생성 인수는 "daily", 2 이며 표의 마지막 상태는 세 add 호출을 모두 마친 뒤입니다. 생성 직후와 세 add 뒤의 동일 배열 참조, 두 슬롯, size 대상 생성 직후 세 add 뒤 members 길이 2 인 배열의 참조 같은 배열의 참조 final 이므로 다른 배열로 재대입하지 못함 members[0] null [email protected] age = 40 인 Member 의 참조 members[1] null [email protected] age = 50 인 Member 의 참조 size 0 2 성공한 추가마다 증가하는 가변 필드 members : 같은 배열 참조 생성 직후에도 세 add 뒤에도 길이 2 인 같은 배열을 가리킵니다. final 필드이므로 다른 배열의 참조로 재대입하지 못합니다. members[0] : 슬롯 변경 처음의 null 대신 이메일 [email protected] , age = 40 인 Member 의 참조를 저장합니다. members[1] : 슬롯 변경 처음의 null 대신 이메일 [email protected] , age = 50 인 Member 의 참조를 저장합니다. size : 회원 수 변경 가변 필드이므로 성공한 추가마다 증가하여 0 → 2 가 됩니다. 첫 요청 [email protected], 40 은 true , 둘째 [email protected], 130 은 나이 검사에서 false 를 반환하고 슬롯· size 를 바꾸지 않습니다. 셋째 [email protected], 50 은 true 입니다. 요약 출력은 daily:count=2, total=90 입니다. final 인 배열 참조가 고정되어도 배열 슬롯까지 불변이 되는 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
빈 저장소

두 null 칸과 size 0을 생성합니다.

  • 배열: [null,null]
  • size: 0
  • 참조를 초기화해도 슬롯 값은 나중에 변경할 수 있다.
정상 첫 회원

Kim 40을 추가합니다.

  • 배열: [Kim 40,null]
  • size: 1
  • 결과: true
  • 성공한 저장은 슬롯과 사용 개수를 바꾼다.
130세 거절

잘못된 나이는 저장하지 않습니다.

  • 배열: [Kim 40,null] 유지
  • size: 1 유지
  • 결과: false
  • final과 별개로 검증 정책이 상태 변경을 막는다.
정상 둘째 회원

Lee 50을 추가합니다.

  • 배열: [Kim 40,Lee 50]
  • size: 2
  • total: 90
  • 참조 재대입 없이 같은 배열의 빈 슬롯을 채운다.

연습 문제

SignupQuota에 객체별 final targetCount를 두고, 클래스 전체 상수 PERCENT_SCALE=100을 사용해 달성률을 계산하세요.

누적 등록 수는 변경 가능해야 하며 달성률은 100을 넘지 않게 합니다.

해설 보기
src/FinalSignupQuota.java
public final class FinalSignupQuota {
    public static void main(String[] args) {
        SignupQuota quota = new SignupQuota(120);
        quota.register(50);
        quota.register(90);
        System.out.println("progress=" + quota.progress());
    }

    private static final class SignupQuota {
        private static final int PERCENT_SCALE = 100;
        private final int targetCount;
        private int registeredCount;

        SignupQuota(int targetCount) {
            if (targetCount <= 0) throw new IllegalArgumentException("target");
            this.targetCount = targetCount;
        }

        void register(int count) {
            if (count <= 0) throw new IllegalArgumentException("count");
            registeredCount += count;
        }

        int progress() {
            int raw = registeredCount * PERCENT_SCALE / targetCount;
            return Math.min(raw, PERCENT_SCALE);
        }
    }
}
progress=100

목표는 객체별로 한 번 정하고, 백분율 배율은 모든 객체가 공유합니다.

누적 값만 행동에 따라 변합니다.

원래 풀이의 작은 입력은 50 + 90 = 140, 140 × 100 / 120 = 116에서 100으로 제한됩니다. 다만 양수 int를 모두 허용하는 일반 계약으로 사용하면 두 산술 경계를 보완해야 합니다. registeredCount * 100은 30,000,000명에서 이미 int 범위를 넘어 음수가 되며, Math.min은 그 음수를 고쳐 주지 못합니다. 반복 등록으로 누적 합계 자체가 넘치는 문제도 별도입니다.

원래 예제는 그대로 관찰하고, 같은 SignupQuota의 다음 두 메서드만 바꾸어 큰 입력을 다루는 버전을 비교하세요. 합계가 표현 범위를 넘으면 거절하고 기존 등록 수를 유지합니다.

FinalSignupQuota의 산술 경계 보완
void register(int count) {
    if (count <= 0) throw new IllegalArgumentException("count");
    registeredCount = Math.addExact(registeredCount, count);
}

int progress() {
    long raw = (long) registeredCount * PERCENT_SCALE / targetCount;
    return (int) Math.min(raw, (long) PERCENT_SCALE);
}

long 변환을 곱하기 전에 했으므로 중간 곱도 넓은 범위에서 계산합니다. Math.addExact가 예외를 던지면 우변 평가가 끝나지 않아 필드 대입도 실행되지 않습니다. 계산을 마친 뒤에만 대입한다는 순서를 이용한 보완입니다.

독립적으로 확인해 보세요. 목표 100에 30,000,000을 등록하면 보완판은 100을 반환합니다. 새 목표 100에 Integer.MAX_VALUE를 등록한 뒤 1을 더하면 ArithmeticException이며, 보관한 등록 수는 2,147,483,647을 유지합니다. 작은 원래 예제의 출력 progress=100도 유지되어야 합니다.