C#의 JIT, AOT, 그리고 Unity의 IL2CPP
C# 코드가 실행되는 과정을 JIT 중심으로 정리하고, 현재 .NET의 Native AOT와 Unity의 IL2CPP가 왜 필요한지 살펴봅니다.
C# 코드는 바로 기계어가 아니다
C#으로 작성한 코드는 보통 빌드할 때 바로 CPU가 실행할 수 있는 기계어가 되지 않는다. 먼저 C# 컴파일러가 코드를 IL, 즉 Intermediate Language로 바꾼다. 이 IL은 .NET 런타임이 이해할 수 있는 중간 표현이다.
그래서 C# 프로그램을 이해할 때는 두 단계를 나누어 보는 편이 좋다.
C# 소스 코드
-> IL 코드
-> 실행 시점의 기계어
여기서 마지막 단계, IL을 실제 기계어로 바꾸는 일을 전통적으로 담당해 온 것이 JIT이다.
이 구조 덕분에 C#은 일반적으로 IL과 JIT을 이용한 멀티플랫폼 언어로 동작한다. 같은 C# 코드와 IL을 여러 운영체제와 CPU 아키텍처 위에서 실행할 수 있고, 런타임이 각 환경에 맞춰 마지막 변환을 맡는다. 다만 모든 플랫폼이 JIT을 허용하는 것은 아니다. 대표적으로 iOS는 보안 정책상 실행 중 새로운 코드를 생성하거나 컴파일하는 JIT 방식을 제한한다. 이런 환경에서는 C#도 실행 전에 미리 컴파일하는 AOT 방식이 필요해진다.
JIT은 실행하면서 컴파일한다
JIT은 Just-In-Time의 줄임말이다. 말 그대로 필요한 시점에 맞춰 컴파일한다. 프로그램 전체를 처음부터 전부 기계어로 바꾸는 것이 아니라, 어떤 메서드가 처음 호출되는 순간 그 메서드의 IL을 현재 실행 중인 CPU와 운영체제에 맞는 기계어로 바꾼다.
이 방식의 장점은 꽤 현실적이다. 같은 IL을 가지고도 x64, ARM64 같은 서로 다른 환경에서 실행할 수 있고, 런타임은 실제 실행 환경을 알고 있기 때문에 그 환경에 맞는 최적화를 적용할 수 있다. 실행 중에 자주 호출되는 코드와 거의 호출되지 않는 코드를 다르게 다루는 것도 가능하다.
하지만 비용도 있다. 처음 호출되는 메서드는 컴파일 비용을 한 번 지불해야 한다. 서버 애플리케이션처럼 오래 떠 있는 프로그램에서는 이 비용이 크게 느껴지지 않을 때가 많지만, 짧게 실행되는 CLI 도구나 빠른 시작 시간이 중요한 앱에서는 JIT 비용이 체감될 수 있다.
JIT은 단순한 변환기가 아니다
JIT을 단순히 "IL을 기계어로 바꿔 주는 도구" 정도로만 보면 조금 아쉽다. 현대 .NET의 JIT은 실행 환경을 보고 꽤 많은 판단을 한다. 인라이닝, bounds check 제거, devirtualization, tiered compilation 같은 최적화가 여기에 들어간다.
특히 tiered compilation은 JIT의 성격을 잘 보여 준다. 처음에는 빠르게 컴파일해서 프로그램을 빨리 시작하게 만들고, 이후 자주 실행되는 코드는 더 많은 시간을 들여 다시 최적화할 수 있다. 즉 JIT은 시작 시간과 실행 성능 사이에서 균형을 잡는 런타임 전략에 가깝다.
이런 특성 때문에 JIT 기반 C#은 일반적인 데스크톱 앱, 서버 앱, 개발 도구, 백엔드 서비스에서 여전히 강력하다. 코드가 실행되는 환경을 런타임이 알고 있고, 그 정보를 성능에 다시 활용할 수 있기 때문이다.
AOT는 미리 컴파일한다
AOT는 Ahead-Of-Time의 줄임말이다. 실행하기 전에 미리 기계어로 컴파일하는 방식이다. C#에서도 AOT 자체는 낯선 개념이 아니다. Xamarin, Blazor WebAssembly, Unity 같은 환경에서는 오래전부터 AOT에 가까운 접근이 필요했다. 특히 iOS처럼 JIT을 막는 플랫폼에서는 AOT가 성능 최적화 옵션을 넘어 실행을 가능하게 만드는 수단이 된다.
현재 .NET에서는 Native AOT가 중요한 선택지로 자리 잡고 있다. Native AOT는 .NET 애플리케이션을 미리 네이티브 실행 파일로 컴파일한다. 결과물은 런타임 의존성이 줄어들고, 시작 시간이 빨라질 수 있으며, 배포 파일의 형태도 단순해질 수 있다.
특히 다음과 같은 상황에서는 Native AOT가 매력적이다.
- 빠르게 시작해야 하는 콘솔 도구
- 작은 단일 실행 파일로 배포하고 싶은 프로그램
- 서버리스 함수처럼 cold start가 중요한 환경
- 컨테이너 이미지 크기와 시작 시간이 중요한 서비스
하지만 AOT가 모든 면에서 JIT보다 우월하다는 뜻은 아니다. AOT는 실행 전에 많은 것을 결정해야 한다. 런타임에 타입을 동적으로 찾거나, 리플렉션으로 멤버를 탐색하거나, 동적으로 코드를 생성하는 패턴은 제약을 받기 쉽다. 그래서 Native AOT를 사용할 때는 trimming, reflection metadata, source generator 같은 주제를 함께 고려해야 한다.
JIT과 AOT는 승부가 아니라 선택지다
JIT과 AOT는 "어느 쪽이 더 좋다"로 끝낼 수 있는 문제가 아니다. 둘은 서로 다른 비용을 서로 다른 시점에 지불한다.
JIT은 실행 중에 컴파일 비용을 내는 대신, 실행 환경을 알고 최적화할 수 있다. AOT는 빌드 시점에 컴파일 비용을 미리 내는 대신, 시작 시간과 배포 형태에서 이점을 얻을 수 있다.
그래서 판단 기준은 보통 다음에 가깝다.
시작 시간이 중요한가?
배포 파일 크기가 중요한가?
런타임 동적 기능을 많이 쓰는가?
오래 실행되며 런타임 최적화의 이점을 받을 수 있는가?
대상 플랫폼이 JIT을 허용하는가?
마지막 질문도 중요하다. 모든 플랫폼이 JIT을 자유롭게 허용하는 것은 아니다. 모바일이나 콘솔처럼 실행 중 코드 생성을 제한하는 환경에서는 AOT가 단순한 최적화 선택이 아니라 필수 조건이 되기도 한다.
Unity는 IL2CPP를 사용한다
Unity에서 C# 스크립트를 작성한다고 해서 최종 빌드가 항상 일반적인 .NET JIT 방식으로 실행되는 것은 아니다. Unity는 자신들만의 IL2CPP라는 기술을 사용한다.
IL2CPP는 이름 그대로 IL을 C++ 코드로 변환하고, 그 C++ 코드를 각 플랫폼의 네이티브 컴파일러로 다시 빌드하는 방식이다.
C# 스크립트
-> IL
-> C++ 코드
-> 플랫폼별 네이티브 바이너리
Unity가 이런 방식을 사용하는 이유는 게임이 배포되는 플랫폼의 특성과 관계가 깊다. iOS 같은 플랫폼은 JIT을 제한하고, 콘솔 플랫폼도 런타임 코드 생성에 엄격한 경우가 많다. Unity는 여러 플랫폼에 같은 C# 코드를 배포해야 하므로, IL2CPP를 통해 AOT 방식의 네이티브 빌드를 만들어 낸다.
IL2CPP는 단순히 "C#을 C++로 바꿔서 빠르게 만든다" 정도로 이해하면 부족하다. 핵심은 플랫폼 호환성, AOT 요구사항, 네이티브 툴체인과의 연결이다. 물론 성능 면에서도 이점이 있을 수 있지만, 실제 성능은 코드 구조, 메모리 할당, GC, 엔진 호출 비용, 플랫폼별 컴파일러 최적화에 따라 달라진다.
Unity 개발자가 알아야 할 차이
Unity Editor에서 플레이할 때와 실제 IL2CPP 빌드에서 동작이 다르게 느껴지는 경우가 있다. Editor는 개발 편의성을 위해 많은 기능과 오버헤드를 가지고 있고, 최종 빌드는 대상 플랫폼의 설정에 맞춰 완전히 다른 경로로 만들어진다.
그래서 Unity에서 성능이나 동작을 판단할 때는 Editor만 보고 결론을 내리면 위험하다. 특히 다음과 같은 부분은 실제 빌드에서 확인하는 습관이 필요하다.
- 시작 시간
- 스크립트 실행 비용
- GC Alloc과 메모리 사용량
- 리플렉션 사용 여부
- AOT 환경에서 제네릭 코드가 제대로 포함되는지
- 플랫폼별 네이티브 플러그인과의 연결
IL2CPP는 C# 코드를 사용할 수 있게 해 주지만, 최종 실행 환경은 네이티브 빌드에 가깝다. 따라서 Unity 개발자는 C# 문법만이 아니라 "내 코드가 최종적으로 어떤 방식으로 실행되는가"까지 알고 있어야 한다.
정리
C#의 기본 실행 모델은 IL과 런타임 위에 서 있다. JIT은 실행 시점에 IL을 기계어로 바꾸면서 유연성과 런타임 최적화를 제공한다. 반대로 AOT는 미리 기계어에 가까운 결과물을 만들어 시작 시간, 배포 형태, 플랫폼 제약에서 이점을 얻는다.
현재 .NET의 Native AOT는 C# 애플리케이션에 더 넓은 배포 선택지를 열어 주고 있다. 다만 리플렉션이나 동적 코드 생성처럼 런타임에 많은 것을 결정하는 패턴은 조심해야 한다.
Unity는 여기에 자신들만의 답을 가지고 있다. IL2CPP는 C# 코드를 IL로 만든 뒤 C++로 변환하고, 다시 플랫폼별 네이티브 바이너리로 빌드한다. 이는 Unity가 다양한 플랫폼, 특히 JIT이 제한되는 환경에 게임을 배포하기 위해 선택한 중요한 실행 전략이다.
결국 JIT, AOT, IL2CPP는 서로를 대체하는 단순한 우열 관계가 아니다. C# 코드가 어떤 환경에서, 어떤 제약 아래, 어떤 성능 목표를 가지고 실행되는지에 따라 달라지는 실행 방식의 선택지다.