Post

GraalVM으로 native image compile 하기 (with Spring Boot)

JVM + JIT compile / native image + AOT compile

  • Spring 애플리케이션은 JVM 위에서 돌아가고, JVM은 그 동작 방식 때문에 초기 구동 속도가 느리다.
    • JVM은 최초에는 interpreter로 동작하다가 자주 사용되는 메서드는 JIT compile하기 때문에, 초기에는 느릴 수 밖에 없다.
    • 런타임에 Class Loading 하는 과정 때문에 느린 것도 한몫 한다.
    • JVM, JIT compile 참고
  • 반면 빌드 시점에 미리 컴파일(AOT compile) 해서 native binary로 만드는 방식(go, C++ 등)은 초기 구동 속도 문제가 없다.
  • SpringBoot 3 부터 GraalVM을 이용한 native image build를 정식 지원한다. ⇒ go, C++ 컴파일 결과로 생성되는 native binary를 java, Spring 진영에서도 생성 할 수 있게 되었다.

native image build 기본 설정

build.gradle.kts 설정

1
2
3
4
5
6
7
8
9
10
plugins {  
    id("org.graalvm.buildtools.native")
}

graalvmNative {  
    binaries.all {  
        val reflectConfigPath = file("src/main/resources/native-config/reflect-config-additional.json").absolutePath  
        buildArgs.add("-H:ReflectionConfigurationFiles=$reflectConfigPath")  
    }  
}
  • graalvm compiler는 정적 분석을 통해 런타임에 필요한 코드들을 탐색하고, reachable code만 컴파일 대상으로 잡는다. (main에서 시작해서 쭉 따라가본다.)
  • 그러나 컴파일 시점의 정적 분석만으로는 탐지되지 않는 runtime reachable code들이 있다.
    • JNI, reflection, dynamic proxy, class path resource 등으로 인해 reachable code가 되는 경우
      • e.g. jackson (de)serialization에 사용되는 DTO. (jackson은 reflection을 사용한다)
    • 이 항목들이 빠진 채로 컴파일하면, 당연히 런타임에 에러가 발생한다.
  • 이렇게 runtime에 reachable 해지는 클래스들은 .json 형태의 메타데이터로 컴파일러에게 따로 알려줘야만 한다.
  • native-image-agent 를 이용하면 실제로 jar를 실행해서 런타임 정보를 수집한 뒤 .json 형태로 export 해준다.
1
java -agentlib:native-image-agent=config-output-dir=./native-config-dir -jar your-app.jar
  • gradle plugin 사용하면 직접 커맨드 호출 할 필요 없이 위 작업을 자동으로 처리해준다. (reflect-config.json을 자동으로 생성해준다)
    • gradle processAot로 트리거 가능하다.

native-image-agent가 runtime reachable code를 탐색하는 원리는, 실제로 애플리케이션을 실행하고 그 동안 호출된 class를 기록하는 방식이다.
그래서 애플리케이션 시작 시점에 호출되지 않는 class는 .json으로 기록되지 않는다!
생각보다 너무 rough하다… ㅎ;

리플렉션 대상 클래스가 다른 곳에서 일반적으로 사용되어 이미 컴파일이 되었다고 해도, .json에 해당 클래스가 포함되어 있지 않다면 리플렉션 부분 실행하다가 에러가 발생한다. 아마도 .json을 테이블 형태로 binary가 가지고 있다가, 리플렉션 코드가 등장하면 해당 테이블 참조해서 처리하는 방식이지 않을까?

native-image-agent로 안잡히는 class들이 있다.

  • Controller request/response 로 들어가는 DTO는 metadata 생성 대상으로 포함되는데, (Spring Bean은 앱 시작 시점에 생성되므로)
  • WebClient request/response 로 들어가는 DTO는 대상으로 포함되지 않는다. (이는 앱 시작 시점에 호출되지 않으므로)
  • => 전반적인 REST request/response DTO에 reflection 설정이 필요하다.
