본문 바로가기

JAVA

스프링 첫요청이 처리되는데 오래 걸리는 이유

728x90

[ 디스패처 서블릿과 서블릿의 생명 주기 ]

 

스프링에는 모든 요청을 가장 먼저 받아 적합한 컨트롤러에 위임하는 디스패처 서블릿이 존재한다.

 

첫요청이 오래걸리는 이유는 서블릿의 생명주기를 봐야한다.

 

 

- 초기화단계 : 요청이 들어오면 서블릿이 웹 컨테이너에 등록되어 있는지 확인하고, 없으면 초기화를 진행함

- 요청처리: 요청이 들어오면 각각의 HTTP메소드에 맞게 요청 처리

- 소멸: 웹 컨테이너가 서블릿에 종료 요청을 하여 종료 시에 처리해야하는 작업들 처리

 

init() 메소드는 첫 요청이 왔을 때 한번만 실행되기 때문에 서블릿의 쓰레드에서 공통적으로 필요로 하는 작업이

진행이 되며, 첫요청 시 많은 시간을 필요로한다.

 

[ 디스패처 서블릿과 서블릿의 생명 주기 ]

- Multipart 파일 업로드를 위한 MutlpartResolver

- Locale을 결정하기 위한 LocalResolver

 

- 요청을 처리할 컨트롤러를 찾기 위한 HandlerMapping

 

- 요청을 컨트롤러로 위임하기 위한 Handler Adapter

 

- 뷰를 반환하기 위한 ViewResolver

 

- 기타 등등

 

스프링은 Lazy-Init 전략을 사용해 애플리케이션을 빠르게 구동하도록 하고 있어서, 디스패처 서블릿에 해당 도구들이 설정되지 않은 상태로 띄워지게 된다. 그리고 서블릿 초기화 시에 애플리케이션 컨택스트로부터 해당 타입의 빈을 찾아서 디스패처 서블릿에 설정(Set)해준다. 그래야 요청을 정상적으로 처리할 수 있기 때문이다. 물론 위에서 적은 내용들 외에도 다양한 서블릿 초기화 작업들이 진행된다.

위의 내용은 DispatcherServlet에 다음과 같이 구현되어 있다.

 

@Override
protected void onRefresh(ApplicationContext context) {
    initStrategies(context);
}

protected void initStrategies(ApplicationContext context) {
    initMultipartResolver(context);
    initLocaleResolver(context);
    initThemeResolver(context);
    initHandlerMappings(context);
    initHandlerAdapters(context);
    initHandlerExceptionResolvers(context);
    initRequestToViewNameTranslator(context);
    initViewResolvers(context);
    initFlashMapManager(context);
}

 

이러한 작업들은 서블릿 초기화 시점에 처리되는데, 사실 이러한 부분은 성능에 많은 영향을 주지는 않는다. 오히려 JVM의 JIT 컴파일러에 의한 영향이 훨씬 큰데

 

 

[ 컴파일러의 웜업 (Warm-up) ]

 

 

 

자바는 먼저 작성된 소스 코드를 바이트 코드로 컴파일하는데, 바이트 코드는 주로 JAR 또는 WAR로 아카이브하여 활용하게 된다. JVM은 아카이빙된 파일을 구동하는데, 실시간으로 바이트 코드를 기계어로 번역하면 CPU가 해당 기계어를 처리한다. 이러한 구조 덕분에 Java는 플랫폼에 종속되지 않게 되었지만, 코드를 실행할 때 바이트 코드를 기계어로 번역하는 작업 때문에 성능이 느려졌다. 그래서 이러한 문제를 해결하고자 JIT 컴파일러를 도입하여 사용하고 있다.

 

[JIT 컴파일러의 웜업문제 ]

 

JIT 컴파일러는 바이트 코드를 기계어로 번역하는 과정에서 캐시를 활용한다. 그래서 이미 번역된 기계어를 재사용할 수 있도록 하며, 그에 더해 런타임 환경에 맞춰 코드도 최적화함으로써 성능을 향상시킨다.

하지만 문제는 애플리케이션이 시작될 때에는 캐싱된 기계어가 없다는 것이고, 그래서 스프링에서 첫 요청이 오래걸리는 것이다. 만약 요청이 많은 서비스에서 캐싱된 기계어가 없는 상태라면 배포 직후에는 응답 지연이 발생하여 문제가 발생할 수 있다.

 

그래서 애플리케이션 시작 후에 강제로 로직을 호출하여 기계어를 캐싱해두는 작업이 필요한데, 이를 warm-up 이라고 한다. 트래픽이 많은 서비스라면 warm-up 작업은 반드시 고려되어야 한다.

 

[JIT 컴파일러의 주요 최적화 기법 심화 설명]

1. 핫스팟(Hotspot) 최적화

  • 핫스팟은 애플리케이션에서 반복적으로 실행되는 코드 영역(주로 메서드나 루프)을 의미합니다. JIT 컴파일러는 이러한 핫스팟 코드를 식별하고, 추가적인 최적화를 수행하여 실행 속도를 높입니다.
  • 작동 방식:
    • 핫스팟 코드를 식별하기 위해 JVM은 메서드 호출 횟수나 루프 반복 횟수를 계측합니다.
    • 특정 기준(예: 10,000번 이상 실행)이 넘는 코드가 핫스팟으로 간주됩니다.
    • 핫스팟으로 식별된 코드는 더 복잡한 최적화 과정을 거쳐 기계어로 변환됩니다.
  • 효과: 자주 실행되는 코드의 실행 속도를 비약적으로 향상시킬 수 있습니다.

