namalzi

← 글 목록

Gradle 소스 파헤치기 6: 프로젝트와 빌드 스크립트 — build.gradle.kts가 실행되기까지

Gradle · 게시

build.gradle.kts 한 파일은 소스에서 무엇이 될까요. settings가 적어 둔 설명서로 DefaultProject를 만드는 곳, 프로젝트 잠금 안에서 도는 평가기 사슬과 beforeEvaluate·afterEvaluate 상태, 파일 이름 끝으로 Groovy와 Kotlin을 고르는 선택기, plugins {}를 먼저 떼어 두 번 컴파일하는 두 DSL, tasks.compileJava를 가능하게 하는 생성된 접근자, settings에서 하위 프로젝트로 이어지는 클래스로더 스코프까지, 같은 스크립트를 두 DSL로 돌리고 스택을 찍어 소스와 함께 확인합니다.

이 글에서 다루는 것

"Gradle 소스 파헤치기" 연재의 여섯 번째 글입니다. 5편에서는 빌드 한 번이 초기화·설정·실행으로 갈리는 자리, 즉 DefaultBuildLifecycleController 와 VintageBuildModelController 의 상태가 바뀌는 순서를 봤습니다. 그 가운데 "설정 단계"는 결국 프로젝트마다 빌드 스크립트를 실행하는 일입니다. 이번 글은 그 한 칸을 확대합니다.

2편의 지도로 말하면 **D 구역(설정 단계)의 첫 상자, "빌드 스크립트 실행(Kotlin·Groovy DSL)"**입니다. D 구역의 출발점으로 적어 둔 LifecycleProjectEvaluator 와 KotlinScriptEvaluator 가 이 글의 주인공이고, 같은 구역의 나머지인 플러그인(DefaultPluginManager)과 태스크(DefaultTaskContainer)는 7·8편에서 봅니다.

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

항목 내용
대상 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() 은 상태 객체(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 확장은 이 훅으로, 빌드 스크립트가 돌기 직전에 프로젝트마다 붙습니다.

프로젝트를 이루는 객체들. 상태 인터페이스 ProjectState 를 DefaultProjectState 가 구현하고 프로젝트 잠금(projectLock)과 runWithModelLock() 을 가진다. DefaultProjectState 는 ProjectLifecycleController 를 품고, 이 컨트롤러는 NotCreated, Created, Configured 상태를 지나며 createMutableModel() 로 ProjectFactory 를 통해 DefaultProject 를 만들고 ensureSelfConfigured() 로 설정한다. DefaultProject 는 공개 API Project 를 물려받은 ProjectInternal 을 구현하는 abstract 모델 클래스로, ProjectStateInternal 상태와 클래스로더 스코프 둘을 가진다. 실제 객체는 실행 중에 생성된 하위 클래스 DefaultProject_Decorated 다. 파란 상자가 "상태" 쪽, 나머지가 빌드 스크립트가 만지는 "모델" 쪽입니다.

ProjectLifecycleController 의 상태는 NotCreated, Created, Configured 셋뿐입니다(49–51행). Created 에서 Configured 로 가는 전이가 곧 "빌드 스크립트 실행"입니다. 그 전이를 일으키는 메서드가 ensureSelfConfigured() 입니다.

    public void ensureSelfConfigured() {
        controller.maybeTransitionIfNotCurrentlyTransitioning(State.Created, State.Configured, () -> lockGuard.withProjectLock(() -> project.evaluateUnchecked()));
    }

subprojects/core/src/main/java/org/gradle/api/internal/project/ProjectLifecycleController.java 95–97행

5편의 StateTransitionController 가 여기서도 쓰입니다. "아직 Created 이고 지금 전이 중이 아니면" 한 번만 설정합니다. 그래서 여러 곳에서 "이 프로젝트를 설정해 둬"라고 불러도 스크립트는 한 번만 돕니다. 그리고 그 일은 프로젝트 잠금(withProjectLock) 안에서 합니다. 잠금은 2절에서 봅니다.

헷갈리기 쉬운 곳

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

바깥에서 안으로 읽으면 사슬은 이렇게 생겼습니다.

  1. LifecycleProjectEvaluator — 빌드 오퍼레이션 Configure project :… 을 열고, beforeEvaluate → (안쪽 평가기) → afterEvaluate 를 부르며 상태를 바꿉니다.
  2. 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();
                }
            });
        }

