반응형

[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 파이프라인 전체 그림

C# 빌드·실행 파이프라인

핵심은 점선으로 표시한 배포 지점입니다. 빌드 타임과 런타임이 딱 한 번 만나는 지점은 "IL이 담긴 어셈블리"뿐이고, 그 위쪽은 개발자의 머신, 아래쪽은 사용자의 실행 환경입니다.

CLR — Common Language Runtime .NET 프로그램의 실행 엔진입니다. 어셈블리 로드, 타입 안전성 검증, JIT 컴파일, 가비지 컬렉터(GC) 등을 한 묶음으로 관리합니다. Unity에서는 CoreCLR 대신 Mono 런타임 또는 IL2CPP 런타임 이 그 자리를 대신합니다.

2.3 기본 C# 코드 — IL이 정말 "중간 단계"인지 확인

아래 메서드를 빌드하고 실제로 무엇이 생성되는지 보겠습니다.

C#
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 로 추출한 결과입니다.

IL
.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 컴파일러 내부

Roslyn 컴파일러의 5단계

가장 재미있는 단계는 Lowering(탈당화)입니다. async/await, yield return, 람다, foreach, using 같은 문법은 IL 명령어로 존재하지 않습니다. Roslyn이 이들을 일반 if/goto/클래스/메서드로 풀어쓴 뒤에야 IL을 방출합니다. 그래서 IL만 봐도 "아, 이 메서드는 async였구나"라고 복원할 수 있는 경우가 많습니다.

3.2 IL → 네이티브 — JIT 컴파일러가 호출되는 순간

메서드가 처음 호출될 때 무슨 일이 일어나는지 그림으로 보겠습니다.

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: Mono(JIT) vs IL2CPP(AOT)

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 결합

C#
public class ConcatSample
{
    public string Bad(int score)
    {
        return "Score: " + score;
    }
}

이 코드는 겉보기엔 단순하지만 실제 IL은 다음과 같습니다.

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)

C#
public string Good(int score)
{
    return $"Score: {score}";
}

같은 결과를 내지만 IL은 완전히 다른 경로를 탑니다.

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> 로 값을 직접 쓰기 때문에, intobject로 박싱되지 않고 중간 문자열 할당도 사라집니다. 최종 문자열만 한 번 힙에 할당됩니다.

Mono GC든 Unity의 Boehm GC든, 할당이 줄면 곧바로 GC 스파이크 빈도가 줄어듭니다. Update() 처럼 60/120 fps로 호출되는 핫패스에서는 이런 한 줄 차이가 눈에 띄는 프레임 드랍으로 나타납니다.

4.3 박싱 — int 하나가 힙으로 가는 IL

boxing이라는 용어가 처음 등장했으니 정확히 짚고 갑니다.

❌ Before — 값 타입을 object로 대입

C#
public class BoxingSample
{
    public void Bad()
    {
        int value = 42;
        object obj = value;       // 박싱
        int unboxed = (int)obj;   // 언박싱
    }
}

Debug 빌드에서 추출한 실제 IL입니다.

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 — 값 타입 박싱 IL box는 스택 위의 값 타입을 힙에 할당된 래퍼로 감싸 참조를 반환합니다. unbox.any 는 그 래퍼에서 값을 꺼내 스택에 올립니다. 박싱은 IL 한 줄이지만 런타임에는 힙 할당·복사·GC 추적이 동반됩니다.

✅ After — 값 그대로 유지

C#
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 ... 으로 터집니다.

C#
// ❌ 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에게는 해당 네이티브 코드가 아예 존재하지 않아 예외가 터집니다.

C#
// ✅ 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 는 릴리스에서 공짜"라는 착각

C#
// ❌ Before — 인자가 boxing 되는 로그
void Update()
{
    int hp = 100;
    Debug.Log("HP: " + hp);  // Release에서도 문자열 결합과 ToString은 먼저 실행됨
}

Unity에서 Debug.Log 는 릴리스 빌드에서도 호출 자체가 살아 있습니다(조건부 컴파일이 아닙니다). 즉, 위 라인의 인자 "HP: " + hpDebug.Log 진입 전에 이미 완성되어야 합니다. IL 레벨에서는 앞서 본 String.Concat 경로가 그대로 실행되어 힙 할당이 발생합니다.

C#
// ✅ 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 면 코드가 그대로다"라는 착각

C#
// 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.any IL 명령어를 보고 힙 할당이 있는지 판단할 수 있다.
  • [ ] Release 빌드·Tier 1·IL2CPP 각각에서 측정값이 달라질 수 있음을 전제로 성능을 비교한다.
반응형

+ Recent posts