Dart 타입 오류 읽는 순서: analyzer 진단·null·dynamic 경계

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으로 바꾸기 전에 진단 코드, 파일, 줄·열, 실제 메시지를 그대로 남깁니다.

Dart analyzer 진단과 런타임 예외를 실패 시점과 타입 경계별로 분류한 직접 검증 결과
Dart 3.13.4 strict analyzer의 네 진단과 VM의 네 런타임 예외, Map pattern 입력 검증을 분리 실행한 결과입니다.

두 번째 분기: 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로만 교체

재현용 최소 파일을 만드는 순서

  1. 전체 앱 로그에서 첫 analyzer 진단 또는 첫 unhandled exception을 복사합니다.
  2. 문제가 난 값의 선언, 대입, 사용 세 지점만 새 Dart 파일로 옮깁니다.
  3. dart analyze --format machine 파일.dart의 종료 코드와 진단 코드를 저장합니다.
  4. analyzer가 통과할 때만 dart run 파일.dart로 런타임 실패를 확인합니다.
  5. 가드·pattern·명시 타입 등 한 가지 수정만 적용하고 두 명령을 다시 실행합니다.

Flutter 위젯 전체를 최소 재현에 남기면 layout, 생명주기, plugin 문제가 섞일 수 있습니다. 타입 경계가 순수 Dart로 재현된다면 먼저 UI 없이 줄이는 편이 빠릅니다. 반대로 BuildContext나 위젯 lifecycle에서만 발생한다면 그때 최소 Flutter test로 확장합니다.

겹치는 문법 설명 대신 목적별 실험으로 이동한다

이 글은 오류를 분류하는 입구입니다. 각 키워드의 상세 동작은 실제 실험이 있는 글로 나눴습니다.

이번 실행의 한계

  • Dart SDK 3.13.4 stable의 Windows VM과 analyzer에서 최소 파일을 실행했습니다.
  • Flutter widget, isolate, FFI, JavaScript interop 경계는 포함하지 않았습니다.
  • 진단 코드와 메시지는 SDK 버전에 따라 달라질 수 있으므로 실제 출력의 첫 오류를 우선해야 합니다.
  • Map pattern 예제는 작은 고정 schema이며 중첩 JSON 전체의 validation 전략을 대신하지 않습니다.

실행 결과와 대조한 공식 자료