이 글을 작성하는 이유
현재 회사에서는 EKS 기반 k8s 환경에서 spring boot 를 포함 각종 프레임워크 애플리케이션을 운영 중이다.
기존 모노리스 Django Rest Framework 에서 점진적으로
도메인 분리를 Spring Boot 를 이용한 Micro Service Application 으로 진행 중인데,
내가 맡은 결제 도메인 관련 애플리케이션도 마찬가지이다. (Kotlin 기반 Spring Boot App)
문제는, 결제가 주문 및 유관 DB 와 너무 강하게 얽혀 있어서 바라보아야 하는 테이블이 많다는 점이고,
이것은 곧 배포 직후 속도에 커다란 영향을 끼쳤다.
JVM 은 컴파일 된 .class 파일을 필요 시 클래스 로딩을 통해 사용하며,
기본적으로 인터프리터 방식을 사용하므로 Jit Compiler 를 사용하기까지 여러 번의 호출이 필요하다.
이 것이 근본적인 문제다.
내가 최근에 개발했던 API 는 결제 내역 적재 API 이다.
하나의 API 로 거진 20개 가량의 테이블들을 read, write 하는데, 배포 직후 테스트를 하면 항상 40 ~ 50 초 대 (1분 넘는 것도 있었음..) 를 기록했다. => 뭔가 이상하다 생각했다.
처음엔 내 코드를 의심했고, Data Dog Span 을 통해 확인해본 결과 문제는 다른 부분에 있었다.
(물론 내 코드 또한 처음 JVM 메모리에 로드 되는데 시간이 걸렸다.)
인덱스를 타는 단순 조회 쿼리나, 간단한 Insert 쿼리 실행 latency 가 비정상적으로 길다는 것을 확인했다.
특히 조회 쿼리의 경우, 애플리케이션 코드에 적용하기 전, 항상 실행 계획을 통해 확인 후 작성을 한다.
JPA 를 사용하므로, 어쩌면 캐싱이 되지 않은 데이터를 조회하기 위해 디스크에 직접적으로 IO 진행하므로, 이 부분에 대해서 문제를 파악하게 되었고, hibernate 코드 또한 처음 인터프리터로 실행 후 메모리에 로드 되므로, 한 몫을 하리라 생각했다.
"WarmUp" 이란 개념을 시니어 개발자 분께 들었고,
API Controller 코드 부터 진행하면 좋겠지만 필요한 샘플 데이터가 방대하므로,
Latency 가 가장 큰 DB IO 단 Repository 부분만 웜업을 적용하기로 했다.
WarmUp 를 적용하는 과정
기본적으로, Spring Boot 기반 Micro Service 를 운영 중이면,
Spring 에서 제공하는 Actuator 를 사용하고 있을 것이다.
액츄에이터는 애플리케이션의 모니터링과 매트릭 (서비스의 성능 측정에 사용되는 항목이나 지표) 같은 기능을 http or JMX 엔드포인트를 통해서 제공한다.
=> 자세한 건 이 블로그를 참조해보라~
우리 Spring Boot Application 의 경우 actuator 를 활용한 지표를 요청하기 위해 필요한 설정을 아래와 같이 설정했다.
management:
endpoints:
web:
base-path: "/"
만약 health 상태를 확인하고 싶다면,
(도메인 호스트)/health
-> 요런 식으로 요청하면 된다~
K8s 를 활용 중이라면,
아래와 같이 StartupProbe 를 통해 Health check 를 진행할 수 있다,
Probe 란 컨테이너에서 kubelet 에 의해 주기적으로 수행되는 진단인데,
여러 Probe 를 통해 각 컨테이너의 상태를 주기적으로 체크 후 컨테이너의 재시작이나 문제가 있을 경우 서비스에서 제외 시킬 수 있다.
이 중, StartUp Probe 의 경우, 컨테이너의 애플리케이션이 시작 되었는지 나타낸다.
성공할 때까지 나머지 Probe 는 활성화 되지 않고, 실패하면, kubelet 은 컨테이너를 죽이고,
재시작 정책에 따라 처리가 된다.
각 Probe 가 궁금하다면?
https://velog.io/@hoonki/%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-Probe
=> 여기를 참고하자~
우리 StartUp Probe 설정의 경우 아래와 같다. (실제 설정이 아닌 예시이다 ㅎㅎ)
startupProbe:
httpGet:
path: /health/readiness
port: http
failureThreshold: 100
periodSeconds: 5
5초마다 한번 씩 실행 되는 StartUp Probe 는 100 번의 실패
즉, 500 초 동안 컨테이너가 시작 되는 동안 응답을 제대로 하지 않을 경우 실패한다.
이 엔드포인트에 웜업 코드를 적용할 것이다.
Warm Up 코드 적용
웜업을 적용하기 위해서는 Spring Framework 의 HealthIndicator 를 상속받아 구현해야 하는데,
@Component
class WarmupHealthIndicator(
private val warmer: Warmer,
) : AbstractHealthIndicator() {
override fun doHealthCheck(builder: Health.Builder) {
warmer.run()
if (warmer.isDone) {
builder.up()
} else {
builder.down()
}
}
}
abstract class ExactlyOnceRunWarmer : Warmer {
override var isDone: Boolean = false
private val mutex = Mutex()
override fun run() {
if (!isDone && mutex.tryLock()) {
try {
doRun()
setDone()
} finally {
mutex.unlock()
}
}
}
protected fun setDone() {
this.isDone = true
}
abstract fun doRun()
}
@Component
class PaymentCustomWarmer(
private val paymentDataWarmer: PaymentDataWarmer,
) : ExactlyOnceRunWarmer() {
override fun run() {
paymentDataWarmer.dataWarmUp() // 실제 웜업이 필요한 코드를 작성했다.
}
}
위와 같다.
Exactly Once Warmer 부분의 경우,
Line 공식 기술 블로그를 차용했다. (감사합니다..)
https://engineering.linecorp.com/ko/blog/apply-warm-up-in-spring-boot-and-kubernetes
=> 궁금하다면 참고~
이런식으로 진행하면 끝이다~ 간단하지 않은가?
(웜업 코드 노가다는 말 안해도 아시죠..?)
실제로 배포를 진행하였고,
Start Up Probe 가 실행 되며 웜업을 적용한 쿼리가 실행 되는 것을 확인할 수 있었다.
쿼리 실행이 완료 되면 실제로 트래픽을 받을 수 있게 된다.
결과
DataDog 을 통해 Latency 를 비교해보았다.
=> 웜업 적용 전 첫 요청.. 처참..
=> 웜업 적용 후 첫 요청, 확연하게 차이가 나는 것을 확인할 수 있다.
결론
실제로 Warm up 을 진행하면서,
k8s 의 Probe 개념을 더 명확히 하게 됨과 동시에, JVM 이 왜 얼마나 느린지 체감할 수 있었다.
현재 프로젝트를 진행하면서, 난이도는 가장 높음과 동시에, 배우는 게 정말 많다는 것을 느낀다.
앞으로 더 결제 관련 API 가 분리될 예정인데,
리소스 관련해서 더 문제에 봉착하게 될 것 같고 (ㅎㅎ) 공부를 더 진행하게 될 것 같다.
최근 주말 내내 작업하며 어려웠지만,
이번 기회에 훌륭한 자산을 얻게 되었고, 차후 동일한 문제에 직면한 JVM 기반 애플리케이션에 동일하게 적용하는 어드바이스를 줄 수 있을 것 같다!
'📕 Spring Framework' 카테고리의 다른 글
Spring Cloud OpenFeign 더 잘 사용해보기 (0) | 2024.05.11 |
---|