1. 들어가며
공공프로젝트를 진행하다보면, 암호모듈들은 모두 KCMVP 승인을 득한 제품들만을 사용할 수 있다. 그래서 오픈소스 라이브러리를 사용하기 보다는, 외부 솔루션사의 jar를 사용하는 경우가 많다. 이를 연동하면서 발생했던 내용을 기록하여 나와 같은 일로 인하여 시간을 낭비하는 사람이 없기를 소망한다.
이 글에서는 다음 내용을 정리한다.
boot jar는 내부적으로 어떤 구조로 동작하는가- 암복호화 모듈의 서명 검증은 어떤 전제를 갖고 있는가
- 왜 그런 전제가
boot jar에서는 깨질 수 있는가 - 이를 해결하기 위해
PropertiesLauncher를 어떻게 적용하였는가
2. boot jar의 구조와 동작 원리
Spring Boot의 boot jar는 다음과 같은 구조를 가진다.
app.jar
├── META-INF/
│ └── MANIFEST.MF
├── org/springframework/boot/loader/...
├── BOOT-INF/
│ ├── classes/
│ │ └── com/example/DemoApplication.class
│ └── lib/
│ ├── crypto-module.jar
│ ├── bcpkix.jar
│ └── bcprov.jar
여기서 중요한 점은 의존 라이브러리가 BOOT-INF/lib아래에 그대로 nested jar형태로 들어간다는 것이다.
즉 일반적인 파일 시스템 상의 독립적으로 존재하는 jar가 아니라 jar 내부에 jar가 다시 들어있는 구조이다.
2-1 Spring Boot Loader가 애플리케이션을 시작하는 방식
Spring Boot excutable jar는 애플리케이션의 main()을 바로 실행하지 않는다.
실제로는 Launcher가 먼저 실행되어 클래스패스를 구성한 후, 애플리케이션의 main()을 호출한다.
예를 들어 MANIFEST.MF는 아래의 형태를 가진다.
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.lch275.DemoApplication
실행 흐름을 단순화하면 다음과 같다.
java -jar app.jar
→ JarLauncher 실행
→ BOOT-INF/classes, BOOT-INF/lib 스캔
→ 전용 ClassLoader 구성
→ Start-Class 실행
3. 서명 검증 라이브러리와 boot jar의 충돌
3.1 서명 검증 라이브러리가 기대하는 전제
암복호화 모듈이나 보안 라이브러리는 종종
자기 자신이 서명된 JAR인지 확인하거나,
로드된 코드의 CodeSource와 인증서 체인을 검사한다.
아래는 단순화한 예시다.
package com.example.security;
import java.security.CodeSource;
import java.security.ProtectionDomain;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
public final class ModuleIntegrityVerifier {
private ModuleIntegrityVerifier() {}
public static void verify(Class<?> targetClass) {
ProtectionDomain pd = targetClass.getProtectionDomain();
CodeSource codeSource = pd.getCodeSource();
if (codeSource == null) {
throw new IllegalStateException("CodeSource가 없습니다.");
}
Certificate[] certs = codeSource.getCertificates();
if (certs == null || certs.length == 0) {
throw new SecurityException("서명 인증서를 찾을 수 없습니다.");
}
boolean trusted = false;
for (Certificate cert : certs) {
if (cert instanceof X509Certificate x509) {
String subject = x509.getSubjectX500Principal().getName();
if (subject.contains("CN=MyTrustedSigner")) {
trusted = true;
break;
}
}
}
if (!trusted) {
throw new SecurityException("허용되지 않은 서명자입니다.");
}
}
}
3.2 왜 boot jar에서는 이런 전제가 깨질 수 있는가
Certificate[]를 읽는 수준의 검증은 일반적으로 boot jar에서도 동작한다.
문제는 라이브러리가 자기 자신을 파일 시스템의 독립 JAR로 전제하는 경우다. 예를 들어 아래와 같은 코드가 있다고 하자.
package com.example.security;
import java.net.URL;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.jar.JarFile;
public final class NaiveJarVerifier {
private NaiveJarVerifier() {}
public static void verifyOwnJar(Class<?> targetClass) throws Exception {
URL location = targetClass.getProtectionDomain()
.getCodeSource()
.getLocation();
// boot jar 환경에서 location은 아래와 같은 형태가 된다:
// jar:file:/path/to/app.jar!/BOOT-INF/lib/crypto-module.jar!/
// 이 URL은 Paths.get(location.toURI())로 변환할 수 없다.
// → FileSystemNotFoundException 발생
Path jarPath = Paths.get(location.toURI());
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
if (jarFile.getManifest() == null) {
throw new SecurityException("MANIFEST가 없습니다.");
}
}
}
}
이 코드는 다음 전제를 갖는다.
CodeSource.getLocation()이 실제 파일 시스템 경로를 반환한다- 그 경로가 라이브러리 자신의 독립 JAR 파일이다
new JarFile(...)로 열 수 있다
하지만 boot jar 환경에서 nested jar의 CodeSource.getLocation()은 다음 형태를 반환한다.
jar:file:/path/to/app.jar!/BOOT-INF/lib/crypto-module.jar!/
이 URL은 Paths.get(location.toURI())로 변환이 불가능하다.
java.nio.file.FileSystemNotFoundException이 발생하거나,
변환에 성공하더라도 outer jar 경로(app.jar)로 해석되는 등
라이브러리가 기대한 동작과 전혀 다른 결과가 나온다.
그 결과 발생할 수 있는 문제를 정리하면 다음과 같다.
| 기대 동작 | boot jar에서 실제 동작 |
|---|---|
new JarFile(jarPath.toFile()) 성공 | FileSystemNotFoundException |
CodeSource.getLocation() → 자기 JAR 경로 | outer jar(app.jar) 또는 nested URL |
Certificate[] 정상 로드 | nested jar URL 처리 방식에 따라 null 가능 |
jarsigner로 검증 가능한 서명 확인 | outer jar 기준으로 검증됨 |
3.3 signed JAR 자체는 어떻게 검증하는가
서명된 JAR은 클래스 파일 외에 서명 메타데이터를 포함한다.
META-INF/MANIFEST.MF
META-INF/SIGNER.SF
META-INF/SIGNER.RSA
개발 단계에서 직접 검증할 때는 jarsigner를 사용한다.
# 기본 검증
jarsigner -verify -verbose -certs crypto-module.jar
# 더 엄격한 검증
jarsigner -verify -strict -verbose -certs crypto-module.jar
중요한 점은 JAR 파일 자체의 서명 검증은 가능하지만,
문제는 그 signed JAR을 boot jar 내부의 nested jar로 넣었을 때
라이브러리의 런타임 검증 로직이 같은 전제를 유지하지 못할 수 있다는 것이다.
4. 해결 방법
문제의 본질이 라이브러리가 "독립 JAR 파일"을 전제한다는 것이므로, 해결 방향은 두 가지다.
- 해당 라이브러리를 실행 시점에 실제 파일 시스템 경로에 풀어두기 (
requiresUnpack) - 아예 외부
libs/디렉터리에 두고 클래스패스에 추가하기 (PropertiesLauncher)
4.1 방법 1: requiresUnpack
requiresUnpack은 특정 JAR을 실행 시 임시 디렉터리에 unpack해서
파일 시스템 경로를 확보하는 Spring Boot 공식 기능이다.
boot jar 단일 파일 배포 구조를 유지하면서 문제를 해결할 수 있어
대부분의 경우 이 방법이 더 적합하다.
// build.gradle (Groovy DSL)
tasks.named("bootJar") {
requiresUnpack("**/crypto-module.jar")
}
// build.gradle.kts (Kotlin DSL)
tasks.named<BootJar>("bootJar") {
requiresUnpack("**/crypto-module.jar")
}
실행 시 Spring Boot Loader는 해당 JAR을 $TMPDIR/spring-boot-libs/ 등의
임시 경로에 풀어서 로드한다. 라이브러리 입장에서는 실제 파일 경로를 갖게 되므로
CodeSource.getLocation()이 유효한 file: URL을 반환하게 된다.
4.2 방법 2: PropertiesLauncher + 외부 libs 분리
검증 대상 라이브러리를 BOOT-INF/lib 안의 nested jar로 두지 않고,
외부 libs/ 디렉터리의 실제 JAR 파일로 두는 방법이다.
PropertiesLauncher는 loader.path를 통해 외부 디렉터리를 클래스패스에 포함할 수 있다.
Gradle 설정 예시
// build.gradle (Groovy DSL)
tasks.named("bootJar") {
manifest {
attributes(
"Main-Class": "org.springframework.boot.loader.PropertiesLauncher"
// Start-Class는 Spring Boot 플러그인이 자동으로 설정한다
)
}
// boot jar에서 해당 라이브러리를 제외한다
exclude("BOOT-INF/lib/crypto-module.jar")
}
tasks.register("copySecureLibs", Copy) {
// 특정 라이브러리만 선별해서 외부 libs로 복사
from(configurations.runtimeClasspath.filter {
it.name.contains("crypto-module")
})
into("${buildDir}/libs/libs")
}
tasks.named("bootJar") {
dependsOn("copySecureLibs")
}
빌드 결과는 다음과 같은 형태가 된다.
build/libs/
├── app.jar ← boot jar (crypto-module.jar 제외)
└── libs/
└── crypto-module.jar ← 실제 파일 시스템 경로
실행 방법
# loader.path는 시스템 프로퍼티 또는 loader.properties로 전달
java -Dloader.path=libs -jar build/libs/app.jar
또는 loader.properties 파일을 클래스패스 루트에 두어도 된다.
# src/main/resources/loader.properties
loader.path=libs
주의:
MANIFEST.MF의Loader-Path헤더는 공식 지원이 아니므로loader.path는 반드시 시스템 프로퍼티나loader.properties로 전달해야 한다.
방법 2의 트레이드오프
| 항목 | 내용 |
|---|---|
| 장점 | 보안 모듈이 실제 파일 JAR로 존재, 독립적 무결성 검증 가능 |
| 단점 | 배포 산출물이 jar 1개로 끝나지 않음 |
| 단점 | libs/ 누락 시 즉시 ClassNotFoundException 장애 |
| 단점 | 운영 배포 절차에 디렉터리 구성 단계 추가 필요 |
4.3 어떤 방법을 선택할까
일반적으로 아래 기준으로 판단하면 된다.
requiresUnpack: 단일 jar 배포를 유지하고 싶고, 해당 라이브러리가 unpack 후 정상 동작한다면 이 방법이 먼저다PropertiesLauncher: 라이브러리가 unpack만으로 해결이 안 되거나, 특정 파일 경로에 명시적으로 위치해야 한다면 이 방법을 쓴다
나의 경우에는 빌드된 docker image를 업로드하는 방식이어서 반드시 단일 jar 배포를 유지할 필요가 없었고 솔루션사에서 fat jar 형식으로 사용을 하면 버그가 발생할 수도 있다고 하여, PropertiesLauncher를 사용하였다.
5. 회고: Spring Boot가 기능을 전달하는 방식
이번 문제를 겪기 전까지는 Spring Boot의 boot jar를
"의존성이 포함된 실행 가능한 JAR" 정도로만 이해하고 있었다.
하지만 실제로는 그보다 훨씬 구체적이다.
boot jar는 단순 압축 결과물이 아니다JarLauncher가 런타임 클래스패스를 구성한다- 의존성 전달 방식 자체가 일반 JAR 실행과 다르다
- 따라서 일부 라이브러리는 이 추상화 위에서 정상 동작하지 않을 수 있다
문제의 본질은 "보안 라이브러리가 이상하다"가 아니었다. Spring Boot가 애플리케이션 기능을 전달하는 방식과 라이브러리가 기대하는 로딩 모델이 달랐다는 것이 원인이었다.
어떤 기능이든 프레임워크가 감싸서 전달할 때는, 그 전달 방식이 라이브러리의 전제와 충돌하지 않는지까지 봐야 한다. Spring Boot는 많은 것을 자동화해주지만, 그 자동화는 결국 특정 실행 모델 위에서만 성립한다.
6. 마무리
정리하면 다음과 같다.
boot jar는nested jar기반의 실행 모델이다- 일부 서명 검증 라이브러리는 독립 JAR 파일 전제를 가진다
- nested jar의
CodeSource.getLocation()은jar:file:/app.jar!/BOOT-INF/lib/...형태를 반환하며, 이는 파일 시스템 경로로 변환할 수 없다 - 이 충돌을 해결하는 방법은
requiresUnpack(단일 jar 유지) 또는PropertiesLauncher+ 외부libs/분리다 - 두 방법 모두 라이브러리에게 "파일 시스템의 독립 JAR"이라는 전제를 복원해주는 전략이다
Spring Boot는 마법이 아니라 런타임 전달 메커니즘을 제공한다. 그 메커니즘이 무엇인지 이해하는 것이, 예상 밖의 문제를 마주쳤을 때 빠르게 원인을 찾는 힘이 된다.