Spring Boot Executable JAR에서 JSP 접근 불가 문제 해결
배경
Spring Framework 2.5.6 기반의 레거시 시스템을 Spring Boot 2.7.18로 업그레이드하는 작업을 진행했다. 기존 시스템은 EAR 파일로 패키징하여 JavaEE 스펙인 외부 JEUS에 배포하는 방식이었는데, Spring Boot로 전환하면서 Executable JAR(bootJar) 형태로 패키징해야 했다.
Executable JAR를 선택한 이유
- 외부 WAS 의존성 제거 (내장 Tomcat 사용)
- 배포 단순화 (
java -jar로 실행) - 컨테이너 환경(Docker/Kubernetes) 대응 용이
- 운영 환경 표준화
그러나 레거시 시스템에는 수백 개의 JSP 파일이 존재했고, 이를 당장 Thymeleaf 등으로 마이그레이션하는 것은 현실적으로 불가능했다. JSP를 유지하면서 Executable JAR로 패키징해야 하는 상황이었다.
문제 상황
Spring Boot 애플리케이션을 bootJar로 패키징한 후 실행했더니 JSP 페이지에 접근할 수 없었다. 분명 src/main/webapp 디렉토리에 JSP 파일들이 존재하는데, 404 에러가 발생했다.
Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.
...
There was an unexpected error (type=Not Found, status=404).
처음에는 ViewResolver 설정 문제인 줄 알았다. application.properties에서 prefix/suffix 설정을 확인해봤지만 정상이었다.
spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp
IDE에서 실행할 때는 잘 되는데, JAR로 패키징하면 안 되는 상황이었다. "그러면 Gradle에서 webapp 파일을 JAR에 포함시키면 되지 않을까?" 생각했다.
원인 분석
Executable JAR의 구조적 한계
Spring Boot의 Executable JAR는 일반적인 WAR 파일과 구조가 다르다.
WAR 파일 구조:
myapp.war
├── WEB-INF/
│ ├── classes/
│ ├── lib/
│ └── views/
│ └── index.jsp
├── META-INF/
└── index.jsp
Spring Boot Executable JAR 구조:
myapp.jar
├── BOOT-INF/
│ ├── classes/
│ │ └── (컴파일된 클래스들)
│ └── lib/
│ └── (의존성 JAR들)
├── META-INF/
│ └── MANIFEST.MF
└── org/springframework/boot/loader/
└── (Spring Boot Loader 클래스들)
문제의 핵심은 src/main/webapp 디렉토리가 Executable JAR에 포함되지 않는다는 것이다.
Gradle의 기본 동작
Gradle의 bootJar 태스크는 다음 위치만 JAR에 포함한다:
| 소스 위치 | JAR 내 위치 | 포함 여부 |
|---|---|---|
src/main/java |
BOOT-INF/classes |
O |
src/main/resources |
BOOT-INF/classes |
O |
src/main/webapp |
- | X |
webapp 디렉토리는 WAR 패키징 전용이다. JAR 패키징 시에는 완전히 무시된다.
Gradle 복사만으로 해결되지 않는 이유
"그러면 Gradle에서 src/main/webapp을 META-INF/resources로 복사하면 되지 않을까?" 생각했다.
task copyWebappToResources(type: Copy) {
from 'src/main/webapp'
into 'build/resources/main/META-INF/resources'
}
이렇게 하면 JAR 내부에 JSP 파일이 포함된다:
myapp.jar
└── BOOT-INF/
└── classes/
└── META-INF/
└── resources/
└── WEB-INF/
└── views/
└── index.jsp
그런데 이것만으로는 동작하지 않았다. 이유는 다음과 같다.
1. Servlet 스펙의 제약
Servlet 스펙에 따르면 META-INF/resources는 WEB-INF/lib에 패키징된 JAR에서만 로드되어야 한다. 주 아카이브(메인 JAR) 내의 META-INF/resources에서는 로드되면 안 된다.
Spring Boot 1.4.3부터 이 스펙을 엄격히 준수하기 시작했다. 따라서 BOOT-INF/classes/META-INF/resources에 있는 리소스는 Tomcat이 자동으로 인식하지 않는다.
2. 경로 불일치
┌─────────────────────────────────────────────────────────────┐
│ Gradle 복사 결과 │
│ → BOOT-INF/classes/META-INF/resources/WEB-INF/views/*.jsp │
└─────────────────────────────────────────────────────────────┘
≠
┌─────────────────────────────────────────────────────────────┐
│ Tomcat이 탐색하는 위치 (Servlet 스펙 기준) │
│ → WEB-INF/lib/*.jar 내부의 META-INF/resources │
└─────────────────────────────────────────────────────────────┘
Tomcat은 BOOT-INF/classes 안에 있는 META-INF/resources를 웹 리소스로 인식하지 않는다.
3. ClassLoader 계층 구조 문제
Spring Boot의 Executable JAR는 특수한 ClassLoader 구조를 사용한다:
┌─────────────────────────────────────────┐
│ AppClassLoader │
│ URL: file:/path/to/myapp.jar │
│ (fat jar 자체만 포함) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ LaunchedURLClassLoader │
│ URL: jar:file:/path/to/myapp.jar!/ │
│ BOOT-INF/classes/ │
│ BOOT-INF/lib/*.jar │
└─────────────────────────────────────────┘
AbstractEmbeddedServletContainerFactory.getUrlsOfJarsWithMetaInfResources() 메서드는 ClassLoader의 URL을 순회하며 META-INF/resources가 있는 JAR를 찾는다. 그러나:
- AppClassLoader는 fat jar URL만 반환한다
- LaunchedURLClassLoader는
BOOT-INF내부 경로만 포함한다 - 이 메서드는 부모 ClassLoader를 탐색하지 않는다
결과적으로 BOOT-INF/classes/META-INF/resources는 Tomcat에 등록되지 않는다.
4. Spring Boot 팀의 공식 입장
이 문제는 GitHub Issue #8324에서 논의되었다. Spring Boot 팀의 Andy Wilkinson은 다음과 같이 설명했다:
"Servlet 스펙은
META-INF/resources의 리소스가WEB-INF/lib에 패키징된 JAR에서만 로드되어야 하며, 주 아카이브 내의META-INF/resources에서는 로드되지 않아야 한다고 명시하고 있습니다."
이 이슈는 "status: declined"로 종료되었다. Spring Boot 팀은 BOOT-INF/classes/META-INF/resources 지원을 공식적으로 거부했다.
해결/대응 전략
Gradle 복사만으로는 부족하다. 두 가지 전략을 함께 적용해야 한다.
전략 1: META-INF/resources로 복사 (필요조건)
먼저 JSP 파일을 JAR 내부에 포함시켜야 한다.
// webapp 디렉토리를 META-INF/resources로 복사
task copyWebappToResources(type: Copy) {
from 'src/main/webapp'
into 'build/resources/main/META-INF/resources'
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
// processResources가 webapp 복사 이후에 실행되도록
processResources {
dependsOn copyWebappToResources
}
// bootJar 설정
bootJar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
주의: 이것만으로는 부족하다! 위에서 설명한 대로 Tomcat이 이 경로를 자동으로 인식하지 않기 때문이다.
전략 2: TomcatServletWebServerFactory 커스터마이징 (충분조건)
Tomcat에 명시적으로 리소스 경로를 등록해야 한다.
@Configuration
public class JspConfiguration {
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatCustomizer() {
return factory -> factory.addContextCustomizers(context -> {
// JAR 스캔 필터 설정
context.getJarScanner().setJarScanFilter((jarScanType, jarName) -> true);
WebResourceRoot resources = new StandardRoot(context);
try {
URL resourceUrl = getClass().getClassLoader()
.getResource("META-INF/resources");
if (resourceUrl != null) {
String protocol = resourceUrl.getProtocol();
if ("jar".equals(protocol)) {
// JAR 내부 리소스 등록
String jarPath = resourceUrl.toString();
int separatorIndex = jarPath.indexOf("!/");
if (separatorIndex > 0) {
// jar:file:/path/to/file.jar!/META-INF/resources
String jarFileUrl = jarPath.substring(4, separatorIndex);
jarFileUrl = jarFileUrl.substring(5); // "file:" 제거
resources.addPreResources(
new JarResourceSet(resources, "/",
jarFileUrl, "/META-INF/resources")
);
}
} else {
// IDE 실행 시 (파일 시스템)
File resourceFile = new File(resourceUrl.toURI());
resources.addPreResources(
new DirResourceSet(resources, "/",
resourceFile.getAbsolutePath(), "/")
);
}
}
} catch (Exception e) {
throw new RuntimeException(
"Failed to configure JSP resources", e);
}
context.setResources(resources);
});
}
}
이 설정이 하는 일:
┌─────────────────────────────────────────────────────────────┐
│ 요청: /WEB-INF/views/index.jsp │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Tomcat WebResourceRoot │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PreResources (우선 탐색) │ │
│ │ → JarResourceSet: JAR!/META-INF/resources/ │ │
│ │ 또는 │ │
│ │ → DirResourceSet: 파일시스템 경로 │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 매핑: / → META-INF/resources/ │
│ 결과: META-INF/resources/WEB-INF/views/index.jsp 찾음 │
└─────────────────────────────────────────────────────────────┘
두 전략이 모두 필요한 이유
| 전략 | 역할 | 단독 적용 시 |
|---|---|---|
| 전략 1 (Gradle 복사) | JAR 내부에 JSP 파일 포함 | Tomcat이 인식 못함 |
| 전략 2 (Tomcat 설정) | Tomcat에 리소스 경로 등록 | 파일이 없어서 404 |
- 전략 1은 필요조건이다. JSP 파일이 JAR 안에 있어야 한다.
- 전략 2는 충분조건이다. Tomcat이 해당 경로를 인식하도록 명시적으로 등록해야 한다.
- 둘 다 있어야 동작한다.
전략 선택 가이드
JSP를 Executable JAR에서 사용해야 하는가?
│
├─ YES → 전략 1 + 전략 2 모두 적용
│
└─ NO → WAR 패키징 또는 Thymeleaf 등 템플릿 엔진 사용 권장
주의: Spring Boot 공식 문서에서는 Executable JAR + JSP 조합을 권장하지 않는다. 레거시 시스템이 아니라면 Thymeleaf 등 템플릿 엔진으로 마이그레이션하는 것이 장기적으로 유리하다.
결론
Spring Boot Executable JAR에서 JSP가 동작하지 않는 이유는 Servlet 스펙과 Spring Boot의 중첩 JAR 구조 때문이다.
핵심 인사이트:
- Gradle로
META-INF/resources에 복사하는 것만으로는 부족하다 - Servlet 스펙상
BOOT-INF/classes/META-INF/resources는 웹 리소스로 인식되지 않는다 - Spring Boot 팀은 이 동작을 의도적으로 설계했으며, 지원 요청을 거부했다
JarResourceSet으로 Tomcat에 명시적으로 리소스 경로를 등록해야 한다- Gradle 복사(필요조건) + Tomcat 설정(충분조건) 두 가지를 함께 적용해야 동작한다
참고 자료
- Spring Boot Reference - JSP Limitations
- Apache Tomcat - WebResourceRoot
- Gradle - Copy Task
- GitHub Issue #9525 - Static resources from META-INF/resources not made available to Tomcat
- GitHub Issue #8324 - Configure embedded containers to load static resources from fat jar's BOOT-INF/classes/META-INF/resources
'Java&Spring > 차세대를 하면서...' 카테고리의 다른 글
| Spring Boot 애플리케이션 실행 시 JAR 스캔 경고 문제 분석 (0) | 2025.12.08 |
|---|