Skip to main content

Command Palette

Search for a command to run...

GraalVM Native Image 내부 동작 — Substrate VM, Points-to Analysis, 그리고 Closed-World Assumption

Updated
15 min read

Spring Boot 3과 함께 GraalVM Native Image가 1급 시민이 된 지 몇 년이 흘렀습니다. 이 글은 native-image 명령 한 줄 뒤에서 일어나는 빌드 파이프라인을 정적 분석, 클래스 초기화, 메타데이터, 그리고 Substrate VM 런타임의 관점에서 깊게 파헤칩니다. 대상 독자는 JIT 기반 JVM의 동작을 어느 정도 알고 있고, AOT 컴파일이 왜 이렇게 다른 모델인지 궁금한 백엔드 개발자입니다.

0. 들어가며

JVM은 오랫동안 한 가지 약속 위에서 동작했습니다. 바이트코드는 모든 플랫폼에서 동일하게 동작하며, 실행 시점에 JIT 컴파일러가 핫스팟을 식별해 점진적으로 최적화한다는 약속입니다. 이 모델은 장시간 실행되는 서버에서는 훌륭하게 동작하지만, 컨테이너 시대의 빠른 스타트업과 낮은 메모리 풋프린트라는 요구와는 정면으로 충돌합니다.

GraalVM Native Image는 정반대의 접근을 택합니다. 실행 시점이 아니라 빌드 시점에 도달 가능한 모든 코드와 객체를 결정하고, 그것을 단일 네이티브 실행 파일로 ahead-of-time 컴파일합니다. 결과 바이너리는 클래스 로더도, 인터프리터도, JIT 컴파일러도 들고 있지 않습니다. 대신 Substrate VM이라 불리는 작은 런타임이 GC와 스레드 스케줄링 등 최소한의 책임만을 맡습니다.

이 글은 그 변환 과정을 단계별로 따라갑니다.

1. AOT 컴파일이 답하는 두 가지 문제

1.1 JIT의 워밍업 비용

HotSpot JVM은 메서드 호출 횟수와 백엣지 카운터를 추적하다가 일정 임계치를 넘으면 C1, 그다음 C2 컴파일러로 점진적 최적화를 수행합니다. 이 과정에서 인터프리터로 한참 동안 코드를 돌려야 충분한 프로파일이 수집되고, 그제서야 최고 성능에 도달합니다. 마이크로서비스가 띄워지자마자 트래픽을 받아야 하는 환경에서는 이 워밍업 구간 자체가 비용입니다.

1.2 컨테이너 시대의 메모리/스타트업

쿠버네티스 환경에서는 Pod 하나가 수십 초 만에 뜨고 죽기를 반복합니다. JVM의 기본 startup이 1초 미만이라고 해도, JIT 코드 캐시, 메타스페이스, 클래스 데이터 등 런타임 자료구조의 메모리는 그대로 남습니다. Native Image는 이 모든 런타임 인프라를 빌드 시점에 정적으로 결정해 실행 파일에 박아 넣음으로써, 시작 시간을 수십 밀리초 수준으로 끌어내리고 메모리 풋프린트를 큰 폭으로 줄입니다.

물론 공짜는 아닙니다. 빌드 시간이 길어지고, 동적 기능에 대한 제약이 생기며, 일부 라이브러리는 추가 메타데이터 없이는 동작하지 않습니다. 이 글의 후반부는 그 비용이 정확히 어디에서 발생하는지를 다룹니다.

2. 빌드 파이프라인 한 장 정리

native-image MyApp 명령을 실행하면 다음과 같은 일이 일어납니다.

flowchart TD
    A[Entry Points: main, JNI, reflection roots] --> B[Points-to Analysis]
    B --> C[Heap Snapshotting]
    C --> B
    B --> D[Class Initialization decisions]
    D --> E[AOT Compilation with Graal compiler]
    E --> F[Image Heap Layout]
    F --> G[Native Executable + Substrate VM runtime]

핵심은 points-to analysis와 heap snapshotting이 fixed point에 도달할 때까지 반복된다는 점입니다. 정적 필드 하나가 새 객체를 가리키면, 그 객체의 클래스가 새로운 메서드를 도달 가능하게 만들 수 있고, 그 메서드가 다시 새로운 정적 필드를 건드릴 수 있습니다. 이 상호작용이 더 이상 새로운 도달 가능 요소를 만들어내지 않을 때까지 분석이 반복됩니다.

