이 글에서 다루는 것
"Gradle 소스 파헤치기" 연재의 여섯 번째 글입니다. 5편에서는 빌드 한 번이 초기화·설정·실행으로 갈리는 자리, 즉 DefaultBuildLifecycleController 와 VintageBuildModelController 의 상태가 바뀌는 순서를 봤습니다. 그 가운데 "설정 단계"는 결국 프로젝트마다 빌드 스크립트를 실행하는 일입니다. 이번 글은 그 한 칸을 확대합니다.
2편의 지도로 말하면 **D 구역(설정 단계)의 첫 상자, "빌드 스크립트 실행(Kotlin·Groovy DSL)"**입니다. D 구역의 출발점으로 적어 둔 LifecycleProjectEvaluator 와 KotlinScriptEvaluator 가 이 글의 주인공이고, 같은 구역의 나머지인 플러그인(DefaultPluginManager)과 태스크(DefaultTaskContainer)는 7·8편에서 봅니다.
답하려는 질문은 네 가지입니다.
- 내
build.gradle.kts의project는 언제, 누가 만든 객체인가. 설정은 어떤 순서로 불리는가 - Gradle 은
build.gradle과build.gradle.kts중 무엇을 실행할지 어떻게 정하는가 plugins {}블록은 왜 맨 위에 따로 있어야 하고, 본문의 변수를 쓸 수 없는가.tasks.compileJava같은 이름은 어디서 오는가- 루트 프로젝트의
buildscript {}에 넣은 라이브러리는 하위 프로젝트에서 보이는데, 반대는 왜 안 보이는가
| 항목 | 내용 |
|---|---|
| 대상 | Gradle Build Tool |
| 버전 | 9.10.0 개발판 (master) |
| 커밋 | 07ecbf9ead (2026-10-24) |
| 주로 읽는 클래스 | DefaultProject, ProjectFactory, DefaultProjectState, LifecycleProjectEvaluator, BuildScriptProcessor, ScriptPluginFactorySelector, DefaultScriptPluginFactory, KotlinScriptPluginFactory, Interpreter, ProgramParser, DefaultClassLoaderScope |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.
Kotlin DSL 모듈(platforms/core-configuration/kotlin-dsl)만 해도 Kotlin 파일이 수백 개이고, 스크립트를 컴파일하는 쪽에는 증분 컴파일·메타데이터 검사·IDE 지원 같은 곁가지가 많습니다. 이 글은 "파일 하나가 실행되기까지"의 큰 줄기만 따라갑니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적었습니다.
먼저 알아 둘 말
프로젝트 상태와 프로젝트 모델. 2편에서 본 대로 Gradle 은 프로젝트 하나를 두 객체로 나눠 다룹니다. ProjectState(상태)는 settings 를 읽자마자 생기고 "지금 누가 이 프로젝트를 만지나"를 관리합니다. DefaultProject(모델)는 빌드 스크립트의 project 로 보이는 가변 객체이고, 상태가 필요할 때 만듭니다.
평가(evaluate)는 Gradle 소스에서 "프로젝트를 설정한다"는 뜻으로 쓰는 옛 이름입니다. LifecycleProjectEvaluator 의 클래스 주석이 "evaluate 라는 말은 옛날부터의 제약이고, 프로젝트 평가는 프로젝트 설정(configuration)과 같은 말"이라고 적어 두었습니다(LifecycleProjectEvaluator.java 50–51행). afterEvaluate {} 의 그 evaluate 입니다.
스크립트 플러그인(script plugin)은 Gradle 이 스크립트 파일 하나를 실행 가능한 꼴로 감싼 객체(ScriptPlugin)입니다. 빌드 스크립트, settings 스크립트, init 스크립트, apply(from = "other.gradle") 로 거는 스크립트가 모두 이것으로 실행됩니다. apply(target) 하나가 "이 스크립트를 이 객체에 대고 실행하라"는 뜻입니다.
클래스로더 스코프(class loader scope)는 Gradle 이 클래스로더를 나무 모양으로 관리하려고 둔 객체(ClassLoaderScope)입니다. settings 하나, 프로젝트마다 하나씩 있고, 각 스코프는 "나만 보는 클래스(local)"와 "자식에게도 보여 줄 클래스(export)"를 따로 가집니다. 8절에서 봅니다.
1단계와 2단계(stage 1·stage 2)는 Kotlin DSL 이 스크립트를 나누는 말입니다. 1단계는 buildscript {}·plugins {} 블록, 2단계는 나머지 본문입니다. Groovy DSL 은 같은 일을 "1차 패스(pass)·2차 패스"로 합니다. 왜 나누는지가 5·6절의 주제입니다.
접근자(accessor)는 Kotlin DSL 이 플러그인을 적용한 뒤 그 프로젝트에 맞춰 만들어 내는 확장 프로퍼티입니다. tasks.compileJava, java { … }, implementation(…) 이 모두 접근자입니다. 7절에서 봅니다.
1. 프로젝트 객체는 언제, 누가 만드나
settings 스크립트의 include("app") 은 프로젝트를 만들지 않습니다. settings 단계가 끝났을 때 있는 것은 프로젝트의 설명서(ProjectDescriptor — 이름, 폴더, 빌드 파일 이름)와, 설명서마다 하나씩 만든 상태 객체 ProjectState 뿐입니다(DefaultSettingsPreparer.java 163행의 registerProjects()). 진짜 Project 객체는 설정 단계 첫머리에 InstantiatingBuildLoader 가 한꺼번에 만듭니다.
private void createChildProjectsRecursively(BuildState owner, ProjectDescriptorInternal parentProjectDescriptor, ClassLoaderScope parentProjectClassLoaderScope, ClassLoaderScope baseProjectClassLoaderScope) {
for (ProjectDescriptorInternal childProjectDescriptor : parentProjectDescriptor.children()) {
ClassLoaderScope childProjectClassLoaderScope = parentProjectClassLoaderScope.createChild("project-" + childProjectDescriptor.getName(), null);
ProjectState projectState = owner.getProjects().getProject(childProjectDescriptor.path());
projectState.createMutableModel(childProjectClassLoaderScope, baseProjectClassLoaderScope);
createChildProjectsRecursively(owner, childProjectDescriptor, childProjectClassLoaderScope, baseProjectClassLoaderScope);
}
}
subprojects/core/src/main/java/org/gradle/initialization/InstantiatingBuildLoader.java 47–54행
루트는 바로 위의 createProjects() 가 먼저 만들고(37–45행), 그다음 이 메서드로 내려갑니다. 읽을 것이 두 가지입니다.
- 루트부터 설명서 나무를 깊이 우선으로 내려가며 프로젝트마다
createMutableModel()을 부릅니다. 이때는 만들기만 하고 빌드 스크립트는 아직 하나도 돌지 않습니다. - 프로젝트를 만들 때마다 클래스로더 스코프를 하나씩 만들어 넘깁니다. 루트의 스코프(
root-project[:])는baseProjectClassLoaderScope의 자식(39행), 하위 프로젝트의 스코프는 부모 프로젝트 스코프의 자식입니다. 그리고 모든 프로젝트에 공통으로baseProjectClassLoaderScope도 같이 넘깁니다. 이 두 스코프가 8절의 이야기입니다.
createMutableModel() 은 상태 객체(DefaultProjectState)가 가진 ProjectLifecycleController 로 넘어가고, 여기서 프로젝트 스코프의 서비스 레지스트리(4편의 ProjectScopeServices)를 만들 팩토리를 준비해 ProjectFactory 에 넘깁니다(레지스트리 자체는 DefaultProject 생성자 238행에서 만들어집니다).
File buildFile = scriptResolution.resolveScriptsForProject(owner.getIdentity(), projectDescriptor::getBuildFile);
TextResource resource = textFileResourceLoader.loadFile("build file", buildFile);
ScriptSource source = new TextResourceScriptSource(resource);
DefaultProject project = instantiator.newInstance(DefaultProject.class,
buildFile,
source,
owner,
serviceRegistryFactory,
selfClassLoaderScope,
baseClassLoaderScope
);
subprojects/core/src/main/java/org/gradle/api/internal/project/ProjectFactory.java 57–67행
프로젝트를 만들 때 빌드 파일을 찾아 내용까지 읽어 ScriptSource 로 쥐여 줍니다. 빌드 스크립트는 프로젝트가 처음부터 들고 태어나는 셈입니다. new DefaultProject(...) 가 아니라 instantiator.newInstance(...) 로 만드는 것도 눈여겨볼 곳입니다. DefaultProject 는 abstract 클래스이고(159행), getProjectEvaluator() 같은 abstract 게터에 @Inject 가 붙어 있습니다. 4편에서 본 대로 Gradle 은 이런 클래스의 하위 클래스 DefaultProject_Decorated 를 ASM 으로 만들어 게터를 채웁니다. 예제 2에서 project.javaClass.name 이 정말 org.gradle.api.internal.project.DefaultProject_Decorated 로 찍힙니다.
이 바로 뒤(70–73행)에서 beforeEvaluate 훅을 하나 겁니다. 버전 카탈로그의 libs 확장은 이 훅으로, 빌드 스크립트가 돌기 직전에 프로젝트마다 붙습니다.
파란 상자가 "상태" 쪽, 나머지가 빌드 스크립트가 만지는 "모델" 쪽입니다.
ProjectLifecycleController 의 상태는 NotCreated, Created, Configured 셋뿐입니다(49–51행). Created 에서 Configured 로 가는 전이가 곧 "빌드 스크립트 실행"입니다. 그 전이를 일으키는 메서드가 ensureSelfConfigured() 입니다.
public void ensureSelfConfigured() {
controller.maybeTransitionIfNotCurrentlyTransitioning(State.Created, State.Configured, () -> lockGuard.withProjectLock(() -> project.evaluateUnchecked()));
}
5편의 StateTransitionController 가 여기서도 쓰입니다. "아직 Created 이고 지금 전이 중이 아니면" 한 번만 설정합니다. 그래서 여러 곳에서 "이 프로젝트를 설정해 둬"라고 불러도 스크립트는 한 번만 돕니다. 그리고 그 일은 프로젝트 잠금(withProjectLock) 안에서 합니다. 잠금은 2절에서 봅니다.
헷갈리기 쉬운 곳
- 루트가 먼저 설정되는 것은 우연이 아닙니다.
DefaultProjectState.ensureConfigured()는 자기를 설정하기 전에 부모의ensureConfigured()부터 부릅니다(DefaultProjectState.java182–190행). 주석은 configure-on-demand(필요한 프로젝트만 설정하는 모드) 때문이라고 적었는데, 8절의 클래스로더 나무를 보면 다른 이유도 보입니다. 하위 프로젝트의 스크립트는 부모 스크립트가 클래스패스를 다 정한 뒤에야 컴파일할 수 있습니다. - 기본 모드에서 프로젝트는 하나씩 차례로 설정됩니다. 프로젝트들을 병렬로 설정하는 길(
TaskPathProjectEvaluator.configureHierarchyInParallel())도 있지만, 그 길을 켜는isParallelProjectConfiguration()이true일 수 있는 것은 격리된 프로젝트(Isolated Projects) 모드뿐입니다(BuildModelParameters.kt의 모드별 구현 64·94·131행). 나머지 모드의 구현은false를 돌려주므로, 평범한 빌드에서는--parallel을 줘도 설정 단계는 차례로 갑니다.
2. 프로젝트 하나를 설정하는 평가기 사슬
project.evaluateUnchecked() 는 서비스 레지스트리에서 ProjectEvaluator 를 꺼내 evaluate(this, state) 를 부르는 것이 전부입니다(DefaultProject.java 787–791행). ProjectEvaluator 의 실체는 의외의 곳, 설정 캐시 모듈의 BuildModelControllerServices.kt 에서 조립됩니다.
): ProjectEvaluator {
val withActionsEvaluator = ConfigureActionsProjectEvaluator(
PluginsProjectConfigureActions.from(cachingServiceLocator),
BuildScriptProcessor(scriptPluginFactory),
DelayedConfigurationActions()
)
return LifecycleProjectEvaluator(buildOperationRunner, withActionsEvaluator, cancellationToken)
}
platforms/core-configuration/configuration-cache/src/main/kotlin/org/gradle/internal/cc/impl/BuildModelControllerServices.kt 118–125행 (VintageModelProvider.createProjectEvaluator())
바깥에서 안으로 읽으면 사슬은 이렇게 생겼습니다.
LifecycleProjectEvaluator— 빌드 오퍼레이션Configure project :…을 열고,beforeEvaluate→ (안쪽 평가기) →afterEvaluate를 부르며 상태를 바꿉니다.ConfigureActionsProjectEvaluator— 받은 동작 셋을 차례로 실행할 뿐입니다.PluginsProjectConfigureActions—META-INF/services/org.gradle.configuration.project.ProjectConfigureAction에 이름을 올린 클래스들을ServiceLocator로 찾아 실행합니다.BuildScriptProcessor— 빌드 스크립트를 실행합니다.DelayedConfigurationActions— 그 사이에 미뤄 둔 설정 동작(ProjectConfigurationActionContainer)을 실행합니다.
같은 파일에는 이 사슬을 ConfigurationCacheAwareProjectEvaluator 로 한 번 더 감싸는 ConfigurationCacheModelProvider 도 있습니다(95–108행). 하지만 그쪽을 고르는 조건 isCachingModelBuilding 은 격리된 프로젝트 모드에서 IDE 모델을 만들 때만 켜집니다(BuildModelParametersProvider.kt 216행). 평범한 빌드는 위의 VintageModelProvider 를 씁니다. 설정 캐시를 켰을 때 무엇이 달라지는지는 11편에서 봅니다.
바깥쪽 LifecycleProjectEvaluator 의 핵심입니다.
@Override
public void run(final BuildOperationContext context) {
project.getOwner().applyToMutableState(p -> {
// Note: beforeEvaluate and afterEvaluate ops do not throw, instead mark state as failed
try {
state.toBeforeEvaluate();
buildOperationRunner.run(new NotifyBeforeEvaluate(project, state));
if (!state.hasFailure()) {
state.toEvaluate();
try {
delegate.evaluate(project, state);
} catch (Exception e) {
addConfigurationFailure(project, state, e, context);
} finally {
state.toAfterEvaluate();
buildOperationRunner.run(new NotifyAfterEvaluate(project, state));
}
}
if (state.hasFailure()) {
state.rethrowFailure();
} // ... 성공이면 결과를 적는다 ...
} finally {
state.configured();
}
});
}
여기서 세 가지를 읽습니다.
- 스크립트가 실패해도
afterEvaluate는 돕니다.finally안에 있기 때문입니다. 실패는 바로 던지지 않고state에 모아 두었다가(addConfigurationFailure) 맨 끝에rethrowFailure()로 한꺼번에 던집니다. 이때 예외를ProjectConfigurationException("A problem occurred configuring root project 'x'.")로 감쌉니다(82–86행). 설정 단계에서 실패하면 늘 보던 그 첫 줄입니다. - 반대로
beforeEvaluate가 실패하면 빌드 스크립트는 아예 돌지 않습니다(if (!state.hasFailure())). - 이 모든 것이
applyToMutableState()안, 즉 프로젝트 잠금을 쥔 채로 일어납니다.
가운데 고리 ConfigureActionsProjectEvaluator.evaluate() 는 받은 동작을 for 문으로 차례로 부르는 것이 전부입니다(32–37행). 그리고 빌드 스크립트를 실제로 넘기는 BuildScriptProcessor 입니다.
ScriptSource scriptSource = project.getBuildScriptSource();
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Evaluating {} using {}.", project, scriptSource.getDisplayName());
}
final Timer clock = Time.startTimer();
try {
final ScriptPlugin configurer = configurerFactory.create(scriptSource, project.getBuildscript(), project.getClassLoaderScope(), project.getBaseClassLoaderScope(), true);
project.getOwner().applyToMutableState(configurer::apply);
} finally {
// ... 걸린 시간을 debug 로 찍는다 ...
}
--info 를 주면 찍히는 Evaluating root project 'x' using build file '…'. 가 이 40행입니다. 넘기는 인자를 보면 이 글의 뒷부분이 다 들어 있습니다. 1절에서 프로젝트가 들고 태어난 스크립트 원본, buildscript {} 를 받아 줄 ScriptHandler, 프로젝트의 클래스로더 스코프 둘, 그리고 "최상위 스크립트(빌드 스크립트 자체)인가"를 뜻하는 true 입니다. 마지막 값이 false 면 apply(from = …) 로 건 스크립트입니다.
①·③·⑤·⑦은 아래 예제의 스택에서 아래부터 위로 그대로 보입니다.
예제 1: 빌드 스크립트 본문에서 스택 찍기
정말 이 사슬을 지나는지 보려고, 빌드 스크립트 본문에서 StackWalker 로 스택을 훑어 이 글이 다루는 클래스만 골라 찍었습니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// ... import 와 buildscript {} 는 8절에서 ...
// 1) 이 스크립트를 부른 길: 스택에서 이 글이 다루는 클래스만 골라 찍는다 (Java 람다 프레임은 뺀다)
val interesting = Regex(
"ProjectLifecycleController|DefaultProject\\.|LifecycleProjectEvaluator|ConfigureActionsProjectEvaluator|" +
"BuildScriptProcessor|DefaultProjectState|ScriptPlugin|Interpreter|Program\\.|Build_gradle"
)
println("--- call path of the root build script (inner first) ---")
StackWalker.getInstance().forEach { f ->
val name = "${f.className}.${f.methodName}"
if (interesting.containsMatchIn(name) && ".lambda\$" !in name) println(" at $name:${f.lineNumber}")
}
// ... 2) 프로젝트 상태, 3) 클래스로더 스코프, 4) lateHook 태스크는 3절과 8절에서 ...
[예제 ex/u06-evaluator-chain/project/build.gradle.kts 에서 줄임]
이 예제는 기준 커밋(07ecbf9ead)을 그대로 빌드한 Gradle 9.10.0 개발판으로 도커 컨테이너(Eclipse Temurin JDK 25)에서 실행했습니다. ex/u06-evaluator-chain/run.sh 가 gradle help --offline --console=plain -q 를 돌립니다. 결과의 앞부분입니다(Java 람다 프레임은 걸렀습니다).
=== configure both projects ===
--- call path of the root build script (inner first) ---
at Build_gradle.<init>:16
at Program.execute:-1
at org.gradle.kotlin.dsl.execution.Interpreter$ProgramHost$eval$1.run:544
at org.gradle.kotlin.dsl.execution.Interpreter$ProgramHost.eval:539
at org.gradle.kotlin.dsl.execution.Interpreter$ProgramHost.evaluateSecondStageOf:470
at Program.execute:-1
at org.gradle.kotlin.dsl.execution.Interpreter$ProgramHost$eval$1.run:544
at org.gradle.kotlin.dsl.execution.Interpreter$ProgramHost.eval:539
at org.gradle.kotlin.dsl.execution.Interpreter.eval:234
at org.gradle.kotlin.dsl.provider.KotlinScriptPluginFactory.create$lambda$0:61
at org.gradle.kotlin.dsl.provider.KotlinScriptPlugin.apply:35
at org.gradle.configuration.BuildOperationScriptPlugin$1.run:71
at org.gradle.configuration.BuildOperationScriptPlugin.apply:68
at org.gradle.api.internal.project.DefaultProjectState.runWithModelLock:246
at org.gradle.api.internal.project.DefaultProjectState.fromMutableState:228
at org.gradle.api.internal.project.DefaultProjectState.applyToMutableState:220
at org.gradle.configuration.project.BuildScriptProcessor.execute:46
at org.gradle.configuration.project.BuildScriptProcessor.execute:27
at org.gradle.configuration.project.ConfigureActionsProjectEvaluator.evaluate:35
at org.gradle.api.internal.project.DefaultProjectState.runWithModelLock:246
at org.gradle.api.internal.project.DefaultProjectState.fromMutableState:228
at org.gradle.api.internal.project.DefaultProjectState.applyToMutableState:220
at org.gradle.configuration.project.LifecycleProjectEvaluator$EvaluateProject.run:100
at org.gradle.configuration.project.LifecycleProjectEvaluator.evaluate:72
at org.gradle.api.internal.project.DefaultProject.evaluateUnchecked:789
at org.gradle.api.internal.project.DefaultProjectState.runWithModelLock:248
at org.gradle.api.internal.project.DefaultProjectState.withProjectLock:232
at org.gradle.api.internal.project.ProjectLifecycleController.ensureSelfConfigured:96
at org.gradle.api.internal.project.DefaultProjectState.ensureConfigured:189
아래(바깥)에서 위(안쪽)로 읽습니다.
ensureConfigured189행 →ensureSelfConfigured96행 →evaluateUnchecked789행 →LifecycleProjectEvaluator72·100행 →ConfigureActionsProjectEvaluator35행 →BuildScriptProcessor46행. 위에서 인용한 줄과 그대로 맞습니다.runWithModelLock이 세 번 나옵니다. 그런데 248행(잠금을 새로 잡는 줄)은 맨 아래 한 번뿐이고, 위의 둘은 246행입니다. 다음 소절에서 보겠지만 246행은 "이미 잠금을 쥐고 있으면 그냥 실행"하는 줄입니다.BuildScriptProcessor위로는BuildOperationScriptPlugin(빌드 오퍼레이션Apply build file …),KotlinScriptPluginFactory,Interpreter가 이어지고, 맨 위에서Build_gradle.<init>이 이 스크립트 본문입니다. 줄 번호 16은build.gradle.kts에서StackWalker…가 있는 줄입니다. 그 사이의Program.execute두 번과evaluateSecondStageOf는 6절에서 풉니다.
프로젝트 잠금
여러 곳이 "모델을 만질 때는 잠금부터"를 지키게 만드는 것이 DefaultProjectState.runWithModelLock() 입니다.
@Override
public <S extends @Nullable Object> S runWithModelLock(Supplier<S> action) {
if (canDoAnythingToThisProject.contains(Thread.currentThread())) {
// Current thread is allowed to access anything at any time, so run the action
return action.get();
}
if (workerLeaseService.holdsProjectLock(projectLock)) {
// We're already holding the lock for this project.
return action.get();
}
return workerLeaseService.withReplacedLocks(workerLeaseService.getCurrentProjectLocks(), Collections.singletonList(projectLock), action::get);
}
subprojects/core/src/main/java/org/gradle/api/internal/project/DefaultProjectState.java 238–249행
잠금은 WorkerLeaseService 가 프로젝트마다 하나씩 내주는 ResourceLock 입니다(75행). 248행의 withReplacedLocks 는 이름 그대로 지금 쥔 다른 프로젝트의 잠금을 내려놓고 이 프로젝트의 잠금으로 바꿔 쥡니다. 루트를 설정하다가 하위 프로젝트를 건드려야 할 때 두 잠금을 동시에 쥐지 않게 하려는 것, 즉 서로 기다리는 교착을 피하려는 것으로 읽힙니다. 같은 상태 객체는 실행 단계용 잠금(taskLock, 76행)도 따로 가지고 있습니다. 실행 단계의 잠금과 --parallel 의 관계는 9편에서 봅니다.
헷갈리기 쉬운 곳
help·tasks·wrapper·init태스크는 빌드 스크립트보다 먼저 생깁니다(wrapper·init은 루트 프로젝트에만 —WrapperPlugin·BuildInitPlugin이project.getParent() == null일 때만 등록합니다). 아무것도 적지 않은 프로젝트에서도gradle tasks가 되는 이유가 사슬의 첫 고리PluginsProjectConfigureActions입니다. 이 커밋에서META-INF/services/org.gradle.configuration.project.ProjectConfigureAction파일은 여섯 모듈에 있고, 거기 적힌HelpTasksAutoApplyAction,BuildInitAutoApplyAction,WrapperPluginAutoApplyAction,SoftwareReportingTasksAutoApplyAction등이 플러그인을 겁니다. 예제 2의 빌드 오퍼레이션 기록에서도Apply plugin org.gradle.help-tasks·org.gradle.build-init·org.gradle.wrapper가Apply build file보다 먼저 찍혔습니다(지면 때문에 6절에 싣는 출력에서는 걸렀습니다).
3. 프로젝트 상태 — beforeEvaluate 와 afterEvaluate 사이
LifecycleProjectEvaluator 가 바꾸던 state 는 ProjectStateInternal 이고, 다섯 상태를 가집니다.
enum State {
UNCONFIGURED,
IN_BEFORE_EVALUATE,
IN_EVALUATE,
IN_AFTER_EVALUATE,
CONFIGURED
}
// ...
@Override
public boolean getExecuted() {
// We intentionally consider "execution" done before doing afterEvaluate.
// The Android plugin relies on this behaviour.
return state.ordinal() > State.IN_EVALUATE.ordinal();
}
subprojects/core/src/main/java/org/gradle/api/internal/project/ProjectStateInternal.java 37–54행에서 줄임
빌드 스크립트에서 쓰는 project.state.executed 가 이 getExecuted() 입니다. 주석이 솔직합니다. afterEvaluate 를 하기 전에 이미 "실행됨"으로 치는 것은 의도이고, Android 플러그인이 이 동작에 기대고 있다고 적었습니다.
빌드 스크립트 본문은 IN_EVALUATE 에서, afterEvaluate {} 의 블록은 IN_AFTER_EVALUATE 에서 돕니다.
afterEvaluate 리스너를 부르는 쪽(NotifyAfterEvaluate)은 한 번 부르고 끝내지 않습니다. do { … } while (nextBatch != null) 로, 리스너가 실행되는 동안 새로 걸린 afterEvaluate 를 다음 묶음으로 모아 그것이 빌 때까지 다시 부릅니다(LifecycleProjectEvaluator.java 251–258행, 묶음을 바꿔 끼우는 것은 DefaultProject.stepEvaluationListener() 1540–1552행). afterEvaluate 안에서 afterEvaluate 를 걸어도 버려지지 않는 이유입니다.
그런데 설정이 다 끝난 뒤에 거는 것은 막습니다. DefaultProject.afterEvaluate() 는 등록하기 전에 failAfterProjectIsEvaluated() 를 부르고, 상태가 "아직 설정 전"도 "설정 중"도 아니면 InvalidUserCodeException("Cannot run Project.afterEvaluate(Action) when the project is already evaluated.") 을 던집니다(DefaultProject.java 1067–1091행).
예제 1 (이어서): 상태 찍기와 늦은 afterEvaluate
예제 1의 루트 스크립트는 본문과 afterEvaluate {} 안에서 각각 project.state.executed 를 찍고, 실행 단계에 project.afterEvaluate { } 를 거는 lateHook 태스크도 등록합니다. gradle help 와 gradle lateHook 의 결과에서 해당하는 줄입니다(가운데의 스코프 출력은 8절에서 봅니다).
--- project state ---
in script body : executed=false
...
in afterEvaluate: executed=true
...
=== afterEvaluate during the execution phase ===
* What went wrong:
Execution failed for task ':lateHook' (registered in build file 'build.gradle.kts').
> Cannot run Project.afterEvaluate(Action) when the project is already evaluated.
본문에서는 false, afterEvaluate 안에서는 true 입니다. 실행 단계에서 거는 afterEvaluate 는 1089행의 메시지로 실패합니다.
헷갈리기 쉬운 곳
afterEvaluate는 "모든 프로젝트가 설정된 뒤"가 아닙니다. 그 프로젝트 하나의 설정 끝입니다. 루트의afterEvaluate는 하위 프로젝트 스크립트가 돌기 전에 불립니다(1절에서 본 대로 프로젝트를 하나씩 끝내고 다음으로 가기 때문입니다). 모든 프로젝트를 기다리는 훅은gradle.projectsEvaluated이고, 그 자리는 5편에서 봤습니다.- 스크립트가 실패해도
afterEvaluate가 돕니다. 2절의finally때문입니다.afterEvaluate안에서 스크립트가 만들었어야 할 것을 꺼내 쓰다가 두 번째 예외가 나면, 실패 보고에 원인이 둘 겹쳐 보일 수 있습니다(ProjectStateInternal.failed()가 원인을 합칩니다, 90–98행).
4. build.gradle 이냐 build.gradle.kts 냐
BuildScriptProcessor 가 부른 configurerFactory 는 ScriptPluginFactorySelector 입니다(BuildScopeServices.java 566–574행에서 이것을 ScriptPluginFactory 서비스로 등록합니다). 그 전에 "어느 파일이 빌드 파일인가"는 1절의 ProjectFactory 가 설명서에 물어 이미 정했습니다. 설명서는 이름을 따로 정하지 않았으면 DefaultScriptFileResolver 로 찾습니다.
resolveScriptFile() 은 확장자 목록을 차례로 돌며 build + 확장자 파일이 있는지 보고, 처음 찾은 것을 고르고 그 뒤에 찾은 것은 "무시한 후보"로 따로 모읍니다(DefaultScriptFileResolver.java 43–63행). EXTENSIONS 의 순서는 스크립트 언어 목록 ScriptingLanguages 가 정합니다.
private static final List<ScriptingLanguage> ALL =
Collections.unmodifiableList(
Arrays.asList(
scriptingLanguage(".gradle", null),
scriptingLanguage(".gradle.kts", "org.gradle.kotlin.dsl.provider.KotlinScriptPluginFactory"),
scriptingLanguage(".gradle.dcl", "org.gradle.internal.declarativedsl.provider.DeclarativeDslScriptPluginFactory")
)
);
이 목록 하나가 두 일을 합니다. 파일을 찾을 때는 이 순서대로 build.gradle → build.gradle.kts → build.gradle.dcl 을 보고 처음 찾은 것을 씁니다. 하나도 없으면 빈 build.gradle 이 있는 것으로 칩니다(ScriptFileUtil.java 36–37행). 그리고 실행할 때는 선택기가 같은 목록으로 파일 이름의 끝을 맞춰 봅니다.
private ScriptPluginFactory scriptPluginFactoryFor(String fileName) {
for (ScriptingLanguage scriptingLanguage : ScriptingLanguages.all()) {
if (fileName.endsWith(scriptingLanguage.getExtension())) {
String provider = scriptingLanguage.getProvider();
if (provider != null) {
return instantiate(provider);
}
return defaultScriptPluginFactory;
}
}
return defaultScriptPluginFactory;
}
subprojects/core/src/main/java/org/gradle/configuration/ScriptPluginFactorySelector.java 112–123행
- 제공자 이름이 없는
.gradle과, 어느 확장자에도 맞지 않는 파일은 기본값DefaultScriptPluginFactory(Groovy DSL)로 갑니다. .gradle.kts는 클래스 이름 문자열"org.gradle.kotlin.dsl.provider.KotlinScriptPluginFactory"로 이어집니다. 코어(subprojects/core)는 Kotlin DSL 모듈을 컴파일 시점에 모릅니다. 이름으로 클래스를 읽어Instantiator로 만듭니다(62–79행).KotlinScriptPluginFactory쪽에@Suppress("unused") // The name of this class is hardcoded in Gradle주석이 붙어 있는 것이 그래서입니다(KotlinScriptPluginFactory.kt38행).- 무엇을 고르든 바로 위의
create()가BuildOperationScriptPlugin으로 감쌉니다(109행). 빌드 오퍼레이션Apply build file 'build.gradle.kts' to root project 'x'가 여기서 생깁니다. 두 DSL 이 같은 이름의 오퍼레이션을 남기는 이유입니다.
빨간 상자가 아래 예제의 함정입니다.
예제 2: 두 빌드 파일이 함께 있으면
같은 폴더에 build.gradle(println("running build.gradle"))과 build.gradle.kts(println("running build.gradle.kts"))를 함께 두고 gradle help 를 돌렸습니다(ex/u06-script-classes/project-both). 마지막 두 줄은 run.sh 가 문제 보고서 HTML 에서 grep 으로 꺼낸 문장입니다. 시간(in 6s)은 실행마다 다릅니다.
=== both build.gradle and build.gradle.kts in one directory ===
running build.gradle
[Incubating] Problems report is available at: file:///tmp/both/build/reports/problems/problems-report.html
BUILD SUCCESSFUL in 6s
problems-report.html
Multiple build script files were found in directory '/tmp/both'. Selected 'build.gradle', and ignoring 'build.gradle.kts'.
Multiple build script files were found in directory '/tmp/both'
build.gradle 만 돌았고, 무엇을 버렸는지는 콘솔이 아니라 문제 보고서(problems report)에만 있습니다. 두 문장은 ScriptResolutionResultReporter.reportResolutionProblemsOf() 가 만드는 문제 하나의 자세한 설명(details, 49행)과 짧은 제목(contextual label, 46행)입니다(ScriptResolutionResultReporter.java 43–54행). 처음 돌렸을 때 --warning-mode=all 을 붙였더니 같은 내용이 콘솔에 Problem found: Multiple script files in one directory 로 찍혔습니다. 붙이지 않으면 위처럼 보고서 경로 한 줄만 남습니다.
헷갈리기 쉬운 곳
- Groovy 에서 Kotlin 으로 옮기다가 옛
build.gradle을 남겨 두면, 고친build.gradle.kts는 돌지 않습니다. 위 예제 그대로입니다. 순서가.gradle먼저라서입니다. .gradle.dcl은 선언형 DSL(Declarative Gradle)입니다. 이 글에서는 다루지 않습니다. 참고로 Kotlin DSL 스크립트도 프로젝트 빌드 스크립트라면org.gradle.kotlin.dsl.dcl속성이 켜져 있으면 먼저 선언형 해석기로 돌려 보고, 안 되면 보통의 Kotlin 해석으로 돌아갑니다(KotlinScriptPluginFactory.kt54–58행). 이 속성의 기본값은false입니다(AccessorsClassPath.kt158행).
5. Groovy DSL — 같은 파일을 두 번 컴파일한다
Groovy DSL 쪽 DefaultScriptPluginFactory 의 apply() 가 이 글에서 가장 많은 것을 담은 메서드입니다.
@Override
public void apply(final Object target) {
// ... 스크립트 전용 서비스 레지스트리를 만든다 ...
final ScriptTarget initialPassScriptTarget = initialPassTarget(target);
ScriptCompiler compiler = scriptCompilerFactory.createCompiler(scriptSource);
// Pass 1, extract plugin requests and plugin repositories and execute buildscript {}, ignoring (i.e. not even compiling) anything else
CompileOperation<?> initialOperation = compileOperationFactory.getPluginsBlockCompileOperation(initialPassScriptTarget);
Class<? extends BasicScript> scriptType = initialPassScriptTarget.getScriptClass();
ScriptRunner<? extends BasicScript, ?> initialRunner = compiler.compile(scriptType, target, baseScope, initialOperation, Actions.doNothing());
initialRunner.run(target, services);
// ... 1차 실행이 모은 플러그인 요청(allPluginRequests)을 꺼낸다 ...
pluginRequestApplicator.applyPlugins(allPluginRequests, scriptHandler, pluginManager, targetScope);
// Pass 2, compile everything except buildscript {}, pluginManagement{}, and plugin requests, then run
final ScriptTarget scriptTarget = secondPassTarget(target);
scriptType = scriptTarget.getScriptClass();
CompileOperation<BuildScriptData> operation = compileOperationFactory.getScriptCompileOperation(scriptSource, scriptTarget);
final ScriptRunner<? extends BasicScript, BuildScriptData> runner = compiler.compile(scriptType, target, targetScope, operation, ClosureCreationInterceptingVerifier.INSTANCE);
// ...
Runnable buildScriptRunner = () -> runner.run(target, services);
boolean hasImperativeStatements = runner.getData().getHasImperativeStatements();
scriptTarget.addConfiguration(buildScriptRunner, !hasImperativeStatements);
}
subprojects/core/src/main/java/org/gradle/configuration/DefaultScriptPluginFactory.java 97–145행에서 줄임
주석 그대로 두 패스입니다.
- 1차 패스:
buildscript {},plugins {}, (settings 라면)pluginManagement {}만 남기고 나머지는 컴파일조차 하지 않은 클래스를 만들어 실행합니다. 이 실행으로buildscript의 저장소·클래스패스 의존성이ScriptHandler에 쌓이고plugins {}의 요청이 모입니다. 컴파일에 쓰는 클래스로더는baseScope입니다. - 모은 요청으로
applyPlugins(…, targetScope)를 부릅니다. 여기서 클래스패스를 해석해 프로젝트의 스코프(targetScope)에 붙이고 플러그인을 적용합니다(8절에서 다시 봅니다). - 2차 패스: 그 블록들을 뺀 나머지를 이번에는
targetScope로 컴파일해 실행합니다. 이제buildscript로 더한 라이브러리와 플러그인의 클래스가 보이므로, 본문에서 그 타입을 쓸 수 있습니다.
왜 두 번일까요. 스크립트 본문이 쓰는 타입(예를 들어 플러그인이 만든 JavaPluginExtension, buildscript 로 받은 라이브러리의 클래스)은 스크립트의 일부를 실행해 봐야 클래스패스에 올라옵니다. 본문 전체를 한 번에 컴파일하면 그 타입을 모르는 상태에서 컴파일해야 합니다. 그래서 "클래스패스를 정하는 부분"을 먼저 떼어 실행하고, 나머지는 그 결과 위에서 컴파일합니다. Kotlin DSL 도 같은 이유로 둘로 나눕니다(6절).
두 패스가 무엇을 남기고 빼는지는 변환기(transformer)가 정합니다. 2차 패스의 BuildScriptTransformer 는 getClasspathBlockName(), plugins, pluginManagement 세 블록을 걸러 내는 필터를 겁니다(BuildScriptTransformer.java 37–46행). getClasspathBlockName() 은 빌드 스크립트면 "buildscript", init 스크립트면 "initscript" 입니다(ProjectScriptTarget.java 45–47행, InitScriptTarget.java 39–41행). 1차 패스는 반대로 SubsetScriptTransformer 가 그 블록들만 남깁니다. 두 컴파일에는 서로 다른 id 와 단계 이름이 붙습니다. 1차는 "cp_" + 대상 id(빌드 스크립트면 cp_proj)와 CLASSPATH, 2차는 proj 와 BODY 입니다(DefaultCompileOperationFactory.java 45–58행).
노란 원통 셋은 아래에서 위로, 가까운 캐시부터 찾는 순서입니다.
컴파일 결과는 세 층에서 찾는다
compiler.compile(...) 뒤에는 캐시가 세 겹 있습니다.
- 빌드 하나 안의 메모리 캐시
BuildScopeInMemoryCachingScriptClassCompiler— 같은 빌드 안에서 같은 스크립트를 다시 컴파일하지 않습니다. 클래스 주석에 "프로젝트를 병렬로 설정하면 동시에 불리니 스레드 안전해야 한다"고 적혀 있습니다(35–36행). - 데몬 안의 메모리 캐시
CrossBuildInMemoryCachingScriptClassCache—@ServiceScope(Scope.UserHome.class)라서 같은~/.gradle을 쓰는 동안 데몬이 빌드 사이에 들고 있습니다. - 디스크
GroovyScriptClassCompiler— 컴파일을 실행 엔진의 작업 단위(unit of work)로 만들어getExecutionEngine(target).createRequest(unitOfWork).execute()로 맡깁니다(170–174행).
태스크가 UP-TO-DATE 를 판단받는 그 실행 엔진(10편)에 스크립트 컴파일도 태우는 것입니다. 입력(원본 해시, 템플릿 id, 클래스패스)이 같으면 작업 공간 ~/.gradle/caches/<판>/groovy-dsl(GroovyDslWorkspaceProvider.java 42–45행)에 이미 있는 결과를 씁니다. 프로젝트 빌드 스크립트일 때는 빌드 캐시까지 쓸 수 있는 프로젝트 쪽 실행 엔진을 고릅니다(177–191행의 주석).
예제 2 (이어서): 같은 스크립트를 두 DSL 로
같은 내용의 빌드 스크립트를 Groovy 와 Kotlin 으로 하나씩 두었습니다. 스크립트 자신의 클래스를 찍습니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// ... build.gradle.kts 와 같은 내용을 Groovy DSL 로: plugins { id 'base' } ...
println("script class = ${this.getClass().name}")
println("superclass = ${this.getClass().superclass.name}")
println("project class = ${project.getClass().name}")
println("script loader = ${this.getClass().classLoader.getClass().simpleName}")
// ... hello 태스크 ...
[예제 ex/u06-script-classes/project-groovy/build.gradle 에서 줄임]
run.sh 는 gradle hello --offline --console=plain --info 를 같은 데몬에서 두 번 돌리고, 컴파일·평가에 관한 줄만 남깁니다.
=== groovy: first run (--info, filtered) ===
Compiling settings file '/tmp/groovy/settings.gradle' using SubsetScriptTransformer.
Compiling settings file '/tmp/groovy/settings.gradle' using BuildScriptTransformer.
Evaluating root project 'groovy' using build file '/tmp/groovy/build.gradle'.
Compiling build file '/tmp/groovy/build.gradle' using SubsetScriptTransformer.
Compiling build file '/tmp/groovy/build.gradle' using BuildScriptTransformer.
script class = build_cb61zcm69ge6cvh1owhi4eh57
superclass = org.gradle.api.internal.project.ProjectScript
project class = org.gradle.api.internal.project.DefaultProject_Decorated
script loader = ScriptClassLoader
BUILD SUCCESSFUL in 2s
=== groovy: second run, same daemon ===
Evaluating root project 'groovy' using build file '/tmp/groovy/build.gradle'.
script class = build_cb61zcm69ge6cvh1owhi4eh57
BUILD SUCCESSFUL in 1s
- 첫 실행에는 변환기 이름이 다른 컴파일 줄이 둘씩 찍힙니다(settings 도 빌드 스크립트도).
DefaultScriptCompilationHandler135행의Compiling {} using {}.입니다. - 스크립트 클래스는
ProjectScript를 물려받은build_+ 해시입니다. 이름은TextResourceScriptSource.classNameFromPath()가 파일 이름(최대 30자)에 파일 경로의 해시를 붙여 만듭니다(95–112행). 하위 프로젝트마다build.gradle이 있어도 클래스 이름이 겹치지 않습니다. 해시는 실행 환경의 경로마다 다릅니다. - 두 번째 실행은 같은 데몬이라 컴파일 줄이 없습니다.
BUILD SUCCESSFUL in …의 시간은 실행마다 다릅니다.
빌드 오퍼레이션 기록(-Dorg.gradle.internal.operations.trace=… -Dorg.gradle.internal.operations.trace.tree=true, 1편에서 쓴 방법)의 Apply build file 'build.gradle' 아래에는 Executing Groovy DSL script compilation (cp_proj) → Compile build file 'build.gradle' (CLASSPATH) → Apply plugin org.gradle.base → Executing Groovy DSL script compilation (proj) → Compile build file 'build.gradle' (BODY) 가 차례로 찍혔습니다. Executing … 은 실행 엔진의 ExecuteStep 이 붙이는 이름이니, 스크립트 컴파일이 정말 실행 엔진을 지난다는 증거입니다. Kotlin 쪽 기록은 6절에 싣습니다.
헷갈리기 쉬운 곳
- 디스크의 클래스 이름은
_BuildScript_입니다. 실행 뒤~/.gradle/caches/<판>/groovy-dsl/<해시>/classes/cp_proj/를 열어 보면_BuildScript_.class와_BuildScript_$_run_closure1.class가 있습니다. 컴파일은 경로와 무관한 이름(RemappingScriptSource.MAPPED_SCRIPT = "_BuildScript_", 29행)으로 하고, 읽어 들일 때build_해시로 바꿔 씁니다(GroovyScriptClassCompiler.java193–218행). 경로가 다른 두 프로젝트라도 내용이 같으면 디스크의 컴파일 결과를 함께 쓰려는 장치로 읽힙니다(그 의도를 적은 주석은 찾지 못했습니다). model {}만 있는 스크립트는 나중에 돕니다. 마지막 줄의addConfiguration(buildScriptRunner, !hasImperativeStatements)는, 스크립트에 명령문이 하나도 없으면(옛 규칙 기반 모델 블록뿐이면) 실행을 미룹니다(ProjectScriptTarget.java74–81행). 2절 사슬의DelayedConfigurationActions가 아니라 프로젝트의 지연 설정 쪽으로 갑니다. 요즘 빌드에서 볼 일은 드뭅니다.
6. Kotlin DSL — 두 단계로 나눈 "프로그램"
Kotlin 쪽 입구 KotlinScriptPluginFactory.create() 는 KotlinScriptPlugin 을 돌려주고, 그 apply() 는 (4절에서 말한 선언형 해석을 건너뛰면) kotlinScriptEvaluator.evaluate(…) 에 대상·원본·ScriptHandler·스코프 둘을 그대로 넘깁니다(45–70행). StandardKotlinScriptEvaluator 는 원본의 해시를 구해 Interpreter.eval() 로 넘깁니다. Interpreter 의 클래스 주석이 설계를 설명합니다. 스크립트를 그대로 해석하지 않고, 스크립트의 구조(buildscript 블록이 있나, plugins 블록이 있나, 본문이 있나)와 대상(프로젝트·settings·init), 최상위인가에 맞춘 "특화된 프로그램"을 만들어 낸다고 합니다. 부분 평가(partial evaluation)라는 기법입니다(Interpreter.kt 52–77행). 예를 들어 plugins {} 만 있고 본문이 없는 스크립트는 그 블록 클래스를 바로 만드는 것 말고는 아무것도 하지 않는 프로그램이 됩니다.
Interpreter.eval() 의 뒷부분은 단순합니다. 메모리 캐시에서 프로그램을 찾아 있으면 실행하고, 없으면 emitSpecializedProgramFor() 로 만들어 캐시에 넣은 뒤 실행합니다(207–234행). programId 는 템플릿 id(Project/TopLevel/stage1 같은 문자열, 580–581행), 스크립트 경로, 원본 해시, 부모 클래스로더, 그리고 컴파일러 옵션으로 이루어집니다(198–205행). 메모리 캐시는 KotlinScriptClassloadingCache 이고, Groovy 의 데몬 캐시와 같은 UserHome 스코프입니다.
ProgramParser — 맨 위의 블록을 떼어 낸다
"특화된 프로그램"을 만드는 첫 단계가 원본을 조각내는 ProgramParser 입니다.
val pluginsFragment =
topLevelBlocks
.takeIf { topLevelBlockIds.contains(TopLevelBlockId.plugins) && kind == ProgramKind.TopLevel }
?.singleSectionOf(TopLevelBlockId.plugins)
?.let { sourceWithoutComments.fragment(it) }
// ... buildscript · pluginManagement 도 같은 방법으로 떼고, 셋을 1단계로 묶는다 ...
val remainingSource =
sourceWithoutComments.map {
it.erase(
rangesToErase(buildscriptFragment, pluginManagementFragment, pluginsFragment, annotations)
)
}
- 간단한 렉서(
Lexer.kt)로 최상위 블록만 찾습니다.if안이나 함수 안의plugins {}는 최상위 블록이 아니므로 1단계로 가지 않습니다. plugins {}는 최상위 스크립트(ProgramKind.TopLevel)일 때만 떼어 냅니다.apply(from = …)로 거는 스크립트 플러그인에서는 떼지 않습니다.- 남은 원본이 2단계(
Program.Script, 86–88행)가 됩니다. 뗀 자리는erase()로 지우는데, 지우는 방법이 눈여겨볼 곳입니다.
for (ch in subSequence(range)) {
result.append(
if (Character.isWhitespace(ch)) ch
else ' '
)
}
공백 문자(줄 바꿈 포함)는 그대로 두고 나머지 글자를 공백으로 바꿉니다. 그래서 2단계 본문은 원본과 줄 수·열 위치가 같습니다. 예제 1의 스택에서 Build_gradle.<init>:16 이 원본의 16행을 정확히 가리킨 것, 아래 예제 3의 컴파일 오류가 원본의 줄 번호를 가리키는 것이 이 덕분입니다.
그다음 PartialEvaluator 가 조각의 조합(1단계만, 2단계만, 둘 다…)을 보고 남은 프로그램(ResidualProgram)으로 줄이고, ResidualProgramCompiler 가 그것을 Program 이라는 이름의 클래스로 바이트코드를 직접 만들어 냅니다(ResidualProgramCompiler.kt 82–85행 주석, 674–683행). 1단계 블록과 2단계 본문은 각각 Kotlin 컴파일러로 스크립트 클래스가 됩니다. 이때 단계 이름이 1단계는 CLASSPATH, 2단계는 BODY 입니다(762–768행). Groovy 와 같은 이름입니다. 주석은 이 상수를 바꿀 때 "GE 팀"(Gradle Enterprise, 지금의 Develocity)과 맞추라고 적었습니다. 빌드 오퍼레이션을 받아 보여 주는 쪽이 이 이름에 기대고 있다는 뜻입니다.
Groovy 의 두 패스와 같은 생각입니다. 다른 점은 가운데의 "접근자 생성"입니다(7절).
1단계 프로그램이 2단계를 부른다
예제 1의 스택에서 Program.execute 가 두 번 나온 것을 기억하실 겁니다. 아래쪽 Program(234행에서 부른 것)이 1단계 프로그램입니다. 이 프로그램은 plugins {}·buildscript {} 를 실행한 뒤 evaluateSecondStageOf() 를 부릅니다. 그 메서드가 2단계 본문을 이제야 컴파일하고(이미 있으면 캐시에서 꺼내고) 실행합니다. 위쪽 Program 이 2단계 프로그램이고, 그 안에서 Build_gradle 의 생성자(<init>)가 불립니다. Kotlin 스크립트는 본문 전체가 스크립트 클래스의 생성자로 컴파일되기 때문입니다.
2단계를 컴파일할 때 쓰는 클래스패스가 1단계와 다릅니다. 1단계는 targetScope.parent 의 클래스패스로 컴파일했습니다(294행). 2단계는 1단계 실행으로 클래스패스가 붙고 잠긴 targetScope 자신의 클래스패스에 접근자를 더해 컴파일합니다(compileSecondStageOf(), 488–489행).
2단계 스크립트 클래스의 부모는 CompiledKotlinBuildScript 입니다. 여기에 함정 하나가 숨어 있습니다.
@KotlinScript(compilationConfiguration = BuildScriptCompilationConfiguration::class)
open class CompiledKotlinBuildScript(
private val host: KotlinScriptHost<Project>
) : DefaultKotlinScript(defaultKotlinScriptHostForProject(host.target)), PluginAware by host.target {
// ... buildscript 프로퍼티와 buildscript {} 함수, plugins 의 문서 주석 ...
@Suppress("unused")
open fun plugins(@Suppress("unused_parameter") block: PluginDependenciesSpecScope.() -> Unit): Unit =
invalidPluginsCall()
}
2단계 본문에서 plugins {} 를 부르면 이 함수가 불리고, invalidPluginsCall() 은 늘 예외를 던집니다(140–144행). 최상위의 plugins {} 는 이미 떼어 냈으니, 본문에 남은 plugins {} 는 "최상위가 아닌 곳"에 있다는 뜻이기 때문입니다.
예제 2 (이어서): Kotlin 스크립트의 컴파일과 캐시
Groovy 와 같은 내용의 build.gradle.kts(plugins { base } 와 println 넷)를 돌렸습니다. Kotlin 쪽 컴파일 줄은 --debug 로만 보여서 --debug 로 돌리고 같은 방법으로 걸렀습니다(앞의 시각·로그 수준 표시는 sed 로 지웠고, Build operation … 으로 시작하는 디버그 줄도 뺐습니다). 두 번째·세 번째 실행은 script class 한 줄만 남겼습니다.
=== kts: first run (--debug, filtered) ===
Compiling Settings/TopLevel/stage1 from settings file '/tmp/kts/settings.gradle.kts'
Evaluating root project 'kts' using build file '/tmp/kts/build.gradle.kts'.
Compiling Project/TopLevel/stage1 from build file '/tmp/kts/build.gradle.kts'
Loading Project/TopLevel/stage1 from build file '/tmp/kts/build.gradle.kts'
Compiling Project/TopLevel/stage2 from build file '/tmp/kts/build.gradle.kts'
Loading Project/TopLevel/stage2 from build file '/tmp/kts/build.gradle.kts'
script class = Build_gradle
superclass = org.gradle.kotlin.dsl.support.CompiledKotlinBuildScript
project class = org.gradle.api.internal.project.DefaultProject_Decorated
script loader = VisitableURLClassLoader
BUILD SUCCESSFUL in 21s
=== kts: second run, same daemon ===
Evaluating root project 'kts' using build file '/tmp/kts/build.gradle.kts'.
script class = Build_gradle
BUILD SUCCESSFUL in 2s
=== stop the daemon, then run both again (new daemon, disk cache only) ===
Evaluating root project 'kts' using build file '/tmp/kts/build.gradle.kts'.
Loading Project/TopLevel/stage1 from build file '/tmp/kts/build.gradle.kts'
Loading Project/TopLevel/stage2 from build file '/tmp/kts/build.gradle.kts'
script class = Build_gradle
BUILD SUCCESSFUL in 8s
Evaluating root project 'groovy' using build file '/tmp/groovy/build.gradle'.
script class = build_cb61zcm69ge6cvh1owhi4eh57
BUILD SUCCESSFUL in 1s
시간은 실행마다 다릅니다. 읽을 것은 이렇습니다.
- 첫 실행은
stage1을 컴파일하고 싣고 → 그다음에stage2를 컴파일하고 싣습니다. 걸러 낸 디버그 줄을 보면 그 사이에Resolve buildscript classpath for configuration 'classpath'오퍼레이션이 있습니다. 위 그림의 순서 그대로입니다. 줄은Interpreter.kt의logCompilationOf()·logClassLoadingOf()(659–668행)가 찍습니다. - 같은 데몬의 두 번째 실행에는
Compiling·Loading이 하나도 없습니다. 메모리 캐시(cachedClassFor)에서 프로그램을 바로 꺼냈기 때문입니다. gradle --stop으로 데몬을 내리고 새 데몬으로 돌리면Loading만 있고Compiling은 없습니다. 메모리 캐시는 비었지만 디스크(~/.gradle/caches/<판>/kotlin-dsl/scripts)의 컴파일 결과를 실행 엔진이 찾아 주었습니다. 이때 Groovy 쪽도 컴파일 줄 없이Evaluating …만 찍혔습니다.- 스크립트 클래스는
Build_gradle, 부모는CompiledKotlinBuildScript입니다. Groovy 와 달리 해시가 붙지 않는 대신, 클래스로더 스코프 id 에 스크립트 경로가 들어갑니다(kotlin-dsl:<경로>:<템플릿 id>,Interpreter.kt584–586행).
같은 컨테이너에서 Gradle 사용자 홈을 보면 caches/9.10.0-20261107025045+0000/ 아래에 groovy-dsl 과 kotlin-dsl 이 나란히 있고, kotlin-dsl 안에는 accessors, classpath-snapshots, ic, scripts 가 있었습니다(run.sh 의 find 출력). accessors 와 scripts 는 KotlinDslWorkspaceProvider.kt 40–42행의 두 작업 공간입니다. ic 는 Kotlin DSL 스크립트의 증분 컴파일 상태(KotlinDslIncrementalCompilationCache)입니다. 이 클래스는 출시판 9.8.2 의 소스에는 없습니다. 기준 커밋의 개발판에서 새로 생긴 것으로 보이며, 이 글에서는 이름만 적어 둡니다.
마지막으로 같은 스크립트의 빌드 오퍼레이션 기록입니다(디스크 캐시를 지우고 새 데몬으로 돌린 것).
Configure project :
Notify beforeEvaluate listeners of :
Apply build file 'build.gradle.kts' to root project 'kts'
Execute unit of work: GENERATE_PLUGIN_SPEC_BUILDER_ACCESSORS
Executing Kotlin DSL plugin specs accessors for classpath '2e5a5830beed53419073fd45087674dc'
Execute unit of work: COMPILE_KOTLIN_SCRIPT
Executing Kotlin DSL script compilation (Project/TopLevel/stage1)
Compile script build.gradle.kts (CLASSPATH)
Evaluate build file 'build.gradle.kts'
Apply plugin org.gradle.base to root project 'kts'
Apply plugin org.gradle.kotlin.dsl.provider.plugins.KotlinScriptBasePlugin to root project 'kts'
Execute container callback action
Execute unit of work: GENERATE_PROJECT_ACCESSORS
Executing Kotlin DSL accessors for root project 'kts'
Execute unit of work: COMPILE_KOTLIN_SCRIPT
Executing Kotlin DSL script compilation (Project/TopLevel/stage2)
Compile script build.gradle.kts (BODY)
Evaluate build file 'build.gradle.kts'
Notify afterEvaluate listeners of :
해시는 실행마다 다릅니다(run.sh 는 Snapshot outputs … 줄과 입력 지문 같은 줄을 걸렀습니다). Groovy 와 다른 점이 셋 보입니다.
- 2단계 컴파일(
stage2,BODY)이 1단계의Evaluate build file안에 들어 있습니다. 1단계 프로그램이 2단계를 부른다는 앞의 설명 그대로입니다. 바깥Evaluate …가 1단계, 안쪽 마지막Evaluate …가 2단계의 실행입니다(Interpreter.kt538–552행의eval()이 붙이는 이름). - 1단계 실행에서
base플러그인이 걸린 직후KotlinScriptBasePlugin이 걸립니다. 모든 Kotlin DSL 프로젝트에kotlinDslAccessorsReport태스크를 등록하는 플러그인입니다(DefaultKotlinScriptBasePluginsApplicator.kt26–37행). - 그 뒤 2단계 컴파일 바로 앞에
GENERATE_PROJECT_ACCESSORS가 있습니다. 다음 절의 접근자입니다. 맨 앞의GENERATE_PLUGIN_SPEC_BUILDER_ACCESSORS는 1단계 블록 안에서 쓰는 접근자, 즉 버전 카탈로그와 buildSrc 클래스패스에 있는 플러그인 id 를 이름으로 쓰게 해 주는 것을 만드는 작업입니다(Stage1BlocksAccessorClassPath.kt56–63행).
예제 3: 두 단계라서 생기는 오류
두 단계로 나눈다는 것을 오류로 확인했습니다(ex/u06-kotlin-stages). 첫째, 본문 3행에서 val pluginId = "java" 를 만들고 5–7행의 plugins { id(pluginId) } 에서 씁니다(project-var-in-plugins/build.gradle.kts).
=== a body variable used inside plugins {} ===
e: file:///tmp/var-in-plugins/build.gradle.kts:6:8: Unresolved reference 'pluginId'.
Build file '/tmp/var-in-plugins/build.gradle.kts' line: 6
* What went wrong:
Script compilation error:
Line 6: id(pluginId)
^ Unresolved reference 'pluginId'.
BUILD FAILED in 3s
(예제 3의 BUILD FAILED in … 시간은 실행마다 다릅니다.) plugins {} 는 떼어 내 혼자 컴파일되므로, 본문의 pluginId 는 그 컴파일 단위에 없습니다. 줄 번호 6이 원본 그대로인 것도 보입니다.
둘째, val wantJava = true 뒤 if (wantJava) { plugins { java } } 처럼 plugins {} 를 if 안(6행)에 넣습니다(project-plugins-in-body/build.gradle.kts).
=== plugins {} inside an if ===
Build file '/tmp/plugins-in-body/build.gradle.kts' line: 6
* What went wrong:
The plugins {} block must not be used here. If you need to apply a plugin imperatively, please use apply<PluginType>() or apply(plugin = "id") instead.
BUILD FAILED in 3s
이번에는 컴파일은 되고 실행하다가 실패합니다. 최상위가 아니라서 2단계 본문에 남았고, 본문의 plugins 는 앞에서 본 CompiledKotlinBuildScript.plugins() → invalidPluginsCall() 이기 때문입니다. 메시지가 그 함수의 문자열 그대로입니다.
헷갈리기 쉬운 곳
- Kotlin 스크립트의 "컴파일 오류"는 본문의 어떤 줄보다도 먼저 납니다. 2단계 본문은 통째로 하나의 클래스로 컴파일되므로, 오류가 마지막 줄에 있어도 첫 줄의
println조차 돌지 않습니다. 예제 3의project-no-plugin(아래 7절)에서 본문 맨 앞(3행)의println("before")가 찍히지 않았습니다. Groovy 의 2차 패스도 본문 전체를 한 클래스로 컴파일하는 것은 같습니다. 다만 Groovy 는 동적 언어라 없는 이름은 보통 실행 중에야 실패합니다(언어의 일반적인 성질이고, 이 글에서 따로 돌려 보지는 않았습니다). - settings 스크립트에서
pluginManagement {}를 쓴다면 맨 처음이어야 합니다.checkForTopLevelBlockOrder()는pluginManagement블록이 있는데 첫 최상위 블록이 그것이 아니면UnexpectedBlockOrder를 던집니다(ProgramParser.kt152–161행). 같은 블록을 두 번 쓰면 "Only onepluginsblock is allowed per script." 로 실패합니다(Lexer.kt36–37행).
7. 생성된 접근자 — tasks.compileJava 는 어디서 오나
Groovy 에서는 compileJava { … } 처럼 아무 이름이나 쓰면 실행 중에 동적으로 찾습니다. 정적 타입 언어인 Kotlin 에서는 컴파일러가 tasks.compileJava 라는 프로퍼티를 컴파일 시점에 알아야 합니다. 그런데 그런 태스크가 있는지는 java 플러그인을 적용해 봐야 압니다. 그래서 Kotlin DSL 은 1단계 실행으로 플러그인이 걸린 뒤에, 그 프로젝트의 모양(스키마)을 읽어 Kotlin 확장 프로퍼티를 만들고, 그것을 2단계 컴파일 클래스패스에 넣습니다.
private
fun configuredProjectSchemaOf(scriptTarget: Any, classLoaderScope: ClassLoaderScope): TypedProjectSchema? {
require(classLoaderScope.isLocked) {
"project.classLoaderScope must be locked before querying the project schema"
}
return projectSchemaProvider.schemaFor(scriptTarget, classLoaderScope)?.takeIf { it.isNotEmpty() }
}
스키마는 확장(extensions), 태스크, 컨테이너 원소, 구성(configurations) 등의 이름과 타입 목록입니다(ProjectSchemaProvider.kt 49–58행). require(classLoaderScope.isLocked) 가 순서를 지킵니다. 스코프가 잠겼다는 것은 1단계의 플러그인 적용이 끝났다는 뜻입니다. 만드는 일 자체(GenerateProjectAccessors)도 실행 엔진의 작업 단위라서, 스키마의 해시와 클래스패스(그리고 선언형 DSL 설정값)가 같으면 다시 만들지 않고 kotlin-dsl/accessors 작업 공간의 jar 를 씁니다(같은 파일 217–224행의 identify()). 같은 플러그인 조합을 쓰는 하위 프로젝트가 여럿이면 접근자를 함께 씁니다.
태스크 접근자 하나가 어떤 코드인지는 생성기의 문자열 틀에 있습니다.
/**
* Provides the existing [$original][$type] task.
*/
${annotations}val TaskContainer.`$kotlinIdentifier`: TaskProvider<$type>
get() = named<$type>("$stringLiteral")
platforms/core-configuration/kotlin-dsl/src/main/kotlin/org/gradle/kotlin/dsl/accessors/CodeGenerator.kt 264–268행 (accessibleExistingTaskAccessorFor() 의 문자열 틀)
tasks.compileJava 는 tasks.named<JavaCompile>("compileJava") 의 다른 이름일 뿐입니다. 태스크를 만들거나 실현(realize)하지 않고 TaskProvider 만 돌려줍니다(8편의 지연 설정과 잘 맞습니다). 한 가지 덧붙이면, 이 문자열은 사람이 읽는 소스(sources jar, 보고서)용이고 실제 클래스 파일은 Kotlin 컴파일러를 거치지 않고 ASM 으로 바로 씁니다. AccessorFragments.kt 의 fragmentsForContainerElementOf() 가 named 를 부르는 바이트코드를 직접 내보냅니다(791–820행).
예제 3 (이어서): 접근자를 쓰고, 보고, 없을 때
java 플러그인을 건 프로젝트에서 접근자와 named() 를 비교했습니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// ... plugins { java } ...
// java 플러그인이 등록한 태스크를 생성된 접근자로 꺼낸다
val byAccessor: TaskProvider<JavaCompile> = tasks.compileJava
val byName = tasks.named<JavaCompile>("compileJava")
println("accessor -> ${byAccessor.name} (${byAccessor.javaClass.simpleName})")
println("named() -> ${byName.name} (${byName.javaClass.simpleName})")
println("same task -> ${byAccessor.get() === byName.get()}")
[예제 ex/u06-kotlin-stages/project-accessors/build.gradle.kts 에서 줄임]
=== accessors: tasks.compileJava ===
accessor -> compileJava (DefaultTaskContainer$TaskCreatingProvider_Decorated)
named() -> compileJava (DefaultTaskContainer$TaskCreatingProvider_Decorated)
same task -> true
=== accessors: what the generated accessor looks like ===
/**
* Provides the existing [compileJava][org.gradle.api.tasks.compile.JavaCompile] task.
*/
val TaskContainer.`compileJava`: TaskProvider<org.gradle.api.tasks.compile.JavaCompile>
get() = named<org.gradle.api.tasks.compile.JavaCompile>("compileJava")
두 길이 같은 종류의 TaskProvider 를 돌려주고 같은 태스크에 닿습니다. 뒤쪽(what the generated accessor looks like) 블록은 gradle kotlinDslAccessorsReport 의 출력에서 compileJava 부분만 고른 것으로, 위 틀에 이름과 타입을 채운 모양 그대로입니다.
같은 접근자를 java 플러그인 없이, 본문 맨 앞(3행)에 println("before") 를 두고 쓰면 이렇게 됩니다(ex/u06-kotlin-stages/project-no-plugin).
=== no java plugin, but tasks.compileJava ===
e: file:///tmp/no-plugin/build.gradle.kts:4:15: Unresolved reference 'compileJava' on receiver of type 'TaskContainer'.
Build file '/tmp/no-plugin/build.gradle.kts' line: 4
* What went wrong:
Script compilation error:
Line 4: println(tasks.compileJava.name)
^ Unresolved reference 'compileJava' on receiver of type 'TaskContainer'.
BUILD FAILED in 2s
스키마에 compileJava 가 없으니 접근자도 없고, 2단계 컴파일이 실패합니다. before 는 찍히지 않았습니다(run.sh 의 grep 은 before 로 시작하는 줄도 남기게 해 두었는데 그런 줄이 없었습니다).
빨간 칸에서 오른쪽 실선을 따르면 고리가 끝나지 않습니다. 가운데 점선이 5절에서 볼 해법의 방향입니다.
헷갈리기 쉬운 곳
apply(plugin = "java")로 건 플러그인에는 접근자가 생기지 않습니다. 접근자는 1단계가 끝난 시점의 스키마로 만들고,apply(plugin = …)은 2단계 본문에서 실행되기 때문입니다. 위 순서에서 따라 나오는 결론이고, 이 경우를 따로 돌려 보지는 않았습니다. 그때는tasks.named<JavaCompile>("compileJava")처럼 이름으로 꺼냅니다.buildSrc나 precompiled script plugin 에서 걸어도 마찬가지입니다. 접근자는 그 빌드 스크립트의plugins {}가 실제로 무엇을 걸었는지로 정해집니다. precompiled script plugin 쪽은 접근자를 만드는 방법이 따로 있는데, 7편에서 짧게 봅니다.
8. 클래스로더 스코프 — settings 에서 하위 프로젝트까지
이제 1절에서 미뤄 둔 스코프 둘을 봅니다. ClassLoaderScope 의 핵심은 두 메서드입니다. local(classPath) 는 "이 스코프에만 보이고 자식에게는 안 보이게", export(classPath) 는 "이 스코프와 자식들 모두에게 보이게" 클래스를 붙입니다. 둘 다 잠근 뒤에는 부를 수 없습니다(ClassLoaderScope.java 67–85행).
스코프마다 클래스로더가 둘 있습니다. getExportClassLoader() 는 자식 스코프가 부모로 삼는 로더이고, getLocalClassLoader() 는 그 스코프 자신의 스크립트가 쓰는 로더입니다. 그리고 lock() 뒤에는 더 붙일 수 없습니다. 잠근 뒤에야 실제 로더를 만들면, 붙인 것이 없을 때 새 로더를 만들지 않고 부모의 것을 그대로 쓰는 식으로 줄일 수 있습니다(DefaultClassLoaderScope.buildEffectiveLoaders() 93–110행). 인터페이스 주석도 "클래스로더를 게으르게, 최적화해서 만들 수 있다"를 이 장치의 목적으로 적었습니다(ClassLoaderScope.java 32행).
누가 무엇을 붙이는지를 따라가면 나무가 그려집니다.
- 맨 위
coreAndPlugins는 Gradle API 와 핵심 플러그인을 담은 뿌리입니다(DefaultClassLoaderScopeRegistry.java30행). settings[:]는 settings 스크립트의 대상 스코프입니다(SettingsFactory.java54행).buildSrc[:]가 모든 프로젝트의baseProjectClassLoaderScope입니다.BuildTreePreparingProjectsPreparer.prepareProjects()가 settings 스코프의 자식으로 만들어gradle.setBaseProjectClassLoaderScope()로 등록하고, 버전 카탈로그 접근자(libs)와 buildSrc 를 빌드한 클래스패스를 export 한 뒤 잠급니다(BuildTreePreparingProjectsPreparer.java48–73행, 카탈로그 쪽은DefaultDependenciesAccessors.java211행).- 그 아래
root-project[:], 다시 그 아래project-<이름>이 1절의InstantiatingBuildLoader가 만든 프로젝트 스코프입니다. 여기에 각 스크립트의buildscript {}클래스패스와plugins {}로 받은 플러그인을 붙이는 것이 5·6절에서 본applyPlugins(…, targetScope)입니다.
// Perform resolution & configure the classloader
classLoaderScope.export(scriptHandler.getInstrumentedScriptClassPath());
pluginDependencies.getAdditionalClassloaders().forEach(classLoaderScope::export);
classLoaderScope.lock();
// Apply the plugins
pluginApplyActions.forEach(action -> action.apply(target));
local 이 아니라 export 입니다. 그러니 루트의 buildscript 로 더한 클래스는 루트의 자식, 즉 모든 하위 프로젝트에 보입니다. 반대로 하위 프로젝트가 더한 것은 부모에게 보이지 않습니다. 클래스패스를 붙인 뒤 곧바로 잠그고 나서 플러그인을 적용하는 순서도 보입니다(플러그인 해석과 적용 자체는 7편에서 봅니다).
노란 상자는 스크립트 클래스 자신이 실리는 잠긴 자식 스코프입니다(createLockedChild).
예제 1 (이어서): 프로젝트마다 무엇이 보이나
예제 1은 루트의 buildscript 에 commons-text 를, 하위 프로젝트 app 의 buildscript 에 guava 를 넣었습니다. 각 스크립트가 자기 프로젝트의 스코프 id 를 찍고, 스크립트 클래스의 클래스로더로 두 라이브러리의 클래스를 찾아봅니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// ... import, 그리고 buildscript {} 에서 guava 33.3.1-jre 를 classpath 에 더한다 ...
println("--- ${project.path} ---")
println("scope = ${(project as ProjectInternal).classLoaderScope.id}")
for (cls in listOf("org.apache.commons.text.WordUtils", "com.google.common.base.Strings")) {
val seen = runCatching { Class.forName(cls, false, this.javaClass.classLoader) }.isSuccess
println(" sees ${cls.substringAfterLast('.')}: $seen")
}
// 부모(루트) 스크립트의 buildscript 에서 온 클래스를 컴파일 시점에도 쓸 수 있다
println(" WordUtils.capitalize -> ${org.apache.commons.text.WordUtils.capitalize("hello app")}")
[예제 ex/u06-evaluator-chain/project/app/build.gradle.kts 에서 줄임]
루트 쪽도 같은 내용을 함수로 찍습니다. --offline 이어도 되는 것은 두 라이브러리가 이미지의 읽기 전용 의존성 캐시에 미리 들어 있어서입니다(1편). 결과입니다.
--- : ---
scope = ClassLoaderScopeIdentifier{coreAndPlugins:settings[:]:buildSrc[:]:root-project[:]}
sees WordUtils: true
sees Strings: false
in afterEvaluate: executed=true
--- :app ---
scope = ClassLoaderScopeIdentifier{coreAndPlugins:settings[:]:buildSrc[:]:root-project[:]:project-app}
sees WordUtils: true
sees Strings: true
WordUtils.capitalize -> Hello App
- 스코프 id 가 그대로 나무의 경로입니다. 그림과 같습니다.
- 루트는 자기의
WordUtils만 보고app의Strings는 못 봅니다.app은 둘 다 봅니다. app스크립트는 루트에서 온WordUtils를 이름으로 직접 써서 컴파일까지 했습니다. 6절에서 본 대로 2단계를 컴파일할 때targetScope의 클래스패스(부모 쪽 export 포함)를 쓰기 때문입니다.- 루트의
afterEvaluate가:app스크립트보다 먼저 찍혔습니다. 3절의 "그 프로젝트 하나의 설정 끝"입니다.
Tomcat 과 비교하면 모양이 반대입니다. Tomcat 4편의 웹앱 로더는 모두 shared 로더의 형제 자식이라 웹앱끼리 서로의 클래스를 볼 수 없었습니다. 웹앱을 서로 떼어 놓는 것이 목적이기 때문입니다. Gradle 의 프로젝트 스코프는 프로젝트 나무를 그대로 따라 부모·자식으로 이어집니다. 부모 프로젝트가 정한 빌드 로직을 하위 프로젝트가 물려받게 하려는 것입니다.
헷갈리기 쉬운 곳
- 같은 플러그인을 하위 프로젝트들에서 따로 걸면 각자의 스코프에 실립니다. 루트의
plugins {}에apply false로 적어 두라는 흔한 조언이 이 나무와 맞아떨어집니다.apply false여도 플러그인의 클래스패스는 루트 스코프에 export 됩니다(위 코드의 export 는 적용 여부와 상관없이 일어나고, 적용만isApply()인 요청에 대해 합니다, 106–108행). 루트 스코프에 한 번 export 해 두면 하위 프로젝트들이 같은 클래스를 봅니다. 루트에 걸지 않고 하위 프로젝트마다 걸면 플러그인 클래스가 스코프마다 따로 실려, 하위 프로젝트끼리 플러그인의 타입을 주고받을 때 "같은 이름인데 다른 클래스" 문제가 생길 수 있습니다. 이 마지막 문장은 위 구조에서 따라 나오는 이야기이고, 이 글에서 직접 재현하지는 않았습니다.
이 글에서 뺀 것
- Kotlin DSL 의 부분 평가기와 남은 프로그램의 종류:
platforms/core-configuration/kotlin-dsl/src/main/kotlin/org/gradle/kotlin/dsl/execution/PartialEvaluator.kt,ResidualProgram.kt - Kotlin DSL 증분 컴파일 상태와 클래스패스 스냅숏:
…/kotlin/dsl/cache/KotlinDslIncrementalCompilationCache.kt,KotlinDslClasspathEntrySnapshotCache - Kotlin 메타데이터 호환성 검사(오래된 Kotlin 으로 만든 플러그인을 걸 때):
…/kotlin/dsl/execution/KotlinMetadataCompatibilityChecker.kt - 선언형 DSL(
.gradle.dcl):DeclarativeDslScriptPluginFactory,platforms/core-configuration/declarative-dsl-provider/ apply(from = …)로 거는 스크립트 플러그인과 그 스코프("script-" + scriptUri):subprojects/core/src/main/java/org/gradle/api/internal/plugins/DefaultObjectConfigurationAction.java151행- init 스크립트의 처리:
subprojects/core/src/main/java/org/gradle/configuration/DefaultInitScriptProcessor.java - 부모 프로젝트의 속성을 이름으로 찾아 올라가는 동적 객체와 그 폐지 준비(
FAIL_ON_PARENT_PROPERTY_LOOKUP):DefaultProject.java166–176·242–253행
정리
| 개념 | 한 줄 요약 |
|---|---|
| 프로젝트 객체 | settings 뒤 InstantiatingBuildLoader 가 설명서 나무를 내려가며 만든다. 실체는 DefaultProject_Decorated |
| 빌드 스크립트 원본 | 프로젝트를 만들 때 ProjectFactory 가 찾아 읽어 ScriptSource 로 쥐여 준다 |
ensureSelfConfigured() |
Created → Configured 전이. 프로젝트 잠금 안에서 한 번만, 부모 먼저 |
| 평가기 사슬 | LifecycleProjectEvaluator → ConfigureActionsProjectEvaluator → 자동 적용 동작 · BuildScriptProcessor · 지연 동작 |
| 프로젝트 잠금 | runWithModelLock(). 이미 쥐었으면 그냥, 아니면 다른 프로젝트 잠금과 바꿔 쥔다 |
| 프로젝트 상태 | UNCONFIGURED → IN_BEFORE_EVALUATE → IN_EVALUATE → IN_AFTER_EVALUATE → CONFIGURED. executed 는 afterEvaluate 전부터 true |
afterEvaluate |
스크립트가 실패해도 돈다. 설정이 끝난 뒤에 걸면 예외 |
| 빌드 파일 고르기 | .gradle → .gradle.kts → .gradle.dcl 순서로 처음 찾은 것. 나머지는 문제 보고서에만 |
| 실행기 고르기 | ScriptPluginFactorySelector 가 파일 이름 끝으로. Kotlin 쪽은 클래스 이름 문자열로 읽는다 |
| Groovy 두 패스 | cp_proj(CLASSPATH, base 스코프) → 플러그인 적용 → proj(BODY, 프로젝트 스코프) |
| Kotlin 두 단계 | ProgramParser 가 최상위 블록을 떼고 공백으로 채운다. 1단계 프로그램이 2단계를 컴파일·실행한다 |
| 스크립트 컴파일 캐시 | 메모리(빌드·데몬) + 실행 엔진 작업 공간(caches/<판>/groovy-dsl, kotlin-dsl/scripts) |
| 접근자 | 1단계 뒤의 스키마로 만든다. tasks.compileJava = tasks.named<JavaCompile>("compileJava") |
| 클래스로더 스코프 | coreAndPlugins → settings → buildSrc → 루트 → 하위 프로젝트. buildscript 는 export, 붙인 뒤 lock |
다음에 볼 것
이 글에서 plugins {} 블록은 "1단계에서 실행되어 플러그인을 적용한다"까지만 봤습니다. 그 안에서 id("java") 가 어떻게 JavaPlugin 클래스가 되는지, 플러그인 포털에서 받는 플러그인은 무엇을 내려받는지, apply() 가 두 번 불리지 않게 하는 장치와 pluginManager.withPlugin() 이 불리는 시점은 7편: 플러그인 적용 — plugins {} 블록에서 apply()까지에서 이어집니다.
앞 연재에서 닮은 것을 다뤘습니다. 프로젝트마다 이어지는 클래스로더 나무는 Tomcat 4편의 웹앱 클래스로더와 정반대의 목적으로 만들어졌습니다(8절). 실행 중에 하위 클래스를 만들어 쓰는 DefaultProject_Decorated 는 4편의 _Decorated 클래스이고, 그 원리는 Spring 10편의 CGLIB 프록시와 닮았습니다. 설정 단계를 여러 겹의 평가기로 감싸는 방식은 Tomcat 8편의 밸브처럼 "안쪽을 부르는 바깥"이고, LifecycleProjectEvaluator 가 finally 로 afterEvaluate 를 꼭 부르는 것은 밸브가 돌아오는 길을 늘 지나는 것과 같은 모양입니다.