var, dynamic, Object?는 모두 타입 이름을 길게 쓰지 않을 수 있지만 안전성은 같지 않습니다. var는 주변 정보로 정적 타입을 정하고, dynamic은 일부 검사를 실행 시점으로 미룹니다. Object?는 어떤 값이나 null을 담을 수 있지만 구체 멤버를 쓰기 전에 타입 확인을 요구합니다.
차이를 문장으로만 설명하면 “결국 셋 다 아무 값이나 받는 것 아닌가?”라는 오해가 남습니다. 2026년 9월 22일 Dart SDK 3.13.4 안정판에서 일곱 개 소스를 만들고 분석과 실행 명령 열 개를 수행해, 오류가 어느 시점에 나타나는지와 외부 JSON을 어떻게 좁힐지를 직접 비교했습니다.
같은 잘못된 호출을 세 타입에 적용했다
Windows 10.0.19045 x64에서 strict-casts, strict-inference, strict-raw-types를 모두 켰습니다. 각 사례는 dart analyze --format machine으로 진단 이름과 종료 코드를 확인했고, 실행 결과가 필요한 파일은 dart run으로 다시 실행했습니다.
| 사례 | 정적 분석 | 실행 결과 |
|---|---|---|
초기값 있는 var에 다른 타입 대입 |
INVALID_ASSIGNMENT |
실행 전 차단 |
초기값 없는 var |
INFERENCE_FAILURE_ON_UNINITIALIZED_VARIABLE |
strict 설정에서 경고 |
dynamic int에 toUpperCase() |
진단 없음 | NoSuchMethodError, exit 255 |
Object? int에 같은 호출 |
UNDEFINED_METHOD |
실행 전 차단 |
Object?를 pattern으로 검사 |
진단 없음 | String·int·그 밖을 분리 |
| JSON dynamic을 int에 암시 대입 | INVALID_ASSIGNMENT |
strict-casts에서 차단 |
JSON을 Object?와 Map pattern으로 검사 |
진단 없음 | 정수만 accepted |

