목차
Foreign Function & Memory API
Foreign Function & Memory API는 Project Panama의 일환으로, Java가 JVM 외부의 코드 및 데이터와 상호 운용성을 개선하는 기능입니다. 이를 통해 네이티브 라이브러리를 호출하고 네이티브 메모리를 다루는 것이 JNI보다 훨씬 더 안전하고 간단해졌습니다.
JVM을 넘어서는 이해
Java 개발자로서 우리는 비-JVM 라이브러리와 서비스를 자주 사용합니다. JDBC를 통해 데이터를 접근하거나, HTTP 클라이언트를 통해 웹 서비스를 사용하거나, Unix 도메인 소켓 채널을 이용해 프로세스와 통신하는 등, 이러한 작업은 모두 JVM의 경계를 넘어 잘 정의된 방식으로 안전하게 이루어집니다.
하지만 JDK는 같은 기기에서 JVM 외부의 코드와 데이터를 접근하는 부분에서 부족한 점이 있었습니다. 이를 해결하기 위해 Foreign Function & Memory API(FFM)는 두 가지 주요 기능을 제공합니다:
- 외부 함수 인터페이스(FFI)
- 메모리 접근 API
네이티브 코드를 호출하는 것, 즉 외부 함수를 호출하는 것은 Java에서 새로운 개념이 아닙니다. Java Native Interface(JNI)는 Java 1.1부터 제공되어 왔으며, Java 코드가 현재 하드웨어 및 운영 체제에 "네이티브"인 애플리케이션 및 라이브러리와 상호 호출할 수 있게 해줍니다. 이 네이티브 코드는 주로 C 언어로 작성되지만, 다른 언어들도 다양한 형태로 JNI 지원을 제공합니다.
JNI의 문제점
JNI를 사용하면 Java와 네이티브 코드를 통합할 수 있지만, 성능, 안전성, 개발 복잡성에 영향을 미칠 수 있는 여러 단점이 있습니다:
복잡성과 오류 발생 가능성
Java와 네이티브 코드를 연결하는 데는 많은 보일러플레이트 코드가 필요하며, 이로 인해 API가 변경되면 매우 취약해질 수 있습니다.
성능 오버헤드
Java와 네이티브 코드 간의 호출은 컨텍스트 스위칭을 포함하며, 이는 성능이 중요한 코드에서 심각한 오버헤드를 초래할 수 있습니다. 또한, 데이터 전달 시 변환이나 복사(마샬링/언마샬링)가 필요해 성능에 영향을 줄 수 있습니다.
수동 메모리 관리
네이티브 코드에서 사용되는 메모리는 Java 코드처럼 자동으로 관리되지 않습니다. 잘못된 수동 처리는 메모리 누수를 초래할 수 있으며, 가비지 컬렉터와 상호 작용해 전체 메모리 관리 및 성능에 영향을 줄 수 있습니다.
안전성과 보안
JNI는 양방향에서 큰 안전 및 보안 문제를 야기할 수 있습니다. 네이티브 코드는 JVM의 안전 검사를 우회하기 때문에 버퍼 오버플로우와 이로 인한 충돌 같은 문제에 쉽게 노출됩니다. 이러한 충돌은 보안 문제를 초래할 수 있어, JNI를 통해 네이티브 코드를 사용하는 것은 보안 위협을 동반하게 됩니다.
이식성
네이티브 코드는 현재 플랫폼에 종속적이므로, Java 코드의 이식성이 떨어질 수 있습니다. 예를 들어, 제가 작업한 SCSS 라이브러리는 모든 개발 및 프로덕션 환경에서 작동하려면 리눅스와 macOS의 aarch64 버전의 libsass를 포함해야 했습니다. 만약 코드를 오픈 소스로 공개하려면, 리눅스 ARM, 윈도우, macOS 인텔 버전도 포함해야 할 것입니다.
유지보수 복잡성
코드가 더 이상 "Java 전용"이 아니므로, 네이티브 언어에 대한 특정 수준의 지식이 필요합니다. 특히, C나 C++에서 문제를 디버그하는 것은 익숙하지 않으면 어려울 수 있습니다.
JNI의 격차 메우기
Java 커뮤니티와 생태계는 JDK에서 발견되는 격차를 빠르게 메우고 개발자 경험을 개선하는 데 적극적으로 나서고 있습니다. FFI의 경우, Java Native Access(JNA), Java Abstract Foreign Function Layer(JNR-FFI), 또는 JavaCPP와 같은 프로젝트들이 네이티브 코드에 더 간단하고 효율적으로 접근할 수 있도록 지원합니다. 하지만 Python이나 Rust와 같은 언어들과 비교했을 때, Java는 여전히 네이티브 상호 운용성에서 불리한 위치에 있었습니다. 하지만 이제 새로운 FFI API가 이 문제를 해결해줍니다!
외부 함수 호출하기
먼저 예제를 살펴본 후, 관련된 다양한 부분들을 확인해보겠습니다. 모든 타입은 별도의 언급이 없으면 java.lang.foreign
패키지에 속해 있습니다.
C 표준 라이브러리 함수는 다음과 같이 정의됩니다:
size_t strlen(const char* str);
이 간단한 함수는 null로 종료된 문자열에 대한 포인터를 받아 size_t
타입의 부호 없는 정수를 반환합니다.
이 함수를 호출하기 위해서는 다음과 같은 Java 코드가 필요합니다:
void main(String[] args) {
// STEP 1: 외부 함수 찾기
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MemorySegment strlenAddress = stdlib.find("strlen").orElseThrow();
// STEP 2: 입력/출력 정의 및 메서드 핸들 생성
FunctionDescriptor descriptor =
FunctionDescriptor.of(ValueLayout.JAVA_LONG,
ValueLayout.ADDRESS);
MethodHandle strlen = linker.downcallHandle(strlenAddress,
descriptor);
// STEP 3: 오프힙 메모리 관리
try (Arena offHeap = Arena.ofConfined()) {
// STEP 4: C-호환 인수 만들기
MemorySegment funcArg = offHeap.allocateFrom(args[0]);
// STEP 5: 함수 호출
long len = (long) strlen.invoke(funcArg);
}
}
이 코드는 JNI 기반 접근 방식보다 훨씬 더 명확하지만, 여전히 많은 부분을 이해해야 합니다.
STEP 1: 외부 함수 찾기
먼저 외부 함수에 접근할 수 있는 Linker가 필요합니다. Linker는 다운콜(Java -> 네이티브) 및 업콜(네이티브 -> Java)을 모두 지원합니다. nativeLinker()
호출은 현재 애플리케이션 바이너리 인터페이스(ABI)에 맞는 플랫폼 전용 Linker를 제공합니다. Linker 인터페이스는 "중립적"이지만, 네이티브 버전은 다음 플랫폼의 호출 규칙에 맞게 최적화되어 있습니다:
- 리눅스 (x64, AArch64, RISC-V, PPC64, s390)
- macOS (x64, Aarch64)
- 윈도우 (x64, Aarch64)
- AIX (ppc64)
기타 플랫폼은 libffi를 통해 지원됩니다. 다음으로 심볼의 주소를 찾아야 합니다. Linker.defaultLookup()
은 "유용한 것으로 널리 인식된" 라이브러리와 현재 OS/프로세서 조합에 대해 SymbolLookup을 반환하도록 되어 있습니다. 우리의 경우, C 표준 라이브러리가 여기에 포함됩니다. 하지만 명확한 목록은 없으며, 각 Linker 구현이 목록을 관리할 책임이 있습니다.
마지막으로 함수가 실제로 위치한 메모리를 MemorySegment
로 찾아야 합니다. 이 인터페이스는 메모리, 즉 Java 힙 또는 네이티브 메모리 세그먼트("오프힙")에서 메모리에 접근할 수 있게 해줍니다.
STEP 2: 입력 및 출력 인수를 정의하고 메서드 핸들 생성
외부 함수가 위치한 메모리를 얻었으니, 이제 함수 시그니처를 FunctionDescriptor
로 정의해야 합니다. FunctionDescriptor
는 MemoryLayout
인스턴스를 받아들입니다. FunctionDescriptor
와 MemorySegment
를 갖춘 후에는 Java에서 네이티브 코드로의 다운콜을 위한 java.lang.invoke.MethodHandle
을 생성할 수 있습니다.
STEP 3: 메모리 관리
Arena
는 네이티브 메모리에 접근을 관리하며, 할당된 각 메모리 블록이 범위가 끝난 후 해제되도록 합니다. 이는 try-with-resources 덕분입니다. Arena
에는 여러 종류가 있습니다:
이러한 방식으로 "수동" 메모리 관리는 상당히 간단해지고 참을 수 있는 수준으로 개선되었습니다.
STEP 4: 인수 준비
네이티브 함수를 호출하려면 네이티브 인수 타입이 필요하므로, 모든 Java 타입을 해당 네이티브 대응 타입으로 변환해야 합니다. Arena#allocateFrom
호출로 필요한 메모리를 할당합니다. JNI에서 오프힙 메모리에 접근하기 위해 사용했던 이전의 ByteBuffer
접근 방식은 연속적인 메모리 영역을 나타내는 안전하고 간단한 MemorySegment
로 대체되었습니다. SegmentAllocator
에서 사용할 수 있는 다양한 옵션을 확인하세요.
STEP 5: 함수 호출
java.lang.invoke.MethodHandle
은 기대한 대로 작동합니다. 호출은 lenient invoke(Object...)
에 의해 수행되며, 이 메서드는 필요한 경우 인수와 반환 타입에 대한 변환을 수행하거나, 호출자 타입 디스크립터와 인수 사이의 정확한 타입 일치를 요구하는 strict invokeExact(Object...)
메서드를 사용합니다. strlen
에 대한 간단한 예제는 캐스트 외에 반환 값을 특별히 처리할 필요가 없습니다. 다만, 인수와 반환 타입에 따라, 오프힙에서 Java 힙으로 메모리를 다시 복사하거나 메모리를 재해석하는 것처럼 조금 더 복잡해질 수 있습니다. MemorySegment::reinterpret
에 대한 자세한 내용은 Java 22 문서를 참조하세요.
결론
JEP에 정의된 목표를 살펴보면, 최신 기능에서 특정 패턴이 나타나는 것 같습니다. 보다 일반적인 관점에서 보면, 생산성, 성능, 건전성, 통합성으로 요약될 수 있습니다. 우리는 새로운 기능에서 이러한 속성들을 기대하며, FFM API는 그 좋은 예라고 할 수 있습니다.
FFM API는 JNI의 많은 한계를 해결합니다:
- 복잡성을 줄이고 필요한 보일러플레이트를 줄여 실행을 용이하게 하며,
- 필요한 오버헤드를 줄여 성능을 향상시키고,
- 메모리 누수 가능성을 줄여 더 간단하고 안전한 추상화를 통한 메모리 관리가 가능하게 하며,
- 이전보다 훨씬 취약했던 보일러플레이트가 줄어듦에 따라 이식성이 향상되고,
- API가 이전보다 복잡하지 않아 유지보수가 더 쉬워졌습니다.
대부분의 개발자는 네이티브 코드와 메모리를 자주 접하지 않지만, 역사적으로 복잡하고 오류가 발생하기 쉬운 작업에 대해 더 현대적이고, 안전하며, 효율적이고 간단한 API를 제공하는 것은 여전히 엄청난 이점입니다.
읽어주셔서 감사합니다! 😊
개발 관련 궁금증이나 고민이 있으신가요?
아래 링크를 통해 저에게 바로 문의해 주세요! 쉽고 빠르게 도움 드리겠습니다.
'Development > Code' 카테고리의 다른 글
[자바] 개발자 필독! 지금 바로 적용할 수 있는 Java 꿀팁 30가지! (2) | 2024.08.27 |
---|---|
Java Mockito로 테스트 완성도 높이는 4가지 필수 팁 (0) | 2024.08.19 |
React 전문가들이 추천하는 15가지 라이브러리 (1) | 2024.08.06 |
Hibernate 6 로 마이그레이션: 새로운 기능과 변경된 사항 (0) | 2024.08.05 |
[React/리액트] React 개발자라면 반드시 알아야 할 디자인 패턴 9가지 (0) | 2024.07.31 |