어쩌다보니 JNI를 사용해 자바와 C 세상을 연결하게 되었는데, 출발부터 삐걱거리다 몇 번 혼이 난 다음에야 가까스로 정신을 차렸다. 자바 대가인 dynaxis 군에 따르면 JNI 표준을 정확하게 이해하지 못하고 감으로 프로그램을 만들면 나중에(나중에 - 이 얼마나 무서운 단어인가!) 심각한 문제를 초래한다고 하는데, 그게 무슨 말인지 이해하려면 바로 이 책을 읽어보면 된다. 아주 어려운 로켓 과학은 아니지만 무척 꼼꼼하게 '이러면 안 되고 저러면 큰일나고 요러면 망가진다'는 설명 앞에서 무심코 저지른 실수가 생각나며 몇 번을 화들짝 놀라게 될 책으로 보면 틀림 없겠다. 온라인 시대에 책 말고 인터넷에 올라온 아티클만으로 어떻게 안 될까? 유감스럽지만, 선 마이크로시스템오라클에서 제공하는 공식 문서인 Java™ Native Interface(JNI 6.0 기준)만으로는 미묘한 뉘앙스를 파악하기에 부족한 점이 많다는 사실도 짚고 넘어가겠다. 하지만 너무 겁먹지 마시라. 이 책은 분량이 300페이지 정도지만 후반부는 참조 매뉴얼 형태라 정신 바짝차려 읽으면 며칠 내 독파가 가능하니까.
이 책을 처음부터 끝까지 읽으면 가장 바람직하지만, 시간/여건 상 완독이 힘드신 분들이라면 11장 "Overview of the JNI Design"(11장을 읽다보면 왜 JNI가 이렇게 요상(응?)하게 만들어졌는지 이해가 갈 것이다)과 10장 'Traps and Pitfalls' 만이라도 꼭 읽어보기 바란다. 그래도 시간이 없는 분들을 위해 10장에서 나오는 몇 가지 함정에 대해 요약 정리해드리겠다.
- 오류 점검: native 메소드(C로 만든)를 작성할 때 가장 잊어버리기 쉬운 실수는 오류 조건이 발생했는지 점검하는 루틴 누락이다. 자바 프로그래밍 언어와는 달리 C에서는 표준 예외 처리를 제공하지 않는다. JNI는 C++ 예외와 같은 전형적인 예외 처리 매커니즘에 의존하지 않는다. 따라서, 예외를 일으킬 가능성이 있는 모든(!) JNI 함수 호출 직후 명시적으로 예외를 점검해야 한다(이 부분 밑줄 좌악). 예외 점검은 따분하지만 튼튼한 애플리케이션 제작을 위해 필수다.
- JNI 함수로 유효하지 않은 인수 전달: JNI 함수는 유효하지 않은 인수를 감지하거나 회복하려 들지 않는다. 참조값을 기대하는 JNI 함수에 NULL이나 (jobject) 0xFFFFFFFF를 넘기면, 이후 결과는 미정의(!)다. 현실에서 이런 미정의는 잘못된 결과나 가상 기계 충돌을 의미한다. -Xcheck:jni 명령행 옵션을 붙이면, 가상 기계에서 JNI 함수에 유효하지 않은 인수를 넘길 경우 (비록 전부는 아니지만) 상당수 잘못된 용례를 잡아낸다. 이 옵션은 부하를 유발하므로 기본적으로 꺼놓아야 한다.
- jboolean 인수 주의: jboolean은 8비트 부호없는 C 타입으로 0부터 255까지 저장 가능하다. 0은 JNI_FALSE이며, 나머지 1부터 255는 JNI_TRUE에 대응한다. 하지만 255보다 큰 16비트나 32비트인 경우 문제가 발생한다. 이런 점에 유의해서 프로그램을 작성해야 한다.
- 자바 애플리케이션과 native 코드 사이의 경계: JNI를 사용하는 자바 애플리케이션을 설계할 때, "무엇을, 얼마나 많이 native 코드에서 처리해야 하나?"라는 질문이 나온다. native 코드와 나머지 자바 애플리케이션 사이의 경계는 애플리케이션에 밀접하지만 몇 가지 원칙이 존재한다.
- 경계를 단순하게 유지하자: JVM과 native 코드 사이에서 복잡한 제어 흐름이 왔다갔다 하면 디버그와 유지보수가 어렵고 고성능 자바 가상 기계가 수행하는 최적화에도 방해가 된다.
- native 코드를 최소로 유지하자: natvie 코드는 이식성도 낮고 타입 안전성도 떨어진다. 되도록 최소로 줄이는 편이 좋다.
- native 코드를 격리하자: 모든 native 메소드는 동일 패키지나 동일 클래스에 둬서 나머지 애플리케이션 코드로부터 격리시킨다. 이런 패키지나 클래스는 애플리케이션을 위한 "이식 계층"이 되어야 한다.
- ID와 참조를 혼동: JNI는 객체를 참조로 외부에 노출한다. 클래스, 문자열, 배열은 참조의 특수한 타입이다. JNI는 메소드와 필드를 ID로 노출한다. ID는 참조가 아니다!
- 필드와 메소드 ID 캐시하기: native 코드는 필드나 메소드의 이름과 타입 기술자를 문자열로 지정해 가상 기계로부터 필드나 메소드 ID를 얻는다. 이름을 사용한 필드와 메소드 탐색은 느리다. 종종 ID를 캐시하는 편이 비용을 줄인다. 캐시 필드나 메소드 ID에 대한 캐시를 사용하지 않을 경우 native 코드에서 성능 문제가 생길지도 모른다. 하지만 상속 관계에 주의해서 필드와 메소드 ID를 캐시해야 한다. 잘못하면 엉뚱한 내용을 캐시할지도 모르니까.
- 유니코드 문자열: GetSTringChars나 GetStringCritical에서 얻은 유니코드 문자열은 NULL로 끝나지 않는다. GetStringLength 함수를 호출해 16비트 유니코드 글자 수를 세야 한다.
- 가상 기계 자원 획득하기: native 메소드에서 흔히 저지르는 실수는 가상 기계 자원을 해제하는 루틴 누락이다. 프로그래머는 오류가 발생할 때 수행할 코드 경로에 대해 특히 주의해야 한다. 오류 발생 시점에서 자원을 해제하지 않고 return하는 경우가 많은데, 이럴 경우 해당 자원은 JVM에서 pinned 상태로 영원히 남게 되어 메모리 단편화를 일으키거나 메모리 누수 현상을 일으킨다. GetStringChars의 isCopy 매개변수를 JNI_FALSE로 지정했을 때조차 ReleaseStringChars를 호출해야 한다. 그렇지 않으면 jstring이 pinned 상태로 남게 된다.
- 과도한 지역 참조 생성: 과도한 지역 참조 생성은 불필요하게 메모리를 많이 소비한다. 불필요한 지역 참조는 참조된 객체 뿐만 아니라 참조 자체에도 메모리를 소비한다. native 메소드 실행 시간이 길어지거나 루프 내에서 지역 참조를 만들거나 유틸리티 함수에서 지역 참조를 만들 경우 특히 주의해야 한다. Push/PopLocalFrame 함수를 잘 활용한다.
- 유효하지 않은 지역 참조: 지역 참조는 지역 메소드의 단일 호출 내에서만 유효하다. native 메소드에서 생성한 지역 참조는 해당 메소드를 구현한 native 함수가 return될 때 자동으로 해제된다. native 코드는 전역 변수에 지역 참조를 저장해 나중에 native 메소드에서 사용해서는 안 된다. 지역 참조는 생성한 스레드 내에서만 유효하다. 지역 참조를 특정 스레드에서 다른 스레드로 전달해서는 안 된다. 스레드 사에에 참조를 전달하려면 전역 참조를 생성하자.
- 스레드 사이에서 JNIEnv 사용하기: JNIEnv 포인터는 연관된 스레드 내부에서만 사용해야 한다. 특정 스레드에서 얻은 JNIEnv 인터페이스 포인터를 캐시해 해당 포인터를 다른 스레드에서 사용하면 안 된다.
지금까지 설명한 내용은 공통적으로 가장 흔히 저지르는 실수이며, 개발자에 따라 다른 유형의 실수를 저지를 가능성도 있으므로 깨알같이 꼼꼼하게 프로그램을 작성해야 한다. 딱 세 가지로 요약하자면, JNI 함수 호출 다음에는 오류 점검이 따라 와야 하고, 코드 경로를 주의 깊게 살펴 자원 해제를 빠뜨리는 경우가 없는지 점검해야 하며, 지역 참조 관련해 한계를 확실하게 이해하고 있어야 한다.
결론: JNI로 뭔가를 하려면 반드시 이 책을 정독하기 바란다.
EOB