다트 기초: 타입·final 과 const·널 안전성을 실행 결과로
다트 강좌 1. dart run 으로 5분 만에 첫 프로그램, var/final/const 실제 오류, ~/ 정수 나눗셈, 문자열 보간, ? ! ?? late 와 dart analyze 진단 코드, 시작 시간 실측.
시리즈: 다트 강좌 1/6 (초급). 다트를 한 줄도 써 보지 않은 분, 또는 플러터 예제를 돌려 보다가 null 관련 빨간 밑줄에서 막힌 분을 위한 글입니다. 다 읽으면 첫 프로그램을 실행하고, var·final·const 를 골라 쓰고, 컴파일러의 널 안전성 오류와 실행 중 크래시 메시지를 읽을 수 있습니다.
다트(Dart)는 플러터(Flutter) 앱을 만드는 언어입니다. 학교나 국비 과정에서 자바로 프로그래밍을 배웠다면 NullPointerException 은 익숙하겠지만, 다트는 그 문제를 컴파일할 때 잡아내도록 설계되어 있어서 처음에는 컴파일러가 유난히 깐깐하게 느껴집니다. 그리고 플러터 팀에 들어가 첫 달에 읽게 될 크래시 리포트는 대부분 이 글에 나오는 오류입니다. Null check operator used on a null value, LateInitializationError, 그리고 분석기가 이미 경고했던 타입 오류. 아래 출력은 모두 Apple Silicon Mac(M1 Pro)의 Dart SDK 3.11.5(stable) 로 직접 실행한 결과이고, 출력 속 파일 경로만 ~/demo/ 로 바꿨습니다.
5분 만에 첫 프로그램
플러터를 설치했다면 다트도 이미 있습니다. dart 명령은 플러터 SDK 안에 들어 있습니다.
$ dart --version
Dart SDK version: 3.11.5 (stable) (Wed Apr 15 00:36:32 2026 -0700) on "macos_arm64"
hello.dart 파일을 만듭니다.
void main() {
print('Hello, Dart!');
}
실행합니다.
$ dart run hello.dart
Hello, Dart!
준비는 이게 전부입니다. 방금 쓴 용어를 정리합니다.
main()은 진입점, 즉 다트가 가장 먼저 호출하는 함수입니다.void는 "아무것도 돌려주지 않는다"는 뜻입니다.print()는 터미널에 한 줄을 출력합니다.- 문장 끝에는
;가 있어야 합니다. 빠뜨리는 것이 가장 흔한 첫 오류인데,dart run은Error: Expected ';' after this.와 함께 캐럿(^)으로 위치를 짚어 주고,dart analyze는Expected to find ';'. - expected_token으로 알려 줍니다.
이 강좌에서는 프로젝트 폴더가 필요 없습니다. dart create -t console my_app 으로 pubspec.yaml 과 테스트가 갖춰진 프로젝트를 만들 수 있지만, .dart 파일 하나만으로도 실행되므로 여기서는 그렇게 진행합니다.
var·final·const 와 각각의 오류
변수는 값에 붙이는 이름입니다. 다트에는 선언 방법이 세 가지 있습니다.
void main() {
var count = 3; // type inferred: int
final name = 'Mina'; // set once, at run time
const pi = 3.14159; // compile-time constant
final now = DateTime.now();
print('${count.runtimeType} ${name.runtimeType} ${pi.runtimeType} ${now.year > 2000}');
}
int String double true
int 나 String 을 쓰지 않았는데도 다트는 타입을 압니다. 이것이 타입 추론으로, 오른쪽 값을 보고 변수의 타입을 확정합니다. var count = 3 은 int count = 3 과 완전히 같습니다. 자바스크립트의 var 처럼 "아무거나 담는 변수"가 아닙니다. 자바 10 의 var 와 같은 개념이라고 생각하면 됩니다.
일부러 망가뜨려 봅니다.
void main() {
final name = 'Mina';
name = 'Jun';
const now = DateTime.now();
var count = 3;
count = 'three';
Object? b = 'hello';
print(b.length);
int half = 7 / 2;
}
dart analyze 는 실행하지 않고 파일을 검사합니다. 각 줄 끝의 진단 코드가 핵심으로, 메시지가 이해되지 않을 때는 이 코드로 검색합니다(사용하지 않는 변수 경고는 생략).
$ dart analyze errors.dart
error - errors.dart:3:3 - The final variable 'name' can only be set once. Try making 'name' non-final. - assignment_to_final_local
error - errors.dart:5:15 - Const variables must be initialized with a constant value. Try changing the initializer to be a constant expression. - const_initialized_with_non_constant_value
error - errors.dart:5:15 - The constructor being called isn't a const constructor. Try removing 'const' from the constructor invocation. - const_with_non_const
error - errors.dart:8:11 - A value of type 'String' can't be assigned to a variable of type 'int'. Try changing the type of the variable, or casting the right-hand type to 'int'. - invalid_assignment
error - errors.dart:11:11 - The getter 'length' isn't defined for the type 'Object'. ... - undefined_getter
error - errors.dart:13:14 - A value of type 'double' can't be assigned to a variable of type 'int'. ... - invalid_assignment
같은 파일을 dart run 하면 아예 시작하지 않고, 다른 표현으로 문제 위치를 가리킵니다.
errors.dart:3:3: Error: Can't assign to the final variable 'name'.
name = 'Jun';
^^^^
errors.dart:5:24: Error: Cannot invoke a non-'const' constructor where a const expression is expected.
Try using a constructor or factory that is 'const'.
const now = DateTime.now();
^^^
여기서 배울 것:
final은 "대입은 한 번". 값은 실행 중에 계산해도 됩니다(DateTime.now()도 가능).const는 "컴파일할 때 값이 정해져 있음". 현재 시각은 그렇지 않으므로const_initialized_with_non_constant_value.var는 첫 줄에서 타입을int로 확정했으므로count = 'three'는invalid_assignment.
플러터 팀에서 흔한 습관은 "기본은 final, 바뀌지 않는 리터럴과 위젯에는 const, 정말 다시 대입할 때만 var" 입니다(선택 lint 인 prefer_final_locals 로 강제하는 팀도 있습니다). const 객체는 컴파일할 때 만들어져 같은 인스턴스를 재사용하므로, 플러터 코드에 const Text(...) 가 그렇게 많이 보이는 것입니다.
dynamic 과 Object?: "아무거나" 를 쓰는 두 가지 방법
dynamic 과 Object? 는 둘 다 어떤 타입의 값이든 담습니다. 차이는 컴파일러가 무엇을 허락하느냐입니다.
void main() {
dynamic a = 'hello';
Object? b = 'hello';
print(a.length);
if (b is String) print(b.length); // promoted to String
a = 42;
print(a.length);
}
이 파일에 dart analyze 는 No issues found! 라고 답하지만, 실행하면 죽습니다.
5
5
Unhandled exception:
NoSuchMethodError: Class 'int' has no instance getter 'length'.
Receiver: 42
Tried calling: length
dynamic 이면 컴파일러가 검사를 멈추므로, 실수는 그 줄이 실행될 때, 즉 사용자의 휴대폰 위에서 처음 드러납니다. Object? 라면 b.length 는 컴파일 단계에서 거부되고(앞의 undefined_getter), is 로 타입을 확인한 뒤에야 쓸 수 있습니다. if (b is String) 블록 안에서 b 는 String 으로 승격됩니다. 이를 타입 승격(type promotion) 이라 하며, 널 안전성도 같은 원리를 씁니다.
| 하고 싶은 것 | 쓸 것 | 검사하는 쪽 |
|---|---|---|
| 타입을 아는 값 | 그 타입(int, String) 또는 final x = … |
컴파일러 |
무엇이든 받되 is 로 확인 후 사용 |
Object? (null 이 아니면 Object) |
컴파일러 |
| 곧바로 변환할 날 JSON | dynamic 으로 받고 즉시 타입 있는 클래스로 |
실행할 때까지 아무도 |
jsonDecode() 의 반환 타입이 dynamic 입니다. API 응답을 읽는 플러터 코드에서 NoSuchMethodError 가 자주 나는 이유이고, 받는 경계에서 바로 타입 있는 데이터로 바꾸는 것이 원칙입니다.
숫자: int·double 과 ~/
print(7 / 2); // 3.5
print(7 ~/ 2); // 3
print(-7 ~/ 2); // -3
print(7 % 3); // 1
print(-7 % 3); // 2
print((-7).remainder(3)); // -1
print(0.1 + 0.2); // 0.30000000000000004
print(1 / 0); // Infinity
print(9223372036854775807 + 1);
print(2.5.round()); // 3
print((-2.5).round()); // -3
3.5
3
-3
1
2
-1
0.30000000000000004
Infinity
-9223372036854775808
3
-3
기억할 네 가지:
/는 언제나double을 돌려줍니다.8 / 2도4.0입니다. 자바처럼 정수끼리 나누면 정수가 나오겠지 하고 쓰면 바로invalid_assignment를 만납니다. 정수 나눗셈은~/이고, 0 쪽으로 버리므로-7 ~/ 2는-4가 아니라-3입니다.- 나누는 수가 양수면
%는 음수가 되지 않습니다.-7 % 3은2입니다. 자바·C 처럼-1이 필요하면.remainder()를 씁니다. 자바에서 넘어온 사람이 가장 많이 틀리는 부분입니다. - 다트 VM 과 네이티브 앱에서
int는 조용히 오버플로합니다. 64비트 최댓값에 1을 더하면 최솟값으로 돌아가고 예외는 없습니다. round()는 .5 를 0 에서 먼 쪽으로 반올림합니다(2.5→3,-2.5→-3).
int.parse('42') 는 문자열을 숫자로 바꾸고 잘못된 입력이면 예외를 던집니다. int.tryParse('4x2') 는 대신 null 을 돌려줍니다. 여기서 널 안전성이 등장합니다.
문자열과 보간
final name = 'Mina';
final apps = 3;
print('Hi $name, you have $apps apps.');
print('Next year: ${apps + 1} apps, ${name.toUpperCase()}');
print('Price: \$${(1234.5).toStringAsFixed(2)}');
print(r'raw \n $name');
print('😀'.length);
print('😀'.runes.length);
Hi Mina, you have 3 apps.
Next year: 4 apps, MINA
Price: $1234.50
raw \n $name
2
1
$name 은 변수를, ${…} 는 임의의 식을 끼워 넣습니다. 달러 기호 자체는 \$ 로 씁니다. 작은따옴표와 큰따옴표는 같은 뜻이고, ''' 는 여러 줄 문자열, 앞에 r 을 붙이면 이스케이프와 보간이 모두 꺼집니다. 'Age: ' + apps 는 int 가 String 이 아니라서 컴파일 오류이며, 보간으로 쓰는 것이 다트다운 방법입니다.
이모지 줄은 실무 함정입니다. .length 는 UTF-16 코드 단위를 세므로 이모지 하나가 2 입니다. 닉네임을 .length 로 "10자까지" 막으면 이모지를 쓰는 사용자는 5자밖에 못 넣습니다. 한글은 완성형이면 글자당 1단위라 '한글'.length 는 2 였습니다. 그런데 macOS 파일 이름 등에서 넘어온 자모 분리형(NFD) 한글은 다릅니다. '한' 는 화면에 한 으로 보이지만 .length 가 3 이고, 완성형 '한' 과 == 비교하면 false 였습니다. 업로드된 파일 이름 비교가 이유 없이 실패할 때 의심할 곳입니다.
건전한 널 안전성: ? ?. ?? ! late
다트 2.12 부터, 그리고 다트 3 부터는 끌 수도 없게 된 규칙으로, String 같은 타입에는 null 을 넣을 수 없습니다. 값이 없을 수도 있다면 ? 를 붙여 String? 로 씁니다. 그러면 컴파일러는 사용하기 전에 null 인 경우를 처리하라고 요구합니다.
String? findNickname(String user) => user == 'mina' ? 'Mimi' : null;
void main() {
String? a = findNickname('mina');
String? b = findNickname('jun');
print(a?.length); // 4
print(b?.length); // null
print(b ?? 'no nickname'); // fallback
b ??= 'guest'; // assign only if null
print(b);
String? c = findNickname('jun');
if (c != null) {
print(c.length); // promoted to String inside the if
}
print(c!.length); // you promised it is not null... it is
}
4
null
no nickname
guest
Unhandled exception:
Null check operator used on a null value
#0 main (file:///~/demo/nulls.dart:18:10)
?.는 값이 null 이 아닐 때만 멤버를 호출하고, null 이면 식 전체가null이 됩니다.??는 대체 값을 주고,??=는 변수가 null 일 때만 대입합니다.if (c != null)블록 안에서c는String으로 승격됩니다.is String과 같은 원리입니다.!(널 단언 연산자)는 컴파일러에게 "null 아님, 믿어 줘" 라고 말합니다. 컴파일러는 조용해지지만 검사는 실행 시점으로 넘어갑니다. 틀렸을 때 나오는 것이 플러터 앱에서 가장 흔한 크래시입니다.
? 없이 쓰면 컴파일러가 먼저 막아 줍니다.
error - errors.dart:16:14 - The property 'length' can't be unconditionally accessed because the receiver can be 'null'. Try making the access conditional (using '?.') or adding a null check to the target ('!'). - unchecked_use_of_nullable_value
error - errors.dart:18:13 - A value of type 'Null' can't be assigned to a variable of type 'int'. ... - invalid_assignment
분석기의 제안에 "or adding a null check to the target ('!')" 가 들어 있다는 점에 주의하세요. 기술적으로는 맞지만 대개 잘못된 해결책입니다. if, ?., ?? 를 먼저 떠올리세요.
late: "읽기 전에 꼭 넣겠습니다" 라는 약속
late 를 붙이면 null 을 허용하지 않는 변수를 값 없이 선언할 수 있습니다. 플러터의 State 클래스에서 자주 보는 형태입니다(initState 에서 대입하는 late final AnimationController controller; 등).
class Settings {
late String token; // "I will set it before reading it"
late final String theme = loadTheme(); // lazy: runs on first read
late final int id; // set once, later
void login() => token = 'abc123';
}
String loadTheme() {
print(' loadTheme() runs now');
return 'dark';
}
void main() {
final s = Settings();
print('created Settings');
print(s.theme);
print(s.theme);
s.id = 1;
try {
s.id = 2;
} catch (e) {
print(e);
}
print(s.token); // forgot to call login()
}
created Settings
loadTheme() runs now
dark
dark
LateInitializationError: Field 'id' has already been initialized.
Unhandled exception:
LateInitializationError: Field 'token' has not been initialized.
#0 Settings.token (~/demo/late.dart)
#1 main (~/demo/late.dart:26:11)
한 번 실행에 세 가지 동작이 보입니다. late final … = loadTheme() 는 처음 읽을 때 딱 한 번 실행되고, late final 에 두 번 대입하면 예외, 대입하지 않은 late 필드를 읽으면 예외입니다. 지역 변수라면 분석기가 잡아냅니다(late String msg; print(msg); 는 definitely_unassigned_late_local_variable). 하지만 필드는 못 잡으므로 late 역시 ! 처럼 실행해야만 확인되는 약속입니다.
흔한 실수: ! 로 분석기 입 막기
상황. 화면에 사용자 닉네임을 띄운다. 모델은 String? nickname, 위젯에서 Text(user.nickname!) 로 쓰니 빨간 밑줄이 사라졌고, 테스트 계정은 모두 닉네임이 있어서 확인도 통과했다.
실제로 일어나는 일. 닉네임을 설정하지 않은 실제 사용자가 그 화면을 여는 순간 Null check operator used on a null value. 플러터 디버그 빌드에서는 빨간 오류 화면, 릴리스 빌드에서는 회색 영역이나 크래시 리포트가 됩니다. 분석기는 경고했는데 ! 가 그 경고를 꺼 버린 것입니다.
해결. 값이 없을 때 화면에 무엇을 보여 줄지 정하고, 그것을 코드로 씁니다.
Text(user.nickname ?? 'Guest')
"없으면 아무것도 안 보여 준다" 라면 if (user.nickname != null) Text(user.nickname!) 도 괜찮은데, 그건 검사가 ! 바로 옆에 있기 때문입니다. 물려받은 코드를 점검할 때는 !. 와 !) 로 검색해서 하나씩 "이걸 누가 보장하지?" 라고 묻는 것이 빠릅니다.
실측: dart run 과 컴파일한 exe 의 시작 시간
dart run 은 시작할 때마다 코드를 그 자리에서 컴파일(JIT)합니다. dart compile exe 는 미리(AOT) 네이티브 실행 파일로 컴파일하며, 플러터 릴리스 빌드와 같은 종류의 컴파일입니다.
$ dart compile exe hello.dart -o hello
Generated: ~/demo/hello
컴파일은 0.87초, 실행 파일은 5,587,776바이트(약 5.3 MB, 다트 런타임을 내장하기 때문)였습니다. hello.dart 를 명령마다 20번씩 실행해 중앙값을 구하고, 이를 3번 반복했습니다.
| 명령 | 시작 시간 중앙값(3회분) |
|---|---|
dart run hello.dart |
330~340 ms |
dart hello.dart |
193~210 ms |
./hello (AOT exe) |
16~18 ms |
요약에는 나오지 않는 발견이 두 가지입니다. 첫째, 같은 파일인데도 dart run file.dart 가 dart file.dart 보다 매번 약 130 ms 느렸습니다. dart run 은 패키지 해석 경로를 거치고(프로젝트의 bin/ 을 실행하는 것과 같은 명령), dart file.dart 는 파일만 읽습니다. 그 이상은 파고들지 않았지만, 간단한 실험이라면 dart file.dart 로 충분합니다. 둘째, AOT 빌드의 크래시 메시지도 똑같이 Null check operator used on a null value 였지만 스택 트레이스에서 열 번호가 사라졌습니다(nulls.dart:18:10 이 아니라 nulls.dart:18). 종료 코드는 둘 다 255 였습니다.
한계와 버전 메모
- 모든 예제는 Dart 3.11.5, Apple M1 Pro 에서만 실행했습니다. 시간은 환경마다 다르니 비율을 보세요.
- 널 안전성은 다트 2.12 에서 들어왔습니다. "이제 끌 수 없다" 는 점은 직접 확인했습니다. 첫 줄에
// @dart=2.9를 쓴 파일은 3.11.5 에서illegal_language_version_override("The language version must be >=2.12.0") 오류가 납니다. - 정수 오버플로 순환은 다트 VM 과 네이티브(플러터 모바일·데스크톱) 이야기입니다. 웹에서는
int가 자바스크립트 숫자가 되어 한계가 다릅니다. 이 글에서는 웹 빌드를 실행하지 않았습니다. dart analyze는pubspec.yaml없이 실행했으므로 기본 진단만 나왔습니다.dart create나flutter create프로젝트에는 권장 lint 패키지가 더해지지만pub get이 필요해서, 여기서는 패키지 없는 단일 파일로만 진행했습니다.
연습 문제
1. 실행하지 말고 출력을 예상해 보세요.
print(17 ~/ 5);
print(17 % 5);
print(-17 ~/ 5);
print(-17 % 5);
print(17 / 5);
2. ! 도 if 도 쓰지 말고, 길이를 돌려주되 name 이 null 이면 0 을 돌려주는 int nameLength(String? name) 을 작성하세요.
3. 다음 코드는 죽습니다. 오류 메시지의 뜻을 설명하고 두 가지 방법으로 고치세요.
String? title;
print(title!.toUpperCase());
정답
1. 실제 출력은 3, 2, -3, 3, 3.4. ~/ 는 0 쪽으로 버리므로 -4 가 아니라 -3 이고, 양수로 나누는 % 는 음수가 되지 않으므로 -17 % 5 는 3 입니다(-17 = -4 × 5 + 3). 자바라면 -17 % 5 는 -2 입니다.
2. int nameLength(String? name) => name?.length ?? 0; 으로 'Mina' 에는 4, null 에는 0 이 출력됐습니다. ?. 때문에 결과가 int? 가 되고, ?? 가 다시 int 로 만듭니다.
3. Null check operator used on a null value: ! 로 null 이 아니라고 단언했는데 null 이었다는 뜻입니다. 대체 값으로 고치면 print(title?.toUpperCase() ?? '(untitled)');, 검사로 고치면 if (title == null) return '(untitled)'; 다음에 title.toUpperCase()(승격되므로 ! 불필요). 덤: 이 짧은 코드 그대로 첫 번째 방법을 쓰면 분석기가 dead_code 경고를 냈습니다. 흐름 분석으로 title 이 항상 null 임을 증명할 수 있었기 때문입니다. 실제 코드에서는 값이 바깥에서 들어오므로 이 경고는 나오지 않습니다.
다음 강좌
다음 강좌는 함수(이름 있는 매개변수와 required, 클로저)와 컬렉션입니다. 지연 평가되는 Iterable 의 함정과, 순회 중에 리스트를 바꿀 때 나는 오류도 직접 띄워 봅니다.
- 다음: 다트 강좌 2/6: 함수와 컬렉션
- 시리즈 이후: 클래스·믹스인·확장, 레코드·패턴·sealed, 비동기·Future·Stream·Isolate, JIT·AOT 와 메모리
- 플러터 실무: 앱 16개를 최신 플러터로 옮기며 깨진 것들, 다트 http 는 POST 의 301 을 따라가지 않는다
- 다른 언어의 같은 주제: 스위프트 강좌 1: 옵셔널과 타입, 파이썬 강좌 1: 자료형과 가변성, 자바 강좌 1: 자료형·문자열·동등성
자주 묻는 질문
플러터에서 "Null check operator used on a null value" 오류는 무슨 뜻인가요?
어딘가에서 그 순간 null 이던 변수에 ! 연산자를 썼다는 뜻입니다. ! 는 컴파일러에게 '이건 null 이 아니니 믿어 달라'고 말하는 것이라 검사가 실행 시점으로 미뤄지고, 틀리면 프로그램이 멈춥니다. ! 대신 if (x != null), ?., ?? 대체 값을 씁니다.
다트의 final 과 const 는 무엇이 다른가요?
둘 다 한 번만 대입할 수 있습니다. const 는 컴파일할 때 값이 정해져 있어야 하고(리터럴, 다른 const), final 은 실행 중에 계산한 값이어도 됩니다. const now = DateTime.now() 는 const_initialized_with_non_constant_value 오류가 나고 final now = DateTime.now() 는 통과합니다.
int half = 7 / 2 가 왜 오류인가요?
다트의 / 는 항상 double 을 돌려주므로 int 에 대입하면 invalid_assignment 컴파일 오류가 납니다. 정수 나눗셈은 ~/ 를 쓰거나(7 ~/ 2 는 3), (7 / 2).toInt() 나 .round() 로 명시적으로 바꿉니다.