[Java/Spring] JVM Warm up

bbidag ㅣ 2024. 3. 25. 23:39

반응형

개요

JVM 위에서 동작하는 자바, 코틀린, 스프링 조합으로 웹 서버 개발을 하면서 각종 API와 의존성 라이브러리를 붙여나가면서 운영중인 서버를 배포해나가면 심심치 않게 발생하는 현상이 있다. 이는 서버 배포 후 초기 응답 시간이 지연되는 문제인데 이는 JVM의 구조적인 문제로 어쩔 수 없이 발생하는 문제인데, 우리의 클라이언트는 당연히 서버가 배포될 때마다 요청 응답이 느려지는 것은 싫어하기 때문에 개발자는 이를 해결해야 한다. 여기서는 컴파일 언어와 JVM 기반의 언어를 비교해서 구조를 알아보고 Spring에서 배포 초기 응답 시간을 개선하는 방법에 대해서 알아보자.

 

VM이 존재하지 않는 컴파일 언어의 동작 방식

C, C++, Golang, rust 등과 같은 컴파일 언어는 컴파일 과정에서 바로 기계어로 번역하고 실행 파일을 만들어낸다. 또한 컴파일 시에 최적화까지 진행해 처리 성능이 뛰어나다. 대신 생성된 기계어가 빌드 환경(CPU architecture)에 종속적이라, 이종간의 플랫폼에서는 실행이 불가능하고 새로 빌드해야 하는 단점이 있다.

 

JVM 언어의 동작 방식

자바는 기존의 컴파일 언어의 프로그램의 플랫폼 종속적인 문제를 해결하고자 JVM을 도입했다.

자바는 우리가 개발한 자바 코드(.java)를 컴파일하면 바이트 코드(.class)로 컴파일한다. 이는 컴퓨터에서 즉시 실행 가능한 기계어는 아니고 중간 단계의 언어이다. 이 바이트 코드를 각 OS에서 JVM에서 실행하면 실행 플랫폼과 상관 없이 VM 내에서 바이트 코드를 기계어로 번역해 코드를 실행하게 된다. 그래서 개발자는 각 OS와 상관없이 컴파일을 한번만 돌리면 돼서 빌드, 배포가 편해진다.

 

문제는 JVM에서 바이트 코드를 실행할 때, 기계어로 번역하는 작업 때문에 성능이 느려지게 된다. 그래서 이 문제를 해결하고자 JVM에서 JIT 컴파일러가 도입되었다. JIT 컴파일러의 목표는 빠른 컴파일 및 특정 환경에 맞춤화된 최적화를 제공하는 것이며, 이를 위해 프로파일 정보를 활용한다.

 

 

JIT 컴파일러가 도입된 이후에 자바 1.3 버전에서는  더 고도화한 Hotspot VM이 추가되었고 2개의 JIT 컴파일러가 포함되었다.

 

 

  • c1
    • 클라이언트 컴파일러(Client Compiler)
    • 코드 최적화는 덜하지만 즉시 시작되는 속도는 빠름
    • 즉시 실행되는 데스크톱 애플리케이션 등에 적합함
  • c2
    • 서버 컴파일러(Server Compiler)
    • 즉시 시작되는 속도는 느리지만 최적화는 많이 되어 warm-up 후에는 빠름
    • 장기 실행되는 서버 애플리케이션 등에 적합함

 

오래된 자바 버전에서는 c1, c2 컴파일러를 선택적으로 사용했지만 자바 8 이후의 자바에서는 기본적으로 모두 사용하게 된다.

 

Hotspot VM은 초기에 인터프리터로 최적화 없이 코드를 실행하지만, 실행 이후 각 메소드의 호출 여부를 추적하고 임계치를 넘어서면 C1 -> C2로 계층적으로 재컴파일해 코드 캐시에 올려 최적화를 한다. 이런 순차적인 컴파일 방식을 계층형 컴파일(Tiered Compliation)이라고 한다.

 

Hotspot VM은 많은 최적화를 통해, 계층형 컴파일을 통해 C2 컴파일러 레벨까지 최적화된 코드는 컴파일 언어를 능가하는 성능을 보여주기도 한다. 정리하자면 JVM에서 동작하는 프로그램은 개발자가 코드를 빌드하는 컴파일 시점에 성능이 정적 최적화되는 것이 아니라, 앱 실행 시점에 최적화되는 동적 최적화가 이뤄지는 것이다.

 

 

이제 이 내용을 보고 이해했다면, 웹 서버를 개발하고 배포할 때 초기 응답 시간이 지연되는 것은 앱 시작 초기 시점에 클라이언트의 요청과 관련된 클래스와 객체, 메소드들이 호출이 빈번하게 일어나면서 계층형 컴파일을 통해 코드 캐시에 올라가야 할 때까지의 최적화 시간이 필요하다는 것을 알 수 있다. Spring에서는 클라이언트의 요청이 유입되기 전에 어떻게 미리 warm-up을 할 수 있을까?

 

JVM Warm UP

이제 JVM 언어가 동작하는 매커니즘을 알았으니, 우리가 작성한 코드를 코드 캐시에 올려 배포 후 첫 유저의 요청을 더 빠르게 응답할 수 있도록 하는 방법에 대해 알아보자.

 

첫 번째 방법