공식 문서는 이 과정을 다음과 같이 설명합니다.

"Points-to analysis makes objects reachable in the image heap, and the snapshotting that builds the image heap can make new methods reachable for the points-to analysis. Thus, points-to analysis and heap snapshotting are performed iteratively until a fixed point is reached."

이 fixed point 도달 이후에야 Graal 컴파일러가 도달 가능 메서드를 네이티브 코드로 변환하고, 최종적으로 image heap이 실행 파일의 데이터 섹션에 직렬화됩니다.

3. Points-to Analysis와 Closed-World Assumption

3.1 정의

Points-to analysis는 "어떤 변수가 어떤 객체를 가리킬 수 있는가"를 정적으로 추적하는 분석 기법입니다. Native Image의 분석기는 main 메서드를 출발점으로 삼아 호출 그래프를 따라가며, 각 호출 지점에서 어떤 타입의 객체가 흐를 수 있는지를 계산합니다.

이 분석이 의미를 가지려면 한 가지 강한 가정이 필요합니다. 바로 closed-world assumption입니다. 공식 문서의 표현 그대로 옮기면 다음과 같습니다.

"GraalVM runs an aggressive static analysis that requires a closed-world assumption, which means that all classes and all bytecodes that are reachable at run time must be known at build time."

런타임에 클래스 로더가 동적으로 새 클래스를 로드하거나, 리플렉션으로 처음 보는 메서드를 호출하는 시나리오는 기본적으로 허용되지 않습니다. 빌드 시점에 분석기가 "이건 절대 호출되지 않는 코드"라고 판정하면, 해당 메서드는 네이티브 바이너리에서 통째로 제거됩니다.

3.2 entry point에서 시작하는 반복 탐색

분석 알고리즘은 다음과 같이 동작합니다.

  1. Entry points 등록: public static void main, JNI에서 호출 가능한 메서드, 명시적으로 reflection 대상이 된 메서드 등이 worklist에 들어갑니다.
  2. Bytecode 스캔: 각 메서드의 바이트코드를 따라가며 호출되는 메서드, 접근되는 필드, 생성되는 객체를 수집합니다.
  3. Type flow 갱신: 각 변수가 가질 수 있는 타입 집합(points-to set)을 업데이트합니다.
  4. 새로운 도달 메서드 발견: 새 타입이 추가되면 가상 호출의 해석 후보가 늘어나, 추가 메서드가 worklist에 들어갑니다.
  5. Fixed point: 더 이상 worklist에 새 메서드가 추가되지 않을 때까지 반복합니다.

3.3 한계 — 동적 기능

정적 분석은 본질적으로 한계가 명확합니다. 다음과 같은 코드는 분석기 입장에서 "어떤 클래스/메서드가 도달 가능한지" 알 방법이 없습니다.

String name = System.getenv("HANDLER_CLASS");
Class<?> clazz = Class.forName(name);
Object handler = clazz.getDeclaredConstructor().newInstance();

런타임 환경 변수에 따라 결정되는 클래스를 빌드 시점에 알 수는 없습니다. 이 케이스는 closed-world 가정을 위반하므로, Native Image는 별도의 메타데이터로 보완하는 메커니즘을 제공합니다. 이는 6장에서 다룹니다.

4. Image Heap과 Heap Snapshotting

4.1 정의

Image heap은 빌드 시점에 이미 생성되어 있던 객체들의 그래프를 그대로 직렬화해 네이티브 실행 파일의 데이터 섹션에 넣은 영역입니다. 실행 파일이 메모리에 로드되는 순간, 이 객체들은 별도의 초기화 없이 곧바로 사용 가능한 상태가 됩니다.

공식 문서는 image heap을 다음과 같이 설명합니다.

"Heap snapshotting constructs an object graph by following root pointers like static fields to build a comprehensive view of all reachable objects. This graph then populates the native image's image heap, ensuring that the application's initial state is efficiently loaded upon startup."

4.2 어떤 객체가 image heap에 들어가는가

크게 두 부류입니다.

  • 빌드 시점에 실행된 클래스 초기화의 결과물: build-time initialized 클래스의 정적 필드가 가리키는 모든 객체. 예를 들어 static final Map<String, Handler> HANDLERS = new HashMap<>()가 빌드 시점에 채워졌다면, 그 HashMap과 키/값 객체 전부가 image heap에 들어갑니다.
  • String, Integer 캐시 등 표준 라이브러리의 상수 객체: JLS가 보장하는 String interning이나 Integer 캐시도 image heap에 미리 배치됩니다.

