작성일 :

타입 시스템에서 실행 환경으로

C# 런타임 기초 (1) - 값 타입과 참조 타입에서는 값 타입과 참조 타입이 메모리에 놓이는 방식과, 그 차이가 GC 비용으로 이어지는 이유를 살펴봤습니다. 다만 그 코드가 실제로 실행되기까지 어떤 단계를 거치는지는 다루지 않았습니다.

작성한 C# 코드는 CPU가 곧바로 실행할 수 있는 형태가 아닙니다. 소스는 먼저 IL(Intermediate Language)이라는 중간 표현으로 컴파일되고, 이 IL을 기계어로 바꿔 실제로 실행하는 일은 별도의 계층이 맡습니다. 이 계층을 런타임 시스템(Runtime System)이라고 부릅니다.

런타임 시스템이 IL을 언제, 어떤 방식으로 기계어로 바꾸느냐는 한 가지로 정해져 있지 않습니다. 실행 도중에 변환할 수도 있고, 빌드할 때 미리 네이티브 코드로 만들어 둘 수도 있습니다. 이 선택은 빌드 속도와 실행 성능을 좌우하며, iOS처럼 실행 중 코드 생성을 제한하는 플랫폼에서는 특정 방식이 아예 허용되지 않기도 합니다.

Unity는 이 변환을 주로 두 방식으로 처리합니다. 하나는 실행 시점에 IL을 기계어로 변환하는 Mono(JIT)이고, 다른 하나는 빌드 시점에 IL을 C++로 변환한 뒤 네이티브 코드로 컴파일하는 IL2CPP(AOT)입니다.

이번 글에서는 Mono와 IL2CPP가 IL을 기계어로 바꾸는 과정을 각각 따라가며, 그 차이가 빌드 속도와 실행 성능, 플랫폼 제약에서 어떻게 드러나는지 살펴봅니다.


C# 컴파일 과정

C#으로 작성한 코드는 실행 전에 컴파일을 거칩니다. 소스를 곧바로 기계어로 번역하는 C++ 컴파일러와 달리, C# 컴파일러 Roslyn은 소스를 IL로 변환합니다. 이렇게 만들어진 IL은 CIL(Common Intermediate Language)이나 MSIL(Microsoft Intermediate Language)이라고도 불립니다.

IL은 특정 CPU나 OS에 묶이지 않는 중간 표현이라, CPU가 직접 실행하지 못합니다. 실제로 실행하려면 먼저 기계어로 변환되어야 합니다.


C# 소스 코드 (.cs 파일) 개발자가 작성 C# 컴파일러 (Roslyn) 구문 분석 → 의미 분석 → IL 생성 IL 바이트코드 (.dll 파일) 플랫폼 독립적 중간 코드 여기서부터 방식이 갈라짐 (JIT 또는 AOT) 기계어 (네이티브 코드) CPU가 직접 실행 플랫폼 종속적


C#이 IL 단계를 거치는 이유는 .NET의 플랫폼 독립성에 있습니다.

플랫폼마다 CPU 아키텍처와 실행 파일 형식이 다르므로, 최종 기계어도 플랫폼별로 달라져야 합니다. 이런 차이에 대응하기 위해, C#은 플랫폼과 무관한 IL까지만 만들고, 이 IL을 기계어로 바꾸는 일은 각 플랫폼의 런타임에 맡깁니다.


하나의 C# 소스 코드 하나의 IL x86 기계어 (Windows) ARM 기계어 (iOS/Android) 기타 기계어 (WebGL 등)


IL은 평가 스택(Evaluation Stack)이라는 임시 공간을 사용하는 명령어 집합입니다. 계산에 쓸 값을 이 스택에 올려 두고, 명령어가 스택에서 값을 꺼내 처리한 다음 그 결과를 다시 스택에 올리는 방식으로 동작합니다. 평가 스택은 명령어 사이에서 값을 주고받는 논리적 공간으로, 물리적인 CPU 레지스터와 달리 실제 하드웨어로 존재하지는 않습니다.

다음은 두 정수를 더해 돌려주는 간단한 메서드입니다.


1
2
3
4
static int Add(int a, int b)
{
    return a + b;
}

이 메서드를 릴리스 빌드로 컴파일하면 다음과 같은 IL이 만들어집니다.

1
2
3
4
ldarg.0
ldarg.1
add
ret

디버그 빌드라면 디버깅을 돕는 명령어가 몇 개 더 붙어 이보다 길어집니다.


