Foreign Function & Memory API — JNI를 대체하는 모던 네이티브 접근
Java에서 C/C++ 라이브러리를 호출하려면 꼭 JNI의 복잡한 글루 코드를 작성해야 할까요?
FFM API(Foreign Function & Memory API)는 순수 Java 코드만으로 네이티브 함수를 호출하고 오프힙 메모리를 안전하게 다루는 API입니다. Java 22에서 정식 API가 되었습니다.
기존 JNI는 C/C++ 글루 코드 작성, 수동 메모리 관리, 플랫폼별 빌드가 필요했는데, FFM API는 이 모든 과정을 Java 코드 안에서 해결합니다.
JNI의 문제점 — 왜 대체가 필요한가
기존 JNI로 C의 strlen 하나를 호출하려면 세 단계가 필요합니다. 먼저 Java 측에서 native 메서드를 선언합니다.
public class NativeLib {
static { System.loadLibrary("mylib"); }
public native long strlen(String str);
}
그 다음 C 측에서 JNI 규약에 맞는 글루 코드를 작성해야 합니다.
#include <jni.h>
#include <string.h>
JNIEXPORT jlong JNICALL Java_NativeLib_strlen(
JNIEnv *env, jobject obj, jstring str) {
const char *nativeStr = (*env)->GetStringUTFChars(env, str, NULL);
jlong len = strlen(nativeStr);
(*env)->ReleaseStringUTFChars(env, str, nativeStr); // ← 수동 해제 필수
return len;
}
ReleaseStringUTFChars를 빠뜨리면 메모리 릭이 발생합니다. 그리고 이 C 코드를 플랫폼별로 컴파일해야 합니다. 단순한 strlen 호출에 C 코드 작성 + 컴파일 + 수동 메모리 관리가 필요한 것이 JNI의 근본적 문제입니다.
FFM API 핵심 개념
FFM API는 두 부분으로 구성됩니다.
1. Foreign Memory Access — 오프힙 메모리 관리
| 클래스 | 역할 |
|---|---|
MemorySegment | 연속된 메모리 영역을 나타내는 추상화 |
MemoryLayout | 메모리의 구조(크기, 정렬, 필드)를 선언 |
Arena | MemorySegment의 생명 주기를 관리 |
ValueLayout | 기본 타입(int, long, pointer 등)의 레이아웃 |
2. Foreign Linker — 네이티브 함수 호출
| 클래스 | 역할 |
|---|---|
Linker | 네이티브 함수와 Java를 연결 |
SymbolLookup | 네이티브 라이브러리에서 함수/심볼 검색 |
FunctionDescriptor | 네이티브 함수의 시그니처(매개변수, 반환 타입) 정의 |
오프힙 메모리 다루기
Arena와 MemorySegment
Arena는 메모리 할당과 해제의 범위(scope)를 정의합니다. try-with-resources로 Arena를 닫으면 그 안에서 할당한 모든 MemorySegment가 자동 해제되기 때문에, JNI에서 겪던 수동 메모리 관리 문제가 사라집니다.
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(100); // 100바이트 할당
segment.set(ValueLayout.JAVA_INT, 0, 42); // offset 0에 int 쓰기
segment.set(ValueLayout.JAVA_INT, 4, 100); // offset 4에 int 쓰기
int value1 = segment.get(ValueLayout.JAVA_INT, 0); // 42
int value2 = segment.get(ValueLayout.JAVA_INT, 4); // 100
} // Arena 닫힘 → 메모리 자동 해제
Arena가 닫힌 뒤 MemorySegment에 접근하면 IllegalStateException이 발생합니다. 이것이 "use-after-free" 버그를 컴파일 타임이 아닌 런타임에서라도 잡아주는 안전장치입니다.
구조체 매핑
네이티브 코드와 상호작용할 때 C 구조체에 해당하는 메모리 레이아웃을 Java에서 정의해야 합니다. MemoryLayout으로 구조체의 필드 이름, 크기, 정렬을 선언하고 VarHandle로 각 필드에 접근합니다.
C의 struct Point { int x; int y; };를 Java로 표현하면 다음과 같습니다.
StructLayout pointLayout = MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")
);
VarHandle xHandle = pointLayout.varHandle(
MemoryLayout.PathElement.groupElement("x"));
VarHandle yHandle = pointLayout.varHandle(
MemoryLayout.PathElement.groupElement("y"));
레이아웃과 핸들이 준비되면, Arena에서 할당한 메모리에 구조체처럼 읽고 쓸 수 있습니다.
try (Arena arena = Arena.ofConfined()) {
MemorySegment point = arena.allocate(pointLayout);
xHandle.set(point, 0L, 10); // point.x = 10
yHandle.set(point, 0L, 20); // point.y = 20
int x = (int) xHandle.get(point, 0L); // 10
int y = (int) yHandle.get(point, 0L); // 20
}
배열 다루기
try (Arena arena = Arena.ofConfined()) {
// int 배열 할당 (10개 요소)
MemorySegment array = arena.allocate(
ValueLayout.JAVA_INT, 10);
for (int i = 0; i < 10; i++) {
array.setAtIndex(ValueLayout.JAVA_INT, i, i * i);
}
// 배열 읽기
for (int i = 0; i < 10; i++) {
int value = array.getAtIndex(ValueLayout.JAVA_INT, i);
System.out.println("array[" + i + "] = " + value);
}
}
네이티브 함수 호출
C 표준 라이브러리의 strlen 호출
네이티브 함수를 호출하려면 네 가지를 준비해야 합니다. (1) Linker로 플랫폼 ABI를 확보하고, (2) SymbolLookup으로 함수 주소를 찾고, (3) FunctionDescriptor로 시그니처를 정의하고, (4) MethodHandle을 생성해서 호출합니다.
C의 strlen을 호출하는 전체 과정입니다.
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
// strlen 함수 심볼 찾기
MemorySegment strlenAddr = stdlib.find("strlen").orElseThrow();
// 함수 시그니처: long strlen(const char*)
FunctionDescriptor desc = FunctionDescriptor.of(
ValueLayout.JAVA_LONG, // 반환 타입
ValueLayout.ADDRESS // 매개변수: 포인터
);
MethodHandle strlen = linker.downcallHandle(strlenAddr, desc);
MethodHandle이 준비되면 Java 메서드를 호출하듯 네이티브 함수를 사용할 수 있습니다.
try (Arena arena = Arena.ofConfined()) {
MemorySegment cString = arena.allocateFrom("Hello, FFM!");
long length = (long) strlen.invokeExact(cString);
System.out.println("길이: " + length); // 11
}
JNI에서는 C 글루 코드 + 컴파일이 필요했던 것을, 순수 Java 코드만으로 해결한 것입니다.
사용자 라이브러리 호출
// libmath.so의 double add(double, double) 호출
try (Arena arena = Arena.ofConfined()) {
SymbolLookup lib = SymbolLookup.libraryLookup("libmath.so", arena);
MemorySegment addAddr = lib.find("add").orElseThrow();
FunctionDescriptor desc = FunctionDescriptor.of(
ValueLayout.JAVA_DOUBLE,
ValueLayout.JAVA_DOUBLE,
ValueLayout.JAVA_DOUBLE
);
MethodHandle add = Linker.nativeLinker().downcallHandle(addAddr, desc);
double result = (double) add.invokeExact(3.14, 2.72);
System.out.println("결과: " + result);
}
콜백 — Java 메서드를 네이티브에 전달
C의 qsort처럼 함수 포인터를 인자로 받는 네이티브 함수에는 upcall stub 을 사용합니다. Java 메서드를 네이티브 함수 포인터로 변환하는 것입니다.
먼저 비교 함수를 Java로 구현합니다.
static int compare(MemorySegment a, MemorySegment b) {
return Integer.compare(
a.reinterpret(4).get(ValueLayout.JAVA_INT, 0),
b.reinterpret(4).get(ValueLayout.JAVA_INT, 0)
);
}
그 다음 이 메서드를 네이티브 함수 포인터로 변환합니다.
try (Arena arena = Arena.ofConfined()) {
MethodHandle compareMH = MethodHandles.lookup()
.findStatic(MyClass.class, "compare",
MethodType.methodType(int.class,
MemorySegment.class, MemorySegment.class));
FunctionDescriptor compareDesc = FunctionDescriptor.of(
ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS);
MemorySegment compareStub = Linker.nativeLinker()
.upcallStub(compareMH, compareDesc, arena);
// compareStub을 qsort에 전달하면 네이티브 코드가 Java 메서드를 호출
}
이 구조 덕분에 JNI에서 복잡했던 네이티브→Java 콜백도 순수 Java로 처리됩니다.
JNI vs FFM API 비교
| 항목 | JNI | FFM API |
|---|---|---|
| 네이티브 코드 필요 | C/C++ 글루 코드 필수 | 순수 Java만으로 가능 |
| 메모리 안전성 | 수동 관리 | Arena로 자동 관리 |
| 성능 | JNI 호출 오버헤드 | 직접 호출에 가까운 성능 |
| 개발 생산성 | 낮음 (헤더 생성, 컴파일) | 높음 (Java 코드만 작성) |
| 구조체 매핑 | 직접 처리 | MemoryLayout으로 선언 |
jextract — 자동 바인딩 생성
# C 헤더에서 Java 바인딩 자동 생성
jextract --source -t com.example.bindings \
-I /usr/include \
/usr/include/math.h
# 생성된 코드 사용
double result = math_h.pow(2.0, 10.0); // Math.pow와 비슷하게 사용
jextract가 C 헤더 파일을 분석하여 MemoryLayout, FunctionDescriptor, MethodHandle 등을 자동으로 생성해줍니다.
주의할 점
--enable-native-access를 빠뜨리면 실행 자체가 안 된다
FFM API의 네이티브 접근 기능은 "제한된(restricted)" 연산으로 분류됩니다. 실행 시 명시적으로 허용하지 않으면 IllegalCallerException이 발생합니다.
# 비모듈 프로젝트
java --enable-native-access=ALL-UNNAMED -jar myapp.jar
# 모듈 시스템 사용 시 — 모듈 이름을 명시
java --enable-native-access=com.myapp -jar myapp.jar
Arena 유형 선택 오류
Arena.ofConfined()는 생성한 스레드에서만 사용 가능합니다. 다른 스레드에서 접근하면 WrongThreadException이 발생합니다. 멀티스레드 환경에서는 Arena.ofShared()를 사용해야 하지만, 동기화 비용 때문에 성능이 떨어집니다.
메모리 레이아웃의 정렬(alignment) 불일치
C 구조체의 패딩 규칙과 Java의 MemoryLayout 정렬이 맞지 않으면 필드를 잘못된 오프셋에서 읽게 됩니다. 디버깅이 매우 어려운 데이터 손상이 발생할 수 있으므로, jextract로 자동 생성하는 것이 안전합니다.
정리
| 항목 | 설명 |
|---|---|
| FFM API 핵심 | 순수 Java 코드로 네이티브 함수 호출 + 오프힙 메모리 관리 |
| Arena | 메모리 생명 주기 관리. try-with-resources로 자동 해제 |
| MemorySegment | 연속된 메모리 영역 추상화. Arena가 닫히면 접근 불가 |
| Linker + FunctionDescriptor | 네이티브 함수 시그니처 정의 및 호출 |
| jextract | C 헤더에서 Java 바인딩 자동 생성. 정렬 오류 방지 |
| JNI 대비 장점 | C 글루 코드 불필요, 메모리 자동 관리, 직접 호출에 가까운 성능 |