namalzi

← 글 목록

Gradle 소스 파헤치기 5: 빌드 생명주기 — 초기화, 설정, 실행은 어디서 갈리나

Gradle · 게시

데몬이 받은 빌드 요청이 실행기 사슬을 지나 빌드 생명주기에 닿는 길, 상태 전이를 지키는 StateTransitionController, init 스크립트와 settings를 읽는 초기화, 프로젝트를 설정하는 설정 단계, 설정 시간 장벽과 작업 그래프, 그리고 buildFinished와 Flow API로 끝나는 마무리까지 기준 커밋의 소스로 따라갑니다. 생명주기 훅을 모두 걸어 순서를 찍고, 빌드 오퍼레이션 나무와 세 단계에서 일부러 낸 실패로 확인합니다.

이 글에서 다루는 것

"Gradle 소스 파헤치기" 연재의 다섯 번째 글입니다. 4편에서는 Gradle 안의 DI 컨테이너인 서비스 레지스트리가 스코프마다 서비스를 만들고 이어 주는 것을 봤습니다. 이번 글은 그 서비스들이 실제로 어떤 순서로 불려 빌드 한 번을 이루는지, 사용자 문서가 "초기화 → 설정 → 실행"이라고 부르는 세 단계가 소스의 어디서 갈리는지를 따라갑니다.

2편의 지도로 돌아가면, 이 글은 "C · 빌드 생명주기" 구역을 확대합니다. 2편에서는 DefaultBuildLifecycleController의 상태 그림(Configure → TaskSchedule → ReadyToRun)과 빌드 한 번의 호출 순서를 큰 그림으로 봤습니다. 이번에는 그 위(데몬에서 생명주기까지 오는 길)와 아래(각 단계 안에서 실제로 일하는 클래스), 그리고 상태를 지키는 장치와 실패했을 때의 길까지 들어갑니다.

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

항목 내용
대상 Gradle Build Tool
버전 9.10.0 개발판 (master)
커밋 07ecbf9ead (2026-10-24)
주로 읽는 클래스 LauncherServices, RootBuildLifecycleBuildActionExecutor, DefaultBuildTreeLifecycleController, DefaultBuildLifecycleController, StateTransitionController, VintageBuildModelController, DefaultSettingsPreparer, BuildTreePreparingProjectsPreparer, DefaultProjectsPreparer, DefaultBuildOperationRunner

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 이 블로그 자신도 Gradle(Kotlin DSL)로 빌드되는데, 포털의 래퍼는 9.5.1이라 이 글의 기준 소스와 판이 다릅니다.

생명주기에 걸린 클래스는 수십 개이고, 같은 일을 하는 길도 여럿입니다(설정 캐시가 켜졌을 때, IDE가 Tooling API로 모델을 달라고 할 때, 포함된 빌드일 때). 이 글은 설정 캐시가 꺼진 상태에서 명령줄로 gradle <태스크>를 실행하는 길 하나만 따라갑니다. 다른 길은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적었습니다.

먼저 알아 둘 말

빌드 액션(build action)은 클라이언트가 데몬에게 보내는 "해 달라는 일" 하나입니다. 명령줄의 gradle hello는 ExecuteBuildAction이 되고, IDE가 프로젝트 모델을 달라고 하면 BuildModelAction이 됩니다. 데몬은 이 객체를 받아 사슬로 넘깁니다. 클라이언트와 데몬 사이는 3편에서 봤습니다.

실행기 사슬(executor chain)은 빌드 액션을 받아 앞뒤로 일을 하고 다음 것에 넘기는 객체들의 줄입니다. 이름이 …ActionExecutor이거나 …BuildActionRunner로 끝납니다. Tomcat 8편의 밸브 사슬과 같은 모양인데, 연결 리스트가 아니라 생성자에 다음 것을 넣어 감싼 데코레이터(decorator)입니다.

상태(state)는 2편에서 본 다섯 층(빌드 프로세스 > 세션 > 빌드 트리 > 빌드 > 프로젝트)의 상태 객체입니다. 이 글에서는 BuildSessionState, BuildTreeState, 루트 빌드의 RootBuildState가 언제 만들어지는지를 봅니다.

생명주기 훅(lifecycle hook)은 빌드 스크립트나 init 스크립트에서 gradle.projectsEvaluated { … }처럼 거는 콜백입니다. 소스에서는 BuildListener·ProjectEvaluationListener·TaskExecutionGraphListener 같은 리스너 인터페이스의 메서드이고, Gradle은 정해진 지점에서 이 리스너들에게 한꺼번에 알립니다(broadcast).

빌드 오퍼레이션(build operation)은 Gradle이 일의 덩어리마다 붙이는 이름표입니다. 이름, 부모 오퍼레이션, 시작·끝 시각을 가지고, 나무 모양으로 쌓입니다. 1편에서 trace 파일로 그 나무를 봤습니다(Load build, Configure build, …).

설정 시간 장벽(configuration time barrier)은 "지금이 설정 시간인가"를 기억하는 깃발 하나입니다. 설정이 시작될 때 세우고, 작업 그래프가 다 만들어지면 넘습니다. 이 글의 제목 질문 "설정과 실행은 어디서 갈리나"에 가장 직접 답하는 장치입니다. 5절에서 봅니다.

1. 데몬에서 생명주기까지 — 실행기 사슬

사슬이 만들어지는 곳

데몬의 명령 사슬 끝인 ExecuteBuild는 빌드 액션을 "빌드 액션 실행기"에 넘깁니다(3편). 그 실행기부터 생명주기 클래스까지 사이에는 열 겹이 넘는 객체가 있는데, 대부분 서비스 레지스트리의 팩토리 메서드에서 생성자로 서로를 감싸며 만들어집니다. 가장 두꺼운 묶음이 빌드 트리 스코프의 LauncherServices입니다.

            return new RootBuildLifecycleBuildActionExecutor(
                // ... 인자 7개 ...
                new BuildCompletionNotifyingBuildActionRunner(
                    // ... 인자 3개 ...
                    new FileSystemWatchingBuildActionRunner(
                        // ... 인자 8개 ...
                        new BuildOutcomeReportingBuildActionRunner(
                            // ... 인자 7개 ...
                            new ProblemReportingBuildActionRunner(
                                // ... 인자 3개 ...
                                new ChainingBuildActionRunner(buildActionRunners)
                            )
                        )
                    )
                ));

platforms/core-runtime/launcher/src/main/java/org/gradle/tooling/internal/provider/LauncherServices.java 223–260행

new가 안쪽으로 들어갈수록 사슬의 뒤쪽입니다. 각 러너는 자기 일을 하고 delegate.run(...)으로 안쪽을 부르며, 안쪽이 돌아오면 남은 일을 합니다.

러너 안쪽을 부르기 전 안쪽이 돌아온 뒤
BuildCompletionNotifyingBuildActionRunner — Develocity(빌드 스캔) 플러그인 관리자에게 결과를 알린다 (61–73행)
FileSystemWatchingBuildActionRunner Watching the file system is configured to be … 로그, 파일 감시 시작 파일 감시 상태를 다음 빌드를 위해 정리 (74–131행)
BuildOutcomeReportingBuildActionRunner BuildLogger를 "로거"로 걸어 둔다 BUILD SUCCESSFUL in …·N actionable tasks 출력 (69–95행)
ProblemReportingBuildActionRunner — 모아 둔 문제 보고서를 내고 실패로 더한다 (53–60행)
ChainingBuildActionRunner 등록된 러너들에게 차례로 "이 액션 처리할 수 있나" 묻는다 —

