예전에 테스트 환경에서 쿠버네티스 환경에서 어플리케이션을 배포한 후 첫 요청을 보낼 때의 기억이 아직도 떠오른다.
워커 노드의 스케일 업도 하지 않은 상태라 Pod의 CPU / Memory 모두 현저히 스펙을 낮춰서 배포를 했었는데, 그것을 감안하더라도 첫 요청에 대해 응답 시간이 10초가 넘어가는 것을 보고 충격을 금치 못하였다.
물론 그 이후 워커 노드와 Pod의 리소스 모두 스케일 업을 하였지만 그럼에도 첫 요청에 대한 응답 시간이 6~8초를 넘어서는 경우가 많았고 로딩 스패너는 하염없이 돌기만 했다.
그나마 Python, Flask 기반 어플리케이션은 사정은 나았지만 유독 Java, Spring 기반 어플리케이션이 첫 요청에 대한 딜레이가 상당했는데 첫 요청 이후에는 응답 시간이 상당히 빨라지긴 하지만 어쨌든 배포를 한 후의 첫 사용자는 큰 불편을 겪을 것이라는 사실은 자명했다.
이후 개발 일정이 타이트해서 언젠가 고쳐야겠다는 마음을 먹은 지 몇 개월이 지나고, 어느 정도 인프라가 구축이 된 이후 이 문제에 대해 단순 스케일 업만으로 해결할 사항은 아니라고 판단되어 문제의 원인과 해결 방법에 대해 알아보기 시작했는데 다행히 이런 문제는 Java, JVM 기반 어플리케이션에서 반드시 겪게 되는 일이었고 레퍼런스가 많이 나와 있어서 금방 문제의 원인과 개선 방안을 찾아낼 수 있었다.
결론부터 먼저 이야기하자면, 유독 Java 기반의 어플리케이션이 첫 요청에 한해 응답 속도가 굉장히 늦어지는 이유는 JVM, JIT의 특성 때문이며 이 문제를 해결하기 위한 여러 가지 방법 중 대표적인 방법으로 JVM Warm-UP을 많이 사용한다는 것을 알게 되었다.
Java 파일은 기본적으로 javac (자바 컴파일러) 를 통해 바이트 코드로 변환되며 이 바이트 코드는 JVM 위에서 기계어로 번역되는데 초기 이 방식이 속도가 느리다는 단점이 있었다고 하며 이후 JIT (Just In Time Compiler) 이 도입되어 속도가 개선되었다고 한다. (Java 8 이후부터는 JIT의 C1, C2 컴파일러가 모두 사용된다고 한다)
JIT는 JVM 내부에 존재하며 초기 컴파일 이후 바이트 코드의 프로파일링 정보 및 코드 내 메서드의 호출 회수를 주시하면서 자주 호출되는 메서드에 대해 추가적인 최적화 / 컴파일을 진행하며 (c1 -> c2 컴파일러) 이렇게 최적화된 코드를 코드 캐시에 저장한다.
즉, 초기에 많이 호출되는 (=많이 사용되는) 코드에 대한 최적화 및 캐싱을 해주는 것이 JIT의 주요한 역할이라고 할 수 있다.
다만 코드 캐시는 크기가 한정되어 있다고 하며 이로 인해 모든 코드를 JIT로 최적화할 수 없어 최적화되지 않은 코드들은 여전히 인터프리터에 의해 실행 단계에서 기계어로 번역되므로 초기 요청 속도가 느릴 수 밖에 없다.
JVM Warm-UP은 JVM을 예열한다는 의미와 비슷하게 사용자가 실제로 코드를 호출하기 전에 미리 어느 정도 최적화를 진행시켜놓는 일련의 작업을 의미한다.
위에서 JIT는 기본적으로 바이트 코드에 대한 프로파일링 정보와 메서드의 호출 회수를 주시하면서 코드 내에서 자주 사용되는 메서드를 기준으로 최적화를 진행한다고 설명했는데, JVM Warm-UP은 필요한 API나 기능과 관련된 메서드를 인위적으로 많이 호출해서 어느 정도 JIT가 미리 컴파일을 해둘 수 있도록 유도한다.
예전 기준 레퍼런스를 보면 ApplicationEvent를 통해 어플리케이션 실행 시점에 Warm-UP 코드를 작동시키는 경우나 Spring Boot Actuator의 커스텀 엔드포인트와 연계하는 것이 많이 나와 있었는데, Kubernetes의 경우 기본적으로 startupProbe, readinessProbe와 같이 컨테이너가 정상적으로 실행되었고, 요청을 받아들일 준비가 되었는지를 체크해주는 기능을 지원하고 있기 때문에 이와 연계하여
배포 -> startupProbe + JVM Warm-UP으로 중요 API / 기능 최적화 및 컨테이너 정상 실행 여부 체크 -> readinessProbe로 실제 요청을 받아들일 준비가 되어있는지 체크
와 같은 흐름을 자연스럽게 유도할 수 있다.
간단하게 livenessProbe, readinessProbe 체크 API를 제공하던 Rest Controller에 JVM Warm-UP 기능을 하는 API를 추가한다.
이 API는 실행되어야 하는 자기 자신의 API를 RestTemplate이나 WebClient, 또는 FeignClient를 통해 호출하며 이를 for문으로 특정 횟수만큼 반복한다.
필자의 경우 이미 마이크로서비스 간 내부 통신을 위한 FeignClient를 사용하고 있었기 때문에 별도의 RestTemplate, WebClient를 사용하지 않고 FeignClient를 사용하였다.
// Warm-UP용 FeignClient
@FeignClient(name = ${spring.application.name})
public interface WarmUpClient {
// 로그인 API, 실제로 존재해야 함
@PostMapping("/api/v1/login")
void login(@Valid @RequestBody LoginRequestDTO dto);
....
}
// probe 체크용 Rest Controller
@RequiredArgsConstructor
@RestController
public class ProbeController {
private final WarmUpClient client;
...
@GetMapping("/api/v1/internal/probe/warm-up")
public String warmUp() {
// 10회 반복
for (int i = 0; i < 10; i++) {
// 로그인 API 호출
LoginRequestDTO dto = new LoginRequestDTO();
// ... dto 세팅 (필요할 경우)
client.login(dto);
...
}
return "ok";
}
}
그리고 해당 어플리케이션의 Deployment 명세에서 startupProbe 부분을 다음과 같이 설정한다.
참고로 여기서 initialDelaySeconds, periodSeconds, timeoutSeconds는 실제 배포 후 어플리케이션이 언제 작동되는지, Warm-UP을 수행하는데 걸리는 시간이 얼마나 걸리는지를 확인한 후 최적화하는 것이 좋다.
spec:
template:
spec:
contatiners:
#... 다른 설정 부분
startupProbe:
initialDelaySeconds: 120 # startupProbe 초기 시작 딜레이 시간
periodSeconds: 60 # startupProbe 확인 주기
timeoutSeconds: 60 # startupProbe 실패 timeout 시간
httpGet:
port: 9082
path: /api/v1/internal/probe/warm-up
#...
위의 예시대로라면 컨테이너가 배포된 후 2분 뒤부터 1분 간격으로 JVM Warm-UP을 진행하며 1분 내로 성공하지 않을 경우 기본적으로 총 3번 실패할 때까지 반복 실행하며 3번이 모두 실패할 경우 startupProbe가 실패하여 컨테이너가 실패 상태로 처리된다.
JVM Warm-UP을 적용하지 않은 상태에서 로그인 API는 첫 요청 시 응답 시간이 약 6초~8초 사이로 측정되었다.
반면, JVM Warm-UP을 간단히 적용한 상태에서는 첫 요청 시 응답 시간이 약 2초로 줄어들었다.
로그인 API 말고도 다른 API에 JVM Warm-UP을 적용해 본 결과 평균적으로 적용하지 않았을 때 대비 50% 이상 시간이 감소하는 경향을 보였다.
Warm-UP의 경우 호출하는 API 및 기능이 많아질수록, 그리고 호출 횟수가 많아질수록 작업 시간이 길어지며 이는 서비스 배포의 시간이 길어짐을 의미하게 된다.
또한, 외부 API와 연동된 API 또는 기능을 Warm-UP할 경우 단기간 내의 대량 호출로 비정상적인 트래픽 또는 DDoS 공격 등으로 간주될 수 있으므로 주의가 필요하다.
그리고 실제 사용되는 API를 호출하는 것이기 때문에 단순히 데이터를 조회하는 API면 몰라도 DB를 건드리거나 외부 서비스에 영향을 주는 API를 호출할 경우 어떻게 처리를 할 것인지, API에 대한 인증/인가 처리는 어떻게 할 것인지 등도 잘 처리를 해야 한다.
추가로 필자의 경우 Spring Cloud Kubernetes LoadBalancer, Spring Cloud Kubernetes Discovery Client, Spring Cloud OpenFeign을 통해 Warm-UP에 필요한 API를 호출하였는데 간혹 Spring Cloud LoadBalancer 기본 설정으로 인해 요청이 실패한 인스턴스를 회피하려다 인스턴스를 최신화할 수 없다는 이유로 Warm-UP이 제대로 되지 않는 경우도 있었다. (spring.cloud.loadbalancer.retry.avoid-previous-instance
옵션, 기본값은 true)
그 밖에 호출 횟수를 늘려보며 응답 시간이 더 빨라지는지를 테스트해보았으나 호출 횟수를 단순히 조금 더 늘린다고 해서 시간이 눈에 띄게 감소되거나 그러지는 않았으며 추가적인 최적화 방안을 더 찾아봐야 할 것으로 보인다.
https://engineering.linecorp.com/ko/blog/apply-warm-up-in-spring-boot-and-kubernetes
https://www.javatpoint.com/jit-in-java
https://mangkyu.tistory.com/343