너무 좋은 세상에 태어나서 개발을 시작한 덕에, 이미 오랜 시간 다듬어져 온 고수준 기술들을 사용하여 비교적 쉽게 개발을 하고 있는데요.
그 고수준의 기술들이 대체 어떤 귀찮고, 어떤 복잡한 일을, 어떻게 대신함으로써 우리에게 편의를 제공하는지 모르는 채로 사용법만 익혀서 개발한다면, 그런 기술들은 그저 마법 같은 현상으로만 느껴지게 될 것입니다.
하지만 기술은 마법이 아니기에, 모든걸 다 해결해 줄 수 없는데요, 문제가 발생하거나 디테일한 튜닝이 필요한 경우 다시 로우 레벨로 내려가 조작해야 하는 상황이 있을 수 있습니다.
때문에 기술의 사용법 만큼이나 이해도 중요하다고 생각합니다.
서론이 길었는데, 평소에 제가 마법 같다고 느꼈던 것들 중 하나인 Gradle에 대해 이해하는 시간을 가져보고자 앞선 이야기를 남겼습니다.
# 00. Build와 Build Tools
Gradle Build Tool is a fast, dependable, and adaptable open-source
build automation tool with an elegant and extensible declarative build language.
Gradle은 자신을 빠르고, 신뢰할 수 있으며, 유연한 오픈소스 빌드 자동화 도구라고 소개하고 있습니다.
일반적으로 개발자가 작성한 코드는 바로 서비스에 올라가지 않습니다.
Java 프로젝트 기준으로 보면, 개발자가 작성 가능한 .java 소스를 컴파일해서 .class로 만들고, 테스트를 돌려 정상동작을 확인한 후 실행 가능한 JAR/WAR와 같은 산출물로 묶는 과정이 있습니다.
이러한 실행 가능한 산출물을 만드는 일련의 과정을 빌드라고 합니다.
문제는, 이 과정을 사람이 매번 손으로 하기 시작하면 금방 귀찮아진다는 것입니다.
프로젝트가 커질수록 해야 할 일도 많아지고, 단순 컴파일 뿐만 아니라, 테스트, 외부 라이브러리 다운로드, 패키징, 환경별 설정 분기 등 해야 하는 것들이 많습니다.
이러한 이유로 빌드 자동화 도구에 대한 개념이 등장합니다.
Ant
가장 먼저 등장한 빌드 자동화 도구는 Ant입니다.
Apache Ant's buildfiles are written in XML. Each buildfile contains one project and at least one (default) target. Targets contain task elements. Each task element of the buildfile can have an id attribute and can later be referred to by the value supplied to this. The value has to be unique.
Ant는 XML 기반의 빌드 파일 안에 작업 순서를 비교적 직접적으로 적는 방식입니다.
예시) Ant - build.xml
<project name="MyProject" default="dist" basedir=".">
<description>
simple example build file
</description>
<!-- set global properties for this build -->
<property name="src" location="src"/>
<property name="build" location="build"/>
<property name="dist" location="dist"/>
<target name="init">
<!-- Create the time stamp -->
<tstamp/>
<!-- Create the build directory structure used by compile -->
<mkdir dir="${build}"/>
</target>
<target name="compile" depends="init"
description="compile the source">
<!-- Compile the Java code from ${src} into ${build} -->
<javac srcdir="${src}" destdir="${build}"/>
</target>
<target name="dist" depends="compile"
description="generate the distribution">
<!-- Create the distribution directory -->
<mkdir dir="${dist}/lib"/>
<!-- Put everything in ${build} into the MyProject-${DSTAMP}.jar file -->
<jar jarfile="${dist}/lib/MyProject-${DSTAMP}.jar" basedir="${build}"/>
</target>
<target name="clean"
description="clean up">
<!-- Delete the ${build} and ${dist} directory trees -->
<delete dir="${build}"/>
<delete dir="${dist}"/>
</target>
</project>
컴파일 하려면 init부터 하라 (<target name="compile" depends="init">),
패키징 하려면 compile부터 하라(<target name="dist" depends="compile">) 등 개발자가 무엇을 어떤 순서로 할지를 명시하는 것이 자유도는 높지만, 프로젝트가 커질수록 설정이 장황해지고 표준화 측면에서 아쉬움이 생깁니다.
Maven
이러한 불편함을 해결하기 위해 등장한 것은 Maven입니다.
Apache Maven is a build tool for Java projects. Using a project object model (POM), Maven manages a project's compilation, testing, and documentation.
Maven은 Ant보다 더 강한 컨벤션을 제공합니다.
표준 디렉토리 구조, 빌드 라이프사이클 등의 공통 규칙을 제공해서, 개발자가 매번 절차를 처음부터 설계하지 않도록 돕습니다.
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
덕분에, 팀 차원에서의 일관성을 만들기 쉬워졌지만, 반대로 정해진 툴 바깥의 요구사항을 다룰 때는 답답하다고 느껴질 수 있습니다.
Gradle
그리고 현재의 JVM 진영에서 가장 주류의 빌드 툴인 Gradle이 등장합니다.
Gradle Build Tool is a fast, dependable, and adaptable open-source
build automation tool with an elegant and extensible declarative build language.
Gradle은 Maven처럼 관례를 활용하면서도, 빌드를 더 유연하게 구성할 수 있게 된 빌드 자동화 도구입니다.
plugins {
id 'java'
}
group = 'com.example'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2'
}
태그를 열고 닫아야 하는 Maven의 XML 문법에 비해 가독성이 좋고 길이가 짧으며,
Gradle은 Groovy(or Kotlin)로 작성된 스크립트를 빌드 파일로 사용하기 때문에, 문법이 자바 친화적이며, if 조건문 등의 커스텀 로직을 조작할 수 있습니다.
이는 Ant의 유연함과 Maven의 편리함을 모두 합친 형태라고 볼 수 있습니다.
# 01. Gradle 구조

