이 글에서 다루는 것
"Gradle 소스 파헤치기"는 빌드 도구 Gradle의 소스 코드를 AI의 도움을 받아 조금씩 읽고 정리하는 연재입니다. 앞서 끝낸 Spring 소스 파헤치기(27편), Apache httpd 소스 파헤치기(16편), Tomcat 소스 파헤치기(16편)와 같은 방식으로 읽습니다. 바로 앞 연재는 MariaDB 소스 파헤치기입니다.
앞의 연재들이 "서버가 요청을 받아 처리하는 길"을 읽었다면, 이번에는 그 서버들을 만드는 도구를 읽습니다. Spring Framework 저장소도 Gradle로 빌드되고, 이 블로그도 Gradle(Kotlin DSL)로 빌드됩니다. 블로그 저장소의 build.gradle.kts에는 kotlin("jvm") 플러그인과 로컬 미리보기를 띄우는 tasks.register<JavaExec>("preview")가 있고, 글을 올리기 전에 ./gradlew test를 돌립니다. 매일 쓰는 이 명령들 뒤에서 무슨 일이 일어나는지가 이 연재의 질문입니다.
이 글은 그 첫 번째 글이고, 코드를 깊이 읽기 전에 준비를 합니다. 답하려는 질문은 다섯 가지입니다.
- Gradle은 무엇이고, 어떻게 동작하고, 어디에 쓰는가 (처음 보는 분을 위한 절)
- 어떤 판의 소스를 읽는가. 우리가 내려받아 쓰는 Gradle과는 무엇이 다른가
- 이 연재와 똑같은 소스를 어떻게 받고, 어떻게 빌드해서 돌려 보는가 — Gradle은 무엇으로 빌드하는가
- 100만 줄쯤 되는 소스는 어떤 덩어리로 나뉘어 있고, 어디서부터 읽어야 하는가
gradle build한 번은 소스의 어떤 클래스들을 지나 내가 쓴 태스크 액션에 닿는가
Gradle을 처음 듣는 분은 바로 아래 "Gradle은 무엇인가" 절부터 읽으면 됩니다. 그림 두 장과 3분짜리 실습으로 Gradle이 하는 일을 먼저 눈으로 보고, 이 연재가 어떤 궁금증을 풀어 가는지 훑어본 뒤 본론으로 들어갑니다. 그다음부터는 Gradle을 프로젝트에서 써 본 개발자가 Gradle 소스를 처음 여는 상황을 기준으로 씁니다. build.gradle(.kts)를 고쳐 봤고, plugins { java }로 플러그인을 걸고, implementation("…")으로 의존성을 더하고, tasks.register("…") { doLast { … } }로 태스크를 하나쯤 만들어 본 사람입니다. Java나 Kotlin은 잘 알지만 Gradle의 소스는 처음 연다고 봅니다.
| 항목 | 내용 |
|---|---|
| 대상 | Gradle Build Tool (gradle/gradle) |
| 버전 | 9.10.0 개발판 (master 브랜치) |
| 커밋 | 07ecbf9ead (2026-10-24) |
| 언어 | Java, Kotlin (빌드 스크립트 엔진은 Kotlin·Groovy) |
| 빌드에 쓰는 것 | 래퍼 Gradle 9.9.0-milestone-2, JDK 25 |
| 이 글에서 주로 읽는 파일 | version.txt, CONTRIBUTING.md, settings.gradle.kts, launcher/GradleMain.java, launcher/cli/BuildActionsFactory.java, launcher/daemon/server/DaemonServices.java, execution/plan/DefaultPlanExecutor.java |
글에 나오는 줄 번호와 세부 내용은 모두 이 커밋 기준입니다. Gradle은 내부를 자주 고치는 프로젝트라 다른 버전에서는 줄 번호뿐 아니라 클래스 이름이 다른 곳도 있습니다(6절에서 예를 듭니다). 이 글은 연재 전체의 지도이므로 큰 줄기만 다루고, 깊은 내용은 각 편으로 넘깁니다.
연재는 모두 12편입니다.
- AI와 함께 소스를 읽는 방법과 분석 지도 (이 글)
- 전체 지도 — 클라이언트와 데몬, 서비스 스코프, 빌드의 세 단계
- gradle 명령에서 데몬까지 — 런처, 래퍼, 데몬 프로토콜
- 서비스 레지스트리 — Gradle 안의 DI 컨테이너
- 빌드 생명주기 — 초기화, 설정, 실행은 어디서 갈리나
- 프로젝트와 빌드 스크립트 — build.gradle.kts가 실행되기까지
- 플러그인 적용 — plugins {} 블록에서 apply()까지
- 태스크와 지연 설정 — DefaultTask, TaskContainer, Provider
- 태스크 그래프와 실행 계획 — 무엇을, 어떤 순서로, 몇 개씩
- 실행 엔진과 증분 빌드 — UP-TO-DATE는 어떻게 정해지나
- 빌드 캐시와 설정 캐시 — 결과를 다시 쓰는 두 가지 방법
- 의존성 해석 — implementation 한 줄이 jar 파일이 되기까지, 그리고 연재를 마치며
Gradle은 무엇인가 — 어떻게 동작하고, 어디에 쓰나
소스를 열기 전에, Gradle이 하는 일부터 그림과 실습으로 봅니다. 이미 Gradle을 써 봤다면 이 절 끝의 "이 연재가 풀어 갈 질문"만 보고 넘어가도 됩니다.
한 문장으로 — 할 일 목록을 보고 순서대로, 바뀐 것만 다시 하는 "작업 반장"
Gradle은 빌드의 작업 반장입니다. 프로그램 하나를 내놓으려면 "소스를 컴파일한다", "테스트를 돌린다", "결과를 jar 파일로 묶는다" 같은 일을 정해진 순서로 해야 합니다. 이런 일 하나하나를 Gradle에서는 태스크(task)라고 부릅니다. 반장은 할 일 목록(빌드 스크립트)을 읽고, 무엇이 무엇보다 먼저인지 따져 순서를 정하고, 지난번과 달라진 것이 없는 일은 건너뜁니다. 어제 컴파일한 소스를 하나도 고치지 않았다면 오늘 다시 컴파일할 이유가 없으니까요.
이 연재 전체가 결국 "반장은 할 일 목록을 어떻게 읽고, 순서를 어떻게 정하고, 건너뛰어도 된다는 것을 어떻게 아나"를 소스에서 따라가는 이야기입니다.
왜 이런 것이 필요한가
Java 파일 하나라면 javac Hello.java로 끝납니다. 그런데 프로젝트가 커지면 손으로 하기 어려워집니다.
- 소스 파일이 수백 개가 되고, 테스트를 돌리고, jar로 묶는 일을 매번 같은 순서로 해야 합니다. 한 단계를 빼먹으면 옛 결과물이 그대로 나갑니다.
- 남이 만든 라이브러리(로그, JSON, 테스트 도구 …)를 쓰려면 맞는 판의 jar를 내려받아 클래스패스에 넣어야 합니다. 그 라이브러리가 또 다른 라이브러리를 필요로 하면 그것도 찾아야 합니다.
- 파일 하나를 고쳤는데 모든 것을 처음부터 다시 하면 기다리는 시간이 길어집니다.
- 내 PC에서는 되는데 동료 PC나 CI 서버(커밋할 때마다 자동으로 빌드·테스트하는 서버)에서는 안 되는 일이 생깁니다.
Gradle을 쓰면 이 일들을 build.gradle.kts라는 파일에 한 번 적어 둡니다. 그 뒤에는 누구든 ./gradlew build 한 줄로 같은 순서의 빌드를 합니다. 라이브러리는 implementation("…") 한 줄이면 Gradle이 받아 오고, 바뀌지 않은 태스크는 건너뜁니다.
Gradle이 놓이는 자리. 사람이든 IDE든 CI 서버든 같은 빌드 스크립트로 같은 빌드를 합니다.
어떻게 동작하나 — 네 가지 말로
Gradle을 움직이는 말은 네 개면 충분합니다. 아래 실습과 그림이 이 넷을 그대로 보여 줍니다.
- 태스크(task) — 할 일 하나입니다.
compileJava(컴파일),test(테스트),jar(묶기)처럼 이름이 있고, 실행되면 태스크 액션(실제로 도는 코드)을 돌립니다. - 의존 관계(
dependsOn) — "이 태스크 전에 저 태스크가 끝나 있어야 한다"는 약속입니다. Gradle은 내가 부른 태스크에서 시작해 이 약속을 따라가며 필요한 태스크를 모읍니다. 이렇게 모은 것을 태스크 그래프라고 부릅니다. 부르지 않았고 아무도 기대지 않는 태스크는 그래프에 들어가지 않습니다. - 두 단계 — 설정과 실행 — Gradle은 먼저 빌드 스크립트를 처음부터 끝까지 실행해 "어떤 태스크가 있고 서로 어떻게 이어지는지"를 알아내고(설정 단계), 그다음 태스크 그래프의 순서대로 태스크 액션을 돌립니다(실행 단계). 빌드 스크립트 본문은 설정 단계에,
doLast { … }안의 코드는 실행 단계에 돕니다. - 입력과 출력 — 태스크마다 "무엇을 읽고(입력) 무엇을 만드는지(출력)"를 적어 둘 수 있습니다. Gradle은 지난번 실행 때의 입력·출력을 기억해 두었다가, 둘 다 그대로면 태스크를 돌리지 않고
UP-TO-DATE(이미 최신)라고 찍고 넘어갑니다.
여기에 플러그인(plugin)이 더해집니다. 플러그인은 "자주 쓰는 태스크 묶음"입니다. plugins { java } 한 줄이면 compileJava, test, jar, build 같은 태스크가 이미 서로 이어진 채로 생깁니다.
3분 실습 — 태스크 셋을 이어 두 번 돌려 보기
말로만 보면 감이 오지 않으니 직접 돌려 봅니다. 이 연재가 빌드한 Gradle(3절에서 만드는 도커 이미지)로 돌렸지만, 특별한 기능은 쓰지 않으므로 내려받은 Gradle로도 같은 순서로 해 볼 수 있습니다(출시판으로는 돌려 보지 않았습니다). 예제 폴더는 ex/u01-first-look이고, 빌드 스크립트 전체가 이렇습니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// 태스크 셋을 dependsOn 으로 이어 둔 아주 작은 빌드: greet → shout → hello
println("[configuration] running build.gradle.kts") // 설정 단계에 찍힌다
val who = providers.gradleProperty("who").orElse("world") // -Pwho=… 로 바꿀 수 있는 값
// 1) 인사말을 파일에 쓴다 — 입력: who, 출력: greeting.txt
val greet = tasks.register("greet") {
val out = layout.buildDirectory.file("greeting.txt")
inputs.property("who", who)
outputs.file(out)
doLast {
out.get().asFile.writeText("Hello, ${who.get()}!\n")
println("greet: wrote greeting.txt")
}
}
// 2) greet 가 쓴 파일을 대문자로 바꾼다 — 입력: greeting.txt, 출력: shout.txt
val shout = tasks.register("shout") {
dependsOn(greet)
val src = layout.buildDirectory.file("greeting.txt")
val out = layout.buildDirectory.file("shout.txt")
inputs.file(src)
outputs.file(out)
doLast {
out.get().asFile.writeText(src.get().asFile.readText().uppercase())
println("shout: wrote shout.txt")
}
}
// 3) 결과를 화면에 찍는다 — 출력을 선언하지 않았으니 매번 돈다
tasks.register("hello") {
dependsOn(shout)
val src = layout.buildDirectory.file("shout.txt")
doLast { print("hello: " + src.get().asFile.readText()) }
}
// 4) 아무도 부르지 않는 태스크 — 등록만 되고 실행 계획에는 들어가지 않는다
tasks.register("unused") { doLast { println("unused: this never runs") } }
tasks.register("이름") { … }가 태스크를 하나 등록하고, inputs·outputs로 입력과 출력을 적고, doLast { … }에 태스크 액션을 넣습니다. 하는 일은 이렇습니다.
--dry-run으로hello를 부르면 무엇을 어떤 순서로 할지만 보여 줍니다.hello를 처음 실행합니다.- 아무것도 바꾸지 않고 한 번 더 실행합니다.
- 입력
who를Gradle로 바꿔 실행합니다. - 다른 프로젝트에서
plugins { java }한 줄만 적고, 생긴 태스크를 봅니다. - 그 프로젝트에 Java 파일 하나를 두고
jar를 만들어 실행합니다.
1~4단계의 명령은 gradle hello --dry-run, gradle hello, gradle hello, gradle hello -Pwho=Gradle이고(모두 --offline --console=plain을 붙였습니다), 5단계는 gradle tasks --all의 출력에서 일곱 줄만 골랐습니다. 출력입니다.
=== 1. dry run: the plan for 'hello' (nothing is executed)
Welcome to Gradle 9.10.0-20261107025045+0000!
Starting a Gradle Daemon (subsequent builds will be faster)
Shared read-only dependency cache is an incubating feature.
> Configure project :
[configuration] running build.gradle.kts
:greet SKIPPED
:shout SKIPPED
:hello SKIPPED
BUILD SUCCESSFUL in 15s
=== 2. first run
> Configure project :
[configuration] running build.gradle.kts
> Task :greet
greet: wrote greeting.txt
> Task :shout
shout: wrote shout.txt
> Task :hello
hello: HELLO, WORLD!
BUILD SUCCESSFUL in 1s
3 actionable tasks: 3 executed
=== 3. second run, nothing changed
> Configure project :
[configuration] running build.gradle.kts
> Task :greet UP-TO-DATE
> Task :shout UP-TO-DATE
> Task :hello
hello: HELLO, WORLD!
BUILD SUCCESSFUL in 1s
3 actionable tasks: 1 executed, 2 up-to-date
=== 4. change an input (-Pwho=Gradle)
> Configure project :
[configuration] running build.gradle.kts
> Task :greet
greet: wrote greeting.txt
> Task :shout
shout: wrote shout.txt
> Task :hello
hello: HELLO, GRADLE!
BUILD SUCCESSFUL in 981ms
3 actionable tasks: 3 executed
=== 5. one line 'plugins { java }' adds these tasks (excerpt of 'gradle tasks --all')
build - Assembles and tests this project.
classes - Assembles main classes.
clean - Deletes the build directory.
jar - Assembles a jar archive containing the classes of the 'main' feature.
test - Runs the test suite.
compileJava - Compiles main Java source.
processResources - Processes main resources.
=== 6. build the jar and run it
> Task :compileJava
> Task :processResources NO-SOURCE
> Task :classes
> Task :jar
BUILD SUCCESSFUL in 1s
2 actionable tasks: 2 executed
Hello from a jar built by Gradle
(실행할 때마다 붙는 마지막 줄 "Consider enabling configuration cache to speed up this build: …"는 빌드마다 똑같아서 지웠습니다. BUILD SUCCESSFUL in …의 시간은 실행마다 다릅니다. "Shared read-only dependency cache is an incubating feature."는 이 연재의 도커 이미지가 미리 받아 둔 라이브러리 캐시를 쓰기 때문에 나오는 줄입니다(3절).)
실습에서 본 것을 그대로 그린 모습. 파란 칸이 설정 단계, 노란 칸이 태스크 그래프, 초록 칸이 실행 단계입니다.
출력에서 볼 것은 다섯 가지입니다.
- 실행하기 전에 계획부터 세웁니다. 1단계의
--dry-run은 태스크를 하나도 돌리지 않고:greet,:shout,:hello를 순서대로 찍었습니다.hello만 불렀는데dependsOn을 따라 셋이 모였고, 순서는 기대는 쪽이 뒤에 옵니다. 등록만 해 둔unused는 목록에 없습니다.--dry-run일 때 Gradle은 실행 계획에 든 태스크마다 이름 뒤에SKIPPED를 붙여 찍고 끝냅니다(DefaultBuildWorkExecutor.java 106–117행). 이 계획을 어떻게 세우는지는 9편에서 봅니다. - 빌드 스크립트는 매번 먼저 실행됩니다.
[configuration]줄은 네 번 모두, 그리고 늘> Task줄보다 먼저 찍혔습니다. 스크립트 본문은 설정 단계,doLast안은 실행 단계라는 것이 출력 순서로 보입니다. 두 단계가 소스의 어디서 갈리는지는 5편의 주제입니다. - 바뀐 것이 없으면 건너뜁니다. 3단계에서
greet와shout는UP-TO-DATE로 넘어갔고, 마지막 줄도3 actionable tasks: 1 executed, 2 up-to-date로 바뀌었습니다. 실행 엔진의 한 단계가 "지난번과 달라진 것"이 없고 다시 돌릴 이유도 없으면 태스크 액션을 부르지 않고UP_TO_DATE로 끝냅니다(SkipUpToDateStep.java 49–58행). 그런데hello는 매번 돌았습니다. 출력을 하나도 선언하지 않은 태스크는 "Task has not declared any outputs despite executing actions."라는 이유로 늘 다시 실행되기 때문입니다(DefaultTaskExecutionMode.java 32행). "무엇이 바뀌었나"를 어떻게 알아내는지는 10편에서 봅니다. - 입력이 바뀌면 그 뒤가 줄줄이 다시 돕니다. 4단계에서
-Pwho=Gradle로greet의 입력을 바꾸자greet가 다시 돌았고, 그 결과greeting.txt의 내용이 바뀌니 그것을 입력으로 받는shout도 다시 돌았습니다. Gradle은 "누가 무엇을 바꿨나"를 따로 추적하지 않습니다. 태스크마다 자기 입력과 출력만 지난번과 견줍니다. - 플러그인 한 줄이 태스크를 만듭니다. 5단계의 프로젝트는 빌드 스크립트가
plugins { java }한 줄뿐인데compileJava,processResources,classes,jar,test,build,clean이 생겼고, 6단계에서jar만 불러도compileJava→processResources→classes→jar가 순서대로 돌아 jar 파일이 나왔고, 마지막 줄은 그 jar 안의 클래스를java -cp로 실행한 결과입니다(processResources는 리소스 파일이 없어NO-SOURCE로 건너뛰었습니다).java플러그인은 그 아래의JavaBasePlugin을 먼저 걸고(JavaPlugin.java 272–273행), 그 안에서 우리가 쓴 것과 같은tasks.register로compileJava를 등록합니다(JavaBasePlugin.java 237–242행).build는assemble과check에dependsOn으로 기대는 빈 태스크입니다(LifecycleBasePlugin.java 116–120행). 플러그인이 걸리는 길은 7편에서 따라갑니다.
어디에 쓰나
| 쓰임 | 하는 일 | 예 |
|---|---|---|
| JVM 언어 빌드 | Java·Kotlin·Groovy·Scala 소스를 컴파일하고 테스트를 돌리고 jar로 묶는다 | plugins { java }, 이 블로그의 kotlin("jvm") |
| Android 앱 빌드 | Android Studio의 프로젝트가 Gradle 빌드 스크립트로 되어 있고, 그 위에 Android용 플러그인이 얹힌다 | ./gradlew assembleDebug |
| 의존성 관리 | 필요한 라이브러리와 그것이 다시 필요로 하는 라이브러리를 받아 오고, 판이 부딪히면 하나로 고른다 | implementation("…") 한 줄 |
| 테스트·패키징 자동화 | 테스트, 정적 검사, 배포판(zip·tgz) 만들기, 저장소에 올리기(publish)를 태스크로 엮어 한 명령으로 돌린다 | CI 서버의 ./gradlew build |
| 큰 다중 프로젝트 | 모듈 수십~수백 개를 한 빌드로 묶고, 바뀐 모듈만 다시 빌드한다 | Gradle 저장소 자신(서브프로젝트 233개, 4절), Kafka, Spring Framework |
Gradle 저장소의 README도 Gradle을 Java, Kotlin, Scala, Android, Groovy, C++, Swift 애플리케이션을 빌드·테스트·배포하는 도구로 소개합니다(README.md 26행). C·C++·Swift 빌드는 platforms/native에 있지만 이 연재에서는 다루지 않습니다(4절의 표).
반대로 맞지 않는 곳도 있습니다. 파일 한두 개짜리 스크립트나 연습용 코드라면 java Hello.java처럼 바로 돌리는 편이 빠르고, Gradle은 처음 뜰 때 JVM과 데몬(뒤의 "먼저 알아 둘 말")을 띄우는 시간이 듭니다. 위 실습에서도 첫 명령은 15초, 그다음은 1초 안팎이었습니다. 또 Gradle은 빌드 규칙을 코드(Kotlin·Groovy)로 적는 도구라서, 규칙이 단순한 프로젝트에서는 그 자유가 오히려 읽을거리만 늘립니다. 같은 JVM 세계에는 Maven이라는 다른 빌드 도구도 있지만, 이 연재는 둘을 견주지 않고 Gradle만 읽습니다.
이 연재가 풀어 갈 질문
실습을 돌려 보면 궁금한 것이 생깁니다. 이 연재는 그 궁금증을 소스로 하나씩 풉니다. 끌리는 질문이 있으면 그 편부터 읽어도 됩니다.
| 궁금한 것 | 답이 있는 곳 |
|---|---|
터미널에서 친 gradle이 정말 빌드를 하는 프로세스일까? 전체는 어떤 덩어리로 되어 있을까? |
2편: 전체 지도 |
| 첫 명령은 15초, 그다음은 1초 — "Starting a Gradle Daemon"의 데몬은 무엇이고 어떻게 찾아갈까? | 3편: 런처, 래퍼, 데몬 프로토콜 |
빌드 스크립트에서 그냥 쓴 layout, providers는 어디서 왔을까? |
4편: 서비스 레지스트리 |
[configuration] 줄은 왜 매번, 태스크보다 먼저 찍힐까? |
5편: 빌드 생명주기 |
build.gradle.kts는 Kotlin 파일인데, 누가 컴파일하고 어떻게 실행할까? |
6편: 프로젝트와 빌드 스크립트 |
plugins { java } 한 줄은 어떻게 태스크 일곱 개를 만들까? |
7편: 플러그인 적용 |
tasks.register로 등록만 한 unused는 객체가 만들어지기는 할까? |
8편: 태스크와 지연 설정 |
dependsOn에서 순서는 어떻게 나오고, 서로 상관없는 태스크는 동시에 돌 수 있을까? |
9편: 태스크 그래프와 실행 계획 |
UP-TO-DATE는 무엇을 무엇과 견줘서 정할까? 기록은 어디에 남을까? |
10편: 실행 엔진과 증분 빌드 |
| 남이 빌드한 결과를 받아 쓸 수 있을까? 매번 도는 설정 단계를 건너뛸 수는 없을까? | 11편: 빌드 캐시와 설정 캐시 |
implementation("…") 한 줄은 어떻게 내 PC의 jar 파일이 될까? |
12편: 의존성 해석 |
이 글의 나머지는 이 질문들을 소스로 따라가기 위한 준비입니다. 어떤 판을 읽는지(1절), 소스를 받아 빌드하는 법(2·3절), 폴더 지도(4절), 그리고 gradle build 한 번이 실제로 어떤 클래스들을 지나가는지(5절)를 봅니다. 직접 빌드해 볼 생각이 없다면 2·3절은 건너뛰어도 뒤의 내용을 읽는 데 지장이 없습니다.
먼저 알아 둘 말
Gradle을 쓰면서 이름은 들어 봤지만 소스에서 무엇인지는 몰랐을 말 여섯 가지를 먼저 잡아 둡니다.
데몬(daemon)은 빌드를 실제로 하는 오래 사는 JVM 프로세스입니다. 터미널에서 gradle을 치면 뜨는 프로세스(이것을 클라이언트라고 부릅니다)는 빌드를 하지 않고, 데몬을 찾거나 띄워서 "이 태스크들을 돌려 달라"고 소켓으로 부탁한 뒤 데몬이 보내 주는 로그를 화면에 찍을 뿐입니다. 첫 빌드에 Starting a Gradle Daemon (subsequent builds will be faster)가 찍히는 것이 이 데몬입니다. 왜 이렇게 나눴는지는 5절에서 조금, 3편에서 자세히 봅니다.
래퍼(wrapper)는 프로젝트에 들어 있는 gradlew 스크립트와 gradle/wrapper/gradle-wrapper.properties입니다. 적힌 판의 Gradle을 내려받아 ~/.gradle/wrapper/dists에 풀고 그것으로 빌드합니다. 그래서 PC에 어떤 Gradle이 깔렸든 프로젝트가 정한 판으로 빌드됩니다. 이 블로그는 9.5.1, Gradle 저장소 자신은 9.9.0-milestone-2를 씁니다.
빌드 오퍼레이션(build operation)은 Gradle이 빌드 안에서 하는 일 하나하나에 붙이는 이름 있는 기록 단위입니다. "settings 읽기", "프로젝트 설정", "태스크 :hello 실행" 같은 일이 시작하고 끝날 때마다 기록되고, 이것들이 부모·자식으로 쌓여 나무가 됩니다. 콘솔의 진행 표시, Build Scan, IDE의 빌드 창이 모두 이 기록을 받아 그립니다. 5절의 예제에서 이 나무를 파일로 뽑아 봅니다.
플랫폼·모듈·서브프로젝트는 Gradle 저장소를 나누는 세 층의 단위입니다. 서브프로젝트는 우리가 아는 Gradle 다중 프로젝트의 하위 프로젝트 하나(platforms/core-runtime/launcher 같은 폴더 하나, jar 하나)입니다. 이것을 기능별로 묶은 것이 아키텍처 모듈(core-runtime 등)이고, 모듈을 다시 크게 묶은 것이 플랫폼(core, jvm 등)입니다. 4절에서 지도로 봅니다.
작업 그래프와 실행 계획(task graph, execution plan)은 "이번 빌드에서 돌릴 태스크들과 그 순서"입니다. gradle build라고 치면 build가 의존하는 check, assemble, jar, compileJava … 를 모두 모아 순서를 정한 것이 실행 계획이고, 그 안의 태스크 하나하나를 노드(node)라고 부릅니다. 9편에서 봅니다.
태스크 액션(task action)은 태스크가 실행될 때 실제로 도는 코드입니다. doLast { … }로 넣은 블록, 커스텀 태스크 클래스의 @TaskAction 메서드가 태스크 액션입니다. 빌드 스크립트 본문에 쓴 코드는 설정 단계에, 태스크 액션은 실행 단계에 돕니다. 이 차이를 5절의 예제에서 호출 스택 두 개로 눈으로 봅니다.
1. 어떤 판을 읽나 — 9.10.0 개발판
판 번호는 한 줄짜리 파일이 정합니다
Gradle 저장소의 판 번호는 맨 위의 version.txt에 적혀 있습니다. 파일 전체가 이 한 줄입니다(줄 바꿈도 없습니다).
9.10.0
그런데 9.10.0은 아직 나온 적이 없는 판입니다. git으로 가장 가까운 태그를 물어보면 이렇게 답합니다.
$ git describe --tags
v9.9.0-M2-631-g07ecbf9ead1
"v9.9.0-M2 태그(9.9.0의 두 번째 마일스톤, 정식판 전에 내는 시험판)에서 631 커밋 지난 곳, 커밋 07ecbf9ead1"이라는 뜻입니다. 기준 커밋의 메시지는 Merge release into master (20261024) (#39583)입니다. Gradle은 출시를 준비하는 release 브랜치와 다음 판을 만드는 master 브랜치를 따로 두고, release의 고친 것을 주기적으로 master에 합칩니다. 이 커밋이 그런 합치기 커밋이고, 이 연재는 9.9.0 다음 판인 9.10.0을 준비하는 master 줄기를 읽습니다.
왜 출시판(예: 9.8.x)이 아니라 master를 읽느냐 하면, Tomcat 연재에서 main을, httpd 연재에서 trunk를 읽은 것과 같은 이유입니다. 고친 것과 새 구조는 개발 줄기에 먼저 들어가고, 글에 링크한 코드가 GitHub에서 그대로 열립니다. 대신 Gradle은 내부 클래스를 자주 바꾸므로, 인터넷의 옛 글에 나오는 클래스 이름이 이 커밋에 없는 일이 많습니다. 이 연재는 늘 "이 커밋에 그 클래스가 있는가"부터 확인합니다(6절).
Gradle은 Gradle로 빌드됩니다
Gradle 저장소는 그 자체가 아주 큰 Gradle 다중 프로젝트입니다. 저장소 맨 위에 gradlew와 settings.gradle.kts가 있고, 래퍼 설정은 이렇습니다.
distributionUrl=https\://services.gradle.org/distributions/gradle-9.9.0-milestone-2-bin.zip
gradle/wrapper/gradle-wrapper.properties 4행
즉 9.9.0-milestone-2가 9.10.0의 소스를 빌드합니다. 앞 판의 Gradle로 다음 판의 Gradle을 만드는 셈입니다. 컴파일러가 자기 자신을 컴파일하는 것과 비슷한 구조입니다.
데몬을 띄울 JDK는 같은 폴더의 gradle-daemon-jvm.properties가 정합니다.
toolchainVersion=25
gradle/gradle-daemon-jvm.properties 12행
이 파일은 첫 줄 주석대로 :updateDaemonJvm 태스크가 만든 것이고, "이 빌드의 데몬은 JDK 25로 띄워라"는 뜻입니다(이 기능은 연재에서 깊이 다루지 않고, 3편에서 데몬을 고를 때 이 기준도 본다는 것만 짧게 짚습니다). CONTRIBUTING.md도 "Adoptium JDK version 25"가 있어야 하고, 원격 빌드 캐시를 쓰려면 이 판이 꼭 맞아야 한다고 적고 있습니다(CONTRIBUTING.md 65행).
저장소의 빌드 규칙(어떤 모듈을 어떻게 컴파일하고, jar를 어떻게 묶고, 배포판을 어떻게 만드는지)은 build-logic* 폴더 셋에 있습니다. settings.gradle.kts가 이것들을 포함된 빌드(included build — 다른 빌드를 통째로 끌어와 그 안의 플러그인을 쓰는 것)로 겁니다.
includeBuild("build-logic-commons")
includeBuild("build-logic")
나머지 하나 build-logic-settings는 그 위 12행의 pluginManagement { includeBuild("build-logic-settings") }로 걸립니다. 우리 프로젝트에서 buildSrc나 컨벤션 플러그인으로 하던 일을 Gradle 저장소는 이 세 빌드로 하고 있습니다(합쳐서 Kotlin·Java·Groovy 파일 589개, 약 6만 4천 줄). **Gradle의 소스를 읽을 때 build-logic*/는 Gradle 자체가 아니라 "Gradle 저장소를 빌드하는 플러그인"**이라는 것을 기억해 두면 헷갈리지 않습니다.
Gradle이 Gradle로 빌드되는 모습. 회색은 바깥에서 오는 것, 파란색은 빌드 과정, 초록색은 결과입니다.
헷갈리기 쉬운 곳
세 가지 판이 함께 나옵니다. 이 연재에서 "Gradle 몇 판"이라고 할 때 가리키는 것이 셋이라 처음에는 헷갈립니다.
| 무엇 | 판 | 어디에 적혀 있나 |
|---|---|---|
| 이 연재가 읽는 소스 | 9.10.0 개발판 (9.10.0-20261107025045+0000) |
version.txt, 빌드하면 판 뒤에 빌드 시각이 붙음 |
| 그 소스를 빌드하는 Gradle | 9.9.0-milestone-2 | Gradle 저장소의 gradle-wrapper.properties |
| 이 블로그를 빌드하는 Gradle | 9.5.1 | 블로그 저장소의 gradle-wrapper.properties |
블로그의 ./gradlew test는 9.5.1로 돌기 때문에 이 연재가 읽는 코드와 줄 번호가 다릅니다. 글의 예제는 모두 기준 커밋을 빌드한 9.10.0 개발판으로 돌렸습니다.
2. 같은 소스 받기
이 절과 다음 3절은 글의 예제를 직접 돌려 보고 싶은 분을 위한 것입니다. 읽기만 할 거라면 4절(폴더 지도)로 넘어가도 됩니다.
소스 받고 커밋 고정하기
글의 줄 번호를 직접 열어 보려면 같은 커밋의 소스가 있어야 합니다. Git이 있으면 다음 순서로 합니다.
- 저장소를 받습니다. 이력이 길고 파일이 3만 개 가까이 되어 시간이 꽤 걸립니다.
- 기준 커밋을 따로 꺼내 둡니다. 저는 받은 폴더(
gradle)는 그대로 두고 git worktree로 기준 커밋만 다른 폴더(gradle-07ecbf9ead)에 꺼냈습니다. worktree는 저장소 하나에서 다른 커밋을 다른 폴더로 동시에 꺼내 두는 기능입니다. 원래 폴더에서git pull로 최신 소스를 받아도 기준 커밋 폴더의 줄 번호는 바뀌지 않습니다. - 제대로 맞춰졌는지 확인합니다. 긴 커밋 번호가 그대로 나오면 됩니다.
git clone https://github.com/gradle/gradle.git
cd gradle
git -c core.longpaths=true worktree add --detach ../gradle-07ecbf9ead 07ecbf9ead10aaeb060ca3e2dcba1f738815c083
git -C ../gradle-07ecbf9ead rev-parse HEAD
worktree가 번거로우면 받은 폴더에서 git checkout 07ecbf9ead10aaeb060ca3e2dcba1f738815c083으로 그 커밋에 맞춰도 됩니다. 내려받지 않고 웹에서만 봐도 됩니다. GitHub에서 이 커밋을 여는 주소로 들어가면 같은 파일이 보이고, 이 연재의 코드 링크도 전부 이 커밋을 가리킵니다.
헷갈리기 쉬운 곳 — Windows에서는 경로 길이에 걸립니다
위 명령에 -c core.longpaths=true가 붙은 이유는 실제로 겪은 일 때문입니다. Windows에서 worktree를 처음 만들 때 일부 파일을 꺼내지 못해 실패했습니다. Windows는 오래전부터 경로 길이를 260자(MAX_PATH)로 제한해 왔고, Git for Windows는 core.longpaths를 켜야 그보다 긴 경로의 파일을 만듭니다. Gradle 저장소에서 가장 긴 경로를 세어 보면 저장소 안 경로만 255자입니다.
platforms/documentation/docs/src/snippets/how-to/dependency-management/dependency-management/dependency-management/managingTransitiveDependencies-dependencyAlignmentWithPlatform/kotlin/buildSrc/src/main/kotlin/myproject.java-library-conventions.gradle.kts
여기에 D:\openSource\gradle-07ecbf9ead\(32자)가 앞에 붙으면 260자를 넘습니다. 230자를 넘는 경로가 5개 있고 모두 사용자 안내서의 예제(docs/src/snippets)입니다. 설정 파일을 바꾸지 않으려고 저는 git -c core.longpaths=true …처럼 명령마다 붙여서 썼습니다(git config core.longpaths true로 저장소에 켜 두어도 됩니다). macOS·Linux에서는 이 문제가 없습니다.
파일이 많아서 grep -r도 느립니다. 이 연재는 git grep(git이 관리하는 파일만, 색인을 써서 찾는 명령)으로 찾고, 클래스가 있는지는 git ls-files '*/DefaultBuildLifecycleController.java'처럼 파일 이름으로 먼저 확인합니다.
3. Gradle을 빌드해서 돌려 보기
저장소가 권하는 방법 — install
읽기만 할 거면 빌드는 필요 없습니다. 하지만 이 연재는 글에 싣는 예제를 전부 실제로 돌려 보기 때문에 기준 커밋을 빌드한 Gradle이 필요합니다. 내려받은 출시판으로 돌리면 스택 트레이스의 줄 번호도, 있는 클래스도 다르기 때문입니다.
CONTRIBUTING.md는 고친 Gradle을 직접 써 보는 방법을 이렇게 적고 있습니다.
#### Install Gradle locally
and try out a change in behavior manually.
- Install: `./gradlew install -Pgradle_installPath=/any/path`
- Use: `/any/path/bin/gradle taskName`.
It's also a good idea to run `./gradlew sanityCheck` before submitting your change because this will help catch code style issues.
> **NOTE:** Do **NOT** run `gradle build` on the local development environment,
> even if you have Gradle or Develocity build caching enabled for the project.
> The Gradle Build Tool repository is massive, and it will take ages to build on
> a local machine without necessary parallelization and caching.
gradle build는 하지 말라는 경고가 눈에 띕니다. 우리 프로젝트에서는 습관처럼 치는 명령이지만, Gradle 저장소에서 build는 모든 모듈의 테스트(통합 테스트 포함)까지 돌리는 일이라 개인 PC로는 끝나지 않습니다. 대신 쓰는 install은 build-logic의 플러그인에 이렇게 정의되어 있습니다.
tasks.register<Sync>("install") {
description = "Installs the minimal distribution"
group = "build"
from(tasks.named("binInstallation", Sync::class.java).map { it.destinationDir })
into(installDirectory)
}
build-logic/packaging/src/main/kotlin/gradlebuild.install.gradle.kts 22–27행
우리가 쓰던 바로 그 tasks.register입니다. Sync(대상 폴더를 원본과 똑같이 맞추는 복사 태스크)로 최소 배포판(binInstallation)을 installDirectory로 옮길 뿐이고, 무거운 일은 binInstallation이 의존하는 컴파일·jar 태스크들이 합니다. installDirectory는 -Pgradle_installPath=…로 준 값입니다(BuildParams.kt 102행의 GRADLE_INSTALL_PATH = "gradle_installPath", 없으면 "You can't install without setting the gradle_installPath property." 로 멈춥니다).
.git이 없으면 커밋 id를 따로 줘야 합니다
도커 안에서 빌드하려고 저는 저장소를 git archive(커밋 하나의 파일만 .tar.gz로 뽑는 명령)로 뽑아 넣었습니다. 이렇게 하면 .git 폴더가 없습니다. 그런데 Gradle 배포판에는 자기가 어느 커밋에서 빌드됐는지가 들어갑니다. gradle --version의 Revision: 줄이 그것입니다. 그 값이 어디서 오는지 따라가 보면 이렇습니다.
val Project.buildCommitId: Provider<String>
get() = environmentVariable(BUILD_COMMIT_ID)
.orElse(gradleProperty(BUILD_PROMOTION_COMMIT_ID))
.orElse(environmentVariable(BUILD_VCS_NUMBER))
.orElse(currentGitCommitViaFileSystemQuery())
build-logic-commons/basics/src/main/kotlin/gradlebuild/basics/BuildParams.kt 233–237행
환경 변수 BUILD_COMMIT_ID(88행의 상수)를 먼저 보고, 없으면 CI용 값 둘을 보고, 마지막으로 git rev-parse HEAD를 돌립니다. 그 마지막 단계는 git이 실패하면 이렇게 됩니다.
return execOutput.result.zip(execOutput.standardOutput.asText) { result, outputText ->
if (result.exitValue == 0) outputText.trim()
else "<unknown>" // It's a source distribution, we don't know.
}
이 값은 base-services 모듈의 createBuildReceipt 태스크(this.commitId = project.buildCommitId, base-services/build.gradle.kts 78행)가 build-receipt.properties라는 파일에 적어 jar에 넣고, 실행 중에는 DefaultGradleVersion이 그 파일의 commitId를 읽어 Revision으로 보여 줍니다. 그래서 .git 없이 빌드하면서 진짜 커밋 id를 남기려고 BUILD_COMMIT_ID를 환경 변수로 주었습니다. 주지 않았다면 Revision에 <unknown>이 들어갔을 것입니다(이 경우는 소스로 따라간 결과이고, 직접 빌드해 보지는 않았습니다).
도커에서 빌드하기
실제로 쓰는 Dockerfile입니다. 세 단계(multi-stage)로 나뉩니다.
# syntax=docker/dockerfile:1
# "Gradle 소스 파헤치기" 예제용 이미지.
# 기준 커밋 07ecbf9ead (master, version.txt = 9.10.0) 의 소스를 그대로 빌드해 /opt/gradle 에 설치한다.
# 소스는 git archive 로 뽑은 gradle-07ecbf9ead.tar.gz (LF 줄 끝, 손대지 않은 원본).
#
# docker build -t gradle-lab:07ecbf9ead . (12 CPU 기준 수십 분, 네트워크 필요)
# ---- 1단계: Gradle 빌드 (저장소가 요구하는 JDK 25 — gradle/gradle-daemon-jvm.properties 의 toolchainVersion=25) ----
FROM eclipse-temurin:25-jdk AS build
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git unzip \
&& rm -rf /var/lib/apt/lists/*
COPY gradle-07ecbf9ead.tar.gz /usr/src/
WORKDIR /usr/src
# .git 이 없으므로 커밋 id 를 환경 변수로 준다 (build-logic-commons/basics/.../BuildParams.kt 의 buildCommitId).
# install = 최소 배포판(bin)을 gradle_installPath 에 설치하는 태스크 (build-logic/packaging/.../gradlebuild.install.gradle.kts).
RUN --mount=type=cache,target=/root/.gradle \
tar xzf gradle-07ecbf9ead.tar.gz && cd gradle \
&& BUILD_COMMIT_ID=07ecbf9ead10aaeb060ca3e2dcba1f738815c083 \
./gradlew --no-daemon --console=plain install -Pgradle_installPath=/opt/gradle \
&& rm -rf /usr/src/gradle
# ---- 2단계: 예제가 쓸 의존 라이브러리를 미리 받아 읽기 전용 캐시로 만든다 ----
FROM eclipse-temurin:25-jdk AS warm
COPY --from=build /opt/gradle /opt/gradle
COPY warmup /usr/src/warmup
RUN cd /usr/src/warmup \
&& GRADLE_USER_HOME=/tmp/gh /opt/gradle/bin/gradle --no-daemon --console=plain warmup \
&& mkdir -p /opt/gradle-ro-cache && cp -r /tmp/gh/caches/modules-2 /opt/gradle-ro-cache/ \
&& rm -f /opt/gradle-ro-cache/modules-2/*.lock /opt/gradle-ro-cache/modules-2/gc.properties
# ---- 3단계: 예제 실행 이미지 ----
FROM eclipse-temurin:25-jdk
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends procps unzip tree \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /opt/gradle /opt/gradle
COPY --from=warm /opt/gradle-ro-cache /opt/gradle-ro-cache
# GRADLE_RO_DEP_CACHE: Gradle 의 읽기 전용 의존성 캐시. 여기 있는 라이브러리는 네트워크 없이(--offline) 쓸 수 있다.
ENV GRADLE_HOME=/opt/gradle PATH=/opt/gradle/bin:$PATH GRADLE_RO_DEP_CACHE=/opt/gradle-ro-cache
WORKDIR /work
CMD ["gradle", "--version"]
Dockerfile의 세 단계. 노란 칸은 의존성 캐시처럼 저장해 두는 것, 초록 칸은 최종 이미지에 남는 Gradle입니다.
하는 일을 순서대로 풀면 이렇습니다.
- JDK 25 이미지에서 시작합니다.
eclipse-temurin:25-jdk는 Eclipse Adoptium이 만든 OpenJDK 25 이미지입니다. 1절에서 본 대로 저장소가 JDK 25를 요구합니다. - 소스를 압축 파일로 넣습니다. 주석의 "LF 줄 끝"은 httpd 1편에서 겪은 일 때문입니다. Windows에서 꺼낸 파일의 줄 끝이 CRLF가 되면 리눅스에서 셸 스크립트(
gradlew)가 돌지 않습니다. Gradle 저장소는.gitattributes에* text eol=lf를 두어 Windows에서도 LF로 꺼내지지만,git archive로 뽑으면 이 걱정 자체가 없습니다. - 래퍼로
install을 돌립니다../gradlew가 9.9.0-milestone-2를 내려받고, 그것이 9.10.0을 빌드해/opt/gradle에 설치합니다. 데몬을 남길 이유가 없으니--no-daemon, 진행 막대 없이 로그를 남기려고--console=plain을 붙였습니다.--mount=type=cache는 빌드를 다시 할 때 내려받은 것을 재사용하게 하는 도커 빌드 캐시입니다. - 예제용 라이브러리를 미리 받습니다. 2단계는 방금 만든 Gradle로 작은 빌드(
warmup)를 한 번 돌려 Guava, commons-text, JUnit 같은 라이브러리와kotlin-dsl플러그인을 받고, 받은 의존성 캐시(caches/modules-2)를/opt/gradle-ro-cache로 복사합니다. - 실행 이미지에는 결과만 넣습니다. 3단계에는 빌드에 쓴 소스·캐시는 빼고
/opt/gradle과 읽기 전용 캐시만 복사합니다.GRADLE_RO_DEP_CACHE는 Gradle이 "여기도 의존성 캐시로 쓰되 쓰지는 말라"는 뜻으로 읽는 환경 변수라, 예제를--offline(네트워크를 쓰지 않음)으로 돌려도 이 라이브러리들은 찾습니다. 이 캐시는 12편에서 다시 봅니다.
실제 빌드 로그의 마지막 줄은 이랬습니다.
BUILD SUCCESSFUL in 47m 47s
1245 actionable tasks: 1131 executed, 114 from cache
12 CPU에서 48분쯤 걸렸고, 1245개 태스크 가운데 114개는 원격 빌드 캐시(Gradle 팀이 운영하는, 다른 사람이 빌드한 결과를 받아 오는 서버)에서 받아 왔습니다(FROM-CACHE). 이 "결과를 받아 오는" 원리가 11편의 주제입니다.
헷갈리기 쉬운 곳 — 처음 이미지를 만들 때 2단계에서 한 번 실패했습니다
처음 이미지를 만들 때는 1단계의 48분짜리 빌드가 끝난 뒤 2단계에서 이렇게 멈췄습니다.
* What went wrong:
Execution failed for task ':test'.
> There are test sources present and no filters are applied, but the test task did not discover any tests to execute. This is likely due to a misconfiguration. Please check your test configuration. If this is not a misconfiguration, this error can be disabled by setting the 'failOnNoDiscoveredTests' property to false.
warmup 빌드에 JUnit 5 테스트를 넣고 tasks.test { useJUnitPlatform() }를 빠뜨린 것이 원인이었습니다. 테스트 소스가 있는데 테스트를 하나도 찾지 못하면 실패로 처리하는데, JUnit 5 테스트는 JUnit Platform을 켜야 찾습니다. 한 줄을 더해 다시 만들었고, 다행히 1단계는 도커가 캐시해 두어 다시 빌드하지 않았습니다. 이 연재의 테스트 예제는 모두 useJUnitPlatform()을 넣습니다.
예제를 돌리는 방법
예제는 편마다 폴더 하나(ex/u01-reading-plan처럼)에 run.sh를 두고, 이것을 컨테이너 안에서 실행하는 작은 스크립트(run-example.sh)로 돌립니다. 핵심은 한 줄입니다.
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd -W 2>/dev/null || pwd)/$dir:/work:ro" -w /work \
gradle-lab:07ecbf9ead bash ./run.sh
예제 폴더를 컨테이너의 /work에 읽기 전용(:ro)으로 붙이고, run.sh가 그 안의 프로젝트를 /tmp로 복사해서 빌드합니다. 호스트 폴더에 build/나 .gradle/이 생기지 않게 하려는 것입니다. 컨테이너는 끝나면 지웁니다(--rm). 그래서 Gradle 사용자 홈(~/.gradle)이 매번 새것입니다. 첫 빌드는 늘 데몬을 새로 띄우고 빌드 스크립트를 새로 컴파일하며, "두 번째 빌드"를 보이려면 같은 run.sh 안에서 두 번 돌립니다. pwd -W와 MSYS_NO_PATHCONV=1은 Git Bash에서 Windows 경로를 다루려는 것으로, 앞 연재와 같습니다.
예제: 빌드한 Gradle에게 직접 물어보기
이 예제는 기준 커밋(07ecbf9ead)을 그대로 빌드한 Gradle 9.10.0 개발판으로 도커 컨테이너(Eclipse Temurin JDK 25)에서 실행했습니다. 연재의 모든 예제가 같습니다. 예제 폴더는 ex/u01-reading-plan이고, 먼저 gradle --version입니다.
Welcome to Gradle 9.10.0-20261107025045+0000!
------------------------------------------------------------
Gradle 9.10.0-20261107025045+0000
------------------------------------------------------------
Build time: 2026-11-07 02:50:45 UTC
Revision: 07ecbf9ead10aaeb060ca3e2dcba1f738815c083
Kotlin: 2.4.21
Groovy: 4.0.33
Ant: Apache Ant(TM) version 1.10.17 compiled on April 6 2026
Launcher JVM: 25.0.5 (Eclipse Adoptium 25.0.5+8-LTS)
Daemon JVM: /opt/java/openjdk (no Daemon JVM specified, using current Java home)
OS: Linux 5.15.146.1-microsoft-standard-WSL2 amd64
읽을 곳은 넷입니다.
- 판 번호 뒤의
-20261107025045+0000은 개발판이라 붙은 빌드 시각입니다. 바로 아래Build time과 같은 시각입니다. 출시판이라면9.10.0만 찍힙니다. Revision이 기준 커밋입니다. 위에서BUILD_COMMIT_ID를 준 결과입니다.Kotlin: 2.4.21,Groovy: 4.0.33은 Gradle 배포판 안에 든 Kotlin과 Groovy의 판입니다.build.gradle.kts를 컴파일하는 Kotlin 컴파일러,build.gradle을 돌리는 Groovy가 이것입니다. 우리 프로젝트가kotlin("jvm") version "…"으로 고른 Kotlin과는 따로입니다.Launcher JVM과Daemon JVM이 따로 적힌 것이 "먼저 알아 둘 말"의 클라이언트와 데몬입니다. 클라이언트를 띄운 JVM과 데몬을 띄울 JVM은 다를 수 있습니다. 여기서는 데몬 JVM을 정하지 않았으니 현재JAVA_HOME을 쓴다고 알려 줍니다.
4. 폴더 지도 — 100만 줄은 어디에 있나
플랫폼과 모듈
Gradle 저장소의 맨 위에서 소스가 든 곳은 둘입니다. 대부분은 platforms/ 아래 11개 폴더에, 오래된 핵심 셋은 subprojects/ 아래에 있습니다. 이 나눔은 폴더 이름에만 있는 것이 아니라 settings.gradle.kts에 코드로 적혀 있습니다. 저장소의 build-logic이 platform(…), module(…), uses(…)라는 작은 DSL을 만들어 두었기 때문입니다. 예를 들어 플러그인을 다루는 플랫폼은 이렇게 선언됩니다.
// Extensibility Platform
platform("extensibility") {
uses(core)
uses(jvm)
subproject("plugin-use")
subproject("plugin-development")
subproject("unit-test-fixtures")
subproject("test-kit")
}
"extensibility 플랫폼은 core와 jvm 플랫폼을 쓰고(uses), 서브프로젝트 넷으로 이루어진다"는 뜻입니다. subproject("plugin-use")가 platforms/extensibility/plugin-use/ 폴더입니다. 우리가 settings.gradle.kts에 include(":app")을 적던 자리를, Gradle 저장소는 이렇게 플랫폼별로 묶어 적습니다. architecture/platforms.md의 그림도 이 선언에서 만든 것입니다(파일 첫머리에 "generated diagram"이라고 적혀 있습니다).
각 플랫폼의 크기를 직접 세면 이렇습니다. 모듈 수는 그 폴더 바로 아래 build.gradle.kts가 있는 서브프로젝트 수이고, 파일·줄 수는 src/main/ 아래 .java·.kt·.groovy 파일을 git ls-files로 골라 wc -l로 센 값입니다(주석과 라이선스 머리말 포함).
| 플랫폼 / 폴더 | 하는 일 | 서브프로젝트 | 파일 | 줄 수 | 다루는 편 |
|---|---|---|---|---|---|
platforms/core-runtime |
프로세스와 그 안의 바탕 — 클라이언트, 데몬, 서비스 레지스트리, 로깅, 빌드 오퍼레이션 | 61 | 1,737 | 151,488 | 2–5편 |
platforms/core-configuration |
빌드를 적는 쪽 — 모델, Provider/Property, Kotlin DSL, 설정 캐시 |
43 | 1,697 | 184,545 | 6–8·11편 |
platforms/core-execution |
일을 효율 있게 돌리는 쪽 — 실행 엔진, 빌드 캐시, VFS, 워커 | 25 | 753 | 59,996 | 9–11편 |
subprojects/ |
core·core-api·composite-builds — 프로젝트·태스크·생명주기의 핵심 |
3 | 1,598 | 146,778 | 2·5–10편 |
platforms/software |
소프트웨어 빌드 일반 — 의존성 관리, 배포(publish), 테스트 기반 | 34 | 2,150 | 210,897 | 12편 |
platforms/jvm |
JVM 언어 — java 플러그인, 컴파일, 테스트 실행, 툴체인 |
35 | 695 | 72,834 | 7편 |
platforms/extensibility |
플러그인 — plugins {} 처리, 플러그인 개발 |
4 | 106 | 11,504 | 7편 |
platforms/native |
C·C++·Swift 빌드 | 7 | 798 | 54,660 | — |
platforms/ide |
IDE 연동 — Tooling API | 7 | 970 | 70,529 | 3편(짧게) |
platforms/enterprise |
Develocity(Build Scan) 연동 | 5 | 166 | 9,301 | — |
platforms/observability |
문제 보고(Problems API) | 5 | 164 | 12,178 | — |
platforms/documentation |
사용자 안내서와 예제 | 4 | 563 | 12,157 | — |
| 합계 | 233 | 11,397 | 996,867 |
본 코드만 약 100만 줄입니다. src/main 밖(테스트, 테스트 픽스처 등)의 코드는 파일 6,931개, 약 118만 줄로 본 코드보다 많습니다. 저장소 전체에서 git이 관리하는 파일은 29,461개입니다. 앞 연재와 견주면, Spring Framework의 본 코드가 약 84만 줄, Tomcat이 약 50만 줄이었으니 이 연재가 읽는 소스가 가장 큽니다. 다만 그 가운데 우리가 머물 곳은 core 플랫폼 셋과 subprojects/입니다.
Gradle의 플랫폼 지도. 화살표는 settings.gradle.kts의 uses(…)입니다. 파란 칸이 이 연재가 주로 읽는 곳이고, 회색 칸은 그 위에 얹힌 플랫폼입니다.
이 그림에서 기억할 것은 하나입니다. 아래로만 의존합니다. java 플러그인이 있는 jvm 플랫폼은 core를 알지만, core는 Java를 모릅니다. architecture/platforms.md의 말로는 core 플랫폼은 "작업의 목적에 무관한(agnostic) 범용 자동화 플랫폼"입니다. 태스크를 정의하고, 순서를 정하고, 빠르게 돌리는 일까지만 하고, "그 태스크가 Java를 컴파일한다"는 것은 위의 플랫폼이 플러그인으로 얹습니다. 그래서 이 연재의 1~11편은 거의 core만 읽고도 gradle build가 어떻게 도는지 설명할 수 있습니다.
이 연재가 머물 파일들
자주 열 파일의 크기를 적어 둡니다. 생각보다 짧은 파일이 많습니다.
| 파일 | 줄 수 | 다루는 편 |
|---|---|---|
platforms/core-runtime/client-services/…/launcher/daemon/client/DaemonClient.java |
316 | 3편 |
platforms/core-runtime/service-registry-impl/…/internal/service/DefaultServiceRegistry.java |
1,495 | 4편 |
subprojects/core/…/internal/build/DefaultBuildLifecycleController.java |
428 | 5편 |
subprojects/core/…/api/internal/project/DefaultProject.java |
1,602 | 6편 |
platforms/core-configuration/kotlin-dsl/…/kotlin/dsl/execution/Interpreter.kt |
672 | 6편 |
subprojects/core/…/api/internal/tasks/DefaultTaskContainer.java |
914 | 8편 |
subprojects/core/…/execution/plan/DefaultExecutionPlan.java |
468 | 9편 |
subprojects/core/…/execution/plan/DefaultPlanExecutor.java |
772 | 9편 |
subprojects/core/…/api/internal/tasks/execution/DefaultTaskNodeExecutor.java |
392 | 10편 |
subprojects/core/…/internal/service/scopes/ExecutionBuildServices.java |
223 | 10편 |
platforms/core-configuration/configuration-cache/…/internal/cc/impl/DefaultConfigurationCache.kt |
758 | 11편 |
platforms/software/dependency-management/…/api/internal/artifacts/configurations/DefaultConfiguration.java |
1,948 | 12편 |
(…는 src/main/java/org/gradle, Kotlin 파일은 src/main/kotlin/org/gradle입니다.)
헷갈리기 쉬운 곳
subprojects/core는 "core 플랫폼"이 아닙니다. 이름이 같아 헷갈리지만, settings.gradle.kts는 core·composite-builds·core-api 셋을 unassigned { … }, 곧 아직 어느 플랫폼에도 배정되지 않은 서브프로젝트로 적어 둡니다(settings.gradle.kts 30–35행). 플랫폼 구조를 만들기 전부터 있던 큰 덩어리를 조금씩 플랫폼으로 옮기는 중이라고 보면 됩니다. 실제로 DefaultProject, DefaultTaskContainer, DefaultBuildLifecycleController처럼 연재에서 가장 많이 볼 클래스가 아직 subprojects/core에 있습니다(1,002개 파일, 96,944줄).
같은 패키지가 여러 폴더에 흩어져 있습니다. 예를 들어 org.gradle.launcher 패키지의 클래스는 gradle-cli-main, gradle-cli, client-services, launcher, daemon-server 등 여러 서브프로젝트에 나뉘어 있습니다. 패키지 이름으로 폴더를 짐작하지 말고 git ls-files '*/클래스이름.java'로 찾는 편이 빠릅니다.
5. gradle build 한 번이 지나는 큰 길
한 장으로 보면
빌드 스크립트를 쓰면서 늘 보던 것부터 이어 봅니다. 터미널에 gradle build를 치면 ① 잠깐 뒤 데몬 이야기가 나오고, ② settings.gradle.kts와 build.gradle.kts가 실행되고(여기서 println을 하면 태스크보다 먼저 찍힙니다), ③ > Task :compileJava처럼 태스크가 하나씩 돌고, 어떤 태스크는 UP-TO-DATE나 FROM-CACHE로 건너뜁니다. 이 세 덩어리가 소스의 어디인지 한 장으로 그리면 이렇습니다.
gradle build 한 번의 큰 길. 위의 상자는 클라이언트 프로세스, 아래 상자는 데몬 프로세스입니다. 칸마다 그 단계를 다루는 편이 적혀 있습니다.
빌드 스크립트에서 보던 것과 이어 보면 이렇습니다.
| 단계 | 써 본 것 | 다루는 편 |
|---|---|---|
| 클라이언트 · 데몬 찾기 | gradlew, --no-daemon, gradle --status, org.gradle.jvmargs |
3편 |
| 서비스 주입 | 태스크의 @Inject 생성자, ObjectFactory |
4편 |
| 생명주기 | 초기화 → 설정 → 실행, init 스크립트, gradle.projectsEvaluated |
5편 |
| 빌드 스크립트 · 플러그인 | build.gradle.kts, plugins { java }, buildSrc |
6·7편 |
| 태스크 등록 | tasks.register, Property<String>, @Input/@OutputFile |
8편 |
| 작업 그래프 · 워커 | dependsOn, mustRunAfter, --parallel, --max-workers |
9편 |
| 실행 엔진 | UP-TO-DATE, FROM-CACHE, --build-cache, --configuration-cache |
10·11편 |
| 의존성 | implementation(…), dependencyInsight |
12편 |
왜 데몬을 두나
큰 길에서 가장 먼저 눈에 띄는 것은 프로세스가 둘이라는 점입니다. Gradle은 빌드를 매번 새 JVM에서 하지 않고, 오래 사는 데몬 JVM에 맡깁니다. 이유는 아래 예제 출력에서 바로 보입니다. 같은 빌드가 첫 실행은 35초, 두 번째는 2초였습니다. 첫 실행에는 JVM을 띄우고, Gradle의 클래스 수천 개를 싣고, build.gradle.kts를 Kotlin 컴파일러로 컴파일하고, JIT 컴파일러가 뜨거워지기를 기다리는 비용이 모두 듭니다. 데몬이 살아 있으면 두 번째 빌드는 이 비용을 다시 내지 않습니다. 컴파일한 스크립트, 파일 시스템 상태(VFS — 10편), 메모리에 든 캐시를 다음 빌드가 그대로 씁니다.
서버 프로그램과 닮았습니다. httpd나 Tomcat이 요청마다 프로세스를 띄우지 않고 한 번 띄운 프로세스가 요청을 계속 받듯, Gradle 데몬도 "빌드 요청"을 계속 받는 서버입니다. 다만 architecture/README.md의 설명대로 데몬은 한 번에 요청 하나만 처리합니다. 데몬이 바쁘면 클라이언트는 다른 데몬을 찾거나 새로 띄웁니다(3편).
코드에서 세 군데만 미리 보기
하나, 클라이언트의 입구. bin/gradle 셸 스크립트는 결국 java … -jar lib/gradle-gradle-cli-main-9.10.0.jar를 실행합니다(설치된 스크립트 213행). 그 jar의 메인 클래스가 GradleMain입니다(gradle-cli-main/build.gradle.kts의 mainClassName = "org.gradle.launcher.GradleMain").
public class GradleMain {
public static void main(String[] args) throws Exception {
ProcessBootstrap.run(GradleMain.class.getName(), "gradle-gradle-cli", "org.gradle.launcher.Main", args);
}
}
platforms/core-runtime/gradle-cli-main/src/main/java/org/gradle/launcher/GradleMain.java 24–28행
한 줄뿐입니다. ProcessBootstrap은 gradle-gradle-cli 모듈이 필요로 하는 jar들로 클래스로더를 새로 만들고, 그 안에서 org.gradle.launcher.Main을 찾아 부릅니다. 처음 실행하는 jar는 작게 두고 나머지는 클래스로더로 싣는 구조입니다. Main은 명령줄을 "할 일" 하나로 바꿔 실행합니다.
@Override
protected void doAction(String[] args, ExecutionListener listener) {
createActionFactory().convert(Arrays.asList(args)).execute(listener);
}
platforms/core-runtime/gradle-cli/src/main/java/org/gradle/launcher/Main.java 33–36행
--version이면 판을 찍는 일, --help면 도움말, 태스크 이름이면 빌드하는 일로 바뀝니다. 빌드라면 BuildActionsFactory가 어디서 빌드할지를 고릅니다.
if (daemonParameters.isEnabled()) {
return Actions.toAction(runBuildWithDaemon(startParameter, daemonParameters, requestContext, buildLayoutConfiguration));
}
if (canUseCurrentProcess(daemonParameters, requestContext)) {
return Actions.toAction(runBuildInProcess(startParameter, daemonParameters));
}
return Actions.toAction(runBuildInSingleUseDaemon(startParameter, daemonParameters, requestContext, buildLayoutConfiguration));
세 갈래입니다. 데몬을 쓰면(기본) 데몬에게 맡기고, --no-daemon이어도 지금 프로세스의 JVM이 조건에 맞으면 그 자리에서 빌드하고, 맞지 않으면 이번 빌드만 쓰고 끝낼 데몬(single-use daemon)을 띄웁니다. --no-daemon이 "데몬 프로세스를 절대 안 띄운다"는 뜻이 아니라는 것이 이 마지막 줄에서 드러납니다. 조건은 3편에서 봅니다.
둘, 데몬 쪽의 명령 사슬. 데몬에 도착한 요청은 DaemonServices가 만든 명령 사슬을 차례로 지납니다.
return ImmutableList.of(
new HandleStop(listenerManager),
new HandleInvalidateVirtualFileSystem(userHomeServiceRegistry),
new HandleCancel(),
new HandleReportStatus(),
new CleanUpVirtualFileSystemAfterBuild(executorFactory, userHomeServiceRegistry),
new ReturnResult(),
new StartBuildOrRespondWithBusy(daemonDiagnostics), // from this point down, the daemon is 'busy'
new EstablishBuildEnvironment(processEnvironment),
new LogToClient(loggingManager, daemonDiagnostics), // from this point down, logging is sent back to the client
new ApplyClientEnvironmentVariables(processEnvironment),
new LogAndCheckHealth(healthStats, healthCheck, runningStats),
new ForwardClientInput(inputReader, eventDispatch),
new RequestStopIfSingleUsedDaemon(),
new ResetDeprecationLogger(),
new WatchForDisconnection(),
new ExecuteBuild(buildActionExecuter, runningStats)
);
16개입니다. 각 명령은 자기 일을 하고 다음 명령을 부르거나(execution.proceed()), 자기가 처리할 요청이면 거기서 끝냅니다. gradle --stop은 맨 앞의 HandleStop에서, gradle --status류의 상태 질문은 HandleReportStatus에서 끝나고, 빌드 요청만 끝까지 내려가 ExecuteBuild에 닿습니다. 주석 두 줄이 친절합니다. StartBuildOrRespondWithBusy부터 아래는 데몬이 "바쁨" 상태이고, LogToClient부터 아래의 로그는 클라이언트 화면으로 보내집니다. Tomcat 8편의 밸브 사슬, Spring MVC의 인터셉터 사슬과 같은 생김새입니다. 이 연재에서는 이런 사슬을 계속 만납니다. 데몬 안 실행기 사슬(5편), 실행 엔진의 스텝 사슬(10편)이 그렇습니다.
셋, 태스크를 돌리는 스레드. 실행 계획이 정해지면 DefaultPlanExecutor.process()가 태스크를 워커 스레드에 나눠 줍니다. 여기에 의외의 한 줄이 있습니다.
maybeStartWorkers(queue, executor);
// Run the work from the source from this thread as well, given that it will be blocked waiting for that work to complete anyway
WorkerLease currentWorkerLease = workerLeaseService.getCurrentWorkerLease();
MergedQueues thisPlanOnly = new MergedQueues(coordinationService, true);
thisPlanOnly.add(planDetails);
new ExecutorWorker(thisPlanOnly, currentWorkerLease, cancellationToken, coordinationService, workerLeaseService, stats).run();
subprojects/core/src/main/java/org/gradle/execution/plan/DefaultPlanExecutor.java 112–118행
maybeStartWorkers()가 최대 워커 수보다 하나 적게 Execution worker 스레드를 띄우고(같은 파일 184–190행), process()를 부른 스레드 자신도 일꾼 하나로 참여합니다. 주석대로 "어차피 일이 끝나기를 기다리며 막혀 있을 테니" 놀리지 않는 것입니다. 그 스레드가 누구인지가 의외인데, 아래 예제 3의 "헷갈리기 쉬운 곳"에서 봅니다.
예제 1: 데몬이 뜨는 첫 줄들
ex/smoke의 프로젝트를 그대로 가져왔습니다. 입력(@Input) 하나, 출력(@OutputFile) 하나를 가진 커스텀 태스크입니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// 입력 하나·출력 하나를 가진 작은 커스텀 태스크
abstract class Hello : DefaultTask() {
@get:Input
abstract val greeting: Property<String>
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun run() {
val text = "${greeting.get()} from ${javaClass.name}"
outputFile.get().asFile.writeText(text + "\n")
println(text)
}
}
tasks.register<Hello>("hello") {
greeting = "Hello, Gradle"
outputFile = layout.buildDirectory.file("hello.txt")
}
run.sh는 이것을 gradle hello --offline --console=plain --info로 한 번 돌리고, 출력의 첫 25줄을 찍습니다. 긴 두 줄은 잘라서 싣습니다(… 표시).
Initialized native services in: /root/.gradle/native
Initialized jansi services in: /root/.gradle/native
Removing 0 daemon stop events from registry
Starting a Gradle Daemon (subsequent builds will be faster)
Starting process 'Gradle build daemon'. Working directory: /root/.gradle/daemon/9.10.0-20261107025045+0000 Command: /opt/java/openjdk/bin/java --add-opens=java.base/java.lang=ALL-UNNAMED … -XX:MaxMetaspaceSize=384m -XX:+HeapDumpOnOutOfMemoryError -Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.country=US -Duser.language=en -Duser.variant -cp /opt/gradle/lib/gradle-daemon-main-9.10.0.jar -javaagent:/opt/gradle/lib/agents/gradle-instrumentation-agent-9.10.0.jar org.gradle.launcher.daemon.bootstrap.GradleDaemon 9.10.0-20261107025045+0000
Successfully started process 'Gradle build daemon'
An attempt to start the daemon took 3.219 secs.
The client will now receive all logging from the daemon (pid: 86). The daemon log file: /root/.gradle/daemon/9.10.0-20261107025045+0000/daemon-86.out.log
Starting build in new daemon [memory: 512 MiB]
Shared read-only dependency cache is an incubating feature.
The read-only dependency cache is enabled
The GRADLE_RO_DEP_CACHE environment variable was set to /opt/gradle-ro-cache
Using 12 worker leases.
Operational build model parameters: {cachingModelBuilding=false, configurationCache=false, …, vintage=true}
Received JVM installation metadata from '/opt/java/openjdk': {JAVA_HOME=/opt/java/openjdk, JAVA_VERSION=25.0.5, …}
Watching the file system is configured to be enabled if available
Not watching /tmp/p since the file system is not supported
File system watching is active
Starting Build
Caching disabled for Kotlin DSL script compilation (Settings/TopLevel/stage1) because:
Build cache is disabled
Settings evaluated using settings file '/tmp/p/settings.gradle.kts'.
Projects loaded. Root project using build file '/tmp/p/build.gradle.kts'.
Included projects: [root project 'smoke']
(데몬 pid, 데몬을 띄우는 데 걸린 시간은 실행마다 다릅니다.) 큰 길 그림의 위 상자에서 아래 상자로 넘어가는 순간이 그대로 찍혀 있습니다.
- 1~7행은 클라이언트가 찍은 것입니다. 쓸 만한 데몬이 없으니(사용자 홈이 새것이라 당연합니다)
Starting a Gradle Daemon, 그리고 데몬을 띄우는 명령줄 전체가 나옵니다. 데몬은gradle-daemon-mainjar의org.gradle.launcher.daemon.bootstrap.GradleDaemon을 메인으로, 힙 512MB(-Xmx512m)로 뜹니다. 이 기본값은gradle.properties의org.gradle.jvmargs로 바꾸던 그 값입니다. - 8행 "The client will now receive all logging from the daemon"부터는 데몬이 보낸 로그입니다. 이 줄 자체가 위에서 본
LogToClient가 클라이언트로 로그를 보내기 시작하며 찍는 첫 줄입니다(LogToClient.java 60행). 데몬 자신의 로그 파일 위치(~/.gradle/daemon/<판>/daemon-<pid>.out.log)도 알려 줍니다. Shared read-only dependency cache is an incubating feature.는 3절의GRADLE_RO_DEP_CACHE때문에 찍히는 줄입니다. 읽기 전용 의존성 캐시는 아직 "인큐베이팅"(정식이 아닌 시험 기능)이라는 경고입니다.Using 12 worker leases.는 이 빌드가 동시에 돌릴 수 있는 일의 최대 수입니다.--max-workers를 주지 않으면 CPU 수(Runtime.getRuntime().availableProcessors())가 기본값이고, 이 PC가 12 CPU입니다. 9편에서 봅니다.Settings evaluated …,Projects loaded …가 빌드의 초기화 단계입니다. 이 뒤에 설정 단계와> Task :hello가 이어지고, 마지막은 이랬습니다.
> Task :hello
Hello, Gradle from Build_gradle$Hello_Decorated
BUILD SUCCESSFUL in 35s
1 actionable task: 1 executed
Build_gradle$Hello_Decorated라는 이름이 눈에 띕니다. 우리가 쓴 클래스는 Hello인데, 빌드 스크립트 안의 클래스라 Build_gradle$Hello가 되고, 거기에 Gradle이 abstract 프로퍼티를 채운 하위 클래스를 만들어 _Decorated를 붙였습니다. Spring이 CGLIB으로 프록시 클래스를 만드는 것(Spring 10편)과 비슷한 일이고, 4편에서 봅니다.
같은 run.sh에서 곧바로 한 번 더 돌린 결과입니다.
> Task :hello UP-TO-DATE
BUILD SUCCESSFUL in 2s
1 actionable task: 1 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.10.0-20261107025045+0000/userguide/configuration_cache_enabling.html
35초가 2초가 되었습니다(걸린 시간은 실행마다 조금씩 다릅니다. 다시 돌렸을 때는 34초와 1초였습니다). 이번에는 데몬을 띄운다는 말이 없습니다. 같은 데몬이 받은 것입니다. 입력과 출력이 지난번과 같으니 태스크도 UP-TO-DATE로 건너뛰었습니다(10편). 마지막 줄은 설정 캐시를 켜 보라는 권유입니다(11편).
예제 2: 빌드 오퍼레이션 나무로 큰 길의 이름 보기
Gradle에는 빌드 오퍼레이션을 파일로 뽑는 내부 옵션이 있습니다. BuildOperationTrace의 SYSPROP 상수입니다.
public static final String SYSPROP = "org.gradle.internal.operations.trace";
같은 파일의 javadoc(95–115행)에 따르면 -Dorg.gradle.internal.operations.trace=<경로>를 주면 <경로>-log.txt에 사건을 시간 순으로 적고, -Dorg.gradle.internal.operations.trace.tree=true를 더하면 나무 모양의 -tree.json과 -tree.txt도 씁니다. 출력 파일을 지우고 이 두 옵션을 붙여 한 번 더 돌렸습니다.
rm -f build/hello.txt
gradle hello --offline --console=plain \
-Dorg.gradle.internal.operations.trace=/tmp/trace \
-Dorg.gradle.internal.operations.trace.tree=true
빌드가 끝나면 Build operation trace: /tmp/trace-log.txt, writing tree to /tmp/trace-tree.json이 찍히고 /tmp에 trace-log.txt, trace-tree.json, trace-tree.txt 세 파일이 생깁니다. trace-tree.txt의 각 줄에는 오퍼레이션 이름 뒤에 JSON으로 된 세부 정보와 걸린 시간이 붙어 있어서, run.sh에서 이름만 남기고 4단계 깊이까지 잘랐습니다.
Run build
Build started for file system watching
Load build
Evaluate settings
Apply settings file 'settings.gradle.kts' to settings 'p'
Finalize build cache configuration
Configure build
Load projects
Notify projectsLoaded listeners
Configure project :
Notify beforeEvaluate listeners of :
Apply plugin org.gradle.help-tasks to root project 'smoke'
Apply plugin org.gradle.software-reporting-tasks to root project 'smoke'
Apply plugin org.gradle.build-init to root project 'smoke'
Apply plugin org.gradle.wrapper to root project 'smoke'
Register task :updateDaemonJvm
Register task :javaToolchains
Register task :prepareKotlinBuildScriptModel
Apply build file 'build.gradle.kts' to root project 'smoke'
Notify afterEvaluate listeners of :
Notify projectsEvaluated listeners
Calculate build tree task graph
Calculate task graph
Realize task :hello
Notify task graph whenReady listeners
Run main tasks
Run tasks
Resolve mutations for task :hello
Task :hello
Finish root build tree
Build finished for file system watching
Develocity plugin build finished
Deleting unused version-specific caches in /tmp/p/.gradle
큰 길 그림의 데몬 쪽 아래 절반이 이름으로 나옵니다. Load build(초기화 — settings 읽기), Configure build(설정 — 프로젝트마다 빌드 스크립트 실행), Calculate build tree task graph(작업 그래프 만들기), Run main tasks(실행)입니다. 우리가 "초기화·설정·실행 세 단계"라고 배운 것이 소스에서는 이 이름의 오퍼레이션들입니다. 이 단계들이 어느 클래스의 무엇인지가 2편과 5편의 주제입니다.
몇 줄은 미리 짚어 둘 만합니다.
Configure project :아래에 플러그인 넷이 걸려 있습니다. 빌드 스크립트에plugins {}를 하나도 쓰지 않았는데도help-tasks(gradle tasks,gradle help같은 태스크),build-init(gradle init),wrapper(gradle wrapper)가 루트 프로젝트에 걸렸습니다. 우리가 늘 쓰던 그 기본 태스크들이 여기서 옵니다(7편).Realize task :hello가 작업 그래프를 만들 때 나옵니다.tasks.register로 등록한 태스크는 등록만 해 두었다가 필요해지는 순간 객체로 만드는데(실현, realize), 이번 빌드에서는 그 순간이 "hello를 그래프에 넣을 때"였습니다. 설정 단계의Register task …와 나란히 보면 지연 설정이 눈에 보입니다(8편).- 맨 위의
Run build바깥에Deleting unused version-specific caches가 따로 있습니다. 빌드가 끝난 뒤의 정리 작업이라 빌드 오퍼레이션 나무의 바깥에서 돕니다.
예제 3: 호출 스택 두 개 — 설정 단계와 실행 단계
나무는 "무엇이 어떤 순서로" 일어났는지 보여 주지만, "어떤 클래스가 불렀는지"는 보여 주지 않습니다. 그래서 Tomcat 1편처럼 호출 스택을 찍었습니다. 빌드 스크립트 본문(설정 단계)과 태스크 액션(실행 단계)에서 각각 한 번씩입니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// 1) 설정 단계: 빌드 스크립트 본문이 실행되는 순간의 호출 스택
println("[configure] thread: ${Thread.currentThread().name}")
for (e in Throwable().stackTrace) {
println("[configure] at ${e.className}.${e.methodName}(${e.fileName}:${e.lineNumber})")
}
// 2) 실행 단계: 태스크 액션 안에서 호출 스택을 찍는다 — 맨 위가 이 액션, 맨 아래가 스레드의 시작
tasks.register("where") {
doLast {
println("[execute] thread: ${Thread.currentThread().name}")
for (e in Throwable().stackTrace) {
println("[execute] at ${e.className}.${e.methodName}(${e.fileName}:${e.lineNumber})")
}
}
}
gradle where --offline --console=plain -q로 돌리면 설정 단계 스택이 206줄, 실행 단계 스택이 130줄입니다. 둘 다 너무 길어 의미 있는 줄만 남기고 줄였습니다(...가 줄인 자리이고, 줄 앞의 [configure]·[execute] 표시는 지웠습니다). 먼저 설정 단계입니다. 맨 위가 빌드 스크립트, 맨 아래가 스레드의 시작입니다.
thread: Daemon worker
at Build_gradle.<init>(build.gradle.kts:4)
...
at org.gradle.kotlin.dsl.execution.Interpreter.eval(Interpreter.kt:234)
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator.evaluate(KotlinScriptEvaluator.kt:141)
...
at org.gradle.configuration.project.BuildScriptProcessor.execute(BuildScriptProcessor.java:46)
...
at org.gradle.configuration.project.ConfigureActionsProjectEvaluator.evaluate(ConfigureActionsProjectEvaluator.java:35)
...
at org.gradle.configuration.project.LifecycleProjectEvaluator.evaluate(LifecycleProjectEvaluator.java:72)
at org.gradle.api.internal.project.DefaultProject.evaluateUnchecked(DefaultProject.java:789)
...
at org.gradle.configuration.DefaultProjectsPreparer.prepareProjects(DefaultProjectsPreparer.java:52)
...
at org.gradle.initialization.VintageBuildModelController.prepareToScheduleTasks(VintageBuildModelController.java:70)
at org.gradle.internal.build.DefaultBuildLifecycleController.lambda$prepareToScheduleTasks$0(DefaultBuildLifecycleController.java:184)
...
at org.gradle.internal.buildtree.DefaultBuildTreeWorkPreparer.scheduleRequestedTasks(DefaultBuildTreeWorkPreparer.java:35)
...
at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.scheduleAndRunTasks(DefaultBuildTreeLifecycleController.java:74)
at org.gradle.tooling.internal.provider.ExecuteBuildActionRunner.run(ExecuteBuildActionRunner.java:31)
at org.gradle.launcher.exec.ChainingBuildActionRunner.run(ChainingBuildActionRunner.java:35)
at org.gradle.internal.buildtree.ProblemReportingBuildActionRunner.run(ProblemReportingBuildActionRunner.java:55)
at org.gradle.launcher.exec.BuildOutcomeReportingBuildActionRunner.run(BuildOutcomeReportingBuildActionRunner.java:83)
at org.gradle.tooling.internal.provider.FileSystemWatchingBuildActionRunner.run(FileSystemWatchingBuildActionRunner.java:118)
at org.gradle.launcher.exec.BuildCompletionNotifyingBuildActionRunner.run(BuildCompletionNotifyingBuildActionRunner.java:64)
at org.gradle.launcher.exec.RootBuildLifecycleBuildActionExecutor.lambda$execute$0(RootBuildLifecycleBuildActionExecutor.java:100)
...
at org.gradle.internal.session.BuildSessionState.run(BuildSessionState.java:73)
...
at org.gradle.launcher.daemon.server.exec.ExecuteBuild.doBuild(ExecuteBuild.java:70)
...
at org.gradle.launcher.daemon.server.exec.LogToClient.doBuild(LogToClient.java:63)
...
at org.gradle.launcher.daemon.server.exec.EstablishBuildEnvironment.doBuild(EstablishBuildEnvironment.java:74)
...
at org.gradle.launcher.daemon.server.exec.StartBuildOrRespondWithBusy$1.run(StartBuildOrRespondWithBusy.java:52)
at org.gradle.launcher.daemon.server.DaemonStateCoordinator.lambda$runCommand$0(DaemonStateCoordinator.java:321)
...
at java.lang.Thread.run(Thread.java:1474)
아래에서 위로 읽으면 큰 길 그림의 데몬 상자가 그대로 있습니다.
- 데몬의 명령 사슬:
StartBuildOrRespondWithBusy→EstablishBuildEnvironment→LogToClient→ … →ExecuteBuild. 위에서 본 16개 목록의 뒤쪽 10개가 모두 스택에 있습니다(3편). - 빌드 세션과 실행기 사슬:
BuildSessionState.run()을 지나RootBuildLifecycleBuildActionExecutor→BuildCompletionNotifying…→FileSystemWatching…→BuildOutcomeReporting…→ProblemReporting…→Chaining…→ExecuteBuildActionRunner. 이 여섯 겹을 감싸는 곳이LauncherServices이고, 왜 이 순서인지는 5편에서 봅니다. - 빌드 트리와 빌드의 생명주기:
DefaultBuildTreeLifecycleController.scheduleAndRunTasks()→DefaultBuildLifecycleController.prepareToScheduleTasks()→VintageBuildModelController. 이름에서 보이듯 "태스크를 계획하려고 준비하다 보니" 프로젝트를 설정하게 됩니다(5편). - 프로젝트 평가기 사슬:
DefaultProject.evaluateUnchecked()→LifecycleProjectEvaluator→ConfigureActionsProjectEvaluator→BuildScriptProcessor(6편). - Kotlin DSL:
StandardKotlinScriptEvaluator→Interpreter.eval()→ 컴파일된 스크립트 클래스Build_gradle의 생성자. 빌드 스크립트 본문이 클래스의 생성자로 컴파일되어 돈다는 것이 맨 윗줄Build_gradle.<init>에서 보입니다(6편).
다음은 실행 단계입니다.
thread: Execution worker Thread 6
at Build_gradle$$$result$1$1.execute(build.gradle.kts:12)
at Build_gradle$$$result$1$1.execute(build.gradle.kts:10)
at org.gradle.api.internal.AbstractTask$TaskActionWrapper.execute(AbstractTask.java:864)
...
at org.gradle.api.internal.tasks.execution.TaskExecution.execute(TaskExecution.java:185)
...
at org.gradle.internal.execution.steps.ExecuteStep.execute(ExecuteStep.java:134)
...
at org.gradle.internal.execution.steps.BuildCacheStep.execute(BuildCacheStep.java:74)
...
at org.gradle.internal.execution.steps.SkipUpToDateStep.execute(SkipUpToDateStep.java:53)
...
at org.gradle.internal.execution.steps.IdentifyStep.execute(IdentifyStep.java:56)
...
at org.gradle.internal.execution.impl.DefaultExecutionEngine$1.execute(DefaultExecutionEngine.java:68)
...
at org.gradle.api.internal.tasks.execution.DefaultTaskNodeExecutor.execute(DefaultTaskNodeExecutor.java:133)
at org.gradle.execution.plan.LocalTaskNode.execute(LocalTaskNode.java:229)
...
at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.run(DefaultPlanExecutor.java:392)
...
at java.lang.Thread.run(Thread.java:1474)
(스레드 이름 끝의 번호는 실행마다 다릅니다. 같은 예제의 다른 실행에서는 그냥 Execution worker였습니다.)
이 스택의 바닥에는 데몬의 명령 사슬도, 생명주기 클래스도 없습니다. 다른 스레드이기 때문입니다. DefaultPlanExecutor의 ExecutorWorker가 실행 계획에서 노드(LocalTaskNode)를 하나 집어 DefaultTaskNodeExecutor에 넘기고, 그것이 DefaultExecutionEngine을 부르면 스텝 사슬이 시작됩니다. IdentifyStep에서 ExecuteStep까지, 이 스택에 …Step으로 끝나는 클래스 이름이 27가지 나옵니다(공통 부모 MutableStep, AbstractResolveCachingStateStep 포함). 그 가운데 SkipUpToDateStep이 UP-TO-DATE를, BuildCacheStep이 FROM-CACHE를 정하는 곳입니다(10편·11편). 이 스텝들을 다 지나야 맨 위의 우리 doLast 블록(10행이 doLast {, 12행이 for 줄)에 닿습니다.
두 스택의 관계. 설정 단계는 데몬의 Daemon worker 스레드에서, 이번 실행의 태스크 액션은 Execution worker 스레드에서 돌았습니다. 실행 단계 동안 Daemon worker는 기다리기만 합니다.
헷갈리기 쉬운 곳
명령 사슬의 앞쪽 절반은 스택에 없습니다. 16개 명령 가운데 스택에 보이는 것은 StartBuildOrRespondWithBusy부터입니다. HandleStop이나 HandleCancel이 먼저 불렸을 텐데 왜 없을까요. StartBuildOrRespondWithBusy가 나머지 사슬을 Runnable로 감싸 다른 스레드에 넘기기 때문입니다.
Runnable command = new Runnable() {
@Override
public void run() {
LOGGER.info("Daemon is about to start building {}. Dispatching build started information...", build);
execution.getConnection().buildStarted(new BuildStarted(diagnostics));
execution.proceed();
}
};
stateCoordinator.runCommand(command, execution.toString());
DaemonStateCoordinator.runCommand()는 이것을 "Daemon worker"라는 이름의 실행기로 돌립니다(DaemonStateCoordinator.java 74행). 그래서 설정 단계 스택의 스레드 이름이 Daemon worker이고, 앞쪽 명령들은 연결을 받은 다른 스레드의 스택에 남아 있습니다. Tomcat 1편에서 Mapper가 스택에 없던 것과 같은 교훈입니다. 호출 스택은 "지금 이 스레드에서 열려 있는 메서드"만 보여 줍니다.
태스크 액션이 늘 Execution worker에서 도는 것은 아닙니다. 위의 DefaultPlanExecutor 112–118행에서 본 대로, process()를 부른 스레드도 일꾼으로 참여합니다. 그런데 그 스레드는 설정 단계를 돌린 Daemon worker가 아닙니다. 빌드 하나의 실행은 DefaultBuildController가 included builds라는 이름의 실행기(executorFactory.create("included builds"), DefaultIncludedBuildTaskGraph.java 96행)에 넘겨 돌리고, 포함된 빌드가 없는 평범한 단일 빌드도 이 길을 탑니다. 그동안 Daemon worker는 모든 빌드가 끝나기를 기다립니다(DefaultBuildControllers.java 105–123행). 그래서 이 길(설정 캐시 없이 명령줄로 태스크를 돌린 경우)에서 태스크 액션은 Execution worker나 included builds 스레드에서 돌고, 이번 실행에서는 Execution worker가 where를 먼저 집었을 뿐입니다. "태스크는 워커 스레드 풀에서 돈다"고만 알고 있으면 스레드 이름을 보고 헷갈립니다. 워커 스레드의 이름은 executorFactory.create("Execution worker")(DefaultPlanExecutor.java 90행)에서 옵니다. 실제로 included builds 스레드가 태스크를 도는 모습과 jstack으로 본 스레드들은 9편에서 봅니다.
스택의 상당 부분은 DefaultBuildOperationRunner입니다. 위에서 ...로 줄인 자리에 가장 많이 들어 있던 것이 DefaultBuildOperationRunner.run()/call()/execute()의 6~7줄 묶음입니다. 단계마다 일을 빌드 오퍼레이션으로 감싸기 때문에(예제 2의 나무가 그 결과입니다) 스택이 두꺼워집니다. 스택을 읽을 때 이 묶음은 건너뛰고 그 위아래의 클래스만 보면 됩니다.
6. 어떻게 읽나 — AI와 함께, 그러나 소스로 확인하며
읽는 순서
한 편을 읽고 쓰는 순서. 파란 칸은 절차이고, 빨간 칸은 AI가 쓴 내용에서 틀린 곳을 걸러 내는 단계입니다.
앞 연재들과 원칙은 같습니다. 전체를 한 번에 읽지 않고, 지도에서 질문 하나를 골라 그 질문에 답하는 데 필요한 코드만 깊게 읽습니다. 이 연재에서는 그 질문 하나가 곧 글 한 편입니다.
- 기준 커밋을 고정합니다. 2절의 worktree입니다. 줄 번호가 흔들리지 않아야 AI가 쓴 근거를 검증할 수 있습니다.
- AI가 노트와 지도를 씁니다. 12편의 구성, 편마다 출발점이 될 클래스 목록이 이렇게 만들어졌습니다. 편마다 같은 지시서(무엇을 읽고, 어떤 형식으로, 어떤 근거를 달아 쓰는지)를 주고 AI 작업 하나에 한 편씩 맡겼습니다.
- 기준 커밋의 소스로 다시 확인합니다. Gradle에서는 특히 클래스가 이 커밋에 있는지부터 봅니다. 지시서에 적은 클래스는 출발점일 뿐이고, 글에 싣는 것은 소스에서 직접 확인한 것만입니다. 코드는 파일에서 그대로 복사하고, 줄 번호는 직접 찾은 것만 적습니다.
- 예제를 실제로 돌립니다. 5절처럼 글의 주장 하나를 눈으로 확인하는 작은 예제를 컨테이너에서 돌리고, 출력을 그대로 싣습니다.
- 올리기 전에 한 번 더 확인합니다. 글을 다 쓰면 소스를 기준으로 코드 블록, 줄 번호, 단정한 사실, 예제 출력을 처음부터 다시 대조합니다. 이 확인을 거치지 않은 글은 올리지 않습니다.
Gradle에서 특히 조심할 것 — 옛 이름
인터넷에서 "Gradle 내부 구조"를 찾으면 DefaultGradleLauncher, ExecuteActionsTaskExecuter, DefaultSettingsLoader 같은 이름이 자주 나옵니다. AI도 학습한 글에서 이 이름들을 꺼내 옵니다. 그런데 기준 커밋에서 찾아보면 셋 다 없습니다.
$ git -c core.longpaths=true ls-files '*/DefaultGradleLauncher.java' '*/ExecuteActionsTaskExecuter.java' '*/DefaultSettingsLoader.java'
$
아무것도 나오지 않습니다. 그 일들은 지금 각각 DefaultBuildLifecycleController·VintageBuildModelController, DefaultTaskNodeExecutor와 TaskExecution, DefaultSettingsPreparer 쪽이 합니다. 5절 스택에 나온 이름들이 바로 그것입니다. AI가 클래스 이름을 말하면 먼저 git ls-files로 있는지 봅니다. 없으면 그 설명 전체를 의심합니다.
이번 글에서 확인하다 걸린 것
3단계가 왜 필요한지는 이 글을 쓰는 동안에도 드러났습니다.
Main.java는 하나가 아닙니다. 지시서에는 큰 길의 한 칸으로Main이라고만 적혀 있었는데,git ls-files '*/Main.java'를 치면 32개가 나옵니다. 그 가운데 31개는 사용자 안내서의 예제(docs/src/snippets/…)입니다. 클라이언트의Main은platforms/core-runtime/gradle-cli/src/main/java/org/gradle/launcher/Main.java하나이고, 이것은GradleMain의ProcessBootstrap.run(…, "org.gradle.launcher.Main", …)을 따라가야 확실해집니다.- "태스크는 워커 스레드에서 돈다"는 반만 맞습니다. 노트만 보면 그렇게 쓰기 쉽습니다. 스택에서 스레드 이름을 보고,
DefaultPlanExecutor를 열어 "this thread as well" 주석을 찾았습니다. 처음에는 "그 스레드는Daemon worker"라고 짐작해 썼다가, 9편을 쓰며 jstack으로 스레드를 찍어 보고process()를 부르는 것이included builds스레드라는 것을 알고 고쳤습니다. 짐작이 섞인 설명은 이렇게 실행과 소스로 다시 확인해야 걸러집니다. - 명령 사슬은 16개인데 스택에는 일부만 보였습니다. "사슬이니 스택에 다 보이겠지"라고 생각하고 예제를 돌렸다가, 스레드가 바뀌는 자리(
StartBuildOrRespondWithBusy)를 찾게 되었습니다.
저장소 안의 AI 안내문
Gradle 저장소 맨 위에는 AI와 관련된 파일이 셋 있습니다. git 기록으로 처음 들어온 날짜를 보면 AI_POLICY.md가 2026-03-27(커밋 073f7e5b901, "add new AI policy to root"), AGENTS.md와 CLAUDE.md가 2026-07-01(커밋 783c1ba6c01, "Add code review guide and gradle-code-review skill")입니다. CLAUDE.md는 세 줄짜리로 "이 저장소의 모든 작업에서 AGENTS.md를 읽고 따르라"는 말뿐이고, AGENTS.md는 AI 코딩 에이전트에게 사람이 쓴 기여 안내(CONTRIBUTING.md, 오류 메시지·javadoc·nullability·테스트 지침)를 먼저 보라고 가리킵니다.
AI_POLICY.md는 AI의 도움을 받은 기여에 대한 방침입니다. 첫 번째 요구가 이렇습니다.
Understand the content you submit, PRs as well as issues, and be able to explain it. Our goal is for humans to use AI tools intentionally and with full control, rather than merely serving as proxies for AI agents. If you cannot explain the reason for something, we will assume you do not understand it, and the PR or issue will likely be closed.
"제출하는 내용을 이해하고 설명할 수 있어야 한다. 사람이 AI의 대리인이 아니라 AI를 의도를 갖고 완전히 통제하며 쓰기를 바란다"는 뜻입니다. 이 연재가 글에 대해 지키려는 것과 같은 말입니다. AI가 찾아 준 클래스와 설명을 그대로 옮기지 않고, 소스와 실행 결과로 이유를 설명할 수 있는 것만 씁니다.
7. 연재 지도
다섯 묶음
연재의 다섯 묶음. 파란 칸이 연재의 중심이고, 회색 칸은 그 앞의 지도와 마지막 글입니다.
| 묶음 | 편 | 답하려는 질문 |
|---|---|---|
| 지도 | 1 | 어떤 소스를 어떻게 읽나 (이 글) |
| 2 | 프로세스는 몇 개이고, 빌드의 상태와 서비스는 어떤 층으로 나뉘며, 세 단계는 어느 클래스인가 | |
| 바탕 | 3 | gradle 명령은 어떻게 데몬을 찾거나 띄우고 무엇을 보내나 |
| 4 | Gradle 안의 DI 컨테이너는 어떻게 생겼고, _Decorated 클래스는 누가 만드나 |
|
| 설정 단계 | 5 | 초기화·설정·실행은 어디서 갈리고, 어떤 순서로 상태가 바뀌나 |
| 6 | build.gradle.kts는 어떻게 컴파일되고 실행되나 |
|
| 7 | plugins {}의 id는 어떻게 플러그인 클래스가 되고 apply()가 불리나 |
|
| 8 | register와 Provider는 왜 늦게 만들고, 그 덕에 무엇이 되나 |
|
| 실행 단계 | 9 | 실행 계획은 어떻게 정해지고, 태스크는 몇 개씩 어떤 스레드에서 도나 |
| 10 | UP-TO-DATE는 무엇을 비교해서 정하나 |
|
| 11 | 태스크 결과와 설정 결과는 어떻게 저장하고 다시 쓰나 | |
| 의존성 | 12 | implementation(…) 한 줄은 어떻게 jar 파일 목록이 되나 |
읽는 순서
번호 순서대로 읽으면 됩니다. 1편이 큰 길을, 2편이 그 길이 지나는 "층"(프로세스 → 세션 → 빌드 트리 → 빌드 → 프로젝트)을 보여 주고, 3편부터는 매번 2편의 지도로 돌아가 "이번에는 이 부분을 확대한다"고 밝히고 들어갑니다. 데몬과 서비스 레지스트리(3·4편)는 모든 편에 나오므로 먼저 두었고, 설정 단계(5–8편)와 실행 단계(9–11편)는 5절의 빌드 오퍼레이션 나무를 위에서 아래로 읽는 순서, 곧 빌드가 실제로 도는 순서입니다. 의존성 해석은 설정과 실행 양쪽에 걸쳐 있어 마지막에 둡니다.
이 글에서 뺀 것
- Tooling API와 IDE 연동. IntelliJ가 Gradle 프로젝트를 불러올 때 쓰는
platforms/ide/tooling-api는 3편에서 "IDE도 같은 데몬에 붙는다"는 데까지만 봅니다. - native 플랫폼. C·C++·Swift 빌드(
platforms/native, 약 5만 5천 줄)는 다루지 않습니다. - Develocity와 Build Scan.
platforms/enterprise는 다루지 않고, 예제에서도--scan은 쓰지 않습니다(바깥 서비스로 데이터를 보냅니다). 빌드 오퍼레이션 나무의Develocity plugin build finished가 이 연동의 흔적입니다. - 문제 보고(Problems API),
platforms/observability는 설정 캐시의 문제 보고서를 볼 때(11편) 이름만 나옵니다. - Gradle 저장소의 빌드 자체.
build-logic*/,packaging/,testing/(테스트 기반, 코드 파일 973개)은 3절에서 필요한 만큼만 봤습니다. - 설정 캐시가 켜졌을 때의 길. 5절의 큰 길은 설정 캐시를 쓰지 않는 기본 경로(
Operational build model parameters의vintage=true)입니다. 캐시를 쓰면 설정 단계를 통째로 건너뛰는 다른 길이 생기고, 11편에서 봅니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| 읽는 소스 | Gradle 9.10.0 개발판(master), 커밋 07ecbf9ead(2026-10-24). v9.9.0-M2에서 631 커밋 뒤 |
| 판 번호 | version.txt 한 줄(9.10.0). 개발판은 빌드 시각이 붙어 9.10.0-20261107025045+0000 |
| Gradle을 빌드하는 것 | 래퍼 9.9.0-milestone-2, JDK 25, 규칙은 build-logic* 포함된 빌드 |
| 같은 소스 받기 | git clone 뒤 git -c core.longpaths=true worktree add --detach …로 커밋 고정 (Windows는 긴 경로 주의) |
| 빌드 | ./gradlew install -Pgradle_installPath=… (gradle build는 하지 않는다). .git이 없으면 BUILD_COMMIT_ID |
| 폴더 | platforms/ 11개 + subprojects/{core,core-api,composite-builds}, 서브프로젝트 233개, 본 코드 약 100만 줄. 중심은 core 플랫폼 셋 |
| 큰 길 | GradleMain → RunBuildAction → DaemonClient →(소켓)→ 명령 사슬 → ExecuteBuild → 실행기 사슬 → 빌드 트리·빌드 생명주기 → 실행 계획 → DefaultPlanExecutor → DefaultTaskNodeExecutor → 스텝 사슬 → 태스크 액션 |
| 두 스레드 | 설정 단계는 Daemon worker, 태스크 액션은 Execution worker(또는 included builds). 실행 단계 동안 Daemon worker는 기다린다 |
| 읽는 방법 | 커밋 고정 → AI 노트 → 클래스가 있는지부터 소스로 확인 → 예제 실행 → 글 → 더블 체크 |
다음에 볼 것
다음 글은 전체 지도입니다. 이 글에서 큰 길을 한 줄로 따라가며 클라이언트와 데몬, Daemon worker와 Execution worker, Load build·Configure build·Run main tasks라는 이름들을 봤습니다. 다음 글에서는 그 길이 지나는 층을 봅니다. Gradle이 쓰는 프로세스 네 종류(CLI 클라이언트, 데몬, Tooling API 클라이언트, 워커 프로세스), 빌드 프로세스 → 빌드 세션 → 빌드 트리 → 빌드 → 프로젝트로 이어지는 상태의 계층과 그것과 짝을 이루는 서비스 스코프, 그리고 초기화·설정·실행 세 단계가 DefaultBuildLifecycleController의 어떤 상태인지를 한 장의 지도로 그리고, 3~12편이 그 지도의 어디를 확대하는지 표시합니다.
- 다음 글: Gradle 소스 파헤치기 2: 전체 지도 — 클라이언트와 데몬, 서비스 스코프, 빌드의 세 단계
- 데몬으로 바로 가려면: 3편: gradle 명령에서 데몬까지 — 런처, 래퍼, 데몬 프로토콜
UP-TO-DATE가 궁금하면: 10편: 실행 엔진과 증분 빌드 — UP-TO-DATE는 어떻게 정해지나
같은 방식으로 읽은 앞 연재도 있습니다. Spring 소스 파헤치기 1편은 이 연재와 가장 많이 닮은 곳을 가진 연재의 첫 글입니다. Gradle의 서비스 레지스트리는 Spring의 IoC 컨테이너처럼 부모·자식으로 이어진 DI 컨테이너이고(Spring 3편·4편), 빌드의 생명주기 상태는 refresh()의 단계들(Spring 7편)과 견줘 볼 수 있습니다. Tomcat 소스 파헤치기 1편은 서버 프로세스 하나가 요청을 받아 스레드에 나눠 맡기는 길을 따라갔습니다. Gradle 데몬도 "빌드 요청"을 받는 서버이고, 5절의 명령 사슬은 Tomcat 8편의 밸브 사슬과 같은 생김새입니다. httpd 소스 파헤치기 1편은 C로 짠 서버를 같은 원칙으로 읽기 시작한 글입니다. 바로 앞 연재인 MariaDB 소스 파헤치기는 데이터베이스 서버를 같은 방식으로 읽습니다.