포스트

GraalVM 네이티브 이미지와 AOT

GraalVM 네이티브 이미지와 AOT

서버리스에서 JVM 시작이 너무 느리다

JVM 앱은 시작할 때 클래스 로딩 → 바이트코드 검증 → 인터프리터 실행 → JIT 워밍업을 거칩니다. 평소엔 괜찮지만 서버리스(짧게 떴다 사라짐), 빠른 오토스케일링, CLI 도구에선 이 수 초의 시작 지연과 큰 힙이 그대로 비용입니다. 콜드 스타트가 과금되고, 트래픽 급증 시 새 인스턴스가 0.05초가 아니라 5초 만에 뜨면 그 5초 동안 장애가 납니다.

그래서 발상을 뒤집습니다. “런타임에 하던 일을 빌드 타임으로 당긴다.” 이게 GraalVM 네이티브 이미지와 Spring AOT의 본질이고, 이 글의 목표는 “빠르다더라”에서 멈추지 않고 무엇이 빌드 타임으로 옮겨가는지, 그래서 무엇을 포기하는지까지 내려가는 것입니다.

핵심 발상: 런타임을 빌드 타임으로 — closed-world

일반 JVM은 열린 세계(open-world) 를 가정합니다. 실행 중에 클래스를 새로 로딩하고, 리플렉션으로 임의의 멤버에 접근하고, 동적 프록시를 만들 수 있죠. 유연하지만, 그래서 시작할 때마다 “무엇이 있는지” 다시 알아내야 합니다.

GraalVM native-image는 정반대인 닫힌 세계(closed-world) 를 가정합니다.

빌드 시점에 도달 가능한(reachable) 모든 코드가 결정되어 있어야 한다. 빌드 후에 클래스패스에 없던 클래스를 로딩하거나, 힌트 없이 리플렉션으로 접근하는 일은 허용되지 않는다.

빌더는 main에서 출발해 정적 도달 가능성 분석(points-to analysis)으로 실제로 쓰이는 코드만 추려 바이너리에 넣고, 나머지는 버립니다(dead code elimination). 그 결과:

  • 시작 시간: 수 초 → 수십 밀리초 (할 일을 이미 다 해뒀으니까)
  • 메모리(RSS): 대폭 감소 (JIT·메타데이터·클래스로더 부담이 없음)
  • 이미지 크기: 작은 단일 실행 파일

대가도 분명합니다. 빌드가 길고(수 분), 리플렉션·프록시 같은 동적 기능은 빌드 타임에 미리 선언해야 하며, 장시간 고부하의 피크 처리량은 프로파일 기반으로 최적화하는 JIT를 못 따라갈 수 있습니다(PGO로 일부 만회).

전체 파이프라인을 움직임으로

소스가 Spring AOT 처리 → native-image 빌드(오래 걸리는 분석) → 즉시 부팅되는 단일 바이너리로 흘러갑니다. 주황 토큰이 긴 빌드 구간에서 한참 머무는 게 핵심이고, 그 대가로 마지막 바이너리는 초록으로 번쩍 — 거의 즉시 뜹니다.

소스 + Spring AOT Bean 정의·힌트 생성 native-image 빌드 closed-world 분석 (수 분) 네이티브 바이너리 JVM 불필요 ⚡ Started in 0.05s

Spring AOT — 자동 구성을 “빌드 타임”에 끝내기

문제는 Spring이 런타임 리플렉션·동적 프록시·조건부 Bean 등록을 대량으로 쓴다는 점입니다. closed-world에선 이게 전부 걸림돌이죠. 그래서 Spring AOT 엔진이 빌드 타임에 다음을 미리 처리합니다.

  • @Configuration·컴포넌트 스캔·@Conditional을 빌드 타임에 평가해, 최종적으로 등록될 Bean 정의를 Java 코드(*__BeanDefinitions)로 생성한다.
  • 즉 자동 구성의 @Conditional 평가가 런타임이 아니라 빌드 타임에 한 번 일어난다. 그래서 네이티브 이미지에선 자동 구성 후보 탐색·조건 평가 비용이 0이고, --debug 리포트 같은 동적 평가도 사라진다.
  • 필요한 리플렉션/리소스/프록시/직렬화 힌트를 RuntimeHints로 수집해 native-image에 넘긴다.
flowchart TD
    A["소스 + @Configuration"] --> B["Spring AOT 처리<br/>(빌드 타임)"]
    B --> C["생성: *__BeanDefinitions<br/>(조건 평가 끝난 빈 정의)"]
    B --> D["생성: RuntimeHints<br/>(리플렉션/리소스/프록시)"]
    C --> E["native-image 빌드"]
    D --> E
    E --> F["단일 네이티브 바이너리"]

핵심 차이를 한 줄로: JVM에선 자동 구성이 매 부팅마다 돌고, 네이티브에선 빌드 때 딱 한 번 돌고 결과만 박제된다. 그래서 시작이 빠른 것이지, 마법이 아닙니다.

빌드하기 — 두 경로

1
2
3
4
5
6
7
# 1) GraalVM(native-image)로 직접 컴파일 — 로컬에 GraalVM 필요
./gradlew nativeCompile
./build/native/nativeCompile/demo
# Started DemoApplication in 0.048 seconds

# 2) Buildpacks로 컨테이너 이미지 — 로컬 GraalVM 설치 불필요
./gradlew bootBuildImage

테스트도 네이티브로 돌려 힌트 누락을 빌드 단계에서 잡을 수 있습니다.

1
./gradlew nativeTest   # AOT 적용 상태로 테스트 실행

가장 흔한 함정: 런타임 리플렉션 → “빌드는 됐는데 실행하면 죽는다”

