GraalVM Native Image — 스프링 애플리케이션을 네이티브로 컴파일하는 방법
Java 애플리케이션이 시작하는 데 10초 이상 걸린다면, 서버리스 환경에서 콜드 스타트 문제를 어떻게 해결할 수 있을까요?
개념 정의
GraalVM Native Image 는 Java 애플리케이션을 AOT(Ahead-Of-Time) 컴파일하여 독립 실행 가능한 네이티브 바이너리로 변환하는 기술입니다. JVM 없이 직접 실행되므로 시작 시간이 밀리초 단위로 줄어들고 메모리 사용량도 크게 감소합니다.
JVM vs Native Image
| 기준 | JVM (JIT) | Native Image (AOT) |
|---|---|---|
| 시작 시간 | 수 초~수십 초 | 수십 밀리초 |
| 메모리 사용 | 수백 MB | 수십 MB |
| 피크 성능 | JIT 최적화로 높음 | 상대적으로 낮을 수 있음 |
| 빌드 시간 | 빠름 | 느림 (수 분) |
| 패키징 크기 | JRE 필요 | 단일 바이너리 |
| 디버깅 | 풍부한 도구 | 제한적 |
Spring Boot 3 네이티브 지원
Spring Boot 3부터 네이티브 이미지를 공식 지원합니다.
프로젝트 설정
// build.gradle
plugins {
id 'org.graalvm.buildtools.native' version '0.10.4'
id 'org.springframework.boot' version '3.4.0'
}
// Maven — pom.xml
// <parent>
// <artifactId>spring-boot-starter-parent</artifactId>
// <version>3.4.0</version>
// </parent>
// native 프로필이 자동 포함됨
빌드 명령
# Gradle
./gradlew nativeCompile
# Maven
./mvnw -Pnative native:compile
# Docker 이미지로 빌드 (GraalVM 설치 불필요)
./gradlew bootBuildImage
# 또는
./mvnw -Pnative spring-boot:build-image
실행
# 빌드된 바이너리 직접 실행
./build/native/nativeCompile/my-app
# 시작 시간 비교
# JVM: Started MyApp in 3.245 seconds
# Native: Started MyApp in 0.065 seconds ← 약 50배 빠름
AOT 컴파일의 제약
네이티브 이미지는 빌드 시점에 모든 코드를 분석하고 사용되지 않는 코드를 제거합니다. 이 때문에 Java의 동적 기능에 제약이 생깁니다.
1. 리플렉션 (Reflection)
// 런타임에 동적으로 클래스에 접근 — 네이티브 이미지에서 실패할 수 있음
Class<?> clazz = Class.forName("com.example.MyClass");
Object obj = clazz.getDeclaredConstructor().newInstance();
빌드 시점에 MyClass가 사용되는지 알 수 없으므로 바이너리에서 제거될 수 있습니다.
2. 동적 프록시
// JDK 동적 프록시 — 빌드 시점에 프록시 대상을 알아야 함
Proxy.newProxyInstance(
classLoader,
new Class<?>[]{ MyInterface.class },
invocationHandler
);
3. 리소스 접근
// 리소스 파일도 명시적으로 포함시켜야 함
getClass().getResourceAsStream("/config/rules.json");
4. 직렬화
// Serializable 클래스도 hints 등록 필요
class MyDto implements Serializable { ... }
Hints 등록 — 동적 기능 지원
RuntimeHintsRegistrar
@Component
@ImportRuntimeHints(MyRuntimeHints.class)
public class MyService {
// 리플렉션으로 접근하는 로직
}
public class MyRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// 리플렉션 힌트
hints.reflection()
.registerType(MyDto.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
MemberCategory.INVOKE_DECLARED_METHODS,
MemberCategory.DECLARED_FIELDS);
이어서 나머지 구현 부분입니다.
// 리소스 힌트
hints.resources()
.registerPattern("config/*.json")
.registerPattern("templates/*.html");
// JDK 프록시 힌트
hints.proxies()
.registerJdkProxy(MyInterface.class);
// 직렬화 힌트
hints.serialization()
.registerType(MyDto.class);
}
}
@RegisterReflectionForBinding
간단한 경우 어노테이션으로 DTO 클래스의 리플렉션을 등록할 수 있습니다.
@RestController
@RegisterReflectionForBinding({
UserResponse.class,
OrderResponse.class,
ErrorResponse.class
})
public class ApiController {
// Jackson이 이 DTO들에 리플렉션 접근
}
reflect-config.json
GraalVM의 네이티브 설정 파일을 직접 작성할 수도 있습니다.
// src/main/resources/META-INF/native-image/reflect-config.json
[
{
"name": "com.example.dto.UserResponse",
"allDeclaredConstructors": true,
"allDeclaredMethods": true,
"allDeclaredFields": true
}
]
Spring AOT 엔진
Spring Boot 3는 빌드 시점에 AOT 처리를 수행하여 네이티브 이미지에 필요한 코드를 미리 생성합니다.
[소스 코드]
↓ AOT 처리 (빌드 시점)
[빈 정의 코드 생성] — XML/어노테이션 → 직접 Java 코드로 변환
[프록시 클래스 생성] — CGLIB 프록시를 빌드 시점에 생성
[리플렉션 hints 자동 생성] — Spring이 사용하는 리플렉션 정보 수집
↓ native-image 컴파일
[네이티브 바이너리]
Spring이 자동으로 처리하는 것:
@Component,@Bean등록 → 코드 기반 빈 정의로 변환@Transactional등 AOP → 빌드 시점 프록시 생성@ConfigurationProperties→ 바인딩 코드 생성
호환성 확인
모든 라이브러리가 네이티브 이미지를 지원하는 것은 아닙니다.
잘 지원되는 라이브러리
- Spring Framework 6+ / Spring Boot 3+
- Spring Data JPA, MongoDB, Redis
- Spring Security
- Spring Cloud (일부)
- Lombok (빌드 시점에 동작하므로 문제 없음)
주의가 필요한 라이브러리
- Hibernate (일부 기능 제한)
- 리플렉션 기반 라이브러리 (MapStruct는 빌드 시점이라 OK)
- 바이트코드 조작 라이브러리 (Byte Buddy 등)
테스트
네이티브 이미지에서 테스트를 실행할 수 있습니다.
# Gradle
./gradlew nativeTest
# Maven
./mvnw -Pnative test
// AOT 처리된 테스트
@SpringBootTest
class MyServiceTest {
@Test
void contextLoads() {
// 네이티브 이미지로 빌드 후 실행되는 테스트
}
}
빌드 최적화
네이티브 이미지 빌드는 시간이 오래 걸립니다 (수 분~수십 분). 개발 중에는 JVM으로 실행하고, CI/CD에서만 네이티브 빌드를 하는 것이 효율적입니다.
# GitHub Actions 예시
jobs:
native-build:
runs-on: ubuntu-latest
steps:
- uses: graalvm/setup-graalvm@v1
with:
java-version: '21'
distribution: 'graalvm'
- run: ./gradlew nativeCompile
- run: ./build/native/nativeCompile/my-app &
- run: curl --retry 10 --retry-delay 1 http://localhost:8080/actuator/health
네이티브 이미지가 적합한 경우
적합
- 서버리스 / FaaS: 콜드 스타트 시간이 중요
- **CLI 도구 **: 빠른 시작, 단일 바이너리 배포
- ** 마이크로서비스 **: 컨테이너 크기와 메모리 절약
- ** 임베디드 시스템 **: 리소스 제한 환경
부적합
- ** 장시간 실행 서버 **: JIT의 피크 성능이 더 높을 수 있음
- ** 리플렉션 의존도 높은 앱 **: hints 관리 부담이 큼
- ** 빠른 개발 사이클 **: 긴 빌드 시간이 개발을 방해
주의할 점
1. 리플렉션 hints를 누락하면 런타임에 ClassNotFoundException이 발생한다
네이티브 이미지는 빌드 시점에 사용되지 않는 코드를 제거합니다. Jackson이 리플렉션으로 접근하는 DTO 클래스의 hints를 등록하지 않으면, JVM에서는 정상 동작하던 API가 네이티브 빌드에서 ClassNotFoundException이나 직렬화 실패로 터집니다. JVM 모드에서 테스트를 통과해도 네이티브에서 실패할 수 있으므로, nativeTest를 CI에 반드시 포함해야 합니다.
2. 동적 프록시를 사용하는 라이브러리가 네이티브에서 동작하지 않을 수 있다
Hibernate의 일부 기능, Byte Buddy 기반 라이브러리, 동적 프록시를 사용하는 서드파티 라이브러리는 AOT 컴파일과 호환되지 않을 수 있습니다. JVM에서 잘 동작하던 기능이 네이티브에서 갑자기 실패하므로, 의존하는 라이브러리의 GraalVM 호환성을 미리 확인해야 합니다.
3. 네이티브 빌드 시간이 수십 분이라 개발 사이클이 느려진다
네이티브 이미지 빌드는 메모리를 많이 사용하고(8GB 이상 권장) 시간이 수 분에서 수십 분 걸립니다. 개발 중에 네이티브 빌드를 반복하면 생산성이 크게 떨어집니다. 개발 시에는 JVM 모드로 실행하고, CI/CD 파이프라인에서만 네이티브 빌드를 수행하는 전략이 필수입니다.
정리
| 항목 | 설명 |
|---|---|
| 장점 | 시작 시간 ~50ms, 메모리 수십 MB. 서버리스에 최적 |
| 빌드 | nativeCompile (Gradle) / -Pnative native:compile (Maven) |
| 제약 | 리플렉션, 동적 프록시, 리소스에 hints 등록 필요 |
| hints 등록 | RuntimeHintsRegistrar 또는 @RegisterReflectionForBinding |
| 개발 전략 | 개발 시 JVM, CI/CD에서만 네이티브 빌드 |
| 부적합 케이스 | 장시간 실행 서버 (JIT 피크 성능이 더 높을 수 있음) |