다트 기초: 타입·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.
판단 그림. 질문 1: 나중에 다시 대입하는가? 그렇다면 var(또는 int 같은 명시적 타입). 아니라면 질문 2: 값이 컴파일할 때 정해져 있는가(숫자·문자열 리터럴 등)? 그렇다면 const. DateTime.now() 나 파일에서 읽은 값처럼 실행 중에 정해지면 final.
var, final, const 중 무엇을 쓸까. 먼저 const, 안 되면 final, 값이 정말 바뀔 때만 var.

플러터 팀에서 흔한 습관은 "기본은 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

기억할 네 가지:

  1. / 는 언제나 double 을 돌려줍니다. 8 / 2 도 4.0 입니다. 자바처럼 정수끼리 나누면 정수가 나오겠지 하고 쓰면 바로 invalid_assignment 를 만납니다. 정수 나눗셈은 ~/ 이고, 0 쪽으로 버리므로 -7 ~/ 2 는 -4 가 아니라 -3 입니다.
  2. 나누는 수가 양수면 % 는 음수가 되지 않습니다. -7 % 3 은 2 입니다. 자바·C 처럼 -1 이 필요하면 .remainder() 를 씁니다. 자바에서 넘어온 사람이 가장 많이 틀리는 부분입니다.
  3. 다트 VM 과 네이티브 앱에서 int 는 조용히 오버플로합니다. 64비트 최댓값에 1을 더하면 최솟값으로 돌아가고 예외는 없습니다.
  4. 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 아님, 믿어 줘" 라고 말합니다. 컴파일러는 조용해지지만 검사는 실행 시점으로 넘어갑니다. 틀렸을 때 나오는 것이 플러터 앱에서 가장 흔한 크래시입니다.
흐름 그림. String? 타입 값을 다루는 방법은 네 가지다. if (s != null) 은 블록 안에서 String 으로 승격시킨다. s?.length 는 s 가 null 이면 죽지 않고 null 을 돌려준다. s ?? 'default' 는 null 을 대체 값으로 바꾼다. s! 는 null 이 아니라고 단언하는 것이고, null 이면 실행 중 오류 Null check operator used on a null value 로 멈춘다.
String? 값을 다루는 네 가지 방법. 실행 중에 죽을 수 있는 것은 ! 하나뿐이고, 나머지 셋은 컴파일러가 안전을 보장한다.

? 없이 쓰면 컴파일러가 먼저 막아 줍니다.

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
같은 hello world 파일의 시작 시간 막대그래프. dart run hello.dart 는 334밀리초, dart hello.dart 는 199밀리초, dart compile exe 로 만든 실행 파일은 17밀리초.
hello world 의 시작 시간(20회 중앙값, 3번 잰 평균). Dart 3.11.5, Apple M1 Pro. 컴파일한 exe 가 dart file.dart 보다 약 12배 빨리 시작한다.

요약에는 나오지 않는 발견이 두 가지입니다. 첫째, 같은 파일인데도 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 의 함정과, 순회 중에 리스트를 바꿀 때 나는 오류도 직접 띄워 봅니다.

자주 묻는 질문

플러터에서 "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() 로 명시적으로 바꿉니다.

  • #다트
  • #초급
  • #널 안전성
  • #타입
  • #플러터
  • #dart analyze

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