대규모 Dart 프로젝트의 단계적 null-safety 마이그레이션

대규모 Dart 프로젝트의 단계적 null-safety 마이그레이션

Dart null-safety 마이그레이션은 단순하고 작은 패키지라면 1-2시간이면 끝나지만, 대규모 프로젝트라면 몇 달이 걸리는 마라톤이 될 수 있어요. 이상적으로는 프로젝트를 단계적으로 마이그레이션하고 싶을 거예요 — 마라톤 동안에도 프로젝트를 확장 가능하고, 유지보수 가능하며, 출시하기 쉽게 유지하고 싶으니까요. 저는 대규모 프로젝트를 null-safety로 마이그레이션하면서, 이 과정을 안정적이고 효율적으로 만드는 단계와 팁을 정리했어요. 여러분의 시간을 아껴주길 바라요.

출처: Gradual null safety migration for large Dart projects

본문

1단계: unsound null-safety로 전환

먼저 프로젝트를 unsound null safety로 마이그레이션해요. 의존성을 null-safe 버전으로 업그레이드하는 것부터 시작해요. Unsound null safety는 모든 의존성이 null-safe일 것을 요구하지 않아요. 하지만 상위 의존성이 모두 마이그레이션될 때까지 기다리는 것을 강력히 권장해요. 의존성을 마이그레이션하면 자체 코드의 마이그레이션 결정을 다시 검토해야 할 수도 있으니까요. 서로 의존하는 패키지의 경우 순서를 벗어나 마이그레이션하거나 패키지를 동시에 마이그레이션해야 할 수도 있어요(서로 의존하는 많은 패키지는 대부분 테스트에서만 서로를 참조해요). 코드를 마이그레이션하기 전에 dart.dev의 지침에 따라 업그레이드할 수 있는 의존성을 최대한 많이 업그레이드해요. 다음으로 패키지의 Dart SDK를 업데이트하고 다음 단계에 따라 마이그레이션되지 않은 각 라이브러리를 legacy로 표시해요.

  • IDE(VSCode, IntelliJ / Android Studio)에 Dart 플러그인이 설치되어 있는지 확인해요.
  • IDE에서 패키지를 열고 컴파일 오류가 없는지 확인해요.
  • pubspec.yaml 파일의 dart_sdk 의존성을 버전 범위 '>=2.12.0 <3.0.0'을 요구하도록 업데이트해요.
  • IDE가 아직 null-safe가 아닌 라이브러리의 null-safe 관련 오류를 강조 표시할 거예요. 각 영향받는 파일 맨 위에 '// @dart=2.9' 주석을 추가해 오류를 제거해요.
  • 오류가 없더라도 main.dart 파일에 주석을 추가해서, 전환할 준비가 될 때까지 앱이 unsound 모드로 계속 실행되도록 해요.
  • 모든 테스트가 통과하는지 확인하고 변경 사항을 메인 브랜치에 제출해요.
  • 테스트가 이미 null-safe라면 null-safe 오류를 억제하기 위해 명령줄 플래그 --no-sound-null-safety가 필요할 거예요.
  • 애플리케이션 시작 시 콘솔에 Running with unsound null-safety가 보이는지 확인해요.

이제 한 라이브러리씩 프로젝트를 sound null-safety로 마이그레이션할 준비가 됐어요.

2단계: sound null-safety를 향해 반복

마이그레이션할 라이브러리(또는 라이브러리 집합)를 선택해요. 프로 팁: 선택한 라이브러리가 크다면 마이그레이션 전에 더 작은 라이브러리로 나누는 게 좋을 수 있어요. dart pub deps를 사용해 프로젝트의 의존성 그래프를 만들어요. 패키지는 아래에서 위로 마이그레이션하는 것이 가장 좋아요. 의존성 트리의 잎(leaf)에서 시작해 루트까지 반복해요. 하지만 프로젝트에 의존성 순환이 있다면 이 순서가 불가능할 수 있는데, 이 순서를 따르지 않아도 괜찮아요. 마이그레이션 도구를 사용해 라이브러리(또는 라이브러리 집합)를 마이그레이션해요.

  • dart migrate --skip-import-check를 실행해 대화형 마이그레이션 도구를 시작해요. 트리 탐색을 쉽게 하기 위해 선택한 라이브러리가 있는 디렉토리로 cd 해도 좋아요.
  • 왼쪽 패널에서 파일 뷰 트리의 루트를 선택 해제해 모든 것을 선택 해제해요. (관심이 있다면 Deselect All 버튼에 투표해 주세요.)
  • Control+F를 사용해 마이그레이션하려는 파일을 찾아요.
  • 파일을 선택하고 Apply Migration을 클릭해요.

