[Unity] 제네릭의 컴파일
들어가며
제네릭을 쓰면 빌드가 오래걸린다거나, 코드가 복사된다거나 하는 이야기를 들어본적이 있다.
이 글에서는 그 점을 다룬다.
제네릭의 컴파일
일단 Unity의 컴파일은 두 번 나눠서 이루어진다는 점을 떠올려보자.
- Roslyn에 의한 C# --> IL
- JIT이나 AOT로부터 IL --> 기계어
그리고 제네릭은 T가 구조체냐 참조냐에 따라서 아래와 같이 다르게 컴파일된다.
- Roslyn에 의해 IL이 될 땐 코드가 복사되지 않는다. 여전히 T로 남아있다.
- JIT, AOT에 의해 기계어가 될 때, 구조체는 타입마다 기계어가 새로 생성된다. 참조는 아니다.
위 차이는 T의 크기에 있다. List<T>.Add를 떠올려보면, T를 추가할 때 몇 바이트를 복사해서 offset은 얼마나 줘야하는지 궁금해진다. 기계어가 되는 시점에서 T의 크기가 정해져있어야 그 기계어가 고정된 명령으로써 존재할 수 있다. 만약 안 정해지면 런타임에 또 물어봐야됨.
값타입은 크기가 제각각이다. 그래서 기계어로 컴파일되면 각 타입마다 기계어가 생성된다. (List<int>, List<Vector3>, ...)
참조 타입의 주소는 (64비트 환경에서) 8바이트 고정이다. 그래서 타입별로 코드를 따로 둘 필요가 없다. 그래서 참조 타입 공유 코드를 쓴다.
이로부터 알 수 있는 실무적 팁이 있다.
T에 참조타입이 들어간 제네릭은 그 참조타입 종류의 수와 IL2CPP 빌드의 실행파일의 크기가 상관이 없다. 대신 값타입이 들어간 제네릭이 그 크기를 늘린다.
IL2CPP 괴롭히기
우리가 제네릭을 아래와 같이 만들면,
Struct<Struct<Struct<Struct<...>>>>
IL2CPP가 닫힌 타입을 찾지 못한다고 한다. 이외에도 다음과 같은 경우가 있다.
MakeGenericType, MakeGenericMethod로 실행 중에 닫힌 타입을 만드는 경우. 리플렉션 기반 직렬화 라이브러리가 내부에서 이 방식을 쓴다.
컴파일 시점에 대상을 추론할 수 없는 제네릭 가상 메서드 호출 일부
그래서 과거엔 ExecutionEngineException 라는 오류가 발생했다는데, 요즘은 이 오류를 뱉는 대신 Full Generic Sharing을 쓴다고 한다. (Unity 2022.1부터. Unity6 포함)
Full Generic Sharing
IL2CPP 빌드 시점에 T가 무엇인지 알 수 없었던 것들을 위한 완전 공유 코드이다. 이러한 T들은 하나의 공용 코드를 쓰는데, 런타임에 타입을 조회하여야하므로 그 비용이 발생한다.
제약으로 where T : class를 붙이면 어차피 8바이트로 취급하면 돼서 Full Generic Sharing 코드를 쓸 필요가 없다. 그래서 참조 타입 공유 코드를 그대로 쓰므로 런타임에서 크기 조회와 값/참조 여부 조회를 건너뛸 수 있다. 반면 struct 제약은 일부 최적화가 이뤄진다고는 하나, 어차피 구조체 크기가 제각각이라 크기 조회는 해야해서 여전히 타입 전용 코드보단 느리다.
끗.

![[Unity] PC 세이브 데이터 Android로 옮기기](https://data.develog.develrocket.com/upload/develog/muchan/1792206352286-hd4xz4/_____2026-10-17_114953.png)
![[Unity] 제네릭 박싱 예방](https://data.develog.develrocket.com/upload/develog/gmumdev/1792123312021-bhxx2i/_____.png)