4.3 startup 비용을 빌드 시점으로 옮기는 효과

이는 단순한 직렬화가 아닙니다. 실행 파일 로딩만으로 이미 완성된 객체 그래프를 갖게 된다는 의미입니다. JVM에서는 첫 요청 처리 직전에 <clinit>이 줄줄이 실행되며 정적 초기화 비용이 누적되지만, Native Image에서는 그 비용이 이미 빌드 시점에 지불되었습니다.

대신 부작용도 있습니다. 빌드 시점에 만들어진 객체는 빌드 머신의 OS, 파일 시스템, 네트워크 상태를 반영할 수 있습니다. 그래서 static { socket = new Socket(...); } 같은 부주의한 초기화는 절대 build-time에 실행되어서는 안 됩니다.

5. Build-Time vs Run-Time Class Initialization

5.1 기본 정책

Native Image는 기본적으로 사용자 클래스를 run-time에 초기화합니다. 공식 문서의 표현입니다.

"By default, Native Image initializes application classes at run time, except for the classes that Native Image proves 'safe' for initialization at build time."

이 정책의 이유는 HotSpot 동작과의 호환성입니다. HotSpot에서는 클래스의 <clinit>이 첫 사용 시점에 실행되므로, 같은 의미를 유지하려면 Native Image도 기본적으로는 run-time 초기화를 따라야 합니다.

5.2 안전성 분석

분석기는 다음을 만족하는 클래스를 "safe"로 판정합니다.

  • 모든 상위 타입이 safe하다.
  • 정적 초기화자가 unsafe 메서드를 호출하지 않는다.
  • 정적 초기화자가 unsafe 클래스를 초기화하지 않는다.

unsafe의 대표 사례는 네이티브 호출, 파일 IO, 소켓 생성, 현재 시간 의존 등 빌드 머신과 런타임 머신에서 다른 결과를 낼 수 있는 모든 동작입니다.

5.3 명시적 지정

빌드 명령에 옵션으로 강제할 수 있습니다.

native-image \
  --initialize-at-build-time=com.example.constants \
  --initialize-at-run-time=com.example.network \
  -jar app.jar

공식 문서는 --initialize-at-build-time개별 클래스 단위로만 사용할 것을 권장합니다. 패키지 단위로 강제하면 그 안의 모든 클래스가 build-time에 초기화되어, 의도치 않게 빌드 머신 상태가 image heap에 박혀버릴 수 있습니다.

디버깅용으로는 -H:+PrintClassInitialization 옵션이 있어, 어떤 클래스가 어느 시점에 어떤 이유로 초기화되었는지 출력해줍니다.

5.4 흐름 정리

flowchart LR
    A[Class C referenced] --> B{Safe for build-time init?}
    B -->|Yes| C[Run clinit during build]
    C --> D[Static fields into image heap]
    B -->|No| E[Defer to run time]
    E --> F[clinit on first use in native binary]

6. Reachability Metadata — 정적 분석이 못 보는 동적 기능

6.1 필요한 이유

리플렉션, JNI, 리소스 로딩, 직렬화, 프록시 같은 동적 기능은 closed-world 가정 안에서는 정적으로 추적할 수 없습니다. Native Image는 이 빈틈을 reachability metadata로 메웁니다.

"Native Image performs static analysis at build time to determine necessary program elements. However, static analysis cannot always predict dynamically-accessed elements since their reachability depends on data only available at run time."

6.2 파일 위치와 구조

GraalVM JDK 23부터는 모든 동적 메타데이터를 하나의 파일로 통합한 포맷이 표준입니다.

META-INF/native-image/<groupId>/<artifactId>/reachability-metadata.json

이 파일은 세 가지 주요 섹션을 포함합니다.

  • reflection: 리플렉션으로 접근될 클래스, 필드, 메서드
  • jni: JNI에서 참조할 자바 측 요소
  • resources: ClassLoader.getResourceAsStream으로 읽힐 리소스 패턴

예시:

{
  "reflection": [
    {
      "type": "com.example.Handler",
      "allDeclaredConstructors": true,
      "allDeclaredMethods": true,
      "fields": [{ "name": "config" }]
    }
  ],
  "resources": {
    "includes": [{ "pattern": "config/.*\\.properties" }]
  }
}

기존의 reflect-config.json, resource-config.json, proxy-config.json, jni-config.json도 여전히 호환되지만 deprecated입니다.

