내장 톰캣 – 빌드와 배포

내장 톰캣(Embedded Tomcat)은 애플리케이션에 톰캣 서버를 라이브러리 형태로 포함하여 빌드하고 배포하는 방식이다. 이를 통해 애플리케이션 단독으로 실행 가능한 패키지를 만들 수 있다

Jar 파일 빌드의 기본 원칙

  • 자바 애플리케이션의 main() 메서드를 실행하기 위해서는 JAR(Java Archive) 형식으로 빌드해야 한다. 이때 JAR 파일 내부의 META-INF/MANIFEST.MF 파일에 실행할 main() 메서드의 클래스를 지정해주어야 한다
  • 예를 들어, Main-Class: hello.embed.EmbedTomcatSpringMain과 같이 지정하면 java -jar 명령으로 해당 JAR 파일을 실행했을 때 지정된 클래스의 main() 메서드가 자동으로 실행된다. Gradle과 같은 빌드 도구를 사용하면 이러한 MANIFEST.MF 파일 생성을 자동화하여 편리하게 관리할 수 있다
task buildJar(type: Jar) {
    manifest {
        attributes 'Main-Class': 'hello.embed.EmbedTomcatSpringMain'
    }
    with jar
}

이 buildJar 태스크를 실행하면 build/libs 디렉토리에 embed-0.0.1-SNAPSHOT.jar와 같은 형태로 JAR 파일이 생성된다

jar 빌드
./gradlew clean buildJar

일반 JAR 파일의 한계점: NoClassDefFoundError

  • 생성된 JAR 파일을 java -jar embed-0.0.1-SNAPSHOT.jar 명령으로 실행했을 때 java.lang.NoClassDefFoundError: org/springframework/web/context/WebApplicationContext와 같은 오류가 발생할 수 있다. 이는 일반적인 JAR 파일이 애플리케이션이 의존하는 외부 라이브러리(예: 스프링 프레임워크, 톰캣 등)을 직접 포함하지 않기 때문에 발생한다
  • JAR 파일과 ClassLoader의 제약사항
    • JAR 파일은 물리적으로 다른 JAR 파일을 내부에 포함할 수 있다(예: lib/spring-core.jar 형태로 저장 가능)
    • 하지만 JVM의 기본 ClassLoader(URLClassLoader)는 JAR 내부의 JAR 파일에서 클래스를 로드할 수 없다
    • 즉, 파일 시스템에는 존재하지만 런타임에 클래스 로딩이 불가능하다
    • WAR 파일이 WEB-INF/lib에 JAR를 포함할 수 있는 이유는 서블릿 컨테이너가 특별한 ClassLoader(WebappClassLoader)를 제공하기 때문이다
  • JAR vs WAR 파일의 파이
    • JAR 파일: java -jar 실행 시 기본 URLClassLoader 사용 (JAR 내부의 JAR 인식 불가)
    • WAR 파일: 서블릿 컨테이너(톰캣)가 제공하는 WebappClassLoader 사용(WEB_INF/lib 디렉토리의 JAR 파일들을 인식하고 로드 가능)
    • 일반 JAR 실행 환경에서는 의존성 라이브러리를 포함하는 특별한 방법이 필요하며, 이것이 Fat Jar가 등장한 배경이다
jar 파일 실행
java -jar embed-0.0.1-SNAPSHOT.jar
jar 압축 풀기
jar -xvf embed-0.0.1-SNAPSHOT.jar 

Fat Jar (Uber Jar)

  • 일반 JAR 파일의 한계를 극복하기 위한 대안으로 Fat Jar (또는 Uber Jar) 방식이 있다. 이 방식은 JAR 파일 내부에 JAR 파일을 포함할 수 없지만, 클래스 파일은 포함할 수 있다는 점을 활용한다
  • Fat Jar는 애플리케이션 코드뿐만 아니라, 의존하는 모든 라이브러리 JAR 파일의 압축을 해제하여 그 안에 있는 모든 클래스 파일과 리소스 파일들을 하나의 거대한 JAR 파일에 직접 포함시키는 방식이다. 이렇게 하면 외부 라이브러리에 대한 의존성 없이 하나의 JAR 파일만으로 애플리케이션을 실행할 수 있게 되며, 이 단일 JAR 파일이 매우 “뚱뚱해지기(Fat)” 때문에 Fat Jar라고 부른다