IL 명령어는 수행할 동작을 가리키는 opcode(연산 코드)와, 경우에 따라 그 동작이 다룰 피연산자(operand)로 이뤄집니다. ldarg.0은 opcode ldarg(인자를 읽어 스택에 올리는 동작)와 피연산자 0(몇 번째 인자인지)으로 나뉘고, add처럼 피연산자 없이 opcode만으로 끝나는 명령어도 있습니다.

ldarg.0ldarg.1은 첫 번째 인자 a와 두 번째 인자 b를 차례로 스택에 올립니다. 그러면 스택에는 두 값이 a, b 순으로 쌓입니다. add는 이 두 값을 꺼내 더한 다음 그 합을 다시 스택에 올리므로, 스택에는 a + b 하나만 남습니다. 마지막으로 ret이 그 값을 호출자에게 돌려줍니다.

ldarg.0이 인자 a를 가리키는 것은 Add가 정적 메서드이기 때문입니다. 인스턴스 메서드였다면 ldarg.0this를 가리키고, ab는 각각 ldarg.1ldarg.2로 밀려납니다.

이처럼 IL은 계산 과정을 평가 스택의 흐름으로 표현할 뿐, CPU가 그대로 실행하지는 못합니다. 런타임이나 AOT 컴파일러는 이 스택 기반 흐름을 읽어, 각 값을 실제 레지스터와 메모리에 배치하면서 기계어로 옮깁니다. 그리고 이 변환을 언제 하느냐에 따라 JIT(Just-In-Time)AOT(Ahead-Of-Time)로 나뉩니다.


JIT 컴파일 — Mono 런타임

IL을 실행 중에 기계어로 변환하는 방식을 JIT(Just-In-Time) 컴파일이라고 합니다. Unity에서 Mono 런타임은 이 JIT 방식을 사용합니다.

JIT 변환은 보통 메서드 단위로 이뤄집니다.

어떤 메서드가 처음 호출되면 Mono는 그 메서드의 IL을 읽어 기계어로 변환합니다. 변환된 기계어는 메모리에 캐시되므로, 같은 메서드를 다시 호출할 때는 변환 과정을 반복하지 않고 캐시된 기계어를 실행합니다.


메서드 A 첫 호출 IL 바이트코드 (메서드 A) JIT 컴파일러 IL → 기계어 변환 기계어 (캐시 저장) 실행 메서드 A 두 번째 호출 캐시된 기계어 직접 실행 (변환 과정 없음)


JIT는 기계어 변환을 빌드가 아니라 실행 시점으로 미룹니다. 변환을 이렇게 미루기 때문에 JIT는 빌드 속도, 첫 호출 비용, 최적화 수준, 플랫폼 호환성에서 AOT와 차이를 보입니다.

먼저 빌드가 빠릅니다. 빌드 시점에는 IL까지만 만들면 되고 기계어 변환은 실행할 때로 넘어가므로, 코드를 고치고 결과를 확인하는 반복 주기가 짧아집니다. Unity 에디터에서 Play 모드가 비교적 빨리 시작되는 것도 같은 이유에서입니다.

대신 메서드를 처음 호출할 때는 변환 비용이 따릅니다. 그 메서드를 실행하기 전에 IL을 기계어로 변환하는 과정을 한 번 거쳐야 하기 때문입니다. 게임을 시작한 직후나 새 씬을 불러온 직후처럼 많은 메서드가 한꺼번에 처음 불리는 구간에서는 이 변환 비용이 고스란히 그 프레임 시간에 더해집니다. 다만 한 번 변환한 메서드는 캐시에 남으므로, 같은 메서드를 다시 호출할 때는 변환을 거치지 않아 이 비용이 다시 들지 않습니다.


프레임 시간 (ms) 일반 실행 JIT 변환 비용 16.6 60fps 예산 33.3 0 1 2 3 4 5 6 7 8 시작 직후 첫 호출 집중 새 씬/코드 경로 프레임 JIT 비용은 메서드 첫 호출 시점에 붙음: 시작 직후, 새 씬 로드, 새 코드 경로 진입 이미 변환된 메서드는 캐시된 기계어를 실행하므로 같은 비용이 반복되지 않음


