Dart에서 var와 명시 타입을 선택하는 기준

Dart에서 var와 명시 타입 중 하나를 일괄 규칙으로 정하면 오히려 중요한 차이를 놓칩니다. var는 “아무 타입이나 받는다”가 아니라 초기값과 주변 문맥으로 정적 타입을 추론하라는 선언입니다. 반면 명시 타입은 추론 결과를 반복해서 쓰는 장식이 아니라, 추론보다 넓거나 더 구체적인 계약이 필요할 때 가치가 있습니다.

이 판단을 문체 취향으로만 설명하지 않기 위해 2026년 9월 22일 Dart SDK 3.13.4 안정판을 내려받아 일곱 개의 작은 소스를 분석했습니다. 추론이 실제로 어떤 대입을 막는지, 빈 제네릭과 함수 경계에서 어떤 진단이 생기는지, 중복 지역 타입은 lint가 어떻게 보는지를 같은 설정에서 비교했습니다.

질문을 코드로 바꾸고 analyzer에 맡겼다

Windows 10.0.19045 x64에서 공식 Dart archive의 3.13.4 SDK를 사용했습니다. 프로젝트의 analysis_options.yaml에는 다음 설정을 적용했습니다.

analyzer:
  language:
    strict-casts: true
    strict-inference: true
    strict-raw-types: true

linter:
  rules:
    - omit_local_variable_types
    - strict_top_level_inference

strict-inference는 문맥이 부족해 analyzer가 dynamic을 선택하는 상황을 드러내고, strict-raw-types는 타입 인자가 빠진 제네릭을 찾습니다. lint는 컴파일 오류와 성격이 다릅니다. 예를 들어 omit_local_variable_types의 INFO가 있어도 이번 실행의 종료 코드는 0이었습니다.

비교 사례 실행 명령 관찰 결과
var n = 1 뒤 n = 1.5 dart analyze --format machine INVALID_ASSIGNMENT 오류
num n = 1 뒤 n = 1.5 analyze 후 dart run 분석 통과, 1.5 출력
타입 없는 빈 Map dart analyze --format machine 타입 인자 추론 실패 경고
타입 있는 빈 Map 같은 분석 진단 없음
초기값이 명백한 지역 String 같은 분석 중복 타입 INFO lint
타입 없는 top-level 함수 경계 같은 분석 반환·매개변수 추론 실패 경고
완전한 함수 시그니처 같은 분석 진단 없음
Dart analyzer에서 var의 int 추론 오류와 num 명시 타입 통과, 빈 Map 경고를 비교한 실행 결과
2026년 9월 22일 Windows와 Dart SDK 3.13.4 안정판에서 strict 분석과 두 lint를 적용한 결과입니다. Flutter 앱 성능이 아니라 명령줄 정적 분석 결과입니다.

var는 dynamic이 아니었다

첫 소스는 정수 1로 지역 변수를 초기화한 뒤 실수 1.5를 대입했습니다.

void main() {
  var retryDelaySeconds = 1;
  retryDelaySeconds = 1.5;
  print(retryDelaySeconds);
}

Dart 3.13.4 analyzer는 두 번째 대입을 다음과 같이 거절했습니다.

ERROR | INVALID_ASSIGNMENT | line 3
A value of type 'double' can't be assigned
to a variable of type 'int'.

초기값으로부터 retryDelaySeconds의 정적 타입을 int로 정했기 때문입니다. 이후 대입은 추론에 다시 반영되지 않습니다. 따라서 코드 리뷰에서 var만 보고 “타입 검사가 사라졌다”고 판단하면 틀립니다. 실제로 검사가 사라질 수 있는 dynamic, 모든 객체를 받을 수 있지만 멤버 접근에는 검사가 필요한 Object?의 차이는 Dart var·dynamic·Object? 비교에서 별도로 다룹니다.

원하는 범위가 추론보다 넓다면 타입을 써야 한다

재시도 간격이 처음에는 정수지만 이후 소수도 받을 설계라면, 첫 초기값의 정밀한 타입보다 num 계약이 맞습니다.

void main() {
  num retryDelaySeconds = 1;
  retryDelaySeconds = 1.5;
  print('retryDelaySeconds=$retryDelaySeconds');
}

이 파일은 같은 strict 설정에서 진단 없이 통과했고, 실행 결과는 retryDelaySeconds=1.5였습니다. 여기서 명시 타입의 이유는 읽는 사람에게 int라는 정보를 한 번 더 보여주기 위해서가 아닙니다. 초기값만으로 추론할 타입보다 의도적으로 넓은 허용 범위를 고정하기 위해서입니다.

반대로 값의 재할당 여부는 다른 축입니다. var와 final은 타입 안전성의 높고 낮음이 아니라 재할당 가능성의 차이이므로, var·final·const 선언 기준과 함께 결정해야 합니다.

빈 제네릭에는 추론할 재료가 없었다

요소가 있는 collection literal은 요소에서 타입 인자를 추론할 수 있지만, 빈 literal은 주변 문맥도 없다면 근거가 없습니다.

void main() {
  final headers = {};
  headers['retry-after'] = '30';
  print(headers);
}

