티스토리 뷰

최근 cocos2d-x로 작업을 하면서 `JNI`를 좀 알아봐야 했다. 먼저 글에서는 편의상 `C/C++``Native`라고 말함을 알린다. 이에 따라 JNI가 왜 `Java Native Interface`라고 이름지어졌는지 알 수 있을 것이다. 이 글에서는 Java 자체보다는 안드로이드 환경, 그리고 크로스 플랫폼 환경에 대한 설명에 좀 더 초점을 맞춰 썼다. 실행 환경 자체는 맥과 안드로이드이기 때문에 리눅스에 치중되어 있지만, 공유 라이브러리와 빌드 개념 자체에 대한 이해만 있으면 된다. 


앞서 cocos2d-x를 언급한 것에서 눈치챘겠지만, 게임 엔진측 코드(C++)에서 안드로이드 플랫폼에 의존성이 있는 부분(Java)을 처리하기 위해 삽질하면서 정리한 것이다. JNI를 쓰는 방법에 대한 자료 조사를 하면서 대충 사용법만 찾는 것은 어렵지 않았다. 하지만 안드로이드 환경에서 JNI라는 기능을 어떻게 사용할 수 있는지, 그리고 이게 실제로는 어떻게 동작되는지에 대한 설명이 부족함을 느꼈다. 개념 차원에서의 정리를 해두면 많은 분들에게 도움이 되지 않을까 하여 정리해본다.


매우 잘 정리되어 있는 영어 문서를 참고 자료로 보시길 권한다.



## 서론


여러가지 이유에 의해서 Java에서 Native 함수를 호출하거나, Native에서 Java의 메소드를 호출해야 할 일이 생긴다. 예를 들면 안드로이드에서 기기 정보를 읽어오는 일을 생각해보자. 안드로이드 SDK는 Java로 제공되기 때문에 Native에서 그냥은 Java로 제공되는 API를 호출하여 결과를 얻을 수가 없다. 따라서, Java로 구현된 메소드를 호출할 수 있는 방법이 꼭 필요하다. JNI는 바로 그것을 위한 것이다. 이 정도면 필요성에 대해서는 충분히 전달이 되었으리라 생각하고, 배경 지식에 관련된 부분을 설명하고자 한다.


### JVM 환경


Java는 모두들 알듯이 `JVM`이라는 가상환경에서 구동된다. `javac`라는 컴파일러를 통해 Java 코드를 컴파일하여 바이트 코드를 만들고, 해당 바이트 코드는 JVM 위에서 실행된다. 이해를 돕고자 다른 예를 들자면, `javac`로 Java 코드를 컴파일하고 나면 어떤 어셈블리 코드(바이트 코드)가 만들어지고, 그 어셈블리 코드를 실행해주는 컴퓨터, OS(JVM)가 또 따로 존재한다고 생각하면 된다. 


어쨌든 컴파일 된 바이트 코드는 실행할 때 JVM에 어떤 방식으로든 로드되어 있다. 사실 이것은 OS에서 프로세스를 실행시킬 때의 이야기와 비슷하다. 흔히 프로세스를 실행하면 OS가 프로세스를 위해 코드 영역, 데이터 영역, 힙 영역, 스택 영역을 할당해준다고 알려져있다. 코드 영역에 실행하고자 하는 명령어(Instruction)들이 쓰여 있는 것이고, 어떤 코드를 실행할 때는 해당 코드가 존재하는 메모리 영역의 데이터를 CPU로 불러와 실행하는 것이다. 


이 부분을 현재 문제 상황에 맞게 간략화하면 다음과 같다: 어떤 함수를 실행하기 위해서는 원하는 함수가 존재하는 위치를 찾고, 그 위치에 존재하는 명령어들을 실행하면 된다.



### 심볼 테이블과 타입 시그니쳐(Type Signature), 네임 맨글링(Name Mangling)


컴파일러에 의해 소스 코드가 컴파일되면서 심볼 테이블이 만들어진다. 심볼 테이블은 심볼로 이뤄진 테이블인데, 클래스 이름, 함수 이름, 변수 이름 등이 심볼에 해당된다. 예를 들어 다음과 같은 코드가 있다고 하자.



