개발
home
🦀

JNI 실전 입문: Rust로 만든 초성 검색을 Java에서 부르기

Created
2026/11/03
Tags
Java
한글
2026-11-03

개요

JNI(Java Native Interface)는 이름은 익숙한데 막상 실무 코드로 본 적은 드문 기술입니다. 대부분은 이미 누군가 감싸 둔 라이브러리(SQLite 드라이버, 이미지 코덱 등)를 통해 간접적으로만 쓰게 돼요. 이 글은 JNI를 들어는 봤지만 직접 써 본 적 없는 개발자를 위해 정리했습니다. JNI가 하는 일, 어떤 언어로 붙이는 게 편한지(C, C++, Go, Rust 비교)를 짚고, 실무에서 있을 법한 예제 하나를 호출하는 쪽(Java)과 호출 받는 쪽(Rust)으로 나눠 조각조각 보여 드립니다. 전체 코드는 글 끝에 접어 두었어요.

본문

JNI가 하는 일

JNI는 JVM 위의 Java/Kotlin 코드와 네이티브 코드(기계어로 컴파일된 .so, .dylib, .dll)가 서로 함수를 부르게 해 주는 표준 규약입니다. 흐름은 4단계예요.
1.
Java에 본문 없는 native 메서드를 선언한다
2.
네이티브 라이브러리가 정해진 이름(Java_패키지_클래스_메서드)으로 C 호출 규약 함수를 내보낸다
3.
System.loadLibrary로 라이브러리를 열면 JVM이 이름으로 함수를 찾아 연결한다
4.
네이티브 함수는 첫 인자로 JNIEnv*를 받는다. 문자열 변환, 배열 접근, 객체 생성, 예외 던지기가 전부 이 포인터의 함수 표를 거친다
어려운 건 C ABI 자체가 아니라 JVM과의 약속입니다.
•
네이티브 쪽이 받은 Java 객체는 로컬 참조라서 호출이 끝나면 무효가 되고, 개수 한도도 있다
•
네이티브 함수는 예외를 "걸어 두고" 반환해야 한다. 예외가 걸린 채로 대부분의 JNI 호출은 금지다
•
JVM이 모르는 스레드에서 Java를 부르려면 AttachCurrentThread로 먼저 등록해야 한다
•
경계를 넘을 때마다 수십 ns가 들고 문자열은 복사된다. 작은 함수를 루프에서 수천 번 부르기보다 큰 일을 한 번에 넘기는 편이 낫다

어떤 언어로 붙일까: C, C++, Go, Rust

네이티브 쪽 언어를 고르는 기준은 "JVM과 한 프로세스에 살 수 있는가"와 "실수했을 때 얼마나 빨리 알 수 있는가"입니다.
C
C++
Go
Rust
연결 방식
JNI가 원래 C용, 그대로
extern "C"로 이름 맞추기
cgo, -buildmode=c-shared
extern "system", #[unsafe(no_mangle)], cdylib
바인딩 도구
jni.h
jni.h, 래퍼
사실상 C 코드를 직접
jni 크레이트(타입 안전 래퍼)
자체 런타임 충돌
없음
없음(예외, RTTI는 경계 안에 가둬야 함)
큼. Go 런타임(GC, 스케줄러, 시그널 처리기)이 JVM과 공존
없음
오류가 경계를 넘으면
실수하면 바로 크래시
C++ 예외가 JNI 경계를 넘으면 정의되지 않은 동작
Go 패닉은 프로세스를 죽임
catch_unwind로 잡아 Java 예외로 바꿈
참조, 메모리 안전
전부 수동
RAII로 일부 자동화
Java 참조를 Go 쪽에 오래 두기 어려움
수명(lifetime)으로 로컬 참조 오용을 컴파일러가 일부 막음
호출 비용
가장 작음
C와 같음
JNI 비용에 cgo 전환 비용까지
C와 같음
크로스 컴파일
툴체인을 플랫폼별로
C와 같음, ABI 문제 더 있음
cgo라 번거로움
cargo에 target 추가, Android는 cargo-ndk
한 줄 평
무난, 실수에 관대하지 않음
기존 C++ 자산이 있으면
비추천
새로 만든다면 추천
Go를 비추천하는 이유는 런타임이 둘이 되기 때문이에요. JVM과 Go가 각자 GC, 스레드, 시그널을 관리하니 시그널 처리기가 엉킬 수 있고, Go 공유 라이브러리를 둘 이상 올리는 것도 문제가 됩니다. Go 로직을 꼭 써야 하면 JNI보다 별도 프로세스로 띄우고 gRPC나 stdin/stdout으로 주고받는 편이 운영하기 쉽습니다.

