개발일기/Spring

Spring Boot에서 JNI 사용하기(linux 환경)

ignuy 2024. 10. 14.

Java와 Spring은 거대한 커뮤니티를 바탕으로 다양한 라이브러리와 프레임워크를 지원하는 하나의 생태계를 구축하고 있다. 정말 오랫동안 꾸준히 사랑받아온 프로그래밍 언어와 프레임워크로 다양한 장점이 존재한다.

 

대표적으로 “Write Once, Run Anywhere”라는 원칙으로 플랫폼 독립성을 지향하며 가비지 컬렉션이라는 엄격한 메모리 관리 기능으로 메모리 릭 문제를 줄일 수 있다. 뿐만 아니라 OOP에 특화된 언어 특성상 코드 재사용성과 유지보수성이 향상되어 대규모 애플리케이션 개발에 적합하다는 평가를 받고 있다.

하지만,,,, 지금까지 Java와 Spring과 함께라면 무엇이든 할 수 있는 강력한 조합이라고 생각했지만 의외로 간단하게 난관에 부딪혔다. “Java는 시스템을 직접 제어하지 못한다.

Java는 JVM 위에서 실행되기 때문에, 운영 체제와 하드웨어에 대한 직접적인 접근이 제한된다. 특히, 하드웨어와 밀접하게 연관된 작업(예: 드라이버 개발, 시스템 리소스 관리 등)을 수행하기 어렵다. Java의 개발을 담당했었던 Sun 측도 이를 알고 대안을 준비했다. 바로 JNI이다.

 

JNI(Java Native Interface)는 네이티브 쪽과 자바 쪽을 연동시키는 것이 필요한 경우를 위해 썬에서 공개한 SDK의 일종이다. JNI를 통해서 자바 쪽에서 네이티브 호출, 또는 네이티브 쪽에서 자바 호출이 가능하다. 물론, 네이티브에서 굳이 자바를 호출하는 경우는 드물다.

필자의 경우 방화벽 설정, 네트워크 설정, PCI slot 정보 조회 등 “하드웨어 제어”라는 역할을 Spring에게 맡기기 위해 JNI를 선택했다. 

Spring JNI를 사용하는 방법

테스트 환경

java - 21
springboot - 3.3.4
OS - alpine linux:3.20

테스트 편의상 프로젝트 폴더를 docker에 바인드 마운트하여 실행시켰습니다.

jni_test 프로젝트 생성

jni_test/
├── c_library/
├── src/
│   └── main/
│       └── java/com/test/jni_test
└── build/

프로젝트 이름은 jni_test로 생성했다. 그 후 프로젝트 루트 폴더 바로 아래에 c 파일을 저장할 c_library 디렉터리를 생성한다. c 라이브러리를 반드시 정상적으로 연결해줘야 한다(JNI 초기에 연동할 때 90% 실패하는 이유가 이 링크를 제대로 걸어주지 않아서라고 한다. 본인의 프로그램이 이상하다면 가장 먼저 경로를 의심하면서 디버깅해보자).

테스트 Controller 생성

package com.test.jni_test;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/test")
@RequiredArgsConstructor
public class TestController {

    private final HelloJNI helloJNI;

    @GetMapping()
    public String test() {
        helloJNI.printString("Hello World form java ~~~ ");
        helloJNI.printHello();
        return "success";
    }
}

테스트에서 활용할 HelloJNI 클래스를 컨트롤러에서 주입받을 것이다. 이제 GET /test로 요청이 오면 HelloJNI Service Class에서 문자열을 출력할 것이다.

HelloJNI 작성

package com.test.jni_test;

public class HelloJNI {

    // native method 선언(C/C++, 호출 함수 선언)
    native void printHello();

    native void printString(String str);

    static {
        try {
            System.loadLibrary("hellojni");
        } catch (UnsatisfiedLinkError e) {
            System.err.println("Native code library failed to load.\\n" + e);
        }
    }
}

일단 아무 애노테이션도 달지 않은 상태로 HelloJNI를 선언한다. @Service, @Slf4j와 같은 애너테이션을 달면 아래 과정에서 javac가 에러를 뱉어낸다.

필요한 함수를 native키워드와 함께 선언하고 아래 static 블록으로 System.loadLibrary() 함수를 호출해 준다. 이때, 연결할 라이브러리의 이름은 사용하는 운영체제에 따라 표준이 다르다.

 