`Helloer`, `say(std::string)`, `say(std::string, std::string)`, `myName`, `firstName`, `lastName` 등이 모두 심볼에 해당한다. C++은 함수 오버로딩을 지원하는 언어이기 때문에, 심볼 테이블을 만들 때 함수 이름이 같더라도 인자 타입에 따라 다른 함수로 처리해준다. `say(std::string)` 메소드와 `say(std::string, std::string)` 메소드는 메소드 이름이 같을 뿐, 완전히 다른 메소드다. 코드에서 `say(std::string)` 메소드를 호출하게끔 했는데 `say(std::string, std::string)` 메소드가 호출되면 잘못된 실행이지 않은가? 어셈블리 레벨에서 간단하게 생각하면 함수를 호출한다는 것은 다음 실행할 명령어 위치를 다른 위치로 바꾸는 것인데, 심볼 테이블을 통해 그 위치를 정하는 것이다. 따라서 `say(“Loki”, “Jung”)`과 같이 호출할 경우 `say(std::string, std::string)`에 대한 심볼로 테이블에서 주소를 찾고, `say(“Loki Jung”)`과 같이 호출할 경우 `say(std::string)`에 대한 심볼로 테이블에서 주소를 찾아야 한다. 이렇게 심볼의 주소를 찾아 연결해주는 것을 링킹이라고 한다.


이렇게 타입과 인자 갯수에 따라 다르게 보면서 타입 시그니쳐(Type Signature)라는 것을 곁들여서 생각하면 좋다. 사실 변수명 같은건 이미 심볼 테이블에 정리되었으므로 중요한건 어떤 타입이 쓰이는지이다. `say(std::string)` 메소드를 시그니쳐로 풀어보면 `(std::string)(std::string);` 이고, `say(std::string, std::string)`을 풀면 `(std::string)(std::string, std::string)`이다. 딱 보면 알겠지만 그냥 변수명을 빼고 타입에 대한 정보만 남겨놓은 것이다.


네임 맨글링 작업은 쉽게 생각했을 때, 시그니쳐, 네임 스페이스 등을 풀어서 심볼을 유니크하게 만드는 것이다. 위키피디아 예제에 너무 잘 설명되어 있어서 예제를 위키피디아의 내용으로 잠깐 바꿔보자. wikipedia 네임 스페이스의 article 클래스의 print_to 메소드는 `_ZN9wikipedia7article8print_toERSo`라는 이름의 메소드가 된다. 컴파일 후 만들어지는 오브젝트 코드에서는 이런 풀어헤쳐진 이름으로 바뀐 코드가 되어 있고, 링킹 과정을 거치면서 심볼 테이블에서 적절한 주소값으로 대체된다고 보면 컴파일 후 링킹하는 과정을 대충 설명한 것이다. 이것에 대해서 자세히 설명하려면 너무 글이 글어지기 때문에 이만 줄이겠다. (이미 길어진 것 같지만)


위 예제는 C++ 얘기였다. 하지만 개념 자체는 Java에서도 비슷하게 적용된다. `String String.substring(int, int)` 메소드는 `Ljava/lang/String/substring(II)Ljava/lang/String;` 으로 풀어헤쳐져서 바이트코드에 명시될 것이다. `Ljava/lang/String/substring` : java.lang 패키지의 String 클래스의 substring 메소드는 `(II)` : int 타입 인자 2개를 받고, `Ljava/lang/String;` : java.lang 패키지의 String 타입을 리턴한다는 의미이다. 이 부분을 알아야 하는 이유는, 나중에 어떤 클래스의 메소드를 찾아올 때 이 심볼 정보를 통해 찾아오기 때문이다. JVM에서는 이러한 심볼 테이블을 따로 저장해두고 있기 때문에 런타임에 심볼 정보를 통해 동적으로 타입을 찾을 수 있다.





### 공유 라이브러리