조정하는 방법은 두 가지가 있어요: (1) 마이그레이션 적용 전에 주석으로 도구의 선택을 조정하거나, (2) 마이그레이션 적용 후에 IDE를 사용해 필드, 매개변수, 변수의 null 가능성(nullability)을 평가해요.

  • IDE에서 패키지를 열어요.
  • 오류를 고치고, 도구가 부정확할 수 있는 경우를 파일에서 검색해요(아래 잠재적 문제 목록 참조).
  • upstream 및 downstream 코드를 대화형으로 정리하기 위해 lint 경고를 사용해 수정해요.

두 가지 lint 오류는 고칠 수 없을 거예요.

  • import_of_legacy_library_into_null_safe(마이그레이션된 라이브러리에서)
  • avoid_redundant_argument_values(레거시 라이브러리에서)

지금은 이 오류들을 주석으로 비활성화해요. 마이그레이션이 끝난 후에 정리할 거예요.

주의해야 할 잠재적 도구 부정확성:

  • dynamic 또는 num 타입이 추가된 경우. 대신 어떤 특정 타입을 사용해야 하는지 아마 알고 있을 거예요. 대부분의 경우 bool?은 기본값과 함께 bool이 될 수 있어요.
  • 타입 캐스트(' as ' 검색)는 도구가 제네릭 타입 매개변수를 추가하지 않았다는 뜻일 수 있어요. 추가하면 캐스트가 불필요해졌다는 것을 lint가 나타내므로 제거할 수 있어요.
  • 어떤 경우 도구는 제네릭 매개변수의 바운드를 nullable로 만들기도 하는데, non-nullable로 만드는 게 더 나을 수 있어요(?>?, 검색).
  • 도구는 latelate final로 더 잘 표현되거나, 생성자의 이니셜라이저 리스트에서 초기화할 수 있도록 리팩터링할 수 있는 것들을 nullable로 만들 수도 있어요. (관심이 있다면 lint에 투표해 주세요.)
  • null 값 확인 없이 null 단언 연산 !을 사용하는 것은 변수나 매개변수가 실제로 non-nullable이어야 한다는 뜻일 수 있어요. (관심이 있다면 lint에 투표해 주세요.)
  • 도구는 collection as Iterable<TheType> 형태로 컬렉션에 캐스팅을 추가해요. 때로는 이 변경이 이미 암시적이던 캐스트를 명시적으로 만들 뿐이에요. 하지만 다른 경우에는 이런 캐스트가 제네릭 인자의 nullability 불일치 때문에 런타임 오류를 도입할 수 있어요. 의심스러우면 캐스트를 명시적 요소별 변환(예: collection.cast<TheType>())으로 바꾸거나, package:collectionwhereNotNull 확장 메서드 사용을 고려해 보세요.
  • 변수, 필드, 매개변수가 nullable이지만 nullability가 테스트에서만 사용된다면, 그 식별자에서 nullability를 제거하도록 코드를 리팩터링하는 게 좋을 수 있어요. (이 케이스들을 찾는 데 도움을 준 Kenzie Davisson에게 감사드려요.)

3단계: 정리

모든 라이브러리를 마이그레이션한 후 최종 정리를 해요.

  • lint 비활성화 주석을 정리해요.
  • 남은 의존성을 null-safe 버전으로 업그레이드해요.
  • 앱에 //@dart = 2.9 주석이 남아 있지 않은지 확인해요.
  • 이 시점에 앱 시작 시 콘솔에 Running with sound null-safety가 보여야 해요. 보이지 않는다면 아직 마이그레이션되지 않은 라이브러리(// @dart = 2.9 검색) 또는 마이그레이션되지 않은 의존성이 있는 거예요.
  • 앱이 여전히 올바르게 실행되고 테스트가 통과하는지 확인해요. sound 모드는 더 강한 런타임 보장을 가능하게 하므로, sound null safety를 켤 때 고쳐야 할 새로운 런타임 오류가 (드물지만) 보일 수 있어요. 보통은 nullable 컬렉션(예: List<int?>)을 non-nullable 컬렉션 타입(예: List<int>)으로 캐스팅한 결과예요.

즐거운 마이그레이션 되세요!

더 알아보기