namalzi

← 글 목록

Gradle 소스 파헤치기 10: 실행 엔진과 증분 빌드 — UP-TO-DATE는 어떻게 정해지나

Gradle · 게시

콘솔의 UP-TO-DATE 한 단어는 소스에서 어떻게 정해질까요. 태스크를 엔진에 넘기기 전에 거르는 DefaultTaskNodeExecutor, 태스크를 작업 단위로 감싸는 TaskExecution, 스물다섯 개 스텝이 겹겹이 감싼 실행 엔진, 지난 실행 기록과 지금 상태를 비교하는 ResolveChangesStep과 SkipUpToDateStep, @PathSensitive가 바꾸는 파일 지문, 빌드 사이에 파일 상태를 기억하는 VFS, 바뀐 파일만 넘겨주는 InputChanges까지, 직접 돌린 예제와 함께 소스로 확인합니다.

이 글에서 다루는 것

"Gradle 소스 파헤치기" 연재의 열 번째 글입니다. 9편에서는 실행 계획이 태스크의 순서를 정하고, DefaultPlanExecutor 의 워커 스레드가 "지금 돌릴 수 있는 노드"를 하나씩 집어 가는 데까지 봤습니다. 노드를 집은 다음에 무슨 일이 일어나는지, 곧 노드 하나가 태스크 액션에 닿거나 UP-TO-DATE 로 건너뛰어지기까지가 이번 글입니다.

2편 지도로 돌아가면, 이 글은 E 구역(실행 단계)의 가운데 상자 "실행 엔진 스텝 사슬(UP-TO-DATE, 10편)"을 확대합니다. 2편의 그림 "실행 쪽 흐름"에서 DefaultTaskNodeExecutor → TaskExecution → ExecutionEngine 스텝 사슬 → 태스크 액션으로 한 줄에 그렸던 부분입니다.

답하려는 질문은 네 가지입니다.

항목 내용
대상 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 입니다. 여기서부터 태스크 액션까지는 세 층입니다.

노드에서 태스크 액션까지. Execution worker 스레드의 LocalTaskNode.execute()가 DefaultTaskNodeExecutor로 간다. DefaultTaskNodeExecutor는 onlyIf가 거짓이거나 액션이 없으면 엔진까지 가지 않고 SKIPPED 등으로 끝낸다. 그렇지 않으면 TaskExecution이 태스크를 MutableUnitOfWork로 감싸 엔진에 넘긴다. 엔진의 앞 스텝 넷(IdentifyStep, IdentityCacheStep, ExecuteWorkBuildOperationFiringStep, ChoosePipelineStep)을 지나 작업 단위가 MutableUnitOfWork면 스텝 21개의 가변 파이프라인으로, ImmutableUnitOfWork면 스텝 14개의 불변 파이프라인으로 간다. 가변 파이프라인은 실행 기록(.gradle/판/executionHistory/)과 VFS를 읽고 쓰며 UP-TO-DATE를 정하고, 마지막에 ExecuteStep이 태스크 액션을 부른다. 불변 파이프라인은 스크립트 컴파일과 아티팩트 변환 등에 쓰이고 같은 정체성의 작업 공간이 있으면 재사용한다. 파란 상자가 이 글에서 따라가는 길입니다. 노란 상자는 디스크(실행 기록)와 데몬 메모리(VFS)에 남는 것입니다.

  1. 거르기 — DefaultTaskNodeExecutor 가 엔진에 넘길 필요가 없는 태스크를 먼저 걸러 냅니다. 꺼 둔 태스크, onlyIf 가 거짓인 태스크, 액션이 하나도 없는 태스크입니다(2절).
  2. 감싸기 — 남은 태스크를 TaskExecution 이라는 작업 단위로 감쌉니다. 엔진은 이 작업 단위만 봅니다(3절).
  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 줄 열 줄이 있어 줄였습니다. 걸린 시간은 실행마다 다릅니다. 첫 실행이 긴 것은 데몬을 새로 띄우고 스크립트를 처음 컴파일하기 때문입니다.)

벌써 읽을 것이 셋 있습니다.

나머지 다섯 번은 5·6절에서 이유 문장과 함께 봅니다.

2. 엔진 앞의 거름망 — DefaultTaskNodeExecutor

메서드 이름이 곧 순서다

