내장 Tomcat에서 잘 돌던 Spring Boot WAR가 JBoss EAP에 올리는 순간 NoSuchMethodError나 ClassCastException으로 멈추는 일은 흔하다. 원인은 대부분 같은 라이브러리가 서버 모듈과 WAR 안에 동시에 있고, JBoss가 생각과 다른 쪽을 먼저 고르기 때문이다. 이 글은 JBoss Modules가 클래스를 찾는 순서, jboss-deployment-structure.xml로 그 순서를 바꾸는 방법, 자주 부딪치는 충돌과 진단 방법을 정리한 것이다.

한눈에 보기
| 증상 | 대개의 원인 | 먼저 볼 곳 |
|---|---|---|
NoSuchMethodError |
컴파일한 버전과 실제로 읽힌 버전이 다름 | 그 클래스가 어느 jar에서 왔는지 |
ClassCastException: X cannot be cast to X |
같은 이름의 클래스를 두 클래스로더가 각각 읽음 | 서버 모듈과 WEB-INF/lib의 중복 |
LinkageError: loader constraint violation |
한 클래스가 두 경로로 보임 | exclusions와 export 설정 |
NoClassDefFoundError |
컴파일 때는 있었지만 배포 모듈에서 안 보임 | 모듈 의존성 누락 |
| 배포는 되는데 Spring이 안 뜸 | Servlet API 세대(javax/jakarta) 불일치 | Spring Boot와 EAP 버전 조합 |