기계어의 최적화 수준도 낮은 편입니다. 인라이닝이나 데드 코드 제거, 루프 최적화 같은 작업은 더 빠른 기계어를 만들어 주지만, 그만큼 코드를 분석하고 변환하는 시간이 듭니다. JIT는 실행 도중에 변환을 끝내야 해서 한 메서드에 그런 시간을 길게 들이기 어렵습니다. 변환이 길어질수록 실행도 그만큼 미뤄지기 때문입니다. 그래서 빌드 시점에 충분히 시간을 들이는 AOT 컴파일러와 비교하면, Mono JIT가 만든 기계어는 최적화가 덜 된 상태에 머무를 수 있습니다.

마지막으로, JIT를 아예 쓸 수 없는 플랫폼이 있습니다. JIT는 실행 중에 새 기계어를 만들어 메모리에 올리고, 그 메모리를 실행할 수 있는 상태로 바꿔야 합니다. 운영체제는 메모리 영역마다 읽기·쓰기·실행 권한을 따로 두는데, JIT는 코드를 써 넣은 영역에 다시 실행 권한을 부여해야 합니다. 그런데 iOS처럼 실행 중 코드 생성을 막아 둔 플랫폼에서는 이 과정 자체가 허용되지 않습니다. 이런 환경에서는 JIT 대신, 빌드 시점에 미리 기계어를 만들어 두는 AOT 방식이 필요합니다.


AOT 컴파일 — IL2CPP

JIT가 변환을 실행 시점까지 미룬다면, AOT(Ahead-Of-Time) 컴파일은 빌드 시점에 미리 기계어를 만들어 둡니다. 실행할 때는 이미 만들어진 네이티브 코드를 사용하므로, JIT처럼 첫 호출 시점의 변환 비용이 없습니다.

Unity에서 AOT 빌드를 담당하는 파이프라인이 IL2CPP입니다.

IL2CPP는 이름 그대로 IL을 C++ 코드로 변환합니다. 이후 플랫폼별 C++ 컴파일러가 그 C++ 코드를 대상 플랫폼의 실행 형식으로 컴파일합니다. 네이티브 플랫폼에서는 기계어 바이너리가 되고, WebGL에서는 Emscripten을 거쳐 WebAssembly가 됩니다.


C# 소스 코드 .cs Roslyn IL 어셈블리 .dll IL2CPP C++ 소스 자동 생성 C++ 컴파일러 Clang / Emscripten 등 플랫폼별 바이너리 기계어 / Wasm 빌드 시점에 전체 변환이 끝나므로 실행 중 JIT 변환 단계가 없음


IL2CPP는 C#의 class, struct, 메서드, 배열, 제네릭 같은 요소를 대응되는 C++ 코드로 변환합니다. 이 C++ 코드는 사람이 직접 작성한 코드라기보다 빌드를 위한 중간 산출물이며, 최종적으로는 플랫폼별 C++ 컴파일러와 툴체인이 최적화하고 컴파일합니다.


Add 메서드가 빌드 중 바뀌는 형태 1 C# 소스 int Add(int a, int b) => a + b; .cs Roslyn 2 IL 어셈블리 ldarg.0 ldarg.1 add ret 평가 스택 명령 IL2CPP 3 C++ 산출물 함수 본문 + 타입/메서드 메타데이터 + 런타임 연결 정보 자동 생성 툴체인 4 플랫폼별 실행 코드 ARM64: ADD ... RET / WebGL: Wasm 명령 빌드 결과 실제 출력은 플랫폼, 최적화 옵션, 타입 정보에 따라 달라지며 실행 전 빌드 단계에서 확정됨


실행 시점에는 기계어가 이미 만들어져 있으므로, JIT와 달리 메서드를 처음 호출할 때도 변환 비용이 들지 않습니다.

IL2CPP가 C++를 거치는 이유는 플랫폼별 C++ 컴파일러와 툴체인을 활용하기 위해서입니다.

Unity가 모든 플랫폼에 대해 자체 코드 생성기를 직접 만드는 대신, 이미 각 플랫폼에 맞게 검증된 C++ 컴파일러와 툴체인을 사용합니다. 이 덕분에 IL2CPP는 플랫폼 호환성과 컴파일러 최적화 기능을 함께 활용할 수 있습니다.


Mono vs IL2CPP 비교

앞 두 절에서 Mono의 JIT와 IL2CPP의 AOT를 따로 살펴봤습니다. 두 방식은 변환 시점이 다르다는 한 가지 차이에서 출발하지만, 그 차이는 빌드 시간, 실행 성능, 첫 호출 비용, 앱 크기, 코드 보호, 플랫폼 지원으로 이어집니다.


