2. 웹개발/스프링부트_ETC

[스프링(부트)] Spring (Boot) 배포 직후 발생하는 지연 원인 및 JVM warm up 하기

갓대희 2024. 4. 8. 01:52
728x90

[스프링(부트)] Spring (Boot) 배포 직후 발생하는 지연 원인 및 JVM warm up

안녕하세요. 갓대희 입니다. 이번 포스팅은 [ JVM warm up 하기입니다. : )

 

 

 

 

 

 

※ 문제점 ( 웜업(Warm-Up) 적용 하게된 이유 )

 - 우리 사이트의 경우 서버 실행 직후, 배포 직후 속도가 느려지는 현상, 초기 응답 지연이 발생이 확인 되고있다.

ex) 배포 직후 관측되고 있는 문제

 

 - 이러한 현상은 was 기동 직후  첫 요청처리에 대해 오래 걸리기 때문인데,

   원인은 클래스 로더, JVM(Java.Virtual.Machine 의 줄임말)의 JIT 컴파일러, 콜드 스타트와 연관이 있다.

 

ex) 서버 1대 기준으로 확인 시 Restart 직후, 서비스 투입 직후 시점 발생 확인 가능

 

해당 문제를 해소하기위해 자동차의 예열과 비슷하게 JVM을 워밍업, Warm-Up 하려고 한다. :-)

Java 어플리케이션은 시작 과정에서 성능보장을 위해 warm up이 필요 하다고 볼 수 있다.

 

웜업 적용 전 왜 이런 문제가 발생하는지 원인에 대해, 간단히 살펴 보도록 하자.

 

1. Java의 동작 방식

 

 - 기존 Compile 언어들의 동작 방식도 간단하게 함께 살펴 보자.

 

컴파일 언어(C, C++, Rust, GO 등)의 동작 방식

 

 - 컴파일 과정에서 바로 기계어 생성(인터프리터), 컴파일 시에 코드 최적화까지 진행 한다.(Compile with Optimization)
 - 단점으로는 생성된 기계어가 빌드 환경(CPU 아키텍처)에 종속적이기 때문에 플랫폼 마다 빌드해야 한다.

 

Java의 동작 방식

 

 - Java는 흔히 알고있듯이 플랫폼 종속적인 문제를 해결하고자, 'Write Once, Run Anywhere'이라는 철학 하 JVM 도입 하였다.

 - 동작 방식
1) 작성된 소스 코드를 바이트 코드로 컴파일한다.(.class 파일 생성)
2) 이 바이트 코드는 JAR 또는 WAR로 아카이브하여 활용한다.
3) JVM은 아카이빙된 파일을 구동하는데, 실시간으로 바이트 코드를 기계어로 번역하면 CPU가 해당 기계어로 번역하여 실행하는데 이를 인터프리트라고 한다.

 

즉, 장점으로 이러한 구조 덕분에 Java는 플랫폼에 종속되지 않는다.
      하지만 단점으로, 코드를 실행할 때 바이트 코드를 기계어로 번역하는 작업이 필요하기 때문에 성능이 느려진다.
      이러한 문제를 해결하고자 JIT 컴파일러를 도입하게 되었다.

 

 - JIT이 도입되기 전에는 매번 클래스가 사용될 때 마다 기계어로 번역하였다. 이를 개선하기 위해 JIT(Just In Time) 컴파일러 개념이 생겼다. ( HotSpot JVM이 도입 되었다. )

이미지 : https://foojay.io/today/what-does-a-modern-jvm-look-like-and-how-does-it-work/

 

 

이번 글의 목적인 Tomcat 기동 직후의 지연 현상 개선을 위해선 ClassLoader와 JIT 컴파일러에 대해서 알아보아야 한다.

https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/ClassLoader.html

https://www.baeldung.com/java-jvm-warmup

 - 자세한 내용은 상기 내용을 참고 하였고, 최대한 간단하게 요약하여 살펴보자.

 

▶ Class Loader(클래스 로더)

 - 클래스 파일을 찾고, 동적으로 JVM에 로드 한다. (Dynamic Loading)
 - 필요할때 메모리에 로드해 실행 가능한 상태로 만드는 역할을 한다.

 

 - Class Loader는 하기 3가지의 역할을 담당한다.