1
2
3
4
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: 
  Cannot construct instance of `dev.umbum...dto.ExchangeInfo`: 
    cannot deserialize from Object value (no delegate- or property-based Creator): 
      this appears to be a native image, in which case you may need to configure reflection for the class that is to be deserialized
  • 이런 클래스들은 직접 reflect-config-additional.json에 추가하고, 컴파일 시 이를 적용하기 위해 -H:ReflectionConfigurationFiles 설정 필요하다.
  • reflection hint option을 패키지에 대해서 줄 수는 없는지? => 아직 안된다ㅠ
  • 일일히 클래스 명시해줘야 한다.

find 사용하여 class 추출하는 방법

1
2
3
$ find . -type f
./dto/Account.kt
./dto/MarketItem.kt
  • 추출한 class 들을 reflect-config-additional.json에 추가
  • 단점)
    • 가장 간단한 방법이지만, nested class가 검출되지 않는다.
    • nested class가 있다면 그 것도 다 등록해줘야 해서, 이 방법은 좋지 않다.

ClassLoader 사용해서 nested class까지 추출하는 방법

RuntimeHintsRegistrar 이용해서 native-image-agent 스캔 대상으로 포함되도록 만드는 방법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import io.github.classgraph.ClassGraph

@Configuration  
@ImportRuntimeHints(ReflectionHintRegisterConfig.HintRegister::class)  
class ReflectionHintRegisterConfig {  
    class HintRegister : RuntimeHintsRegistrar {  
        override fun registerHints(hints: RuntimeHints, classLoader: ClassLoader?) {  
            val classes = getClassesInNestedPackages("dev.umbum.external")  
            val typeReferences = TypeReference.listOf(*classes.toTypedArray())  
  
            hints
                .reflection()  
                .registerTypes(typeReferences) {  
                    it.withMembers(  
                        MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,  
                        MemberCategory.INVOKE_DECLARED_METHODS,  
                        MemberCategory.DECLARED_FIELDS,  
                    )  
                }  
        }  
  
        private fun getClassesInNestedPackages(basePackage: String): List<Class<*>> {  
            val scanResult = ClassGraph()  
                .acceptPackages(basePackage)  
                .scan()  
  
            return scanResult.allClasses.map { it.loadClass() }  
        }  
    }
}
  • native-image-agent에서 스캔 할 때 dev.umbum.external 패키지 하위의 모든 클래스들을 포함하기 때문에 자동으로 .json이 생성되어 따로 관리 할 필요가 없는 방식.

현재 가능한 방법 중 최선으로 보인다.
native compile 하지 않는 경우에는 profile 이용해서 실행되지 않도록 할 수 있음.

기타 troubles

Mockito 호환 안됨

  • kotlin을 사용하고 있다면 어차피 mockk를 쓰고 있을거라 문제는 안됨.
  • 라이브러리 자체도 여러모로 mockk가 더 낫다.

SnakeCaseStrategy 생성자 못찾음

1
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)

사용하고 있다면 아래 에러 발생.

1
2
3
4
5
Caused by: org.springframework.beans.BeanInstantiationException:
  Failed to instantiate [com.fasterxml.jackson.databind.PropertyNamingStrategies$SnakeCaseStrategy]:
    No default constructor found
Caused by: java.lang.NoSuchMethodException:
  com.fasterxml.jackson.databind.PropertyNamingStrategies$SnakeCaseStrategy.<init>()

jackson reflection 설정 필요. reflect-config-additional.json에 추가

1
2
3
4
5
6
7
[  
  {    
    "name": "com.fasterxml.jackson.databind.PropertyNamingStrategies$SnakeCaseStrategy",  
    "allDeclaredConstructors": true,  
    "allPublicMethods": true
  }
]

java.lang.Enum$EnumDesc 못찾음

