bootJar(spring-boot)에서 package의 class 목록 가져오기


들어가기전에…

spring boot로 만든 어플리케이션에서 package에 속한 class 목록을 전부 가져오고 싶었습니다만… 실패하는 과정에서 알게된 bootJar과 일반 jar의 다른점에 대해서 정리한 글입니다. 결론은 이런 경우에는 좀 더 spring 답게 Annotation으로 해결하라입니다.

테스트용 Spring boot application 만들기

  1. https://start.spring.io/ 에서 아래 설정으로 gradle + kotlin을 사용하도록 생성 합니다. spring-initializer-spring-boot-reflection-demo
  2. IntelliJ에서 Import 합니다.

bootJar에서 특정 package 하위의 class 목록 가져오기.

DemoApplication.kt에 아래와 같이 package에 속한 class 목록을 가져오는 코드를 추가 합니다. 보통 이런 경우 ClassLoader의 getResource를 이용합니다. 가져온 package(resource..?)를 Directory로 보고 File 객체를 만든 후 listFiles()(shell에서 치면 ls)를 실행해서 파일 목록을 가져 온 후, Class.forName() 혹은 ClassLoader.loadClass() 메소드를 이용해 Class 객체를 가져 옵니다. java get class list in package 같은 키워드로 검색하면 java 샘플코드도 많이 나옵니다.

fun main(args: Array<String>) {
  val cls = getClasses("net.ruinnel.demo")
  println("classes - [${cls.joinToString(", ") { it.name }}]")

	runApplication<DemoApplication>(*args)
}

fun getClasses(packageName: String): List<Class<*>> {
  val classLoader = Thread.currentThread().contextClassLoader
  val path = packageName.replace('.', '/')
  val resources = classLoader.getResources(path)

  return resources.toList()
    .map {
      println("url - $it")
      File(it.file)
    }
    .mapNotNull { findClasses(it, packageName, classLoader) }
    .flatten()
}

fun findClasses(directory: File, packageName: String, loader: ClassLoader): List<Class<*>>? {
  if (!directory.exists()) {
    return null
  }
  val files = directory.listFiles()
  return files.mapNotNull { file ->
    when {
      file.isDirectory -> {
        assert(!file.name.contains("."))
        findClasses(file, packageName + "." + file.name, loader)
      }
      file.name.endsWith(".class") -> {
        val className = packageName + '.'.toString() + file.name.substring(0, file.name.length - 6)
        listOf(loader.loadClass(className))
      }
      else -> null
    }
  }
    .flatten()
}
  1. IntelliJ에서 main() 함수를 실행해 봅니다.
url - file:/Users/ruinnel/develop/test/demo/build/classes/kotlin/main/net/ruinnel/demo
classes - [net.ruinnel.demo.DemoApplication, net.ruinnel.demo.DemoApplicationKt$main$1, net.ruinnel.demo.DemoApplicationKt]

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.2.6.RELEASE)

2020-04-26 16:09:37.860  INFO 36075 --- [           main] net.ruinnel.demo.DemoApplicationKt       : Starting DemoApplicationKt on MacBook-Pro.local with PID 36075 (/Users/ruinnel/develop/test/demo/build/classes/kotlin/main started by ruinnel in /Users/ruinnel/develop/test/demo)
2020-04-26 16:09:37.862  INFO 36075 --- [           main] net.ruinnel.demo.DemoApplicationKt       : No active profile set, falling back to default profiles: default
2020-04-26 16:09:39.484  INFO 36075 --- [           main] net.ruinnel.demo.DemoApplicationKt       : Started DemoApplicationKt in 2.023 seconds (JVM running for 2.709)

Process finished with exit code 0
  1. project root에서 ./gradlew bootJar를 실행해 bootJar을 생성 후 실행해봅니다.
MacBook-Pro:libs ruinnel$ java -jar demo-0.0.1-SNAPSHOT.jar
url - jar:file:/Users/ruinnel/develop/test/demo/build/libs/demo-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/net/ruinnel/demo
classes - []

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.2.6.RELEASE)

