String?, !, late는 모두 null과 관련 있지만 같은 문제를 해결하지 않습니다. ?는 값이 없을 수 있다는 상태를 타입에 남기고, !는 현재 값이 null이 아니라고 개발자가 단언하며, late는 non-null 값의 초기화 시점만 뒤로 미룹니다. 어느 것을 고르느냐에 따라 오류가 분석 단계에서 멈출지, 실행 중에 앱을 종료시킬지가 달라집니다.
2026년 9월 30일 Windows 10 x64에서 Dart SDK 3.13.4 stable로 다섯 개의 최소 프로그램을 만들었습니다. 각 파일에 dart analyze --format machine을 실행한 뒤 VM에서도 실행해 종료 코드와 첫 오류를 기록했습니다. 가드가 있는 nullable 처리, nullable 멤버 직접 접근, ! 단언, 초기화 전 late 읽기, late final 두 번째 대입을 서로 분리했고 12개 기대 조건이 모두 통과했습니다.
같은 null 문제라도 멈추는 단계가 달랐다
| 코드 상황 | analyze | run | 실제 관찰 |
|---|---|---|---|
String?를 가드 후 사용 |
0 | 0 | null·빈 문자열은 대체값, 값은 trim 후 출력 |
name.length 직접 접근 |
3 | 실행 안 함 | UNCHECKED_USE_OF_NULLABLE_VALUE |
name!.length |
0 | 255 | Null check operator used on a null value |
초기화 전 late 읽기 |
0 | 255 | Field 'token' has not been initialized |
late final 두 번째 대입 |
0 | 255 | Field 'token' has already been initialized |

