AOT
contents
JIT가 "암기력이 좋은 통역사"라면, AOT(Ahead-Of-Time) 컴파일은 "완성된 책을 인쇄해버리는 출판사"라고 비유할 수 있습니다.
JIT는 프로그램이 실행되는 동안 코드를 컴파일하지만, AOT는 프로그램이 시작되기도 전(빌드 타임) 에 코드를 미리 컴파일해 버립니다. 이것은 C, C++, Rust 같은 언어들이 작동하는 방식이며, GraalVM은 이 기능을 자바에 도입했습니다.
GraalVM 관점에서 AOT에 대해 알아보겠습니다.
1. 핵심 개념: "스냅샷 (The Snapshot)"
일반적인 자바(JIT)에서는 .class나 .jar 파일을 배포합니다. 그리고 이를 실행하려면 컴퓨터에 반드시 JVM이 설치되어 있어야 합니다.
반면, AOT(GraalVM Native Image)의 빌드 프로세스는 어플리케이션의 코드, 사용하는 라이브러리, 그리고 JDK의 필요한 부분들을 모두 합쳐서 독립적인 네이티브 실행 파일(Standalone Native Executable) 로 컴파일합니다. (윈도우의 .exe나 리눅스의 바이너리 파일처럼 변합니다.)
결과: 자바가 설치되지 않은 컴퓨터에서도 이 파일을 실행할 수 있습니다.
2. 작동 원리: "네이티브 이미지" 빌더
이 마법은 native-image라는 도구를 통해 일어납니다. 단계별 과정은 다음과 같습니다.
- 정적 분석 (Static Analysis): 빌더가
main()메서드에서부터 분석을 시작합니다. - 도달 가능성 탐색 (Reachability Scan): 메인 메서드에서 시작하여 호출되는 모든 메서드, 변수, 클래스 참조를 끈질기게 추적합니다.
- 트리 쉐이킹 (Tree Shaking): 라이브러리에는 포함되어 있지만, 여러분의 코드가 절대 닿지 않는(사용하지 않는) 클래스나 메서드가 있다면, AOT는 이를 과감히 버립니다. 최종 결과물에 포함하지 않습니다.
- 스냅샷 (Snapshotting): 남은 코드들을 즉시 기계어로 컴파일합니다. 또한 빌드 타임에 정적 변수(static variables)들을 초기화하고 그 상태를 "힙 스냅샷(Heap Snapshot)"으로 저장해 둡니다.
- Substrate VM (SVM): Substrate VM이라고 불리는 아주 작은 런타임을 바이너리 안에 함께 포장합니다. SVM은 메모리 관리(GC)나 스레드 스케줄링 같은 기본적인 작업을 수행하지만, 거대한 HotSpot JVM보다는 훨씬 가볍습니다.
3. "닫힌 세계 가설" (Closed World Assumption) - 가장 중요한 제약
이것이 AOT를 이해하는 데 있어 가장 중요한 개념입니다. JIT는 열린 세계(Open World) 를 가정합니다 (언제든 새로운 클래스를 동적으로 로딩할 수 있음). 하지만 AOT는 닫힌 세계(Closed World) 를 가정합니다.
규칙: 실행될 가능성이 있는 모든 바이트코드는 반드시 빌드 타임에 알려져 있어야 합니다.
결과:
- 동적 클래스 로딩 불가: 인터넷에서
.class파일을 다운로드하여 실행하는 등의 작업은 불가능합니다. - 리플렉션 (Reflection) 제약: 자바의 리플렉션(
Class.forName("com.example.User"))은 정적 분석기가 "com.example.User"라는 문자열을 코드로 인식하지 못하기 때문에 문제가 됩니다. 분석기는User클래스가 안 쓰인다고 판단하고 트리 쉐이킹 과정에서 삭제해 버릴 수도 있습니다.- 해결책: 빌더에게 설정 파일(
reflect-config.json)을 제공하여 "이User클래스는 지우지 마세요. 나중에 리플렉션으로 쓸 겁니다"라고 알려줘야 합니다.
- 해결책: 빌더에게 설정 파일(
4. 성능 프로필: AOT vs JIT
많은 개발자가 혼동하는 부분입니다. "네이티브"니까 무조건 더 빠를까요? 상황에 따라 다릅니다.
A. 시동 시간 (Startup Time): AOT 승리 🏆
- JIT: JVM 로딩 -> 클래스 로딩 -> 검증 -> 인터프리팅 -> 프로파일링 -> 컴파일 과정을 거쳐야 합니다. (수 초 소요)
- AOT: OS가 바이너리를 RAM에 올리고 첫 명령어로 점프하면 끝입니다. (밀리초 단위 소요)
- 사용 사례: 서버리스 함수(AWS Lambda), CLI 도구, 0개로 스케일 다운되는 마이크로서비스.
B. 메모리 사용량 (Memory Footprint): AOT 승리 🏆
- JIT: JVM 내부 구조, 바이트코드, JIT 컴파일러 자체, 프로파일링 데이터를 위한 메모리가 필요합니다.
- AOT: 실행 코드와 애플리케이션 데이터만 있으면 됩니다. 매우 가볍습니다.
C. 최대 처리량 (Peak Throughput - 최고 속도): (보통) JIT 승리 🏆
- JIT: 추측성 최적화(Speculative Optimization) 를 수행합니다. 실시간으로 흐르는 데이터를 보면서 "사용자의 99%가 로그인을 하네?"라고 판단하면 그 경로를 최적화합니다. 패턴이 바뀌면 최적화를 풀고 다시 컴파일할 수도 있습니다.
- AOT: 코드가 빌드 타임에 얼어붙(Frozen)습니다. 런타임 데이터에 따라 전략을 바꿀 수 없으므로, JIT만큼 공격적인 최적화는 어렵습니다.
참고: GraalVM Enterprise 버전은 PGO (Profile-Guided Optimization) 를 제공합니다. 앱을 한번 실행해서 데이터를 모은 뒤, 그 데이터를 AOT 빌더에 입력하여 다시 빌드하는 방식입니다. 이렇게 하면 AOT도 JIT급의 최고 속도를 낼 수 있습니다.
5. 요약 테이블: JIT vs AOT
| 특징 | JIT (표준 자바) | AOT (GraalVM 네이티브 이미지) |
|---|---|---|
| 빌드 시간 | 빠름 (javac는 금방 끝남). |
매우 느림 (몇 분 걸리며 RAM을 많이 씀). |
| 시동 시간 | 느림 (워밍업 필요). | 즉시 실행. |
| 실행 파일 크기 | 작은 .jar (하지만 거대한 JVM 필요). |
더 큰 바이너리 (하지만 독립 실행). |
| 메모리 사용량 | 높음 (JVM 오버헤드). | 낮음. |
| 최대 성능 | 매우 우수 (적응형). | 좋음 (정적). |
| 동적 기능 | 완벽 지원 (리플렉션, 프록시 등). | 제한적 (설정 필요). |
| 이식성 | "Write Once, Run Anywhere". | OS 종속 (리눅스/맥용을 따로 빌드해야 함). |
6. 왜 지금 유행인가요? (쿠버네티스와 클라우드)
쿠버네티스에 자바 파드(Pod)를 배포한다고 가정해 봅시다:
- 표준 자바: 시동에 10초가 걸리고 메모리를 500MB나 먹습니다. 트래픽이 폭주할 때(Scale-up) 빠르게 대응하기 어렵습니다.
- GraalVM AOT: 0.05초 만에 켜지고 메모리를 30MB만 씁니다.
이 덕분에 자바가 클라우드 네이티브 환경에서 Go(Golang)나 Rust와 경쟁할 수 있게 되었습니다. Spring Boot 3, Quarkus, Micronaut 같은 최신 프레임워크들이 GraalVM AOT를 강력하게 지원하는 이유입니다.
Maven을 사용하여 표준 JIT(Jar) 워크플로우와 AOT(Native) 워크플로우를 나란히 비교해 보겠습니다.
1. pom.xml 설정
표준 애플리케이션의 경우 특별히 할 게 없지만, 네이티브 이미지의 경우 GraalVM Build Tools가 필요합니다.
시나리오: 간단한 REST API (DemoApplication)
A. 표준 (JIT)
기본적인 spring-boot-starter-parent만 있으면 됩니다.
org.springframework.boot
spring-boot-starter-parent
3.2.0
B. 네이티브 (AOT)
코드를 변경할 필요는 없지만, 보통 native 프로파일 처리를 추가합니다. (Spring Boot 3의 부모 POM에는 이미 native라는 이름의 프로파일이 정의되어 있습니다.)
빌드할 때 GraalVM Native Maven Plugin이 활성화되도록 설정만 확인하면 됩니다.
org.graalvm.buildtools
native-maven-plugin
2. 빌드 명령어 (Build Command)
여기서부터 사용자 경험이 확연히 달라집니다.
A. 표준 (JIT)
- 명령어:
mvn clean package - 소요 시간: 약 10초 내외.
- 결과물:
.jar파일 (예:target/demo-0.0.1-SNAPSHOT.jar). - 크기: 약 18 MB.
B. 네이티브 (AOT)
- 명령어:
mvn -Pnative native:compile - 소요 시간: 2분 ~ 5분 (CPU와 RAM을 엄청나게 사용합니다).
- 결과물: 독립 실행 바이너리 파일 (예:
target/demo). - 크기: 약 40 MB ~ 80 MB (Substrate VM이 포함됨).
참고:
-Pnative플래그는 Spring Boot 부모 POM에 정의된 프로파일을 활성화하여 AOT 처리 단계를 트리거합니다.
3. 애플리케이션 실행 (Running)
고생해서 빌드한 결과의 차이를 볼 시간입니다.
A. 표준 (JIT)
실행하려는 머신에 JVM이 설치되어 있어야 합니다.
$ java -jar target/demo-0.0.1-SNAPSHOT.jar
. ____ _
/\\ / ___'_ __ _ _(_)_ __ __ _
...
Started DemoApplication in 2.45 seconds (JVM running for 3.1)
B. 네이티브 (AOT)
Java가 설치되어 있지 않아도 됩니다. 마치 Rust로 만든 바이너리처럼 취급하면 됩니다.
$ ./target/demo
. ____ _
/\\ / ___'_ __ _ _(_)_ __ __ _
...
Started DemoApplication in 0.048 seconds (JVM running for 0.051)
- 결과: 시동 속도가 사실상 즉시(Instant) 입니다.
4. DevOps/Kubernetes 주의사항 (macOS 사용자 필독)
맥북에서 mvn -Pnative native:compile을 실행하면 GraalVM은 macOS용 실행 파일(Mach-O) 을 만듭니다.
- 문제점: 이 macOS 실행 파일은 표준 리눅스 도커 컨테이너(쿠버네티스가 사용하는 환경) 안에서는 실행되지 않습니다.
- 해결책: Cloud Native Buildpacks를 사용하거나 멀티 스테이지 도커 빌드를 사용하여, 리눅스 환경 안에서 바이너리를 컴파일해야 합니다.
"도커 방식" (배포 시 권장)
맥에서 직접 바이너리를 굽는 대신, Maven에게 "도커 컨테이너를 띄워서 그 안에서 네이티브 이미지를 빌드한 다음, 도커 이미지를 줘"라고 명령해야 합니다.
명령어:
mvn -Pnative spring-boot:build-image
Dockerfile 비교 (만약 수동으로 작성한다면):
| 표준 (JIT) Dockerfile | 네이티브 (AOT) Dockerfile |
|---|---|
FROM eclipse-temurin:17 (JVM 포함됨) |
FROM debian:stable-slim (JVM 필요 없음) |
COPY target/app.jar app.jar |
COPY target/app app |
ENTRYPOINT ["java", "-jar", "app.jar"] |
ENTRYPOINT ["./app"] |
| 이미지 크기: ~300MB | 이미지 크기: ~50MB |
5. 요약 체크리스트
| 항목 | 표준 Spring Boot | 네이티브 Spring Boot |
|---|---|---|
| 설정 (Config) | 표준 POM | native-maven-plugin 추가 |
| 빌드 (Build) | mvn clean package |
mvn -Pnative native:compile |
| 결과물 (Artifact) | app.jar (크로스 플랫폼) |
app (OS 종속 바이너리) |
| 실행 (Run) | java -jar app.jar |
./app |
| 시동 속도 (Startup) | ~2.5초 | ~0.05초 |
| 메모리 (Memory) | ~250MB+ | ~40MB |
references