iBetter Books
수정

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 finallatefinal의 결합입니다. 딱 한 번만 할당할 수 있고, 이후에는 변경이 불가합니다. 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를 함께 활용하기

실제 코드에서는 laterequired를 함께 활용하는 패턴이 자주 등장합니다. 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) 이후에 자동으로 타입이 변경되는 타입 프로모션, 그리고 프로모션이 동작하지 않는 상황과 해결법을 알아봅니다.