DefaultTaskNodeExecutor.execute() 는 태스크 하나를 Task :render 라는 이름의 빌드 오퍼레이션으로 감싸고(5편에서 본 그 장치입니다), 그 안에서 executeWithLifecycle() 로 시작하는 메서드들을 차례로 부릅니다. 메서드 하나가 한 가지를 확인하고 다음 메서드를 부르는 모양이라, 이름만 이어 읽어도 순서가 보입니다.

executeWithLifecycle → executeWithFallbackOperationId → executeCatchingFailures → executeSkippingOnlyIf → executeSkippingWithNoActions → executeFinalizingProperties → executeTrackingTaskIdentity → doExecute

앞의 셋은 리스너 호출, 로그를 지금 빌드 오퍼레이션에 붙이기, 예외를 태스크 실패로 바꾸기입니다. 건너뛰기를 정하는 것은 그다음 둘입니다.

DefaultTaskNodeExecutor 안의 거름망. executeSkippingOnlyIf()는 첫 조건 'Task is enabled'와 그다음 onlyIf 들을 확인해 거짓이면 SKIPPED로 끝낸다. 참이면 executeSkippingWithNoActions()가 액션이 하나라도 있는지 보고, 없으면 의존 태스크가 모두 건너뛰었는지에 따라 UP-TO-DATE 또는 EXECUTED로 끝낸다. 액션이 있으면 doExecute()가 getExecutionMode()로 실행 방식(untracked, 출력 없음, --rerun-tasks, upToDateWhen 거짓, incremental)을 정하고, new TaskExecution으로 작업 단위를 만들어 executionEngine.createRequest(work)를 부른다. 강제 재실행 이유가 있으면 request.forceNonIncremental(이유)로 넘기고 request.execute()로 스텝 사슬에 들어간다. 초록 상자는 "실패가 아닌 끝"입니다. 왼쪽의 두 끝은 실행 엔진에 들어가지도 않습니다.

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);
    }

subprojects/core/src/main/java/org/gradle/api/internal/tasks/execution/DefaultTaskNodeExecutor.java 239–273행

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);
    }

subprojects/core/src/main/java/org/gradle/api/internal/tasks/execution/DefaultTaskNodeExecutor.java 278–290행

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();
    }

subprojects/core/src/main/java/org/gradle/api/internal/changedetection/changes/DefaultTaskExecutionModeResolver.java 40–63행

결과는 다섯 가지이고, 각각 "강제로 다시 돌릴 이유"와 두 가지 스위치를 가집니다(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;

subprojects/core/src/main/java/org/gradle/api/internal/tasks/execution/DefaultTaskNodeExecutor.java 359–369행

"다시 돌릴 이유"가 있으면 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 네 줄이 있어 줄였습니다. 걸린 시간은 실행마다 다릅니다.)

헷갈리기 쉬운 곳: 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());
        }
    }

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/ChoosePipelineStep.java 35–44행

태스크의 정체성이 입력과 상관없는 태스크 경로(:render)인 것도 이 차이에서 나옵니다. 태스크는 ":render 라는 자리"이고, 그 자리의 지난 기록과 지금을 비교합니다.

엔진에 들어오는 작업 단위 셋이 ChoosePipelineStep 에서 갈리는 모양. 태스크 하나를 감싼 TaskExecution(정체성은 태스크 경로 :render)과 증분 아티팩트 변환 MutableTransformExecution 은 가변 파이프라인으로, ImmutableTransformExecution·스크립트 컴파일·버전 카탈로그 접근자·Kotlin DSL 접근자 생성은 불변 파이프라인으로 간다. ChoosePipelineStep 은 instanceof 로 가른다. 가변 파이프라인(MutableUnitOfWork)은 같은 자리에서 여러 번 실행되는 일이라 지난 실행 기록과 지금을 비교해 SkipUpToDateStep 으로 건너뛰고, 태스크의 작업 공간은 null 이다(출력 위치를 스스로 정함). 불변 파이프라인(ImmutableUnitOfWork)은 입력 전체로 정체성을 만들어 그 이름의 폴더에 결과를 두고, 폴더가 이미 있으면 그대로 쓰며(AssignImmutableWorkspaceStep), 비교할 일이 없어 NeverUpToDateStep 을 쓴다. 위 두 표와 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);

subprojects/core/src/main/java/org/gradle/internal/service/scopes/ExecutionBuildServices.java 188–219행

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);
}

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/Step.java 21–23행

DefaultExecutionEngine 은 이 사슬의 맨 앞(IdentifyStep)을 들고 있다가, 요청의 execute() 에서 pipeline.execute(work, createExecutionRequestContext()) 한 줄로 부를 뿐입니다(DefaultExecutionEngine.java 66–69행). 2절에서 forceNonIncremental() 로 실은 이유가 이 ExecutionRequestContext 에 들어갑니다.

