ㅌ
포스트

[Gradle] Gradle 빌트 툴 분석

[Gradle] Gradle 빌트 툴 분석

Gradle과 다른 빌드 툴

Groovy 기반 오픈소스 빌드 도구인 Gradle은 Ant의 자유도와 Maven의 관례를 합친 개선된 선언형 빌드 툴로, 현재 구축되는 서비스들에서 자주 채택되는 도구입니다.

Ant 빌드 build.xml 기반으로 2000년대에 주로 사용된 빌드 도구로, javac, jar를 자유롭게 활용하여 스크립트 형식으로 빌드를 지원합니다. 별도의 의존성 도구가 없고 조건·반복문이 없습니다.

Maven 빌드 관례 우선 방식을 도입했으며, 고정된 라이프사이클과 pom.xml을 기반으로 빌드를 진행합니다. 의존성 도구가 있지만 xml 기반의 한계로 커스터마이징이 어렵습니다.

Gradle의 라이프 사이클

Gradle 프로젝트를 처음 만들면 아래 파일들을 확인할 수 있습니다.

Untitled일반적인 Gradle 디렉토리

Gradle Wrapper

이 중 gradlew, gradlew.bat, gradle/wrapper/ 폴더가 Gradle Wrapper입니다. Wrapper는 프로젝트가 정한 버전의 Gradle을 받아서 실행해 주는 실행기(기계 독립적)입니다.

이 프로젝트가 어떤 Gradle로 빌드해야 하는지 properties에 적어 두면, 해당 버전을 찾고, 없다면 버전을 외부에서 내려받은 뒤 빌드를 진행할 수 있게 하여 기계 독립성을 보장합니다.

그 외에 gradlew는 Mac과 Linux, gradlew.bat은 윈도우 환경에서의 실행 스크립트입니다.

Gradle의 라이프 사이클

Wrapper가 Gradle을 준비하면, 실제 빌드는 settings.gradle과 build.gradle을 통해 진행됩니다.

이 두 파일이 Gradle의 라이프사이클을 이루며, 빌드는 초기화 → 설정 → 실행 세 단계로 나뉩니다.

초기화(settings 평가, 프로젝트 결정)

./gradlew 명령어를 받으면 settings.gradle을 읽어 프로젝트 목록을 결정합니다.

settings.gradle 예시

1
2
3
4
5
6
7
8
9
10
11
12
rootProject.name = "gradle-sample"

include("core", "app")

// 모든 모듈이 공통으로 쓸 저장소를 한곳에서 선언
dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
}

println("[초기화] settings.gradle.kts 평가됨 (프로젝트: core, app)")

설정과 실행(모든 build 스크립트 실행, task 그래프 구성)

모든 build.gradle을 위에서 아래로 실행해서 task 그래프를 구성합니다.

task를 구성할 때 build.gradle은 두 개의 시점으로 나뉘는데, 아래와 같은 build.gradle이 있다고 가정합니다.

1
2
3
4
5
6
7
8
9
10
11
// build.gradle.kts
println("1. 이 줄은 설정 단계에 실행")      // build.gradle을 읽을 때 바로 실행

tasks.register("hello") {
    println("2. 이 줄도 설정 단계")
    doLast {
        println("3. 이 줄만 실행 단계")      // ./gradlew hello 할 때만 실행
    }
}


./gradlew build를 실행하면 설정 단계에서 1번이 출력됩니다. 다만 실행할 task가 hello가 아니기에 tasks.register 안의 구문은 출력되지 않습니다.

hello의 2번과 3번을 실행하려면 ./gradlew hello처럼 실행할 task로 hello를 지정해야 합니다. 이때 설정 단계에서 1번과 2번이 출력되고, 실행 단계에서 3번이 출력됩니다.

정리하면 설정 단계에서는 전체 설계도에서 실행할 것을 분류하고, 이를 기반으로 실행 단계에서 지정된 doLast를 실행합니다.

무거운 작업은 doLast 안에 넣어야 하는데 그 이유는 UP-TO-DATE에 있습니다

tasks.create()는 무조건 설정 블록이 실행됩니다. 다만 실행하지 않을 설정은 불필요하기에 일반적으로는 .register가 권장됩니다.

지정된 Task들은 입력/출력/동작(doLast) 으로 구성됩니다. 이때 입력과 출력이 그대로면 UP-TO-DATE 로 건너뛰는데, 아래와 같은 예시가 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
tasks.register("makeInfo") {
    val input  = layout.projectDirectory.file("gradle.properties")   // 입력
    val output = layout.buildDirectory.file("info.txt")              // 출력

    inputs.file(input)
    outputs.file(output)

    doLast {
        output.get().asFile.parentFile.mkdirs()
        output.get().asFile.writeText("만듦\n")
        println("info.txt 생성")
    }
}

여기서 info.txt는 gradle.properties를 기반으로 만들어지는 파일입니다.

이 파일은 굳이 바뀌지 않는다면 새로 생성할 필요가 없기에, Gradle은 원재료인 gradle.properties가 바뀔 때, 또는 info.txt가 어떤 이유로 수정되었을 때만 gradle.properties를 기반으로 info.txt를 다시 생성합니다.

두 파일에 변화가 없다면 빌드 시간 단축을 위해 UP-TO-DATE 기반으로 스킵 처리합니다.

즉 UP-TO-DATE로 띄어 넘는것은 doLast() 구문이며, 무거운 작업을 여기에 넣어야 불필요한 작업을 줄일 수 있습니다.

Plugin

Gradle 자체는 task를 실행하는 엔진입니다.

빌드 파일에 어떤 플러그인을 적용하느냐에 따라 Java 프로젝트가 되기도 하고, Python 프로젝트가 되기도 합니다.