현재 JVM이 동작하는 시스템이 리눅스인 경우, 파일 확장자는 자동으로 .so이고 앞에 접두사 lib가 붙어서 libhellojni.so파일을 링크한다. Window의 경우 파일확장자는 .dll이고 접두사는 붙지 않아 JVM에서 자동으로 hellojni.dll파일을 링크한다. 현재는 리눅스를 테스트 환경으로 사용하고 있으므로 추후에 공유 라이브러리 파일을 생성할 때, 결과물의 파일명에 주의하자.

 

만약 해당되는 라이브러리를 찾아서 링크하지 못하는 상황을 빠르게 확인하기 위해 catch로 로그를 달아줄 것이다.

헤더 파일 생성

java 8 까지는 javah라는 명령어로 헤더 파일을 생성할 수 있었지만 java 9부터 지원하지 않는다. 대신에 javac 컴파일러에서 옵션을 주어 헤더 파일을 생성할 수 있다.

javac -h <output path> <target>
javac -h .\c_library\ .\src\main\java\com\test\jni_test\HelloJNI.java

이후 c_library에 패키지명이 덕지덕지 붙은 헤더파일이 생성될 것이다.

jni_test/
├── c_library/
│	  └── com_test_jni_test_HelloJNI.h
├── src/
│   └── main/
│       └── java/com/test/jni_test
└── build/

헤더파일이나 c 파일명은 이름 규칙이나 표준이 따로 없다(어처피 컴파일하여 공유 라이브러리로 만들어 사용하기 때문에). 따라서 자유롭게 수정하자.

jni_test/
├── c_library/
│	  └── HelloJNI.h
├── src/
│   └── main/
│       └── java/com/test/jni_test
└── build/

 

이제 헤더 파일의 내용을 살펴보자.

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_test_jni_test_HelloJNI */

#ifndef _Included_com_test_jni_test_HelloJNI
#define _Included_com_test_jni_test_HelloJNI
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com_test_jni_test_HelloJNI
 * Method:    printHello
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_com_test_jni_1test_HelloJNI_printHello
  (JNIEnv *, jobject);

/*
 * Class:     com_test_jni_test_HelloJNI
 * Method:    printString
 * Signature: (Ljava/lang/String;)V
 */
JNIEXPORT void JNICALL Java_com_test_jni_1test_HelloJNI_printString
  (JNIEnv *, jobject, jstring);

#ifdef __cplusplus
}
#endif
#endif

제일 첫 번째 줄을 읽어보면 컴파일러가 자동으로 만들어준 헤더 파일을 임의로 변경하지 말라고 적혀있다. JNI에서 사용하는 메서드의 이름 규칙은 Java 메서드를 C/C++에서 네이티브 메서드로 구현할 때, JVM이 해당 메서드를 올바르게 찾을 수 있도록 하기 위해 매우 중요하다. 따라서 강력한 명명 규칙을 따르고 있기 때문에 절대로 헤더파일을 수정해서는 안된다.

 

JNI 메서드는 Java 클래스, 메서드, 패키지 이름 등에 기반하여 만들어진다. 몇 가지 살펴보자.

  • JNIEXPORTJNICALL: JNI 함수 선언에 사용되는 매크로. 플랫폼에 따라 다르게 정의될 수 있는 함수 호출 규약을 지정한다.
  • Java_패키지명_클래스명_메서드명: JNI 함수 이름은 Java 메서드와 매칭되어야 하므로, 패키지명, 클래스명, 메서드명을 모두 포함한다.
    • 이때, .(점)은 _로 변환되고, 패키지나 클래스 이름에 포함된 밑줄은 _1로 변환된다(따라서 jni_test는 jni_1test로 변환되었으니 이상하게 생각하지 말자).
  • JNIEnv *env: JNI 환경을 나타내는 포인터. JNI 함수를 통해 Java와 상호작용할 때 사용된다.
  • jobject obj: 이 JNI 메서드를 호출하는 Java 객체(this 객체)를 가리킨다. 정적 메서드인 경우 jobject 대신 jclass가 전달된다.
  • 매개변수들: Java 메서드에서 전달된 매개변수들에 해당하는 네이티브 타입이다.
    • 위 경우 jstring을 매개변수로 받고 있다.

C 또는 C++ 파일 구현

생성된 헤더 파일을 기반으로 C/C++에서 네이티브 메서드를 구현하자.

#include "HelloJNI.h"
#include <jni.h>
#include <stdio.h>

JNIEXPORT void JNICALL Java_com_test_jni_1test_HelloJNI_printHello(JNIEnv *env, jobject obj) {
    printf("Hello from C!\\n");
    // 출력 버퍼를 비우기
    fflush(stdout);
    return;
}