2. 캐시를 활용한 실행 속도 향상

  • JIT 컴파일러는 바이트코드를 기계어로 변환한 후, 변환된 기계어를 캐시에 저장합니다.
  • 장점:
    • 동일한 코드가 다시 실행될 때, 이미 번역된 기계어를 재사용하여 번역 시간을 절약할 수 있습니다.
    • 런타임 중 캐시를 활용함으로써 실행 속도가 지속적으로 개선됩니다.
  • 문제점:
    • 애플리케이션 초기 구동 시에는 캐시된 코드가 없기 때문에, 모든 바이트코드를 처음부터 번역해야 하므로 초기 로딩 시간이 길어질 수 있습니다.
  • 해결책: 웜업(Warm-up) 과정에서 자주 실행되는 코드를 미리 실행하여 기계어를 캐싱합니다.

3. 인라인 확장(Inline Expansion)

  • 메서드 호출 오버헤드를 제거하기 위해, 자주 호출되는 메서드의 본문을 호출 위치에 직접 삽입하는 최적화 기법입니다.
  • 예제:
    java
    코드 복사
    public int add(int a, int b) { return a + b; } public int calculate() { return add(5, 10); // 메서드 호출 }
    • 인라인 확장 후:
      java
      코드 복사
      public int calculate() { return 5 + 10; // 메서드 호출 제거 }
  • 장점:
    • 메서드 호출 오버헤드 제거.
    • 최적화 기법 간 연계(예: 루프 언롤링과 함께 사용 가능).
  • 단점:
    • 메서드가 너무 크거나 복잡하면 메모리 사용량이 증가할 수 있음.
    • JIT 컴파일러는 크기가 작은 메서드에 대해서만 인라인 확장을 수행하여 이러한 문제를 방지합니다.

4. 루프 언롤링(Loop Unrolling)

  • 루프 반복 횟수를 줄이고, 반복문 내부의 작업을 복제하여 성능을 향상시키는 기법입니다.
  • 예제:
    • 원래 코드:
      for (int i = 0; i < 10; i++) { process(i); }
    • 루프 언롤링 후:
       
      for (int i = 0; i < 10; i += 2) { process(i); process(i + 1); }
    • 반복 횟수를 줄이고, 조건문 검사와 루프 인덱스 증가에 드는 비용을 감소시킴.
  • 장점:
    • 루프 내에서 조건문과 인덱스 계산의 오버헤드 제거.
    • 캐시 적중률(Cache Hit Rate) 증가.
  • 단점:
    • 루프 크기가 커지면 코드의 크기(메모리 사용량)가 증가할 수 있음.

5. 탈출 분석(Escape Analysis)

  • JIT 컴파일러는 객체가 메서드 밖으로 "탈출"하지 않는 경우, 힙 메모리 대신 스택 메모리에 객체를 할당합니다.
  • 효과:
    • 스택 메모리는 힙 메모리보다 접근 속도가 빠름.
    • 불필요한 가비지 컬렉션(GC)을 줄여 성능을 향상시킴.
  •  

[정리] JIT 컴파일러가 초기 로딩에 미치는 영향과 최적화 기법

  1. 초기 로딩 속도 저하 원인:
    • 캐싱된 기계어가 없어 모든 바이트코드를 번역해야 함.
    • DispatcherServlet 및 서블릿 초기화 과정과 결합되어 초기 요청 시간이 느려짐.
  2. JIT 컴파일러 최적화 기법:
    • 핫스팟(Hotspot) 최적화: 자주 실행되는 코드를 식별하고 최적화.
    • 캐시 활용: 번역된 기계어를 재사용하여 성능 향상.
    • 인라인 확장: 메서드 호출 오버헤드 제거.
    • 루프 언롤링: 반복문 실행 비용 감소.
    • 탈출 분석: 객체를 스택에 할당하여 메모리 접근 속도 향상.

[첫 요청이 느린 문제의 해결방법]

 

첫 요청이 느린 문제의 해결 방법은 간단하다. 스프링 애플리케이션이 실행된 후에 핵심 로직들을 강제로 호출시켜 warm-up 하면 된다. warm-up 후에 해당 서버를 투입시키는 것이다. 

개발자라면 애플리케이션이 실행된 후에 1회 초기화 과정을 자동화하기를 원할 수 있다. 스프링은 애플리케이션이 구동된 후에 초기화를 위한 여러 가지 방법을 제공하는데

https://mangkyu.tistory.com/233

 

[Spring] SpringBoot 실행 후에 초기화 코드를 넣는 3가지 방법과 이벤트 리스너(CommandLineRunner, Applicatio

SpringBoot 애플리케이션이 실행 후에 초기화 코드를 넣어야 하는 상황이 생길 수 있습니다. 크게 3가지 방법으로 초기화 코드를 넣을 수 있는데, 이번에는 이 3가지 방법에 대해 알아보도록 하겠습

mangkyu.tistory.com

 

https://velog.io/@blue564/SpringBoot-WarmUp

 

SpringBoot WarmUp

서버가 실행된 후, 혹은 서버의 요청이 없는 첫 번째 요청 또는 새로운 요청이 발생하면 응답 시간이 매우 느리다.Spring boot의 cold start 문제원인 파악을 위해서는 JVM 흐름을 알아야 한다.JVM 흐름1\

velog.io

 

 

 /actuator/health 요청하면 호출되는 checkWarmup 메서드를 작동시켜 warmup을 시키는방법이다

MSA의 환경이라면 여러대의 서버를 웜업해야하는데 이럴경우는 rabbitmq와 같은 경량메세지큐를 이용해서 warmup을 시키는방안도 생각해볼만한 것 같다.

 

 

 

728x90

'JAVA' 카테고리의 다른 글

Effective java 정복기 2장  (0) 2025.01.06
ParallelStream은 무엇일까?  (0) 2024.12.01