마지막 ChainingBuildActionRunner만 모양이 다릅니다. 감싸지 않고 목록을 돌며 러너마다 run()을 불러 보고, 결과를 내놓은 첫 러너의 결과를 돌려줍니다. 아무도 맡지 않으면 Don't know how to run a build action of type … 예외입니다(ChainingBuildActionRunner.java 32–41행). 목록에는 LauncherServices가 등록하는 ExecuteBuildActionRunner(90행) 말고도 Tooling API 쪽 모듈이 등록하는 넷(BuildModelActionRunner, TestExecutionRequestActionRunner, ClientProvidedBuildActionRunner, ClientProvidedPhasedActionRunner — ToolingBuilderServices.java 34–37행)이 있습니다. 각자 자기 액션이 아니면 Result.nothing()을 돌려줍니다. 명령줄 빌드를 맡는 것은 이것입니다.

        if (!(action instanceof ExecuteBuildAction)) {
            return Result.nothing();
        }
        try {
            buildController.scheduleAndRunTasks();
            return Result.of(null);

platforms/core-runtime/launcher/src/main/java/org/gradle/tooling/internal/provider/ExecuteBuildActionRunner.java 27–32행

buildController.scheduleAndRunTasks() 한 줄이 생명주기로 들어가는 문입니다. buildController는 빌드 트리의 생명주기를 맡는 DefaultBuildTreeLifecycleController입니다(2절 뒤에서 봅니다).

예제 1: 훅에서 호출 스택을 찍어 사슬 보기

사슬 전체를 눈으로 보려고 init 스크립트에서 훅 넷(beforeSettings, projectsEvaluated, taskGraph.whenReady, buildFinished)을 걸고 호출 스택을 찍었습니다. 스택은 수백 줄이라, 이름이 …Executor·…Runner·…Controller·…Preparer·…Processor처럼 "단계"를 나타내는 Gradle 클래스만 남겼습니다(이름에 BuildOperation이 든 클래스는 뺐습니다. 8절에서 따로 봅니다).

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// init 스크립트: 훅 넷에서 호출 스택을 찍는다. 스택이 수백 줄이라
// 이름이 …Executor/…Runner/…Controller/…Preparer/…Processor 처럼 "단계"를 나타내는 Gradle 클래스만 남긴다.
fun stack(label: String) {
    val keep = Regex("""(Executor|Executer|Runner|Controller|Preparer|Processor|Notifier|Loader|State)$""")
    println("--- $label (bottom = thread start) ---")
    // ... Throwable().stackTrace 를 뒤집어 org.gradle. 클래스 가운데 keep 에 맞고
    //     이름에 BuildOperation 이 없는 것만, 같은 클래스가 이어지면 한 번만 찍는다 ...
}

gradle.beforeSettings { stack("beforeSettings") }
gradle.projectsEvaluated { stack("projectsEvaluated") }
gradle.taskGraph.whenReady { stack("taskGraph.whenReady") }
@Suppress("DEPRECATION")
gradle.buildFinished { stack("buildFinished") }

이 예제는 기준 커밋(07ecbf9ead)을 그대로 빌드한 Gradle 9.10.0 개발판으로 도커 컨테이너(Eclipse Temurin JDK 25)에서 실행했습니다. ex/u05-stacks/run.sh가 gradle hello -I init.gradle.kts --offline --console=plain -q를 돌립니다. 먼저 beforeSettings의 스택입니다. 위가 스레드의 시작, 아래가 훅에 가까운 쪽입니다.

--- beforeSettings (bottom = thread start) ---
  AbstractManagedExecutor.run:47
  SetupLoggingActionExecutor.execute:31
  SessionFailureReportingActionExecutor.execute:39
  StartParamsValidatingActionExecutor.execute:32
  BuildSessionLifecycleBuildActionExecutor.execute:45
  BuildSessionState.run:73
  BuildSessionLifecycleBuildActionExecutor.apply:94
  SubscribableBuildActionExecutor.execute:72
  ContinuousBuildActionExecutor.execute:111
  DefaultBuildTreeActionExecutor.runBuildTreeAction:97
  RootBuildLifecycleBuildActionExecutor.execute:100
  DefaultRootBuildState.run:114
  RootBuildLifecycleBuildActionExecutor.lambda:100
  BuildCompletionNotifyingBuildActionRunner.run:64
  FileSystemWatchingBuildActionRunner.run:118
  BuildOutcomeReportingBuildActionRunner.run:83
  ProblemReportingBuildActionRunner.run:55
  ChainingBuildActionRunner.run:35
  ExecuteBuildActionRunner.run:31
  DefaultBuildTreeLifecycleController.scheduleAndRunTasks:74
  StateTransitionController.transition:227
  DefaultBuildTreeLifecycleController.lambda:159
  VintageBuildTreeWorkController.scheduleAndRunRequestedTasks:36
  BarrierAwareBuildTreeWorkPreparer.scheduleRequestedTasks:33
  VintageConfigurationTimeActionRunner.runConfigurationTimeAction:48
  BarrierAwareBuildTreeWorkPreparer.scheduleRequestedTasks:34
  DefaultBuildTreeWorkPreparer.scheduleRequestedTasks:35
  DefaultBuildLifecycleController.prepareToScheduleTasks:182
  StateTransitionController.maybeTransition:244
  DefaultBuildLifecycleController.lambda:184
  VintageBuildModelController.prepareToScheduleTasks:69
  StateTransitionController.transitionIfNotPreviously:267
  VintageBuildModelController.lambda:79
  DefaultSettingsPreparer.prepareSettings:123
  RootBuildCacheControllerSettingsProcessor.process:47
  SharedModelDefaultsSettingsProcessor.process:37
  SettingsEvaluatedCallbackFiringSettingsProcessor.process:34
  ScriptEvaluatingSettingsProcessor.process:70

(AbstractManagedExecutor는 데몬의 Daemon worker 스레드를 돌리는 실행기입니다. 그 위의 데몬 명령 사슬은 이름이 걸러지는 규칙에 맞지 않아 빠졌습니다.) 이 서른여덟 줄이 이 글의 지도입니다. 위에서부터 묶으면 이렇습니다.

데몬 명령 사슬의 끝 ExecuteBuild 아래로 실행기 사슬이 이어진다. 빌드 프로세스 스코프에서 SetupLogging, SessionFailureReporting, StartParamsValidating을 지나 BuildSessionLifecycleBuildActionExecutor가 세션 상태를 만든다. 세션 스코프에서 SubscribableBuildActionExecutor가 Tooling API 구독을 연결하고, ContinuousBuildActionExecutor는 --continuous면 여기서 되풀이하며, DefaultBuildTreeActionExecutor가 Run build 오퍼레이션을 열고 빌드 트리 상태를 만든다. 빌드 트리 스코프에서 RootBuildLifecycleBuildActionExecutor가 루트 빌드 상태를 만들고, LauncherServices의 러너 다섯 겹(완료 알림, 파일 감시, 결과 출력, 문제 보고, Chaining)을 지나 ExecuteBuildActionRunner가 DefaultBuildTreeLifecycleController.scheduleAndRunTasks()를 부른다. 노란 상자가 다음 층의 상태 객체를 만드는 곳입니다. 상자 묶음 하나가 스코프 하나와 같습니다.

  1. 빌드 프로세스 스코프(BuildProcessScopeServices가 만드는 넷): 로깅을 이 빌드의 설정대로 맞추고, 세션 밖에서 난 실패를 보고하고, 시작 인자를 검사한 뒤, BuildSessionLifecycleBuildActionExecutor가 CrossBuildSessionState와 BuildSessionState를 만듭니다(try (BuildSessionState buildSessionState = new BuildSessionState(…)), 66행).
  2. 세션 스코프: SubscribableBuildActionExecutor가 Tooling API 클라이언트가 구독한 이벤트를 리스너로 걸고, ContinuousBuildActionExecutor가 --continuous인지 봅니다. 연속 빌드면 같은 세션 안에서 빌드를 여러 번 돌리고(executeMultipleBuilds, 108행), 아니면 한 번만 넘깁니다(111행). DefaultBuildTreeActionExecutor가 빌드 오퍼레이션 Run build를 열고 BuildTreeState를 만듭니다(145행).
  3. 빌드 트리 스코프: RootBuildLifecycleBuildActionExecutor가 buildStateRegistry.createRootBuild(...)로 루트 빌드의 상태를 만들고, rootBuild.run(buildController -> buildActionRunner.run(action, buildController))로 위의 러너 다섯 겹에 넘깁니다(RootBuildLifecycleBuildActionExecutor.java 99–100행).

왜 사슬로 쌓았나

사슬의 층 나눔은 2편의 상태 계층과 꼭 맞습니다. 세션 상태를 만드는 실행기는 세션 스코프 바깥에, 빌드 트리 상태를 만드는 실행기는 세션 안쪽에 있습니다. 각 실행기가 try (… State … = new …State(…))로 상태를 열고 안쪽을 부른 뒤 닫으니, 상태가 사는 기간이 곧 그 실행기의 메서드가 열려 있는 기간이 됩니다. 연속 빌드에서 세션 하나 안에 빌드 트리가 여러 번 생겼다 사라지는 것도, ContinuousBuildActionExecutor가 세션과 빌드 트리 사이에 끼어 있기 때문에 저절로 됩니다.

다른 하나는 관심사 나누기입니다. "파일 감시를 켜고 끄는 일", "BUILD SUCCESSFUL을 찍는 일", "빌드 스캔에 알리는 일"은 빌드 액션이 무엇이든(명령줄 빌드든 IDE 모델 요청이든) 똑같이 해야 합니다. 이것을 러너 하나씩으로 떼어 감싸 두면, 사슬 끝의 ChainingBuildActionRunner가 어떤 러너를 고르든 앞뒤 일은 빠지지 않습니다. Spring MVC의 인터셉터나 서블릿 필터(Spring 20편)가 하는 일과 같습니다.

헷갈리기 쉬운 곳

2. 상태를 지키는 장치 — StateTransitionController

생명주기 객체 셋, 저마다의 상태 열거형

스택의 아랫부분에는 StateTransitionController가 세 번 나옵니다(transition, maybeTransition, transitionIfNotPreviously). 생명주기 객체마다 하나씩 들고 있기 때문입니다.

객체 맡는 범위 상태 열거형
DefaultBuildTreeLifecycleController 빌드 트리 하나 NotStarted, Complete (43–45행)
DefaultBuildLifecycleController 빌드 하나 (루트 빌드, 포함된 빌드마다) Configure, TaskSchedule, ReadyToRun, BuildFinishHooks, ReadyToReset, Finished (56–66행)
VintageBuildModelController 빌드 모델(settings와 프로젝트) Created, SettingsLoaded, Configured (30–32행)

뒤의 둘이 어떻게 겹치는지는 2편의 상태 그림(lifecycle-states)에 있습니다. 여기서는 그 상태들을 누가 어떻게 지키는지를 봅니다. 빌드 트리의 runBuild()부터 봅니다.

    private <T> T runBuild(Supplier<ExecutionResult<? extends T>> action) {
        return state.transition(State.NotStarted, State.Complete, () -> {
            ExecutionResult<? extends T> result;
            try {
                result = action.get();
            } catch (Throwable t) {
                result = ExecutionResult.failed(t);
            }

            RuntimeException finalReportableFailure = finishExecutor.finishBuildTree(result.getFailures());
            if (finalReportableFailure != null) {
                throw finalReportableFailure;
            }

            return result.getValue();
        });
    }

subprojects/core/src/main/java/org/gradle/internal/buildtree/DefaultBuildTreeLifecycleController.java 155–171행

NotStarted → Complete 전이 하나 안에서 일(action.get())과 마무리(finishBuildTree)를 둘 다 합니다. 일이 예외를 던져도 catch가 결과로 바꿔 두고 마무리로 갑니다. 예제 1의 스택에서 lambda:159(일)와 buildFinished 스택의 lambda:164(마무리)가 이 두 줄입니다. 빌드 트리 하나는 이 메서드를 한 번만 지날 수 있습니다. 두 번째로 부르면 상태가 이미 Complete라 전이가 거절됩니다.

전이 한 번의 속

transition(from, to, action)은 결국 doTransition()에 닿습니다.

    private <S> ExecutionResult<S> doTransition(T fromState, T toState, Supplier<ExecutionResult<S>> action) {
        CurrentState<T> current = state;
        current.assertCanTransition(fromState, toState);
        state = current.transitioningTo(toState);
        ExecutionResult<S> result;
        try {
            result = action.get();
        } catch (Throwable t) {
            result = ExecutionResult.failed(t);
        }
        if (!result.getFailures().isEmpty()) {
            state = state.failed(result);
        } else {
            state = state.nextState(toState);
        }
        return result;
    }

subprojects/core/src/main/java/org/gradle/internal/model/StateTransitionController.java 318–334행

네 줄로 요약됩니다. ① 지금 상태가 from인지 확인하고, ② "to로 가는 중"이라고 표시한 뒤, ③ 일을 하고, ④ 성공하면 to로, 실패하면 "실패"로 바꿉니다. 재미있는 것은 상태 값이 열거형 하나가 아니라 객체라는 점입니다. 같은 파일 안에 CurrentState의 하위 클래스가 셋 있습니다.

StateTransitionController 안의 상태 객체 세 가지. 처음에는 InState(지금 상태 값과 지나온 앞 상태)다. transition(from, to)를 부르면 from인지 먼저 확인하고 TransitioningToNewState(전이 중, 다른 전이는 거절)가 된다. 일이 성공하면 nextState(to)로 다시 InState가 되고, 예외를 던지면 failed(result)로 Failed(실패를 저장해 둔다)가 된다. Failed에서 보통의 전이·조회를 하면 호출자에게 저장한 예외를 다시 던진다. finishBuild() 같은 ignoreFailures 전이는 마무리 일을 돌리고 쌓인 실패를 넘겨주며, 끝나도 Failed로 남는다. 상태 값(Configure 등)은 이 객체들의 필드이고, 전이 규칙은 객체의 종류가 정합니다.

그리고 상태를 바꾸거나 상태를 확인하며 일을 하는 메서드는 거의 모두 synchronizer.withLock(...) 안에서 돕니다(예외는 이름대로 다른 스레드를 기다리지 않는 notInStateIgnoreOtherThreads()로, 일 자체는 잠그지 않고 돌립니다. 117–130행). synchronizer는 워커 리스 서비스가 내주는 자원입니다(StateTransitionControllerFactory.java 46행). 여러 스레드가 같은 빌드의 상태를 건드려도 한 번에 하나만 전이합니다.

실패해도 마무리는 한다 — ignoreFailures

Failed가 모든 호출을 막으면 실패한 빌드는 buildFinished 훅도 못 부르게 됩니다. 그래서 예외가 하나 있습니다.

        @Override
        public void assertCanTransition(T fromState, T toState, boolean ignoreFailures) {
            if (!ignoreFailures) {
                throwFailure();
            }
        }

subprojects/core/src/main/java/org/gradle/internal/model/StateTransitionController.java 595–600행

ignoreFailures를 true로 넘기는 곳은 함수(Function<ExecutionResult<Void>, ExecutionResult<Void>>)를 받는 두 transition() 오버로드(278–292행)와, 상태를 실패 기록 없이 새로 시작시키는 restart()(203–210행, 모델을 버리고 Configure로 되돌리는 resetModel()이 씁니다)입니다. 이 가운데 두 transition()은 쌓인 실패를 함수에 인자로 넘겨줍니다(action.apply(currentResult), 299행). 6절의 finishBuild()가 바로 이 길로 들어갑니다. 실패한 빌드에서도 마무리 훅이 돌고, 그 훅이 "무엇이 실패했는지"를 받을 수 있는 이유입니다.

Tomcat에도 모든 부품이 따르는 상태 기계가 있었습니다(Tomcat 3편의 LifecycleBase). 다만 Tomcat은 상태가 바뀔 때마다 이벤트를 내는 데, Gradle의 StateTransitionController는 실패를 쌓아 두고 다시 던지는 데 무게를 둡니다. Gradle에서 훅을 부르는 것은 상태 기계가 아니라 생명주기 코드입니다(3~6절).

헷갈리기 쉬운 곳

3. 초기화 — Load build

생명주기의 첫 걸음

scheduleAndRunTasks()에서 초기화까지는 세 번 내려갑니다. 빌드 트리의 작업 준비기 DefaultBuildTreeWorkPreparer.scheduleRequestedTasks()가 먼저 빌드에게 "태스크를 예약할 준비를 하라"(prepareToScheduleTasks(), 35행)고 하고, 그다음에 작업 그래프를 만듭니다(graph.scheduleWork(...), 36–41행. 5절). 빌드의 생명주기는 상태를 Configure → TaskSchedule로 옮기며 모델에게 넘깁니다.

    @Override
    public void prepareToScheduleTasks() {
        buildState.maybeTransition(State.Configure, State.TaskSchedule, () -> {
            hasTasks = true;
            modelController.prepareToScheduleTasks();
        });
    }

subprojects/core/src/main/java/org/gradle/internal/build/DefaultBuildLifecycleController.java 180–186행

모델(VintageBuildModelController)은 prepareSettings() → prepareProjects()를 차례로 부르고(2편), 앞의 것이 DefaultSettingsPreparer.prepareSettings()입니다. 이 메서드는 일을 빌드 오퍼레이션 Load build로 감싸고(123–145행) 안에서 loadBuild()를 부릅니다. 초기화 단계의 본체가 이 메서드 하나입니다.

    private void loadBuild(GradleInternal gradle) {
        loadGradlePropertiesForBuild(gradle);
        // ... 루트 빌드면 데몬 JVM 설정 검사 ...
        initScriptHandler.executeScripts(gradle);

        SettingsState state = findAndLoadSettings(gradle);
        gradle.attachSettings(state);

        SettingsInternal settings = state.getSettings();
        projectRegistry.registerProjects(gradle.getOwner(), settings.getProjectRegistry());
        // ... 명령줄의 --include-build 를 더한다 ...
        // Load included builds defined in settings and those added above
        gradle.setIncludedBuilds(loadIncludedBuildsRecursively(settings));
        // ...
    }

subprojects/core/src/main/java/org/gradle/initialization/DefaultSettingsPreparer.java 148–181행

순서가 그대로 사용자가 아는 초기화입니다. ① gradle.properties를 읽고(149행), ② init 스크립트를 돌리고(157행), ③ settings 파일을 찾아 실행하고(159행), ④ settings에서 include한 프로젝트들을 상태 레지스트리에 등록하고(163행), ⑤ includeBuild로 포함한 빌드를 불러옵니다(173행). gradle.properties가 init 스크립트보다 먼저인 것은 init 스크립트 안에서도 providers.gradleProperty(...)로 그 값을 읽을 수 있어야 하기 때문입니다.

DefaultSettingsPreparer.loadBuild()가 연 Load build 오퍼레이션 안의 순서. ① gradle.properties 읽기, ② init 스크립트 실행(InitScriptHandler, Run init scripts), ③ settings 찾고 실행 — SettingsProcessor 양파: 바깥부터 BuildOperationSettingsProcessor(Evaluate settings 오퍼레이션), RootBuildCacheControllerSettingsProcessor(끝난 뒤 빌드 캐시 설정 확정), SharedModelDefaultsSettingsProcessor, SettingsEvaluatedCallbackFiringSettingsProcessor(끝난 뒤 settingsEvaluated 훅), ScriptEvaluatingSettingsProcessor(beforeSettings 훅 다음 settings.gradle.kts 실행). ④ include한 목록을 ProjectStateRegistry에 프로젝트로 등록, ⑤ includeBuild한 포함된 빌드를 재귀로 불러온다. ③ 안쪽은 바깥에서 안으로 감싼 순서입니다. 안쪽이 끝나야 바깥의 "끝난 뒤" 일이 돕니다.

settings 처리기의 양파

③의 findAndLoadSettings()는 settingsProcessor.process(...)를 부르는데(262행), 이 처리기도 감싼 사슬입니다.

        SettingsProcessor settingsProcessor = new SettingsEvaluatedCallbackFiringSettingsProcessor(
            new ScriptEvaluatingSettingsProcessor(
                // ... 인자 5개 ...
            )
        );
        if (buildModelParameters.isIsolatedProjects()) {
            settingsProcessor = new StartParameterMutationReportingSettingsProcessor(settingsProcessor, isolatedProjectsProblemsReporter);
        }
        return new BuildOperationSettingsProcessor(
            new RootBuildCacheControllerSettingsProcessor(
                new SharedModelDefaultsSettingsProcessor(settingsProcessor)
            ),
            buildOperationRunner
        );

subprojects/core/src/main/java/org/gradle/internal/service/scopes/BuildScopeServices.java 625–646행

가장 안쪽의 ScriptEvaluatingSettingsProcessor가 Settings 객체를 만들고, beforeSettings 훅을 부르고, settings 스크립트를 적용합니다(67–73행). 그 바깥의 SettingsEvaluatedCallbackFiringSettingsProcessor가 스크립트가 끝난 뒤 훅을 부르고 settings를 잠급니다.

    public SettingsState process(GradleInternal gradle, SettingsLocation settingsLocation, ClassLoaderScope buildRootClassLoaderScope, StartParameterInternal startParameter) {
        SettingsState state = delegate.process(gradle, settingsLocation, buildRootClassLoaderScope, startParameter);
        SettingsInternal settings = state.getSettings();
        gradle.getBuildListenerBroadcaster().settingsEvaluated(settings);
        settings.preventFromFurtherMutation();
        return state;
    }

subprojects/core/src/main/java/org/gradle/initialization/SettingsEvaluatedCallbackFiringSettingsProcessor.java 33–39행

preventFromFurtherMutation()이 눈에 띕니다. settingsEvaluated 훅이 settings를 마지막으로 고칠 기회이고, 그 뒤로는 settings를 바꿀 수 없게 막습니다. 그 바깥의 RootBuildCacheControllerSettingsProcessor는 settings의 buildCache { } 블록이 다 적용된 뒤에 빌드 캐시 설정을 확정하고(1편 trace의 Finalize build cache configuration, 11편), 가장 바깥의 BuildOperationSettingsProcessor가 이 전부를 오퍼레이션 Evaluate settings로 감쌉니다.

양파로 만든 이유는 1절의 실행기 사슬과 같습니다. "스크립트를 실행하는 일"과 "끝난 뒤 훅을 부르는 일", "빌드 캐시를 확정하는 일"이 서로를 몰라도 되고, 격리된 프로젝트(isolated projects) 모드처럼 조건이 있을 때만 한 겹을 더 끼우기도 쉽습니다(638–640행).

예제 2: 생명주기 훅을 모두 걸고 순서 보기

init 스크립트에서 공개 API의 훅을 모두 걸었습니다.

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// init 스크립트: 생명주기 훅을 전부 걸어 두고, 불리는 순서를 찍는다. 명령줄의 -I 로 넘긴다.
println("[script] init.gradle.kts")

gradle.beforeSettings { println("[hook  ] beforeSettings") }
gradle.settingsEvaluated { println("[hook  ] settingsEvaluated") }
gradle.projectsLoaded { println("[hook  ] projectsLoaded") }
gradle.beforeProject { println("[hook  ] beforeProject $path") }
gradle.afterProject { println("[hook  ] afterProject $path") }
gradle.projectsEvaluated { println("[hook  ] projectsEvaluated") }
gradle.taskGraph.whenReady { println("[hook  ] taskGraph.whenReady ${allTasks.map { it.path }}") }

// buildFinished 는 9.x 에서 @Deprecated 다 (설정 캐시와 함께 쓸 수 없다)
@Suppress("DEPRECATION")
gradle.buildFinished { println("[hook  ] buildFinished action=$action failure=${failure?.javaClass?.simpleName}") }

프로젝트는 루트와 하위 프로젝트 둘(app, lib)입니다. settings 스크립트는 include("app", "lib")만 하고, 루트와 app의 빌드 스크립트는 hello 태스크를 tasks.register로 등록합니다. 설정 블록 안에서 [config]를, doLast에서 [task ]를 찍습니다. lib에는 태스크가 없습니다. 루트 빌드 스크립트에는 6절에서 쓸 Flow API 액션도 하나 걸었습니다([flow ]). ex/u05-hooks/run.sh가 gradle hello -I init.gradle.kts --offline --console=plain --warning-mode=all로 돌린 결과입니다.

Welcome to Gradle 9.10.0-20261107025045+0000!

Starting a Gradle Daemon (subsequent builds will be faster)
Shared read-only dependency cache is an incubating feature.
[script] init.gradle.kts
[hook  ] beforeSettings
[script] settings.gradle.kts
[hook  ] settingsEvaluated
[hook  ] projectsLoaded

> Configure project :
[hook  ] beforeProject :
[script] build.gradle.kts (:)
[hook  ] afterProject :

> Configure project :app
[hook  ] beforeProject :app
[script] app/build.gradle.kts
[hook  ] afterProject :app

> Configure project :lib
[hook  ] beforeProject :lib
[script] lib/build.gradle.kts
[hook  ] afterProject :lib
[hook  ] projectsEvaluated
[config] configure task :hello
[config] configure task :app:hello
[hook  ] taskGraph.whenReady [:hello, :app:hello]

> Task :hello
[task  ] :hello action

> Task :app:hello
[task  ] :app:hello action
[flow  ] SayFinished failed=false
[hook  ] buildFinished action=Build failure=null

BUILD SUCCESSFUL in 53s
2 actionable tasks: 2 executed

(BUILD SUCCESSFUL in …의 시간은 실행마다 다릅니다. 첫 실행이라 데몬을 띄우고 스크립트를 컴파일하느라 오래 걸렸습니다. Shared read-only dependency cache …는 이 연재의 도커 이미지가 쓰는 읽기 전용 의존성 캐시 때문에 찍히는 줄입니다.) 이 절에서 볼 것은 맨 앞 다섯 줄입니다. init 스크립트 → beforeSettings → settings 스크립트 → settingsEvaluated. 위의 loadBuild() 157행과 159행, 그리고 양파의 안쪽에서 바깥 순서 그대로입니다. 나머지 줄은 4~6절에서 하나씩 봅니다.

헷갈리기 쉬운 곳

4. 설정 — Configure build

프로젝트 설정을 감싼 세 겹

모델의 두 번째 걸음 prepareProjects()는 ProjectsPreparer를 부릅니다. 이것도 세 겹입니다(BuildScopeServices.java 658–667행): 바깥부터 BuildOperationFiringProjectsPreparer(오퍼레이션 Configure build) → BuildTreePreparingProjectsPreparer → DefaultProjectsPreparer. 가운데 것이 의외로 많은 일을 합니다.

    @Override
    public void prepareProjects(GradleInternal gradle) {
        // Setup classloader for root project, all other projects will be derived from this.
        SettingsInternal settings = gradle.getSettings();
        ClassLoaderScope settingsClassLoaderScope = settings.getClassLoaderScope();
        ClassLoaderScope buildSrcClassLoaderScope = settingsClassLoaderScope.createChild("buildSrc[" + gradle.getIdentityPath() + "]", null);
        gradle.setBaseProjectClassLoaderScope(buildSrcClassLoaderScope);
        generateDependenciesAccessorsAndAssignPluginVersions(gradle.getServices(), settings, buildSrcClassLoaderScope);
        // attaches root project
        buildLoader.load(gradle.getSettings(), gradle);
        // ... 포함된 빌드의 의존성 치환을 등록 ...
        // Build buildSrc and export classpath to root project
        buildBuildSrcAndLockClassloader(gradle, buildSrcClassLoaderScope);

        delegate.prepareProjects(gradle);
        // ...
    }

subprojects/core/src/main/java/org/gradle/configuration/BuildTreePreparingProjectsPreparer.java 48–68행

프로젝트의 빌드 스크립트를 하나도 돌리기 전에 준비할 것이 넷 있습니다. ① 프로젝트들이 쓸 클래스로더 스코프의 뿌리를 settings 아래에 만들고(53–54행, 클래스로더 나무는 6편), ② gradle/libs.versions.toml이 있으면 버전 카탈로그 접근자(libs.…)를 만들고(55행, 75–93행), ③ buildLoader.load()로 Project 객체를 만들고(57행), ④ buildSrc를 빌드해 그 클래스패스를 프로젝트 클래스로더에 붙입니다(63행, 7편). 그다음에야 delegate.prepareProjects()로 빌드 스크립트들이 돕니다.

③의 buildLoader는 NotifyingBuildLoader가 ProjectPropertySettingBuildLoader(만든 프로젝트에 gradle.properties 등의 속성을 넣는다)를, 그것이 다시 InstantiatingBuildLoader를 감싼 세 겹인데(BuildScopeServices.java 479–486행), 맨 바깥이 오퍼레이션 Load projects 안에서 프로젝트를 만들게 한 뒤, 이어지는 오퍼레이션 Notify projectsLoaded listeners 안에서 projectsLoaded 훅을 부릅니다(NotifyingBuildLoader.java 46–90행). 프로젝트를 실제로 만드는 쪽은 settings의 설명서 나무를 따라 내려가며 프로젝트마다 createMutableModel()을 부릅니다(InstantiatingBuildLoader.java 37–54행).

안쪽의 DefaultProjectsPreparer는 짧습니다.

    @Override
    public void prepareProjects(GradleInternal gradle) {
        if (skipEvaluationDuringProjectPreparation(buildModelParameters, gradle)) {
            return;
        }

        ProjectState rootProject = gradle.getOwner().getRootProject();
        if (buildModelParameters.isParallelProjectConfiguration()) {
            projectConfigurer.configureHierarchyInParallel(rootProject);
        } else {
            projectConfigurer.configureHierarchy(rootProject);
        }
        new ProjectsEvaluatedNotifier(buildOperationRunner).notify(gradle);
    }

subprojects/core/src/main/java/org/gradle/configuration/DefaultProjectsPreparer.java 42–55행

프로젝트 나무를 차례로 설정하고(configureHierarchy() — TaskPathProjectEvaluator.java 75–79행에서 프로젝트마다 ensureConfigured()), 끝나면 projectsEvaluated 훅을 부릅니다(54행). 맨 앞의 skipEvaluationDuringProjectPreparation()은 configure-on-demand(org.gradle.configureondemand=true)일 때 루트 빌드에서 true가 됩니다(DeferredProjectEvaluationCondition.java 26–31행). 그때는 여기서 아무 프로젝트도 설정하지 않고, 요청한 태스크에 필요한 프로젝트만 나중에 설정합니다.

Configure build 오퍼레이션(BuildOperationFiringProjectsPreparer, 콘솔은 CONFIGURING) 안의 순서. BuildTreePreparingProjectsPreparer가 클래스로더 스코프와 버전 카탈로그 접근자(libs.versions.toml)를 준비하고, Load projects로 Project 객체를 만든 뒤 projectsLoaded 훅을 부르고, buildSrc를 빌드한다(7편). 이어서 DefaultProjectsPreparer가 configure-on-demand면 여기서 건너뛰고, 아니면 configureHierarchy()로 프로젝트를 차례로 ensureConfigured()한다. 프로젝트마다 LifecycleProjectEvaluator가 beforeEvaluate, build.gradle.kts, afterEvaluate를 돌리고(6편), 끝으로 projectsEvaluated 훅이 불린다. 회색 상자는 다른 편에서 자세히 보는 부분입니다.

프로젝트 하나의 설정 — beforeEvaluate, 스크립트, afterEvaluate

ensureConfigured()는 결국 프로젝트 평가기 사슬의 맨 바깥인 LifecycleProjectEvaluator에 닿습니다. 평가기 사슬 전체는 6편에서 보고, 여기서는 훅이 걸리는 자리만 봅니다.

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

subprojects/core/src/main/java/org/gradle/configuration/project/LifecycleProjectEvaluator.java 101–119행

delegate.evaluate()가 빌드 스크립트를 실행하는 자리이고, 그 앞뒤에 beforeEvaluate·afterEvaluate 훅이 오퍼레이션으로 감싸여 불립니다. afterEvaluate가 finally 안에 있다는 점을 기억해 둡니다. 스크립트가 예외를 던져도 afterEvaluate는 돕니다(7절에서 확인합니다). 예제 2의 출력에서 > Configure project : 아래 beforeProject : → 스크립트 → afterProject :이 이 자리이고, : → :app → :lib 순서는 ProjectOrderingUtil.orderedAllProjectsOf()가 정합니다(TaskPathProjectEvaluator.java 76행). 마지막 projectsEvaluated는 위의 DefaultProjectsPreparer 54행입니다.

헷갈리기 쉬운 곳

5. 설정과 실행 사이 — 작업 그래프와 설정 시간 장벽

태스크의 설정 블록은 projectsEvaluated 뒤에 돈다

예제 2의 출력에서 [config] configure task :hello는 projectsEvaluated 다음에 찍혔습니다. 빌드 스크립트는 tasks.register("hello") { … }로 등록만 했고, 설정 블록은 태스크가 실제로 필요해지는 순간, 즉 작업 그래프를 만들며 hello를 그래프에 넣을 때 돌았습니다. 이 "필요할 때 만들기"(지연 설정)는 8편의 주제이고, 작업 그래프를 만드는 계산은 9편의 주제입니다. 생명주기 쪽에서 볼 것은 이 계산이 어디에 끼어 있나입니다.

3절의 DefaultBuildTreeWorkPreparer가 prepareToScheduleTasks()(초기화+설정) 다음에 부르던 graph.scheduleWork(...)가 그 자리입니다. 이것이 오퍼레이션 Calculate build tree task graph를 열고(DefaultIncludedBuildTaskGraph.java 207–224행), 그 안에서 빌드마다 실행 계획을 채우고 확정합니다. 확정하는 쪽은 생명주기의 상태를 TaskSchedule → ReadyToRun으로 옮깁니다.

    @Override
    public void finalizeWorkGraph(BuildWorkPlan plan) {
        DefaultBuildWorkPlan workPlan = unpack(plan);
        if (workPlan.empty) {
            return;
        }
        buildState.transition(State.TaskSchedule, State.ReadyToRun, () -> {
            // ...
            workPlan.finalizedPlan = workPreparer.finalizeWorkGraph(gradle, workPlan.plan);
        });
    }

subprojects/core/src/main/java/org/gradle/internal/build/DefaultBuildLifecycleController.java 201–213행

workPreparer.finalizeWorkGraph() 안에서 계획을 확정하고 --info로 Tasks to be executed: …를 찍은 뒤, taskGraph.populate(...)가 whenReady 훅을 부릅니다(DefaultBuildWorkPreparer.java 57–64행 → DefaultTaskExecutionGraph.java 89–100행, 283–290행). 예제 1의 taskGraph.whenReady 스택 끝이 바로 DefaultBuildLifecycleController.finalizeWorkGraph:207 → DefaultBuildWorkPreparer.finalizeWorkGraph:64였습니다. 그러니 whenReady는 "작업 그래프가 확정된 직후, 아직 아무 태스크도 돌지 않은 때"이고, 상태로는 ReadyToRun으로 가는 전이의 한가운데입니다.

설정 시간 장벽 — 경계는 깃발 하나

그러면 "설정이 끝나는 순간"은 정확히 어디일까요. 예제 1의 스택에 그 답이 있었습니다. BarrierAwareBuildTreeWorkPreparer가 작업 준비 전체를 이것으로 감쌉니다.

    fun <T> runConfigurationTimeAction(action: () -> T): T {
        configurationTimeBarrier.prepare()
        InstrumentedInputs.setListener(inputsListener)
        try {
            return action()
        } finally {
            configurationTimeBarrier.cross()
            InstrumentedInputs.discardListener()
        }
    }

platforms/core-configuration/configuration-cache/src/main/kotlin/org/gradle/internal/cc/impl/barrier/VintageConfigurationTimeActionRunner.kt 44–53행

prepare()는 atConfigurationTime = true, cross()는 false로 바꾸는 깃발입니다(DefaultConfigurationTimeBarrier.java 23–32행). 그리고 action()은 DefaultBuildTreeWorkPreparer.scheduleRequestedTasks() 전체, 곧 초기화 + 설정 + 작업 그래프 확정입니다. 깃발을 넘는 것은 그래프가 확정된 직후이고, 그다음에 VintageBuildTreeWorkController가 실행기를 부릅니다.

    override fun scheduleAndRunRequestedTasks(taskSelector: EntryTaskSelector?): ExecutionResult<Void> {
        return taskGraph.withNewWorkGraph { graph: BuildTreeWorkGraph ->
            val finalizedGraph: Try<BuildTreeWorkGraph.FinalizedGraph> = Try.ofFailable { workPreparer.scheduleRequestedTasks(graph, taskSelector) }

            if (finalizedGraph.isSuccessful) {
                workExecutor.execute(finalizedGraph.get())
            } else {
                ExecutionResult.failed(finalizedGraph.failure.get())
            }
        }
    }

platforms/core-configuration/configuration-cache/src/main/kotlin/org/gradle/internal/cc/impl/VintageBuildTreeWorkController.kt 35–45행

설정 단계와 실행 단계는 37행과 40행 사이에서 갈립니다. 37행이 돌아오는 순간 깃발은 이미 넘었고, 40행부터는 실행입니다. 이 깃발을 읽는 곳이 실행 시점의 규칙을 정합니다. 예를 들어 설정 캐시가 켜진 길에서는 이 깃발로 "태스크 액션이 실행 중에 project를 만지는지"를 가립니다(TaskExecutionAccessCheckers.kt 104–113행, 11편).

예제 3: --dry-run도 설정까지는 다 한다

gradle hello --dry-run(-m)은 "태스크를 실제로 돌리지 않고 무엇을 돌릴지만 보여 달라"는 옵션입니다. 소스에서는 실행 쪽의 맨 앞에서 갈립니다.

    public ExecutionResult<Void> displayOrExecutePlan(GradleInternal gradle, FinalizedExecutionPlan plan) {
        if (configurationTimeBarrier.isAtConfigurationTime()) {
            return executePlan(plan, gradle.getTaskGraph());
        }

        if (gradle.getStartParameter().isDryRun()) {
            for (Task task : plan.getContents().getTasks()) {
                // ... 태스크 경로 뒤에 "SKIPPED" 를 붙여 찍는다 ...
            }

            return ExecutionResult.succeeded();
        }
        // ...

subprojects/core/src/main/java/org/gradle/execution/DefaultBuildWorkExecutor.java 101–117행

(102행은 장벽을 아직 넘지 않은 설정 시간 안에서 계획을 돌리는 경우의 길입니다. 어떤 경우에 이 길로 오는지는 이번에 따라가지 않았습니다.) 드라이 런은 실행 계획의 태스크 이름에 SKIPPED를 붙여 찍고 끝납니다. 그 앞의 초기화와 설정, 그래프 계산은 모두 그대로 합니다. 같은 예제를 --dry-run으로 돌린 결과입니다. projectsEvaluated 앞의 스무 줄(init 스크립트부터 :lib의 afterProject까지)은 예제 2와 한 줄도 다르지 않아 ...로 줄였습니다.

...
[hook  ] projectsEvaluated
[config] configure task :hello
[config] configure task :app:hello
[hook  ] taskGraph.whenReady [:hello, :app:hello]
:hello SKIPPED
:app:hello SKIPPED
[flow  ] SayFinished failed=false
[hook  ] buildFinished action=Build failure=null

BUILD SUCCESSFUL in 3s

태스크 액션([task ])만 빠지고 나머지는 모두 찍혔습니다. 설정 블록([config])까지 돈 것을 보면, 드라이 런이 "빌드 스크립트에 버그가 있는지 빠르게 보는" 도구로는 쓸 수 있어도 "설정이 느린 빌드를 빠르게 훑는" 도구는 아니라는 것을 알 수 있습니다. 두 번째 실행이라 데몬이 살아 있어 3초였습니다(시간은 실행마다 다릅니다).

예제 4: 콘솔의 INITIALIZING · CONFIGURING · EXECUTING

터미널에서 Gradle을 돌리면 아래쪽에 <====> 66% CONFIGURING [2s] 같은 진행 표시줄이 뜹니다. 이 단계 이름은 누가 정할까요. 콘솔 쪽의 BuildStatusRenderer입니다.

            if (startEvent.isBuildOperationStart()) {
                if (buildStartTimestamp == 0 && startEvent.getParentProgressOperationId() == null) {
                    // The very first event starts the Initializing phase
                    // ...
                    phaseStarted(startEvent, Phase.Initializing);
                } else if (startEvent.getBuildOperationCategory() == BuildOperationCategory.CONFIGURE_ROOT_BUILD) {
                    // Once the root build starts configuring, we are in Configuring phase
                    phaseStarted(startEvent, Phase.Configuring);
                // ...
                } else if (startEvent.getBuildOperationCategory() == BuildOperationCategory.RUN_MAIN_TASKS) {
                    phaseStarted(startEvent, Phase.Executing);

platforms/core-runtime/logging/src/main/java/org/gradle/internal/logging/console/BuildStatusRenderer.java 75–92행

콘솔은 생명주기 클래스를 전혀 모릅니다. 빌드 오퍼레이션이 시작될 때 붙어 오는 분류(category)만 봅니다. 첫 오퍼레이션(Run build)이 오면 INITIALIZING, 분류가 CONFIGURE_ROOT_BUILD인 오퍼레이션(BuildOperationFiringProjectsPreparer가 Configure build에 붙이는 것, 59–60행)이 오면 CONFIGURING, RUN_MAIN_TASKS(BuildOperationFiringBuildTreeWorkExecutor의 Run main tasks, 49행)가 오면 EXECUTING입니다. 표시줄의 퍼센트도 오퍼레이션이 알려 줍니다. Configure build는 전체 개수로 프로젝트 수를(BuildOperationFiringProjectsPreparer.java 64행), Run tasks는 실행 계획의 크기를(DefaultBuildWorkExecutor.java 94행) 내놓습니다.

같은 예제를 --console=rich로 돌려 출력에서 진행 표시줄 줄만 뽑았습니다. 빌드가 빨리 끝나면 표시줄이 거의 그려지지 않아서, 훅마다 0.8초씩 잠드는 init 스크립트(slow.gradle.kts)를 따로 붙였습니다. 제어 문자를 지우고, 경과 시간 [Ns]는 지웠습니다.

0% INITIALIZING   > Running init scripts
0% INITIALIZING   > IDLE
0% CONFIGURING   > root project
33% CONFIGURING   > :app
66% CONFIGURING   > :lib
100% CONFIGURING   > Running projects evaluated listeners
100% CONFIGURING   > Running task graph ready listeners
0% EXECUTING   > IDLE
50% EXECUTING   > :app:hello
0% WAITING   > IDLE

(화면을 다시 그리는 때와 잠드는 때가 맞물리는 정도에 따라 잡히는 줄이 실행마다 조금씩 다릅니다.) 프로젝트 셋을 설정하며 33% → 66% → 100%로 오르고, 태스크 둘을 실행하며 0% → 50%로 오릅니다. 아래 줄의 > Running projects evaluated listeners, > Running task graph ready listeners는 각 오퍼레이션의 진행 표시 이름(ProjectsEvaluatedNotifier.java 55행, DefaultTaskExecutionGraph.java 314행)입니다. 작업 그래프를 계산하는 동안(whenReady 포함)도 콘솔은 아직 CONFIGURING 이라는 점이 2절의 상태, 위의 장벽과 맞아떨어집니다.

빌드 한 번을 위에서 아래로 나눈 그림. Load build(훅 beforeSettings·settingsEvaluated, 콘솔 INITIALIZING, Stage Created → SettingsLoaded), Configure build(훅 projectsLoaded·beforeProject·afterProject·projectsEvaluated, 콘솔 CONFIGURING, Stage SettingsLoaded → Configured), Calculate build tree task graph(register한 태스크의 설정 블록, 훅 whenReady, 콘솔 CONFIGURING, State TaskSchedule → ReadyToRun), 설정 시간 장벽을 넘는다(VintageConfigurationTimeActionRunner의 cross()), Run main tasks(태스크 액션 doFirst·doLast, 콘솔 EXECUTING, State ReadyToRun → Configure), Finish root build tree(Flow API 액션, buildFinished 훅, State → BuildFinishHooks), 끝으로 BuildOutcomeReportingBuildActionRunner가 BUILD SUCCESSFUL 또는 FAILED를 찍는다. 상자 이름은 빌드 오퍼레이션 이름입니다. 콘솔 단계, 상태 값, 장벽이 같은 자리에서 바뀌지는 않습니다.

헷갈리기 쉬운 곳

6. 빌드의 끝 — finishBuild, buildFinished, Flow API

마무리 전이

실행이 끝나면 runBuild()(2절)의 164행 finishExecutor.finishBuildTree(...)로 갑니다. DefaultBuildTreeFinishExecutor가 포함된 빌드들을 먼저 마무리하고, 실패 목록을 정리한 뒤, 루트 빌드의 finishBuild()를 부릅니다(DefaultBuildTreeFinishExecutor.java 44–73행). 오퍼레이션 이름은 Finish root build tree입니다(OperationFiringBuildTreeFinishExecutor.java 54행).

    @Override
    public ExecutionResult<Void> finishBuild(@Nullable Throwable failure) {
        return buildState.transition(CONFIGURATION_STATES, State.BuildFinishHooks, stageFailures -> {
            // Fire the build finished events even if nothing has happened to this build, because quite a lot of internal infrastructure
            // adds listeners and expects to see a build finished event. Infrastructure should not be using the public listener types
            // In addition, they almost all should be using a build tree scoped event instead of a build scoped event

            Throwable reportableFailure = failure;
            if (reportableFailure == null && !stageFailures.getFailures().isEmpty()) {
                reportableFailure = exceptionAnalyser.transform(stageFailures.getFailures());
            }
            BuildResult buildResult = new BuildResult(hasTasks ? "Build" : "Configure", gradle, reportableFailure);
            return ExecutionResult.maybeFailing(() -> buildListener.buildFinished(buildResult));
        });
    }

subprojects/core/src/main/java/org/gradle/internal/build/DefaultBuildLifecycleController.java 343–357행

2절에서 본 함수를 받는 transition()입니다. 그래서 앞에서 실패했어도 들어오고, 그 실패(stageFailures)를 받아 BuildResult에 담아 buildFinished를 알립니다. 소스 주석이 사정을 솔직하게 적어 둡니다. "아무 일도 없었던 빌드에도 build finished 이벤트를 낸다. 내부 장치 상당수가 이 이벤트를 기다리기 때문이다. 내부 장치는 공개 리스너 타입을 쓰지 않아야 하는데…"

buildFinished는 9.x에서 어떤가

공개 API의 Gradle.buildFinished()는 이 커밋에서 @Deprecated입니다.

    /**
     * Adds a closure to be called when the build is completed.
     // ...
     * @see org.gradle.api.flow.FlowProviders#getBuildWorkResult()
     * @deprecated This method is not supported when configuration caching is enabled.
     * @since 0.9
     */
    @Deprecated
    void buildFinished(Closure closure);

subprojects/core-api/src/main/java/org/gradle/api/invocation/Gradle.java 308–320행

설정 캐시를 켜면 쓸 수 없다는 것이 이유이고, 대신 볼 곳으로 FlowProviders.getBuildWorkResult()를 가리킵니다. 이것이 Flow API(인큐베이팅, 8.1부터)입니다. 빌드가 끝나면 돌 액션을 FlowScope.always(...)로 등록하고, 결과는 Provider로 받습니다. 예제의 루트 빌드 스크립트에 이렇게 걸었습니다.

// buildFinished 대신 권하는 길: Flow API (인큐베이팅). 빌드가 끝나면 FlowAction 이 불린다.
abstract class SayFinished : FlowAction<SayFinished.Params> {
    interface Params : FlowParameters {
        @get:Input
        val failed: Property<Boolean>
    }

    override fun execute(parameters: Params) {
        println("[flow  ] SayFinished failed=${parameters.failed.get()}")
    }
}

abstract class FlowHooks @Inject constructor(val scope: FlowScope, val results: FlowProviders)

val flow = objects.newInstance<FlowHooks>()
flow.scope.always(SayFinished::class.java) {
    parameters.failed = flow.results.buildWorkResult.map { it.failure.isPresent }
}

[예제 ex/u05-hooks/project/build.gradle.kts에서 줄임 — 앞의 머리 주석·import·println 한 줄과 hello 태스크 등록을 뺐습니다]

FlowScope와 FlowProviders는 빌드 스크립트가 직접 받을 수 없어서, @Inject 생성자로 받는 작은 클래스를 objects.newInstance()로 만들었습니다(4편에서 본 주입입니다). 그런데 Flow API를 구현한 쪽을 열어 보면 뜻밖입니다.

    @Deprecated("Deprecated in Java")
    override fun buildFinished(result: BuildResult) {
        setBuildWorkResult(result.failure)
        schedulePendingActions()
    }

platforms/core-configuration/flow-services/src/main/kotlin/org/gradle/internal/flow/services/BuildFlowScope.kt 116–120행

BuildFlowScope는 그 자신이 buildFinished 리스너입니다(BuildAdapter를 물려받습니다, 39–45행). 같은 finishBuild()의 알림을 받아, 결과 Provider를 채우고 등록된 액션을 오퍼레이션 Run flow action …으로 돌립니다(FlowScheduler.kt 65–80행). 사용자에게 막은 것은 "빌드 스크립트의 람다를 리스너로 거는 방식"이지 "빌드가 끝나는 시점" 자체가 아닙니다. Flow API의 액션은 클래스와 파라미터(FlowParameters)로 나뉘어 있어서, 설정 캐시가 파라미터만 저장했다가 다음 빌드에서 다시 만들 수 있습니다. 람다는 그렇게 할 수 없습니다(설정 캐시가 무엇을 저장하는지는 11편).

예제 2의 출력 끝을 다시 보면 이렇습니다.

[flow  ] SayFinished failed=false
[hook  ] buildFinished action=Build failure=null

BUILD SUCCESSFUL in 53s
2 actionable tasks: 2 executed

--warning-mode=all을 줬지만 buildFinished에 대한 폐기(deprecation) 경고는 찍히지 않았습니다. 설정 캐시가 꺼진 이 길에서는 API에 @Deprecated만 붙어 있고 실행 중 경고는 나오지 않았습니다. Flow 액션이 init 스크립트의 훅보다 먼저 찍힌 것은 리스너가 불리는 순서 때문인데, 그 순서를 정하는 코드는 이번에 확인하지 못했습니다.

BUILD SUCCESSFUL은 누가 찍나

마지막 두 줄은 생명주기 바깥, 1절의 BuildOutcomeReportingBuildActionRunner가 찍습니다. 빌드 전에 BuildLogger를 "로거"로 걸어 두고(81행), 안쪽(delegate.run, 83행)이 다 돌아온 뒤 buildLogger.logResult(...)와 TaskExecutionStatisticsReporter를 부릅니다(88–89행). BuildLogger도 리스너입니다. --info에 찍히는 Starting Build, Settings evaluated using …, Projects loaded. …, All projects evaluated.이 모두 이 클래스의 훅 메서드에서 나옵니다(BuildLogger.java 77–110행). buildFinished 훅에서는 BuildResult의 동작 이름("Build" 또는 "Configure")만 기억해 두었다가(113–116행), 위의 logResult()에서 대문자로 바꿔 BUILD SUCCESSFUL in 53s를 만듭니다(BuildResultLogger.java 68–76행). 태스크를 하나도 예약하지 않는 길(hasTasks가 false)이면 같은 자리에서 CONFIGURE SUCCESSFUL이 됩니다. 그 아래 2 actionable tasks: 2 executed는 TaskExecutionStatisticsReporter가 찍습니다.

빌드가 끝나는 순서. ① BuildOutcomeReportingBuildActionRunner 가 delegate.run()으로 빌드 전체를 돌린다. ② runBuild()가 DefaultBuildTreeFinishExecutor 의 finishBuildTree()를 부른다(오퍼레이션 Finish root build tree). ③ 이 실행기는 포함된 빌드들의 finishBuild()를 먼저 부르고 실패 목록을 exceptionAnalyser 로 정리한 뒤, ④ 루트 빌드의 DefaultBuildLifecycleController.finishBuild(failure)를 부른다. ⑤ 이것은 BuildFinishHooks 로 가는 전이이고, 앞에서 실패했어도 들어온다. ⑥ 그 안에서 buildFinished 리스너들에게 BuildResult(동작 이름 Build 또는 Configure, 실패)를 알린다. 리스너로는 init 스크립트의 훅, 결과 Provider 를 채우고 Run flow action … 으로 액션을 돌리는 BuildFlowScope, 동작 이름만 기억하는 BuildLogger 가 있다. 리스너끼리의 순서는 확인하지 못했다. ⑦ 생명주기가 끝나고 결과가 러너로 돌아오면, ⑧ 러너가 buildLogger.logResult()로 BUILD SUCCESSFUL in 53s 를, TaskExecutionStatisticsReporter 로 2 actionable tasks: 2 executed 를 찍는다. 훅과 Flow 액션은 ⑥에서, 결과 두 줄은 생명주기 바깥의 ⑧에서 찍힙니다. 시간과 태스크 수는 예제 2의 출력입니다.

헷갈리기 쉬운 곳

7. 실패하면

예제 5: 세 단계에서 일부러 실패하기

-Pfail=settings|configure|execute로 실패할 자리를 고르는 프로젝트를 만들었습니다. settings 스크립트는 fail이 settings면 5행에서 throw GradleException("boom in settings")를 하고, 빌드 스크립트는 이렇습니다.

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
val fail = providers.gradleProperty("fail").orNull

// 스크립트가 예외로 끝나도 afterEvaluate 는 불리는지 본다
afterEvaluate { println("[hook  ] afterEvaluate failure=${state.failure?.javaClass?.simpleName}") }

println("[script] build.gradle.kts")
if (fail == "configure") throw GradleException("boom in configuration")

tasks.register("hello") {
    doLast {
        if (fail == "execute") throw GradleException("boom in execution")
        println("[task  ] :hello action")
    }
}

init 스크립트에는 buildFinished에서 action과 failure의 클래스 이름을 찍는 훅 하나만 있습니다. ex/u05-failures/run.sh는 데몬을 먼저 띄워 둔 뒤 세 경우를 차례로 돌리고, 표준 출력과 표준 오류를 따로 받아 찍습니다. 처음에 둘을 합쳐 받았더니 settings 실패에서 buildFinished 줄이 BUILD FAILED 뒤에 찍혀 순서가 뒤바뀌어 보였습니다. 실패 보고(FAILURE: …, BuildExceptionReporter.java 184행)와 BUILD FAILED 줄(BuildResultLogger.java 66행)은 오류 수준(LogLevel.ERROR)이라 표준 오류로 나가고, 두 흐름은 섞이는 순서가 보장되지 않기 때문입니다. 먼저 설정 단계 실패입니다.

=== -Pfail=configure ===
exit code: 1
--- stdout ---
[script] settings.gradle.kts

> Configure project :
[script] build.gradle.kts
[hook  ] afterEvaluate failure=ProjectConfigurationException
[hook  ] buildFinished action=Build failure=LocationAwareException
--- stderr ---

FAILURE: Build failed with an exception.

* Where:
Build file '/tmp/p/build.gradle.kts' line: 8

* What went wrong:
boom in configuration

* Try:
...

BUILD FAILED in 4s

(* Try: 아래 네 줄의 안내는 줄였습니다. 시간은 실행마다 다릅니다.) 읽을 것이 셋 있습니다.

  1. afterEvaluate는 돌았습니다. 4절의 LifecycleProjectEvaluator에서 afterEvaluate가 finally 안에 있던 그대로입니다. 그리고 그 순간 프로젝트의 실패는 ProjectConfigurationException입니다. 스크립트의 예외를 A problem occurred configuring root project 'failures'.라는 메시지로 감싼 것입니다(LifecycleProjectEvaluator.java 82–86행).
  2. buildFinished가 받은 실패는 LocationAwareException입니다. DefaultBuildTreeFinishExecutor가 exceptionAnalyser.transform()으로 실패를 정리할 때(56행), 예외 사슬의 가장 깊은 원인을 찾아 파일 이름과 줄 번호를 붙여 감쌉니다(DefaultExceptionAnalyser.java 67–97행). 보고서의 * Where: Build file '/tmp/p/build.gradle.kts' line: 8이 거기서 옵니다.
  3. 보고서의 "What went wrong"은 바깥 메시지가 아니라 원래 메시지(boom in configuration)입니다. 감싼 ProjectConfigurationException의 메시지는 보고서에 나오지 않았습니다. 사용자는 자기가 던진 예외를 그대로 봅니다.

settings에서 실패하면 표준 출력에는 [script] settings.gradle.kts와 [hook ] buildFinished action=Build failure=LocationAwareException 두 줄만 나오고, 보고서의 * Where:는 Settings file '/tmp/p/settings.gradle.kts' line: 5, "What went wrong"은 boom in settings였습니다. 실행 단계 실패는 이렇습니다.

=== -Pfail=execute ===
exit code: 1
--- stdout ---
[script] settings.gradle.kts

> Configure project :
[script] build.gradle.kts
[hook  ] afterEvaluate failure=null

> Task :hello FAILED
[hook  ] buildFinished action=Build failure=LocationAwareException
1 actionable task: 1 executed
--- stderr ---

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':hello' (registered in build file 'build.gradle.kts').
> boom in execution

* Try:
...

BUILD FAILED in 4s

(* Try: 아래 네 줄은 앞과 같아 줄였고, 시간은 실행마다 다릅니다. 세 경우 모두 exit code: 1이었고, settings 실패 출력 맨 앞에는 Welcome to Gradle … 인사도 찍혔습니다.) 어느 단계에서 실패하든 buildFinished는 불렸고, action은 늘 Build였습니다. settings 스크립트에서 실패했으니 태스크를 하나도 예약하지 못했는데도 Build인 것은, prepareToScheduleTasks()가 일을 시작하기 전에 hasTasks = true를 해 두기 때문입니다(3절의 183–184행). 실행 실패의 보고서에는 * Where:가 없고 대신 "어느 태스크인지, 그 태스크를 어느 파일에서 등록했는지"가 나옵니다.

설정 단계에서 실패했을 때의 길. build.gradle.kts 8행에서 GradleException이 나면 LifecycleProjectEvaluator가 ProjectConfigurationException으로 감싸고, afterEvaluate는 finally에서 그래도 돈다. StateTransitionController가 빌드 상태를 Failed로 바꾸고 예외를 다시 던진다. VintageBuildTreeWorkController가 Try로 받아 실패한 ExecutionResult로 runBuild()에 돌려준다. DefaultBuildTreeFinishExecutor에서 ExceptionAnalyser가 LocationAwareException으로 파일 이름과 줄 번호를 붙인다. DefaultBuildLifecycleController.finishBuild()가 ignoreFailures 전이로 들어가 buildFinished 훅이 실패를 받는다. 끝으로 BuildOutcomeReportingBuildActionRunner가 FAILURE 보고와 BUILD FAILED를 표준 오류로 찍는다. 실패는 위로 던져지다가 VintageBuildTreeWorkController에서 "결과"로 바뀌고, 그 뒤로는 값으로 전달됩니다.

예외를 던지다가 결과로 바꾸는 이유

그림의 가운데가 이 설계의 핵심입니다. 프로젝트 평가기와 상태 전이 장치는 예외를 던지지만, VintageBuildTreeWorkController(5절의 37행 Try.ofFailable)와 runBuild()(2절의 catch)부터는 실패를 ExecutionResult라는 값으로 들고 다닙니다. 값으로 바꾸면 실패가 여럿일 때 모을 수 있고(--continue로 태스크 여러 개가 실패할 때, 포함된 빌드가 따로 실패할 때), 마무리 단계가 그 목록을 받아 처리할 수 있습니다. 예외로 계속 던지면 첫 실패만 남고 마무리 코드는 finally에서 무엇이 실패했는지 모른 채 돌아야 합니다.

Spring의 refresh()와 비교해 볼 만합니다(Spring 7편). refresh()는 열두 단계 중 하나가 실패하면 catch에서 이미 만든 빈을 부수고(destroyBeans()) 예외를 다시 던집니다. 실패한 컨텍스트는 버리는 것이 목표입니다. Gradle은 실패한 빌드도 끝까지 마무리하는 것이 목표입니다. 훅을 부르고, 파일 감시 상태를 정리하고, 보고서를 쓰고, 그다음에 데몬은 다음 빌드를 받아야 하니까요. 상태 기계의 ignoreFailures와 실패를 값으로 바꾸는 층이 그 목표에서 나왔습니다.

헷갈리기 쉬운 곳

8. 빌드 오퍼레이션 — 왜 단계마다 이름을 붙이나

오퍼레이션 하나의 일생

지금까지 거의 모든 단계에 BuildOperationFiring…이나 buildOperationRunner.run(...)이 붙어 있었습니다. 그 본체는 짧습니다.

    private <O> O execute(BuildOperationDescriptor.Builder descriptorBuilder, BuildOperationExecution<O> execution) {
        BuildOperationState descriptorParent = (BuildOperationState) descriptorBuilder.getParentState();
        BuildOperationState parent = descriptorParent == null ? getCurrentBuildOperation() : descriptorParent;
        OperationIdentifier id = new OperationIdentifier(buildOperationIdFactory.nextId());
        BuildOperationDescriptor descriptor = descriptorBuilder.build(id, parent == null
            ? null
            : parent.getDescription().getId());
        // ...
        BuildOperationState operationState = new BuildOperationState(descriptor, clock.getCurrentTime());
        BuildOperationTrackingListener listener = new BuildOperationTrackingListener(currentBuildOperationRef, listenerFactory.createListener());
        // ...
    }

platforms/core-runtime/build-operations/src/main/java/org/gradle/internal/operations/DefaultBuildOperationRunner.java 155–174행

부모를 따로 주지 않으면 이 스레드에서 지금 돌고 있는 오퍼레이션이 부모가 됩니다(157행). 그래서 메서드가 메서드를 부르는 모양이 그대로 오퍼레이션의 나무가 됩니다. 일 자체는 listener.start()와 listener.stop() 사이에서 돌고(64–74행), 예외가 나면 오퍼레이션을 실패로 표시한 뒤 다시 던집니다(68–77행). 오퍼레이션은 일의 흐름을 바꾸지 않고 기록만 합니다.

빌드 오퍼레이션의 흐름. 단계 코드(예: BuildOperationFiringProjectsPreparer)가 DefaultBuildOperationRunner.run()을 부르면, 이 스레드의 현재 오퍼레이션을 부모로 새 id·이름·분류를 붙이고 listener.start()를 부른 뒤 실제 일(delegate.prepareProjects())을 하고 listener.stop()을 부른다. 실패면 다시 던진다. 같은 시작·끝 사건을 콘솔 진행 표시줄(BuildStatusRenderer, INITIALIZING·CONFIGURING·EXECUTING), trace 파일(BuildOperationTrace), IDE·Tooling API(BuildPhaseOperationListener)가 받아 쓴다. 단계 코드는 누가 사건을 받는지 모릅니다. 받는 쪽도 단계 코드를 모릅니다.

예제 6: 훅이 오퍼레이션 나무의 어디에 걸리나

예제 2를 trace 옵션(-Dorg.gradle.internal.operations.trace=/tmp/trace -Dorg.gradle.internal.operations.trace.tree=true)으로 한 번 더 돌리고, trace-tree.txt에서 오퍼레이션 이름만 남겼습니다. println 한 줄은 » …로 그 줄을 찍은 오퍼레이션 밑에 두었고, 플러그인 적용·태스크 등록·스냅숏처럼 이 글과 상관없는 오퍼레이션은 run.sh의 awk로 뺐습니다. :app·:lib에 대한 부분은 :와 같은 모양이라 줄였습니다(...).

Run build
  Load build
    Run init scripts
      Apply initialization script 'init.gradle.kts' to build ':'
          » [script] init.gradle.kts
    Evaluate settings
      » [hook  ] beforeSettings
      » [hook  ] settingsEvaluated
      Apply settings file 'settings.gradle.kts' to settings 'p'
          » [script] settings.gradle.kts
      Finalize build cache configuration
  Configure build
    Load projects
    Notify projectsLoaded listeners
      Execute Gradle.projectsLoaded listener
        » [hook  ] projectsLoaded
    Configure project :
      Notify beforeEvaluate listeners of :
        Execute Gradle.beforeProject listener
          » [hook  ] beforeProject :
      Apply build file 'build.gradle.kts' to root project 'hooks'
            » [script] build.gradle.kts (:)
      Notify afterEvaluate listeners of :
        Execute Gradle.afterProject listener
          » [hook  ] afterProject :
    Configure project :app
    ...
    Configure project :lib
    ...
    Notify projectsEvaluated listeners
      Execute Gradle.projectsEvaluated listener
        » [hook  ] projectsEvaluated
  Calculate build tree task graph
    Calculate task graph
      Realize task :hello
        Execute container callback action
          » [config] configure task :hello
      Realize task :app:hello
      ...
    Notify task graph whenReady listeners
      Execute TaskExecutionGraph.whenReady listener
        » [hook  ] taskGraph.whenReady [:hello, :app:hello]
  Run main tasks
    Run tasks
      Task :hello
        Executing task ':hello'
          Execute doLast {} action for :hello
            » [task  ] :hello action
      Task :app:hello
      ...
  Finish root build tree
    » [hook  ] buildFinished action=Build failure=null
    Run flow action Build_gradle$SayFinished
      » [flow  ] SayFinished failed=false

이 글에서 따라온 것이 한 장에 다 있습니다.

왜 단계마다 이름을 붙이나

오퍼레이션이 없으면 "이 빌드의 시간이 어디로 갔나"를 알려면 단계마다 시간을 재는 코드를 따로 넣어야 하고, 콘솔과 IDE와 빌드 스캔이 각자 단계 코드에 손을 대야 합니다. Gradle은 반대로 했습니다. 단계 코드는 "지금부터 이 이름의 일을 한다"고만 알리고, 받는 쪽은 그 사건을 구독합니다. 예제 4의 콘솔(BuildStatusRenderer)은 분류를 보고 단계 이름을, 1편과 예제 6의 trace 파일(BuildOperationTrace)은 나무 전체를, IDE는 Tooling API의 BuildPhaseOperationListener로 같은 분류(CONFIGURE_ROOT_BUILD, RUN_MAIN_TASKS 등, 48–53행)를 받아 진행 상황을 그립니다. 같은 사건 하나를 여러 곳이 다르게 쓰는 구조라서, 새 단계를 더할 때 오퍼레이션 이름 하나만 붙이면 콘솔·IDE·스캔에 저절로 나타납니다. 대신 대가가 있습니다. 1편의 호출 스택이 두꺼웠던 이유가 이 감싸기였고, 예제 1에서는 BuildOperation이 든 이름을 일부러 걸러야 했습니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

정리

개념 한 줄 요약
실행기 사슬 데몬의 ExecuteBuild부터 ExecuteBuildActionRunner까지 열 겹 남짓. 층마다 상태(세션·빌드 트리·루트 빌드)를 하나씩 열고 닫는다
러너 다섯 겹 완료 알림 → 파일 감시 → 결과 출력 → 문제 보고 → Chaining. 빌드 액션 종류와 상관없는 앞뒤 일을 맡는다
ChainingBuildActionRunner 감싸지 않고 목록을 돌며 "이 액션 처리할 수 있나" 묻는다. 명령줄 빌드는 ExecuteBuildActionRunner
StateTransitionController 상태를 InState·TransitioningToNewState·Failed 객체로 들고, 잘못된 전이와 실패 뒤 사용을 막는다
ignoreFailures 함수를 받는 transition()(과 상태를 새로 시작시키는 restart())만 실패 뒤에도 들어간다. transition()은 쌓인 실패를 넘겨받고, finishBuild()가 이 길
초기화 DefaultSettingsPreparer.loadBuild(): gradle.properties → init 스크립트 → settings 양파 → 프로젝트 등록 → 포함된 빌드
settings 양파 오퍼레이션 → 빌드 캐시 확정 → … → settingsEvaluated → beforeSettings+스크립트. 끝나면 settings를 잠근다
설정 BuildTreePreparingProjectsPreparer가 Project를 만들고(projectsLoaded) buildSrc를 빌드한 뒤, DefaultProjectsPreparer가 프로젝트마다 평가하고 projectsEvaluated
afterEvaluate finally 안에서 불려 스크립트가 실패해도 돈다. 실패는 그 뒤 다시 던져진다
작업 그래프 register한 태스크의 설정 블록과 whenReady는 projectsEvaluated 뒤, 설정 시간 안에서 돈다
설정 시간 장벽 초기화·설정·그래프 확정을 감싸 prepare()/cross(). 설정과 실행은 VintageBuildTreeWorkController의 37행과 40행 사이에서 갈린다
콘솔 단계 BuildStatusRenderer가 오퍼레이션 분류만 보고 INITIALIZING·CONFIGURING·EXECUTING을 정한다
buildFinished 9.x에서 @Deprecated. 대신 Flow API — 그 구현 BuildFlowScope도 내부적으로는 buildFinished 리스너
실패 위로 던지다가 VintageBuildTreeWorkController에서 ExecutionResult로 바뀌고, 마무리는 끝까지 한다. 보고서는 표준 오류로
빌드 오퍼레이션 현재 스레드의 오퍼레이션을 부모로 하는 나무. 일을 바꾸지 않고 기록만 하며, 콘솔·trace·IDE가 같은 사건을 받아 쓴다

다음에 볼 것

이 글에서 delegate.evaluate(project, state) 한 줄로 넘긴 곳, 곧 build.gradle.kts가 실제로 실행되는 자리로 들어갑니다. LifecycleProjectEvaluator 안쪽의 평가기 사슬, Kotlin DSL 스크립트가 plugins {}와 본문으로 나뉘어 따로 컴파일되는 과정, settings → 루트 프로젝트 → 하위 프로젝트로 이어지는 클래스로더 나무를 봅니다. 6편: 프로젝트와 빌드 스크립트 — build.gradle.kts가 실행되기까지에서 이어집니다.

앞 연재에서 닮은 것을 다뤘습니다. Spring 7편의 refresh()는 컨테이너를 켜는 열두 단계를 한 메서드에 순서대로 적고, 실패하면 만든 것을 부수고 예외를 던집니다. Gradle의 생명주기는 단계를 여러 객체의 상태 전이로 나누고, 실패해도 마무리 훅까지 돕니다. Tomcat 3편의 LifecycleBase는 상태가 바뀔 때마다 이벤트를 내는 상태 기계이고, Tomcat 8편의 밸브 사슬은 이 글의 실행기 사슬과 같은 "감싸서 다음을 부르는" 모양입니다. httpd 6편의 훅은 코어가 정해진 자리에서 등록된 함수들을 차례로 부르는 점에서 Gradle의 생명주기 훅과 닮았습니다.

다른 글