윈도우에서는 `.dll`, 리눅스에서는 `.so` 파일 익스텐션으로 익숙한 그것이다. 작동 원리는 정적 라이브러리와 링킹 과정에서 차이가 있다. 자세한 것은 따로 찾아보고, 중요한 것은 프로그램이 실행될 때 공유 라이브러리와 결합된다는 것이다. 공유 라이브러리는 프로그램 실행시 결합이 일어나기 때문에 미리 로드시켜놔야 한다. Java에서 익숙한 jar 파일도 Java 프로그램이 실행되면서 미리 로드 시켜놓고, `.so` 파일도 미리 로드되는 등, 방식이 비슷하다.


안드로이드 프로젝트를 생각해보자. 다들 사용하고자 하는 라이브러리들(jar)을 함께 패키징하여 빌드한 경험이 있을 것이다. 앞서 설명한 것과 같이, Java 프로그램은 실행하면서 jar를 함께 JVM에 올려둔다. 이렇게 JVM에 올리면서 라이브러리의 심볼 테이블도 함께 메모리에 올라가는 것이다. 그렇기 때문에 JNI를 통해 클래스나 메소드를 찾을 때 jar에 담긴 라이브러리의 클래스나 메소드를 찾는다고 해서 특별히 다른 방법이 요구되지 않는다. 자세한 찾는 방법을 알 필요 없이, JVM이나 JNI에서 제공해주는 인터페이스를 이용하여 사용하기만 하면 된다.


앞서 언급한 `.so` 파일과 같은 공유 라이브러리를 JVM에 올려주는 기능도 제공된다. `System.loadLibrary` 메소드가 바로 그것이다. 이 메소드의 인자로 적절한 공유 라이브러리 파일 경로를 넣어주면 JVM이 메모리에 잘 올려준다. JNI 기반으로 잘 컴파일된 공유 라이브러리라면 JNIExport 되어 있는 메소드는 자동으로 Java의 메소드에 맵핑까지 해준다. `public native int intMethod(int n)` 과 같은 메소드 프로토타입 선언을 해두면 로드한 공유 라이브러리에서 형식이 맞는 함수를 찾아서 맵핑해준다. 



### 안드로이드 NDK와 JNI


안드로이드는 결국 리눅스 기반의 OS이다. 권한에 의해 불가능한 일들을 제외하고는 데스크톱 리눅스 환경에서 할 수 있는 일은 할 수 있다. `adb shell` 명령어를 실행해보면 안드로이드 기기에서의 shell이 열리는 것을 확인할 수 있다. 간략하게 생각해보면 이러한 안드로이드 OS 위에서 JVM을 구동하고, JVM을 이용하여 일반적으로 개발하는 Java 언어로 작성된 애플리케이션을 실행시켜주는 것이다.


하지만 여기서 유념해둬야 할 것이 있다. 윈도우 환경을 대상으로 만들어진 실행 프로그램은 당연히 리눅스 환경에서 실행이 안 된다. CPU x86 아키텍쳐를 대상으로 컴파일된 코드는 ARM 아키텍쳐에서 그냥 실행되지 않는다. 마찬가지로 윈도우, 맥 환경을 대상으로 컴파일 된 공유 라이브러리는 안드로이드 환경에서 실행이 안 되거나, 안 될 가능성을 갖고 있다. 그리고 안드로이드에는 많은 API 버전이 존재한다. 복잡하다. 이를 해결하기 위해 NDK라는 툴셋이 제공된다. 


좀 더 자세히 알아보자. 일단 `Application Binary Interface` 라는 것이 있다. 안드로이드는 여러가지 CPU 아키텍쳐 위에서 구동될 수 있게 디자인되어 있다. 따라서 다른 CPU 아키텍쳐에서는 다른 명령어 셋으로 이뤄진 어셈블리 코드로 빌드되어야 한다. `armeabi`, `armeabi-v7a`, `arm64-v8a`, `x86`, `x86_64`, `mips`, `mips64`와 같이 정말 많은 아키텍쳐가 존재한다. (사실 `armeabi`, `armeabi-v7a`이 대부분이긴 하다.) NDK를 이용하여 빌드하면 이 부분에 있어서 도움을 받을 수 있다. NDK 빌드시 참조되는 `Android.mk` 파일에서 `TARGET_ARCH_ABI` 변수에 어떤 ABI를 지원할 것인지 써주면 된다. 


