마이크로서비스와 컨테이너 기반 배포 환경이 보편화되면서, Java 애플리케이션의 Cold Start 문제와 높은 메모리 사용량이 운영 비용에 직접적인 영향을 주고 있다. Kubernetes 클러스터에서 HPA(Horizontal Pod Autoscaler)로 파드를 빠르게 확장하려면 새 파드가 수 초 안에 트래픽을 받을 수 있어야 한다. Quarkus Native는 이 문제를 GraalVM의 AOT(Ahead-of-Time) 컴파일 기술로 해결한다. JVM 없이 OS 네이티브 바이너리로 실행되어 10ms 수준의 시작 시간과 50~100MB 수준의 메모리 사용량을 실현한다.
Quarkus Native란 무엇인가
Quarkus Native는 Quarkus 공식 문서에서 설명하듯, GraalVM의 Native Image 기술을 사용하여 Quarkus 애플리케이션을 플랫폼 특화 실행 파일로 컴파일하는 기능이다. 결과물은 JVM이 전혀 필요 없는 독립 바이너리이며, Linux 환경이라면 ./application처럼 직접 실행할 수 있다.
일반적인 JVM 기반 Java 프로세스는 시작 시 클래스 로더가 수백 개의 클래스를 메모리에 적재하고, JIT 컴파일러가 자주 실행되는 코드를 기계어로 최적화하는 Warm-up 과정을 거친다. 이 과정이 1~3초 이상 걸리기 때문에, 컨테이너 환경에서 스케일 아웃이 발생했을 때 새 파드가 즉시 트래픽을 받지 못하는 문제가 생긴다. Quarkus Native는 이 과정을 전부 빌드 타임에 완료하여 런타임 오버헤드를 제거한다.
빌드 타임 처리가 핵심이다
Quarkus 프레임워크가 타 Java 프레임워크와 구별되는 설계 원칙은 빌드 타임 메타데이터 처리다. Spring Boot가 애플리케이션 시작 시점에 어노테이션을 스캔하고 Bean 컨테이너를 구성하는 반면, Quarkus는 Maven 또는 Gradle 빌드 단계에서 CDI Bean 등록, 어노테이션 처리, 의존성 주입 설정을 완료한다. 이렇게 빌드 타임에 처리된 메타데이터 덕분에 GraalVM이 도달 가능한 코드 경로(Reachability Analysis)를 정밀하게 분석하여, 실제로 실행되지 않는 클래스와 메서드를 최종 바이너리에서 제거할 수 있다.
Quarkus Native는 이러한 설계를 기반으로 생성된 바이너리를 의미한다. GraalVM이 전체 코드 경로를 정적 분석하여 dead code를 제거하고, 필요한 클래스만 포함한 단일 실행 파일을 만들어낸다. 이 과정에서 Java의 동적 특성인 Reflection이나 런타임 클래스 로딩은 제약이 생기는데, 이 부분이 Quarkus Native 적용 시 가장 주의해야 할 지점이다.
Quarkus Native 빌드 환경 설정
GraalVM 설치
Quarkus Native 빌드를 로컬에서 실행하려면 GraalVM이 필요하다. SDKMAN을 이용하면 여러 GraalVM 버전을 손쉽게 관리할 수 있다.
# SDKMAN! 설치 (이미 설치된 경우 생략)
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
# GraalVM JDK 21 설치 (Oracle GraalVM Community Edition)
sdk install java 21.0.3-graalce
# 설치 확인
java -version
# java version "21.0.3" 2024-04-16 LTS
# Java(TM) SE Runtime Environment Oracle GraalVM 21.0.3+7.1 (build 21.0.3+7-LTS-jvmci-23.1-b37)
native-image --version
# native-image 21.0.3 2024-04-16
# GraalVM Runtime Environment Oracle GraalVM 21.0.3+7.1 (build 21.0.3+7-LTS-jvmci-23.1-b37)ShellScriptSDKMAN으로 GraalVM을 설치하면 native-image 커맨드도 함께 포함된다. Oracle GraalVM Community Edition은 무료이며 상용 기능 일부가 포함된 버전이다. GraalVM 21 이상부터는 native-image가 기본 포함되어 별도 설치가 필요하지 않다.
SDKMAN 사용법에 대해서 SDKMAN으로 개발도구 버전 쉽게 관리하자 포스팅을 참고하기 바란다.
Maven 프로젝트 설정
Quarkus Native 빌드를 위한 Maven pom.xml 설정이다. 핵심은 quarkus-maven-plugin과 native 프로파일 설정이다.
<?xml version="1.0"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>greeting-native</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<quarkus.platform.version>3.8.4</quarkus.platform.version>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>${quarkus.platform.artifact-id}</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- REST API (RESTEasy Reactive 기반) -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<!-- JSON 직렬화 (Native 환경에서 Reflection 최소화) -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<!-- 헬스체크 (Kubernetes liveness/readiness probe용) -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<activation>
<!-- -Pnative 또는 -Dnative 옵션 사용 시 활성화 -->
<property>
<name>native</name>
</property>
</activation>
<properties>
<!-- 이 설정 하나로 Quarkus Native 빌드 전환 -->
<quarkus.native.enabled>true</quarkus.native.enabled>
<!-- Native 빌드 시 통합 테스트도 실행 -->
<skipITs>false</skipITs>
</properties>
</profile>
</profiles>
</project>XMLquarkus.native.enabled=true 프로퍼티가 Quarkus Native 빌드의 핵심 스위치다. native 프로파일을 활성화하면 quarkus-maven-plugin이 GraalVM native-image 도구를 호출하여 AOT 컴파일을 시작한다. quarkus-rest-jackson 의존성은 Quarkus가 Jackson의 Reflection 사용을 빌드 타임에 미리 등록해주므로 일반 Jackson보다 Native 환경에서 더 안전하게 동작한다.
Quarkus Native 애플리케이션 작성
JAX-RS Resource와 Service 계층
Quarkus Native에서 RESTful API를 작성하는 방식은 JVM 모드와 동일하다. CDI, JAX-RS 어노테이션이 모두 동일하게 동작한다.
// src/main/java/com/example/GreetingResource.java
package com.example;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/greetings")
@Produces(MediaType.APPLICATION_JSON)
public class GreetingResource {
@Inject
GreetingService greetingService; // CDI 의존성 주입 (Native에서도 동일하게 동작)
@GET
@Path("/{name}")
public Response greet(@PathParam("name") String name) {
return Response.ok(greetingService.greet(name)).build();
}
}Java// src/main/java/com/example/GreetingService.java
package com.example;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped // Quarkus가 빌드 타임에 이 Bean을 CDI 컨테이너에 등록한다
public class GreetingService {
public Greeting greet(String name) {
return new Greeting("Hello, " + name + "!", System.currentTimeMillis());
}
}Java// src/main/java/com/example/Greeting.java
package com.example;
// Java 16+ Record 타입: 불변 데이터 클래스를 간결하게 표현
// Jackson이 Record의 컴포넌트 메서드를 자동으로 직렬화 필드로 인식한다
public record Greeting(String message, long timestamp) {}Java@ApplicationScoped로 선언된 Bean은 Quarkus가 빌드 단계에서 프록시 클래스를 미리 생성한다. 런타임에 Reflection으로 프록시를 생성하는 방식이 아니므로 Quarkus Native 환경에서도 CDI가 완전히 동작한다. Java Record를 DTO로 사용하면 Lombok 없이도 간결하게 불변 데이터 클래스를 정의할 수 있으며, Jackson의 Record 지원으로 별도 어노테이션 없이 JSON 직렬화가 가능하다.
application.properties 설정
# src/main/resources/application.properties
# 기본 HTTP 포트
quarkus.http.port=8080
# Native 빌드 옵션: 예외 발생 시 전체 스택 트레이스 포함
quarkus.native.additional-build-args=-H:+ReportExceptionStackTraces
# 리소스 파일 포함 (classpath의 JSON/YAML 설정 파일을 바이너리에 포함)
quarkus.native.resources.includes=**/*.json,**/*.yaml
# HTTPS URL 핸들러 활성화 (외부 HTTPS 호출이 있는 경우 필요)
quarkus.native.enable-https-url-handler=true
# 컨테이너 환경에서 GraalVM 없이 Native 빌드 (CI/CD용)
# 아래 주석을 해제하면 Docker 컨테이너 안에서 빌드가 실행된다
# quarkus.native.container-build=true
# quarkus.native.builder-image=quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-21
# 개발 모드 전용 설정 (%dev. 접두사는 dev 프로파일에서만 적용)
%dev.quarkus.log.level=DEBUGquarkus.native.container-build=true는 로컬에 GraalVM이 설치되지 않은 CI/CD 파이프라인에서 특히 유용한 설정이다. 이 옵션을 활성화하면 Quarkus가 GraalVM이 설치된 Docker 컨테이너를 자동으로 실행하여 그 안에서 Native 빌드를 완료한다. 빌드 결과 바이너리는 Linux 전용이므로, 이후 Docker 이미지 패키징 시 별도의 크로스 컴파일 설정 없이 바로 사용할 수 있다.
Quarkus Native 빌드 실행
빌드 명령어
# 방법 1: 로컬 GraalVM으로 Native 빌드
./mvnw package -Pnative
# 방법 2: Docker 컨테이너 안에서 빌드 (GraalVM 미설치 환경)
./mvnw package -Pnative \
-Dquarkus.native.container-build=true
# 방법 3: 특정 GraalVM 빌더 이미지 지정
./mvnw package -Pnative \
-Dquarkus.native.container-build=true \
-Dquarkus.native.builder-image=quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-21
# 방법 4: Docker 이미지까지 한 번에 빌드
./mvnw package -Pnative \
-Dquarkus.native.container-build=true \
-Dquarkus.container-image.build=true \
-Dquarkus.container-image.group=my-org \
-Dquarkus.container-image.name=greeting-native \
-Dquarkus.container-image.tag=1.0.0
# 빌드 완료 후 Native 바이너리 직접 실행
./target/greeting-native-1.0.0-SNAPSHOT-runnerShellScript-Pnative 옵션으로 Maven native 프로파일을 활성화하면 Quarkus Maven Plugin이 GraalVM native-image 도구를 자동 호출한다. 빌드 시간은 시스템 사양에 따라 2~5분 정도 소요된다. 빌드가 완료되면 target/ 디렉터리에 *-runner 형태의 실행 파일이 생성되며, JVM 없이 직접 실행 가능하다. 방법 4처럼 quarkus-container-image-docker Extension을 함께 사용하면 빌드부터 Docker 이미지 생성까지 단일 명령으로 처리된다.
Quarkus Native에서 Reflection 처리
Quarkus Native 적용 시 가장 자주 만나는 문제가 Reflection 관련 오류다. GraalVM은 빌드 타임에 도달 가능한 코드만 바이너리에 포함하는데, Reflection으로 동적 접근하는 클래스는 정적 분석에서 감지되지 않아 런타임에 ClassNotFoundException이 발생할 수 있다.
@RegisterForReflection 어노테이션
// src/main/java/com/example/ExternalApiResponse.java
package com.example;
import io.quarkus.runtime.annotations.RegisterForReflection;
// 외부 라이브러리 응답 DTO처럼 Quarkus가 관리하지 않는 클래스에 적용
// 이 어노테이션으로 GraalVM Native Image에 Reflection 접근을 명시적으로 등록한다
@RegisterForReflection
public class ExternalApiResponse {
private String status;
private String data;
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getData() { return data; }
public void setData(String data) { this.data = data; }
}Java아래 JSON 파일은 어노테이션 적용이 불가능한 외부 라이브러리 클래스를 등록하는 방법이다. JSON 표준은 주석을 허용하지 않으므로, 실제 파일에서는 주석을 제거하고 사용해야 한다.
[
{
"name": "com.example.ExternalApiResponse",
"allDeclaredFields": true,
"allDeclaredMethods": true,
"allDeclaredConstructors": true
}
]JSON@RegisterForReflection은 Quarkus가 제공하는 어노테이션으로, 해당 클래스를 GraalVM Native Image의 Reflection 허용 목록에 등록한다. Quarkus 내부에서 사용하는 CDI Bean이나 JAX-RS Resource는 프레임워크가 자동으로 등록하므로 직접 어노테이션을 붙일 필요가 없다. 문제가 생기는 경우는 주로 외부 라이브러리의 DTO 클래스나 직접 Class.forName()을 호출하는 코드다. reflect-config.json 파일은 Quarkus가 자동으로 인식하여 빌드 시 GraalVM에 전달한다.
Quarkus Native vs JVM 모드 성능 비교
Quarkus Native와 JVM 모드의 성능 특성은 사용 목적에 따라 유리한 측면이 다르다. 단순히 “Native가 더 빠르다” 라고 단정할 수 없으며, 워크로드 특성을 고려한 선택이 필요하다.
| 항목 | JVM 모드 | Quarkus Native |
|---|---|---|
| 시작 시간 | 1~3초 | 10~50ms |
| 메모리 사용(RSS) | 200~500MB | 30~100MB |
| 최대 Throughput | 높음 (JIT 장기 최적화) | 중간 (AOT 고정 최적화) |
| 빌드 시간 | 수 초 | 2~5분 |
| Docker 이미지 크기 | 200~400MB | 30~80MB |
| GC 동작 | JVM GC 전략 선택 가능 | Serial GC 기본 (G1 일부 지원) |
JVM 모드는 오랜 시간 실행될수록 JIT 컴파일러가 핫스팟을 최적화하여 Throughput이 높아진다. 반면 Quarkus Native는 시작 시간이 극히 짧고 메모리 사용량이 낮다는 장점이 있지만, JIT 최적화가 없으므로 장시간 실행되는 고부하 워크로드에서는 JVM 모드보다 Throughput이 낮을 수 있다. 따라서 Quarkus Native는 서버리스 함수, 빠른 스케일 아웃이 필요한 마이크로서비스, 메모리 제한이 엄격한 엣지 컴퓨팅 환경에 특히 적합하다.
Quarkus Native Docker 멀티스테이지 빌드
Quarkus Native 바이너리를 컨테이너 이미지로 패키징할 때는 멀티스테이지 빌드를 사용하면 최종 이미지 크기를 최소화할 수 있다.
# src/main/docker/Dockerfile.native-micro
# Stage 1: GraalVM 빌더 이미지에서 Native 바이너리 컴파일
FROM quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-21 AS build
USER quarkus
WORKDIR /code
# 의존성 레이어를 먼저 캐시하기 위해 pom.xml과 mvnw를 먼저 복사
COPY --chown=quarkus:quarkus mvnw /code/mvnw
COPY --chown=quarkus:quarkus .mvn /code/.mvn
COPY --chown=quarkus:quarkus pom.xml /code/
RUN ./mvnw -B dependency:resolve -q
# 소스 코드 복사 후 Native 빌드 실행
COPY --chown=quarkus:quarkus src /code/src
RUN ./mvnw package -Pnative -DskipTests -q
# Stage 2: 최소 런타임 이미지 (JVM 미포함, ~9MB 기반 이미지)
FROM quay.io/quarkus/quarkus-micro-image:2.0
WORKDIR /work/
# 빌드 스테이지에서 생성된 Native 바이너리만 복사
COPY --from=build /code/target/*-runner /work/application
RUN chmod 775 /work/application
EXPOSE 8080
USER 1001 # non-root 사용자로 실행 (보안 원칙)
# 0.0.0.0 바인딩: 컨테이너 외부에서 접근 가능하도록 설정
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]Dockerfile멀티스테이지 Dockerfile에서 Stage 1은 GraalVM이 포함된 빌더 이미지를 사용하고, Stage 2는 quarkus-micro-image라는 극도로 작은 기반 이미지를 사용한다. quarkus-micro-image는 Quarkus Native 바이너리 실행에 필요한 최소한의 라이브러리만 포함하므로 최종 Docker 이미지 크기가 30~80MB 수준이다. JVM 기반 이미지(eclipse-temurin:21-jre)를 사용하면 200MB를 넘는 것과 대비된다. 의존성 레이어를 먼저 복사하여 캐시하는 패턴은 소스 코드만 변경됐을 때 mvnw dependency:resolve 레이어를 재사용할 수 있어 CI/CD 빌드 시간을 단축한다.
# Docker 이미지 빌드
docker build -f src/main/docker/Dockerfile.native-micro \
-t my-org/greeting-native:1.0.0 .
# 이미지 크기 확인 (30~80MB 수준)
docker images my-org/greeting-native
# 로컬에서 실행 테스트
docker run -i --rm -p 8080:8080 my-org/greeting-native:1.0.0ShellScriptKubernetes에 Quarkus Native 배포하기
Quarkus Native 애플리케이션은 시작 시간이 극도로 짧기 때문에 Kubernetes의 liveness/readiness probe 설정을 JVM 모드보다 훨씬 공격적으로 설정할 수 있다.
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: greeting-native
labels:
app: greeting-native
spec:
replicas: 3
selector:
matchLabels:
app: greeting-native
template:
metadata:
labels:
app: greeting-native
spec:
containers:
- name: greeting-native
image: my-org/greeting-native:1.0.0
ports:
- containerPort: 8080
resources:
requests:
memory: "64Mi" # JVM 모드의 1/5 수준으로 요청량 설정
cpu: "50m"
limits:
memory: "128Mi" # Quarkus Native의 낮은 메모리 사용량 반영
cpu: "500m"
livenessProbe:
httpGet:
path: /q/health/live # quarkus-smallrye-health Extension이 제공하는 엔드포인트
port: 8080
initialDelaySeconds: 1 # Native는 10ms에 시작되므로 1초만 대기
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /q/health/ready
port: 8080
initialDelaySeconds: 1
periodSeconds: 5
failureThreshold: 3
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: greeting-native-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: greeting-native
minReplicas: 3
maxReplicas: 20 # Native의 빠른 시작 특성으로 스케일 아웃 상한을 높게 설정
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60YAMLQuarkus Native 배포의 핵심 이점이 이 YAML에 집약되어 있다. resources.requests.memory를 64Mi로 설정하는 것은 JVM 기반 동일 애플리케이션에서 요구하는 256Mi 대비 1/4 수준이며, Kubernetes 클러스터의 메모리 자원 효율이 크게 향상된다. initialDelaySeconds: 1 설정은 JVM 모드에서 통상적으로 사용하는 30초와 비교했을 때 Quarkus Native의 빠른 시작을 직접적으로 활용한 것이다. HPA의 maxReplicas를 높게 설정해도 새 파드가 수십 밀리초 안에 트래픽을 받을 수 있으므로, 급격한 트래픽 증가에 훨씬 유연하게 대응할 수 있다.
# Kubernetes 클러스터에 배포
kubectl apply -f k8s/deployment.yaml
# 파드 시작 시간 확인 (Native는 수초 안에 Running + Ready 상태)
kubectl get pods -w -l app=greeting-native
# 리소스 사용량 확인
kubectl top pods -l app=greeting-nativeShellScript지금까지 Quarkus Native에 대해서 정리해 보았다. GraalVM AOT 컴파일 원리부터 Maven 빌드 설정, Reflection 처리, Docker 멀티스테이지 빌드, Kubernetes 배포 전략까지 실무에서 바로 적용 가능한 내용을 중심으로 살펴보았다. Quarkus Native는 Cold Start가 중요한 서버리스 환경이나 메모리 제약이 있는 엣지 환경에서 특히 강력한 선택지다. 다만 빌드 시간이 길고 Reflection 제약이 존재하므로, 팀의 기술 성숙도와 운영 환경 특성을 고려하여 JVM 모드와 Native 모드를 상황에 맞게 선택하는 것이 현명하다.
