Flutter 하다가 name.length 한 줄 썼는데 갑자기 에러가 뜨고, ! 붙이니까 또 어느 날 앱이 크래시 나는 경험을 하게 됩니다. 그 원인은 대부분 null safety에서 ?, !, late를 헷갈려서입니다. 이 글에서 세 가지를 “실무에서 왜 자주 쓰는지” 기준으로 깔끔하게 정리해드리겠습니다.
Null safety 한 줄 정의
Dart의 null safety는 “null이 들어올 수 있는지”를 타입으로 구분해서 위험한 코드를 실행 전에 막는 규칙입니다. String은 null 불가, String?는 null 가능입니다.
?의 의미와 사용법
String? name;
name은 문자열일 수도 있고 null일 수도 있습니다.
String? name; // String 또는 null
그래서 바로 쓰면 막힙니다.
String? name; print(name.length); // ❌ 컴파일 에러: null일 수도 있음
실무에서 자주 쓰는 처리 3가지
null 체크 후 사용합니다.
String? name = fetchName();
if (name != null) {
print(name.length); // 안전
}
?.로 null이면 멈춥니다.
String? name; final int? len = name?.length; // name이 null이면 len도 null
??로 기본값을 줍니다.
String? name; final int len = (name ?? '').length; // null이면 ''로 대체
!의 의미와 주의점
!는 nullable을 강제로 non-null처럼 쓰는 연산자입니다.
String? name; print(name!.length); // 실행 중 name이 null이면 크래시
!는 컴파일 단계 안전장치를 끄는 것이므로, 문제를 실행 중 크래시로 미루게 됩니다.
! 대신 권장되는 가드 패턴
void printLen(String? name) {
if (name == null) return; // null이면 종료
print(name.length); // ! 없이 안전
}
late의 의미와 사용법
late는 null 불가 타입을 유지하면서 초기화를 미루고 싶을 때 씁니다.
late String name; // null은 불가, 초기화를 나중에 함
초기화 전에 읽으면 LateInitializationError가 납니다.
late String name;
void main() {
print(name); // ❌ 초기화 전 접근 -> LateInitializationError
}
Flutter에서 late가 자주 나오는 이유
initState에서 컨트롤러를 만들기 때문입니다.
class MyPageState extends State{ late final TextEditingController controller; @override void initState() { super.initState(); controller = TextEditingController(); // ✅ 여기서 초기화 } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return TextField(controller: controller); } }
late final의 의미와 사용법
final은 한 번 대입하면 바뀌지 않습니다. 여기에 late가 붙으면 초기화는 나중에 하되 딱 한 번만 대입하는 형태가 됩니다.
late final String token;
void init(String t) {
token = t; // 첫 대입 OK
// token = 'x'; // 두 번째 대입은 불가
}
컨트롤러나 서비스 인스턴스처럼 한 번 세팅되면 고정되는 값에 특히 자주 씁니다.
String?와 late를 고르는 기준
String?는 “없을 수도 있음이 정상”인 경우에 맞습니다. 예를 들면 로그인 전 닉네임, 선택 입력값, API 응답 필드 누락 가능성입니다.
String? nickname; // null 자체가 정상 상태
late는 “반드시 있어야 하는데 초기화 시점만 뒤”인 경우에 맞습니다. 예를 들면 initState에서 무조건 세팅되는 컨트롤러, 런타임 주입되는 의존성입니다.
late final ApiClient client; // 반드시 세팅될 값
초보자가 자주 만나는 에러 두 가지
String?을 String에 바로 넣으려 할 때
String? a = 'hi'; String b = a; // 불가
대부분 이렇게 처리합니다.
String? a = 'hi'; String b = a ?? ''; // 기본값
! 때문에 런타임 크래시가 날 때
String? a; print(a!.length); // a가 null이면 크래시
이 경우 !를 제거하고 가드, 기본값, required 같은 구조로 null을 처리하는 편이 안전합니다.
결론
Dart의 null safety는 “null이 들어올 수 있는가”를 타입으로 구분해, 앱이 실행 중에 터질 수 있는 문제를 작성 단계에서 줄이는 장치입니다. String?처럼 null 가능 타입을 쓰면 반드시 null 처리를 동반해야 하고, !는 그 처리를 건너뛰는 대신 런타임 크래시 위험을 떠안는 선택입니다. 반대로 late와 late final은 “null이 오면 안 되는 값”을 nullable로 끌고 가지 않으면서도 초기화 시점을 늦출 수 있게 해주는 도구라, Flutter의 생명주기(initState 등)와 결합해 실무에서 매우 자주 사용됩니다. 결국 기준은 단순합니다. null이 “정상 상태”라면 ?로 모델링하고, null이 “원래 있으면 안 되는 값인데 초기화만 늦는 것”이라면 late/late final로 구조를 잡는 것이 가장 안전하고 유지보수에 유리합니다.
FAQ
String과 String?의 차이는 정확히 뭔가요?
String은 null이 들어올 수 없는 타입이고, String?는 null이 들어올 수 있는 타입입니다. String?로 선언하면 컴파일러가 “null일 수도 있다”는 가능성을 끝까지 추적하므로, 사용 시점에 null 체크나 대체값 처리 같은 방어 코드를 요구합니다.
String? name;을 선언했을 때 왜 name.length가 바로 안 되나요?
name이 null일 수도 있으니 length를 호출하는 순간 크래시가 날 수 있기 때문입니다. Dart는 이런 위험을 실행 전에 차단하려고, nullable 값에 바로 멤버 접근을 허용하지 않습니다. 따라서 null이 아님을 보장하는 흐름(조건문, 기본값, 안전 접근 등)을 먼저 만들어야 합니다.
!를 붙이면 왜 컴파일은 되는데 앱이 크래시 날 수 있나요?
!는 “여기서는 null이 아니다”라고 개발자가 강제로 보증하는 연산자입니다. 컴파일러의 안전장치를 우회하므로 컴파일은 통과하지만, 실제 실행 시 값이 null이면 즉시 런타임 에러로 앱이 종료될 수 있습니다. 그래서 !는 근거가 명확할 때만 제한적으로 사용하는 편이 안전합니다.
! 대신 더 안전한 방식은 무엇인가요?
가장 안전한 방식은 null을 미리 걸러내는 구조를 만드는 것입니다. 예를 들어 null이면 함수에서 빠져나가거나(가드 패턴), 기본값으로 대체하는 방식이 대표적입니다. 이런 구조는 크래시를 “가능성”으로 남겨두지 않고 코드 흐름에서 제거하므로 유지보수에도 유리합니다.
late는 null을 허용하는 키워드인가요?
아닙니다. late는 null을 허용하는 것이 아니라, null이 들어오면 안 되는 타입을 유지하면서 “초기화 시점만 뒤로 미루겠다”는 선언입니다. 그래서 초기화하기 전에 읽으면 LateInitializationError가 발생할 수 있습니다.
late를 써서 발생하는 대표적인 오류는 무엇이고 왜 생기나요?
대표적으로 LateInitializationError가 있습니다. late 변수는 “나중에 반드시 초기화된다”는 전제가 있는데, 그 전제보다 먼저 값을 읽으려 하면 런타임에서 오류가 발생합니다. 따라서 초기화가 반드시 선행되도록 생명주기와 흐름을 설계해야 합니다.
late final은 왜 Flutter에서 자주 보이나요?
Flutter에서는 컨트롤러나 서비스 인스턴스를 initState에서 생성한 뒤, 이후에는 변경되지 않게 고정하는 패턴이 흔합니다. 이때 late final을 쓰면 “초기화는 늦게, 대입은 한 번만”이라는 의도가 코드에 명확히 드러나고, 불필요한 ?나 ! 사용을 줄일 수 있습니다.
String?을 써야 할지 late를 써야 할지 판단 기준이 있나요?
null이 “정상적인 상태”인지가 기준입니다. 예를 들어 로그인 전 닉네임처럼 “없을 수도 있음”이 자연스러운 값은 String?가 맞습니다. 반대로 “반드시 있어야 하는 값인데 초기화 시점만 늦는 것”이라면 late 또는 late final이 더 적합합니다.
API 응답처럼 값이 없을 수도 있는 상황에서 !로 밀어붙이면 왜 위험한가요?
API 응답은 필드 누락, 서버 변경, 데이터 오류 등으로 언제든 null이 들어올 가능성이 있습니다. 이런 값을 !로 강제하면 특정 상황에서만 재현되는 런타임 크래시로 이어질 수 있어 디버깅 비용이 커집니다. 이런 경우는 ?로 모델링하고 기본값, 조건 분기 등으로 처리하는 방식이 안정적입니다.