6.3 --exact-reachability-metadata

새 빌드 옵션은 다음과 같이 표현됩니다.

"Native Image is migrating to a more user-friendly implementation of reachability metadata that shows problems early and allows easy debugging, enabled by passing --exact-reachability-metadata at build time."

이 옵션을 켜면 메타데이터 누락이 빌드 시 경고가 아니라 런타임에 명확한 예외로 전환되어, 첫 호출 직전에 즉시 발견됩니다. 누락을 조용히 우회하는 것보다 빠른 실패가 디버깅에 훨씬 유리합니다.

6.4 Tracing Agent — 메타데이터 자동 수집

손으로 reachability-metadata.json을 채우는 것은 현실적이지 않습니다. GraalVM은 HotSpot에서 애플리케이션을 실행할 때 동적 기능 호출을 모두 추적해 메타데이터를 자동 생성하는 에이전트를 제공합니다.

java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
     -jar app.jar

JVM 종료 시 에이전트가 reachability-metadata.json을 디렉터리에 떨궈줍니다. 동일 디렉터리에 다시 실행하면서 결과를 누적하려면 config-merge-dir 옵션을 사용합니다.

내부 동작은 다음과 같습니다.

flowchart TD
    A[App run on HotSpot] --> B[Native Image Agent attached via JVMTI]
    B --> C[Intercept Class.forName, getMethod, getResource, ...]
    C --> D[Filter standard library noise]
    D --> E[Aggregate in memory]
    E --> F[Write reachability-metadata.json on JVM exit]

테스트 스위트나 부하 시나리오로 모든 동적 경로를 한 번씩만 실행시켜도, 그 호출 흔적이 그대로 메타데이터가 됩니다.

6.5 한계

에이전트는 "실행된 경로"만 봅니다. 테스트 커버리지가 빈약하면 메타데이터도 빈약합니다. 또한 if-else 분기에서 false 가지로만 들어가본 적이 없다면, 그 가지에서 호출되는 리플렉션은 메타데이터에 없습니다. 운영 트래픽 일부 구간에서 에이전트를 돌리거나, 라이브러리 벤더가 제공하는 메타데이터 리포지토리를 함께 사용하는 패턴이 일반적입니다.

7. Substrate VM — Native Binary 안의 작은 런타임

7.1 구성 요소

네이티브 실행 파일은 자바 코드가 ahead-of-time 컴파일된 결과만으로는 동작할 수 없습니다. 객체를 할당하고 회수하는 GC, 스레드를 다루는 코드, 예외 처리, 그리고 가끔은 deoptimization까지 필요합니다. Substrate VM은 이 모든 런타임 책임을 떠맡는 자바로 작성된 작은 런타임입니다.

공식 문서의 정의입니다.

"Substrate VM is the name for the runtime components (like the deoptimizer, garbage collector, thread scheduling etc.)."

흥미로운 점은 Substrate VM 자체가 Java로 작성되어 있고, native-image 빌드 시 다른 사용자 코드와 함께 AOT 컴파일된다는 사실입니다. 즉 별도의 C 런타임이 아니라, 빌드 결과 바이너리의 일부입니다.

7.2 Garbage Collector 선택지

Substrate VM은 세 가지 GC를 제공합니다.

Serial GC — 기본값입니다.

"The Serial GC is the default GC and optimized for low memory footprint and small Java heap sizes. In its core, the Serial GC is a simple (non-parallel, non-concurrent) stop and copy GC."

작은 힙과 빠른 스타트업이 목표인 컨테이너 워크로드에 잘 어울립니다. 기본 최대 힙은 물리 메모리의 80%입니다.

G1 GC — Linux x64와 Linux AArch64에서 사용 가능합니다.

"The G1 GC is a multithreaded GC that is optimized to reduce stop-the-world pauses and therefore improve latency, while achieving high throughput. G1 partitions the heap into a set of equally sized heap regions, each a contiguous range of virtual memory."

지연 시간이 중요한 장시간 실행 워크로드에 적합합니다. --gc=G1로 활성화합니다. 기본 최대 힙은 물리 메모리의 25%로 더 보수적입니다.

Epsilon GC — no-op GC입니다.

"The Epsilon GC is a no-op garbage collector that does not do any garbage collection and therefore never frees any allocated memory, with primary use case for this GC being very short running applications that only allocate a small amount of memory."

CLI 도구나 서버리스 함수처럼 한 번 실행하고 종료되는 워크로드에 사용합니다.

