Gradle과 Maven, 무엇이 다르고 Gradle은 어떻게 써야 할까
Java 프로젝트를 시작하면 거의 항상 Gradle과 Maven 중 하나를 선택하게 된다. 둘 다 소스 코드를 컴파일하고, 테스트하고, JAR·WAR를 만들고, 외부 라이브러리를…
Java 프로젝트를 시작하면 거의 항상 Gradle과 Maven 중 하나를 선택하게 된다. 둘 다 소스 코드를 컴파일하고, 테스트하고, JAR·WAR를 만들고, 외부 라이브러리를 내려받는 빌드 도구다. 그래서 처음에는 XML이냐 Groovy/Kotlin DSL이냐 정도의 차이로 보이지만, 프로젝트가 커지면 빌드 모델과 운영 방식의 차이가 분명해진다.
이번 글에서는 두 도구의 문법보다 빌드가 어떤 입력을 받아 어떤 순서로 산출물을 만드는지에 집중한다. 마지막에는 Gradle을 선택했을 때 팀에서 유지보수하기 좋은 기본 구성과 피해야 할 설정까지 예시로 정리한다.
빌드 도구가 실제로 하는 일
WAS에 배포할 Spring Boot 애플리케이션을 예로 들면 빌드는 다음 일을 수행한다.
- 저장소에서 의존성과 플러그인을 찾는다.
- 컴파일 클래스패스를 구성한다.
- 소스와 리소스를 컴파일한다.
- 단위 테스트와 검증 작업을 실행한다.
- 실행 가능한 JAR 또는 WAR를 만든다.
- 필요한 경우 사설 저장소나 CI 아티팩트 저장소에 게시한다.
Gradle과 Maven 모두 이 흐름을 자동화하지만, Maven은 정해진 lifecycle과 XML 설정을 중심으로 하고 Gradle은 task 그래프와 프로그래밍 가능한 빌드 모델을 중심으로 한다.
Maven은 예측 가능한 lifecycle이 강점이다
Maven 프로젝트의 핵심은 pom.xml이다. validate, compile, test, package, verify, install, deploy로 이어지는 표준 lifecycle이 있고, 명령을 실행하면 해당 단계 이전의 단계도 순서대로 실행된다.
1<project>2 <modelVersion>4.0.0</modelVersion>3 <groupId>me.hae02</groupId>4 <artifactId>order-service</artifactId>5 <version>1.0.0</version>6
7 <properties>8 <maven.compiler.release>21</maven.compiler.release>9 </properties>10
11 <dependencies>12 <dependency>13 <groupId>org.springframework.boot</groupId>14 <artifactId>spring-boot-starter-web</artifactId>15 <version>3.x.x</version>16 </dependency>17 </dependencies>18</project>1./mvnw clean verify2./mvnw dependency:tree3./mvnw spring-boot:runMaven의 장점은 프로젝트를 처음 보는 사람도 실행 단계와 디렉터리 구조를 빠르게 예상할 수 있다는 점이다. 플러그인이 lifecycle의 특정 phase에 연결되고, XML에 선언한 설정이 비교적 명시적이다. 조직 표준이 이미 Maven으로 굳어 있거나 빌드 커스터마이징이 많지 않은 서비스라면 지금도 좋은 선택이다.
단점은 XML이 장황해지기 쉽고, 여러 모듈의 공통 설정이나 조건부 작업을 표현할 때 플러그인 설정이 빠르게 복잡해진다는 점이다. Maven이 기능이 부족해서가 아니라, 선언 구조가 깊어질수록 변경 위치와 실행 순서를 찾는 비용이 커진다.
Gradle은 task 그래프와 증분 빌드가 중심이다
Gradle에서는 compileJava, test, jar, build 같은 task가 있고 task 사이의 의존 관계로 실행 그래프가 만들어진다. 필요한 task만 실행하고, 입력이 바뀌지 않은 작업은 결과를 재사용할 수 있다.
Kotlin DSL을 사용한 최소 예시는 다음과 같다.
1plugins {2 java3 id("org.springframework.boot") version "3.x.x"4 id("io.spring.dependency-management") version "1.x.x"5}6
7group = "me.hae02"8version = "1.0.0"9
10java {11 toolchain {12 languageVersion = JavaLanguageVersion.of(21)13 }14}15
16repositories {17 mavenCentral()18}19
20dependencies {21 implementation("org.springframework.boot:spring-boot-starter-web")22 testImplementation("org.springframework.boot:spring-boot-starter-test")23}24
25tasks.test {26 useJUnitPlatform()27}1./gradlew clean build2./gradlew test3./gradlew dependencies --configuration runtimeClasspath4./gradlew bootRunGradle은 빌드 스크립트가 코드이기 때문에 강력하다. 동시에 그 강력함이 아무 함수나 build.gradle.kts에 넣어도 된다는 뜻은 아니다. 빌드 스크립트가 애플리케이션 코드처럼 커지면 task 간 암묵적 의존성과 전역 상태 때문에 원인을 찾기 어려워진다.
차이를 한 표로 정리하면
| 관점 | Maven | Gradle |
|---|---|---|
| 기본 모델 | 표준 lifecycle과 phase | task 그래프와 입력·출력 |
| 설정 형식 | XML pom.xml | Groovy 또는 Kotlin DSL |
| 빌드 스크립트 | 선언 중심 | 선언 + 프로그래밍 가능 |
| 증분 빌드 | 플러그인과 설정에 의존 | 입력·출력 기반 자동화가 강함 |
| 멀티모듈 | parent·module POM | settings와 subproject |
| 의존성 관리 | dependencyManagement·BOM | version catalog·platform·BOM |
| 학습 곡선 | 규칙이 정해져 있어 낮음 | 초반에는 task/configuration 이해 필요 |
| 커스터마이징 | 플러그인 XML 설정 중심 | convention plugin으로 재사용 가능 |
| 적합한 상황 | 표준화·예측 가능성 우선 | 큰 멀티모듈·빠른 반복·빌드 로직 재사용 |
둘 중 하나가 모든 상황에서 우월한 것은 아니다. 기존 조직의 플러그인, CI 템플릿, 사설 저장소, 운영 담당자의 경험까지 합쳐서 선택해야 한다. 이미 Maven 생태계가 잘 갖춰진 조직에서 “Gradle이 빠르다”는 이유만으로 바꾸면 마이그레이션 비용이 더 클 수 있다.
Gradle을 쓴다면 Wrapper부터 커밋한다
개발자마다 전역 Gradle 버전이 다르면 같은 소스가 다른 결과를 낼 수 있다. 그래서 Gradle 명령을 직접 gradle로 실행하지 않고 프로젝트가 제공하는 Wrapper를 사용한다.
1./gradlew wrapper --gradle-version <조직에서 검증한 버전>2./gradlew --version3./gradlew build저장소에는 다음 파일을 함께 커밋한다.
1gradlew2gradlew.bat3gradle/wrapper/gradle-wrapper.jar4gradle/wrapper/gradle-wrapper.propertiesCI도 gradle build가 아니라 ./gradlew build를 실행해야 로컬과 CI의 Gradle 런타임을 맞출 수 있다. Wrapper의 배포 URL과 체크섬 검증 정책은 조직 보안 규정에 맞게 관리한다.
Kotlin DSL과 Version Catalog를 기본으로 둔다
Gradle을 새로 시작한다면 Kotlin DSL인 build.gradle.kts가 IDE 자동완성과 타입 검사를 받기 쉽다. 의존성 버전을 여러 모듈에서 반복하지 않으려면 gradle/libs.versions.toml에 좌표와 버전을 모은다.
1[versions]2spring-boot = "3.x.x"3junit = "5.x.x"4
5[libraries]6spring-boot-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }7spring-boot-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" }8junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }1dependencies {2 implementation(libs.spring.boot.web)3 testImplementation(libs.spring.boot.test)4 testRuntimeOnly(libs.junit)5}Version Catalog는 좌표를 한 곳에서 관리하는 도구다. 여러 라이브러리 버전을 함께 맞춰야 하는 경우에는 BOM이나 Gradle platform을 추가로 사용한다. 카탈로그에 버전을 적었다고 해서 서로 연관된 라이브러리의 호환성이 자동으로 보장되는 것은 아니다.
멀티모듈은 루트에 로직을 몰아넣지 않는다
실무 프로젝트가 커지면 api, domain, infra, batch처럼 변경 이유가 다른 코드를 모듈로 나누게 된다.
1order-service/2├── settings.gradle.kts3├── build.gradle.kts4├── gradle/libs.versions.toml5├── build-logic/6│ └── convention/7├── api/8│ └── build.gradle.kts9├── domain/10│ └── build.gradle.kts11└── infra/12 └── build.gradle.kts1// settings.gradle.kts2rootProject.name = "order-service"3include(":api", ":domain", ":infra")1// api/build.gradle.kts2dependencies {3 implementation(project(":domain"))4 implementation(libs.spring.boot.web)5}공통 Java 설정과 테스트 설정을 모든 모듈의 build.gradle.kts에 복사하면 한 모듈만 다르게 동작하기 시작한다. Gradle 공식 문서가 권장하는 convention plugin을 build-logic에 두고, 각 모듈에는 id("hae02.java-library")처럼 적용하는 편이 낫다.
1// build-logic/src/main/kotlin/hae02.java-library.gradle.kts2plugins {3 `java-library`4}5
6java {7 toolchain {8 languageVersion = JavaLanguageVersion.of(21)9 }10}11
12tasks.withType<Test>().configureEach {13 useJUnitPlatform()14}1// domain/build.gradle.kts2plugins {3 id("hae02.java-library")4}buildSrc는 시작하기 쉽지만 변경 때마다 별도 빌드 로직이 함께 컴파일된다. 규모가 커지면 독립적인 build-logic composite build로 옮겨 경계를 분리하는 것이 관리하기 좋다.
의존성은 직접 사용하는 것을 직접 선언한다
A가 B를 사용하고 B가 C를 끌고 온다고 해서 A가 C를 직접 선언하지 않아도 되는 것은 아니다. C의 버전이나 B의 의존성이 바뀌면 A의 컴파일이 갑자기 깨질 수 있다.
1dependencies {2 // 코드에서 직접 import하는 라이브러리는 직접 선언3 implementation(libs.jackson.databind)4 implementation(libs.spring.boot.web)5}런타임에만 필요한 의존성은 runtimeOnly, 테스트에만 필요한 것은 testImplementation으로 범위를 분리한다. implementation과 api도 구분해야 한다. 라이브러리 모듈의 공개 API 타입에 다른 라이브러리 타입이 노출될 때만 api를 사용하고, 내부 구현에서만 사용하면 implementation으로 숨긴다.
1dependencies {2 api(libs.public.contract) // 소비자 컴파일 클래스패스에 노출3 implementation(libs.internal.db) // 이 모듈 내부에서만 사용4 runtimeOnly(libs.postgresql) // 실행 시 필요5 testImplementation(libs.junit) // 테스트에서만 필요6}Repository는 넓게 열지 않는다
다음처럼 모든 저장소를 무차별적으로 추가하면 같은 모듈이 다른 저장소에서 내려오거나, 사설 저장소 장애가 전체 빌드를 막을 수 있다.
1repositories {2 mavenCentral()3 maven { url = uri("https://repo.example.com/releases") }4}사설 저장소가 있다면 그룹이나 모듈 패턴을 제한하고, 인증 정보는 build.gradle.kts에 적지 않는다. 환경 변수나 Gradle properties, CI secret을 사용한다. 의존성 다운로드 로그에 토큰이 출력되지 않는지도 확인한다.
캐시와 병렬 실행은 측정하면서 켠다
Gradle에는 up-to-date 검사, 병렬 실행, build cache가 있다.
1# gradle.properties2org.gradle.caching=true3org.gradle.parallel=true4org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8캐시는 작업의 입력과 출력이 정확하게 선언되어야 안전하다. 시간을 줄이려고 무조건 clean을 먼저 실행하면 증분 빌드의 이점을 없애게 된다. 로컬 개발에서는 ./gradlew test처럼 필요한 task를 실행하고, 릴리스 검증이나 캐시 오염이 의심될 때만 clean을 사용한다.
CI에서는 다음처럼 단계별로 목적을 나눈다.
1# 빠른 PR 검증2./gradlew test --no-daemon3
4# 릴리스 후보 검증5./gradlew clean check build --scan캐시가 잘못된 결과를 재사용하지 않는지 먼저 검증하고 원격 캐시를 공유해야 한다. 작업의 입력에 현재 시각, 외부 파일, 환경 변수 등이 숨어 있으면 캐시 결과가 재현되지 않을 수 있다.
내가 권하는 Gradle 사용 순서
- 프로젝트 Gradle 버전을 Wrapper로 고정한다.
build.gradle.kts와settings.gradle.kts를 사용한다.libs.versions.toml에서 의존성 좌표와 버전을 중앙 관리한다.- 모듈 사이의 의존 방향을 먼저 설계하고, 공통 설정은 convention plugin으로 뺀다.
- 직접 사용하는 의존성을 명시하고
api노출을 최소화한다. - 저장소와 credential 범위를 제한한다.
test,check,build의 역할을 CI에 고정한다.- 캐시와 병렬 실행은 빌드 입력·출력이 올바른지 확인한 뒤 활성화한다.
dependencies,dependencyInsight, Build Scan으로 느린 작업과 충돌을 확인한다.- Gradle 설정 코드가 길어지면 애플리케이션 코드처럼 모듈화한다.
Gradle의 장점은 설정을 원하는 만큼 자동화할 수 있다는 데 있다. 하지만 팀이 이해하지 못하는 마법 같은 task를 계속 추가하면 Maven의 장황한 XML보다 유지보수하기 어려운 빌드가 된다. 좋은 Gradle 빌드는 짧은 빌드 파일이 아니라, 의존 방향과 실행 규칙이 드러나고 같은 명령이 어디서나 같은 결과를 내는 빌드다.