항목Mono (JIT)IL2CPP (AOT)
컴파일 시점실행 시 (런타임)빌드 시 (사전)
변환 경로IL → 기계어IL → C++ → 플랫폼별 실행 코드
빌드 시간짧음김 (C++ 컴파일 추가)
실행 성능JIT 최적화 제약 있음C++ 컴파일러/툴체인 최적화 활용
첫 호출 비용있음 (JIT 변환)없음 (이미 기계어)
앱 크기작음 (IL만 포함)큼 (플랫폼별 바이너리 포함)
코드 보호IL 역컴파일이 비교적 쉬움역공학 난도가 상대적으로 높음
iOS 지원불가 (JIT 금지)가능 (필수)
에디터에서 사용기본 (빠른 이터레이션)불가 (빌드 필요)


빌드 시간은 두 방식이 밟는 단계 수에서 차이가 납니다. Mono 빌드는 C# 소스를 IL로 변환하면 대부분 끝나지만, IL2CPP 빌드는 그 뒤에 IL을 C++로 옮기고 그 C++를 컴파일하는 두 단계를 더 거칩니다. 그래서 프로젝트가 커질수록 빌드가 길어집니다. 이를 줄이려면 출력 경로를 그대로 두고 바뀐 부분만 다시 컴파일하는 증분 빌드(Incremental Build)가 도움이 됩니다. IL2CPP의 Code Generation 옵션에는 Faster (smaller) builds도 있지만, 이 값은 생성되는 코드 양을 줄여 빌드를 앞당기는 대신 실행 성능을 그만큼 내주므로, 출시 빌드보다는 개발 중 반복 작업에 맞습니다. 출시 빌드에서는 실행 성능을 우선하는 기본값 Faster runtime을 그대로 두는 편이 낫습니다.

실행 성능은 최적화에 얼마나 시간을 들일 수 있느냐에 따라 달라집니다. Mono JIT는 실행 도중에 기계어를 만들어야 해서 최적화에 긴 시간을 들이기 어렵지만, IL2CPP는 빌드 시점에 C++ 컴파일러가 최적화를 맡으므로 그만큼 시간을 충분히 쓸 수 있습니다. 그래서 수학 연산이나 반복문이 많은 CPU 중심 코드는 IL2CPP에서 더 빠르게 실행되기도 합니다. 다만 코드에 따라 빨라지는 정도가 다릅니다. 컴파일러 최적화가 줄여 주는 것은 CPU가 계산에 쓰는 시간뿐이기 때문입니다. 파일을 읽거나 네트워크 응답을 기다리는 데 시간을 대부분 쓰는 코드라면, 정작 오래 걸리는 부분은 그 대기 시간이라 계산이 조금 빨라져도 전체 실행 시간은 거의 그대로입니다. 이런 코드에서는 Mono와 IL2CPP의 체감 차이가 크지 않습니다.

코드 보호는 빌드 결과물에 무엇이 남느냐에 달려 있습니다. Mono 빌드에는 IL 어셈블리가 그대로 들어가는데, IL은 클래스와 메서드 구조를 비교적 잘 보존하므로 역컴파일 도구로 원본에 가까운 C#를 복원하기 쉽습니다. 반면 IL2CPP 빌드는 C# 코드가 C++를 거쳐 네이티브 바이너리로 바뀌므로, 원래의 C# 구조를 복원하기가 더 어렵습니다. 그렇다고 IL2CPP가 코드를 완전히 숨겨 주는 것은 아닙니다. 네이티브 바이너리와 그 안에 남은 메타데이터를 분석하는 도구가 있는 만큼, 민감한 로직이라면 빌드 방식만으로 보호된다고 가정해서는 안 됩니다.


Mono 빌드 앱 패키지 안에 Assembly-CSharp.dll 포함 → IL 역컴파일러로 C# 소스 복원 가능 → 클래스, 메서드, 변수명이 그대로 보임 IL2CPP 빌드 앱 패키지 안에 네이티브 바이너리 포함 → 기계어 수준의 역공학만 가능 → 원본 C# 구조 파악이 어려움 다만 global-metadata.dat에 클래스명, 메서드명, 문자열 리터럴 등의 메타데이터가 남아 있어 Il2CppDumper 같은 도구로 구조 복원이 가능하므로 완전한 보호는 아님

IL2CPP의 최적화 이점