Gradle이 어떤 구조로 빌드를 처리하는지 살펴보겠습니다.
그림은 크게 보면 왼쪽의 프로젝트 입력, 가운데의 Gradle 처리 영역, 오른쪽의 출력 결과로 나눠서 이해할 수 있습니다.
Project 영역
빌드 대상 그 자체입니다.
Gradle은 프로젝트를 기준으로 빌드를 수행합니다.
이는 단일 프로젝트일 수도 있고, 여러 모듈로 이루어진 멀티 프로젝트일 수도 있습니다.
sub-project-1, sub-project-2 같은 하위 프로젝트가 연결되어 있는데, 이는 하나의 빌드 안에 여러 모듈이 포함될 수 있음을 보여줍니다.
여기서 settings.gradle은 전체 프로젝트의 뼈대를 잡는 역할을 합니다.
프로젝트에 고유한 이름을 부여하며, 프로젝트의 경계를 지정하여 어떤 하위 프로젝트들이 이번 빌드에 포함되는지 정의합니다.
이러한 이유로 멀티 프로젝트에서 settings.gradle은 필수라고 볼 수 있습니다.
rootProject.name = 'root-project-name'
include 'module-a'
include 'module-b'
또한 각 하위 프로젝트는 보통 자신만의 build.gradle과 소스 코드를 가집니다.
예를 들어 모듈 A는 API 서버일 수 있고, 모듈 B는 공통 라이브러리일 수도 있습니다.
Gradle은 이 각각의 프로젝트를 하나의 빌드 단위로 인식하고 관리합니다.
Gradle 영역
빌드를 실질적으로 수행하는 영역입니다.
Cache 영역은 이전 빌드 결과를 재사용하기 위한 영역입니다.
Gradle은 매번 처음부터 모든 작업을 다시 수행하지 않으려 합니다.
Gradle이 빠르다고 느껴지는 이유 중 하나가 이 캐시 전략이며, Gradle은 라이브러리 캐시 뿐만 아니라 Maven에서는 지원하지 않는 빌드 캐시까지 지원합니다.
즉, 동일한 입력에 대해 이미 수행한 작업의 결과를 재사용할 수 있으며, 이 덕분에 컴파일 등 일부 빌드 작업을 다시 수행하지 않아도 될 수 있습니다.
Build Flow는 task들이 어떤 순서와 관계로 실행될지를 조직한 흐름이라고 볼 수 있습니다.
앞서, Ant에서는 매번 작업 순서를 개발자가 직접 작성해야하는 번거로움이 있고, Maven과 Gradle에서는 공통 컨벤션을 제공하여 이러한 불편함을 해소한다고 말씀드렸습니다.
Java 프로젝트의 경우 보통 `compileJava`, `processResources`, `test`, `jar`, `build`와 같은 task들이 등장합니다.
Gradle은 이렇게 작업을 잘게 쪼개어 task 단위로 관리하며, 순서와 관계를 조직합니다.
하지만 여기서, Gradle 자체가 Java 프로젝트의 task 순서와 관계를 이해하고 있는 것은 아닙니다.
이는 그림의 Plugins와 연결됩니다. 사용자가 java 플러그인처럼 프로젝트 유형을 명시하면, 해당 플러그인이 자바 빌드에 필요한 task들과 기본 실행 관계를 프로젝트에 추가하고, Gradle은 그 관계를 바탕으로 실제 빌드를 수행합니다.
즉, Java 플러그인을 설정함으로써, `compileJava`, `processResources`, `test`, `jar`, `build`와 같은 task들이 자동으로 순서와 관계를 가지게 된다고 이해할 수 있습니다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.7'
id 'io.spring.dependency-management' version '1.1.7'
}
Plugins를 통해 빌드에 필요한 task의 순서와 관계가 정리되었다면, Dependency Manager에서는 빌드에 필요로 하는 외부 라이브러리들을 관리해 줍니다.
스프링부트 프로젝트를 예로 들면, `spring-boot-starter-web`, `JUnit`, `Lombok`, `Jackson` 등의 라이브러리들을 필요로 합니다.
개발자는 직접 위 라이브러리들을 다운로드하여 적용할 필요 없이, plugins 혹은 dependencies만 관리하며 Gradle이 제공해 주는 기능을 누리기만 하면 됩니다.
Output 영역
Gradle 영역에서 실질적인 빌드가 진행된 후 테스트 결과, 혹은 JAR/WAR과 같은 실행가능한 파일이 산출되며, 이는 CI 서버로 전달되거나, 저장소에 업로드되는 등으로 이어질 수 있습니다.
# 02. build.gradle
build.gradle은 Gradle에게 해당 프로젝트를 어떻게 빌드할 것인지를 알려주는 설정 파일입니다.
프로젝트에 필요한 플러그인 적용하고, 어떤 라이브러리 사용할 것인지 결정하며, 필요하다면 task 조작도 가능합니다.
가장 단순한 형태의 자바 프로젝트 설정 파일을 보며 사용법을 익혀보겠습니다.
plugins {
id 'java'
}
group = 'com.example'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
}
`plugins` 블록은 앞서 설명드렸듯, 프로젝트에 어떤 빌드 기능을 적용할지 선언하는 영역입니다.
java 플러그인을 적용함으로써, 테스트 -> 컴파일 -> 산출물 패키징과 같은 task들의 순서와 관계가 알아서 설정됩니다.
`group`과 `version`은 최종 산출물의 좌표를 정의하는 정보입니다.
라이브러리나 애플리케이션을 패키징 했을 때, 이 정보는 결과물의 식별자처럼 사용됩니다.
`repositories` 블록은 의존성을 어디에서 받아올지 정의하는 영역입니다.
프로젝트에서 다양한 외부 라이브러리에 의존하기 때문에, 그 라이브러리들을 어디에서 찾아올지 설정하기 위한 블록입니다.
위와 같이 mavenCentral()로 설정되어 있다면, 라이브러리들을 Maven Central에서 받아오겠다고 설정이 되는 것이며, 여러 저장소를 명시하여 선언된 순서대로 라이브러리를 찾아가도록 하는 설정도 가능합니다.
`dependencies` 블록은 프로젝트가 어떤 외부 라이브러리를 사용할 것인지 선언하는 영역입니다.
프로젝트에서 사용되는 모든 라이브러리가 모든 순간 필요하지는 않기 때문에 dependencies에서는 configuration의 종류를 구분하여 사용합니다.
- `implementation`: 컴파일할 때, 실행할 때 모두 사용되며, 이 프로젝트를 의존하는 다른 모듈에게는 노출하지 않음
- `api`: 컴파일 할 때, 실행할 때 모두 사용되며, 이 프로젝트를 의존하는 다른 모듈에게도 노출됨
- `compileOnly`: 컴파일 할 때만 필요하고, 실행 시에는 포함되지 않음 (Lombok 등은 실행 시 포함될 필요가 없음)
- `runtimeOnly`: 컴파일할 때는 필요 없고, 실행할 때는 필요함
- `testImplementation`: 테스트 코드를 컴파일 및 실행할 때만 필요함 (Junit, Mockito 같은 테스트 라이브러리)
- `annotationProcessor`: 컴파일 과정에서 애너테이션을 처리해 코드를 생성하는 라이브러리에 사용
# 03. Build LifeCycle


