Post

컴파일러와 인터프리터, 그리고 JIT과 AOT

기계어로 언제 바꾸는가를 기준으로 컴파일러, 인터프리터, 바이트코드와 가상 머신, JIT과 AOT 컴파일을 정리하고 Unity의 Mono와 IL2CPP를 살펴봤습니다.

컴파일러와 인터프리터, 그리고 JIT과 AOT

개요


C++ 코드는 Windows용, 콘솔용, 모바일용 실행 파일을 플랫폼마다 따로 빌드해야 한다. 반면 유니티의 C# 스크립트는 한 번 컴파일된 .dll이 Windows 에디터에서도 macOS 에디터에서도 그대로 돌아가고, iOS로 빌드할 때는 IL2CPP라는 다른 방식으로 바뀐다.

CPU가 직접 실행할 수 있는 것은 자기 명령어 집합(x86-64, ARM64 등)으로 된 기계어뿐이다. C++, C#, Python 같은 고수준 언어로 쓴 소스 코드는 그대로는 실행될 수 없으므로, 어떤 방식으로든 기계어 수준의 동작으로 바뀌어야 한다. 그렇다면 언제 어떤게 기계어로 바뀔까?

  • 실행 전에 전부 바꿔 둔다 : 컴파일러
  • 바꾸지 않고 다른 프로그램이 대신 실행한다 : 인터프리터
  • 중간 형태로 바꿔 두고, 기계어로는 나중에 바꾼다 : 바이트코드와 가상 머신
  • 그 “나중”이 실행 중이면 JIT, 빌드 때면 AOT


컴파일러


프로그램을 실행하기 전에 전체를 미리 다른 언어(보통 기계어)로 번역해 두는 프로그램이다. 번역 결과물은 실행 파일로 저장되고, 실행은 번역과 별개로 나중에 일어난다. 실행할 때는 컴파일러가 필요 없다.

C++ 빌드의 흐름을 보면 흔히 컴파일이라고 부르는 과정은 네 단계이다.

  1. 전처리 - #include, #define, #if를 처리해 텍스트를 펼친다.
  2. 컴파일 - 전처리된 소스 파일(번역 단위) 하나를 어셈블리 코드로 번역한다.
  3. 어셈블 - 어셈블리를 기계어로 바꿔 목적 파일(.o, .obj)을 만든다.
  4. 링크 - 여러 목적 파일과 라이브러리를 합쳐 하나의 실행 파일로 만들고, 다른 파일에 정의된 함수 호출을 실제 주소로 연결한다.

컴파일은 소스 파일마다 독립적으로 일어나고, 파일 간 연결은 링커의 몫이다. 그래서 선언만 있고 정의가 없는 함수를 호출하면 컴파일은 통과하고 링크 단계에서 오류가 난다.

특징으로는 실행 전에 전체를 분석하므로 타입 오류 같은 문제를 실행 전에 발견할 수 있고, 시간을 들여 강하게 최적화할 수 있어 실행 속도가 빠르다. 대신 코드를 고칠 때마다 다시 빌드해야 하고, 결과물인 기계어가 특정 CPU와 운영체제에 묶인다. x64용 기계어는 ARM64 CPU가 직접 실행할 수 없으므로, 플랫폼마다 따로 빌드해야 한다.


인터프리터


소스 코드(또는 그로부터 만든 중간 형태)를 읽으면서 그 자리에서 실행하는 프로그램이다. 실행 파일을 따로 만들지 않고, 실행할 때마다 인터프리터가 있어야 한다.

동작 방식은 인터프리터도 보통 소스를 먼저 파싱해서 트리나 명령어 목록으로 만든다. 차이는 그 다음이다. 그 구조를 기계어로 번역하는 대신, 인터프리터 자신의 코드가 각 명령을 하나씩 읽고 그에 해당하는 동작을 수행한다.

실제 CPU에서 돌아가는 것은 인터프리터라는 프로그램이고, 사용자 코드는 그 프로그램이 처리하는 데이터이다.

특징으로는 수정 후 바로 실행해 볼 수 있고, 인터프리터만 있으면 어느 플랫폼에서나 같은 코드가 돌아간다. 실행 중 코드를 만들어 실행하는 것 같은 동적 기능도 지원하기 쉽다. 대신 명령 하나마다 “이게 무슨 명령인지 확인하고 해당 처리로 분기하는” 비용이 붙어 기계어로 직접 실행하는 것보다 느리고, 타입 오류 같은 문제는 그 줄을 실행할 때야 드러난다.

게임에서는 이 장점 때문에 Lua 같은 스크립트 언어를 엔진에 내장해 쓰기도 한다. 퀘스트나 UI 로직을 스크립트로 두면, 스크립트를 고쳐도 게임 실행 파일을 다시 빌드하지 않고 반영할 수 있다.


바이트코드와 가상 머신


