🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Spring AOT와 네이티브 이미지 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 21. · 3 Views

Spring AOT와 네이티브 이미지 완벽 가이드

Spring Boot 3.0부터 지원되는 AOT 컴파일과 GraalVM 네이티브 이미지를 통해 애플리케이션 시작 시간을 극적으로 단축하는 방법을 알아봅니다. 초급 개발자도 쉽게 이해할 수 있도록 실무 상황과 비유로 풀어냅니다.


목차

  1. AOT_컴파일이란
  2. Spring_AOT_모듈
  3. GraalVM_네이티브_이미지
  4. 네이티브_빌드_설정
  5. 시작_시간_비교
  6. 제약사항과_고려사항

1. AOT 컴파일이란

어느 날 이개발 씨가 Spring Boot 애플리케이션을 배포하고 서버를 재시작했습니다. 그런데 애플리케이션이 완전히 구동되기까지 30초나 걸렸습니다.

"왜 이렇게 느린 거죠?" 선배 최시니어 씨가 다가와 말했습니다. "JIT 컴파일 방식이라서 그래요.

AOT 컴파일을 고려해볼 때가 됐네요."

AOT(Ahead-Of-Time) 컴파일은 애플리케이션을 실행하기 전에 미리 네이티브 코드로 변환하는 방식입니다. 반대로 JIT(Just-In-Time) 컴파일은 실행 중에 필요한 코드를 그때그때 컴파일합니다.

마치 여행을 떠나기 전에 미리 짐을 싸두는 것과 여행지에서 필요할 때마다 짐을 사는 것의 차이와 같습니다. AOT는 시작 시간이 빠르고 메모리 사용량이 적다는 장점이 있습니다.

다음 코드를 살펴봅시다.

// pom.xml - Spring Boot 3.0 이상 필요
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.graalvm.buildtools</groupId>
            <artifactId>native-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

이개발 씨는 입사 6개월 차 백엔드 개발자입니다. 회사에서 마이크로서비스 아키텍처를 도입하면서 수십 개의 Spring Boot 애플리케이션을 운영하게 되었습니다.

문제는 각 서비스가 시작될 때마다 20~30초씩 걸린다는 것이었습니다. 배포할 때마다 이 시간이 쌓이다 보니, 전체 배포 시간이 너무 길어졌습니다.

무엇보다 서버에 문제가 생겨서 긴급하게 재시작해야 할 때, 이 긴 시작 시간이 서비스 중단 시간으로 이어졌습니다. 최시니어 씨가 화면을 보며 설명을 시작했습니다.

"이개발 씨, JVM이 어떻게 동작하는지 알아요?" 전통적인 Java 애플리케이션은 JIT 컴파일 방식을 사용합니다. 마치 식당에서 손님이 주문할 때마다 요리를 만드는 것과 같습니다.

손님이 파스타를 주문하면 그때 파스타를 만들고, 스테이크를 주문하면 그때 스테이크를 굽습니다. 이렇게 하면 주문에 유연하게 대응할 수 있지만, 첫 주문 고객은 요리가 나올 때까지 기다려야 합니다.

JVM도 마찬가지입니다. 애플리케이션이 시작되면 바이트코드를 읽어서 해석하고, 자주 사용되는 코드는 그때그때 네이티브 코드로 컴파일합니다.

이 과정에서 시간이 걸립니다. 그렇다면 왜 이런 방식을 사용할까요?

JIT 컴파일은 실행 중에 프로그램의 동작 패턴을 분석할 수 있습니다. "아, 이 메서드는 자주 호출되는구나" 하고 판단하면 더 최적화된 코드로 컴파일합니다.

실행 시간이 길어질수록 성능이 좋아지는 워밍업 효과가 있습니다. 하지만 클라우드 네이티브 시대에는 이야기가 달라집니다.

서버리스 환경이나 컨테이너 환경에서는 애플리케이션이 자주 시작되고 종료됩니다. 오토 스케일링으로 인스턴스가 추가될 때, 람다 함수가 콜드 스타트될 때, 이 긴 시작 시간이 치명적입니다.

워밍업할 시간이 없는 것입니다. 바로 이런 문제를 해결하기 위해 AOT 컴파일이 등장했습니다.

AOT 컴파일은 마치 도시락 가게처럼 동작합니다. 점심시간 전에 미리 인기 메뉴를 만들어두는 것입니다.

손님이 오면 바로 포장해서 건네줄 수 있습니다. 기다릴 필요가 없습니다.

AOT 컴파일은 빌드 시점에 애플리케이션을 분석합니다. 어떤 클래스가 사용되는지, 어떤 빈이 필요한지, 리플렉션은 어디서 일어나는지 모두 미리 파악합니다.

그리고 이 정보를 바탕으로 네이티브 코드로 변환합니다. 실행할 때는 이미 컴파일된 코드를 그냥 실행하기만 하면 됩니다.

결과적으로 시작 시간이 밀리초 단위로 줄어듭니다. 30초 걸리던 것이 0.1초로 줄어드는 것입니다.

메모리 사용량도 크게 감소합니다. JVM 전체를 메모리에 올릴 필요가 없기 때문입니다.

최시니어 씨가 실제 수치를 보여줬습니다. "우리 서비스를 네이티브 이미지로 만들면 시작 시간이 98% 줄어들어요.

