Dart에 타입 시스템이 생겨요

Dart에 타입 시스템이 생겨요

이 글은 Dart가 겪은 첫 번째 큰 호환성 변경, 바로 그 '2'가 붙은 이유에 관한 이야기예요. 호환성이 깨지는 변경이기에 더 중요하게 다뤄지는 이 개발 과정을 핵심부터 짚어볼게요. 먼저 Dart 1 코드를 하나 볼게요:

void main() {
  cleanUp([new TempFile(), new BankAccount()]);
}

void cleanUp(List<TempFile> files) =>
    files.forEach((f) => f.delete());

class TempFile {
  void delete() => print('TempFile deleted.');
}

class BankAccount {
  void delete() => print('BankAccount deleted. Whoops!');
}

> TempFile deleted.
> BankAccount deleted. Whoops!

코드가 조금 놀라워요. TempFileBankAccount 둘 다 delete 메서드를 갖고 있죠. 그런데 Dart 1은 기본적으로 타입을 신경 쓰지 않기 때문에, 우리가 분명히 TempFile에서 delete를 호출하려 했는데도 BankAccount에서 delete를 호출하는 걸 기꺼이 허용해요. 좋지 않아요.

Strong Mode 분석

여기서 Dart의 새 타입 시스템인 Strong Mode가 등장해요. (Dart 2.0이 오면 이 이름은 더 필요 없어져요. 그냥 '타입 시스템'이 되죠.)

Dart 분석기(analyzer)는 한동안 Strong Mode 정적 분석을 지원해 왔어요. 타입 추론으로 여러분이 의도한 타입을 짐작하고, 각 변수가 단 하나의 타입만 가질 수 있도록 요구하죠. 오버라이드와 제네릭에 대한 엄격함도 더해요.

위 코드 조각을 Strong Mode가 켜진 분석기에 넣으면 이렇게 알려줘요:

error: The element type 'BankAccount' can't be assigned to the list type 'TempFile'.

…나름 합리적으로 보이죠. 문제 해결! 권가요? 아쉽게도 아니에요. 정적 분석을 속일 방법이 몇 가지 있어요. 예를 들어 아무 Iterable이나 받는 new List.from을 쓰는 거죠:

void main() {
  var list = new List<TempFile>.from(
      [new TempFile(), new BankAccount()]);
  cleanUp(list);
}

> TempFile deleted.
> BankAccount deleted. Whoops!

…그리고 분석기는 속수무책이에요. 정적 검사로 이 문제를 잡을 방법이 전혀 없어요.

Strong Mode 본격적으로

여기서 우리는 건전성(soundness), 즉 타입 시스템이 약속한 것을 실제로 지킬 수 있는지 문제에 다가가요. 분석기의 추가 정적 검사는 버그를 잡는 데 도움을 주지만, 모든 구멍을 막기엔 부족해요. 필요한 건 추가적인 런타임 검사예요.

현재 그 검사를 구현하는 Dart 런타임은 딱 하나예요. 바로 Dart Dev Compiler(DDC)죠. 마지막 코드 조각을 DDC로 돌리면 런타임 에러가 나요:

Type 'BankAccount' is not a subtype of type 'TempFile'

그러니 우리의 은행 계좌는 안전해요. Strong Mode와 런타임 검사를 함께 쓰면, TempFile.delete를 실행하려 할 때 어떤 상황에서도[1] BankAccount.delete가 대신 실행되는 일이 절대 없어요.

Dart 2.0

여기서 다시 Dart 2.0 발표로 돌아와서, 왜 중요한 건지 살펴볼게요.

DDC가 유일하게 이 검사를 추가한 런타임인 이유는, 이 검사들이 언어에 대한 호환성 파괴 변경이기 때문이에요. 예전에는 유효했던 프로그램이 더는 유효하지 않게 되죠. DDC는 개발 도구라서 모든 유효한 Dart를 컴파일하고 실행할 필요가 없어요. VM과 dart2js는 그래야 해요.

그래서 Dart 2.0으로 바뀌면서, 타입에 문제가 있던 그런 프로그램들은 이제 유효하지 않다고 선언할 수 있게 됐어요. 그러면 모든 런타임이 약속한 대로 타입을 제공하는 방향으로 나아갈 수 있죠. 행복한 결말이고, 제가 가장 좋아하는 프로그래밍 언어에게 중요한 한 걸음이에요.

여담: 잃어버린 것도 없어요

이 변경의 단점이 뭔지 묻는 건 자연스러워요. Dart에서 느슨한 타입을 원하면 어쩌죠?

다행히 언어가 그걸 다뤄줘요. 특별한 dynamic 타입으로 타입 검사를 언제든 빠져나갈 수 있죠. 사실 타입 주석을 붙이지 않으면 기본값이 dynamic이라서, List<TempFile> 대신 그냥 List라고 써도 됐어요:

void main() {
  cleanUp([new TempFile(), new BankAccount()]);
}

void cleanUp(List files) =>
    files.forEach((f) => f.delete());

class TempFile {
  void delete() => print('TempFile deleted.');
}

class BankAccount {
  void delete() => print('BankAccount deleted. Whoops!');
}

> TempFile deleted.
> BankAccount deleted. Whoops!

…그리고 완전히 유효한 Dart 2.0 코드가 돼요[2]. cleanUp 메서드는 이제 delete 메서드를 가진 어떤 클래스에서도 동작하도록 쓰였고, 실제로 그렇게 동작해요.

[1] 다른 언어들이 보여줬듯, 타입 시스템을 100% 건전하게 만드는 건 어려워요. 하지만 타입 시스템을 깨뜨릴 알려진 방법은 없을 거예요.

[2] Dart 2.0은 아직 존재하지 않아서 100% 보장되지는 않아요. 하지만 타입에 한해서는 이대로 문제없을 거예요.

더 알아보기