Dart 타입 오류를 해결할 때 var, final, dynamic, ?의 정의를 처음부터 다시 외우는 것보다 먼저 할 일이 있습니다. 오류가 analyzer에서 멈췄는지, 실행 중 멈췄는지, 잘못된 값이 어느 경계에서 들어왔는지를 기록하는 일입니다. 이 세 가지가 정해지면 고칠 위치가 크게 줄어듭니다.
2026년 9월 30일 Dart SDK 3.13.4 stable에서 strict-casts, strict-inference, strict-raw-types를 켰습니다. 정적 오류 네 종류를 한 파일에서 분석하고, analyzer를 통과하는 dynamic 호출·null 단언·late 읽기·강제 cast를 각각 VM에서 실행했습니다. 외부 Map은 pattern으로 타입을 좁힌 안전 경로와 비교했습니다. 14개 기대 조건이 모두 통과했습니다.
첫 분기: analyzer가 실행 전에 멈췄는가
아래 네 코드는 같은 “타입 오류”처럼 보여도 고칠 경계가 다릅니다. machine 형식으로 받은 실제 진단 코드와 위치를 기준으로 분류했습니다.
| 진단 코드 | 실험한 코드 | 고칠 경계 |
|---|---|---|
INVALID_ASSIGNMENT |
int로 추론된 변수에 String 대입 | 재대입 값 또는 선언 타입 |
UNCHECKED_USE_OF_NULLABLE_VALUE |
String?의 멤버 직접 접근 |
null 상태 처리 |
STRICT_RAW_TYPE |
타입 인자가 없는 List |
컬렉션 요소 타입 |
CONST_EVAL_METHOD_INVOCATION |
const int.parse('42') |
컴파일 타임과 런타임 값 결정 |
dart analyze --format machine static_errors.dart
# 종료 코드 3
ERROR|COMPILE_TIME_ERROR|INVALID_ASSIGNMENT|...
ERROR|COMPILE_TIME_ERROR|UNCHECKED_USE_OF_NULLABLE_VALUE|...
ERROR|COMPILE_TIME_ERROR|CONST_EVAL_METHOD_INVOCATION|...
WARNING|STATIC_WARNING|STRICT_RAW_TYPE|...
첫 오류부터 고치는 이유는 뒤의 진단이 앞선 타입 정보에 영향을 받을 수 있기 때문입니다. 타입을 억지로 넓히거나 dynamic으로 바꾸기 전에 진단 코드, 파일, 줄·열, 실제 메시지를 그대로 남깁니다.