task buildFatJar(type: Jar) {
    manifest {
        attributes 'Main-Class': 'hello.embed.EmbedTomcatSpringMain'
    }
    duplicatesStrategy = DuplicatesStrategy.WARN // 파일명 중복 시 경고만 출력
    from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } }
    with jar
}
  • configurations.runtimeClasspath: 프로젝트가 런타임에 필요한 모든 의존성 목록
  • collect { … }: 각 의존성을 순회하며 처리
  • it.isDirectory() ? it : zipTree(it)
    • 디렉토리면 그대로 포함
    • JAR 파일이면 zipTree()로 압축 해제하여 내부 파일들 추출
  • from { … }: 추출된 모든 파일을 현재 JAR에 포함

결과 구조

embed-0.0.1-SNAPSHOT.jar
├── META-INF/
│ └── MANIFEST.MF (Main-Class 포함)
├── hello/
│ └── embed/
│ └── EmbedTomcatSpringMain.class (내 코드)
├── org/
│ ├── apache/catalina/ (톰캣 클래스들)
│ └── springframework/ (스프링 클래스들)
└── jakarta/servlet/ (서블릿 API 클래스들)
  • ./gradlew clean buildFatJar 명령으로 Fat Jar를 빌드하면, build/libs 디렉토리에 용량이 훨씬 커진 JAR 파일이 생성된다. 이 Fat Jar를 java -jar embed-0.0.1-SNAPSHOT.jar 명령으로 실행하면, 필요한 모든 클래스 파일이 JAR 내부에 포함되어 있으므로 NoClassDefFoundError 없이 애플리케이션이 정상적으로 실행된다
  • WARN은 경고만 하고 마지막 파일로 덮어쓰므로 중복 문제를 해결하지 못한다
    • 다른 옵션: EXCLUDE(첫 번째 유지), INCLUDE(마지막으로 덮어쓰기), FAIL(빌드 실패)

jar 빌드와 파일 생성

./gradlew clean buildFatJar

java -jar embed-0.0.1-SNAPSHOT.jar

브라우저로 접속 및 폴더 확인

Fat Jar의 장점

  • 단순한 배포 및 실행: 하나의 JAR 파일만 있으면 되므로, WAS(Web Application Server)를 별도로 설치하거나 복잡한 환경 설정을 할 필요 없이 java -jar 명령으로 어디서든 애플리케이션을 실행할 수 있다
  • WAS 설치 불필요: 내장 톰캣과 같은 WAS 라이브러리가 JAR 내부에 포함되어 있어 별도의 WAS 설치가 필요 없다
  • 간편한 버전 관리: 내장 톰캣 라이브러리의 버전을 변경하려면 빌드 스크립트에서 버전 정보만 수정하고 다시 빌드하면 된다
  • 개발 환경 단순화: IDE에서 복잡한 WAS 연동 설정 없이 main() 메서드만 실행하면 바로 웹 애플리케이션을 개발하고 테스트할 수 있다

Fat Jar의 단점

  • 라이브러리 추적이 어려움: 모든 라이브러리가 .class 파일 형태로 풀려서 하나의 JAR에 담기기 때문에, 특정 클래스가 어느 라이브러리에서 왔는지 확인하기 어렵다
파일명(클래스 / 리소스)중복 문제 – 중복이 발생하는 주요 케이스
  • 리소스 파일 중복
    • META-INF/services/jakarta.servlet.ServletContainerInitializer
    • META-INF/spring.factories
    • META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
  • 설정 파일 중복: application.properties 여러 라이브러리가 각자의 기본 설정 포함 시
  • 네이티브 라이브러리 중복: 같은 네이티브 라이브러리의 다른 버전
문제 발생 예시

라이브러리 A와 B 모두 모두 META-INF/services/jakarta.servlet.ServletContainerInitializer 파일을 포함하는 경우

  • Fat Jar 빌드 시 둘 중 하나만 최종 JAR에 포함된다
  • 나머지 하나의 ServletContainerInitializer는 등록되지 않는다
  • 해당 초기화 로직이 실행되자 않아 기능이 오작동할 수 있다
  • 이는 duplicatesStrategy 설정으로도 완전히 해결할 수 없는 구조적 한계이다

스프링 부트는 이러한 Fat Jar의 단점을 보완하고 더욱 효율적인 방식으로 실행 가능한 JAR 파일을 생성하는 고급 해결책을 제공한다. 하지만 그 기반에는 Fat Jar의 개념이 깔려 있으므로, 이 과정을 이해하는 것이 중요하다

출처 – 김영한 님의 강의 중 스프링 부트 – 핵심 원리와 활용