7.3 Deoptimizer — AOT에서도 필요한 이유

HotSpot에서 deoptimization은 JIT가 한 추측이 깨졌을 때 인터프리터로 돌아가기 위해 필요한 메커니즘입니다. Native Image는 인터프리터가 없는데도 deoptimizer가 필요한 까닭은, 일부 코드(예: Truffle 기반 게스트 언어, JIT 모드의 native image)에서 런타임 컴파일이 동작할 때 추측적 최적화의 폐기 경로가 여전히 필요하기 때문입니다. 순수 closed-world AOT만 사용한다면 실제로 trigger될 일이 거의 없지만, Substrate VM은 이를 위한 메타데이터를 유지해둡니다.

7.4 Thread, Exception, Stack Walking

스레드 생성/종료, throw/catch, 스택 트레이스 생성도 Substrate VM이 책임집니다. 자바 측 인터페이스는 동일하지만, 그 뒷단의 OS 시스템콜은 native-image 빌드 시점에 함께 컴파일됩니다.

8. Spring Boot 3 AOT 처리와 RuntimeHints

8.1 왜 Spring이 별도 AOT 처리가 필요한가

Spring은 @Configuration 클래스를 런타임에 파싱해 BeanDefinition을 만들고, 이 BeanDefinition을 기반으로 의존성 그래프를 풀어 인스턴스를 생성합니다. 이 과정은 본질적으로 동적이며 closed-world 가정과 충돌합니다.

Spring Framework 6 / Spring Boot 3은 AOT processing이라는 별도 빌드 단계를 도입했습니다. 이 단계는 빌드 시점에 컨텍스트를 부분적으로 띄워보고, 그 결과 BeanDefinition을 분석해 동등한 소스 코드를 생성합니다. 생성된 코드는 평범한 자바 코드이므로 GraalVM의 정적 분석기가 추적 가능합니다.

8.2 RuntimeHints API

생성된 코드만으로는 충분하지 않습니다. 런타임에 리플렉션이나 리소스 로딩이 필요한 부분이 여전히 남아 있으면, 그것을 reachability metadata로 변환해줘야 합니다. Spring은 이를 위해 RuntimeHints API를 제공합니다.

public class MyRuntimeHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        hints.reflection().registerType(MyDto.class,
            MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
            MemberCategory.DECLARED_FIELDS);
        hints.resources().registerPattern("messages*.properties");
        hints.serialization().registerType(MyEvent.class);
        hints.proxies().registerJdkProxy(MyService.class);
    }
}

@ImportRuntimeHints(MyRuntimeHints.class) 어노테이션으로 컨피그에 연결하면, Spring AOT 엔진이 이 hints를 GraalVM의 reachability-metadata.json으로 컴파일해 빌드 디렉터리에 떨굽니다.

8.3 BeanRegistrationAotProcessor

각 BeanDefinition은 자체적으로 AOT 기여(contribution)를 제공할 수 있습니다.

"A core BeanFactoryInitializationAotProcessor implementation is responsible for collecting the necessary contributions for each candidate BeanDefinition using a dedicated BeanRegistrationAotProcessor."

"An AOT contribution is a component that contributes generated code which reproduces a particular behavior and can also contribute RuntimeHints to indicate the need for reflection, resource loading, serialization, or JDK proxies."

즉 각 빈 등록 처리가 두 가지 결과물을 만들어냅니다.

  1. 그 빈을 생성하는 자바 소스 코드
  2. 그 빈을 실행하는 데 필요한 runtime hints

Spring Data, Spring Security, Spring AMQP 같은 모듈들은 자기들이 사용하는 리플렉션과 프록시를 표현하는 AotProcessor를 내장하고 있어, 사용자가 직접 hints를 작성하지 않아도 대부분의 경우 동작합니다.

8.4 전체 흐름

flowchart TD
    A[@Configuration classes] --> B[Spring AOT Processing during build]
    B --> C[Generated bean factory code]
    B --> D[Aggregated RuntimeHints]
    D --> E[reachability-metadata.json under META-INF/native-image]
    C --> F[native-image compile]
    E --> F
    F --> G[Native executable]

9. Profile-Guided Optimization

9.1 동기

AOT 컴파일러는 JIT처럼 런타임 프로파일을 볼 수 없습니다. 어떤 메서드가 핫이고 어떤 분기가 자주 타지는지 알 수 없으므로, 인라이닝과 분기 예측의 품질이 떨어집니다. GraalVM은 이 정보 격차를 메우기 위해 PGO를 제공합니다.

