[Unity] 내 게임은 어떻게 실행되는가

KYO
KYO
∙∙조회 16
[Unity] 내 게임은 어떻게 실행되는가

이 글은 <Unity Native 이해를 위한 학습 로드맵> 시리즈의 첫 번째 글입니다. 이 시리즈는 Unity의 Fake Null 문제에 대해 포스팅하려다가 그에 앞서 스스로 이해가 부족한 부분이 있어서 시작되었습니다.

주요 개념

  • CPU와 기계어
  • 컴파일 언어와 런타임 언어
  • C# 코드와 C++ 코드의 실행 과정
  • IL(Intermediate Language)과 네이티브 코드

학습 목표

  • CPU는 실제로 무엇을 실행하는가?
  • C#과 C++은 어떤 과정을 거쳐 실행되는가?
  • 언어와 실행 환경(Runtime)의 차이는 무엇인가?

1. 기계어

CPU가 프로그램을 실행하기 위해 파일을 연다. 그 파일의 일부는 대충 이렇게 생겼다. 01100110 10101010 . 사람은 이해할 수 없지만, 컴퓨터는 이 언어를 가장 빠르게 실행한다. 그 언어의 포맷은 CPU마다 다르지만, 통틀어서 기계어라 부른다.

2. C++의 컴파일러

우리가 C++로 코드를 작성하면, 컴파일러(clang이나 MSVC 등)가 그 코드를 기계어로 번역한다. 이 과정을 통해 .exe나 .dll 같은 파일이 생성되는데, 둘은 모두 컴퓨터가 실행할 수 있는 기계어 파일이다.

  • .exe는 사용자가 실행하는 진입점
  • .dll은 진입점은 없으나 다른 파일에 의해 실행된다.

이 기계어 파일은 네이티브 코드(Native Code) 라고도 부른다.

3. 런타임 언어

C#도 컴파일 언어다. 그러나 C# 컴파일러(csc나 Roslyn)는 컴파일의 결과가 기계어가 아니다. 대신 C# 코드를 IL(Intermediate Language, 중간 언어)로 변환한다.

IL은 기계어가 아니므로 CPU가 실행할 수 없다. 따라서 이 IL을 기계어로 변환시켜주는 과정이 따로 필요한데, 이 때 필요한 실행 환경이 CLR(Common Language Runtime)이다. 이 실행 환경 안에서 이루어지는 IL → 기계어 번역 과정이 바로 JIT(Just In Time) 컴파일이다.

4. C++과 C# 실행 과정에서의 중요한 차이점

C++은 컴파일 즉시 기계어로 번역된다 했다. 이 기계어는 CPU에서 바로 실행 가능한 형태일 것이다.

C#은 컴파일 결과가 IL이다. CLR이 실제 프로그램 실행 도중에 JIT 컴파일을 통해 IL을 기계어로 번역하고, 그렇게 나온 기계어를 CPU가 실행한다.

이 차이가 두 언어의 성격을 완전히 구분 짓는데, 가장 중요한 것은 C++의 컴파일 결과는 플랫폼에 종속된다는 점이고, C#은 그렇지 않다는 점이다.

즉, C++은 컴파일 시점에 특정 플랫폼의 기계어가 결정된다. 예를들어 Windows로 컴파일 했다면 Android에서는 실행되지 않는다. C#은 IL로 컴파일하고, 실행 시점에 JIT컴파일을 통해 기계어로 번역한다. 따라서 ‘이론적으로는’ 어느 플랫폼이든 CLR이 설치되어있다면 실행 가능하다.

  • CLR이 있더라도, Linux에서 윈폼 API을 사용할 수 없는 것처럼 일부 API 호환성 문제가 있어 IL이 반드시 실행 가능한 것은 아니다.

5. Unity의 두 가지 C# 런타임

Mono는 닷넷 이전부터 존재하던 오픈소스 CLR 구현체다.

IL2CPP는 Unity가 자체 개발한 방식으로, IL을 C++ 코드로 변환한 뒤 다시 기계어로 컴파일한다.

Unity는 오늘날 모바일(Android, IOS), 콘솔(PS5, Xbox, Switch 등) 플랫폼에서 IL2CPP를 기본으로 채택한다. 그 이유는 성능과 보안에 있다.

예를 들어, IOS는 Apple의 정책 상 런타임에 코드 생성을 금지하기에, JIT 방식인 Mono는 사용할 수 없다. 따라서 우리는 IOS를 대상으로 Mono를 사용할 수 없다. 애초에 선택지가 IL2CPP밖에 없다는 것이다.

또 예를 들면, Android는 역사적으로 Microsoft의 CLR 대상이 아니었다. 다행히 Mono는 Android에 대해 JIT 백엔드를 구현해두었기에, 우리는 Mono를 사용하여 Android에서 프로그램을 실행할 수 있었다. 그러나 Mono JIT은 ARM환경에서 성능이 낮다. 런타임 변환 비용이 있기 때문이다. 그리고 IL은 기계어에 비해 역공학이 쉬우므로 코드 보호 측면에서 불리하다. 따라서 IL2CPP를 사용함으로써 성능 문제와 보안 문제를 극복한다.

  • 역공학: 원본 코드를 복구하는 것
  • ARM 환경: x86, x64와 같이 CPU가 인식하는 명령어 집합 중 하나. 즉 기계어 언어의 종류. Android와 같은 모바일이나 콘솔에서 주로 쓴다.

6. 마무리

이제 학습 목표였던 아래 질문에 답할 수 있다.

  • CPU는 실제로 무엇을 실행하는가?

기계어. ARM, x86, x64 등 다양하고, 세부로 들어가면 이들도 나뉜다.

  • C#과 C++은 어떤 과정을 거쳐 실행되는가?

C++은 컴파일 시점에 플랫폼이 결정된다. 플랫폼에 맞는 기계어가 결과물로 나온다. C#은 컴파일 시점엔 IL이다. JIT 컴파일 방식으로 런타임에 CLR이 기계어로 번역한다. 하지만 보안/성능 상의 이유로 AOT만을 허용하는 플랫폼에서는, IL2CPP를 통해 타겟 플랫폼에 맞는 기계어로 미리 컴파일해야한다. 플랫폼에서는 기계어를 실행할 뿐이다.

  • 언어와 실행 환경(Runtime)의 차이는 무엇인가?

C#과 C은 언어이다. 그 소스코드 자체는 플랫폼 중립적이다. 어떤 플랫폼이든 C#과 C의 코드가 의미하는 것은 항상 동일하게 그 자신이다.

실행 환경은 언어 그대로 프로그램이 실행되는 환경이므로, JIT 컴파일도 이 환경에서 이루어진다. CLR도 Runtime이다. 즉 CLR 구현체가 없다면 JIT 컴파일도 없다. 참고로 Runtime은 JIT 컴파일 만을 의미하는 것은 아니다. Runtime에는 힙 메모리 할당(malloc), GC, 타입 검사(캐스팅, 리플랙션, is/as 연산자), try-catch-finally, 스레드 관리, 메모리 불법 접근과 같은 보안 검증 등 많은 일이 이루어진다.

이제 Mono와 IL2CPP에 대해서도 충분히 알았다.