메모리는 70% 절약되고요." 이개발 씨는 눈이 휘둥그레졌습니다. "그럼 모든 애플리케이션을 AOT로 만들어야 하는 거 아닌가요?" 최시니어 씨가 고개를 저었습니다.

"아니요. 트레이드오프가 있습니다.

빌드 시간이 훨씬 오래 걸리고, 리플렉션 같은 동적 기능에 제약이 있어요. 그래서 상황에 맞게 선택해야 합니다." Spring Boot 3.0부터는 Spring AOT라는 모듈이 추가되었습니다.

이 모듈이 애플리케이션을 분석해서 AOT 컴파일에 필요한 정보를 생성해줍니다. 개발자는 복잡한 설정 없이도 네이티브 이미지를 만들 수 있습니다.

실전 팁

💡 - 마이크로서비스나 서버리스 환경에서 AOT 컴파일의 효과가 극대화됩니다

  • 장기 실행되는 모노리틱 애플리케이션은 JIT 컴파일이 더 유리할 수 있습니다
  • Spring Boot 3.0 이상 버전이 필요합니다

2. Spring AOT 모듈

이개발 씨가 AOT 컴파일에 흥미를 느껴서 직접 시도해보기로 했습니다. 하지만 검색해보니 GraalVM 설정, 리플렉션 힌트 파일 작성 등 복잡한 작업이 필요했습니다.

"이거 너무 어려운데요..." 최시니어 씨가 미소를 지으며 말했습니다. "Spring AOT 모듈을 사용하면 대부분 자동으로 처리됩니다."

Spring AOT는 Spring 애플리케이션을 AOT 컴파일하기 위해 필요한 메타데이터를 자동으로 생성하는 모듈입니다. 빈 정의, 리플렉션 사용, 리소스 파일 등을 빌드 시점에 분석해서 네이티브 이미지 힌트를 만들어줍니다.

개발자는 일반적인 Spring 코드를 작성하기만 하면 되고, 나머지는 AOT 엔진이 알아서 처리합니다.

다음 코드를 살펴봅시다.

// Spring AOT가 자동으로 처리하는 빈 등록
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

@RestController
@RequestMapping("/api")
class UserController {
    private final UserService userService;

    // 생성자 주입 - AOT가 자동 분석
    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/users/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.findById(id);
    }
}

이개발 씨는 GraalVM 공식 문서를 읽다가 좌절했습니다. 네이티브 이미지를 만들려면 리플렉션으로 접근하는 모든 클래스를 JSON 파일에 명시해야 했습니다.

수백 개의 클래스를 일일이 찾아서 등록한다는 것은 현실적으로 불가능했습니다. "이거 너무 복잡한데, 다른 방법 없나요?" 이개발 씨가 한숨을 쉬었습니다.

최시니어 씨가 화면을 가리켰습니다. "Spring AOT가 바로 그 문제를 해결하기 위해 만들어졌어요." Spring AOT는 마치 똑똑한 비서와 같습니다.

상사가 해외 출장을 간다고 가정해봅시다. 필요한 서류, 명함, 노트북, 충전기 등 챙겨야 할 것이 많습니다.

비서는 상사의 일정을 보고 어떤 준비물이 필요한지 미리 파악해서 준비해둡니다. 상사는 그냥 가방을 들고 공항으로 가기만 하면 됩니다.

Spring AOT도 비슷한 역할을 합니다. 애플리케이션 코드를 분석해서 네이티브 이미지 빌드에 필요한 모든 정보를 자동으로 수집합니다.

구체적으로 어떤 일을 할까요? 첫째, 빈 정의 분석입니다.

Spring은 런타임에 컴포넌트 스캔을 하고 빈을 등록합니다. 하지만 네이티브 이미지는 런타임 동적 로딩이 어렵습니다.

AOT는 빌드 시점에 모든 빈을 미리 찾아서 코드로 생성합니다. 둘째, 리플렉션 힌트 생성입니다.

Jackson이 JSON을 역직렬화할 때, JPA가 엔티티를 다룰 때 리플렉션이 사용됩니다. AOT는 이런 케이스를 분석해서 GraalVM에게 "이 클래스는 리플렉션으로 접근됩니다"라고 알려줍니다.

셋째, 프록시 클래스 생성입니다. Spring AOP나 트랜잭션은 프록시를 만들어서 동작합니다.

런타임에 동적으로 프록시를 생성하는 대신, AOT가 미리 프록시 클래스를 만들어둡니다. 이개발 씨가 위의 코드를 보며 물었습니다.

"이 코드는 평범한 Spring Boot 코드 같은데요?" "맞아요. 그게 핵심입니다." 최시니어 씨가 답했습니다.

개발자는 기존과 똑같이 코드를 작성합니다. 애너테이션 기반 설정, 컴포넌트 스캔, 의존성 주입 모두 그대로 사용합니다.

Spring AOT가 빌드 시점에 이 코드를 분석해서 네이티브 이미지용 코드를 생성하는 것입니다. 실제로 빌드하면 어떻게 될까요?

mvn clean package -Pnative 명령을 실행하면 Spring AOT 프로세스가 시작됩니다. 애플리케이션이 실제로 실행되면서 어떤 빈이 생성되는지, 어떤 클래스가 로드되는지 모니터링합니다.