안드로이드에는 OS 버전이라는 개념도 존재한다. 안드로이드 OS 버전에 따라서 어떤 기능은 존재하고, 어떤 기능은 존재하지 않기 때문에 이에 대한 처리가 필요하다. Target SDK 버전을 통해 빌드될 때 자동으로 처리되는데, 보통 가장 최신의 버전을 쓰면 빌드할 때 안드로이드 SDK에 의해서 자동으로 처리되지만, 프로젝트에서 이에 대한 명시는 꼭 필요하다. JNI도 마찬가지로 안드로이드 버전 문제가 있다. 당장 차이를 확인하고 싶다면 NDK 디렉토리에서 `platforms` 디렉토리를 확인해보자. `android-9`에서는 GLES3 헤더와 라이브러리가 없지만 `android-24`에서는 존재한다. 이에 대한 처리도 NDK를 통해 빌드할 때 자동으로 처리할 수 있다. `TARGET_PLATFORM` 변수에 그냥 명시해주면 된다. 이제는 `android-24` 정도로 써주면 될 것이다.


여기서 빌드 과정에 대해 조금만 더 언급하고 넘어가보자. NDK로 Native 빌드를 할 때 어떤 컴파일러가 쓰일까? NDK의 `toolchains` 디렉토리를 보면 몇 가지 플랫폼에 따른 prebuilt 파일들이 존재한다. 아마 맥에서는 `llvm` 디렉토리에 있는 것들이 쓰일 것이다. 우리가 Native 코드를 작성하면서 `#include <jni.h>` directive를 쓰면 어떻게 JNI 라이브러리를 쓸 수 있게 되는 걸까? `platforms/android-*/arch-arm/usr/include` 디렉토리리 내에 `jni.h` 파일이 존재한다. `TARGET_PLATFORM``android-24`, `TARGET_ARCH_ABI``armeabi`로 명시했으면 NDK 빌드를 할 때 컴파일러에게 알려주는 헤더 파일 탐색 경로상에 `${NDK_ROOT_PATH}/platforms/android-24/arch-arm/usr/include`가 추가되고, pcc에 의해 `jni.h` 헤더 파일을 잘 처리될 수 있는 것이다. 그리고 아마 빌드 과정에서 포함되는 라이브러리의 오브젝트 코드와 우리의 코드가 링킹되면서 우리가 작성한 JNI 코드가 정상 작동 될 수 있을 것이다.



## 실제 작업


배경 지식들을 소개하느라 서론이 길었다. 이제 실제로 안드로이드에서 Native측에서 Java의 메소드를 호출하는 방법에 대해 알아보자. 배경 지식 설명에 비해 이 부분은 너무 간단해서 당황스러울지도 모른다. 로직은 다음과 같다.


1. 어떻게든 `JNIEnv` 객체를 찾는다. 이 부분을 이렇게 밖에 설명할 수 없는게 어떤 환경이느냐에 따라서 좀 복잡하게 숨겨져 있다. 일단 원칙적으로는 `JNI_OnLoad` 함수를 통해 안드로이드로부터 `JavaVM` 객체를 전달받는다. `JavaVM` 클래스에는 `GetEnv` 메소드가 있는데, 이 녀석으로 `JNIEnv` 객체를 얻을 수 있다.


2. `FindClass` 함수에 `JNIEnv`와 Java 클래스 path를 넣어주고 `jclass` 변수를 얻는다. 즉, 어떤 JVM의 메모리에 올라와있는 심볼 테이블에서 Java 클래스 path에 해당하는 심볼을 찾아오는 것이다. 예를 들어 `org.cocos2dx.lib` 패키지의 `Cocos2dxDownloader` 클래스의 메소드를 사용하고 싶다면 Java 클래스 path는 `org/cocos2dx/lib/Cocos2dxDownloader` 이다. 라이브러리의 클래스, 즉, jar 내에 존재하는 클래스도 상관없다. 안드로이드 빌드를 할 때 해당 jar를 잘 로드하게끔 해두면 된다.


