JNI가 “Write Once, Run Anywhere”와 만날 때: Java의 멀티 아키텍처 현실을 탐색하기

발행: (2025년 12월 26일 오후 11:59 GMT+9)
17 min read
원문: Dev.to

Source: Dev.to

Source:

Introduction

Java의 가장 유명한 약속 중 하나는 “한 번 작성하고, 어디서든 실행”(WORA) 입니다.
그 아이디어는 간단하면서도 강력합니다: 코드를 한 번 작성하고, 컴파일한 뒤, Windows, Linux, macOS, x86, ARM 등 어떤 플랫폼에서도 수정 없이 실행할 수 있다는 것이죠. 이 약속 덕분에 Java는 엔터프라이즈 애플리케이션, Android 개발, 그리고 수많은 다른 사용 사례에서 선택받는 언어가 되었습니다.

순수 Java 코드에 대해서는 이 약속이 아름답게 지켜집니다. 하지만 한 가지 주의점이 있습니다.

JNI (Java Native Interface), 즉 Java와 C, C++, Rust 등으로 작성된 네이티브 코드 사이의 다리를 사용하게 되면, 크로스‑플랫폼의 단순함이 사라집니다. 갑자기 플랫폼‑특정 바이너리를 신경 써야 하고, 암호 같은 UnsatisfiedLinkError 메시지를 마주하게 됩니다.

이 글에서는 다음을 살펴볼 것입니다:

  • Java가 일반적으로 어떻게 플랫폼에 독립적인가
  • 언제, 왜 JNI와 함께 그 독립성이 깨지는가
  • 현대 라이브러리들이 다중 아키텍처 문제를 영리하게 해결하는 방법
  • 실제 프로덕션 다중 아키텍처 라이브러리의 내부 구조 살펴보기

Source:

“한 번 작성, 어디서든 실행” 철학

Java의 WORA 약속은 단순한 마케팅이 아니라 1990년대 중반 소프트웨어 개발에 혁신을 일으킨 근본적인 아키텍처 결정입니다.

해결한 문제

Java 이전에 크로스‑플랫폼 소프트웨어를 만들려면 고통스러운 타협이 필요했습니다:

  • 플랫폼별 별도 코드베이스 (Windows, macOS, Unix)
  • POSIX 같은 추상화 레이어를 사용했지만 여전히 각 플랫폼마다 별도 컴파일이 필요했음
  • Perl이나 Python 같은 인터프리터 언어는 속도가 느리고 생태계가 제한적이었음

Java의 뛰어난 해결책

Java는 바이트코드라는 중간 레이어를 도입했습니다. 머신 코드로 직접 컴파일하는 대신, Java는 보편적이고 플랫폼에 구애받지 않는 명령 집합으로 컴파일합니다. 이 바이트코드는 JVM이 있는 어느 기계에서든 실행될 수 있습니다.

Source Code (.java) → Bytecode (.class) → JVM (x86/ARM/…) → Machine Code
   [Developer]          [Compiler]          [Runtime]          [CPU]

javac로 Java 코드를 컴파일하면 .class 파일이 생성되며, 여기에는 x86, ARM 또는 특정 CPU 아키텍처에 종속되지 않은 보편적인 바이트코드가 들어 있습니다. 이러한 바이트코드 명령(aload_0, getstatic, invokevirtual 등)은 하드웨어가 아니라 JVM에 의해 이해됩니다.

마법은 런타임에 일어납니다: 각 플랫폼은 자체 JVM 구현(x86‑linux, aarch64‑darwin 등)을 제공하는데, 이 JVM은 보편적인 바이트코드를 읽어 Just‑In‑Time (JIT) 컴파일을 통해 네이티브 머신 코드로 변환합니다. 동일한 .jar(.class 파일들의 압축 폴더)는 Linux x86_64, macOS ARM64, Windows x86_64 어디서든 수정 없이 실행될 수 있습니다.

이 아키텍처는 진정한 이식성을 제공하며, 동일한 .jar가 모든 환경에서 동작한다는 점을 보장합니다.

Source:

예외: 아키텍처가 중요한 경우

이제 흥미로운 부분이 나옵니다: 이 아름다운 추상화가 언제 깨지는가?

JNI 등장: 네이티브 코드와의 다리

JNI(Java Native Interface)는 Java 코드가 C, C++, Rust 또는 네이티브 머신 코드로 컴파일될 수 있는 어떤 언어로 작성된 함수를 호출할 수 있게 해줍니다.

왜 이렇게 하고 싶을까요?

  • 성능 – 암호화와 같은 중요한 작업은 네이티브 코드에서 더 빠른 경우가 많습니다.
  • 하드웨어 접근 – Java에서는 수행할 수 없는 저수준 하드웨어 작업.
  • 레거시 통합 – 기존 C/C++ 라이브러리를 재사용.
  • 시스템 API – Java에 노출되지 않은 OS‑특정 기능에 접근.

Note: JNI 사용이 항상 유리한 것은 아닙니다. JNI 호출에는 오버헤드가 발생하므로 가벼운 작업을 자주 호출하면 지연 시간이 증가할 수 있습니다. 네이티브 구현이 상당한 속도 향상을 제공할 때만 JNI를 사용하세요.

문제점: 네이티브 라이브러리는 플랫폼‑및‑아키텍처‑특정

네이티브 코드를 컴파일할 때는 각 대상 플랫폼(Windows, macOS, Linux, …) 각 대상 아키텍처(x86‑64, ARM64, armv7, …)에 맞는 바이너리를 만들어야 합니다.

간단한 테스트로 SQLite JDBC 드라이버를 살펴볼 수 있습니다:

# Download the SQLite JDBC jar from Maven
mvn dependency:get -Dartifact=org.xerial:sqlite-jdbc:3.44.1.0

# Extract its contents
jar xf ~/.m2/repository/org/xerial/sqlite-jdbc/3.44.1.0/sqlite-jdbc-3.44.1.0.jar

# Inspect the native libraries
file native/Mac/x86_64/libsqlitejdbc.dylib
# → Mach-O 64-bit dynamically linked shared library x86_64

file native/Mac/aarch64/libsqlitejdbc.dylib
# → Mach-O 64-bit dynamically linked shared library arm64

file native/Windows/x86_64/sqlitejdbc.dll
# → PE32+ executable (DLL) (console) x86-64 ...

file native/Windows/x86/sqlitejdbc.dll
# → PE32 executable (DLL) (console) Intel 80386 ...

file native/Windows/armv7/sqlitejdbc.dll
# → PE32 executable (DLL) (console) ARMv7 ...

각 파일은 특정 OS + CPU 조합을 위해 컴파일된 것이며, 하나의 JAR에 “모두에게 맞는” 네이티브 라이브러리를 담을 수 없습니다.

Source:

라이브러리가 멀티‑아키텍처 문제를 해결하는 방법

현대 Java 라이브러리는 하나의 Maven/Gradle 아티팩트가 여러 플랫폼에서 동작하도록 하는 패턴을 채택합니다.

“모두 묶어 배포” 패턴

  • 정의: 지원되는 모든 플랫폼에 대한 모든 네이티브 바이너리를 하나의 JAR 안에 포함합니다.
  • 동작 방식: 런타임에 라이브러리가 현재 OS/CPU를 감지하고, 해당 네이티브 라이브러리를 임시 위치에 추출한 뒤 System.load() 또는 System.loadLibrary() 로 로드합니다.
  • 장점: 최종 사용자를 위한 의존성 관리가 단순합니다 (아티팩트 하나만 사용).
  • 단점: JAR 크기가 커짐; 추출 오버헤드 발생; 일부 네이티브 바이너리가 제한적인 라이선스를 가질 경우 라이선스 문제 가능성.

“플랫폼/아키텍처‑별 JAR” 패턴

  • 정의: 각 플랫폼·아키텍처별로 별도 JAR을 배포합니다 (예: my-lib-linux-x86_64.jar, my-lib-windows-arm64.jar).
  • 동작 방식: 메인 “코어” JAR에는 순수 Java 코드만 포함하고, 플랫폼‑별 JAR에 대한 옵션 의존성을 선언합니다. Maven, Gradle, sbt 같은 빌드 도구가 대상 classifier에 따라 올바른 변형을 해결합니다.
  • 장점: 아티팩트가 작아짐; 런타임 추출이 필요 없음; 플랫폼별 라이선스 관리가 명확함.
  • 단점: 의존성 선언이 다소 복잡해짐; 사용자가 올바른 classifier를 가져오도록 해야 함.

각 패턴을 언제 사용해야 할까

상황권장 패턴
플랫폼 수가 적고 바이너리 크기가 무시할 수 있을 정도모두 묶어 배포
플랫폼 수가 많거나 바이너리 크기가 큰 경우 (예: OpenCV, TensorFlow)플랫폼/아키텍처‑별 JAR
바이너리마다 라이선스가 다르게 적용되는 경우플랫폼/아키텍처‑별 JAR
시작 속도가 빨라야 하는 경우 (추출 없음)플랫폼/아키텍처‑별 JAR
배포를 단순화하고 싶을 때 (최종 사용자를 위한 단일 아티팩트)모두 묶어 배포

결론

Java의 “Write Once, Run Anywhere” 약속은 순수 Java 코드에 대해 JVM의 바이트코드 추상화 덕분에 실제로 성립합니다. JNI를 사용해 네이티브 영역에 들어서는 순간, 플랫폼‑특정 문제를 다시 도입하게 됩니다. 최신 라이브러리들은 모든 네이티브 바이너리를 하나의 아티팩트에 묶거나, 플랫폼별로 구분된 아티팩트를 제공함으로써 이러한 어려움을 완화합니다. 이러한 패턴을 이해하면 프로젝트에 맞는 올바른 접근 방식을 선택하고 개발자 경험을 원활하게 유지할 수 있습니다.

추가 읽을거리

  • The Java™ Native Interface: Programmer’s Guide and Specification – Sun Microsystems
  • JEP 391: macOS/AArch64 Port – OpenJDK
  • Class Loading and Native Libraries – Oracle Documentation

참고 문헌

  1. Oracle. Java Platform, Standard Edition Documentation. https://docs.oracle.com/javase/8/docs/
  2. Xerial. SQLite JDBC Driver. https://github.com/xerial/sqlite-jdbc
  3. OpenJDK. JEP 391: macOS/AArch64 Port. https://openjdk.org/jeps/391

UI) ARMv7 Thumb, for MS Windows

% file native/Windows/aarch64/sqlitejdbc.dll
native/Windows/aarch64/sqlitejdbc.dll: PE32+ executable (DLL) (GUI) Aarch64, for MS Windows

x86 .so 파일은 ARM에서 동작하지 않으며, 그 반대도 마찬가지입니다. Linux .so 파일은 Windows에서 동작하지 않습니다. 이를 시도하면 유명한

java.lang.UnsatisfiedLinkError

이 발생합니다. “한 번 작성하면 어디서든 실행한다”(Write Once, Run Anywhere)라는 약속은 깨졌지만, 이는 네이티브 코드가 관여될 때만 해당됩니다.

Source:

라이브러리가 다중 아키텍처 문제를 해결하는 방법 (확장)

“모두 번들링” 패턴 (재조명)

가장 일반적인 해결책: 모든 플랫폼용 네이티브 라이브러리를 하나의 JAR에 포함하는 것. 앞 섹션에서 sqlite-jdbc를 예로 보았습니다.

런타임에서 Java 코드는:

  1. 시스템 속성으로부터 OS와 아키텍처를 감지합니다.
  2. JAR에서 올바른 네이티브 라이브러리를 추출하여 임시 위치에 저장합니다.
  3. System.load()라이브러리를 로드합니다.
  4. JVM 종료 시 임시 파일을 정리합니다.

JAR 파일은 (여러 네이티브 라이브러리를 포함하므로) 커지지만, 개발자 경험은 매끄럽습니다.

“플랫폼/아키텍처‑별 JAR” 패턴 (재조명)