컴파일러는 빠르지만 플랫폼에 묶이고, 인터프리터는 어디서나 돌지만 느리다. 오늘날 주로 쓰는 언어의 대부분은 그 사이를 택한다. 소스를 먼저 바이트코드라는 중간 형태로 컴파일해 두고, 실행할 때 가상 머신(VM)이 그 바이트코드를 해석하거나 기계어로 바꿔 실행한다.

바이트코드는 특정 CPU가 아니라 VM을 위해 설계된 명령어 집합이다. 그래서 배포물은 하나여도 VM만 있으면 어느 플랫폼에서나 실행된다. 동시에 소스를 파싱하고 분석하는 무거운 작업은 빌드 때 끝나 있으므로, VM은 단순한 명령어만 처리하면 된다.


컴파일 언어와 인터프리터 언어

흔히 C, C++처럼 미리 기계어로 번역하는 언어를 컴파일 언어, Python, JavaScript처럼 인터프리터가 실행하는 언어를 인터프리터 언어라고 부른다. 그런데 이 구분은 엄밀히 말하면 언어가 아니라 구현의 성질이다. 언어 명세는 문법과 의미를 정할 뿐, 어떻게 실행할지는 정하지 않는다. 같은 Python도 표준 구현인 CPython은 인터프리터로 실행하고, PyPy는 JIT 컴파일로 실행한다. 그리고 주로 쓰는 언어의 대부분은 바이트코드를 거치기 때문에 어느 한쪽에 딱 들어맞지 않는다.


C, C++

바이트코드를 거치지 않고, 컴파일러가 소스를 바로 대상 CPU의 기계어로 번역한다. 앞에서 본 빌드 4단계가 그 과정이고, 대표적인 컴파일 언어이다.


C#

C# 컴파일러는 소스를 기계어가 아니라 IL(Intermediate Language)로 컴파일하고, 그 결과를 .dll 어셈블리 파일에 저장한다. 유니티 프로젝트라면 Assembly-CSharp.dll이 대표적이다. IL이 .NET의 바이트코드이다.

같은 덧셈 함수를 C++과 C#으로 컴파일한 결과를 비교하면 차이가 보인다.

1
int Add(int a, int b) { return a + b; }
1
2
3
; C++ → x64 기계어 (MSVC 최적화 빌드)
lea eax, [rcx+rdx]
ret
1
static int Add(int a, int b) => a + b;
1
2
3
4
5
// C# → IL (릴리스 빌드)
ldarg.0
ldarg.1
add
ret

x64 결과는 rcx, rdx, eax 같은 x64 레지스터를 직접 쓰므로 x64 CPU에서만 의미가 있다. IL은 레지스터 대신 인자를 스택에 올리고(ldarg) 꺼내 더하는(add) 식으로 쓰여 있어서 어떤 CPU에도 묶이지 않는다.

CPU는 IL을 실행할 수 없으므로 어느 시점엔가 IL을 기계어로 바꿔야 하고, 그 시점에 따라 JIT과 AOT로 나뉜다.


Java, Python, JavaScript

  • Java : javac가 소스를 바이트코드(.class)로 컴파일하고, JVM이 처음에는 인터프리터로 실행하다가 자주 호출되는 메서드를 JIT으로 기계어로 바꾼다.
  • Python : CPython은 실행을 시작할 때 소스를 바이트코드로 컴파일하고(__pycache__ 폴더의 .pyc), 그 바이트코드를 인터프리터로 실행한다. 인터프리터 언어라고 불리지만 해석하는 대상은 바이트코드이다.
  • JavaScript : V8 엔진은 소스를 불러올 때 바이트코드로 바꿔 인터프리터로 먼저 실행하고, 자주 실행되는 함수를 JIT으로 컴파일한다.
언어대표 구현먼저 번역되는 형태실행 방식
C, C++MSVC, Clang, GCC기계어 (빌드 시)CPU가 직접 실행
C#.NET, MonoIL (빌드 시)JIT 또는 AOT
JavaJVM바이트코드 (빌드 시)인터프리터 + JIT
PythonCPython바이트코드 (실행 시작 시)인터프리터
JavaScriptV8바이트코드 (로드 시)인터프리터 + JIT


JIT과 AOT


IL 같은 바이트코드를 기계어로 바꾸는 시점이 실행 중이면 JIT, 실행 전(빌드 시)이면 AOT이다. C# 기준이다.

JIT (Just-In-Time) 컴파일

  1. 프로그램이 시작되면 런타임이 어셈블리를 로드한다. 아직 메서드들은 기계어로 바뀌지 않았다.
  2. 각 메서드의 호출 진입점은 처음에 “이 메서드를 컴파일하라”는 스텁(작은 코드)을 가리킨다.
  3. 메서드가 처음 호출되면 스텁이 JIT 컴파일러를 부른다. JIT 컴파일러는 그 메서드의 IL을 읽어 기계어를 생성하고, 이를 실행 가능한 메모리 영역에 쓴다.
  4. 진입점을 새로 만든 기계어 주소로 바꿔 놓는다. 그래서 두 번째 호출부터는 JIT 없이 바로 기계어가 실행된다.