가변 파이프라인을 일의 순서로 묶은 그림. ① 자리 잡기(AssignMutableWorkspaceStep, HandleStaleOutputsStep) → ② 지난 기록 읽기(LoadPreviousExecutionStateStep) → ③ 입력이 비었나(SkipEmptyMutableWorkStep, NO-SOURCE) → ④ 지금 상태 찍기와 검사(CaptureMutableStateBeforeExecutionStep, ValidateStep) → ⑤ 무엇이 바뀌었나(ResolveChangesStep) → ⑥ 캐시 키 계산(ResolveMutableCachingStateStep, 11편) → ⑦ 건너뛸까(SkipUpToDateStep). 바뀐 것이 없으면 UP-TO-DATE로 여기서 돌아간다. 이유가 있으면 ⑧ 돌아오는 길에 기록 저장(StoreExecutionStateStep) → ⑨ 캐시에서 가져올까(BuildCacheStep, FROM-CACHE, 11편) → ⑩ 실행 준비와 뒤처리(ResolveInputChangesStep, CaptureOutputsAfterExecutionStep, RemovePreviousOutputsStep, 시간 제한, 취소) → ⑪ ExecuteStep이 work.execute()로 태스크 액션을 부른다. 스텝 21개를 하는 일로 묶었습니다. 빌드 스캔용 측정을 시작·끝내는 MarkSnapshottingInputs…Step 둘과 BroadcastChangingOutputsStep·PreCreateOutputParentsStep 은 그림에서 뺐습니다.

그림에서 보듯 사슬은 둘로 나뉩니다. ⑦까지는 "돌려야 하나"를 판단하는 앞 절반이고, ⑧부터는 "돌린다"로 정해진 뒤의 뒤 절반입니다. UP-TO-DATE 면 ⑦에서 돌아가므로 뒤 절반은 아예 불리지 않습니다. 반대로 실행 기록 저장(⑧)은 ⑦보다 안쪽에 있어서, 안쪽 스텝들이 다 끝나고 돌아오는 길에 기록을 씁니다.

왜 사슬인가 — 문맥이 한 겹씩 두꺼워진다

스텝이 다음 스텝에 넘기는 context 는 스텝을 지날 때마다 정보가 한 겹씩 붙은 새 타입이 됩니다.

스텝을 지나며 두꺼워지는 문맥 타입. IdentityContext(getIdentity, getImplementation)를 AssignMutableWorkspaceStep이 WorkspaceContext(getWorkspace)로, LoadPreviousExecutionStateStep이 PreviousExecutionContext(getPreviousExecutionState)로, CaptureMutableStateBeforeExecutionStep이 MutableBeforeExecutionContext(getBeforeExecutionState)로, ValidateStep이 MutableValidationFinishedContext(getValidationProblems)로 감싼다. 거기서 ResolveChangesStep이 MutableChangesContext(getRebuildReasons, getChanges)를, ResolveMutableCachingStateStep이 다시 MutableCachingContext(getCachingState)를 만든다. ResolveInputChangesStep은 MutableValidationFinishedContext를 물려받은 InputChangesContext(getInputChanges)를 만든다. 화살표 옆의 이름이 그 문맥을 만드는 스텝입니다. 각 문맥은 부모 문맥을 물려받으므로 앞에서 붙은 정보를 모두 가집니다.

그리고 각 스텝은 자기가 받을 문맥의 타입을 제네릭으로 못 박습니다. LoadPreviousExecutionStateStep<C extends WorkspaceContext, …>, ResolveChangesStep<C extends MutableValidationFinishedContext, …>, SkipUpToDateStep<C extends MutableChangesContext> 처럼요(각 클래스의 선언 줄). 그래서 누군가 SkipUpToDateStep 을 ResolveChangesStep 보다 앞에 끼우려 하면, "바뀐 것" 정보가 없는 문맥을 넘기게 되어 컴파일이 되지 않습니다. 스텝의 순서를 주석이나 문서가 아니라 타입이 지킵니다.

이렇게 나눈 이점은 세 가지로 보입니다.

이 이점들은 소스의 구조에서 읽은 것이고, 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

헷갈리기 쉬운 곳: 스텝의 수를 셀 때