strict-inference: true에서 final headers = {};는 다음 경고를 냈습니다.

WARNING | INFERENCE_FAILURE_ON_COLLECTION_LITERAL
The type argument(s) of 'Map' can't be inferred.

literal 자체에 타입 인자를 쓰면 같은 파일이 진단 없이 통과했습니다.

final headers = <String, String>{};

변수 쪽에 Map<String, String>을 쓰는 것도 문맥을 제공하는 방법입니다. 둘을 동시에 반복할 필요는 없습니다. 컬렉션을 안전하게 수정하고 빈 literal의 타입을 정하는 예시는 Dart List·Map·Set 선언과 수정에서 이어집니다.

짧은 지역 변수에는 명시 타입이 중복일 수 있다

이번에는 초기값만 봐도 타입이 명확한 지역 변수에 String을 썼습니다.

void main() {
  String status = 'ready';
  print(status);
}

분석기는 컴파일 오류를 내지 않았지만 OMIT_LOCAL_VARIABLE_TYPES INFO를 반환했습니다.

INFO | OMIT_LOCAL_VARIABLE_TYPES
Unnecessary type annotation on a local variable.

이 결과는 모든 명시 타입을 제거하라는 뜻이 아닙니다. 범위가 짧고 initializer가 타입을 분명히 보여주는 지역 변수에서는 var status = 'ready';가 같은 정적 타입을 유지하면서 반복 표기를 줄인다는 뜻입니다. 타입이 보이지 않는 긴 호출 체인이라 독자의 이해에 도움이 되거나, 앞 사례처럼 원하는 타입을 넓혀야 한다면 명시할 근거가 남습니다.

함수 경계는 지역 변수와 같은 규칙으로 줄이지 않는다

top-level 함수에서 반환 타입과 매개변수 타입을 모두 생략한 파일도 분석했습니다.

formatUser(user) => '${user['name']}';

strict 설정에서는 반환 타입의 INFERENCE_FAILURE_ON_FUNCTION_RETURN_TYPE, 매개변수의 INFERENCE_FAILURE_ON_UNTYPED_PARAMETER 경고가 각각 발생했고, STRICT_TOP_LEVEL_INFERENCE lint도 두 위치를 표시했습니다. 다음처럼 경계를 완성하자 진단이 없어졌습니다.

String formatUser(Map<String, Object?> user) =>
    '${user['name']}';

함수 시그니처는 호출자와 구현 사이의 계약입니다. 구현 본문을 읽지 않아도 허용 입력과 반환을 알 수 있어야 하며, 외부 데이터가 null을 포함할 수 있다면 그 가능성도 타입에 드러냅니다. nullable 경계를 정할 때 ?, !, late를 혼동하면 다른 문제가 되므로 Dart null safety 판단 기준을 함께 확인할 수 있습니다.

코드 리뷰에서 사용할 위치별 결정표

코드 위치와 상황 우선 선택 판정 질문
짧은 함수 안, 명백한 initializer var 또는 팀 규칙에 따른 final 타입 표기가 새 정보를 주는가?
초기값보다 넓은 타입을 나중에 대입 num, 상위 클래스·인터페이스 등 명시 추론 타입이 의도한 값 범위를 막는가?
빈 List·Map·Set 또는 생성자 타입 인자 부족 완전한 제네릭 타입 인자 요소나 주변 문맥만으로 T를 결정할 수 있는가?
함수 매개변수·반환 타입 완전한 시그니처 호출자가 구현 없이 계약을 알 수 있는가?
공개 field·top-level 변수 initializer가 분명하지 않으면 명시 다른 라이브러리 변화가 공개 타입을 바꿀 수 있는가?
의도적으로 정적 검사를 끄는 경계 dynamic을 숨기지 말고 명시 런타임 검증과 변환 지점이 따로 있는가?
List, Map만 쓴 raw type List<T>, Map<K,V> 빠진 타입 인자가 dynamic으로 흐르는가?

리뷰 순서는 “명시했는가?”보다 “analyzer가 어떤 정적 타입을 선택했는가?”가 먼저입니다. IDE의 inferred type을 확인하고, 원하는 계약과 다를 때만 표기를 바꿉니다. 그 뒤 dart analyze를 CI에서 같은 options로 실행해야 개인 IDE 설정 차이로 진단이 빠지는 것을 줄일 수 있습니다.

이번 실험이 말하지 않는 것

  • Flutter 위젯 트리, 앱 크기, 실행 속도에서 var와 명시 타입의 성능 차이를 측정하지 않았습니다.
  • 가독성을 사람 대상 실험으로 수치화하지 않았습니다. 확인한 것은 analyzer의 정적 계약과 진단입니다.
  • 진단 결과는 Dart 3.13.4와 위에 공개한 strict·lint 설정에 한정됩니다. 프로젝트 설정이 다르면 INFO나 경고의 표시가 달라질 수 있습니다.
  • String status = 'ready';는 유효한 Dart 코드입니다. lint는 오류가 아니라 선택한 스타일 규칙의 진단입니다.
  • Flutter 전용 패키지와 코드 생성 모델은 실행하지 않았습니다.

판정에 사용한 Dart 공식 자료