iBetter Books
수정

프로그래밍 언어를 처음 배울 때, "타입을 매번 직접 써야 하나요?"라는 질문을 자주 합니다. 코드를 보면 타입이 뭔지 눈에 보이는데, 굳이 또 써야 한다면 번거롭습니다. Dart는 이 고민을 해결해 주었습니다. 타입 추론(type inference)입니다.

이번 챕터에서는 Dart가 타입을 어떻게 스스로 알아내는지, 그리고 타입 추론을 쓸 때와 명시적 타입을 쓸 때를 어떻게 구분하는지 배웁니다.

var를 쓸 때 일어나는 일

var로 변수를 선언하면 Dart 컴파일러가 오른쪽 값을 보고 타입을 결정합니다.

void main() {
  var x = 10;         // int로 추론
  var y = 3.14;       // double로 추론
  var z = 'hello';    // String으로 추론
  var w = true;       // bool로 추론

  // 이미 타입이 정해졌습니다 — 다른 타입 대입 불가
  // x = 'ten';  // 오류! x는 int
}

여기서 핵심은 var가 타입이 없는 것이 아니라는 점입니다. 처음 할당하는 순간 타입이 고정됩니다. var는 단지 "타입은 네가 알아서 봐"라는 의미입니다.

IDE나 편집기에서 var 위에 마우스를 올리면 추론된 타입을 보여줍니다. 직접 확인해보면 재미있습니다.

복잡한 추론도 잘 합니다

단순한 값뿐 아니라 연산 결과나 함수 반환값도 추론합니다.

void main() {
  var sum = 3 + 4;           // int
  var quotient = 10 / 3;     // double (/ 연산은 항상 double)
  var text = 'Hi' + ' there'; // String

  print(sum.runtimeType);      // int
  print(quotient.runtimeType); // double
  print(text.runtimeType);     // String
}
String greet(String name) {
  return 'Hello, $name!';
}

void main() {
  var result = greet('Dart');  // String으로 추론
  print(result.runtimeType);   // String
}

dynamic — 타입을 포기한 변수

dynamic은 Dart의 특별한 타입입니다. 어떤 값이든 담을 수 있고, 나중에 다른 타입으로 바꿀 수도 있습니다.

void main() {
  dynamic anything = 42;
  print(anything);  // 42

  anything = 'now a string';
  print(anything);  // now a string

  anything = true;
  print(anything);  // true
}

dynamic은 타입 검사를 컴파일 시점이 아닌 런타임으로 미룹니다. 즉, 컴파일러가 오류를 잡아주지 않습니다.

void main() {
  dynamic value = 'hello';

  // 컴파일 오류 없음 — 하지만 실행하면 오류!
  print(value.toUpperCase());  // HELLO (String 메서드라 OK)

  value = 42;
  // print(value.toUpperCase());  // 런타임 오류! int에는 toUpperCase 없음
}

dynamic은 강력하지만 위험합니다. 컴파일러의 도움을 받지 못하기 때문에 실수를 찾기 어려워집니다. 꼭 필요한 경우가 아니면 사용을 피하는 것이 좋습니다.

Object — 모든 타입의 조상

Object는 Dart의 모든 클래스가 상속받는 최상위 타입입니다. dynamic처럼 어떤 값이든 담을 수 있지만, 차이가 있습니다.

void main() {
  Object value = 42;
  value = 'hello';  // OK, Object는 모든 타입을 담을 수 있음

  // 하지만 String 메서드를 바로 부를 수 없습니다
  // print(value.toUpperCase());  // 컴파일 오류!

  // 타입을 확인한 후에야 사용 가능
  if (value is String) {
    print(value.toUpperCase());  // HELLO
  }
}

dynamic vs Object 차이

특징 dynamic Object
모든 타입 담기 가능 가능
타입별 메서드 호출 가능 (런타임 오류 가능) 불가 (컴파일 오류)
타입 안전성 낮음 높음
사용 권장도 최후 수단 필요 시 OK

Object는 컴파일러가 타입을 체크하기 때문에 dynamic보다 안전합니다. "어떤 타입이든 받겠다"면 Object를, "타입 검사 자체를 끄겠다"면 dynamic을 쓴다고 기억하면 됩니다.