장점

  • 실행 중의 정보를 활용할 수 있다. 예를 들어 프로그램이 실제로 돌아가는 CPU가 지원하는 명령어(AVX2 같은 SIMD 확장)를 확인하고 그에 맞는 기계어를 만들 수 있다.
  • 동적인 기능을 지원하기 쉽다. 실행 중에 새로 만든 IL도 그 자리에서 컴파일하면 된다.
  • 배포물이 플랫폼 독립적이다. 같은 IL을 어느 플랫폼의 런타임에서든 실행할 수 있다.

단점

  • 메서드가 처음 호출될 때 컴파일 비용이 든다. 게임에서는 어떤 기능이 처음 실행되는 순간 프레임이 튀는 현상으로 나타날 수 있다.
  • 컴파일이 실행 시간을 잡아먹으므로 오래 걸리는 최적화를 하기 어렵다.
  • 실행 중에 기계어를 만들어 실행할 수 있어야 한다. 즉 프로그램이 메모리에 코드를 쓰고, 그 메모리를 실행 가능하게 바꿀 권한이 필요하다. iOS와 대부분의 게임 콘솔은 보안상 이 권한을 주지 않으므로 JIT을 쓸 수 없다.
  • IL이 그대로 배포된다. IL은 메타데이터가 풍부해서 ILSpy 같은 도구로 원본에 가까운 C# 코드로 되돌릴 수 있다.


AOT (Ahead-Of-Time) 컴파일

빌드 시점에 IL을 분석해서 모든 메서드를 미리 기계어로 컴파일하고, 그 결과를 실행 파일에 담아 배포한다. 실행 중에는 컴파일이 일어나지 않는다.

장점

  • 빌드 시간에 오래 걸리는 최적화까지 할 수 있다.
  • 첫 호출 비용이 없어 실행 성능을 예측하기 쉽다.
  • JIT이 금지된 플랫폼에서도 실행된다.

단점

  • 빌드가 오래 걸리고 결과물이 커진다. 모든 메서드를 기계어로 만들어야 하고, 플랫폼마다 따로 빌드해야 한다.
  • 동적인 기능에 제약이 생긴다. Reflection.Emit처럼 실행 중에 새 코드를 만드는 기능은 쓸 수 없다.
  • 제네릭에 제약이 있다. 빌드 때 코드에 등장하지 않은 제네릭 조합, 특히 값 타입을 인자로 쓰는 조합(예를 들어 리플렉션으로 실행 중에 처음 만드는 List<MyStruct>)은 미리 컴파일해 둘 수 없다.
  • 실행 중의 정보를 최적화에 활용할 수 없다.


Unity의 Mono와 IL2CPP


Unity는 C# 코드를 실행하는 방식(스크립팅 백엔드)으로 두 가지를 제공한다. 둘 다 출발점은 C# 컴파일러가 만든 IL이고, 그 IL을 기계어로 바꾸는 시점이 다르다.

Mono : JIT

Mono 런타임이 실행 중에 IL을 JIT 컴파일한다. 빌드가 빠르고, 에디터의 플레이 모드도 항상 Mono로 실행된다. 대신 첫 호출 비용이 있고, JIT이 금지된 iOS와 콘솔에서는 쓸 수 없다. Android에서는 32비트 ARM만 지원하는데, 구글 플레이는 64비트 지원을 필수로 요구하므로 출시용 Android 빌드에도 쓸 수 없다. 주로 PC 플랫폼과 개발 중 테스트에 쓴다.

IL2CPP : AOT

IL을 C++ 소스 코드로 변환한 뒤, 각 플랫폼의 C++ 컴파일러로 기계어를 만드는 Unity 고유의 AOT 방식이다. C++ 컴파일러의 최적화를 그대로 활용하고 실행 중 JIT 비용이 없어 대체로 빠르다. iOS, 콘솔, 64비트 Android, WebGL을 지원한다. 대신 C++ 컴파일 시간이 더해져 빌드가 오래 걸리고, 결과물도 커진다.

 MonoIL2CPP
방식JITAOT (IL → C++ → 기계어)
빌드 시간짧음김
실행 성능상대적으로 느림, 첫 호출 비용대체로 빠름, 예측 가능
배포물IL 어셈블리 + Mono 런타임네이티브 바이너리 + 메타데이터 파일
지원 플랫폼PC, 32비트 AndroidiOS, 콘솔, 64비트 Android, WebGL 포함 모든 플랫폼
코드 노출쉽게 디컴파일됨상대적으로 어려움 (완벽하지는 않음)
동적 기능제약 적음제네릭, Reflection.Emit 등 제약
This post is licensed under CC BY 4.0 by the author.