가장 중요한 차이는 !와 late가 분석기를 통과했다는 점입니다. 문법적으로 허용된다는 사실은 입력과 생명주기가 안전하다는 증거가 아닙니다. 이 실험에서는 둘 다 실행 전 오류가 0개였지만, null 또는 잘못된 초기화 순서를 넣자 VM이 종료 코드 255로 멈췄습니다.
값이 없을 수 있는 상태는 String?로 숨기지 않는다
사용자가 닉네임을 입력하지 않았거나 API 응답에서 보조 필드가 빠질 수 있다면 null은 오류가 아니라 도메인의 정상 상태입니다. 이 경우 non-null 타입에 억지로 빈 문자열을 넣기보다 String?로 표현하고, 사용하는 지점에서 분기를 닫는 편이 의미가 분명합니다.
String displayName(String? value) {
if (value == null || value.trim().isEmpty) {
return '이름 없음';
}
return value.trim();
}
이 코드는 null에 이름 없음, 공백에도 이름 없음, ' Ada '에는 Ada를 출력했습니다. if 이후에는 flow analysis가 value를 String으로 승격하므로 !가 필요하지 않았습니다.
처리 방법은 결과 의미로 고릅니다.
- 값이 없으면 아무 작업도 하지 않는다:
value?.trim() - 값이 없을 때 실제 기본값이 존재한다:
value ?? '이름 없음' - 값이 없으면 요청 자체가 잘못됐다: 함수 입구에서 가드 후 오류 결과 반환
- 다음 코드에서는 반드시 값이 필요하다: null 분기 후 flow promotion 사용
?? ''를 습관처럼 붙이면 “입력 없음”과 “빈 문자열 입력”이 같은 값이 됩니다. 두 상태를 구분해야 하는 로그인, 검색 필터, PATCH 요청에서는 편한 기본값이 데이터 의미를 지울 수 있습니다.
!는 null을 처리하지 않고 책임 위치만 옮긴다
String? name;
print(name!.length);
위 파일은 analyzer 종료 코드 0이었지만 실행하면 Null check operator used on a null value로 끝났습니다. !는 값을 채우거나 검증하지 않습니다. 단지 타입 시스템에 “여기서는 null이 아니다”라고 선언할 뿐입니다.
!를 쓰기 전에 null 불가능성을 누가 보장하는지 한 문장으로 설명할 수 있어야 합니다. 같은 함수 안의 선행 가드로 증명된다면 대개 ! 없이도 승격됩니다. 외부 프레임워크 콜백, 라우트 인자, 위젯 탐색 결과가 근거라면 그 계약이 실제로 모든 호출 경로에서 지켜지는지 확인해야 합니다. 설명이 “아마 값이 있을 것”이라면 가드·required 매개변수·생성자 검증으로 책임을 앞당기는 편이 낫습니다.
late는 존재 여부가 아니라 초기화 순서에 대한 약속이다
late String token;
void main() {
print(token.length);
}
이 파일도 분석은 통과했지만 실행 시 LateInitializationError: Field 'token' has not been initialized.가 발생했습니다. late는 null을 허용하지 않으면서 나중에 대입할 수 있게 하지만, “사용 전에 반드시 대입”이라는 순서를 analyzer가 모든 런타임 경로에서 증명해 주지는 않습니다.
다음 질문 중 하나라도 답이 불명확하면 late보다 생성자 주입이나 nullable 상태가 낫습니다.
| 질문 | 답이 예일 때 | 아닐 때 |
|---|---|---|
| 모든 읽기보다 먼저 실행되는 초기화 지점이 하나인가? | late 후보 |
생성자 주입 또는 ? |
| 값이 없는 상태가 정상인가? | ?로 모델링 |
non-null 계약 유지 |
| 초기화는 한 번뿐이어야 하는가? | late final 후보 |
상태 전이 모델 검토 |
| 초기화 실패를 복구해야 하는가? | 명시적 loading/error 상태 | 동기 주입 가능 여부 확인 |
late final은 두 번째 대입도 런타임 실패가 될 수 있다
late final String token;
void main() {
token = 'first';
print(token);
token = 'second';
}
테스트한 top-level 필드는 analyzer를 통과하고 first까지 출력한 뒤, 두 번째 대입에서 Field 'token' has already been initialized.로 종료됐습니다. “final이니 컴파일 단계에서 항상 두 번째 대입을 잡아 줄 것”이라고 일반화하면 안 됩니다. 초기화 지점이 여러 콜백에 흩어져 있으면 재호출 여부를 테스트하거나, 한 번만 생성되는 객체의 생성자로 값을 옮겨야 합니다.
Flutter의 State에서 컨트롤러처럼 생명주기 중 반드시 한 번 만드는 값은 late final이 맞을 수 있습니다. 다만 initState에서 만들고 dispose에서 정리한다는 쌍이 코드에 같이 보여야 합니다. 비동기 요청 결과처럼 loading·error·없음 상태가 실제로 존재한다면 단순 late로 그 상태를 지우지 않는 편이 안전합니다.
실패 메시지로 원인을 먼저 분류한다
| 첫 메시지 | 실패 시점 | 먼저 고칠 설계 |
|---|---|---|
UNCHECKED_USE_OF_NULLABLE_VALUE |
분석 | 가드, ?., ??, 입력 계약 |
Null check operator used on a null value |
실행 | ! 근거 제거 후 null 경로 모델링 |
has not been initialized |
실행 | 읽기보다 초기화를 앞당기거나 상태를 nullable로 표현 |
has already been initialized |
실행 | 초기화 지점을 하나로 합치고 재호출 차단 |
이번 결과의 범위
- Dart SDK 3.13.4 stable의 analyzer와 Windows VM에서 최소 예제를 실행한 결과입니다.
- Flutter 위젯 생명주기나 실제 API 응답은 실행하지 않았고, 해당 부분은 적용 판단 기준으로만 설명했습니다.
late의 성능 이점은 측정하지 않았으며 성능을 사용 근거로 제시하지 않습니다.- 복잡한 비동기 콜백과 isolate 간 초기화 순서는 이 실험 범위에 포함하지 않았습니다.
인접 주제를 겹치지 않게 이어서 보기
이 글은 null 가능성과 초기화 순서만 다룹니다. var·final·const의 재대입과 객체 변경 차이는 Dart var·final·const 실행 비교에서 확인할 수 있습니다. 외부 JSON 경계에서 dynamic과 Object?가 오류를 언제 드러내는지는 Dart var·dynamic·Object? 안전한 사용법에 별도 실행 결과가 있습니다.