1. Class Loading

JVM은 새로운 프로세스가 시작될 때마다 ClassLoader 인스턴스를 통해 필요한 클래스들을 메모리에 로드한다. 

( 다음 세 단계로 진행 된다. )

 

1.1) Bootstrap Class Loading : "부트스트랩 클래스" 로딩
JVM은 새로운 프로세스가 시작될 때마다 ClassLoader 인스턴스를 통해 필요한 클래스들을 메모리에 로드한다. 

 - JVM 기본 클래스와 Java 코드를 로딩
 - Java코드와 java.lang.Object 와 같은 필수 Java 클래스를 메모리에 로드한다.

   ( 로드된 클래스는 JRE\lib\rt.jar에 있다. )

 

1.2) Extenstion Class Loading : "확장 클래스" 로딩

 - 자바 핵심 라이브러리를 로딩 한다.

 -  ExtClassLoader는 java.ext.dirs 경로에 있는 모든 JAR 파일을 로딩하는 역할을 한다.

 - 개발자가 JAR을 수동으로 추가하는 비 Maven 또는 비 Gradle 기반 애플리케이션에서는 이 단계에서 해당 클래스가 모두 로드된다.

 

1.3) Application Class Loading : "애플리케이션 클래스" 로딩
 - 개발자가 직접 작성하여 classpath에 있는 클래스를 로딩 한다. 즉 애플리케이션 클래스 경로에 있는 모든 클래스를 로드한다.
 - 이 초기화 프로세스는 지연 로딩 방식 기반이다. 

   (로딩 된 클래스를 OS가 이해할 수 있는 기계어로 변환 (인터프리터))

※ 이 Lazy Loading Scheme / 지연 로딩 방식이 상기 지연 현상과 관련이 있다.

   ( 스프링 부트문제는 애플리케이션이 시작될 때에는 캐싱된 기계어가 없다는 것이다. 그래서 첫 요청이 오래걸린다. )

 

▶ JitCompiler - 핫스팟(Hotspot)

 - Java 1.2 부터 JIT(Just In Time) 컴파일러 개념이 생겼으며 Java 1.3 부터 HotSpot JVM이 도입 되었다.

 - 자주 실행되는 바이트 코드를 캐싱

   (자주 실행된다고 판단되는 특정 부분만을 기계어로 컴파일)하여 재사용(JVM 내의 code cache 영역에 저장) 하는 것이 목표이다.

 - 프로파일링 : 애플리케이션의 동작을 분석하고 코드 실행 횟수, 루프 반복 횟수, 메서드 호출 등의 정보를 측정하고 기록 한다.
   (결국 JIT 컴파일러가 자주 사용되는 코드는 캐싱하고 최적화하므로, 인터프리터로 번역해서 실행할 때보다 훨씬 빠르다.)

 

 

※ 하기 내용을 바탕으로 JitCompiler에 대해 조금 더 살펴보자.

https://www.baeldung.com/jvm-tiered-compilation

 - JIT 컴파일러는 자주 실행되는 섹션에 대해 바이트코드를 기본 코드로 컴파일한다고 하였다. 

   이러한 섹션을 핫스팟이라고 부르므로 Hotspot JVM이라는 이름이 붙었다.

 - 이를 통해 Java도 완전히 컴파일된 언어와 유사한 성능으로 실행될 수 있는데, 조금더 자세히 살펴본다면

   JVM에서 사용할 수 있는 JIT 컴파일러를 구성하는 두 가지 컴파일러(C1, C2 컴파일러)를 살펴보도록 하자.

 

1) C1 – 클라이언트 컴파일러

 - 가능한 한 빨리 코드를 최적화하고 컴파일하려고 하는 더 빠른 시작 시간에 최적화된 JIT 컴파일러 유형이다.

 - 하기에서 다시 살펴볼 내용이지만 특정 메서드가 C1 컴파일러의 임계치 설정 이상으로 호출되면, C1 컴파일러를 통해 제한된 수준으로 최적화되며, 컴파일된 기계어는 코드 캐시에 저장된다.

 

