Spring Boot 3는 자바 세계에서 큰 주목을 받고 있으며, 출시된 지 몇 달 만에 커뮤니티는 이미 새로운 버전으로의 마이그레이션을 시작했습니다. Maven Central에서 parent pom 3.0.2의 사용이 500에 육박하고 있는 것을 보면, 이 새로운 버전이 얼마나 빠르게 채택되고 있는지 알 수 있습니다.
Spring Boot 3의 흥미로운 새로운 기능 중 하나는 GraalVM Native Image에 대한 내장 지원입니다. 이 기능은 수년간 기다려온 기능으로, 이제는 프로젝트를 Native Image로 마이그레이션할 적기입니다. 하지만 기존 작업을 Native Image로 단순히 전환할 수는 없습니다. 기술적으로 몇 가지 자바 기능과 호환되지 않기 때문입니다. 이 글에서는 Spring Boot Native Image 개발과 관련된 복잡성을 다루고, 성공적인 마이그레이션을 위해 알아야 할 중요한 사항들을 살펴보겠습니다.
자바 개발의 패러다임 변화
오랫동안 자바의 중요한 특징 중 하나는 동적 로딩(dynamic loading)이었습니다. IntelliJ IDEA와 Eclipse와 같은 자바 기반의 개발자 도구는 모두 "모든 것이 플러그인"이라는 가정에 기반하여 개발되었습니다. 따라서 개발 환경을 재시작하지 않고도 원하는 만큼 새로운 플러그인을 로드할 수 있었습니다. Spring 역시 AOP와 같은 기능 덕분에 동적 환경의 훌륭한 예라 할 수 있습니다.
그러나 시간이 지나면서 우리는 동적 코드 로딩이 편리할 뿐만 아니라 리소스 소모도 크다는 것을 알게 되었습니다. 웹 애플리케이션이 시작되기까지 20분을 기다리는 것은 결코 즐거운 일이 아닙니다. 따라서 시작 시간을 단축하고 메모리 소비를 줄이는 방법을 고민하기 시작했습니다. 그 결과, 개발자들은 과도한 동적 로딩을 자제하고 필요한 모든 리소스를 정적으로 미리 컴파일하는 방법을 선호하게 되었습니다.
이런 상황에서 GraalVM Native Image가 등장했습니다. 이 기술은 JVM 기반 애플리케이션을 컴파일된 바이너리로 변환하여, 때로는 JDK 없이도 실행할 수 있습니다. 그 결과로 생성된 네이티브 바이너리는 매우 빠르게 시작됩니다.
하지만 Native Image는 "클로즈드 월드 가정(closed-world assumption)" 하에서 작동합니다. 즉, 사용되는 모든 클래스는 컴파일 시점에 이미 알려져 있어야 합니다. 따라서 Native Image로의 마이그레이션은 단순히 몇 줄의 코드를 변경하는 문제가 아닙니다. 이는 개발 접근 방식을 전환하는 것과 같습니다. 동적 리소스를 컴파일 단계에서 Native Image에 알릴 수 있도록 해야 합니다.
Native Image의 구체적인 특징
클래스의 Finalization
Spring 애플리케이션 개발은 단순한 자바 애플리케이션 작성과는 다릅니다. Native Image와 Spring을 함께 사용하려면 자바의 몇 가지 특성을 깊이 이해해야 합니다.
예를 들어, 클래스의 finalization을 고려해 봅시다. 자바의 초기 버전에서는 finalize() 메서드에서 일부 정리 코드를 작성하고, System.runFinalizersOnExit(true) 플래그를 설정한 후 프로그램이 종료되기를 기다릴 수 있었습니다. 하지만 현재의 자바 버전에서는 가비지 컬렉션의 특성 때문에 이 코드를 실행해도 "Goodbye World!"라는 출력이 나타나지 않습니다. 자바 11에서는 이 기능이 제거되었으며, 더 이상 사용할 수 없습니다.
Native Image 문서에서도 finalizers는 작동하지 않으며, 약한 참조(weak references)나 참조 큐(reference queues)와 같은 다른 방법으로 대체해야 한다고 명시하고 있습니다.
자바 플랫폼의 이러한 변화는 개발 트렌드가 예측 불가능한 동작을 제거하고, 더 안정적인 방법을 사용하는 방향으로 발전하고 있음을 보여줍니다. 만약 종료 시 문자열 출력을 보장하고 싶다면, Runtime.getRuntime().addShutdownHook()를 사용하는 것이 좋습니다.
Spring 개발자들에게는 추가적인 도구가 있습니다. 예를 들어, @PreDestroy 어노테이션을 사용하여 종료 전에 실행할 메서드를 정의하거나, ConfigurableApplicationContext.close()를 사용하여 컨텍스트를 수동으로 종료할 수 있습니다.
다음은 finalizer 대신 사용할 수 있는 코드 예시입니다:
@Component
public class WorldComponent {
@PreDestroy
public void bye() {
System.out.println("Goodbye World!");
}
}
또는 Shutdown Hook 대신 사용할 수 있는 코드 예시는 다음과 같습니다:
@SpringBootApplication
public class PredestroyApplication {
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(PredestroyApplication.class, args);
int exitCode = SpringApplication.exit(ctx, new ExitCodeGenerator() {
@Override
public int getExitCode() {
System.out.println("Goodbye World!");
return 0;
}
});
System.exit(exitCode);
}
}
이제 이러한 방법들을 하나의 코드로 정리해보겠습니다:
@SpringBootApplication
public class PredestroyApplication {
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("Goodbye World! (shutdown-hook)");
}));
ConfigurableApplicationContext ctx = SpringApplication.run(PredestroyApplication.class, args);
int exitCode = SpringApplication.exit(ctx, new ExitCodeGenerator() {
@Override
public int getExitCode() {
System.out.println("Goodbye World! (context-exit)");
return 0;
}
});
System.exit(exitCode);
}
@PreDestroy
public void bye() {
System.out.println("Goodbye World! (pre-destroy)");
}
@Override
protected void finalize() throws Throwable {
System.out.println("Goodbye World! (finalizer)");
}
}
이 코드는 Spring 애플리케이션을 Native Image로 컴파일할 때도 작동합니다.
초기화(Initialization)
finalization에 이어 초기화도 중요합니다. Spring은 여러 가지 필드 초기화 방법을 제공합니다. 예를 들어, @Value 어노테이션을 사용하여 값을 직접 할당하거나, @Autowired 또는 @PostConstruct를 사용하여 프로퍼티를 정의할 수 있습니다. GraalVM은 컴파일 단계에서 데이터를 기록할 수 있는 또 다른 흥미로운 방법을 제공합니다. 컴파일 단계에서 초기화하려는 클래스는 --initialize-at-build-time=my.class 옵션으로 표시됩니다.
Spring Initializr를 사용하여 새로운 Spring 애플리케이션을 빌드해보겠습니다. 필요로 하는 유일한 종속성은 GraalVM Native Support입니다. 네이티브 실행 파일을 생성하려면 Native Image Build Tool이 필요합니다. BellSoft는 Liberica Native Image Kit을 개발하였으며, 이는 Spring에서 추천하는 GraalVM 기반의 도구입니다.
GraalVM Native Image는 기존의 자바 애플리케이션을 새로운 방식으로 생각하도록 유도합니다. 특정 자바 기능이 지원되지 않거나 다르게 동작할 수 있으며, 이러한 제한 사항을 인지하고 코드에 반영하는 것이 중요합니다.
Native Image의 한계
GraalVM에서는 다음과 같은 기능이 다르게 작동하거나 아예 지원되지 않을 수 있습니다:
- 리플렉션(Reflection)
- 프록시(Proxy)
- 메서드 핸들(Method Handles)
- 직렬화(Serialization)
- JNI
- 리소스(Resources)
이러한 기능들이 지원되지 않는 경우, 애플리케이션을 재작성하거나 컴파일 시점에 필요한 데이터를 설정 파일에 넣는 방법이 필요합니다.
레거시 라이브러리와의 호환성
Native Image는 전통적인 자바 애플리케이션과 다르게 동작할 수 있으며, 특히 서드파티 라이브러리와의 호환성에서 문제를 일으킬 수 있습니다. Native Image는 글로벌 코드 분석을 사용하여 라이브러리를 애플리케이션 코드와 함께 컴파일합니다. 이로 인해 컴파일 오류가 발생할 수 있으며, 라이브러리와의 호환성 문제를 해결해야 할 수도 있습니다.
Spring 팀은 Native Image 기술을 에코시스템에 통합하기 위해 많은 노력을 기울였습니다. 많은 Spring 라이브러리와 모듈이 이미 Native Image와 호환되지만, 모든 것이 완벽히 작동하는지 확인하기 위해 라이브러리를 코드와 함께 실행해보는 것이 좋습니다.
개발 및 디버깅의 복잡성
Native Image로의 컴파일은 시간이 걸리므로, 코드를 작성하고 디버깅할 때는 표준 JVM을 사용하는 것이 더 실용적일 수 있습니다. 하지만 최종적으로는 네이티브 바이너리를 별도로 테스트해야 합니다. 애플리케이션이 "클래식" JVM 버전과 다르게 작동할 수 있기 때문입니다.
테스트 과정을 가속화하려면 CI 서버를 설정하여 모든 커밋을 Native Image로 컴파일하도록 설정할 수 있습니다. 이와 함께, Native Image 바이너리만 제공하여 테스트 시간을 절약할 수 있습니다.
성능 프로파일
Native Image는 "작고 빠른" 애플리케이션을 만들 수 있다고 알려져 있습니다. 하지만 "작고 빠른"이 무엇을 의미하는지 명확히 이해하는 것이 중요합니다. Native Image는 AOT(선행 컴파일)를 사용하여 시작 시간을 크게 단축할 수 있습니다. 그러나 메모리 소비에 대해서는 네이티브 실행 파일이 항상 Uber JAR 또는 Fat JAR보다 작지는 않을 수 있습니다. 따라서 프로젝트나 그 일부를 Native Image로 빌드하고, 그 가치가 있는지 확인하는 것이 중요합니다.
애플리케이션의 로드 패턴을 고려하여 JIT와 AOT 중 어떤 것이 더 적합한지 결정해야 합니다. 웹 서비스와 같은 부하 프로파일이 평탄한 경우 Native Image로의 마이그레이션이 적합할 수 있습니다.
결론
Spring Boot 3와 GraalVM Native Image의 조합은 자바 개발에 새로운 가능성을 열어줍니다. 이 강력한 도구는 자바 개발자들이 이전에는 불가능했던 작업을 수행할 수 있게 해주며, 애플리케이션의 성능과 효율성을 크게 향상시킬 수 있습니다.
Spring Boot와 GraalVM Native Image를 사용하여 더 나은 애플리케이션을 구축하고, 성능 최적화를 달성하는 방법을 배우는 것은 자바 개발자로서 큰 이점이 될 것입니다.
'SW > Spring Boot' 카테고리의 다른 글
스프링 프레임워크 대안으로서의 쿠버네티스: 클라우드 네이티브 애플리케이션을 위한 선택 (0) | 2024.10.28 |
---|---|
Spring Boot, Quarkus, Micronaut 비교: 어떤 REST API 프레임워크를 선택해야 할까? (0) | 2024.09.15 |
Spring Boot Security를 활용한 기본 인증 구현: 단계별 가이드 (0) | 2024.09.10 |
Spring Boot 3.0과 Spring Data JPA 및 Querydsl 업그레이드 가이드 (0) | 2024.07.16 |
Spring Boot와 Quarkus: 웹 애플리케이션 성능 비교 (0) | 2024.06.16 |