[SpringBoot] 스프링 부트 톰캣 애플리케이션의 Graceful Shutdown 동작 방식(Spring Boot Tomcat Graceful Shutdown)
1. 스프링 부트 톰캣 애플리케이션의 Graceful Shutdown 동작 방식
(Spring Boot Tomcat Graceful Shutdown)
[ GracefulShutdown의 필요성 ]
새로운 버전의 서버를 배포하기 위해서는 기존에 실행중인 서버를 종료하는 과정이 필요한데, 이때 기존의 서버 프로세스를 바로 종료하게 되면, 처리중 혹은 대기중인 요청에 문제가 될 수 있다. 따라서 서버 애플리케이션은 기존에 처리되던 요청을 모두 마무리하고 안전하게 서버를 내릴 수 있는 GracefulShutdown 을 지원해야 한다.
프로세스를 종료할 때 강제 종료(리눅스의 -9, SIGKILL)를 하지 않는 한 운영체제는 프로세스에게 종료 시그널을 전달하게 되는데, JVM은 종료 시그널을 받게 될 경우 종료 훅(shutdown hook)을 실행하게 된다. 그러면 종료훅을 통해 사용중인 리소스와 현재 진행중인 요청에 대해서 상황을 정리하고 안전하게 종료할 수 있는 것이다. JVM의 경우에는 종료훅을 받고 데몬 스레드가 아닌 모든 스레드가 종료되는 경우에 JVM이 정상적으로 종료된다. 데몬 스레드란 별도의 스레드로 부수적인 기능을 처리하고 싶지만, 그렇다고 해서 해당 스레드가 떠 있다는 이유로 JVM이 종료되지 않게 하고 싶지는 않을 경우 사용된다. 일반 스레드와 데몬 스레드는 종료될 때 처리 방법이 약간 다를 뿐 그 외에는 모든 것이 완전히 동일하다.
[ ThreadPoolExecutor(ExecutorService)의 종료 메서드 ]
앞선 포스팅에서 설명하였듯 톰캣은 내부적으로 요청의 관리를 위해 ThreadPoolExecutor를 활용한다. ThreadPoolExecutor는 자바 ExecutorService 인터페이스의 구현체에 해당하는데, ExecutorService는 GracefulShutdown을 위한 인터페이스를 제공하고 있고, ThreadPoolExecutor는 이를 구현하고 있다. 해당 기능을 분류하여 살펴보면 다음과 같다.
- 서비스 종료
- void shutdown()
- 새로운 작업을 받지 않으며, 이미 제출된 작업을 모두 완료한 후에 종료함
- 논 블로킹 메서드로, 해당 메서드를 호출한 스레드는 대기하지 않고 즉시 다음 코드를 호출함
- List<Runnable> shutdownNow()
- 실행 중인 작업을 중단하고, 큐애서 대기 중인 작업을 반환하며 즉시 종료함
- 실행 중인 작업을 중단하기 위해 인터럽트를 발생시킴
- 논 블로킹 메서드로, 해당 메서드를 호출한 스레드는 대기하지 않고 즉시 다음 코드를 호출함
- void shutdown()
- 서비스 상태 확인
- boolean isShutdown()
- 서비스의 종료 여부를 반환하는 메서드
- boolean isTerminated()
- shutdown() , shutdownNow() 호출 후, 모든 작업의 완료 여부를 반환하는 메서드
- boolean isShutdown()
- 작업 완료 대기
- boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException
- 서비스 종료 시에 지정된 시간만큼 모든 작업이 완료될 때까지 대기함
- 블로킹 메서드로 해당 메서드를 호출한 스레드는 대기하게 됨
- close()
- 자바 19부터 지원하는 종료 메서드로, shutdown() 을 호출하고 하루를 기다려도 작업이 완료되지 않으면 shutdownNow() 를 호출함
- 호출한 스레드에 인터럽트가 발생해도 shutdownNow() 를 호출함
- boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException
shutdown() 을 호출해서 이후의 요청들은 받지 않고, 이미 들어온 모든 요청들은 다 처리하여 서비스를 우아하게 종료(graceful shutdown)하는 것이 가장 이상적일 수 있다. 하지만 대기중인 작업이 너무 많아서 작업 완료가 어렵거나, 작업이 너무 오래 걸리거나, 또는 버그가 발생해서 특정 작업이 끝나지 않을 수 있다. 이렇게 되면 서비스가 너무 늦게 종료되거나, 종료되지 않는 문제가 발생할 수 있다.
따라서 이러한 경우에 대비해 awaitTermination()을 호출하여 일정 시간 대기하고, 해당 시간이 초과되었음에도 불구하고 종료되지 않는 작업이 있다면 무언가 문제가 있다고 가정하고 shutdownNow() 를 호출해서 작업들을 강제로 종료하는 것이 일반적이다.
- shutdown 메서드를 호출하여 새로운 작업을 받지 않으며, 처리 중이거나 큐에 이미 대기중인 작업은 마무리하고 종료시킴
- shutdown는 논블로킹 메서드이므로, awaitTermination으로 해당 작업들의 마무리까지 특정 시간 기다림
- 특정 시간을 기다려도 정상 종료가 되지 않는 경우, shutdownNow 메서드를 호출하여 강제 종료를 시도함
- shutdownNow는 논블로킹 메서드이므로, awaitTermination으로 해당 작업들의 마무리까지 특정 시간 기다림
이러한 방식은 ExecutorService 공식 API 문서에서 제안하는 방식으로, 이를 코드로 구현하면 다음과 같다.
public void shutdownAndAwaitTermination(ExecutorService es) {
es.shutdown(); // 새로운 작업을 받지 않으며, 처리 중이거나 큐에 이미 대기중인 작업은 마무리하고 종료시킴
try {
log("서비스 정상 종료 시도");
// shutdown 메서드는 non-blocking 메서드이므로 즉시 아래 라인이 호출되며, 이미 대기중인 작업들의 마무리까지 최대 10초 기다림
if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
log("서비스 정상 종료 실패 -> 강제 종료 시도");
// 정상 종료가 너무 오래 걸리는 경우, shutdownNow로 강제 종료를 시도함
es.shutdownNow();
// 작업이 취소될 때 까지 최대 10초 대기함
if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
log("서비스가 종료되지 않았습니다.");
}
}
} catch (InterruptedException ex) {
// awaitTermination()으로 대기중인 현재 스레드가 인터럽트 될 수 있으므로 예외를 잡아줌
es.shutdownNow();
}
}
그렇다면 톰캣의 경우에는 GracefulShutdown을 어떻게 처리하고 있는지 살펴보도록 하자.
[ 스프링 부트에서 톰캣의 GracefulShutdown 동작 방식 ]
전반적인 GracefulShutdown 흐름
스프링 애플리케이션 역시 서버가 종료될 때, 안전한 마무리 과정이 처리될 수 있도록 JVM에 다음과 같이 SpringApplicationShutdownHook을 등록하고 있다. 참고로 SpringApplicationShutdownHook은 애플리케이션의 종료 시점 중에서도 SmartLifecycle 빈들을 종료하는 매우 초기 시점에 수행된다.
![]() |
해당 종료 훅을 통해 톰캣 애플리케이션의 GracefulShutdown이 처리되는 과정을 정리하면 다음과 같다.
- OS가 JVM 프로세스에 종료 시그널을 전달함
- JVM은 등록되어 있던 SpringApplicationShutdownHook을 실행하게 됨
- SpringApplicationShutdownHook 내부에서 웹서버의 라이프사이클을 관리하는 빈들을 순차적으로 실행시킴
- 먼저 WebServerGracefulShutdownLifecycle가 실행되며, 이는 웹서버(TomcatWebServer)로 하여금 신규 요청을 받지 않도록 함
- 이후 WebServerStartStopLifecycle 빈이 실행되며 웹서버를 종료시키는데, 이때 미처리중인 연결들이 끊어지며 처리되지 않은 작업들은 종료됨
이러한 흐름은 다음과 같은 로그를 통해서 확인할 수 있는데, 참고로 여기서 가장 우선적으로는 @Async로 비동기 요청을 처리하는 스레드 풀의 종료 작업이 가장 먼저 처리됨을 확인할 수 있다.
2025-04-03T15:46:44.946+09:00 DEBUG 85658 --- [ionShutdownHook] o.s.c.support.DefaultLifecycleProcessor : Stopping beans in phase 2147483647
2025-04-03T15:46:44.946+09:00 DEBUG 85658 --- [ionShutdownHook] o.s.c.support.DefaultLifecycleProcessor : Bean 'applicationTaskExecutor' completed its stop procedure
2025-04-03T15:46:44.946+09:00 DEBUG 85658 --- [ionShutdownHook] o.s.c.support.DefaultLifecycleProcessor : Stopping beans in phase 2147482623
2025-04-03T15:46:44.946+09:00 INFO 85658 --- [ionShutdownHook] o.s.b.w.e.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2025-04-03T15:46:46.960+09:00 INFO 85658 --- [tomcat-shutdown] o.s.b.w.e.tomcat.GracefulShutdown : Graceful shutdown complete
2025-04-03T15:46:46.961+09:00 DEBUG 85658 --- [tomcat-shutdown] o.s.c.support.DefaultLifecycleProcessor : Bean 'webServerGracefulShutdown' completed its stop procedure
2025-04-03T15:46:46.961+09:00 DEBUG 85658 --- [ionShutdownHook] o.s.c.support.DefaultLifecycleProcessor : Stopping beans in phase 2147481599
2025-04-03T15:46:46.964+09:00 DEBUG 85658 --- [ionShutdownHook] o.s.c.support.DefaultLifecycleProcessor : Bean 'webServerStartStop' completed its stop procedure
2025-04-03T15:46:46.964+09:00 DEBUG 85658 --- [ionShutdownHook] o.s.c.support.DefaultLifecycleProcessor : Stopping beans in phase -2147483647
2025-04-03T15:46:46.964+09:00 DEBUG 85658 --- [ionShutdownHook] o.s.c.support.DefaultLifecycleProcessor : Bean 'springBootLoggingLifecycle' completed its stop procedure
WebServerGracefulShutdownLifecycle의 구체적인 동작 분석
새로운 요청이 허용되지 않도록 하는 GracefulShutdown의 구현 방식은 웹 서버마다 다를 수 있다. 네트워크 계층에서 요청의 수락을 중지할 수도 있고, 특정 HTTP 상태 코드나 HTTP 헤더와 함께 응답을 반환할 수도 있다. 또한 지속 연결(persistent connection)의 사용 여부에 따라서도 그 방식이 달라질 수 있다. 스프링에서 기본적으로 사용되는 톰캣의 경우에는 별도로 HTTP 상태 코드나 헤더를 제공하지는 않고, 연결한 클라이언트는 FIN 패킷을 받음으로써 연결 끊김을 탐지하게 된다.
톰캣의 GracefulShutdown 구현의 경우 일단 새로운 연결을 거부하는데, 해당 처리 과정을 정리하면 다음과 같다.
- SpringApplicationShutdownHook 스레드가 새로운 'tomcat-shutdown' 스레드를 생성하여 doShutdown을 실행함
- SpringApplicationShutdownHook는 CountDownLatch를 사용하여 tomcat-shutdown가 연결을 pause(더 이상 새로운 요청을 받지 않음) 완료할 때까지 대기함
- 이후 tomcat-shutdown은 CountDownLatch를 풀어 SpringApplicationShutdownHook이 다음 처리( WebServerGracefulShutdownLifecycle)를 진행하도록 함
- tomcat-shutdown은 처리중인 요청이 있다면 일정 시간 대기하고, 일정 시간이 지나도 처리되지 않으면 aborted가 true로 바뀜에 따라 다음의 같이 처리를 진행함
- 아직 처리중인 요청이 없다면 "Graceful shutdown complete"을 출력하고 콜백을 호출하며 종료됨
- 아직 처리중인 요청이 있다면 일정 시간을 초과하면 “Graceful shutdown aborted” 출력과 함께 콜백을 호출하며 종료됨
먼저 다음의 코드를 통해 SpringApplicationShutdownHook 스레드가 tomcat-shutdown 스레드를 생성하여 doShutdown을 실행함을 확인할 수 있다.
그리고 doShutdown 내부에서는 먼저 연결을 중단하고, 처리중인 요청이 있다면 일정 시간 대기했다가, 해당 시간을 초과하면 종료되고 있다.
일정 시간이 초과되면 톰캣의 GracefulShutdown 처리 스레드인 'tomcat-shutdown'가 종료되는 내부 로직은 awaitInactiveOrAborted에 해당한다. 해당 메서드 내부를 살펴보면 aborted 변수가 존재하는데, 앞서 설명하였듯 ' tomcat-shutdown' 스레드가 연결의 pause 완료하면 SpringApplicationShutdownHook이 다른 처리를 진행할 수 있게 되는데, 이로 인해 WebServerStartStopLifecycle 이 실행되고 해당 작업 내부에서 aborted를 true로 바꾸게 되면서, awaitInactiveOrAborted 내부에서 반복의 종료와 함께 GracefulShutdown 작업이 마무리되는 것이다.
WebServerStartStopLifecycle의 구체적인 동작 분석
WebServerStartStopLifecycle는 내부적으로 다음과 같이 구현되어 있으며, 결국 각각의 웹서버 구현체(Tomcat)에게 종료를 요청하게 된다.
그러면 톰캣 내부의 stop을 최종적으로 호출하게 되는데, 여기서 위의 톰캣 GracefulShutdown 클래스의 abort 메서드를 호출하여 aborted 값을 true로 변경하고 있음을 먼저 확인할 수 있다. 이로 인해 아직 처리중인 요청이 남아있음에도 불구하고 'tomcat-shutdown'가 종료되는 것이다.
그리고 내부 호출을 거쳐서 최종적으로 모든 작업 스레드를 종료시키는 NioEndpoint의 stopInternal에 도달하게 된다.
해당 메서드 내부에서는 최종적으로 ThreadPoolExecutor를 종료시키고 있음을 확인할 수 있다.
해당 메서드 내부의 구현의 동작을 분석 및 정리하면 다음과 같다.
- shutdownNow 메서드를 호출하여 작업들을 강제로 종료함
- 작업이 취소될 때 까지 최대 5초 대기함(timeout값이 기본 5초로 설정되어 있음)
앞선 내용에서 shutdown 을 호출해서 이후의 요청들은 받지 않도록 하고, 기존의 요청들은 정상 종료를 시도하는 것이 권장되는 방식이라고 하였는데, 스프링 부트에서 톰캣이 곧바로 shutdownNow를 호출하는 이유는 무엇일까?
그 이유는 이미 선행되었던 단계(Phase)였던 WebServerGracefulShutdownLifecycle 처리에서 요청들을 더 이상 받지 않도록 하였을 뿐만 아니라 정상 종료까지 시도했기 때문이다. 따라서 현재 웹서버를 종료시키고자 하는 WebServerStartStopLifecycle 단계에서는 정상 종료를 위해 또 다시 대기할 필요가 없다.
[ GracefulShutdown을 위한 설정들 ]
앞서 설명하였듯 GracefulShutdown을 통해 종료를 해야 하는데, 처리중인 요청이 데드락에 걸렸거나 무한 루프에서 반복중이라면 서버가 영원히 종료될 수 없다. 따라서 스프링은 각각의 Shutdown 작업들에 대한 timeout을 제공하여 해당 시간을 초과해서도 종료가 되지 않는다면 강제로 종료하는 기능도 제공한다. 별도로 값을 지정하지 않는다면 해당 값은 기본 30초로 설정된다.
spring.lifecycle.timeout-per-shutdown-phase=30s
또한 스프링은 Shutdown 모드를 크게 2가지(GRACEFUL, IMMEDIATE) 제공하지만, 기본적으로 IMMEDIATE로 설정되어 있다. 따라서 GracefulShutdown 기능을 활성화하기 위해서는 다음의 설정을 반드시 해주어야 한다.
server.shutdown=graceful
관련 포스팅
- 멀티 스레드 기반으로 다중 요청을 처리하는 톰캣(Tomcat)의 구조와 동작 방식
- 올바른 스프링 부트 톰캣 애플리케이션 설정 가이드(SpringBoot Tomcat Configuration)
- 스프링 부트 톰캣 애플리케이션의 Graceful Shutdown 동작 방식(Spring Boot Tomcat Graceful Shutdown)
- 요청량이 급증하여 톰캣의 초과 스레드가 활용될 때, TIMED_WAITING 상태의 스레드가 급증하는 이유
참고 자료
- https://docs.spring.io/spring-boot/reference/web/graceful-shutdown.html
- https://docs.spring.io/spring-boot/api/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.html#shutDownGracefully(org.springframework.boot.web.server.GracefulShutdownCallback)
- 자바 병렬 프로그래밍
- 김영한의 실전 자바 고급 1편