개발놀이터

클래스 로더 warm up / JIT 컴파일러 warm up 본문

배포/CI , CD

클래스 로더 warm up / JIT 컴파일러 warm up

마늘냄새폴폴 2023. 7. 7. 23:37

이번 포스팅에선 warm up에 대해서 알아보도록 하겠습니다. 자바에선 JVM의 존재 때문에 항상 느린 언어 취급을 받아야 했습니다 (실제로도 느린건 사실입니다). 

 

하지만 그런 느린 언어라는 누명을 벗기위해 다양한 노력을 시도했습니다. 언어적으로 노력한 부분도 있고 자바를 사용하는 개발자들의 꼼수(?)로 자바는 생각보다 컴파일언어 (C, C++, Rust) 와 견줄만한 속도를 가지게 되었습니다. 

 

우리는 이번 포스팅에선 warm up 이라는 개념이 왜 등장했는지 클래스 로더와 JIT 컴파일러의 관점에서 알아보고, warm up을 어떻게 진행하는지 방법에 대해서 알아보도록 하겠습니다. 

 

클래스 로더

자바에서는 클래스를 읽어오기 위해 클래스 로더를 이용합니다. 클래스 로더는 클래스 파일을 찾고, 메모리에 로드해 실행 가능한 상태로 만드는 역할을 합니다. 

 

클래스 로더는 아래와 같은 작업을 통해 동작합니다. 

 

  • Class Loading : 클래스 파일을 가져와 JVM 메모리에 적재합니다. 이 단계도 JVM 기본 클래스와 자바 코드를 로딩하는 Bootstrap Class Loading, 자바 핵심 라이브러리를 로딩하는 Extension Class Loading, 개발자가 직접 작성한 classpath를 로딩하는 Application Class Loading으로 나뉩니다. 
  • Class Linking : 클래스가 참조하는 다른 클래스, 메서드, 필드 등을 확인하고 필요하면 메모리 상에서 연결하는 단계입니다. 이 단계도 크게 Verification, Prepare, Resolution 단계로 나뉩니다. 
  • Class Initialization : 클래스 변수를 초기화 하거나 static 블록 내의 코드를 실행하는 등의 클래스 초기화 작업을 수행합니다. 

이런 자세한 부분까지는 알 필요 없고 그냥 클래스 로더가 동작하기 위해서는 많은 자원이 소모되는 큰 작업이라는 것만 아시면 됩니다. 

 

실제 애플리케이션에 있는 모든 클래스를 클래스 로더를 통해 JVM에 올리게 되면 굉장한 부하가 생기기 때문에 클래스 로더는 독특한 방식을 사용합니다. 

 

바로 Lazy Loading 이죠 지연 로딩이라고도 불리는 이 작업은 JPA를 공부해보셨다면 한번쯤 들어보셨을 익숙한 단어일 것입니다. 

 

간단하게 설명해서 JVM에서 모든 클래스를 한번에 로드하는게 아니라 필요할 때마다 로드해서 성능을 최적화시켰다는 얘기입니다. 

 

이 말은 맨 처음 호출할 때는 Latency가 발생할 수 있다는 얘기입니다. 

 

 

JIT 컴파일러

왜 클래스 로더 얘기하다가 갑자기 JIT 컴파일러로 넘어왔나 싶을 수도 있습니다. 하지만 JIT 컴파일러 역시 클래스 로더와 비슷한 문제를 겪고 있기 때문에 바로 다음에 소개해보도록 하겠습니다. 

 

우선 JIT 컴파일러가 뭔지 간단하게 설명하고 자세히 들어가도록 하죠. 

 

JIT 컴파일러는 자주 실행되는 부분의 코드를 바이트코드에서 네이티브 코드로 컴파일합니다. 이렇게 자주 실행되는 부분을 HotSpot이라고 부릅니다. 

 

때문에 이런 JIT 컴파일러를 통해 자바는 컴파일 언어들 (C, C++, Rust) 와 비슷한 성능을 보여줍니다. 

 

간단하게 설명하면 JIT 컴파일러가 없는 인터프리터 언어는 영어 원서를 한줄 한줄 해석해서 읽는 것이고 JIT 컴파일러가 있는 인터프리터 언어는 번역본을 읽는 속도입니다. 

 

여기까지는 기본적인 얘기였고 여기까지만 아셔도 사실 상관 없습니다. 더 자세한 내용은 아래의 링크를 확인해주세요!

 

https://coding-review.tistory.com/123

 

JVM (Java Virtual Machine)

