이 글에서 다루는 것
"Gradle 소스 파헤치기" 연재의 여덟 번째 글입니다. 7편에서는 plugins {} 블록의 플러그인이 apply()까지 가는 길을 봤고, 그 끝에서 JavaPlugin 같은 플러그인이 tasks.register(…)로 태스크를 잔뜩 등록하는 것을 봤습니다. 이번 글은 그 등록된 태스크가 어떻게 생기고, 설정되고, 서로 이어지는지를 따라갑니다.
2편의 지도로 돌아가면, 이 글은 큰 지도 그림의 "D · 설정 단계" 구역에서 세 번째 상자, "태스크 등록 · Provider(지연 설정)"를 확대합니다. 6편이 빌드 스크립트를 실행하고 7편이 플러그인을 걸었다면, 그 둘이 하는 일의 대부분은 결국 태스크 컨테이너에 무언가를 등록하고 Property에 값을 잇는 것입니다. 5편에서 register한 태스크의 설정 블록이 projectsEvaluated 다음에 찍힌 것을 봤는데, 그 이유가 이 글의 첫 번째 주제입니다.
답하려는 질문은 네 가지입니다.
tasks.register("x") { … }는 무엇을 돌려주고, 설정 블록은 언제 도는가.create와는 무엇이 다른가configureEach는 아직 만들지 않은 태스크에 어떻게 걸리는가. 어떤 API를 쓰면 지연이 깨지는가- 태스크 객체는 누가 만들고,
doFirst·doLast·@TaskAction은 어디에 쌓이는가 - 출력
Provider를 입력에 이으면dependsOn없이 순서가 생기는 이유는 무엇인가.Property는 언제부터 바꿀 수 없게 되는가
| 항목 | 내용 |
|---|---|
| 대상 | 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행
이 메서드가 하는 일은 네 가지뿐입니다.
- 같은 이름이 이미 있으면 실패합니다(
hasWithName()— 만들어진 것과 대기 중인 것을 함께 봅니다). - 이름·타입·프로젝트를 묶은
TaskIdentity를 만듭니다. TaskCreatingProvider를 하나 만듭니다. 생성자에 넘기는 것은 신분(identity)과 설정 블록(configurationAction)입니다. 태스크 객체가 아닙니다.- 그 공급자를
addLaterInternal()로 컬렉션의 대기 목록(pending)에 넣고 돌려줍니다.
Task 객체는 어디에서도 만들지 않습니다. 설정 블록도 부르지 않고 공급자 안에 맡겨만 둡니다. 이 모든 일은 Register task :lazyA라는 빌드 오퍼레이션으로 감싸집니다. 줄인 466–468행은 eagerlyCreateLazyTasks면 곧바로 get()하는 자리인데, 이 깃발은 시스템 프로퍼티 org.gradle.internal.tasks.eager(79행)로 켜는, 등록하자마자 만들게 하는 내부용 스위치입니다.
위 절반(①–④)이 설정 단계에서 빌드 스크립트가 도는 동안, 아래 절반(⑤–⑨)은 누군가 그 태스크를 처음 필요로 할 때 일어납니다.
예제 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분 반 정도까지 걸렸습니다.)
register가 돌려준 것은TaskCreatingProvider_Decorated입니다. 위 코드 3번의 공급자이고, 끝의_Decorated는 4편에서 본, Gradle이 실행 중에 만든 하위 클래스입니다(getInstantiator().newInstance(…)로 만들었기 때문입니다).lazyA,lazyB의 블록은 한 줄도 찍히지 않았습니다.named블록도 마찬가지입니다. 이 빌드는help만 실행하니 두 태스크는 끝까지 필요 없었습니다.create로 만든eager는 스크립트가 도는 그 자리에서configureEach블록과create블록이 모두 돌았습니다.help의configureEach는[script] end뒤에 찍혔습니다.help태스크도 Gradle이register로 등록해 둔 것이라, 실행할 태스크를 고르는 시점에 처음 만들어졌습니다.
같은 빌드를 빌드 오퍼레이션 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보다 먼저 찍힌 것이 이 순서입니다.
헷갈리기 쉬운 곳
create는 이 커밋에서@Deprecated입니다.TaskContainer의create(…)여덟 개 모두에 붙어 있고, 자바독은register를 쓰라고 합니다(TaskContainer.java168–172행 등). 출시판 9.8.2의TaskContainer에도 이미 같은 표시가 있습니다. 다만DefaultTaskContainer의create는 실행 중에 경고를 찍는 코드(DeprecationLogger)를 부르지 않습니다. 컴파일러가 보는 어노테이션일 뿐이어서, 예제에서는 Kotlin 컴파일 경고를 막으려고@Suppress("DEPRECATION")을 붙였습니다.TaskContainer에는add가 없는 셈입니다.add(Task),addLater(Provider)는 모두UnsupportedOperationException("… Use register() instead." 또는 "… Use the register() method instead.")을 던집니다(791–811행). 태스크는 반드시 컨테이너가 신분을 붙여 만들어야 하기 때문입니다(4절).
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();
}
}
- 처음 값을 물으면
tryCreate()로 객체를 만듭니다(createDomainObject()— 태스크 컨테이너에서는 4절의 태스크 팩토리를 부릅니다). doAdd(object, onCreate)가 객체를 컬렉션에 넣으면서onCreate에 쌓아 둔 액션을 실행합니다.register의 설정 블록,named로 더한 블록,configureEach블록이 모두 이onCreate안에 있습니다(3절).- 만든 뒤에는
object필드에 들고 있다가 다음부터는 그것을 돌려줍니다. 실패도failure에 기억해 두고 다음에 또 물으면 같은 예외를 던집니다. finally에서onCreate를 비웁니다. 한 번 만든 뒤에는 맡겨 둔 블록이 필요 없기 때문입니다.
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 때와 같아서 뺐습니다). 스택은 위가 안쪽입니다. 아래에서부터 읽으면:
- 5편에서 본 생명주기의 "실행할 태스크를 작업 그래프에 넣는" 단계(
addRequestedTasks→scheduleRequestedTasks)에서 - 명령줄의
lazyA라는 이름을 해석하는CommandLineTaskParser→DefaultTaskSelector→TaskNameResolver가 tasks.getByName("lazyA")를 부르고,findByName()이 대기 목록에서 공급자를 찾아getOrNull()로 값을 물으니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)을 돌려 본다 ...
이미 만든 것에 없으면 대기 목록(index.getPending)을 보고, 거기 있으면 getOrNull()을 불러 만들어지는 부수 효과를 일으킨 다음 다시 찾습니다. 소스 주석도 "부수 효과로 원소가 더해진다고 가정한다"고 적어 둡니다. 그러니 findByName·getByName은 이름으로 찾는 메서드이면서 동시에 실현하는 메서드입니다.
TaskNameResolver는 실현을 아끼려고 먼저 이름 목록으로 있는지만 봅니다(getNames().contains(taskName), TaskNameResolver.java 110–112행). 이름 목록은 대기 중인 공급자의 이름까지 담고 있어서 만들지 않고도 답할 수 있습니다. 실현은 고른 태스크만 getByName()으로 합니다(114–120행). 고른 태스크가 의존하는 태스크들을 따라가며 실현하는 것은 작업 그래프 쪽 일이고 9편에서 봅니다.
빨간 상자는 "모르는 새" 여러 태스크를 한꺼번에 만드는 것들입니다. 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
...
(두 번째 목록은 스물두 줄이라 앞의 셋만 남겼습니다. 개수가 같은 줄끼리의 순서는 실행마다 다를 수 있습니다.)
java플러그인 하나가 등록하는 태스크까지 합쳐 31개가 모두 "New API"(register)로 등록됐습니다.gradle help는 그 가운데 2개만 만들었습니다(94%를 피했습니다).gradle tasks --all은 태스크 목록을 보여 줘야 하니 31개를 전부 만듭니다.
그런데 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()));
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 {…}을 읽으면 그 태스크가 실현됩니다. 이 글의 그림에서 "의존을 따라갈 때"를 실현 쪽에 둔 이유입니다.
헷갈리기 쉬운 곳
named는 만들지 않고,getByName·findByName은 만듭니다. 이름이 비슷해서 자주 섞입니다.named(name)은 대기 목록에서 공급자를 찾아 그대로 돌려주고(searchForDomainObject(), 847–863행),named(name) { }는 그 공급자의configure()로 블록을 맡깁니다(3절). 예제 1의[named block] lazyB가 끝까지 찍히지 않은 이유입니다.TaskProvider로dependsOn을 걸어도 의존을 계산할 때는 결국 만들어집니다.DefaultTaskDependency는Provider를 받으면provider.getProducer()를 묻는데(DefaultTaskDependency.java113–122행), 위에서 본 대로TaskCreatingProvider는 그 답을 내려고get()합니다. 지연 설정이 아끼는 것은 "그래프에 들어가지 않는 태스크"이고, 그래프에 들어가는 태스크는 어차피 만들어집니다.
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);
}
}
}
두 갈래입니다.
- 나중에 올 것:
eventRegister.registerLazyAddAction()이 블록을 컬렉션의 "더할 때 할 일" 목록(addActions)에 넣습니다. 2절의tryCreate()가 태스크를 만들 때onCreate.mergeFrom(getEventRegister().getAddActions())로 이 목록을 합쳐 실행하니, 대기 중이던 태스크는 만들어지는 순간 이 블록을 받습니다. - 지금 있는 것:
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);
}
공급자가 생길 때 그때까지의 컨테이너 액션(configureEach 블록)을 먼저 담고, 그 뒤에 register 블록을 붙입니다. 나중에 named(…) { }로 더한 블록은 그 뒤에 붙습니다. 이미 만들어졌으면(object != null) 맡기지 않고 바로 실행합니다. 예제 1에서 configureEach를 register보다 먼저 걸었더니 [configureEach] lazyA → [register block] lazyA 순서로 찍힌 것이 이 순서입니다.
초록 상자는 실현을 미루고, 빨간 상자는 실현을 앞당깁니다. 둘 다 "모든 태스크에 블록을 건다"는 결과는 같습니다.
지연이 깨지는 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;
}
private Action<? super T> addEagerAction(Action<? super T> action) {
store.realizePending(type);
return eventRegister.registerEagerAddAction(type, decorate(action));
}
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]은 알파벳 순서로 찍혔습니다.
헷갈리기 쉬운 곳
- 반복도 실현입니다.
tasks.forEach { },for (t in tasks)는 반복자를 묻고,SortedSetElementSource.iterator()는 첫 줄에서realizePending()을 부릅니다(82–86행). 반면tasks.size는 만들지 않습니다.size()(68–75행)는 대기 중인 공급자마다collector.size()를 더할 뿐이고, 태스크 하나짜리 공급자의 수집기(Collectors.ElementFromProvider)는 값을 묻지 않고 1을 돌려줍니다. 태스크 둘을 등록한 빌드에서tasks.size를 찍어 봤더니 등록 블록은 돌지 않았고, 이어서tasks.forEach { }를 부르자 그제야 돌았습니다(이 확인은 글에 싣지 않은 따로 돌린 빌드입니다). withType()(블록 없이)은 괜찮습니다. 걸러 낸 "보기"(view)만 돌려주고 아무것도 만들지 않습니다(150–152행). 위험한 것은 블록을 함께 넘기는 오버로드입니다.- 지연 블록 안에서는 컨테이너를 바꿀 수 없습니다.
configureEach·register의 블록은wrapLazyAction()으로 감싸지고, 그 안에서register·add같은 변경 메서드를 부르면assertCanMutate()→assertEagerContext()가 막습니다. 소스 주석은 "사용자는 지연 콜백 안에서 컨테이너를 바꿀 수 없다"고 적어 둡니다(DefaultDomainObjectCollection.java474–476행). 이 경우의 오류 메시지는 예제로 확인하지 않았습니다.
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행
- 타입을 주지 않은
register("x")는DefaultTask.class로 등록되고(register(String)431행), 여기서 그대로DefaultTask가 됩니다. - 모든 태스크는
DefaultTask의 하위 클래스여야 합니다.AbstractTask를 직접 물려받은 타입은 거절됩니다.AbstractTask는 이 커밋에서 "Gradle 10에서 지운다"는@Deprecated클래스입니다(AbstractTask.java106–111행). 그런데 상태와 기능은 거의 다 그쪽에 있고,DefaultTask는 그 메서드들을 다시 선언해 부모를 부르기만 하는 것이 대부분입니다. 소스 주석은 그 이유를 "Eclipse가 deprecated된AbstractTask의 메서드를DefaultTask에서도 deprecated로 보이게 하기 때문"이라고 적습니다(DefaultTask.java51–52행). injectIntoNewInstance()는 스레드 로컬에 신분과 프로젝트를 넣고 생성자를 부르는 기법입니다. 태스크를new로 만들 수 없는 이유, 그리고 실제 객체가…_Decorated인 이유(instantiator가 Gradle이 만든 하위 클래스를 씁니다)는 4편 6절에서 스택과 함께 자세히 봤습니다.
바깥의 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));
}
@TaskAction이 붙은 메서드는 이때 액션 목록에 들어갑니다. 태스크를 만든 직후, 설정 블록이 돌기 전입니다. 그 뒤 57–62행에서 @CacheableTask면 캐시를 켜고, 상태 추적을 끄라는 표시가 있으면 끕니다(11편).
내가 쓰는 것은 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입니다. 구조부터 봅니다.
왼쪽 줄기는 "값을 어떻게 구하나", 오른쪽 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);
}
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);
}
});
}
값을 물으면 그제야 원본의 값을 구해(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));
}
고정 값도 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>;
// }
처음 부를 때 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);
}
}
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();
}
}
}
- 자기가 어떤 태스크의 출력이면(
gen.outputFile)ValueProducer.task(gen)을 답합니다. - 아니면(
show.inputFile) 자기가 들고 있는 공급자에게 다시 묻습니다.show.inputFile이 들고 있는 것은gen.flatMap { it.outputFile }이고,FlatMapProvider.getProducer()는 펼친 결과(gen.outputFile)에게 묻고(FlatMapProvider.java80–84행), 그것이TaskProducer(:gen)을 돌려줍니다.map도 같은 식으로 원본에게 넘깁니다(TransformBackedProvider55–60행).
ValueProducer.task()와 taskState()는 이렇게 생겼습니다.
static ValueProducer task(Task task) {
return new TaskProducer(task, true);
}
// ...
static ValueProducer taskState(Task task) {
return new TaskProducer(task, false);
}
둘의 차이는 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행) 프로퍼티마다 방문자를 부릅니다. 갈래는 둘입니다.
@Input처럼 파일이 아닌 입력은 값의getTaskDependencies()를 넣습니다. 값이Provider면 Provider 자신이 그 역할을 하고(DefaultPropertyWalker126–129행),AbstractMinimalProvider.visitDependencies()는 주석 그대로 "입력으로 쓰일 때는 생산 태스크를 더한다" —getProducer().visitDependencies(context)한 줄입니다(135–139행).- 예제의
inputFile같은 파일 입력은 값을 파일 컬렉션으로 감싸 넣습니다(resolveInputFileValue()). 그 안에서 Provider는ProviderBackedFileCollection이 되는데, 이 컬렉션이 의존을 알려 주는 방법도 같습니다.
@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
// ...
}
}
어느 갈래든 끝은 getProducer()입니다. TaskProducer.visitDependencies()는 visitProducerTasks()로 태스크 하나(:gen)를 넘기고(ValueSupplier.java 66–70, 127–129행), 그것이 show의 의존이 됩니다. 생산자를 모르면(isKnown()이 거짓) 값을 직접 풀어 봅니다. 이 의존을 받아 노드를 잇고 순서를 정하는 것은 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행). 예제에서는 예외를 잡아 메시지만 찍어서 빌드가 성공으로 끝났습니다.
헷갈리기 쉬운 곳
map이 아니라flatMap을 쓴 이유.gen.map { it.outputFile }이면 결과가Provider<RegularFileProperty>(Provider 안의 Provider)가 됩니다.flatMap은 안쪽 Provider를 펼쳐Provider<RegularFile>을 줍니다.dependsOn이 아예 필요 없는 것은 아닙니다. 파일을 주고받지 않는 순서(예: "배포 전에 테스트")나, 입력으로 선언하지 않은 파일에 기대는 경우에는 여전히dependsOn을 씁니다. 입력으로 선언하지 않은 생산 태스크의 출력을 읽으면 Gradle이 경고하는데(MissingTaskDependencyDetector), 이것은 9편에서 봅니다.
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();
}
}
세 메서드 모두 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) {
// ...
이것은 굳지 않았지만 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행).
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() ...
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
a:finalizeValueOnRead()뒤에도 읽기 전에는set("two")가 됐고, 읽은 값도two입니다. 읽은 뒤의set은 "is final" 메시지로 실패했습니다.b:disallowChanges()는 곧바로 막았고, 메시지에 "is final"이 없습니다. 위 그림의 빨간 상자입니다.- 이름표가 없는 프로퍼티(
objects.property()로 만든 것)는this property, 태스크의 프로퍼티는task ':greet' property 'who'로 불립니다. 5절의 생성된 게터가 붙인 이름표입니다. greet의 액션 안에서 자기 입력who를 바꾸려 하자 "is final"로 실패했습니다. 실행 직전에implicitFinalizeValue()가 두 깃발을 세웠고, 그 뒤 누군가 값을 읽어(입력 핑거프린트를 뜨거나 액션의who.get()) 굳음 상태가 된 것입니다.- 같은 액션 안의
doLast { }는 4절의TaskMutator가 막았습니다. 메시지의Task.doLast(Action)이mutate()에 넘긴 이름 그대로입니다.
헷갈리기 쉬운 곳
finalizeValueOnRead()는 "지금부터 못 바꾼다"가 아닙니다. 이름 그대로 "읽을 때 굳힌다"입니다. 플러그인이 확장 객체의 프로퍼티에 걸어 두면, 사용자는 빌드 스크립트에서 여전히 값을 바꿀 수 있고, 처음 읽힌 순간부터 바꿀 수 없게 됩니다. 지금 당장 막으려면disallowChanges()를, 지금 계산해 굳히려면finalizeValue()를 씁니다.- 처음 예제를 돌렸을 때
greet태스크를 만들지 못했습니다. 처음에는 태스크 액션 안에서도 스크립트 맨 위의attempt()함수를 불렀는데, 그러자Could not create task of type 'Greet'.→Class Build_gradle.Greet is a non-static inner class.로 실패했습니다. 메시지대로Greet가 스크립트 클래스(Build_gradle)에 묶인 안쪽 클래스가 되었고, Gradle은 그런 클래스로 태스크를 만들지 못합니다. 그래서Greet안에서는try를 직접 썼습니다(위 코드의 주석). 스크립트가 어떤 클래스로 컴파일되는지는 6편의 영역이라, 이 원인을 소스로 따라가지는 않았습니다. - "업그레이드된 프로퍼티"는 예외입니다.
implicitFinalizeValue()의 첫 갈래(264–267행)는 예전에 일반 게터·세터였다가Property로 바뀐 프로퍼티(@ReplacesEagerProperty)를 굳히지 않고, 실행 중에 바꾸면 경고만 냅니다. 주석은 "Gradle 10에서 없앤다"고 적어 둡니다. 이 갈래는 예제로 돌려 보지 않았습니다.
이 글에서 뺀 것
- 소프트웨어 모델(규칙 기반 모델)과 태스크 컨테이너를 잇는 다리(
TaskInstantiator,realize(),discoverTasks()):DefaultTaskContainerFactory.java113–170행,DefaultTaskContainer.java586–633행 replace()와 태스크 규칙(addRule,clean<태스크>같은 이름 규칙):DefaultTaskContainer.java245–285·474–496행,DefaultNamedDomainObjectCollection.java467–506행,LifecycleBasePlugin의CleanRule- 설정 캐시에서 태스크를 되살릴 때 쓰는 길(
AddTaskBehavior.ASSUME_UNIQUE,createWithoutConstructor()):DefaultTaskContainer.java236–243·498–502행 — 11편 ListProperty·SetProperty·MapProperty와 파일 프로퍼티(DefaultFilePropertyFactory),convention():platforms/core-configuration/model-core/…/api/internal/provider/,platforms/core-configuration/file-collections/…/api/internal/file/DefaultFilePropertyFactory.java- 순환 평가를 막는 장치(
EvaluationContext,openScope()),toString()이 값을 계산하지 않게 한 것:platforms/core-configuration/model-core/…/api/internal/provider/EvaluationContext.java - 안전하지 않은 읽기 검사(
disallowUnsafeRead(),PropertyHost.beforeRead()):ValueState.java209–234행 - 태스크 클래스 정보 수집(
DefaultTaskClassInfoStore—@TaskAction메서드 찾기, 증분 액션):subprojects/core/src/main/java/org/gradle/api/internal/project/taskfactory/ - 입력·출력 어노테이션 처리기 전체(
InputFilePropertyAnnotationHandler등)와 검증:subprojects/core/src/main/java/org/gradle/api/internal/tasks/properties/annotations/
정리
| 개념 | 한 줄 요약 |
|---|---|
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는 여기에 "누가 만드나"(생산자)를 함께 들고 다녀 실행 순서까지 정한다는 점이 다릅니다.