subprojects/core/src/main/java/org/gradle/configuration/project/LifecycleProjectEvaluator.java 98–127행에서 줄임

여기서 세 가지를 읽습니다.

가운데 고리 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 로 찍는다 ...
        }

subprojects/core/src/main/java/org/gradle/configuration/project/BuildScriptProcessor.java 37–51행에서 줄임

--info 를 주면 찍히는 Evaluating root project 'x' using build file '…'. 가 이 40행입니다. 넘기는 인자를 보면 이 글의 뒷부분이 다 들어 있습니다. 1절에서 프로젝트가 들고 태어난 스크립트 원본, buildscript {} 를 받아 줄 ScriptHandler, 프로젝트의 클래스로더 스코프 둘, 그리고 "최상위 스크립트(빌드 스크립트 자체)인가"를 뜻하는 true 입니다. 마지막 값이 false 면 apply(from = …) 로 건 스크립트입니다.

평가기 사슬의 호출 순서. ① ProjectLifecycleController 가 프로젝트 잠금 안에서 LifecycleProjectEvaluator.evaluate() 를 부르고, 빌드 오퍼레이션 Configure project 가 열린다. ② beforeEvaluate 리스너를 부른다. ③ ConfigureActionsProjectEvaluator.evaluate() 를 부르면 ④ help·wrapper·init 같은 자동 적용 동작을 먼저 실행하고 ⑤ BuildScriptProcessor.execute() 를 부른다. ⑥ BuildScriptProcessor 는 ScriptPluginFactorySelector.create() 에 빌드 파일과 스코프 둘을 넘겨 파일 이름으로 고른 ScriptPlugin 을 받고 ⑦ apply(project) 를 부른다. 이것이 빌드 오퍼레이션 Apply build file 이다. ⑧ 지연된 설정 동작을 실행하고 ⑨ afterEvaluate 리스너를 부른다. ①·③·⑤·⑦은 아래 예제의 스택에서 아래부터 위로 그대로 보입니다.

예제 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

아래(바깥)에서 위(안쪽)로 읽습니다.

프로젝트 잠금

여러 곳이 "모델을 만질 때는 잠금부터"를 지키게 만드는 것이 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편에서 봅니다.

헷갈리기 쉬운 곳

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 플러그인이 이 동작에 기대고 있다고 적었습니다.

ProjectStateInternal 의 상태 그림. DefaultProject 가 생기면 UNCONFIGURED 다. toBeforeEvaluate() 로 IN_BEFORE_EVALUATE 가 되고, beforeEvaluate 리스너가 성공하면 IN_EVALUATE 로 간다. 리스너가 실패하면 실패를 기록하고 곧장 CONFIGURED 로 간다. IN_EVALUATE 에서는 자동 적용 동작, 빌드 스크립트, 지연된 동작이 돌고, 스크립트가 끝나면 실패했더라도 finally 로 IN_AFTER_EVALUATE 가 된다. 여기부터 executed 가 true 다. afterEvaluate 리스너를 묶음이 빌 때까지 부른 뒤 CONFIGURED 가 된다. 빌드 스크립트 본문은 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행의 메시지로 실패합니다.

헷갈리기 쉬운 곳

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

platforms/core-runtime/build-discovery/src/main/java/org/gradle/internal/scripts/ScriptingLanguages.java 30–37행

이 목록 하나가 두 일을 합니다. 파일을 찾을 때는 이 순서대로 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행

빌드 파일을 고르고 실행기를 고르는 흐름. 프로젝트 폴더에서 DefaultScriptFileResolver 가 build 뒤에 확장자 셋을 1 build.gradle, 2 build.gradle.kts, 3 build.gradle.dcl 순서로 보고 처음 찾은 파일 하나만 쓰며 나머지는 문제로 보고한다. 하나도 없으면 빈 build.gradle 로 친다. ScriptPluginFactorySelector 는 파일 이름의 끝을 보고 .gradle 이면 Groovy DSL 의 DefaultScriptPluginFactory, .gradle.kts 면 Kotlin DSL 의 KotlinScriptPluginFactory, .gradle.dcl 이면 DeclarativeDslScriptPluginFactory 를 고른다. 셋 모두 BuildOperationScriptPlugin 으로 감싸 Apply build file 오퍼레이션이 된다. 빨간 상자가 아래 예제의 함정입니다.

