final 변수·필드·상수
final이 변수에 값을 한 번만 대입하게 하는 컴파일 규칙임을 이해하고, 객체마다 정하는 final 필드와 클래스 전체가 함께 쓰는 static final 상수를 구분해 쓰는 방법을 배웁니다.
프로그램에는 한 번 정한 뒤로는 바뀌면 안 되는 값이 많습니다. 가입 캠페인의 목표 인원, 회원 저장소의 이름과 용량, 가입할 수 있는 최소 나이 같은 값입니다. 그런데 보통의 변수는 언제든 다시 대입할 수 있어서, 수십 줄 아래에서 누군가 targetCount = 180;이라고 쓰면 목표가 아무 경고 없이 바뀝니다. 이런 실수는 대개 실행 결과가 이상하게 나온 뒤에야 발견됩니다.
변수 선언에 final을 붙이면 이 실수를 컴파일 단계에서 잡을 수 있습니다. final은 '이 변수에는 값을 한 번만 대입한다'는 선언이고, 컴파일러는 두 번째 대입이 일어날 수 있는 코드를 발견하면 컴파일을 거부합니다. 이 글에서는 final을 지역 변수, 인스턴스 필드, static 필드에 차례로 붙여 봅니다. 한 번만 대입한다는 규칙은 세 곳에서 같지만, 언제 대입하는지와 값이 몇 개 존재하는지는 서로 다릅니다. 마지막으로 static final 상수로 숫자에 이름을 붙여 코드의 뜻을 드러내는 방법을 다룹니다.
참고로 이 교재의 예제 프로그램은 대부분 public final class로 시작합니다. 클래스에 붙은 final은 변수에 붙은 final과 뜻이 달라서, 다른 클래스가 이 클래스를 상속할 수 없게 합니다. 상속은 7장에서 배우므로, 이 글에서는 변수와 필드에 붙는 final만 다룹니다.
final 지역 변수에 다시 대입하면
먼저 final 지역 변수에 값을 두 번 대입해 보겠습니다. 다음 코드는 목표 인원 targetCount를 120으로 정한 뒤 180으로 바꾸려고 합니다. javac -d out lab/FinalLocalReassignment.java로 컴파일하면 코드 아래와 같은 오류가 납니다.
public final class FinalLocalReassignment {
public static void main(String[] args) {
final int targetCount = 120;
targetCount = 180;
System.out.println(targetCount);
}
}lab/FinalLocalReassignment.java:4: error: cannot assign a value to final variable targetCount
targetCount = 180;
^
1 error오류 메시지는 'final 변수 targetCount에는 값을 대입할 수 없다'는 뜻이고, 4번째 줄의 두 번째 대입을 가리킵니다. 컴파일이 실패했으므로 class 파일은 만들어지지 않고 프로그램은 실행되지도 않습니다. final이 없었다면 이 코드는 아무 문제 없이 컴파일되어 180을 출력했을 것입니다. 즉 final은 실행 중에 값을 감시하는 장치가 아니라, 컴파일러가 코드를 읽으면서 대입이 몇 번 일어날 수 있는지 따지는 규칙입니다.
선언할 때 값을 정할 수 없다면
final 변수라고 해서 선언과 동시에 값을 줘야 하는 것은 아닙니다. 값이 실행 조건에 따라 달라진다면 선언만 먼저 하고 나중에 한 번 대입할 수 있습니다. 이렇게 초깃값 없이 선언한 final 변수를 빈 final(blank final)이라고 부릅니다. 다음 프로그램은 실행 인자가 없으면 목표를 60으로 정하고, 인자가 있으면 그 숫자로 정합니다.
public final class FinalBranchAssignment {
public static void main(String[] args) {
final int target;
if (args.length == 0) {
target = 60;
} else {
target = Integer.parseInt(args[0]);
}
System.out.println("target=" + target);
}
}javac -d out src/FinalBranchAssignment.java로 컴파일한 뒤 인자 없이, 그리고 인자 90을 주고 실행한 결과입니다. >로 시작하는 줄이 입력한 명령입니다.
> java -cp out FinalBranchAssignment
target=60
> java -cp out FinalBranchAssignment 90
target=90인자가 없으면 if 쪽에서, 있으면 else 쪽에서 target에 값이 들어갑니다. 어느 쪽으로 실행되든 대입은 정확히 한 번 일어나고, println에 도달하기 전에 값이 반드시 정해집니다. 컴파일러는 모든 실행 경로를 따라가며 이 두 가지를 확인합니다. 변수를 읽기 전에 값이 확실히 대입되었는지는 final이 아닌 지역 변수에서도 확인하는 규칙이고, final 변수에서는 이미 대입된 변수에 다시 대입할 가능성이 없는지를 하나 더 확인합니다.
두 조건 중 하나라도 어기면 컴파일 오류가 납니다. 위 코드에서 else 부분을 지우면, 인자가 있을 때 target이 비어 있는 채로 println에 도달하는 경로가 생깁니다. 그러면 컴파일러는 target을 읽는 println 줄에서 다음 오류를 냅니다.
error: variable target might not have been initialized반대로 target = 60;을 먼저 실행하고 if (args.length > 0) 안에서 다시 대입하도록 바꾸면, 인자가 있을 때 두 번 대입하는 경로가 생깁니다. 이번에는 두 번째 대입 줄에서 다른 오류가 납니다.
error: variable target might already have been assigned두 메시지에는 모두 '그럴 수도 있다'는 뜻의 might가 들어 있습니다. 컴파일러는 조건이 실행 중에 참일지 거짓일지 계산하지 않고, if의 두 갈래가 모두 실행될 수 있다고 보고 경로를 따지기 때문입니다. 그래서 args.length >= 0처럼 사람이 보기에는 늘 참인 조건을 쓰더라도, else가 없으면 똑같이 거부됩니다. 조건이 true나 1 < 2처럼 컴파일할 때 값이 정해지는 상수식인 경우만 예외이고, 이때는 컴파일러가 그 값을 알고 판단합니다.
final이 확인하는 것은 대입 횟수뿐이고, 값이 올바른지는 확인하지 않습니다. 위 프로그램에 인자 -1을 주면 Integer.parseInt가 정상적으로 -1을 돌려주므로 target=-1이 출력됩니다. '한 번만 대입한다'와 '목표는 양수다'는 서로 다른 규칙이라서, 목표가 양수여야 한다면 그 검사는 따로 작성해야 합니다. 한편 인자가 abc처럼 숫자가 아니면 parseInt가 NumberFormatException을 던집니다. 이 경로에서는 target에 값이 대입되지 않지만, 예외 때문에 println까지 가지도 않으므로 값이 없는 target을 읽는 일은 생기지 않습니다.
더 깊이: 지역 변수의 final은 class 파일에 남지 않는다
class 파일 형식에는 메서드 안에서 선언한 지역 변수가 final인지 기록하는 자리가 없습니다. 그래서 지역 변수의 final은 컴파일러가 확인하고 끝나는 규칙이고, JVM은 그런 선언이 있었는지 알지 못합니다. 실제로 위 FinalBranchAssignment에서 final만 지우고 다시 컴파일하면 바이트 단위로 똑같은 class 파일이 나옵니다. 예외는 final int limit = 120;처럼 상수식으로 초기화한 지역 변수입니다. 이런 변수는 뒤에서 설명할 상수 변수가 되어, 컴파일러가 변수를 읽는 자리에 값 120을 직접 써넣기 때문에 class 파일이 달라집니다.
그러니 지역 변수의 final은 성능을 위한 장치라기보다, 값이 바뀌지 않는다는 사실을 읽는 사람에게 알리고 실수로 다시 대입하는 것을 막는 장치입니다. 반면 필드에 붙인 final은 class 파일의 필드 정보에 ACC_FINAL 표시로 남으므로, JVM과 리플렉션도 그 사실을 알 수 있습니다.
final을 붙이지 않았어도 값이 한 번 정해진 뒤 다시 대입되지 않는 지역 변수를 사실상 final(effectively final)이라고 합니다. Java 8에서 언어 명세에 들어간 개념으로, 지역 클래스나 람다가 바깥의 지역 변수를 사용하려면 그 변수가 final이거나 사실상 final이어야 한다는 규칙에 쓰입니다. 이 규칙은 지역 클래스와 변수 캡처와 익명 클래스와 람다의 차이에서 다룹니다.
final 필드는 객체마다 한 번 정한다
final은 필드에도 붙일 수 있습니다. 다음 예제의 회원 저장소 MemberRegistry는 이름과 용량을 생성자에서 받아 두 final 필드에 저장합니다. main은 이름과 용량이 다른 저장소 두 개를 만들어 각각의 설정을 출력합니다.
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:10personal과 work는 서로 다른 객체이므로 각자 자기 name과 capacity를 가집니다. JVM 메모리 구조: 스택, 힙, 메서드 영역에서 본 것처럼 인스턴스 필드는 힙에 있는 객체 안에 들어 있고, new를 실행할 때마다 새 객체와 함께 새로 생깁니다. 그래서 final 인스턴스 필드는 객체마다 다른 값을 가질 수 있습니다. final이 뜻하는 것은 '모든 객체의 값이 같다'가 아니라 '각 객체가 생성자에서 정한 값을 그 뒤로 바꾸지 않는다'입니다.
final 인스턴스 필드에는 선언할 때 값을 주거나 생성자에서 대입해야 합니다. 인스턴스 초기화 블록에서 대입하는 방법도 있지만 이 교재에서는 쓰지 않습니다. 컴파일러는 생성자마다 그 생성자가 끝나는 시점에 모든 final 인스턴스 필드가 정확히 한 번 대입되었는지 확인합니다. 예를 들어 이름만 받는 생성자를 하나 더 만들면서 capacity 대입을 잊으면 다음과 같은 오류가 납니다.
MemberRegistry(String name) {
this.name = name;
}error: variable capacity might not have been initialized이 오류는 대입을 빠뜨린 자리가 아니라 생성자의 닫는 중괄호가 있는 줄에서 보고됩니다. 생성자가 끝나는 순간이 모든 final 필드의 값이 정해져 있어야 하는 시점이기 때문입니다. 생성자가 끝난 뒤에는 어떤 메서드에서도 final 필드에 대입할 수 없습니다. 저장소 이름을 바꾸는 rename 메서드를 만들고 그 안에서 this.name = newName;이라고 쓰면, 앞에서 본 것과 같은 cannot assign a value to final variable name 오류가 납니다.
생성자가 여러 개라면 생성자 오버로딩과 this() 호출에서 배운 this(...) 위임으로 필드 대입을 한 생성자에 모으는 것이 안전합니다. 다음처럼 쓰면 이름만 받는 생성자도 모든 필드를 대입하는 생성자를 거치므로 필드를 빠뜨릴 수 없습니다. 위임한 뒤에 this.name = name;을 한 번 더 쓰면 이번에는 두 번 대입하게 되어 variable name might already have been assigned 오류가 납니다.
MemberRegistry(String name) {
this(name, 10);
}필드를 final로 만들면 두 가지를 얻습니다. 첫째, 객체를 만들 때 반드시 정해야 하는 값이 생성자 매개변수로 드러납니다. MemberRegistry를 만들려면 이름과 용량을 줘야 하므로 이름 없는 저장소는 만들 방법이 없습니다. 둘째, 코드를 읽는 사람이 따라가야 할 상태가 줄어듭니다. capacity가 final이라는 것만 보면, 메서드를 하나하나 읽지 않고도 어떤 메서드도 용량을 바꾸지 않는다는 것을 알 수 있습니다.
설정을 바꿔야 할 일이 생기면 같은 객체의 필드를 고치는 대신 새 설정으로 새 객체를 만드는 방법을 먼저 생각해 볼 수 있습니다. 이 방식은 다음 글의 불변 객체에서 다룹니다.
final이 입력 검증을 대신하지 못한다는 점은 필드에서도 같습니다. 위 생성자는 name.isBlank()를 먼저 호출하므로, 이름으로 null을 넘기면 조건을 계산하는 도중에 NullPointerException이 납니다. 빈 이름이나 0 이하의 용량에 대해 던지는 IllegalArgumentException과는 다른 예외입니다. null도 같은 예외로 거절하려면 뒤에 나올 FinalMemberRegistryPolicy처럼 name == null을 가장 먼저 검사해야 합니다.
static final 상수는 클래스에 하나만 있다
가입할 수 있는 나이 기준처럼 모든 객체에 똑같이 적용되고 바뀌지도 않는 값은 객체마다 따로 둘 이유가 없습니다. static 변수: 모든 객체가 함께 쓰는 값에서 본 것처럼 static 필드는 객체가 몇 개든 클래스에 하나만 존재합니다. 여기에 final을 더하면 클래스에 하나뿐이고 다시 대입할 수 없는 값이 되는데, 이런 필드를 흔히 상수(constant)라고 부릅니다. 다음 SignupPolicy는 최소 나이와 최대 나이를 상수로 두고, 그 상수로 나이를 판정합니다.
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첫 두 줄은 두 상수의 값이고, 나머지 두 줄은 isAllowed가 두 상수로 나이를 판정한 결과입니다. 40은 14 이상 120 이하이므로 true, 130은 120을 넘으므로 false입니다. main은 SignupPolicy 객체를 하나도 만들지 않고 SignupPolicy.MIN_MEMBER_AGE처럼 클래스 이름으로 상수를 읽었습니다. SignupPolicy는 상수와 static 메서드만 가지므로 객체가 필요 없습니다. 그래서 static 메서드와 정적 import의 유틸리티 클래스처럼 생성자를 private으로 선언해, 객체를 만들어 쓰는 클래스가 아니라는 뜻을 드러냈습니다.
static final 필드에는 보통 선언할 때 값을 줍니다. 생성자에서는 대입할 수 없는데, 생성자는 객체를 만들 때마다 실행되므로 클래스에 하나뿐인 값을 객체가 생길 때마다 다시 정하는 셈이 되기 때문입니다. 실제로 다음처럼 쓰면 컴파일러가 거부합니다.
static final int MAX_MEMBER_AGE;
SignupPolicy() {
MAX_MEMBER_AGE = 120;
}error: cannot assign a value to static final variable MAX_MEMBER_AGE여러 문장으로 계산해야 해서 선언 한 줄로 값을 줄 수 없을 때는, 클래스가 초기화될 때 한 번 실행되는 정적 초기화 블록(static { ... })에서 대입할 수 있습니다. 이 교재의 예제는 모두 선언과 함께 값을 줍니다.
상수 이름은 MAX_MEMBER_AGE처럼 모두 대문자로 쓰고 단어 사이를 밑줄로 잇는 것이 관례입니다. 이 관례는 클래스 수준의 상수에만 적용합니다. final 지역 변수나 final 인스턴스 필드는 값이 바뀌지 않을 뿐 상수는 아니므로, targetCount처럼 보통 변수와 같은 방식으로 이름을 짓습니다.
어떤 값을 상수로 만들지 고를 때는 '바뀌지 않는다'는 것만으로는 부족하고, '모든 객체에 똑같이 적용된다'는 조건도 맞아야 합니다. 예를 들어 가입 캠페인의 목표 인원은 한 캠페인 안에서는 바뀌지 않지만 캠페인마다 다릅니다. 이런 값은 static이 아니라 다음처럼 객체마다 갖는 final 인스턴스 필드로 둡니다. 이 필드는 연습 문제 3에서 직접 써 봅니다.
private final int targetCount;지금까지 본 세 가지를 나란히 놓으면 다음 그림과 같습니다. final은 세 경우 모두 '한 번만 대입한다'는 같은 뜻이고, 필드의 값이 객체마다 있는지 클래스에 하나만 있는지는 static이 정합니다. 그림의 세 영역은 서로 독립된 예제입니다.
final 지역 변수는 실행 경로별 한 번 대입하고, 생성자에서 정한 final 필드는 객체마다 다른 값을 가질 수 있다. 예제의 나이 기준 두 static final int 필드는 클래스에 공유되며 상수식으로 초기화한 상수 변수이다.
final의 대입 제약과 static의 공유 범위는 별개입니다
앞의 서로 다른 예제를 비교합니다. final은 대입 제약이고, static 유무는 필드가 클래스에 속하는지 객체별로 존재하는지를 구분합니다.
| 구분 | 실제 예제와 값의 범위 |
|---|---|
| final 지역 변수 |
별도 분기 예제의 |
| 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 int를 상수식으로 초기화했으므로 JLS의 상수 변수이기도 합니다. 이 분류는 final인 기본형·String과 상수식 초기화가 조건이며, static final이라는 표기만으로 정해지지는 않습니다.
| 선언 | 값이 존재하는 단위 | 대입하는 곳 | 이 글의 예 |
|---|---|---|---|
final 지역 변수 | 메서드 호출마다 하나 | 선언할 때, 또는 실행 경로마다 한 번 | targetCount, target |
final 인스턴스 필드 | 객체마다 하나 | 선언할 때, 또는 생성자에서 | name, capacity |
static final 필드 | 클래스에 하나 | 선언할 때, 또는 정적 초기화 블록에서 | MIN_MEMBER_AGE, MAX_MEMBER_AGE |
그림의 설명에도 나오듯이, 두 나이 기준은 Java 언어 명세(JLS)가 상수 변수(constant variable)라고 부르는 것이기도 합니다. 상수 변수는 기본형이나 String 타입의 final 변수를 상수식(constant expression)으로 초기화한 것입니다. 상수식은 120, "daily", 60 * 2처럼 리터럴과 연산자, 다른 상수 변수만으로 이루어져서 컴파일할 때 값을 계산할 수 있는 식입니다.
그래서 static final이라고 모두 상수 변수인 것은 아닙니다. static final int[] LIMITS = {14, 120};은 배열 타입이라서, static final String LABEL = String.valueOf(120);은 메서드 호출이 상수식이 아니라서 상수 변수가 아닙니다. 반대로 final int limit = 120;처럼 static이 없는 지역 변수도 상수 변수가 될 수 있습니다. 이 분류가 실제로 무엇을 바꾸는지는 바로 아래에서 확인합니다.
배열 상수에는 주의할 점이 하나 더 있습니다. final은 LIMITS가 다른 배열을 가리키지 못하게 할 뿐이어서, 다른 코드가 LIMITS[1] = 999;처럼 배열의 칸을 바꾸는 것은 막지 못합니다. 이름은 상수처럼 보여도 내용은 바뀔 수 있는 것입니다. 왜 그런지는 다음 글에서 자세히 다룹니다.
더 깊이: 상수 변수의 값은 사용하는 코드에 복사된다
상수 변수를 따로 분류하는 이유는 컴파일러가 그 값을 사용하는 자리에 직접 써넣기 때문입니다. JLS는 상수 변수인 필드를 참조하는 코드를 컴파일할 때 필드 참조 대신 그 값을 쓰도록 정해 두었습니다. 그래서 다른 클래스의 상수를 쓰는 코드는 실행 중에 그 필드를 읽지 않습니다. 이 차이는 상수를 고친 뒤 일부 파일만 다시 컴파일할 때 드러납니다.
public final class Policy {
public static final int MAX_MEMBER_AGE = 120;
public static final int MAX_LOGIN_FAILURES = Integer.parseInt("5");
}public final class App {
public static void main(String[] args) {
System.out.println(Policy.MAX_MEMBER_AGE);
System.out.println(Policy.MAX_LOGIN_FAILURES);
}
}MAX_LOGIN_FAILURES는 비교를 위해 일부러 메서드 호출로 초기화했습니다. 메서드 호출은 상수식이 아니므로 이 필드는 static final이지만 상수 변수가 아닙니다. 먼저 두 파일을 함께 컴파일해 실행하고, 그다음 Policy.java의 120을 100으로, "5"를 "3"으로 고친 뒤 Policy.java만 다시 컴파일해 실행합니다.
> javac -d out lab/Policy.java lab/App.java
> java -cp out App
120
5
> javac -d out lab/Policy.java
> java -cp out App
120
3MAX_LOGIN_FAILURES는 실행할 때 Policy의 필드를 읽으므로 새 값 3이 나옵니다. 반면 MAX_MEMBER_AGE는 처음 컴파일할 때 App.class 안에 120이라는 값으로 들어갔기 때문에, App을 다시 컴파일하기 전까지 옛값을 출력합니다. 프로젝트 전체를 늘 함께 빌드한다면 문제가 되지 않지만, 라이브러리의 public 상수를 바꾸고 그 라이브러리를 쓰는 쪽을 다시 컴파일하지 않으면 이런 불일치가 생깁니다. 그래서 public 상수에는 앞으로도 바뀌지 않을 값만 두는 것이 좋습니다. 같은 이유로, 상수 변수를 읽는 것만으로는 그 상수를 선언한 클래스의 초기화도 일어나지 않습니다(JLS 12.4.1).
더 깊이: static 필드의 값은 실제로 어디에 있을까
JVM의 메모리 구조를 설명하는 그림에서는 흔히 static 필드를 클래스 정보와 함께 메서드 영역에 그립니다. JVM 메모리 구조: 스택, 힙, 메서드 영역의 세 영역으로 프로그램의 동작을 이해하기에는 이 그림으로 충분하지만, 명세가 정한 부분과 JVM 구현이 정한 부분은 구분해 둘 필요가 있습니다. JLS가 정하는 것은 static 필드가 객체를 몇 개 만들든 클래스마다 정확히 하나 존재하고, 클래스가 초기화될 때 생긴다는 점입니다(JLS 8.3.1.1). JVM 명세(JVMS 2.5.4)는 메서드 영역에 런타임 상수 풀, 필드와 메서드 정보, 메서드와 생성자의 코드 같은 클래스별 구조가 저장된다고 설명합니다. 하지만 메서드 영역을 메모리의 어디에 둘지는 정하지 않고 구현에 맡기며, 메서드 영역이 논리적으로는 힙의 일부라고까지 적어 둡니다.
실제 배치는 구현마다 다릅니다. Oracle JDK와 대부분의 OpenJDK 배포판이 쓰는 HotSpot JVM은 JDK 8부터 클래스 메타데이터를 자바 힙 바깥의 네이티브 메모리인 메타스페이스(Metaspace)에 둡니다. 반면 static 필드의 값은 그 클래스를 나타내는 java.lang.Class 객체 안에 함께 저장되어 자바 힙에 있습니다. 예전 HotSpot은 클래스 정보와 static 필드를 영구 영역(PermGen)이라는 별도 공간에 두었습니다. 이 공간을 없애는 작업(JEP 122)을 거치며 static 필드는 자바 힙으로, 클래스 메타데이터는 네이티브 메모리로 옮겨졌고, JDK 8부터는 영구 영역이 없습니다.
그러니 'static 필드는 메서드 영역에 있다'는 설명은 클래스마다 하나뿐이라는 성질을 보여 주는 개념 그림으로는 쓸 만하지만, HotSpot의 실제 메모리 위치로 읽으면 맞지 않습니다. 게다가 앞 절에서 본 것처럼 상수 변수는 사용하는 쪽 코드에 값이 복사되므로, 실행 중에 그 필드를 읽는 일 자체가 없을 수도 있습니다.
숫자에 이름을 붙이는 상수
조건문에 숫자를 그대로 쓰면 그 숫자가 무엇을 뜻하는지 코드만 보고는 알기 어렵습니다. 다음 조건의 120이 회원의 최대 나이인지, 다른 업무 한도인지는 이 줄만으로는 알 수 없습니다.
if (age > 120) { ... }이렇게 뜻을 설명하지 않은 채 코드에 박혀 있는 숫자를 매직 넘버(magic number)라고 부릅니다. 상수로 이름을 붙이면 같은 조건이 어떤 규칙인지를 스스로 말하게 됩니다.
if (age > MAX_MEMBER_AGE) { ... }이름을 붙이면 값을 바꿀 때도 편합니다. 최대 나이를 바꾸려면 상수 선언 한 곳만 고치면 되고, 코드 곳곳에서 120을 찾아 하나씩 고치다가 하나를 빠뜨릴 걱정도 없습니다. 다만 상수가 없애는 것은 같은 '값'의 반복이 아니라 같은 '개념'의 반복입니다. 고령 회원 기준과 게시판 한 페이지의 글 수가 우연히 둘 다 60이라고 해 봅시다.
static final int SENIOR_MEMBER_AGE = 60;
static final int DEFAULT_PAGE_SIZE = 60;값이 같다고 SIXTY 같은 상수 하나로 합치면, 회원 분류와 게시판 조회라는 관계없는 두 규칙이 한 값에 묶입니다. 나중에 고령 회원 기준만 65로 올리면 게시판의 페이지 크기까지 함께 바뀌는 버그가 생깁니다. 그래서 값이 같더라도 뜻이 다르면 상수를 따로 둡니다.
그렇다고 모든 숫자를 상수로 뽑을 필요는 없습니다. 배열의 첫 인덱스 0이나 '하나 늘린다'는 뜻의 1처럼 문맥만으로 뜻이 분명한 숫자는 그대로 쓰는 편이 오히려 읽기 쉽습니다. 상수로 만들지는 두 가지로 판단하면 됩니다. 이 숫자를 처음 보는 사람이 뜻을 바로 알 수 있는지, 그리고 이 값이 바뀔 때 함께 고쳐야 하는 곳이 여러 군데인지입니다.
상수, 설정, 상태를 한 클래스에 두기
이제 지금까지의 세 가지를 한 클래스에 모아 보겠습니다. 다음 MemberRegistry는 모든 저장소가 함께 따르는 나이 기준, 저장소마다 만들 때 정하는 이름과 회원 배열, 회원을 추가할 때마다 바뀌는 회원 수를 함께 가집니다. main은 용량이 2인 저장소에 세 번 추가를 요청하는데, 두 번째 요청은 나이가 130이라서 거절됩니다.
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 MIN_MEMBER_AGE = 14;
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 < MIN_MEMBER_AGE || 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첫 번째와 세 번째 요청은 나이가 기준 안에 있고 자리도 남아 있어서 true를 돌려줍니다. 두 번째 요청은 130이 MAX_MEMBER_AGE를 넘으므로 아무것도 바꾸지 않고 false를 돌려줍니다. 마지막 줄의 합계는 저장된 두 회원의 나이를 더한 40 + 50 = 90입니다.
이 클래스의 필드는 바뀌는 방식에 따라 세 종류로 나뉩니다. MIN_MEMBER_AGE와 MAX_MEMBER_AGE는 모든 MemberRegistry가 함께 따르는 정책이므로 static final입니다. name과 members는 저장소마다 다르지만 생성자에서 한 번 정한 뒤 다른 값으로 바꾸지 않으므로 final 인스턴스 필드입니다. size는 회원을 추가할 때마다 늘어나야 하므로 final을 붙일 수 없습니다. 이렇게 필드마다 언제 바뀌어도 되는지를 따져서 static과 final을 정해 두면, 선언만 보고도 그 클래스에서 무엇이 변하는지 알 수 있습니다.
그런데 members가 final인데도 회원을 추가할 때마다 배열의 내용은 바뀝니다. 아래 단계별 그림은 같은 저장소의 네 시점을 보여 줍니다. members는 처음부터 끝까지 길이 2인 같은 배열을 가리키지만, 그 배열의 칸은 null에서 회원 참조로 바뀌고 size는 0에서 2가 됩니다. 130세 요청은 final과 관계없이 나이 검사에서 거절되므로 칸도 size도 바꾸지 않습니다.
final 배열 참조를 유지하면서 바뀌는 슬롯과 회원 수
동일 final 배열에서 생성 직후·Kim 성공·130세 거절·Lee 성공의 슬롯과 size가 변합니다. 참조는 고정이어도 내부 상태가 바뀌는 실제 네 시점입니다.
FinalMemberRegistryPolicy에서 members의 final 참조는 동일한 길이2 배열을 계속 가리키지만 슬롯은 null에서 회원 참조로 바뀌고 size는0에서2가 된다. 나이130 요청은 상태를 바꾸지 않고 false를 반환한다.
배열 참조는 고정되어도 슬롯과 회원 수는 바뀝니다
FinalMemberRegistryPolicy의 같은 registry를 비교합니다. 생성 인수는 "daily", 2이며 표의 마지막 상태는 세 add 호출을 모두 마친 뒤입니다.
| 대상 | 생성 직후 | 세 add 뒤 |
|---|---|---|
members | 길이 2인 배열의 참조 | 같은 배열의 참조
|
members[0] | null | [email protected]
|
members[1] | null | [email protected]
|
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인 배열 참조가 고정되어도 배열 슬롯까지 불변이 되는 것은 아닙니다.
이 예의 조건
- members의 final 참조는 모든 시점에 같은 길이 2 배열이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | final 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 |
| 23 | Kim 40 |
| 24 | Lee 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을 생성합니다. |
|
|
| 정상 첫 회원 Kim 40을 추가합니다. |
|
|
| 130세 거절 잘못된 나이는 저장하지 않습니다. |
|
|
| 정상 둘째 회원 Lee 50을 추가합니다. |
|
|
final 배열 참조를 유지하면서 바뀌는 슬롯과 회원 수
동일 final 배열에서 생성 직후·Kim 성공·130세 거절·Lee 성공의 슬롯과 size가 변합니다. 참조는 고정이어도 내부 상태가 바뀌는 실제 네 시점입니다.
FinalMemberRegistryPolicy에서 members의 final 참조는 동일한 길이2 배열을 계속 가리키지만 슬롯은 null에서 회원 참조로 바뀌고 size는0에서2가 된다. 나이130 요청은 상태를 바꾸지 않고 false를 반환한다.
배열 참조는 고정되어도 슬롯과 회원 수는 바뀝니다
FinalMemberRegistryPolicy의 같은 registry를 비교합니다. 생성 인수는 "daily", 2이며 표의 마지막 상태는 세 add 호출을 모두 마친 뒤입니다.
| 대상 | 생성 직후 | 세 add 뒤 |
|---|---|---|
members | 길이 2인 배열의 참조 | 같은 배열의 참조
|
members[0] | null | [email protected]
|
members[1] | null | [email protected]
|
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인 배열 참조가 고정되어도 배열 슬롯까지 불변이 되는 것은 아닙니다.
이 예의 조건
- members의 final 참조는 모든 시점에 같은 길이 2 배열이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | final 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 |
| 23 | Kim 40 |
| 24 | Lee 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을 생성합니다. |
|
|
| 정상 첫 회원 Kim 40을 추가합니다. |
|
|
| 130세 거절 잘못된 나이는 저장하지 않습니다. |
|
|
| 정상 둘째 회원 Lee 50을 추가합니다. |
|
|
final이 고정한 것은 members 변수에 든 참조값, 곧 어느 배열을 가리키는지입니다. 그 배열의 칸에 무엇을 넣는지는 final과 상관이 없습니다. 이 차이를 다음 글에서 자세히 살펴봅니다.
정리
final은 변수에 값을 한 번만 대입하게 하는 규칙이고, 컴파일러가 모든 실행 경로를 따져 확인합니다. 값이 올바른지는 확인하지 않으므로 입력 검증은 따로 작성합니다.- 선언할 때 값을 정할 수 없으면 빈 final로 선언하고, 모든 경로에서 읽기 전에 정확히 한 번 대입합니다.
final인스턴스 필드는 객체마다 하나씩 있고, 모든 생성자가 끝나기 전에 정확히 한 번 대입해야 합니다. 생성자가 여럿이면this(...)위임으로 대입을 한곳에 모읍니다.static final필드는 클래스에 하나뿐인 상수입니다. 바뀌지 않으면서 모든 객체에 똑같이 적용되는 값만 상수로 만들고, 이름은MAX_MEMBER_AGE처럼 대문자와 밑줄로 짓습니다.- 기본형이나
String을 상수식으로 초기화한final변수는 상수 변수이고, 그 값은 사용하는 쪽 코드에 복사됩니다. - 상수는 같은 값이 아니라 같은 개념의 반복을 없앱니다. 값이 같아도 뜻이 다르면 상수를 따로 둡니다.
연습 문제
문제 1: 컴파일되지 않는 이유 찾기
다음 코드는 실행 인자로 받은 나이에 따라 회원 등급을 정합니다. 컴파일하면 어느 줄에서 어떤 오류가 날지 예상하고, 고치는 방법을 생각해 보세요.
public final class GradeLabel {
public static void main(String[] args) {
int age = Integer.parseInt(args[0]);
final String grade;
if (age >= 65) {
grade = "senior";
} else if (age >= 18) {
grade = "adult";
}
System.out.println(grade);
}
}해설 보기
javac -d out lab/GradeLabel.java로 컴파일하면 grade를 읽는 10번째 줄에서 오류가 납니다.
lab/GradeLabel.java:10: error: variable grade might not have been initialized
System.out.println(grade);
^
1 error나이가 18 미만이면 두 조건이 모두 거짓이므로, grade에 아무것도 대입하지 않은 채 println에 도달합니다. 컴파일러는 이 경로를 찾아내고 변수를 읽는 줄에서 오류를 냅니다. 가장 간단한 해결은 else를 추가해 모든 경로에서 정확히 한 번 대입하는 것입니다.
if (age >= 65) {
grade = "senior";
} else if (age >= 18) {
grade = "adult";
} else {
grade = "teen";
}이렇게 고치면 인자 30, 70, 10에 대해 각각 adult, senior, teen이 출력됩니다. 조건 연산자를 써서 final String grade = age >= 65 ? "senior" : age >= 18 ? "adult" : "teen";처럼 선언과 대입을 한 줄로 합쳐도 됩니다.
반대로 final String grade = "teen";처럼 초깃값을 먼저 주고 if 안에서 바꾸려 하면 해결되지 않습니다. 이미 값이 대입된 final 변수에 다시 대입하게 되므로 cannot assign a value to final variable grade 오류가 납니다. 초깃값을 먼저 주는 방식을 쓰려면 final을 빼야 합니다.
문제 2: 필드마다 static과 final 정하기
게시판을 나타내는 Board 클래스에 다음 네 값을 필드로 두려고 합니다. 각각을 static final 필드, final 인스턴스 필드, 일반 인스턴스 필드 중 무엇으로 선언할지 정하고 이유를 설명하세요.
- 모든 게시판에 공통으로 적용하는 제목 최대 길이 100
- 게시판마다 만들 때 정하는 게시판 이름
- 지금까지 그 게시판에 올라온 글 수
- 게시판마다 만들 때 정하고 그 뒤로는 바꾸지 않는 한 페이지의 글 수
해설 보기
static final int MAX_TITLE_LENGTH = 100;입니다. 모든 게시판에 같은 값이고 바뀌지 않으므로 클래스에 하나만 둡니다.private final String name;입니다. 게시판마다 다르므로static이 아니고, 만든 뒤에는 바뀌지 않으므로final입니다.private int postCount;입니다. 글이 올라올 때마다 바뀌어야 하므로final을 붙일 수 없습니다. 모든 게시판의 글을 합친 수가 아니라 게시판마다 세는 값이므로static도 아닙니다.private final int pageSize;입니다. 바뀌지 않는다는 점은 1번과 같지만 게시판마다 다를 수 있으므로static으로 두면 안 됩니다. '바뀌지 않는다'와 '모든 객체에 같다'를 따로 따져야 하는 예입니다.
문제 3: 목표 인원과 달성률
가입 캠페인을 나타내는 SignupQuota를 만드세요. 목표 인원 targetCount는 캠페인마다 생성자에서 정하는 final 필드로 두고, 백분율 계산에 쓰는 배율 100은 클래스 상수 PERCENT_SCALE로 둡니다. 누적 등록 수는 register(int count)를 호출할 때마다 늘어나야 하고, progress()는 달성률을 백분율로 돌려주되 100을 넘지 않게 합니다. 목표 120인 캠페인에 50명과 90명을 차례로 등록한 뒤 달성률을 출력해 보세요.
해설 보기
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목표 인원은 캠페인마다 다르지만 정한 뒤에는 바뀌지 않으므로 final 인스턴스 필드입니다. 배율 100은 모든 캠페인에 똑같으므로 static final 상수이고, 누적 등록 수는 계속 바뀌므로 일반 필드입니다. 출력을 따라가 보면 등록 수는 50 + 90 = 140이고, 140 * 100 / 120은 정수 나눗셈이라 116이 됩니다. 이 값이 100을 넘으므로 Math.min이 100을 돌려줍니다.
이 풀이는 작은 숫자에서는 맞지만, int의 범위를 생각하면 두 곳에 문제가 있습니다. 첫째, registeredCount * PERCENT_SCALE은 int 곱셈이므로 등록 수가 21,474,836을 넘으면 곱한 결과가 int의 최댓값 2,147,483,647을 넘어 엉뚱한 값이 됩니다. 실제로 목표 100인 캠페인에 30,000,000명을 등록하면 progress()는 -12,949,672를 돌려주고, Math.min은 이미 음수가 된 값을 바로잡지 못합니다. 둘째, register를 여러 번 호출하면 누적 합계 자체가 int 범위를 넘을 수 있습니다. 두 메서드를 다음처럼 바꾸면 이 문제를 막을 수 있습니다.
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) registeredCount처럼 곱하기 전에 long으로 바꾸면 곱셈 전체가 long으로 계산되므로 중간 결과가 넘치지 않습니다. 결과는 100 이하로 줄인 뒤에 int로 되돌리므로 안전합니다. Math.addExact는 합이 int 범위를 넘으면 ArithmeticException을 던집니다. 이때 오른쪽 식의 계산이 끝나지 않았으므로 registeredCount에 대한 대입도 일어나지 않고, 등록 수는 예외 전의 값을 그대로 유지합니다.
보완한 버전에서는 목표 100인 캠페인에 30,000,000명을 등록해도 progress()가 100을 돌려줍니다. Integer.MAX_VALUE명을 등록한 뒤 1명을 더 등록하면 ArithmeticException이 나고, 등록 수는 2,147,483,647로 남습니다. 처음 예제의 progress=100 출력도 그대로입니다.
다음 글
final 참조와 객체 변경에서는 final 참조 변수가 가리키는 객체의 내용이 왜 바뀔 수 있는지 살펴봅니다. 이어서 private final 필드만으로는 지킬 수 없는 내부 상태를 방어적 복사와 불변 객체로 지키는 방법을 다룹니다.