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 메서드를 선언합니다.

JAVA
public class NativeLib {
    static { System.loadLibrary("mylib"); }
    public native long strlen(String str);
}

그 다음 C 측에서 JNI 규약에 맞는 글루 코드를 작성해야 합니다.

C
#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메모리의 구조(크기, 정렬, 필드)를 선언
ArenaMemorySegment의 생명 주기를 관리
ValueLayout기본 타입(int, long, pointer 등)의 레이아웃

2. Foreign Linker — 네이티브 함수 호출

클래스역할
Linker네이티브 함수와 Java를 연결
SymbolLookup네이티브 라이브러리에서 함수/심볼 검색
FunctionDescriptor네이티브 함수의 시그니처(매개변수, 반환 타입) 정의

오프힙 메모리 다루기

Arena와 MemorySegment

Arena는 메모리 할당과 해제의 범위(scope)를 정의합니다. try-with-resources로 Arena를 닫으면 그 안에서 할당한 모든 MemorySegment가 자동 해제되기 때문에, JNI에서 겪던 수동 메모리 관리 문제가 사라집니다.

JAVA
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로 표현하면 다음과 같습니다.

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에서 할당한 메모리에 구조체처럼 읽고 쓸 수 있습니다.

JAVA
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
}

배열 다루기

JAVA
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을 호출하는 전체 과정입니다.

JAVA
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 메서드를 호출하듯 네이티브 함수를 사용할 수 있습니다.

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 코드만으로 해결한 것입니다.

사용자 라이브러리 호출

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로 구현합니다.

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)
    );
}

그 다음 이 메서드를 네이티브 함수 포인터로 변환합니다.

JAVA
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 비교

항목JNIFFM API
네이티브 코드 필요C/C++ 글루 코드 필수순수 Java만으로 가능
메모리 안전성수동 관리Arena로 자동 관리
성능JNI 호출 오버헤드직접 호출에 가까운 성능
개발 생산성낮음 (헤더 생성, 컴파일)높음 (Java 코드만 작성)
구조체 매핑직접 처리MemoryLayout으로 선언

jextract — 자동 바인딩 생성

BASH
# 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이 발생합니다.

BASH
# 비모듈 프로젝트
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네이티브 함수 시그니처 정의 및 호출
jextractC 헤더에서 Java 바인딩 자동 생성. 정렬 오류 방지
JNI 대비 장점C 글루 코드 불필요, 메모리 자동 관리, 직접 호출에 가까운 성능
댓글 로딩 중...