namalzi

← 글 목록

Gradle 소스 파헤치기 8: 태스크와 지연 설정 — DefaultTask, TaskContainer, Provider

Gradle · 게시

tasks.register 한 줄은 태스크를 만들지 않습니다. 이름과 설정 블록만 맡겨 둔 TaskCreatingProvider가 언제 실제 태스크가 되는지, configureEach가 지금 있는 태스크와 나중에 올 태스크에 함께 걸리는 원리, withType { }처럼 모르는 새 모든 태스크를 만들어 버리는 API, TaskFactory와 DefaultTask가 액션 목록을 쥐는 법, 그리고 Provider가 값 대신 "값을 만드는 태스크"를 들고 다녀 dependsOn 없이 순서가 생기는 이유와 Property를 굳히는 세 가지 방법까지, 예제 세 개로 소스와 함께 확인합니다.

이 글에서 다루는 것

"Gradle 소스 파헤치기" 연재의 여덟 번째 글입니다. 7편에서는 plugins {} 블록의 플러그인이 apply()까지 가는 길을 봤고, 그 끝에서 JavaPlugin 같은 플러그인이 tasks.register(…)로 태스크를 잔뜩 등록하는 것을 봤습니다. 이번 글은 그 등록된 태스크가 어떻게 생기고, 설정되고, 서로 이어지는지를 따라갑니다.

2편의 지도로 돌아가면, 이 글은 큰 지도 그림의 "D · 설정 단계" 구역에서 세 번째 상자, "태스크 등록 · Provider(지연 설정)"를 확대합니다. 6편이 빌드 스크립트를 실행하고 7편이 플러그인을 걸었다면, 그 둘이 하는 일의 대부분은 결국 태스크 컨테이너에 무언가를 등록하고 Property에 값을 잇는 것입니다. 5편에서 register한 태스크의 설정 블록이 projectsEvaluated 다음에 찍힌 것을 봤는데, 그 이유가 이 글의 첫 번째 주제입니다.

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

항목 내용
대상 Gradle Build Tool
버전 9.10.0 개발판 (master)
커밋 07ecbf9ead (2026-10-24)
주로 읽는 클래스 DefaultTaskContainer(+TaskCreatingProvider), DefaultNamedDomainObjectCollection, DefaultDomainObjectCollection, TaskFactory, AnnotationProcessingTaskFactory, AbstractTask/DefaultTask, AbstractMinimalProvider, TransformBackedProvider, AbstractProperty/DefaultProperty, ValueSupplier.ValueProducer, ValueState, DefaultTaskInputs

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 이 블로그 저장소의 build.gradle.kts도 예로 몇 번 드는데, 그쪽 Gradle은 래퍼로 9.5.1이라 이 글의 기준 소스와 판이 다릅니다.

도메인 객체 컬렉션과 Provider API는 platforms/core-configuration 아래에서 가장 큰 덩어리 가운데 하나입니다(ValueSupplier.java 하나가 900줄이 넘습니다). 이 글은 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

먼저 알아 둘 말

태스크 컨테이너(task container)는 프로젝트 하나에 하나 있는 태스크 목록입니다. 빌드 스크립트의 tasks가 이것이고, 구현은 DefaultTaskContainer입니다. 프로젝트 스코프 서비스로 만들어집니다(ProjectScopeServices, 4편의 서비스 레지스트리).

도메인 객체 컬렉션(domain object collection)은 Gradle이 "사용자가 이름을 붙여 쌓는 것들"에 공통으로 쓰는 컬렉션입니다. 태스크 컨테이너, configurations, sourceSets, repositories가 모두 이 계열이고, DefaultDomainObjectCollection → DefaultNamedDomainObjectCollection → … → DefaultTaskContainer로 이어집니다. 그래서 이 글에서 보는 configureEach·named·withType의 동작은 태스크만이 아니라 이 계열 전부에 해당합니다.

등록(register)과 실현(realize)은 이 글의 두 핵심 낱말입니다. 등록은 "이런 이름, 이런 타입의 태스크가 있을 것이고, 만들면 이 블록으로 설정해 달라"는 약속만 맡기는 것이고, 실현은 그 약속을 꺼내 실제로 태스크 객체를 만들고 맡겨 둔 블록을 돌리는 것입니다. 소스의 빌드 오퍼레이션(Gradle이 "무슨 일을 언제부터 언제까지 했나"를 이름을 붙여 남기는 기록 단위, 5편 8절) 이름도 Register task :x, Realize task :x입니다.

지연 설정(lazy configuration)은 이렇게 "필요할 때까지 만들지도, 계산하지도 않는" 방식 전체를 부르는 말입니다. 사용자 문서는 태스크 쪽을 "설정 회피"(configuration avoidance)라고 부릅니다(TaskContainer 자바독이 가리키는 문서 이름이 task_configuration_avoidance입니다).

Provider와 Property는 "값 자체" 대신 "값을 구하는 방법"을 들고 있는 객체입니다. Provider<T>는 읽기만 하고, Property<T>는 값을(또는 다른 Provider를) 넣을 수 있습니다. get()을 부를 때 비로소 계산합니다.

생산자(producer)는 어떤 값을 만들어 내는 태스크입니다. 소스에서는 ValueSupplier.ValueProducer라는 작은 객체로, Provider마다 "내 값은 이 태스크가 만든다"를 알려 줄 수 있습니다. 6절의 주인공입니다.

1. 큰 그림: register는 태스크를 만들지 않는다

빌드 스크립트에서 가장 흔한 한 줄부터 봅니다. Kotlin DSL의 tasks.register("lazyA") { … }는 TaskContainer.register(String, Action)이고, 결국 DefaultTaskContainer의 이 메서드에 닿습니다.

    private <T extends Task> TaskProvider<T> registerTask(final String name, final Class<T> type, @Nullable final Action<? super T> configurationAction, final Object... constructorArgs) {
        if (hasWithName(name)) {
            failOnDuplicateTask(name);
        }

        final TaskIdentity<T> identity = taskIdentityFactory.create(name, type, project, getUserCodeSource());

        TaskProvider<T> provider = buildOperationRunner.call(new CallableBuildOperation<TaskProvider<T>>() {
            // ... description(): "Register task :<이름>" ...
            @Override
            public TaskProvider<T> call(BuildOperationContext context) {
                TaskProvider<T> provider = Cast.uncheckedNonnullCast(
                    getInstantiator().newInstance(
                        TaskCreatingProvider.class, DefaultTaskContainer.this, identity, configurationAction, constructorArgs
                    )
                );
                addLaterInternal(provider);
                context.setResult(REGISTER_RESULT);
                return provider;
            }
        });
        // ...
        return provider;
    }

subprojects/core/src/main/java/org/gradle/api/internal/tasks/DefaultTaskContainer.java 440–471행

이 메서드가 하는 일은 네 가지뿐입니다.

  1. 같은 이름이 이미 있으면 실패합니다(hasWithName() — 만들어진 것과 대기 중인 것을 함께 봅니다).
  2. 이름·타입·프로젝트를 묶은 TaskIdentity를 만듭니다.
  3. TaskCreatingProvider 를 하나 만듭니다. 생성자에 넘기는 것은 신분(identity)과 설정 블록(configurationAction)입니다. 태스크 객체가 아닙니다.
  4. 그 공급자를 addLaterInternal()로 컬렉션의 대기 목록(pending)에 넣고 돌려줍니다.

Task 객체는 어디에서도 만들지 않습니다. 설정 블록도 부르지 않고 공급자 안에 맡겨만 둡니다. 이 모든 일은 Register task :lazyA라는 빌드 오퍼레이션으로 감싸집니다. 줄인 466–468행은 eagerlyCreateLazyTasks면 곧바로 get()하는 자리인데, 이 깃발은 시스템 프로퍼티 org.gradle.internal.tasks.eager(79행)로 켜는, 등록하자마자 만들게 하는 내부용 스위치입니다.