대안 접근법: 플랫폼별로 별도 JAR을 배포하고, 빌드 도구가 빌드 시 올바른 JAR을 선택하도록 합니다.

예를 들어 netty‑tcnative 은 다음과 같이 제공됩니다:

netty-tcnative-2.0.74.Final-javadoc.jar
netty-tcnative-2.0.74.Final-linux-x86_64-fedora.jar
netty-tcnative-2.0.74.Final-linux-x86_64.jar
netty-tcnative-2.0.74.Final-osx-aarch_64.jar
netty-tcnative-2.0.74.Final-osx-x86_64.jar
netty-tcnative-2.0.74.Final-sources.jar
netty-tcnative-2.0.74.Final.jar

작은 문제점: pom.xml에 classifier를 하드코딩하면 빌드가 특정 플랫폼에 종속됩니다.

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-tcnative</artifactId>
    <version>2.0.74.Final</version>
    <classifier>linux-x86_64</classifier>
</dependency>

이제 pom.xml은 Linux x86_64에서만 동작합니다. macOS 개발자는 빌드할 수 없으며, ARM 서버에도 배포할 수 없습니다.

해결책: Maven 의 os-maven-plugin

os‑maven‑plugin 은 빌드 플랫폼을 자동으로 감지하고 올바른 JAR을 선택합니다.

<plugin>
    <groupId>kr.motd.maven</groupId>
    <artifactId>os-maven-plugin</artifactId>
    <version>1.7.1</version>
</plugin>

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-tcnative</artifactId>
    <version>2.0.74.Final</version>
    <classifier>${os.detected.classifier}</classifier>
</dependency>

플러그인은 빌드 환경에 따라 ${os.detected.classifier} 를 설정하므로, 동일한 pom.xml 로 모든 개발자 머신과 모든 CI/CD 파이프라인에서 동작합니다.

각 패턴을 언제 사용할지 (확장)

패턴선택 시점
모두 번들링- 알 수 없는 최종 사용자를 위한 데스크톱 애플리케이션 배포
- 어디서든 동작하는 단일 범용 아티팩트 필요
- 동일 JAR을 여러 아키텍처에 배포
- 단순함이 JAR 크기보다 중요할 때
플랫폼‑별 JAR- 알려진 플랫폼을 대상으로 서버 애플리케이션 빌드
- 컨테이너화 사용 (각 컨테이너 이미지가 이미 플랫폼‑특정)
- JAR 크기가 중요 (예: AWS Lambda 콜드 스타트 시간)
- 아키텍처별 일관된 CI/CD 파이프라인 필요

결론 (확장)

Java의 “Write Once, Run Anywhere” 약속은 신화가 아니라, 네이티브 코드가 들어올 때 실용적인 별표가 붙을 뿐이다. 위의 패턴을 사용하면, 현대 Java 라이브러리는 아키텍처‑특정 네이티브 코드에 의존하더라도 교차‑플랫폼 호환성을 유지할 수 있다.

추가 읽기 (확장)

  • Project Panama – 외부 함수 및 메모리 API로, JNI를 더 안전하고 간단한 API로 대체하는 것을 목표로 합니다.
  • GraalVM Native Image – Java를 네이티브 바이너리로 컴파일하여 패러다임을 완전히 바꿉니다.
  • Improved JIT compilation – Java와 네이티브 코드 간의 성능 격차를 좁히고 있습니다.

이 글이 마음에 드셨다면, **복잡한 라이브러리 세계를 단순화하기**에 대한 제 다른 글도 즐겨 보세요.

참고 문헌 (확장)

Back to Blog

관련 글

더 보기 »

Rust에 JVM 임베딩

Java ↔ Rust 상호 운용 with the jni Crate !Ivan Yurchenko https://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fd...

내 TicketDesk 시스템

소개: 프로그래밍 입문 모듈을 위해 Java로 TicketDesk 시스템을 만들었습니다. 이 시스템은 다음을 수행할 수 있습니다: - 티켓 추적 - 로그인 정보 추적 - 역할 기반 인증 제공