IL2CPP가 실행 성능에서 앞서는 것은 주로 최적화를 빌드 시점에 끝내 두기 때문입니다. JIT는 실행 도중에 변환을 마쳐야 해서 최적화에 긴 시간을 들이기 어렵지만, IL2CPP는 빌드할 때 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;
}


인라인화의 효과는 호출 비용을 없애는 데서 그치지 않습니다. 인라인화 전이라면 GetSpeed는 독립된 함수 하나로 컴파일됩니다. 이 함수 하나가 모든 호출 지점에 쓰이므로, 컴파일러는 multiplier가 그때그때 무슨 값일지 가정할 수 없어 baseSpeed * multiplier를 일반적인 곱셈 그대로 둡니다. 그런데 본문이 호출 지점 안으로 들어오면, 컴파일러는 그 자리에 실제로 놓인 값과 조건까지 함께 보면서 코드를 더 줄일 수 있습니다.

앞의 예에서 multiplier가 늘 1.0f임을 컴파일러가 알아낼 수 있다고 하면, 호출 지점에 들어온 baseSpeed * multiplierbaseSpeed * 1.0f가 됩니다. 이렇게 변수 자리에 알려진 상수를 대입하는 것을 상수 전파(constant propagation)라고 합니다. 그러면 * 1.0f는 곱해도 값이 그대로라 쓸모가 없으니, 컴파일러가 이 곱셈을 제거해 식을 baseSpeed 하나로 줄입니다. 이처럼 의미를 잃은 연산을 없애는 것이 불필요한 연산 제거입니다.


데드 코드 제거 (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) 블록은 조건이 참이 되는 일이 없어 DoExpensiveWork 호출까지 한 번도 실행되지 않습니다. 컴파일러는 이 블록을 통째로 제거하고, 실제로 실행되는 DoActualWork만 남깁니다.

다만 모든 데드 코드가 if (false)처럼 눈에 잘 띄는 것은 아닙니다. 컴파일러는 상수 전파와 조건 분석을 거쳐, 겉보기에는 실행될 듯하지만 실제로는 도달할 수 없는 분기까지 가려냅니다.


루프 최적화

루프는 같은 코드를 여러 번 반복하므로, 최적화 한 번으로 얻는 이득이 반복 횟수만큼 커집니다. 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)은 루프 본문을 여러 번 복사해, 원래 여러 번에 걸쳐 하던 일을 한 번의 반복에서 처리하도록 펼치는 최적화입니다. 예를 들어 원소를 하나씩 처리하던 루프를 한 반복에서 네 개씩 처리하고 카운터를 4씩 올리도록 바꾸면, 반복 횟수가 1/4로 줄어듭니다. 루프는 한 바퀴마다 조건 비교와 카운터 증가, 처음으로 되돌아가는 분기 같은 제어 동작을 거치므로, 반복이 줄어든 만큼 이 동작도 그만큼 덜 거치게 됩니다. 주로 반복 횟수가 적거나 컴파일 시점에 정해지는 루프에 적용됩니다.


벡터화(Vectorization)는 배열의 원소마다 같은 연산을 반복하는 루프를 SIMD(Single Instruction, Multiple Data) 명령어로 바꾸는 최적화입니다. SIMD는 이름 그대로 명령 하나로 여러 데이터를 한꺼번에 처리합니다. 값을 하나씩 다루는 보통의 코드라면 네 개의 float를 더하는 데 덧셈이 네 번 필요하지만, SIMD는 이 네 값을 한 레지스터에 묶어 한 번의 덧셈으로 끝냅니다.


IL2CPP가 활용하는 C++ 컴파일러 최적화 인라인화 짧은 메서드의 호출 오버헤드 제거 추가 최적화의 기회 확대 데드 코드 제거 도달 불가능한 코드를 바이너리에서 제거 바이너리 크기 감소 루프 최적화 불변 코드 이동, 언롤링, 벡터화 반복 연산의 CPU 비용 감소 상수 전파 (Constant Propagation) 컴파일 시 확정 가능한 값을 상수로 치환 공통 부분식 제거 (Common Subexpression Elimination) 같은 계산의 반복 수행 방지 IL2CPP는 빌드 시점에 네이티브 컴파일러 최적화를 활용

코드 스트리핑