3. 메소드 정보를 찾는다. static 메소드를 호출하려는 경우 `GetStaticMethodID` 함수, 인스턴스 메소드를 호출할려는 경우 `GetMethodID` 함수로 `jmethodID`를 얻어온다. `JNIEnv`와 아까 찾아왔던 `jclass`, 사용하고자 하는 메소드의 이름, 해당 메소드의 시그니쳐를 넣어주면 된다. 시그니쳐 인코딩은 위 배경지식 부분에서 설명한 규칙을 참고하자.


4. `CallObjectMethod`류 메소드로 실제 메소드 호출을 한다. static 메소드면 `CallStaticObjectMethod`류 함수이다. "류"라는 단어를 붙인 이유는 리턴 타입에 따라 `Call(Static)BooleanMethod`, `Call(Static)VoidMethod`와 같이 함수가 여러가지 존재하기 때문이다. `Call(Static)VoidMethod` 함수를 제외하고는 `jobject`, `jboolean`, `jbyte`, `jchar`, `jshort`, `jint`, `jlong`, `jfloat`, `jdouble`를 리턴한다. 


5. `Call(Static)ObjectMethod` 함수로 Java 메소드 호출 결과를 받아온 경우, `jobject`를 얻게 된다. 기본적으로 이는 로컬 레퍼런스이다. 즉, 찾아온 `jobject`를 어딘가에 저장해두고 나중에 또 쓸 수가 없다. (운이 좋으면 될 수도 있지만 기본적으로 안 된다고 생각해야 한다.) 나중에 또 쓰고 싶으면 `NewGlobalRef` 함수를 호출하여 리턴되는 `jobject` 글로벌 레퍼런스를 사용해야 한다. 기본적으로 로컬 레퍼런스는 자동으로 메모리가 해제되지만 글로벌 레퍼런스는 자동으로 해제되지 않으니, 메모리 릭이 생기지 않도록 나중에 `DeleteGlobalRef` 함수를 호출하여 메모리 해제를 해줘야 한다. `Call(Static)ObjectMethod` 함수를 반복문 안에서 호출하는 경우에는 로컬 레퍼런스에 대한 메모리가 너무 많아질 수도 있으니 경우에 따라 알아서 `DeleteLocalRef` 함수를 호출하여 메모리 해제를 잘 해주자.


6. `FindClass` 함수의 결과로 얻은 `jclass`도 사실은 `jobject`이다. (`jni.h`에 typedef 되어 있다.)  `jclass``DeleteLocalRef`로 메모리를 해제한 뒤에는 해당 `jclass`를 이용하여 메소드 호출이 불가능하다. JNI 예제를 찾아보면 정말 열심히 `DeleteLocalRef` 함수를 호출해주는데, 자동으로 메모리 해제가 되기 전에 메모리가 부족해질 경우로 많이 쓰이는 경우는 사실상 거의 없다고 봐야하지 않나 싶다. 나 같은 경우엔 꼭 필요한 때를 제외하고는 직접 `DeleteLocalRef`를 호출해주지 않는다. 이 부분은 내 자의적 판단이 들어간 것이니 각자 알아서 잘 판단하도록 하자.



## 결론


원하는대로 작동하기는 하는 코드를 만들어 문제를 해결한 뒤에도 뭔가 마음이 많이 쓰였다. 빌드 과정을 곁들여 개념적으로 JNI를 설명한 글이 없다보니 내가 만든 코드가 정확히 어떻게 돌아가는지에 대해 확신을 갖지 못해서 그랬던 것 같다. 우연히 크로스 플랫폼 작업을 집중적으로 많이 하다보니 빌드 과정에 대해서도 많이 신경써야 했고, 그러다보니 NDK 빌드 과정에 대해서도 좀 더 생각해봐야만 했다. 그렇게 삽질을 통해 나름대로 내린 개념적 결론이 생겼는데, 이걸 그냥 생각만 해두기엔 좀 아까워서 포스팅으로 남겨둬야겠다고 결심했다.


사실 빌드라는 것을 생각 없이 그냥 하는 경우가 많은데, 워낙 툴들이 잘 만들어져 있다보니 그 과정에 대해 자세히 알지 못하게 되는 것 같다. 이 부분을 알아가면서 코드를 짜니 좀 더 내 코드에 대한 확신을 가질 수 있는 것 같다. 컴파일러 수업을 들었던게 이렇게 도움이 되는구나.




댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday