Quarkus Native Image 빌드 완벽 가이드: 0.049초 기동의 비밀

이번 포스팅에서는 Quarkus Native Image 빌드의 전체 과정에 대해서 정리하고자 한다. JVM 모드에서 1.154초 걸리던 기동 시간이 네이티브 빌드 한 번으로 0.049초까지 줄어든다. 빌드 타임 초기화라는 원리 때문인데, 실제로 어떻게 동작하는지는 아래에서 다룬다. 프로젝트 생성부터 Maven 빌드, 컨테이너 배포, 벤치마크 비교까지 순서대로 실습해 본다.

빌드 타임에 해치운다 – Quarkus Native Image의 작동 원리

일반적인 Java 애플리케이션은 JVM이 클래스 로딩, 바이트코드 해석, JIT 컴파일을 런타임에 수행한다. Quarkus Native Image는 이 과정을 빌드 타임으로 옮긴다. GraalVM의 네이티브 이미지 컴파일러가 애플리케이션 코드를 정적으로 분석하고, 도달 가능한 모든 코드를 미리 기계어로 컴파일한다. 그 결과 런타임에는 JVM 자체가 필요 없어진다.

Quarkus는 여기서 한 발 더 나아간다. 프레임워크 수준에서 CDI 빈 탐색, 설정 파싱, 어노테이션 처리를 모두 빌드 타임에 실행하도록 설계되어 있다. Quarkus 공식 네이티브 레퍼런스에 따르면, Quarkus는 Mandrel에게 가능한 한 많은 것을 빌드 타임에 초기화하도록 지시한다. 런타임에 남는 일이 거의 없으니 기동이 빠를 수밖에 없다.

이 구조 덕분에 static 변수는 빌드 시점의 값을 유지하게 되며, Random 시드처럼 런타임마다 달라져야 하는 클래스는
--initialize-at-run-time 옵션으로 명시적 제외가 필요하다.

준비물 체크리스트 — 환경 구성

빌드를 시작하기 전에 아래 환경이 갖춰져 있는지 확인한다.

항목최소 요구사항
JDK21 이상 (JAVA_HOME 설정 필수)
Maven3.9.6 이상
메모리4GB 이상
CPU4코어 이상
컨테이너 런타임Docker 또는 Podman (컨테이너 빌드 시)

로컬에 GraalVM을 직접 설치하는 방법과 컨테이너 안에서 Mandrel 빌더 이미지를 사용하는 방법이 있다. 로컬 설치 없이 컨테이너 빌드를 사용하면 개발 환경에 GraalVM을 설치할 필요가 없어서 팀 전체의 빌드 환경 일관성을 확보하기 쉽다.

프로젝트 생성과 네이티브 프로파일 설정

Quarkus CLI로 REST 프로젝트를 생성한다.

# Quarkus CLI로 새 프로젝트 생성
quarkus create app com.example:native-demo \
  --extension='rest,rest-jackson' \
  --no-code
ShellScript

위 명령은 restrest-jackson 익스텐션을 포함한 빈 프로젝트를 생성한다. --no-code 옵션은 샘플 소스 코드 생성을 건너뛰어 깔끔한 상태에서 시작할 수 있게 한다.

생성된 pom.xml에는 이미 네이티브 빌드용 프로파일이 포함되어 있다.

<!-- pom.xml의 native 프로파일 — Quarkus가 자동 생성 -->
<profiles>
    <profile>
        <id>native</id>
        <activation>
            <property>
                <name>native</name>
            </property>
        </activation>
        <properties>
            <!-- 통합 테스트를 건너뛰지 않도록 설정 -->
            <skipITs>false</skipITs>
            <!-- 네이티브 빌드 활성화 -->
            <quarkus.native.enabled>true</quarkus.native.enabled>
        </properties>
    </profile>
</profiles>
XML

-Dnative 시스템 프로퍼티가 전달되면 native 프로파일이 자동 활성화되고, quarkus.native.enabled=true가 설정되어 네이티브 빌드가 시작된다. skipITs=false는 네이티브 바이너리에 대한 통합 테스트를 기본으로 실행하겠다는 의미이다.

간단한 REST 엔드포인트를 하나 만든다.

package com.example;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/hello")
public class GreetingResource {

    // GET /hello 요청에 대해 평문 텍스트 응답을 반환한다
    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String hello() {
        return "Hello from Quarkus Native!";
    }
}
Java

JAX-RS 표준 어노테이션을 사용한 단순한 엔드포인트이다. Quarkus는 빌드 타임에 이 어노테이션을 분석하여 라우팅 테이블을 미리 구성하기 때문에, 네이티브 이미지에서도 런타임 리플렉션 없이 요청을 처리한다.

Maven으로 Quarkus Native Image 빌드하기

네이티브 빌드 명령은 환경에 따라 골라 쓰면 된다.

# 방법 1: Quarkus CLI (가장 간결)
quarkus build --native

# 방법 2: Maven Wrapper
./mvnw install -Dnative

# 방법 3: Gradle
./gradlew build -Dquarkus.package.jar.type=native
ShellScript

위 세 가지 명령은 모두 동일한 결과를 만들어 낸다. 로컬에 GraalVM 또는 Mandrel이 설치되어 있어야 하며, GRAALVM_HOME 또는 JAVA_HOME이 해당 경로를 가리켜야 한다. 빌드 시간은 프로젝트 규모에 따라 2~10분 정도 소요된다.

빌드가 완료되면 target/ 디렉토리에 *-runner라는 실행 가능한 바이너리가 생성된다.

# 네이티브 바이너리 실행
./target/native-demo-1.0.0-SNAPSHOT-runner

# 출력 예시:
# native-demo 1.0.0-SNAPSHOT native (powered by Quarkus 3.31.x) started in 0.011s.
# Listening on: http://0.0.0.0:8080
ShellScript

JVM 모드로 실행했을 때와 비교했을 때 기동시간이 초 단위에서 ms 단위로 10 ~ 100배 가량 빠르다는 것을 확인할 수 있다. 이 바이너리는 JVM 없이 단독 실행되며, 배포 시 JRE 설치가 필요하지 않다.

GraalVM 없이 빌드한다 — 컨테이너 기반 네이티브 빌드

개발 머신에 GraalVM을 설치하지 않고도 Quarkus Native Image를 빌드할 수 있다. Quarkus는 Mandrel 빌더 이미지를 컨테이너 안에서 실행하여 네이티브 컴파일을 수행하는 기능을 제공한다.

# 컨테이너 내부에서 네이티브 빌드 실행
./mvnw install -Dnative \
  -DskipTests \
  -Dquarkus.native.container-build=true \
  -Dquarkus.native.builder-image=quay.io/quarkus/ubi9-quarkus-mandrel-builder-image:jdk-21
ShellScript

container-build=true 옵션이 핵심이다. Docker 또는 Podman이 설치되어 있으면 Mandrel 빌더 이미지를 자동으로 Pull하여 컨테이너 안에서 네이티브 컴파일을 수행한다. 빌더 이미지 태그의 jdk-21은 사용할 JDK 버전을 지정한다.

application.properties에 기본값으로 설정해 두면 매번 커맨드라인에 옵션을 붙일 필요가 없다.

# src/main/resources/application.properties

# 컨테이너 빌드를 기본으로 사용 (GraalVM 로컬 설치 불필요)
quarkus.native.container-build=true

# Mandrel JDK 21 빌더 이미지 지정
quarkus.native.builder-image=quay.io/quarkus/ubi9-quarkus-mandrel-builder-image:jdk-21

# 네이티브 빌드 시 사용할 최대 힙 메모리 (빌드 머신에 맞게 조정)
quarkus.native.native-image-xmx=4g

native-image-xmx는 네이티브 컴파일 과정에서 사용할 최대 힙 크기이다. 이 값을 너무 낮게 설정하면 빌드 시간이 늘어나고, 극단적으로 낮으면 OutOfMemoryError로 빌드가 실패할 수 있다. 4GB는 대부분의 프로젝트에서 안정적인 기본값이다.

배포 준비 완료 — 멀티 스테이지 Dockerfile 작성

실제 프로덕션에 올리려면 Dockerfile이 필요하다. 빌드와 실행을 분리하는 멀티 스테이지 구성으로 최종 이미지 크기를 줄인다.

# 1단계: Mandrel 빌더 이미지에서 네이티브 컴파일 수행
FROM quay.io/quarkus/ubi9-quarkus-mandrel-builder-image:jdk-21 AS build
COPY --chown=quarkus:quarkus --chmod=0755 mvnw /code/mvnw
COPY --chown=quarkus:quarkus .mvn /code/.mvn
COPY --chown=quarkus:quarkus pom.xml /code/
USER quarkus
WORKDIR /code
# 의존성을 먼저 다운로드하여 Docker 레이어 캐싱 활용
RUN ./mvnw -B dependency:go-offline
COPY src /code/src
# 네이티브 바이너리 빌드
RUN ./mvnw package -DskipTests -Dnative

# 2단계: 경량 런타임 이미지 (약 30MB)
FROM quay.io/quarkus/ubi9-quarkus-micro-image:2.0
WORKDIR /work/
COPY --from=build /code/target/*-runner /work/application
RUN chmod 775 /work /work/application \
  && chown -R 1001 /work \
  && chmod -R "g+rwX" /work \
  && chown -R 1001:root /work
EXPOSE 8080
USER 1001
CMD ["./application", "-Dquarkus.http.host=0.0.0.0"]
Dockerfile

이 Dockerfile은 두 단계로 구성된다. 첫 번째 스테이지에서 Mandrel 빌더 이미지 위에서 소스를 컴파일하고, 두 번째 스테이지에서 ubi9-quarkus-micro-image라는 경량 베이스 이미지 위에 바이너리만 복사한다. 의존성 다운로드를 별도 레이어로 분리한 것은 Docker 빌드 캐시를 활용하여 소스 변경 시 의존성 재다운로드를 방지하기 위함이다. 최종 이미지 크기는 약 70~100MB 수준으로, JVM 기반 이미지(300MB 이상)의 3분의 1 이하이다.

빌드와 실행은 다음과 같다.

# 이미지 빌드
docker build -f Dockerfile -t native-demo:latest .

# 컨테이너 실행 및 동작 확인
docker run -i --rm -p 8080:8080 native-demo:latest
curl -w '\n' http://localhost:8080/hello
# 출력: Hello from Quarkus Native!
ShellScript

컨테이너를 실행하면 0.0x초 만에 기동이 완료되고 즉시 요청을 받을 수 있다. Kubernetes에서 트래픽 급증으로 Pod를 10개 늘려야 할 때, JVM 기반이면 1초 넘게 기다려야 하지만 네이티브 바이너리는 사실상 즉시 투입된다.

네이티브 바이너리도 테스트해야 한다

Quarkus Native Image 빌드 후에는 반드시 네이티브 바이너리 전용 통합 테스트를 실행해야 한다. JVM에서 통과한 테스트가 네이티브에서 실패하는 경우가 존재하기 때문이다. 리플렉션, 동적 프록시, 리소스 로딩 등이 대표적인 원인이다.

# 네이티브 바이너리에 대한 통합 테스트 실행
./mvnw verify -Dnative

# 테스트 대기 시간이 부족하면 늘릴 수 있다 (기본 60초)
./mvnw verify -Dnative -Dquarkus.test.wait-time=300
ShellScript

verify 단계에서 Quarkus는 네이티브 바이너리를 실행한 뒤 @QuarkusIntegrationTest가 붙은 테스트 클래스를 실행한다. wait-time은 바이너리가 기동될 때까지 기다리는 최대 시간(초)이다.

통합 테스트 클래스는 다음과 같이 작성한다.

package com.example;

import io.quarkus.test.junit.QuarkusIntegrationTest;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import org.junit.jupiter.api.Test;

// @QuarkusIntegrationTest: JVM 모드와 네이티브 모드 모두에서 실행 가능한 통합 테스트
@QuarkusIntegrationTest
public class GreetingResourceIT {

    @Test
    public void testHelloEndpoint() {
        // /hello 엔드포인트가 200 OK와 예상 문자열을 반환하는지 검증
        given()
          .when().get("/hello")
          .then()
             .statusCode(200)
             .body(is("Hello from Quarkus Native!"));
    }
}
Java

@QuarkusIntegrationTest@QuarkusTest와 달리 외부 프로세스로 기동된 애플리케이션에 HTTP 요청을 보내는 방식으로 테스트를 수행한다. 따라서 네이티브 바이너리에 대해서도 동일한 테스트 코드가 동작한다.

리플렉션 관련 문제가 발생하면 Quarkus의 @RegisterForReflection 어노테이션을 사용하여 해당 클래스를 네이티브 이미지에 등록할 수 있다.

import io.quarkus.runtime.annotations.RegisterForReflection;

// 이 어노테이션이 붙은 클래스는 네이티브 빌드 시 리플렉션 메타데이터가 자동 포함된다
@RegisterForReflection
public class MyDTO {
    private String name;
    private int value;
    // getter/setter 생략
}
Java

@RegisterForReflection은 Quarkus가 제공하는 네이티브 이미지 전용 어노테이션이다. 이 어노테이션이 붙은 클래스는 GraalVM의 리플렉션 설정 파일(reflect-config.json)에 자동 등록되어 런타임에 리플렉션 접근이 가능해진다. Jackson 직렬화 대상 DTO나 JPA 엔티티에 주로 사용한다.

숫자로 보는 차이 — JVM vs Quarkus Native Image 벤치마크

Quarkus vs Spring Boot vs Micronaut 네이티브 빌드 성능 벤치마크 비교표

Java Code Geeks의 2026 벤치마크 결과를 기반으로 세 프레임워크의 성능을 비교한다. 테스트 환경은 Java 25, 동일 REST API 기준이다.

JVM 모드

프레임워크기동 시간힙 메모리
Quarkus1.154초14.4 MB
Micronaut0.656초17.6 MB
Spring Boot1.909초28.2 MB

Native 모드

프레임워크기동 시간힙 메모리Max RSS
Quarkus0.049초3.2 MB70.5 MB
Micronaut0.050초6.0 MB83.8 MB
Spring Boot0.104초11.0 MB149.4 MB

Quarkus Native Image의 기동 시간 0.049초는 JVM 모드 대비 23배 빠르다. 힙 메모리 3.2MB는 Spring Boot Native의 11MB 대비 3.4배 적다. Max RSS(실제 물리 메모리 사용량) 70.5MB는 Spring Boot Native의 149.4MB 대비 절반 이하이다.

AWS Lambda 같은 서버리스 환경에서는 콜드 스타트 시간이 곧 응답 지연이다. GraalVM 공식 벤치마크에서 보고하는 콜드 스타트 10배 개선은 이러한 시나리오에서 실질적인 비용 절감으로 이어진다.

GC 튜닝으로 메모리 더 쥐어짜기

Quarkus Native Image의 기본 GC는 Serial GC이다. Mark-Copy 알고리즘을 사용하며, 단순하고 예측 가능한 동작이 특징이다. 사용 시나리오에 따라 GC 정책을 변경할 수 있다.

# application.properties — 서버리스 함수처럼 극히 짧은 수명의 워크로드용
# Epsilon GC: GC를 아예 수행하지 않아 오버헤드가 0이지만, 메모리가 고갈되면 프로세스가 종료된다
quarkus.native.additional-build-args=-gc=epsilon

Epsilon GC는 GC 오버헤드를 완전히 제거하지만 메모리 회수가 불가능하다. AWS Lambda처럼 실행 시간이 수 초 이내인 서버리스 함수에서 유효한 선택이다. 일반적인 장기 실행 서비스에서는 기본 Serial GC를 유지하는 것이 안전하다.

런타임에서 GC 동작을 모니터링하려면 다음 옵션을 사용한다.

# GC 로그를 활성화하여 실행
./target/native-demo-1.0.0-SNAPSHOT-runner \
  -XX:+PrintGC \
  -XX:+VerboseGC
ShellScript

PrintGC는 GC 발생 시점과 소요 시간을, VerboseGC는 세대별 메모리 변화와 힙 구조 정보를 출력한다. 이 로그를 분석하면 메모리 압박 지점을 파악하고 힙 크기를 적절히 조정할 수 있다.

네이티브 빌드가 실패할 때 — 트러블슈팅 가이드

네이티브 빌드는 JVM 빌드보다 실패할 여지가 많다. 자주 부딪히는 문제 두 가지를 정리한다.

리플렉션 관련 오류

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

Random 시드, 런타임 환경 변수 의존 클래스, JNI를 사용하는 라이브러리 등은 빌드 타임 초기화와 충돌할 수 있다. --initialize-at-run-time 옵션으로 해당 클래스를 런타임 초기화로 전환하면 해결된다.

빌드 리포트 활성화

# 빌드 분석 리포트 생성 — 어떤 클래스가 포함되었는지 확인
./mvnw package -DskipTests -Dnative \
  -Dquarkus.native.enable-reports
ShellScript

빌드 리포트는 target/ 디렉토리에 CSV 형식으로 생성되며, 네이티브 이미지에 포함된 클래스와 메서드의 호출 트리를 확인할 수 있다. 불필요한 클래스가 포함되어 바이너리 크기가 비대해졌을 때 원인을 추적하는 데 유용하다.

마치며

지금까지 Quarkus Native Image를 GraalVM/Mandrel로 빌드하고 컨테이너로 배포하는 전체 과정을 정리해 보았다. quarkus.native.container-build=true 한 줄이면 로컬 GraalVM 설치 없이 네이티브 빌드가 가능하고, 멀티 스테이지 Dockerfile로 70~100MB짜리 프로덕션 이미지까지 만들 수 있다. 다만 JVM에서 잘 돌던 코드가 네이티브에서 깨지는 경우가 있으니 @QuarkusIntegrationTest로 검증하는 과정은 빠뜨리지 않는 편이 좋다.

더 알아보기