register와 실현의 순서. ① 빌드 스크립트가 DefaultTaskContainer에 register("lazyA") { … }를 부른다. ② 컨테이너는 TaskCreatingProvider를 만들어 설정 블록을 맡기고 ③ addLater로 대기 목록에 넣은 뒤 ④ TaskProvider를 돌려준다. 여기까지 태스크 객체는 없고 빌드 오퍼레이션은 Register task다. 나중에 ⑤ 태스크 고르기(TaskNameResolver)가 getByName("lazyA")를 부르면 ⑥ 컨테이너가 공급자의 getOrNull()을 부르고 ⑦ 공급자가 tryCreate()에서 태스크 팩토리의 create()를 불러 ⑧ _Decorated 태스크 객체를 받는다. ⑨ doAdd()가 맡아 둔 액션을 차례로 실행한다 — configureEach 블록, register 블록, named 블록. 빌드 오퍼레이션은 Realize task다. 위 절반(①–④)이 설정 단계에서 빌드 스크립트가 도는 동안, 아래 절반(⑤–⑨)은 누군가 그 태스크를 처음 필요로 할 때 일어납니다.

예제 1: 설정 블록은 언제 도는가

블록마다 println을 찍는 빌드 스크립트를 만들었습니다.

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// 태스크의 설정 블록이 "언제" 도는지 보려고 블록마다 println 을 찍는다.

println("[script] start")

// 지금 있는 태스크와 앞으로 생길 태스크 모두에 걸리는 지연 설정
tasks.configureEach {
    println("[configureEach] $name")
}

// register: 이름과 설정 블록만 맡겨 둔다
val lazyA = tasks.register("lazyA") {
    println("[register block] lazyA")
    // ... -Pstack 이면 호출 스택을 찍는다 (2절) ...
    doLast { println("[action] lazyA") }
}
println("[script] register returned ${lazyA.javaClass.simpleName}")

tasks.register("lazyB") {
    println("[register block] lazyB")
}
// named 로 설정을 더해도 아직 만들지 않는다
tasks.named("lazyB") {
    println("[named block] lazyB")
}

// create: 그 자리에서 만들고 설정한다 (이 커밋에서는 @Deprecated)
@Suppress("DEPRECATION")
tasks.create("eager") {
    println("[create block] eager")
}

// ... -PeagerAll 이면 withType<DefaultTask> { } 를 건다 (3절) ...

println("[script] end")

[예제 ex/u08-register-vs-create/project/build.gradle.kts에서 줄임]