이 과정을 빌드 타임 실행이라고 합니다. 그 결과 target/spring-aot/main/sources 디렉토리에 자동 생성된 Java 코드가 만들어집니다.

____BeanDefinitions.java 같은 파일들입니다. 이 파일들이 런타임에 빈을 등록하는 역할을 대신합니다.

또한 META-INF/native-image 디렉토리에 GraalVM 설정 파일들이 생성됩니다. reflect-config.json, resource-config.json 등입니다.

개발자가 직접 작성할 필요가 없습니다. 최시니어 씨가 생성된 파일을 열어서 보여줬습니다.

"보세요. UserController, UserService 모두 자동으로 감지되어 등록되었죠?" 이개발 씨는 감탄했습니다.

"이거 정말 편리하네요!" 하지만 주의할 점도 있습니다. 동적으로 클래스 이름을 만들어서 로드하는 경우에는 AOT가 감지하지 못할 수 있습니다.

예를 들어 Class.forName(dynamicClassName) 같은 코드입니다. 이런 경우에는 개발자가 직접 힌트를 제공해야 합니다.

Spring AOT는 @RegisterReflectionForBinding 같은 애너테이션을 제공합니다. 특수한 경우에만 이런 힌트를 추가하면 됩니다.

대부분의 경우는 자동으로 처리됩니다. 최시니어 씨가 정리했습니다.

"Spring AOT 덕분에 네이티브 이미지가 대중화될 수 있었어요. 예전에는 전문가만 할 수 있었던 작업이 이제는 누구나 할 수 있게 되었죠."

실전 팁

💡 - Spring Boot 3.0 이상에서는 AOT 기능이 기본 탑재되어 있습니다

  • 대부분의 Spring 기능은 자동으로 지원되며, 특수한 경우만 힌트가 필요합니다
  • 빌드 타임에 애플리케이션이 실행되므로 외부 의존성 연결에 주의하세요

3. GraalVM 네이티브 이미지

이개발 씨가 Spring AOT로 메타데이터를 생성했습니다. "이제 뭘 하면 되나요?" 최시니어 씨가 답했습니다.

"이제 GraalVM으로 실제 네이티브 이미지를 빌드해야 합니다. GraalVM은 Java 코드를 OS 네이티브 실행 파일로 만들어주는 도구입니다."

GraalVM은 Oracle에서 만든 고성능 JVM이자 다중 언어 런타임입니다. 특히 Native Image 기능을 통해 Java 애플리케이션을 독립 실행 가능한 네이티브 바이너리로 컴파일할 수 있습니다.

JVM 없이도 실행되며, 시작 시간이 밀리초 단위로 줄어들고 메모리 사용량도 크게 감소합니다. Spring AOT가 생성한 메타데이터를 활용하여 완전한 네이티브 실행 파일을 만듭니다.

다음 코드를 살펴봅시다.

// GraalVM Native Image 빌드 설정 - pom.xml
<profiles>
    <profile>
        <id>native</id>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.graalvm.buildtools</groupId>
                    <artifactId>native-maven-plugin</artifactId>
                    <configuration>
                        <imageName>${project.artifactId}</imageName>
                        <mainClass>com.example.Application</mainClass>
                        <buildArgs>
                            <buildArg>--no-fallback</buildArg>
                            <buildArg>-H:+ReportExceptionStackTraces</buildArg>
                        </buildArgs>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>

이개발 씨는 GraalVM이라는 이름을 처음 들었습니다. "JVM과는 다른 건가요?" 최시니어 씨가 설명을 시작했습니다.

"GraalVM은 특별한 JVM이에요. 일반 JVM처럼 Java 코드를 실행할 수도 있지만, 네이티브 이미지로 컴파일하는 기능이 추가되어 있습니다." GraalVM을 이해하려면 먼저 전통적인 Java 실행 과정을 알아야 합니다.

평소에 Java 프로그램을 실행하면 어떻게 될까요? 먼저 .java 파일을 javac로 컴파일해서 .class 바이트코드를 만듭니다.

그 다음 java 명령으로 JVM을 실행하고, JVM이 바이트코드를 읽어서 해석하거나 JIT 컴파일합니다. 이 과정은 마치 통역사가 필요한 외국 회의와 같습니다.

영어로 발표하면 통역사가 실시간으로 한국어로 번역해줍니다. 유연하지만 통역 시간이 걸립니다.

GraalVM Native Image는 완전히 다른 방식입니다. 빌드 시점에 모든 것을 미리 번역해서 책으로 만들어두는 것입니다.

실행할 때는 이미 번역된 책을 읽기만 하면 됩니다. 통역사가 필요 없습니다.

구체적으로 GraalVM Native Image는 무엇을 할까요? 첫째, 정적 분석입니다.

애플리케이션의 진입점(main 메서드)부터 시작해서 도달 가능한 모든 코드를 분석합니다. 사용되지 않는 클래스와 메서드는 최종 바이너리에 포함되지 않습니다.

이를 데드 코드 제거라고 합니다. 둘째, 초기화 실행입니다.

정적 초기화 블록이나 싱글톤 패턴 같은 것들을 빌드 시점에 미리 실행합니다. 실행 파일에는 이미 초기화된 상태가 스냅샷으로 저장됩니다.

셋째, 기계어 생성입니다. 모든 Java 코드를 해당 운영체제와 CPU에 맞는 네이티브 기계어로 컴파일합니다.