2020-04-26 16:19:05.980  INFO 37964 --- [           main] net.ruinnel.demo.DemoApplicationKt       : Starting DemoApplicationKt on MacBook-Pro.local with PID 37964 (/Users/ruinnel/develop/test/demo/build/libs/demo-0.0.1-SNAPSHOT.jar started by ruinnel in /Users/ruinnel/develop/test/demo/build/libs)
2020-04-26 16:19:05.984  INFO 37964 --- [           main] net.ruinnel.demo.DemoApplicationKt       : No active profile set, falling back to default profiles: default
2020-04-26 16:19:07.506  INFO 37964 --- [           main] net.ruinnel.demo.DemoApplicationKt       : Started DemoApplicationKt in 2.681 seconds (JVM running for 3.459)
MacBook-Pro:libs ruinnel$

음? bootJar에서 실행한 결과에서는 Class 목록을 가져오지 못합니다.!!?

분명히 같은 소스인데 IntelliJ에서 실행한 것과 bootJar로 만들어서 실행한 결과가 다릅니다.

# IntelliJ 에서 실행한 결과
url - file:/Users/ruinnel/develop/test/demo/build/classes/kotlin/main/net/ruinnel/demo
classes - [net.ruinnel.demo.DemoApplication, net.ruinnel.demo.DemoApplicationKt$main$1, net.ruinnel.demo.DemoApplicationKt]

# bootJar을 실행한 결과
url - jar:file:/Users/ruinnel/develop/test/demo/build/libs/demo-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/net/ruinnel/demo
classes - []

url은 package를 resource로 가져 온 값을 찍은 것인데 이 값도 서로 다릅니다. IntelliJ에서 실행한 것은 file:, bootJar은 jar:로 시작합니다.

아무리 봐도 File 클래스의 생성자가 jar:file:/...!/ 이런 path로 제대로 된 원하는 위치에 접근 가능한 객체를 생성해 줄거 같지 않습니다.

그러면 bootJar에서도 Class를 가져오려면 jar파일 내의 Class를 가져오는 방법으로 시도해 보면 될거 같습니다.

Get list of classes in package 같은 키워드로 검색해보면 jar 파일에서 Class를 가져오는 법에 대한 예제도 많습니다.

ex) https://www.rgagnon.com/javadetails/java-0513.html

그런데… 시도하지 마세요 안됩니다.

bootJar을 실행했을때 로그를 다시 살펴보면…

url - jar:file:/Users/ruinnel/develop/test/demo/build/libs/demo-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/

url의 뒷쪽을 보면 BOOT-INF/ 라는게 보입니다. 일반적인 jar 파일의 경우 zip으로 확장자를 변환 후 압축을 풀어보면 아래와 같은 구조를 가집니다.

.
├── META-INF
│   └── MANIFEST.MF
└── net
    └── ruinnel
        └── demo
            └── package
                ├── YourClass1.class
                └── YourClass2.class

위에서 실행했던 bootJar을 동일하게 압축을 풀어봅시다.

.
├── BOOT-INF
│   ├── classes
│   │   ├── META-INF
│   │   │   └── demo.kotlin_module
│   │   ├── application.properties
│   │   └── net
│   │       └── ruinnel
│   │           └── demo
│   │               ├── DemoApplication.class
│   │               ├── DemoApplicationKt$main$1.class
│   │               └── DemoApplicationKt.class
│   └── lib
│       ├── spring-core-5.2.5.RELEASE.jar
│       └── ....
├── META-INF
│   └── MANIFEST.MF
└── org
    └── springframework
        └── boot
            └── loader
                ├── ExecutableArchiveLauncher.class
                ├── JarLauncher.class
                ├── LaunchedURLClassLoader$UseFastConnectionExceptionsEnumeration.class
                ├── LaunchedURLClassLoader.class
                ├── Launcher.class
                ├── MainMethodRunner.class
                ├── PropertiesLauncher$1.class
                ├── PropertiesLauncher$ArchiveEntryFilter.class
                ├── PropertiesLauncher$PrefixMatchingArchiveFilter.class
                ├── PropertiesLauncher.class
                ├── WarLauncher.class
                └── .....