예제 상황: 서버와 앱이 같은 초성 검색을 써야 한다

사내 주소록이 있다고 해 볼게요. 서버(Java/Spring)도 검색을 하고 Android 앱도 오프라인 검색을 합니다. "ㅂㅅ"으로 박선영을 찾고, "박ㅅ"처럼 완성 글자와 초성을 섞은 검색어도 받아야 해요.
•
서버와 앱에 같은 규칙을 따로 구현하면 언젠가 결과가 어긋난다
•
규칙을 Rust 라이브러리 하나로 만들고 JNI로 붙이면 서버(macOS/Linux)와 Android(arm64)가 같은 코드를 쓴다
이 글의 예제가 바로 그 라이브러리입니다. 문자열, 문자열 배열, int 배열, 예외까지 JNI에서 자주 다루는 것들이 한 번씩 나와요.

프로젝트 구조

파일은 다섯 개면 충분합니다.
jni-rust-example/ ├── Cargo.toml # Rust 라이브러리 설정 ├── src/lib.rs # 호출 받는 쪽: 초성 로직, JNI 함수 ├── java/com/example/Chosung.java # 호출하는 쪽: native 메서드 선언 ├── java/com/example/Main.java # 호출하는 쪽: 사용 예 └── run.sh # 빌드하고 실행
Plain Text
복사
익숙한 Java 쪽에서 시작해서, 그 선언 한 줄 한 줄이 Rust에서 어떻게 받아지는지 따라가 보겠습니다.

호출하는 쪽: native 메서드 선언

