이 글에서 다루는 것
"Gradle 소스 파헤치기" 연재의 열 번째 글입니다. 9편에서는 실행 계획이 태스크의 순서를 정하고, DefaultPlanExecutor 의 워커 스레드가 "지금 돌릴 수 있는 노드"를 하나씩 집어 가는 데까지 봤습니다. 노드를 집은 다음에 무슨 일이 일어나는지, 곧 노드 하나가 태스크 액션에 닿거나 UP-TO-DATE 로 건너뛰어지기까지가 이번 글입니다.
2편 지도로 돌아가면, 이 글은 E 구역(실행 단계)의 가운데 상자 "실행 엔진 스텝 사슬(UP-TO-DATE, 10편)"을 확대합니다. 2편의 그림 "실행 쪽 흐름"에서 DefaultTaskNodeExecutor → TaskExecution → ExecutionEngine 스텝 사슬 → 태스크 액션으로 한 줄에 그렸던 부분입니다.
답하려는 질문은 네 가지입니다.
- 태스크가 실행되지 않고 건너뛰어지는 경우는 몇 가지이고, 각각 어디서 정해지는가 (
SKIPPED,UP-TO-DATE,NO-SOURCE) - 실행 엔진은 왜 태스크를 직접 다루지 않고 "작업 단위"로 감싸서 받는가. 왜 작은 스텝을 사슬로 쌓았는가
UP-TO-DATE는 무엇과 무엇을 비교해서 정하는가. 그 기록은 어디에 남는가.--info의 "not up-to-date because" 문장은 누가 쓰는가@PathSensitive, 파일 감시(VFS),InputChanges는 그 비교에서 무엇을 바꾸는가
| 항목 | 내용 |
|---|---|
| 대상 | Gradle Build Tool |
| 버전 | 9.10.0 개발판 (master) |
| 커밋 | 07ecbf9ead (2026-10-24) |
| 주로 읽는 클래스 | DefaultTaskNodeExecutor, TaskExecution, ExecutionBuildServices, DefaultExecutionEngine, steps/ 의 LoadPreviousExecutionStateStep·CaptureMutableStateBeforeExecutionStep·ResolveChangesStep·SkipUpToDateStep·StoreExecutionStateStep·ResolveInputChangesStep·ExecuteStep, DefaultExecutionStateChangeDetector, DefaultFileSystemAccess |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.
실행 엔진의 steps 패키지에만 클래스가 쉰 개 남짓 있고, 바뀐 것을 찾는 history/changes 패키지에 마흔 개 남짓이 더 있습니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다. 빌드 캐시(FROM-CACHE)는 같은 사슬 안에 있지만 11편에서 따로 봅니다.
먼저 알아 둘 말
실행 엔진(execution engine)은 "입력과 출력이 있는 일 하나"를 받아, 돌릴지 건너뛸지 정하고, 돌렸으면 그 결과를 기록하는 장치입니다. 인터페이스는 platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/ExecutionEngine.java 이고 구현은 DefaultExecutionEngine 하나입니다. 2편에서 "엔진은 태스크를 모른다"고 짧게 말했던 그것입니다.
작업 단위(unit of work)는 엔진이 받는 일의 모양입니다. "내 입력은 이것들이고, 출력은 저것들이고, 실행하려면 이 메서드를 부르면 된다"를 알려 주는 인터페이스 UnitOfWork 입니다. 태스크는 TaskExecution 이라는 작업 단위로 감싸져 엔진에 들어갑니다.
스텝(step)은 엔진 안의 작은 처리 하나입니다. 스텝은 다음 스텝(delegate)을 들고 있다가 자기 일을 하고 다음 스텝을 부릅니다. 스텝이 스텝을 감싸는 모양이라, 바깥 스텝은 안쪽이 끝난 뒤 돌아오는 길에 또 일을 할 수 있습니다. Tomcat 8편의 밸브와 닮았습니다.
실행 기록(execution history)은 태스크가 지난번에 성공했을 때의 입력과 출력의 요약입니다. 프로젝트 폴더의 .gradle/<Gradle 판>/executionHistory/ 에 저장되고, 다음 빌드가 "지난번과 같은가"를 판단할 때 읽습니다.
스냅숏(snapshot)과 지문(fingerprint)은 파일 상태를 비교하기 쉬운 모양으로 줄인 것입니다. 스냅숏은 파일 하나하나의 절대 경로·종류·내용 해시이고, 지문은 그것을 태스크가 원하는 기준(절대 경로를 볼지, 상대 경로만 볼지)으로 다시 정리한 것입니다. @PathSensitive 가 이 기준을 정합니다.
VFS(virtual file system, 가상 파일 시스템)는 데몬이 메모리에 들고 있는 파일 스냅숏의 나무입니다. 같은 파일을 빌드 안에서 여러 번 읽지 않게 해 주고, 파일 감시가 켜져 있으면 빌드와 빌드 사이에도 남습니다. 데몬이 오래 사는 이유 가운데 하나입니다(3편).
1. 큰 그림: 노드에서 태스크 액션까지
9편의 워커 스레드는 LocalTaskNode 를 하나 집으면 node.execute() 를 부르고, 노드는 그 일을 TaskNodeExecutor 서비스에 넘깁니다(2편에서 본 LocalTaskNode.java 227–230행). 그 구현이 subprojects/core/src/main/java/org/gradle/api/internal/tasks/execution/DefaultTaskNodeExecutor.java 입니다. 여기서부터 태스크 액션까지는 세 층입니다.
파란 상자가 이 글에서 따라가는 길입니다. 노란 상자는 디스크(실행 기록)와 데몬 메모리(VFS)에 남는 것입니다.
- 거르기 —
DefaultTaskNodeExecutor가 엔진에 넘길 필요가 없는 태스크를 먼저 걸러 냅니다. 꺼 둔 태스크,onlyIf가 거짓인 태스크, 액션이 하나도 없는 태스크입니다(2절). - 감싸기 — 남은 태스크를
TaskExecution이라는 작업 단위로 감쌉니다. 엔진은 이 작업 단위만 봅니다(3절). - 스텝 사슬 — 엔진은 스텝 스물다섯 개가 겹겹이 감싼 사슬입니다. 앞의 넷은 모든 작업 단위가 지나고,
ChoosePipelineStep에서 작업 단위의 종류에 따라 두 파이프라인 가운데 하나로 갈라집니다. 태스크는 가변 파이프라인(스텝 21개)으로 갑니다.UP-TO-DATE를 정하는 곳이 여기입니다(4·5절).
UP-TO-DATE 판단만 놓고 보면 결국 이 한 문장입니다. 지난번 성공했을 때의 입력·출력 요약(실행 기록)과 지금의 입력·출력 요약을 비교해서, 다른 점이 하나도 없으면 액션을 부르지 않는다. 나머지는 그 비교를 빠르고, 정확하고, 설명 가능하게 하려는 장치들입니다.
예제 1: 한 태스크를 일곱 번 돌리기
글 전체에서 쓸 태스크를 하나 만들었습니다. 입력이 둘(문자열 @Input, 파일 @InputFile), 출력이 하나(@OutputFile)입니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// 입력 둘(@Input 문자열, @InputFile 파일)과 출력 하나(@OutputFile)를 가진 태스크
abstract class Render : DefaultTask() {
@get:Input
abstract val greeting: Property<String>
@get:InputFile
abstract val template: RegularFileProperty
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun render() {
val text = template.get().asFile.readText().replace("{greeting}", greeting.get())
outputFile.get().asFile.writeText(text)
println("render: wrote ${outputFile.get().asFile.name}")
}
}
tasks.register<Render>("render") {
greeting = providers.gradleProperty("greeting").orElse("Hello")
template = layout.projectDirectory.file("src/template.txt")
outputFile = layout.buildDirectory.file("out/greeting.txt")
}
[예제 ex/u10-up-to-date/project/build.gradle.kts 에서 줄임]
이 예제는 기준 커밋(07ecbf9ead)을 그대로 빌드한 Gradle 9.10.0 개발판으로 도커 컨테이너(Eclipse Temurin JDK 25)에서 실행했습니다. run.sh 가 같은 데몬으로 gradle render --info 를 일곱 번 돌리며, 그 사이에 입력과 출력을 하나씩 건드립니다. --info 출력은 길어서 grep 으로 이 글에 필요한 줄만 남겼습니다. 처음 두 번입니다.
=== 1st run (no history) ===
Caching disabled for Kotlin DSL script compilation (Settings/TopLevel/stage1) because:
Build cache is disabled
...
> Task :render
Caching disabled for task ':render' because:
Build cache is disabled
Caching has not been enabled for the task
Task ':render' is not up-to-date because:
No history is available.
render: wrote greeting.txt
BUILD SUCCESSFUL in 40s
1 actionable task: 1 executed
=== 2nd run (nothing changed) ===
Included projects: [root project 'up-to-date']
Evaluating root project 'up-to-date' using build file '/tmp/p/build.gradle.kts'.
> Task :render UP-TO-DATE
Caching disabled for task ':render' because:
Build cache is disabled
Caching has not been enabled for the task
Skipping task ':render' as it is up-to-date.
BUILD SUCCESSFUL in 2s
1 actionable task: 1 up-to-date
(첫 실행의 ... 자리에는 Included projects·Evaluating 두 줄과, Kotlin DSL 스크립트 컴파일·접근자 생성에 대한 같은 모양의 Caching disabled 줄 열 줄이 있어 줄였습니다. 걸린 시간은 실행마다 다릅니다. 첫 실행이 긴 것은 데몬을 새로 띄우고 스크립트를 처음 컴파일하기 때문입니다.)
벌써 읽을 것이 셋 있습니다.
- 첫 실행의 이유는
No history is available.입니다. 비교할 지난 기록이 없으니 실행합니다. 두 번째는Skipping task ':render' as it is up-to-date.입니다. 이 두 문장을 쓰는 곳이 5절의ResolveChangesStep과SkipUpToDateStep입니다. Caching disabled for task ':render'줄이UP-TO-DATE판단보다 먼저 찍혔습니다. 캐시 키를 계산하는 스텝(ResolveMutableCachingStateStep)이 건너뛸지 정하는 스텝(SkipUpToDateStep)보다 앞에 있기 때문입니다. 4절의 순서와 맞습니다.- 첫 실행에는
Kotlin DSL script compilation에도Caching disabled줄이 붙었습니다. 빌드 스크립트 컴파일도 같은 실행 엔진을 탄다는 뜻입니다. 다만 태스크와 다른 파이프라인(불변 파이프라인)입니다. 3절에서 봅니다.
나머지 다섯 번은 5·6절에서 이유 문장과 함께 봅니다.
2. 엔진 앞의 거름망 — DefaultTaskNodeExecutor
메서드 이름이 곧 순서다
DefaultTaskNodeExecutor.execute() 는 태스크 하나를 Task :render 라는 이름의 빌드 오퍼레이션으로 감싸고(5편에서 본 그 장치입니다), 그 안에서 executeWithLifecycle() 로 시작하는 메서드들을 차례로 부릅니다. 메서드 하나가 한 가지를 확인하고 다음 메서드를 부르는 모양이라, 이름만 이어 읽어도 순서가 보입니다.
executeWithLifecycle → executeWithFallbackOperationId → executeCatchingFailures → executeSkippingOnlyIf → executeSkippingWithNoActions → executeFinalizingProperties → executeTrackingTaskIdentity → doExecute
앞의 셋은 리스너 호출, 로그를 지금 빌드 오퍼레이션에 붙이기, 예외를 태스크 실패로 바꾸기입니다. 건너뛰기를 정하는 것은 그다음 둘입니다.
초록 상자는 "실패가 아닌 끝"입니다. 왼쪽의 두 끝은 실행 엔진에 들어가지도 않습니다.
onlyIf 와 enabled 는 같은 자리에서 걸러진다
private ExecutionEngine.@Nullable Result executeSkippingOnlyIf(LocalTaskNode node) {
TaskInternal task = node.getTask();
TaskStateInternal state = task.getState();
Spec<? super TaskInternal> unsatisfiedSpec = null;
try {
Spec<? super TaskInternal> onlyIf = task.getOnlyIf();
// ...
if (unsatisfiedSpec != null) {
if (unsatisfiedSpec instanceof SelfDescribingSpec) {
SelfDescribingSpec<? super TaskInternal> selfDescribingSpec = Cast.uncheckedCast(unsatisfiedSpec);
LOGGER.info("Skipping {} as task onlyIf '{}' is false.", task, selfDescribingSpec.getDisplayName());
state.setSkipReasonMessage("'" + selfDescribingSpec.getDisplayName() + "' not satisfied");
} else {
// ...
}
state.setOutcome(TaskExecutionOutcome.SKIPPED);
return null;
}
return executeSkippingWithNoActions(node);
}
task.getOnlyIf() 가 돌려주는 조건 묶음에서 만족하지 않는 첫 조건을 찾고(줄인 자리, 246–253행), 있으면 상태를 SKIPPED 로 두고 null 을 돌려줍니다. 엔진은 부르지 않습니다.
그런데 enabled = false 를 확인하는 코드는 이 클래스에 따로 없습니다. 답은 AbstractTask 에 있습니다. 조건 묶음을 새로 만들 때 첫 조건으로 "켜져 있는가"를 넣어 둡니다.
private DescribingAndSpec<Task> createNewOnlyIfSpec() {
return new DescribingAndSpec<>(new Spec<Task>() {
@Override
public boolean isSatisfiedBy(Task element) {
return element == AbstractTask.this && enabled;
}
}, "Task is enabled");
}
subprojects/core/src/main/java/org/gradle/api/internal/AbstractTask.java 402–409행
빌드 스크립트의 onlyIf { … } 는 이 묶음에 and 로 조건을 덧붙입니다(343–370행). setOnlyIf(…) 로 조건을 바꿔도 새 묶음을 createNewOnlyIfSpec() 에서 시작하므로 "켜져 있는가"는 사라지지 않습니다(373–400행). 그래서 꺼 둔 태스크의 --info 문장은 onlyIf 'Task is enabled' is false 가 됩니다. 예제에서 확인합니다.
액션이 없는 태스크
private ExecutionEngine.@Nullable Result executeSkippingWithNoActions(LocalTaskNode node) {
TaskInternal task = node.getTask();
if (!task.hasTaskActions()) {
LOGGER.info("Skipping {} as it has no actions.", task);
TaskStateInternal state = task.getState();
state.setActionable(false);
state.setOutcome(allDependencyTasksSkipped(node)
? TaskExecutionOutcome.UP_TO_DATE
: TaskExecutionOutcome.EXECUTED);
return null;
}
return executeFinalizingProperties(node);
}
build·assemble 같은 생명주기 태스크는 보통 액션 없이 의존만 가집니다. 이런 태스크는 엔진에 들어가지 않고, 의존한 태스크가 모두 건너뛰었으면 UP-TO-DATE, 하나라도 돌았으면 EXECUTED 로 표시됩니다. gradle build 가 다 최신일 때 > Task :build UP-TO-DATE 가 찍히는 이유입니다. 이 UP-TO-DATE 는 실행 기록과 비교해서 나온 것이 아닙니다. setActionable(false) 는 이 태스크를 끝의 "N actionable tasks" 수에서 빼는 표시입니다.
실행 방식 정하기 — TaskExecutionMode
두 거름망을 지나면 doExecute() 가 작업 단위를 만들기 전에 "이 태스크를 어떤 방식으로 돌릴지"를 정합니다.
public TaskExecutionMode getExecutionMode(TaskInternal task, TaskProperties properties) {
if (task.getReasonNotToTrackState().isPresent()) {
return DefaultTaskExecutionMode.untracked(task.getReasonNotToTrackState().get());
}
// Only false if no declared outputs AND no Task.upToDateWhen spec. We force to true for incremental tasks.
AndSpec<? super TaskInternal> upToDateSpec = task.getOutputs().getUpToDateSpec();
if (!properties.hasDeclaredOutputs() && upToDateSpec.isEmpty()) {
// ... 증분 태스크면 오류, 아니면 noOutputs() ...
}
if (startParameter.isRerunTasks()) {
return DefaultTaskExecutionMode.rerunTasksEnabled();
}
if (!upToDateSpec.isSatisfiedBy(task)) {
return DefaultTaskExecutionMode.upToDateWhenFalse();
}
return DefaultTaskExecutionMode.incremental();
}
결과는 다섯 가지이고, 각각 "강제로 다시 돌릴 이유"와 두 가지 스위치를 가집니다(DefaultTaskExecutionMode.java 29–33행).
| 방식 | 다시 돌릴 이유 (--info 문장) |
실행 기록을 남기나 | 캐시를 쓰나 |
|---|---|---|---|
doNotTrackState(…) 로 꺼 둠 |
Task state is not tracked. 또는 준 이유 |
아니오 | 아니오 |
출력 선언도 upToDateWhen 도 없음 |
Task has not declared any outputs despite executing actions. |
아니오 | 아니오 |
--rerun-tasks |
Executed with '--rerun-tasks'. |
예 | 아니오 |
upToDateWhen 이 거짓 |
Task.upToDateWhen is false. |
예 | 아니오 |
| 보통 (incremental) | 없음 | 예 | 예 |
doExecute() 는 이 방식을 넣어 TaskExecution 을 만들고(340–357행) 엔진에 넘깁니다.
TaskStateInternal state = task.getState();
try {
ExecutionEngine.Request request = executionEngine.createRequest(work);
taskExecutionMode.getRebuildReason().ifPresent(request::forceNonIncremental);
request.withValidationContext(node.getValidationContext());
ExecutionEngine.Result result = request.execute();
result.getExecution().ifSuccessfulOrElse(
success -> state.setOutcome(convertOutcome(success.getOutcome())),
failure -> state.setOutcome(new TaskExecutionException(task, failure))
);
return result;
"다시 돌릴 이유"가 있으면 forceNonIncremental(이유) 로 요청에 실어 보냅니다. 이 이유는 5절의 ResolveChangesStep 이 꺼내 씁니다. 엔진이 돌려준 결과(ExecutionOutcome)는 convertOutcome() 에서 콘솔에 찍히는 결과로 바뀝니다(376–390행). UP_TO_DATE → UP-TO-DATE, FROM_CACHE → FROM-CACHE, 그리고 엔진의 SHORT_CIRCUITED 가 콘솔의 NO-SOURCE 입니다.
예제 1 이어서: 엔진 앞에서 끝나는 태스크들
같은 빌드 스크립트에 태스크를 넷 더 두었습니다.
// 출력을 선언하지 않은 태스크 — 기록을 남기지 않으므로 매번 실행된다
tasks.register("noOutputs") { doLast { println("noOutputs: ran") } }
// 꺼 둔 태스크 — enabled = false
tasks.register("disabled") { enabled = false; doLast { println("disabled: never printed") } }
// onlyIf 조건이 거짓이면 건너뛰는 태스크
val go = providers.gradleProperty("go")
tasks.register("conditional") {
onlyIf("property 'go' is set") { go.isPresent }
doLast { println("conditional: ran") }
}
// 액션은 돌았지만 "한 일이 없다"고 알리는 태스크
tasks.register("nothingToDo") {
outputs.file(layout.buildDirectory.file("nothing.txt"))
doLast { println("nothingToDo: action ran, but reports didWork = false"); didWork = false }
}
[예제 ex/u10-up-to-date/project/build.gradle.kts 에서 줄임]
같은 명령을 두 번 돌렸습니다.
=== tasks that are skipped before the engine ===
> Task :noOutputs
Task ':noOutputs' is not up-to-date because:
Task has not declared any outputs despite executing actions.
noOutputs: ran
> Task :disabled SKIPPED
Skipping task ':disabled' as task onlyIf 'Task is enabled' is false.
> Task :conditional SKIPPED
Skipping task ':conditional' as task onlyIf 'property 'go' is set' is false.
> Task :nothingToDo UP-TO-DATE
Task ':nothingToDo' is not up-to-date because:
No history is available.
nothingToDo: action ran, but reports didWork = false
BUILD SUCCESSFUL in 1s
=== same tasks, second run ===
> Task :noOutputs
Task ':noOutputs' is not up-to-date because:
Task has not declared any outputs despite executing actions.
noOutputs: ran
...
> Task :nothingToDo UP-TO-DATE
Skipping task ':nothingToDo' as it is up-to-date.
BUILD SUCCESSFUL in 2s
(두 번째 실행의 ... 자리에는 첫 실행과 같은 disabled·conditional 의 SKIPPED 네 줄이 있어 줄였습니다. 걸린 시간은 실행마다 다릅니다.)
disabled와conditional은 둘 다 같은 문장 모양(as task onlyIf '…' is false)으로 건너뛰었습니다.enabled = false가onlyIf의 첫 조건이라는 것이 출력으로도 보입니다.noOutputs는 두 번째에도 실행되었습니다. 출력을 선언하지 않으면(upToDateWhen도 걸지 않았으면)TaskExecutionMode가 "기록을 남기지 않음"이 되어, 비교할 기록이 영영 생기지 않습니다. 입력만 선언하고 출력을 선언하지 않은 태스크는UP-TO-DATE가 될 수 없습니다(outputs.upToDateWhen { … }을 걸면 이 갈래를 타지 않습니다,DefaultTaskExecutionModeResolver.java46행).nothingToDo는 첫 실행에 액션이 돌았는데도(action ran줄) 결과가UP-TO-DATE입니다. 이것은 5절 끝의 "헷갈리기 쉬운 곳"에서 설명합니다.
헷갈리기 쉬운 곳: SKIPPED 와 UP-TO-DATE 는 다른 층에서 정해진다
콘솔에서는 둘 다 "안 돌았다"로 보이지만, SKIPPED 는 엔진에 들어가기 전에 DefaultTaskNodeExecutor 가 정하고, 진짜 UP-TO-DATE 는 엔진 안의 SkipUpToDateStep 이 정합니다. 그래서 SKIPPED 인 태스크는 입력을 읽지도, 실행 기록을 건드리지도 않습니다. 반대로 액션 없는 태스크의 UP-TO-DATE 는 엔진 밖에서 붙는 표시라, 실행 기록과 상관이 없습니다. 같은 글자가 두 곳에서 나옵니다.
3. 태스크를 작업 단위로 — TaskExecution
엔진이 묻는 질문들
엔진이 작업 단위에게 묻는 것은 인터페이스에 다 적혀 있습니다. 태스크가 구현하는 MutableUnitOfWork 에는 getWorkspaceProvider()(작업 공간), getHistory()(실행 기록 저장소), getExecutionBehavior()(증분 실행이 되나)가 있고(MutableUnitOfWork.java 29–43행), 부모 UnitOfWork 에는 identify()(이 일의 정체성), execute()(실제 실행), visitImplementations()(구현 클래스), visitMutableInputs()·visitImmutableInputs()(입력), visitOutputs()(출력) 같은 질문이 있습니다(UnitOfWork.java 37–93행). TaskExecution 은 이 질문마다 태스크에서 답을 꺼내 줍니다.
| 엔진의 질문 | TaskExecution 의 답 |
줄 |
|---|---|---|
identify() — 너는 누구냐 |
태스크 경로(task::getPath, 예: :render) |
172–175 |
visitImplementations() — 어떤 코드가 도나 |
태스크 클래스, 그리고 액션마다의 구현 | 331–339 |
visitMutableInputs() — 입력은 |
@Input 프로퍼티와 @InputFile·@InputDirectory 등 파일 프로퍼티 |
341–367 |
visitOutputs() — 출력은 |
@OutputFile·@OutputDirectory 프로퍼티, 로컬 상태, 지울 파일 |
369–389 |
getHistory() — 기록을 남기나 |
TaskExecutionMode 가 기록을 남기는 방식일 때만 실행 기록 저장소 |
319–324 |
getExecutionBehavior() — 증분 실행이 되나 |
액션 가운데 증분 액션(IncrementalTaskAction, InputChanges 를 받는 액션)이 있으면 INCREMENTAL |
463–471 |
execute() — 실행하라 |
태스크 액션을 차례로 부른다 | 177–206 |
입력과 출력은 8편에서 본 @Input·@OutputFile 어노테이션을 읽어 만든 TaskProperties 에서 옵니다(8편). 엔진은 어노테이션을 모릅니다. TaskExecution 이 "이름 붙은 입력 값"과 "이름 붙은 파일 묶음"으로 번역해서 넘깁니다. --info 의 Input property 'template' 에서 template 이 그 이름, 곧 프로퍼티 이름입니다.
왜 감싸나 — 같은 엔진을 타는 일들
이 커밋에서 MutableUnitOfWork·ImmutableUnitOfWork 를 구현하는 클래스를 git grep 으로 찾으면 태스크 말고도 여럿이 나옵니다.
| 작업 단위 | 종류 | 무엇 |
|---|---|---|
TaskExecution |
가변 | 태스크 하나 |
MutableTransformExecution |
가변 | 프로젝트가 만든 아티팩트에 거는 증분(InputChanges 를 받는) 아티팩트 변환 (platforms/software/dependency-management, DefaultTransformInvocationFactory.java 92–96행) |
ImmutableTransformExecution |
불변 | 그 밖의 아티팩트 변환 |
BuildScriptCompilationAndInstrumentation 의 하위 클래스 |
불변 | Groovy·Kotlin 빌드 스크립트 컴파일 (GroovyScriptClassCompiler, KotlinScriptEvaluator) |
DefaultDependenciesAccessors 안의 작업 단위 |
불변 | 버전 카탈로그 접근자 생성 |
Kotlin DSL 접근자 생성 (AccessorsClassPath.kt, Stage1BlocksAccessorClassPath.kt) |
불변 | tasks.compileJava 같은 접근자 코드 생성 |
1절의 첫 실행에서 Caching disabled for Kotlin DSL script compilation 과 Caching disabled for Kotlin DSL accessors 가 찍힌 것이 바로 이 표의 아래쪽 줄들이 엔진을 지나간 흔적입니다. 입력을 지문으로 만들고, 캐시 키를 계산하고, 이미 만든 결과를 다시 쓰는 일을 엔진이 한 번만 구현하고, 태스크·변환·스크립트 컴파일이 모두 그것을 씁니다. 엔진이 태스크를 모르는 이유입니다.
두 종류는 ChoosePipelineStep 에서 갈립니다.
@Override
public R execute(UnitOfWork work, C context) {
if (work instanceof ImmutableUnitOfWork) {
return immutablePipeline.execute(work, context);
} else if (work instanceof MutableUnitOfWork) {
return mutablePipeline.execute(work, context);
} else {
throw new AssertionError("Invalid work type: " + work.getClass().getName());
}
}
- 가변(mutable) 작업 단위는 "같은 자리(작업 공간)에서 여러 번 실행될 수 있고, 지난 실행의 출력을 다시 쓸 수 있는" 일입니다(
MutableUnitOfWork.java25–28행의 설명). 태스크의 출력 폴더build/는 실행할 때마다 덮어쓰이니 가변입니다. 그래서 "지난번과 같은가"를 물어야 하고, 실행 기록이 필요합니다. - 불변(immutable) 작업 단위는 "한 번에 통째로 실행되고, 그 출력을 불변 작업 공간에서 끝없이 다시 쓰는" 일입니다(
ImmutableUnitOfWork.java26행). 입력 전체로 정체성(identity)을 만들고 그 정체성 이름의 폴더에 결과를 둡니다. 같은 정체성의 폴더가 이미 있으면 그대로 씁니다(AssignImmutableWorkspaceStep). 입력이 바뀌면 정체성이 바뀌어 다른 폴더가 되니, "지난번과 비교"할 필요가 없습니다. 불변 파이프라인에SkipUpToDateStep대신NeverUpToDateStep이 있는 이유입니다.
태스크의 정체성이 입력과 상관없는 태스크 경로(:render)인 것도 이 차이에서 나옵니다. 태스크는 ":render 라는 자리"이고, 그 자리의 지난 기록과 지금을 비교합니다.
위 두 표와 ChoosePipelineStep 코드를 한 장으로 옮겼습니다. 회색 칸은 여러 작업 단위를 한데 묶은 것입니다.
헷갈리기 쉬운 곳: 태스크의 "작업 공간"은 없다
가변 파이프라인 첫 스텝 AssignMutableWorkspaceStep 은 작업 단위에게 작업 공간을 달라고 합니다. 그런데 TaskExecution 의 답은 null 입니다.
public <T> T withWorkspace(String path, WorkspaceAction<T> action) {
// TODO Use a better way to handle a no workspace situation for tasks
return action.executeInWorkspace(null);
}
subprojects/core/src/main/java/org/gradle/api/internal/tasks/execution/TaskExecution.java 312–315행
태스크는 출력 위치를 스스로(@OutputFile 로) 정하기 때문입니다. UnitOfWork.visitOutputs() 의 설명에도 "태스크에서는 지금 workspace 가 null"이라고 적혀 있습니다(UnitOfWork.java 83행). 작업 공간은 아티팩트 변환처럼 Gradle 이 결과 위치를 정하는 일을 위한 것입니다.
4. 스텝 사슬 — 스물다섯 겹의 양파
사슬은 생성자 안에서 한 번에 조립된다
엔진을 만드는 곳은 빌드 스코프의 서비스 묶음 subprojects/core/src/main/java/org/gradle/internal/service/scopes/ExecutionBuildServices.java 입니다(4편의 @Provides 팩토리 메서드입니다). 가변 파이프라인과 엔진 부분입니다.
Step<IdentityContext, WorkspaceResult> mutablePipeline =
new AssignMutableWorkspaceStep<>(
new HandleStaleOutputsStep<>(buildOperationRunner, buildOutputCleanupRegistry, deleter, outputChangeListener, outputFilesRepository,
new LoadPreviousExecutionStateStep<>(
new MarkSnapshottingInputsStartedStep<>(
new SkipEmptyMutableWorkStep(problemHandler, outputChangeListener, workInputListeners, skipEmptyWorkOutputsCleanerSupplier,
new CaptureMutableStateBeforeExecutionStep<>(buildOperationRunner, outputSnapshotter, overlappingOutputDetector,
new ValidateStep.Mutable<>(problemHandler,
new ResolveChangesStep<>(changeDetector,
new ResolveMutableCachingStateStep<>(buildCacheController, emitBuildCacheDebugLogging,
new MarkSnapshottingInputsFinishedStep<>(
new SkipUpToDateStep<>(
new StoreExecutionStateStep<>(
new BuildCacheStep<>(buildCacheController, deleter, fileSystemAccess, outputChangeListener,
new ResolveInputChangesStep<>(
new CaptureOutputsAfterExecutionStep<>(buildOperationRunner, buildId, outputSnapshotter, new OverlappingOutputsFilter(),
new BroadcastChangingOutputsStep<>(outputChangeListener,
new RemovePreviousOutputsStep<>(deleter, outputChangeListener,
new PreCreateOutputParentsStep<>(
new TimeoutStep<>(timeoutHandler, currentBuildOperationRef,
new CancelExecutionStep<>(cancellationToken,
new ExecuteStep.Mutable(buildOperationRunner
)))))))))))))))))))));
return new DefaultExecutionEngine(
new IdentifyStep<>(buildOperationRunner, classLoaderHierarchyHasher,
new IdentityCacheStep<>(buildOperationProgressEventEmitter,
new ExecuteWorkBuildOperationFiringStep<>(buildOperationRunner,
new ChoosePipelineStep<>(
immutablePipeline,
mutablePipeline
)))), problems);
new A(new B(new C(…))) 처럼 생성자 안에 생성자를 넣어, 위에서 아래로 읽는 순서가 그대로 실행 순서가 되게 했습니다(@formatter:off 로 자동 정렬을 꺼 두었습니다, 169행). 가변 파이프라인이 21개, 앞의 넷을 더해 태스크 하나가 지나는 스텝은 25개입니다. 불변 파이프라인(171–186행)은 14개입니다. 9.8.2 출시판의 같은 파일도 두 파이프라인과 앞의 넷이 이름과 순서까지 같았습니다.
스텝의 인터페이스는 메서드 하나입니다.
public interface Step<C extends Context, R extends Result> {
R execute(UnitOfWork work, C context);
}
DefaultExecutionEngine 은 이 사슬의 맨 앞(IdentifyStep)을 들고 있다가, 요청의 execute() 에서 pipeline.execute(work, createExecutionRequestContext()) 한 줄로 부를 뿐입니다(DefaultExecutionEngine.java 66–69행). 2절에서 forceNonIncremental() 로 실은 이유가 이 ExecutionRequestContext 에 들어갑니다.
스텝 21개를 하는 일로 묶었습니다. 빌드 스캔용 측정을 시작·끝내는 MarkSnapshottingInputs…Step 둘과 BroadcastChangingOutputsStep·PreCreateOutputParentsStep 은 그림에서 뺐습니다.
그림에서 보듯 사슬은 둘로 나뉩니다. ⑦까지는 "돌려야 하나"를 판단하는 앞 절반이고, ⑧부터는 "돌린다"로 정해진 뒤의 뒤 절반입니다. UP-TO-DATE 면 ⑦에서 돌아가므로 뒤 절반은 아예 불리지 않습니다. 반대로 실행 기록 저장(⑧)은 ⑦보다 안쪽에 있어서, 안쪽 스텝들이 다 끝나고 돌아오는 길에 기록을 씁니다.
왜 사슬인가 — 문맥이 한 겹씩 두꺼워진다
스텝이 다음 스텝에 넘기는 context 는 스텝을 지날 때마다 정보가 한 겹씩 붙은 새 타입이 됩니다.
화살표 옆의 이름이 그 문맥을 만드는 스텝입니다. 각 문맥은 부모 문맥을 물려받으므로 앞에서 붙은 정보를 모두 가집니다.
그리고 각 스텝은 자기가 받을 문맥의 타입을 제네릭으로 못 박습니다. LoadPreviousExecutionStateStep<C extends WorkspaceContext, …>, ResolveChangesStep<C extends MutableValidationFinishedContext, …>, SkipUpToDateStep<C extends MutableChangesContext> 처럼요(각 클래스의 선언 줄). 그래서 누군가 SkipUpToDateStep 을 ResolveChangesStep 보다 앞에 끼우려 하면, "바뀐 것" 정보가 없는 문맥을 넘기게 되어 컴파일이 되지 않습니다. 스텝의 순서를 주석이나 문서가 아니라 타입이 지킵니다.
이렇게 나눈 이점은 세 가지로 보입니다.
- 한 스텝은 한 가지만 안다.
SkipUpToDateStep은 89줄짜리 파일이고, "이유 목록이 비었나"만 봅니다. 이유를 어떻게 구했는지는 모릅니다. - 두 파이프라인이 스텝을 나눠 쓴다.
BuildCacheStep,CaptureOutputsAfterExecutionStep,TimeoutStep,CancelExecutionStep등은 가변·불변 파이프라인 양쪽에 같은 클래스로 들어 있습니다. - 돌아오는 길에 일할 수 있다. 실행 기록 저장(
StoreExecutionStateStep)과 "출력이 없으면 기록 지우기"(LoadPreviousExecutionStateStep38–44행)는delegate.execute()가 돌아온 뒤에 합니다. Tomcat 밸브의ErrorReportValve가 다음 밸브를 먼저 부르고 돌아와서 일하는 것과 같은 모양입니다.
이 이점들은 소스의 구조에서 읽은 것이고, Gradle 의 설계 문서가 이유를 이렇게 적어 둔 것은 아닙니다(architecture/ 의 문서에는 실행 엔진 스텝에 대한 설명이 따로 없었습니다).
예제 2: 태스크 액션에서 스텝 사슬 보기
정말 이 순서로 감싸여 있는지, 태스크 액션 안에서 StackWalker 로 호출 스택을 걸어 보았습니다. 같은 클래스의 연속된 프레임은 한 줄로 묶고, 바깥(스택의 아래)부터 찍습니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// ... (같은 파일의 Mirror 태스크는 7절에서 봅니다) ...
abstract class WhereAmI : DefaultTask() {
// ... @OutputFile 하나 ...
@TaskAction
fun run() {
marker.get().asFile.writeText("here\n")
val keep = Regex(""".*(Step|DefaultTaskNodeExecutor|DefaultExecutionEngine|TaskExecution|LocalTaskNode)""")
val frames = StackWalker.getInstance().walk { s -> s.toList() }.reversed()
// ... 같은 클래스의 연속된 프레임을 한 줄로 묶어 "번호 클래스 메서드 > 메서드" 로 찍는다 ...
}
}
[예제 ex/u10-input-changes/project/build.gradle.kts 에서 줄임]
> Task :whereAmI
thread: Execution worker
1 LocalTaskNode execute
2 DefaultTaskNodeExecutor execute > run > access > executeWithLifecycle > executeWithFallbackOperationId > executeCatchingFailures > executeSkippingOnlyIf > executeSkippingWithNoActions > executeFinalizingProperties > executeTrackingTaskIdentity > doExecute
3 DefaultExecutionEngine execute
4 IdentifyStep execute
5 IdentityCacheStep execute
6 ExecuteWorkBuildOperationFiringStep execute
7 ChoosePipelineStep execute
8 AssignMutableWorkspaceStep executeMutable
9 TaskExecution withWorkspace
10 AssignMutableWorkspaceStep executeMutable
11 HandleStaleOutputsStep executeMutable
12 LoadPreviousExecutionStateStep executeMutable
13 MarkSnapshottingInputsStartedStep execute
14 SkipEmptyMutableWorkStep executeMutable > executeWithNonEmptySources
15 CaptureMutableStateBeforeExecutionStep execute > executeMutable
16 ValidateStep execute > executeDelegate
17 ResolveChangesStep executeMutable
18 AbstractResolveCachingStateStep execute
19 ResolveMutableCachingStateStep executeDelegate
20 MarkSnapshottingInputsFinishedStep execute
21 SkipUpToDateStep execute > executeBecause
22 StoreExecutionStateStep executeMutable
23 BuildCacheStep execute > executeWithoutCache
24 ResolveInputChangesStep executeMutable
25 CaptureOutputsAfterExecutionStep execute
26 BroadcastChangingOutputsStep execute
27 RemovePreviousOutputsStep executeMutable
28 PreCreateOutputParentsStep execute
29 TimeoutStep execute > executeWithoutTimeout
30 CancelExecutionStep execute
31 ExecuteStep execute > call > access > executeInternal
32 TaskExecution execute > executeWithPreviousOutputFiles > executeActions > executeAction > run
- 첫 줄의 스레드 이름은 실행마다 다릅니다(다시 돌렸을 때는
Execution worker Thread 8이었습니다). 9편에서 본DefaultPlanExecutor의 워커 스레드입니다. - 4~31번이
ExecutionBuildServices188–219행의 순서와 그대로 같습니다. 앞의 넷(4~7번), 그리고 가변 파이프라인 21개가 모두 스택에 있습니다. 실행할 때는 모든 스텝이 아직 끝나지 않은 채 태스크 액션 아래에 깔려 있다는 뜻입니다. 양파 껍질입니다. - 9번
TaskExecution.withWorkspace는 3절의 "작업 공간 없음" 콜백입니다.AssignMutableWorkspaceStep이 작업 단위에게 작업 공간을 묻고, 작업 단위가 그 안에서 다음 스텝을 부르므로 이렇게 끼어 보입니다. - 18·19번은 한 스텝입니다.
ResolveMutableCachingStateStep이 부모AbstractResolveCachingStateStep의execute()를 물려받아 쓰기 때문입니다. 마찬가지로 많은 스텝의execute()는 공통 부모MutableStep이 작업 단위를MutableUnitOfWork로 바꿔executeMutable()을 부르는 것인데, 출력에서는 뺐습니다. - 21번
SkipUpToDateStep에executeBecause가 있습니다. "이유가 있어서 실행한다"는 쪽으로 갔다는 것입니다(5절). 23번BuildCacheStep은 캐시가 꺼져 있어executeWithoutCache로 지나갔습니다.
헷갈리기 쉬운 곳: 스텝의 수를 셀 때
2편의 예제는 같은 자리의 스택에서 "이름이 Step 으로 끝나는 클래스가 27가지"라고 셌습니다. 이 글의 "25개"와 다른 것은 세는 대상이 달라서입니다. 2편은 스택에 보인 클래스 이름을 셌으므로 공통 부모 MutableStep 과 AbstractResolveCachingStateStep 이 더해져 27입니다. 이 글은 ExecutionBuildServices 에서 new 로 만든 스텝 객체를 셌습니다(4 + 21 = 25). 둘 다 맞는 숫자입니다.
5. UP-TO-DATE 판단 — 기록 읽기, 지금 찍기, 비교, 건너뛰기
이제 사슬에서 UP-TO-DATE 에 직접 관여하는 다섯 스텝을 차례로 봅니다.
가운데의 다른 스텝들(빈 입력 확인, 검사, 캐시 키 계산)은 그림에서 뺐습니다.
① 지난 기록 읽기 — LoadPreviousExecutionStateStep
@Override
protected R executeMutable(MutableUnitOfWork work, C context) {
Identity identity = context.getIdentity();
PreviousExecutionState previousExecutionState = work.getHistory()
.flatMap(history -> history.load(identity.getUniqueId()))
.orElse(null);
R result = delegate.execute(work, new PreviousExecutionContext(context, previousExecutionState));
// If we did not capture any outputs after execution, remove them from history
work.getHistory()
.ifPresent(history -> {
if (!result.getAfterExecutionOutputState().isPresent()) {
history.remove(context.getIdentity().getUniqueId());
}
});
return result;
}
열쇠는 3절의 정체성, 곧 태스크 경로입니다. work.getHistory() 가 비어 있으면(출력 선언이 없거나 doNotTrackState) 읽지도 않습니다. 2절의 noOutputs 가 매번 도는 이유가 여기서 한 번 더 보입니다.
기록 저장소는 DefaultExecutionHistoryStore 이고, 디스크 쪽은 DefaultExecutionHistoryCacheAccess 가 executionHistory 라는 이름의 캐시로 엽니다(DefaultExecutionHistoryCacheAccess.java 28–34행). ExecutionBuildServices 가 이것을 빌드 스코프 캐시 팩토리(BuildScopedCacheBuilderFactory, 101–103행)로 만들기 때문에 위치는 사용자 홈이 아니라 그 빌드의 .gradle/<판>/ 입니다. 그 안에서 executionHistory 라는 이름의 색인 캐시(DefaultExecutionHistoryStore.java 58–61행)를 씁니다. 예제 1을 마친 뒤의 .gradle 입니다.
=== where the history lives ===
.gradle
.gradle/9.10.0-20261107025045+0000
.gradle/9.10.0-20261107025045+0000/checksums
.gradle/9.10.0-20261107025045+0000/executionHistory
...
.gradle/9.10.0-20261107025045+0000/fileHashes
...
.gradle/buildOutputCleanup
...
total 16
-rw-r--r-- 1 root root 18897 Nov 7 05:01 executionHistory.bin
-rw-r--r-- 1 root root 17 Nov 7 05:01 executionHistory.lock
(... 자리의 폴더·파일 열 줄은 줄였습니다. 마지막 두 줄이 executionHistory/ 안입니다. 파일 시각은 실행마다 다릅니다.)
판 폴더의 이름이 9.10.0-20261107025045+0000 인 것은 개발판이라 판 번호에 빌드한 시각이 붙어서입니다. 출시판이면 9.10.0 같은 이름이 됩니다. 폴더가 판마다 다르므로, Gradle 판을 올리면 첫 빌드에서는 지난 기록을 찾지 못해 실행 기록을 쓰는 태스크가 모두 다시 돕니다(빌드 캐시가 켜져 있으면 캐시에서 가져올 수는 있습니다, 11편). fileHashes 는 6절에서 봅니다.
② 지금 상태 찍기 — CaptureMutableStateBeforeExecutionStep
다음 스텝은 지금의 입력과 출력을 찍어 BeforeExecutionState 를 만듭니다. 이 스텝도 work.getHistory().isPresent() 일 때만 찍습니다(71–73행). 찍는 것은 세 가지입니다(115–139행).
- 구현: 태스크 클래스와 액션의 클래스로더 해시 (
IdentifyStep이 미리 구해 둔 것) - 입력:
@Input값의 스냅숏(ValueSnapshot)과 입력 파일의 지문 —work.getInputFingerprinter().fingerprintInputProperties(…) - 출력: 출력 위치의 파일 스냅숏 —
outputSnapshotter.snapshotOutputs(…)
이 일은 Snapshot inputs and outputs before executing task ':render' 라는 빌드 오퍼레이션으로 감싸여 있습니다(98–100행). 파일을 실제로 어떻게 찍는지는 6절에서 봅니다.
③ 비교 — ResolveChangesStep 과 DefaultExecutionStateChangeDetector
private ExecutionStateChanges resolveExecutionStateChanges(MutableUnitOfWork work, MutableValidationFinishedContext context, BeforeExecutionState beforeExecution) {
IncrementalInputProperties incrementalInputProperties = createIncrementalInputProperties(work);
return context.getNonIncrementalReason()
.map(ImmutableList::of)
.map(nonIncrementalReason -> nonIncremental(nonIncrementalReason, beforeExecution, incrementalInputProperties))
.orElseGet(() -> context.getPreviousExecutionState()
.map(previousExecution -> context.getValidationProblems().isEmpty()
? changeDetector.detectChanges(work, previousExecution, beforeExecution, incrementalInputProperties, context.getDetectedOverlappingOutputs().isPresent())
: nonIncremental(VALIDATION_FAILED, beforeExecution, incrementalInputProperties)
)
.orElseGet(() -> nonIncremental(NO_HISTORY, beforeExecution, incrementalInputProperties))
);
}
Optional 사슬이라 읽기 어렵지만, 순서대로 풀면 "다시 돌릴 이유"를 정하는 우선순위입니다.
- 2절에서 실어 보낸 강제 이유가 있으면 그것 (
Executed with '--rerun-tasks'.등) - 지난 기록이 있고 검사 문제가 없으면 →
changeDetector.detectChanges(…)로 진짜 비교 - 지난 기록은 있는데 검사 문제가 있으면 →
Incremental execution has been disabled to ensure correctness. …(37행) - 지난 기록이 없으면 →
No history is available.(35행)
지금 상태 자체를 찍지 않았으면(기록을 남기지 않는 태스크) 2절의 이유, 없으면 Change tracking is disabled.(36행)가 이유가 됩니다(56–61행).
진짜 비교는 DefaultExecutionStateChangeDetector.detectChanges() 가 합니다. 지난 상태와 지금 상태에서 항목별로 "바뀐 것 목록"을 만들고, 하나로 묶습니다.
// Collect changes that would trigger a rebuild
ChangeContainer rebuildTriggeringChanges = errorHandling(executable, new SummarizingChangeContainer(
previousSuccessState,
implementationChanges,
inputPropertyChanges,
inputPropertyValueChanges,
outputFilePropertyChanges,
outputFileChanges,
inputFilePropertyChanges,
nonIncrementalInputFileChanges
));
ImmutableList<String> rebuildReasons = collectChanges(rebuildTriggeringChanges);
묶은 순서가 곧 --info 에 이유가 찍히는 순서입니다. 각각이 만드는 문장은 그 클래스에 그대로 적혀 있습니다.
| 순서 | 비교하는 것 | 문장의 모양 (출처) |
|---|---|---|
| 1 | 지난번에 성공했나 | Task has failed previously. (PreviousSuccessChanges 20행) |
| 2 | 구현 클래스·클래스패스 | The type of … has changed from '…' to '…'., Class path of … has changed from … to …. (ImplementationChanges 48·53행) |
| 3 | @Input 프로퍼티가 생기거나 없어졌나 |
Input property '…' has been added/removed for … (PropertyChanges 50·53행) |
| 4 | @Input 값 |
Value of input property '…' has changed for … (InputValueChanges 54행) |
| 5·6 | 출력 프로퍼티, 출력 파일 | Output property '…' file … has been removed. 등 (OutputFileChanges 60행, DefaultFileChange 58행) |
| 7·8 | 입력 파일 프로퍼티, 입력 파일 | Input property '…' file … has changed. 등 |
collectChanges() 는 MessageCollectingChangeVisitor 에 MAX_OUT_OF_DATE_MESSAGES 를 넘겨 이유를 모읍니다(127–131행). 그 값이 3입니다(ExecutionStateChangeDetector.java 27행). 이유가 백 개여도 --info 에는 세 줄만 찍힙니다. 6절의 예제에서 실제로 세 줄에서 끊기는 것을 봅니다.
이유가 하나라도 있으면 "증분이 아닌" 변경으로 돌려줍니다(105–110행). 이유가 없을 때만 증분 입력의 바뀐 파일 목록을 따로 모읍니다(111–123행). 7절의 InputChanges 가 그 목록입니다.
노란 칸은 묻는 곳, 빨간 칸은 실행으로 가는 이유입니다. ①~⑧은 위 표의 순서이고 --info 에 찍히는 순서이기도 합니다.
④ 건너뛰기 — SkipUpToDateStep
@Override
public UpToDateResult execute(UnitOfWork work, C context) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Determining if {} is up-to-date", work.getDisplayName());
}
ImmutableList<String> reasons = context.getRebuildReasons();
return context.getChanges()
.filter(__ -> reasons.isEmpty())
.map(changes -> skipExecution(work, context))
.orElseGet(() -> executeBecause(work, reasons, context));
}
판단은 한 줄입니다. 바뀐 것을 계산했고(getChanges() 가 있음) 이유 목록이 비었으면 건너뛴다. 건너뛸 때(skipExecution())는 Skipping … as it is up-to-date. 를 찍고(58행), delegate 를 부르지 않은 채 지난 실행의 출력 상태를 그대로 결과로 돌려줍니다(61–70행). 실행할 때(executeBecause())만 delegate.execute() 를 부릅니다(73–77행). 그 직전에 logExecutionReasons() 가 Task ':render' is not up-to-date because: 와 그 아래 이유들을 찍습니다(79–88행). --info 의 두 문장이 모두 이 파일에서 나옵니다.
⑤ 돌아오는 길에 기록 쓰기 — StoreExecutionStateStep
@Override
protected R executeMutable(MutableUnitOfWork work, C context) {
R result = delegate.execute(work, context);
work.getHistory()
.ifPresent(history -> context.getCachingState().getCacheKeyCalculatedState()
.flatMap(cacheKeyCalculatedState -> result.getAfterExecutionOutputState()
.filter(afterExecutionState -> result.getExecution().isSuccessful() || shouldPreserveFailedState(context, afterExecutionState))
.map(executionOutputState -> new DefaultAfterExecutionState(
((BuildCacheKeyInternal) cacheKeyCalculatedState.getKey()).getHashCodeInternal(),
cacheKeyCalculatedState.getBeforeExecutionState(),
executionOutputState
)))
.ifPresent(afterExecutionState -> history.store(
context.getIdentity().getUniqueId(),
// TODO: Encode the "no cache key available" case in the context type hierarchy
afterExecutionState)));
return result;
}
첫 줄에서 먼저 안쪽을 다 돌리고, 돌아와서 저장합니다. 저장하는 것은 "실행 전에 찍은 입력 상태 + 실행 뒤에 찍은 출력 상태 + 캐시 키"입니다. 다음 빌드의 ①이 이것을 읽습니다. 두 가지를 눈여겨볼 만합니다.
- 캐시 키가 계산되어 있어야 저장합니다(48행). 빌드 캐시를 쓰지 않아도 캐시 키는 계산됩니다. 캐시 키를 계산하는
AbstractResolveCachingStateStep은 캐시가 꺼져 있으면 "꺼진 이유"에Build cache is disabled를 더할 뿐, 키 계산 자체는 합니다(78–89행). 1절 출력의Caching disabled … because: Build cache is disabled가 그 이유 목록입니다. - 실패한 실행은 원칙적으로 저장하지 않습니다. 예외는 실패했는데 출력이 바뀐 경우입니다(50행, 63–73행. 지난 기록이 없으면 출력이 바뀐 것으로 칩니다). 주석의 설명이 좋습니다. 컴파일이 실패해서 출력이 그대로라면 기록을 남기지 않아야, 소스를 고친 다음 빌드가 "고친 파일 하나만" 증분으로 처리할 수 있다는 것입니다.
예제 1 이어서: 무엇을 바꾸면 어떤 이유가 나오나
1절의 render 를 같은 데몬으로 다섯 번 더 돌렸습니다. 매번 하나씩 건드립니다.
=== touch the template (timestamp only) ===
...
> Task :render UP-TO-DATE
...
Skipping task ':render' as it is up-to-date.
BUILD SUCCESSFUL in 1s
1 actionable task: 1 up-to-date
=== change the template content ===
...
> Task :render
...
Task ':render' is not up-to-date because:
Input property 'template' file /tmp/p/src/template.txt has changed.
render: wrote greeting.txt
=== change the @Input value (-Pgreeting=Hi) ===
...
Task ':render' is not up-to-date because:
Value of input property 'greeting' has changed for task ':render'
render: wrote greeting.txt
=== delete the output file ===
...
Task ':render' is not up-to-date because:
Output property 'outputFile' file /tmp/p/build/out/greeting.txt has been removed.
render: wrote greeting.txt
=== --rerun-tasks ===
...
Task ':render' is not up-to-date because:
Executed with '--rerun-tasks'.
render: wrote greeting.txt
(매번 같은 Included projects·Evaluating·Caching disabled 줄을 ... 로 줄였습니다. -Pgreeting=Hi 부터의 세 블록은 첫 ... 가 > Task :render 줄까지 덮고, 템플릿 내용을 바꾼 블록부터는 끝의 BUILD SUCCESSFUL in 1s·1 actionable task: 1 executed 두 줄도 뺐습니다. 걸린 시간은 실행마다 다릅니다.)
| 건드린 것 | 이유 문장 | 만든 곳 |
|---|---|---|
touch 로 수정 시각만 바꿈 |
(없음 — UP-TO-DATE) |
6절 |
| 템플릿 파일 내용 | Input property 'template' file … has changed. |
입력 파일 지문 비교 |
-Pgreeting=Hi |
Value of input property 'greeting' has changed for task ':render' |
InputValueChanges |
| 출력 파일 삭제 | Output property 'outputFile' file … has been removed. |
OutputFileChanges |
--rerun-tasks |
Executed with '--rerun-tasks'. |
2절의 TaskExecutionMode → ResolveChangesStep |
출력 파일을 지웠을 때 다시 도는 것이 중요합니다. Gradle 은 입력만 보는 것이 아니라 지난번에 만든 출력이 지금도 그대로 있는지도 봅니다. 누가 build/ 아래 파일을 손으로 고치거나 지우면 다시 만듭니다.
헷갈리기 쉬운 곳: 액션이 돌아도 UP-TO-DATE 일 수 있다
2절의 nothingToDo 는 첫 실행에서 액션이 돌았는데도 UP-TO-DATE 로 찍혔습니다. 결과를 정하는 ExecuteStep.Mutable 을 보면 이유가 있습니다.
@Override
protected ExecutionOutcome determineOutcome(InputChangesContext context, WorkOutput workOutput) {
switch (workOutput.getDidWork()) {
case DID_NO_WORK:
return UP_TO_DATE;
case DID_WORK:
return context.getInputChanges()
.filter(InputChanges::isIncremental)
.map(Functions.constant(EXECUTED_INCREMENTALLY))
.orElse(EXECUTED_NON_INCREMENTALLY);
default:
throw new AssertionError();
}
}
액션이 didWork = false 로 "한 일이 없다"고 알리면(TaskExecution.java 220행에서 DID_NO_WORK 가 됩니다) 결과는 UP_TO_DATE 입니다. 그래서 콘솔의 UP-TO-DATE 는 세 곳에서 나옵니다. 액션 없는 태스크(2절), SkipUpToDateStep(이 절), 그리고 액션이 스스로 한 일이 없다고 한 경우입니다. 셋째는 "실행 기록과 같아서 건너뛰었다"가 아닙니다. 액션이 실제로 돌았지만 스스로 "바꾼 것이 없다"고 알렸을 때의 표시입니다.
6. 파일 지문과 VFS — 무엇을 "같다"고 보나
touch 해도 다시 돌지 않는 이유
5절 예제의 첫 줄에서, 수정 시각만 바꾼 파일은 바뀐 것으로 치지 않았습니다. 지문에 들어가는 것이 파일의 내용 해시이기 때문입니다. VFS 가 들고 있는 파일 스냅숏 RegularFileSnapshot 은 절대 경로, 이름, 내용 해시, 메타데이터를 가지는데(RegularFileSnapshot.java 31–39행), 지문을 만들 때는 그 가운데 경로(의 일부)와 해시만 씁니다. 기본 전략 AbsolutePathFingerprintingStrategy 는 파일마다 new DefaultFileSystemLocationFingerprint(절대 경로, 종류, 내용 해시) 를 만들어 모읍니다(AbsolutePathFingerprintingStrategy.java 51–67행). 수정 시각은 들어가지 않습니다.
그렇다고 매번 파일을 다 읽어 해시를 내지는 않습니다. 해시를 내는 CachingFileHasher 는 크기와 수정 시각이 지난번과 같으면 저장해 둔 해시를 그대로 씁니다.
private FileInfo snapshot(File file, long length, long timestamp) {
String absolutePath = file.getAbsolutePath();
if (timestampInspector.timestampCanBeUsedToDetectFileChange(absolutePath, timestamp)) {
FileInfo info = cache.getIfPresent(absolutePath);
if (info != null && length == info.length && timestamp == info.timestamp) {
return info;
}
}
HashCode hash = delegate.hash(file);
// ... 새 해시를 크기·시각과 함께 저장하고 돌려준다 ...
}
이 캐시의 이름이 "fileHashes" 이고, 프로젝트 파일의 해시는 빌드 세션 스코프에서 만드는 것(VirtualFileSystemServices.java 341행)이 빌드 트리의 캐시 폴더에 둡니다. 5절의 .gradle/<판>/fileHashes 폴더가 그것입니다. (~/.gradle/caches/ 처럼 Gradle 이 관리하는 전역 캐시 위치의 파일은 사용자 홈 스코프의 같은 이름 캐시(174행)가 맡고, 둘을 SplitFileHasher 가 나눕니다, 342행.) 정리하면 수정 시각은 "다시 해시를 낼까"를 정하는 데만 쓰고, "바뀌었나"는 해시로 정합니다. touch 하면 수정 시각이 달라져 해시를 다시 내지만, 내용이 같으니 해시도 같고, 그래서 UP-TO-DATE 입니다.
@PathSensitive — 경로를 얼마나 볼까
앞에서 본 기본 전략은 지문에 넣는 경로로 절대 경로를 썼습니다. 이것이 기본값입니다. 어노테이션이 없으면 null 이었던 정규화 방식이 여기서 ABSOLUTE_PATH 로 채워집니다.
public static FileNormalizer normalizerOrDefault(@Nullable FileNormalizer fileNormalizer) {
// If this default is ever changed, ensure the documentation on PathSensitive is updated as well as this guide:
// https://docs.gradle.org/current/userguide/build_cache_concepts.html#relocatability
return fileNormalizer == null
? InputNormalizer.ABSOLUTE_PATH
: fileNormalizer;
}
@PathSensitive 를 달면 그 값이 정규화 방식으로 바뀝니다. NONE → IGNORE_PATH, NAME_ONLY → NAME_ONLY, RELATIVE → RELATIVE_PATH, ABSOLUTE → ABSOLUTE_PATH 입니다(InputNormalizer.java 42–55행).
RELATIVE_PATH 에 해당하는 RelativePathFingerprintingStrategy 는 입력 디렉터리 안에서의 상대 경로를 지문의 경로로 씁니다. 설명 주석대로 "루트 디렉터리의 이름은 무시하고, 루트가 파일이면 파일 이름을 쓴다"입니다(RelativePathFingerprintingStrategy.java 36–40행, 64–73행).
빨간 상자와 초록 상자가 아래 예제 3의 결과입니다.
예제 3: 입력 디렉터리를 옮기면
같은 입력 디렉터리를 읽는 태스크 둘을 만들었습니다. 하나는 @PathSensitive 없이, 하나는 @PathSensitive(RELATIVE) 로.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// ... 부모 클래스 CountFiles 는 줄였다 ...
// @PathSensitive 가 없다 → 기본값 ABSOLUTE (절대 경로까지 지문에 들어간다)
abstract class CountAbsolute : CountFiles() {
@get:InputDirectory
abstract val inputDir: DirectoryProperty
}
// @PathSensitive(RELATIVE) → 입력 디렉터리 안에서의 상대 경로만 지문에 들어간다
abstract class CountRelative : CountFiles() {
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val inputDir: DirectoryProperty
}
val dirName = providers.gradleProperty("dir").orElse("data")
// ... countAbs, countRel 을 등록하고 inputDir = layout.projectDirectory.dir(dirName) ...
[예제 ex/u10-path-sensitivity/project/build.gradle.kts 에서 줄임 — 부모 CountFiles 는 파일 수를 세어 @OutputFile 에 쓴다]
data/ 에는 a.txt 와 sub/b.txt 가 있습니다. 먼저 -Pdir=data 로 돌리고, data/ 를 moved/ 로 그대로 복사한 뒤 -Pdir=moved 로 돌렸습니다.
=== 1st run with -Pdir=data ===
> Task :countAbs
Task ':countAbs' is not up-to-date because:
No history is available.
countAbs: counted 2 files in data
> Task :countRel
Task ':countRel' is not up-to-date because:
No history is available.
countRel: counted 2 files in data
BUILD SUCCESSFUL in 26s
=== copy data/ to moved/ and run with -Pdir=moved ===
> Task :countAbs
Task ':countAbs' is not up-to-date because:
Input property 'inputDir' file /tmp/p/moved/a.txt has been added.
Input property 'inputDir' file /tmp/p/moved/sub/b.txt has been added.
Input property 'inputDir' file /tmp/p/data/a.txt has been removed.
countAbs: counted 2 files in moved
> Task :countRel UP-TO-DATE
Skipping task ':countRel' as it is up-to-date.
BUILD SUCCESSFUL in 4s
countAbs는 다시 돌았습니다. 지문에 절대 경로가 들어가니moved/…는 "생긴 파일",data/…는 "없어진 파일"입니다. 내용은 하나도 바뀌지 않았는데도요.- 이유가 세 줄에서 끊겼습니다. 없어진
data/sub/b.txt도 바뀐 것인데 찍히지 않았습니다. 5절의MAX_OUT_OF_DATE_MESSAGES = 3입니다. countRel은UP-TO-DATE입니다. 상대 경로(a.txt,sub/b.txt)와 내용이 같으니 지문이 같습니다.
(걸린 시간은 실행마다 다릅니다.)
@PathSensitive(RELATIVE) 가 중요한 것은 11편의 빌드 캐시 때문입니다. 캐시 키도 이 지문으로 만들므로, 절대 경로가 들어가면 다른 폴더에 받은 같은 프로젝트(다른 개발자의 PC, CI)가 캐시를 나눠 쓰지 못합니다. 이 글의 범위에서는 "같은 PC 에서도 폴더를 옮기면 다시 돈다"까지만 확인했습니다.
VFS — 같은 파일을 두 번 읽지 않기
입력 파일을 찍는 DefaultFileCollectionSnapshotter 는 파일 묶음의 뿌리마다 fileSystemAccess.read(…) 를 부를 뿐입니다(DefaultFileCollectionSnapshotter.java 60–81행). 실제 일은 DefaultFileSystemAccess 가 합니다.
private <T> T readSnapshotFromLocation(
String location,
Function<FileSystemLocationSnapshot, T> snapshotProcessor,
Supplier<T> readFromDisk
) {
return virtualFileSystem.findSnapshot(location)
.map(snapshotProcessor)
// Avoid snapshotting the same location concurrently
// This is only a performance optimization for a common scenario; the VFS handles its own concurrency
.orElseGet(() -> producingSnapshots.guardByKey(location,
() -> virtualFileSystem.findSnapshot(location)
.map(snapshotProcessor)
.orElseGet(readFromDisk)
));
}
VFS 에 이미 그 위치의 스냅숏이 있으면 디스크를 보지 않고 그것을 씁니다. 없을 때만 디스크를 읽어 스냅숏을 만들고 VFS 에 넣습니다. 같은 위치를 여러 스레드가 동시에 찍지 않도록 위치별 잠금(guardByKey)도 겁니다. 한 태스크의 출력이 다음 태스크의 입력이 되는 일이 흔하니, 이것만으로도 한 빌드 안에서 같은 디렉터리를 여러 번 훑는 일이 줄어듭니다.
반대로 태스크가 파일을 쓰면 그 위치의 스냅숏은 틀린 것이 됩니다. 그래서 write() 는 쓰기 전에 invalidate() 로 그 위치를 VFS 에서 지웁니다(DefaultFileSystemAccess.java 208–218행).
파일 감시 — 빌드와 빌드 사이
그러면 빌드가 끝난 뒤에는요? 다음 빌드 전에 사용자가 편집기에서 파일을 고칠 수 있으니, 원래는 VFS 를 버려야 맞습니다. 파일 감시(file system watching)가 켜져 있으면 버리지 않습니다. 운영체제의 파일 변경 알림을 받아, 바뀐 위치만 지우고 나머지는 다음 빌드까지 들고 갑니다. 그 구현이 platforms/core-execution/file-watching/src/main/java/org/gradle/internal/watch/vfs/impl/WatchingVirtualFileSystem.java 의 afterBuildStarted()(115행~)와 beforeBuildFinished()(220행~)입니다. 감시가 꺼져 있으면 빌드가 끝날 때 VFS 를 비웁니다(currentRoot.empty(), 293행). 기본값은 WatchMode.DEFAULT, 곧 "지원되는 파일 시스템이면 켬"입니다(StartParameterInternal.java 39행, WatchMode.java 29행).
-Dorg.gradle.vfs.verbose=true 를 주면 이 과정을 로그로 볼 수 있습니다(StartParameterBuildOptions.java 447행). 예제 1과 같은 빌드 스크립트로 --watch-fs 를 켜고 세 번 돌렸습니다(ex/u10-vfs).
=== 1st run (--watch-fs) ===
> Task :render
BUILD SUCCESSFUL in 31s
Received 3 file system events during the current build while watching 1 locations
Virtual file system retains information about 70 files, 16 directories and 3 missing files until next build
=== 2nd run (--watch-fs, same daemon) ===
Received 1 file system events since last build while watching 1 locations
Virtual file system retained information about 70 files, 16 directories and 3 missing files since last build
> Task :render UP-TO-DATE
BUILD SUCCESSFUL in 1s
Received 0 file system events during the current build while watching 1 locations
Virtual file system retains information about 71 files, 17 directories and 3 missing files until next build
=== change the template between builds ===
Received 2 file system events since last build while watching 1 locations
Virtual file system retained information about 70 files, 17 directories and 3 missing files since last build
> Task :render
BUILD SUCCESSFUL in 1s
Received 0 file system events during the current build while watching 1 locations
Virtual file system retains information about 71 files, 17 directories and 3 missing files until next build
- 첫 빌드가 끝날 때 "다음 빌드까지 파일 70개, 디렉터리 16개, 없는 파일 3개의 정보를 들고 간다"고 합니다. 데몬 메모리에 남은 VFS 입니다.
- 두 번째 빌드는 시작할 때 "지난 빌드 뒤로 받은 이벤트"와 "들고 온 정보"를 알리고,
render는UP-TO-DATE입니다. 입력 파일을 다시 디스크에서 찍지 않고 VFS 의 스냅숏으로 비교했을 것입니다(이 부분은 로그로 직접 보이지는 않아, 소스의 흐름으로 말한 것입니다). - 빌드 사이에 템플릿을 고친 세 번째 빌드에서는 이벤트를 받았고, 들고 온 파일 수가 하나 줄었습니다. 고친 파일의 스냅숏이 지워졌다는 뜻입니다. 그래서
render가 다시 돌았습니다.
두 번째 빌드는 아무것도 고치지 않았는데도 이벤트를 1개 받았습니다. 무엇에 대한 이벤트였는지는 확인하지 않았습니다. 이벤트 수와 파일 수, 걸린 시간은 실행마다 다를 수 있습니다. 이 예제는 리눅스 컨테이너 안에서 돌렸고, 다른 운영체제에서의 동작은 확인하지 않았습니다.
헷갈리기 쉬운 곳: @PathSensitive 는 입력 파일에만 의미가 있다
출력 파일은 정규화하지 않습니다. 출력 쪽 비교(OutputFileChanges)는 지난 실행 뒤 찍은 출력 스냅숏과 지금의 스냅숏을 절대 경로로 맞춰 봅니다(OutputFileChanges.java 35–64행). 그리고 @PathSensitive 를 읽어 정규화 방식으로 바꾸는 곳도 입력 파일 어노테이션을 처리하는 AbstractInputFilePropertyAnnotationHandler 입니다(63–77행). 또 Java 소스를 받는 SourceTask 는 일부러 @PathSensitive(PathSensitivity.ABSOLUTE) 를 달아 두었습니다(SourceTask.java 84행). 기본값이라 생략해도 같지만, 명시해 두었습니다.
7. 증분 태스크 — InputChanges 로 바뀐 파일만
여기까지는 "다 돌리거나, 안 돌리거나"였습니다. 입력 파일이 천 개인데 하나만 바뀌었다면, 태스크가 그 하나만 처리하게 할 수도 있습니다. 태스크 액션이 InputChanges 를 인자로 받으면 됩니다. 3절의 표대로, 그런 액션이 있으면 getExecutionBehavior() 가 INCREMENTAL 이 됩니다.
증분 실행을 정하는 곳은 가변 파이프라인의 ResolveInputChangesStep 입니다.
@Nullable
private static InputChangesInternal determineInputChanges(MutableUnitOfWork work, MutableChangesContext context) {
if (work.getExecutionBehavior() == NON_INCREMENTAL) {
return null;
}
ExecutionStateChanges changes = context.getChanges()
.orElseThrow(() -> new IllegalStateException("Changes are not tracked, unable determine incremental changes."));
InputChangesInternal inputChanges = changes.createInputChanges();
if (!inputChanges.isIncremental()) {
LOGGER.info("The input changes require a full rebuild for incremental {}.", work.getDisplayName());
}
return inputChanges;
}
changes 는 5절의 ResolveChangesStep 이 만든 것입니다. 5절에서 본 대로 DefaultExecutionStateChangeDetector 는 "다시 돌릴 이유"가 하나라도 있으면 증분이 아닌 변경을, 없을 때만 증분 입력 파일의 바뀐 목록을 돌려줍니다. 여기서 헷갈리기 쉬운 것이 있습니다. @Incremental 이 붙은 입력 파일의 변경은 "다시 돌릴 이유"에 들어가지 않습니다. 입력 파일 가운데 이유 목록에 들어가는 것은 증분이 아닌 입력 파일의 변경(nonIncrementalInputFileChanges, 73–76행)뿐입니다(출력 변경 등 다른 항목은 그대로 들어갑니다). 그래서 증분 입력만 바뀌면 이유 목록에는 그 파일들이 찍히지만(collectChanges(incrementalInputFileChanges), 118행) 실행은 증분으로 갑니다.
예제 4: 바뀐 파일만 처리하는 태스크
src/ 의 파일을 대문자로 바꿔 build/mirror/ 에 두는 태스크입니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// InputChanges 로 바뀐 파일만 처리하는 증분 태스크: src/ 의 파일을 대문자로 바꿔 build/mirror/ 에 둔다
abstract class Mirror : DefaultTask() {
@get:Incremental
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputDirectory
abstract val sourceDir: DirectoryProperty
@get:OutputDirectory
abstract val targetDir: DirectoryProperty
@TaskAction
fun mirror(changes: InputChanges) {
println("mirror: incremental = ${changes.isIncremental}")
changes.getFileChanges(sourceDir).forEach { change ->
if (change.fileType == FileType.DIRECTORY) return@forEach
println("mirror: ${change.changeType} ${change.normalizedPath}")
val target = targetDir.file(change.normalizedPath).get().asFile
if (change.changeType == ChangeType.REMOVED) {
target.delete()
} else {
target.writeText(change.file.readText().uppercase())
}
}
}
}
[예제 ex/u10-input-changes/project/build.gradle.kts 에서 줄임]
처음 실행하고, a.txt 를 고치고 c.txt 를 더하고 b.txt 를 지운 뒤 다시, 마지막으로 출력 파일 하나를 지우고 또 돌렸습니다.
=== 1st run ===
> Task :mirror
Task ':mirror' is not up-to-date because:
No history is available.
The input changes require a full rebuild for incremental task ':mirror'.
mirror: incremental = false
mirror: ADDED a.txt
mirror: ADDED b.txt
BUILD SUCCESSFUL in 59s
=== modify a.txt, add c.txt, remove b.txt ===
> Task :mirror
Task ':mirror' is not up-to-date because:
Input property 'sourceDir' file /tmp/p/src/a.txt has changed.
Input property 'sourceDir' file /tmp/p/src/b.txt has been removed.
Input property 'sourceDir' file /tmp/p/src/c.txt has been added.
mirror: incremental = true
mirror: MODIFIED a.txt
mirror: REMOVED b.txt
mirror: ADDED c.txt
BUILD SUCCESSFUL in 2s
a.txt
c.txt
=== delete an output file ===
> Task :mirror
Task ':mirror' is not up-to-date because:
Output property 'targetDir' file /tmp/p/build/mirror/a.txt has been removed.
The input changes require a full rebuild for incremental task ':mirror'.
mirror: incremental = false
mirror: ADDED a.txt
mirror: ADDED c.txt
BUILD SUCCESSFUL in 2s
- 첫 실행은 기록이 없으니
The input changes require a full rebuild와 함께incremental = false이고, 모든 파일이ADDED로 넘어왔습니다. 증분 태스크도 첫 실행에는 전체를 처리합니다. 이 줄을 찍은 것이 위 코드 51행입니다. - 두 번째는 이유 목록에 바뀐 파일 셋이 찍혔지만
incremental = true이고, 태스크는 바뀐 셋만 받았습니다(MODIFIED a.txt,REMOVED b.txt,ADDED c.txt).build/mirror에는a.txt,c.txt만 남았습니다. - 세 번째는 출력 파일을 지웠더니 다시 전체 처리로 돌아갔습니다. 출력 변경은 "다시 돌릴 이유"이므로 증분이 아닌 변경이 되고,
ResolveInputChangesStep이 전체 처리용InputChanges를 만듭니다. 이때 남은 입력 파일a.txt,c.txt가 모두ADDED로 넘어왔습니다.
(걸린 시간은 실행마다 다릅니다.)
회색 칸의 파일 목록은 위 출력의 mirror: 줄 그대로입니다.
헷갈리기 쉬운 곳: 전체 처리일 때 출력은 누가 지우나
전체 처리로 돌아갈 때 지난번 출력이 남아 있으면, 태스크가 "지운 입력에 해당하는 출력"을 모르고 남겨 둘 수 있습니다. 그래서 RemovePreviousOutputsStep 이 증분이 아닌 실행이면 출력을 미리 지웁니다(57–58행). 다만 증분 태스크일 때만입니다. TaskExecution.shouldCleanupOutputsOnNonIncrementalExecution() 이 getExecutionBehavior() == INCREMENTAL 일 때만 참을 돌려주기 때문입니다(TaskExecution.java 429–436행). 주석의 설명대로, InputChanges 를 받는 태스크는 전체 처리에 대비되어 있다고 볼 수 있지만, 그렇지 않은 보통 태스크는 출력 폴더를 지워도 되는지 알 수 없어서입니다. 보통 태스크는 출력 폴더를 스스로 정리해야 합니다.
이 글에서 뺀 것
- 빌드 캐시(
ResolveMutableCachingStateStep,BuildCacheStep, 캐시 키 계산): 11편 - 남은 출력 지우기(다른 판의 Gradle 이 만들었거나 지금은 이 빌드가 만들지 않는 출력):
platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/HandleStaleOutputsStep.java, 기록은.gradle/buildOutputCleanup/outputFiles.bin(ExecutionBuildServices.java120–129행) - 겹치는 출력(두 태스크가 같은 폴더에 쓸 때)의 감지와 처리:
CaptureMutableStateBeforeExecutionStep.detectOverlappingOutputs()(104–113행),OverlappingOutputsFilter - 입력이 비었을 때
NO-SOURCE로 건너뛰는@SkipWhenEmpty처리:steps/SkipEmptyMutableWorkStep.java - 검사(어노테이션을 빼먹은 프로퍼티, 선언 안 한 의존):
steps/ValidateStep.java,TaskExecution.validate()(510–519행), 선언 안 한 의존은 9편의MissingTaskDependencyDetector - 시간 제한과 취소:
steps/TimeoutStep.java,steps/CancelExecutionStep.java - 실행 기록의 직렬화 형식:
platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/history/impl/DefaultPreviousExecutionStateSerializer.java - 클래스패스 정규화(
@Classpath,@CompileClasspath)와 줄 끝 정규화(@NormalizeLineEndings):AbstractInputFilePropertyAnnotationHandler.java69–72·98–102행 - 불변 파이프라인과 작업 공간 재사용:
steps/AssignImmutableWorkspaceStep.java - 파일 감시의 운영체제별 구현:
platforms/core-execution/file-watching/src/main/java/org/gradle/internal/watch/registry/impl/
정리
| 개념 | 한 줄 요약 |
|---|---|
DefaultTaskNodeExecutor |
엔진 앞의 거름망. onlyIf(첫 조건이 enabled)가 거짓이면 SKIPPED, 액션이 없으면 엔진 없이 끝 |
TaskExecutionMode |
출력 없음·--rerun-tasks·upToDateWhen 거짓 등 "강제로 다시 돌릴 이유"와 기록·캐시 사용 여부 |
TaskExecution |
태스크를 MutableUnitOfWork 로 감싼 것. 정체성은 태스크 경로, 입출력은 어노테이션에서 |
| 두 파이프라인 | 가변(태스크 등, 스텝 21개, 실행 기록과 비교)과 불변(스크립트 컴파일 등, 스텝 14개, 작업 공간 재사용) |
| 스텝 사슬 | ExecutionBuildServices 에서 new 를 겹쳐 조립. 문맥 타입이 한 겹씩 두꺼워져 순서를 타입이 지킨다 |
| 실행 기록 | 프로젝트의 .gradle/<판>/executionHistory/. 열쇠는 태스크 경로, 원칙적으로 성공한 실행만 저장 |
ResolveChangesStep |
강제 이유 → 기록과 비교 → No history is available. 순으로 이유를 정한다 |
| 이유 문장 | DefaultExecutionStateChangeDetector 가 항목별로 모은다. --info 에는 최대 3줄 |
SkipUpToDateStep |
이유가 없으면 delegate 를 부르지 않고 UP-TO-DATE |
StoreExecutionStateStep |
돌아오는 길에 "실행 전 입력 + 실행 뒤 출력"을 저장 |
| 파일 지문 | 경로(정규화) + 내용 해시. 수정 시각은 해시를 다시 낼지만 정한다 |
@PathSensitive |
없으면 ABSOLUTE. RELATIVE 면 폴더를 옮겨도 UP-TO-DATE |
| VFS | 스냅숏을 데몬 메모리에 둔다. 파일 감시가 켜지면 빌드 사이에도 남고 바뀐 곳만 지운다 |
InputChanges |
증분 입력만 바뀌면 바뀐 파일만 넘긴다. 다른 이유가 있으면 전체 처리 |
다음에 볼 것
이 글에서는 가변 파이프라인의 ⑥ 캐시 키 계산과 ⑨ BuildCacheStep 을 건너뛰었습니다. 실행 기록이 "이 자리의 지난번"과만 비교한다면, 빌드 캐시는 같은 입력의 결과가 어디에든 있으면 가져옵니다. clean 뒤에도, 다른 폴더에서도, 다른 PC 에서도요. 그리고 설정 캐시는 한 걸음 더 앞, 태스크 그래프를 만드는 설정 단계 자체를 건너뜁니다. 11편: 빌드 캐시와 설정 캐시 — 결과를 다시 쓰는 두 가지 방법에서 이어집니다.
앞 연재에서 닮은 것을 다뤘습니다. 스텝이 다음 스텝을 감싸 부르는 모양은 Tomcat 8편의 밸브와 같습니다. 다만 Tomcat 의 밸브는 server.xml 에 적힌 대로 실행 중에 연결 리스트로 이어지고, Gradle 의 스텝은 코드 한 곳에서 생성자로 조립되며 문맥의 타입이 순서를 강제합니다. 설정으로 스텝을 끼울 수는 없습니다. Spring 11편의 어드바이스 체인은 인터셉터가 proceed() 로 다음 인터셉터를 부르는 목록이라, 체인을 런타임에 만든다는 점에서 Tomcat 쪽에 가깝습니다. httpd 6편의 훅은 코어가 함수들을 반복문으로 부르고 돌려받은 값으로 다음을 정합니다. Gradle 의 SkipUpToDateStep 이 "다음 스텝을 부르지 않는 것"으로 실행을 끊는 것은 Tomcat 밸브가 getNext().invoke() 를 부르지 않는 것과 같은 방식입니다.