두 번째 분기: analyzer는 통과했는데 실행 중 멈췄는가
별도 파일에 네 런타임 경계를 넣었고 이 파일 자체는 analyzer 종료 코드 0이었습니다. 모드를 하나씩 실행하자 모두 종료 코드 255로 멈췄습니다.
| 표현 | 첫 런타임 예외 | 숨겨진 계약 |
|---|---|---|
dynamicValue.missingMethod() |
NoSuchMethodError |
멤버가 실제 타입에 존재한다 |
nullableValue! |
Null check operator used on a null value |
이 경로에서는 null이 아니다 |
초기화 전 late 읽기 |
LateInitializationError |
모든 읽기보다 초기화가 먼저다 |
objectValue as int |
String is not a subtype of int |
외부 값이 실제로 int다 |
네 표현은 타입 검사를 없앤 것이 아니라 실패를 실행 시점으로 옮겼습니다. 따라서 예외가 난 줄에 다시 !, dynamic, as를 덧붙이는 방식으로는 원래 경계를 고칠 수 없습니다. 호출 직전이 아니라 값이 들어온 입구를 찾습니다.
세 번째 분기: 값이 처음 들어온 경계를 찾는다
앱에서 타입이 불명확해지는 지점은 대개 HTTP JSON, 플랫폼 channel, 로컬 저장소, route 인자, 폼 입력입니다. 내부 전체를 dynamic으로 유지하지 않고 입구에서 검증한 값만 좁은 타입으로 넘깁니다.
({int id, String name})? parseUser(Object? input) {
if (input case {'id': int id, 'name': String name}) {
return (id: id, name: name.trim());
}
return null;
}
이 함수는 analyzer를 오류 없이 통과했습니다. {'id': 7, 'name': ' Ada '}는 (id: 7, name: Ada)로 변환됐고, id가 문자열인 입력과 name이 없는 입력은 모두 null이었습니다. 성공한 뒤의 코드는 Map<String, dynamic>를 계속 들고 다니지 않고 int와 String이 보장된 record만 사용합니다.
null 반환이 적절한지는 제품 요구에 따라 다릅니다. 입력이 잘못됐다는 상세 이유가 필요하면 성공/실패 sealed class나 validation 오류 목록을 반환할 수 있습니다. 중요한 점은 잘못된 값을 UI 깊숙한 곳까지 보내지 않는 것입니다.
진단 코드별로 확인할 한 줄
| 보이는 신호 | 먼저 묻는 질문 | 피할 임시 조치 |
|---|---|---|
INVALID_ASSIGNMENT |
초기값이 의도보다 좁게 추론됐나, 뒤 값이 잘못됐나? | 무조건 dynamic으로 변경 |
| nullable access | null이 정상 상태인가, 입력 오류인가? | 근거 없는 ! |
| raw List/Map | 요소·키·값 타입을 경계에서 정할 수 있나? | 경고 무시 |
| const 평가 오류 | 값이 컴파일 전에 정말 결정되는가? | parse 결과를 const로 강제 |
NoSuchMethodError |
dynamic이 어디서 들어왔나? |
try/catch만 추가 |
| cast 오류 | as 전에 런타임 타입을 검증했나? |
다른 타입으로 재캐스팅 반복 |
LateInitializationError |
초기화 순서가 모든 경로에서 보장되는가? | late를 nullable로만 교체 |
재현용 최소 파일을 만드는 순서
- 전체 앱 로그에서 첫 analyzer 진단 또는 첫 unhandled exception을 복사합니다.
- 문제가 난 값의 선언, 대입, 사용 세 지점만 새 Dart 파일로 옮깁니다.
dart analyze --format machine 파일.dart의 종료 코드와 진단 코드를 저장합니다.- analyzer가 통과할 때만
dart run 파일.dart로 런타임 실패를 확인합니다. - 가드·pattern·명시 타입 등 한 가지 수정만 적용하고 두 명령을 다시 실행합니다.
Flutter 위젯 전체를 최소 재현에 남기면 layout, 생명주기, plugin 문제가 섞일 수 있습니다. 타입 경계가 순수 Dart로 재현된다면 먼저 UI 없이 줄이는 편이 빠릅니다. 반대로 BuildContext나 위젯 lifecycle에서만 발생한다면 그때 최소 Flutter test로 확장합니다.
겹치는 문법 설명 대신 목적별 실험으로 이동한다
이 글은 오류를 분류하는 입구입니다. 각 키워드의 상세 동작은 실제 실험이 있는 글로 나눴습니다.
- 재대입·객체 내부 변경·const canonicalization: Dart var·final·const 실행 비교
- 추론 타입과 명시 타입을 놓을 위치: Dart var와 명시 타입 선택 기준
- 외부 JSON 경계와 런타임 멤버 오류: Dart var·dynamic·Object? 비교
- null 가능성과 초기화 순서: Dart null safety의 ?, !, late 실행 비교
이번 실행의 한계
- Dart SDK 3.13.4 stable의 Windows VM과 analyzer에서 최소 파일을 실행했습니다.
- Flutter widget, isolate, FFI, JavaScript interop 경계는 포함하지 않았습니다.
- 진단 코드와 메시지는 SDK 버전에 따라 달라질 수 있으므로 실제 출력의 첫 오류를 우선해야 합니다.
- Map pattern 예제는 작은 고정 schema이며 중첩 JSON 전체의 validation 전략을 대신하지 않습니다.