초기값이 있는 var는 타입을 고정했다
void main() {
var value = 'hello';
value = 7;
}
analyzer는 두 번째 대입에 A value of type 'int' can't be assigned to a variable of type 'String'이라는 INVALID_ASSIGNMENT를 냈습니다. var가 String으로 추론된 뒤에는 다른 타입을 자유롭게 받지 않는다는 증거입니다. 지역 추론과 명시 타입을 언제 바꿔야 하는지는 Dart에서 var와 명시 타입을 선택하는 기준에서 분석 결과와 함께 확인할 수 있습니다.
초기값 없는 var는 strict inference가 경고했다
void main() {
var value;
value = 'hello';
print(value.length);
value = 7;
print(value.isEven);
}
이 파일은 INFERENCE_FAILURE_ON_UNINITIALIZED_VARIABLE 경고를 냈습니다. 초기값도 타입 표기도 없으므로 구체 타입을 결정할 근거가 없다는 뜻입니다. 기본 설정에서 이런 코드가 허용되는지를 근거로 안전하다고 판단하면 안 됩니다. 타입을 나중에 넣어야 한다면 control-flow가 확정할 수 있는 구체 타입을 먼저 선언하거나, 외부 값의 경계라면 Object?로 받은 뒤 검사합니다.
null을 허용하는 타입과 아직 초기화하지 않은 값은 같은 문제가 아닙니다. 초기화 시점과 late의 위험은 Dart null safety: ?, !, late 차이에서 별도로 판단합니다.
dynamic은 분석을 통과하고 실행 중 실패했다
void main() {
dynamic value = 7;
print(value.toUpperCase());
}
이 파일의 정적 분석 종료 코드는 0이었습니다. 하지만 실행하자 종료 코드 255와 함께 다음 오류가 발생했습니다.
Unhandled exception:
NoSuchMethodError: Class 'int' has no instance method 'toUpperCase'.
Receiver: 7
Tried calling: toUpperCase()
dynamic은 잘못된 호출을 안전하게 바꾼 것이 아니라 발견 시점을 운영 중 실행으로 옮겼습니다. 외부 라이브러리나 언어 상호운용 때문에 꼭 필요한 작은 경계가 아니라면 내부 모델까지 dynamic으로 전파하지 않습니다. 필요한 경우에도 받은 즉시 검사·변환하고 이후 함수에는 구체 타입을 전달합니다.
Object?는 확인하지 않은 멤버 접근을 먼저 막았다
void main() {
Object? value = 7;
print(value.toUpperCase());
}
같은 호출을 Object?에 적용하자 analyzer가 UNDEFINED_METHOD로 실행 전에 막았습니다. 담을 수 있는 값의 범위는 넓지만, 그 값이 String이라고 증명하기 전에는 String 멤버를 사용할 수 없습니다.
String describe(Object? value) {
if (value case String text) {
return 'string:length=${text.length}';
}
if (value case int number) {
return 'int:isEven=${number.isEven}';
}
return 'other';
}
이 파일은 분석을 통과했고 'hello', 7, null을 각각 string:length=5, int:isEven=false, other로 분리했습니다. 검사에 성공한 분기 안에서만 변수의 타입이 승격되므로 무조건적인 as String보다 실패 경로가 명확합니다.
JSON은 decode 직후가 타입 경계다
Dart의 jsonDecode() 반환 타입은 dynamic입니다. 다음 코드는 응답의 age가 실제로 int인지 확인하지 않고 int 변수로 넘깁니다.
final decoded = jsonDecode('{"age":"not-an-int"}');
final int age = decoded['age'];
strict-casts: true에서는 두 번째 줄이 dynamic에서 int로의 암시 대입이라 INVALID_ASSIGNMENT로 차단됐습니다. 받은 값을 먼저 Object?로 닫고 Map pattern에서 키와 값 타입을 함께 확인하면 실패를 분기로 바꿀 수 있습니다.
String readAge(String source) {
final Object? decoded = jsonDecode(source);
return switch (decoded) {
{'age': int age} => 'accepted:$age',
_ => 'rejected:age must be int',
};
}
로컬 입력 {"age":42}는 accepted:42가 됐고, {"age":"42"}와 null은 모두 rejected:age must be int로 분리됐습니다. 실제 API에서는 Map 전체 구조, 필수 키, nullable 여부, 숫자 범위까지 제품 계약에 맞게 검사해야 합니다. List와 Map의 타입 인자를 고정하는 기본은 Dart 컬렉션 안전한 선언과 수정으로 이어집니다.
오류 발견 시점으로 선택하는 표
| 상황 | 우선 타입 | 이유와 다음 행동 |
|---|---|---|
| 초기값이 명확한 짧은 지역 변수 | var 또는 final |
정확한 정적 타입이 이미 추론됨 |
| null을 포함한 임의의 외부 값 | Object? |
is·pattern 검사 전 멤버 사용을 차단 |
| JSON decode 결과 | 즉시 Object?로 받고 shape 검사 |
Map/List와 내부 값 타입을 경계에서 검증 |
| 의도적인 동적 호출이 불가피한 상호운용 경계 | 최소 범위의 dynamic |
검사·변환 후 내부에는 구체 타입 전달 |
| 초기값 없이 나중에 할당할 내부 변수 | 구체 타입 또는 nullable 타입 | 할당 경로와 사용 전 초기화를 analyzer로 확인 |
| 서버 모델·DTO·공개 함수 | 완전한 필드·매개변수·반환 타입 | 호출자와 데이터 계약을 명시 |
타입만 바꿔도 런타임 데이터가 자동으로 검증되는 것은 아닙니다. 네트워크 입력은 status, content type, schema와 오류 경로를 함께 기록해야 합니다. 로그인 응답처럼 쿠키·토큰·JSON 상태가 섞인 경우는 웹앱 로그인 유지 오류의 계층별 점검처럼 전송과 모델 변환을 나눠 조사합니다.
이번 재현의 범위 밖
- Dart 명령줄 analyzer와 VM만 사용했고 Flutter 위젯이나 실제 Android 앱을 실행하지 않았습니다.
- JSON은 로컬 문자열 두 개와 null 한 개뿐이며 실제 API의 모든 구조를 대표하지 않습니다.
- 성능 차이, AOT 최적화 결과, 메모리 사용량은 측정하지 않았습니다.
- 진단은 Dart 3.13.4와 공개한 strict 설정에 한정되며 프로젝트 options가 다르면 표시가 달라질 수 있습니다.
Object?가 모든 입력을 자동 검증하지 않습니다. 성공 분기의 type·shape 조건은 개발자가 정의해야 합니다.