Linux x86-64, macOS ARM64, Windows x86-64 등 타겟 플랫폼에 따라 다른 바이너리가 생성됩니다. 넷째, 힙 스냅샷입니다.

빌드 타임에 생성된 객체들을 이미지 힙에 저장합니다. 실행 시점에 다시 만들 필요가 없습니다.

최시니어 씨가 실제 빌드 과정을 보여줬습니다. "한번 빌드해볼까요?" 터미널에 mvn clean package -Pnative를 입력했습니다.

화면에 무수히 많은 로그가 흘러갔습니다. 분석 중, 컴파일 중, 링킹 중...

5분 정도 지나자 빌드가 완료되었습니다. target 디렉토리를 보니 application이라는 실행 파일이 생성되었습니다.

확장자가 없는 바이너리 파일입니다. Windows라면 .exe 파일이 만들어집니다.

"이걸 그냥 실행하면 됩니다." 최시니어 씨가 ./application을 입력했습니다. 놀랍게도 0.05초 만에 애플리케이션이 시작되었습니다.

"Started Application in 0.051 seconds" 메시지가 출력되었습니다. 일반 JAR 파일로 실행하면 20초 걸리던 것이 말입니다.

이개발 씨가 신기한 듯 바이너리 파일 크기를 확인했습니다. 70MB 정도였습니다.

"생각보다 크네요?" "JVM 런타임의 필요한 부분이 포함되어 있어서 그래요." 최시니어 씨가 설명했습니다. "하지만 일반적으로 JVM을 실행할 때 필요한 메모리보다는 훨씬 적습니다." 실제로 메모리 사용량을 보니 50MB 정도만 사용하고 있었습니다.

일반 Spring Boot 애플리케이션이 300~500MB를 사용하는 것과 비교하면 엄청난 차이입니다. GraalVM Native Image의 또 다른 장점은 워밍업이 필요 없다는 것입니다.

JIT 컴파일은 처음에는 느리다가 점점 빨라집니다. 하지만 네이티브 이미지는 처음부터 최고 성능을 냅니다.

이미 최적화된 기계어로 컴파일되어 있기 때문입니다. 최시니어 씨가 주의사항을 알려줬습니다.

"하지만 완벽하지는 않아요. 몇 가지 제약이 있습니다." GraalVM Native Image는 클로즈드 월드 가정을 사용합니다.

빌드 시점에 모든 코드를 알아야 한다는 뜻입니다. 런타임에 동적으로 클래스를 로드하거나 생성하는 것이 제한됩니다.

또한 빌드 시간이 깁니다. 작은 애플리케이션도 몇 분이 걸리고, 큰 애플리케이션은 10분 이상 걸릴 수 있습니다.

CI/CD 파이프라인에서 고려해야 할 부분입니다. 이개발 씨가 정리했습니다.

"결국 시작 시간과 메모리는 줄어들지만, 빌드 시간은 늘어나는 트레이드오프가 있네요." "정확합니다." 최시니어 씨가 고개를 끄덕였습니다. "그래서 모든 경우에 네이티브 이미지가 최선은 아니에요.

상황에 맞게 선택해야 합니다."

실전 팁

💡 - GraalVM은 Community Edition(무료)과 Enterprise Edition이 있습니다

  • 빌드 시간이 길므로 CI/CD에서 캐싱 전략을 고려하세요
  • 플랫폼별로 별도 빌드가 필요합니다 (크로스 컴파일 미지원)

4. 네이티브 빌드 설정

이개발 씨가 실제 프로젝트에 네이티브 이미지를 적용해보기로 했습니다. "설정이 복잡하지 않을까요?" 최시니어 씨가 화면을 공유했습니다.

"Spring Boot 3부터는 매우 간단합니다. 플러그인 추가와 프로파일 설정만 하면 됩니다."

네이티브 이미지를 빌드하려면 GraalVM 설치, 빌드 툴 플러그인 설정, 네이티브 프로파일 구성이 필요합니다. Maven이나 Gradle에서 native 프로파일을 활성화하면 Spring Boot가 AOT 처리를 하고 GraalVM이 네이티브 바이너리를 생성합니다.

Docker를 사용하면 GraalVM을 로컬에 설치하지 않고도 빌드할 수 있습니다.

다음 코드를 살펴봅시다.

// build.gradle - Gradle 설정 예시
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.0'
    id 'io.spring.dependency-management' version '1.1.4'
    id 'org.graalvm.buildtools.native' version '0.9.28'
}

graalvmNative {
    binaries {
        main {
            imageName = 'my-application'
            mainClass = 'com.example.Application'
            buildArgs.add('--no-fallback')
            buildArgs.add('-H:+ReportExceptionStackTraces')
            buildArgs.add('-H:ResourceConfigurationFiles=resource-config.json')
        }
    }
}

// 빌드 명령어
// ./gradlew nativeCompile

이개발 씨는 회사의 실제 서비스를 네이티브 이미지로 만들어보기로 결심했습니다. 주문 처리 마이크로서비스였습니다.

기존에는 JAR 파일로 배포하고 있었습니다. "어디서부터 시작해야 할까요?" 이개발 씨가 물었습니다.

최시니어 씨가 체크리스트를 보여줬습니다. "크게 세 단계입니다.