이전 컨텐츠에서는 application event를 사용하는 방법에 대해 알아봤었다. 우리가 따로 개발하지 않고 스프링에서 기본적으로 제공하는 이벤트들이 있는데 그 중 하나인 ApplicationReadyEvent를 사용하면 웜업을 진행할 수 있다.

 

@Slf4j
@Component
public class MailEventListener {

    @Autowired
    private MailController mailController;

    @EventListener
    public void warmUp(ApplicationReadyEvent applicationReadyEvent) {
        log.info("테스트 웜업 시작");
        mailController.sendMail(new MailParameter("테스트 메일"));
        log.info("테스트 웜업 종료");
    }

}

 

애플리케이션 실행 결과

 

두 번째 방법

두 번째 방법으로는 애플리케이션 초기화 시에 사용할 수 있는 CommandLineRunner가 있다. CommandLineRunner를 Spring Bean으로 올리면 애플리케이션 컨텍스트를 초기화할 시점에 해당 러너가 실행되므로 우리가 개발한 코드를 임의로 호출시켜서 웜업을 할 수 있다. 참고로 CommandLineRunner는 프로그램 실행 아규먼트를 받아서 사용이 가능하다.

@Autowired
private MailController mailController;

@Bean
public CommandLineRunner test() {
    return args -> {
       Arrays.stream(args).forEach(log::info);
       log.info("테스트 웜업 시작");
       mailController.sendMail(new MailParameter("테스트 메일"));
       log.info("테스트 웜업 종료");
    };
}

 

세 번째 방법

 세 번째 방법으로는 애플리케이션 초기화 시에 사용할 수 있는 ApplicationRunner가 있다. ApplicationRunner는 CommandLineRunner와 똑같이 Bean으로 등록하고 웜업 코드를 실행할 수 있다.

@Autowired
private MailController mailController;

@Bean
public ApplicationRunner test() {
    return args -> {
        Arrays.stream(args.getSourceArgs()).forEach(log::info);
        log.info("테스트 웜업 시작");
        mailController.sendMail(new MailParameter("테스트 메일"));
        log.info("테스트 웜업 종료");
    };
}

 

 

JVM 웜업 전 후 응답 시간 비교하기

먼저 아주 간단한 컨트롤러, 서비스에 대해서 웜업 전/후의 성능을 비교해 보았다.

방법 호출 횟수 응답시간(ms)
warm up 미실행 1 122
warm up 미실행 2 9
wam up 실행 (100회) 1 103
wam up 실행 (100회) 2 7

음? 생각보다 웜업을 한 것과 안 한 것이 크게 차이가 나지 않는다.

 

컨트롤러로부터 시작해서 포함되는 로직에 코드가 복잡하고 많으면 좀 더 괜찮은 성능 차이가 날 수 있기는 하다.

 

하지만, 이런 간단한 실험에서의 차이는 spring boot MVC 요청 라이프사이클에 해당하는 전 영역의 코드 캐시가 로드되지 않아서이다.

요청 라이프 사이클은 대략 다음과 같다.

web filter -> dispatcher servlet -> handler mapping -> handler adapter -> controller -> service -> model -> view resolver -> view -> web filter

이 많은 순서의 영역에서의 객체가 모두 사용되지 않았으니 웜업을 해도 부족했던 것이다.

 

기존의 코드에서 서버 내에서 rest api client를 추가해서 서버 자기 자신의 API를 호출하는 예시로 바꿔보자.

@Bean
public ApplicationRunner test() {
    return args -> {
        RestClient restClient = RestClient.create();
        log.info("테스트 웜업 시작");

        ResponseEntity response = restClient.post()
                .uri("http://localhost:8080/mail")
                .body(new MailParameter("테스트 메일"))
                .contentType(MediaType.APPLICATION_JSON)
                .retrieve().toBodilessEntity();

        log.info("테스트 웜업 종료");
    };
}

 

결과를 다시 확인해보자.

방법 호출 횟수 응답시간(ms)
RestClient로 api 직접 실행해 웜업 1 8
RestClient로 api 직접 실행해 웜업 2 7

 

이제 클라이언트에서 배포 시점에 처음 API를 호출하더라도 이후의 API 호출과 거의 차이나지 않을 정도로 성능이 향상되었다.

 

마무리

애플리케이션이 실행되고 해당 웜업 코드를 실행시 따로 API 호출 유입이 차단되거나 하지 않으므로, 웜업을 충분히 수행하고 서비스를 시작하기 위해서는 k8s에서 readiness prove나 온프레미스 환경에서 Spring cloud Eureka Server + Client에 instance status를 수동으로 관리해서 자연스럽게 인스턴스가 투입되도록 해야 하는 것을 유의하자.

애플리케이션이 무거운 정도에 따라서는 각 로직의 웜업 횟수를 적절히 조절해서 실행해보고, 지표를 모니터링해서 적정한 정도를 찾아서 튜닝해 사용하면 되겠다.

 

출처

 

[Java] Hotspot VM의 한계(JIT, Just-In-Time 컴파일러)와 이를 극복하기 위한 GraalVM의 등장

이번에는 Hotspot VM의 한계와 이를 극복하기 위한 GraalVM에 대해 알아보도록 하겠습니다. 1. Hotspot VM과 JIT 컴파일러(Just-In-Time Compiler) [ C 언어의 동작 방식 ] C, C++, GoLang, Rust 등과 같은 컴파일 언어는

mangkyu.tistory.com

반응형