9.2 3단계 워크플로

"Profile-Guided Optimization (PGO) is a technique that brings profile information to an AOT compiler to improve the quality of its output in terms of performance and size."

  1. 계측 이미지 빌드
native-image --pgo-instrument -jar app.jar
  1. 프로파일 수집 실행
./app
# 종료 시 default.iprof 생성

-XX:ProfilesDumpFile=<path>로 출력 경로를 바꿀 수 있습니다.

  1. 최적화 이미지 빌드
native-image --pgo=default.iprof -jar app.jar

9.3 효과와 한계

"The PGO-optimized native executable is approximately 15% smaller than the default native build. This is because the profiles provided for the optimizing build allow the compiler to differentiate between which code is important for performance ('hot code'), and which is not important ('cold code', such as error handling)."

핫 코드는 더 공격적으로 인라이닝/특수화되고, 콜드 코드는 크기를 위해 최적화 강도를 낮춥니다. 단, GraalVM for JDK 21부터 Oracle GraalVM에 PGO가 무료 포함되었습니다. Community Edition은 여전히 미지원이라 비슷한 효과를 다른 방식(JIT 모드 native-image, 외부 기반 통계)으로 흉내내야 합니다.

또한 프로파일은 수집 시점의 트래픽 패턴을 반영하므로, 수집된 시나리오가 운영 시나리오와 크게 다르면 오히려 역효과가 날 수 있습니다.

10. 한계와 트레이드오프

10.1 빌드 시간과 메모리

Points-to analysis는 본질적으로 전체 프로그램 분석입니다. 표준적인 Spring Boot 애플리케이션의 native-image 빌드는 수 분 이상이 걸리며, 빌드 머신에서 수 GB의 RAM을 요구합니다. CI 비용 측면에서 무시할 수 없는 부담입니다.

10.2 디버깅과 관찰성

JFR, JMX, async-profiler 같은 도구는 HotSpot 종속 구현이 많아 Native Image에서는 제한적입니다. 최근 버전에서 JFR 일부 이벤트는 지원되지만, JIT 컴파일 이벤트처럼 본질적으로 의미가 없는 영역은 비어 있습니다.

10.3 라이브러리 호환성

오래된 라이브러리나 동적 기능에 강하게 의존하는 라이브러리(바이트코드 위빙, 동적 프록시, AccessController 등)는 메타데이터 작성이 까다롭습니다. GraalVM 팀은 별도의 reachability metadata 리포지토리를 운영해, 인기 라이브러리의 메타데이터를 모아두고 있습니다. native-image-maven-plugin과 gradle-plugin은 이 리포지토리를 자동으로 끌어와 적용합니다.

10.4 SBOM과 정적 분석 산출물

GraalVM for JDK 24부터는 native image에 **SBOM(Software Bill of Materials)**이 기본으로 임베드됩니다.

"A Software Bill of Materials (SBOM) is now embedded by default in native images, which can be disabled with --enable-sbom=false"

빌드 시점에 모든 의존성과 도달 가능 코드가 결정된다는 closed-world 특성이, 역으로 정확한 SBOM 생성에 유리하게 작용한 결과입니다.

11. 정리

GraalVM Native Image의 빌드 파이프라인은 다음 네 가지 축으로 요약됩니다.

  • Points-to analysis와 closed-world assumption: 런타임이 아니라 빌드 시점에 도달 가능 코드 전체를 결정합니다.
  • Image heap과 build-time class initialization: 객체 그래프를 빌드 시점에 만들어 실행 파일에 박아 startup 비용을 0에 가깝게 줄입니다.
  • Reachability metadata: 정적 분석으로 추적되지 않는 동적 기능을 외부 JSON으로 명시합니다. Tracing Agent와 Spring AOT가 이 작업을 자동화합니다.
  • Substrate VM: GC, 스레드, 예외 처리 등 최소 런타임을 자바 코드로 작성해 함께 AOT 컴파일합니다.

이 모델은 JIT 기반 JVM의 강점인 "런타임 적응성"을 의도적으로 포기하는 대신, 빠른 startup, 낮은 메모리, 정확한 SBOM이라는 다른 종류의 이점을 얻습니다. 마이크로서비스와 서버리스, CLI 도구처럼 워밍업 비용이 곧 사용자 경험인 영역에서 GraalVM Native Image가 점점 1급 선택지가 되어가는 이유입니다.

참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