이 글에서 다루는 것
"Gradle 소스 파헤치기" 연재의 두 번째 글입니다. 1편에서 기준 커밋의 소스를 받아 빌드하고, 폴더 지도와 gradle build 한 번이 지나는 큰 길을 펴 보았습니다. 이번 글은 코드를 한 줄씩 읽기 전에 펴 놓는 전체 지도입니다. 3편부터 12편까지는 모두 이 글의 지도로 돌아와 "이 부분을 확대한다"고 말하고 시작합니다.
Gradle 을 써 온 사람이라면 ./gradlew build 를 처음 쳤을 때 Starting a Gradle Daemon 이라는 줄을 보았을 것이고, "빌드는 초기화·설정·실행 세 단계로 돈다"는 설명도 들어 보았을 것입니다. 이 글은 그 두 가지를 다리 삼아 다음 질문에 답합니다.
gradle명령 한 번에 프로세스가 몇 개 움직이고, 각각 무엇을 맡는가- 데몬 안의 상태는 어떤 층으로 나뉘고, 그 층이 왜 서비스 스코프와 1:1 로 맞물리는가
- 초기화·설정·실행 세 단계는 소스에서 어느 클래스의 어떤 상태인가
- 빌드 스크립트에서 만지는 모델(
Gradle·Settings·Project·Task)과 태스크를 돌리는 실행 쪽(실행 계획·노드·실행 엔진)은 어떻게 이어지는가
| 항목 | 내용 |
|---|---|
| 대상 | Gradle Build Tool |
| 버전 | 9.10.0 개발판 (master) |
| 커밋 | 07ecbf9ead (2026-10-24) |
| 주로 읽는 클래스 | Scope·@ServiceScope, BuildProcessState·BuildSessionState·BuildTreeState·BuildState·ProjectState, DefaultBuildLifecycleController·VintageBuildModelController, DefaultBuildWorkExecutor·LocalTaskNode·DefaultTaskNodeExecutor |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 경로는 저장소 루트에서 시작하고, 처음 한 번만 전체 경로를 쓰고 그다음부터는 subprojects/core/…/DefaultProject.java 처럼 줄입니다.
지도를 그리는 글이라 이 글은 큰 줄기만 따라갑니다. 각 부분을 자세히 읽는 글은 절마다 "N편에서 봅니다"로 이어 두었습니다.
먼저 알아 둘 말
데몬(daemon)은 빌드를 실제로 돌리는, 오래 살아 있는 JVM 프로세스입니다. gradle 명령은 빌드를 직접 하지 않고 데몬에게 "이 빌드를 돌려 달라"고 부탁만 합니다. 빌드가 끝나도 데몬은 바로 죽지 않고 다음 부탁을 기다립니다.
빌드 트리·빌드·프로젝트는 Gradle 이 일을 나누는 단위입니다. settings.gradle.kts 하나가 다스리는 묶음이 빌드(build)이고, 그 안에서 include("app") 로 더한 하나하나가 프로젝트(project)입니다. includeBuild("../lib") 로 다른 빌드를 끌어오면(이것을 컴포지트 빌드, composite build 라고 합니다) 빌드가 여럿이 되는데, 그 전체를 빌드 트리(build tree)라고 부릅니다. 끌려온 빌드는 포함된 빌드(included build)입니다.
서비스 레지스트리(service registry)는 Gradle 안의 DI 컨테이너입니다. Spring 의 ApplicationContext 처럼 "이 타입의 객체를 달라"고 하면 만들어 두었거나 새로 만든 객체를 내줍니다. 서비스(service)는 그렇게 레지스트리에 등록된 객체를 말합니다.
서비스 스코프(service scope)는 서비스가 사는 기간입니다. "데몬이 살아 있는 동안", "빌드 한 번 동안", "프로젝트 하나에 하나" 같은 기간마다 레지스트리가 따로 있고, 서비스는 그중 어디에 들어갈지 @ServiceScope 어노테이션으로 밝힙니다(3절).
빌드 생명주기(build lifecycle)는 빌드 하나가 지나는 상태들입니다. 사용자 문서는 이것을 초기화(initialization)·설정(configuration)·실행(execution) 세 단계로 설명합니다. 소스에서는 상태 열거형 두 개가 이 일을 맡습니다(4절).
실행 계획·노드·실행 엔진은 실행 단계의 부품입니다. 실행 계획(execution plan)은 "무엇을 어떤 순서로 돌릴지" 정한 그래프이고, 그 그래프의 점 하나가 노드(node)입니다. 태스크 하나가 보통 노드 하나입니다. 노드를 실제로 돌리는 곳은 실행 엔진(execution engine)인데, 엔진은 태스크를 작업 단위(unit of work)라는 일반적인 모양으로 받아 "이미 최신인가(UP-TO-DATE), 캐시에 있는가"를 따지는 단계들을 거쳐 실행합니다(6절).
1. 프로세스 넷 — 누가 빌드를 돌리나
저장소의 설계 문서가 말하는 네 프로세스
Gradle 저장소에는 architecture/ 라는 짧은 설계 문서 폴더가 있습니다. 그중 runtimes.md 가 첫 줄에서 Gradle 을 이루는 프로세스를 넷으로 나눕니다.
- Gradle daemon. This is the process that actually runs the build. It hosts build logic and coordinates the lifecycle of the build. It is a long-running daemon process.
- CLI client. This is the
gradleorgradlewcommand, and is responsible for locating, starting and interacting with the Gradle daemon, potentially downloading the Gradle distribution.- Tooling API client. This a library that is embedded into applications, such as IDEs or CI agents, that allows them to act as a Gradle client.
- Worker processes. Long-running daemon processes that the Gradle daemon starts to run specific kinds of work, such as compilation or test execution.
(architecture/runtimes.md 5–8행) 같은 문서는 이 넷이 "모두 자바 프로세스이고, 프로세스마다 대응하는 런타임(runtime)이 있다"고 덧붙입니다(39행). 런타임이란 그 프로세스에서 도는 코드가 지켜야 할 조건 — 쓸 수 있는 JVM 판, 쓸 수 있는 서비스, 쓸 수 있는 라이브러리 — 의 묶음입니다. 저장소의 소스 대부분은 데몬에서 돕니다.
넷을 소스의 입구와 함께 표로 모으면 이렇습니다.
| 프로세스 | 하는 일 | 입구가 되는 클래스 | 자세히 |
|---|---|---|---|
| CLI 클라이언트 | 맞는 데몬을 찾거나 띄우고, 요청을 보내고, 로그를 받아 찍는다 | platforms/core-runtime/gradle-cli-main/…/launcher/GradleMain.java → client-services/…/launcher/daemon/client/DaemonClient.java |
3편 |
| Tooling API 클라이언트 | IDE·CI 안에 든 라이브러리. 빌드도 돌리고 "프로젝트 모델"도 받아 간다 | platforms/ide/tooling-api/…/tooling/internal/consumer/DefaultGradleConnector.java |
3편 |
| 데몬 | 빌드 스크립트를 실행하고 태스크를 돌린다 | platforms/core-runtime/daemon-main/…/launcher/daemon/bootstrap/GradleDaemon.java, daemon-server/…/bootstrap/DaemonMain.java |
이 글 전체 |
| 워커 프로세스 | 데몬이 띄워 테스트·컴파일 같은 일을 맡긴다 | platforms/core-execution/worker-main/…/process/internal/worker/GradleWorkerMain.java |
9편 |
노란 상자가 빌드의 상태를 들고 있는 데몬입니다. 회색은 부탁하는 쪽, 파란색은 데몬이 일을 나눠 주는 쪽입니다.
왜 데몬을 따로 두나
빌드 한 번을 위해 JVM 을 새로 띄우면 매번 같은 값을 치릅니다. JVM 이 뜨고, Gradle 의 클래스 수천 개를 읽고, JIT 이 데워지고, 빌드 스크립트를 컴파일하고, 프로젝트 폴더의 파일 상태를 처음부터 다시 읽어야 합니다. 데몬은 이 값을 첫 빌드에만 치르고 그 뒤로는 이미 데워진 JVM 과 메모리에 남은 상태를 다음 빌드에 다시 씁니다.
"무엇이 남는가"는 막연한 말이 아니라 소스에 적혀 있습니다. 3절에서 볼 서비스 스코프 가운데 Global 과 UserHome 스코프의 서비스가 빌드가 끝나도 살아남는 것들입니다. 예를 들어 파일 시스템의 상태를 기억하는 FileSystemAccess 는 @ServiceScope({Scope.UserHome.class, Scope.BuildSession.class}) 로 선언되어 있습니다(platforms/core-execution/snapshots/src/main/java/org/gradle/internal/vfs/FileSystemAccess.java 36행). 빌드 사이에 파일 상태를 기억하는 일은 10편에서 봅니다.
클라이언트와 데몬이 주고받는 순서도 설계 문서에 여섯 단계로 적혀 있습니다(architecture/build-execution-model.md). 줄이면 이렇습니다. 클라이언트가 쉬고 있는 맞는 데몬을 찾고(없으면 띄우고), 요청을 보내고, 데몬이 바쁘면 거절당해 다시 찾고, 데몬이 요청을 돌리는 동안 로그를 받고, 결과를 받고, 연결을 끊습니다. 소켓 위에서 이것이 어떻게 이루어지는지는 3편에서 봅니다.
예제 1: 빌드 중에 떠 있는 프로세스
이 글의 예제는 모두 기준 커밋(07ecbf9ead)을 그대로 빌드한 Gradle 9.10.0 개발판으로 도커 컨테이너(Eclipse Temurin JDK 25)에서 실행했습니다. 예제 폴더는 ex/u02-phases, ex/u02-processes, ex/u02-scopes, ex/u02-landmarks 넷이고, 출력의 === 줄은 실행 스크립트가 찍은 제목입니다.
ex/u02-phases 의 run.sh 는 같은 프로젝트로 빌드를 두 번 돌린 뒤 gradle --status 로 데몬을 보고, 마지막으로 5초 동안 잠자는 태스크 nap 을 뒤에서 돌리면서 그 사이에 jps -l(돌고 있는 JVM 목록)을 찍습니다. 아래는 그 run.sh 의 뒷부분입니다.
echo "=== daemon status ==="
gradle --status | head -3
echo "=== processes while a build is running ==="
gradle nap --offline --console=plain -q > /dev/null &
sleep 3
jps -l | grep -v Jps | sort -n
wait
=== daemon status ===
PID STATUS INFO
50 IDLE 9.10.0-20261107025045+0000
=== processes while a build is running ===
50 org.gradle.launcher.daemon.bootstrap.GradleDaemon
266 /opt/gradle/lib/gradle-gradle-cli-main-9.10.0.jar
pid 숫자는 실행마다 다릅니다(다른 실행에서는 클라이언트가 245, 248 이었습니다).
- 빌드 두 번이 끝난 뒤 데몬 하나(pid 50)가
IDLE(쉬는 중)로 남아 있습니다. 두 번째 빌드가 새 데몬을 띄우지 않고 이 데몬을 다시 썼다는 뜻입니다. 4절의 출력에서 두 빌드의 스크립트가 모두pid=50을 찍는 것으로도 확인됩니다. - 빌드가 도는 동안에는 JVM 이 둘입니다.
GradleDaemon이 데몬이고,gradle-gradle-cli-main-9.10.0.jar이gradle명령이 띄운 클라이언트입니다. 클라이언트는 빌드가 끝나면 사라집니다.
예제 2: 테스트는 워커 프로세스에서 돈다
넷째 프로세스는 ex/u02-processes 로 보았습니다. java 플러그인을 건 프로젝트에 JUnit 테스트 하나를 두고, 테스트 안에서 자기 pid 와 부모 프로세스의 pid, 그리고 JVM 인자의 마지막 두 개를 찍습니다. 비교하려고 test 태스크의 doFirst 에서도 pid 를 찍습니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
package lab;
// ... (import 생략)
class WhereTest {
@Test
void printWhereIRun() {
ProcessHandle me = ProcessHandle.current();
List<String> args = List.of(me.info().arguments().orElse(new String[0]));
System.out.println("test : pid=" + me.pid()
+ " parent=" + me.parent().map(ProcessHandle::pid).orElse(-1L)
+ " thread=" + Thread.currentThread().getName());
// JVM 인자의 마지막 두 개 = 워커의 main 클래스와 워커 이름
System.out.println("worker : " + args.subList(Math.max(0, args.size() - 2), args.size()));
}
}
=== gradle test ===
> Task :testClasses
> Task :test
daemon : pid=52 thread=Execution worker Thread 2
test : pid=166 parent=52 thread=Test worker
worker : [worker.org.gradle.process.internal.worker.GradleWorkerMain, 'Gradle Test Executor 1']
BUILD SUCCESSFUL in 27s
=== java processes left after the build ===
52 org.gradle.launcher.daemon.bootstrap.GradleDaemon
(run.sh 가 grep 으로 줄을 골랐습니다. pid 와 스레드 이름 끝의 번호, 걸린 시간은 실행마다 다릅니다.) test 태스크는 데몬(pid 52)에서 돌지만, 테스트 코드는 부모가 데몬인 다른 JVM(pid 166)에서 돌았습니다. 그 JVM 의 main 클래스는 표에 적은 GradleWorkerMain 이고, 이름은 Gradle Test Executor 1 입니다. 테스트 코드가 데몬 안에서 돌지 않으니, 테스트가 System.exit() 를 부르거나 메모리를 다 써도 데몬은 무사합니다.
헷갈리기 쉬운 곳: 워커 프로세스가 모두 오래 사는 것은 아니다
설계 문서는 워커 프로세스를 "Long-running daemon processes"라고 부르지만, 예제 2의 마지막 줄을 보면 빌드가 끝난 뒤 남은 JVM 은 데몬 하나뿐입니다. 테스트 실행용 워커는 테스트가 끝나면 함께 끝났습니다. 그림 오른쪽 아래의 워커 데몬도 모두 오래 사는 것은 아닙니다. Worker API 의 프로세스 격리(WorkerDaemonFactory)가 띄우는 워커 데몬은 KeepAliveMode.SESSION 으로 만들어져(platforms/core-execution/workers/src/main/java/org/gradle/workers/internal/DefaultWorkerExecutor.java 216–218행) 빌드 세션이 끝날 때 멈춥니다(WorkerDaemonClientsManager.java 246–254행). 빌드가 끝나도 남아 다음 빌드에 다시 쓰이는 것은 KeepAliveMode.DAEMON 으로 만든 워커 데몬뿐이고, 포크한 Java 컴파일러 데몬이 그 예입니다(이 글에서 돌려 보지는 않았습니다). 자세한 것은 9편에서 봅니다.
헷갈리기 쉬운 곳: 데몬은 한 번에 빌드 하나만 돌린다
architecture/README.md 는 "Each daemon runs one request at a time."이라고 적습니다(40행). 같은 데몬에 두 번째 빌드를 동시에 보내면 데몬이 거절하고, 클라이언트는 쉬는 다른 데몬을 찾거나 새로 띄웁니다. 터미널 두 개에서 동시에 ./gradlew 를 치면 데몬이 둘이 되는 이유입니다. 같은 문서는 데몬이 사용자 코드를 "뒤에서(background)" 돌리는 일은 없다고도 적습니다(42행). 데몬은 부탁받았을 때만 빌드 스크립트를 실행합니다.
2. 상태의 계층 — 프로세스 > 세션 > 빌드 트리 > 빌드 > 프로젝트
다섯 층
데몬은 여러 빌드를 차례로 돌리고, 빌드 하나 안에도 빌드가 여럿(컴포지트 빌드), 프로젝트가 여럿 있습니다. 그래서 상태를 한 덩어리로 들고 있을 수 없습니다. architecture/build-state-model.md 는 데몬이 들고 있는 상태를 다섯 층으로 나누고, 층마다 그 상태를 맡는 클래스를 하나씩 정해 둡니다.
| 층 | 상태를 맡는 클래스 | 사는 기간 | 몇 개 |
|---|---|---|---|
| 빌드 프로세스 | platforms/core-runtime/build-state/…/internal/buildprocess/BuildProcessState.java |
데몬 프로세스가 살아 있는 동안 | 프로세스에 하나 |
| 빌드 세션 | platforms/core-runtime/build-state/…/internal/session/BuildSessionState.java |
gradle 명령 한 번. 연속 빌드(--continuous)면 그 안에서 빌드를 여러 번 |
명령마다 하나 |
| 빌드 트리 | subprojects/core/…/internal/buildtree/BuildTreeState.java |
빌드 실행 한 번 | 실행마다 하나 |
| 빌드 | subprojects/core/…/internal/build/BuildState.java (인터페이스) |
빌드 실행 한 번 | 빌드 트리 안의 빌드마다 하나 |
| 프로젝트 | subprojects/core/…/api/internal/project/ProjectState.java (인터페이스) |
빌드 실행 한 번 | 프로젝트마다 하나 |
세션과 빌드 트리를 나눈 이유는 연속 빌드입니다. --continuous 를 붙이면 Gradle 은 파일이 바뀔 때마다 빌드를 다시 돌리는데, 그동안 명령은 하나(세션 하나)이고 빌드 실행은 여러 번(빌드 트리 여러 개)입니다.
사는 기간이 코드 모양으로 보인다
"명령 한 번 동안 산다"는 말은 코드에서 try-with-resources 의 중괄호로 보입니다. 데몬이 빌드 요청을 받으면 지나가는 BuildSessionLifecycleBuildActionExecutor 입니다.
try (CrossBuildSessionState crossBuildSessionState = new CrossBuildSessionState(globalServices, startParameter, userActionRoot)) {
try (BuildSessionState buildSessionState = new BuildSessionState(userHomeServiceRegistry, crossBuildSessionState, startParameter, requestContext, actionParameters.getInjectedPluginClasspath(), requestContext.getCancellationToken(), requestContext.getClient(), requestContext.getEventConsumer())) {
return buildSessionState.run(actionWrapper);
}
}
요청 하나가 들어올 때마다 세션 상태를 새로 만들고, run() 이 끝나면 close() 로 버립니다. 그 안쪽에서 빌드 트리 상태도 같은 모양으로 만들고 버립니다.
try (BuildTreeState buildTree = new BuildTreeState(buildSessionServices, actionRequirements, buildModelParameters, buildInvocationScopeId)) {
// assign instead of return to allow combining build failures with cleanup failures below
result = buildTree.getServices().get(RootBuildLifecycleBuildActionExecutor.class).execute(action);
}
반대로 프로세스 상태는 데몬이 뜰 때 한 번 만듭니다. 데몬의 DaemonProcessState 생성자가 new BuildProcessState(…) 를 부릅니다(platforms/core-runtime/launcher/src/main/java/org/gradle/launcher/daemon/server/DaemonProcessState.java 44–56행). 첫 인자 !configuration.isSingleUse() 는 "오래 살 프로세스인가"입니다. 빌드 한 번만 하고 끝낼 데몬(단일 사용 데몬, single-use daemon)이면 false 가 됩니다. 어떤 때 그런 데몬이 뜨는지는 3편에서 봅니다.
빌드 층에는 구현이 여럿이다
BuildState 는 인터페이스이고, 빌드의 종류마다 구현이 다릅니다. 모두 subprojects/core/…/internal/build/AbstractBuildState.java 를 상속합니다.
| 구현 | 어떤 빌드 | 만드는 곳 |
|---|---|---|
DefaultRootBuildState |
gradle 을 친 폴더의 빌드 |
BuildStateFactory.createRootBuild() |
DefaultIncludedBuild |
includeBuild(…) 로 끌어온 빌드 |
DefaultIncludedBuildFactory |
DefaultNestedBuild |
buildSrc 폴더의 빌드 |
DefaultIncludedBuildRegistry.maybeAddBuildSrcBuild() → BuildStateFactory.createNestedBuild() |
RootOfNestedBuildTree |
GradleBuild 태스크가 띄운 별도의 빌드 트리의 루트 |
DefaultNestedBuildTree |
(모두 subprojects/composite-builds/src/main/java/org/gradle/composite/internal/ 에 있습니다.) buildSrc 는 같은 빌드 트리 안의 빌드 하나이고, GradleBuild 태스크는 세션까지 새로 만든다는 차이가 있습니다. DefaultNestedBuildTree 가 new BuildSessionState(…) 를 직접 부릅니다(DefaultNestedBuildTree.java 80행). 포함된 빌드와 buildSrc 를 어떻게 다루는지는 5편과 7편에서 조금씩 봅니다.
헷갈리기 쉬운 곳: ProjectState 가 둘이다
ProjectState 라는 이름의 타입이 저장소에 둘 있습니다. subprojects/core-api/src/main/java/org/gradle/api/ProjectState.java 는 빌드 스크립트에서 project.state 로 볼 수 있는 공개 API 로, "이 프로젝트의 평가가 끝났나, 실패했나"를 알려 줍니다. 이 글의 상태 계층에 나오는 것은 subprojects/core/src/main/java/org/gradle/api/internal/project/ProjectState.java, 패키지에 internal 이 든 내부 인터페이스입니다. 소스에서 ProjectState 를 만나면 import 부터 확인하는 것이 좋습니다.
헷갈리기 쉬운 곳: 프로젝트 상태의 부모는 부모 프로젝트가 아니다
:app 은 루트 프로젝트 : 의 하위 프로젝트지만, 상태 계층에서 :app 의 프로젝트 상태 바로 위는 루트 프로젝트가 아니라 빌드입니다. 설계 문서가 "is contained by the build state (and not the state of the parent project)"라고 따로 적어 둔 대목이고, 3절에서 보듯 프로젝트 스코프 레지스트리를 만들 때 부모로 넘기는 것도 빌드의 레지스트리입니다. 프로젝트끼리의 부모·자식은 모델(5절)의 관계일 뿐, 수명의 관계는 아닙니다.
테두리 상자 하나가 층 하나이고, 안쪽 상태는 바깥 상태가 살아 있는 동안만 삽니다. 파란 칸은 같은 층의 다른 상태, 초록 칸은 프로젝트 상태입니다.
3. 서비스 스코프 — 층마다 레지스트리가 하나씩
Scope 인터페이스 하나에 지도가 그려져 있다
2절의 다섯 층은 그대로 서비스 레지스트리의 층이 됩니다. 어느 층에 어떤 서비스가 사는지를 정하는 표식이 Scope 의 하위 인터페이스들이고, 그 파일의 주석에 지도가 통째로 그려져 있습니다.
* <h2>Scope hierarchy</h2>
* The scopes are arranged in a hierarchy (with some scopes having multiple parents):
* <pre>
* Global
* ┌────────┴─────────┐
* UserHome CrossBuildSession
* └────────┬─────────┘
* BuildSession
* │
* BuildTree
* │
* Build
* ┌────┴────┐
* Project Settings
* </pre>
*
* Each scope roughly corresponds to the following user-facing concepts:
* <ul>
* <li>{@link Global} — Gradle daemon process
* <li>{@link UserHome} — Gradle user home
* <li>{@link CrossBuildSession} — exists mainly because of {@code GradleBuild} task
* <li>{@link BuildSession} — continuous build
* <li>{@link BuildTree} — composite build
* <li>{@link Build} — build in a composite build
* <li>{@link Settings} — init scripts, settings script
* <li>{@link Project} — project in a build
* </ul>
2절의 다섯 층에 셋이 더 붙었습니다. UserHome 은 Gradle 사용자 홈(~/.gradle)마다 따로 두는 층이고, CrossBuildSession 은 GradleBuild 태스크가 만든 "안쪽 세션"과 바깥 세션이 함께 쓰는 층이며, Settings 는 init 스크립트와 settings 스크립트를 위한 층입니다. 같은 주석은 이어서 "부모 스코프의 서비스는 자식 스코프에서 보이지만, 그 반대는 아니다"라고 적습니다(77–78행).
서비스 쪽에서는 자기 스코프를 어노테이션으로 밝힙니다.
/**
* Attached to a service interface to indicate in which scopes it is defined in.
* Services are lifecycled with their scope, and stopped/closed when the scope is closed.
* <p>
* Services are visible to other services in the same scope and descendent scopes.
* Services are not visible to services in ancestor scopes.
* <p>
* When a service is defined in multiple scopes, the highest scope determines the visibility.
* The additional instances of the service in lower scopes "override" the instance from the parent
* for their scope and its children.
*
* @see Scope
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Inherited
public @interface ServiceScope {
예를 들어 1편의 큰 길에 나온 클래스들의 인터페이스에는 이렇게 붙어 있습니다.
| 서비스(인터페이스) | @ServiceScope |
뜻 |
|---|---|---|
ExecutorFactory, ListenerManager, BuildOperationRunner |
Global |
데몬 전체에 하나 |
GradleUserHomeDirProvider, GlobalCacheLocations |
UserHome |
~/.gradle 마다 하나, 다음 빌드에도 남는다 |
WorkerLeaseService |
CrossBuildSession |
명령 한 번 — 동시에 일할 수 있는 몫(워커 리스)과 프로젝트 잠금을 나눠 준다 |
InputFingerprinter |
BuildSession |
명령 한 번 |
PlanExecutor, BuildStateRegistry, ProjectStateRegistry |
BuildTree |
빌드 실행 한 번 |
BuildLifecycleController, ExecutionEngine, BuildCacheController, ExecutionHistoryStore |
Build |
빌드마다 하나 |
WorkerExecutor |
Project |
프로젝트마다 하나 |
ObjectFactory |
Global, BuildTree, Project |
여러 스코프에 따로 있다(아래 "헷갈리기 쉬운 곳") |
(각 인터페이스 파일의 @ServiceScope 줄을 직접 확인했습니다. 예: ExecutionEngine.java 32행, PlanExecutor.java 29행, ObjectFactory.java 52행.)
git grep 으로 src/main 아래에서 들여쓰기를 빼고 줄 머리가 @ServiceScope( 인 줄을 세면 820줄이 나옵니다. 스코프마다 세면(여러 스코프를 함께 적은 줄은 스코프마다 한 번씩) Build 237, Global 170, BuildTree 167, Project 131, BuildSession 93, UserHome 76, CrossBuildSession 20, Settings 10 입니다. 서비스 대부분이 빌드 한 번짜리(Build·BuildTree)이고, 데몬과 함께 사는 것(Global·UserHome)이 그다음입니다.
레지스트리를 만드는 자리
그러면 스코프마다 레지스트리는 어디서 만들어질까요. ServiceRegistryBuilder 에 .scopeStrictly(Scope.X.class) 를 넘기는 곳을 git grep 으로 찾으면, 스코프마다 거의 한 군데씩 나옵니다(테스트와 테스트 도구를 빼고).
| 스코프 | 레지스트리를 만드는 곳 | 레지스트리 이름(displayName) |
부모 |
|---|---|---|---|
Global |
BuildProcessState 생성자 (47행) |
Global services |
로깅·네이티브 서비스 |
UserHome |
DefaultGradleUserHomeScopeServiceRegistry.getServicesFor() (86행) |
services for Gradle user home dir … |
Global |
CrossBuildSession |
CrossBuildSessionState 생성자 (56행) |
cross session services |
Global |
BuildSession |
BuildSessionState 생성자 (56행) |
build session services |
UserHome 와 CrossBuildSession |
BuildTree |
BuildTreeState 생성자 (41행) |
build tree services |
BuildSession |
Build |
AbstractBuildState 생성자 (58행) |
Build-scoped services |
BuildTree |
Settings |
SettingsScopeServices.create() (61행) |
settings services |
Build |
Project |
ProjectScopeServices.create() (124행) |
project services |
Build |
(Global 스코프 레지스트리는 데몬 말고도 명령줄 클라이언트 자신(DefaultCommandLineActionFactory 의 Basic global services), 데몬 없이 클라이언트 프로세스 안에서 빌드를 돌리는 길(BuildActionsFactory), Tooling API 의 내장 실행(DefaultConnection)에서도 만들어집니다. 설정 캐시가 켜져 있으면 Settings 레지스트리를 만드는 곳이 하나 더 있습니다(DefaultConfigurationCacheHost.kt).)
모양은 모두 같습니다. 빌드 트리의 것을 보면 이렇습니다.
public BuildTreeState(
ServiceRegistry buildSessionServices,
BuildActionModelRequirements buildActionRequirements,
BuildModelParameters buildModelParameters,
BuildInvocationScopeId buildInvocationScopeId
) {
services = ServiceRegistryBuilder.builder()
.scopeStrictly(Scope.BuildTree.class)
.displayName("build tree services")
.parent(buildSessionServices)
.provider(new BuildTreeScopeServices(buildActionRequirements, buildModelParameters, buildInvocationScopeId))
.build();
}
subprojects/core/src/main/java/org/gradle/internal/buildtree/BuildTreeState.java 34–46행
.parent(…) 로 한 층 위 레지스트리를 잇고, .provider(…) 로 이 층의 서비스를 만드는 객체(BuildTreeScopeServices)를 넘깁니다. scopeStrictly 는 "이 레지스트리에는 @ServiceScope(BuildTree) 인 서비스만 들어올 수 있다"는 검사를 겁니다. 스코프가 틀린 서비스를 막는 검사(ServiceScopeValidator)와 provider 의 @Provides 메서드를 부르는 방식은 4편에서 봅니다.
모듈들은 자기 서비스를 스코프마다 보태는 방법도 가지고 있습니다. GradleModuleServices 인터페이스에 registerGlobalServices() 부터 registerProjectServices() 까지 스코프마다 메서드가 하나씩, 모두 여덟 개 있습니다(platforms/core-runtime/service-provider/src/main/java/org/gradle/internal/service/scopes/GradleModuleServices.java 38–94행). 1편에서 본 수십 개의 모듈이 서로를 직접 알지 않아도 레지스트리에 서비스를 꽂을 수 있는 구멍입니다.
노란 상자의 서비스는 빌드가 끝나도 데몬에 남고, 파란 상자의 서비스는 빌드(또는 명령)마다 새로 만들어집니다.
예제 3: 두 번 돌리면 무엇이 그대로이고 무엇이 새것인가
그림의 색이 정말 그런지 ex/u02-scopes 로 확인했습니다. 빌드 스크립트에서 프로젝트의 서비스 레지스트리를 꺼내, 스코프마다 그 스코프에만 선언된 서비스를 하나씩 받아 객체의 identityHashCode 를 찍습니다. 같은 데몬에서 빌드를 두 번 돌렸을 때 값이 같으면 같은 객체, 다르면 새로 만든 객체입니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// 스코프마다 그 스코프에만 등록된 서비스를 하나씩 꺼내, 객체의 identityHashCode 를 찍는다.
// 같은 데몬에서 두 번 돌렸을 때 값이 그대로면 "빌드가 끝나도 살아남은 객체", 바뀌면 "빌드마다 새로 만든 객체"다.
// (org.gradle.internal.* 는 Gradle 의 내부 API 다 — 소스를 읽으려고 쓰는 것이지 실제 빌드에 쓰면 안 된다)
import org.gradle.api.internal.project.ProjectInternal
fun id(o: Any) = "%08x".format(System.identityHashCode(o))
val services = (project as ProjectInternal).services
val probes = listOf(
"Global " to org.gradle.internal.concurrent.ExecutorFactory::class.java,
"UserHome " to org.gradle.initialization.GradleUserHomeDirProvider::class.java,
"CrossBuildSession" to org.gradle.internal.work.WorkerLeaseService::class.java,
"BuildSession " to org.gradle.internal.execution.InputFingerprinter::class.java,
"BuildTree " to org.gradle.internal.build.BuildStateRegistry::class.java,
"Build " to org.gradle.internal.build.BuildLifecycleController::class.java,
)
for ((scope, type) in probes) {
println("$scope ${id(services.get(type))} ${type.simpleName}")
}
println("Project (:) ${id(project)} ${services}")
project(":app") {
val appServices = (this as ProjectInternal).services
println("Project (:app) ${id(this)} same Build service? " +
(appServices.get(org.gradle.internal.build.BuildLifecycleController::class.java) ===
services.get(org.gradle.internal.build.BuildLifecycleController::class.java)))
}
// ... (아무 일도 하지 않는 태스크 noop 을 등록한다)
run.sh 는 gradle noop --offline --console=plain -q 를 두 번 돌립니다.
=== first build ===
Global 081d790f ExecutorFactory
UserHome 7f4757bd GradleUserHomeDirProvider
CrossBuildSession 59fd2e23 WorkerLeaseService
BuildSession 7f50d4e2 InputFingerprinter
BuildTree 3ac0b52f BuildStateRegistry
Build 7f7ff775 BuildLifecycleController
Project (:) 4f43428f project services
Project (:app) 6f02c140 same Build service? true
=== second build, same daemon ===
Global 081d790f ExecutorFactory
UserHome 7f4757bd GradleUserHomeDirProvider
CrossBuildSession 11893215 WorkerLeaseService
BuildSession 67895b63 InputFingerprinter
BuildTree 1c6fee75 BuildStateRegistry
Build 40a5782c BuildLifecycleController
Project (:) 1864f3b7 project services
Project (:app) 359bf19b same Build service? true
해시 값 자체는 실행마다 다릅니다. 봐야 할 것은 두 빌드 사이에 같은가 다른가입니다.
Global과UserHome의 객체는 두 빌드에서 같습니다. 데몬이 이 둘을 빌드 사이에 들고 있습니다.CrossBuildSession부터Build까지는 두 번째 빌드에서 모두 새 객체입니다. 2절의 try-with-resources 가 빌드마다 세션과 빌드 트리를 새로 만들고 버린 결과입니다.:와:app은 서로 다른 프로젝트 객체지만, 두 프로젝트의 레지스트리에서 꺼낸BuildLifecycleController는 같은 객체입니다(true). 두 프로젝트 레지스트리의 부모가 같은Build레지스트리이기 때문입니다.services를 그대로 찍으면project services가 나옵니다. 위 표의displayName입니다.
Spring 의 컨테이너와 닮은 곳, 다른 곳
부모·자식으로 이어진 레지스트리는 Spring 의 ApplicationContext 계층과 닮았습니다. Spring MVC 에서 웹 컨텍스트가 루트 컨텍스트를 부모로 두고, 자식은 부모의 빈을 보지만 부모는 자식의 빈을 못 보는 것과 같은 규칙입니다(Spring 3편에서 BeanFactory 계층을 보았습니다). 다른 점도 셋 있습니다.
- 부모가 둘일 수 있습니다.
BuildSessionState는.parent(userHomeServices)와.parent(crossBuildSessionServices.getServices())를 둘 다 겁니다(BuildSessionState.java 55–61행). - 층이 고정되어 있고, 서비스가 자기 층을 선언합니다. Spring 에서는 어느 컨텍스트에 빈을 둘지 설정하는 쪽이 정하지만, Gradle 에서는 서비스 인터페이스가
@ServiceScope로 정하고 레지스트리가 그것을 검사합니다. - 층마다 수명이 다릅니다. Spring 의 부모·자식 컨텍스트는 보통 애플리케이션과 함께 살지만, Gradle 의 아래쪽 층은 빌드 한 번마다 만들어지고 닫힙니다.
헷갈리기 쉬운 곳: 같은 서비스가 여러 스코프에 있을 수 있다
ObjectFactory 는 @ServiceScope({Scope.Global.class, Scope.BuildTree.class, Scope.Project.class}) 입니다. ServiceScope 의 주석대로 "아래 스코프의 인스턴스가 부모의 것을 덮어씁니다". 그래서 빌드 스크립트에서 objects 로 받는 ObjectFactory 는 프로젝트 스코프의 것이고, 데몬 전역의 것과 다른 객체입니다. 서비스 하나의 스코프를 확인할 때는 "가장 위 스코프"만 보지 말고 목록 전체를 봐야 합니다.
헷갈리기 쉬운 곳: UserHome 은 "데몬과 함께"가 아니라 "~/.gradle 과 함께" 산다
UserHome 레지스트리는 사용자 홈 폴더를 열쇠로 하는 맵에 들어 있습니다. 다른 폴더로 요청이 오면 쓰이지 않는 옛 폴더의 레지스트리를 닫고 새로 만듭니다.
Services services = servicesForHomeDir.get(gradleUserHomeDir);
if (services == null) {
if (servicesForHomeDir.size() == 1) {
Services otherServices = servicesForHomeDir.values().iterator().next();
if (otherServices.count == 0) {
// Other home dir cached and not in use, clean it up
stoppable(otherServices.registry).stop();
servicesForHomeDir.clear();
}
}
보통은 사용자 홈이 바뀌지 않으므로 설계 문서도 "사실상 프로세스 상태"라고 적습니다. 다만 데몬 레지스트리(registry.bin)도 사용자 홈 안에 있어서, -g 로 다른 사용자 홈을 주면 보통 다른 데몬이 뜹니다. 그래서 이 갈아 끼우기를 예제로 직접 보지는 못했습니다.
4. 빌드의 세 단계 — 초기화, 설정, 실행
사용자 문서의 세 단계와 소스의 상태 둘
사용자 문서의 세 단계는 이렇습니다(소스로 확인한 설명이 아니라 많이 알려진 정의입니다). 초기화에서 init 스크립트와 settings 스크립트를 실행해 어떤 프로젝트가 빌드에 드는지 정하고, 설정에서 각 프로젝트의 빌드 스크립트를 실행해 태스크를 등록하고, 실행에서 요청한 태스크와 그것이 기대는 태스크를 돌립니다.
소스에는 "초기화"라는 이름의 상태가 없습니다. 대신 상태 열거형 두 개가 겹쳐 있습니다. 바깥은 빌드 하나의 생명주기를 지키는 DefaultBuildLifecycleController 입니다.
public class DefaultBuildLifecycleController implements BuildLifecycleController {
private enum State implements StateTransitionController.State {
// Configuring the build, can access build model
Configure,
// Scheduling tasks for execution
TaskSchedule,
ReadyToRun,
BuildFinishHooks,
ReadyToReset,
// build has finished and should do no further work
Finished
}
private static final ImmutableList<State> CONFIGURATION_STATES = ImmutableList.of(State.Configure, State.TaskSchedule, State.ReadyToRun);
subprojects/core/src/main/java/org/gradle/internal/build/DefaultBuildLifecycleController.java 55–68행
안쪽은 빌드 모델(settings 와 프로젝트들)을 얼마나 만들었는지 기억하는 VintageBuildModelController 입니다.
private enum Stage implements StateTransitionController.State {
Created, SettingsLoaded, Configured
}
subprojects/core/src/main/java/org/gradle/initialization/VintageBuildModelController.java 30–32행
사용자 문서의 세 단계는 이 두 열거형 위에 이렇게 놓입니다.
| 사용자 문서의 단계 | 소스에서 | 일을 하는 곳 | 빌드 오퍼레이션 이름 | 자세히 |
|---|---|---|---|---|
| 초기화 | Stage.Created → SettingsLoaded |
VintageBuildModelController.prepareSettings() → DefaultSettingsPreparer |
Load build (그 안에 Run init scripts, Evaluate settings) |
5편 |
| 설정 | Stage.SettingsLoaded → Configured |
prepareProjects() → DefaultProjectsPreparer → 프로젝트마다 평가기 |
Configure build (그 안에 Configure project :…) |
5편, 6편 |
| (설정과 실행 사이) | State.TaskSchedule → ReadyToRun |
실행 계획을 채우고 확정한다 | Calculate task graph |
9편 |
| 실행 | State.ReadyToRun → Configure |
executeTasks() → DefaultBuildWorkExecutor |
Run tasks |
9편, 10편 |
빌드 오퍼레이션(build operation)은 Gradle 이 일의 덩어리마다 붙이는 이름표로, 1편에서 trace 파일로 그 나무를 보았습니다. 표의 이름은 각각 DefaultSettingsPreparer.java 132행, InitScriptHandler.java 65행, BuildOperationSettingsProcessor.java 56행, BuildOperationFiringProjectsPreparer.java 58행, LifecycleProjectEvaluator.java 137행, BuildOperationFiringBuildWorkPreparer.java 114행, DefaultBuildWorkExecutor.java 88행에서 확인했습니다.
Stage 를 움직이는 메서드는 짧습니다.
@Override
public void prepareToScheduleTasks() {
prepareSettings();
prepareProjects();
}
@Override
public void scheduleRequestedTasks(@Nullable EntryTaskSelector selector, ExecutionPlan plan) {
state.inState(Stage.Configured, () -> taskExecutionPreparer.scheduleRequestedTasks(gradle, selector, plan));
}
private void prepareSettings() {
state.transitionIfNotPreviously(Stage.Created, Stage.SettingsLoaded, () -> settingsPreparer.prepareSettings(gradle));
}
private void prepareProjects() {
state.transitionIfNotPreviously(Stage.SettingsLoaded, Stage.Configured, () -> projectsPreparer.prepareProjects(gradle));
}
subprojects/core/src/main/java/org/gradle/initialization/VintageBuildModelController.java 67–84행
transitionIfNotPreviously 는 "이전에 이 전이를 한 적이 없을 때만 한다"는 뜻입니다. settings 를 읽는 일과 프로젝트를 설정하는 일은 빌드마다 한 번만 일어나고, 누가 몇 번을 부르든 두 번째부터는 아무 일도 하지 않습니다. 이런 전이를 지키는 장치가 subprojects/core/…/internal/model/StateTransitionController.java 입니다. 잘못된 상태에서 부르면 예외를 던지는데, 그 안은 5편에서 봅니다.
파란 상태 셋이 소스의 CONFIGURATION_STATES 입니다. 사용자 문서의 "초기화·설정"은 첫 화살표 하나 안에서 일어납니다.
빌드 한 번의 호출 순서
상태 그림을 시간 순서로 펴면 아래와 같습니다. 1편의 큰 길에서 DefaultBuildTreeLifecycleController 다음을 확대한 그림입니다.
참가자 이름은 역할로 적었습니다. ②의 실제 호출은 DefaultBuildTreeWorkPreparer 를, ⑩은 DefaultBuildTreeFinishExecutor 를 거칩니다. ⑦까지는 Daemon worker 스레드에서, ⑧과 ⑨는 빌드마다 하나씩 맡겨지는 included builds 스레드에서 돕니다(6절).
① 다음의 작업 컨트롤러는 Kotlin 으로 쓰인 짧은 클래스입니다.
override fun scheduleAndRunRequestedTasks(taskSelector: EntryTaskSelector?): ExecutionResult<Void> {
return taskGraph.withNewWorkGraph { graph: BuildTreeWorkGraph ->
val finalizedGraph: Try<BuildTreeWorkGraph.FinalizedGraph> = Try.ofFailable { workPreparer.scheduleRequestedTasks(graph, taskSelector) }
if (finalizedGraph.isSuccessful) {
workExecutor.execute(finalizedGraph.get())
} else {
ExecutionResult.failed(finalizedGraph.failure.get())
}
}
}
"계획을 세우고(scheduleRequestedTasks), 성공하면 돌린다(execute)" 두 줄이 빌드 한 번의 뼈대입니다. 계획 세우기 안쪽(②~⑥)은 이렇습니다.
@Override
public BuildTreeWorkGraph.FinalizedGraph scheduleRequestedTasks(BuildTreeWorkGraph graph, @Nullable EntryTaskSelector selector) {
targetBuildController.prepareToScheduleTasks();
return graph.scheduleWork(graphBuilder -> {
if (selector != null) {
graphBuilder.addFinalization(targetBuild, selector::postProcessExecutionPlan);
}
graphBuilder.withWorkGraph(targetBuild, builder -> builder.addRequestedTasks(selector));
});
}
targetBuildController.prepareToScheduleTasks() 한 줄 안에서 초기화와 설정이 모두 끝나고, 그다음에야 명령줄에 적은 태스크(addRequestedTasks)를 그래프에 넣습니다. 빌드 스크립트를 다 돌려야 어떤 태스크가 있는지 알 수 있으니 당연한 순서입니다.
예제 4: 단계마다 어느 스레드, 어느 객체에서 도나
ex/u02-phases 는 init 스크립트(-I 로 넘깁니다), settings.gradle.kts, 루트와 :app 의 build.gradle.kts, 그리고 태스크 액션에서 각각 한 줄씩 찍습니다. 스레드 이름과 pid, 그 자리의 gradle·settings·project·tasks·태스크 객체의 실제 클래스 이름입니다. 파일마다 맨 위에 같은 도우미 함수를 둡니다.
fun where() = "thread=${Thread.currentThread().name} pid=${ProcessHandle.current().pid()}"
루트 프로젝트의 빌드 스크립트는 이렇습니다(나머지 파일도 같은 모양입니다).
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// 루트 프로젝트의 빌드 스크립트: 설정 단계에 돈다.
fun where() = "thread=${Thread.currentThread().name} pid=${ProcessHandle.current().pid()}"
println("[3 config ] build.gradle.kts (:) ${where()}")
println(" project = ${project.javaClass.name}")
println(" tasks = ${tasks.javaClass.name}")
tasks.register("hello") {
// 이 블록(설정 블록)은 태스크가 필요해질 때 돈다. doLast 의 람다는 실행 단계에 돈다.
doLast {
println("[5 execute ] task :hello action ${where()}")
println(" task = ${javaClass.name}")
}
}
// ... (1절의 nap 태스크)
gradle hello -I init.gradle.kts --offline --console=plain 의 첫 실행 출력입니다.
=== first run ===
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.
[1 init ] init.gradle.kts thread=Daemon worker pid=50
gradle = org.gradle.invocation.DefaultGradle_Decorated
[2 init ] settings.gradle.kts thread=Daemon worker pid=50
settings = org.gradle.initialization.DefaultSettings_Decorated
> Configure project :
[3 config ] build.gradle.kts (:) thread=Daemon worker pid=50
project = org.gradle.api.internal.project.DefaultProject_Decorated
tasks = org.gradle.api.internal.tasks.DefaultTaskContainer_Decorated
> Configure project :app
[4 config ] build.gradle.kts (:app) thread=Daemon worker pid=50
> Task :hello
[5 execute ] task :hello action thread=Execution worker pid=50
task = org.gradle.api.DefaultTask_Decorated
> Task :app:hello
[5 execute ] task :app:hello action thread=Execution worker pid=50
BUILD SUCCESSFUL in 22s
2 actionable tasks: 2 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.10.0-20261107025045+0000/userguide/configuration_cache_enabling.html
=== second run (same daemon?) ===
[1 init ] init.gradle.kts thread=Daemon worker pid=50
...
[5 execute ] task :app:hello action thread=Execution worker pid=50
BUILD SUCCESSFUL in 1s
2 actionable tasks: 2 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.10.0-20261107025045+0000/userguide/configuration_cache_enabling.html
(두 번째 실행은 첫 실행과 같은 줄이 나와 가운데를 줄였습니다. pid, 걸린 시간, 판 번호 뒤의 빌드 시각은 실행마다 다릅니다. Shared read-only dependency cache is an incubating feature. 는 예제 이미지가 미리 받아 둔 라이브러리를 읽기 전용 캐시로 붙여 두어서 나오는 줄입니다.)
출력에서 읽을 것은 넷입니다.
- 초기화와 설정은
Daemon worker스레드에서, 태스크 액션은Execution worker스레드에서 돌았습니다. (태스크 액션은included builds라는 스레드에서 돌기도 합니다. 6절에서 봅니다.) 두 이름은 소스에 그대로 있습니다.DaemonStateCoordinator가executorFactory.create("Daemon worker")로 요청을 돌릴 스레드를 만들고(platforms/core-runtime/launcher/src/main/java/org/gradle/launcher/daemon/server/DaemonStateCoordinator.java 74행),DefaultPlanExecutor가executorFactory.create("Execution worker")로 노드를 돌릴 스레드를 만듭니다(subprojects/core/src/main/java/org/gradle/execution/plan/DefaultPlanExecutor.java 90행). - 순서가 init → settings → 루트 →
:app→ 태스크입니다. 표의 단계 순서 그대로입니다. - 두 번째 실행도
pid=50입니다. 같은 데몬이 두 번째 빌드를 돌렸고, 그래서 22초가 1초가 되었습니다. 그런데도 init 스크립트부터 다시 돌았습니다. 데몬이 다시 쓰는 것은 JVM 과Global·UserHome의 상태이지, 빌드의 설정 결과가 아닙니다(설정 결과를 다시 쓰는 것은 마지막 줄이 권하는 설정 캐시이고, 11편에서 봅니다). - 클래스 이름이 모두
_Decorated로 끝납니다. 5절에서 봅니다.
헷갈리기 쉬운 곳: 태스크를 돌린 뒤 상태는 Configure 로 돌아간다
executeTasks() 는 ReadyToRun 에서 Finished 로 가지 않고 Configure 로 돌아갑니다. 소스의 주석이 이유를 적어 둡니다.
@Override
public ExecutionResult<Void> executeTasks(BuildWorkPlan plan) {
// Execute tasks and transition back to "configure", as this build may run more tasks;
DefaultBuildWorkPlan workPlan = unpack(plan);
if (workPlan.empty) {
return ExecutionResult.succeeded();
}
return buildState.tryTransition(State.ReadyToRun, State.Configure, () -> {
주석대로 한 빌드가 태스크를 여러 번 돌릴 수 있기 때문입니다. 빌드를 끝내는 것은 finishBuild() 이고, 이것은 CONFIGURATION_STATES 셋 가운데 어디서든 BuildFinishHooks 로 갑니다(345행). 빌드 결과의 이름도 여기서 정해지는데, 태스크를 하나라도 예약했으면 "Build", 아니면 "Configure" 입니다(354행).
헷갈리기 쉬운 곳: "Vintage" 는 설정 캐시가 꺼진 길이다
VintageBuildModelController, VintageBuildTreeWorkController 의 Vintage(옛 방식)는 설정 캐시가 꺼진 길이라는 뜻입니다. 같은 폴더에 ConfigurationCacheAwareBuildTreeWorkController.kt 가 나란히 있어서, --configuration-cache 를 켜면 이쪽이 ① 자리에 들어옵니다. 이쪽의 scheduleAndRunRequestedTasks() 는 먼저 캐시에서 실행 계획을 읽어 보고(loadAndRun), 읽히면 ②~⑥ 없이 바로 실행합니다. 초기화·설정 단계를 통째로 건너뛰는 것입니다. 이 글의 그림과 예제는 모두 설정 캐시가 꺼진 길입니다. 켜진 길은 11편에서 봅니다.
5. 모델 쪽 큰 그림 — Gradle, Settings, Project, Task
빌드 스크립트의 this 는 무엇인가
빌드 스크립트에서 만지는 객체들 — init 스크립트의 gradle, settings 스크립트의 settings, 빌드 스크립트의 project, 그 안의 tasks — 를 묶어 Gradle 의 모델이라고 부릅니다. 공개 인터페이스는 subprojects/core-api 에, 구현과 내부 인터페이스는 subprojects/core 에 있습니다.
| 빌드 스크립트에서 | 공개 인터페이스 | 내부 인터페이스 | 구현 | 실제 클래스(예제 4) | 상태 쪽 짝 |
|---|---|---|---|---|---|
gradle |
api/invocation/Gradle |
api/internal/GradleInternal |
invocation/DefaultGradle |
DefaultGradle_Decorated |
BuildState |
settings |
api/initialization/Settings |
api/internal/SettingsInternal |
initialization/DefaultSettings |
DefaultSettings_Decorated |
SettingsState |
project |
api/Project |
api/internal/project/ProjectInternal |
api/internal/project/DefaultProject |
DefaultProject_Decorated |
ProjectState |
tasks |
api/tasks/TaskContainer |
api/internal/tasks/TaskContainerInternal |
api/internal/tasks/DefaultTaskContainer |
DefaultTaskContainer_Decorated |
— |
| 태스크 하나 | api/Task |
api/internal/TaskInternal |
api/DefaultTask → api/internal/AbstractTask |
DefaultTask_Decorated |
— |
(경로는 모두 org/gradle/ 아래입니다.) 구현 클래스는 모두 abstract 입니다.
public abstract class DefaultProject extends AbstractPluginAware implements ProjectInternal, DynamicObjectAware {
subprojects/core/src/main/java/org/gradle/api/internal/project/DefaultProject.java 159행
DefaultGradle(81행), DefaultSettings(72행), DefaultTask(50행)도 마찬가지입니다. abstract 클래스는 new 로 만들 수 없으니, Gradle 은 실행 중에 이 클래스를 상속한 하위 클래스를 바이트코드로 만들어 씁니다. 그 하위 클래스의 이름이 …_Decorated 입니다. abstract 로 남겨 둔 게터(@Inject 로 받는 서비스, Property<T> 프로퍼티)를 채우고, 빌드 스크립트의 extra 프로퍼티나 확장 같은 동적 기능을 덧붙입니다. Spring 이 CGLIB 로 @Configuration 클래스의 하위 클래스를 만드는 것과 닮은 수법입니다(Spring 10편). 이 클래스를 만드는 AsmBackedClassGenerator 는 4편에서 봅니다.
상태가 모델을 가진다
2절의 상태 객체와 이 절의 모델 객체는 다른 것입니다. 상태 쪽이 모델을 가지고 있습니다.
/**
* Returns the current state of the mutable model of this build. Try to avoid using the model directly.
*/
GradleInternal getMutableModel();
subprojects/core/src/main/java/org/gradle/internal/build/BuildState.java 142–145행
프로젝트 쪽도 같습니다. 내부 ProjectState 는 public interface ProjectState extends ModelContainer<ProjectInternal> 로 선언되어 있고(subprojects/core/src/main/java/org/gradle/api/internal/project/ProjectState.java 40행), getMutableModel() 로 ProjectInternal 을 내줍니다.
왜 둘로 나눴을까요. getMutableModel() 의 주석 "Try to avoid using the model directly"가 실마리입니다. 모델(DefaultProject)은 빌드 스크립트가 마음대로 고치는 가변 객체이고, 여러 스레드가 동시에 만지면 깨집니다. 상태 객체는 그 앞에 서서 "이 프로젝트의 모델을 지금 누가 만지고 있나"를 관리합니다. ProjectState 가 @ThreadSafe 로 표시된 것(39행)도 그래서입니다. 그리고 상태는 모델보다 먼저 생기고 모델을 버렸다 다시 만들 수도 있습니다(4절 그림의 resetModel()). 프로젝트 잠금은 6편과 9편에서 봅니다.
"상태"라고 적은 둘이 2절의 계층이고, "모델"이라고 적은 넷이 빌드 스크립트가 만지는 객체입니다.
헷갈리기 쉬운 곳: settings 의 rootProject 는 프로젝트가 아니다
settings 스크립트에서 rootProject.name = "phases" 라고 쓸 때의 rootProject 는 Project 가 아닙니다.
ProjectDescriptor getRootProject();
subprojects/core-api/src/main/java/org/gradle/api/initialization/Settings.java 259행
초기화 단계에는 아직 프로젝트 객체가 없기 때문입니다. settings 는 "이런 이름, 이런 폴더의 프로젝트가 있을 것"이라는 설명서(ProjectDescriptor)만 만들고, 설정 단계에 들어가며 그 설명서대로 DefaultProject 를 만듭니다. 반대로 Gradle.getRootProject() 는 Project 를 돌려주지만 throws IllegalStateException 이 붙어 있습니다(subprojects/core-api/src/main/java/org/gradle/api/invocation/Gradle.java 114행). init 스크립트 본문에서 바로 부르면 아직 프로젝트가 없어 실패한다는 뜻입니다. 그래서 init 스크립트는 보통 gradle.rootProject { … } 처럼 "생기면 해 달라"는 콜백을 씁니다(같은 파일 125행).
헷갈리기 쉬운 곳: 공개 Gradle 에는 getSettings() 가 없다
GradleInternal 에는 SettingsInternal getSettings() 가 있지만(GradleInternal.java 95행), 공개 인터페이스 Gradle 에는 없습니다. 공개 API 로는 gradle.settingsEvaluated { … } 같은 콜백으로만 settings 에 닿을 수 있습니다. 소스를 읽다 보면 내부 인터페이스(…Internal)로 형변환해서 공개 API 에 없는 것을 꺼내는 코드를 자주 만납니다. 예제 3도 그렇게 했습니다. 사용자의 빌드에서는 쓰지 말아야 할 방법입니다.
6. 실행 쪽 큰 그림 — 실행 계획, 노드, 실행 엔진
계획을 받아 노드를 나눠 준다
설정 단계가 끝나면 빌드에는 실행 계획이 하나 남습니다. 4절 그림의 ⑨에서 DefaultBuildWorkExecutor 가 그 계획을 받아 Run tasks 라는 빌드 오퍼레이션으로 감싼 뒤, 계획의 노드를 PlanExecutor 에 넘깁니다.
try {
return planExecutor.process(
plan.asWorkSource(),
node -> {
// TODO: A Node should not expose its owning project.
// TODO: A Node should be created with the state required to execute it.
ProjectInternal project = node.getOwningProject();
if (project != null) {
NodeExecutionContext projectContext = projectContexts.computeIfAbsent(project, p ->
new TaskGraphNodeExecutionContext(ProjectExecutionServices.create(p))
);
node.execute(projectContext);
} else {
node.execute(globalContext);
}
}
);
subprojects/core/src/main/java/org/gradle/execution/DefaultBuildWorkExecutor.java 142–158행
process() 의 둘째 인자가 "노드 하나를 받으면 할 일"입니다. 노드가 속한 프로젝트가 있으면 그 프로젝트의 실행용 서비스(ProjectExecutionServices)를 문맥으로 만들어 node.execute() 에 넘깁니다. 여기서도 3절의 레지스트리가 쓰입니다.
같은 파일에서 --dry-run 이 어디서 갈리는지도 볼 수 있습니다. 실행 계획까지는 다 만들고, displayOrExecutePlan() 에서 태스크마다 이름과 SKIPPED 만 찍고 돌아갑니다(106–116행). --dry-run 이 설정 단계의 println 은 찍고 태스크 액션은 안 돌리는 이유입니다.
노드는 태스크만이 아니다
실행 계획의 노드는 subprojects/core/…/execution/plan/Node.java 를 상속합니다. 이 커밋에서 extends Node·extends TaskNode·extends CreationOrderedNode 를 찾으면 이런 것들이 나옵니다.
| 노드 | 무엇 |
|---|---|
LocalTaskNode |
이 빌드의 태스크 하나. 대부분의 노드가 이것이다 |
TaskInAnotherBuild |
포함된 빌드의 태스크. 그 빌드에 실행을 부탁한다 |
TransformStepNode |
의존성 아티팩트 변환(artifact transform) 한 단계 (platforms/software/dependency-management) |
ActionNode |
태스크가 아닌 일(WorkNodeAction) 하나 |
OrdinalNode |
gradle clean build 처럼 지우는 태스크와 만드는 태스크의 순서를 지키는 보조 노드 |
ResolveMutationsNode |
태스크 하나가 무엇을 바꾸는지(출력 위치 등)를 실행 전에 계산하는 보조 노드 (Resolve mutations for task …) |
이 커밋에서 태스크 노드는 다음 두 줄로 일을 넘깁니다.
@Override
public void execute(NodeExecutionContext context) {
context.getService(TaskNodeExecutor.class).execute(this);
}
subprojects/core/src/main/java/org/gradle/execution/plan/LocalTaskNode.java 227–230행
노드가 실행 방법을 직접 알지 않고, 문맥의 레지스트리에서 TaskNodeExecutor 를 꺼내 맡깁니다. 그 구현이 subprojects/core/…/api/internal/tasks/execution/DefaultTaskNodeExecutor.java 이고, 여기서 onlyIf 가 거짓이거나 액션이 없는 태스크를 건너뛴 뒤, 태스크를 TaskExecution 이라는 작업 단위로 감싸 실행 엔진에 넘깁니다.
ExecutionEngine.Request request = executionEngine.createRequest(work);
taskExecutionMode.getRebuildReason().ifPresent(request::forceNonIncremental);
request.withValidationContext(node.getValidationContext());
ExecutionEngine.Result result = request.execute();
(work 는 바로 위 341행의 new TaskExecution(…) 입니다.)
왜 태스크를 "작업 단위"로 감싸나
실행 엔진(platforms/core-execution/execution/…/internal/execution/ExecutionEngine.java)은 태스크를 모릅니다. 엔진이 아는 것은 "입력이 있고, 출력이 있고, 실행할 수 있는 일"이라는 일반적인 모양, 곧 작업 단위뿐입니다. 태스크는 TaskExecution 으로, 의존성 아티팩트 변환은 또 다른 작업 단위로 감싸져 같은 엔진을 탑니다. 그래서 "입력이 그대로면 건너뛴다(UP-TO-DATE)", "같은 입력의 결과가 캐시에 있으면 가져온다(FROM-CACHE)" 같은 판단을 엔진이 한 번만 구현하면 됩니다. 엔진 안은 작은 스텝(step)이 사슬처럼 이어진 구조이고, 맨 끝의 ExecuteStep 이 태스크 액션을 부릅니다. Tomcat 에서 요청이 밸브 사슬을 지나 서블릿에 닿는 것(Tomcat 8편)과 닮은 모양입니다.
노란 상자가 메모리와 디스크에 남는 것입니다. 실행 기록은 프로젝트의 .gradle/ 에, 로컬 빌드 캐시는 ~/.gradle/caches/ 아래(build-cache-1)에 있습니다.
헷갈리기 쉬운 곳: 태스크가 Execution worker 가 아닌 스레드에서 돌 수도 있다
예제 4에서 태스크 액션은 모두 Execution worker 스레드에서 돌았지만, 늘 그런 것은 아닙니다. DefaultPlanExecutor.process() 는 워커 스레드를 깨운 뒤 자기를 부른 스레드도 일꾼으로 씁니다.
// 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 114–118행
어차피 끝날 때까지 기다려야 하니 노는 대신 일을 돕는다는 것입니다. 그런데 이 "부른 스레드"는 Daemon worker 가 아닙니다. 빌드 트리 안의 빌드마다 DefaultBuildController 가 하나씩 있고, 그 빌드의 실행(4절 그림의 ⑧ 이후)을 executorFactory.create("included builds") 로 만든 실행기에 넘깁니다(subprojects/composite-builds/src/main/java/org/gradle/composite/internal/DefaultIncludedBuildTaskGraph.java 96행). 포함된 빌드가 없는 평범한 단일 빌드도 루트 빌드 하나를 이 길로 돌립니다. 그동안 Daemon worker 는 모든 빌드가 끝나기를 기다리기만 합니다(DefaultBuildControllers.awaitCompletion()). 그래서 이 길(설정 캐시 없이 명령줄로 태스크를 돌리는 길)에서 태스크 액션은 Execution worker 들이나 included builds 스레드에서 돌고, Daemon worker 에서는 돌지 않습니다. 실제로 예제 4를 다시 돌렸을 때는 첫 빌드의 :app:hello 가 thread=included builds 를 찍었고, 두 번째 빌드에서는 두 태스크 모두 Execution worker 였습니다. 태스크 코드에서 스레드 이름에 기대면 안 되는 이유입니다. 이 스레드들을 jstack 으로 확인하는 일, 워커 스레드 수와 프로젝트 잠금은 9편에서 봅니다.
7. 한 장으로 보는 지도 — 3~12편은 어디를 읽나
2편 지도
지금까지의 절을 한 장으로 모으고, 연재의 나머지 글이 어디를 확대하는지 구역으로 나눴습니다. 3편부터는 "2편 지도의 C 구역"처럼 이 그림의 글자를 불러 시작합니다.
구역의 글자(A~F)는 이 연재의 다른 글에서 이 그림을 가리킬 때 쓰는 이름입니다.
| 구역 | 이 글의 절 | 다루는 글 | 출발점이 되는 클래스 |
|---|---|---|---|
| A 클라이언트와 데몬 | 1절 | 3편 | GradleMain, DaemonClient, DaemonMain, DaemonServices 의 명령 사슬, DaemonStateCoordinator |
| B 서비스 레지스트리와 스코프 | 2·3절 | 4편 | DefaultServiceRegistry, ServiceRegistryBuilder, GradleModuleServices, AsmBackedClassGenerator |
| C 빌드 생명주기 | 4절 | 5편 | LauncherServices 의 실행기 사슬, DefaultBuildTreeLifecycleController, DefaultBuildLifecycleController, StateTransitionController, DefaultSettingsPreparer |
| D 설정 단계 | 4·5절 | 6편, 7편, 8편 | LifecycleProjectEvaluator, KotlinScriptEvaluator, DefaultPluginManager, DefaultTaskContainer, DefaultProperty |
| E 실행 단계 | 6절 | 9편, 10편, 11편 | DefaultExecutionPlan, DefaultPlanExecutor, DefaultTaskNodeExecutor, ExecutionEngine 스텝, BuildCacheStep, DefaultConfigurationCache |
| F 의존성 해석 | — | 12편 | DefaultConfiguration, DependencyGraphBuilder, 충돌 해결 |
F 를 점선으로 이은 까닭은, 의존성 해석이 정해진 단계에 묶여 있지 않기 때문입니다. implementation(…) 은 설정 단계에 "구성(configuration)"에 적어 두기만 하고, 실제 해석은 누군가 그 파일 목록을 처음 달라고 할 때 — 보통 실행 단계에 compileJava 의 입력을 계산할 때 — 일어납니다. 그 시점은 12편에서 println 으로 확인합니다.
예제 5: 호출 스택 위에 지도를 겹쳐 보기
지도가 실제와 맞는지 마지막으로 호출 스택으로 확인했습니다. 1편에서는 설정 단계와 태스크 액션의 스택을 통째로 찍어 길이를 셌습니다. 이번에는 같은 두 자리에서 스택을 받되, 지도에 이름이 나오는 클래스의 프레임만 남기고 바깥(아래)부터 찍습니다(ex/u02-landmarks).
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// 지금 스레드의 호출 스택에서 "지도에 이름이 나오는 클래스"의 프레임만 골라, 바깥(아래)에서 안(위) 순서로 찍는다.
val landmarks = setOf(
"DaemonStateCoordinator", "ExecuteBuild",
"BuildSessionLifecycleBuildActionExecutor", "DefaultBuildTreeActionExecutor", "RootBuildLifecycleBuildActionExecutor",
"DefaultBuildTreeLifecycleController", "VintageBuildTreeWorkController", "DefaultBuildTreeWorkPreparer",
"DefaultBuildLifecycleController", "VintageBuildModelController",
"DefaultProjectsPreparer", "LifecycleProjectEvaluator",
"DefaultBuildWorkExecutor", "DefaultPlanExecutor", "LocalTaskNode",
"DefaultTaskNodeExecutor", "DefaultExecutionEngine", "ExecuteStep",
)
fun landmarkStack(label: String) {
// ... (스택에서 landmarks 의 프레임만 골라, 같은 클래스가 이어지면 한 줄로 묶어 찍는다.
// 이름이 ...Step 으로 끝나는 클래스가 몇 종류인지도 센다)
}
landmarkStack("configuring build.gradle.kts")
tasks.register("where") {
doLast { landmarkStack("running task action") }
}
gradle where --offline --console=plain -q 의 출력입니다.
--- configuring build.gradle.kts (thread=Daemon worker) ---
DaemonStateCoordinator (lambda)
ExecuteBuild doBuild
BuildSessionLifecycleBuildActionExecutor execute > apply
DefaultBuildTreeActionExecutor runBuildTreeAction > runAsBuildOperation > call > runBuildTreeLifecycle
RootBuildLifecycleBuildActionExecutor execute
DefaultBuildTreeLifecycleController scheduleAndRunTasks > runBuild
VintageBuildTreeWorkController scheduleAndRunRequestedTasks > apply > call
DefaultBuildTreeWorkPreparer scheduleRequestedTasks
DefaultBuildLifecycleController prepareToScheduleTasks
VintageBuildModelController prepareToScheduleTasks > prepareProjects
DefaultProjectsPreparer prepareProjects
LifecycleProjectEvaluator evaluate > run
--- running task action (thread=Execution worker) ---
DefaultPlanExecutor run > execute
DefaultBuildWorkExecutor (lambda)
LocalTaskNode execute
DefaultTaskNodeExecutor execute > run > executeWithLifecycle > executeWithFallbackOperationId > executeCatchingFailures > executeSkippingOnlyIf > executeSkippingWithNoActions > executeFinalizingProperties > executeTrackingTaskIdentity > doExecute
DefaultExecutionEngine execute
ExecuteStep execute > call > executeInternal
(27 distinct *Step classes on the stack)
((lambda) 는 그 클래스의 프레임이 람다뿐이었다는 뜻입니다. 고른 프레임 사이에 있던 나머지 프레임은 모두 뺐습니다.)
| 스택의 줄 | 지도의 구역 | 이 글 |
|---|---|---|
DaemonStateCoordinator ~ ExecuteBuild |
A 데몬 | 1절 |
BuildSessionLifecycleBuildActionExecutor, DefaultBuildTreeActionExecutor |
B 세션·빌드 트리 상태를 만드는 곳 | 2절 |
RootBuildLifecycleBuildActionExecutor ~ VintageBuildModelController |
C 빌드 생명주기 | 4절 |
DefaultProjectsPreparer, LifecycleProjectEvaluator |
D 설정 단계 | 4절 |
DefaultPlanExecutor ~ ExecuteStep |
E 실행 단계 | 6절 |
읽을 것은 셋입니다.
- 첫 스택은 4절 그림의 ①②④ 순서와 그대로 맞습니다.
VintageBuildModelController에prepareSettings가 없는 것은 settings 를 읽는 일이 이미 끝나고 스택에서 빠졌기 때문입니다(같은prepareToScheduleTasks()안에서 먼저 불렸습니다). - 둘째 스택은
DefaultPlanExecutor에서 시작합니다. 이번 실행에서 태스크 액션은 다른 스레드(Execution worker)에서 돌았기 때문에, 그 스레드의 스택에는 데몬도 생명주기도 보이지 않습니다. 워커 스레드는 실행 계획에서 노드를 집어 오는 데서부터 일을 시작합니다. DefaultTaskNodeExecutor안에서executeSkippingOnlyIf·executeSkippingWithNoActions처럼 이름만 봐도 무엇을 건너뛰는지 아는 메서드들이 이어지고,DefaultExecutionEngine과ExecuteStep사이에는 이름이Step으로 끝나는 클래스가 27종 있습니다. 이 사슬을 10편에서 하나씩 읽습니다.
이 글에서 뺀 것
지도를 한 장으로 유지하려고 뺀 것들입니다.
- 데몬 안의 명령 사슬(
DaemonServices의HandleStop…ExecuteBuild)과 데몬 레지스트리.platforms/core-runtime/launcher/…/launcher/daemon/server/DaemonServices.java— 3편 - Tooling API 가 데몬에 붙는 길과 "모델 요청"(
BuildModelAction).platforms/core-runtime/tooling-api-provider/…/DefaultConnection.java,launcher/…/ProviderConnection.java— 3편 LauncherServices의 실행기 사슬(RootBuildLifecycleBuildActionExecutor바깥의 파일 감시·문제 보고 등).platforms/core-runtime/launcher/…/tooling/internal/provider/LauncherServices.java— 5편- 서비스 레지스트리 자체(서비스 찾기,
@Provides, 스코프 검사).platforms/core-runtime/service-registry-impl/…/DefaultServiceRegistry.java— 4편 - 클래스로더 스코프(settings → 루트 → 하위 프로젝트로 이어지는 클래스로더 나무).
subprojects/core/…/api/internal/initialization/ClassLoaderScope.java— 6편 - 프로젝트 격리(isolated projects)와 설정 단계 병렬화, configure-on-demand. 이 글의 그림은 모두 기본 설정의 길이다
- 연속 빌드(
--continuous)가 한 세션에서 빌드를 되풀이하는 곳.platforms/core-runtime/launcher의ContinuousBuildActionExecutor등 - 빌드 오퍼레이션의 기록과 리스너.
platforms/core-runtime/build-operations/— 1편에서 trace 로 보았고, 5편에서 다시 본다
정리
| 개념 | 한 줄 요약 |
|---|---|
| 프로세스 넷 | CLI 클라이언트·Tooling API 클라이언트가 부탁하고, 데몬이 빌드를 돌리고, 워커 프로세스가 테스트·컴파일을 맡는다 |
| 데몬 | 오래 사는 JVM. 한 번에 요청 하나. JVM 과 Global·UserHome 상태를 빌드 사이에 다시 쓴다 |
| 상태 계층 | 빌드 프로세스 ⊃ 빌드 세션 ⊃ 빌드 트리 ⊃ 빌드 ⊃ 프로젝트. 클래스는 BuildProcessState…ProjectState |
| 상태의 수명 | 세션과 빌드 트리는 try-with-resources 로 요청마다 만들고 닫는다 |
| 서비스 스코프 | Global·UserHome·CrossBuildSession·BuildSession·BuildTree·Build·Settings·Project. 스코프마다 레지스트리 하나, 서비스는 @ServiceScope 로 자기 스코프를 밝힌다 |
| 레지스트리의 부모 | 자식은 부모의 서비스를 보고 부모는 자식을 못 본다. BuildSession 은 부모가 둘, Project 의 부모는 Build |
| 빌드 생명주기 | DefaultBuildLifecycleController.State(Configure→TaskSchedule→ReadyToRun→다시 Configure, 끝은 BuildFinishHooks→Finished) |
| 초기화·설정 | VintageBuildModelController.Stage(Created→SettingsLoaded→Configured), prepareToScheduleTasks() 한 줄 안에서 |
| 스레드 | 초기화·설정은 Daemon worker, 태스크 액션은 Execution worker 나 included builds(빌드마다 하나). Daemon worker 는 실행 동안 기다린다 |
| 모델 | DefaultGradle·DefaultSettings·DefaultProject·DefaultTaskContainer·DefaultTask 는 모두 abstract, 실제 객체는 …_Decorated |
| 상태와 모델 | 상태(BuildState·ProjectState)가 가변 모델을 getMutableModel() 로 가지고 접근을 관리한다 |
| 실행 쪽 | 실행 계획 → DefaultPlanExecutor 가 노드를 나눔 → LocalTaskNode → DefaultTaskNodeExecutor → TaskExecution(작업 단위) → 실행 엔진 스텝 → 태스크 액션 |
| 2편 지도 | A 클라이언트·데몬(3편), B 레지스트리(4편), C 생명주기(5편), D 설정(6–8편), E 실행(9–11편), F 의존성(12편) |
다음에 볼 것
지도를 접고 A 구역부터 들어갑니다. 다음 글은 터미널에 gradle build 를 친 순간부터 데몬이 요청을 받기까지입니다. GradleMain 과 Main 이 명령줄을 읽고, --no-daemon 인지 보고, 호환되는 데몬을 고르거나 새로 띄우는 길, 래퍼(gradlew)가 배포판을 받아 푸는 길, 데몬 안에서 요청이 HandleStop 부터 ExecuteBuild 까지 명령 사슬을 지나는 순서, 그리고 데몬이 바쁠 때 두 번째 클라이언트가 새 데몬을 띄우는 모습을 --info 와 jps 로 확인합니다.
Gradle 소스 파헤치기 3: gradle 명령에서 데몬까지 — 런처, 래퍼, 데몬 프로토콜
앞서 끝낸 연재에서도 같은 자리에 지도를 폈습니다. Spring 2편에서 Spring 은 컨테이너 하나(ApplicationContext)가 켜질 때 refresh() 열두 단계를 거쳤는데(Spring 7편), Gradle 은 컨테이너가 스코프마다 따로 있고 빌드마다 아래쪽 층을 새로 켭니다. 단계를 상태 기계로 지키는 모습은 Tomcat 2편의 Lifecycle 과 닮았고, 실행 엔진의 스텝 사슬은 Tomcat 의 밸브 사슬(Tomcat 8편)이나 httpd 3편의 훅 사슬과 같은 생각입니다. 오래 사는 프로세스가 요청을 받아 처리하고 다음 요청을 기다린다는 점에서 Gradle 데몬은 빌드 도구라기보다 서버에 가깝습니다. 같은 때에 쓰이는 nginx 연재와 MariaDB 연재에서도 서버 프로세스의 구조를 다룹니다.