Dart 2.12 발표 — 사운드 null safety와 안정적인 Dart FFI

Dart 2.12 발표 — 사운드 null safety와 안정적인 Dart FFI

Dart 2.12에는 두 가지 주요 기능이 안정 버전으로 등장해요. 놓치기 쉬운 null 오류를 개발 중에 잡아 주는 사운드 null safety, 그리고 C 언어로 작성된 기존 코드(예: Windows Win32 API)를 Dart에서 호출할 수 있게 해 주는 Dart FFI가 그 주인공이에요. 이 글에서 두 기능의 핵심과 생태계 마이그레이션 현황까지 자세히 살펴볼게요.

출처: Announcing Dart 2.12

본문

오늘 우리는 사운드 null safetyDart FFI의 안정 버전을 담은 Dart 2.12를 발표해요. Null safety는 우리의 최신 주요 생산성 기능으로, 이 영상 소개에서 자세히 다루듯 찾아내기 어려운 버그 부류인 null 오류를 피하는 데 도움을 주기 위한 것이에요. FFI는 C 프로그래밍 언어로 작성된 기존 코드를 호출할 수 있게 해 주는 상호 운영(interoperability) 메커니즘이에요. 예를 들어 Windows의 Win32 API를 호출할 수 있죠. Dart 2.12는 오늘 사용할 수 있어요.

Dart 플랫폼의 독특한 역량

사운드 null safety와 FFI를 자세히 살펴보기 전에, 이들이 Dart 플랫폼에 대한 우리의 목표와 어떻게 맞아떨어지는지 이야기해 볼게요. 프로그래밍 언어들은 많은 역량을 공유하는 경향이 있어요. 예를 들어 많은 언어가 객체지향 프로그래밍이나 웹 실행을 지원해요. 언어를 정말로 차별화하는 것은 그 역량의 독특한 조합이에요.

Dart의 독특한 역량은 세 가지 차원에 걸쳐 있어요.

  • 휴대성(Portable): 효율적인 컴파일러가 기기용 x86과 ARM 머신 코드, 웹용 최적화된 JavaScript를 생성해요. 모바일 기기, 데스크톱 PC, 앱 백엔드 등 폭넓은 대상을 지원해요. 방대한 라이브러리와 패키지 집합이 모든 플랫폼에서 동작하는 일관된 API를 제공해서, 진정한 멀티플랫폼 앱을 만드는 비용을 더욱 낮춰 줘요.
  • 생산성(Productive): Dart 플랫폼은 hot reload를 지원하여 네이티브 기기와 웹 모두에서 빠르고 반복적인 개발을 가능하게 해요. 또 Dart는 isolate와 async/await 같은 풍부한 구성 요소를 제공해 일반적인 동시성·이벤트 기반 앱 패턴을 처리해 줘요.
  • 견고성(Robust): Dart의 사운드하고 null-safe한 타입 시스템은 개발 중에 오류를 잡아 줘요. 그리고 전체 플랫폼은 확장성이 높고 신뢰할 수 있어서, Google Ads나 Google Assistant 같은 비즈니스 중대 앱을 포함해 다양한 앱에서 10년 넘게 프로덕션으로 사용돼 왔어요.

사운드 null safety는 타입 시스템을 더욱 견고하게 만들고 더 나은 성능을 가능하게 해요. Dart FFI는 더 나은 휴대성을 위해 기존 C 라이브러리를 활용하고, 성능이 중요한 작업에는 고도로 튜닝된 C 코드를 사용할 선택지를 줘요.

사운드 null safety

사운드 null safety는 Dart 2.0에서 사운드 타입 시스템이 도입된 이후 Dart 언어에 대한 가장 큰 추가 사항이에요. Null safety는 타입 시스템을 더욱 강화해서, 앱 크래시의 흔한 원인인 null 오류를 잡을 수 있게 해 줘요. null safety에 옵트인하면 개발 중에 null 오류를 잡아 프로덕션에서의 크래시를 예방할 수 있어요.

