티스토리 뷰
[Spring] 스프링 첫 요청이 처리되는데 오래 걸리는 이유(서블릿 초기화와 JIT 컴파일러)와 해결 방법(웜업, Warm-Up)
망나니개발자 2022. 4. 1. 10:00Spring 애플리케이션을 실행하고 요청을 보내면 유독 첫 요청이 오래 걸립니다. 이것은 우연이 아니고 디스패처 서블릿의 구조와 관련이 있습니다. 이번에는 왜 스프링에서는 첫 요청이 처리되는데 오래 걸리는지 그리고 어떻게 해결할 수 있는지 살펴보도록 하겠습니다.
1. 스프링의 서블릿 초기화 작업들
[ 디스패처 서블릿과 서블릿의 생명 주기 ]
스프링에는 모든 요청을 가장 먼저 받아 적합한 컨트롤러에 위임하는 디스패처 서블릿이 있다. 디스패처 서블릿은 J2EE 스펙의 HttpServlet 클래스를 확장한 서블릿 기반의 기술인데, 첫 요청이 오래 걸리는 이유는 서블릿의 생명주기와 연관이 있다. 서블릿의 생명 주기는 3가지 단계로 나뉘어지는데, 그림으로 보면 다음과 같다.
- 초기화: 요청이 들어오면 서블릿이 웹 컨테이너에 등록되어 있는지 확인하고, 없으면 초기화를 진행함
- 요청 처리: 요청이 들어오면 각각의 HTTP 메소드에 맞게 요청을 처리함
- 소멸: 웹 컨테이너가 서블릿에 종료 요청을 하여 종료 시에 처리해야하는 작업들을 처리함
여기서 우리가 주목해서 봐야하는 부분은 초기화 단계이다.
클라이언트로부터 요청이 오면 웹 컨테이너는 먼저 서블릿이 초기화 되었는지를 확인한다. 만약 서블릿이 이미 할당되어 있다면 바로 요청을 처리하지만 그렇지 않다면 init() 메소드를 호출해 초기화를 진행하고 할당한다. SpringBoot에서는 스프링 컨테이너가 웹 컨텍스트까지 제어가능하므로 이미 디스패처 서블릿이 할당은 되어 있고, 초기화는 되어있지 않은 상태이다.
init() 메소드는 첫 요청이 왔을 때 한번만 실행되기 때문에 서블릿의 쓰레드에서 공통적으로 필요로 하는 작업들이 진행되며, 이러한 이유로 스프링은 첫 요청을 처리하는데 많은 시간을 필요로 하는 것이다.
[ 서블릿 초기화에서 진행되는 작업들 ]
디스패처 서블릿의 초기화 과정에서는 많은 일들이 일어나지만, 우리가 관심있어 할만한 내용들만 살펴보도록 하자. 디스패처 서블릿은 컨트롤러로 요청을 위임하고, 컨트롤러로부터 받은 결과를 처리하기 위해 다음과 같은 도구들을 필요로 한다.
- Multipart 파일 업로드를 위한 MultipartResolver
- Locale을 결정하기 위한 LocaleResolver
- 요청을 처리할 컨트롤러를 찾기 위한 HandlerMapping
- 요청을 컨트롤러로 위임하기 위한 HandlerAdapter
- 뷰를 반환하기 위한 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);
}
위의 코드에서 도구들을 초기화하는 메소드 이름이 initStrategies인 이유는 전략 패턴이 적용되었기 때문이다. 대표적으로 뷰를 반환하기 위한 도구인 ViewResolver를 중심으로 살펴보도록 하자. ViewResolver는 전략 패턴을 적용하기 위해 인터페이스로 만들어졌다.
public interface ViewResolver {
@Nullable
View resolveViewName(String viewName, Locale locale) throws Exception;
}
애플리케이션이 실행되면 기본 구현체들(ContentNegotiatingViewResolver, BeanNameViewResolver, InternalResourceViewResolver)를 빈으로 등록해둔다. 그리고 개발자가 추가한 Thymeleaf나 Mustache와 같은 템플릿 엔진을 위한 ViewResolver도 추가될 수 있다. (스프링 부트에서는 auto-configure를 통해 해당 의존성을 추가하면 자동으로 등록된다.)
ViewResolver 외에 다른 모든 도구들도 전략 패턴을 적용하기 위한 인터페이스를 갖고 있으며, 인터페이스 타입으로 선언되어 있다.
이러한 작업들이 서블릿 초기화 시점에 처리되는데, 사실 이러한 부분은 성능에 많은 영향을 주지는 않는다. 오히려 JVM의 JIT 컴파일러에 의한 영향이 훨씬 큰데, 이와 관련해서 살펴보도록 하자.
2. JIT 컴파일러의 웜업(Warm-Up)
[ 자바 언어의 동작 방식 ]
첫 요청이 오래 걸리는 주된 이유는 JVM의 JIT 컴파일러와 연관이 있다. 그러므로 우리는 자바 언어의 동작 방식을 살펴보어야 하는데, 그 전에 먼저 C, C++, GoLang, Rust 등과 같은 컴파일 언어의 동작 과정부터 살펴보도록 하자.
컴파일 언어는 컴파일 과정에서 바로 기계어를 만들어낸다. 그리고 컴파일 시에 코드 최적화까지 진행하여 처리 성능이 상당히 뛰어나다. 대신 생성된 기계어가 빌드 환경(CPU 아키텍처)에 종속적이라서, 플랫폼이 바뀐다면 다시 빌드해야 하는 문제가 있다.
하지만 자바는 이러한 플랫폼 종속적인 문제를 해결하고자 JVM을 도입하였고, 그래서 동작 과정이 조금 다르다.
자바는 먼저 작성된 소스 코드를 바이트 코드로 컴파일하는데, 바이트 코드는 주로 JAR 또는 WAR로 아카이브하여 활용하게 된다. JVM은 아카이빙된 파일을 구동하는데, 실시간으로 바이트 코드를 기계어로 번역하면 CPU가 해당 기계어를 처리한다. 이러한 구조 덕분에 Java는 플랫폼에 종속되지 않게 되었지만, 코드를 실행할 때 바이트 코드를 기계어로 번역하는 작업 때문에 성능이 느려졌다. 그래서 이러한 문제를 해결하고자 JIT 컴파일러를 도입하여 사용하고 있다.
[ JIT 컴파일러(Just In Time Compiler)의 웜업(Warm-Up) 문제 ]
자바는 성능 문제를 해결하고자 적시에 기계어를 만들어낸다는 의미의 JIT(Just In Time) 컴파일러를 도입하여 사용하고 있다. JIT 컴파일러는 핫스팟이라고도 불리는데, JDK 1.3부터 반영되어 있다. JIT 컴파일러는 바이트 코드를 기계어로 번역하는 과정에서 캐시를 활용한다. 그래서 이미 번역된 기계어를 재사용할 수 있도록 하며, 그에 더해 런타임 환경에 맞춰 코드도 최적화함으로써 성능을 향상시킨다.
하지만 문제는 애플리케이션이 시작될 때에는 캐싱된 기계어가 없다는 것이고, 그래서 스프링에서 첫 요청이 오래걸리는 것이다. 만약 요청이 많은 서비스에서 캐싱된 기계어가 없는 상태라면 배포 직후에는 응답 지연이 발생하여 문제가 발생할 수 있다.
그래서 애플리케이션 시작 후에 강제로 로직을 호출하여 기계어를 캐싱해두는 작업이 필요한데, 이를 warm-up 이라고 한다. 트래픽이 많은 서비스라면 warm-up 작업은 반드시 고려되어야 한다.
참고로 위에서 설명한 서블릿 초기화 보다는 JIT 컴파일러 부분이 훨씬 성능에 크게 영향을 미친다.
3. 첫 요청이 느린 문제의 해결 방법
[ 첫 요청이 느린 문제의 해결 방법 ]
첫 요청이 느린 문제의 해결 방법은 간단하다. 스프링 애플리케이션이 실행된 후에 핵심 로직들을 강제로 호출시켜 warm-up 하면 된다. warm-up 후에 해당 서버를 투입시키는 것이다.
개발자라면 애플리케이션이 실행된 후에 1회 초기화 과정을 자동화하기를 원할 수 있다. 스프링은 애플리케이션이 구동된 후에 초기화를 위한 여러 가지 방법을 제공하는데, 관련된 내용은 이 포스팅에서 자세히 살펴보도록 하자.
이 방법 외에도 L7 요청을 처음 받았을 때(서버를 투입할 때) Warm-up 시키는 등의 방법도 있으니 참고하도록 하자.
위의 내용들은 개인적으로 공부를 하면서 작성을 한 내용이라 충분히 틀리거나 잘못된 내용들이 있을 수 있습니다. 혹시 수정 또는 추가할 내용들을 발견하셨다면 댓글 남겨주세요! 반영해서 수정하도록 하겠습니다:)
관련 포스팅
- 스프링 첫 요청이 처리되는데 오래 걸리는 이유(서블릿 초기화와 JIT 컴파일러)와 해결 방법
- SpringBoot 실행 후에 초기화 코드를 넣는 3가지 방법과 이벤트 리스너(CommandLineRunner, ApplicationRunner, EventListener)