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
로 트리거 가능하다.
[!danger] native-image-agent가 runtime reachable code를 탐색하는 원리는, 실제로 애플리케이션을 실행하고 그 동안 호출된 class를 기록하는 방식이다.
그래서 애플리케이션 시작 시점에 호출되지 않는 class는 .json
으로 기록되지 않는다!
생각보다 너무 rough하다… ㅎ;
[!info] 리플렉션 대상 클래스가 다른 곳에서 일반적으로 사용되어 이미 컴파일이 되었다고 해도, .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
이 생성되어 따로 관리 할 필요가 없는 방식.
[!tip] 현재 가능한 방법 중 최선으로 보인다.
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
}
]
|
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
계열 애너테이션은 동작하지 않음. - 다만 앱 시작 시점에 넘겼던 값들을, 빌드 시점에 미리 넘기는 것으로 변경하고, 아예 바이너리를 그 값으로 컴파일하면 되기 때문에 큰 문제는 아님.
자원 소모
| JIT | native compile |
---|
CPU | 15% | 20% |
RAM | 13% | 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 쓰는게 나아보인다.