IL2CPP의 이점은 실행 성능에 그치지 않습니다. 빌드 시점에 전체 코드를 분석할 수 있다는 같은 특성 덕분에, 코드 스트리핑(Code Stripping)도 함께 적용됩니다. 앞의 데드 코드 제거가 실행될 수 없는 코드를 없앴다면, 코드 스트리핑은 실행될 수는 있어도 이 앱이 어디서도 호출하지 않는 코드와 라이브러리 일부를 최종 빌드에서 제거합니다.

예를 들어 System.Xml이나 System.Net 같은 .NET 라이브러리에는 타입과 메서드가 수없이 들어 있지만, 대부분의 게임은 그중 극히 일부만 사용합니다. 스트리핑 도구는 게임의 진입점에서 출발해, 직접 호출되는 메서드와 그것이 다시 호출하는 메서드를 참조를 따라가며 하나씩 표시합니다. 끝까지 한 번도 표시되지 않은 코드는 게임이 어디서도 부르지 않는다는 뜻이므로 빌드에서 제거합니다. 이를테면 XML을 전혀 다루지 않는 게임이라면 System.Xml이 통째로 제외됩니다. 데드 코드 제거와는 코드가 제외되는 이유가 다릅니다. 데드 코드는 if (false) 속 코드처럼 실행될 길이 없어서 빠지고, 스트리핑되는 코드는 멀쩡히 실행되는데도 이 게임이 쓰지 않아서 제외됩니다.


.NET 라이브러리 전체 System.IO System.Net System.Xml System.Json System.Text System.Linq System.Collections System.Threading ... 정적 분석 (참조 추적) 게임에서 실제로 사용하는 코드 System.Collections.Generic.List<T> System.Collections.Generic.Dictionary<K,V> System.Text.StringBuilder System.Math ... (사용하는 부분만) 사용된 부분만 빌드에 포함 → 빌드 크기 감소


코드 스트리핑은 빌드 크기를 줄이는 데 도움이 됩니다. 특히 모바일에서는 빌드 크기가 다운로드 시간, 설치 경험, 스토어 정책에 영향을 줄 수 있으므로, 사용하지 않는 코드를 없애는 일이 중요합니다.

다만 정적 분석만으로는 모든 사용을 파악하지 못하는 경우가 있습니다. 리플렉션(reflection)처럼 실행 중에 타입이나 메서드를 문자열로 찾는 코드가 그렇습니다. 이런 호출은 빌드 시점 분석에서 직접 참조로 드러나지 않으므로, 실제로는 사용하는 타입이나 메서드가 스트리핑으로 제거될 수 있습니다.

예를 들어 Type.GetType("MyClass")는 찾으려는 타입을 "MyClass"라는 문자열로만 가리킵니다. 분석기에게 이 문자열은 그저 글자일 뿐이라 실제 MyClass 타입과 이어지지 않고, 그 타입을 직접 쓰는 코드가 따로 없으면 MyClass는 안 쓰이는 것으로 분류돼 제거됩니다. 이렇게 제거된 타입이나 메서드를 실행 중에 찾으면, 대상이 이미 사라진 뒤라 TypeLoadException이나 MissingMethodException 같은 오류가 납니다.

직렬화 라이브러리에서도 비슷한 문제가 생길 수 있습니다. 리플렉션으로 필드나 프로퍼티를 읽는 방식이라면, 직렬화 대상 타입이 스트리핑되지 않도록 보존 설정을 확인해야 합니다.

이런 문제를 막으려면 스트리핑에서 제외할 타입이나 멤버를 명시해야 합니다. Unity에서는 link.xml에 보존할 어셈블리, 타입, 멤버를 지정할 수 있고, 코드에서 직접 표시하려면 [Preserve] 어트리뷰트를 사용할 수 있습니다.


플랫폼별 런타임 제약

Mono와 IL2CPP 중 어느 쪽을 쓸지는 늘 개발자가 정하는 것이 아니라, 플랫폼이 대신 정하기도 합니다. 플랫폼마다 보안 정책과 지원하는 아키텍처, 허용하는 빌드 형식이 다르고, 이런 조건이 특정 런타임만 허용하는 경우가 있기 때문입니다.

iOS: JIT 불가 → IL2CPP 필수

JIT가 동작하려면 실행 중에 기계어를 만들어 실행 가능한 메모리에 올릴 수 있어야 합니다. 그런데 Apple의 iOS 보안 정책은 서드파티 앱이 실행 중에 이렇게 새 실행 코드를 만드는 것을 막습니다. 그래서 iOS에서는 JIT 기반 Mono를 쓸 수 없고, 빌드 시점에 기계어를 미리 만들어 두는 IL2CPP AOT를 써야 합니다.


