| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- docker
- GitHub Packages
- Java 8
- CS
- java
- 컨테이너
- 백엔드면접준비
- GCP
- springboot
- 마이크로서비스
- 아키텍처
- 분산시스템
- 자바
- github actions
- 도커
- 트러블슈팅
- 마이크로서비스아키텍처
- 백엔드
- 멀티모듈
- SpringCloud
- 마이그레이션
- dockercompose
- 인프라
- MSA
- ci/cd
- PostgreSQL
- 공통모듈
- Flyway
- Database
- gradle
- Today
- Total
NYO_O
JAR 파일의 종류(Plain JAR vs Boot JAR) 본문
Java로 개발을 하고, 배포 과정을 진행하다 보면 자연스럽게 다음 질문이 생깁니다.
"우리가 배포하는 JAR 파일, 모듈마다 설정이 달라야 하지 않을까?"
오늘은 이 질문에 답하기 위해 JAR 파일의 종류와 모듈별로 어떤 Gradle 설정을 해야 하는지 정리해 보겠습니다.
JAR 파일이란 무엇인가
JAR(Java ARchive)는 구조적으로는 ZIP 파일과 동일합니다. 확장자만 .jar로 다를 뿐, unzip 명령어로도 풀어볼 수 있습니다. 내부에는 컴파일된 .class 파일들, 리소스(.yaml, .properties 등), 그리고 메타데이터가 담겨 있습니다.
myapp.jar
├─ META-INF/
│ └─ MANIFEST.MF
├─ com/yourorg/chat/
│ ├─ ChatApplication.class
│ ├─ ChatService.class
│ └─ ...
└─ application.yml
핵심은 META-INF/MANIFEST.MF 안에 Main-Class가 적혀 있는지 여부입니다. 이 항목이 있으면 java -jar myapp.jar로 직접 실행되는 애플리케이션이 되고, 없으면 다른 프로젝트에서 가져다 쓰는 라이브러리 역할을 합니다.
# MANIFEST.MF
Manifest-Version: 1.0
Main-Class: com.yourorg.chat.ChatApplication
JAR의 두 가지 형태, Plain JAR vs Boot JAR
JAR를 이야기할 때 가장 중요한 구분은 실행용이냐 라이브러리용이냐입니다.
Plain JAR (라이브러리용)
common-core-1.0.0.jar
├─ META-INF/MANIFEST.MF ← Main-Class 없음
└─ com/yourorg/common/
├─ ApiResponse.class
├─ BusinessException.class
└─ ...
Plain JAR에는 Jackson, Spring 같은 의존성 코드가 포함되지 않습니다. 대신 함께 배포되는 .pom 파일에 "어떤 의존성이 필요한지"를 선언해 두고, 이를 가져다 쓰는 프로젝트의 Gradle/Maven이 실제 의존성을 내려받는 방식입니다.
Boot JAR (실행용)
"Fat JAR" 또는 "Uber JAR"라고도 부릅니다. Spring Boot 플러그인이 만들어주는 결과물로, 내부 구조가 Plain JAR와 크게 다릅니다.
chat-service-1.0.0.jar
├─ META-INF/MANIFEST.MF
│ ├─ Main-Class: org.springframework.boot.loader.JarLauncher
│ └─ Start-Class: com.yourorg.chat.ChatApplication
│
├─ org/springframework/boot/loader/
│ └─ JarLauncher.class
│
├─ BOOT-INF/
│ ├─ classes/ ← 내가 작성한 코드
│ │ └─ com/yourorg/chat/
│ └─ lib/ ← 모든 의존성 JAR가 통째로
│ ├─ spring-boot-3.2.0.jar
│ ├─ spring-core-6.1.0.jar
│ ├─ jackson-databind-2.15.3.jar
│ └─ ... (수십 개)
└─ application.yml
필요한 모든 의존성이 BOOT-INF/lib/ 안에 포함되어 있기 때문에 JDK만 설치되어 있으면 java -jar chat-service-1.0.0.jar로 바로 실행됩니다.
그런데 Boot JAR를 다른 프로젝트에서 의존성으로 가져가면 어떻게 될까요? 클래스들이 BOOT-INF/classes/ 경로 아래에 있기 때문에 일반적인 클래스패스 탐색으로는 찾을 수 없습니다. 런타임에 NoClassDefFoundError가 발생하는 원인이 바로 여기에 있습니다.
결론적으로, 실행용 Boot JAR와 라이브러리용 Plain JAR는 용도가 완전히 다릅니다. 이 두 파일이 함께 생성되면 CI/CD에서 "어느 걸 배포하지?"라는 혼란이 생기고, 패키지 저장소에 올릴 때도 어떤 걸 올려야 할지 모호해집니다. 실무에서는 모듈의 역할에 따라 하나만 활성화하는 것이 좋습니다.
어떤 JAR를 써야 할까
"이 모듈이 직접 실행되는 애플리케이션인가, 아니면 다른 프로젝트에서 가져다 쓰는 라이브러리인가"로 판단하면 됩니다.
| 모듈 유형 | 예시 | 생성할 JAR |
| 실행되는 애플리케이션 | chat-service, product-service | Boot JAR (Fat JAR) |
| 순수 라이브러리 | common-core, common-utils | Plain JAR |
| Spring Boot 의존하는 라이브러리 | common-security, common-kafka | Plain JAR (Boot 플러그인 없이) |
Gradle 설정: 케이스별로 살펴보기
케이스 A: 실행되는 애플리케이션 모듈
직접 배포되어 실행되는 서비스입니다. Spring Boot 플러그인을 적용하고, bootJar만 활성화합니다.
// chat-service/build.gradle
plugins {
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
id 'java'
}
tasks.named('jar') {
enabled = false // Plain JAR 불필요
}
tasks.named('bootJar') {
enabled = true // Fat JAR만 생성
}
이렇게 하면 build/libs/chat-service-1.0.0.jar 파일 하나만 생성됩니다.
케이스 B: 순수 라이브러리 모듈
Spring Boot와 무관하게 공통 유틸리티, 예외 클래스, 응답 포맷 등을 제공하는 모듈입니다.
// common-core/build.gradle
plugins {
id 'java-library'
// Spring Boot 플러그인 적용하지 않음
}
java-library 플러그인만 쓰면 jar task만 생기고 bootJar task 자체가 존재하지 않습니다. 별도의 enabled = false 설정도 필요하지 않습니다.
케이스 C: Spring Boot 의존성을 쓰지만 실행하지 않는 라이브러리
common-security, common-kafka처럼 Spring Boot 기능이 필요하지만 직접 실행하지는 않는 모듈입니다. 선택지가 두 가지 있습니다.
옵션 1 (권장): Spring Boot 플러그인 없이 dependency-management만 적용하기
// common-security/build.gradle
plugins {
id 'java-library'
id 'io.spring.dependency-management' // BOM 관리용
}
dependencyManagement {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:3.2.0"
}
}
dependencies {
api 'org.springframework.boot:spring-boot-starter-security'
api 'io.jsonwebtoken:jjwt-api:0.12.3'
}
org.springframework.boot 플러그인은 제거하고 io.spring.dependency-management만 쓰면, BOM을 통한 Spring 버전 자동 관리는 받으면서 bootJar 같은 실행 관련 task는 생기지 않습니다. 의도가 가장 명확한 방식입니다.
옵션 2: Spring Boot 플러그인을 쓰되 bootJar 끄기
plugins {
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management'
id 'java-library'
}
tasks.named('bootJar') { enabled = false }
tasks.named('jar') { enabled = true }
둘 다 동작하지만, 옵션 1이 "이 모듈은 라이브러리다"라는 의도를 더 명확하게 전달할 수 있습니다.
멀티 모듈 프로젝트에서의 레벨별 설정
멀티 모듈 프로젝트에서는 루트와 하위 모듈, 그리고 별개 레포의 서비스 모듈까지 각 레벨마다 설정 책임이 다릅니다.
루트 build.gradle
루트 프로젝트 자체는 빌드 결과물이 없습니다. 자식 모듈들을 묶는 컨테이너 역할만 하기 때문에 JAR 설정도 필요하지 않습니다.
// common/build.gradle (루트)
plugins {
id 'java-library' apply false
id 'io.spring.dependency-management' version '1.1.4' apply false
}
subprojects {
apply plugin: 'java-library'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'maven-publish'
group = 'com.yourorg'
version = '1.0.0'
java {
sourceCompatibility = '21'
withSourcesJar()
}
dependencyManagement {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:3.2.0"
}
}
}
루트에서 java-library 플러그인을 subprojects에 걸어줬기 때문에, Spring Boot 플러그인은 사용하지 않습니다. bootJar 관련 설정도 필요하지 않습니다.
각 라이브러리 하위 모듈 (common-core, common-kafka 등)
루트에서 이미 java-library를 적용해줬기 때문에 개별 모듈은 추가 설정이 거의 필요하지 않습니다.
// common-kafka/build.gradle
dependencies {
api project(':common-core')
api 'org.springframework.kafka:spring-kafka'
}
// jar task 관련 설정 없음 — 기본 동작으로 Plain JAR가 생성됨
애플리케이션 모듈 (별개 레포)
라이브러리 레포와 별개의 저장소로 관리되는 서비스 모듈입니다. 여기서는 Spring Boot 플러그인을 적용하고 Boot JAR를 만듭니다.
// chat-service/build.gradle
plugins {
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
id 'java'
}
dependencies {
implementation 'com.yourorg:common-core:1.0.0'
implementation 'com.yourorg:common-security:1.0.0'
implementation 'com.yourorg:common-kafka:1.0.0'
implementation 'org.springframework.boot:spring-boot-starter-web'
}
tasks.named('jar') { enabled = false }
tasks.named('bootJar') { enabled = true }
chat-service의 Fat JAR를 열어보면 내부 BOOT-INF/lib/ 경로에 common-core-1.0.0.jar가 들어있는 것을 확인할 수 있습니다. 공통 모듈이 "통째로 포장된" 상태입니다.
전체 구성을 정리하면 아래와 같습니다.
| 구 | 라이브러리 레포 | 애플리케이션 레포 |
| Spring Boot 플러그인 | 적용 안 함 | 적용 |
| java-library 플러그인 | 적용 | 적용 안 함 |
| dependency-management | 적용 | 적용 |
| bootJar task | 없음 | enabled |
| jar task | enabled (기본값) | disabled |
| 생성 결과물 | Plain JAR | Fat JAR |
자주 하는 실수 3가지
실수 1: 라이브러리 모듈에 Spring Boot 플러그인을 걸어놓고 bootJar 비활성화를 빠뜨리는 경우
빌드 시 Plain JAR와 Fat JAR가 둘 다 생성되거나, Publishing 시 의도치 않게 Fat JAR가 패키지 저장소에 올라갑니다. 이 Fat JAR를 의존성으로 가져간 프로젝트는 BOOT-INF/classes/ 경로 문제로 클래스를 찾지 못하는 오류가 발생합니다. 처음에 이 원인을 모르면 꽤 당황스러울 수 있습니다. 라이브러리 모듈이라면 아예 Spring Boot 플러그인을 적용하지 않는 것을 권장합니다.
실수 2: 애플리케이션 모듈에서 jar.enabled = true 상태로 두고 Plain JAR를 실행 환경에 올리는 경우
Plain JAR에는 application.yml이나 의존성 라이브러리가 없어서 실행 즉시 에러가 발생합니다. bootJar와 jar 설정을 명시적으로 관리하는 것이 좋습니다.
실수 3: Boot JAR를 다른 프로젝트가 의존성으로 가져가는 경우
앞서 설명한 것처럼 Boot JAR의 클래스패스 구조(BOOT-INF/classes/)가 달라서 "분명 import했는데 런타임에 NoClassDefFoundError"가 발생합니다. 라이브러리로 쓰일 모듈은 처음부터 Fat JAR를 만들지 않는 구조로 가져가는 것이 좋습니다.
마치며
오늘은 JAR 파일의 종류와 모듈별로 어떤 Gradle 설정을 해야 하는지 살펴보았습니다. Plain JAR와 Boot JAR의 구조적 차이를 이해하고 나면, "왜 라이브러리 모듈에는 Spring Boot 플러그인을 쓰지 말아야 하는가"에 대한 답이 자연스럽게 나옵니다.