JBoss가 클래스를 찾는 순서
JBoss EAP는 평면 클래스패스가 아니라 JBoss Modules라는 모듈 단위 클래스로더를 쓴다. 서버의 라이브러리는 modules/ 아래에 모듈로 나뉘어 있고, 배포한 WAR도 deployment.app.war라는 모듈 하나가 된다. 모듈은 의존성으로 선언한 다른 모듈의 클래스만 볼 수 있다.
배포 모듈이 클래스를 찾는 순서는 다음과 같다.
- 암시적 의존성: 서버가 배포 내용을 보고 자동으로 붙이는 모듈. Jakarta EE(Java EE) API와 서브시스템이 쓰는 구현체가 여기에 든다.
- 명시적 의존성:
jboss-deployment-structure.xml이나MANIFEST.MF의Dependencies로 직접 선언한 모듈. - 로컬 리소스: WAR 안의
WEB-INF/classes와WEB-INF/lib. - 배포 간 의존성: EAR 안의 다른 하위 배포.
핵심은 1번이 3번보다 앞선다는 점이다. 같은 패키지의 클래스가 서버 모듈과 WEB-INF/lib에 모두 있으면 서버 쪽이 이긴다. 개발자는 pom.xml에 적은 버전이 돌 거라고 생각하지만, 실제로는 서버가 가진 버전이 읽힌다.
서버가 붙이는 의존성: 서브시스템이 범인일 때가 많다
암시적 의존성은 배포 내용에 따라 달라진다. JBoss의 서브시스템이 WAR를 훑어보고 자기가 처리할 것이 있으면 관련 모듈을 붙인다.
- jaxrs:
@Path같은 JAX-RS 애너테이션이 보이면 RESTEasy와 Jackson 공급자를 붙인다. - jpa:
persistence.xml이나@PersistenceContext가 보이면 서버의 Hibernate로 영속성 단위를 직접 만들려고 한다. Spring Boot가 만드는EntityManagerFactory와 이중으로 뜬다. - logging: 로깅 API(SLF4J, JBoss Logging 등)를 기본으로 붙여, 앱의 Logback 설정 대신 서버 로그로 출력이 빠진다.
- bean-validation, weld(CDI): Hibernate Validator와 CDI 구현을 붙인다.
그래서 해결은 "WAR의 jar를 지운다"보다 "서버가 붙이는 모듈을 끊는다"인 경우가 많다. 어떤 서브시스템이 어떤 모듈을 붙이는지는 EAP 버전마다 다르므로, 사용하는 EAP 버전의 개발 가이드(Class Loading and Modules 장)를 확인한다.
jboss-deployment-structure.xml로 순서 바꾸기
이 파일은 WAR면 WEB-INF/, EAR면 META-INF/에 둔다. 세 가지를 조정할 수 있다.
exclude-subsystems: 서브시스템이 이 배포를 아예 처리하지 않게 한다. 그 서브시스템이 붙이던 모듈도 함께 빠진다.exclusions: 특정 암시적 모듈만 뺀다. 빼면 같은 클래스는WEB-INF/lib에서 읽힌다.dependencies: 서버 모듈을 직접 붙인다. JDBC 드라이버를 서버 모듈로 등록해 공유할 때 쓴다.
Spring Boot WAR에서 자주 쓰는 형태는 이렇다.
<?xml version="1.0" encoding="UTF-8"?>
<jboss-deployment-structure xmlns="urn:jboss:deployment-structure:1.2">
<deployment>
<!-- Spring이 직접 관리할 기능은 서버가 손대지 않게 -->
<exclude-subsystems>
<subsystem name="jaxrs"/>
<subsystem name="jpa"/>
<subsystem name="weld"/>
</exclude-subsystems>
<!-- 앱이 가진 버전을 쓰기 위해 서버 모듈을 뺀다 -->
<exclusions>
<module name="com.fasterxml.jackson.core.jackson-core"/>
<module name="com.fasterxml.jackson.core.jackson-databind"/>
<module name="org.slf4j"/>
<module name="org.slf4j.impl"/>
</exclusions>
<!-- 서버에 모듈로 설치한 드라이버를 쓴다 -->
<dependencies>
<module name="com.example.jdbc" services="import"/>
</dependencies>
</deployment>
</jboss-deployment-structure>
모듈 이름과 스키마 버전(exclude-subsystems는 1.2 이상)은 EAP 버전에 따라 다를 수 있으니 서버의 modules/system/layers/base 아래 실제 디렉터리 이름과 맞춰 본다. 서브시스템을 빼면 그 기능(예: 서버 관리 JPA)은 더 이상 쓸 수 없으니, 뺀 이유를 파일 주석으로 남긴다.
자주 부딪치는 충돌
| 라이브러리 | 서버가 끼어드는 경로 | 흔한 증상 | 처리 |
|---|---|---|---|
| Jackson | jaxrs 서브시스템의 Jackson 공급자 | 앱이 쓰는 신규 메서드에서 NoSuchMethodError |
jaxrs 제외 또는 Jackson 모듈 exclusions |
| Hibernate·JPA | jpa 서브시스템 | 영속성 단위가 두 번 뜨거나 버전 불일치 | jpa 제외, Spring이 EntityManagerFactory 관리 |
| SLF4J·Logback | logging 서브시스템 | 로그가 서버 로그로만 나오고 logback-spring.xml이 안 먹음 |
org.slf4j 계열 제외 또는 로깅 API 자동 추가 끄기 |
| Bean Validation | Hibernate Validator 모듈 | 제약 애너테이션 동작이 버전마다 다름 | 서버 버전에 맞추거나 모듈 제외 |
| Servlet API·내장 Tomcat | 서버의 Servlet 구현 | ClassCastException, WebSocket 초기화 실패 |
spring-boot-starter-tomcat을 provided로 |
로깅은 방법이 두 가지다. 배포에서 org.slf4j 계열 모듈을 빼고 앱의 Logback을 쓰거나, 서버 쪽 logging 서브시스템의 add-logging-api-dependencies 속성을 false로 바꿔 로깅 API 자동 추가를 끈다. 후자는 서버 전체에 적용되므로 같은 서버의 다른 앱에 영향이 없는지 먼저 본다.
javax와 jakarta는 설정으로 풀 수 없다. Spring Boot 3는 jakarta.* 패키지를 쓰므로 Jakarta EE 10을 지원하는 EAP 8 계열이 필요하고, Spring Boot 2는 javax.*를 쓰는 EAP 7 계열에 맞는다. 세대가 다르면 서버가 Spring의 초기화 클래스를 찾지 못해 오류 없이 앱만 안 뜨거나 ClassNotFoundException: jakarta.servlet…이 난다. 정확한 지원 조합은 Red Hat의 지원 구성 목록에서 확인한다.
어느 jar에서 왔는지 찾기
충돌은 "이 클래스가 실제로 어디서 읽혔나"를 알면 절반은 풀린다.
클래스 로딩 로그를 켠다. JVM 옵션으로 로딩되는 클래스와 출처를 찍는다. JDK 8은 -verbose:class, JDK 9 이상은 -Xlog:class+load=info다. 출처가 modules/system/layers/base/...면 서버 모듈, 배포 경로(vfs: 또는 content/app.war/WEB-INF/lib/...)면 WAR 안의 jar다.
# standalone.conf 또는 standalone.conf.bat 의 JAVA_OPTS 에 추가 후 재기동
JAVA_OPTS="$JAVA_OPTS -Xlog:class+load=info:file=/tmp/classload.log"
grep 'ObjectMapper ' /tmp/classload.log
로그가 많으니 검증 서버에서 켠다.
코드에서 직접 묻는다. 의심 가는 클래스의 출처와 클래스로더를 시작 시점에 한 번 찍는다.
Class<?> c = com.fasterxml.jackson.databind.ObjectMapper.class;
log.info("jar={}, loader={}",
c.getProtectionDomain().getCodeSource().getLocation(),
c.getClassLoader());
클래스로더가 deployment.app.war 모듈이면 WAR 안의 jar, com.fasterxml.jackson... 같은 모듈 이름이면 서버 모듈에서 읽힌 것이다.
오류 메시지를 읽는 법
NoSuchMethodError: 메서드 이름과 시그니처가 나온다. 그 메서드가 추가된 버전을 찾고, 실제로 읽힌 jar 버전과 비교한다.ClassCastException: X cannot be cast to X: 이름이 같은데 형변환이 실패하면 클래스로더가 둘이다.LinkageError: loader constraint violation: 한 인터페이스와 구현이 서로 다른 로더에서 왔다.exclusions를 했는데 다른 모듈이 같은 패키지를export해 다시 보이는 경우가 있다.
빌드에서 막기: provided와 의존성 정리
서버가 제공하는 것은 WAR에 넣지 않는 것이 첫 번째 방어선이다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
Spring Boot Maven 플러그인은 provided 의존성을 WEB-INF/lib-provided에 넣는다. java -jar로 실행할 때만 쓰고, 애플리케이션 서버는 이 디렉터리를 읽지 않는다. 그래서 같은 WAR를 로컬 실행과 서버 배포에 함께 쓸 수 있다.
의존성 트리에서 중복을 미리 찾는다.
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core,org.slf4j,jakarta.servlet,javax.servlet
이 결과와 서버의 modules/system/layers/base 아래 같은 라이브러리 버전을 표로 만들어 두면, 서버 패치나 Spring Boot 업그레이드 때 어느 쪽이 바뀌었는지 바로 비교할 수 있다.
자주 하는 실수
- WAR의 jar 버전만 올린다. 서버 모듈이 먼저 읽히는 구조라면
pom.xml을 바꿔도 실제로 도는 버전은 그대로다. - 서버 모듈 jar를 직접 교체한다.
modules/아래 jar를 바꾸면 같은 서버의 다른 앱과 패치 적용이 깨진다. 배포 단위에서exclusions로 푼다. exclude-subsystems를 이유 없이 복사해 쓴다. 필요한 서버 기능(예: 서버 관리 데이터소스, JAX-RS)까지 꺼져 다른 증상이 생긴다.- 내장 Tomcat을 WAR에 넣은 채 배포한다. Servlet 구현이 두 벌이 되어 초기화 순서에 따라 다른 오류가 난다.
- 오류 메시지의 클래스로더 이름을 읽지 않는다. 메시지에 이미 두 출처가 적혀 있는 경우가 많다.
점검표
- Spring Boot 버전의 Servlet 세대(javax/jakarta)와 EAP 버전이 맞는지 지원 목록에서 확인했다
-
spring-boot-starter-tomcat이provided이고WEB-INF/lib에tomcat-embedjar가 없다 -
WEB-INF/jboss-deployment-structure.xml이 있고, 뺀 서브시스템과 모듈마다 이유가 주석으로 있다 - Jackson, SLF4J, Hibernate처럼 서버에도 있는 라이브러리 목록과 양쪽 버전을 표로 갖고 있다
- 주요 클래스(ObjectMapper, Logger, EntityManagerFactory)의 출처 jar를 시작 로그로 확인했다
- 로그가 앱의 Logback 설정대로 나오는지 확인했다
-
mvn dependency:tree로 같은 라이브러리의 중복 버전을 정리했다 - 서버
modules/아래 jar를 직접 바꾸지 않았다 - 서버 패치나 Spring Boot 업그레이드 뒤 위 확인을 다시 하는 절차가 있다
JBoss에서 클래스 충돌은 서버 모듈이 WAR보다 먼저 읽힌다는 한 가지 규칙에서 나오므로, 무엇이 어디서 읽혔는지 확인하고 서버가 붙이는 모듈을 배포 단위에서 끊으면 대부분 풀린다.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.