Dart 소스 코드 실행 구조 완벽 분석: Flutter에서 Dart VM까지
배경
우연히 React와 React Native에 대해 조금 조사를 할 기회가 있었는데, 그 과정에서 React Native와 Flutter의 차이점을 알아보게 되었습니다.
그러다 보니 "일상적으로 작성하는 Flutter/Dart 소스 코드가 iOS/Android와 같은 네이티브 플랫폼에서 실행되기까지 어떤 과정이 있는지"에 대해 관심이 생겨 관련 정보를 수집하게 되었는데요.
지금까지도 전체적인 이미지는 대충 가지고 있었지만, 구체적으로는 애매했기 때문에 이번에 다시 조사한 내용을 제 방식대로 정리해본 메모에 가까운 내용입니다.
동기는 Flutter였지만, 조사하면서 Dart와 Dart VM까지 파고들게 되었기 때문에 Flutter의 엔진 등에 대한 이야기는 별로 나오지 않아요.
다만, Flutter와 Dart가 약간 다른 부분이 있을 것 같지만, 실행에 Dart VM을 사용하는 이상 근본적으로는 크게 다르지 않을 것이므로 참고가 될 만할 겁니다.
개요
본 글의 요점은 다음과 같습니다.
Flutter → Dart → Dart VM 순으로 컴파일을 중심으로 실행까지의 흐름을 살펴보겠습니다.
- Dart VM에는 JIT&AOT 파이프라인이 있습니다.
- Dart 코드(애플리케이션 코드 및 Flutter Framework 포함)는 JIT/AOT 모두 Kernel AST라고 불리는 중간 표현을 거쳐 기계어(네이티브 코드)로 변환됩니다.
- 예) ARM, x86_64
- Dart 코드를 실행하는 방법에는 소스 코드에서 직접 컴파일하는 방법과 스냅샷에서 생성하는 방법의 두 가지가 있습니다.
- Flutter의 릴리즈 모드(AOT 컴파일)에서도 Dart VM은 런타임에 한정된 경량 형태로 사용됩니다.
- 디버그 모드에서만 Dart VM이 사용되는 것은 아닙니다.
Flutter 관점에서의 실행
Flutter의 3층 레이어를 간략히 복습해볼까요?
먼저, Flutter Engine에 대해 가볍게 리뷰합니다.
Flutter에는 Flutter Framework와 Flutter Engine이 존재한다는 것은 잘 알려진 사실입니다(참고로, 모노레포가 아닌 리포지토리별로 분리된 이유는 Why we have a separate engine repo · flutter/flutter Wiki에 기록되어 있습니다).
Framework는 우리가 평소에 개발하는 Widget을 포함한 Flutter 애플리케이션을 실행하기 위한 환경입니다.
Flutter Engine은 한마디로 "Flutter 애플리케이션을 실행하기 위한 포터블한 런타임"이라고 할 수 있습니다.
애니메이션이나 그래픽, Dart 런타임, 다양한 Flutter 코어 라이브러리가 구현되어 있는데요.
유명한 예로는 2D 그래픽 라이브러리인 Skia(곧 새로운 렌더링 레이어인 Impeller로 대체될 예정)나 iOS, Android 등 플랫폼 디바이스 내에서 Engine을 호스팅할 수 있는 Dart VM 등이 있습니다.
본 글에서는 Dart VM을 중심으로 그 구조를 설명하고 있습니다.
공식 문서에 나와 있는 익숙한 도식을 다시 한번 소개합니다.
네이티브 앱으로 실행되기까지
iOS/Android에서 코드가 어떻게 실행되는지에 대해서는 공식 문서의 FAQ에 있는 How does Flutter run my code on XXX?에서 자세히 설명되어 있습니다.
릴리즈 모드
첫 번째 단락은 릴리즈 모드에 대한 이야기입니다.
먼저, Dart 코드(애플리케이션 코드와 SDK 모두)는 기계어(ARM)로 AOT 컴파일됩니다.
컴파일된 바이너리는 iOS 프로젝트의 "Runner"에 포함되어 .ipa 파일로 빌드됩니다.
참고로 애플리케이션 코드는 Frameworks/App.framework로 컴파일됩니다.
그리고 앱이 실행되면 동시에 Flutter 라이브러리를 로드합니다.
렌더링이나 사용자의 입력 조작 및 이벤트 핸들링 등은 모두 컴파일된 Flutter 앱에 포함되어 있습니다.
Unity 등의 게임 엔진과 유사한 동작을 합니다.
위 내용을 종합하면 아래 도식과 같은 이미지일 것입니다.
디버그 모드
FAQ의 두 번째 단락은 디버그 모드에 대한 이야기입니다.
Dart VM 위에서 실행되며, 익숙한 Hot Reload(재컴파일 없이 변경사항을 반영하는 기능)도 소개되어 있습니다.
나머지는 디버그 모드에서는 성능이 떨어진다는 점을 인식해야 한다는 정도의 서술입니다.
위가 일단 Flutter 관점에서 작성된 네이티브 앱 실행까지의 과정입니다.
전체적인 흐름을 파악했다면 본 글의 후반부에서는 AOT 컴파일과 Dart VM에 대해 언급하며 컴파일 메커니즘을 자세히 살펴보겠습니다.
(여담) React Native의 컴파일 방식과의 차이점
약간의 주제가 벗어나지만, 흔히 비교되는 동일한 크로스 플랫폼인 React Native와는 몇 가지 차이점이 있으며, 그 중 하나가 AOT 컴파일의 가능 여부입니다.
React Native는 Flutter와 달리 네이티브 UI 컴포넌트를 사용하는 접근 방식이기 때문에, 사용자 조작이나 이벤트 핸들링은 Bridge를 통해 네이티브와 JavaScript 간의 왕래가 필요했습니다.
반면 Flutter는 모든 사용자 인터페이스가 기계어로 사전 컴파일되어 있다는 점에서 큰 차이가 있으며, 실행 속도 면에서도 다양한 논의가 있었습니다.
최근에는 JSI(JavaScript Interface)를 통한 Bridge 해소나, 경량화되고 빠른 JavaScript 엔진인 Hermes의 등장으로 이러한 문제들이 점차 해결되고 있는 것 같습니다.
Dart 관점에서의 실행
이번에는 Dart 공식 문서에서 컴파일의 세부 사항을 살펴보겠습니다.
Flutter 관점의 정보보다 더 자세히 설명되어 있습니다.
문서에 나와 있듯이, Dart라는 언어의 목표는 "애플리케이션용 유연한 런타임을 활용하여, 멀티 플랫폼 개발에 생산적인 프로그래밍 언어를 제공하는 것"입니다.
"어떤 플랫폼에서도 애플리케이션을 실행할 수 있는" 점을 강점으로 하는 위치에 있습니다.
Dart is a client-optimized language for developing fast apps on any platform.
Its goal is to offer the most productive programming language for multi-platform development, paired with a flexible execution runtime platform for app frameworks.
그 특징은 아래 도식에서 볼 수 있듯이, 플랫폼에 맞춰 실행 가능한 기계어로 유연하게 변환됩니다.
- 모든 플랫폼에서 실행 가능
- 플랫폼에 맞춰 기계어 또는 JavaScript로 변환
- 개발용과 프로덕션용 툴체인이 분리
- Hot Reload 및 디버그 툴 제공으로 생산성 향상
JIT과 AOT
Dart Native는 모바일, 데스크탑, 서버 등을 지칭하며, Dart Web은 브라우저를 지칭합니다.
전자는 기계어로 변환하는 반면, 후자는 JavaScript로 트랜스파일되기 때문에 다른 접근 방식으로 컴파일됩니다.
정리하면 다음과 같은 매트릭스가 됩니다.
컴파일러 종류 | 개발 모드 | 프로덕션 모드 |
---|---|---|
Dart Native | JIT (just-in-time) | AOT (ahead-of-time) |
Dart Web | dartdevc | dart2js |
잘 아시다시피, Flutter의 빌드에는 디버그/릴리즈/프로파일의 세 가지 모드가 있으며, 이에 맞춰 Dart 모드도 전환되어 실행됩니다.
디버그/프로파일 빌드에서는 JIT 컴파일이, 릴리즈 빌드에서는 AOT 컴파일이 각각 수행됩니다.
JIT과 AOT의 특징을 간단히 표로 정리하면 다음과 같습니다.
항목 | JIT | AOT |
---|---|---|
실행 속도 | 워밍업이 필요해 초기에는 느리지만, 시간이 지나면서 최대 성능에 도달 | 실행 즉시 최대 성능에 도달 (최대 성능은 JIT이 더 높을 때도 있음) |
컴파일 시점 | 실행 시 | 실행 전 |
컴파일 시간 | 짧음 | 김 |
목적 | Hot Reload 등 빠르고 안정적인 개발 흐름 제공 | 사용자 경험을 중시 |
Dart는 이 두 가지 컴파일 방식을 모두 지원하고 있는 것입니다.
dart compile
Dart의 컴파일도 당연히 로컬에서 실행할 수 있으며, 그 절차는 공식 문서에 나와 있습니다.
꽤 단순하지만, 직접 실행해보면 이해가 쉬울 것입니다.
흥미롭진 않지만, 아래와 같이 단순히 출력하는 Dart 파일을 컴파일해보았습니다.
// hello.dart
void main() {
print('Hello World!');
}
실행 결과는 다음과 같습니다.
출력된 산출물과 특징에 대해서는 주석으로 설명했습니다.
❯ dart compile exe hello.dart
Info: Compiling with sound null safety
# output: 4.9MB
# Standalone 실행 파일
# 런타임도 포함되어 가장 크다
-> hello.exe
❯ dart compile aot-snapshot hello.dart
Info: Compiling with sound null safety
# output: 954KB
# 런타임은 포함되지 않고 기계어만으로 가장 가볍다
-> hello.aot
❯ dart compile jit-snapshot hello.dart
Compiling hello.dart to jit-snapshot file hello.jit.
Info: Compiling with sound null safety
Hello World! # JIT 컴파일러로 동시에 실행됨
# output: 4.8MB
# 전체 소스 코드의 중간 표현 + JIT 컴파일 최적화
-> hello.jit
이번에 실행한 커맨드들은 공식 문서의 표에 명확하게 나와 있습니다.
주로 소스 코드에서 직접 실행하는 것이 exe
커맨드이고, 스냅샷에서 생성하여 실행하는 것이 aot-snapshot
, jit-snapshot
입니다.
Dart 코드를 실행하는 접근 방식은 크게 두 가지가 존재하는 것을 알 수 있습니다.
각각 어떤 차이가 있는지, 스냅샷이 언제 사용되는지 등에 대해서는 후에 소개할 Introduction to Dart VM 섹션에서 다룹니다.
생성물의 특징은 공식 문서의 표에도 나와 있습니다.
또한, 우리는 보통 컴파일을 크게 의식하지 않고 dart run
커맨드를 사용하는 것 같습니다.
이는 커맨드 실행 시 내부적으로 Dart VM의 JIT 컴파일러를 사용하고 있음을 명시하고 있습니다.
문서에도 자주 나오지만, 이들 JIT & AOT 컴파일러는 Dart VM에서 실행됩니다.
다음 섹션에서는 Dart VM에 대해 살펴보겠습니다.
Dart VM 소개
Vyacheslav Egorov(@mraleph) 씨를 중심으로 Google 내 Dart VM 팀이 작성한 Dart VM의 세부 사항이 적혀있는 문서입니다.
현재는 WIP(작업 중) 상태이지만 꽤 자세하게 기술되어 있습니다.
본 글에서는 주요 부분만 발췌하여 설명하고 있으니, 자세한 내용과 전체적인 모습은 원문을 참고해 주세요.
VM이라는 혼동을 일으킬 수 있는 이름
"The name 'Dart VM' is historical."
이 설명에서 알 수 있듯이, 그 명칭은 역사적인 것이지만 "VM"이라는 이름만으로는 실행 환경뿐만 아니라 AOT 파이프라인이 존재하는 등 의미가 더 넓다는 것을 알고 있어야 이후의 이해가 쉬워집니다.
저도 처음에 오해했던 부분인데, "Dart VM"이라고 하면 일반적으로 JVM처럼 실행 환경으로만 생각하게 됩니다.
하지만 Dart VM은 약간 다릅니다.
Dart VM은 가상 머신이기도 하지만, 그것은 단지 하나의 요소일 뿐이며 항상 JIT 컴파일이 되는 것은 아닙니다.
릴리즈 모드 실행 시에는 AOT 컴파일이 수행되지만, 이는 Dart VM의 AOT 파이프라인 내에서 이루어집니다.
컴파일된 기계어는 precompiled runtime이라고 불리는 경량의 Dart VM 위의 런타임에서 실행됩니다.
즉, VM에 국한되지 않는 다양한 기능을 Dart VM이 제공하고 있는 것입니다.
Dart VM의 특징
- Dart 런타임 제공
- 가비지 컬렉션, 메모리 관리, Isolate 관리 등 수행
- Dart 코어 라이브러리의 네이티브 메소드 제공
- Dart: The libraries | Dart
- 우수한 개발자 경험 제공
- 디버그 툴
- Hot Reload (디버그/프로파일 시에만)
- JIT & AOT 컴파일러의 두 가지 파이프라인 제공
참고로, Dart VM의 디렉토리는 여기 있습니다.
Dart VM이 코드를 실행하는 방식
앞 섹션의 "dart compile"에서도 언급했지만, Dart 코드를 실행하는 데에는 다음 두 가지 패턴이 있습니다.
이들의 큰 차이는 소스 코드를 언제, 어떻게 실행 가능한 코드로 변환하느냐에 있으며, 런타임 환경은 동일합니다.
- 소스 코드에서 JIT으로 실행하는 방법
- 스냅샷(AOT/JIT 스냅샷)에서 실행하는 방법
앞서 언급한 dart compile
커맨드와 대응시키면 다음과 같습니다.
- 1. 소스 코드에서 실행
- JIT:
$ dart run
- JIT:
- 2. 스냅샷에서 실행
- JIT:
$ dart compile jit-snapshot
- AOT:
$ dart compile aot-snapshot
&dartaotruntime
- JIT:
1. 소스 코드에서 JIT으로 실행하는 단계
다음과 같은 커맨드 라인에서 Dart 코드를 실행할 때, 어떤 일이 일어나는지 설명합니다.
// hello.dart
void main() {
print('Hello World!');
}
$ dart hello.dart
-> Hello World!
Dart VM은 Dart 코드를 그대로 실행하지 않습니다.
처음에는 JIT 컴파일러가 Dart 소스 코드를 그대로 실행하는 줄 알았지만, Dart VM에서는 불가능합니다.
대신, Kernel ASTs(.dill)이라고 하는 중간 표현(추상 구문 트리의 바이너리)으로 한 번 변환한 후, 이를 VM 위에서 실행합니다.
쉽게 말해, Kernel AST는 "Dart VM에서 실행하기 위한 바이너리"라고 할 수 있습니다.
※AST: Abstract Syntax Tree
도식에도 나와 있듯이, Dart 소스 코드를 Kernel AST로 변환하는 것은 common front-end (CFE)
라고 불리는 Dart로 작성된 저수준 API를 통해 이루어집니다.
Common
이라는 이름은 Dart Native(VM)와 Dart Web(dart2js, dartdevc)에서 공통으로 사용되기 때문에 붙여진 것 같습니다.
참고로, 아래와 같이 로컬에서 Dart 코드를 Kernel AST로 변환할 수 있습니다.
❯ dart compile kernel hello.dart
Compiling hello.dart to kernel file hello.dill.
Info: Compiling with sound null safety
# output
-> hello.dill
실행 결과는 이와 같습니다.
.dill
파일은 추상 구문 트리의 바이너리 표현이기 때문에 내용을 봐도 잘 이해되지 않지만, 이것이 바로 Kernel AST 자체입니다.
Flutter에서의 Kernel AST 처리
구체적인 이미지를 그리기 위해 Flutter의 경우에서 Kernel AST의 동작을 살펴보겠습니다.
Flutter에서는 Dart 실행과는 달리, 아래와 같이 커널로의 컴파일과 실행이 각각 다른 위치에서 이루어집니다.
호스트(HOST)라고 표시된 부분은 우리가 빌드하는 머신이나 CI/CD 환경을 의미하고, DEVICE는 iOS/Android 등 실행하는 플랫폼을 의미합니다.
이 사이의 Kernel AST 이동은 flutter_tool
에 의해 이루어집니다.
flutter_tool
은 Dart 코드를 파싱할 수 없지만, Kernel AST로의 변환을 frontend_server(CFE)
프로세스를 지속시켜 Flutter의 커널 변환이 가능하게 합니다.
frontend_server
프로세스는 지속적으로 상태를 유지하며, 이전 컴파일 결과를 가지고 있습니다.
이를 통해 개발자가 요청한 Hot Reload를 감지하여, 이전 컴파일 결과를 재사용하고 변경된 부분만 재컴파일합니다.
요약하자면, 우리가 변경한 Dart 코드는 frontend_server(CFE)
에 의해 동적으로 감지되고, 변경된 바이너리를 생성한 후 VM 위에 변경 사항이 전달되어 변경 결과가 즉시 반영되는 경험을 할 수 있는 것입니다.
2. 스냅샷에서 실행하는 단계
또한, VM에는 바이너리 스냅샷을 생성하는 기능이 있습니다.
스냅샷을 생성하면, 다른 Isolate에서 VM을 실행할 때 스냅샷을 기반으로 빠르고 동일한 상태로 실행할 수 있습니다.
이 스냅샷 기능을 사용하면, 원래 워밍업이 필요한 JIT 컴파일러도 실행 시간을 단축할 수 있습니다(AppJIT 스냅샷).
조금 지친 상태라 이 부분에 대해서는 해석에 자신이 없으니, 관심 있는 분은 원문 기사를 참고해 주세요.
- Running from Snapshots | Dart VM
- Running from AppJIT snapshots | Dart VM
- Running from AppAOT snapshots | Dart VM
결국 Dart VM은 어디에서 사용되는가?
AOT 모드에서도 실행 시에 Dart VM이 사용되는가?
이것이 개인적으로 어려웠던 부분이었습니다.
JIT 모드에서는 컴파일과 실행이 같은 타이밍에 이루어져 이해하기 쉽지만, AOT 모드에서도 사전 컴파일된 기계어를 실행하기 위해 Dart VM은 런타임에 한정된 경량 형태로 사용된다고 합니다.
이 점에 대해서는, 앞서 소개한 Vyacheslav Egorov 씨의 StackOverflow 댓글에 매우 잘 설명되어 있었습니다.
In the JIT mode Dart VM is capable of dynamically loading Dart source, parsing it and compiling it to native machine code on the fly to execute it.
This mode is used when you develop your app and provides features such as debugging, hot reload, etc.
JIT 모드에서는 완전한 Dart VM이 사용되며, 프로그램 실행 중에 동적으로 Dart 코드를 파싱하고 컴파일(JIT 컴파일)할 수 있는 형태입니다.
이를 통해 Hot Reload 등의 개발에 필요한 기능을 이용할 수 있어 원활한 개발 환경이 제공됩니다.
JIT에 대해서는 이해하기 쉽습니다.
AOT에 대한 설명은 다음과 같습니다.
In the AOT mode Dart VM does not support dynamic loading/parsing/compilation of Dart source code.
It only supports loading and executing precompiled machine code.
However even precompiled machine code still needs VM to execute, because VM provides runtime system which contains garbage collector, various native methods needed for dart:* libraries to function, runtime type information, dynamic method lookup, etc.
This mode is used in your deployed app.
사전 컴파일된 기계어가 실행될 수 있는 환경으로 AOT 모드에서도 VM의 런타임이 필요합니다.
이유는 가비지 컬렉터나 Dart 라이브러리의 네이티브 메소드 제공 등, 이들은 배포된 애플리케이션에도 존재하기 때문입니다.
Where does precompiled machine code for the AOT mode comes from?
This code is generated by (a special mode of the) VM from your Flutter application when you build your app in the release mode.
AOT 모드의 기계어 사전 컴파일도 Dart VM(특수 모드라고 표현됨) 내에서 이루어집니다.
VM은 런타임뿐만 아니라 프로그램 실행 전에도 단순한 컴파일러로서 기능하고 있는 것입니다.
이처럼, 릴리즈 모드에서는 디버그 모드의 JIT에서 사용하는 완전한 Dart VM 중에서 실행에 필요한 최소한의 런타임 부분만을 사용해 기계어를 실행합니다.
JIT VM에는 Hot Reload 등 개발에 필요한 부품이 포함되어 있지만, AOT VM에는 이러한 개발에 필요한 부분이 제거된 서브셋 같은 VM이 됩니다.
도식으로 표현하면 다음과 같은 이미지일 것입니다.
JIT 모드에서 AOT 모드로 향하는 화살표는 실제로 그 프로세스가 존재한다는 의미는 아니고, AOT 모드의 VM은 JIT 모드의 서브셋이라는 것을 의도한 것입니다.
JIT 모드에서는 존재했던 개발에 필요한 부품들이 AOT 모드에서는 제거(stripped)되어 있다는 것이 핵심 포인트입니다.
정리
Dart 소스 코드가 실행되기까지의 단계를 정리해볼까요?
- Dart 소스 코드는
front_end
툴인 CFE(Common Front End)로 불리는 도구에 의해 Kernel AST라고 하는 중간 표현(추상 구문 트리)으로 변환됩니다. - Kernel AST(.dill)는 추상 구문 트리의 바이너리 표현입니다.
- AOT 모드에서는 TFA(Total Function Analysis)에 의해 Kernel AST가 최적화됩니다.
- Dart VM에서 Kernel AST를 해석합니다.
- JIT의 경우:
- Hot Reload나 디버그를 위한 모든 개발 부품을 포함한 완전한 VM이 디바이스로 전달됩니다.
- JIT 컴파일러/인터프리터에 의해 실행 시에 프로그램이 단계별로 처리됩니다.
- AOT의 경우:
- VM 내의 AOT 파이프라인에서 기계어로 사전 컴파일됩니다.
- 기계어를 실행하기 위한 경량의 런타임만을 포함한 VM 위에서 프로그램이 실행됩니다.
- 스냅샷을 통해 실행 시간을 단축합니다.
- JIT의 경우:
실제 애플리케이션 개발에서는 이를 의식할 일이 거의 없지만, Flutter, Dart, Dart VM의 관계성을 이해하고 실행 프로세스의 해상도를 조금 높일 수 있었습니다.
'Flutter' 카테고리의 다른 글
Flutter 렌더링 방식 완벽 해부: Widget부터 픽셀까지, 핵심 원리 파헤치기 (1) | 2024.11.29 |
---|---|
Flutter 엔지니어를 위한 아키텍처 입문 가이드: 효율적이고 확장 가능한 앱 설계 방법 (0) | 2024.11.24 |