언제 var를, 언제 명시적 타입을 쓸까

실제 코드를 쓰다 보면 이 질문을 자주 하게 됩니다. 정해진 규칙은 없지만, 널리 통용되는 가이드라인이 있습니다.

var를 쓰는 경우

void main() {
  // 오른쪽을 보면 타입이 명확할 때
  var name = 'Alice';               // 분명히 String
  var count = 0;                    // 분명히 int
  var items = <String>[];           // String 리스트
  var map = <String, int>{};        // Map

  // 복잡한 타입을 반복하기 싫을 때
  var result = getComplexResult();  // 반환 타입이 길 경우
}

Map<String, List<int>> getComplexResult() {
  return {'scores': [90, 85, 92]};
}

명시적 타입을 쓰는 경우

// 함수 매개변수와 반환 타입은 명시적으로
String formatName(String first, String last) {
  return '$first $last';
}

// 클래스 필드는 명시적으로
class Student {
  String name;
  int age;
  double gpa;

  Student(this.name, this.age, this.gpa);
}

void main() {
  // 나중에 값을 넣을 변수는 명시적 타입이 필요
  String message;
  if (true) {
    message = '안녕';
  } else {
    message = 'Hi';
  }
  print(message);
}

Dart 스타일 가이드 요약

Dart 공식 스타일 가이드는 이렇게 권장합니다.

  • 지역 변수: var 권장 (타입이 명확하면)
  • 함수 매개변수/반환값: 명시적 타입 권장
  • 클래스 필드: 명시적 타입 권장
  • dynamic은 최대한 피하기

타입 추론의 장점과 한계

장점

타입 추론을 쓰면 코드가 간결해지고 읽기 쉬워집니다.

// 타입 추론 사용
var users = <Map<String, dynamic>>[];
var firstUser = users.isEmpty ? null : users.first;

// 타입 명시 (훨씬 길고 복잡해 보임)
List<Map<String, dynamic>> users2 = <Map<String, dynamic>>[];
Map<String, dynamic>? firstUser2 = users2.isEmpty ? null : users2.first;

코드를 고칠 때도 좋습니다. 함수 반환 타입을 바꾸면 var로 받은 쪽은 자동으로 따라오지만, 명시적 타입을 썼다면 모두 직접 바꿔야 합니다.

한계

타입 추론이 항상 옳은 것은 아닙니다. 추론된 타입이 의도와 다를 때가 있습니다.

void main() {
  // 의도는 double이지만 int로 추론됨
  var price = 1000;  // int로 추론
  price = 999.9;     // 오류!

  // 명시적으로 double로 지정
  double price2 = 1000;  // 1000을 double로 저장 (1000.0)
  price2 = 999.9;         // OK
}

또한 var를 남발하면 코드의 의도가 불명확해질 수 있습니다. 팀 작업에서는 타입이 명확하게 보이는 것이 더 좋을 때도 많습니다.

정리 예제

// 반환 타입은 명시적으로
double calculateAverage(List<int> scores) {
  if (scores.isEmpty) return 0.0;

  var total = 0;  // int로 추론 — 지역 변수는 var
  for (var score in scores) {
    total += score;
  }

  return total / scores.length;  // int / int = double
}

void main() {
  var scores = [85, 92, 78, 96, 88];  // List<int>로 추론
  var average = calculateAverage(scores);  // double로 추론

  print('점수: $scores');
  print('평균: ${average.toStringAsFixed(1)}');

  // dynamic은 꼭 필요할 때만
  dynamic flexibleValue = 42;
  print('타입: ${flexibleValue.runtimeType}');  // int

  flexibleValue = '이제는 문자열';
  print('타입: ${flexibleValue.runtimeType}');  // String

  // Object는 더 안전한 대안
  Object safeValue = 42;
  if (safeValue is int) {
    print('두 배: ${safeValue * 2}');  // 84
  }
}

다음 챕터에서는 Dart 프로그래밍에서 가장 중요한 개념 중 하나인 null을 만납니다. 값이 없다는 것이 무엇을 의미하는지, 그리고 Dart가 왜 Null Safety라는 체계를 만들었는지 이야기합니다.