Spring Framework

[Spring Boot] NoClassDefFoundError 와 Spring Boot 의 Executable Jar 관계

흥부가귀막혀 2022. 9. 14. 17:51

최근에 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 명령어로 강제 종료시킬 수 밖에 없었습니다.

 

처음 보는 에러가 발생할 때.jpg

 

갑작스러운 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 을 종료하는 방법밖에 없는것으로 알고 있습니다.
(만약에 다른 방법이 있다면 알려주세요!)

Reference