[Spring Boot] NoClassDefFoundError 와 Spring Boot 의 Executable Jar 관계
최근에 Spring Boot 로 개발된 Web Application 을 운영하던 도중에 아래와 같은 Error 를 맞딱뜨렸습니다.
2022-08-30 11:07:35.485 WARN 3038 --- [SpringContextShutdownHook] o.s.c.support.DefaultLifecycleProcessor : Failed to stop bean 'webServerGracefulShutdown'
java.lang.BootstrapMethodError: java.lang.NoClassDefFoundError: org/springframework/boot/web/server/GracefulShutdownResult
at org.springframework.boot.web.servlet.context.WebServerGracefulShutdownLifecycle.stop(WebServerGracefulShutdownLifecycle.java:51)
at org.springframework.context.support.DefaultLifecycleProcessor.doStop(DefaultLifecycleProcessor.java:238)
at org.springframework.context.support.DefaultLifecycleProcessor.access$300(DefaultLifecycleProcessor.java:53)
at org.springframework.context.support.DefaultLifecycleProcessor$LifecycleGroup.stop(DefaultLifecycleProcessor.java:377)
at org.springframework.context.support.DefaultLifecycleProcessor.stopBeans(DefaultLifecycleProcessor.java:210)
at org.springframework.context.support.DefaultLifecycleProcessor.onClose(DefaultLifecycleProcessor.java:128)
at org.springframework.context.support.AbstractApplicationContext.doClose(AbstractApplicationContext.java:1022)
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.doClose(ServletWebServerApplicationContext.java:170)
at org.springframework.context.support.AbstractApplicationContext$1.run(AbstractApplicationContext.java:949)
Caused by: java.lang.NoClassDefFoundError: org/springframework/boot/web/server/GracefulShutdownResult
... 9 common frames omitted
Caused by: java.lang.ClassNotFoundException: org.springframework.boot.web.server.GracefulShutdownResult
at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at org.springframework.boot.loader.LaunchedURLClassLoader.loadClass(LaunchedURLClassLoader.java:151)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
... 9 common frames omitted
2022-08-30 11:08:05.486 INFO 3038 --- [SpringContextShutdownHook] o.s.c.support.DefaultLifecycleProcessor : Failed to shut down 1 bean with phase value 2147483647 within timeout of 30000ms: [webServerGracefulShutdown]
2022-08-30 11:08:35.514 INFO 3038 --- [SpringContextShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2022-08-30 11:08:35.519 INFO 3038 --- [SpringContextShutdownHook] o.s.s.c.ThreadPoolTaskScheduler : Shutting down ExecutorService 'taskScheduler'
2022-08-30 11:08:35.520 INFO 3038 --- [SpringContextShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2022-08-30 11:08:35.528 INFO 3038 --- [SpringContextShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
2022-08-30 11:08:35.530 INFO 3038 --- [SpringContextShutdownHook] o.s.s.c.ThreadPoolTaskScheduler : Shutting down ExecutorService
Application 을 배포하기 위해 종료하던 과정에서 발생하였는데, 해당 Error 로 인해 Application 이 정상적으로 종료되지 않았고 결국 kill -9
명령어로 강제 종료시킬 수 밖에 없었습니다.
갑작스러운 Error 였고 언제 다시 발생할지 모른다는 불안감에 오랜 시간 해당 Error 가 발생한 원인을 분석해보았는데 그 결과 위 에러는 SpringBoot 의 Executable Jar 동작 방식과 연관이 있었습니다.
Java 는 Nested Jar 파일 (즉, jar 안에 jar 가 들어가있는 파일)을 로드하는 표준 방법을 제공하지 않습니다.
그래서 Spring Boot 에서는 자체적으로 Nested Jar 파일을 실행하기 위한 Jar 파일 구조를 정의하고 Loader 를 구현하였습니다.
Spring Boot Loader 를 사용하기 위한 Jar 파일 구조는 다음과 같이 정의되어있습니다.
example.jar
|
+-META-INF
| +-MANIFEST.MF
+-org
| +-springframework
| +-boot
| +-loader
| +-<spring boot loader classes>
+-BOOT-INF
+-classes
| +-mycompany
| +-project
| +-YourClasses.class
+-lib
+-dependency1.jar
+-dependency2.jar
Application Service 에 관련된 코드(개발자가 작성한 코드) 는 BOOT-INF/classes
경로에 정리되고,
의존성이 추가된 lib 들은 BOOT-INF/lib
에 있도록 정의하였습니다.
실제 spring-boot-maven-plugin
을 통해 만들어진 jar 결과물을 보면 위와 같은 구조를 확인할 수 있고
추가적으로 Spring Boot 에서 구현한 JarLauncher 구현 내용을 보시면 위 경로에 대해 정의되어 있는걸 확인하실 수 있습니다.
JarLauncher 는 Spring Boot 에서 jar 형태로 빌드된 Spring Boot Application 을 실행하기 위해 만들어진 Launcher Class 입니다.
그래서 jar 로 빌드된 결과물의 Main Class 는 해당 Launcher 로 지정이 되야 정상적으로 실행이 가능합니다. 실제 Spring Boot Jar 에서 META-INF/MANIFEST.MF
내용을 확인해보면 아래와 같이 정의된걸 확인할 수 있습니다.
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.mycompany.project.MyApplication
이처럼 java 에서 수행할 Main-Class 는 Spring Boot 에서 구현한 JarLauncher 로 지정된걸 알 수 있습니다.
그렇다면 Spring Boot 에서는 jar 에 안에 있는 각 class 와 jar 들은 어떻게 Load 하는 걸까요?
Spring Boot 는 아래와 같이 각 class 와 jar 의 파일 offset 값을 기록해 둡니다. 그리하여 필요할 때마다 해당 offset 에 있는 class 와 jar 파일을 load 할 수 있도록 구현을 해 두었습니다.
myapp.jar
+-------------------+-------------------------+
| /BOOT-INF/classes | /BOOT-INF/lib/mylib.jar |
|+-----------------+||+-----------+----------+|
|| A.class ||| B.class | C.class ||
|+-----------------+||+-----------+----------+|
+-------------------+-------------------------+
^ ^ ^
0063 3452 3980
이렇게 했을 때 장점은 jar 파일의 압축을 풀거나 모든 항목 데이터를 메모리로 읽지 않아도 필요시 바로 바로 찾아서 Load 할 수 있다는 점입니다.
반대로 단점은 jar 파일이 변경되었을 경우 jar 파일 안에 class 나 jar 파일이 존재함에도 불구하고 제가 위에서 경험한 NoClassDefFoundError
가 발생할 수 있습니다.
jar 파일이 변경되기 전에 이미 Load 된 class 나 jar 파일이면 상관이 없지만, 아직 Load 가 안된 상태에서 jar 파일이 변경된 경우 그리고 이로 인해 각 class 와 jar 파일의 offset 정보가 달라졌을 경우 jar 안에 class 나 jar 파일이 존재하더라도 class 를 찾을 수 없는 상태가 되기 때문에 NoClassDefFoundError
, ClassNotFoundException
이 발생하게 됩니다.
제가 맞딱뜨렸던 Error 상황은 배포 시나리오가 잘못되어 빌드된 jar 파일을 application 종료 전에 먼저 배포하게 되었고
종료시그널을 받았을 때 처음으로 Load 된 GracefulShutdownResult
class 로 인해 위와 같은 현상이 발생하게 된 것이었습니다.
현재 해당 현상을 해결할 수 있는 방법은 jar 파일을 교체하기 전에 반드시 Application 을 종료하는 방법밖에 없는것으로 알고 있습니다.
(만약에 다른 방법이 있다면 알려주세요!)