JNIEXPORT void JNICALL Java_com_test_jni_1test_HelloJNI_printString(JNIEnv *env, jobject obj, jstring str) {
    const char *nativeString = (*env)->GetStringUTFChars(env, str, 0);
    printf("Received string from Java : %s\\n", nativeString);
    fflush(stdout);
    // 자원 해제
    (*env)->ReleaseStringUTFChars(env, str, nativeString);

    return;
}

반드시 주의하자. JNI를 개발하는 개발자가 사용하는 메모리는 Garbage Collector의 관리 대상 밖이다. 따라서 메모리를 반드시 엄격하게 관리해주어야 한다.

GCC 컴파일

리눅스에 아래와 같은 명령어를 주자(JAVA_HOME 환경변수 설정은 되어있다고 가정하겠다).

gcc -shared -o libhellojni.so -fPIC HelloJNI.c -I${JAVA_HOME}/include -I${JAVA_HOME}/include/linux

옵션이 무엇인지 하나하나 살펴보자.

  • -shared : 공유 라이브러리(Shared Library)를 생성하라는 의미(리눅스의 .so, 윈도우의 .dll)
    • 여러 프로그램이 동시에 사용할 수 있는 라이브러리로 JNI에서 C/C++ 네이티브 메서드를 사용하려면 공유 라이브러리 형태로 컴파일해야 한다.
  • -o libNativeLib.so: 출력 파일(output file)을 지정하는 옵션
  • -fPIC: "Position Independent Code"의 약자로, 메모리에서 어느 위치에 로드되더라도 동작할 수 있는 코드를 생성하라는 명령
    • 공유 라이브러리를 만들 때는 일반적으로 메모리 위치에 독립적인 코드를 생성해야 하며, -fPIC 옵션은 이를 보장한다. 이 옵션은 공유 라이브러리를 만들 때 필수적으로 사용된다.
  • HelloJNI.c : 컴파일할 C 소스 파일의 이름
  • -I${JAVA_HOME}/include: 컴파일러에게 JNI 헤더 파일이 위치한 디렉터리를 지정하는 옵션, -I는 “Include”로 디렉터리를 추가하라는 의미이다.
    • JNI와 관련된 표준 헤더 파일(jni.h)이 포함
  • -I${JAVA_HOME}/include/linux : 리눅스 운영체제에 맞는 추가적인 JNI 헤더 파일이 위치한 디렉터리를 지정하는 옵션
    • 각 운영체제마다 추가적인 헤더 파일이 필요할 수 있음. 윈도우는 win32 / MacOs는 darwin 등등…

위 명령어를 입력하면 c_library 밑에 libhellojni.so 파일이 생성될 것이다.

jni_test/
├── c_library/
│   ├── HelloJNI.h
│   ├── HelloJNI.c
│   └── libhellojni.so
├── src/
│   └── main/
│       └── java/com/test/jni_test
└── build/

HelloJNI 클래스 수정

이 상태로 실행하면 Spring Container가 의존성 주입에 실패할 것이다. HelloJNI를 Spring Bean으로 만들어주자. 간단하게 @Service만 붙이면 된다.

package com.test.jni_test;

import org.springframework.stereotype.Service;

@Service
public class HelloJNI {

    // native method 선언(C/C++, 호출 함수 선언)
    native void printHello();

    native void printString(String str);

    static {
        try {
            System.loadLibrary("hellojni");
        } catch (UnsatisfiedLinkError e) {
            System.err.println("Native code library failed to load.\\n" + e);
        }
    }
}

빌드 후 실행

./gradlew clean build
java -Djava.library.path="./c_library" -jar ./build/libs/jni_test-0.0.1-SNAPSHOT.jar > /var/javalog.log &

자 이제 빌드하고 실행해보자. 이때, 주의할 것은 반드시 c_library를 library path로 명시해주어야 한다. 안 그러면 링크할 공유 라이브러리를 못 찾는다.

실행 결과

 

이런 식으로 연결하면 된다!

마무리..

음… 이번 프로젝트에서 java와 네트워크 기반지식을 바탕으로 로우 레벨 하드웨어 제어를 함께 다룰 것이다. 로우 레벨에서 다양한 이슈를 마주칠 생각에 벌써 머리가 지끈거리지만 네트워크는 재밌으니까! 이제 본격적으로 프로젝트를 시작해 보자!!

댓글