위 그림은 자바 코드의 실행 과정을 간략하게 보여준다. 프로그램이 실행되면 JVM은 OS로부터 프로그램이 필요로하는 메모리를 할당 받는다. (JVM은 이 때 메모리를 용도에 따라 여러 영역으로 나

coding-review.tistory.com

 

이제 좀 딥한 내용으로 들어가보죠

 

C1 컴파일러, C2 컴파일러

JIT 컴파일러는 C1 컴파일러 C2 컴파일러로 나뉘어져 있습니다. 

 

C1 컴파일러는 클라이언트 컴파일러라고도 불리고 JIT 컴파일러가 시작되는 초반에 성능을 끌어올리기 위해 만들어진 컴파일러입니다. 

 

C2 컴파일러는 서버 컴파일러라고도 불리고 JIT 컴파일러의 전반적인 성능을 최적화하기 위해 만들어진 컴파일러입니다. C2 컴파일러는 C1에 비해 더 오랜 기간 남아있는 코드들을 관찰하고 분석합니다. 이는 C2를 더 나은 컴파일된 코드로 최적화합니다. 

 

JIT 컴파일러는 Tiered Compilation 이라고 부르는 단계를 거쳐서 성능을 최적화합니다. 

 

애플리케이션이 시작할 때 JVM은 초기에 모든 바이트코드를 해석하고 이 바이트코드르에 대한 프로파일링 한 정보를 모읍니다. 

 

처음에 JIT 컴파일러가 자주 실행되는 코드들을 C1 컴파일러가 컴파일해서 빠르게 성능을 끌어올리고 이후 C2가 더 자주 사용되는 코드들을 모아서 다시 재컴파일합니다. 그렇게 함으로써 성능이 한 단계 더 끌어올려지는 것이죠. 

 

 

여기서 드는 의문은 얼마나 자주 사용되어야 C1, C2 컴파일러가 반응할까? 에 대한 내용입니다.

 

https://if.kakao.com/2022/session/35

 

if(kakao)dev2022

함께 나아가는 더 나은 세상

if.kakao.com

 

위의 링크는 카카오에서 겪었던 배포 후 Latency 문제를 해결한 내용입니다. 해당 영상에서는 C1 컴파일러는 250회 반복되었을 때, C2 컴파일러는 1000회 반복되었을 때 반응했다고 발표했습니다. 

 

 

Warm Up

클래스 로더와 JIT 컴파일러의 공통점에 대해서 눈치채셨나요? 

 

바로 어느정도 시간이 있어야 본 성능을 보여준다는 것입니다. 

 

클래스 로더의 지연 로딩, JIT 컴파일러의 최소 250회 반복조건은 애플리케이션이 초기에 배포되는 시점에 큰 장애물이 됩니다. 

 

위의 카카오 If 카카오 발표에서 문제에 대한 분석을 말해주는데 CPU, 메모리, 외부 데이터베이스 호출 어떤 곳에서도 오버헤드가 발생하지 않았다는 내용이 나옵니다. 

 

때문에 카카오 모빌리티 팀은 애플리케이션 내부에 문제가 있다고 판단, JVM의 문제로 귀결시켰죠. 

 

결국 JVM의 존재 때문에 한번 배포가 진행된 다음엔 제 성능을 보여주지만 맨 처음엔 10초가까이 되는 Latency가 발생했다는 것이 카카오 모빌리티 팀의 내용입니다. 

 

때문에, 배포를 할 때 warm up 의 중요성에 대해서 한번 더 강조됩니다. 

 

빠르게 뛰어야하고 폭발적인 근육을 사용하는 단거리 운동에서 러닝이나 가벼운 근력운동으로 근육을 예열시키는 것이 warm up에 해당하는 내용이라고 할 수 있습니다. 

 

JVM도 이와 마찬가지로 클래스 로더의 지연 로딩, JIT 컴파일러의 반복 조건을 만족하기 위해 배포되기 전에 warm up 하는 과정이 필요합니다. 

 

이제 각각 어떻게 warm up 하는지에 대해서 알아보죠. 

 

클래스 로더 warm up

클래스 로더를 warm up 하기 위해서 꽤 오랜 시간이 걸리는 로직을 준비했습니다. 

 

바로 100만행의 데이터를 풀스캔해 맨 마지막 100만번째 데이터를 JPA로 호출하는 것입니다. 클래스 로더를 warm up 하지 않고 순수한 지연 시간을 확인해보도록 하겠습니다. 

 

참고) 지연시간 확인은 스프링의 StopWatch를 사용했습니다. 

 

100만행 기준 맨 마지막 로우를 검색한 결과 0.03초라는 꽤 긴 시간이 걸렸습니다. 

 

이제 클래스 로더를 예열시켜봅시다. 

 

@Component
@RequiredArgsConstructor
@Slf4j
public class WarmupRunner implements ApplicationRunner {

    private final ItemRepository itemRepository;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        try {
            itemRepository.findById(1L);
        } catch (Exception e) {
            log.error("Warm Up Error");
        }
    }
}