예제 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 로 찍혔습니다. 붙이지 않으면 위처럼 보고서 경로 한 줄만 남습니다.

헷갈리기 쉬운 곳

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. 1차 패스: buildscript {}, plugins {}, (settings 라면) pluginManagement {} 만 남기고 나머지는 컴파일조차 하지 않은 클래스를 만들어 실행합니다. 이 실행으로 buildscript 의 저장소·클래스패스 의존성이 ScriptHandler 에 쌓이고 plugins {} 의 요청이 모입니다. 컴파일에 쓰는 클래스로더는 baseScope 입니다.
  2. 모은 요청으로 applyPlugins(…, targetScope) 를 부릅니다. 여기서 클래스패스를 해석해 프로젝트의 스코프(targetScope)에 붙이고 플러그인을 적용합니다(8절에서 다시 봅니다).
  3. 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행).

Groovy DSL 의 두 패스. build.gradle 을 1차로 SubsetScriptTransformer 가 buildscript 와 plugins 만 남기고 컴파일한다(id cp_proj, 단계 CLASSPATH). 1차를 실행해 플러그인 요청을 모으고, applyPlugins 가 스코프에 클래스패스를 export 하고 lock 한 뒤 플러그인을 apply 한다. 2차로 BuildScriptTransformer 가 그 블록들을 뺀 나머지를 컴파일하고(id proj, 단계 BODY) 실행하면 build_해시 클래스의 run() 이 돈다. 컴파일 결과는 빌드 하나 안의 메모리 캐시, 데몬 안의 메모리 캐시, ~/.gradle/caches/버전/groovy-dsl/ 의 실행 엔진 작업 공간 세 층에서 찾는다. 노란 원통 셋은 아래에서 위로, 가까운 캐시부터 찾는 순서입니다.

컴파일 결과는 세 층에서 찾는다

compiler.compile(...) 뒤에는 캐시가 세 겹 있습니다.

태스크가 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

빌드 오퍼레이션 기록(-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절에 싣습니다.

헷갈리기 쉬운 곳

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

platforms/core-configuration/kotlin-dsl/src/main/kotlin/org/gradle/kotlin/dsl/execution/ProgramParser.kt 55–84행에서 줄임

        for (ch in subSequence(range)) {
            result.append(
                if (Character.isWhitespace(ch)) ch
                else ' '
            )
        }

platforms/core-configuration/kotlin-dsl/src/main/kotlin/org/gradle/kotlin/dsl/execution/ProgramSource.kt 156–161행

공백 문자(줄 바꿈 포함)는 그대로 두고 나머지 글자를 공백으로 바꿉니다. 그래서 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)과 맞추라고 적었습니다. 빌드 오퍼레이션을 받아 보여 주는 쪽이 이 이름에 기대고 있다는 뜻입니다.

Kotlin DSL 의 두 단계. build.gradle.kts 를 ProgramParser 가 맨 위의 buildscript·plugins 블록을 떼어 내 1단계 조각과, 뗀 자리를 공백으로 채운 나머지 본문인 2단계 조각으로 나눈다. 1단계 조각은 부모 스코프의 클래스패스로 컴파일(CLASSPATH)되어 실행되고, 플러그인 해석 → 스코프에 export → lock → apply 를 한다. 그 뒤 적용된 플러그인의 스키마로 tasks.compileJava 같은 접근자를 만든다. 2단계 조각은 프로젝트 스코프와 접근자로 컴파일(BODY)되어 실행되는데, Build_gradle 의 생성자가 곧 스크립트 본문이다. 컴파일 결과와 접근자는 ~/.gradle/caches/버전/kotlin-dsl/ 의 scripts 와 accessors 에 캐시된다. 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()
}

platforms/core-configuration/kotlin-dsl/src/main/kotlin/org/gradle/kotlin/dsl/support/CompiledKotlinBuildScript.kt 33–61행에서 줄임

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

시간은 실행마다 다릅니다. 읽을 것은 이렇습니다.

같은 컨테이너에서 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 와 다른 점이 셋 보입니다.

예제 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() 이기 때문입니다. 메시지가 그 함수의 문자열 그대로입니다.

헷갈리기 쉬운 곳

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

platforms/core-configuration/kotlin-dsl/src/main/kotlin/org/gradle/kotlin/dsl/accessors/AccessorsClassPath.kt 138–144행

스키마는 확장(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 로 시작하는 줄도 남기게 해 두었는데 그런 줄이 없었습니다).