1
2
Caused by: java.lang.UnsupportedOperationException: Type not found: java.lang.Enum$EnumDesc<E>
	at kotlin.reflect.jvm.internal.impl.descriptors.runtime.structure.ReflectJavaClassifierType.getClassifierQualifiedName(ReflectJavaClassifierType.kt:41) ~[na:na]

reflection 설정 필요. reflect-config-additional.json에 추가

1
2
3
4
5
6
7
8
[  
  {  
    "name": "java.lang.Enum$EnumDesc",  
    "allDeclaredConstructors": true,  
    "allDeclaredMethods": true,  
    "allDeclaredFields": true  
  }
]

@field: 매핑 불가

1
2
@field:JsonProperty("free2")  
val free: BigDecimal,

사용하고 있다면 @field:는 제거해준다. 붙이면 아래 오류 발생.

1
2
Caused by: com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException:
  Instantiation of [simple type, class dev.umbum...dto.Account] value failed for JSON property free due to missing (therefore NULL) value for creator parameter free which is a non-nullable type

caffeineCacheManager 빈 생성 불가

1
2
3
4
2024-06-22 14:27:44.459  WARN 91946 --- [app] [           main] w.s.c.ServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: 
  org.springframework.beans.factory.BeanCreationException: 
    Error creating bean with name 'caffeineCacheManager': 
      Instantiation of supplied bean failed

@Profile 등에서 런타임 arg 인식 불가

  • 앱 시작 시점에 넘긴 arg로 분기하는 @Profile이나 @Conditional 계열 애너테이션은 동작하지 않음.
  • 다만 앱 시작 시점에 넘겼던 값들을, 빌드 시점에 미리 넘기는 것으로 변경하고, 아예 바이너리를 그 값으로 컴파일하면 되기 때문에 큰 문제는 아님.

자원 소모

 JITnative compile
CPU15%20%
RAM13%20%
  • native compile이 자원을 더 많이 사용한다.
  • 중간에 CPU 100% 친 것은 빌드

성능 비교

https://www.inner-product.com/posts/benchmarking-graalvm-native-image/ 참고

  • 제대로 warmup 된 이후의 JVM과 비슷한 것 같다. (직접 대충 테스트 해 보았는데 맞는 것 같다)
    • 왜 JIT compile이 최종 성능이 더 우수한가?
    • JIT compile은 runtime profiling 결과를 사용하기 때문에 더 효율적인 컴파일이 가능하기 때문일지도.
    • GraalVM이 아직 컴파일을 충분히 효율적으로 못하고 있는 것일 수도 있고.

GraalVM native image 지금 도입해도 될까? (24.06)

  • 시행 착오를 꽤 겪어야 해서 프로덕션에 사용하기에는 불안한 감이 있다.
    • 일단 마이그 하면서 다 수정해서 띄워놓으면 잘 동작하긴 한다만
    • 운영 중 예상치 못한 문제 발생 가능성이 있고,
    • 문제가 발생 했을 때 참고할 만한 레퍼런스가 많지 않다.
  • 빌드가 오래걸린다는 것은 확실한 단점.
    • 게다가 native binary라 dockerizing이 필수다.
    • 코드 수정 - 빌드 - 실행 사이클이 너무 오래걸려서, 개발+테스트는 JVM에서 하고 native image는 배포 할 때만 빌드해야 할 듯.
  • 벤치마크 결과를 보면, warmed up JVM > native image 이긴 하나…
    • 시스템 구석구석 모든 부분에 warmup을 하는건 거의 불가능한 일이기 때문에, 일반적인 상황에서의 성능은 native image의 손을 들어주고 싶다.
    • 다만 정말로 10ms 라도 응답시간을 줄이는게 더 중요한 비즈니스라면, warm up JVM이 최종 성능은 더 우수하니, warm up을 빡세게 하고 JVM 쓰는게 나아보인다.
This post is licensed under CC BY 4.0 by the author.