2) C2 – 서버 컴파일러

 - C2 서버 컴파일러는 전반적인 성능 향상을 위해 최적화된 JIT 컴파일러 유형이다.

 - C2는 C1에 비해 오랜 시간 동안 코드를 관찰하고 분석하여, 더 나은 최적화를 수행할 수 있다.

 - JIT Tiered Compilation : C2 컴파일러는 동일한 메서드를 컴파일하는 데 더 많은 시간과 메모리를 소비하는 경우가 많다. 하지만 C1에서 생성된 것보다 더 최적화된 네이티브 코드를 생성할 수 있다.

 

 - 다음 이미지를 통해 내용을 정리해 보자.

 - Interpreted and Profiled : 애플리케이션 시작 시 JVM은 처음에 모든 바이트코드를 해석하고 이에 대한 프로파일링 정보를 수집한다. 이후 JIT 컴파일러는 수집된 프로파일링 정보를 사용하여 핫스팟을 찾는다.
 - C1 Compiled and Profiled : 자주 실행되는 코드 섹션을 C1으로 컴파일하여 네이티브 코드 성능에 빠르게 도달한다. 나중에 더 많은 프로파일링 정보가 제공되면 C2가 시작된다.

 - C2 Compiled and Non-Profiled : 성능을 높이기 위해 보다 공격적이고 시간이 많이 걸리는 최적화로 코드를 다시 컴파일한다.

 

※ C1 : 성능을 빠른 시간 내에 향상 시킨다. vs C2 : 핫스팟에 대한 더 많은 정보를 기반으로 더 나은 성능을 향상 시킨다.

 

▶ JIT Tiered Compilation
 - 인터프리터, C1 컴파일러, C2 컴파일러를 통해 5가지 Level로 나눌 수 있다.

 - 이 세 가지 수준의 차이는 수행된 프로파일링의 양에 있다.

 

 --- Interpreted Code 영역 ---

1)  Level 0

 - 초기에 JVM은 모든 코드를 해석하며, 인터프리터를 통해 실행한다.

 - 이 단계는 많이 얘기 한것과 같이, 컴파일된 기계어를 실행하는 것보다 성능이 낮다.

 

 --- C1 컴파일러 영역  ---

1) Level 1 - Simple C1 Compiled Code

 - JIT 컴파일러가 사소하다, 단순하다고 판단한 메서드에 대해 사용된다.

 - 메서드들의 복잡성이 낮아, C2 컴파일러로 컴파일한다고 하더라도 성능이 향상되지 않는다.

 - 따라서 더 이상 최적화할 수 없는 코드에 대한 프로파일링 정보를 수집하는 것이 의미가 없다는 결론을 내린다.

 - 그렇기 때문 이 수준에서 JVM은 C1 컴파일러를 사용하여 코드를 컴파일하지만 프로파일링 정보는 수집하지 않는다.


2) Level 2 - Limited C1 Compiled Code

 - JVM은 라이트 프로파일링과 함께 C1 컴파일러를 사용하여 코드를 컴파일 한다.

 - C2 컴파일러 큐가 꽉 찬경우 실행된다.

 - 목표는 가능한 한 빨리 코드를 컴파일하여 성능을 향상시키는 것이다.


3) Level 3 - Full C1 Compiled Code

 - JVM은 전체 프로파일링과 함께 C1 컴파일러를 사용하여 코드를 컴파일합니다.

 - 사소한, 간단한 메서드나 컴파일러 큐가 가득 찬 경우를 제외한 ( Level1, Level2를 제외한) 모든 경우 에 이를 사용한다.

 

 --- C2 컴파일러 영역  ---
1) Level 4 - C2 Compiled Code

 - 장기적인 성능을 위해 C2 컴파일러가 최적화를 수행한다.

 - Level 4에서 최적화된 코드가 완전히 최적화된 것으로 간주되면, 더이상 프로파일링 정보를 수집하지 않는다.

 - 그러나 코드를 최적화 해제하고 레벨 0으로 다시 보내기로 결정하는 패턴도 존재 한다.

 