사운드 null safety는 몇 가지 핵심 원칙에 따라 설계됐어요. 이 원칙들이 개발자로서 여러분에게 어떤 영향을 주는지 다시 살펴볼게요.

기본적으로 non-nullable: 타입 시스템에 대한 근본적인 변화

null safety 이전의 핵심 과제는 null이 전달될 것을 예상하는 코드와 null에서 동작하지 않는 코드를 구분할 수 없다는 점이었어요. 몇 달 전 우리는 Flutter master 채널에서 버그를 발견했는데, 특정 머신 구성에서 여러 flutter 도구 명령이 null 오류로 크래시하는 문제였어요. The method '>=' was called on null이 그 오류였죠. 근본 원인은 이런 코드였어요.

final int major = version?.major;
final int minor = version?.minor;
if (globals.platform.isMacOS) {
  // plugin path of Android Studio changed after version 4.1.
  if (major >= 4 && minor >= 1) {
  ...

오류를 찾을 수 있나요? version이 null일 수 있기 때문에 majorminor도 모두 null일 수 있어요. 이 버그는 여기서 단독으로 보면 찾기 쉬워 보일 수 있지만, 실제로는 Flutter 저장소에서 쓰는 엄격한 코드 리뷰 과정에서도 이런 코드가 항상 스며들어요. null safety를 쓰면 정적 분석이 이 문제를 즉시 잡아 줘요. (DartPad에서 직접 실행해 보세요.)

그건 아주 단순한 오류였어요. Google 내부에서 코드에 null safety를 일찍 사용하는 동안, 우리는 훨씬 더 복잡한 오류가 잡히는 걸 봤어요. 그중 몇몇은 수년간 알려져 있던 버그인데, null safety가 제공하는 추가 정적 검사가 없으면 팀들이 원인을 찾지 못했던 것들이에요. 몇 가지 예를 볼게요.

  • 내부 팀 하나는, 절대 null이 될 수 없는 표현식에 대해 null 값을 자주 검사하고 있다는 걸 발견했어요. 이 문제는 protobuf를 사용하는 코드에서 가장 흔하게 나타났는데, 선택적 필드가 설정되지 않았을 때 기본값을 반환하고 절대 null을 반환하지 않기 때문이에요. 그 결과 코드는 기본값과 null 값을 혼동해서, 기본 조건을 잘못 검사하고 있었어요.
  • Google Pay 팀은 Flutter 코드에서 버그를 발견했는데, Widget 컨텍스트 밖에서 Flutter State 객체에 접근하려고 할 때 실패하는 문제였어요. null safety 이전에는 그 객체가 null을 반환해서 오류를 가려 줬지만, null safety에서는 사운드 분석이 그 속성들이 절대 null일 수 없다고 판단하고 분석 오류를 던졌어요.
  • Flutter 팀은 Window.render()scene 매개변수에 null이 전달되면 Flutter 엔진이 크래시할 수 있다는 버그를 찾았어요. null safety 마이그레이션 중에 그들은 Scene을 non-nullable로 표시하는 힌트를 추가했고, null이 촉발했을 잠재적 앱 크래시를 쉽게 예방할 수 있었어요.

non-nullable 기본값 다루기

null safety를 켜면 기본 타입이 non-nullable이 되기 때문에 변수 선언의 기본이 바뀌어요.

// In null-safe Dart, none of these can ever be null.
var i = 42; // Inferred to be an int.
String name = getFileName();
final b = Foo();

값이나 null 둘 다 담을 수 있는 변수를 만들고 싶다면, 타입에 ? 접미사를 붙여 변수 선언에서 그것을 명시해야 해요.

// aNullableInt can hold either an integer or null.
int? aNullableInt = null;

null safety의 구현은 견고하며, 풍부한 정적 흐름 분석이 nullable 타입을 더 쉽게 다루게 해 줘요. 예를 들어 null을 검사한 뒤에 Dart는 지역 변수의 타입을 nullable에서 non-nullable로 승격(promote)시켜요.

int definitelyInt(int? aNullableInt) {
  if (aNullableInt == null) {
    return 0;
  }
  // aNullableInt has now promoted to a non-null int.
  return aNullableInt;
}

우리는 required라는 새 키워드도 추가했어요. 명명된 매개변수가 required로 표시되어 있고(Flutter 위젯 API에서 자주 쓰여요) 호출자가 인자를 제공하는 걸 잊으면 분석 오류가 발생해요.

null safety로의 점진적 마이그레이션

null safety는 타입 시스템에 대한 근본적인 변화이기 때문에, 강제 도입을 고집한다면 엄청나게 파괴적일 거예요. 그래서 여러분이 때가 됐다고 판단할 수 있도록, null safety는 옵트인(opt-in) 기능이에요. null safety를 강제로 켜지 않아도 Dart 2.12를 쓸 수 있어요. 앱이나 패키지가 null safety를 켰는지와 무관하게, 이미 null safety를 켠 패키지에 의존하는 것도 가능해요.

기존 코드를 null safety로 마이그레이션하는 데 도움을 주기 위해, 우리는 마이그레이션 도구와 마이그레이션 가이드를 제공해요. 도구는 먼저 기존 코드를 모두 분석해요. 그다음 대화형으로 도구가 추론한 nullability 속성을 검토할 수 있어요. 도구의 결론에 동의하지 않으면 nullability 힌트를 추가해 추론을 바꿀 수 있어요. 마이그레이션 힌트 몇 개를 추가하는 것만으로도 마이그레이션 품질에 큰 영향을 줄 수 있어요.

지금으로서는 dart createflutter create로 만든 새 패키지와 앱이 사운드 null safety를 켜지 않아요. 생태계 대부분이 마이그레이션됐음을 확인할 수 있는 미래의 안정 릴리스에서 그걸 바꿀 것으로 기대해요. 새로 만든 패키지나 앱에서 dart migrate를 사용해 손쉽게 null safety를 켤 수 있어요.

Dart 생태계의 null safety 마이그레이션 현황

지난 1년 동안 우리는 null safety를 지원하는 패키지로 생태계를 채우는 것을 목표로, 사운드 null safety의 여러 미리보기와 베타 빌드를 제공했어요. 이 준비는 중요해요. 우리는 순서대로 사운드 null safety로 마이그레이션할 것을 권장하기 때문이에요. 모든 의존성이 이미 마이그레이션되기 전에는 패키지나 앱을 마이그레이션하면 안 돼요.

우리는 Dart, Flutter, Firebase, Material 팀이 제공하는 수백 개 패키지의 null-safe 버전을 게시했어요. 그리고 훌륭한 Dart와 Flutter 생태계의 큰 지원 덕분에, pub.dev에는 이제 null safety를 지원하는 패키지가 천 개 넘게 있어요. 중요한 점은 가장 인기 있는 패키지가 먼저 마이그레이션되어, 오늘 출시 시점에 인기 상위 100개 패키지의 98%, 상위 250개의 78%, 상위 500개의 57%가 이미 null safety를 지원한다는 거예요. 앞으로 몇 주 안에 pub.dev에서 더 많은 null safety 패키지를 보게 될 것으로 기대해요. 우리 분석에 따르면 pub.dev의 대부분 패키지는 이미 막힌 데 없이 마이그레이션을 시작할 수 있어요.

완전 사운드 null safety의 이점

완전히 마이그레이션하면 Dart의 null safety는 사운드해져요. 이는 Dart가 non-nullable 타입을 가진 표현식은 null일 수 없다고 100% 확신한다는 뜻이에요. Dart가 코드를 분석해 어떤 변수가 non-nullable이라고 판단하면, 그 변수는 항상 non-nullable이에요. Dart는 사운드 null safety를 Swift와 공유하지만, 다른 프로그래밍 언어와는 그리 많지 않아요.

Dart null safety의 사운드함에는 또 다른 반가운 의미가 있어요. 프로그램이 더 작고 빨라질 수 있다는 거예요. non-nullable 변수가 절대 null이 아니라는 것을 Dart가 확신하기 때문에, Dart는 최적화할 수 있어요. 예를 들어 Dart AOT(사전 컴파일) 컴파일러는 변수가 null이 아님을 알면 null 검사를 추가할 필요가 없으므로 더 작고 빠른 네이티브 코드를 만들 수 있어요.

C 라이브러리 통합을 위한 Dart FFI

Dart FFI는 C 라이브러리의 기존 코드를 활용할 수 있게 해 줘요. 더 나은 휴대성을 위한 경우도 있고, 성능이 중요한 작업에 고도로 튜닝된 C 코드를 통합하기 위한 경우도 있어요. Dart 2.12부터 Dart FFIbeta 단계를 벗어나 이제 안정적이며 프로덕션 사용 준비가 된 것으로 간주돼요. 우리는 중첩된 구조체(nested structs)와 값으로 구조체 전달 같은 새 기능도 추가했어요.

값으로 구조체 전달하기

C 코드에서 구조체는 참조와 값 모두로 전달될 수 있어요. FFI는 이전에 참조 전달만 지원했지만, Dart 2.12부터는 구조체를 값으로 전달할 수 있어요. 참조와 값으로 모두 전달하는 두 C 함수의 작은 예시를 볼게요.

struct Link {
  double value;
  Link* next;
};

void MoveByReference(Link* link) {
  link->value = link->value + 10.0;
}

Coord MoveByValue(Link link) {
  link.value = link.value + 10.0;
  return link;
}

중첩된 구조체

C API는 종종 중첩된 구조체 — 자신이 구조체를 포함하는 구조체 — 를 사용해요. 이런 예시죠.

struct Wheel {
  int spokes;
};

struct Bike {
  struct Wheel front;
  struct Wheel rear;
  int buildYear;
};

Dart 2.12부터 FFI에서 중첩된 구조체가 지원돼요.

API 변경

FFI를 안정화하고 위 기능을 지원하는 과정에서, 우리는 몇 가지 작은 API 변경을 했어요.

빈 구조체 생성이 이제 금지되고(브레이킹 체인지 #44622) deprecation 경고를 만들어요. 빈 구조체를 나타내는 데 새로운 타입 Opaque를 사용할 수 있어요. dart:ffi 함수 sizeOf, elementAt, ref는 이제 컴파일 타임 타입 인자를 요구해요(브레이킹 체인지 #44621). package:ffi에 새 편의 함수가 추가되었기 때문에, 일반적인 경우 메모리 할당·해제에 추가 보일러플레이트가 필요 없어요.

// Allocate a pointer to an Utf8 array, fill it from a Dart string,
// pass it to a C function, convert the result, and free the arg.
//
// Before API change:
final pointer = allocate<Int8>(count: 10);
free(pointer);
final arg = Utf8.toUtf8('Michael');
var result = helloWorldInC(arg);
print(Utf8.fromUtf8(result);
free(arg);

// After API change:
final pointer = calloc<Int8>(10);
calloc.free(pointer);
final arg = 'Michael'.toNativeUtf8();
var result = helloWorldInC(arg);
print(result.toDartString);
calloc.free(arg);

FFI 바인딩 자동 생성

큰 API 표면에서는 C 코드와 통합되는 Dart 바인딩을 작성하는 데 시간이 매우 많이 들 수 있어요. 이 부담을 줄이기 위해, 우리는 C 헤더 파일에서 FFI 래퍼를 자동으로 만드는 바인딩 생성기를 만들었어요. 한번 시도해 보세요: package:ffigen.

FFI 로드맵

핵심 FFI 플랫폼이 완성됨에 따라, 우리는 핵심 플랫폼 위에 겹쳐 쌓는 기능들로 FFI 기능 세트를 확장하는 데 초점을 돌리고 있어요. 조사 중인 기능 중 일부는 다음과 같아요.

  • int, long, size_t 같은 ABI 특정 데이터 타입 (#36140)
  • 구조체의 인라인 배열 (#35763)
  • 패킹된 구조체 (#38158)
  • 공용체(union) 타입 (#38491)
  • Dart에 파이널라이저 노출 (#35770. 다만 이미 C에서 파이널라이저를 사용할 수는 있어요.)

FFI 사용 예시

우리는 다양한 C 기반 API와 통합하는 Dart FFI의 창의적인 사용 사례를 많이 봤어요. 몇 가지 예시를 볼게요.

  • open_file은 여러 플랫폼에서 파일을 여는 단일 API예요. Windows, macOS, Linux에서 네이티브 운영체제 API를 호출하는 데 FFI를 사용해요.
  • win32는 가장 일반적인 Win32 API를 감싸서, 광범위한 Windows API를 Dart에서 직접 호출할 수 있게 해 줘요.
  • objectbox는 C 기반 구현에 기반한 빠른 데이터베이스예요.
  • tflite_flutter는 TensorFlow Lite API를 감싸는 데 FFI를 사용해요.

Dart 언어의 다음은 무엇인가?

사운드 null safety는 우리가 몇 년 만에 Dart 언어에 한 가장 큰 변화예요. 다음으로 우리는 강력한 기반 위에서 언어와 플랫폼에 더 점진적인 변화를 만들려고 해요. 우리의 언어 설계 깔때기에서 실험 중인 것 몇 가지를 간단히 보여 드릴게요.

타입 별칭(type alias) (#65): non-function 타입에 대한 타입 별칭을 만들 수 있는 기능이에요. 예를 들어 typedef를 만들어 변수 타입으로 쓸 수 있어요.

typedef IntList = List<int>;
IntList il = [1,2,3];

트리플-시프트 연산자 (#120): 정수에 대한 부호 없는 비트 시프트를 수행하는, 완전히 오버라이드 가능한 새 >>> 연산자를 추가하는 것이에요.

제네릭 메타데이터 어노테이션 (#1297): 타입 인자를 포함하는 어노테이션도 지원하도록 메타데이터 어노테이션을 확장하는 것이에요.

정적 메타프로그래밍 (#1482): 컴파일 중에 새 Dart 소스 코드를 만드는 Dart 프로그램, 즉 Rust 매크로나 Swift function builders와 유사한 정적 메타프로그래밍을 지원하는 것이에요. 이 기능은 아직 초기 탐색 단계지만, 오늘날 코드 생성에 의존하는 사용 사례들을 가능하게 할 수도 있다고 생각해요.

Dart 2.12는 지금 사용 가능해요

사운드 null safety와 안정적인 FFI를 담은 Dart 2.12는 오늘 Dart 2.12Flutter 2.0 SDK에서 사용할 수 있어요. 잠시 시간을 내어 Dart의 알려진 null safety 이슈와 Flutter의 알려진 null safety 이슈를 확인해 주세요. 다른 이슈를 발견하면 Dart 이슈 트래커에 보고해 주세요.

pub.dev에 게시된 패키지를 개발했다면, 오늘 마이그레이션 가이드를 검토하고 사운드 null safety로 마이그레이션하는 방법을 익혀 보세요. 패키지를 마이그레이션하면 그것에 의존하는 다른 패키지와 앱을 막히지 않게 하는 데 도움이 될 가능성이 커요. 이미 마이그레이션한 분들께도 큰 감사를 전하고 싶어요!

사운드 null safety와 FFI에 대한 여러분의 경험을 듣고 싶어요. 아래에 댓글을 남기거나 @dart_lang으로 트윗해 주세요.

더 알아보기