네이티브에서 가장 자주 데는 지점입니다. 라이브러리가 문자열로 클래스를 찾아 리플렉션하거나, JSON 바인딩 대상 DTO를 리플렉션으로 인스턴스화하는데 힌트가 없으면 — 빌드는 멀쩡히 끝나고, 런타임에야 터집니다.

1
2
3
com.oracle.svm.core.jdk.UnsupportedFeatureError:
  No instances of com.example.ExternalDto are allowed in the image heap ...
Caused by: java.lang.ClassNotFoundException: com.example.ExternalDto

JVM에선 잘 되던 코드라 더 헷갈립니다. 닫힌 세계에선 “선언하지 않은 동적 접근은 존재하지 않는 것” 이기 때문입니다.

해결은 힌트를 명시하는 것입니다. 간단한 바인딩 대상은 애너테이션으로:

1
2
3
@Configuration
@RegisterReflectionForBinding({ ExternalDto.class, ApiResponse.class })
class NativeHintsConfig { }

복잡하면 RuntimeHintsRegistrar로 리플렉션·리소스·프록시를 직접 등록합니다.

1
2
3
4
5
6
7
8
9
10
11
class MyHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader cl) {
        hints.reflection().registerType(ExternalDto.class,
            MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
            MemberCategory.DECLARED_FIELDS);
        hints.resources().registerPattern("templates/*.json");
        hints.proxies().registerJdkProxy(MyInterface.class);
    }
}
// @ImportRuntimeHints(MyHints.class) 로 연결

손으로 다 찾기 어렵다면 GraalVM Tracing Agent로 JVM에서 한 번 돌려 실제 동적 접근을 수집합니다.

1
2
3
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
     -jar app.jar
# 앱의 주요 경로를 실제로 한 번 태운 뒤 종료 → reachability-metadata(json) 생성

요즘은 인기 라이브러리들이 reachability-metadata 저장소에 힌트를 함께 배포해, 직접 등록할 일이 줄었습니다. 그래도 “내가 만든 리플렉션/직렬화 경로”는 여전히 내 책임입니다.

build-time vs run-time 초기화

native-image는 일부 클래스의 정적 초기화(static initializer)를 빌드 타임에 실행해 그 상태를 이미지에 박제할 수 있습니다(시작 더 빨라짐). 하지만 빌드 머신의 환경값·난수 시드·열린 파일 핸들이 박제되면 런타임에 엉뚱하게 동작합니다. 그래서 “빌드 타임 초기화” 대상은 신중히 골라야 하고, 시간/난수/네트워크에 의존하는 클래스는 런타임 초기화로 남겨야 합니다. Spring/GraalVM이 기본값을 잘 잡아주지만, 직접 --initialize-at-build-time을 넓게 거는 건 위험합니다.

트레이드오프 — 언제 쓰나

기준네이티브 이미지기존 JVM
시작 시간⚡ 수십 ms수 초
메모리(RSS)작다크다
빌드 시간길다(수 분)짧다
피크 처리량(장시간)JIT보다 낮을 수 있음(PGO로 만회)워밍업 후 최상
동적 기능(리플렉션/프록시)힌트 필요자유
디버깅/관측도구 제약 있음성숙

서버리스·짧은 수명·빠른 스케일·CLI라면 네이티브가 강력합니다. 반대로 장시간 고처리량 서버라면, 가상 스레드를 얹은 일반 JVM이 여전히 합리적인 선택입니다 — 동시성 처리량은 가상 스레드로 끌어올리고, JIT 피크 성능은 유지하면서 네이티브의 빌드·동적 기능 제약을 피할 수 있으니까요.

면접/리뷰 단골 질문

  • Q. 네이티브 이미지가 빠른 근본 이유는? → closed-world 가정으로 도달 가능 코드가 빌드 타임에 확정되고, 클래스 로딩·JIT 워밍업·자동 구성 평가 같은 런타임 작업이 빌드 타임으로 이동했기 때문.
  • Q. JVM에선 되던 코드가 네이티브에선 런타임에 죽는 전형적 원인은? → 힌트 없는 리플렉션/리소스/프록시 접근. closed-world라 선언 안 한 동적 접근은 금지된다. → @RegisterReflectionForBinding/RuntimeHintsRegistrar/Tracing Agent.
  • Q. Spring AOT는 자동 구성을 어떻게 바꾸나? → @Conditional 평가를 빌드 타임에 한 번 끝내 확정된 Bean 정의 코드를 생성한다. 런타임 조건 평가가 사라져 시작이 빨라진다.
  • Q. 빌드 타임 초기화의 위험은? → 빌드 환경값/시드가 박제되어 런타임과 어긋날 수 있어, 시간·난수·네트워크 의존 클래스는 런타임 초기화로 남겨야 한다.

정리

  • 네이티브 이미지 = GraalVM native-image가 closed-world 가정으로 도달 가능 코드만 모아 만든 OS 단일 실행 파일. 시작 수십 ms·메모리 적음.
  • Spring은 Spring AOT로 @Conditional·Bean 정의·리플렉션 힌트를 빌드 타임에 확정한다 → 자동 구성이 매 부팅 도는 JVM과 근본적으로 다르다.
  • 최대 함정은 힌트 없는 리플렉션 → 빌드는 통과하고 런타임에 사망. @RegisterReflectionForBinding·RuntimeHintsRegistrar·Tracing Agent로 해결.
  • 빌드 타임 초기화는 신중히. 시간/난수/네트워크 의존 클래스는 런타임 초기화로.
  • 서버리스·CLI엔 강력, 장시간 고처리량엔 가상 스레드 얹은 JVM이 여전히 유효 — 트레이드오프로 선택한다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.