Development/Code

Java 22 살펴보기: 외부 함수 및 메모리 API

Danny Seo 2024. 8. 9. 10:28

목차

    자바 22, 외부 함수 및 메모리 API

    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)는 두 가지 주요 기능을 제공합니다:

    1. 외부 함수 인터페이스(FFI)
    2. 메모리 접근 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로 정의해야 합니다. FunctionDescriptorMemoryLayout 인스턴스를 받아들입니다. FunctionDescriptorMemorySegment를 갖춘 후에는 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를 제공하는 것은 여전히 엄청난 이점입니다.

     

    읽어주셔서 감사합니다! 😊
    개발 관련 궁금증이나 고민이 있으신가요?
    아래 링크를 통해 저에게 바로 문의해 주세요! 쉽고 빠르게 도움 드리겠습니다.

    '개발자서동우' 프로필 보기