본문으로 바로가기

Spring Boot fat jar에서 서명 검증 라이브러리가 실패한 이유와 해결 방법

수정일: 2026년 4월 6일7분 읽기

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 파일"을 전제한다는 것이므로, 해결 방향은 두 가지다.

  1. 해당 라이브러리를 실행 시점에 실제 파일 시스템 경로에 풀어두기 (requiresUnpack)
  2. 아예 외부 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는 마법이 아니라 런타임 전달 메커니즘을 제공한다. 그 메커니즘이 무엇인지 이해하는 것이, 예상 밖의 문제를 마주쳤을 때 빠르게 원인을 찾는 힘이 된다.