[PART1.C# 런타임과 .NET 플랫폼 기초(2/11)] C# 컴파일 과정 — 소스 → IL → 네이티브 코드
C# 소스가 실행되기까지 왜 두 번 컴파일되는가 / IL·JIT·AOT의 역할 / Unity IL2CPP가 특별한 이유
목차
1. 문제 제기 — 왜 C# 코드는 빌드한 뒤에도 바로 기계어가 아닌가
Unity 에디터에서 .cs 파일을 저장하면 프로젝트 루트 아래 Library/ScriptAssemblies/ 폴더에 .dll 이 생깁니다. 그런데 이 .dll 을 바이너리 뷰어로 열어 보면 CPU가 즉시 이해할 수 있는 x86·ARM 명령어가 아니라, 텍스트에 가까운 이상한 명령어 스트림이 들어 있습니다. 반면 C++로 작성한 네이티브 플러그인(.dylib, .so, .dll)은 빌드하자마자 CPU 기계어 덩어리입니다.
왜 C#은 빌드가 끝난 뒤에도 "기계어가 아닌" 중간 형태로 남아 있을까요? 그리고 에디터(Windows x64)에서 잘 돌던 프로젝트를 iOS 기기(ARM64)에 올리면 왜 같은 .dll 이 그대로 실행되지 않고 IL2CPP 같은 별도 파이프라인을 거쳐야 할까요?
이 장에서는 C# 소스가 CPU 명령어로 변할 때까지의 전체 여정을 따라가 보겠습니다. 이 흐름을 이해하면 다음과 같은 실전 문제에 분명한 답을 낼 수 있습니다.
- Mono 백엔드 빌드에서는 동작하는 리플렉션 코드가 IL2CPP 빌드에서만
ExecutionEngineException으로 죽는 이유 - iOS 배포 시 Apple이 금지하는 "동적 코드 생성"이 무엇이며 왜 Unity가 IL2CPP를 만들 수밖에 없었는지
- Release 빌드 첫 호출이 미세하게 느리다가 몇 초 뒤부터 갑자기 빨라지는 현상의 정체
Debug.Log("Score: " + score)한 줄이 GC 스파이크를 만드는 근본 원인
2. 개념 정의 — 두 번 컴파일되는 언어
2.1 공장 비유
C# 빌드 파이프라인은 컨테이너 물류 시스템과 비슷합니다. 공장(개발자 머신)에서는 물건을 규격화된 컨테이너(IL) 에 담기만 합니다. 배송 트럭이 한국식·미국식·유럽식으로 다 다른데 공장이 일일이 그 규격에 맞춰 포장하지 않습니다. 대신 도착지 항만(런타임)에서 하역할 때 현지 트럭에 맞게 풀어 옮겨 담습니다.
C++이 "출발지에서 아예 목적지 규격에 맞춰 포장하는" 방식이라면, C#은 "표준 컨테이너로 실어 보내고 도착지에서 개봉한다"는 방식입니다. 덕분에 같은 컨테이너(같은 .dll) 하나로 Windows·Linux·macOS 어디서든 동작할 수 있습니다.
2.2 파이프라인 전체 그림

핵심은 점선으로 표시한 배포 지점입니다. 빌드 타임과 런타임이 딱 한 번 만나는 지점은 "IL이 담긴 어셈블리"뿐이고, 그 위쪽은 개발자의 머신, 아래쪽은 사용자의 실행 환경입니다.
CLR — Common Language Runtime .NET 프로그램의 실행 엔진입니다. 어셈블리 로드, 타입 안전성 검증, JIT 컴파일, 가비지 컬렉터(GC) 등을 한 묶음으로 관리합니다. Unity에서는 CoreCLR 대신 Mono 런타임 또는 IL2CPP 런타임 이 그 자리를 대신합니다.
2.3 기본 C# 코드 — IL이 정말 "중간 단계"인지 확인
아래 메서드를 빌드하고 실제로 무엇이 생성되는지 보겠습니다.
public class Calculator
{
public int Sum(int limit)
{
int total = 0;
for (int i = 0; i < limit; i++)
{
total += i;
}
return total;
}
}
Roslyn으로 빌드하면 .dll 이 생기고, 그 안에 다음 IL이 들어 있습니다. Release 최적화된 실제 IL을 ilspycmd 로 추출한 결과입니다.
.method public hidebysig
instance int32 Sum (
int32 limit
) cil managed
{
.maxstack 2
.locals init (
[0] int32, // total
[1] int32 // i
)
IL_0000: ldc.i4.0 // 상수 0을 스택에
IL_0001: stloc.0 // total = 0
IL_0002: ldc.i4.0 // 상수 0을 스택에
IL_0003: stloc.1 // i = 0
IL_0004: br.s IL_000e // 루프 조건 검사로 점프
// loop start
IL_0006: ldloc.0 // total
IL_0007: ldloc.1 // i
IL_0008: add // total + i
IL_0009: stloc.0 // total = total + i
IL_000a: ldloc.1 // i
IL_000b: ldc.i4.1 // 1
IL_000c: add // i + 1
IL_000d: stloc.1 // i = i + 1
IL_000e: ldloc.1 // i
IL_000f: ldarg.1 // limit
IL_0010: blt.s IL_0006 // i < limit 이면 루프 시작으로
// end loop
IL_0012: ldloc.0 // total
IL_0013: ret // return total
} // end of method Calculator::Sum
주목해야 할 점은 이 IL 안에 x86·ARM 명령어가 하나도 없다는 것입니다. add, ldloc, br.s 같은 가상 기계(스택 머신)용 명령어만 있습니다. CPU는 이걸 직접 실행하지 못합니다. JIT이 런타임에 이 IL을 읽어서 현재 CPU에 맞는 mov eax, ..., add eax, ... 같은 명령어로 번역해야만 비로소 실행됩니다.
IL — Intermediate Language (CIL/MSIL과 동의어) 스택 기반 가상 머신을 위한 중간 언어입니다. ECMA-335 표준으로 공개되어 있고, 특정 CPU에 종속되지 않습니다.ldloc(지역변수 로드),stloc(저장),call(정적 호출),callvirt(가상 호출),newobj(객체 할당),box/unbox(박싱) 같은 명령어로 구성됩니다.
쉽게 말하면: Roslyn은 "CPU가 뭘 할지"가 아니라 "의미상 뭘 해야 하는지"만 IL에 적어둡니다. 진짜 CPU 명령어로의 변환은 런타임에 미룹니다.
기술적으로 말하면: C# 컴파일러는 C# 소스를 ECMA-335 표준 IL로 방출(emit)하여 PE(Portable Executable) 형식의 어셈블리에 IL 스트림과 타입 메타데이터를 함께 저장합니다.
3. 내부 동작 — Roslyn · JIT · AOT 각 단계가 실제로 하는 일
3.1 Roslyn 컴파일러 내부

가장 재미있는 단계는 Lowering(탈당화)입니다. async/await, yield return, 람다, foreach, using 같은 문법은 IL 명령어로 존재하지 않습니다. Roslyn이 이들을 일반 if/goto/클래스/메서드로 풀어쓴 뒤에야 IL을 방출합니다. 그래서 IL만 봐도 "아, 이 메서드는 async였구나"라고 복원할 수 있는 경우가 많습니다.
3.2 IL → 네이티브 — JIT 컴파일러가 호출되는 순간
메서드가 처음 호출될 때 무슨 일이 일어나는지 그림으로 보겠습니다.

JIT — Just-In-Time 컴파일 메서드 단위로 "필요한 순간에" IL을 네이티브 기계어로 번역하는 방식입니다. 프로그램 시작 시 전체를 컴파일하지 않고 호출이 일어난 것만 변환하므로, 쓰지 않는 코드는 끝까지 기계어가 되지 않은 채 남아 있을 수 있습니다.
CoreCLR의 현재 JIT 이름은 RyuJIT 이고, .NET 8부터는 티어드 컴파일(Tiered Compilation) 과 Dynamic PGO(Profile-Guided Optimization) 가 기본 활성화되어 있습니다.
- Tier 0: 최적화를 거의 하지 않고 빠르게 IL → 네이티브. 앱 시작 속도 우선.
- Tier 1: Tier 0 코드가 일정 횟수 이상 호출되면(= 핫패스로 판정되면) 백그라운드에서 인라이닝·가상 호출 devirtualization 같은 고급 최적화를 적용해 재컴파일.
- OSR(On-Stack Replacement): 한 메서드 안의 긴 루프가 Tier 0에서 돌고 있어도, 루프가 끝나기 전에 Tier 1 코드로 "현장 교체"가 가능합니다.
이 덕분에 "첫 호출만 느리다", "첫 몇 초 동안은 느리다가 갑자기 빨라진다" 같은 관찰이 설명됩니다. 핫패스가 Tier 1으로 올라간 순간부터 프로그램이 한 단계 더 빨라지는 것입니다.
3.3 AOT — JIT의 반대말
AOT는 Ahead-Of-Time 컴파일 의 약자로, 실행 전 빌드 타임에 모든 IL을 미리 네이티브 기계어로 바꿔두는 방식입니다.
| 기술 | 시점 | 특징 |
|---|---|---|
| NGen (구형) | 설치 시 | .NET Framework 전용. 런타임 의존 여전히 있음 |
| ReadyToRun(R2R) | 빌드 시 | IL과 네이티브 코드가 함께 존재. 런타임에 JIT가 재최적화 가능 |
| .NET Native AOT | 빌드 시 | 완전한 단일 네이티브 실행 파일. 리플렉션·동적 코드 생성 제한 |
| Mono AOT | 빌드 시 | Xamarin iOS 등에서 사용되는 고전적 AOT |
| Unity IL2CPP | 빌드 시 | Unity 전용. IL → C++ → 네이티브 |
AOT — Ahead-Of-Time 컴파일 실행 전에 미리 네이티브 코드를 만들어 두는 방식입니다. JIT과 달리 런타임에 "CPU가 실제로 어떤 모델인지", "어떤 코드가 자주 실행되는지" 같은 정보를 쓸 수 없지만, 시작이 즉시 빠르고 JIT 컴파일러 자체의 메모리 비용이 없습니다. iOS처럼 동적 코드 생성이 금지된 플랫폼에서는 AOT가 유일한 선택지입니다.
쉽게 말하면: JIT은 "뷔페에서 필요할 때마다 덜어 먹는 방식", AOT는 "도시락을 미리 다 싸두는 방식"입니다. 도시락은 바로 꺼내 먹을 수 있지만, 먹기 전에 메뉴를 바꿀 수 없습니다.
4. 실전 적용 — Unity IL2CPP와 Mono, 그리고 성능 관찰
4.1 Unity의 두 가지 스크립팅 백엔드

Unity의 두 백엔드는 같은 IL 까지만 똑같고, 그 뒤부터 완전히 다른 길을 갑니다.
- Mono 백엔드: 생성된 IL을 Mono 런타임이 로드해 JIT으로 실행합니다. 빌드가 빠르고 에디터에서도 같은 런타임이 돌기 때문에 반복 개발에 유리합니다.
- IL2CPP 백엔드: 생성된 IL을
il2cpp트랜스파일러가 C++ 소스로 변환하고, 그 C++을 Xcode의 Clang(iOS)·Android NDK의 Clang(Android)·MSVC(Windows) 같은 네이티브 컴파일러가 최종 기계어로 빌드합니다. 결과물은 일반 네이티브 앱과 구분할 수 없을 정도로 순수한 네이티브 바이너리입니다.
iOS에서 IL2CPP가 필수인 이유는 Apple이 동적 코드 생성을 금지하기 때문입니다. JIT은 런타임에 실행 가능한 메모리에 기계어를 "쓰고 실행"하는데, iOS는 W^X(Write xor Execute) 정책으로 이를 차단합니다. 그래서 iOS 빌드는 반드시 AOT, 즉 IL2CPP 경로를 거쳐야 합니다.
4.2 Before/After — 문자열 결합에서 숨은 박싱 관찰
JIT이든 IL2CPP든, 컴파일러가 결국 같은 IL을 본다는 사실은 실전에서 "C# 표현식 한 줄이 IL에서 얼마나 많은 작업으로 풀리는가" 를 보는 기준이 됩니다. 모바일 핫패스에서 흔히 문제가 되는 문자열 결합 예를 봅니다.
Unity에서 이런 상황이 생깁니다 — Update() 루프 안에서 점수 표시를 문자열로 만드는 코드입니다.
❌ Before — "문자열" + int 결합
public class ConcatSample
{
public string Bad(int score)
{
return "Score: " + score;
}
}
이 코드는 겉보기엔 단순하지만 실제 IL은 다음과 같습니다.
.method public hidebysig
instance string Bad (
int32 score
) cil managed
{
IL_0000: ldstr "Score: " // 문자열 리터럴 로드
IL_0005: ldarga.s score // score 주소 로드
IL_0007: call instance string [System.Runtime]System.Int32::ToString()
// int.ToString() 호출 → string 생성
IL_000c: call string [System.Runtime]System.String::Concat(string, string)
// 두 문자열 합치기 → 또 한 번 string 할당
IL_0011: ret
}
✅ After — 인터폴레이션 (DefaultInterpolatedStringHandler)
public string Good(int score)
{
return $"Score: {score}";
}
같은 결과를 내지만 IL은 완전히 다른 경로를 탑니다.
.method public hidebysig
instance string Good (
int32 score
) cil managed
{
.locals init (
[0] valuetype System.Runtime.CompilerServices.DefaultInterpolatedStringHandler
)
IL_0000: ldloca.s 0 // 핸들러(struct) 주소
IL_0002: ldc.i4.7 // 리터럴 길이 힌트
IL_0003: ldc.i4.1 // 포맷 개수
IL_0004: call instance void DefaultInterpolatedStringHandler::.ctor(int32, int32)
IL_0009: ldloca.s 0
IL_000b: ldstr "Score: "
IL_0010: call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0015: ldloca.s 0
IL_0017: ldarg.1
IL_0018: call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
// 제네릭 메서드 — int 전용 버전 생성, 박싱 없음
IL_001d: ldloca.s 0
IL_001f: call instance string DefaultInterpolatedStringHandler::ToStringAndClear()
IL_0024: ret
}
두 IL의 핵심 차이는 AppendFormatted<int32>(!!0) 입니다.
- Before는
int.ToString()호출 후String.Concat(string, string)을 쓰므로 중간 문자열 + 최종 문자열 총 2번 힙 할당입니다. - After는 값 타입 구조체 인
DefaultInterpolatedStringHandler를 스택에 두고(ldloca.s) 거기에 제네릭AppendFormatted<int>로 값을 직접 쓰기 때문에,int가object로 박싱되지 않고 중간 문자열 할당도 사라집니다. 최종 문자열만 한 번 힙에 할당됩니다.
Mono GC든 Unity의 Boehm GC든, 할당이 줄면 곧바로 GC 스파이크 빈도가 줄어듭니다. Update() 처럼 60/120 fps로 호출되는 핫패스에서는 이런 한 줄 차이가 눈에 띄는 프레임 드랍으로 나타납니다.
4.3 박싱 — int 하나가 힙으로 가는 IL
boxing이라는 용어가 처음 등장했으니 정확히 짚고 갑니다.
❌ Before — 값 타입을 object로 대입
public class BoxingSample
{
public void Bad()
{
int value = 42;
object obj = value; // 박싱
int unboxed = (int)obj; // 언박싱
}
}
Debug 빌드에서 추출한 실제 IL입니다.
.method public hidebysig
instance void Bad () cil managed
{
IL_0000: ldc.i4.s 42
IL_0002: box [System.Runtime]System.Int32 // 힙에 int 래퍼 객체 할당
IL_0007: unbox.any [System.Runtime]System.Int32 // 래퍼를 풀어 값 꺼내기
IL_000c: pop
IL_000d: ret
}
box 명령어 한 줄이 바로 GC 압박의 발원지입니다. int 는 값 타입(보통 스택)이지만 object 변수에 담는 순간 CLR이 힙에 "박스"라는 이름의 작은 래퍼 객체를 할당해서 값을 복사해 넣습니다.
box · unbox.any — 값 타입 박싱 ILbox는 스택 위의 값 타입을 힙에 할당된 래퍼로 감싸 참조를 반환합니다.unbox.any는 그 래퍼에서 값을 꺼내 스택에 올립니다. 박싱은 IL 한 줄이지만 런타임에는 힙 할당·복사·GC 추적이 동반됩니다.
✅ After — 값 그대로 유지
public void Good()
{
int value = 42;
int copy = value; // 값 복사, 박싱 없음
}
이 코드는 Release 최적화 시 IL이 거의 비워집니다(결과를 쓰지 않는 죽은 코드로 판정). 핵심은 어디에도 box 명령어가 생기지 않는다는 점입니다. 값 타입을 값 타입 변수에 대입하는 한, 힙 할당은 0건입니다.
5. 함정과 주의사항
5.1 Mono에서는 되는데 IL2CPP에서 죽는 제네릭 리플렉션
Unity 신입이 가장 당황하는 지점입니다. 에디터(Mono)에서 잘 돌던 코드가 iOS 실기(IL2CPP) 빌드에서만 ExecutionEngineException: Attempting to call method ... 으로 터집니다.
// ❌ Before — IL2CPP에서 터질 수 있는 코드
public object CreateList(Type elementType)
{
var listType = typeof(List<>).MakeGenericType(elementType); // AOT에서 위험
return Activator.CreateInstance(listType);
}
문제는 IL2CPP가 AOT이기 때문입니다. AOT 컴파일러는 빌드 타임에 "어떤 제네릭 조합이 실제로 쓰이는지"를 보고 그 조합만 네이티브 코드로 미리 만들어둡니다. 런타임에 MakeGenericType(typeof(EnemyData)) 로 처음 보는 조합을 요청하면, IL2CPP에게는 해당 네이티브 코드가 아예 존재하지 않아 예외가 터집니다.
// ✅ After — AOT가 인스턴스화를 보장하도록 힌트 제공
public static class AotHint
{
static void PreserveTypes()
{
new List<EnemyData>(); // 빌드 타임에 List<EnemyData> 가 쓰인다는 증거
new List<ItemData>();
}
}
public object CreateList<T>() where T : new()
{
return new List<T>(); // 제네릭 메서드도 호출부에서 타입이 정해져야 함
}
IL2CPP는 실제로 호출되거나 참조되는 제네릭 조합만 코드 생성합니다. 리플렉션으로 간접적으로 만들어지는 조합은 보이지 않으므로, 힌트 역할의 더미 메서드나 link.xml 로 보존 대상을 명시해야 합니다.
5.2 "Debug.Log 는 릴리스에서 공짜"라는 착각
// ❌ Before — 인자가 boxing 되는 로그
void Update()
{
int hp = 100;
Debug.Log("HP: " + hp); // Release에서도 문자열 결합과 ToString은 먼저 실행됨
}
Unity에서 Debug.Log 는 릴리스 빌드에서도 호출 자체가 살아 있습니다(조건부 컴파일이 아닙니다). 즉, 위 라인의 인자 "HP: " + hp 는 Debug.Log 진입 전에 이미 완성되어야 합니다. IL 레벨에서는 앞서 본 String.Concat 경로가 그대로 실행되어 힙 할당이 발생합니다.
// ✅ After — 로그 자체를 조건부로
[System.Diagnostics.Conditional("UNITY_EDITOR")]
static void DebugLog(string msg) => UnityEngine.Debug.Log(msg);
void Update()
{
int hp = 100;
DebugLog($"HP: {hp}"); // 릴리스에서는 호출 자체가 사라짐
}
[Conditional] 어트리뷰트가 붙은 메서드는 해당 심볼이 정의되지 않은 빌드에서 호출 자체가 IL에서 제거 됩니다. 그러면 인자 평가도 사라지므로 문자열 결합 비용이 사라집니다.
5.3 "Release 면 코드가 그대로다"라는 착각
// Release Calculator.Sum 의 IL은 이전 2.3 에서 본 그대로
// 하지만 같은 코드여도 JIT 레벨에서는 Tier 1 최적화 후 완전히 다른 어셈블리가 됨
IL은 Release/Debug에 따라 모양이 약간 다릅니다(Debug은 NOP 삽입, 변수 수명 연장 등). 하지만 같은 IL이라도 JIT이 Tier 0에서 만든 네이티브와 Tier 1에서 만든 네이티브는 완전히 다른 코드 입니다. 따라서 성능 측정을 할 때는 다음을 기억해야 합니다.
- 워밍업 없이 잰 "첫 호출" 수치는 Tier 0이거나 JIT 시간을 포함합니다. 의미 있는 비교가 아닙니다.
- Unity IL2CPP 빌드는 JIT이 없으므로 티어드 최적화도 없습니다. 그래서 프로파일링은 반드시 실기 IL2CPP 빌드에서 해야 실전 수치가 나옵니다.
6. C# 버전별 변화
C# 컴파일 파이프라인 자체는 C# 언어 버전과 독립된 .NET 런타임/툴체인의 영역이라 "C# 언어 버전"마다 큰 변화가 있지는 않습니다. 다만 컴파일러(Roslyn)와 런타임이 함께 진화해 온 주요 이정표를 정리합니다.
| 시점 | 변화 | 개발자 관점의 의미 |
|---|---|---|
| C# 1.0 / .NET 1.0 | IL + JIT 도입 | 언어 상호운용·플랫폼 독립성 확보 |
| .NET Framework 3.5 시대 | NGen (설치 시 AOT) | 일부 시작 속도 개선, 지금은 레거시 |
| C# 6 / Roslyn (2015) | 컴파일러 전면 재작성 | 공개 API, IDE·분석기·코드 수정 플러그인 생태계 |
| C# 7 이후 | ref 반환·Span<T>·인터폴레이션 핸들러 |
IL 수준에서 박싱·복사 줄이는 기능 추가 |
| .NET Core 3.0 | ReadyToRun (빌드 타임 AOT + 런타임 JIT 공존) | 대규모 서버 앱 시작 속도 개선 |
| .NET 6 | Dynamic PGO (옵트인) | 런타임 프로파일 기반 최적화 |
| .NET 7 | Native AOT 정식 출시 | 리플렉션 제약 대신 즉시 실행·작은 바이너리 |
| .NET 8 / 9 | Dynamic PGO · Tiered Compilation 기본 활성화 | 워밍업 후 성능이 자동으로 한 단계 올라감 |
| Unity 2018+ | IL2CPP 기본 백엔드화 (모바일·콘솔) | C# 코드가 사실상 C++ 로 배포됨 |
Before/After IL 비교가 유의미한 버전 단위 변화는 C# 10의 인터폴레이션 핸들러 처럼 컴파일러가 IL 방출 방식을 바꿔 할당을 줄인 경우입니다. 앞 4.2 섹션의 Good IL이 바로 그 예입니다 — 동일한 $"..." 구문이 과거에는 string.Format 호출로 박싱을 유발했지만, 현재 컴파일러는 DefaultInterpolatedStringHandler 경로로 바꿔 박싱을 없앴습니다.
7. 정리
- C# 빌드는 두 번의 컴파일 로 구성된다. 빌드 타임(Roslyn → IL)과 런타임(JIT 또는 사전 AOT → 네이티브).
- IL 은 CPU 독립적인 스택 기반 중간 언어. 플랫폼 이식성·언어 상호운용·타입 안전 검증을 동시에 확보하기 위한 장치.
- JIT(RyuJIT) 은 메서드가 처음 호출될 때 IL → 네이티브로 변환하고 캐시한다. Tier 0 빠른 컴파일 → Tier 1 최적화 재컴파일 단계로 동작.
- AOT 는 빌드 타임에 네이티브를 완성하는 방식. 시작이 즉시 빠르지만 런타임 프로파일 최적화와 일부 동적 기능을 포기한다.
- Unity Mono 는 JIT, Unity IL2CPP 는 IL → C++ → 네이티브의 AOT다. iOS·콘솔이 JIT을 금지하기 때문에 IL2CPP가 필수.
- IL을 읽는 습관은 박싱·중간 할당·리플렉션 사용처 를 눈으로 확인하는 가장 빠른 도구다.
$"..."와"..." + x의 IL 차이처럼, 한 줄 코드가 힙에 몇 번 찍히는지 IL이 그대로 말해준다.
핵심 체크리스트
- [ ]
.dll안에는 기계어가 아니라 IL이 들어 있다는 사실을 이해한다. - [ ] JIT의 Tier 0/Tier 1 차이와 "첫 호출만 느리다"가 왜 일어나는지 설명할 수 있다.
- [ ] JIT과 AOT의 장단점을 상황(서버/모바일/iOS)에 맞게 선택할 수 있다.
- [ ] Unity Mono vs IL2CPP 의 파이프라인 차이를 그릴 수 있다.
- [ ] IL2CPP가 제네릭 리플렉션을 왜 싫어하는지 설명할 수 있다.
- [ ]
box/unbox.anyIL 명령어를 보고 힙 할당이 있는지 판단할 수 있다. - [ ] Release 빌드·Tier 1·IL2CPP 각각에서 측정값이 달라질 수 있음을 전제로 성능을 비교한다.