일반 플랫폼 메모리 영역에 실행 권한(Execute) 부여 가능 → JIT가 생성한 기계어를 실행 가능 → 정상 동작 iOS 서드파티 앱이 메모리에 실행 권한을 동적으로 부여하는 것을 금지 → JIT가 기계어를 생성해도 실행할 수 없음 Mono JIT 사용 불가 IL2CPP (AOT) 필수 Apple 보안 정책에 의한 제한


Unity의 iOS Player 설정에서 IL2CPP가 강제되는 것도 이 때문입니다. 이는 Unity가 택한 방침이 아니라 iOS의 앱 실행 정책에서 비롯된 제약입니다.

Android: IL2CPP 권장

Android는 iOS와 달리 JIT를 전면적으로 막지 않아 Mono도 쓸 수 있습니다. 다만 출시 빌드에서는 IL2CPP를 선택하는 경우가 많습니다.


Android에서 IL2CPP가 권장되는 이유 1. 실행 성능 향상 C++ 컴파일러의 최적화로 더 빠른 기계어 생성 2. 64비트 지원 Google Play는 64비트 바이너리를 요구 — IL2CPP는 ARM64를 완전히 지원 3. 코드 보호 IL 역컴파일 방지 4. 일관된 동작 iOS와 같은 런타임 → 플랫폼 간 동작 차이 최소화


이 네 가지 가운데 출시에서 특히 결정적인 것은 64비트 지원 요건입니다. Google Play는 앱이 64비트(ARM64) 바이너리를 포함하도록 요구하는데, Unity의 Mono 백엔드는 Android에서 이 ARM64를 만들지 못하고 IL2CPP만 지원합니다. 그래서 Play 스토어에 올리는 빌드라면 IL2CPP를 써야 합니다.

WebGL: IL2CPP 필수

WebGL 빌드는 게임을 브라우저 안에서 실행합니다. 브라우저는 보통의 네이티브 실행 파일을 직접 실행하지 못하므로, Unity는 IL2CPP로 만든 C++ 코드를 Emscripten을 거쳐 WebAssembly(Wasm)로 변환합니다.

Emscripten은 C/C++ 코드를 브라우저가 실행할 수 있는 Wasm 형식으로 컴파일하는 도구입니다.


C# IL C++ (IL2CPP) WebAssembly (Emscripten) 브라우저에서 실행


네이티브 빌드가 운영체제 위에서 독립된 프로그램으로 실행되는 것과 달리, WebGL 빌드는 웹 페이지 안에서 브라우저가 제공하는 자원만으로 동작합니다. 이 한계는 런타임에서부터 나타납니다. iOS와 마찬가지로 브라우저도 실행 도중에 만들어 낸 기계어를 받아 주지 않아, 실행 중 변환에 의존하는 Mono JIT를 쓸 수 없습니다. 그래서 WebGL도 빌드 시점에 변환을 끝내는 IL2CPP AOT를 씁니다.

제약은 런타임에만 그치지 않습니다. 브라우저에서 게임은 페이지를 그리는 메인 스레드를 함께 쓰므로, 네이티브와 달리 C# 스레드를 자유롭게 쓸 수 없습니다. 파일을 동기적으로 읽거나 메인 스레드를 오래 붙잡는 작업도 페이지를 멈춰 세우기 때문에 제한됩니다.

파일 시스템과 메모리도 브라우저가 허용하는 API와 한도 안에서만 다룰 수 있습니다. 따라서 WebGL을 겨냥한 프로젝트라면 네이티브 플랫폼과 같은 런타임 환경을 가정해서는 안 됩니다.

플랫폼별 런타임 선택 정리

플랫폼Mono (JIT)IL2CPP (AOT)권장
Unity 에디터 (개발 중)O (기본)XMono (빠른 반복)
Windows / Mac (스탠드얼론)OO상황에 따라
iOSX (금지)O (필수)IL2CPP
AndroidOOIL2CPP (성능 + 64bit)
WebGLXO (필수)IL2CPP
콘솔 (PS, Xbox, etc.)XO (필수)IL2CPP


모바일과 WebGL 출시 빌드에서 IL2CPP를 사용한다는 것은 C# 코드가 IL을 거쳐 C++와 네이티브 코드로 바뀐다는 뜻입니다. 따라서 리플렉션, 코드 스트리핑, 제네릭 사용 방식처럼 IL2CPP에서 달라질 수 있는 지점을 미리 확인해야 합니다.