※ 레벨에 대한 임계값 설정이 가능 하다.
 - 컴파일 임계값은 코드가 컴파일되기 전의 메서드 호출 횟수 이다.

 - JIT Tiered Compilation의 경우 컴파일 Lvel2 - 4에 대해 이러한 임계값을 설정할 수 있다.

ex) 다음과 같이 매개변수를 설정할 수 있습니다 .

 XX:Tier4CompileThreshold=10000

 - 특정 Java 버전에서 사용되는 기본 임계값을 확인하려면 -XX:+PrintFlagsFinal 플래그를 사용하여 Java를 실행할 수 있다.

 

※ 계.속. 반복되어 나오는 문구들이 보이는데

    결국 위에서 말한 붉은 글씨의 2가지 포인트가 이번 warm-up에 중요한 내용이다.

 - JIT Compiler는 바이트코드를 캐싱 >  런타임 환경에 맞춰 수행(지연로딩) > 반복된 작업에 대해 더욱 성능이 올라간다.

 - 즉, Tomcat 기동 후 대고객 투입 전 의도적으로 JVM을 warming-up 하여 캐싱하는 절차가 필요하다.

 

2. JVM Warm up 방법

 

 - JVM JIT Tiered Compilation 레벨에 대한 임계값 설정이 가능 하다. 하지만 이번엔 채택하지 않았다.

 - warm-up 방법에는 여러가지가 있고, 흔히 JMH(Java Microbenchmark Harness) OpenJDK에서 만든 벤치마크 라이브러리를 사용하여 warm-up하는 방법을 많이 예시로 보여주고 있었다.

   ( JVM warm-up 기능을 제공하여 편리하게 성능 측정또한 할 수 있다. )

 

 - 우리의 경우 SpringBoot의 환경이었고 "ApplicationReadyEvent"를 사용하여 직접 초기화 코드를 작성하여

   주요 API를 호출하여 warm-up하는 방식을 택했다.

 

ex) 예시 코스 코드 ( https://github.com/shelltea/warmup-spring-boot-starter/blob/main/src/main/java/io/github/shelltea/warmup/WarmUpListener.java )

package io.github.shelltea.warmup;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext;
import org.springframework.context.ApplicationListener;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.math.BigDecimal;

@Component
@Slf4j
public class WarmUpListener implements ApplicationListener<ApplicationReadyEvent> {
    @Autowired
    private ServerProperties serverProperties;
    @Value("${management.endpoint.warm-up.enable:true}")
    private boolean enable;
    @Value("${management.endpoint.warm-up.times:5}")
    private int times;

    private static WarmUpRequest body() {
        final WarmUpRequest warmUpRequest = new WarmUpRequest();
        warmUpRequest.setValidTrue(true);
        warmUpRequest.setValidFalse(false);
        warmUpRequest.setValidString("warm up");
        warmUpRequest.setValidNumber(15);
        warmUpRequest.setValidBigDecimal(BigDecimal.TEN);
        return warmUpRequest;
    }

    @Override
    public void onApplicationEvent(ApplicationReadyEvent event) {
        if (enable && event.getApplicationContext() instanceof ServletWebServerApplicationContext) {
            AnnotationConfigServletWebServerApplicationContext context =
                    (AnnotationConfigServletWebServerApplicationContext) event.getApplicationContext();

            String contextPath = serverProperties.getServlet().getContextPath();
            int port = serverProperties.getPort() != null && serverProperties.getPort() != 0 ?
                    serverProperties.getPort() : context.getWebServer().getPort();

            if (contextPath == null) {
                contextPath = "";
            }

            final String url = "http://localhost:" + port + contextPath + "/actuator/warm-up";
            log.info("Starting warm up application. Endpoint: {}, {} times", url, times);

            RestTemplate restTemplate = new RestTemplate();

            for (int i = 0; i < times; i++) {
                ResponseEntity<String> response = restTemplate.postForEntity(url, body(), String.class);
                log.debug("Warm up response:{}", response);
            }

            log.info("Completed warm up application");
        }
    }
}

 

ex) 호출 대상 api 선정

