C# 런타임 기초 (2) - .NET 런타임과 IL2CPP - soo:bak
작성일 :
타입 시스템에서 실행 환경으로
C# 런타임 기초 (1) - 값 타입과 참조 타입에서 값 타입은 데이터를 직접 저장하고 스택에 할당되며, 참조 타입은 힙에 객체가 할당되고 변수에 주소만 저장된다는 점을 살펴보았습니다. 이 타입 구분이 메모리 배치와 GC 비용을 결정합니다.
이전 글에서는 데이터가 메모리 어디에 저장되는가를 다루었다면, 이 글에서는 한 단계 나아가 “작성한 코드가 어떻게 CPU에서 실행되는가”를 다루며, 이 과정을 담당하는 것이 런타임 시스템(Runtime System)입니다.
“런타임”이라는 단어 자체는 “실행 시점”을 뜻하지만, 이 글에서는 코드 로딩, 기계어 변환, 메모리 관리, 예외 처리까지 담당하는 소프트웨어 계층을 가리킵니다. 같은 단어가 두 가지 뜻으로 쓰이는 이유는, 이 소프트웨어 계층이 바로 “실행 시점에 동작하는 시스템”이기 때문입니다.
CPU에서 게임 로직이 실행되려면 C# 소스 코드가 기계어(바이너리)로 변환되어야 하는데, C#은 C++처럼 소스 코드가 곧바로 기계어로 변환되는 구조가 아닙니다.
C#의 컴파일 결과물은 IL(Intermediate Language)이라는 중간 표현이며, 이 IL을 기계어로 변환하는 시점과 방식에 따라 런타임의 성격이 달라집니다.
이 변환 방식의 차이는 게임 성능에 직접 영향을 줍니다.
같은 C# 코드라도 런타임이 적용하는 최적화 수준(인라인화, 루프 벡터화 등)에 따라 생성되는 기계어의 품질이 달라지고, CPU 연산 속도가 바뀝니다. iOS처럼 실행 시점에 새 기계어를 생성하는 것 자체를 금지하는 플랫폼에서는 특정 런타임 방식을 아예 사용할 수 없습니다.
Unity에서는 두 가지 런타임 방식 중 하나를 선택하여 이 변환을 수행합니다.
실행 시점에 IL을 기계어로 변환하는 Mono(JIT)와, 빌드 시점에 IL을 C++로 변환한 뒤 네이티브 컴파일하는 IL2CPP(AOT)입니다.
두 방식은 빌드 시간, 실행 성능, 플랫폼 호환성에서 서로 다른 특성을 가지며, 대상 플랫폼에 따라 선택이 제한되기도 합니다.
이 글에서는 C# 코드가 기계어로 변환되는 과정, JIT와 AOT의 차이, Mono와 IL2CPP 비교, C++ 컴파일러 최적화, 코드 스트리핑, 플랫폼별 런타임 제약을 다룹니다.
C# 컴파일 과정
C#은 컴파일 언어입니다. 작성한 코드를 실행하기 전에 컴파일러가 먼저 변환 작업을 수행합니다. 다만 C++처럼 소스 코드가 곧바로 기계어로 변환되지는 않습니다. C#의 컴파일 과정에는 중간 단계가 하나 존재합니다.
C# 컴파일러(Roslyn)는 소스 코드를 IL(Intermediate Language, 중간 언어)이라는 바이트코드로 변환합니다. IL은 CIL(Common Intermediate Language) 또는 MSIL(Microsoft Intermediate Language)이라고도 불립니다.
IL은 기계어가 아닙니다. CPU가 직접 실행할 수 없는, 플랫폼에 독립적인 중간 표현입니다.
IL을 중간에 두는 이유는 .NET의 설계 원칙인 플랫폼 독립성 때문입니다.
C#으로 작성한 코드를 Windows, Linux, macOS, iOS, Android 등 다양한 플랫폼에서 실행하려면, 각 플랫폼의 기계어가 다르므로 플랫폼마다 별도로 컴파일해야 합니다.
IL이라는 중간 단계를 두면 C# 컴파일러는 플랫폼과 무관한 IL만 생성하고, 기계어 변환은 각 플랫폼의 런타임이 맡습니다. 컴파일러와 런타임의 역할이 분리되는 구조입니다.
IL은 스택 기반의 명령어 집합으로 구성됩니다. 간단한 C# 코드가 IL로 변환된 결과를 보면 IL의 성격이 드러납니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
C# 코드:
int Add(int a, int b)
{
return a + b;
}
IL로 변환된 코드 (개념적 표현):
ldarg.0 // 첫 번째 인자(a)를 스택에 올림
ldarg.1 // 두 번째 인자(b)를 스택에 올림
add // 스택의 두 값을 더해서 결과를 스택에 올림
ret // 스택 최상단의 값을 반환
IL은 바이트코드 형식이므로, 각 명령어(opcode)는 1바이트 또는 2바이트로 인코딩되며 명령어에 따라 피연산자(operand)가 추가로 붙습니다.
위 예시에서 ldarg.0은 첫 번째 인자를 평가 스택(Evaluation Stack)에 올리는 명령이고, add는 스택 최상단의 두 값을 꺼내 더한 뒤 결과를 다시 스택에 올리는 명령입니다.
평가 스택은 IL 명령어가 연산에 사용할 값을 임시로 쌓아두는 논리적 자료구조입니다. CPU에서 레지스터가 연산의 피연산자를 보관하는 역할과 비슷하지만, 레지스터는 이름으로 직접 접근하는 반면 평가 스택은 LIFO(후입선출) 방식으로만 접근합니다.
IL 자체는 CPU가 실행할 수 없으므로, 실행 전에 반드시 해당 플랫폼의 기계어로 변환되어야 합니다. 이 변환을 담당하는 것이 런타임이며, 변환 방식에 따라 JIT(Just-In-Time)와 AOT(Ahead-Of-Time)로 나뉩니다.
JIT 컴파일 — Mono 런타임
JIT(Just-In-Time) 컴파일은 프로그램을 실행하는 시점에 IL을 기계어로 변환하는 방식입니다. Unity에서 이 JIT 변환을 담당하는 런타임이 Mono입니다.
Mono 런타임에서의 실행 흐름은 다음과 같습니다.
게임이 시작되고 특정 메서드가 처음 호출되면, Mono가 해당 메서드의 IL을 읽어 기계어로 변환합니다. 변환된 기계어는 메모리에 캐시되므로, 같은 메서드가 두 번째로 호출될 때는 변환 없이 캐시된 기계어를 바로 실행합니다.
이 JIT 방식은 빌드 속도, 첫 호출 비용, 최적화 깊이, 플랫폼 호환성 네 가지 측면에서 AOT 방식과 뚜렷한 차이를 보입니다.
빌드 시간이 짧습니다.
C# 컴파일러가 IL을 생성하면 빌드가 끝납니다. 기계어 변환은 실행 시점에 일어나므로 빌드 과정에 포함되지 않습니다. Unity 에디터에서 Play 버튼을 누르면 즉시 게임이 실행되는 것도, IL → C++ → 기계어로 이어지는 AOT 변환 단계 없이 IL을 그대로 로드하여 실행하기 때문입니다. 덕분에 코드를 수정하고 바로 테스트하는 빠른 이터레이션이 가능합니다.
첫 호출 시 변환 비용이 발생합니다.
메서드가 처음 호출될 때 JIT 컴파일러가 IL을 읽고 기계어를 생성하는 시간이 소요됩니다. 게임 시작 직후나 새 씬 로드 직후에 처음 호출되는 메서드들이 동시에 JIT 컴파일되면, 순간적으로 프레임 시간이 늘어날 수 있습니다. 한 번 컴파일된 메서드는 캐시되므로, 이후에는 이 비용이 발생하지 않습니다.
최적화 수준에 한계가 있습니다.
JIT 컴파일러는 실행 시점에 기계어를 생성해야 하므로, 변환에 쓸 수 있는 시간이 제한됩니다. 변환 시간이 길어지면 게임 실행이 그만큼 지연되기 때문입니다.
컴파일러가 기계어를 생성할 때 적용할 수 있는 대표적인 최적화 기법으로는, 짧은 메서드의 본문을 호출 지점에 직접 삽입하여 호출 비용을 제거하는 인라이닝, 반복문의 연산을 CPU의 SIMD 명령어로 묶어 한 번에 여러 데이터를 처리하는 루프 벡터화, 실행 경로에 도달할 수 없는 코드를 삭제하여 바이너리 크기와 분기 비용을 줄이는 데드 코드 제거 등이 있습니다.
Mono JIT는 인라이닝이나 데드 코드 제거를 기초적인 수준에서 수행하지만, 루프 벡터화 같은 고비용 최적화는 거의 적용하지 못합니다.
AOT 컴파일러는 빌드 시간에 여유가 있어 이런 최적화를 폭넓게 적용할 수 있는 반면, JIT 컴파일러는 빠르게 “충분히 쓸 만한” 기계어를 생성하는 데 초점을 맞춥니다.
런타임에 새로운 코드를 생성합니다.
JIT 컴파일은 실행 시점에 메모리에 실행 가능한 기계어를 새로 만들어 올리는 과정입니다.
운영체제는 메모리의 각 영역에 읽기, 쓰기, 실행 세 종류의 권한을 별도로 관리합니다.
JIT 컴파일러가 메모리에 기계어를 쓴 뒤 해당 영역에 실행 권한을 부여해야 CPU가 그 기계어를 실행할 수 있습니다.
iOS처럼 서드파티 앱이 메모리에 실행 권한을 동적으로 부여하는 것을 금지하는 플랫폼에서는 이 과정 자체가 차단되므로 JIT 방식을 사용할 수 없습니다.
AOT 컴파일 — IL2CPP
AOT(Ahead-Of-Time) 컴파일은 프로그램을 빌드하는 시점에 IL을 기계어로 미리 변환하는 방식입니다. 실행 시점에는 이미 기계어가 완성되어 있으므로, JIT 변환 과정이 없습니다.
Unity에서 AOT 컴파일을 수행하는 파이프라인이 IL2CPP입니다.
IL2CPP는 “IL to C++”의 약자로, Unity가 자체 개발한 변환 도구입니다. IL을 직접 기계어로 변환하는 대신, IL을 먼저 C++ 소스 코드로 변환하고, 그 C++ 코드를 플랫폼의 네이티브 C++ 컴파일러(Clang, GCC, MSVC 등)로 컴파일하여 최종 기계어를 생성합니다.
IL2CPP가 IL을 C++로 변환하는 과정에서, C#의 class, struct, 메서드, 배열, 제네릭 등이 대응하는 C++ 코드로 생성됩니다. 사람이 읽을 수 있는 형태이지만 자동 생성된 코드라 가독성이 높지는 않습니다. 이렇게 생성된 C++ 코드를 플랫폼의 네이티브 컴파일러가 최적화하고 컴파일하여 최종 기계어를 만듭니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
IL2CPP 변환 예시 (개념적):
C# 코드:
int Add(int a, int b)
{
return a + b;
}
↓ C# 컴파일러 → IL ↓ IL2CPP 변환
생성된 C++ 코드 (개념적 표현):
int32_t ClassName_Add(int32_t a, int32_t b)
{
return a + b;
}
↓ C++ 컴파일러 (Clang 등)
ARM 기계어:
ADD W0, W0, W1
RET
실행 시점에는 이미 기계어가 완성되어 있으므로, JIT 변환 비용이 없습니다. 게임 시작 직후 첫 프레임에서도 모든 메서드가 기계어로 즉시 실행됩니다.
IL2CPP가 IL을 C++로 변환하는 이유는 실용적인 비용 절감에 있습니다.
각 플랫폼(iOS, Android, Windows, WebGL 등)에 대한 기계어 코드 생성기(code generator)를 직접 만드는 대신, 이미 각 플랫폼에 존재하는 고품질 C++ 컴파일러(Clang, GCC, MSVC 등)를 활용합니다. 수십 년간 발전해 온 C++ 컴파일러의 코드 생성 능력과 최적화 역량을 빌리는 구조입니다.
Mono vs IL2CPP 비교
앞에서 Mono(JIT)와 IL2CPP(AOT)의 동작 방식을 각각 살펴보았습니다. 두 런타임의 주요 특성을 정리하면 다음과 같습니다.
| 항목 | Mono (JIT) | IL2CPP (AOT) |
|---|---|---|
| 컴파일 시점 | 실행 시 (런타임) | 빌드 시 (사전) |
| 변환 경로 | IL → 기계어 | IL → C++ → 기계어 |
| 빌드 시간 | 짧음 | 김 (C++ 컴파일 추가) |
| 실행 성능 | 보통 (JIT 최적화 제한적) | 높음 (C++ 컴파일러 최적화) |
| 첫 호출 비용 | 있음 (JIT 변환) | 없음 (이미 기계어) |
| 앱 크기 | 작음 (IL만 포함) | 큼 (네이티브 바이너리) |
| 코드 보호 | 약함 (IL 역컴파일 용이) | 강함 (C++/기계어로 역공학 어려움) |
| iOS 지원 | 불가 (JIT 금지) | 가능 (필수) |
| 에디터에서 사용 | 기본 (빠른 이터레이션) | 불가 (빌드 필요) |
빌드 시간.
Mono 빌드는 C# 컴파일러가 IL을 생성하면 끝납니다.
IL2CPP 빌드는 여기에 IL → C++ 변환, C++ 컴파일이 추가되므로, 대규모 프로젝트에서는 수십 분에 이를 수 있습니다.
빌드 시간을 줄이려면, 이전 빌드와 같은 디렉터리에 빌드하여 C++ 컴파일러가 변경된 파일만 재컴파일하는 증분 빌드(Incremental Build)를 활용할 수 있습니다.
IL2CPP Code Generation 옵션을 Faster (Smaller) Builds로 설정하면 생성되는 C++ 코드량이 줄어들어 빌드 시간이 더 단축됩니다.
실행 성능.
Mono의 JIT 컴파일러는 실행 시점에 빠르게 기계어를 생성해야 하므로 복잡한 최적화에 시간을 쓸 수 없습니다.
반면 IL2CPP는 빌드 시점에 C++ 컴파일러가 충분한 시간을 들여 최적화하므로, 생성된 기계어의 품질이 더 높습니다.
수학 연산이 많은 게임 로직, 물리 시뮬레이션, AI 경로 탐색 등에서 IL2CPP가 Mono보다 빠른 실행 속도를 보이며, 수학 연산이나 루프가 집중된 코드에서 1.5배에서 2배의 속도 향상이 보고된 바 있습니다. 다만 이 수치는 코드 패턴에 따라 편차가 크며, I/O 중심 코드에서는 차이가 미미할 수 있습니다.
코드 보호.
Mono 빌드에서는 IL이 그대로 포함됩니다.
IL은 고수준 언어에 가까운 정보(클래스명, 메서드명, 변수명 등)를 보존하고 있어, ILSpy나 dnSpy 같은 도구로 쉽게 역컴파일됩니다.
IL2CPP 빌드에서는 C# 코드가 C++을 거쳐 기계어로 변환되므로, 원본 C# 코드의 구조가 상당 부분 사라집니다.
완전한 보호는 아니지만, 역공학 난이도가 크게 높아집니다.
IL2CPP의 최적화 이점
Mono JIT는 실행 중에 빠르게 기계어를 생성해야 하므로 최적화에 쓸 시간이 제한되지만, IL2CPP는 빌드 시점에 C++ 컴파일러가 시간 제약 없이 최적화를 적용할 수 있습니다.
C++ 컴파일러가 적용하는 대표적인 최적화 기법은 다음과 같습니다.
인라인화 (Inlining)
함수를 호출하면 매개변수 전달, 스택 프레임 생성, 반환 등의 오버헤드가 발생하며, 함수 본문이 짧을수록 이 오버헤드가 실제 연산 비용에 비해 커집니다.
인라인화는 함수를 호출하는 대신 호출 지점에 함수 본문을 직접 삽입하여 이 오버헤드를 제거합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 인라인화 예시
// 인라인화 전:
float GetSpeed()
{
return baseSpeed * multiplier;
}
void Update()
{
float s = GetSpeed(); // 함수 호출 오버헤드
transform.position += direction * s * Time.deltaTime;
}
// 인라인화 후 (컴파일러가 자동 수행):
void Update()
{
float s = baseSpeed * multiplier; // 함수 본문이 직접 삽입됨
transform.position += direction * s * Time.deltaTime;
}
인라인화의 효과는 호출 오버헤드 제거에 그치지 않습니다.
함수가 분리되어 있으면 컴파일러는 각 함수를 독립적으로 분석하지만, 인라인화로 함수 본문이 호출 지점에 합쳐지면 호출자의 문맥까지 함께 볼 수 있습니다.
위 예시에서 multiplier가 항상 1.0f라면, 컴파일러는 baseSpeed * 1.0f를 baseSpeed로 단순화할 수 있습니다.
GetSpeed()가 별도 함수일 때는 이런 최적화가 불가능하지만, 인라인화 후에는 호출자의 값이 보이므로 가능해집니다.
데드 코드 제거 (Dead Code Elimination)
실행 경로에서 도달할 수 없는 코드를 컴파일 결과에서 제거하는 최적화입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 데드 코드 제거 예시:
// 컴파일러 분석 전:
void Process(int value)
{
if (false)
{
// 이 블록은 실행되지 않음
DoExpensiveWork();
}
DoActualWork(value);
}
// 컴파일러 분석 후:
void Process(int value)
{
DoActualWork(value);
}
단순한 if (false) 외에도, 컴파일러는 상수 전파를 통해 실행 시점에 값이 확정되는 조건문을 분석하고, 도달 불가능한 분기를 제거할 수 있습니다.
IL2CPP 빌드에서는 이 분석이 C++ 컴파일러의 최적화 패스로 수행되므로, JIT보다 더 넓은 범위의 데드 코드를 식별하고 제거합니다.
루프 최적화
인라인화와 데드 코드 제거 외에도, C++ 컴파일러는 루프에 대해 다양한 최적화를 수행합니다.
루프 불변 코드 이동(Loop-Invariant Code Motion).
루프 안에서 매 반복마다 같은 결과를 내는 계산을 루프 밖으로 이동시킵니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 루프 불변 코드 이동:
// 최적화 전:
for (int i = 0; i < count; i++)
{
float radius = maxRange * 0.5f; // 매 반복 같은 값
if (distances[i] < radius)
{
// ...
}
}
// 최적화 후 (컴파일러 자동 수행):
float radius = maxRange * 0.5f; // 루프 밖으로 이동
for (int i = 0; i < count; i++)
{
if (distances[i] < radius)
{
// ...
}
}
루프 언롤링(Loop Unrolling).
루프의 반복 횟수가 적고 고정적일 때, 루프 본문을 여러 번 복사하여 루프 제어(비교, 증가, 분기) 오버헤드를 줄입니다.
벡터화(Vectorization, SIMD).
배열의 각 요소에 같은 연산을 반복하는 루프를 SIMD(Single Instruction, Multiple Data) 명령어로 변환하여, 한 번의 명령어로 여러 데이터를 동시에 처리합니다.
예를 들어, 4개의 float 값에 같은 덧셈을 수행할 때, 일반 코드는 4번의 덧셈 명령을 실행하지만, SIMD 명령어는 1번의 명령으로 4개의 덧셈을 동시에 수행합니다.
코드 스트리핑
IL2CPP의 이점은 실행 성능만이 아닙니다.
IL2CPP 빌드 과정에서 Unity는 코드 스트리핑(Code Stripping)도 수행합니다.
게임에서 실제로 사용하지 않는 코드를 식별하여 최종 빌드에서 제거하는 과정입니다.
.NET 라이브러리에는 수많은 클래스와 메서드가 포함되어 있지만, 대부분의 게임에서는 그 일부만 사용합니다.
코드 스트리핑은 진입점(entry point)에서 시작하여 참조를 따라가며, 실제로 도달 가능한 코드만 남기고 나머지를 제거합니다.
코드 스트리핑으로 줄어드는 크기는 수 MB에서 수십 MB에 이릅니다.
모바일에서는 이 차이가 큰데, Google Play 기준으로 앱 크기가 200 MB를 넘으면 다운로드 시 경고가 표시되어 이탈률이 높아집니다.
다만 코드 스트리핑에는 주의할 점이 있습니다.
리플렉션(reflection)을 통해 동적으로 접근하는 코드는 정적 분석에서 참조가 감지되지 않아 제거될 수 있습니다.
예를 들어, Type.GetType("MyClass")으로 문자열 기반으로 타입에 접근하면, 스트리핑 도구는 MyClass가 사용되고 있다는 사실을 인식하지 못합니다.
제거된 코드를 런타임에 사용하려 하면 MissingMethodException이나 TypeLoadException이 발생합니다.
JSON 직렬화 라이브러리(예: JsonUtility, Newtonsoft.Json)가 리플렉션으로 프로퍼티에 접근하는 경우에도 같은 문제가 발생할 수 있으므로, 직렬화 대상 타입은 특히 주의해야 합니다.
Unity의 link.xml 파일에 보존할 타입이나 어셈블리를 명시하면 이 문제를 방지할 수 있습니다.
link.xml은 프로젝트의 Assets 폴더에 배치하는 XML 파일로, 코드 스트리핑에서 제외할 대상을 지정합니다.
[Preserve] 어트리뷰트를 클래스나 메서드에 직접 적용하는 방법도 있습니다.
플랫폼별 런타임 제약
모든 플랫폼에서 Mono와 IL2CPP를 자유롭게 선택할 수 있는 것은 아닙니다.
보안 정책이나 플랫폼 아키텍처에 따라 특정 런타임 방식이 강제되거나 제한됩니다.
iOS: JIT 불가 → IL2CPP 필수
앞서 JIT 컴파일이 “런타임에 새로운 코드를 생성한다”는 특성을 확인했습니다.
Apple의 iOS는 보안 정책상 실행 시점에 새로운 실행 가능 코드를 메모리에 생성하는 것을 허용하지 않습니다.
JIT 컴파일은 런타임에 기계어를 생성하여 메모리에 올리는 과정이므로, 이 정책에 의해 차단됩니다.
iOS 빌드에서는 반드시 IL2CPP를 사용해야 합니다.
Unity의 iOS 빌드 설정에서 Scripting Backend로 IL2CPP만 선택할 수 있는 것도 이 정책 때문입니다.
iOS의 JIT 금지 정책은 Unity 게임뿐 아니라 iOS의 모든 서드파티 앱 프로세스에 적용됩니다.
다만 WKWebView를 통해 웹 콘텐츠를 표시하면, WebKit이 Apple이 관리하는 별도의 WebContent 프로세스에서 실행되므로 해당 프로세스에서는 JIT가 허용됩니다.
Safari가 JIT를 사용할 수 있는 것도 이 구조 덕분입니다.
반면, JavaScriptCore를 앱 프로세스 내에 직접 임베드하면 JIT 없이 인터프리터 모드로만 동작합니다.
Android: IL2CPP 권장
Android는 iOS와 달리 JIT를 금지하지 않으므로 Mono 런타임도 사용할 수 있지만, 실무에서는 IL2CPP가 권장됩니다.
Google Play 스토어는 2019년 8월부터 새로 제출하는 앱에 64비트(ARM64) 바이너리를 포함하도록 요구하고 있습니다. IL2CPP는 ARM64 네이티브 바이너리를 직접 생성하므로 이 요구를 충족합니다.
따라서 개발 중에는 에디터에서 Mono를 사용하여 빠르게 이터레이션하고, 기기 테스트와 출시 빌드에서 IL2CPP를 사용하는 워크플로가 일반적입니다.
WebGL: IL2CPP 필수
WebGL 빌드는 게임을 웹 브라우저에서 실행하기 위한 플랫폼입니다.
브라우저 환경에서는 네이티브 바이너리를 직접 실행할 수 없으므로, Unity는 IL2CPP로 생성한 C++ 코드를 Emscripten 컴파일러를 통해 WebAssembly(Wasm)로 변환합니다.
Emscripten은 C/C++ 코드를 브라우저에서 실행 가능한 Wasm 바이너리로 변환하는 도구입니다.
WebGL에서는 브라우저의 보안 샌드박스 안에서 코드가 실행됩니다.
브라우저 자체는 JavaScript나 Wasm을 JIT 컴파일하지만, 이는 브라우저 엔진 내부의 동작이며 외부 코드가 임의로 기계어를 생성하여 실행할 수는 없습니다.
Mono 런타임이 브라우저 안에서 JIT 컴파일을 수행할 수 없으므로, IL2CPP를 통한 AOT 컴파일이 유일한 방법입니다.
런타임 방식 외에도 브라우저 환경에서 오는 제약이 있습니다.
C# 수준의 멀티스레딩(System.Threading)은 사용할 수 없는데, WebAssembly에서는 GC가 다른 스레드를 동기적으로 일시 정지시키는 메커니즘을 지원하지 않기 때문입니다.
네이티브(C/C++) 수준의 스레딩만 실험적으로 지원됩니다.
파일 시스템 접근은 브라우저의 가상 파일 시스템으로 제한되고, Thread.Sleep이나 동기적 파일 I/O처럼 메인 스레드를 블로킹하는 패턴도 사용할 수 없습니다.
브라우저가 할당하는 메모리에도 상한이 있어, 대규모 에셋을 한꺼번에 로드하면 메모리 부족으로 크래시가 발생할 수 있습니다.
플랫폼별 런타임 선택 정리
| 플랫폼 | Mono (JIT) | IL2CPP (AOT) | 권장 |
|---|---|---|---|
| Unity 에디터 (개발 중) | O (기본) | X | Mono (빠른 반복) |
| Windows / Mac (스탠드얼론) | O | O | 상황에 따라 |
| iOS | X (금지) | O (필수) | IL2CPP |
| Android | O | O | IL2CPP (성능 + 64bit) |
| WebGL | X | O (필수) | IL2CPP |
| 콘솔 (PS, Xbox, etc.) | X | O (필수) | IL2CPP |
모바일 게임 개발에서 최종 빌드가 IL2CPP로 만들어진다는 것은, C# 코드가 IL을 거쳐 C++로 변환된 뒤 기계어로 실행된다는 뜻입니다.
리플렉션 제한, 코드 스트리핑, 제네릭 인스턴스화 등 IL2CPP 환경의 제약을 인식한 상태에서 코드를 작성해야 안정적인 빌드가 가능합니다.
개발 워크플로
플랫폼별 제약까지 확인했으므로, 실무에서 Mono와 IL2CPP를 어떻게 조합하여 사용하는지 정리합니다.
에디터에서 Mono로 빠르게 개발하고, 기기 빌드에서 IL2CPP의 성능 이점과 플랫폼 호환성을 확보하는 구조입니다.
2단계에서 IL2CPP 빌드를 정기적으로 수행하는 이유는, Mono에서 정상 동작하던 코드가 IL2CPP에서 문제를 일으킬 수 있기 때문입니다.
코드 스트리핑 절에서 다룬 리플렉션 접근 타입의 제거 문제나, AOT 환경에서 특정 제네릭 인스턴스화가 누락되는 문제가 대표적입니다.
예를 들어, List<MyCustomStruct>를 코드에서 직접 사용하지 않고 리플렉션으로만 생성하면, AOT 컴파일러가 해당 제네릭 인스턴스를 생성하지 않아 런타임에 ExecutionEngineException이 발생할 수 있습니다.
AOT 환경에서는 JIT처럼 실행 시점에 새 제네릭 인스턴스를 만들 수 없으므로, 빌드 시점에 사용될 모든 제네릭 타입 조합이 코드에서 명시적으로 참조되어야 합니다.
직접 참조하지 않는 제네릭 조합이 필요하다면, 해당 조합을 사용하는 더미 코드를 작성하여 AOT 컴파일러가 인스턴스를 생성하도록 유도할 수 있습니다.
이런 문제를 출시 직전에 발견하면 수정 비용이 커지므로, 개발 초기부터 기기 빌드를 자주 수행하여 미리 잡아내는 것이 중요합니다.
마무리
C# 코드가 CPU에서 실행되려면 기계어로 변환되어야 하며, 이 변환 과정에는 IL이라는 중간 단계가 존재합니다. Unity에서는 Mono(JIT)와 IL2CPP(AOT) 두 가지 런타임 방식 중 하나를 선택하여 이 변환을 수행합니다.
- IL(중간 언어)은 C# 컴파일러가 소스 코드를 변환한 플랫폼 독립적 바이트코드입니다. CPU가 직접 실행할 수 없으며, 런타임이 기계어로 변환해야 합니다.
- Mono(JIT)는 실행 시점에 IL을 기계어로 변환합니다. 빌드가 빠르고 에디터에서 즉시 실행할 수 있지만, 첫 호출 시 변환 비용이 발생하고 최적화 수준에 한계가 있습니다.
- IL2CPP(AOT)는 빌드 시점에 IL을 C++로 변환한 뒤, C++ 컴파일러가 인라인화, 데드 코드 제거, 루프 최적화 등을 적용하여 JIT보다 품질 높은 기계어를 생성합니다. 빌드 시간은 길지만 실행 성능이 높습니다.
- 코드 스트리핑은 사용되지 않는 코드를 빌드에서 제거하여 앱 크기를 줄입니다. 리플렉션으로 접근하는 코드가 제거될 수 있으므로 link.xml로 보존 대상을 지정해야 합니다.
- iOS와 WebGL에서는 JIT가 불가능하므로 IL2CPP가 필수이며, Android에서도 성능과 64비트 지원을 위해 IL2CPP가 권장됩니다.
C# 코드의 컴파일 과정과 런타임 방식을 이해했으므로, 이 지식은 스크립트 최적화 (1) - C# 실행과 메모리 할당에서 다루는 IL2CPP 환경에서의 실행 비용 분석에 직접 연결됩니다. 다음 글에서는 런타임이 담당하는 또 다른 핵심 기능인 가비지 컬렉션을 다룹니다. C# 런타임 기초 (3) - 가비지 컬렉션의 기초에서 GC의 동작 원리, Unity의 Boehm GC 특성, GC 스파이크와 Incremental GC를 설명합니다.
관련 글
시리즈
- C# 런타임 기초 (1) - 값 타입과 참조 타입
- C# 런타임 기초 (2) - .NET 런타임과 IL2CPP (현재 글)
- C# 런타임 기초 (3) - 가비지 컬렉션의 기초
- C# 런타임 기초 (4) - 스레딩과 비동기
전체 시리즈
- 하드웨어 기초 (1) - CPU 아키텍처와 파이프라인
- 하드웨어 기초 (2) - 메모리 계층 구조
- 하드웨어 기초 (3) - GPU의 탄생과 발전
- 하드웨어 기초 (4) - 모바일 SoC
- 그래픽스 수학 (1) - 벡터와 벡터 연산
- 그래픽스 수학 (2) - 행렬과 변환
- 그래픽스 수학 (3) - 좌표 공간의 전환
- 그래픽스 수학 (4) - 투영
- C# 런타임 기초 (1) - 값 타입과 참조 타입
- C# 런타임 기초 (2) - .NET 런타임과 IL2CPP (현재 글)
- C# 런타임 기초 (3) - 가비지 컬렉션의 기초
- C# 런타임 기초 (4) - 스레딩과 비동기
- 색과 빛 (1) - 빛의 물리적 원리
- 색과 빛 (2) - 색 표현과 색공간
- 색과 빛 (3) - 셰이딩 모델
- 래스터화 파이프라인 (1) - 삼각형에서 프래그먼트까지
- 래스터화 파이프라인 (2) - 버퍼 시스템
- 래스터화 파이프라인 (3) - 디스플레이와 안티앨리어싱
- Unity 엔진 핵심 (1) - GameObject와 Component
- Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프
- Unity 엔진 핵심 (3) - Unity 실행 순서
- Unity 엔진 핵심 (4) - Unity의 스레딩 모델
- Unity 에셋 시스템 (1) - Asset Import Pipeline
- Unity 에셋 시스템 (2) - Serialization과 Instantiation
- Unity 에셋 시스템 (3) - Scene Management
- Unity 렌더링 (1) - Camera와 Rendering Layer
- Unity 렌더링 (2) - Render Target과 Frame Buffer
- Unity 렌더링 (3) - Render Pipeline 개요