플러터 책을 공수하여 개발 환경 구성을 마치고 난 뒤 1장에서 바로 막혀버린 부분이 있었다.
바로 Dart 는 AOT 컴파일이 가능하여 어떤 플랫폼에서도 빠른 속도를 낸다
라는 부분이었다. 뒤이어 다트 언어의 크로스 플랫폼이 개발, 배포할 때 어떻게 이루어지는지 간단한 소개가 나왔는데, 네이티브 플랫폼에서 개발시 JIT + VM
, 배포시 AOT + 런타임
방식으로 진행한다고 했다. 긴 문단에 걸쳐 설명이 되어있었으나, 개인적으로 도저히 이해가 되지 않는 것들이 많았다.
예를 들어, 모든 학습 자료에서 “바이트코드는 플랫폼 중립적이다” 라는 설명을 볼 수 있는데, 굳이 따지면 자바, 플러터, 다트 원본 소스 코드 자체도 중립적이라고 부를 수 있지 않나 라는 궁금증이 자꾸만 일어났다. 그리고 그렇다 치면 왜 바이트코드로 변환하여 한 단계를 거치느니 마찬가지로 ‘중립적인’ 원본 코드를 각 플랫폼에 맞게 만든 VM 에서 처리하면 왜 안되는 것인지, 등 궁금증이 끝도 없이 몰려와 나름대로 공부한 내용을 나만의 언어로 풀어 정리해 보았다.
Q. VM계열 언어에서 JIT/AOT 컴파일 방식이 정확히 무슨 뜻인가?
JIT Compile: Just-In-Time 컴파일로, 원 언어에서 컴파일 된 바이트코드를 실행할 때 런타임 환경에서 해당 시스템 및 아키텍처에 최적화된 기계어(machine code) 로 변환하고 최종적으로 CPU 에서 기계어 명령을 수행하는 방식을 뜻한다. 이 과정에서 VM은 바이트코드를 실행하기 위한 런타임 환경을 제공하며, 특정 ‘핫스팟’(자주 사용되는 코드 섹션)의 바이트코드를 최적화된 기계어로 컴파일해 CPU에서 실행한다. 또한 VM은 메모리 관리 및 가비지 컬렉션을 포함한 전반적인 기능을 제공한다.
AOT Compile: Ahead-Of-Time 컴파일 방식으로 크게 두 가지로 나뉜다
- C/C++ 계열 언어에서의 AOT 컴파일: 원본 소스 코드를 기계어로 직접 컴파일하여 실행 가능한 바이너리 파일을 생성한다. 실행 시점에는 별도의 VM이나 런타임이 필요하지 않다. 다만 개발자가 각 플랫폼에 맞는 빌드를 개별적으로 생성해야 한다.
- VM 계열 언어에서의 AOT 컴파일: 먼저 컴파일 시점에 소스코드를 바이트코드로 변환하는 단계를 거친 뒤 바이트코드를 각 플랫폼에 알맞는 기계어로 컴파일하는 방식이다. JIT 컴파일에서 바이트코드 -> 기계어 변환이 런타임에 진행되는 것과 달리, AOT 방식은 프로그램 실행 전 개발자가 릴리즈용 앱을 빌드할 때(다트/플러터) 혹은 최종 사용자가 앱을 마켓에서 내려받아 설치할 때(안드로이드의 ART가 apk 파일 내부에 바이트코드로 이루어진 dex 파일들을 시스템/아키텍처에 알맞은 기계어로 변환한다) 기계어로 미리 변환한다. AOT 컴파일의 경우 실행시 VM이 필요하지 않지만 런타임(실행시 메모리 관리, 가비지 콜렉션, 기타 리소스 및 기능 지원 등을 위해 존재하는 통합 실행 환경)을 필요로 한다.
Q. VM 계열언어의 AOT 컴파일 방식에 굳이 원본->바이트코드->기계어 단계를 거치는 이유는?
그냥 C++ 처럼 바로 원본 소스-> 기계어로 바꿔주면 안 되는 건가? 그리고 vm based 언어에서 최종 결과물이 기계어라면 결국은 타겟 시스템/아키텍처에 맞게 개별적으로 프로그램을 제작해야 할텐데, WORA 철학에 위배되는 건 아닌가?
A. 개발자가 릴리즈용 앱/프로그램을 빌드할 때 (다트/플러터) 기계어로 변환된다. 안드로이드는 플레이 스토어에서 앱을 내려받고 설치할 때 컴파일이 진행된다. iOS는 빌드 단계에서 플러터처럼 네이티브 기계어로 변환되어 설치된다.
Q. JIT+VM, AOT+runtime? 런타임은 bytecode 로 작성된 프로그램을 실행할 때 필요한 여러 네이티브 라이브러리, 가비지 콜렉션, 기타 리소스 관리 등의 역할을 맡는 일종의 매니저 요소?
자바 언어는 JIT(Just In Time) 컴파일만을 지원하는 데 반해, jvm 계열 여러 언어들(클로저, 코틀린 등)이나 다트와 같은 VM 계열 언어들은 AOT(Ahead of Time) 컴파일 또한 지원하여 최종 결과물 앱을 실행시 bytecode 를 그때 그때 처리하는 VM이 필요 없다. AOT 컴파일 방식은 모든 소스 코드를 bytecode 가 아닌 머신 코드로 변환하기 때문에 vm이 필요치 않으며, 다만 언어 자체의 관리를 위해 런타임이 필요하다.
Q. 자바가 bytecode 를 통해 WORA(Write Once, Run Anywhere) 개념을 적용했다고 하는데, 어차피 개발자나 최종 사용자는 VM, runtime 등을 신경쓸 필요가 없는데 왜 굳이 자바 소스 코드가 아닌 바이트코드가 필요한 건데?
어차피 언어 제작사에서 각 아키텍처, 시스템에 맞춘 vm 이니 런타임이니 이런것들을 다 책임져주지 않은가? 그렇다면 바이트코드나 raw source code나 무슨 차이이며, 굳이 바이트코드로 단계를 하나 추가하면서까지 vm 을 껴서 프로그램을 개발하는 이유는?
우선, 자바와 같은 추상적 개념, 고차원 개념(oop, 함수, 클래스, loops, etc) 이 저수준 차원에서 해석하고 실행하기 위해 컴퓨터 연산이 상당히 소모된다. 바이트코드는 약간 더럽지만 우리가 밥을 먹고 나서 반쯤 소화된 결과물로 비유할 수 있으며, 생 음식을 장에서 흡수하는 것과 중간 단계에서 소화된 것에서 영양분을 흡수하는 것의 효율 차이가 난다. 바이트코드를 사용하지 않는다면 매번 모든 시스템에서 고차원적인 기능 해석을 지원하는 코드를 언어 제작사들이 지원해야 할 뿐 아니라, 최종 사용자가 raw source code가 들어있는 실행 파일을 실행할 때 vm 이(바이트코드가 아니라 언어 자체를 해석한다고 할 때) 그 사용자의 디바이스가 매번 고수준 언어를 해석하게 된다면 사용자 입장에서도 훨씬 느린 프로그램을 사용하기 싫을 것이다. java 소스 코드던 바이트코드던 둘 다 ‘플랫폼 중립적’이며 모든 vm이 해석할 순 있다. 다만 바이트코드는 자바 혹은 jvm 계열 언어의 수많은 고차원 기능들을 최대한 단순화 하여 표준에 맞게 소화해 놓은 것이므로 실행하는 기기/사람 입장에서도 원본 소스코드에 비해 훨씬 더 빠르고 효율적으로 소프트웨어를 실행할 수 있다는 이점을 갖는다. 언어 제조사에서도 모든 고차원 기능을 지원하기 위해 땀을 뻘뻘 흘리는 것보다 중간 단계인 바이트코드를 지원하는게 훨씬 더 본인들 입장에서도 편할 것이다.
Q. 아니 그럼, 바이트코드를 안 쓴다고 쳤을때 C++ 같은 언어들과 비교해서 이점이 뭐야? 바이트코드 안 쓰면 중간 단계 거칠일이 없으니 C++ 급 퍼포먼스가 나와야 되는거 아니야?
C/C++ 같은 언어들은 메모리와 같은 하드웨어 리소스를 직접 수동으로 관리하므로 개발자의 실수가 없다면 당연히 별도 리소스 관리가 필요한 VM 계열 언어보다 높은 퍼포먼스를 가질 수 밖에 없다. 반면, 바이트코드 기반 언어들은 개발자가 직접 메모리를 관리하지 않고 VM 이나 런타임(프로그램 실행 시점, 혹은 프로그램 실행시 vm 처럼 바이트코드를 한줄한줄 해석해주거나 미리 AOT 컴파일 된 머신 코드일지라도 가비지 컬렉션, 메모리 관리 등 프로그램의 정상적인 실행을 위해 필요한 모든 것들이 모인 환경) 환경이 리소스 관리를 해주므로 개발자가 직접 복잡한 메모리 관리를 할 필요가 없고 안정성 측면에서도 높아진다.
Q. 보통 바이트코드 계열 언어는 VM이 가비지 콜렉션을 담당한다고 배우는데, 갑자기 AOT 컴파일은 실행시 VM을 사용하지 않는다는 게 무슨 말이야? 그럼 GC 은 누가 해?
정확히 말하면 VM 은 실행시 자신을 필요로 하는 언어들(다트, 자바, 코틀린) 의 컴파일 결과물인 바이트코드가 실행될 때 런타임에서 인터프리터 역할을 하여 바이트코드 -> 기계어 컴파일 없이 직접적으로 cpu 에 머신 레벨 instruction 을 던져주는 역할을 하거나, 바이트코드를 한줄 한줄 기계어 로 번역한 결과물을 최적화하거나 메모리에 저장하는 JIT Compiler 로서의 역할을 수행한다.
VM 은 바이트코드로 제작된 프로그램 실행을 도맡으며 메모리 관리와도 깊게 관련되어 있으므로 가비지 콜렉터로서의 역할 또한 수행한다. (GC가 VM일부 요소) 그런데 AOT 컴파일 방식으로 된 프로그램은 실행시 VM을 필요로 하지 않는데 GC를 사용할 수 있는 이유는 런타임 라이브러리 또한 GC 역할을 하는 구현체를 포함하고 있기 때문이다.
VM이 동작하는 경우 자체적으로 바이트코드를 실행함과 동시에 GC 역할 또한 수행한다.
VM을 사용하지 않는 경우(AOT) 실행 플랫폼 자체적인 런타임 환경에서 GC 구현체 라이브러리를 지원한다. 플러터 릴리즈 빌드 파일을 실행하는 경우가 이에 속한다.