어떤 프로젝트인지 먼저 결정돼야 그에 맞는 설정 블록을 사용할 수 있기에, 일반적으로 맨 위에 둡니다.

만약 Java 프로젝트나 Spring Boot를 쓰는 프로젝트라면 아래와 같이 플러그인을 여러 개 작성할 수도 있습니다.

1
2
3
4
5
plugins {
    java // Java    
    id("org.springframework.boot") version "3.3.0"     // Java + Spring Boot
}

플러그인은 여러 task와 설정들이 묶여 있는 집합으로, 여러 개를 선언할 수 있으며 원하는 플러그인을 지정하여 적용할 수 있습니다.

그래서 내부적으로는 아래와 같이 설정되어 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// java 플러그인
tasks.register("jar") {          // 플러그인이 등록
    // java 빌드를 묶은 Jar
}

// Spring Boot 플러그인
tasks.register("bootJar") {     // 플러그인이 등록
    // java 빌드 + Spring Boot + 런타임 의존성을 묶은 Jar
}

// Spring Boot 플러그인
tasks.register("bootRun") {     // 플러그인이 등록
    // java 빌드 + Spring Boot + 런타임 의존성으로 APP 바로 실행
}

Task 등록뿐만 아니라 플러그인은 별도의 설정 블록(extension)도 추가해 줍니다. 아래처럼 JDK를 지정하는 블록도 extension입니다.

1
2
3
4
5
java { 
	toolchain { 
		languageVersion = JavaLanguageVersion.of(17) // Java 17로 빌드 
    } 
}

Source Set과 Configuration

이 단계에서는 소스 코드와 라이브러리 의존성들을 쓰는 시기에 따라 나눠서 관리합니다.

각 단계마다 필요한 코드와 라이브러리들이 달라지며, 어느 시점에 어떤 도구가 사용될지 지정할 수 있습니다.

1. 소스 설정

java 플러그인이 main과 test 코드를 찾습니다.

main (앱 코드)

ㄴsrc/main/java, src/main/resources

test (테스트 코드)

ㄴsrc/test/java, src/test/resources

당연하겠지만 앱 코드는 전 과정에서, 테스트 코드는 테스트할 때만 사용됩니다.

2. Configuration 설정 (라이브러리 분류)

두 가지로 나뉘는 소스 코드와 달리 라이브러리는 시점이 조금 더 많습니다.

  • implementation: 컴파일 + 실행
  • compileOnly: 컴파일 (컴파일할 때만 필요한 것)
  • runtimeOnly: 실행 (코드에서 직접 import하지 않는 것)
  • testImplementation: 테스트만

예시를 들면 아래와 같이 적습니다.

1
2
3
4
5
6
dependencies {
    implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
    compileOnly 'org.projectlombok:lombok:1.18.34'
    runtimeOnly 'org.postgresql:postgresql:42.7.3'
    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
}

3. classpath 만들기

라이브러리의 시점에 따라 실제 필요한 jar 목록을 만듭니다.

compileClasspath = implementation + compileOnly

runtimeClasspath = implementation + runtimeOnly

여기서 classpath는 그 시점에 필요한 라이브러리들의 실제 jar 목록입니다.

최종적으로 bootJar에는 runtimeClasspath가 담깁니다.

의존성 해석

runtimeClasspath에는 jar 목록이 들어가 있습니다.

하나의 라이브러리를 추가하면, 그 라이브러리가 필요로 하는 또 다른 의존성이 함께 들어올 수 있습니다.

이 목록을 만드는 것을 의존성 해석이라고 합니다.

예를 들어 아래와 같은 Spring Boot 라이브러리를 가져오려고 했습니다.

1
2
3
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'   // 한 줄만 적음
}

저는 한 줄을 적었지만, 내부적으로는 아래와 같이 따라옵니다.

1
2
3
4
5
6
spring-boot-starter-web          ← 내가 적은 것
├── spring-webmvc                ← 따라온 것
├── spring-boot-starter-tomcat   ← 따라온 것
│   └── tomcat-embed-core        ← 따라온 것의 따라온 것
└── spring-boot-starter-json
    └── jackson-databind         ← 따라온 것

겨우 한 줄 적었는데 우수수 따라옵니다.

라이브러리가 많아지면 하나의 라이브러리를 여러 라이브러리가 가지고 오는 상황도 생깁니다.

  • 라이브러리 A → jackson-databind 2.15.0이 필요
  • 라이브러리 B → jackson-databind 2.17.2가 필요

라이브러리 충돌

jackson 라이브러리가 여러 버전일 때 Gradle은 기본적으로 가장 높은 버전을 선택합니다.

트리로 확인하면 아래와 같이 보입니다.

1
2
3
4
+--- A
|    \--- com.fasterxml.jackson.core:jackson-databind:2.15.0 -> 2.17.2
\--- B
     \--- com.fasterxml.jackson.core:jackson-databind:2.17.2

여기서 ->는 버전 충돌이 난 라이브러리가 이 버전으로 바뀌었다는 것을 표현합니다.

Maven은 트리 기준 가장 가까운 버전을 선택합니다.

정리

IntelliJ에서 ./gradlew build를 실행하면 아래와 같은 흐름으로 실행됩니다.

  1. Gradle Wrapper가 프로젝트에 맞는 Gradle 버전을 찾고 실행합니다.
  2. settings.gradle을 보고 프로젝트를 결정한 뒤, build.gradle을 보고 plugin과 그에 맞는 task 그래프를 구성합니다.
  3. 플러그인이 넣어 준 task들에 대해서 입력과 출력을 확인한 뒤, 변화가 없으면 UP-TO-DATE로 건너뜁니다.
  4. 의존성을 확인하고 각 classpath를 생성합니다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.