출처: http://growingdever.tistory.com/240
네이티브에서 직접 작업을 하든, 게임 엔진을 쓰든, 실제 빌드 과정은 동일하다.
하지만 게임 엔진을 쓰는 경우 그 과정이 직접 드러나지 않고 숨겨져 있기 때문에 많은 사람들이 헷갈려 한다.
특히, 게임 개발을 주로 하는 개발자들은 네이티브 애플리케이션 개발 경험이 없다보니 네이티브에 직결되는 기능 개발을 할 때
곤란함을 많이 겪는다.
이 문제 해결을 위해서는 정확히 어떤 과정을 통해 우리가 배포할 애플리케이션이 만들어지고 실행되는지 알아두면 유용하다.
이번 기회에 그 빌드 및 패키징, 실행 과정에 대해 글을 써보려 한다. 이해를 도우려다보니 배경에 대해 설명을 조금씩 덧붙였고,
그 결과 다소 글 내용이 너저분하다. 하지만 소설 읽듯이 읽어보면 나름대로의 재미가 있지 않을까 생각해본다 ^^;
Android
안드로이드에서 APK가 만들어지고 실행되는 과정은 다음과 같다.
- 1) Java 코드 작성
- 2) Java 코드를 컴파일하여 `class` 파일 생성
- 3) class 파일을 `dex` 파일로 변환
- 4) dex 파일과 사용중인 리소스들을 `apkbuilder`로 apk 파일 생성
- 5)`zipalign`으로 apk 파일 binary aligning
- 6)`apksigner` 로 apk 파일 signing
- 7)`adb` 혹은 스토어를 통해 apk 다운로드
- 8) apk의 sign 체크
- 9) 애플리케이션이 실행될 때 VM을 켜고 자신의 코드 실행
아마 감이 잘 안 올 것이다. 좀 더 자세히 살펴보기 전에 배경 지식들을 알아보자.
일반적인 Java 프로그램 실행 환경
어디선가 들어본 그 이름, `JVM(Java Virtual Machine)`. Java는 여러 플랫폼에서 동일하게 실행 가능한데,
JVM 위에서 `바이트 코드`가 실행되기 때문이다. JVM은 Java 측에서 OS, CPU 아키텍처마다 빌드한 실행 가능한 프로그램으로 제공된다.
`JRE(Java Runtime Environment)`는 JVM을 포함하며, 프로그램 실행 과정에 필요한 여러 라이브러리들을 포함하고 있다.
`JDK(Java Development Kit)`는 JRE와 동시에 Java 언어 컴파일을 위한 컴파일러 및 여러 툴들도 포함한 패키지이다.
`javac`가 바로 그 Java 언어를 위한 컴파일러다. JDK를 설치하고 나면 javac를 사용할 수 있다.
이것이 eclipse 같은 프로그램을 실행하기 위해 JRE가 꼭 필요한 이유이다.
만약 추가적으로 JRE를 설치하지 않아도 된다면 이미 JRE가 설치되어 있기 때문일 것이다.
Java 코드를 javac 명령어로 컴파일하면 바이트 코드인 `*.class` 파일을 만들어내고, `java` 명령어로 컴파일된 class 파일을 메모리에 올린다.
JVM에서는 메인 클래스의 `Main(String[] args)` 시그니쳐를 가진 메소드를 찾는다. 그리고 처음 실행할 프로그램 명령어 주소(Program Counter)
를 해당 메소드의 처음 지점으로 설정한다. 그리고 만나는 명령어마다 정해진 규칙으로 연산을 수행한다.
이는 CPU가 어셈블리 명령어를 실행하는 것과 비슷하다.
덤. `JAR(Java Archive)` 파일은 이름에서 나타나듯이 Java의 아카이브 파일이다. class 파일 뿐만 아니라 이미지, 사운드 파일 등을
마치 zip 파일처럼 함께 아카이빙할 수 있다. 이 jar 파일에 아예 Main 메소드까지 포함시킬 수 있다.
jar 파일을 만들 때 `jar` 명령어를 사용하는데, jar 명령어를 실행하면서 메인 클래스를 알려줄 수 있다.
그렇게 만든 jar 파일은 `java -jar jar-file` 명령어를 통해 바로 jar 파일 내의 바이트 코드들을 실행할 수도 있다.
안드로이드 실행 환경
Android 운영체제는 모바일 기기 위해서 실행되는 Linux OS라고 생각하면 된다. 데스크톱 환경에서 `java …` 명령어를 통해
JVM 프로세스를 켜고, 그 위에서 바이트 코드를 실행하는 것처럼 모바일 기기에서도 *거의* 똑같은 일이 일어난다.
JVM은 라이센스 문제가 걸려있어 안드로이드에서는 JVM을 바로 사용하지 않는다. 대신 JVM을 변형한 `Dalvik Virtual Machine`이라는
가상 머신을 이용한다. 달빅 가상 머신에서는 바이트 코드로 `dex` 파일을 이용한다. 안드로이드 SDK에는 `dx` 라는 프로그램이 포함되어 있는데,
SDK의 루트 디렉토리에서 `build-tools/24.0.3/dx` 프로그램이 바로 그것이다. SDK 매니저에서 허구한 날 자꾸 업데이트하거나 설치하라고 뜨는
Build-Tools에 포함되어 있다.
Dalvik은 여러가지 문제가 많아 롤리팝 버전부터는 `ART(Android RunTime)`라는 새로운 런타임으로 교체되었다.
ART에서는 애플리케이션이 처음 설치될 때 dex 파일을 또 다시 컴파일하여 `OAT` 파일을 만들어 실행한다.
이 파일 포맷 이름의 유래는 다소 특이하다. 원래 dex 파일은 `dex2opt` 프로그램을 통해 `odex(optimized dex)` 라는 최적화된 dex 파일로
바뀌어 사용가능했었다. 이와 비슷하게 `dex2oat` 프로그램은 dex 파일을 받아 `oat(optimized ahead-of-time)` 파일을 만든다.
그래서 oat 파일이 된 것이다. dex2oat 프로그램은 안드로이드 OS 내에 설치되어 있고, 앞서 설명한 것과 같이 처음 apk이 설치될 때 실행된다.
oat 파일은 `ELF(Executable and Linkable Format)` 파일이다. 즉, 직접 CPU에 의해 실행 가능하다. 따라서 VM 위에서 실행되는 것에 비해
훨씬 빠른 실행 속도를 보여준다. 또한, 좀 더 훌륭한 메모리 할당과 가비지 컬렉션 성능을 가진다. 더 자세한 설명은 너무 원글의 주제에서
벗어나므로 생략한다. 일단은 이해를 쉽게 하기 위해 일반적인 Java 프로그램들과 비슷하게 VM 위에서 코드가 실행된다고 알아두자.
좀 더 전반적인 내용에 대해 알고 싶다면 Application Fundamentals 문서를 읽어보자.
JNI(Java Native Interface), NDK(Native Development Kit)
`JNI`를 이용하면 C/C++로 작성된 네이티브 코드에서 JVM을 생성할 수도, JVM에서 실행된 Java 프로그램에서 Shared Library 로딩을 할 수도
있다. 네이티브 코드에서 `JNI_CreateJavaVM` 함수를 실행하면 그 함수를 실행한 쓰레드가 JVM의 메인 쓰레드가 된다.
만들어진 JVM은 `JNIEnv*`가 되어 포인터로 자유롭게 이용 가능하다. 어떤 클래스를 찾아서 그 클래스의 인스턴스를 만들수도, 만들어진
인스턴스의 메소드 혹은 클래스의 static 메소드를 실행할 수도 있다. Java에서 할 수 있는 일의 거의 전부를 할 수 있다고 생각하면 된다.
역으로 JVM에서 실행된 Java 프로그램에서 `System.loadLibrary` 메소드를 실행하면 인자로 주어진 Shared Library를 메모리에 로딩하고,
Shared Library에 선언된 함수들과 Java 측의 클래스들에 `native` 키워드로 선언된 메소드들을 매칭시킨다. native 메소드들을 실행하면
Shared Library에 선언된 함수를 실행하는데, 각 함수들은 항상 인자로 `JNIEnv*`를 받도록 되어 있다.
따라서, 받아온 JVM을 자유롭게 이용하면 된다.
그렇기 때문에 게임 엔진들(언리얼 엔진, 유니티 엔진, Cocos2d-x 등)은 NDK를 이용해 코드를 Shared Library로 빌드하고
안드로이드 애플리케이션에서 해당 Shared Library를 로딩하는 방식으로 게임을 실행한다. Shared Library를 빌드할 때는 C++ 코드가
그 라이브러리가 사용될 CPU(거의 보통은 ARM 아키텍처)에서 실행 가능한 어셈블리로 컴파일 되어야 한다.
이를 위해 사용되는 것이 `NDK`다. NDK로 빌드하면 네이티브 코드가 원하는 CPU 아키텍처의 Intstruction Set으로 컴파일 되는 것을
믿고 쓸 수 있다. 또한, 안드로이드 측의 여러 시스템들을 좀 더 편리하게 사용할 수 있게 하기 위한 라이브러리들이 포함되어 있다.
이런 방식으로 작업하는 가장 큰 이유는 속도다. 일단 네이티브 코드가 직접 CPU에 의해 실행되기 때문에 명령어 실행 속도가 더 빠르다.
그리고, 메모리 관리 차원에서도 Java의 메모리 관리보다는 네이티브 코드에서 `malloc`, `free`, `new`, `delete`와 같은 명령어들을 사용하는게
더 효과적이다. JVM이 아닌 OS를 통해 직접 관리하는 것이기 때문이다.
Shared Library의 네이티브 코드에서는 하드웨어 가속을 받아 열심히 렌더링하여 최종 결과물 비트맵을 만든다.
만들어진 비트맵을 애플리케이션의 View에 입힌다. 그러면 우리가 보는 게임 화면이 완성된다.
IBM의 웹 페이지 중 JNI에 설명이 잘 되어 있는 글이 있으므로 참고하자.
ant, gradle, 빌드를 돕는 녀석들
앞서 간략한 빌드 순서를 보면 알겠지만 이것 저것 귀찮은 짓을 많이 필요로 한다.
하지만 실제로 IDE를 통해 안드로이드 개발을 할 때는 빌드 버튼만 누르면 apk 파일이 툭 나온다.
그 뒤에는 `ant`나 `gradle`과 같은 빌드 툴들의 도움이 있다.
과거에는 거의 항상 ant를 이용해서 빌드했었지만, 2013년 쯤부터 혜성처럼 나타난 gradle이 점차 세력을 넓혀갔다.
(2013년은 내가 기억하는 와닿는 시기이다.) 그러던 차에 구글에서는 Eclipse를 버리고 Android Studio를 공식 IDE로 선정했다.
Android Studio는 기본적으로 gradle을 쓰고 있었는데, Android Studio가 공식 IDE로 선정된 뒤부터 쭉쭉 성장해서
이제는 거의 항상 gradle을 쓴다고 봐도 된다.
어쨌든, ant나 gradle이나 컴파일러 사용하는 것과 어느 정도 비슷하다고 봐도 된다. `ant build`를 실행하면 ant를 이용하여
미리 안드로이드 SDK에 명세된 대로 이런 저런 작업을 한 뒤 apk 파일이 만들어진다. `gradlew assembleDebug`를 실행하면
gradle을 이용하여 미리 명세된 대로 이런 저런 작업을 한 뒤 debug용 apk 파일이 만들어진다.
이런 저런 작업에는 앞서 제시한 순서인 Java 소스 컴파일, dex 파일 생성, aapt로 apk 생성, zipalign으로 aligning, apksigner로
signing이 포함된다.
AndroidManifest.xml
공식문서에서는 안드로이드 OS가 멀티 유저 리눅스 시스템이라고 말하고 있다.
설치된 애플리케이션에 대해 안드로이드 OS는 각각의 애플리케이션을 하나의 유저처럼 인식한다.
이것이 안드로이드에서 말하는 샌드박스다. 다른 유저의 홈 디렉토리를 함부로 접근할 수 없는 것으로 샌드박스가 되는 것이다.
`AndroidManifest.xml` 파일은 시스템에게 애플리케이션의 몇 가지 꼭 필요한 정보를 제공하기 위해 필요하다.
예를 들면 애플리케이션에서 사용하는 기능들에 대한 권한, 빌드하는데 사용된 SDK 버전, 코드에 존재하는 App 컴포넌트들(Activity, Service, Broadcast Receiver, Content Provider) 등이 있다. AndroidManifest.xml에 명시된 App 컴포넌트로는 아마 `activity` element가 가장 익숙할 것이다.
애플리케이션에 어떤 Activity가 존재하는지 알려주는 element이다.
빌드를 통해 만들어진 dex, oat 파일들은 바이트 코드 뭉치들에 불과하다. 안드로이드 OS에서 애플리케이션을 실행하면서 VM 프로세스를 켠 뒤, 바이트 코드들도 메모리에 올린다. 그 다음은? Java에서 메인 클래스의 Main 메소드를 Entry 함수로 지정하듯이, 어디서부터 코드 실행을 시작할지 VM에게 알려줘야 한다. `activity` element 내의 `intent-filter`에서 그 정보를 제공한다.
처음 시작될 Activity에 대한 전형적인 선언이다. 이 내용을 조금만 더 깊게 보면 이렇다.
- 나의 패키지에 MainActivity 라는 클래스가 존재한다.
- 이 MainActivity는 메인 Activity니까, 애플리케이션이 처음 시작될 때 MainActivity의 인스턴스를 만들어서 실행해달라.
- 그리고 Launcher를 통해 애플리케이션을 노출시킬 때, 이 Activity를 Entry Point로 생각해 달라.
이런 식으로 App 컴포넌트인 Activity, Service, Broadcast Receiver, Content Provider에 대한 정보들을 명시적으로 제공하는 것이
안드로이드의 구조다. 특히, 메인 Activity에 대한 부분은 엔진들로부터 만들어지는 Intermediate 소스들을 보기 위해 꼭 필요한 지식이므로
좀 더 자세히 설명했다.
이 정도면 필요한 배경 지식이 어느 정도 설명된 것 같다. 이제 예시를 보자.
예시1: 언리얼 엔진에서의 안드로이드 빌드
언리얼 엔진에서는 `UAT(Unreal Automation Tool)`라는 것으로 이 과정들이 숨겨져있다.
여러가지 복잡한 인자들을 많이 요구하는데, UAT를 이용하여 리소스 쿠킹, 바이너리 빌드, 기기에 배포 등을 모두 할 수 있도록 만들었기
때문이다. 언리얼 에디터에서 패키징 혹은 프리뷰 런칭을 할 때도 내부적으로는 UAT를 통해 빌드하고 기기에 배포까지 한다.
사실 그 모든 것이 내부적으로는 적절하게 프로젝트 파일들을 세팅한 뒤 `ant build`, `adb install apk-file` 명령어 같은 것을 실행하는 것이다.
그렇기 때문에 프로젝트 디렉토리의 Intermediate 파일들을 보면 전형적인 안드로이드 프로젝트의 디렉토리 구조를 갖고 있으며,
UAT 명령어의 실행 로그를 보면 도중에 ant 명령어 실행과 그 로그들을 확인할 수 있다. 안드로이드 프로젝트의 AndoridManifest.xml 파일을
보면 메인 Activity로 `GameActivity`라는 언리얼 엔진 측의 클래스를 사용하는 것을 확인할 수 있다.
당연히 프로젝트에 생성한 C++ 클래스들에서 JNI를 사용할 수 있기 때문에 직접 Java의 코드들을 실행할 수도 있다.
이를 이용하여 Tapjoy와 같은 네이티브 기능을 사용하는 SDK를 사용할 수 있다.
물론, 그 역도 성립한다. 프로젝트의 C++ 코드들은 빌드를 통해 Shared Library `libUE4.so`로 만들어진다.
그리고 `GameActivity`에서는 `System.loadLibrary`로 `libUE4.so`를 로드함으로써 네이티브 코드들이 실행 가능해진다.
써드파티 Java 클래스들을 사용하기 위해서는 먼저 사용하려는 Java 클래스들을 컴파일하여 jar 파일로 만들어야 한다.
다음으로 이 jar 파일이 함께 패키징되도록 해야 한다. 슬프게도 언리얼 엔진의 빌드 프로세스는 굉장히 복잡하게 숨겨져있다.
가장 쉬운 방법은 사용하려는 jar 파일을 엔진 디렉토리의 안드로이드 템플릿 프로젝트의 `libs` 디렉토리에 넣어버리는 것이다.
이 방법의 문제점은 해당 엔진을 사용하는 모든 프로젝트에 영향을 미친다는 것이다.
다른 방법으로는 4.13버전까지 기준 Plugin Language 기능을 이용하는 방법이 있다.
Plugin Language에 명세로 어떤 파일들을 Intermediate 프로젝트에 복사시키거나, 추가 권한 획득이나 App 컴포넌트 추가를 위한
AndroidManifest.xml 수정, GameActivity의 몇몇 라이프 사이클 메소드에 코드 추가 등이 가능하다. 그러나 굉장히 제한적인 수준이다.
예시2: 유니티 엔진에서의 안드로이드 빌드
유니티 엔진에서도 에디터를 통해 빌드하는 것이 일반적이다.
안드로이드 빌드를 하면 프로젝트의 `Temp/StagingArea` 디렉토리에 안드로이드 빌드 관련 파일들이 생성된다.
이를 똑같이 ant나 gradle로 빌드하면 APK를 만들게 되는 것이다.
`AndroidManifest.xml` 을 확인해보면 `UnityPlayerActivity`라는 Activity로 시작하게 되어 있음을 알 수 있다.
또한, `libs/armeabi-v7a` 디렉토리에 `libmain.so`, `libmono.so`, `libunity.so` 파일이 생성되어 있다. 조금 검색해보면 `UnityPlayerActivity`는
`UnityPlayer`를 실행하도록 하고, `UnityPlayer`는 `libmain.so`를 로드하게 되어 있음을 찾아볼 수 있다. 아마 `libmono.so`, `libunity.so`도
어디선가 로드해서 사용하고 있을 것이다. 유니티 엔진에서는 특별히 콘솔 같은 곳에서 로그를 출력해주지 않아 더 자세한 것은
좀 더 수고를 들여 찾아봐야 한다.
유니티 엔진에서는 C#으로 코딩하기 때문에 C/C++로 된 JNI를 직접 사용하는 경우는 드물다. `AndroidJavaClass`, `AndroidJavaObject` 등
클래스로 Wrapping 된 것을 사용하는데, 결국 이는 내부적으로 JNI를 사용하는 것이기 때문에 사용법만 조금 달라졌을 뿐, C/C++에서
JNI를 사용하는 것과 본질적으로는 비슷하다.
써드파티 Java 클래스들을 사용하기 위해서는 먼저 사용하려는 Java 클래스들을 컴파일하여 jar 파일로 만들어야 한다.
그리고 `Assets/Plugins/Android` 디렉토리에 그 jar 파일을 두면 빌드 과정에서 함께 패키징되므로 Java 측에서 바로 이용하든, JNI로 찾아와서
이용하든 편한대로 사용하면 된다. 웃기는 것은 Temp 디렉토리를 유니티 엔진 에디터가 종료될 때 삭제하도록 되어 있다.
굳이 왜 그랬을까… 그 용량 얼마를 위해…?
예시3: Cocos2d-x에서의 Downloader
Cocos2d-x에는 `Cocos2dxDownloader` 라는 것이 있는데, 인터넷을 통해 어떤 데이터를 다운로드 받아와서 쓰기 위핸 클래스로 생각하면 된다. 정확한 버전은 기억나지 않지만, 안드로이드에서는 네트워킹과 같은 Blocking operation을 수행하는 코드를 메인 쓰레드에서 실행하면
애플리케이션이 꺼져버리게 변경되었다. 그렇기 때문에 HTTP 통신과 같은 작업을 하기 위해서는 백그라운드 쓰레드를 만들고,
그 쓰레드에서 작업을 수행해야 한다.
Cocos2d-x에서는 C++로 코드를 작성하며, `libc`에 포함된 `pthread`를 쓸 수도 있고, ARM 아키텍처로 빌드된 `curl` 라이브러리를 쓸 수도 있다. 하지만 엔진에서는 Java로 만들어진 `Cocos2dxDownloader` 클래스도 잘 쓰고 있다. 다운로딩 기능이 필요할 때 C++ 측에서 JNI로
`Cocos2dxDownloader`를 생성해주는 static 메소드를 찾고 그 메소드를 실행한다. 만들어진 클래스는 안드로이드의 VM 위에서
잘 작업이 수행되고, 작업이 끝나면 native 메소드 실행을 통해 C++ 측으로 Callback 함수를 호출해준다.
JNI에서는 함수 포인터를 넘겨줄 수 없는 한계가 있다. 그렇기 때문에 `Cocos2dxDownlaoder`를 생성할 때 id를 지정해주고,
Callback 함수를 호출할 때는 어떤 다운로더가 끝났는지를 함께 넘겨준다. C++ 측에서는 각 다운로더 id별 Callback 함수들을 따로 관리하고
있기 때문에, Callback 함수로부터 받은 id로 적절한 함수를 찾아 실행해준다.
iOS
iOS에서 애플리케이션이 만들어지고 실행되는 순서는 다음과 같다.
1) 각 소스 파일 컴파일 - ARM 아키텍처의 목적 파일 생성- 2) 각 목적 파일 링킹
- 3) 스토리보드를 비롯한 각종 리소스들 처리
- 4) 애플리케이션 패키징
- 5) 패키지 내에 번들 리소스들 복사
- 6) 패키지 내에 프로비전 파일 내장
- 7) 패키지 내 파일들 기반으로 signing
8) 안드로이드와 다르게 iOS에서 애플리케이션은 완벽하게 네이티브 애플리케이션으로써 실행된다.
하지만 `Framework` 같은 생소한 용어와 `modulemap`, 그리고 안드로이드 작업시에는 만날 일이 드문 CPU 아키텍처 심볼 에러 때문에
많은 사람들이 당황한다. 게다가 XCode는 겉보기에는 많은 부분을 감춘 채 작업하기 때문에 그 속에서는 어떤 작업을 하는지 잘 모르는 경우가 많다. 컴파일러를 비롯하여 좀 더 근본적인 부분부터 살펴보면 앞으로 그 문제들에 대해 덜 고통스러울 것이다.
잠깐. Swift가 이제는 많이 쓰이고 있지만, 이 글에서는 일단 Objective-C 기준으로 설명을 하려 한다.
그 이유는 Swift에서 Static Library를 사용하는 과정이 다소 복잡하고, 아직 많은 엔진들이 Objective-C 기준으로 만들어져 있기 때문이다.
다만 중간 중간 비교를 위해 Swift에 대한 설명을 조금 곁들이도록 하겠다.
Xcode Build 디렉토리
XCode에서 빌드를 하면 `Build` 디렉토리가 생성된다.
이 디렉토리 내의 구조를 알아두는건 앞으로 이야기할 것들을 이해하는데 좀 더 도움이 된다.
`Intermediate` 디렉토리는 말 그대로 중간 파일들을 모아두는 디렉토리다. 컴파일 과정에서 생겨나는 목적 파일, 디버거를 위한 파일들,
후 처리 된 리소스들 등이 이 디렉토리 내에 생성된다.
`Products` 디렉토리에는 빌드 최종 결과물이 위치하게 되는 디렉토리이다.
XCode에서 빌드 대상으로 선택한 기기와 Debug, Release 여부에 따라 이름이 정해지게 되는데, iOS에 한정하여 보면 다음과 같다.
Debug-iphoneos : General iOS Devices 선택 상태에서 Debug 빌드 결과물들이 위치- Debug-iphonesimulator : Simulator 선택 상태에서 Debug 빌드 결과물들이 위치
- Release-iphoneos : General iOS Devices 선택 상태에서 Release 빌드 결과물들이 위치
- Release-iphonesimulator : Simulator 선택 상태에서 Release 빌드 결과물들이 위치
그렇기 때문에 `Debug-iphonesimulator` 디렉토리에 생성된 정적 라이브러리 파일에 대한 아키텍처 정보를 보면
x86_64와 같은 Intel 아키텍처로, `Debug-iphoneos` 디렉토리에 생성된 정적 라이브러리 파일에 대한 아키텍처 정보를 보면
arm64와 같은 ARM 아키텍처로 나타난다.
LLVM 빌드 과정과 그 결과물
위에서 iOS의 애플리케이션은 VM 같은 것이 없는 네이티브 애플리케이션이라 했다. 어쨌건, C/C++ 류의 언어를 컴파일하여
어떤 실행 가능한 프로그램을 얻기 위해서는 컴파일러가 꼭 필요하다. 애플 진영에서는 그 컴파일러로 LLVM이라는 컴파일러 기반 구조를
운영하고 있다. 그리고 문법 분석, 의미 분석 등을 위한 프론트엔드로 clang이라는 프로젝트를 이용한다. LLVM의 빌드 과정은 다음과 같다.
- clang으로 컴파일하여 Bitcode를 만든다.
- 만들어진 Bitcode를 Optimizer로 최적화 한다.
- 최적화된 Bitcode를 원하는 CPU 아키텍처의 Instruction Set으로 변환한다.
사실 intermediate 파일을 만들고, 그것을 이용하여 최종 Instruction Set의 어셈블리로 만든다는 점에서 기존의 컴파일러 구조와 큰 차이는 없다. 그러니 용어를 숙지하는 차원에서 언급한 것으로 알고 넘어가자.
안드로이드는 어떤 작업을 하기 위해 세부적으로 숨겨진 옵션을 많이 건드려야 하는 경우가 흔치 않지만 iOS는 은근히 그런 경우가 많다.
그래서 앞서 Android 빌드 과정에서는 실행 파일이 만들어지기까지의 과정에 대한 자세한 언급 없이 넘어갔지만, iOS의 빌드 과정에서는
설명이 필요할 것 같으므로 아래 배경 지식들에서 설명한다.
링킹
링킹은 나눠진 각각의 목적 파일들을 연결(Link) 하는 작업이라는 의미에서 링킹이라 불린다.
링킹은 정적 링킹(Static Linking)과 동적 링킹(Dynamic Linking)으로 나뉜다.
`gcc -o output file1.o file2.o` 와 같이 여러 목적 파일을 링킹한 경험이 있을 것이다. 이렇게 링킹하는 것을 컴파일 타임에 링킹하기 때문에
정적 링킹이라 한다. 정적 링킹을 할 때 Static Library(정적 라이브러리)도 함께 링킹할 수 있다. `gcc -o output main.c -lsome.a` 와 같이
명령어를 실행하면 `main.c` 소스 파일과 함께 `libsome.a` 라는 정적 라이브러리를 함께 링킹하여 output 이라는 실행 파일을 만들겠다는 뜻이다.
(`-l` 옵션 뒤에는 라이브러리의 파일명에서 `lib` 를 뗀 이름이 온다.) 정적 라이브러리는 보통 `*.a`, `*.lib` 파일로 많이 알려져 있다.
동적 링킹은 실행 파일을 먼저 실행한 뒤 링킹되는 것을 말한다. 정적 링킹을 하는 경우에는 실행 파일에 정적 라이브러리가 포함되어야 하기
때문에 실행 파일의 용량도 커지고, 메모리에 올려둬야 할 코드 영역도 너무 커지게 된다.
동적 라이브러리는 여러 프로세스가 동일한 코드 영역을 공유할 수 있도록 함으로써 실행 파일의 용량과 필요한 메모리 양을 줄여준다.
윈도우에서 `C:\Windows\System32` 디렉토리에 수많은 `dll` 파일들이 모여있는 것을 본 적 있을 것이다.
윈도우에서는 dll 파일이 동적 라이브러리에 해당되는 파일인데, 윈도우 실행 파일은 처음 실행되면서 동적 링킹 대상 dll 파일을 찾기 위해
`C:\Windows\System32` 디렉토리를 먼저 뒤져본 뒤, 파일을 찾으면 링킹 작업을 수행한다.
링킹에 대해서 너무 깊게 다루는 것은 글의 초점을 흐리게 할 것 같으므로 이 정도까지만 설명하고, 애플 진영과 iOS에 한정된 지식을
좀 더 알아보자. 링킹을 더 알아보고 싶으면 아래 레퍼런스를 참고.
리눅스에서 `/usr/lib` 디렉토리에 각종 시스템 공용 라이브러리가 있듯이, iOS에서도 마찬가지다. iOS라는 OS도 리눅스 OS와 비슷하게
시스템 공용 라이브러리가 `/usr/lib` 디렉토리에 존재한다. 애플 진영에서는 동적 라이브러리를 `dylib` 라는 확장자로 표시하는데,
예를 들면 `libz` 라이브러리의 핸들을 얻은 뒤 `deflate` 함수에 대한 포인터를 얻기 위해 다음과 같이 코딩한다.
사용자 정의 프레임워크 빌드 결과물 뜯어보기
시스템 라이브러리가 아닌 것들을 사용하는 경우에는 어떻게 될까?
예를 들어, 내가 만든 라이브러리를 포함시켜 사용하기 위해서는 어떤 작업이 필요한지 알아보자.
이를 위해 샘플 프로젝트로 `SimpleProjectWithFramework` 프로젝트를 만들었다.
XCode에서는 각각 빌드 결과물을 Target이라는 용어로 부른다. 이 프로젝트는 SimpleProjectWithFramework라는 iOS 애플리케이션 타겟과
SimpleFramework라는 Cocoa Touch 프레임워크 타겟으로 구성되어 있다. 여기서 Cocoa Touch 프레임워크는 앞서 언급한 dylib와 비슷하다고
생각하면 되는데, iOS 어느 버전부터는 dylib를 포함시켜 빌드한 애플리케이션은 배포할 수 없게 바뀌었다. 대신, framework 확장자를 가진
패키지들이 기존의 동적 라이브러리 파일 역할을 수행한다.
위 스크린샷은 SimpleFramework.framework의 내용물이다. Headers 디렉토리에 Public 헤더로 추가했던 파일들이 위치하고,
Modules 디렉토리에는 `module.modulemap` 이라는 파일이 존재한다. 이 파일은 프레임워크 내에 있는 모듈들을 찾기 위해 존재하는 파일이다. Umbrella Header와 어떤 모듈을 노출시킬 것인지에 대한 설정 파일으로, clang에서 컴파일 할 때 이 파일을 참고하도록 되어 있다.
그리고 Umbrella Header 이름은 보통 프레임워크 이름으로 지정되어 있다.
이는 Swift 언어가 추가되면서 생긴 것으로 보인다. C/C++ 계열과 비슷하게 `#import` 매크로로 헤더 파일을 추가하던 방식에 비해
Swift에서는 `import FrameworkName` 과 같이 클래스를 import 하도록 바뀌면서, 이에 대한 언어간의 대응을 위해 필요하게 되었다.
이 modulemap 파일을 통해 컴파일러는 컴파일 과정에서 어떤 심볼이 무슨 모듈에 대한 심볼인지 알 수 있게 되는 것이다.
Modules 디렉토리에 대한 좀 더 극명한 예를 위해 언어를 Swift로 설정한 SimpleSwiftFramework 타겟의 빌드된 결과물에서
Modules 디렉토리를 스크린샷으로 찍었다. `arm64.swiftmodule` 이라는 파일이 추가된 것을 알 수 있다. (`arm64.swiftmodule` 이라
이름 붙게 된 것은 General iOS Device를 선택하여 빌드했기 때문이다. Simulator를 선택하여 빌드하면 `x86_64.swiftmodule` 이라는 파일로
생성된다.) Swift 언어는 Java와 같은 언어와 다르게 컴파일 결과 생성되는 목적 파일을 이용하는데, 이는 C 계통 언어와 비슷하다.
프레임워크 내에 여러개의 Swift 소스 파일이 존재할 수 있기 때문에, 컴파일된 목적 파일들은 swiftmodule 파일로 모두 합쳐지며,
이는 Xcode 빌드 메세지에서도 확인할 수 있다.
사용자 정의 프레임워크 사용
어쨌건, 만들어진 프레임워크를 사용하여 애플리케이션 개발을 할 수 있어야 한다.
다른 프레임워크를 import해와서 사용하는건 어떻게 되는걸까?
먼저 컴파일 타임을 생각해보자. 사용하려는 프레임워크의 심볼을 찾아 존재하는 것을 확인해야한다.
따라서, 컴파일 할 때는 프레임워크를 마치 헤더 찾듯이 찾을 수 있어야 한다. 그러기 위해 clang으로 컴파일 할 때 `-F` 옵션으로
프레임워크 검색 디렉토리를 추가해준다. 프레임워크 검색 디렉토리에서 사용하려는 프레임워크가 존재하면 그 안의 modulemap을 통해
Umbrella 헤더를 import 할 수 있음을 알게된다. 정적 링킹을 할 때도 마찬가지로 `-F` 옵션으로 프레임워크 검색 디렉토리를 추가해주고,
그 중에서도 링킹 대상 프레임워크의 이름을 `-framework` 옵션으로 지정해준다. 예를 들면 `-framework SimpleFramework` 와 같은 식이다.
SimpleFramework.framework는 시스템에 미리 존재하는 프레임워크가 아니므로 애플리케이션에 함께 포함되어 있어야 한다.
프로젝트 세팅에 대한 스크린샷을 보면 SimpleProjectWithFramework 타겟에서는 Embedded Binaries로 SimpleFramework.framework를
포함하고 있는 것을 볼 수 있는데, 이렇게 프레임워크를 Embedded Binaries에 추가하면 애플리케이션이 빌드될 때 애플리케이션의
패키지 내에 해당 프레임워크를 포함시켜준다. 그 결과, 빌드된 iOS 애플리케이션의 내부는 다음과 같다.
빌드된 iOS 애플리케이션은 `SimpleProjectWithFramework.app` 이라는 패키지 파일인데, 맥에서 더블 클릭하면 실행할 수 없는
애플리케이션이라는 에러 메세지가 뜬다. 사실 이 파일은 디렉토리라서 오른쪽 클릭 후 패키지 내부 보기 옵션을 선택하면 패키지 내 파일들을
볼 수 있는데, 파일들을 살펴보면 Frameworks라는 디렉토리 내에 SimpleFramework.framework가 추가된 것을 볼 수 있다.
이를 통해 iOS에서 애플리케이션을 실행할 때 Frameworks 디렉토리 내의 framework들을 모두 동적 링킹을 해줄 것음을 추측할 수 있다.
윈도우에서 프로그램을 배포할 때 사용중인 dll 파일을 함께 패키징하여 배포하는 것과 같은 맥락이라 생각하면 이해하기 쉬울 것이다.
참고로, Objective-C 프로젝트에 Swift로 만들어진 프레임워크를 쓰고자 하는 경우에는 `Always Embed Swift Standard Libraries` 옵션을
Yes로 설정하면 되는데, 이는 Swift를 위한 표준 라이브러리들을 동적 링킹시키겠다는 의미이다. 이렇게 하면 Frameworks 디렉토리에
`libswiftCore.dylib`, `libswiftFoundation.dylib` 와 같은 표준 Swift 라이브러리들이 추가되며, 동적 링킹시 이 표준 라이브러리들과
링킹시킴으로써 정상적으로 Swift의 심볼들을 이용할 수 있게 된다.
덤. `ipa` 파일은 위의 애플리케이션 패키지를 그냥 압축한 것이므로 결과적으로는 큰 차이 없다고 보면 된다.
정적 라이브러리 사용
위에서 언급한 것들은 동적 라이브러리에 해당하는 프레임워크에 대한 이야기였다.
정적 라이브러리는 링킹에 대해 설명한 것처럼 컴파일 타임에 함께 링킹되어야 한다.
XCode에서는 정적 라이브러리를 Embedded Binaries 메뉴 아래의 `Linked Frameworks and Libraries` 메뉴를 통해 추가하도록 되어 있다.
만약 여기에 정적 라이브러리를 추가해주지 않은 상태로 빌드할 경우 다음과 같은 에러가 발생할 것이다.
SimpleStaticLibrary라는 심볼을 링킹하려 하는데 찾지 못했다는 의미의 에러 메세지이다.
XCode 프로젝트에서 타겟 추가를 통해 정적 라이브러리를 추가하는 경우 자주 만나게 되는 에러 메세지이다.
`Product/SCHEMA/include` 디렉토리는 기본적으로 헤더 검색 대상 디렉토리에 추가되어 있기 때문에 해당 소스만을 컴파일 할 때는
헤더 파일을 통해 심볼을 찾을 수 있어 에러가 나지 않지만, 소스 컴파일 후 링킹 과정에서는 정작 해당 심볼의 내용물이 들어있는
정적 라이브러리 파일을 찾을 수 없기 때문이다. Linked Frameworks and Libraries 메뉴에 원하는 정적 라이브러리를 추가해주면
링킹 명령어에 해당 라이브러리 파일을 명시해주기 때문에 에러가 나지 않게 된다.
또한, 이 링킹 대상은 반드시 같은 CPU 아키텍처로 빌드된 것이어야 한다.
예를 들어 arm64 ABI로 빌드된 목적 파일에 x86_64 ABI로 빌드된 정적 라이브러리를 링킹하려 하면 arm64 아키텍처를 위한 심볼을 찾을 수
없다는 에러가 뜰 것이다. 덤으로, 정적 라이브러리 만드는 것에 대해서는 이 글을 추가로 살펴보는 것도 좋다.
tbd 파일
tbd는 `text-based stub libraries` 의 약자이다.
이제는 개발자에게 노출되는 라이브러리, 예를 들면 Foundation 프레임워크 같은 라이브러리들은 직접적으로는 tbd 파일을 가지고 있기만
한 것으로 바뀌었다. tbd 파일은 라이브러리를 설명하는 파일이라고 볼 수 있다. 아래는 libsqlite3.tbd 파일의 내용 중 일부이다.
내용물을 보면 어떤 아키텍처에 대해 어떤 심볼들이 존재하고, 또 어떤 dylib를 참조하고 있는지 등에 대한 내용이다.
libsqlite3는 OSX에서도, iOS에서도 `/usr/lib` 디렉토리에 미리 존재하는 라이브러리인데, 이런 것들을 잘 관리하기 위해
정리된 모듈 맵 파일 같은 것이다. tbd 파일에 명시된 정보를 바탕으로 앱스토어에서 애플리케이션을 다운로드 받을 때
[다운로드 용량을 줄일 수 있다는 글](https://forums.developer.apple.com/message/9176#9176)을 애플측 포럼에서 볼 수 있지만,
나도 실험을 해본 것은 아닌지라 확신은 못하겠다. 일단은 여러 심볼들과 빌드된 바이너리의 CPU 아키텍처, 공유 라이브러리의 위치 등의
정보를 제공한다는 것 정도까지만 알고 있어도 이해하는데 문제는 없다.
이 파일을 참조하여 컴파일 할 때 심볼이 존재하지 않으면 심볼이 존재하지 않다는에러를 띄울 수 있고,
애플리케이션 프로세스를 띄운 뒤 동적 링킹을 할 때는 어떤 경로에 공유 라이브러리가 있는지 찾아 로드한 뒤 링킹할 수 있다.
Packaging, Provisioning 및 Signing
이제 링킹까지 끝내고 iOS 기기들의 CPU인 ARM 아키텍처에서 실행될 수 있는 실행 파일 만들기까지 할 수 있다.
마지막으로 필요한 것은 패키징 작업 및 프로비저닝, 사이닝이다.
패키징 작업은 필요한 파일들을 정해진 규칙에 맞게 디렉토리 구조로 만드는 작업이다.
실행 파일과 Info.plist 파일, 각종 이미지, 스토리보드 리소스 등을 적절한 디렉토리에 둔다.
필요한 경우 Frameworks 디렉토리 내에 사용 중인 프레임워크들을 둔다.
여기에 프로비전 파일이 임베디드 된다. 별 거창한 것은 아니고, 개발자 등록을 하고 애플 개발자 콘솔에서 만든 프로비전 파일을 패키지에
둠으로써 누가 만든 애플리케이션인지 알도록 하는 것이다. 간단하게 프로비전 파일을 복사하여 패키지 내에 위치시키는 것으로 된다.
마지막으로 `codesign`으로 사이닝 작업을 한다. 패키지 내에 있는, 프레임워크들까지 포함한 모든 파일들에 대해 특정 키로 연산한 결과값을
리스팅한다. 이로써 누군가 패키지 내 파일을 변조한 뒤 다시 배포하는 경우 값 대조를 통해 변조 여부를 알 수 있게 된다.
사이닝 결과 값들은 `_CodeSignature/CodeResources` 파일에 있다. 아래는 그 내용의 일부이다.
xcodebuild 명령어 사용
XCode 프로젝트 혹은 워크스페이스 파일에 이미 저장된 설정들을 기반으로 빌드 명령어를 실행할 수 있다.
`man xcodebuild` 명령어로 매뉴얼을 보면 더 자세한 설명을 볼 수 있는데, 이 항목에서는 매뉴얼 페이지의 유용한 예제 몇 개만 소개한다.
- `xcodebuild clean install`
- Products 디렉토리를 정리한 뒤 프로젝트의 첫 번째 타겟을 빌드하여 설치한다.
- `xcodebuild -project MyProject.xcodeproj -target Target1 -target Target2 -configuration Debug`
- MyProject 프로젝트의 Target1, Target2를 Debug 모드로 빌드한다.
- `xcodebuild archive -workspace MyWorkspace.xcworkspace -scheme MyScheme`
- MyWorkspace의 MyScheme에 해당하는 빌드 결과를 아카이빙한다.
이렇게 명령어를 실행하면 XCode의 UI에서 빌드하는 것과 동일하게 작업을 수행할 수 있다.
다만, Provisioning, Signing 작업을 위해 프로젝트 세팅에서 미리 개발자 정보를 세팅해두는 것이 불필요한 삽질을 막을 수 있는 길이다.
컴파일 및 빌드 과정에 대해 다루다보니 배경 지식 설명이 길었다. 이제 예시를 보자!
예시1: 언리얼 엔진에서의 iOS 빌드
언리얼 엔진에서는 iOS 빌드 역시 Automation Tool로 빌드 과정이 감싸져있다.
특이사항으로, 언리얼 엔진의 Automation Tool, Build Tool, Header Tool 등이 mono로 실행된다. 각각의 툴들은 C#으로 짜여져 있어
.NET Framework 기반이기 때문에 OSX에서도 mono로 실행 가능하기 때문이다. 그리고 블루프린트 프로젝트가 아닌 C++ 네이티브 프로젝트는 OSX에서만 iOS 패키징이 가능하다. 블루프린트 프로젝트는 블루프린트 관련 리소스만 추가 바이너리로 업데이트하고 Siging만 하면 되지만,
C++ 네이티브 코드들은 빌드를 위해 iOS SDK와 XCode 빌드 툴 체인이 꼭 필요하기 때문이다.
clang은 C/C++ 문법과 Objective-C 문법을 섞어 코드를 짜도 컴파일이 정상적으로 되는 놀라운 컴파일러다.
이 덕분에 C++로 짜여진 모듈들을 고통 없이 컴파일하여 사용할 수 있다. 홈페이지에서 배포하는 엔진을 받으면 iOS 관련 엔진 소스들이
이미 iOS SDK 기반으로 정적 라이브러리로 빌드된 상태이기 때문에 프로젝트의 코드들만 빌드한 뒤 링킹하여 최종 정적 라이브러리를 생성한다.
만약 소스 기반으로 빌드를 진행한다면 엔진 소스를 빌드하는 것부터 시작할 것이다.
엔진 소스를 빌드할 때 iOS SDK 기반으로 컴파일되는 것이다.
만들어진 정적 라이브러리는 그냥 사용된다. iOS 네이티브 애플리케이션에서 빌드된 정적 라이브러리의 이런 저런 메소드를 그냥 호출하여
사용할 수 있다. 왜냐하면 그 라이브러리는 iOS 네이티브로 컴파일된 정적 라이브러리니까! 원래 XCode의 Objective-C 언어 기반
iOS 프로젝트를 만들면 기본적으로 만들어지는 AppDelegate 같은 것도 언리얼 엔진의 소스에 이미 포함되어 있다.
`IOSAppDelegate.cpp`, `LaunchIOS.cpp` 파일 등을 참고해보면 iOS 애플리케이션의 라이프사이클 위에서 언리얼 엔진을 잘 실행하는 것을
확인할 수 있다.
예시2: 유니티 엔진에서의 iOS 빌드
유니티 엔진에서는 iOS 빌드시 XCode 프로젝트와 파일들을 만들도록 되어있다.
iOS 빌드를 하면 지정한 디렉토리에 XCode 프로젝트가 생긴다.
위 스크린샷에서 포커스 되어 있는 파일은 유니티 엔진에 대한 정적 라이브러리이다.
유니티 엔진이 작동하기 위해 Mono를 비롯한 여러가지 모듈들이 한 정적 라이브러리로 제공된다. 물론 이 파일은 유니티 엔진이 배포될 때
함께 받은 라이브러리이다. `/Applications/Unity/PlaybackEngines/iOSSupport/Trampoline/Libraries` 디렉토리에 존재하고, iOS 빌드를 할 때
복사된다. 만들어진 XCode 프로젝트에서 애플리케이션 타겟 설정을 확인해보면 당연히 이 정적 라이브러리가 Linked Frameworks and Libraies로 추가되어 있다.
만들어진 XCode 프로젝트 내부를 보면 이런 저런 소스가 굉장히 많은 것을 알 수 있다.
살짝 더 뒤져보기 위해 `Rotator`라는 간단한 C# 소스 파일을 만들고, 여기에 `Debug.Log` 메소드를 사용하는 메소드를 추가한 뒤
다시 iOS 빌드를 하도록 해봤다. 아래는 그 결과 생성되는 cpp 파일의 내용 중 일부이다.
`Debug_Log_m920475918` 이라는 함수를 사용하는 것이 보이는가? 이 함수는 `Build_UnityEngine_0.cpp` 소스에 있는 함수인데,
이 소스 파일 내용을 보면 Debug 클래스를 비롯한 온갖 클래스에 대한 Wrapping 함수들로 가득한 것을 알 수 있다.
그렇다. 유니티에서 작성한 C# 코드들은 C++ 소스로 변환되면서, 원래 C#에서 호출하려 했던 함수를 C++에서 호출하도록 interpret 된다.
이것이 유니티 엔진의 작동 원리였던 것이다.
어쨌건, 이렇게 프로젝트 세팅이 만들어진 뒤라면 기존의 iOS 애플리케이션 빌드하는 것과 마찬가지로 XCode에서 원하는 빌드 Scheme으로
빌드를 하기만 하면 된다. 아마 General iOS Devices를 대상으로 Release 모드로 빌드하여 나온 애플리케이션을 배포하면 될 것이다.
예시3: Cocos2d-x
Cocos2d-x는 최근에는 어떻게 바뀌었는지 모르겠지만, 예전에 내가 작업할 때(3.x 버전) 기준으로 보면 완전히 XCode 기반의 작업이다.
Cocos2d-x에서는 템플릿 프로젝트를 복사해주는 것으로 유저의 프로젝트를 생성해주는데, 그 프로젝트의 구조는 다음과 같다.
- Cocos2d-x 엔진을 위한 XCode 프로젝트가 존재하고, 이 프로젝트는 Cocos2d-x 엔진을 정적 라이브러리로 빌드하는 것을 담당한다.
- 유저의 프로젝트는 Cocos2d-x 엔진을 위한 XCode 프로젝트를 하위 프로젝트로 두고 Target Dependency를 걸고,
Cocos2d-x 엔진의 정적 라이브러리를 유저 프로젝트의 Linked Frameworks and Libraries에 추가한다. - Cocos2d-x 엔진의 소스가 빌드된 적이 없거나 수정되는 경우에는 유저의 프로젝트를 빌드할 때 함께 빌드된다.
누누히 말해온 것처럼 clang은 C++ 빌드를 문제없이 진행할 수 있기 때문에 이 프로젝트를 iOS SDK 기반으로 컴파일한 결과물을
이용하기만 하면 된다.
마치며
이것으로 각 모바일 OS에서의 빌드 과정과 대표적인 게임 엔진이라 할 수 있는 언리얼, 유니티, Cocos2d-x 엔진에서의 빌드 과정에 대해
설명을 마친다. 모바일 게임을 개발하면서 네이티브 OS에 대한 작업을 하는 것을 피할 수 없지만 게임 개발자들은 대체로 게임 개발 자체만
집중해서 학습을 하다보니 사소한 네이티브 관련 작업도 큰 고통이 되곤 한다.
시간을 투자해서 자세한 프로세스를 보고 나면 그러한 작업과 네이티브 OS와 직결되는 문제 해결에 큰 도움이 되지만 게임 개발이라는게
워낙 일정에 치이는 경우가 많아서 그런 시간 투자를 쉽사리 할 수 없는 것이 현실이다. 나 역시 이 짧은 글로 완벽히 빌드 프로세스를
전달하는 것은 너무 큰 기대라고 생각하기 때문에 이 글을 바탕으로 앞으로 문제 해결을 할 때 조금의 도움이나마 된다면 충분히 가치있다고
생각한다. 부디 많은 게임 개발자들에게 도움이 되었기를!