Dart의 Null Safety는 non-nullable 변수를 선언하면 반드시 즉시 초기화하도록 요구합니다. 그런데 현실에서는 "지금 당장 초기화할 수 없지만, 나중에 반드시 초기화할" 상황이 생깁니다. 예를 들어, 객체 생성 후 외부에서 값을 주입받거나, 처음 사용될 때만 초기화하고 싶은 경우가 있습니다. 이럴 때 late가 등장합니다.
late — 나중에 초기화하겠다는 약속
late는 "지금은 초기화하지 않지만, 사용하기 전에 반드시 초기화하겠다"는 개발자의 약속입니다. 컴파일러는 이 약속을 믿고 null 체크를 요구하지 않습니다. 대신 런타임에서 실제로 초기화되었는지 확인합니다.
class UserProfile {
late String name; // 나중에 초기화합니다
late int age; // 나중에 초기화합니다
String email; // 즉시 초기화 필요합니다
UserProfile({required this.email}); // email은 생성자에서 초기화합니다
void loadFromServer(String serverName, int serverAge) {
name = serverName;
age = serverAge;
}
}
void main() {
UserProfile profile = UserProfile(email: '[email protected]');
// loadFromServer 호출 전에 name을 사용하면 런타임 오류가 발생합니다
// print(profile.name); // LateInitializationError!
profile.loadFromServer('김다트', 25);
// 초기화 후에는 안전하게 사용할 수 있습니다
print(profile.name); // 김다트
print(profile.age); // 25
print(profile.email); // [email protected]
}
late의 동작 원리
late 변수는 선언 시점에 초기화되지 않습니다. 하지만 Dart 런타임은 이 변수가 초기화되었는지 추적합니다. 처음 접근할 때 초기화 여부를 확인하고, 초기화되지 않았다면 LateInitializationError를 던집니다.
void main() {
late String message;
// 초기화 전에 읽으려 하면 오류가 발생합니다
// print(message); // 런타임 오류: LateInitializationError: Field 'message' has not been initialized.
message = '안녕하세요';
// 초기화 후에는 정상 동작합니다
print(message); // 안녕하세요
}
late는 nullable 타입(String?)의 대안이 아닙니다. String?는 null 값 자체를 허용하지만, late String은 null을 허용하지 않고 초기화 시점만 늦추는 것입니다.
void main() {
String? nullable = null; // null 값을 가질 수 있습니다
late String lateVar; // null이 아닌 값이 나중에 올 것입니다
nullable = null; // OK — null 대입 가능합니다
// lateVar = null; // 컴파일 오류 — late String은 null 불가합니다
lateVar = '이제 초기화';
print(lateVar); // 이제 초기화
}
late가 유용한 상황들
의존성 주입과 setUp 패턴
테스트 코드에서 setUp에서 초기화하고 각 테스트에서 사용하는 패턴에 자주 쓰입니다.
// 테스트 환경에서의 late 활용 예
class DatabaseService {
late String connectionString;
void initialize(String host, int port) {
connectionString = 'postgres://$host:$port/mydb';
}
void query(String sql) {
print('[$connectionString] $sql 실행');
}
}
void main() {
DatabaseService db = DatabaseService();
db.initialize('localhost', 5432);
db.query('SELECT * FROM users');
// [postgres://localhost:5432/mydb] SELECT * FROM users 실행
}
순환 참조 해결
두 객체가 서로를 참조할 때 late가 해결책이 됩니다.
class Node {
final String value;
late Node next; // 생성 시점에 next가 없을 수 있습니다
Node(this.value);
}
void main() {
Node a = Node('A');
Node b = Node('B');
Node c = Node('C');
// 생성 후 연결합니다
a.next = b;
b.next = c;
c.next = a; // 순환 연결
print(a.next.value); // B
print(a.next.next.value); // C
}
late — 지연 초기화 (Lazy Initialization)
late에는 숨겨진 강력한 기능이 있습니다. 초기화 표현식을 함께 작성하면, 해당 변수가 처음 접근될 때만 초기화가 실행됩니다. 이것을 지연 초기화(lazy initialization)라고 합니다.
class HeavyResource {
late String data = _loadData(); // 처음 접근 시에만 실행됩니다
String _loadData() {
print('무거운 데이터를 로드합니다...');
// 실제로는 DB 조회, 파일 읽기 등 비용이 큰 작업
return '로드된 데이터';
}
}
void main() {
HeavyResource resource = HeavyResource();
print('객체 생성 완료'); // 여기서는 아직 로드되지 않습니다
// 처음 접근 시 로드됩니다
print(resource.data); // 무거운 데이터를 로드합니다... → 로드된 데이터
print(resource.data); // 두 번째 접근 — 로드 없이 캐시된 값 반환합니다
}
late 없이 같은 효과를 내려면 nullable 타입과 null 체크 코드를 직접 작성해야 합니다. late를 쓰면 이 보일러플레이트를 줄일 수 있습니다.
late final — 한 번만 할당 가능한 지연 초기화
late final은 late와 final의 결합입니다. 딱 한 번만 할당할 수 있고, 이후에는 변경이 불가합니다. final의 불변성과 late의 지연 초기화를 동시에 얻는 것입니다.
class Config {
late final String apiKey;
late final String baseUrl;
void setup(String key, String url) {
apiKey = key; // 처음 할당합니다
baseUrl = url; // 처음 할당합니다
}
}
void main() {
Config config = Config();
config.setup('abc123', 'https://api.example.com');
print(config.apiKey); // abc123
print(config.baseUrl); // https://api.example.com
// 두 번 할당하려 하면 오류가 발생합니다
// config.setup('xyz789', 'https://other.com');
// LateInitializationError: Field 'apiKey' has already been initialized.
}
late 사용 시 주의사항
LateInitializationError를 방지하려면
late 변수는 사용 전에 반드시 초기화해야 합니다. 초기화 경로를 명확히 설계해야 합니다.
class Counter {
late int count;
void start() {
count = 0; // 반드시 start()를 먼저 호출해야 합니다
}
void increment() {
count++; // start() 없이 호출하면 LateInitializationError!
}
}
void main() {
Counter c = Counter();
// c.increment(); // 오류! count가 초기화되지 않았습니다
c.start();
c.increment();
c.increment();
print(c.count); // 2
}
late보다 생성자 초기화가 낫다
가능하면 late보다 생성자에서 초기화하는 것이 더 안전합니다. late는 런타임 오류의 가능성을 가지고 있습니다.
// late 사용 — 런타임 오류 가능성이 있습니다
class UserBad {
late String name;
}
// 생성자 초기화 — 더 안전합니다
class UserGood {
final String name;
UserGood(this.name);
}
required — null 없이 필수값을 표현하기
PART 03에서 named 매개변수를 배웠습니다. named 매개변수는 기본적으로 선택적이어서 null이 될 수 있었습니다. required는 이 named 매개변수를 "반드시 제공해야 하는 필수값"으로 만드는 키워드입니다.
// required 없이 — age는 선택적이므로 int? 타입이어야 합니다
class Student {
String name;
int? age; // nullable이어야 합니다
Student({required this.name, this.age});
}
// required 사용 — age가 필수입니다, null 없이 int 타입 사용 가능합니다
class Teacher {
String name;
int age; // non-nullable입니다
Teacher({required this.name, required this.age});
}
void main() {
// Student — age 생략 가능합니다
Student s1 = Student(name: '이다트'); // age 생략 OK
Student s2 = Student(name: '김플러터', age: 20);
// Teacher — name과 age 모두 필수입니다
// Teacher t1 = Teacher(name: '박교수'); // 컴파일 오류 — age 누락
Teacher t2 = Teacher(name: '박교수', age: 35); // OK
print(s1.age); // null
print(s2.age); // 20
print(t2.name); // 박교수
print(t2.age); // 35
}
required와 Null Safety의 관계를 정확히 이해해야 합니다. required는 호출 시점에 값 제공을 강제합니다. 그 결과로 해당 매개변수는 non-nullable 타입으로 선언할 수 있습니다.
void createPost({
required String title, // 필수 — null 불가합니다
required String content, // 필수 — null 불가합니다
String? tags, // 선택 — null 가능합니다
int views = 0, // 기본값 — null 불가합니다
}) {
print('제목: $title');
print('내용: $content');
print('태그: ${tags ?? "없음"}');
print('조회수: $views');
}
void main() {
createPost(
title: '첫 번째 포스트',
content: 'Dart는 정말 좋습니다.',
// tags는 생략 가능합니다
// views는 기본값 0이 사용됩니다
);
}
late와 required를 함께 활용하기
실제 코드에서는 late와 required를 함께 활용하는 패턴이 자주 등장합니다. Flutter 위젯에서는 이 패턴이 특히 흔합니다.
class AppConfig {
static late final AppConfig _instance;
static bool _initialized = false;
final String appName;
final String version;
final String apiEndpoint;
AppConfig._({
required this.appName,
required this.version,
required this.apiEndpoint,
});
static void initialize({
required String appName,
required String version,
required String apiEndpoint,
}) {
if (_initialized) return;
_instance = AppConfig._(
appName: appName,
version: version,
apiEndpoint: apiEndpoint,
);
_initialized = true;
}
static AppConfig get instance {
if (!_initialized) {
throw StateError('AppConfig가 초기화되지 않았습니다. initialize()를 먼저 호출하세요.');
}
return _instance;
}
}
void main() {
AppConfig.initialize(
appName: '내 앱',
version: '1.0.0',
apiEndpoint: 'https://api.example.com',
);
print(AppConfig.instance.appName); // 내 앱
print(AppConfig.instance.version); // 1.0.0
print(AppConfig.instance.apiEndpoint); // https://api.example.com
}
late와 required 정리
| 키워드 | 용도 | 오류 시점 |
|---|---|---|
late |
초기화를 사용 시점으로 미룸 | 런타임 (미초기화 접근 시) |
late final |
한 번만 할당 가능한 지연 초기화 | 런타임 (미초기화 접근 또는 재할당 시) |
required |
named 매개변수를 필수로 만듦 | 컴파일 타임 (누락 시) |
required는 컴파일 타임에 잡히므로 안전합니다. late는 런타임 오류 가능성이 있으므로 신중하게 사용합니다.
다음 챕터에서는 Dart 컴파일러가 null을 어떻게 추적하는지 배웁니다. if (x != null) 이후에 자동으로 타입이 변경되는 타입 프로모션, 그리고 프로모션이 동작하지 않는 상황과 해결법을 알아봅니다.