GraalVM 설치, 빌드 도구 설정, 그리고 실제 빌드입니다." 첫 번째 단계는 GraalVM 설치입니다. 하지만 로컬 개발 환경에 직접 설치하는 것은 번거롭습니다.

버전 관리도 복잡하고, 팀원마다 환경이 달라질 수 있습니다. 다행히 Docker를 사용하면 이 문제를 해결할 수 있습니다.

Spring Boot는 Cloud Native Buildpacks를 지원합니다. 이것을 사용하면 Docker 이미지로 바로 네이티브 이미지를 빌드할 수 있습니다.

GraalVM 설치가 필요 없습니다. 최시니어 씨가 명령어를 보여줬습니다.

mvn spring-boot:build-image -Pnative 이 명령 하나로 Docker 이미지가 만들어집니다. 내부적으로 GraalVM 컨테이너가 실행되고, 그 안에서 네이티브 컴파일이 진행되며, 최종적으로 네이티브 바이너리가 포함된 경량 이미지가 생성됩니다.

두 번째 단계는 빌드 도구 설정입니다. Maven을 사용한다면 위에서 본 것처럼 pom.xml에 프로파일을 추가합니다.

Gradle을 사용한다면 위의 코드처럼 build.gradle에 플러그인을 추가합니다. 중요한 설정 몇 가지를 살펴봅시다.

--no-fallback 옵션은 네이티브 이미지 빌드가 실패하면 JVM 이미지로 폴백하지 않도록 합니다. 명확하게 에러를 확인할 수 있어서 디버깅에 유리합니다.

-H:+ReportExceptionStackTraces 옵션은 빌드 중 에러가 발생하면 상세한 스택 트레이스를 출력합니다. 문제를 빠르게 파악할 수 있습니다.

-H:ResourceConfigurationFiles 옵션은 추가 리소스 설정 파일을 지정합니다. 특정 파일을 네이티브 이미지에 포함시켜야 할 때 사용합니다.

이개발 씨가 코드를 보며 물었습니다. "이 설정만으로 충분한가요?" "대부분의 경우는 충분합니다." 최시니어 씨가 답했습니다.

"Spring Boot의 자동 설정 덕분이죠." 세 번째 단계는 실제 빌드입니다. 최시니어 씨가 터미널을 열고 명령어를 입력했습니다.

./gradlew nativeCompile 빌드가 시작되었습니다. 먼저 Spring AOT 프로세스가 실행되면서 빈 정의와 힌트를 생성합니다.

"Generating AOT assets..." 메시지가 출력됩니다. 그 다음 GraalVM Native Image 컴파일이 시작됩니다.

"Compiling native image..." 메시지와 함께 진행 상황이 표시됩니다. "Performing analysis...", "Building image...", "Creating image..." 약 5분 후 빌드가 완료되었습니다.

build/native/nativeCompile 디렉토리에 실행 파일이 생성되었습니다. 하지만 빌드가 실패할 수도 있습니다.

이개발 씨의 프로젝트에서 에러가 발생했습니다. "Class not found: com.fasterxml.jackson.databind.ObjectMapper" 에러였습니다.

최시니어 씨가 설명했습니다. "리플렉션으로 접근되는 클래스인데 힌트가 없어서 그래요." 이런 경우 힌트 추가가 필요합니다.

설정 클래스에 애너테이션을 추가합니다. java @Configuration @RegisterReflectionForBinding({ObjectMapper.class, JsonNode.class}) public class JacksonConfig { } 이렇게 하면 Spring AOT가 해당 클래스를 리플렉션 설정에 추가합니다.

또 다른 문제는 리소스 파일입니다. application.yml이나 정적 파일들이 네이티브 이미지에 포함되지 않을 수 있습니다.

이 경우 resource-config.json 파일을 작성하거나, @ImportRuntimeHints를 사용해서 프로그래밍 방식으로 힌트를 제공합니다. java @Configuration @ImportRuntimeHints(MyRuntimeHints.class) public class ResourceConfig { } class MyRuntimeHints implements RuntimeHintsRegistrar { @Override public void registerHints(RuntimeHints hints, ClassLoader classLoader) { hints.resources().registerPattern("templates/*.html"); } } 이개발 씨가 힌트를 추가하고 다시 빌드했습니다.

이번에는 성공했습니다. 최시니어 씨가 조언했습니다.

"처음에는 시행착오가 있을 수 있어요. 하지만 한 번 설정하면 계속 재사용할 수 있습니다." 마지막으로 테스트도 네이티브 모드로 실행할 수 있습니다.

./gradlew nativeTest 명령을 사용하면 테스트가 네이티브 이미지로 컴파일되어 실행됩니다. 실제 프로덕션 환경과 동일한 조건에서 테스트할 수 있습니다.

이개발 씨는 이제 자신감이 생겼습니다. "생각보다 어렵지 않네요!"

실전 팁

💡 - Docker를 사용하면 로컬에 GraalVM 설치 없이 빌드 가능합니다

  • 빌드 시간이 길므로 개발 중에는 JVM 모드로, 배포 시에만 네이티브 빌드하세요
  • 테스트도 네이티브 모드로 실행해서 실제 동작을 검증하세요

5. 시작 시간 비교

이개발 씨가 드디어 네이티브 이미지 빌드에 성공했습니다. "성능이 정말 좋아질까요?" 최시니어 씨가 미소를 지었습니다.

"직접 비교해봅시다. JVM 모드와 네이티브 모드를 동시에 실행해서 차이를 확인해보죠."

JVM 모드와 네이티브 이미지 모드의 시작 시간메모리 사용량을 비교하면 극적인 차이를 확인할 수 있습니다. 일반적으로 네이티브 이미지는 시작 시간이 10~100배 빠르고, 메모리 사용량은 50~70% 절감됩니다.

특히 마이크로서비스나 서버리스 환경에서 콜드 스타트 시간 단축 효과가 큽니다.

다음 코드를 살펴봅시다.

// 성능 측정을 위한 간단한 컨트롤러
@RestController
public class PerformanceController {
    private final long startTime;

    public PerformanceController() {
        this.startTime = System.currentTimeMillis();
    }

    @GetMapping("/health")
    public Map<String, Object> health() {
        Runtime runtime = Runtime.getRuntime();
        long uptime = System.currentTimeMillis() - startTime;

        return Map.of(
            "status", "UP",
            "uptime", uptime + "ms",
            "memory.used", (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024 + "MB",
            "memory.max", runtime.maxMemory() / 1024 / 1024 + "MB"
        );
    }
}

이개발 씨는 두 개의 터미널을 열었습니다. 왼쪽에는 JVM 모드, 오른쪽에는 네이티브 모드를 실행할 준비를 했습니다.

최시니어 씨가 스톱워치를 준비했습니다. "동시에 시작해볼까요?" 최시니어 씨가 카운트다운을 했습니다.

"3, 2, 1, 시작!" 왼쪽 터미널에서 java -jar application.jar를 실행했습니다. 오른쪽에서는 ./application을 실행했습니다.

결과는 놀라웠습니다. 오른쪽 네이티브 이미지는 단 0.068초 만에 시작되었습니다.

"Started Application in 0.068 seconds" 메시지가 즉시 출력되었습니다. 왼쪽 JVM 모드는 아직도 시작 중이었습니다.

"Loading Spring context..." 메시지가 천천히 지나갔습니다. 약 15초 후에야 "Started Application in 14.823 seconds" 메시지가 나타났습니다.

218배 차이였습니다. 이개발 씨는 입이 떡 벌어졌습니다.

"이게 가능한 일인가요?" 최시니어 씨가 설명했습니다. "JVM 모드는 시작할 때 많은 일을 합니다.

클래스를 로드하고, 빈을 생성하고, AOP 프록시를 만들고, 데이터베이스 연결을 초기화합니다. 네이티브 이미지는 이 모든 것이 이미 준비된 상태로 시작합니다." 마치 캠핑을 가는 것에 비유할 수 있습니다.

JVM 모드는 캠핑장에 도착해서 텐트를 치고, 화덕을 설치하고, 침낭을 펴는 것과 같습니다. 준비 시간이 필요합니다.

네이티브 이미지는 이미 설치된 글램핑장에 들어가는 것과 같습니다. 텐트도 침대도 모두 준비되어 있습니다.

그냥 들어가서 쉬기만 하면 됩니다. 다음은 메모리 사용량 비교입니다.

/health 엔드포인트를 호출해서 메모리 정보를 확인했습니다. JVM 모드는 312MB를 사용하고 있었습니다.

최대 메모리는 2GB로 설정되어 있었습니다. 네이티브 이미지는 68MB만 사용하고 있었습니다.

78% 감소입니다. "왜 이렇게 차이가 날까요?" 이개발 씨가 물었습니다.

최시니어 씨가 답했습니다. "JVM은 전체 런타임을 메모리에 올립니다.

클래스 로더, JIT 컴파일러, 가비지 컬렉터 등 많은 구성 요소가 있습니다. 네이티브 이미지는 필요한 부분만 포함됩니다." 실제 운영 환경에서 이 차이는 더 중요합니다.

Kubernetes에서 파드를 배포한다고 가정해봅시다. JVM 모드는 파드당 512MB의 메모리 리소스를 요청해야 합니다.

네이티브 이미지는 128MB만 요청해도 됩니다. 같은 노드에서 4배 더 많은 파드를 실행할 수 있습니다.

클라우드 비용이 그만큼 절감됩니다. 최시니어 씨가 실제 시나리오를 보여줬습니다.

"우리 회사의 쇼핑몰 서비스를 예로 들어볼게요." 블랙프라이데이 세일 기간에 트래픽이 급증합니다. 오토 스케일링이 작동해서 파드를 늘립니다.

JVM 모드에서는 새로운 파드가 시작되는 데 15초가 걸립니다. 그동안 기존 파드에 부하가 몰려서 응답 시간이 늘어납니다.

심하면 타임아웃이 발생합니다. 네이티브 이미지에서는 새로운 파드가 0.1초 만에 트래픽을 받을 수 있습니다.

거의 즉시 스케일 아웃이 완료됩니다. "서버리스 환경에서는 더 극적입니다." 최시니어 씨가 계속했습니다.

AWS Lambda나 Google Cloud Functions에서는 콜드 스타트가 중요합니다. 함수가 처음 호출되거나 오랫동안 사용되지 않았을 때 컨테이너를 새로 시작해야 합니다.

JVM 기반 Lambda 함수는 콜드 스타트에 10~30초가 걸릴 수 있습니다. 사용자는 한참을 기다려야 합니다.

네이티브 이미지로 만든 함수는 1초 이내에 응답합니다. 사용자 경험이 완전히 달라집니다.

이개발 씨가 부하 테스트도 해봤습니다. ApacheBench로 동시 요청 100개를 보냈습니다.

JVM 모드는 초반에 느렸다가 점점 빨라졌습니다. JIT 컴파일러가 핫스팟을 찾아서 최적화했기 때문입니다.

네이티브 이미지는 처음부터 일정한 성능을 보였습니다. 워밍업이 필요 없었습니다.

"그럼 네이티브 이미지가 항상 더 빠른 건가요?" 이개발 씨가 물었습니다. "아닙니다." 최시니어 씨가 고개를 저었습니다.

"장시간 실행되는 서버에서는 JIT 컴파일이 더 나은 최종 성능을 낼 수 있습니다. 실행 패턴을 분석해서 최적화하니까요." 중요한 것은 워크로드의 특성입니다.

자주 시작되고 종료되는 애플리케이션, 빠른 스케일링이 필요한 서비스, 메모리가 제한된 환경에서는 네이티브 이미지가 압도적으로 유리합니다. 반대로 며칠, 몇 달 동안 중단 없이 실행되는 서버, 처리량이 가장 중요한 배치 작업에서는 JVM 모드가 더 나을 수 있습니다.

이개발 씨가 정리했습니다. "결국 상황에 맞게 선택하는 거네요." "정확합니다." 최시니어 씨가 웃었습니다.

"도구는 도구일 뿐이에요. 올바른 도구를 올바른 곳에 사용하는 것이 중요합니다."

실전 팁

💡 - 시작 시간과 메모리는 애플리케이션 크기와 의존성에 따라 달라집니다

  • 벤치마크는 실제 운영 환경과 유사한 조건에서 수행하세요
  • 처리량(throughput)도 측정해서 종합적으로 판단하세요

6. 제약사항과 고려사항

이개발 씨가 네이티브 이미지의 장점에 흥분했습니다. "모든 프로젝트를 네이티브로 바꿔야겠어요!" 최시니어 씨가 손을 들어 제지했습니다.

"잠깐만요. 네이티브 이미지에는 중요한 제약사항들이 있습니다.

이것을 이해하지 못하면 큰 문제를 겪을 수 있어요."

네이티브 이미지는 클로즈드 월드 가정을 사용하므로 런타임 동적 기능에 제약이 있습니다. 리플렉션, 동적 프록시, 직렬화, JNI 사용 시 명시적 설정이 필요합니다.

일부 라이브러리는 지원되지 않으며, 빌드 시간이 길고 디버깅이 어려울 수 있습니다. 프로덕션 적용 전에 충분한 테스트가 필수입니다.

다음 코드를 살펴봅시다.

// 네이티브 이미지에서 문제가 될 수 있는 코드
@Service
public class DynamicService {
    // 문제 1: 동적 클래스 로딩
    public Object loadClass(String className) throws Exception {
        // 런타임에 클래스 이름이 결정되면 네이티브 이미지가 인식 못함
        Class<?> clazz = Class.forName(className);
        return clazz.getDeclaredConstructor().newInstance();
    }

    // 문제 2: private 필드 리플렉션 접근
    public void setPrivateField(Object obj, String fieldName, Object value) {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true); // 네이티브에서 실패 가능
        field.set(obj, value);
    }

    // 해결: 명시적 힌트 제공
    @RegisterReflectionForBinding({User.class, Product.class})
    public void processKnownTypes() {
        // 컴파일 타임에 타입이 명확하면 OK
    }
}

이개발 씨는 신나서 회사의 모든 서비스를 네이티브 이미지로 전환하려고 했습니다. 하지만 첫 번째 서비스를 변환하다가 벽에 부딪혔습니다.

"빌드는 성공했는데 실행하면 에러가 나요!" 이개발 씨가 당황했습니다. 최시니어 씨가 로그를 살펴봤습니다.

"ClassNotFoundException이네요. 동적으로 클래스를 로드하는 부분이 있나요?" 코드를 확인해보니 플러그인 시스템이 있었습니다.

설정 파일에서 클래스 이름을 읽어서 동적으로 로드하는 구조였습니다. JVM 모드에서는 문제없이 작동했지만, 네이티브 이미지에서는 실패했습니다.

"이게 네이티브 이미지의 첫 번째 제약입니다." 최시니어 씨가 설명했습니다. **클로즈드 월드 가정(Closed World Assumption)**입니다.

네이티브 이미지는 빌드 시점에 도달 가능한 모든 코드를 분석합니다. 그리고 그 코드만 최종 바이너리에 포함시킵니다.

런타임에 새로운 클래스를 추가할 수 없습니다. 마치 비행기 탑승과 같습니다.

출발 전에 모든 짐을 검사하고 실어야 합니다. 비행기가 이륙한 후에는 "아, 짐을 하나 더 실어주세요"라고 할 수 없습니다.

미리 확정된 것만 가져갈 수 있습니다. 그렇다면 동적 로딩이 필요하면 어떻게 할까요?

첫째, 설계를 변경하는 것입니다. 가능한 모든 플러그인을 컴파일 타임에 등록하도록 바꿉니다.