개발 워크플로

실제 개발에서는 Mono와 IL2CPP를 목적에 따라 나누어 사용하는 경우가 많습니다.


1단계: 코드 작성 및 에디터 테스트 Mono (JIT) Play 즉시 실행 · 빠른 이터레이션 · 로직 검증에 집중 2단계: 기기 테스트 IL2CPP (AOT) 실제 기기에서 성능 측정 · IL2CPP 특유의 문제 확인 (코드 스트리핑, 리플렉션 제한 등) 3단계: 출시 빌드 IL2CPP (AOT) 최종 성능 최적화 · 코드 스트리핑으로 빌드 크기 최소화 · 코드 보호


그래서 에디터에서는 Mono로 빠르게 반복하며 개발하고, 기기 테스트와 출시 빌드에서는 IL2CPP로 실제 런타임 조건을 확인하는 흐름이 일반적입니다.

그런데 Mono에서 정상 동작하던 코드가 IL2CPP에서는 다르게 동작할 수 있습니다. 그 원인은 앞서 본 코드 스트리핑과 리플렉션, 그리고 아직 다루지 않은 AOT의 제네릭 처리입니다.

AOT는 코드에 쓰인 제네릭 타입 조합을 빌드 시점에 미리 모두 컴파일해 두고, 실행 중에 새 조합을 JIT로 만들어 내지는 못합니다. 그래서 어떤 제네릭 조합이 코드에서 직접 참조되지 않고 리플렉션으로만 쓰이면, 빌드 분석이 그 조합을 보지 못해 컴파일하지 않고, 실행 중에 그 조합을 쓸 때 예외가 납니다. 이런 조합이 필요하다면 빌드 시점에 드러나도록 코드에서 한 번 명시적으로 참조해 두거나 보존 설정에 넣어야 합니다.

이런 차이는 출시 직전에야 발견하면 수정 비용이 커집니다. 그래서 개발 중에도 일정한 주기로 IL2CPP 기기 빌드를 만들어 미리 확인해 두는 것이 안전합니다.


마무리

이번 글에서는 C# 소스가 기계어가 되기까지 거치는 단계와, Unity가 그 변환을 Mono(JIT)와 IL2CPP(AOT)로 어떻게 처리하는지 정리했습니다. 핵심은 다음과 같습니다.

  • IL은 C# 컴파일러가 만드는 플랫폼 독립적인 중간 코드입니다. CPU가 직접 실행하지 못하므로 런타임이 기계어로 바꿔야 합니다.
  • Mono(JIT)는 실행 중에 IL을 기계어로 바꿉니다. 빌드와 에디터 반복은 빠르지만, 첫 호출 비용과 실행 중 최적화 제약이 있습니다.
  • IL2CPP(AOT)는 빌드 시점에 IL을 C++로 옮겨 네이티브 바이너리로 컴파일합니다. 빌드 시간은 길어지지만, 첫 호출 비용이 없고 플랫폼별 C++ 컴파일러의 최적화를 활용합니다.
  • 코드 스트리핑은 빌드에서 쓰지 않는 코드를 제거해 크기를 줄입니다. 리플렉션이나 직렬화로만 쓰이는 코드는 정적 분석에 드러나지 않아 link.xml이나 [Preserve]로 따로 남겨야 할 수 있습니다.
  • 플랫폼 제약으로 iOS와 WebGL은 IL2CPP가 필수이고, Android 출시 빌드도 64비트 요건 때문에 보통 IL2CPP로 검증합니다.

정리하면, 이 차이들은 결국 IL을 언제 기계어로 바꾸느냐로 모입니다. 실행 시점에 바꾸면 개발 반복이 빠른 대신 런타임 비용과 플랫폼 제약을 안고, 빌드 시점에 미리 바꾸면 빌드 시간은 늘어도 실행 환경이 더 예측 가능해집니다.

이 글에서 다룬 변환 파이프라인을 바탕으로, 다음 글 C# 런타임 기초 (3) - 가비지 컬렉션의 기초에서는 런타임의 또 다른 핵심 역할인 메모리 회수를 다룹니다. GC가 어떤 기준으로 힙의 객체를 찾아 회수하는지, Unity의 Boehm GC와 Incremental GC가 어떻게 다른지 살펴봅니다.



관련 글

전체 시리즈

Tags: C#, IL2CPP, Mono, Unity, 모바일

Categories: ,