이 예제는 기준 커밋(07ecbf9ead)을 그대로 빌드한 Gradle 9.10.0 개발판으로 도커 컨테이너(Eclipse Temurin JDK 25)에서 실행했습니다. run.sh가 gradle help --offline --console=plain을 돌리고 [로 시작하는 줄만 걸러 찍습니다(이 글의 다른 출력도 run.sh가 grep으로 필요한 줄만 거른 것입니다).

=== gradle help ===
[script] start
[script] register returned DefaultTaskContainer$TaskCreatingProvider_Decorated
[configureEach] eager
[create block] eager
[script] end
[configureEach] help
BUILD SUCCESSFUL in 20s

읽을 것이 네 가지 있습니다. (걸린 시간은 이 글의 다른 출력에서도 모두 실행마다 다릅니다. 컨테이너마다 데몬을 새로 띄우고 스크립트를 새로 컴파일해서, 첫 실행은 돌릴 때마다 20초에서 1분 반 정도까지 걸렸습니다.)

같은 빌드를 빌드 오퍼레이션 trace(-Dorg.gradle.internal.operations.trace=/tmp/trace, 1편 참고)로 다시 돌려 Register task·Realize task만 골라 보면 이렇습니다.

=== build operations of gradle help: Register / Realize ===
"displayName":"Register task :help"
"displayName":"Register task :projects"
"displayName":"Register task :tasks"
...
"displayName":"Register task :kotlinDslAccessorsReport"
"displayName":"Register task :lazyA"
"displayName":"Register task :lazyB"
"displayName":"Realize task :eager"
"displayName":"Realize task :help"

(Register task 줄은 열여덟 줄이라 가운데 열두 줄을 줄였습니다.) 아무 플러그인도 걸지 않은 빌드인데 Gradle이 스스로 태스크 열여섯 개(help, tasks, wrapper, …)를 등록합니다. 거기에 내 것 둘을 더해 등록 18, 실현 2입니다. eager는 create로 만들었으니 Register 없이 바로 Realize이고, 나머지 열일곱은 등록만 된 채로 빌드가 끝났습니다.

create는 무엇이 다른가

create의 몸통(doCreate())은 Realize task 빌드 오퍼레이션 안에서 이렇게 합니다.

                    T task = createTask(identity, constructorArgs);
                    statistics.eagerTask(identity.getTaskType());
                    addTask(task, behavior);
                    configureAction.execute(task);

subprojects/core/src/main/java/org/gradle/api/internal/tasks/DefaultTaskContainer.java 336–339행

그 자리에서 태스크를 만들고(createTask), 컬렉션에 넣고(addTask — 이때 configureEach 같은 컨테이너 쪽 액션이 돕니다), 설정 블록을 부릅니다. 예제에서 [configureEach] eager가 [create block] eager보다 먼저 찍힌 것이 이 순서입니다.

헷갈리기 쉬운 곳

2. 언제 실현되나

맡겨 둔 공급자가 실제 태스크가 되는 곳은 공급자의 값 계산입니다. TaskCreatingProvider의 부모 AbstractDomainObjectCreatingProvider(도메인 객체 컬렉션 공통)에 있습니다.

        @Override
        protected Value<? extends I> calculateOwnValue(ValueConsumer consumer) {
            // ...
            if (failure != null) {
                throw failure;
            }
            if (object == null) {
                object = getType().cast(findByNameWithoutRules(getName()));
                if (object == null) {
                    tryCreate();
                }
            }
            return Value.of(object);
        }

        protected void tryCreate() {
            try {
                // Collect any container level add actions added since the last call to configure()
                onCreate = onCreate.mergeFrom(getEventRegister().getAddActions());
                // ...
                    object = createDomainObject();
                    // ...
                        doAdd(object, onCreate);
                        // ...
            } catch (Throwable ex) {
                failure = domainObjectCreationException(ex);
                throw failure;
            } finally {
                // Discard state that is no longer required
                onCreate = ImmutableActionSet.empty();
            }
        }

platforms/core-configuration/domain-object-collections/src/main/java/org/gradle/api/internal/DefaultNamedDomainObjectCollection.java 981–1024행

TaskCreatingProvider는 이 tryCreate()를 Realize task :x 빌드 오퍼레이션으로 한 번 더 감쌉니다(737–756행).

누가 값을 묻나 — 예제 1을 gradle lazyA로

같은 예제를 gradle lazyA -Pstack으로 돌렸습니다. -Pstack을 주면 lazyA의 설정 블록이 자기를 부른 Gradle 쪽 프레임을 찍습니다.

    if (providers.gradleProperty("stack").isPresent) {
        // 누가 이 블록을 부르는지 — Gradle 의 프레임만 골라 찍는다
        Throwable().stackTrace
            .filter { it.className.startsWith("org.gradle.") }
            .forEach { println("    at ${it.className}.${it.methodName}") }
    }

[예제 ex/u08-register-vs-create/project/build.gradle.kts에서]

=== gradle lazyA -Pstack ===
...
[script] end
[configureEach] lazyA
[register block] lazyA
    ...
    at org.gradle.api.internal.DefaultDomainObjectCollection.doAdd
    at org.gradle.api.internal.DefaultNamedDomainObjectCollection.doAdd
    at org.gradle.api.internal.DefaultNamedDomainObjectCollection$AbstractDomainObjectCreatingProvider.tryCreate
    ...
    at org.gradle.api.internal.DefaultNamedDomainObjectCollection$AbstractDomainObjectCreatingProvider.calculateOwnValue
    at org.gradle.api.internal.provider.AbstractMinimalProvider.getOrNull
    at org.gradle.api.internal.DefaultNamedDomainObjectCollection.findByName
    ...
    at org.gradle.api.internal.DefaultNamedDomainObjectCollection.getByName
    at org.gradle.api.internal.tasks.DefaultTaskCollection.getByName
    at org.gradle.execution.TaskNameResolver.getExistingTask
    ...
    at org.gradle.execution.DefaultTaskSelector.getSelection
    ...
    at org.gradle.execution.commandline.CommandLineTaskParser.parseTasks
    ...
    at org.gradle.initialization.DefaultTaskExecutionPreparer.scheduleRequestedTasks
    ...
    at org.gradle.internal.build.DefaultBuildLifecycleController$DefaultWorkGraphBuilder.addRequestedTasks
    ...
> Task :lazyA
[action] lazyA
BUILD SUCCESSFUL in 1s

org.gradle. 프레임만 191개가 찍혔고, 위에서는 이 글과 상관있는 줄만 남기고 ...로 줄였습니다(앞의 다섯 줄은 gradle help 때와 같아서 뺐습니다). 스택은 위가 안쪽입니다. 아래에서부터 읽으면:

  1. 5편에서 본 생명주기의 "실행할 태스크를 작업 그래프에 넣는" 단계(addRequestedTasks → scheduleRequestedTasks)에서
  2. 명령줄의 lazyA라는 이름을 해석하는 CommandLineTaskParser → DefaultTaskSelector → TaskNameResolver가
  3. tasks.getByName("lazyA")를 부르고, findByName()이 대기 목록에서 공급자를 찾아 getOrNull()로 값을 물으니
  4. calculateOwnValue() → tryCreate() → doAdd()가 맡아 둔 액션을 실행해 내 블록이 불렸습니다.

findByName()은 이렇게 생겼습니다.

    @Override
    @Nullable
    public T findByName(String name) {
        T value = findByNameWithoutRules(name);
        if (value != null) {
            return value;
        }
        ProviderInternal<? extends T> provider = index.getPending(name);
        if (provider != null) {
            // TODO - this isn't correct, assumes that a side effect is to add the element
            provider.getOrNull();
            // Use the index here so we can apply any filters to the realized element
            return index.get(name);
        }
        // ... 없으면 규칙(addRule)을 돌려 본다 ...

platforms/core-configuration/domain-object-collections/src/main/java/org/gradle/api/internal/DefaultNamedDomainObjectCollection.java 314–332행

이미 만든 것에 없으면 대기 목록(index.getPending)을 보고, 거기 있으면 getOrNull()을 불러 만들어지는 부수 효과를 일으킨 다음 다시 찾습니다. 소스 주석도 "부수 효과로 원소가 더해진다고 가정한다"고 적어 둡니다. 그러니 findByName·getByName은 이름으로 찾는 메서드이면서 동시에 실현하는 메서드입니다.

TaskNameResolver는 실현을 아끼려고 먼저 이름 목록으로 있는지만 봅니다(getNames().contains(taskName), TaskNameResolver.java 110–112행). 이름 목록은 대기 중인 공급자의 이름까지 담고 있어서 만들지 않고도 답할 수 있습니다. 실현은 고른 태스크만 getByName()으로 합니다(114–120행). 고른 태스크가 의존하는 태스크들을 따라가며 실현하는 것은 작업 그래프 쪽 일이고 9편에서 봅니다.

실현을 일으키는 것과 일으키지 않는 것. 등록만 된 태스크(TaskCreatingProvider, 대기 목록)에 대해 configureEach { }, named(name) { }, map·flatMap으로 새 Provider 만들기는 액션을 맡겨 두기만 하고 만들지 않는다. get()·getOrNull()·findByName·getByName, 명령줄의 태스크 이름(TaskNameResolver), 의존을 따라갈 때(TaskProvider.getProducer()), iterator·forEach·for 반복(realizePending()), all { }·whenObjectAdded·withType(T) { }는 태스크를 만든다. 마지막 두 묶음은 빨간색이다. 만들어지면 태스크 객체가 생기고 설정 블록이 돌며 빌드 오퍼레이션 Realize task가 남는다. 빨간 상자는 "모르는 새" 여러 태스크를 한꺼번에 만드는 것들입니다. 3절에서 봅니다.

예제 1-2: java 플러그인 빌드에서 세어 보기

내 태스크 두 개로는 실감이 나지 않으니, plugins { java } 한 줄만 있는 빌드에서 세어 봤습니다. Gradle에는 이것을 세는 내부 스위치가 있습니다. 시스템 프로퍼티 org.gradle.internal.tasks.stats를 주면 TaskStatistics가 등록·실현 수를 빌드 끝에 찍습니다(TaskStatistics.java 43행, 찍는 곳은 116–127행). TaskCreatingProvider 생성자의 statistics.lazyTask()와 실현할 때의 onLazyDomainObjectRealized()가 세는 곳입니다.

=== java project: gradle help with task statistics ===
BUILD SUCCESSFUL in 3s
Task counts: Old API 0, New API 31, total 31
Task counts: created 2, avoided 29, %-lazy 94
Task types that were registered with the new API but were created anyways
class org.gradle.api.tasks.Delete 1
class org.gradle.configuration.Help 1
=== java project: gradle tasks --all with task statistics ===
BUILD SUCCESSFUL in 2s
Task counts: Old API 0, New API 31, total 31
Task counts: created 31, avoided 0, %-lazy 0
Task types that were registered with the new API but were created anyways
class org.gradle.api.DefaultTask 8
class org.gradle.language.jvm.tasks.ProcessResources 2
class org.gradle.api.tasks.compile.JavaCompile 2
...

(두 번째 목록은 스물두 줄이라 앞의 셋만 남겼습니다. 개수가 같은 줄끼리의 순서는 실행마다 다를 수 있습니다.)

그런데 help를 실행했을 뿐인데 Delete 타입 하나(clean)가 만들어졌습니다. 같은 스위치에 파일 경로를 주면(-Dorg.gradle.internal.tasks.stats=/tmp/stats.txt) 실현될 때마다 스택을 그 파일에 남기므로(TaskStatistics.java 107–109행) 범인을 찾을 수 있습니다. 줄인 스택의 핵심은 이렇습니다.

	...
	at org.gradle.api.internal.DefaultNamedDomainObjectCollection$AbstractDomainObjectCreatingProvider.get(DefaultNamedDomainObjectCollection.java:978)
	at org.gradle.api.internal.tasks.DefaultTaskContainer$TaskCreatingProvider.getProducer(DefaultTaskContainer.java:734)
	at org.gradle.api.internal.provider.TransformBackedProvider.beforeRead(TransformBackedProvider.java:94)
	at org.gradle.api.internal.provider.TransformBackedProvider.calculateOwnValue(TransformBackedProvider.java:79)
	...
	at org.gradle.internal.cleanup.DefaultBuildOutputCleanupRegistry.resolveOutputs(DefaultBuildOutputCleanupRegistry.java:79)
	at org.gradle.internal.build.DefaultBuildWorkPreparer.finalizeWorkGraph(DefaultBuildWorkPreparer.java:66)

작업 그래프를 마무리하는 단계에서 "빌드 출력 정리 목록"(DefaultBuildOutputCleanupRegistry)을 풀다가, TransformBackedProvider(map으로 만든 Provider, 5절)가 TaskCreatingProvider.getProducer()를 부르고, 그것이 get()으로 clean을 만들었습니다. 그 map을 건 곳은 Gradle 자신의 LifecycleBasePlugin입니다.

        final Provider<Delete> clean = project.getTasks().register(CLEAN_TASK_NAME, Delete.class, cleanTask -> {
            cleanTask.setDescription("Deletes the build directory.");
            cleanTask.setGroup(BUILD_GROUP);
            cleanTask.delete(buildDir);
        });
        buildOutputCleanupRegistry.registerOutputs(clean.map(cl -> cl.getTargetFiles()));

platforms/software/platform-base/src/main/java/org/gradle/language/base/plugins/LifecycleBasePlugin.java 89–94행

TaskCreatingProvider가 생산자를 알려 주는 방식이 원인입니다.

        @Override
        public ValueProducer getProducer() {
            return ValueProducer.taskState(get());
        }

subprojects/core/src/main/java/org/gradle/api/internal/tasks/DefaultTaskContainer.java 732–735행

"이 값은 누가 만드나"라는 질문에 답하려고 태스크를 get() 합니다. map으로 만든 Provider는 값을 읽기 전에 생산자를 확인하므로(5절의 beforeRead()), 결국 TaskProvider.map {…}을 읽으면 그 태스크가 실현됩니다. 이 글의 그림에서 "의존을 따라갈 때"를 실현 쪽에 둔 이유입니다.

헷갈리기 쉬운 곳

3. configureEach — 지금 있는 것과 나중에 올 것

tasks.configureEach { … }는 "지금 있는 태스크 전부와, 앞으로 생길 태스크 전부에" 블록을 겁니다. 그러면서도 아무것도 실현하지 않습니다. 어떻게 하는지 봅니다.

    @Override
    public void configureEach(Action<? super T> action) {
        assertEagerContext("configureEach(Action)");
        Action<? super T> wrappedAction = wrapLazyAction(decorate(action));
        Action<? super T> registerLazyAddActionDecorated = eventRegister.registerLazyAddAction(wrappedAction);

        // ...
        Iterator<T> iterator = iteratorNoFlush();
        // ... 이미 만든 원소를 copied 에 모은다 ...
        if (copied != null) {
            for (T next : copied) {
                registerLazyAddActionDecorated.execute(next);
            }
        }
    }

platforms/core-configuration/domain-object-collections/src/main/java/org/gradle/api/internal/DefaultDomainObjectCollection.java 194–220행

두 갈래입니다.

  1. 나중에 올 것: eventRegister.registerLazyAddAction()이 블록을 컬렉션의 "더할 때 할 일" 목록(addActions)에 넣습니다. 2절의 tryCreate()가 태스크를 만들 때 onCreate.mergeFrom(getEventRegister().getAddActions())로 이 목록을 합쳐 실행하니, 대기 중이던 태스크는 만들어지는 순간 이 블록을 받습니다.
  2. 지금 있는 것: iteratorNoFlush()로 이미 만들어진 원소만 훑어 지금 실행합니다. 이름의 "NoFlush"가 핵심입니다. 대기 목록을 비우지(flush, 곧 실현하지) 않는 반복자입니다. 줄인 주석(206–209행)도 "estimatedSize()로 크기를 잡으면 대기 중인 원소를 실현해 configureEach가 즉시 실행이 되어 버리니 쓰지 않는다"고 적어 둡니다.

등록할 때 맡긴 블록도 같은 onCreate에 들어갑니다. 공급자 생성자와 configure()입니다.

        public AbstractDomainObjectCreatingProvider(String name, Class<I> type, @Nullable Action<? super I> configureAction) {
            super(name, type);
            this.onCreate = ImmutableActionSet.<I>empty().mergeFrom(getEventRegister().getAddActions());

            if (configureAction != null) {
                configure(configureAction);
            }
        }
        // ...
        @Override
        public void configure(final Action<? super I> action) {
            // ...
            if (object != null) {
                // Already realized, just run the action now
                decoratedAction.execute(object);
                return;
            }
            // Collect any container level add actions then add the object specific action
            onCreate = onCreate.mergeFrom(getEventRegister().getAddActions()).add(decoratedAction);
        }

platforms/core-configuration/domain-object-collections/src/main/java/org/gradle/api/internal/DefaultNamedDomainObjectCollection.java 939–971행

공급자가 생길 때 그때까지의 컨테이너 액션(configureEach 블록)을 먼저 담고, 그 뒤에 register 블록을 붙입니다. 나중에 named(…) { }로 더한 블록은 그 뒤에 붙습니다. 이미 만들어졌으면(object != null) 맡기지 않고 바로 실행합니다. 예제 1에서 configureEach를 register보다 먼저 걸었더니 [configureEach] lazyA → [register block] lazyA 순서로 찍힌 것이 이 순서입니다.

configureEach와 all의 차이. 왼쪽: configureEach(action)는 eventRegister.addActions에 블록을 더하고(registerLazyAddAction), 이미 만든 태스크는 iteratorNoFlush()로 훑어 지금 실행하며, 대기 중인 태스크는 만들 때 tryCreate()가 addActions를 onCreate에 합쳐 실행한다. 오른쪽(빨간색): all(action)과 withType(T) { }는 store.realizePending(type)으로 대기 중인 것을 지금 전부 만들고, registerEagerAddAction()으로 그 타입을 구독해, 앞으로 register하는 같은 타입도 addPending()에서 곧바로 만든다. 초록 상자는 실현을 미루고, 빨간 상자는 실현을 앞당깁니다. 둘 다 "모든 태스크에 블록을 건다"는 결과는 같습니다.

지연이 깨지는 API: all과 블록을 받는 withType

configureEach와 결과가 같아 보이는 all { }은 정반대로 움직입니다.

    @Override
    public <S extends T> DomainObjectCollection<S> withType(Class<S> type, Action<? super S> configureAction) {
        assertEagerContext("withType(Class, Action)");
        DomainObjectCollection<S> result = withType(type);
        result.all(configureAction);
        return result;
    }

platforms/core-configuration/domain-object-collections/src/main/java/org/gradle/api/internal/DefaultDomainObjectCollection.java 231–237행

    private Action<? super T> addEagerAction(Action<? super T> action) {
        store.realizePending(type);
        return eventRegister.registerEagerAddAction(type, decorate(action));
    }

platforms/core-configuration/domain-object-collections/src/main/java/org/gradle/api/internal/DefaultDomainObjectCollection.java 255–258행

all()(168–192행)과 whenObjectAdded()(245–248행)는 이 addEagerAction()을 부릅니다. 첫 줄 store.realizePending(type)이 대기 중인 그 타입의 원소를 지금 전부 만듭니다. 그리고 registerEagerAddAction()은 그 타입을 "구독"으로 표시하는데, 구독된 타입은 앞으로 register할 때도 대기 목록에 넣는 순간 곧바로 만들어집니다(SortedSetElementSource.addPending()의 isSubscribed 검사, 175–183행). 한 번 all을 걸면 그 뒤의 register까지 지연을 잃는 셈입니다.

여기서 함정은 Kotlin DSL의 tasks.withType<Test> { … }입니다. 블록을 받는 이 확장 함수는 withType(S::class.java, configuration), 곧 위의 withType(Class, Action)을 부릅니다(DomainObjectCollectionExtensions.kt 34–35행). 블록을 받는 withType은 all입니다. 지연을 지키려면 tasks.withType<Test>().configureEach { … }처럼 withType()으로 걸러 낸 컬렉션에 configureEach를 걸어야 합니다.

이 블로그 저장소의 build.gradle.kts에도 바로 그 모양이 있습니다.

tasks.withType<Test> {
    useJUnitPlatform()
}

[이 블로그 저장소의 build.gradle.kts 34–36행. 블로그를 만드는 빌드이고 Gradle의 소스가 아닙니다]

포털의 Gradle(9.5.1)에서도 withType(Class, Action)이 all()을 부르고 addEagerAction()이 realizePending()을 부르는 것은 같습니다(9.5.1에서는 이 파일이 subprojects/core 아래에 있었고, 기준 커밋에서는 platforms/core-configuration/domain-object-collections로 옮겨졌습니다). 이 빌드에는 Test 타입 태스크가 test 하나뿐이고 ./gradlew test를 돌릴 때는 어차피 만들어지니 손해가 거의 없지만, 다른 태스크(preview 등)를 돌릴 때도 test가 만들어진다는 뜻입니다.

예제 1-3: -PeagerAll로 withType { } 걸기

예제 1에 -PeagerAll을 주면 다음 블록이 켜집니다.

// -PeagerAll 을 주면: 블록을 받는 withType 은 all() 이라 등록만 된 태스크까지 만든다
if (providers.gradleProperty("eagerAll").isPresent) {
    tasks.withType<DefaultTask> {
        println("[withType block] $name")
    }
}

[예제 ex/u08-register-vs-create/project/build.gradle.kts에서]

=== gradle help -PeagerAll ===
...
[create block] eager
[configureEach] help
[configureEach] projects
...
[configureEach] lazyA
[register block] lazyA
[configureEach] lazyB
[register block] lazyB
[named block] lazyB
[withType block] artifactTransforms
...
[withType block] lazyA
[withType block] lazyB
...
[withType block] wrapper
[script] end
BUILD SUCCESSFUL in 2s

(앞의 세 줄은 gradle help 때와 같아서 뺐고, [configureEach] 줄과 [withType block] 줄은 각각 열여덟 줄, 열아홉 줄이라 가운데를 줄였습니다.)

help 하나만 실행하는데 열여덟 개가 전부 [script] end 전에, 스크립트의 그 줄에서 만들어졌습니다. lazyB의 named 블록까지 돌았습니다. realizePending()이 대기 순서대로(help … lazyB) 만들고, 그다음 all()이 컬렉션을 훑으며 블록을 실행하는데, 태스크 컨테이너는 이름순 정렬 집합이라(SortedSetElementSource) [withType block]은 알파벳 순서로 찍혔습니다.

헷갈리기 쉬운 곳

4. 태스크 객체는 누가 만드나 — 태스크 팩토리와 DefaultTask

2절의 createDomainObject()는 DefaultTaskContainer.createTask()를 거쳐 태스크 팩토리를 부릅니다. 태스크 팩토리는 둘이 겹쳐 있습니다. 빌드 스코프 서비스가 new AnnotationProcessingTaskFactory(instantiator, taskClassInfoStore, new TaskFactory())로 감싸 만들고(BuildScopeServices.java 489–496행), 프로젝트마다 createChild()로 그 프로젝트용을 하나씩 뽑습니다. 바깥의 AnnotationProcessingTaskFactory가 안쪽 TaskFactory를 부릅니다.

안쪽 TaskFactory.create()가 실제로 객체를 만듭니다.

        final Class<? extends DefaultTask> implType;
        if (identity.getTaskType() == Task.class) {
            implType = DefaultTask.class;
        } else if (DefaultTask.class.isAssignableFrom(identity.getTaskType())) {
            implType = identity.getTaskType().asSubclass(DefaultTask.class);
        } else if (identity.getTaskType() == org.gradle.api.internal.AbstractTask.class || identity.getTaskType() == TaskInternal.class) {
            // ...
        } else {
            throw new InvalidUserDataException(String.format(
                "Cannot create task '%s' of type '%s' as directly extending AbstractTask is not supported.",
                // ...
        }
        // ...
        return org.gradle.api.internal.AbstractTask.injectIntoNewInstance(project, identity, new Callable<S>() {
            // ...
                        instance = instantiationScheme.instantiator().newInstanceWithDisplayName(implType, displayName, constructorArgs);

subprojects/core/src/main/java/org/gradle/api/internal/project/taskfactory/TaskFactory.java 63–88행

바깥의 AnnotationProcessingTaskFactory는 만들어진 태스크에 클래스에서 읽은 것을 덧붙입니다.

    private <S extends Task> S process(S task) {
        TaskClassInfo taskClassInfo = taskClassInfoStore.getTaskClassInfo(task.getClass());

        for (TaskActionFactory actionFactory : taskClassInfo.getTaskActionFactories()) {
            ((TaskInternal) task).prependParallelSafeAction(actionFactory.create(instantiator));
        }

subprojects/core/src/main/java/org/gradle/api/internal/project/taskfactory/AnnotationProcessingTaskFactory.java 50–55행

@TaskAction이 붙은 메서드는 이때 액션 목록에 들어갑니다. 태스크를 만든 직후, 설정 블록이 돌기 전입니다. 그 뒤 57–62행에서 @CacheableTask면 캐시를 켜고, 상태 추적을 끄라는 표시가 있으면 끕니다(11편).

태스크 클래스 구조. 인터페이스 Task(doFirst, doLast)를 TaskInternal(getTaskActions, prependParallelSafeAction)이 확장하고, deprecated 표시가 붙은 AbstractTask가 TaskInternal을 구현한다. AbstractTask는 actions 목록, taskInputs(DefaultTaskInputs), taskOutputs(DefaultTaskOutputs)를 가진다. abstract DefaultTask가 AbstractTask를 물려받고, 사용자의 abstract Gen(getOutputFile, @TaskAction run)이 DefaultTask를, Gradle이 만든 Gen_Decorated가 Gen을 물려받는다. Gen_Decorated의 getOutputFile()은 처음 부를 때 프로퍼티 객체를 만든다. AbstractTask는 실행이 시작되면 변경을 막는 TaskMutator와, 입력 선언을 담는 DefaultTaskInputs를 가리킨다. 내가 쓰는 것은 Gen뿐이고, 그 위의 클래스와 아래의 Gen_Decorated는 모두 Gradle이 채웁니다.

액션 목록 — doFirst는 맨 앞, doLast는 맨 뒤

    public Task doFirst(final String actionName, final Action<? super Task> action) {
        // ...
        taskMutator.mutate("Task.doFirst(Action)", new Runnable() {
            @Override
            public void run() {
                getTaskActions().add(0, wrap(action, actionName));
            }
        });
        // ...
    public Task doLast(final String actionName, final Action<? super Task> action) {
        // ...
        taskMutator.mutate("Task.doLast(Action)", new Runnable() {
            @Override
            public void run() {
                getTaskActions().add(wrap(action, actionName));
            }
        });

subprojects/core/src/main/java/org/gradle/api/internal/AbstractTask.java 540–570행

태스크의 액션은 ArrayList 하나(actions, 119행)입니다. doFirst는 0번 자리에, doLast는 맨 뒤에 넣습니다. @TaskAction은 만들 때 이미 들어가 있으니, 설정 블록에서 doFirst·doLast를 하나씩 걸면 순서는 doFirst → @TaskAction → doLast 가 됩니다. doFirst를 여러 번 걸면 나중에 건 것이 앞에 섭니다. 6절 예제의 show는 설정 블록에서 doFirst·doLast를 하나씩 걸고 actions.size를 찍었는데 2가 아니라 3이 나옵니다. 하나는 이미 들어가 있던 @TaskAction입니다.

두 메서드 모두 taskMutator.mutate(…) 안에서 목록을 고칩니다. TaskMutator.mutate()는 태스크 상태가 isConfigurable()이 아니면 IllegalStateException을 던지고, 맞으면 받은 일을 실행하는 짧은 메서드입니다(TaskMutator.java 34–39행). isConfigurable()은 "아직 실행 중도 아니고 실행을 마치지도 않았다"입니다(TaskStateInternal). 그러니 태스크가 실행을 시작한 뒤에는 액션 목록, 입력·출력 선언(DefaultTaskInputs·DefaultTaskOutputs도 같은 taskMutator를 씁니다)을 바꿀 수 없습니다. 7절의 예제에서 실제 메시지를 봅니다.

5. Provider와 Property — 값 대신 "값을 구하는 법"

태스크를 늦게 만드는 것과 같은 생각을 값에도 적용한 것이 Provider API입니다. 구조부터 봅니다.

Provider 계층. 공개 인터페이스 Provider(get, map)와 ValueSupplier(getProducer → ValueProducer)를 내부 인터페이스 ProviderInternal가 함께 확장한다. 추상 클래스 AbstractMinimalProvider가 ProviderInternal을 구현하고 map()은 TransformBackedProvider를 돌려주며 calculateOwnValue()는 하위 클래스가 채운다. TransformBackedProvider(원본 provider와 transformer를 가진다)와 추상 AbstractProperty(state ValueState, value 공급자, producer 소유 태스크)가 AbstractMinimalProvider를 물려받고, DefaultProperty(set(T), set(Provider))가 AbstractProperty를 물려받는다. ValueSupplier는 값을 만드는 쪽인 ValueProducer(visitProducerTasks)를 가리킨다. 왼쪽 줄기는 "값을 어떻게 구하나", 오른쪽 ValueProducer는 "그 값을 누가 만드나"입니다. 6절은 오른쪽을 봅니다.

map은 계산을 미룬다

    @Override
    public <S> ProviderInternal<S> map(final Transformer<? extends @Nullable S, ? super T> transformer) {
        requireNonNull(transformer, "Cannot map a provider using a null transformer.");
        // Could do a better job of inferring the type
        return new TransformBackedProvider<>(null, this, transformer);
    }

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/AbstractMinimalProvider.java 40–45행

map은 아무것도 계산하지 않고 "원본 Provider와 변환 함수"를 쥔 TransformBackedProvider를 돌려줄 뿐입니다. 계산은 그 Provider의 값을 물을 때 합니다.

    @Override
    protected Value<? extends OUT> calculateOwnValue(ValueConsumer consumer) {
        // ...
            beforeRead(context);
            Value<? extends IN> value = provider.calculateValue(consumer);
            return mapValue(context, value);
        // ...
    protected void beforeRead(EvaluationScopeContext context) {
        provider.getProducer().visitContentProducerTasks(producer -> {
            if (!producer.getState().getExecuted()) {
                throw new ProducerNotCompletedException("mapped", provider, producer);
            }
        });
    }

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/TransformBackedProvider.java 76–99행

값을 물으면 그제야 원본의 값을 구해(provider.calculateValue) 변환합니다. 그 앞의 beforeRead()는 6절에서 다시 봅니다. 2절에서 clean을 만들어 버린 것이 바로 이 beforeRead()의 provider.getProducer()였습니다.

Property는 값을 "넣을 수 있는" Provider입니다. objects.property<String>()이 만드는 것은 DefaultProperty이고(DefaultPropertyFactory.property(), 50–68행), 고정 값을 넣는 set(T)는 값을 Providers.fixedValue(…)로 감싸 setSupplier()에 넘깁니다(DefaultProperty.java 81–88행). Provider를 넣는 쪽은 이렇습니다.

    @Override
    public void set(Provider<? extends T> provider) {
        requireNonNull(provider, "Cannot set the value of a property using a null provider.");
        ProviderInternal<? extends T> p = Providers.internal(provider);
        setSupplier(p.asSupplier(getValidationDisplayName(), type, sanitizer));
    }

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/DefaultProperty.java 115–120행

고정 값도 Provider로 감싸서 넣습니다. Property가 들고 있는 것은 언제나 "값을 내주는 공급자"(value 필드, 타입 이름은 S extends ValueSupplier)입니다. 다른 Provider를 넣으면 그 Provider를 그대로 들고 있게 되고, 값은 get() 때 그 공급자 사슬을 따라 계산됩니다.

태스크의 abstract val …: Property<String>은 누가 채우나

예제의 태스크들은 @get:Input abstract val who: Property<String>처럼 프로퍼티를 abstract 게터로만 선언합니다. 이 게터는 Gradle이 만든 _Decorated 하위 클래스가 구현합니다. 4편에서 서비스를 주입하는 게터를 봤는데, 프로퍼티용 게터는 이렇게 생성됩니다.

        public void applyReadOnlyManagedStateToGetter(PropertyMetadata property, Method getter, boolean applyRole) {
            // GENERATE public <type> <getter>() {
            //     if (<field> == null) {
            //         <field> = getFactory().newInstance(this, <display-name>, <type>, <prop-name>);
            //     }
            //     return <field>;
            // }

platforms/core-configuration/model-core/src/main/java/org/gradle/internal/instantiation/generator/AsmBackedClassGenerator.java 1176–1182행

처음 부를 때 ManagedObjectFactory.newInstance()로 프로퍼티 객체를 만들어 필드에 넣고, 이름표(owner)를 붙여 둡니다. 예제 3에서 찍어 보니 who의 실제 클래스는 org.gradle.api.internal.provider.DefaultProperty, toString()은 task ':greet' property 'who'였습니다. 오류 메시지에 이 이름표가 쓰입니다(7절). 1221–1224행에서는 필요하면 applyRole()을 부르는데, 이것이 6절에서 출력 프로퍼티에 생산자를 붙이는 자리입니다.

예제 3 (일부): map은 나중 값을 본다

// 3) map 은 계산을 미룬다 — 원본을 나중에 바꾸면 결과도 바뀐다
val name = objects.property<String>()
val greeting = name.map { "hello, $it" }
name.set("first")
name.set("second")
println("[map] ${greeting.get()}")

[예제 ex/u08-property-finalize/project/build.gradle.kts에서]

[map] hello, second

map을 부른 시점에 name은 비어 있었는데도 문제없고, 결과는 마지막에 넣은 값으로 계산됐습니다. 이것이 "설정 순서를 신경 쓰지 않아도 된다"는 Provider API의 이점입니다. 플러그인이 먼저 값을 이어 두고 사용자가 나중에 빌드 스크립트에서 값을 바꿔도, 나중에 읽는 쪽은 마지막 값을 봅니다.

6. dependsOn 없이 순서가 생기는 이유 — ValueProducer

이제 이 글의 마지막 큰 질문입니다. 파일을 만드는 태스크 gen과 그 파일을 읽는 태스크 show를 이렇게 이었습니다.

예제 2: 출력 Provider를 입력에 잇기

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// 생산 태스크의 출력 Provider 를 소비 태스크의 입력에 이으면, dependsOn 없이도 순서가 생긴다.

import org.gradle.api.internal.provider.ProviderInternal

// 파일을 만드는 태스크
abstract class Gen : DefaultTask() {
    @get:Input
    abstract val text: Property<String>

    @get:OutputFile
    abstract val outputFile: RegularFileProperty
    // ... @TaskAction: text 를 outputFile 에 쓰고 "[gen @TaskAction] wrote …" 를 찍는다 ...
}

// 그 파일을 읽는 태스크
abstract class Show : DefaultTask() {
    @get:InputFile
    abstract val inputFile: RegularFileProperty
    // ... @TaskAction: inputFile 을 읽어 "[show @TaskAction] read: …" 를 찍는다 ...
}

val gen = tasks.register<Gen>("gen") {
    text.set("hello from gen")
    outputFile.set(layout.buildDirectory.file("gen/message.txt"))
}

tasks.register<Show>("show") {
    // dependsOn(gen) 을 쓰지 않는다 — 출력 Provider 를 입력에 잇기만 한다
    inputFile.set(gen.flatMap { it.outputFile })
    doFirst { println("[show doFirst]") }
    doLast { println("[show doLast]") }

    // 내부 API 로 들여다보기: 이 입력 값을 "누가 만드나"
    val producer = (inputFile as ProviderInternal<*>).producer
    println("[configure show] producer of inputFile = ${producer.javaClass.simpleName}")
    producer.visitProducerTasks { println("[configure show] producer task = $path") }
    println("[configure show] actions = ${actions.size}")
}
// ... -PreadEarly 블록 (아래), taskGraph.whenReady 로 그래프 찍기 ...

[예제 ex/u08-provider-wiring/project/build.gradle.kts에서 줄임]

ProviderInternal은 공개 API가 아닌 내부 인터페이스입니다. 소스에서 본 것을 눈으로 확인하려고 썼을 뿐이고, 실제 빌드 스크립트에서 쓸 것은 아닙니다. gradle show --dry-run(-m, 태스크를 실행하지 않고 고르기만 합니다)과 gradle show의 결과입니다.

=== gradle show --dry-run ===
[configure show] producer of inputFile = TaskProducer
[configure show] producer task = :gen
[configure show] actions = 3
[graph] :gen -> :show
:gen SKIPPED
:show SKIPPED
BUILD SUCCESSFUL in 32s
=== gradle show ===
[configure show] producer of inputFile = TaskProducer
[configure show] producer task = :gen
[configure show] actions = 3
[graph] :gen -> :show
> Task :gen
[gen @TaskAction] wrote message.txt
> Task :show
[show doFirst]
[show @TaskAction] read: hello from gen
[show doLast]
BUILD SUCCESSFUL in 3s

show만 요청했고 dependsOn도 없는데, 그래프에 :gen이 :show 앞에 들어갔습니다. show의 입력 inputFile은 자기 값을 만드는 생산자가 TaskProducer, 그 태스크가 :gen이라고 답합니다. 실행 순서도 4절대로 doFirst → @TaskAction → doLast입니다.

생산자는 어디서 붙나

출발점은 gen의 @OutputFile 프로퍼티입니다. 5절의 생성된 게터가 프로퍼티 객체를 만들면서 applyRole()을 부르고, 출력 어노테이션(@OutputFile·@OutputDirectory 등)이 붙은 프로퍼티라면 이 핸들러가 불립니다.

    @Override
    public void applyRoleTo(ModelObject owner, Object target) {
        if (target instanceof PropertyInternal) {
            ((PropertyInternal<?>) target).attachProducer(owner);
        }
    }

subprojects/core/src/main/java/org/gradle/api/internal/tasks/properties/annotations/OutputPropertyRoleAnnotationHandler.java 44–49행

owner는 그 프로퍼티를 가진 태스크(gen)입니다. 이제 gen.outputFile은 "나는 gen의 출력"이라는 것을 압니다. AbstractProperty.getProducer()가 그것을 씁니다.

    @Override
    public ValueProducer getProducer() {
        Task task = getProducerTask();
        if (task != null) {
            return ValueProducer.task(task);
        } else {
            try (EvaluationScopeContext context = openScope()) {
                return getSupplier(context).getProducer();
            }
        }
    }

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/AbstractProperty.java 231–241행

ValueProducer.task()와 taskState()는 이렇게 생겼습니다.

        static ValueProducer task(Task task) {
            return new TaskProducer(task, true);
        }
        // ...
        static ValueProducer taskState(Task task) {
            return new TaskProducer(task, false);
        }

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/ValueSupplier.java 105–114행

둘의 차이는 content 깃발 하나입니다. 자바독대로 task()는 "값과 그 내용까지 태스크가 만든다"(출력 파일 — 파일의 경로는 설정 때 알지만 내용은 태스크가 돌아야 생긴다), taskState()는 "태스크의 프로퍼티에서 나온 값이라 태스크에 기대기만 한다"(2절의 TaskProvider 자체)입니다. 이 깃발이 아래 -PreadEarly 예제의 오류를 가릅니다.

의존은 누가 묻나

show가 무엇에 기대는지는 작업 그래프를 만들 때 묻습니다. 태스크의 의존 목록에는 dependsOn으로 건 것 말고도 입력 선언 전체(taskInputs)가 들어 있습니다(AbstractTask 생성자 207행의 configurableDependency(ImmutableSet.of(taskInputs, lifecycleDependencies))). 입력 쪽 의존은 DefaultTaskInputs.visitDependencies()가 셉니다(193–216행). PropertyWalker가 태스크 클래스의 게터를 훑으며 @Input·@InputFile 같은 어노테이션을 찾아(구현은 DefaultPropertyWalker, 어노테이션별 처리기를 고르는 곳은 DefaultPropertyWalker.java 89–101행) 프로퍼티마다 방문자를 부릅니다. 갈래는 둘입니다.

    @Override
    public void visitDependencies(TaskDependencyResolveContext context) {
        ValueSupplier.ValueProducer producer = provider.getProducer();
        if (producer.isKnown()) {
            producer.visitDependencies(context);
        } else {
            // Producer is unknown, so unpack the value
            // ...
        }
    }

platforms/core-configuration/file-collections/src/main/java/org/gradle/api/internal/file/collections/ProviderBackedFileCollection.java 48–58행

어느 갈래든 끝은 getProducer()입니다. TaskProducer.visitDependencies()는 visitProducerTasks()로 태스크 하나(:gen)를 넘기고(ValueSupplier.java 66–70, 127–129행), 그것이 show의 의존이 됩니다. 생산자를 모르면(isKnown()이 거짓) 값을 직접 풀어 봅니다. 이 의존을 받아 노드를 잇고 순서를 정하는 것은 9편의 실행 계획입니다.

dependsOn 없이 의존이 생기는 길. gen의 outputFile은 @OutputFile 때문에 attachProducer(gen)가 붙어 있다. gen.flatMap { it.outputFile }(FlatMapProvider)를 show의 inputFile.set(…)에 넣으면 show의 입력은 값 대신 공급자를 들고 있다. 작업 그래프를 만들 때 show의 의존을 물으면 DefaultTaskInputs.visitDependencies()가 PropertyWalker로 @InputFile을 찾고, inputFile.getProducer()가 공급자를 따라 공급자의 getProducer()를 불러 TaskProducer(:gen)에 닿는다. 그 결과 실행 계획에 :gen → :show가 생긴다(9편). 회색이 빌드 스크립트에서 한 일, 파란색이 Gradle이 의존을 물을 때 하는 일입니다. 점선이 "공급자를 따라" 묻는 길입니다.

왜 이렇게 만들었나. dependsOn(gen)은 "순서"만 알려 주고, 어느 파일을 주고받는지는 모릅니다. 출력 Provider를 입력에 이으면 "무엇을 주고받는지"(파일 경로)와 "누가 먼저인지"(생산자)가 한 번에 선언됩니다. 출력 경로를 바꿔도 소비 쪽을 고칠 필요가 없고, 생산 태스크를 그래프에서 빼먹는 실수도 생기지 않습니다.

예제 2-2: 설정 단계에서 출력 "내용"을 읽으면

예제 2에 -PreadEarly를 주면 설정 단계에서 gen의 출력 파일 내용을 map으로 읽으려 합니다.

// -PreadEarly: 설정 단계에서 생산 태스크의 출력 "내용"을 읽으려 하면
if (providers.gradleProperty("readEarly").isPresent) {
    val content = gen.flatMap { it.outputFile }.map { it.asFile.readText() }
    try {
        println(content.get())
    } catch (e: Exception) {
        println("[readEarly] ${e.javaClass.simpleName}: ${e.message}")
    }
}

[예제 ex/u08-provider-wiring/project/build.gradle.kts에서]

=== gradle help -PreadEarly ===
[readEarly] ProducerNotCompletedException: Querying the mapped value of flatmap(provider(task 'gen', class Build_gradle$Gen)) before task ':gen' has completed is not supported. The mapped value may depend on the content produced by task ':gen', so it can only be calculated after task ':gen' has completed.
BUILD SUCCESSFUL in 2s

5절의 TransformBackedProvider.beforeRead()가 던진 예외입니다. 원본(flatMap)의 생산자가 task()로 만든 것, 곧 content == true라서 visitContentProducerTasks()가 :gen을 넘겼고, :gen이 아직 실행되지 않았으니(getExecuted() == false) 막았습니다. 파일 경로는 설정 때 알 수 있지만, 파일 내용은 태스크가 돈 뒤에만 안다는 구분을 소스가 지켜 주는 셈입니다. ProducerNotCompletedException은 해결책 세 가지(다른 태스크의 입력으로 선언하라, 그 태스크의 액션 안에서 get() 하라, 위치만 필요하면 locationOnly를 쓰라)도 함께 들고 있습니다(ProducerNotCompletedException.java 30–42행). 예제에서는 예외를 잡아 메시지만 찍어서 빌드가 성공으로 끝났습니다.

헷갈리기 쉬운 곳

7. 값을 굳히기 — finalizeValueOnRead, disallowChanges, 그리고 실행 직전

Provider의 "나중에 계산한다"에는 대가가 있습니다. 누군가 읽은 뒤에 값이 바뀌면, 앞에서 읽은 쪽과 뒤에서 읽은 쪽이 서로 다른 값을 보게 됩니다. 그래서 Property는 값을 굳히는(finalize) 방법을 셋 가지고 있습니다.

    public void disallowChanges() {
        state.disallowChanges();
    }
    // ...
    public void finalizeValueOnRead() {
        state.finalizeOnNextGet();
    }
    // ...
    public void implicitFinalizeValue() {
        if (state.isUpgradedPropertyValue()) {
            // Upgraded properties should not be finalized to simplify migration.
            // This behaviour should be removed with Gradle 10.
            state.warnOnUpgradedPropertyValueChanges();
        } else {
            state.disallowChangesAndFinalizeOnNextGet();
        }
    }

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/AbstractProperty.java 253–271행

세 메서드 모두 state(ValueState)에 깃발을 세울 뿐입니다. ValueState의 굳지 않은 상태(NonFinalizedValue)는 깃발을 byte 하나(flags)에 비트로 담고(194–202행), 소스 주석은 그 이유를 "Gradle 자신의 빌드에는 ValueState가 100만 개 넘게 생겨서 참조 하나만 늘어도 메모리가 크게 는다"고 적어 둡니다(184–191행). 깃발이 실제로 쓰이는 곳은 값을 바꾸려 할 때입니다.

        @Override
        public void beforeMutate(Describable displayName) {
            if ((flags & DISALLOW_CHANGES) != 0) {
                throw new IllegalStateException(String.format("The value for %s cannot be changed any further.", displayName.getDisplayName()));
            } else if ((flags & WARN_ON_UPGRADED_PROPERTY_CHANGES) != 0) {
                // ...

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/ValueState.java 245–249행

이것은 굳지 않았지만 DISALLOW_CHANGES 깃발이 선 상태의 검사이고, 다 굳은 상태(FinalizedValue)의 beforeMutate()는 묻지도 않고 "The value for %s is final and cannot be changed any further."를 던집니다(436–439행). set()은 setSupplier() → assertCanMutate() → state.beforeMutate()를 거치므로(AbstractProperty.java 285–288, 401–403행) 여기서 막힙니다. 메시지에 "is final"이 들어 있느냐로 어느 상태였는지 알 수 있습니다.

그런데 beforeMutate()는 FINALIZE_ON_NEXT_GET은 보지 않습니다. finalizeValueOnRead()는 "다음에 읽을 때 굳혀라"라는 표시일 뿐, 읽기 전까지는 여전히 바꿀 수 있습니다. 실제로 굳는 것은 읽을 때 beforeRead() → finalizeOnReadIfNeeded()가 깃발을 보고 finalizeNow()로 값을 계산해 고정하고, 상태를 FinalizedValue로 바꿀 때입니다(298–321행).

Property 값의 상태. 처음은 바꿀 수 있음(NonFinalizedValue). finalizeValueOnRead()를 부르면 읽으면 굳는다는 표시(FINALIZE_ON_NEXT_GET)가 서고 set()은 아직 된다. disallowChanges()를 부르면 바꾸기 금지(DISALLOW_CHANGES)가 되어 set()이 cannot be changed any further로 실패한다. implicitFinalizeValue()는 둘 다를 세우는데, 태스크 실행 직전에 Gradle이 건다. finalizeValue()는 곧바로 굳음 상태로 간다. 읽으면 굳는다는 표시 상태와 둘 다 상태에서 처음 get()을 하면 굳음(FinalizedValue)이 되어 값을 고정하고, 이후 set()은 is final로 실패한다. disallowChanges()만 걸면 굳음 상태로는 가지 않습니다. set()은 막히지만, 값은 여전히 읽을 때마다 공급자 사슬을 따라 계산됩니다.

태스크의 입력은 실행 직전에 Gradle이 굳힌다

태스크의 입력·출력 프로퍼티에는 Gradle이 직접 implicitFinalizeValue()를 겁니다. 태스크 노드를 실행할 때, 실행 엔진(10편)에 넘기기 직전입니다.

    private ExecutionEngine.@Nullable Result executeFinalizingProperties(LocalTaskNode node) {
        TaskProperties properties = node.getTaskProperties();
        for (LifecycleAwareValue value : properties.getLifecycleAwareValues()) {
            value.prepareValue();
        }
        try {
            return executeTrackingTaskIdentity(node);
        // ... finally: cleanupValue() ...

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

prepareValue()는 프로퍼티 값의 maybeFinalizeValue()를 부르고(AbstractValidatingProperty.java 92–95행), 그것이 값이 HasConfigurableValue(곧 Property 계열)일 때 implicitFinalizeValue()를 부릅니다(DefaultPropertyWalker.java 141–147행). 위에 인용한 executeFinalizingProperties()의 자바독(304–306행)은 그 이유를 "입력과 출력의 핑거프린트를 효율적으로 뜨고 검증할 수 있게 상태를 굳히고 캐시해 두라고 알리는 것"이라고 설명합니다. 실행 엔진이 "입력이 지난번과 같은가"를 판단하는 동안, 그리고 태스크 액션이 도는 동안 입력이 바뀌면 안 되기 때문입니다.

예제 3: 세 가지 굳히기와 실행 중의 변경

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// Property 를 "언제부터" 못 바꾸게 되는지 본다.

// ... attempt(label) { … }: 블록을 실행해 "[label] ok" 또는 IllegalStateException 의 메시지를 찍는다 ...

// 1) finalizeValueOnRead: 표시만 해 두고, 처음 읽을 때 굳는다
val a = objects.property<String>()
println("[a: class] ${a.javaClass.name}")
a.set("one")
a.finalizeValueOnRead()
attempt("a: set before read") { a.set("two") }
println("[a: get] ${a.get()}")
attempt("a: set after read") { a.set("three") }

// 2) disallowChanges: 그 자리에서 바꾸기를 막는다
// ... b 를 set("x") → disallowChanges() → attempt { b.set("y") } ...
// ... 3) map 예제 (5절) ...

// 4) 태스크의 입력 프로퍼티는 실행 직전에 Gradle 이 굳힌다
abstract class Greet : DefaultTask() {
    @get:Input
    abstract val who: Property<String>

    @TaskAction
    fun run() {
        println("[greet] hello, ${who.get()}")
        // 스크립트의 함수(attempt)를 부르면 스크립트에 묶인 안쪽 클래스가 되므로 여기서는 try 를 직접 쓴다
        try {
            who.set("changed")
        } catch (e: IllegalStateException) {
            println("[greet: set input during execution] ${e.message}")
        }
        // ... 같은 모양으로 doLast { } 를 불러 본다 ("[greet: doLast during execution] …") ...
    }
}

tasks.register<Greet>("greet") {
    who.set("gradle")
    // abstract 게터가 돌려주는 실제 객체
    println("[configure greet] who is ${who.javaClass.name}: $who")
}

[예제 ex/u08-property-finalize/project/build.gradle.kts에서 줄임]

=== gradle greet ===
[a: class] org.gradle.api.internal.provider.DefaultProperty
[a: set before read] ok
[a: get] two
[a: set after read] The value for this property is final and cannot be changed any further.
[b: set after disallowChanges] The value for this property cannot be changed any further.
[map] hello, second
[configure greet] who is org.gradle.api.internal.provider.DefaultProperty: task ':greet' property 'who'
> Task :greet
[greet] hello, gradle
[greet: set input during execution] The value for task ':greet' property 'who' is final and cannot be changed any further.
[greet: doLast during execution] Cannot call Task.doLast(Action) on task ':greet' after task has started execution.
BUILD SUCCESSFUL in 25s

헷갈리기 쉬운 곳

이 글에서 뺀 것

정리

개념 한 줄 요약
register TaskCreatingProvider를 만들어 대기 목록에 넣을 뿐. 태스크 객체도 설정 블록 실행도 없다(Register task)
실현 공급자의 값을 처음 물을 때 tryCreate()가 태스크를 만들고 onCreate 액션을 실행한다(Realize task)
실현을 일으키는 것 get·findByName·getByName, 명령줄 이름 해석, 반복(size는 아님), all·whenObjectAdded·블록을 받는 withType, 생산자 묻기
create 그 자리에서 만들고 설정한다. 이 커밋에서 @Deprecated
configureEach 컬렉션의 addActions에 넣고, 이미 만든 것만 iteratorNoFlush()로 지금 실행. 대기 중인 것은 만들 때 받는다
withType<T> { } all()이다. 대기 중인 것을 전부 만들고, 그 타입을 구독해 이후 register도 즉시 만든다
태스크 팩토리 AnnotationProcessingTaskFactory가 TaskFactory를 감싼다. DefaultTask 하위 클래스만, @TaskAction은 만들 때 액션 목록에
액션 목록 ArrayList 하나. doFirst는 0번, doLast는 끝. 실행이 시작되면 TaskMutator가 막는다
map TransformBackedProvider를 돌려줄 뿐. 계산은 get() 때, 원본의 마지막 값으로
Property.set 고정 값도 공급자로 감싸 들고 있다. 다른 Provider를 넣으면 그 사슬을 들고 있다
ValueProducer "이 값은 누가 만드나". 출력 프로퍼티는 attachProducer()로 소유 태스크를 안다
dependsOn 없는 순서 입력 프로퍼티 → getProducer() → TaskProducer → 의존. DefaultTaskInputs.visitDependencies()
ProducerNotCompletedException 내용까지 태스크가 만드는 값을 그 태스크 실행 전에 map으로 읽으면
굳히기 finalizeValueOnRead(읽을 때), disallowChanges(지금 set 금지), implicitFinalizeValue(실행 직전, Gradle이)

다음에 볼 것

이 글에서 show는 gen에 기댄다는 사실을 의존으로 내놓는 데까지 왔습니다. 그다음, 요청한 태스크에서 출발해 의존을 따라 노드를 모으고, mustRunAfter·finalizedBy까지 반영해 순서를 정하고, 워커 스레드 여러 개에 나눠 실행하는 것이 작업 그래프와 실행 계획입니다. 2절에서 본 TaskNameResolver가 고른 태스크가 어떻게 DefaultExecutionPlan의 노드가 되는지, --parallel이 없으면 왜 한 번에 태스크 하나만 도는지를 9편: 태스크 그래프와 실행 계획 — 무엇을, 어떤 순서로, 몇 개씩에서 봅니다.

앞 연재에서 닮은 것을 다뤘습니다. "등록만 하고 필요할 때 만든다"는 Spring 3편의 BeanDefinition과 같은 생각입니다. Spring도 빈의 설계도(BeanDefinition)만 먼저 등록하고 객체는 나중에 만듭니다. 다만 Spring은 refresh()의 끝에서 lazy-init이 아닌 싱글턴을 모두 미리 만드는 쪽이 기본이고(Spring 7편), Gradle은 반대로 그래프에 들어가는 것만 만드는 쪽이 기본입니다. 값 쪽의 Provider는 Spring 5편의 ObjectProvider·@Lazy처럼 "지금 찾지 않고 나중에 꺼낸다"는 점이 닮았는데, Gradle의 Provider는 여기에 "누가 만드나"(생산자)를 함께 들고 다녀 실행 순서까지 정한다는 점이 다릅니다.

다른 글