순환 참조의 고리. getBean("a")가 캐시에 없으니 A 를 인스턴스화한다(메모리에 A 가 생기지만 필드 b 는 비어 있다). 주입 단계에서 B 가 필요해 getBean("b")를 부르고, B 도 캐시에 없으니 인스턴스화한다(필드 a 는 비어 있다). B 의 주입 단계에서 다시 A 가 필요해 getBean("a")를 부른다. 완성된 빈만 캐시에 넣는다면 A 는 아직 캐시에 없으므로 처음으로 돌아가 또 만든다. 빠져나갈 길은 이미 메모리에 있는 A 의 참조를 B 에게 먼저 건네는 것(조기 참조)이다 빨간 칸에서 오른쪽 실선을 따르면 고리가 끝나지 않습니다. 가운데 점선이 5절에서 볼 해법의 방향입니다.

헷갈리기 쉬운 곳

8. 클래스로더 스코프 — settings 에서 하위 프로젝트까지

이제 1절에서 미뤄 둔 스코프 둘을 봅니다. ClassLoaderScope 의 핵심은 두 메서드입니다. local(classPath) 는 "이 스코프에만 보이고 자식에게는 안 보이게", export(classPath) 는 "이 스코프와 자식들 모두에게 보이게" 클래스를 붙입니다. 둘 다 잠근 뒤에는 부를 수 없습니다(ClassLoaderScope.java 67–85행).

스코프마다 클래스로더가 둘 있습니다. getExportClassLoader() 는 자식 스코프가 부모로 삼는 로더이고, getLocalClassLoader() 는 그 스코프 자신의 스크립트가 쓰는 로더입니다. 그리고 lock() 뒤에는 더 붙일 수 없습니다. 잠근 뒤에야 실제 로더를 만들면, 붙인 것이 없을 때 새 로더를 만들지 않고 부모의 것을 그대로 쓰는 식으로 줄일 수 있습니다(DefaultClassLoaderScope.buildEffectiveLoaders() 93–110행). 인터페이스 주석도 "클래스로더를 게으르게, 최적화해서 만들 수 있다"를 이 장치의 목적으로 적었습니다(ClassLoaderScope.java 32행).

누가 무엇을 붙이는지를 따라가면 나무가 그려집니다.

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

platforms/extensibility/plugin-use/src/main/java/org/gradle/plugin/use/internal/DefaultPluginRequestApplicator.java 129–135행

local 이 아니라 export 입니다. 그러니 루트의 buildscript 로 더한 클래스는 루트의 자식, 즉 모든 하위 프로젝트에 보입니다. 반대로 하위 프로젝트가 더한 것은 부모에게 보이지 않습니다. 클래스패스를 붙인 뒤 곧바로 잠그고 나서 플러그인을 적용하는 순서도 보입니다(플러그인 해석과 적용 자체는 7편에서 봅니다).

클래스로더 스코프의 나무. 맨 위 coreAndPlugins(Gradle API·핵심 플러그인) 아래에 settings[:](settings 스크립트의 클래스패스)가 있고, 그 아래 buildSrc[:](buildSrc 결과·버전 카탈로그 접근자)가 모든 프로젝트의 base 스코프다. 그 아래 root-project[:](루트 buildscript·plugins), 다시 그 아래 project-app(app 의 buildscript·plugins)이 있다. 각 프로젝트 스코프 옆에 그 스크립트 클래스를 담은 잠긴 자식 스코프 kotlin-dsl:…/build.gradle.kts:Project/TopLevel/stage2 가 매달려 있다. 노란 상자는 스크립트 클래스 자신이 실리는 잠긴 자식 스코프입니다(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

Tomcat 과 비교하면 모양이 반대입니다. Tomcat 4편의 웹앱 로더는 모두 shared 로더의 형제 자식이라 웹앱끼리 서로의 클래스를 볼 수 없었습니다. 웹앱을 서로 떼어 놓는 것이 목적이기 때문입니다. Gradle 의 프로젝트 스코프는 프로젝트 나무를 그대로 따라 부모·자식으로 이어집니다. 부모 프로젝트가 정한 빌드 로직을 하위 프로젝트가 물려받게 하려는 것입니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

정리

개념 한 줄 요약
프로젝트 객체 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 를 꼭 부르는 것은 밸브가 돌아오는 길을 늘 지나는 것과 같은 모양입니다.

다른 글