자바 애플리케이션을 하나의 실행 파일로 만들 수 있다면, 그게 진짜 자바라고 부를 수 있을까? JVM 없이 돌아가는 자바는 어떤 것을 얻고, 어떤 것을 잃는 걸까?

Native Image란 무엇인가

GraalVM Native Image는 자바 애플리케이션을 AOT(Ahead-Of-Time) 컴파일 하여 독립적인 실행 파일(바이너리)로 만드는 기술입니다. JVM 위에서 바이트코드를 해석하는 것이 아니라, 빌드 시점에 기계어로 변환하여 OS가 직접 실행할 수 있는 파일을 생성합니다.

이렇게 만들어진 바이너리는 Substrate VM 이라는 경량 런타임 위에서 실행됩니다. Substrate VM은 GC와 스레드 관리 같은 최소한의 런타임 기능만 포함하고 있어서, 전체 JVM에 비해 훨씬 가볍습니다.

결과적으로 다음과 같은 특성을 가집니다.

  • **시작 시간 **: 수십 밀리초 수준 (JVM 모드의 수초 대비)
  • ** 메모리 사용량 **: RSS 기준 1/3 ~ 1/5 수준
  • ** 실행 파일 크기 **: 50~100MB 내외 (JVM + JAR 대비 작음)
  • **JVM 불필요 **: 타겟 OS에 맞는 바이너리 하나만 있으면 됨

빌드 방법

Quarkus에서 네이티브 이미지를 빌드하는 방법은 크게 두 가지입니다.

로컬 GraalVM 설치 후 빌드

먼저 GraalVM이 로컬에 설치되어 있어야 합니다.

BASH
# GraalVM 설치 확인
native-image --version

# 네이티브 빌드
./mvnw package -Dnative

이 방식은 로컬 OS에 맞는 바이너리를 생성합니다. macOS에서 빌드하면 macOS용 바이너리가, Linux에서 빌드하면 Linux용 바이너리가 나옵니다.

컨테이너 빌드 (GraalVM 설치 불필요)

GraalVM을 로컬에 설치하기 귀찮거나, Linux 바이너리가 필요한 경우 컨테이너 안에서 빌드할 수 있습니다.

BASH
./mvnw package -Dnative -Dquarkus.native.container-build=true

Docker(또는 Podman)가 설치되어 있으면 알아서 GraalVM이 포함된 빌더 이미지를 받아서 빌드합니다. CI/CD 파이프라인에서는 이 방식이 훨씬 편리합니다.

BASH
# 빌더 이미지를 직접 지정할 수도 있음
./mvnw package -Dnative \
  -Dquarkus.native.container-build=true \
  -Dquarkus.native.builder-image=quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-21

Mandrel은 Red Hat이 배포하는 GraalVM 커뮤니티 에디션 기반의 네이티브 이미지 빌더입니다. Quarkus에서는 공식적으로 Mandrel을 권장합니다.


빌드 시간의 현실

네이티브 이미지 빌드는 솔직히 느립니다. 공부하다 보니 여기서 적잖이 당황했습니다.

항목JVM 빌드Native 빌드
빌드 시간10~30초3~10분
필요 메모리1~2GB8GB+
증분 빌드지원미지원 (매번 전체 빌드)

네이티브 빌드가 오래 걸리는 이유는 AOT 컴파일러가 해야 하는 일의 양 때문입니다.

  1. ** 정적 분석(Reachability Analysis)**: 애플리케이션의 모든 코드 경로를 추적
  2. ** 힙 스냅샷 **: 빌드 시점에 초기화된 객체들을 이미지에 포함
  3. ** 기계어 변환 **: 바이트코드를 네이티브 코드로 변환
  4. ** 데드 코드 제거 **: 사용되지 않는 코드를 제거하여 바이너리 크기 최소화

개발 중에는 반드시 JVM 모드(Dev Mode)를 사용하고, 네이티브 빌드는 CI/CD에서만 돌리는 것이 현실적인 전략입니다.

PROPERTIES
# CI/CD에서 메모리 부족을 방지하기 위한 설정
quarkus.native.native-image-xmx=8g

Closed World Assumption

네이티브 이미지를 이해하는 핵심 개념이 바로 Closed World Assumption(폐쇄 세계 가정) 입니다.

빌드 시점에 애플리케이션이 사용하는 모든 코드가 결정되어야 한다.

일반적인 JVM에서는 런타임에 클래스를 동적으로 로드하거나, 리플렉션으로 아무 클래스나 접근할 수 있습니다. 하지만 네이티브 이미지에서는 빌드 시점에 도달할 수 없는 코드는 바이너리에 포함되지 않습니다.

이 가정 때문에 문제가 되는 자바의 동적 기능들이 있습니다.

  • Reflection: Class.forName(), Method.invoke()
  • Dynamic Proxy: java.lang.reflect.Proxy
  • JNI: Java Native Interface
  • ** 리소스 로딩 **: ClassLoader.getResource()
  • ** 직렬화/역직렬화 **: Serializable 인터페이스 기반

이 기능들을 사용하는 코드가 있다면, 빌드 시점에 "이 클래스가 리플렉션으로 접근될 것이다"라고 ** 명시적으로 알려줘야** 합니다. 안 그러면 바이너리에서 해당 클래스가 빠져서 런타임에 에러가 납니다.


Reflection 문제와 해결

네이티브 빌드에서 가장 자주 만나는 문제가 리플렉션 관련입니다.

@RegisterForReflection

Quarkus가 제공하는 가장 간단한 해결책입니다. DTO나 엔티티 클래스에 붙이면 됩니다.

JAVA
@RegisterForReflection
public class UserDto {
    private String name;
    private String email;

    // getter, setter
}

JSON 직렬화/역직렬화에 사용되는 클래스들이 대표적인 대상입니다. Jackson이나 JSON-B가 리플렉션으로 필드에 접근하기 때문입니다.

JAVA
// 패키지 전체를 등록할 수도 있음
@RegisterForReflection(targets = {UserDto.class, OrderDto.class})
public class ReflectionConfig {
}

reflect-config.json

서드파티 라이브러리의 클래스처럼 소스코드를 수정할 수 없는 경우, 설정 파일로 등록합니다.

JSON
// src/main/resources/META-INF/native-image/reflect-config.json
[
  {
    "name": "com.example.external.SomeClass",
    "allDeclaredConstructors": true,
    "allPublicMethods": true,
    "allDeclaredFields": true
  }
]

native-image-agent로 자동 수집

어떤 클래스가 리플렉션을 사용하는지 모를 때는 에이전트를 돌려서 자동으로 수집할 수 있습니다.

BASH
# JVM 모드로 실행하면서 리플렉션 사용 정보를 수집
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
  -jar target/quarkus-app/quarkus-run.jar

에이전트가 실행 중에 사용된 리플렉션, 프록시, JNI 등의 정보를 JSON 파일로 자동 생성합니다. 단, 에이전트가 수집하는 것은 ** 실제로 실행된 코드 경로뿐 **이므로, 모든 기능을 테스트해봐야 정확한 설정을 얻을 수 있습니다.


JNI, 동적 프록시, 리소스 등록

리플렉션 외에도 네이티브 빌드에서 명시적으로 등록해야 하는 것들이 있습니다.

동적 프록시

JSON
// proxy-config.json
[
  {
    "interfaces": ["com.example.MyInterface"]
  }
]

JNI

JSON
// jni-config.json
[
  {
    "name": "com.example.NativeHelper",
    "methods": [{"name": "nativeMethod", "parameterTypes": []}]
  }
]

리소스

클래스패스에서 로드하는 리소스 파일들도 등록이 필요합니다.

PROPERTIES
# application.properties
quarkus.native.resources.includes=templates/**,static/**

또는 JSON 설정으로도 가능합니다.

JSON
// resource-config.json
{
  "resources": {
    "includes": [
      {"pattern": "templates/.*"},
      {"pattern": "static/.*"}
    ]
  }
}

Quarkus의 공식 확장(Extension)을 사용하면 이런 설정들이 자동으로 처리됩니다. 문제가 생기는 건 주로 Quarkus 확장이 없는 서드파티 라이브러리를 직접 사용할 때입니다.


자주 만나는 에러와 해결법

네이티브 빌드를 하다 보면 반복적으로 마주치는 에러 패턴이 있습니다.

ClassNotFoundException / NoClassDefFoundError

빌드 시 정적 분석에서 해당 클래스가 도달 가능하지 않다고 판단되어 바이너리에 포함되지 않은 경우입니다.

PLAINTEXT
com.oracle.svm.core.jdk.UnsupportedFeatureError:
  Proxy class defined by interfaces [com.example.MyService] not found.

**해결 **: @RegisterForReflection이나 reflect-config.json에 해당 클래스를 등록합니다.

UnsupportedFeatureException

네이티브 이미지에서 지원하지 않는 기능을 사용할 때 발생합니다.

PLAINTEXT
com.oracle.svm.core.jdk.UnsupportedFeatureError:
  Defining hidden classes at runtime is not supported.

** 해결 **: 보통 바이트코드 생성 라이브러리(CGLIB, ByteBuddy 등)가 런타임에 클래스를 동적 생성할 때 발생합니다. 해당 라이브러리가 GraalVM을 지원하는 버전인지 확인하거나, 대안 라이브러리를 찾아야 합니다.

빌드 시 메모리 부족 (OOM)

PLAINTEXT
Error: Image build request failed with exit status 137

** 해결 **: 빌드 메모리를 늘립니다.

PROPERTIES
quarkus.native.native-image-xmx=12g

초기화 시점 문제 (Build Time vs Run Time Init)

일부 클래스가 빌드 시점에 초기화되면서 문제를 일으킬 수 있습니다. 예를 들어, 빌드 시점에 네트워크 연결을 시도하는 코드가 있으면 빌드가 실패합니다.

PROPERTIES
# 특정 클래스를 런타임 초기화로 강제
quarkus.native.additional-build-args=--initialize-at-run-time=com.example.SomeClass

디버깅 전략

네이티브 바이너리는 디버깅이 까다롭습니다. 몇 가지 유용한 전략을 정리합니다.

디버그 정보 포함 빌드

BASH
./mvnw package -Dnative -Dquarkus.native.debug.enabled=true

디버그 심볼이 포함된 바이너리가 생성되어 GDB로 디버깅할 수 있습니다. 단, 바이너리 크기가 상당히 커집니다.

native-image-agent 활용

위에서 언급한 에이전트를 적극적으로 활용합니다. JVM 모드에서 통합 테스트를 돌리면서 에이전트로 설정을 수집하면, 네이티브 빌드에서 발생할 수 있는 문제를 미리 파악할 수 있습니다.

BASH
# 테스트를 돌리면서 수집
java -agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image \
  -jar target/quarkus-app/quarkus-run.jar &

# 테스트 실행
./mvnw test

# 앱 종료 후 생성된 설정 파일 확인
ls src/main/resources/META-INF/native-image/

네이티브 테스트

Quarkus는 네이티브 바이너리에 대한 통합 테스트도 지원합니다.

JAVA
@QuarkusIntegrationTest
public class UserResourceIT {

    @Test
    public void testGetUser() {
        given()
            .when().get("/users/1")
            .then()
            .statusCode(200)
            .body("name", is("홍길동"));
    }
}
BASH
# 네이티브 통합 테스트 실행
./mvnw verify -Dnative

이 테스트는 실제 네이티브 바이너리를 띄우고 HTTP 요청을 보내서 검증합니다. 네이티브 빌드에서만 발생하는 문제를 잡을 수 있는 유일한 방법이기도 합니다.


Native vs JVM — 선택 기준

모든 상황에서 네이티브가 좋은 것은 아닙니다. 공부하면서 정리해보니 선택 기준이 명확했습니다.

기준Native ImageJVM 모드
** 시작 속도**수십 ms수 초
** 메모리 사용량**낮음높음
** 최대 처리량(throughput)**낮음~보통높음
** 빌드 시간**3~10분10~30초
** 디버깅**어려움쉬움
** 동적 기능**제한적전부 가능
JIT 최적화없음있음 (C2 컴파일러)

JVM의 JIT(Just-In-Time) 컴파일러는 런타임에 핫스팟 코드를 분석하여 최적화합니다. 오래 실행되는 서비스일수록 JVM 모드의 처리량이 네이티브보다 높아지는 이유입니다.

Native가 적합한 경우

  • ** 서버리스/AWS Lambda**: 콜드 스타트 시간이 사용자 경험에 직결
  • **CLI 도구 **: 빠른 시작이 핵심
  • ** 사이드카/유틸리티 서비스 **: 메모리 제한이 타이트한 환경
  • **Kubernetes에서 빠른 스케일아웃 **: Pod가 빠르게 준비되어야 하는 경우

JVM이 적합한 경우

  • ** 높은 처리량이 필요한 서비스 **: 트래픽이 꾸준히 많은 API 서버
  • ** 복잡한 동적 기능 사용 **: 리플렉션, 동적 프록시가 광범위하게 쓰이는 경우
  • ** 빠른 개발 주기 **: 빌드 시간이 짧아야 하는 경우
  • ** 프로파일링/모니터링 **: JVM 도구 생태계를 활용해야 하는 경우

정리

GraalVM Native Image는 자바의 약점이던 시작 속도와 메모리 사용량을 극적으로 개선합니다. 하지만 Closed World Assumption이라는 제약을 이해하고, 리플렉션 같은 동적 기능에 대한 설정을 직접 관리해야 합니다.

기억해둘 포인트를 정리하면 이렇습니다.

  • 네이티브 빌드는 ** 빌드 시점에 모든 코드 경로가 결정 **되어야 한다
  • @RegisterForReflectionreflect-config.json은 필수 도구
  • 개발 중에는 JVM Dev Mode, 배포 시에만 네이티브 빌드가 현실적
  • ** 시작 속도가 중요하면 Native, 처리량이 중요하면 JVM** — 이 한 줄이 선택 기준의 핵심
  • Quarkus 공식 확장을 사용하면 대부분의 네이티브 호환성이 자동으로 처리되므로, 가급적 공식 확장을 쓰는 것이 트러블슈팅을 줄이는 가장 좋은 방법
댓글 로딩 중...