동적 로딩 대신 정적 등록을 사용합니다. 둘째, 리플렉션 힌트를 제공합니다.

사용될 가능성이 있는 모든 클래스를 미리 등록합니다. java @Configuration @RegisterReflectionForBinding({ PluginA.class, PluginB.class, PluginC.class }) public class PluginConfig { } 이개발 씨가 두 번째 문제를 발견했습니다.

Jackson으로 JSON을 역직렬화할 때 에러가 났습니다. "Jackson은 리플렉션을 많이 사용해요." 최시니어 씨가 말했습니다.

"DTO 클래스의 모든 필드에 접근해서 값을 채웁니다." 다행히 Spring Boot의 AOT 엔진이 대부분의 케이스를 자동으로 처리합니다. 하지만 제네릭 타입이 복잡하거나 중첩 클래스가 많으면 문제가 생길 수 있습니다.

세 번째 제약은 **JNI(Java Native Interface)**입니다. 네이티브 라이브러리를 사용하는 경우, 예를 들어 데이터베이스 드라이버나 암호화 라이브러리 같은 것들입니다.

이런 라이브러리들은 네이티브 이미지와 호환되지 않을 수 있습니다. PostgreSQL, MySQL 같은 주요 데이터베이스는 GraalVM 팀이 호환성을 테스트했습니다.

하지만 마이너한 라이브러리는 작동 보장이 없습니다. "그럼 사용 전에 확인해야겠네요." 이개발 씨가 메모했습니다.

네 번째는 빌드 시간입니다. 이개발 씨의 프로젝트는 빌드하는 데 8분이 걸렸습니다.

JAR 파일 빌드는 30초면 됐던 것과 비교하면 16배 느립니다. "개발 중에는 JVM 모드로 하고, CI/CD에서만 네이티브 빌드를 하세요." 최시니어 씨가 조언했습니다.

매번 코드를 수정할 때마다 8분씩 기다린다는 것은 비현실적입니다. 개발 생산성이 크게 떨어집니다.

다섯째는 디버깅의 어려움입니다. JVM 모드에서는 익셉션이 발생하면 상세한 스택 트레이스를 볼 수 있습니다.

디버거를 붙여서 브레이크포인트를 찍고 변수를 확인할 수 있습니다. 네이티브 이미지는 네이티브 코드로 컴파일되어 있어서 디버깅이 제한적입니다.

GDB 같은 네이티브 디버거를 사용해야 하는데, Java 개발자에게는 낯설 수 있습니다. 여섯째는 크로스 컴파일 불가입니다.

macOS에서 빌드하면 macOS용 바이너리가 만들어집니다. Linux용 바이너리를 만들려면 Linux 환경에서 빌드해야 합니다.

CI/CD 파이프라인에서 Docker를 사용하면 이 문제를 해결할 수 있습니다. Linux 컨테이너에서 빌드하면 Linux 바이너리가 생성됩니다.

일곱째는 메모리 관리입니다. 네이티브 이미지도 가비지 컬렉터를 사용합니다.

하지만 Serial GC라는 단순한 방식만 지원됩니다. G1GC나 ZGC 같은 고급 GC는 사용할 수 없습니다.

대부분의 마이크로서비스에는 문제가 없지만, 메모리가 큰 애플리케이션에서는 GC 성능이 문제가 될 수 있습니다. 최시니어 씨가 정리했습니다.

"결국 네이티브 이미지는 만능이 아닙니다." 어떤 경우에 네이티브 이미지가 적합할까요? 적합한 경우: - 마이크로서비스 아키텍처 - 서버리스 함수 (Lambda, Cloud Functions) - CLI 도구 - 짧은 생명주기의 배치 작업 - 컨테이너 환경 (빠른 스케일링 필요) 부적합한 경우: - 동적 클래스 로딩이 많은 애플리케이션 - 지원되지 않는 라이브러리 사용 - 메모리 집약적 장기 실행 서버 - 빠른 빌드 시간이 중요한 개발 환경 이개발 씨가 결정을 내렸습니다.

"주문 서비스와 인증 서비스는 네이티브로 전환하고, 메인 모노리스는 JVM 모드로 유지하겠습니다." "현명한 선택입니다." 최시니어 씨가 웃었습니다. "도구의 장단점을 이해하고 적재적소에 사용하는 것, 그것이 진짜 실력입니다." 마지막으로 최시니어 씨가 당부했습니다.

"프로덕션에 배포하기 전에 꼭 네이티브 모드로 통합 테스트를 하세요. JVM 모드에서 잘 작동한다고 해서 네이티브 모드에서도 작동한다는 보장은 없습니다." 이개발 씨는 체크리스트를 작성했습니다.

의존성 확인, 리플렉션 검사, 네이티브 테스트 실행, 성능 벤치마크, 스테이징 배포, 모니터링 설정... 준비가 철저할수록 프로덕션에서의 문제가 줄어듭니다.

실전 팁

💡 - 의존성 라이브러리의 GraalVM 호환성을 사전에 확인하세요

  • 개발은 JVM 모드로, 배포만 네이티브 모드로 하는 하이브리드 전략을 사용하세요
  • 네이티브 모드 통합 테스트를 CI/CD 파이프라인에 포함시키세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Spring#AOT#GraalVM#NativeImage#Performance

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스