2편의 예제는 같은 자리의 스택에서 "이름이 Step 으로 끝나는 클래스가 27가지"라고 셌습니다. 이 글의 "25개"와 다른 것은 세는 대상이 달라서입니다. 2편은 스택에 보인 클래스 이름을 셌으므로 공통 부모 MutableStep 과 AbstractResolveCachingStateStep 이 더해져 27입니다. 이 글은 ExecutionBuildServices 에서 new 로 만든 스텝 객체를 셌습니다(4 + 21 = 25). 둘 다 맞는 숫자입니다.

5. UP-TO-DATE 판단 — 기록 읽기, 지금 찍기, 비교, 건너뛰기

이제 사슬에서 UP-TO-DATE 에 직접 관여하는 다섯 스텝을 차례로 봅니다.

UP-TO-DATE 판단의 호출 순서. ① LoadPreviousExecutionStateStep이 실행 기록 executionHistory.bin에서 태스크 경로로 지난 실행 상태를 읽는다(없으면 비어 있음). ② PreviousExecutionContext를 CaptureMutableStateBeforeExecutionStep으로 넘긴다. ③ 그 스텝이 VFS와 지문 만들기로 입력 값, 입력 파일, 출력 파일을 찍어 스냅숏과 지문을 받는다. ④ BeforeExecutionState를 실어 ResolveChangesStep으로 넘긴다. ⑤ ResolveChangesStep이 지난 상태와 지금 상태를 비교해 바뀐 이유 목록(최대 3줄)을 만든다. ⑥ 이유 목록과 바뀐 것을 SkipUpToDateStep으로 넘긴다. ⑦ 이유가 없으면 UP-TO-DATE로 액션을 부르지 않고 돌아간다. 이유가 있으면 이유를 --info로 찍고 다음 스텝으로 가며, ⑧ 실행 결과가 돌아오는 길에 StoreExecutionStateStep이 기록을 쓴다. 가운데의 다른 스텝들(빈 입력 확인, 검사, 캐시 키 계산)은 그림에서 뺐습니다.

① 지난 기록 읽기 — 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;
    }

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/LoadPreviousExecutionStateStep.java 30–46행

열쇠는 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행).

이 일은 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))
            );
    }

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/ResolveChangesStep.java 67–79행

Optional 사슬이라 읽기 어렵지만, 순서대로 풀면 "다시 돌릴 이유"를 정하는 우선순위입니다.

  1. 2절에서 실어 보낸 강제 이유가 있으면 그것 (Executed with '--rerun-tasks'. 등)
  2. 지난 기록이 있고 검사 문제가 없으면 → changeDetector.detectChanges(…) 로 진짜 비교
  3. 지난 기록은 있는데 검사 문제가 있으면 → Incremental execution has been disabled to ensure correctness. …(37행)
  4. 지난 기록이 없으면 → 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);

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/history/changes/DefaultExecutionStateChangeDetector.java 92–103행

묶은 순서가 곧 --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 가 그 목록입니다.

다시 돌릴 이유를 정하는 순서. ResolveChangesStep 은 먼저 2절에서 실어 보낸 강제 이유(Executed with '--rerun-tasks'. 등)가 있으면 그것을 쓴다. 없으면 지난 기록이 있는지 보고, 없으면 No history is available. 이 이유가 된다. 기록이 있는데 검사 문제가 있으면 Incremental execution has been disabled to ensure correctness. … 가 이유가 된다. 검사 문제가 없으면 DefaultExecutionStateChangeDetector.detectChanges()가 ① 지난번에 성공했나, ② 구현 클래스·클래스패스, ③ @Input 프로퍼티 추가·제거, ④ @Input 값, ⑤ 출력 프로퍼티, ⑥ 출력 파일, ⑦ 입력 파일 프로퍼티, ⑧ 증분이 아닌 입력 파일의 순서로 비교하고, 이유는 앞에서부터 최대 3줄(MAX_OUT_OF_DATE_MESSAGES = 3)만 남긴다. SkipUpToDateStep 은 이유가 없으면 Skipping task ':render' as it is up-to-date. 를 찍고 건너뛰고, 이유가 있으면 Task ':render' is not up-to-date because: … 를 찍고 실행한다. 강제 이유, 기록 없음, 검사 문제도 모두 실행으로 이어진다. 노란 칸은 묻는 곳, 빨간 칸은 실행으로 가는 이유입니다. ①~⑧은 위 표의 순서이고 --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));
    }

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/SkipUpToDateStep.java 44–54행

판단은 한 줄입니다. 바뀐 것을 계산했고(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;
    }

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/StoreExecutionStateStep.java 44–61행