1) 주요 페이지에서 사용하고 있는 API
 - 특히 고객 진입점이 되는 페이지에서 고객 체감 속도 저하 방지를 위해
 - 주요 페이지 대상 : 메인, 상품상세, 검색, 장바구니, 주문서, 마이페이지, 기획전 등


2) 호출 빈도가 높고, 응답 속도가 느리며 CPU 사용율이 높은 API

 - 두번째 호출 부터는 응답시간, cpu 사용율도 낮아 진다.

 

ex) 대상 api 선정 과정 및 개선 포인트 도출

 

 - 초기 호출시에는 다음과 같이 ClassLoader가 바이트 코드를 기계어로 번역하면서 CPU 사용율도 올라가는 것을 볼 수 있었다.

 

3. JVM Warm up 적용 전 / 후 Test

 

Test1) - 실제 warm-up 전 / 후 효과를 확실하게 POC, 증명하기 위해 기존 운영 환경에서 발생하던 문제상황을 재현 해 보았다. 

( 어느정도 유사한 문제현상을 재현할 수 있었다.)

기존 운영환경 테스트 환경 (부하강제 발생 중 Tomcat 재기동)

 

Test2) 동일하게 계속 부하를 발생시킨 후 웜업을 수행(웜업 수행중 외부 트래픽 차단(부하 테스트 차단))하고 효과를 관찰 해 보았다.

 - 웜업을 1회만 수행하여도 어느정도 효과가 있어 보였다.

 

Test3) 하기 참고하였던 자료 (카카오 발표)를 통해 JVM이 어느정도 최적화 할 수 있게 

              ( 다음과 같은 핫스팟, JIT의 컴파일 방식을 활용 )

              웜업을 좀더 충분히 수행 한 후 테스트 해보았다. 

 

ex) 참고 >>>

   핫스팟의 단계별 컴파일 방식 : 인터프리터 모드로 -> 단순한 C1 컴파일 형식 -> 다시 이를 C2 고급 최적화를 수행하는 방식.
   레벨 0: 인터프리터
   레벨 1: C1 - 풀 최적화(프로파일링 없음)
   레벨 2: C1 - 호출 카운터 + 백엣지 카운터
   레벨 3: C1 - 풀 프로파일링
   레벨 4: C2

 

ex) 테스트(강제 부하 발생) 환경하

 - 웜업을 N회 수행 후 지연 API 및 안정화 정도 관찰결과 약 10회 수행시 1회 수행보다 효과가 있음을 가시적으로 확인

 

 - 어느 정도 수행함에 따라 응답시간이 차츰 안정화 되는것을 볼 수 있었다.

 

※ 시간이 어느정도 주어진다면 충분히 warm-up을 해 주는것이 도움이 될 것으로 보여, 짜두었던 wam-up 스크립트를 10회 정도 수행하려고 한다.

다만, 너무 오래 수행한다면 대고객 투입시 까지 시간이 오래 걸릴 수도 있다.

 

4. 실제 운영 환경에서의 Warm up 적용

 

 

 - 최종적으로 운영 환경에서의 테스트를 완료한 후 운영 투입하여 적용 효과도 확인 해 보았다.

1. was 재기동 직후 비교

 - 새벽 시간대 : 트래픽이 상대적으로 적은 오전 7시 배포 시

 

1) 전체 그래프 확인

 - case1) 트래픽이 적은 정기 배포 (오전7시 시간때 예시)

 

 - case2) 트래픽이 많은 11시 비정기 배포때의 예시 ( 좌측 : 웜업 적용 전 / 우측 : 웜업 적용 후)

 

 

2) 1대씩 비교

 -정기 배포시 (트래픽이 상대적으로 적은)

웜업 전

 

웜업 후

 

 - 비정기배포(트래픽이 상대적으로 많은)시

 

 

※ 하기 내용을 참고하고, 응용하고 활용하여 현재 근무환경에서의 warm-up을 적용할 수 있었다.

 

https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/ClassLoader.html

https://www.baeldung.com/java-jvm-warmup

https://www.youtube.com/watch?v=CQi3SS2YspY

https://github.com/steinsag/warm-me-up

https://github.com/shelltea/warmup-spring-boot-starter/blob/main/src/main/java/io/github/shelltea/warmup/WarmUpListener.java

300x250