스프링에선 간단한 방법으로 예열을 시켜줄 수 있는데요. 바로 ApplicationRunner를 구현하는 것으로 예열할 수 있습니다. 

 

이 구현체의 run 메서드에선 스프링이 가동될 때 한번 실행해주는 것이 전부입니다. 이렇게 한번 실행하게 되면 지연 로딩이었던 클래스가 JVM에 로드되면서 속도를 높일 수 있죠. 

 

이제 다시 테스트해보도록 하겠습니다. 

 

자세히 보시면 JPA 로그가 두개 찍힌 것을 볼 수 있습니다. 처음 스프링이 로드될 때 해당 메서드를 한번 실행함으로써 JVM에 클래스를 로드하였다는 증거입니다. 

 

그리고 실제로 실행했을 때는 앞서 봤던 0.03초보다 훨씬 상회하는 0.002초로 약 150배 성능이 향상된 것을 볼 수 있습니다. 퍼센트로 본다면 약 6퍼센트의 성능 향상이 있었습니다. 

 

 

JIT 컴파일러 warm up

JIT 컴파일러의 warm up은 JIT 컴파일러의 최소 반복 조건인 250회를 반복하는 것이 우리의 최종 목표입니다. 

 

그렇다고 애플리케이션 내부적으로 250회 반복문을 돌리는 것은 의미없습니다. JVM의 부하만 있을 뿐이죠. 

 

왜냐하면 JVM과 JIT 컴파일러는 실제 workload만 최적화하기 때문입니다. JVM과 JIT 컴파일러는 프로그램의 실제 실행을 모니터링하고 실제로 사용되는 코드들만 최적화를 진행합니다. 

 

그렇기 때문에 애플리케이션 내부에서 여러번 실행하는 것은 의미가 없습니다. 직접적으로 JIT 컴파일러의 warm up을 진행하고 싶다면 리눅스의 curl 명령어를 통해 실질적인 API 호출이 있어야 warm up 효과를 받을 수 있습니다. 

 

때문에 이를 해결하기 위해선 Jenkins와 같은 CI 자동화 툴에서 파이프라인을 구축해 warm up 을 진행하는 것이 올바른 방법입니다. 

 

하지만 여기서 의문이 들 수 있습니다. 

 

애플리케이션이 완전히 뜨고 warm up을 해야하는데 그러는 도중에 사용자의 요청이 들어오면 CPU가 터질듯이 부하가 생기지 않을까? 

 

맞습니다. 때문에 카카오 모빌리티 팀에서도 warm up을 하는 도중에는 HTTP status 400을 내려줌으로써 아직 서버가 활성화되지 않았다는 것을 외부에 알렸다고 합니다. 

 

그리고 정상적으로 warm up이 완료 되었으면 HTTP status 200을 내려줌으로써 실제 배포를 진행했다고 합니다. 

 

이를 한번 구현해보도록 하겠습니다. 

 

먼저 curl 명령어를 이용해 250번 예열을 해보겠습니다. 

 

1. warmup.sh 파일 생성

#!/bin/bash

DOCKER_APP_NAME='garlicpollpoll-capston'

EXIST_BLUE=$(docker-compose -p ${DOCKER_APP_NAME}-blue -f ./docker-compose-blue.yml ps | grep Up)

if [ -z "$EXIST_BLUE" ]; then
        for var in 250
        do
                curl -X POST 3.38.103.33:8081/warmup/item/list
                sleep 10
        done
        curl -X POST 3.38.103.33:8081/warmup/completed
else
        for var in 250
        do
                curl -X POST 3.38.103.33:8080/warmup/item/list
                sleep 10
        done
        curl -X POST 3.38.103.33:8080/warmup/completed
fi

echo "warm up is done"

jenkins 로 자동화를 위해서 linux 서버를 오랜만에 띄웠습니다. 위의 코드처럼 warmup.sh 파일을 만들어줍니다. 

 

중간에 저 sleep은 warm up 테스트를 위해 집어넣은 것입니다. 실제 코드에서는 빼야합니다. 

 

2. jenkins 파이프라인 구축

pipeline {
    agent any
    stages {
        stage('Github') {
            // get in github stage
        }
        stage('Build') {
            // build stage
        }
        stage('DownloadDockerAndDockerCompose') {
            // download docker and docker compose stage
        }
        stage('MakeDockerImage') {
            // make docker image stage
        }
        stage('Deplolyment') {
            // deploy stage
        }
        stage('Warm Up') {
            steps {
                sh'''
                    cd /var/jenkins_home/project/deploy
                    chmod 777 ./warmup.sh
                    ./warmup.sh
                '''
            }
        }
    }
}

jenkins 파이프라인에 대해서 궁금하신 분들은 아래의 링크를 참고해주세요!

 

https://coding-review.tistory.com/415

 