첫 줄에서 먼저 안쪽을 다 돌리고, 돌아와서 저장합니다. 저장하는 것은 "실행 전에 찍은 입력 상태 + 실행 뒤에 찍은 출력 상태 + 캐시 키"입니다. 다음 빌드의 ①이 이것을 읽습니다. 두 가지를 눈여겨볼 만합니다.

예제 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();
            }
        }

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/ExecuteStep.java 106–119행

액션이 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);
        // ... 새 해시를 크기·시각과 함께 저장하고 돌려준다 ...
    }

platforms/core-execution/hashing-services/src/main/java/org/gradle/api/internal/changedetection/state/CachingFileHasher.java 84–99행

이 캐시의 이름이 "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;
    }

subprojects/core/src/main/java/org/gradle/api/internal/tasks/properties/FileParameterUtils.java 39–45행

@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행).

파일 지문이 만들어지는 길. 디스크의 파일 /tmp/p/data/a.txt를 FileSystemAccess.read()가 VFS 스냅숏(절대 경로, 종류, 내용 해시)으로 만든다. 태스크 프로퍼티의 어노테이션에 따라 정규화 방식이 정해지는데 @PathSensitive가 없으면 ABSOLUTE다. ABSOLUTE_PATH는 지문의 경로가 /tmp/p/data/a.txt이고 그 옆에 내용 해시, RELATIVE_PATH는 지문의 경로가 a.txt로 입력 디렉터리 이름을 뺀다. NONE과 NAME_ONLY는 해시만, 파일 이름만 쓴다. 이 지문을 DefaultExecutionStateChangeDetector가 지난 실행의 지문과 비교한다. data를 moved로 옮기면 ABSOLUTE는 added와 removed로 보고, RELATIVE는 그대로 UP-TO-DATE다. 빨간 상자와 초록 상자가 아래 예제 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

(걸린 시간은 실행마다 다릅니다.)

@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)
            ));
    }

platforms/core-execution/snapshots/src/main/java/org/gradle/internal/vfs/impl/DefaultFileSystemAccess.java 143–157행

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

두 번째 빌드는 아무것도 고치지 않았는데도 이벤트를 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;
    }

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/ResolveInputChangesStep.java 42–54행

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

(걸린 시간은 실행마다 다릅니다.)

예제 4의 세 번 실행을 증분 판단으로 나눈 그림. 액션이 InputChanges 를 받는 증분 태스크에서 ResolveInputChangesStep 은 다시 돌릴 이유(증분이 아닌 변경)가 있는지 본다. 있으면 전체 처리다 — incremental = false, The input changes require a full rebuild … 가 찍히고, RemovePreviousOutputsStep 이 출력을 먼저 지우며, 남은 입력 파일이 모두 ADDED 로 넘어온다. 1번째 실행(No history is available. → ADDED a.txt, ADDED b.txt)과 3번째 실행(출력 build/mirror/a.txt 를 지움 → ADDED a.txt, ADDED c.txt)이 여기에 해당한다. 증분 입력만 바뀌었으면 증분 처리다 — incremental = true 이고 @Incremental 입력의 바뀐 파일만 넘어온다. 2번째 실행(a.txt 수정, b.txt 삭제, c.txt 추가 → MODIFIED a.txt, REMOVED b.txt, ADDED c.txt)이 여기에 해당한다. 회색 칸의 파일 목록은 위 출력의 mirror: 줄 그대로입니다.

헷갈리기 쉬운 곳: 전체 처리일 때 출력은 누가 지우나

전체 처리로 돌아갈 때 지난번 출력이 남아 있으면, 태스크가 "지운 입력에 해당하는 출력"을 모르고 남겨 둘 수 있습니다. 그래서 RemovePreviousOutputsStep 이 증분이 아닌 실행이면 출력을 미리 지웁니다(57–58행). 다만 증분 태스크일 때만입니다. TaskExecution.shouldCleanupOutputsOnNonIncrementalExecution() 이 getExecutionBehavior() == INCREMENTAL 일 때만 참을 돌려주기 때문입니다(TaskExecution.java 429–436행). 주석의 설명대로, InputChanges 를 받는 태스크는 전체 처리에 대비되어 있다고 볼 수 있지만, 그렇지 않은 보통 태스크는 출력 폴더를 지워도 되는지 알 수 없어서입니다. 보통 태스크는 출력 폴더를 스스로 정리해야 합니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
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() 를 부르지 않는 것과 같은 방식입니다.

다른 글