final, const, late - 셋이 뭐가 다른가
Flutter 코드 보다 보면 변수 앞에 final, const, late가 섞여서 나오는데, 처음엔 그냥 다 "안 바뀌는 값" 정도로 뭉뚱그려서 이해하고 넘어가기 쉽다. 근데 실제로 코드 짜다 보면 바로 막힌다.
이런 코드를 짠다고 해보자. 서버에서 사용자 이름을 나중에 받아와서 채워 넣을 생각으로 필드를 하나 만들었다.
class UserProfile {
final String name; // 나중에 서버에서 받아와서 넣을 생각
void loadFromApi() {
name = "홍길동"; // 여기서 에러남
}
}이거 실행하면 안 된다. 근데 코드만 보면 이상하다. loadFromApi() 안에서 분명히 값을 넣어줬는데, 왜 에러가 나는 걸까? "나 밑에서 넣어줬잖아, 뭐가 문제야?"라는 생각이 자연스럽게 든다.
원인: "채워주는 코드가 있냐"가 아니라 "태어나는 순간에 이미 있냐"가 규칙이다
여기서 컴파일과 런타임을 구분해야 한다.
- 컴파일: 코드를 실행하기 전에, 문법과 구조만 보고 검사·변환하는 단계. 프로그램이 아직 시작도 안 됐고, 메모리에 아무것도 안 올라간다.
- 런타임: 컴파일이 끝난 결과물이 실제로 실행되는 단계. 여기서 객체가 실제로 메모리에 만들어지고, 코드가 한 줄씩 진짜로 동작한다.
컴파일러는 프로그램을 실행해보는 게 아니라서 "실제로 값이 채워지는지"를 지켜볼 방법이 없다. 대신 **코드의 생김새만 보고 "이 구조라면 무조건 값이 채워진다고 증명할 수 있는가"**를 판단한다.
원래 코드에는 생성자가 따로 없다. 그러면 Dart가 자동으로 "아무것도 안 받는 기본 생성자"(UserProfile())를 만들어준다. 문제는 이 기본 생성자가 name을 채우는 코드를 전혀 갖고 있지 않다는 거다.
final profile = UserProfile(); // 이 한 줄로 객체가 완성됨이 한 줄이 실행되면 런타임에서 순서대로:
UserProfile을 위한 자리가 메모리에 생긴다- 생성자가 실행되는데, 이 생성자엔
name을 채우는 코드가 없다 - 그대로 객체가 완성된다 → **이 순간이 "객체가 태어나는 지점"**이고, 여기서
name은 이미 비어있다
loadFromApi()를 나중에 부르든 안 부르든 상관없이, 1~3번은 이미 다 끝나버린 뒤다. 컴파일러 입장에선 "이 클래스로 만든 모든 객체는, 태어나는 순간에 name이 비어있을 수 있다"는 게 코드 구조만 보고도 확실하니까, 아예 이런 구조 자체를 막아버린다.
반대로, 이건 왜 되는가
class UserProfile {
final String name;
UserProfile(this.name); // 이렇게 쓰면 다르다
}
final profile = UserProfile("홍길동");UserProfile(this.name)은 "생성자 괄호로 들어온 값을, 필드 name에 그대로 넣어라"는 뜻이다. 풀어서 쓰면 이거랑 같다.
UserProfile(String name) {
this.name = name; // 괄호로 받은 값을 필드에 대입
}이렇게 정의해두면 UserProfile()처럼 값 없이 호출하는 것 자체가 문법 에러가 된다 — "빈 값이라서 안 됨"이 아니라 "생성자가 요구하는 값을 아예 안 줘서" 나는 에러다. 그 결과 "name이 비어있는 UserProfile"이 존재할 수 있는 방법 자체가 없어진다. 객체를 만드는 행위와 값을 넣는 행위가 한 동작으로 묶여버리는 거라서, 컴파일러가 "이 객체가 있다 = name도 항상 있다"고 100% 확신할 수 있다.
규칙을 한 줄로: "객체가 태어나는 순간에, final 필드에 값이 무조건 채워져 있다"는 걸 컴파일러가 코드 구조만으로 증명할 수 있어야 한다. 선언과 동시에 값을 주거나, 생성자가 그 값을 직접 받는 것만 이 증명에 들어간다. loadFromApi()처럼 "호출될지 안 될지 알 수 없는" 일반 메서드는 여기 포함되지 않는다.
고친 코드: late를 붙인다
class UserProfile {
late final String name; // 지금은 모름, 나중에 채워짐
void loadFromApi() {
name = "홍길동"; // OK, 최초 대입
}
}
void main() {
final profile = UserProfile();
profile.loadFromApi();
print(profile.name); // 홍길동
}late는 딱 하나만 한다: "태어나는 순간까지 보장해야 한다는 규칙을, 읽는 순간까지만 보장하면 되는 걸로 미뤄준다. 대신 그 보장은 네(개발자)가 책임져라."
late final은 빈 상자에 이름표만 붙여둔 것
late final String name;을 쉽게 생각하면, "name이라는 이름표가 붙은, 문자열이 들어갈 빈 상자"를 하나 만들어두는 거다. 상자 종류(타입)는 정해뒀는데 안에 뭘 넣을지는 아직 모른다. 왜 모르냐면, 코드를 적는 시점에는 그 값을 알 방법이 없어서다 — 서버 응답을 기다려야 하거나, 뭔가 계산이 끝나야 하거나, 사용자가 입력을 해야 하거나.
핵심 구분은 이거 하나다: "코드 쓸 때 이미 아는 값이냐, 아니면 실행해봐야(런타임에) 아는 값이냐." 후자면 late가 필요하다.
late 혼자 쓰면 불변이 아니다
late는 "초기화를 늦춰도 된다"는 뜻일 뿐이지, "한 번만 대입 가능"이라는 뜻은 아니다. 그 "한 번만"은 final이 만드는 규칙이다. late만 있고 final이 없으면 보통 변수(var)처럼 몇 번이든 다시 대입할 수 있다.
class Counter {
late int value; // final 없음
void reset() => value = 0;
}
void main() {
final counter = Counter();
counter.reset();
counter.value = 1;
counter.value = 2; // 문제 없음 - late만 있고 final은 없으니까
print(counter.value); // 2
}late final을 두 번 대입하면?
profile.name = "홍길동";
profile.name = "김철수"; // 컴파일 에러가 아니라 런타임 에러컴파일은 통과되는데, 실행해서 저 두 번째 대입 줄이 실제로 실행되는 순간 LateInitializationError: Field 'name' has already been initialized.가 뜬다. final의 "한 번만" 규칙이 컴파일 타임이 아니라 런타임에 체크되는 거다.
값을 채우기 전에 읽으면?
late를 붙였다고 "값이 없어도 안전하게 처리된다"는 뜻이 아니다. 체크 시점이 뒤로 밀린 것뿐이라, 채우기 전에 먼저 읽으면 똑같이 런타임 에러가 난다.
class UserProfile {
late final String name;
void printName() {
print("1. 함수 시작");
print(name); // 2. 아직 대입 안 했는데 읽으려 함 - 여기서 에러
print("3. 여기는 실행 안 됨");
}
void loadFromApi() {
name = "홍길동";
}
}
void main() {
final profile = UserProfile();
profile.printName(); // 먼저 호출 - 에러남
profile.loadFromApi();
}"1. 함수 시작"은 찍히고, 바로 다음 줄에서 LateInitializationError: has not been initialized.가 터지면서 함수가 거기서 멈춘다. "함수 전체가 미뤄지는" 게 아니라, 그 변수를 읽는 그 줄에서만 체크가 일어난다.
순서는 누가 맞춰주나
한 함수 안에서는 위에서 아래로 그대로 실행된다. 근데 여러 함수가 얽히면 "코드에 적힌 위치"가 아니라 "누가 먼저 호출했냐"가 실행 순서를 정한다.
void a() => print("A");
void b() => print("B");
void main() {
b(); // 코드에는 a가 먼저 정의돼 있지만
a(); // 실행은 여기서 정한 순서대로 감
}
// B, A 순서로 찍힘위 예시에서도 printName()과 loadFromApi()가 클래스 안에 어떤 순서로 적혀있든 상관없이, main()에서 어느 걸 먼저 호출했는지가 실제 실행 순서를 정한다. late final을 쓸 때 챙겨야 하는 건 "채우는 호출이 읽는 호출보다 먼저 일어나게 만드는 것" — 이건 Dart가 자동으로 해주는 게 아니라 개발자가 코드 구조로 보장해야 한다.
차라리 기본값을 넣으면 안 되나?
class UserProfile {
String name = ""; // final도 late도 없이, 그냥 즉시 초기화
void loadFromApi() {
name = "홍길동";
}
}이것도 에러는 안 난다. name = ""으로 선언 시점에 이미 값이 있으니 "태어나는 순간에 값이 있어야 한다"는 조건을 만족하고, final이 없으니 재대입 제한도 없다.
근데 실전에서는 이 방식을 잘 안 쓴다. ""은 가짜 초기값이라서, 실수로 loadFromApi()를 안 부르고 name을 읽어도 에러 없이 그냥 빈 문자열이 조용히 찍혀버린다. "값이 아직 없다"는 걸 에러로 바로 알려주는 게 아니라 넘어가버리니, 버그를 찾기가 더 어려워진다. 반면 late final은 값이 없는 상태로 읽으면 그 자리에서 바로 에러를 던져준다. 그래서 "값이 꼭 있어야 하고, 없으면 바로 알고 싶다"면 late final을, "기본값이 있어도 상관없는 값"이면 = 기본값 방식을 쓴다.
var, final, const 정리
final은 var가 아니라 오히려 정반대(재대입 금지) 성질이고, Kotlin의 val과 짝이다.
Dart | Kotlin | 성질 |
|
| 여러 번 재대입 가능 |
|
| 한 번만 대입, 런타임에 값이 정해져도 됨 |
|
| 한 번만 대입, 컴파일 타임에 값이 고정돼야 함 |
여기에 late를 조합하면 "언제 초기화하냐"와 "몇 번 대입 가능하냐"라는 서로 다른 두 축이 생긴다.
즉시 초기화 필요 | 나중에 초기화 가능 ( | |
여러 번 대입 가능 |
|
|
한 번만 대입 가능 |
|
|
const는 여기서 한 단계 더 엄격해서, 아예 컴파일 시점에 값이 고정돼야 한다.
final now = DateTime.now(); // OK - 런타임에 값이 정해짐
const now2 = DateTime.now(); // 에러 - 컴파일 시점엔 지금이 언제인지 모름late final이 실제로 필요한 상황들
"코드 쓸 때 이미 아는 값이냐, 실행해봐야 아는 값이냐" 기준으로 세 가지만 보면 감이 온다.
// 계산해야 나오는 값
class Rectangle {
final double width;
final double height;
late final double area;
Rectangle(this.width, this.height) {
area = width * height;
}
}
// 서버 응답으로 아는 값
class Quiz {
late final String answer;
void receiveAnswerFromServer() => answer = "서울";
}
// 사용자 입력으로 아는 값
class LoginForm {
late final String username;
void onSubmit(String inputValue) => username = inputValue;
}셋 다 패턴이 같다. 코드를 적는 시점엔 "이런 타입의 값이 있을 거다"까지만 정해두고, 실행 중에 뭔가(계산/응답/입력)가 끝난 다음 한 번 채워 넣는다.
정리
- 컴파일러는 실행 중 값을 지켜보는 게 아니라, "객체가 태어나는 순간까지 값이 무조건 채워진다"는 걸 코드 구조만 보고 미리 증명한다
final은 이 증명이 즉시(선언 또는 생성자) 되어야 하고,late는 그 증명 시점을 읽는 순간으로 미뤄주는 것뿐이다late final은 채우기 전에 읽으면has not been initialized, 두 번 채우면has already been initialized런타임 에러로 걸린다late final을 쓰려면, 채우는 코드가 읽는 코드보다 먼저 실행되도록 순서를 직접 보장해야 한다= 기본값으로 때우는 건 에러는 안 나지만, 값이 없는 상태를 조용히 숨겨버려서 버그를 찾기 더 어렵게 만든다final은 Kotlin의var가 아니라val과 짝이고,const는const val과 짝이다
다음 글에서는 Null safety(?, !, ??, ?.)를 다뤄보겠다.