CI / CD 자동화 (3) : Jenkins 파이프라인 작성하기

이전 포스팅과 이어집니다. https://coding-review.tistory.com/414 CI / CD 자동화 (2) : Jenkins 시작하기 앞선 포스팅과 이어지는 내용입니다. https://coding-review.tistory.com/413 CI / CD 자동화 (1) : Jenkins vs Github Action

coding-review.tistory.com

 

3. 인터셉터 만들기

@Component
public class ReadinessInterceptor implements HandlerInterceptor {

    private boolean isReady = false;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!isReady && !request.getRequestURI().equals("/warmup/completed")) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Application is not ready yet");
            return false;
        }
        return true;
    }

    public void setReady(boolean ready) {
        isReady = ready;
    }
}

이 인터셉터는 warm up이 진행되는 도중에는 400을 내려주고 warm up이 끝나면 200을 내려줄겁니다. 

 

4. warm up 전용 API 만들기

@RestController
@RequiredArgsConstructor
@Slf4j
public class WarmUpController {

    private final ItemRepository itemRepository;
    private final ReadinessInterceptor interceptor;

    @PostMapping("/warmup/item/list")
    public ResponseEntity<?> warmupItemList() {
        PageRequest page = PageRequest.of(0, 9);
        List<Item> findAllItem = itemRepository.findAllItem(page);
        itemRepository.count();

        return new ResponseEntity<>(new WarmupDto("warmup execute"), HttpStatus.ACCEPTED);
    }

    @PostMapping("/warmup/completed")
    public ResponseEntity<?> warmupComplete() {
        interceptor.setReady(true);
        return ResponseEntity.ok(new WarmupDto("warmup success"));
    }
}

먼저 /warup/item/list 라는 API로 warm up을 진행합니다. 그리고 모든 warm up이 끝나면 /warmup/completed 라는 API로 curl 명령어를 쏴서 이전 인터셉터의 isReady 부분을 true로 바꿔주어 이후 모든 요청을 200으로 내려줄겁니다. 

 

5. 인터셉터 등록

@Component
@RequiredArgsConstructor
public class WarmupConfig implements WebMvcConfigurer {

    private final ReadinessInterceptor interceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(interceptor).excludePathPatterns("/css/**", "/image/**", "/js/**", "/warmup/completed");
    }
}

보통 인터셉터를 등록할 때 addInterceptor 안에 new 연산자를 이용해 인터셉터를 등록하지만 저희는 그렇게 하면 isReady 값이 항상 false가 되어버려 warm up이 종료되고 나서도 400 코드로 응답합니다. 

 

때문에 인터셉터를 스프링 빈으로 등록하고 인터셉터를 등록할 때 주입을 받아서 추가해주면 이후 isReady 값이 true로 변경되어도 싱글톤으로 빈이 등록되었기 때문에 이후 요청은 200으로 응답받게 됩니다. 

 

이제 테스트 해봐야겠죠? 

 

 

Jenkins 스케줄링을 보시면 warmup이 진행되는 중입니다. 이 때 API 요청을 보냈을 때 보시다시피 400코드로 내려주는 것을 확인할 수 있습니다. 

 

모든 스케줄링이 끝나고 다시 요청한 결과 200코드로 내려주는 것을 확인했습니다. 

마치며

이번에는 warm up에 대해서 알아봤습니다. 이전 Graceful Shutdown과 마찬가지로 warm up은 배포 과정에서 반드시 짚고 넘어가야할 과정이기 때문에 한번 공부해봤습니다. 

 

제 프로젝트는 단순히 서버가 내려갔다 올라갈 뿐이고 그렇게 해도 상관없는 프로젝트이지만 컨셉이 사용자 수가 1000만명이 된다면 어떻게 될까를 가정하고 만드는 프로젝트이기 때문에 반드시 고려해야할 사항이라고 생각합니다. 

 

여기까지 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

 

 

출처

https://hudi.blog/jvm-warm-up/

 

스프링 애플리케이션 배포 직후 발생하는 Latency의 원인과 이를 해결하기 위한 JVM Warm-up

최근 같은 팀원 중 한분께서 JVM Warm-up 이슈로 인해 발생한 성능 저하 이슈와 그 해결방법에 대해 공유해주셨다. JVM Warm-up 키워드에 대해서는 언젠가 한번 공부해 보아야겠다고 생각했지만, 계속

hudi.blog

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

 

https://velog.io/@dbwogml15/JIT-COMPILER-JVM-WARM-UP

 

JIT COMPILER, JVM WARM UP

https://if.kakao.com/2022/session/35Kakao T 사용자의 계정 서비스 내에 있던 성능 이슈 해결에 대해TPS 요청이 높은 서비스중 하나.Transaction Per Second(TPS)는 초당 트랜잭션의 개수입니다. 실제 계산하는

velog.io