반응형

[PART1.C# 런타임과 .NET 플랫폼 기초(1/11)] .NET 플랫폼의 구조 — CLR · BCL · 어셈블리

CLR은 엔진, BCL은 부품 창고, 어셈블리는 완성된 차 한 대 — C# 코드가 실행되기까지의 전 과정을 한 번에 정리합니다.


1. 왜 .NET 구조를 알아야 하는가

Unity 프로젝트에서 List<Enemy> 하나를 new 했을 뿐인데, 빌드를 돌리면 갑자기 System.Collections.dll, mscorlib.dll, UnityEngine.CoreModule.dll 같은 파일이 따라붙습니다. Android로 빌드하면 IL2CPP 변환 에러가 뜨기도 하고, 에디터에서는 잘 되던 코드가 iOS에선 PlatformNotSupportedException을 던지기도 합니다.

이 현상들은 전부 "C# 코드가 어떻게 실제 기기에서 실행되는가"에 대한 이해가 없으면 모호하게 느껴집니다. 반대로 구조를 한 번 정리하면 아래 문제들이 전부 같은 그림 안에서 해석됩니다.

  • 빌드 시 빠지는 어셈블리가 있음 → 어셈블리 매니페스트와 참조 해석의 문제입니다.
  • Update()에서 GC 스파이크가 발생함 → CLR의 가비지 컬렉터(GC, Garbage Collector — 메모리를 자동으로 회수하는 런타임 구성요소)가 힙을 청소하는 순간입니다.
  • iOS 빌드에서 리플렉션이 안 됨 → JIT(Just-In-Time Compiler, 실행 시점에 IL을 기계어로 번역하는 컴파일러) 대신 AOT(Ahead-Of-Time Compiler, 빌드 시점에 미리 기계어로 번역하는 컴파일러)로 변환됐기 때문입니다.
  • .dll 안에 뭐가 들어 있는지 모름 → IL(Intermediate Language, 중간 언어) 코드와 메타데이터가 들어 있습니다.

이 글에서는 C# 소스 코드가 .dll이 되고, CLR(Common Language Runtime, .NET 프로그램의 실행을 관리하는 가상 머신)이 그것을 읽어 실행하기까지의 경로를 따라가며 CLR · BCL · 어셈블리라는 세 축을 한 번에 정리합니다. Unity 신입 개발자가 빌드·성능·플랫폼 문제를 맞닥뜨렸을 때 "어느 층에서 터진 문제인가"를 구분할 수 있도록 하는 것이 목표입니다.


2. .NET 플랫폼이란 무엇인가

2.1 비유 — 자동차 공장

.NET 플랫폼을 자동차 공장에 비유해 보겠습니다.

  • C#, F#, VB.NET 같은 언어는 설계도를 그리는 여러 팀입니다. 각 팀은 도면을 그리는 방식이 다르지만 최종 산출물은 같은 규격의 표준 부품(IL 코드)으로 맞춰 냅니다.
  • BCL(Base Class Library, 기본 클래스 라이브러리)은 이미 검증된 부품 창고입니다. 볼트·너트·엔진 블록·타이어 같은 공용 부품(List<T>, String, File, HttpClient 등)이 빼곡히 준비돼 있어 팀마다 바퀴를 다시 만들지 않아도 됩니다.
  • 어셈블리(Assembly)는 완성된 자동차 한 대입니다. 실행 가능한 단위(.exe)거나 재사용 가능한 모듈(.dll)이며, 안에는 부품 목록(매니페스트)·부품 사양서(메타데이터)·실제 동작 코드(IL)가 함께 담겨 있습니다.
  • CLR(Common Language Runtime)은 그 자동차가 달릴 수 있도록 해 주는 엔진 + 도로 관리인입니다. 실제 기기의 CPU에 맞게 IL을 기계어로 번역하고, 쓰지 않는 메모리를 자동으로 치우고, 타입이 섞이지 않도록 감시합니다.

이 네 역할이 맞물려야 C# 코드가 Windows·Linux·macOS·Android·iOS 어디서든 돕니다.

2.2 시각화 — 전체 구조 한 장

.NET 플랫폼 — 언어부터 하드웨어까지 언어 & 컴파일러 (C#, F#, VB.NET) 소스 코드(.cs) → IL 코드로 번역 어셈블리 (.dll / .exe) 매니페스트 · 메타데이터 · IL · 리소스 배포·버전 관리·보안의 단위 CLR (실행 엔진) JIT 컴파일 · GC 타입 안전 검증 · 예외 처리 스레드 · 어셈블리 로딩 BCL (공용 라이브러리) System.* · Collections · IO Threading · Linq · Text 모든 앱이 공통 참조 OS · CPU (Windows · Linux · macOS · Android · iOS)

2.3 예시 코드 — 한 줄 프로그램이 건드리는 층

C#
using System;

public class Program
{
    public static void Main()
    {
        int a = 10;
        int b = 20;
        int sum = a + b;
        Console.WriteLine(sum);
    }
}

이 9줄짜리 코드가 실제로 동작하려면 위 그림의 모든 층이 관여합니다. C# 컴파일러가 이 코드를 IL로 바꾸고, 빌드가 끝나면 .dll 한 개가 나옵니다. dotnet run으로 실행하면 CLR이 그 .dll을 읽고 Console 타입을 BCL 어셈블리에서 찾아 로드한 뒤, JIT이 IL을 현재 CPU에 맞는 기계어로 번역해 실행합니다. 뒤에서 이 과정을 IL 레벨로 확인하겠습니다.

2.4 쉬운 설명과 공식 정의

쉬운 설명: .NET 플랫폼은 "설계도를 공통 부품으로 바꾸고 → 완성된 묶음으로 포장해서 → 엔진 위에서 돌리는" 세 단계 공장입니다.

기술 정의: .NET 플랫폼은 ECMA-335 표준의 공통 타입 시스템(CTS, Common Type System)공통 언어 사양(CLS, Common Language Specification)을 기반으로 여러 언어가 하나의 런타임(CLR)과 공용 라이브러리(BCL) 위에서 상호 운용 가능하게 동작하도록 설계된 관리형 실행 환경입니다.


3. CLR — .NET의 실행 엔진

3.1 CLR이 하는 일

CLR은 .dll 안에 들어 있는 IL과 메타데이터를 읽어 실제로 동작하게 만드는 계층입니다. C# 개발자가 직접 호출하진 않지만, C# 코드가 돌아가는 동안 끊임없이 백그라운드에서 일합니다.

CLR의 다섯 가지 주요 역할

3.2 예시 코드 — 가장 단순한 C# 한 조각과 IL

앞서 본 Hello World 수준 코드를 IL로 내려다봅니다. 컴파일러가 실제로 어떤 IL을 만들어 냈는지 ilspycmd로 디컴파일한 결과입니다.

C#
using System;

public class Program
{
    public static void Main()
    {
        int a = 10;
        int b = 20;
        int sum = a + b;
        Console.WriteLine(sum);
    }
}
IL
.class public auto ansi beforefieldinit Program
    extends [System.Runtime]System.Object                  // Object는 System.Runtime 어셈블리에 있음
{
    .method public hidebysig static
        void Main () cil managed
    {
        .maxstack 2                                         // 평가 스택 최대 깊이 2
        .entrypoint                                         // 이 메서드가 프로그램 진입점
        .locals init (                                      // 지역 변수 3개 (a, b, sum)
            [0] int32,
            [1] int32,
            [2] int32
        )

        IL_0000: nop
        IL_0001: ldc.i4.s 10                                // 상수 10을 스택에 push
        IL_0003: stloc.0                                    // 지역 변수 0 (a) 에 저장
        IL_0004: ldc.i4.s 20                                // 상수 20을 스택에 push
        IL_0006: stloc.1                                    // 지역 변수 1 (b) 에 저장
        IL_0007: ldloc.0                                    // a를 스택에 로드
        IL_0008: ldloc.1                                    // b를 스택에 로드
        IL_0009: add                                        // 두 값 더함
        IL_000a: stloc.2                                    // sum 에 저장
        IL_000b: ldloc.2                                    // sum 을 스택에 로드
        IL_000c: call void [System.Console]System.Console::WriteLine(int32)   // BCL 호출 (System.Console 어셈블리)
        IL_0011: nop
        IL_0012: ret                                        // 메서드 종료
    }
}

3.3 쉬운 설명 — IL이 말하는 것

이 IL은 스택 머신(가상의 계산기) 명령어입니다. "10을 스택에 올리고 → 지역 변수에 저장하고 → 다시 꺼내 더하고 → Console.WriteLine으로 넘긴다"는 의미를 담고 있습니다. 여기서 C# 입문자 관점에서 주목할 점은 두 가지입니다.

  1. [System.Runtime]System.Object — 내 Program 클래스의 부모 Object내 dll이 아닌 System.Runtime이라는 다른 어셈블리에 있다고 적혀 있습니다.
  2. [System.Console]System.Console::WriteLineConsole.WriteLine도 내 dll이 아닌 System.Console 어셈블리에 있습니다.

이 대괄호 안의 이름이 바로 어셈블리 참조입니다. 내 프로그램은 내가 작성한 코드만으로 돌지 않고, 항상 System.Runtime·System.Console 같은 BCL 어셈블리에 의존합니다. CLR은 실행할 때 매니페스트에서 이 참조 목록을 읽어 필요한 어셈블리를 찾아 메모리에 올립니다.

3.4 IL 분석 포인트

1. call — 정적/비가상 메서드 호출

IL
IL_000c: call void [System.Console]System.Console::WriteLine(int32)

WriteLine(int32)Console 클래스의 정적 메서드이므로 call이 쓰였습니다. call은 호출할 메서드가 이미 확정되어 있어 가상 디스패치 오버헤드가 없습니다. Unity에서 static 유틸리티 메서드가 인스턴스 메서드보다 약간 빠른 이유가 여기에 있습니다.

2. .entrypoint — 진입점 메타데이터

.entrypoint 지시어가 이 메서드를 "프로그램이 시작될 때 호출할 Main"으로 표시합니다. CLR은 어셈블리 매니페스트에서 이 표식을 찾아 Main을 호출합니다.

3. 외부 어셈블리 참조가 IL 안에 박혀 있음

[System.Runtime], [System.Console] 같은 대괄호 표기가 곧 AssemblyRef 메타데이터 테이블의 항목입니다. CLR 로더가 실행 시점에 이 이름을 보고 해당 .dll을 찾아 로드합니다.


4. BCL — 공용 부품 창고

4.1 BCL이 무엇을 제공하는가

BCL은 "모든 .NET 프로그램이 공통으로 쓰는 타입과 기능"의 집합입니다. Unity 개발자가 매일 쓰는 아래 타입이 전부 BCL 소속입니다.

네임스페이스 대표 타입 용도
System Object, String, Int32, Exception 언어의 기본 타입
System.Collections.Generic List<T>, Dictionary<K,V>, HashSet<T> 컬렉션
System.IO File, Stream, Path 파일 입출력
System.Threading Thread, Task, CancellationToken 스레드·비동기
System.Linq Where, Select, ToList 쿼리 연산
System.Text StringBuilder, Encoding, Regex 문자열 조작

BCL이 없으면 string·List<int>조차 쓸 수 없습니다. C#의 string 키워드는 사실 System.String 타입의 별칭일 뿐이고, 실제 구현은 BCL 안에 있습니다.

4.2 시각화 — BCL 어셈블리가 쪼개져 있는 이유

BCL은 여러 어셈블리로 나뉘어 있다

.NET Framework 시절에는 BCL이 전부 mscorlib.dll 하나에 뭉쳐 있었습니다. .NET Core 이후에는 이를 잘게 나눠 필요한 것만 골라 쓸 수 있도록 바꿨습니다. 덕분에 서버 앱은 System.Windows.Forms를 포함하지 않아 배포 크기가 줄고, Unity에서는 쓰지 않는 어셈블리를 IL2CPP가 제거(strip)할 수 있습니다.

4.3 예시 코드 — BCL을 호출하면 IL에서 어떻게 보이는가

C#
using System;
using System.Collections.Generic;

public class Program
{
    public static void Main()
    {
        List<int> scores = new List<int>();
        scores.Add(100);
        scores.Add(85);
        Console.WriteLine(scores[0]);
    }
}
IL
.method public hidebysig static
    void Main () cil managed
{
    .maxstack 2
    .entrypoint
    .locals init (
        [0] class [System.Collections]System.Collections.Generic.List`1<int32>   // BCL 타입 선언
    )

    IL_0000: nop
    IL_0001: newobj instance void class [System.Collections]System.Collections.Generic.List`1<int32>::.ctor()  // List<int> 힙 할당
    IL_0006: stloc.0                                                                                           // 지역 변수에 참조 저장
    IL_0007: ldloc.0
    IL_0008: ldc.i4.s 100
    IL_000a: callvirt instance void class [System.Collections]System.Collections.Generic.List`1<int32>::Add(!0)  // 가상 호출로 Add
    IL_000f: nop
    IL_0010: ldloc.0
    IL_0011: ldc.i4.s 85
    IL_0013: callvirt instance void class [System.Collections]System.Collections.Generic.List`1<int32>::Add(!0)
    IL_0018: nop
    IL_0019: ldloc.0
    IL_001a: ldc.i4.0
    IL_001b: callvirt instance !0 class [System.Collections]System.Collections.Generic.List`1<int32>::get_Item(int32)  // 인덱서는 get_Item 호출
    IL_0020: call void [System.Console]System.Console::WriteLine(int32)
    IL_0025: nop
    IL_0026: ret
}

4.4 IL 분석 포인트

1. [System.Collections] 어셈블리 참조가 반복적으로 등장

List<T>를 쓰는 모든 호출 앞에 [System.Collections] 꼬리표가 붙습니다. 이 꼬리표는 "이 타입은 System.Collections.dll에 있으니 거기서 찾아 오라"는 CLR에 대한 지시입니다. 빌드 산출물을 열어 보면 실제로 System.Collections.dll이 앱 디렉터리에 포함돼 있거나(자체 포함 배포), 공용 런타임에 존재(프레임워크 종속 배포)합니다.

2. newobj — 힙 할당 발생

IL
IL_0001: newobj instance void class [System.Collections]System.Collections.Generic.List`1<int32>::.ctor()

newobj는 관리 힙(managed heap)에 객체를 할당합니다. new List<int>() 한 줄이 힙 할당 1회라는 뜻입니다. Update 루프에서 매 프레임 new List<int>()를 만들면 프레임당 1회 이상 힙 할당이 발생하고, 쌓이면 GC가 Gen0 수집을 시작해 스파이크가 납니다. Unity 실전에서는 재사용 가능한 List를 한 번 만들고 Clear()로 비워 쓰는 패턴이 정석입니다.

3. callvirt — 가상 디스패치

IL
IL_000a: callvirt instance void ...List`1<int32>::Add(!0)

List<T>.Add는 인스턴스 메서드이므로 callvirt가 쓰였습니다. callvirt는 null 체크와 가상 테이블 조회 비용이 call보다 살짝 큽니다. 핫패스에서는 struct 메서드 호출(call)이나 정적 메서드 호출이 더 빠른 이유가 여기에 있습니다.

4. 인덱서 scores[0]은 실제로 get_Item(0) 호출

[] 접근이 IL에서는 일반 메서드 호출로 바뀌어 있습니다. 프로퍼티·이벤트·인덱서는 C# 컴파일러가 메서드로 풀어 낸다는 것이 IL에서 바로 확인됩니다.


5. 어셈블리 — 배포의 단위

5.1 어셈블리란 무엇인가

C# 프로젝트를 빌드하면 bin/Debug/net10.0/MyApp.dll 같은 파일이 생깁니다. 이 .dll어셈블리입니다. 어셈블리는 단순한 바이너리가 아니라, 자기 자신을 설명하는 자기 완결적(self-describing) 파일입니다.

어셈블리 안에는 네 가지가 들어 있습니다.

구성 요소 설명 어디에 쓰이는가
매니페스트(Manifest) 어셈블리 이름·버전·공개 키·참조 어셈블리 목록 CLR이 식별·로드할 때
메타데이터(Metadata) 내부 타입·멤버·시그니처 설명 리플렉션, JIT, 디버거
IL 코드 실제 메서드 본문 JIT이 네이티브로 번역
리소스 이미지·문자열 테이블 등 런타임 참조
newobj — 객체 생성 IL 명령어 관리 힙에 새 객체를 할당하고 생성자를 호출한다. C#의 new 키워드가 참조 타입에 대해 쓰이면 대부분 이 명령어로 컴파일된다.
예시: new List<int>() → IL에서 newobj ...List::.ctor() 한 줄. 힙 할당 횟수를 IL에서 세고 싶으면 newobj 개수를 세면 된다.

5.2 시각화 — 어셈블리 내부 구조

MyApp.dll — 어셈블리 내부

5.3 예시 — 매니페스트 안에는 뭐가 있는가

앞서 분석한 IL 출력을 보면 .assembly extern 같은 줄은 생략되어 있었지만, 실제 .dllildasm으로 열면 매니페스트에 아래 같은 내용이 나옵니다(개념 표현).

IL
.assembly extern System.Runtime { .ver 10:0:0:0 }          // 이 앱은 System.Runtime v10을 요구
.assembly extern System.Console { .ver 10:0:0:0 }          // System.Console v10 요구
.assembly extern System.Collections { .ver 10:0:0:0 }      // System.Collections v10 요구

.assembly MyApp
{
    .hash algorithm 0x00008004
    .ver 1:0:0:0                                           // 내 어셈블리 버전
}

이 매니페스트가 있기 때문에 CLR은 MyApp.dll을 실행할 때 "아, 이 앱은 System.Runtime v10, System.Console v10이 필요하구나"를 알고 해당 어셈블리를 찾아 올립니다. 참조된 어셈블리를 찾지 못하면 FileNotFoundException 또는 TypeLoadException이 발생합니다. Unity에서 빌드 시 가끔 보는 "missing assembly reference" 에러가 이 메커니즘에서 비롯됩니다.

5.4 쉬운 설명 — 매니페스트와 메타데이터의 차이

  • 매니페스트는 "이 어셈블리 자체에 관한 정보" — 이름·버전·서명·의존성 목록.
  • 메타데이터는 "이 어셈블리가 품고 있는 타입들에 관한 정보" — class ProgramMain이라는 메서드가 있고, 반환형은 void이며, 매개변수는 없다.

둘은 역할이 다르고 저장 영역도 다르지만, 둘 다 있기에 C#은 헤더 파일이나 레지스트리 등록 없이도 라이브러리를 공유할 수 있습니다.


6. 소스 → IL → 네이티브 — 전체 흐름

6.1 두 번의 컴파일

C# 코드가 실제 기기에서 도는 데에는 컴파일이 두 번 일어납니다.

C# 소스부터 네이티브 실행까지의 두 단계

6.2 JIT이 어떻게 동작하는가

CLR의 JIT 컴파일러는 "최초 호출 시점"에 그 메서드의 IL을 네이티브로 번역합니다. 두 번째 호출부터는 이미 번역된 기계어로 바로 점프하므로 오버헤드가 없습니다.

  1. 프로그램 시작 → CLR이 매니페스트의 .entrypoint를 찾아 Main 진입.
  2. Main 내부에서 처음 만나는 타입 참조(예: Console)에 대해 CLR이 System.Console.dll을 로드.
  3. 해당 메서드의 IL을 JIT이 네이티브로 번역 → 메모리 캐시에 저장 → 실행.
  4. 같은 메서드가 두 번째 호출되면 캐시된 네이티브 코드로 직행.

6.3 JIT vs AOT — 두 전략의 트레이드오프

항목 JIT AOT
번역 시점 실행 시점(메서드 첫 호출) 빌드 시점
시작 속도 상대적으로 느림 빠름
실행 속도 워밍업 후 높은 최적화 가능 일관되지만 런타임 프로파일 기반 최적화는 약함
리플렉션·동적 코드 자유롭게 허용 제약 많음(동적 생성 금지)
바이너리 크기 작음 커짐(모든 메서드 네이티브 포함)
대표 사용처 서버·데스크톱 .NET 앱, Unity Mono iOS(필수), WebAssembly, Unity IL2CPP

.NET 7 이후 등장한 Native AOT는 서버 앱에서도 시작 속도를 극단적으로 줄이고 싶을 때 선택할 수 있는 옵션입니다. 반면 Unity는 플랫폼별 상황에 따라 선택이 달라집니다.

6.4 Tiered Compilation과 ReadyToRun

현대 CLR은 "빠른 시작"과 "높은 최적화"를 둘 다 얻기 위해 계층형 컴파일을 씁니다.

  • Tier 0: 메서드 최초 호출 시 최소 최적화로 빠르게 JIT → 시작 속도 확보.
  • Tier 1: 자주 호출되는 메서드가 감지되면 백그라운드에서 고성능 재컴파일 → 장기 성능 확보.
  • ReadyToRun(R2R): 빌드 시점에 어셈블리 일부를 미리 네이티브로 "스냅샷"해 둬서 Tier 0의 JIT 비용조차 줄입니다. crossgen2 도구가 이 작업을 합니다.

Unity 개발자는 이 설정을 직접 만지지 않지만, 게임 엔진이 선택한 전략(예: IL2CPP는 완전 AOT, Mono는 JIT)을 이해하면 "왜 리플렉션이 iOS에서 더 위험한가"가 바로 연결됩니다.


7. AppDomain vs AssemblyLoadContext — 격리의 진화

7.1 왜 격리가 필요한가

하나의 프로세스 안에서 플러그인·모드·DLC 같은 외부 어셈블리를 로드했다가 해제해야 하는 상황이 있습니다. 그냥 로드하면 같은 이름·다른 버전의 어셈블리가 충돌하거나, 언로드가 안 돼서 메모리가 쌓입니다.

7.2 두 모델의 차이

항목 AppDomain (.NET Framework) AssemblyLoadContext (.NET Core+)
개념 프로세스 내부의 논리적 샌드박스 어셈블리 로딩 경계
격리 수준 메모리·타입·보안 전체 격리 주로 로딩 그래프 격리
언로드 도메인 단위로 통째 언로드 컬렉터블 컨텍스트만 언로드
크로스 플랫폼 Windows 중심 크로스 플랫폼
현재 상태 .NET 5+에서는 제한적 지원(단일 도메인) 표준 메커니즘

.NET Core 이후 기본 전략은 AssemblyLoadContext입니다. 플러그인 시스템을 만들고 싶으면 AssemblyLoadContext(isCollectible: true)를 사용해 별도 컨텍스트에 로드하고, 더 이상 쓸 필요가 없으면 Unload()로 메모리에서 해제할 수 있습니다.

7.3 쉬운 설명

AppDomain은 "건물 안에 벽을 세워 방을 나누는" 방식이었다면, AssemblyLoadContext는 "책장마다 구획을 나눠 책을 꽂는" 방식입니다. 격리의 강도는 더 낮지만, 크로스 플랫폼에서 더 가볍고 관리 비용이 적습니다.

Unity에서는 이 레벨의 격리가 표면에 드러나지 않지만, 에디터의 "Reload Assemblies" 과정이 사실 비슷한 문제(스크립트 컴파일 후 기존 타입 해제)를 풀고 있습니다.


8. .NET Framework · .NET Core · .NET 5+ — 세 갈래의 역사

8.1 각 세대의 성격

항목 .NET Framework .NET Core .NET 5+
출시 시점 2002 2016 2020
플랫폼 Windows 전용 Windows · Linux · macOS 통합 크로스 플랫폼
BCL 배포 mscorlib.dll 하나 잘게 쪼갠 다수 어셈블리 .NET Core 방식 계승
배포 모델 시스템 설치형 프레임워크 종속 또는 자체 포함 프레임워크 종속 · 자체 포함 · Native AOT
버전 정책 Windows Update에 종속 앱 단위 버전 앱 단위 버전
신규 개발 권장 ❌ 레거시 ❌ .NET 5+ 로 통합됨 ✅ 현재 표준

8.2 통합의 의미

.NET 5부터는 "Framework/Core/Xamarin/Mono"라는 별개 플랫폼이 한 줄기로 합쳐졌습니다. 이름에서 "Core"가 빠진 이유는 "이제 이게 기본"이라는 선언입니다. 현업 C# 개발자가 새 프로젝트를 만든다면 별 고민 없이 최신 LTS(.NET 8, .NET 10 등)를 씁니다.

Unity는 별도의 스케줄로 런타임을 갱신하기 때문에, 2024~2025 기준 Unity 6 LTS에서도 BCL이 .NET Standard 2.1 수준에 머물고 있습니다. Unity에서 최신 C# 기능을 쓰려면 엔진 버전과 "API Compatibility Level" 설정을 항상 확인해야 합니다.


9. Unity에서의 .NET — Mono와 IL2CPP

9.1 두 백엔드의 역할

Unity는 엔진 내부에 자체 .NET 런타임을 탑재합니다. 프로젝트 설정의 Scripting Backend 옵션에 따라 두 가지 중 하나가 선택됩니다.

항목 Mono IL2CPP
방식 JIT 기반 Mono 런타임 IL을 C++로 변환 후 네이티브 컴파일 (AOT)
시작 속도 보통 빠름
런타임 성능 양호 일반적으로 더 우수
리플렉션·동적 생성 대부분 가능 제약 있음 (AOT 한계)
빌드 시간 짧음 길다 (C++ 컴파일 포함)
결과물 크기 작음
지원 플랫폼 에디터·일부 데스크톱 iOS(필수), Android, 콘솔 등

9.2 IL2CPP가 하는 일

IL2CPP는 이름 그대로 IL을 C++로 "트랜스파일"한 뒤, 플랫폼별 네이티브 컴파일러(Clang 등)로 기계어로 만듭니다. 이는 AOT의 한 형태이며, iOS처럼 JIT이 금지된 플랫폼에서 C#을 돌리는 유일한 길입니다.

이 방식 때문에 IL2CPP 빌드에서는 몇 가지 함정이 생깁니다.

  • 리플렉션으로만 참조되는 타입은 "쓰지 않는 것"으로 판단돼 제거(strip)될 수 있습니다. link.xml로 보존 대상을 명시해야 합니다.
  • Activator.CreateInstance(Type.GetType("..."))처럼 런타임에 타입명을 조합하는 코드는 AOT에서 실패할 수 있습니다.
  • 제네릭 타입을 런타임에 새로 인스턴스화하려면 해당 조합이 빌드 시점에 한 번이라도 정적으로 등장해야 합니다.

이런 제약은 전부 "런타임에 IL이 더 이상 존재하지 않는다"는 AOT의 본질에서 옵니다. "JIT이 있다면 실행 시점에 즉석에서 만들어 낼 수 있었을" 코드가 AOT에서는 빌드 타임에 확정되어야 합니다.

9.3 Unity의 BCL은 완전한 BCL이 아니다

Unity가 탑재한 BCL은 공식 .NET의 BCL과 기능 집합이 약간 다릅니다. 몇 가지 함정:

  • System.IO.Pipes, Process.Start처럼 OS 의존 API는 모바일에서 작동하지 않거나 예외를 던집니다.
  • System.Reflection.Emit은 IL2CPP에서 거의 쓸 수 없습니다.
  • Unity는 Boehm-Demers-Weiser GC를 사용합니다(IL2CPP·Mono 공통). 이 GC는 비압축·비세대형(세대 구분이 없는)이라 .NET 본가의 서버 GC와는 성능 특성이 다릅니다.

결론적으로 Unity C# 코드를 쓸 때는 "BCL API가 존재한다 = 실제로 모든 타깃에서 잘 돈다"가 아님을 늘 가정해야 합니다.


10. 함정과 주의사항

10.1 ❌ 어셈블리 참조를 IDE가 채워 주니까 신경 쓰지 않기

Visual Studio나 Rider가 using을 자동 추가해 주므로 참조의 존재를 잊기 쉽습니다. 하지만 실제 빌드/실행에서 참조가 누락되면 FileNotFoundException이 납니다. Unity에서 asmdef(Assembly Definition)를 쓰는 순간 이 문제가 전면에 드러납니다 — asmdef 하나가 곧 어셈블리 하나이고, 다른 asmdef의 타입을 쓰려면 명시적으로 참조를 추가해야 합니다.

처방: 프로젝트 초기에 asmdef로 코드 영역을 나누고, 각 asmdef의 참조를 명시적으로 관리합니다. "런타임과 에디터 코드가 섞이지 않도록" 하는 경계도 이 수준에서 잡힙니다.

10.2 ❌ 리플렉션 코드를 IL2CPP 빌드에서 그대로 돌리기

에디터(Mono, JIT)에서 잘 돌던 Type.GetType("Enemy")·Activator.CreateInstance 코드가 iOS IL2CPP 빌드에서 TypeLoadException을 던지는 일이 흔합니다. AOT는 런타임에 타입을 "만들어 내지" 못하기 때문입니다.

❌ 잘못된 패턴

C#
public class EnemyFactory
{
    public object Create(string typeName)
    {
        // 런타임에 타입명으로 타입을 찾아 인스턴스 생성
        var type = System.Type.GetType(typeName);
        return System.Activator.CreateInstance(type);
    }
}

✅ 올바른 패턴

C#
public interface IEnemyFactory { Enemy Create(); }

public class GoblinFactory : IEnemyFactory
{
    public Enemy Create() => new Goblin();
}

public class OrcFactory : IEnemyFactory
{
    public Enemy Create() => new Orc();
}

public class EnemySpawner
{
    private readonly Dictionary<string, IEnemyFactory> factories;
    public EnemySpawner()
    {
        factories = new Dictionary<string, IEnemyFactory>
        {
            ["Goblin"] = new GoblinFactory(),
            ["Orc"]    = new OrcFactory(),
        };
    }
    public Enemy Create(string key) => factories[key].Create();
}

IL 레벨에서 보면 올바른 패턴은 전부 정적 newobj로 컴파일됩니다. AOT 변환기가 각 타입이 실제로 사용됨을 추적할 수 있고, IL2CPP는 해당 타입을 strip하지 않습니다. 반면 리플렉션 기반 생성은 IL에 newobj Goblin이 등장하지 않으므로, AOT 입장에서는 "쓰이지 않는 타입"으로 보여 제거될 수 있습니다.

10.3 ❌ Update()에서 매 프레임 새 어셈블리·새 객체를 로드하기

Unity 핫패스(Update, FixedUpdate, LateUpdate)는 초당 수십~수백 번 호출됩니다. 이 안에서 다음은 모두 위험합니다.

  • new List<int>(), new StringBuilder() 등 힙 할당 → GC 스파이크
  • 새 어셈블리 로드나 AssemblyLoadContext 생성 → 초기화 비용이 큼
  • LINQ 체인(Where(...).Select(...).ToList()) → 중간 단계마다 IEnumerable 객체 생성

처방: Update에서는 "이미 만들어 둔 것"만 활용합니다. 새로 만들어야 하는 구조는 Awake/Start에서 1회 준비하고, Update에서는 Clear() → 재사용합니다.

10.4 ❌ .NET Framework 코드를 그대로 Unity에 가져오기

.NET Framework 시절 BCL에는 있었지만 Unity에 없거나, API 동작이 다른 기능이 꽤 있습니다. System.Drawing.Common, System.Configuration, System.Windows.Forms 같은 레거시 네임스페이스는 Unity에서 사용 불가 또는 모바일에서 동작 불가입니다.

처방: 복사해 오는 코드의 using 문을 확인하고, Unity의 API Compatibility Level(.NET Standard 2.1 권장)에서 존재하는 API만 사용합니다.


11. 버전별 변화 — CLR과 런타임 기능

11.1 .NET Framework 4.x vs .NET Core 3.0

  • 단일 mscorlib.dll → 여러 개로 분리된 BCL 어셈블리.
  • Windows 종속 → 크로스 플랫폼.
  • GAC(Global Assembly Cache) 의존 → bin 폴더 배포 또는 자체 포함.
  • Tiered Compilation 도입(3.0) → 시작 속도 개선.

11.2 .NET 5 → .NET 6

  • PublishSingleFile, PublishReadyToRun 정식화.
  • System.Text.Json 성능 개선.

11.3 .NET 7 → .NET 8

  • Native AOT 정식 지원. 완전 AOT 산출물을 만들 수 있음.
  • DynamicPGO(Profile-Guided Optimization) 개선 — Tier 1 재컴파일 품질 향상.
  • System.Runtime 경량화 진전.

11.4 .NET 9 → .NET 10

  • Native AOT 대상 API 확장, 자르기(trimming) 품질 개선.
  • 최신 C# 언어 기능과 런타임 기능이 더 밀접하게 결합.

버전별 세부는 끝이 없지만, 큰 그림은 "AOT 쪽으로 무게 중심이 이동"이라는 흐름입니다. Unity IL2CPP가 먼저 걸어간 길을 .NET 본가가 뒤따라가고 있다고 볼 수도 있습니다.


12. 정리 — 꼭 기억할 것

  • .NET 플랫폼은 언어 → 어셈블리 → CLR + BCL → OS/CPU의 4단 구조로 되어 있습니다.
  • CLR은 JIT 컴파일, GC, 타입 안전 검증, 예외 처리, 어셈블리 로딩을 담당하는 실행 엔진입니다.
  • BCLSystem.*·System.Collections.*·System.Threading.* 등 모든 .NET 프로그램이 공통으로 쓰는 공용 라이브러리입니다. .NET Core 이후에는 여러 어셈블리로 잘게 나뉘어 있습니다.
  • 어셈블리(.dll/.exe)는 배포의 단위이며 안에 매니페스트·메타데이터·IL·리소스를 담은 자기 완결적 파일입니다.
  • 컴파일은 두 번 일어납니다. 1단계에서 C# → IL, 2단계에서 IL → 네이티브. 이 분리 덕분에 같은 .dll이 여러 플랫폼 CLR 위에서 돕니다.
  • JIT은 빠른 시작 + 런타임 최적화, AOT는 즉시 실행 + 제약 이라는 트레이드오프가 있습니다. Tiered Compilation·ReadyToRun·Native AOT는 그 사이를 잇는 타협안입니다.
  • Unity는 Mono(JIT) 또는 IL2CPP(AOT)를 선택하며, 모바일/콘솔은 IL2CPP가 사실상 표준입니다. AOT의 제약이 리플렉션·동적 타입 생성·제네릭 조합의 함정을 만듭니다.
  • 핫패스 힙 할당 금지, 리플렉션 최소화, 어셈블리 참조 경계(asmdef) 명시는 Unity에서 가장 먼저 체화해야 할 세 가지 습관입니다.
반응형

+ Recent posts