빌드 도구 — Maven과 Gradle 이해하기
build.gradle 파일은 매일 보는데, 이게 정확히 뭘 하는 건지 설명할 수 있는가?
dependencies에 뭔가를 추가하면 라이브러리가 알아서 들어오고,./gradlew build를 치면 jar 파일이 뚝 떨어진다. 편리하지만, 그 안에서 무슨 일이 일어나는지 모른 채 쓰고 있었다면 이 글이 도움이 될 것이다.
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
빌드 도구가 왜 필요한가
코드를 작성하고 실행하기까지는 생각보다 많은 단계가 있어요.
[소스 코드] → 컴파일 → 테스트 → 패키징 → 배포
↑
외부 라이브러리 다운로드 & 클래스패스 설정
작은 프로젝트라면 javac로 컴파일하고 java로 실행하면 됩니다. 하지만 프로젝트가 커지면 문제가 생겨요.
- ** 의존성 관리 **: Spring Boot를 쓰려면 수십 개의 jar가 필요합니다. 하나하나 다운로드할 것인가?
- ** 테스트 자동화 **: 코드를 바꿀 때마다 테스트를 수동으로 돌릴 것인가?
- ** 패키징 **: jar, war 같은 배포 파일을 매번 수작업으로 만들 것인가?
- ** 환경 일관성 **: 내 PC에서는 되는데 팀원 PC에서는 안 된다면?
빌드 도구는 이 모든 과정을 ** 자동화 **합니다. Java 생태계에서 주로 쓰이는 빌드 도구는 두 가지예요.
| 도구 | 등장 시기 | 설정 파일 | 설정 방식 |
|---|---|---|---|
| Maven | 2004년 | pom.xml | XML 선언형 |
| Gradle | 2012년 | build.gradle / build.gradle.kts | Groovy/Kotlin DSL |
역사적으로 Ant → Maven → Gradle 순서로 발전해왔습니다. Ant는 자유도가 높지만 표준이 없었고, Maven이 "컨벤션 오버 컨피규레이션"을 도입해 표준을 만들었어요. Gradle은 Maven의 장점을 취하면서 더 유연하고 빠른 빌드를 제공합니다.
Maven 기본 — pom.xml 구조
Maven 프로젝트의 핵심은 pom.xml(Project Object Model) 파일입니다. 크게 프로젝트 식별 정보(GAV)와 의존성 목록으로 나뉘어요.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- GAV 좌표: 이 프로젝트의 고유 식별자 -->
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
의존성은 <dependencies> 블록에 GAV 좌표로 선언합니다. <scope>로 사용 범위를 지정할 수 있어요.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.2.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope> <!-- 테스트에서만 사용 -->
</dependency>
</dependencies>
</project>
GAV 좌표
Maven 생태계에서 모든 라이브러리는 GAV 좌표 로 식별됩니다.
- GroupId: 조직이나 프로젝트 그룹 (예:
org.springframework) - ArtifactId: 프로젝트 이름 (예:
spring-core) - Version: 버전 (예:
6.1.2)
이 세 값으로 Maven Central에서 jar를 다운로드해요. 로컬에는 ~/.m2/repository/ 아래에 저장됩니다.
Maven 디렉토리 구조
별도 설정 없이 다음 구조를 따르면 알아서 빌드돼요. 이 구조는 Gradle에서도 동일합니다.
my-app/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/ ← 소스 코드
│ │ └── resources/ ← 설정 파일
│ └── test/
│ ├── java/ ← 테스트 코드
│ └── resources/ ← 테스트용 설정 파일
└── target/ ← 빌드 결과물 (자동 생성)
Maven 라이프사이클
Maven의 가장 큰 특징은 ** 표준 빌드 라이프사이클 **입니다. 빌드 과정을 단계(phase)로 나누고 순서대로 실행해요.
validate → compile → test → package → verify → install → deploy
| 단계 | 하는 일 |
|---|---|
validate | 프로젝트 설정이 올바른지 검증 |
compile | src/main/java 소스를 컴파일 |
test | 단위 테스트 실행 |
package | 컴파일된 코드를 jar/war로 패키징 |
install | 패키지를 로컬 저장소(~/.m2)에 설치 |
deploy | 패키지를 원격 저장소에 배포 |
핵심은 ** 누적 실행 **이에요. mvn package를 실행하면 validate → compile → test → package가 모두 실행됩니다.
# 실무에서 자주 쓰는 명령어
mvn compile # 컴파일만
mvn test # 테스트까지
mvn clean package # 깨끗하게 빌드 후 패키징 (가장 많이 씀)
mvn clean package -DskipTests # 테스트 건너뛰기 (급할 때만)
Maven 의존성 관리
scope — 의존성의 사용 범위
| scope | 컴파일 | 테스트 | 런타임 | 패키징 | 대표 예시 |
|---|---|---|---|---|---|
compile (기본) | O | O | O | O | spring-core |
test | X | O | X | X | JUnit, Mockito |
provided | O | O | X | X | Servlet API |
runtime | X | O | O | O | JDBC 드라이버 |
provided가 헷갈리기 쉬운데, 컴파일할 때는 필요하지만 실행 환경(톰캣 등)이 이미 갖고 있으니 jar에 안 넣는다는 뜻이에요. war 배포할 때 Servlet API가 이중으로 들어가면 충돌이 나기 때문에 이 구분이 중요합니다.
전이 의존성 (Transitive Dependency)
A가 B를 의존하고, B가 C를 의존하면 — A는 자동으로 C도 가져옵니다.
my-app → spring-boot-starter-web → spring-webmvc → spring-core
편리하지만, 내가 추가하지 않은 라이브러리가 들어오면서 버전 충돌이 발생할 수 있어요. mvn dependency:tree로 의존성 트리를 확인하는 습관이 중요합니다.
Gradle 기본 — build.gradle 구조
Gradle은 Groovy DSL 또는 Kotlin DSL로 빌드 스크립트를 작성합니다. XML보다 간결하고, 조건문이나 반복문도 쓸 수 있어요.
플러그인과 프로젝트 설정부터 볼게요.
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.example'
version = '1.0.0'
java { sourceCompatibility = JavaVersion.VERSION_21 }
repositories { mavenCentral() }
의존성은 dependencies 블록에 한 줄씩 선언합니다. Maven과 달리 implementation, compileOnly 등 configuration으로 사용 범위를 지정해요.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok' // 컴파일 시에만
annotationProcessor 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j' // 런타임에만
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') { useJUnitPlatform() }
Kotlin DSL(build.gradle.kts)도 있습니다. 문법만 다르고 기능은 동일해요. 타입 안전성과 IDE 자동완성이 더 좋아서 최근에는 .kts를 선호하는 추세입니다.
같은 의존성을 두 도구로 비교하면 간결함의 차이가 확연해요.
<!-- Maven: 5줄 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.2.0</version>
</dependency>
// Gradle: 1줄
implementation 'org.springframework.boot:spring-boot-starter-web:3.2.0'
Gradle 태스크와 의존성
의존성 설정(Configuration)
Gradle에서는 Maven의 <scope>에 해당하는 개념을 configuration 이라고 부릅니다.
| Gradle 설정 | Maven scope 대응 | 설명 |
|---|---|---|
implementation | compile | 컴파일 + 런타임, 소비자에게 노출 안 됨 |
api | compile | 컴파일 + 런타임, 소비자에게 노출됨 |
compileOnly | provided | 컴파일에만 사용 |
runtimeOnly | runtime | 런타임에만 사용 |
testImplementation | test | 테스트 컴파일 + 런타임에 사용 |
implementation vs api 의 차이는 멀티모듈 프로젝트에서 중요합니다.
모듈 A (library)
├── api: commons-lang3 → 모듈 B에서도 사용 가능
└── implementation: guava → 모듈 B에서 사용 불가
모듈 B (application)
└── implementation: 모듈 A
implementation으로 선언하면 의존성이 모듈 내부에 캡슐화됩니다. 컴파일 속도도 빨라지고, 내부 구현을 바꿔도 소비자 모듈에 영향이 없어요. 특별한 이유가 없다면 implementation을 기본으로 쓰는 걸 추천합니다.
Gradle 태스크
# 자주 쓰는 명령어
./gradlew build # 컴파일 + 테스트 + jar 생성
./gradlew clean build # 깨끗하게 다시 빌드
./gradlew bootRun # Spring Boot 실행
./gradlew dependencies # 의존성 트리 확인
커스텀 태스크도 만들 수 있어요. Maven에서는 플러그인을 만들어야 하는 일이 Gradle에서는 몇 줄이면 됩니다.
// 커스텀 태스크 정의
tasks.register('hello') {
doLast {
println '안녕하세요, Gradle!'
}
}
의존성 충돌 해결
라이브러리 A가 guava 31.0을 요구하고, B가 guava 30.0을 요구한다면? Maven과 Gradle은 서로 다른 전략으로 해결합니다.
Maven: Nearest-First
의존성 트리에서 루트에 가장 가까운 버전 을 선택해요. 같은 깊이면 먼저 선언된 쪽이 이깁니다.
my-app
├── A (guava 31.0) ← 깊이 1: 선택됨
└── B → C (guava 30.0) ← 깊이 2: 무시됨
Gradle: Newest-First
가장 높은 버전 을 선택합니다. 대부분 상위 버전이 하위 호환되므로 더 안전한 편이에요.
충돌을 직접 해결하는 방법
1. exclude — 특정 전이 의존성 제거
// Gradle
implementation('com.example:library-a:1.0.0') {
exclude group: 'com.google.guava', module: 'guava'
}
<!-- Maven -->
<dependency>
<groupId>com.example</groupId>
<artifactId>library-a</artifactId>
<version>1.0.0</version>
<exclusions>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</exclusion>
</exclusions>
</dependency>
2. BOM(Bill of Materials) — 버전 통합 관리
BOM은 여러 라이브러리의 버전을 한 곳에서 관리합니다. Spring Boot에서 starter 의존성에 버전을 안 써도 되는 이유가 BOM 덕분이에요.
// Gradle: BOM 사용
dependencies {
implementation platform('org.springframework.boot:spring-boot-dependencies:3.2.0')
implementation 'org.springframework.boot:spring-boot-starter-web' // 버전 생략 가능
}
3. 버전 강제 지정
// Gradle: 특정 버전을 강제
configurations.all {
resolutionStrategy {
force 'com.google.guava:guava:31.1-jre'
}
}
Maven vs Gradle 비교
| 비교 항목 | Maven | Gradle |
|---|---|---|
| ** 설정 파일** | pom.xml (XML) | build.gradle (Groovy/Kotlin DSL) |
| ** 빌드 속도** | 느림 (캐시 없음) | 빠름 (증분 빌드, 빌드 캐시) |
| ** 의존성 충돌** | nearest-first | newest-first |
| ** 커스터마이징** | 플러그인에 의존 | 태스크로 자유롭게 작성 |
| ** 러닝 커브** | 낮음 (표준이 명확) | 중간 (DSL 학습 필요) |
| ** 빌드 캐시** | 없음 | 로컬 + 원격 캐시 지원 |
| ** 데몬 프로세스** | 없음 | Gradle Daemon (JVM 재사용) |
Gradle이 빠른 이유는 세 가지입니다.
- ** 증분 빌드 **: 변경된 파일만 다시 컴파일해요
- ** 빌드 캐시 **: 이전 빌드 결과를 캐시해두고, 입력이 같으면 재사용합니다
- Gradle Daemon: JVM을 백그라운드에 띄워두고 재사용해요
대규모 프로젝트에서 Gradle이 Maven보다 2~10배 빠른 것으로 알려져 있습니다. Spring Framework 자체도 Maven에서 Gradle로 전환했어요.
** 어떤 걸 선택해야 할까요?** 새 프로젝트라면 Gradle을 추천합니다. 기존 Maven 프로젝트를 굳이 전환할 필요는 없어요. 중요한 것은 도구가 아니라 ** 의존성 관리와 빌드 프로세스를 이해하는 것 **입니다.
Multi-module 프로젝트
프로젝트가 커지면 모듈을 분리해야 해요.
my-project/
├── settings.gradle ← 모듈 목록 정의
├── build.gradle ← 루트 (공통 설정)
├── core/ ← 공통 모듈 (엔티티, 유틸)
│ ├── build.gradle
│ └── src/
├── api/ ← API 서버 모듈
│ ├── build.gradle
│ └── src/
└── batch/ ← 배치 모듈
├── build.gradle
└── src/
// settings.gradle — 모듈 등록
rootProject.name = 'my-project'
include 'core', 'api', 'batch'
// 루트 build.gradle — 공통 설정
subprojects {
apply plugin: 'java'
group = 'com.example'
version = '1.0.0'
java { sourceCompatibility = JavaVersion.VERSION_21 }
repositories { mavenCentral() }
dependencies {
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
}
// api/build.gradle — 하위 모듈
plugins { id 'org.springframework.boot' }
dependencies {
// 같은 프로젝트의 core 모듈 의존
implementation project(':core')
implementation 'org.springframework.boot:spring-boot-starter-web'
runtimeOnly 'com.mysql:mysql-connector-j'
}
project(':core')로 같은 프로젝트 내 모듈을 참조합니다. core에 엔티티와 공통 유틸을 두고, api와 batch에서 가져다 쓰는 패턴이 실무에서 흔해요. Maven에서도 부모 POM + <modules>로 동일한 구조를 만들 수 있습니다.
Gradle Wrapper — gradlew의 역할
프로젝트에 Gradle 버전 정보와 실행 스크립트 를 포함시켜서, Gradle을 설치하지 않아도 빌드할 수 있게 하는 도구예요.
my-project/
├── gradlew ← Linux/Mac용 실행 스크립트
├── gradlew.bat ← Windows용 실행 스크립트
└── gradle/wrapper/
├── gradle-wrapper.jar
└── gradle-wrapper.properties ← Gradle 버전 정보
# gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
왜 중요한지 정리하면 이렇습니다.
- **설치 불필요 **:
./gradlew만 있으면 지정된 Gradle을 자동 다운로드해요 - ** 버전 일관성 **: 모든 개발자와 CI 서버에서 동일한 버전으로 빌드합니다
- ** 재현 가능한 빌드 **: 6개월 후에 클론받아도 같은 환경에서 빌드돼요
실무에서는 gradle 명령어 대신 ** 항상 ./gradlew를 사용 **하는 것이 원칙입니다.
# 버전 업그레이드도 간단하다
./gradlew wrapper --gradle-version 8.6
Maven에도 Maven Wrapper(
mvnw)가 있다. 같은 원리로 동작한다.
주의할 점
의존성 충돌을 무시하면 런타임에 ClassNotFoundException이 터집니다
전이 의존성으로 들어온 라이브러리 버전이 맞지 않으면, 컴파일은 통과하지만 런타임에 NoSuchMethodError나 ClassNotFoundException이 발생해요. mvn dependency:tree 또는 ./gradlew dependencies로 의존성 트리를 주기적으로 확인해야 합니다.
implementation 대신 api를 남발하면 빌드 속도가 느려집니다
api로 선언하면 해당 의존성이 소비자 모듈의 컴파일 클래스패스에도 포함돼요. 의존성 변경 시 연쇄적으로 재컴파일이 발생하므로, 특별한 이유가 없다면 implementation을 기본으로 써야 합니다.
Gradle Wrapper를 커밋하지 않으면 재현 가능한 빌드가 깨집니다
gradlew, gradle-wrapper.jar, gradle-wrapper.properties는 반드시 버전 관리에 포함해야 해요. Wrapper 없이 로컬에 설치된 Gradle을 쓰면 팀원마다 다른 버전으로 빌드해서 결과가 달라질 수 있습니다.
정리 테이블
| 개념 | Maven | Gradle |
|---|---|---|
| 설정 파일 | pom.xml | build.gradle / .kts |
| 의존성 식별 | GAV 좌표 | 동일 (group:name:version) |
| 의존성 저장소 | ~/.m2/repository | ~/.gradle/caches |
| 빌드 단위 | Phase (라이프사이클) | Task |
| 의존성 범위 | scope | configuration |
| 의존성 충돌 | nearest-first | newest-first |
| 버전 통합 관리 | <dependencyManagement> | platform() |
| 멀티모듈 | 부모 POM + <modules> | settings.gradle + subprojects |
| Wrapper | mvnw | gradlew |
| 증분 빌드 / 캐시 | 미지원 | 지원 |
핵심 요약
- ** 빌드 도구의 역할 **: 컴파일, 의존성 관리, 테스트, 패키징을 하나의 명령으로 처리
- **Maven 핵심 **: GAV 좌표, 표준 라이프사이클, XML 선언형 설정
- **Gradle 핵심 **: DSL 기반 유연한 설정, 증분 빌드와 캐시로 빠른 빌드
- implementation vs api: 의존성 캡슐화 여부. 기본은 implementation
- ** 의존성 충돌 **: Maven은 가까운 것 우선, Gradle은 최신 것 우선
- Wrapper: 빌드 환경의 일관성과 재현성을 보장
./gradlew build가 내부에서 뭘 하는지, 의존성은 어떻게 해석되는지 이해하고 나면 빌드 에러가 났을 때 당황하지 않고 원인을 찾을 수 있다.
다음 글에서는 ** 자바 개발자 로드맵 **을 다룹니다. 34편 시리즈의 마무리로, 지금까지 다룬 내용을 어떤 순서로 학습하면 좋을지 정리해볼 예정이에요.