Chosung.java는 본문 없는 native 메서드 두 개와 라이브러리 로드가 전부예요. 이 파일이 Java와 Rust 사이의 계약서입니다.
package com.example; /** Rust(libchosung_jni)로 구현한 초성 검색. 메서드 본문은 src/lib.rs의 Java_com_example_Chosung_* 함수다 */ public final class Chosung { static { // -Djava.library.path로 준 폴더에서 libchosung_jni.dylib(.so, .dll)를 찾는다 System.loadLibrary("chosung_jni"); } private Chosung() {} public static native String extract(String text); public static native int[] search(String[] names, String query); }
Java
복사
System.loadLibrary("chosung_jni")에는 접두사 lib과 확장자를 빼고 이름만 씁니다. JVM이 OS에 맞게 libchosung_jni.dylib, libchosung_jni.so, chosung_jni.dll로 바꿔 찾아요.

호출 받는 쪽 (1): 빌드 설정

Cargo.toml에서 볼 곳은 crate-type = ["cdylib"] 한 줄입니다. JVM이 열 수 있는 C 호환 동적 라이브러리를 만들라는 뜻이에요.
[package] name = "chosung_jni" version = "0.1.0" edition = "2024" [dependencies] jni = "0.21" [lib] crate-type = ["cdylib"] [profile.release] # 패닉을 abort로 바꾸지 않는다. 경계에서 catch_unwind로 잡아 Java 예외로 바꾼다 panic = "unwind"
TOML
복사

호출 받는 쪽 (2): 문자열 하나 주고받기

String extract(String text)를 받는 Rust 함수입니다. 가장 단순한 형태라 JNI 함수의 생김새를 보기 좋아요.
#[unsafe(no_mangle)] pub extern "system" fn Java_com_example_Chosung_extract<'local>( mut env: JNIEnv<'local>, _class: JClass<'local>, text: JString<'local>, ) -> jstring { guard(&mut env, |env| { if text.is_null() { env.throw_new("java/lang/NullPointerException", "text가 null입니다")?; return Ok(std::ptr::null_mut()); } // Java 문자열을 Rust String으로 복사하고, 결과를 다시 Java 문자열로 만든다 let text: String = env.get_string(&text)?.into(); Ok(env.new_string(to_chosung(&text))?.into_raw()) }) }
Rust
복사
Java 선언과 나란히 놓으면 이렇게 대응합니다.
Java 선언
Rust 함수
com.example.Chosung.extract
함수 이름 Java_com_example_Chosung_extract
보이지 않음
env: JNIEnv. JNI 기능을 쓰는 통로
static 메서드
_class: JClass. 인스턴스 메서드였다면 this 객체가 온다
String text
text: JString
반환 String
반환 jstring
처음 보면 낯선 문법도 몇 가지 있습니다.
•
#[unsafe(no_mangle)]: Rust는 컴파일하면서 함수 이름을 바꿔(mangling) 버린다. JVM이 이름으로 찾아야 하니 그대로 내보내라는 표시다. Rust 2024 에디션부터는 unsafe(...)로 감싸야 한다
•
extern "system": 플랫폼의 JNI 호출 규약을 따르라는 뜻이다. 대부분 C 규약과 같고, 32비트 Windows에서만 stdcall로 달라진다
•
'local 수명: JString<'local>은 이번 호출 동안만 유효한 로컬 참조다. 함수 밖으로 빼돌리려 하면 컴파일러가 막는다. C였다면 런타임 크래시로 알게 될 실수다
•
.into_raw(): Rust 래퍼를 JVM이 받는 원시 포인터로 돌려준다
본문을 감싼 guard는 (4)에서 다룹니다. 지금은 "오류를 Java 예외로 바꿔 주는 틀"로만 봐 주세요.

호출 받는 쪽 (3): 배열 넘기기

int[] search(String[] names, String query)입니다. 이름 목록을 통째로 받아 맞는 위치만 돌려줘요. 경계를 이름마다 넘지 않고 한 번만 넘는 구조입니다.
#[unsafe(no_mangle)] pub extern "system" fn Java_com_example_Chosung_search<'local>( mut env: JNIEnv<'local>, _class: JClass<'local>, names: JObjectArray<'local>, query: JString<'local>, ) -> jintArray { guard(&mut env, |env| { if names.is_null() || query.is_null() { env.throw_new("java/lang/NullPointerException", "names와 query는 null일 수 없습니다")?; return Ok(std::ptr::null_mut()); } let query: String = env.get_string(&query)?.into(); let len = env.get_array_length(&names)?; let mut hits: Vec<jint> = Vec::new(); for i in 0..len { let item = JString::from(env.get_object_array_element(&names, i)?); if !item.is_null() { let name: String = env.get_string(&item)?.into(); if matches(&name, &query) { hits.push(i); } } env.delete_local_ref(item)?; // 원소마다 생긴 로컬 참조를 바로 지운다 } let out = env.new_int_array(hits.len() as i32)?; env.set_int_array_region(&out, 0, &hits)?; Ok(out.into_raw()) }) }
Rust
복사
💡
루프 안의 delete_local_ref가 실무 포인트입니다. 배열 원소를 꺼낼 때마다 로컬 참조가 하나씩 생기고, 지우지 않으면 함수가 끝날 때까지 쌓여요. 이름이 수만 개면 로컬 참조 테이블이 넘쳐 JVM이 죽습니다. 테스트 데이터가 작을 땐 안 보이고 운영에서만 터지는 종류의 버그예요.

호출 받는 쪽 (4): 패닉과 오류를 Java 예외로

두 함수가 공통으로 쓰는 guard입니다. Rust 쪽에서 무슨 일이 생겨도 JVM에는 "예외 하나 걸고 null 반환"으로만 보이게 만들어요.
fn guard<T: Default>(env: &mut JNIEnv, body: impl FnOnce(&mut JNIEnv) -> jni::errors::Result<T>) -> T { match catch_unwind(AssertUnwindSafe(|| body(env))) { Ok(Ok(value)) => value, // 이미 Java 예외가 걸려 있다(예: 위의 NullPointerException). 그대로 반환하면 Java에서 던져진다 Ok(Err(jni::errors::Error::JavaException)) => T::default(), Ok(Err(e)) => { let _ = env.throw_new("java/lang/IllegalStateException", e.to_string()); T::default() } Err(_) => { let _ = env.throw_new("java/lang/RuntimeException", "Rust 쪽에서 패닉이 났습니다"); T::default() } } }
Rust
복사
⚠️
패닉이 JVM 프레임을 넘어 풀리면 정의되지 않은 동작이라 catch_unwind로 경계에서 잡습니다. 이미 Java 예외가 걸린 경우(JavaException)에는 새 예외를 던지지 않아요. 예외가 걸린 상태에서 JNI를 또 부르면 안 된다는 규칙 때문입니다.

호출 받는 쪽 (5): 초성 로직은 평범한 Rust

to_chosung과 matches는 JNI를 전혀 모르는 순수 Rust 함수입니다. 완성형 한글(가~힣)의 유니코드 값에서 초성 번호를 계산하고, 검색어의 초성 글자는 초성끼리, 완성 글자는 그대로 비교해요. JNI 이야기와는 상관이 없어서 접어 두었습니다.
초성 로직과 테스트 코드 보기
이렇게 나눠 두면 초성 로직은 JVM 없이 cargo test로 검증됩니다. JNI 함수는 Java 값을 Rust 값으로 바꿔 넘기는 얇은 껍데기로만 남고요. JNI 프로젝트를 테스트하기 좋게 만드는 가장 쉬운 방법입니다.

호출하는 쪽: 사용하는 코드

Main.java는 평범한 Java 정적 메서드 호출과 똑같습니다. Rust가 던진 예외도 일반 Java 예외처럼 catch로 받아요.
package com.example; import java.util.Arrays; public class Main { public static void main(String[] args) { String[] names = {"박선영", "김철수", "이선정", "박상훈", "Lee YoungHoon"}; System.out.println("초성: " + Chosung.extract("널스페이스 People")); for (String q : new String[] {"ㅂㅅ", "박ㅅ", "ㅅㅈ", "Lee"}) { int[] hits = Chosung.search(names, q); System.out.println(q + " -> " + Arrays.stream(hits).mapToObj(i -> names[i]).toList()); } try { Chosung.extract(null); } catch (NullPointerException e) { System.out.println("Rust가 던진 예외: " + e.getMessage()); } } }
Java
복사

빌드하고 실행하기

Rust 라이브러리를 빌드하고, Java를 컴파일한 뒤, -Djava.library.path로 라이브러리 폴더를 알려 주면 됩니다.
#!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" cargo build --release -q mkdir -p build javac -d build java/com/example/*.java java -Djava.library.path=target/release -cp build com.example.Main
Bash
복사
실행 결과입니다(macOS, Java 21, Rust 1.99).
초성: ㄴㅅㅍㅇㅅ People ㅂㅅ -> [박선영, 박상훈] 박ㅅ -> [박선영, 박상훈] ㅅㅈ -> [이선정] Lee -> [Lee YoungHoon] Rust가 던진 예외: text가 null입니다
Plain Text
복사

실무로 가져갈 때

이 예제를 실제 서비스에 넣는다면 아래를 더 챙깁니다.
•
배포: 플랫폼마다 라이브러리를 따로 빌드해야 한다. 서버는 Linux x86_64/arm64 .so, Android는 cargo-ndk로 ABI별 .so를 만들어 jniLibs/에 넣는다. Java 코드는 그대로다
•
경계 비용: 키 입력마다 이름 하나씩 넘기지 말고, 이 예제처럼 배열 통째로 넘겨 결과만 받는다
•
스레드: Rust 쪽에서 스레드를 띄워 Java 콜백을 부를 일이 생기면 JavaVM::attach_current_thread가 필요하다
•
로그와 디버깅: 네이티브 크래시는 Java 스택트레이스가 아니라 hs_err_pid*.log로 남는다. 개발 중에는 -Xcheck:jni 옵션으로 JNI 규칙 위반을 미리 잡는다
❄️
Java 22부터 정식이 된 FFM(Foreign Function and Memory) API도 있습니다. 네이티브 쪽은 평범한 C 함수만 내보내면 되고, JNIEnv 없이 Java 쪽 코드가 직접 함수를 찾아 부릅니다. 다만 Java 22 이상이어야 하고 Android에서는 못 써서, 이 예제는 JNI로 만들었어요.

전체 코드

위 조각을 한 파일로 모은 Rust 쪽 전체 코드입니다. 그대로 복사해 쓰면 돼요.
Rust 쪽 전체 코드 보기 (src/lib.rs)

정리

JNI 자체는 "이름 규칙에 맞춘 C 함수와 JNIEnv"가 전부입니다. 어려운 건 로컬 참조, 예외, 패닉처럼 JVM과 맺은 약속을 지키는 일이에요. Rust는 그 약속 중 상당수를 컴파일러와 타입으로 지켜 줘서, JNI를 처음 실무에 들여올 때 가장 덜 아픈 선택입니다.
•
로직은 순수 함수로, JNI 함수는 변환만 하는 얇은 껍데기로 나눈다
•
패닉과 오류는 경계에서 Java 예외로 바꾼다
•
루프에서 꺼낸 로컬 참조는 바로 지운다
•
경계는 굵게, 자주 넘지 않게 설계한다