Spring boot 어플리케이션의 첫 요청 지연 현상
경험한 현상
전 회사에서 Shortform VOD 서비스를 위한 REST API 서버를 개발을 하게 되었고, 팀이 확장 될 수도 있다고 하여 인력 수급 등을 고려하여 Spring boot를 선택하여 개발 했었다. 테스트 서버를 구축하고 난 뒤 발견한 현상으로 첫 요청에 대한 응답이 굉장히 느리다는 점이었다. FastAPI나 다른 프레임워크에서도 종종 경험한 현상이긴 했지만 유독 느리다는 느낌을 받았는데 이는 Spring의 Servlet 초기화 작업의 특성 때문이었다.
원인 추측
Dispatcher Servlet and Life Cycle
Spring의 Servlet은 아래 그림과 Life Cycle을 갖는다.

- init() : 사용자로부터 Request가 들어오면, Servlet Container에 Servlet이 등록되어있는지 확인하고, 없다면 초기화 과정을 진행
- service() : Request를 각각의 HTTP Method에 맞게 처리
- destroy() : Servlet Container가 Servlet에 종료 요청을 하면 종료 시 수행해야할 작업을 수행
여기서 첫 요청이 느린 현상과 연관이 있는 부분은 init() 과정과 연관이 있다.
Dispatcher Servlet의 초기화에는 아래 작업들이 수행된다.
// DispatcherServlet.java
@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);
}
- Multipart 를 처리하기 위한 Resolver 초기화
- Locale 처리를 위한 Resolver 초기화
- 테마 사용을 위한 ThemeResolver 초기화
- 요청 처리를 할 컨트롤러를 찾는 HandlerMapping
- 요청을 컨트롤러로 위임하는 HandlerAdapter 초기화
- 예외 처리 핸들러 초기화
- 핸들러에서 View 이름을 명시하지 않은 경우, 요청을 기반으로 View를 반단하는 Translator 초기화
- 명시된 View 이름에 해당하는 View 반환하는 ViewResolver 초기화
- 기타...
Spring Framework는 내부에 굉장히 많은 컴포넌트들이 존재하고 Application 구동 시간을 단축하기 위해 Lazy Loading을 사용한다.(옵션으로 변경 가능하지만) 그로인해 실제로 해당 컴포넌트들이 필요한 순간까지 실제 인스턴스화가 되어있지 않기 때문에 첫 요청시 이러한 초기화 과정들이 수행되고 딜레이가 발생한다.
하지만 이 과정은 그렇게 까지 큰 비용이 들지 않아 딜레이의 원인으로 보이지 않앗다.
Class Loader
JVM 아키텍처를 우선 살펴보자

JVM은 프로세스를 시작하면 어플리케이션 구동을 위해 클래스 로더를 이용하여 필요한 소스코드를 메모리에 적재하는 과정을 거친다. 초기화 과정은 아래와 같다.
- Bootstrap Class Loading : Bootstrap Class Loader가 가장 최초로 실행되어 JAVA Class를 로드할 수 있는 최소한의 JAVA 클래스를 로드한다. (예 java.lang.Object, class, ClassLoader)
- Extension Class Loading : java.ext.dirs 환경 변수에 설정된 바이트 코드들을 로드하고 만일 없다면 ${JAVA_HOME}/jre/lib/ext 의 바이트 코드를 로드한다.
- Application Class Loading : System Class Loader에 의해 JAVA Application의 Classpath 에 있는 바이트코드 및 jar 에 포함된 바이트 코드를 로드한다.
각 클래스 로더는 새로운 클래스를 로드할 때 우선 자신의 상위 로더에게 위임을 하며, 최상위 로드에서 해당 클래스를 찾을 수 없다면 하위 로더에 위임하는 과정을 거쳐 클래스가 로드된다. 이 과정을 통해 바이트 코드가 메모리로 로드되며 일반적으로 Lazy Loading 방식으로 동작한다.
JIT(Just-In-Time)
여기서 하나 더 살펴보자, JVM 계열 언어는 C/C++과 같은 언어들과 달리 소스코드가 JVM Byte Code로 변환되고 JVM에서 Interpreter를 이용해 Machine Code로 변환하는 작업을 수행한다. 이로 인해 성능이 컴파일 언어들에 비해 떨어지는 편이다. 이를 극복하려는 노력중 하나가 JIT이다.
JIT Compiler를 이용한 컴파일 과정은 아래와 같다.
- *.java 코드를 *.class(Byte code)로 컴파일
- JIT Compiler가 Byte code를 확인하여 중복되는 부분들을 Native code로 변환
- 런타임에서 Native code로 번역된 부분은 Interpreter가 아닌 JIT 로 실행
이런 방식으로 수행 속도를 향상시키는데 초기 요청이 느려지는 현상은 JIT를 위한 Native code가 생성되어있지 않기 때문이다. 위 그림에서 JIT는 Runtime에 수행되는 과정이다. 그렇기 때문에 서버가 실행된 직후에는 JIT Compile 과정이 수행되기 전이기 때문에 JIT Compile이 수행되는 과정이 수반되기 때문에 첫 응답이 느려지는 것이다.
해결방법
위에 살펴본 바로는 Spring, JVM 기반의 어플리케이션의 특징은 다음과 같다.
-
자주 실행 되는 코드를 JIT 컴파일러를 통해 기계어로 만들어 캐시로 저장해둔다.
-
클래스는 Lazy Loading으로 메모리에 로드된다.
-
스프링은 실제 요청이 들어와야 Dispatcher Servlet을 초기화한다.
즉 첫 딜레이가 생기는 이유는 다시 말하면 아래와 같다.
-
클래스가 메모리에 적재되지 않았다.
-
코드가 JIT 컴파일러 되지 않았다.
따라서 미리 해당 클래스를 메모리에 적재하고, 코드가 JIT Compiler를 통해 수행될 수 있도록 미리 호출을 한다면 해결이 가능하다.
이런 상황에 사용할 수 있도록 Spring 에서는 ApplicationRunner 인터페이스를 구현하여 Warmup 을 수행할 수 있도록 만들어두었다.
예시는 아래와 같다.
@Component
class WarmupRunner(private val articleController: ArticleController,
private val categoryController: CategoryController,
private val authenticationController: AuthenticationController)
: ApplicationRunner {
override fun run(args: ApplicationArguments?) {
warmupCategoryController()
warmUpArticleController()
warmUpAuthenticationController()
}
private fun warmUpArticleController() {
try {
articleController.getArticles(0L, null)
}catch(e : Exception) {
// nothing to do
}
}
private fun warmUpAuthenticationController() {
try {
val authenticationDto = LoginRequest(email = "[email protected]", password = "testpassword")
authenticationController.login(authenticationDto)
}catch(e : Exception) {
// nothing to do
}
}
private fun warmupCategoryController() {
try {
categoryController.getCategories()
}catch(e : Exception) {
// nothing to do
}
}
}
이것을 실제 적용 한 후 이 홈페이지의 기본 응답을 비교해보았다.
Before

After

기존 6000ms 가 넘는 응답 대기 시간에서 1000ms 대로 확연히 감소한 것을 확인할 수 있다.
위 예시는 이 블로그 한정으로 간단한 warmUp을 수행한 것이지만 다른 서비스와 통신을 해야하는 경우라면 Feign Client들의 warm up 과정등을 실제로 구현해야한다.