Gradle 빌드는 크게 Initialization, Configuration, Execution 3단계로 진행됩니다.
Initialization Phase
In the initialization phase, Gradle detects the set of projects (root and subprojects) and included builds participating in the build.
Initialization 단계는 이번 빌드에 어떤 프로젝트들이 포함되는지 결정하는 단계입니다.
루트 프로젝트가 무엇인지, 어떤 서브 프로젝트들이 있는지, 어떤 included build가 있는지 파악합니다.
Gradle은 `settings.gradle` 파일을 읽고, Settings 객체를 만든 다음, Settings 객체에 정의된 프로젝트들에 대해 Project 객체를 생성합니다.
rootProject.name = 'basic'
println 'This is executed during the initialization phase.'
문서에서 제공하는 예시를 봤을 때 settings.gradle에 작성된 print문은 initialization 단계에서 실행된다는 것을 알 수 있습니다.
Configuration Phase
In the configuration phase, Gradle adds tasks and other properties to the projects found by the initialization phase.
Intialization 단계에서 찾은 프로젝트들에 대해
- 어떤 플러그인이 적용되었는지
- 어떤 task가 있는지
- 어떤 의존성이 있는지
- task들끼리 어떻게 연결되었는지
를 읽고 구성하는 단계입니다.
println 'This is executed during the configuration phase.'
tasks.register('configured') {
println 'This is also executed during the configuration phase, because :configured is used in the build.'
}
tasks.register('test') {
doLast {
println 'This is executed during the execution phase.'
}
}
tasks.register('testBoth') {
doFirst {
println 'This is executed first during the execution phase.'
}
doLast {
println 'This is executed last during the execution phase.'
}
println 'This is executed during the configuration phase as well, because :testBoth is used in the build.'
}
이번에는 `build.gradle`의 최상위에 있는 print문이 configuration 단계에서 실행됨을 알 수 있습니다.
추가적으로, 하단의 testBoth task에서 doFirst와 doLast 블록의 print문은 execution 단계에서 실행되는 반면, 오직 task 블록 내부에만 있는 print문은 configuration 단계에서 실행된다고 명시되어 있습니다.
마지막 print문은 Gradle이 해당 task를 구성할 때 실행되기 때문이고, doFirst와 doLast는 실제 task가 실행될 때 수행할 액션이기 때문에 execution 단계에서 실행된다고 이해할 수 있습니다.
Execution Phase
In the execution phase, Gradle runs tasks.
Execution 단계는 실제로 task를 수행하는 단계입니다.
여기서 또 중요한 문장을 하나 볼 수 있는데요.
Gradle uses the task execution graphs generated by the configuration phase to determine which tasks to execute.
이 단계에서 Gradle은 configuration 단계에서 만들어둔 task graph를 바탕으로, 실행할 task를 결정하고 실행한다고 합니다.
즉, plugin을 설정하거나 개발자가 task를 정의하고, task끼리의 의존 관계를 선언하면 Gradle은 configuration 단계에서 그 정보를 모아 task graph를 만든다고 볼 수 있습니다.
# 04. Gradle Wrapper
./gradlew build
프로젝트를 빌드할 때 가장 많이 사용하는 명령어입니다.
하지만 이건 Gradle 명령어가 아니라,
- ./: 현재 폴더에 있는
- gradlew: 이름이 gradlew 쉘 스크립트 파일을 실행시키는데,
- build: 파라미터로 build를 넘겨줘라
라는 의미의 리눅스 명령어입니다.
이렇게 실행하면, 현재 컴퓨터에 Gradle이 설치되어 있든 없든 상관없이 프로젝트가 지정한 버전의 Gradle로 빌드가 수행됩니다.
그리고 그것이
The recommended way to execute any Gradle build is with the help of the Gradle Wrapper
Gradle이 어떤 빌드든 Gradle Wrapper를 사용하는 것을 권장하는 이유이기도 합니다.
Gradle Wrapper를 사용하는 이유는 표준화입니다.
팀원 A의 컴퓨터에는 Gradle 8 버전이 설치되어 있을 수 있고,
팀원 B의 컴퓨터에는 Gradle 9 버전이 설치되어있을수 있습니다.
이렇게 버전이 제각각인 상황에서, 누구의 환경에서는 빌드가 잘 되고, 누구의 환경에서는 오류가 발생할 수도 있습니다.
wrapper를 사용하면 프로젝트별 Gradle 버전이 고정되기 때문에 위와 같은 문제를 예방할 수 있습니다.
이때 wrapper가 실제로 하는 일은
1. gradlew을 실행하면
2. `gradle-wrapper.properties`를 확인하고,
3. 지정된 Gradle distribution URL을 확인한 후에,
4. 내 컴퓨터에 그 Gradle 버전이 없으면 다운로드를 하고,
5. 다운로드한 Gradle로 실제 빌드를 수행
합니다.
제가 여기서 들었던 의문은,
'나는 내 프로젝트에서 gradlew 파일을 작성한 적이 없는데, 대체 언제부터 존재하던거지?'인데요.
The Gradle Wrapper is not something you download.
wrapper는 애초에 다운로드 받아오는 것이 아닙니다.
gradlew wrapper
wrapper는 Gradle 명령어를 통해 프로젝트에 필요한 파일을 생성할 수 있습니다.
'근데 .... 나는 gradle 명령어로 wrapper를 생성한 적도 없는데 ...?'
현실에서 초기 프로젝트 세팅을 할 때, Spring Initializer 혹은 Intellij와 같은 IDE 수준에서 프로젝트 생성 시점에 Wrapper 파일을 만들어줍니다.
이미 Gradle만 하더라도, 귀찮은 많은 작업들을 뒤에서 대신해 준다고 생각했는데, 그것보다 더 고수준에서 Gradle 조작까지 해주고 있었던 것입니다.
마법 같다고 느껴졌던 Gradle의 동작에 대해 알아봤습니다.
진짜 우연찮게도, 이 글을 작성하기 위해 공부한 내용을 정리하던 기간 중, 직장에서 멀티모듈 구조 개선 잡을 받았는데요.
뿌듯하더라고요.