내가 짠 코드들이 컴파일된 class 파일들이 BOOT-INF아래에 들어가 있고 거기다 META-INF/MANIFEST.MF를 열어 Main-Class를 확인해보면… org.springframework.boot.loader.JarLauncher 라는 값이 지정되어 있습니다.

MacBook-Pro:demo ruinnel$ cat META-INF/MANIFEST.MF
Manifest-Version: 1.0
Start-Class: net.ruinnel.demo.DemoApplicationKt
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Version: 2.2.6.RELEASE
Main-Class: org.springframework.boot.loader.JarLauncher

사실 당연합니다. bootJar은 웹서버도 embedded 되어있으니 그것도 실행하고 내가 만든건 그 위에 돌아가야 될거니까 내가 만든 Class가 시작점이 될 수는 없으니까요.

그건 그렇다고 치고… java에서 jar 파일 spec에는 BOOT-INF 같은거 눈씻고 찾아봐도 안보입니다… 그러니 일반적인 jar 파일에서 class를 찾는 방법으로는 BOOT-INF아래에 있는 class들은 접근이 불가능 합니다.

DemoApplication.ktgetClasses 함수에 print 문을 하나 추가합니다.

fun getClasses(packageName: String): List<Class<*>> {
  val classLoader = Thread.currentThread().contextClassLoader
  println("class-loader - $classLoader")
  // ... 중략..
}

당연히 IntelliJ에서 실행결과와 bootJar을 실행한 결과는 서로 다르게 나옵니다. 그리고 bootJar의 경우 org.springframework.boot.loader.LaunchedURLClassLoader라는 값이 나옵니다. 그러니까.. URLClassLoader를 상속해 'BOOT-INF'안에 있는 class들을 사용 할 수 있게 별도의 ClassLoader를 사용하고 있는 것입니다.

그리고 기본적으로 ClassLoader 접근가능한 메소드들을 보면 package 아래의 class 목록을 가져오는 메소드는 보이지 않습니다. LaunchedURLClassLoader의 메소드 목록을 봐도 역시 없습니다.

bootJar의 압축을 풀고 BOOT-INF 하위의 클래스를 읽어들여서 가져오는 방법도 있겠으나… 런타임에 그런 방법까지 쓰고 싶지는 않습니다. 여기까지 파고 들었는데 깔끔한 해결 방법이 안나온 다는건 접근 방법이 잘못된 거라고 보고 깔끔하게 포기해야지요.

결국 커스텀 Annotation을 만들고 Class에 붙이고 그 커스텀 Annotation이 붙은 객체(Bean)를 가져오는 방식으로 해결 하였습니다.

사실 이 방법이 더 Spring 스러운? 방법 인건 처음부터 알았습니다만… 정확히는 객체가 아니라 Class를 가져오려고 시작한 삽질이었습니다.

객체에서 Class를 가져오는 건 간단하지만…. 이런 구조면 Spring이 Class를 읽어와 new 해서 객체를 만들어서 Bean으로 등록하고 그 Bean을 가져와서 거기서 Class를 가져오는 형태가 되므로 불필요한 객체가 생성되게 됩니다.

하지만… 삽질끝에 불필요한 객체 생성를 무시하는 것이 차라리 낫다고 판단 했습니다. Spring에서 Bean으로 Class의 객체를 등록해 볼까도 생각했지만… 이건 또 다른 삽질의 시작일거 같아서 접었습니다.

결론

  • reflection 코드는 IntelliJ에서 돌아가는 코드라고 배포 되어서(bootJar에서)도 잘 동작 할 거라 확신 하지 마라.
  • bootJar은 일반적인 Jar 파일과 구성이 다르다.(BOOT-INF)
  • bootJar의 실행 클래스(Main-Class)는 내가 만든 클래스가 아니다. (당연하다)
  • spring에서 Class 목록을 가져 오려면 그냥 커스텀 Annotation 붙이고 applicationContext.getBeansWithAnnotation로 긁어 와라.
comments powered by Disqus