namalzi

← 글 목록

Gradle 소스 파헤치기 7: 플러그인 적용 — plugins {} 블록에서 apply()까지

Gradle · 게시

plugins { java } 한 줄은 소스에서 무엇이 될까요. 요청을 모두 먼저 해석하고 나서 한꺼번에 적용하는 DefaultPluginRequestApplicator, 핵심 플러그인·buildSrc·포함된 빌드·플러그인 포털을 차례로 묻는 해석기 사슬과 마커 아티팩트, id를 클래스로 바꾸는 META-INF/gradle-plugins 설명 파일, 같은 플러그인을 두 번 걸어도 한 번만 도는 DefaultPluginManager, withPlugin이 불리는 정확한 시점, 확장이 붙는 자리, java → java-base → base 사슬, buildSrc와 precompiled script plugin까지 직접 만든 플러그인으로 확인합니다.

이 글에서 다루는 것

"Gradle 소스 파헤치기" 연재의 일곱 번째 글입니다. 6편에서는 build.gradle.kts 한 파일이 컴파일되어 실행되기까지를 봤습니다. Kotlin DSL 도 Groovy DSL 도 buildscript {}·plugins {} 블록을 떼어 내 먼저 실행하고, 본문은 그다음에 돈다는 것까지였습니다. 이번 글은 그 사이, 먼저 실행된 plugins {} 블록이 모은 요청이 실제 Plugin.apply() 호출이 되기까지를 따라갑니다.

2편의 지도(2편 지도 그림)로 돌아가면, 이 글은 "D 설정 단계" 구역의 가운데 상자 플러그인 적용(plugins {} → apply()) 을 확대합니다. 왼쪽 상자(빌드 스크립트 실행)는 6편이, 오른쪽 상자(태스크 등록·Provider)는 8편이 맡습니다.

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

항목 내용
대상 Gradle Build Tool
버전 9.10.0 개발판 (master)
커밋 07ecbf9ead (2026-10-24)
주로 읽는 클래스 plugin/use/internal/DefaultPluginRequestApplicator, PluginResolverFactory, plugin/use/resolve/internal/{AlreadyOnClasspath,Core,ArtifactRepositories}PluginResolver, api/internal/plugins/{DefaultPluginManager,DefaultPluginRegistry,DefaultPluginContainer}, internal/extensibility/DefaultExtensionContainer, JavaPlugin·JavaBasePlugin·BasePlugin

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.

플러그인 쪽 코드는 platforms/extensibility/plugin-use(요청 해석), subprojects/core 의 api/internal/plugins(적용), 그리고 플러그인마다 흩어진 수십 개 모듈에 걸쳐 있습니다. 이 글은 plugins {} 한 블록이 지나는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

먼저 알아 둘 말

플러그인(plugin)은 Plugin<T> 인터페이스를 구현한 클래스입니다. 메서드는 apply(T target) 하나뿐이고, T 는 보통 Project 입니다(Settings 나 Gradle 에 거는 플러그인도 있습니다). 플러그인이 하는 일은 결국 apply() 안에서 프로젝트에 태스크·확장·설정을 더하는 것입니다. 빌드 스크립트에 손으로 적을 수 있는 일을 묶어서 다시 쓰게 해 주는 상자라고 보면 됩니다.

플러그인 id 는 org.gradle.java, org.jetbrains.kotlin.jvm 처럼 점으로 나뉜 이름입니다. 마지막 점 앞까지를 이름공간(namespace)이라고 부릅니다. Gradle 에 들어 있는 플러그인은 모두 org.gradle 이름공간이고, 이것을 핵심 플러그인(core plugin)이라고 합니다. id("java") 처럼 이름공간 없이 쓰면 org.gradle.java 를 뜻합니다(3절).

플러그인 요청(plugin request)은 plugins {} 블록의 한 줄입니다. id, 버전, apply false 여부, 그리고 몇 번째 줄에 적었는지를 담은 PluginRequestInternal 객체가 됩니다. 요청은 아직 "이런 플러그인을 원한다"는 말일 뿐, 클래스를 찾지도 않았습니다.

해석(resolution)은 요청을 보고 플러그인을 어디서 가져올지 정하는 일입니다. 해석하는 객체를 해석기(PluginResolver)라고 부르고, 여러 해석기를 한 줄로 세워 앞에서부터 묻습니다(2절).

마커 아티팩트(marker artifact)는 플러그인 포털 같은 저장소에서 "id 가 foo.bar 인 플러그인은 이 라이브러리에 있다"를 알려 주는 빈 껍데기 라이브러리입니다. 좌표가 foo.bar:foo.bar.gradle.plugin:<버전> 으로 정해져 있어서, id 만 알면 좌표를 만들 수 있습니다.

확장(extension)은 플러그인이 프로젝트에 붙여 두는 설정 객체입니다. 빌드 스크립트의 java { … }, base { … } 블록이 확장을 설정하는 것입니다. project.extensions 에 이름으로 붙습니다(6절).

클래스로더 스코프(ClassLoaderScope)는 settings → buildSrc → 루트 프로젝트 → 하위 프로젝트로 이어지는 클래스로더의 나무입니다. 플러그인 jar 는 이 나무의 한 마디에 실립니다. 나무 자체는 6편에서 봤고, 이 글에서는 플러그인이 어느 마디에 실리는지만 봅니다(1·8절).

1. 큰 그림: 해석을 다 끝낸 뒤에 적용한다

6편의 되짚기부터 합니다. Groovy DSL 쪽 DefaultScriptPluginFactory 는 스크립트를 두 번 컴파일합니다. 첫 번째는 plugins {}·buildscript {} 만, 두 번째는 나머지 본문입니다. 그 사이에 이 글의 주인공이 있습니다.

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

            PluginManagerInternal pluginManager = topLevelScript ? initialPassScriptTarget.getPluginManager() : null;
            PluginRequests initialPluginRequests = getInitialPluginRequests(initialRunner);
            PluginRequests allPluginRequests = pluginHandler.getAllPluginRequests(initialPluginRequests, target);
            pluginRequestApplicator.applyPlugins(allPluginRequests, scriptHandler, pluginManager, targetScope);

subprojects/core/src/main/java/org/gradle/configuration/DefaultScriptPluginFactory.java 114–123행

Kotlin DSL 도 같은 자리로 옵니다. 6편에서 본 Kotlin 해석기가 plugins {} 블록을 실행해 요청을 모은 뒤 applyPluginsTo() 를 부르고(KotlinScriptEvaluator.kt 262–269행), 이것이 PluginRequestsHandler 를 거쳐 같은 PluginRequestApplicator.applyPlugins() 에 닿습니다(PluginRequestsHandler.kt 43–50행). 4절 예제의 스택에서 이 길이 그대로 보입니다.

두 길 모두에서 pluginHandler.getAllPluginRequests() 가 한 번 끼어듭니다. 사용자가 적지 않았지만 자동으로 걸리는 플러그인(예: --scan 을 주면 settings 스크립트에 붙는 Develocity 플러그인 — 프로젝트 빌드 스크립트에는 이 목록이 비어 있습니다)을 요청 목록에 더하는 곳입니다. 이 글에서는 더 따라가지 않습니다.

요청 목록을 받은 DefaultPluginRequestApplicator.applyPlugins() 가 이 글의 중심입니다. 줄인 곳을 표시하고 옮깁니다.

    @Override
    public void applyPlugins(PluginRequests requests, final ScriptHandlerInternal scriptHandler, @Nullable final PluginManagerInternal target, final ClassLoaderScope classLoaderScope) {
        // ... 요청이 없으면 export·lock 만 하고 돌아간다 ...

        PluginArtifactRepositories resolveContext = pluginRepositoriesProvider.createPluginResolveRepositories();
        resolveContext.applyRepositoriesTo(scriptHandler.getRepositories());

        List<ApplyAction> pluginApplyActions = new ArrayList<>();
        CollectingPluginRequestResolutionVisitor pluginDependencies = new CollectingPluginRequestResolutionVisitor();

        // Resolve the plugin requests
        PluginResolver pluginResolver = wrapInAlreadyInClasspathResolver(classLoaderScope, resolveContext);
        for (PluginRequestInternal originalRequest : requests) {
            PluginRequestInternal request = pluginResolutionStrategy.applyTo(originalRequest);
            PluginResolution resolved = resolvePluginRequest(pluginResolver, request);

            resolved.accept(pluginDependencies);

            if (request.isApply()) {
                pluginApplyActions.add(new ApplyAction(request, resolved));
            }
            // ... 플러그인 버전 기록 ...
        }

        // Configure the resolution
        pluginDependencies.getAdditionalDependencies().forEach(scriptHandler::addScriptClassPathDependency);
        // ... 모듈 대체(replacement) 설정 ...

        // 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 84–136행

주석 네 개가 그대로 단계 이름입니다. 순서가 중요합니다.

  1. 해석(Resolve) — 요청을 하나도 빠짐없이 먼저 해석합니다. 이때는 "어디서 가져올지"와 "스크립트 클래스패스에 무엇을 더할지"만 정합니다. apply false 인 요청은 해석만 하고 적용 목록(pluginApplyActions)에는 넣지 않습니다.
  2. 의존성 더하기(Configure the resolution) — 저장소에서 받아야 하는 플러그인은 마커 아티팩트 의존성을 스크립트 클래스패스(buildscript { dependencies { classpath(…) } } 와 같은 곳)에 더합니다.
  3. 클래스로더 확정(Perform resolution & configure the classloader) — getInstrumentedScriptClassPath() 가 그 클래스패스를 실제로 해석해 jar 파일들을 얻고, 이 스크립트의 클래스로더 스코프에 export 한 뒤 lock() 합니다. 잠근 뒤에는 이 스코프에 jar 를 더 넣을 수 없습니다.
  4. 적용(Apply) — 그제서야 요청 순서대로 ApplyAction.apply() 를 부릅니다.

plugins 블록이 apply()가 되기까지. build.gradle.kts의 plugins 블록(id lab.hello, java)이 ① 블록만 먼저 실행되어 PluginRequestCollector가 요청 목록 PluginRequests를 모은다. ② 요청마다 해석기 사슬로 해석한다. ③ 스크립트 클래스패스에 의존성을 더하고 클래스로더 스코프를 export한 뒤 lock한다. ④ 요청 순서대로 ApplyAction.apply()로 적용하고, DefaultPluginManager.apply()가 플러그인 인스턴스의 apply(project)를 부른다. ⑤ 그 뒤에 스크립트 본문이 실행되어 hello 블록과 java 블록을 쓸 수 있다. 파란 상자가 이 글에서 읽는 구간입니다. ① 은 6편, ⑤ 의 본문 실행도 6편의 몫입니다.

왜 "다 해석하고 나서 적용"일까요. 소스 주석이 이유를 직접 적어 두지는 않았지만, 코드의 모양에서 읽을 수 있는 것이 둘 있습니다. 첫째, 모든 요청의 의존성을 스크립트 클래스패스 한 곳에 더한 뒤 한 번에 해석하므로, 플러그인 두 개가 같은 라이브러리의 다른 버전을 원해도 의존성 해석의 충돌 해결(12편)이 한 번에 정리합니다. 둘째, 적용(플러그인 클래스를 로드하고 apply() 를 부르는 것)은 클래스로더 스코프를 lock() 한 뒤에만 일어납니다. 플러그인 코드가 도는 동안 그 클래스로더의 클래스패스가 바뀌지 않는다는 약속을 순서로 지키는 셈입니다.

헷갈리기 쉬운 곳

2. 해석: 해석기 사슬이 앞에서부터 묻는다

1단계의 wrapInAlreadyInClasspathResolver() 는 해석기 사슬을 만듭니다. 사슬의 본체는 PluginResolverFactory 가 만듭니다.

    private void addDefaultResolvers(PluginArtifactRepositories pluginResolveContext, List<PluginResolver> resolvers) {
        resolvers.add(new NoopPluginResolver(corePluginRegistry));
        resolvers.add(new CorePluginResolver(documentationRegistry, corePluginRegistry));

        injectedClasspathPluginResolver.collectResolversInto(resolvers);

        pluginResolverContributors.forEach(contributor -> contributor.collectResolversInto(resolvers));
        resolvers.add(pluginResolveContext.getPluginResolver());
    }

platforms/extensibility/plugin-use/src/main/java/org/gradle/plugin/use/internal/PluginResolverFactory.java 83–91행

바로 위의 Javadoc(66–82행)이 순서를 설명합니다. NoopPluginResolver 는 테스트 전용, CorePluginResolver 는 Gradle 에 들어 있는 플러그인, 그다음은 TestKit 테스트의 클래스패스, 그다음은 "이 배포판이 보탠 해석기 — 포함된 빌드(included build)의 플러그인", 마지막이 저장소입니다. 이 목록을 CompositePluginResolver 가 for 문으로 앞에서부터 묻고, 처음 "찾았다"고 답한 것을 돌려줍니다. 못 찾으면 해석기마다의 "못 찾은 이유"를 모아 둡니다(CompositePluginResolver.java 32–43행). Tomcat 의 밸브나 Spring 의 인터셉터처럼 "다음을 부를지"를 각자 정하는 사슬이 아니라, 감독 하나가 줄 선 해석기들에게 차례로 묻는 모양입니다.

그리고 이 사슬 전체를 AlreadyOnClasspathPluginResolver 가 한 겹 더 감쌉니다(DefaultPluginRequestApplicator.java 138–143행).

    @Override
    public PluginResolutionResult resolve(PluginRequestInternal pluginRequest) {
        PluginId pluginId = pluginRequest.getId();
        if (isCorePlugin(pluginId) || !isPresentOnClasspath(pluginId)) {
            return delegate.resolve(pluginRequest);
        }

        String version = getVersion(pluginRequest);
        if (version == null) {
            return resolveAlreadyOnClasspath(pluginId, null);
        }
        // ... 버전을 적었으면 이미 실린 버전과 같은지 확인 ...

platforms/extensibility/plugin-use/src/main/java/org/gradle/plugin/use/resolve/internal/AlreadyOnClasspathPluginResolver.java 60–70행

"이미 클래스패스에 있다"의 클래스패스는 부모 클래스로더 스코프입니다(classLoaderScope.getParent() 의 export 클래스로더, 139–140행). 루트 프로젝트 스크립트라면 부모는 buildSrc 의 스코프이므로, buildSrc 에서 만든 플러그인은 여기서 찾습니다(8절). 하위 프로젝트라면 부모는 루트 프로젝트의 스코프라서, 루트의 plugins {} 에서 받아 둔 플러그인을 다시 받지 않고 씁니다.

플러그인 해석기 사슬. 플러그인 요청 하나(id, version)가 먼저 AlreadyOnClasspathPluginResolver에 간다. 부모 스코프 클래스패스에 이미 있으면(buildSrc의 lab.hello) 바로 찾음이다. 없거나 핵심 플러그인이면 NoopPluginResolver(테스트 전용) → CorePluginResolver(org.gradle 이름공간의 핵심 플러그인, java) → TestKit 클래스패스 해석기 → 포함된 빌드(CompositeBuildPluginResolverContributor) → ArtifactRepositoriesPluginResolver(마커 아티팩트 id:id.gradle.plugin, pluginManagement 저장소, 없으면 플러그인 포털) 순서로 묻는다. CorePluginResolver나 저장소에서 찾으면 PluginResolution이 되고, 아무 데도 없으면 출처마다 이유를 모아 was not found in any of the following sources 오류가 된다. 처음 "찾았다"고 답한 해석기가 이깁니다. 앞에 있는 해석기가 뒤의 것을 가린다는 뜻이기도 합니다.

핵심 플러그인: 이름공간만 보고 거른다

    @Override
    public PluginResolutionResult resolve(PluginRequestInternal pluginRequest) {
        PluginId id = pluginRequest.getId();
        if (!isCorePluginRequest(id)) {
            return PluginResolutionResult.notFound(getDescription(), format("plugin is not in '%s' namespace", CORE_PLUGIN_NAMESPACE));
        }

        PluginImplementation<?> plugin = pluginRegistry.lookup(id);
        if (plugin == null) {
            return PluginResolutionResult.notFound(getDescription(), "not a core plugin. " + documentationRegistry.getDocumentationRecommendationFor("available plugins", "plugin_reference"));
        }

        validate(pluginRequest);
        return PluginResolutionResult.found(new SimplePluginResolution(plugin));
    }

platforms/extensibility/plugin-use/src/main/java/org/gradle/plugin/use/resolve/internal/CorePluginResolver.java 39–53행

이름공간이 없거나 org.gradle 이면(80–83행) Gradle 자신의 플러그인 레지스트리에서 찾습니다. 찾은 뒤 validate() 가 세 가지를 막습니다(55–74행). 핵심 플러그인에는 버전을 적을 수 없고, 다른 구현 아티팩트를 지정할 수 없고, apply false 도 쓸 수 없습니다. 핵심 플러그인은 Gradle 과 함께 배포되어 이미 클래스패스에 있으니 셋 다 뜻이 없다는 것입니다.

저장소: 마커 아티팩트로 찾는다

사슬 끝의 ArtifactRepositoriesPluginResolver 는 id 로 마커 아티팩트 좌표를 만듭니다.

    private ModuleDependency getMarkerDependency(PluginRequestInternal pluginRequest) {
        ComponentSelector selector = pluginRequest.getSelector();
        if (!(selector instanceof ModuleComponentSelector)) {
            String id = pluginRequest.getId().getId();
            return getDependencyFactory().create(id, id + PLUGIN_MARKER_SUFFIX, pluginRequest.getVersion());
        } else {
            // ... pluginManagement 의 resolutionStrategy 로 모듈을 직접 지정한 경우 ...
        }
    }

platforms/extensibility/plugin-use/src/main/java/org/gradle/plugin/use/resolve/internal/ArtifactRepositoriesPluginResolver.java 189–198행

PLUGIN_MARKER_SUFFIX 는 ".gradle.plugin" 입니다(45행). id 가 org.jetbrains.kotlin.jvm 이면 좌표는 org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:<버전> 입니다. 버전이 없으면 이 해석기는 바로 "버전이 있어야 한다"며 물러납니다(59–62행). 버전이 있으면 그 마커가 저장소에 정말 있는지 한 번 해석해 보고(exists(), 179–187행 — 소스의 TODO 주석이 "요청마다 해석을 하니 비효율적"이라고 스스로 적어 두었습니다), 있으면 ExternalPluginResolution 을 돌려줍니다. 이 결과가 1절 2단계에서 마커를 스크립트 클래스패스 의존성으로 더합니다(122–132행). 마커의 POM 이 실제 플러그인 jar 를 의존성으로 끌고 오므로, 3단계의 해석이 끝나면 클래스로더에 진짜 jar 가 실립니다.

저장소는 어디일까요. DefaultPluginArtifactRepositories 의 생성자가 정합니다(42–49행). 공유 저장소 목록을 하나씩 감싸 담고, 다 담은 뒤에도 비어 있으면 repositoryHandler.gradlePluginPortal() 을 부릅니다. sharedRepositories 는 settings.gradle.kts 의 pluginManagement { repositories { … } } 에 적은 저장소입니다(DefaultPluginManagementSpec.getRepositories() 가 같은 저장소 목록을 돌려줍니다, 60–63행). 아무것도 적지 않았으면 Gradle 플러그인 포털 하나를 씁니다.

이 블로그의 build.gradle.kts 첫 줄이 kotlin("jvm") version "2.3.21" 입니다. kotlin(…) 은 Kotlin DSL 이 미리 만들어 둔 함수로 id("org.jetbrains.kotlin.$module") 을 부릅니다(GenerateKotlinDependencyExtensions.kt 120–121행). 그러니 이 줄은 org.gradle 이름공간이 아니어서 CorePluginResolver 를 지나치고, 포털의 settings.gradle.kts 에는 pluginManagement 가 없으므로 플러그인 포털에서 org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:2.3.21 마커를 찾는 길로 갑니다(소스를 따라 정리한 것이고, 포털 빌드의 로그로 확인한 것은 아닙니다. 포털은 래퍼로 Gradle 9.5.1 을 쓰므로 기준 소스와 판도 다릅니다).

예제 1: 해석이 실패할 때 보이는 사슬

사슬을 눈으로 보는 가장 쉬운 방법은 일부러 실패시키는 것입니다. 두 프로젝트를 만들었습니다.

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
plugins {
    id("java") version "9.10.0"   // 핵심 플러그인에 버전을 붙이면?
}

[예제 ex/u07-resolve-errors/core-with-version/build.gradle.kts]

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
plugins {
    id("lab.missing") version "1.0"   // 어디에도 없는 플러그인
}

[예제 ex/u07-resolve-errors/missing-plugin/build.gradle.kts]

이 예제는 기준 커밋(07ecbf9ead)을 그대로 빌드한 Gradle 9.10.0 개발판으로 도커 컨테이너(Eclipse Temurin JDK 25)에서 실행했습니다. run.sh 는 두 프로젝트에서 gradle help --offline --console=plain 을 돌리고 실패 메시지만 잘라 찍습니다.

=== core-with-version ===
* What went wrong:
Error resolving plugin [id: 'java', version: '9.10.0']
> Plugin 'java' is a core Gradle plugin, which cannot be specified with a version number. Such plugins are versioned as part of Gradle. Please remove the version number from the declaration.

* Try:
=== missing-plugin ===
* What went wrong:
Plugin [id: 'lab.missing', version: '1.0'] was not found in any of the following sources:

- Gradle Core Plugins (plugin is not in 'org.gradle' namespace)
- Included Builds (No included builds contain this plugin)
- Plugin Repositories (could not resolve plugin artifact 'lab.missing:lab.missing.gradle.plugin:1.0')
  Searched in the following repositories:
    Gradle Central Plugin Repository

* Try:

헷갈리기 쉬운 곳

3. id 에서 클래스로: META-INF/gradle-plugins

해석기가 "찾았다"고 할 때 실제로 한 일은 id 를 클래스로 바꾸기입니다. 그 규칙은 놀랄 만큼 단순합니다. 클래스패스에서 파일 하나를 찾습니다.

    @Override
    public PluginDescriptor findPluginDescriptor(String pluginId) {
        URL resource = classLoader.getResource("META-INF/gradle-plugins/" + pluginId + ".properties");
        if (resource == null) {
            return null;
        } else {
            return new PluginDescriptor(resource);
        }
    }

subprojects/core/src/main/java/org/gradle/api/internal/plugins/ClassloaderBackedPluginDescriptorLocator.java 29–37행

이 .properties 파일을 플러그인 설명 파일(plugin descriptor)이라고 부릅니다. 안에는 implementation-class 한 줄만 읽습니다(PluginDescriptor.java 32–35행). java 플러그인의 설명 파일은 이렇습니다.

implementation-class=org.gradle.api.plugins.JavaPlugin

platforms/jvm/plugins-java/src/main/resources/META-INF/gradle-plugins/org.gradle.java.properties

DefaultPluginRegistry.lookup() 이 이 파일로 클래스를 찾습니다. 이름공간 없는 id 를 org.gradle 로 채우는 곳도 여기입니다.

    @Nullable
    private PluginImplementation<?> lookup(PluginId pluginId, ClassLoader classLoader) {
        // Don't go up the parent chain.
        // Don't want to risk classes crossing "scope" boundaries and being non collectible.

        if (pluginId.getNamespace() == null) {
            PluginId qualified = pluginId.withNamespace(DefaultPluginManager.CORE_PLUGIN_NAMESPACE);
            Optional<PluginImplementation<?>> lookup = uncheckedGet(idMappings, new PluginIdLookupCacheKey(qualified, classLoader));
            if (lookup.isPresent()) {
                return lookup.get();
            }
        }

        return uncheckedGet(idMappings, new PluginIdLookupCacheKey(pluginId, classLoader)).orElse(null);
    }

subprojects/core/src/main/java/org/gradle/api/internal/plugins/DefaultPluginRegistry.java 170–184행

idMappings 는 Guava LoadingCache 입니다. 처음 묻는 (id, 클래스로더) 쌍이면 55–86행의 로더가 돌아 설명 파일을 찾고, implementation-class 를 loadClass() 하고, PluginInspector.inspect() 로 "이 클래스가 Plugin 을 구현했나, 규칙 기반(rule source)인가"를 살펴 PluginImplementation 으로 묶습니다. 두 번째부터는 캐시에서 꺼냅니다.

플러그인 id를 클래스로 바꾸는 길. id("java") 또는 Kotlin의 java는 id("org.gradle.java")다. DefaultPluginRegistry.lookup()이 이름공간이 없으면 org.gradle.을 붙여 먼저 찾는다. ClassloaderBackedPluginDescriptorLocator가 getResource("META-INF/gradle-plugins/org.gradle.java.properties")를 찾는다. 있으면 PluginDescriptor의 implementation-class=org.gradle.api.plugins.JavaPlugin을 읽고, classLoader.loadClass() 뒤 PluginInspector.inspect()를 거쳐 클래스와 id를 묶은 PluginImplementation이 된다. 설명 파일이 없으면 null을 돌려주고 다음 해석기로 넘어간다. 노란 상자가 클래스패스 안의 파일입니다. id → 클래스의 대응표는 어디에도 모여 있지 않고, jar 마다 설명 파일로 흩어져 있습니다.

왜 이렇게 했을까요. 대응표를 한 곳에 두면 플러그인을 하나 만들 때마다 그 표를 고쳐야 합니다. 설명 파일을 jar 안에 두면 jar 를 클래스패스에 올리는 것만으로 id 가 생깁니다. Java 의 ServiceLoader(META-INF/services/…)와 같은 생각입니다. 실제로 Gradle 안에서도 ServiceLoader 방식을 같이 씁니다(7절의 자동 적용 플러그인).

내 플러그인의 설명 파일은 누가 만들까요. java-gradle-plugin 플러그인의 pluginDescriptors 태스크입니다. gradlePlugin { plugins { create(…) { id = …; implementationClass = … } } } 에 적은 id 마다 <id>.properties 파일을 만들고 implementation-class 한 줄을 씁니다(GeneratePluginDescriptors.java 95–102행). 4절 예제의 buildSrc jar 를 열어 보면 그 결과가 있습니다(ex/u07-plugin-apply/run.sh 의 마지막 부분).

=== plugin descriptor inside the buildSrc jar ===
lab/
lab/HelloExtension.class
lab/HelloPlugin.class
META-INF/gradle-plugins/
META-INF/gradle-plugins/lab.hello.properties
implementation-class=lab.HelloPlugin

Kotlin DSL 의 java 는 무엇인가

plugins { java } 의 java 는 Kotlin 키워드도 마법도 아닙니다. PluginDependenciesSpec 의 확장 프로퍼티입니다. Gradle 을 빌드할 때 build-logic 의 코드 생성기가 Gradle 의 jar 들을 훑어 설명 파일마다 프로퍼티를 하나씩 만들고, 이름은 id 에서 org.gradle. 을 뗀 것으로 짓습니다(PluginIdExtensionsFacade.kt 106–113행, 만드는 코드의 틀은 67–75행). 생성된 코드는 배포판의 lib/gradle-kotlin-dsl-extensions-9.10.0.jar 안에 소스 째로 들어 있습니다. 같은 예제의 run.sh 가 그 부분을 꺼냅니다.

=== Kotlin DSL accessor for 'java' inside the distribution ===
inline val org.gradle.plugin.use.PluginDependenciesSpec.`java`: org.gradle.plugin.use.PluginDependencySpec
    get() = id("org.gradle.java")

그러니 plugins { java } 는 정확히 plugins { id("org.gradle.java") } 입니다. `java-library`, application 도 같은 식으로 생깁니다.

4. 적용: DefaultPluginManager 가 apply() 를 부르기까지

해석이 끝나고 1절 4단계의 ApplyAction.apply() 가 불리면, 해석 결과(PluginResolution)마다 applyTo(pluginManager) 를 부릅니다. 핵심 플러그인(SimplePluginResolution)도 buildSrc 플러그인(ClassPathPluginResolution)도 한 줄입니다 — pluginManager.apply(plugin)(ClassPathPluginResolution.java 47–50행). 저장소에서 온 것(ExternalPluginResolution)은 id 문자열로 pluginManager.apply(id) 를 부릅니다(149–156행). 그 사이에 클래스로더가 확정되었으니 이제는 id 로 찾을 수 있습니다.

project.pluginManager 의 실체가 DefaultPluginManager 입니다. 프로젝트마다 하나씩, 프로젝트 스코프 서비스로 만들어집니다(ProjectScopeServices.java 215–249행 — 서비스 스코프는 4편). 빌드 스크립트 본문의 apply(plugin = "…"), 플러그인 안의 project.getPluginManager().apply(…), plugins {} 블록이 모두 이 객체의 apply() 로 모입니다. apply 메서드는 셋(apply(String), apply(Class), apply(PluginImplementation), 144–161행)이고, project.plugins.apply(…)(PluginContainer)가 쓰는 addImperativePlugin()(95–105행)까지 모두 doApply() 를 부릅니다.

    private void doApply(final PluginImplementation<?> plugin) {
        PluginId pluginId = plugin.getPluginId();
        final String pluginIdStr = pluginId == null ? null : pluginId.toString();
        final Class<?> pluginClass = plugin.asClass();
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        try {
            Thread.currentThread().setContextClassLoader(pluginClass.getClassLoader());
            if (plugin.getType().equals(PotentialPlugin.Type.UNKNOWN)) {
                throw new InvalidPluginException("'" + pluginClass.getName() + "' is neither a plugin or a rule source and cannot be applied.");
            } else {
                final Runnable adder = addPluginInternal(plugin);
                if (adder != null) {
                    UserCodeApplicationContext.Target appTarget = getApplicationTargetFor(target);
                    UserCodeSource source = new UserCodeSource.Binary(plugin.getDisplayName(), pluginClass.getName(), pluginIdStr);
                    userCodeApplicationContext.apply(source, appTarget, userCodeApplicationId ->
                        buildOperationRunner.run(new AddPluginBuildOperation(adder, plugin, pluginIdStr, pluginClass, userCodeApplicationId))
                    );
                }
            }
        } // ... catch 두 개(예외를 PluginApplicationException 으로 감싸기), finally(컨텍스트 클래스로더 되돌리기) ...
    }

subprojects/core/src/main/java/org/gradle/api/internal/plugins/DefaultPluginManager.java 163–189행

읽을 것이 넷입니다.

같은 플러그인을 두 번 막는 장치는 짧습니다.

    @Nullable // if the plugin has already been added
    private Runnable addPluginInternal(final PluginImplementation<?> plugin) {
        final Class<?> pluginClass = plugin.asClass();
        if (plugins.containsKey(pluginClass)) {
            return null;
        }

        plugins.put(pluginClass, plugin);
        return new Runnable() {
            @Override
            public void run() {
                appliedPlugins.add(plugin);
                // ... withPlugin 을 기다리는 id 목록에 이 플러그인을 더한다 (5절) ...
            }
        };
    }

subprojects/core/src/main/java/org/gradle/api/internal/plugins/DefaultPluginManager.java 107–128행

열쇠는 id 가 아니라 클래스입니다. apply(plugin = "java"), apply(plugin = "org.gradle.java"), apply<JavaPlugin>() 은 모두 같은 JavaPlugin.class 라서 한 번만 적용됩니다. 그리고 plugins 맵에는 적용을 시작할 때 넣고, 돌려준 Runnable(adder)은 적용이 끝난 뒤에 돌립니다. 이 시간차가 5절의 이야기입니다.

빌드 오퍼레이션 안에서 불리는 addPlugin() 이 진짜 일을 합니다.

    private void addPlugin(Runnable adder, PluginImplementation<?> plugin, String pluginId, Class<?> pluginClass) {
        boolean imperative = plugin.isImperative();
        if (imperative) {
            Plugin<?> pluginInstance = producePluginInstance(pluginClass);

            if (plugin.isHasRules()) {
                target.applyImperativeRulesHybrid(pluginId, pluginInstance, pluginClass);
            } else {
                target.applyImperative(pluginId, pluginInstance);
            }

            // Important not to add until after it has been applied as there can be
            // plugins.withType() callbacks waiting to build on what the plugin did
            instances.put(pluginClass, pluginInstance);
            pluginContainer.pluginAdded(pluginInstance);
        } else {
            target.applyRules(pluginId, pluginClass);
        }

        adder.run();
    }

subprojects/core/src/main/java/org/gradle/api/internal/plugins/DefaultPluginManager.java 204–224행

"imperative"(명령형)는 Plugin 을 구현한 보통의 플러그인입니다. 그렇지 않은 "rules" 쪽은 옛 소프트웨어 모델의 규칙 기반 플러그인이라 이 글에서는 넘어갑니다.

① 인스턴스 만들기. producePluginInstance() 는 결국 PluginInstantiator.newInstance() 를 부릅니다.

    @Override
    public <T> T newInstance(Class<? extends T> type, @Nullable Object... parameters) throws ObjectInstantiationException {
        // If the plugin type is abstract, it likely has abstract methods that should be implemented
        // during class generation. In this case, we should use the decorated instantiator to create
        // the plugin instance.  Otherwise, we should use the injected instantiator to create the plugin
        // since some existing plugin types are known to be final.
        if (Modifier.isAbstract(type.getModifiers())) {
            return decoratedInstantiator.newInstance(type, parameters);
        } else {
            return injectedInstantiator.newInstance(type, parameters);
        }
    }

subprojects/core/src/main/java/org/gradle/api/internal/plugins/PluginInstantiator.java 41–52행

플러그인 클래스가 abstract 면 4편에서 본 클래스 생성기가 …_Decorated 하위 클래스를 만들어 @Inject 게터 같은 추상 메서드를 채웁니다. abstract 가 아니면 생성자 주입만 하는 인스턴스화기를 씁니다. 두 경우 모두 생성자의 @Inject 매개변수는 프로젝트 스코프 서비스에서 채웁니다(ProjectScopeServices 240–241행의 withServices(projectScopeServiceRegistry)). JavaBasePlugin 의 생성자가 JvmPluginServices 와 PropertyFactory 를 받는 것이 이 덕분입니다(JavaBasePlugin.java 153–158행).

② apply() 부르기. target 은 프로젝트라면 RuleBasedPluginTarget 이 감싼 ImperativeOnlyPluginTarget 입니다(ProjectScopeServices 230–235행). 그 applyImperative() 가 하는 일은 플러그인을 Plugin<T> 로 캐스팅해 cast.apply(target) 을 부르는 것뿐입니다(ImperativeOnlyPluginTarget.java 48–58행). 드디어 우리가 쓴 apply(Project) 입니다. Plugin<Settings> 를 빌드 스크립트에 걸면 여기서 ClassCastException 이 나고, maybeThrowOnTargetMismatch() 가 제네릭 타입 인자를 읽어 "The plugin must be applied in a settings script (or to the Settings object), but was applied in a build script (or to the Project object)" 같은 친절한 문장으로 바꿉니다(이 문장은 소스의 틀(71행)에 값을 채워 본 것이고 실행해 확인하지는 않았습니다. 60–78행, 문장 조각은 PluginTargetType.java 28–30행).

③ 알리기. apply() 가 돌아오면 pluginContainer.pluginAdded() 로 project.plugins 컬렉션에 인스턴스를 넣고(이때 plugins.withType(…) 콜백이 불립니다), 마지막으로 adder.run()(이때 withPlugin(…) 콜백이 불립니다). 주석이 말하듯 "적용이 끝난 뒤에야 더한다" — 콜백은 플러그인이 해 놓은 일 위에 무언가를 쌓으려는 것이기 때문입니다.

플러그인 하나를 적용하는 호출 순서. ① RequestApplicator가 DefaultPluginManager.apply(plugin)를 부른다. ② addPluginInternal()이 이미 있으면 끝낸다. ③ BuildOperationRunner로 Apply plugin lab.hello 오퍼레이션을 열고 그 안에서 addPlugin()이 돈다. ④ PluginInstantiator.newInstance(HelloPlugin)가 HelloPlugin_Decorated를 돌려준다. ⑤ PluginTarget.applyImperative()가 ⑥ HelloPlugin.apply(project)를 부르고, 플러그인은 확장·태스크·콜백을 등록하고 돌아온다. ⑦ pluginAdded() 뒤 adder.run()이 돌아 withType 콜백, 그다음 withPlugin 콜백이 불린다. 번호는 예제 2의 스택에서 아래에서 위로 읽는 순서와 같습니다.

예제 2: buildSrc 플러그인의 apply() 안에서 호출 경로 보기

buildSrc 에 java-gradle-plugin 으로 Java 플러그인 하나를 만들었습니다. buildSrc 의 빌드 스크립트입니다.

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
plugins {
    `java-gradle-plugin`
}

// java-gradle-plugin 이 이 선언으로 META-INF/gradle-plugins/lab.hello.properties 를 만든다
gradlePlugin {
    plugins {
        create("hello") {
            id = "lab.hello"
            implementationClass = "lab.HelloPlugin"
        }
    }
}

[예제 ex/u07-plugin-apply/project/buildSrc/build.gradle.kts]

HelloExtension.java 는 public abstract Property<String> getMessage(); 하나만 가진 추상 클래스입니다(구현은 Gradle 이 만들어 줍니다, 6절). 플러그인 본체입니다.

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
package lab;

import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.plugins.JavaBasePlugin;

public abstract class HelloPlugin implements Plugin<Project> {

    @Override
    public void apply(Project project) {
        System.out.println("[apply] HelloPlugin.apply(" + project.getPath() + "), class = " + getClass().getName());
        System.out.println("[apply] hasPlugin(\"lab.hello\") = " + project.getPluginManager().hasPlugin("lab.hello"));
        printCallers();

        // 1) 확장: 빌드 스크립트에서 hello { message = "..." } 로 설정한다
        HelloExtension hello = project.getExtensions().create("hello", HelloExtension.class);
        hello.getMessage().convention("Hello from lab.hello");

        // 2) 태스크: 등록만 해 둔다 (8편)
        project.getTasks().register("hello", task -> {
            task.setGroup("lab");
            task.doLast(t -> System.out.println("[task] " + hello.getMessage().get()));
        });

        // 3) 다른 플러그인에 반응하기: 지금 java 가 없어도, 나중에 걸리면 그때 불린다
        project.getPluginManager().withPlugin("java-base", p ->
            System.out.println("[withPlugin java-base] fired, hasPlugin(\"java\") = "
                + project.getPluginManager().hasPlugin("java")));
        project.getPlugins().withType(JavaBasePlugin.class, p ->
            System.out.println("[withType JavaBasePlugin] fired"));
        project.getPluginManager().withPlugin("java", p ->
            System.out.println("[withPlugin java] fired, id = " + p.getId()));

        System.out.println("[apply] HelloPlugin.apply() done");
    }

    // ... printCallers(): 스택에서 플러그인·스크립트에 관계된 프레임만 찍는다 ...
}

[예제 ex/u07-plugin-apply/project/buildSrc/src/main/java/lab/HelloPlugin.java에서 printCallers() 본문만 줄임]

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
plugins {
    id("lab.hello")   // buildSrc 에서 만든 플러그인
    java              // = id("org.gradle.java")
}

println("[script] body starts")

// 플러그인이 만든 확장을 설정한다
hello {
    message = "Hi from build.gradle.kts"
}
println("[script] hello extension class = " + hello.javaClass.name)

// 같은 플러그인을 한 번 더 걸어 본다 -> 아무 일도 일어나지 않는다
apply(plugin = "lab.hello")

println("[script] plugins applied to this project:")
plugins.forEach { println("    " + it.javaClass.name) }
println("[script] extensions = " + extensions.extensionsSchema.elements.map { it.name })

[예제 ex/u07-plugin-apply/project/build.gradle.kts]

run.sh 는 gradle hello --offline --console=plain 을 돌립니다. 출력의 앞부분입니다(맨 앞의 구분 줄 === gradle hello === 와 Welcome to Gradle … 안내 줄은 뺐고, 스택은 printCallers() 자신과 컴파일러가 만든 다리 메서드 줄 둘을 빼고 가운데를 ... 로 줄였습니다).

Starting a Gradle Daemon (subsequent builds will be faster)
Shared read-only dependency cache is an incubating feature.
> Task :buildSrc:compileJava
> Task :buildSrc:compileGroovy NO-SOURCE
> Task :buildSrc:pluginDescriptors
> Task :buildSrc:processResources
> Task :buildSrc:classes
> Task :buildSrc:jar

> Configure project :
[apply] HelloPlugin.apply(:), class = lab.HelloPlugin_Decorated
[apply] hasPlugin("lab.hello") = true
[apply] called through:
    at lab.HelloPlugin.apply:14
    at org.gradle.api.internal.plugins.ImperativeOnlyPluginTarget.applyImperative:53
    at org.gradle.api.internal.plugins.RuleBasedPluginTarget.applyImperative:52
    at org.gradle.api.internal.plugins.DefaultPluginManager.addPlugin:212
    at org.gradle.api.internal.plugins.DefaultPluginManager.access$300:56
    at org.gradle.api.internal.plugins.DefaultPluginManager$AddPluginBuildOperation.run:309
    at org.gradle.api.internal.plugins.DefaultPluginManager.lambda$doApply$0:178
    at org.gradle.api.internal.plugins.DefaultPluginManager.doApply:177
    at org.gradle.api.internal.plugins.DefaultPluginManager.apply:146
    at org.gradle.plugin.use.resolve.internal.ClassPathPluginResolution.applyTo:49
    at org.gradle.plugin.use.internal.DefaultPluginRequestApplicator$ApplyAction.apply:161
    at org.gradle.plugin.use.internal.DefaultPluginRequestApplicator.lambda$applyPlugins$1:135
    at org.gradle.plugin.use.internal.DefaultPluginRequestApplicator.applyPlugins:135
    at org.gradle.kotlin.dsl.provider.PluginRequestsHandler.handle:45
    at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator$InterpreterHost.applyPluginsTo:263
    at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator.evaluate:141
    ...
    at org.gradle.configuration.project.BuildScriptProcessor.execute:46
    at org.gradle.configuration.project.ConfigureActionsProjectEvaluator.evaluate:35
    at org.gradle.configuration.project.LifecycleProjectEvaluator$EvaluateProject.lambda$run$0:109
    at org.gradle.configuration.project.LifecycleProjectEvaluator$EvaluateProject.run:100
    at org.gradle.configuration.project.LifecycleProjectEvaluator.evaluate:72
    at org.gradle.execution.TaskPathProjectEvaluator.configureHierarchy:77
[apply] HelloPlugin.apply() done

Shared read-only dependency cache is an incubating feature. 는 예제 이미지가 미리 받아 둔 라이브러리를 읽기 전용 캐시(GRADLE_RO_DEP_CACHE)로 쓰기 때문에 찍히는 줄입니다. 빌드 결과와는 관계없습니다.

헷갈리기 쉬운 곳

5. withPlugin: 순서에 기대지 않고 반응하기

예제 2의 HelloPlugin 은 java 보다 먼저 적용되었습니다(plugins {} 에 먼저 적었으니까요). 그때는 java 가 없었으므로 if (hasPlugin("java")) 로 물었다면 false 였을 것입니다. 플러그인 순서는 사용자가 정하니, 플러그인은 순서에 기대면 안 됩니다. 그래서 withPlugin(id, action) 이 있습니다. "지금 있으면 바로, 없으면 나중에 걸릴 때" 부르는 것입니다. 구현은 액션을 한 번 감싸 pluginsForId(id).all(…) 에 넘기는 것이 전부입니다(DefaultPluginManager.java 280–289행).

pluginsForId(id) 는 id 마다 하나씩 만드는 DomainObjectSet 이고, all(action) 은 "지금 들어 있는 것과 앞으로 들어올 것 모두에" 액션을 거는 도메인 객체 컬렉션의 메서드입니다(configureEach 와 같은 원리 — 8편). 그 집합에 무엇이 언제 들어가는지가 핵심입니다.

    // Plugins whose application has started, including those still being applied
    private final Map<Class<?>, PluginImplementation<?>> plugins = new LinkedHashMap<>();
    // Plugins whose application has completed; withPlugin() callbacks only see these
    private final List<PluginImplementation<?>> appliedPlugins = new ArrayList<>();

subprojects/core/src/main/java/org/gradle/api/internal/plugins/DefaultPluginManager.java 65–68행

목록이 둘입니다. plugins 는 적용을 시작한 것, appliedPlugins 는 적용이 끝난 것입니다. pluginsForId() 는 처음 만들 때 appliedPlugins 만 훑고(254–258행), 그 뒤로는 4절의 adder 가 적용이 끝날 때마다 채웁니다. 반면 hasPlugin()(findPlugin(), 264–278행)은 plugins 를 봅니다. 정리하면 이렇습니다.

묻는 것 보는 목록 적용 중인 플러그인
pluginManager.hasPlugin(id) plugins (시작한 것) true
pluginManager.withPlugin(id) { } appliedPlugins → adder 끝난 뒤에 불림
project.plugins.withType(T) { } pluginContainer pluginAdded() 때 불림(끝난 뒤, adder 직전)
project.plugins.hasPlugin(id) pluginContainer(와 pluginsForId) false

마지막 줄은 PluginManager.hasPlugin() 의 Javadoc 이 직접 밝힌 차이입니다("In contrast, withPlugin and PluginContainer#hasPlugin only consider a plugin once its application has completed", PluginManager.java 82–84행).

예제 2의 HelloPlugin 이 건 콜백 셋이 언제 불렸는지 봅니다. 위 출력의 바로 다음 줄들입니다.

[apply] HelloPlugin.apply() done
[withType JavaBasePlugin] fired
[withPlugin java-base] fired, hasPlugin("java") = true
[withPlugin java] fired, id = java
[script] body starts

java 를 적용하는 동안 무슨 일이 일어났는지 그림으로 펼치면 이렇습니다. JavaPlugin.apply() 의 첫 일은 JavaBasePlugin 을 거는 것입니다(7절).

java를 적용할 때 콜백이 불리는 순서. ① plugins 블록이 DefaultPluginManager.apply(java)를 부르고, 관리자는 plugins 맵에 JavaPlugin을 넣은 뒤 JavaPlugin.apply(project)를 부른다. ② JavaPlugin이 apply(JavaBasePlugin.class)를 부르고 관리자가 JavaBasePlugin.apply(project)를 부른다. JavaBasePlugin이 끝나면 ③ withType JavaBasePlugin 콜백, ④ withPlugin java-base 콜백이 lab.hello의 콜백으로 불린다. 이때 hasPlugin java는 이미 true다. 관리자가 JavaPlugin으로 돌아가고 ⑤ JavaPlugin.apply가 끝나면 ⑥ withPlugin java 콜백이 불린다. withPlugin("java-base") 는 java 가 아직 적용 중일 때 불립니다. 그 순간 hasPlugin("java") 는 true 지만 withPlugin("java") 는 아직입니다.

헷갈리기 쉬운 곳

6. 확장: hello { } 블록은 어디서 오나

HelloPlugin 은 project.getExtensions().create("hello", HelloExtension.class) 로 확장을 붙였고, 본문에서 hello { message = "…" } 로 설정했습니다. DefaultExtensionContainer.create() 는 인스턴스를 만들고(instantiate()), 그 클래스의 공개 타입과 함께 이름으로 저장할 뿐입니다(DefaultExtensionContainer.java 82–87행).

instantiate() 는 플러그인과 같은 클래스 생성기를 씁니다(174–175행의 instanceGenerator). 그래서 HelloExtension 의 추상 메서드 getMessage() 가 Property<String> 을 돌려주도록 채워집니다. 예제의 출력이 그것을 보여 줍니다.

[script] hello extension class = lab.HelloExtension_Decorated

담는 곳은 ExtensionsStorage 의 LinkedHashMap 하나입니다.

    private final Map<String, ExtensionHolder<?>> extensions = new LinkedHashMap<>();

    public <T> void add(TypeOf<T> publicType, String name, T extension) {
        if (hasExtension(name)) {
            throw new IllegalArgumentException(
                format("Cannot add extension with name '%s', as there is an extension already registered with that name.", name));
        }
        extensions.put(name, new ExtensionHolder<>(name, publicType, extension));
    }

platforms/core-configuration/model-core/src/main/java/org/gradle/internal/extensibility/ExtensionsStorage.java 35–43행

이름이 열쇠이고, 값과 함께 공개 타입(public type)을 적어 둡니다. getByType(JavaPluginExtension.class) 처럼 타입으로 찾을 때는 공개 타입이 정확히 같은 것을 먼저, 없으면 대입 가능한 것을 찾습니다(93–118행). JavaBasePlugin 이 create(JavaPluginExtension.class, "java", DefaultJavaPluginExtension.class, …) 로 공개 타입을 따로 주는 것은 사용자에게는 인터페이스만 보이게 하려는 것입니다(JavaBasePlugin.java 183–187행).

예제 프로젝트에 붙은 확장의 목록은 이렇습니다.

[script] extensions = [ext, versionCatalogs, hello, base, sourceSets, reporting, javaToolchains, java, testing]

맨 앞 ext 는 DefaultExtensionContainer 의 생성자가 스스로 붙이는 "extra properties" 입니다(49–52행). hello 다음은 java 를 걸면서 생긴 것들이고, 누가 붙였는지는 7절의 그림에 적었습니다. 맵이 LinkedHashMap 이라 붙인 순서 그대로입니다.

확장 hello 가 붙고 쓰이기까지. HelloPlugin.apply()가 project.getExtensions().create("hello", HelloExtension.class)를 부르면, DefaultExtensionContainer.create()가 instantiate()로 클래스 생성기를 써서 HelloExtension_Decorated 인스턴스를 만들고, _Decorated 를 벗긴 HelloExtension 을 공개 타입으로 삼아 add(공개 타입, 이름, 값)으로 넘긴다. 같은 이름이 이미 있으면 예외다. ExtensionsStorage 는 이름을 열쇠로 하는 LinkedHashMap 이라 붙인 순서가 그대로 남는다. 예제 프로젝트의 실제 목록은 ext(생성자가 스스로 붙임, 공개 타입 ExtraPropertiesExtension), versionCatalogs, hello(공개 타입 HelloExtension, 값 HelloExtension_Decorated), 그리고 java 를 걸면서 붙은 base, sourceSets, reporting, javaToolchains, java(공개 타입 JavaPluginExtension), testing 이다. Kotlin DSL 은 plugins {} 적용 뒤의 이 확장 목록으로 hello 접근자를 만들어 본문을 컴파일하고, 그래서 본문에서 hello { message = … }를 쓸 수 있다. 확장은 이름과 공개 타입을 달고 맵에 들어갑니다. 목록은 예제의 실제 출력 순서 그대로입니다.

본문에서 hello { } 를 그냥 쓸 수 있는 이유는 Kotlin DSL 이 plugins {} 적용이 끝난 뒤의 확장 목록(extensionsSchema)을 보고 hello 라는 타입 안전 접근자를 만들어 본문을 컴파일하기 때문입니다. 접근자 생성은 6편에서 봤습니다. plugins {} 대신 본문에서 apply(plugin = "lab.hello") 로만 걸었다면 컴파일 시점에는 hello 가 없으니 the<HelloExtension>() 이나 configure<HelloExtension> { } 로 써야 합니다 — plugins {} 블록을 권하는 큰 이유 하나가 이것입니다.

헷갈리기 쉬운 곳

7. 실례: java 한 줄이 플러그인 아홉 개가 되기까지

예제 2 의 plugins 목록입니다.

[script] plugins applied to this project:
    org.gradle.api.plugins.HelpTasksPlugin_Decorated
    org.gradle.api.plugins.SoftwareReportingTasksPlugin_Decorated
    org.gradle.buildinit.plugins.BuildInitPlugin_Decorated
    org.gradle.buildinit.plugins.WrapperPlugin_Decorated
    lab.HelloPlugin_Decorated
    org.gradle.language.base.plugins.LifecycleBasePlugin_Decorated
    org.gradle.api.plugins.BasePlugin_Decorated
    org.gradle.api.plugins.JvmEcosystemPlugin_Decorated
    org.gradle.api.plugins.ReportingBasePlugin_Decorated
    org.gradle.api.plugins.JvmToolchainsPlugin_Decorated
    org.gradle.api.plugins.JavaBasePlugin_Decorated
    org.gradle.testing.base.plugins.TestSuiteBasePlugin_Decorated
    org.gradle.api.plugins.JvmTestSuitePlugin_Decorated
    org.gradle.api.plugins.JavaPlugin_Decorated
    org.gradle.kotlin.dsl.provider.plugins.KotlinScriptBasePlugin_Decorated

내가 건 것은 lab.hello 와 java 둘인데 열다섯 개입니다. 세 무리로 나뉩니다.

맨 위 넷은 누구도 걸지 않았는데 걸려 있습니다. gradle tasks, gradle help, gradle dependencies, gradle init, gradle wrapper 같은 태스크가 어느 프로젝트에나 있는 이유입니다. 6편의 평가기 사슬에서 빌드 스크립트(BuildScriptProcessor)보다 앞에 도는 PluginsProjectConfigureActions 가 겁니다(BuildModelControllerServices.kt 119–123행). 이것은 META-INF/services/org.gradle.configuration.project.ProjectConfigureAction 파일로 등록된 액션들을 ServiceLocator 로 모아 차례로 부르고(PluginsProjectConfigureActions.java 27–49행), 그 가운데 HelpTasksAutoApplyAction 은 project.getPluginManager().apply("org.gradle.help-tasks") 한 줄입니다(HelpTasksAutoApplyAction.java 22–27행). 3절의 설명 파일과 같은 "jar 안의 파일로 등록" 방식입니다.

맨 아래 KotlinScriptBasePlugin 은 Kotlin DSL 이 겁니다. plugins {} 적용이 끝난 뒤 applyBasePluginsTo() 가 부릅니다(DefaultKotlinScriptBasePluginsApplicator.kt 26–28행).

가운데 아홉은 java 하나가 건 것입니다. JavaPlugin.apply() 의 처음입니다.

    @Override
    public void apply(final Project project) {
        if (project.getPluginManager().hasPlugin("java-platform")) {
            throw new IllegalStateException("The \"java\" or \"java-library\" plugin cannot be applied together with the \"java-platform\" plugin. " +
                "A project is either a platform or a library but cannot be both at the same time.");
        }
        final ProjectInternal projectInternal = (ProjectInternal) project;

        project.getPluginManager().apply(JavaBasePlugin.class);
        project.getPluginManager().apply("org.gradle.jvm-test-suite"); // TODO: change to reference plugin class by name after project dependency cycles untangled; this will affect ApplyPluginBuildOperationIntegrationTest (will have to remove id)

        SourceSetContainer sourceSets = project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets();
        // ... java 컴포넌트와 main 기능(소스 셋 main, jar 태스크), 기본 test 스위트 ...
    }

platforms/jvm/plugins-java/src/main/java/org/gradle/api/plugins/JavaPlugin.java 264–298행

플러그인 안에서 다른 플러그인을 거는 방법은 사용자와 똑같이 pluginManager.apply() 입니다. 그러니 4절의 장치(두 번 막기, 빌드 오퍼레이션, 콜백)가 안쪽 플러그인에도 그대로 적용됩니다. JavaBasePlugin.apply() 도 같은 모양입니다.

    @Override
    public void apply(final Project project) {
        project.getPluginManager().apply(BasePlugin.class);
        project.getPluginManager().apply(JvmEcosystemPlugin.class);
        project.getPluginManager().apply(ReportingBasePlugin.class);
        project.getPluginManager().apply(JvmToolchainsPlugin.class);

        DefaultJavaPluginExtension javaPluginExtension = addExtensions(project);

        configureCompileDefaults(project, javaPluginExtension);
        configureSourceSetDefaults(project, javaPluginExtension);
        // ... javadoc, test, buildNeeded·buildDependents, 아카이브, JavaExec 기본값 ...
    }

platforms/jvm/plugins-java-base/src/main/java/org/gradle/api/plugins/JavaBasePlugin.java 163–181행

BasePlugin 은 다시 LifecycleBasePlugin 을 걸고 base 확장을 붙입니다(BasePlugin.java 62–70행). LifecycleBasePlugin 이 clean·assemble·check·build 를 등록합니다(LifecycleBasePlugin.java 73–80행). build 가 assemble 과 check 에 기대는 것도 여기서 정해집니다(115–121행).

java 플러그인이 거는 플러그인들. JavaPlugin(java, main 소스 셋 만들기, 기본 test 스위트, jar)이 ① apply(JavaBasePlugin.class)와 ② apply(org.gradle.jvm-test-suite)를 부른다. JavaBasePlugin(java-base, 확장 java, 소스 셋마다 compileJava 등록)은 BasePlugin(base, 확장 base, 아카이브 기본값), JvmEcosystemPlugin(확장 sourceSets), ReportingBasePlugin(확장 reporting), JvmToolchainsPlugin(확장 javaToolchains)을 건다. BasePlugin은 LifecycleBasePlugin(clean, assemble, check, build)을 건다. JvmTestSuitePlugin(jvm-test-suite)은 TestSuiteBasePlugin을 거쳐 확장 testing을 붙인다. 파란 상자가 이 절에서 인용한 플러그인, 회색은 이름과 하는 일만 적은 플러그인입니다. 6절의 확장 목록이 여기서 나왔습니다.

같은 예제를 빌드 오퍼레이션 기록(-Dorg.gradle.internal.operations.trace=/tmp/trace, 1편)과 함께 돌려 Apply plugin 오퍼레이션의 이름만 뽑으면 시작한 순서가 보입니다(run.sh 가 /tmp/trace-log.txt 에서 루트 프로젝트 것만 골랐습니다).

=== build operation trace: Apply plugin (root project) ===
"Apply plugin org.gradle.help-tasks to root project 'plugin-apply'"
"Apply plugin org.gradle.software-reporting-tasks to root project 'plugin-apply'"
"Apply plugin org.gradle.build-init to root project 'plugin-apply'"
"Apply plugin org.gradle.wrapper to root project 'plugin-apply'"
"Apply plugin lab.hello to root project 'plugin-apply'"
"Apply plugin org.gradle.java to root project 'plugin-apply'"
"Apply plugin org.gradle.api.plugins.JavaBasePlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.api.plugins.BasePlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.language.base.plugins.LifecycleBasePlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.api.plugins.JvmEcosystemPlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.api.plugins.ReportingBasePlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.api.plugins.JvmToolchainsPlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.jvm-test-suite to root project 'plugin-apply'"
"Apply plugin org.gradle.testing.base.plugins.TestSuiteBasePlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.kotlin.dsl.provider.plugins.KotlinScriptBasePlugin to root project 'plugin-apply'"

왜 이렇게 잘게 쪼갰을까요. JavaBasePlugin 과 JavaPlugin 이 하는 일을 비교하면 보입니다. JavaBasePlugin 은 "소스 셋이 생기면 그 소스 셋마다 compileJava 류 태스크를 등록한다"는 규칙만 정합니다.

    private void configureSourceSetDefaults(Project project, final JavaPluginExtension javaPluginExtension) {
        javaPluginExtension.getSourceSets().all(sourceSet -> {

            ConfigurationContainer configurations = project.getConfigurations();

            defineConfigurationsForSourceSet(sourceSet, (RoleBasedConfigurationContainerInternal) configurations);
            definePathsForSourceSet(sourceSet, project);

            createProcessResourcesTask(sourceSet, sourceSet.getResources(), project);
            TaskProvider<JavaCompile> compileTask = createCompileJavaTask(sourceSet, sourceSet.getJava(), project);
            createClassesTask(sourceSet, project);

            configureLibraryElements(compileTask, sourceSet, configurations);
            configureTargetPlatform(compileTask, sourceSet, configurations);
        });
    }

platforms/jvm/plugins-java-base/src/main/java/org/gradle/api/plugins/JavaBasePlugin.java 189–204행

소스 셋을 실제로 만드는 것은 JavaPlugin 입니다(sourceSets.create(SourceSet.MAIN_SOURCE_SET_NAME), JavaPlugin.java 301행). 그 순간 위의 all(…) 규칙이 불려 compileJava 가 등록됩니다. 즉 "능력(base 플러그인)"과 "관례(convention 플러그인)"를 나눈 것입니다. main·test 라는 관례가 필요 없는 빌드(예: Kotlin 이나 Groovy 플러그인, 소스 셋을 직접 만드는 빌드)는 java-base 만 걸고 규칙만 빌려 쓸 수 있습니다.

헷갈리기 쉬운 곳

8. buildSrc 와 precompiled script plugin

예제 2 의 lab.hello 는 저장소 없이 어떻게 해석되었을까요. 답은 루트 프로젝트를 설정하기 전에 있습니다.

    @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);
        // ... 버전 카탈로그 접근자 생성 ...
        // attaches root project
        buildLoader.load(gradle.getSettings(), gradle);
        // ... 포함된 빌드 치환 등록 ...

        // Build buildSrc and export classpath to root project
        buildBuildSrcAndLockClassloader(gradle, buildSrcClassLoaderScope);

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

    private void buildBuildSrcAndLockClassloader(GradleInternal gradle, ClassLoaderScope baseProjectClassLoaderScope) {
        ClassPath buildSrcClassPath = buildSourceBuilder.buildAndGetClassPath(gradle);
        baseProjectClassLoaderScope.export(buildSrcClassPath).lock();
    }

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

settings 스코프의 자식으로 buildSrc[:] 스코프를 만들고, 이것을 "프로젝트들의 바탕 스코프"로 정합니다. 그리고 BuildSourceBuilder.buildAndGetClassPath() 가 buildSrc 를 별도의 빌드로 돌려(Build buildSrc 빌드 오퍼레이션, BuildSourceBuilder.java 61–90행) 나온 jar 를 그 스코프에 export 하고 잠급니다. 루트 프로젝트의 스코프 root-project[:] 는 그 아래에 만들어지므로(InstantiatingBuildLoader.java 38–42행), 루트 build.gradle.kts 에서 보면 buildSrc jar 는 부모 스코프에 이미 있는 클래스패스입니다. 2절의 AlreadyOnClasspathPluginResolver 가 거기서 META-INF/gradle-plugins/lab.hello.properties 를 찾은 것입니다. 예제 출력에서 > Configure project : 보다 :buildSrc:jar 가 먼저 찍힌 것도 이 순서 때문입니다.

buildSrc 프로젝트는 아무것도 적지 않아도 java-library 와 groovy 플러그인, 그리고 gradleApi()·localGroovy() 의존성을 받습니다. GroovyBuildSrcProjectConfigurationAction 이 그 넷을 그대로 하는 네 줄짜리 액션입니다(27–35행). 예제 출력의 > Task :buildSrc:compileGroovy NO-SOURCE 가 그 흔적입니다. Groovy 소스가 없어도 groovy 플러그인이 걸려 있어서 태스크가 있습니다.

플러그인이 실리는 클래스로더 스코프의 나무. settings[:] 스코프(settings.gradle.kts의 plugins) 아래에 buildSrc[:] 스코프가 있다. buildSrc 빌드(Build buildSrc 오퍼레이션, compileJava → pluginDescriptors → jar)의 클래스패스가 이 스코프로 들어가고, BuildTreePreparingProjectsPreparer가 export한 뒤 lock한다. 그 아래 root-project[:] 스코프는 루트 build.gradle.kts의 plugins로 받은 jar를 export하고, 그 아래에 하위 프로젝트 project-app 스코프가 있다. AlreadyOnClasspathPluginResolver는 부모 스코프에서 설명 파일을 찾는다. 노란 마디가 buildSrc jar 가 실리는 곳입니다. 아래 마디는 위 마디의 클래스를 볼 수 있고, 반대는 안 됩니다.

이 나무는 Tomcat 4편의 클래스로더 계층과 닮았습니다. Tomcat 에서 공통 클래스로더 아래에 웹앱마다 클래스로더가 있어 웹앱끼리는 서로의 클래스를 못 보듯, Gradle 에서는 하위 프로젝트가 루트와 buildSrc 의 플러그인은 보지만 형제 프로젝트가 plugins {} 로 받은 jar 는 보지 못합니다. 다른 점은 방향입니다. Tomcat 웹앱 클래스로더는 기본으로 자기 것을 먼저 찾지만(child-first), 이 글에서 본 플러그인 해석은 부모 스코프에 이미 있으면 그것을 씁니다(AlreadyOnClasspathPluginResolver 가 사슬 맨 앞).

precompiled script plugin: 스크립트 파일 하나가 플러그인이 되기까지

buildSrc 에 kotlin-dsl 플러그인을 걸고 src/main/kotlin/lab.greeting.gradle.kts 같은 스크립트를 두면, 그 파일 하나가 id lab.greeting 인 플러그인이 됩니다. 이것을 precompiled script plugin 이라고 부릅니다. 규칙은 PrecompiledScriptPlugin 에 있습니다.

        val simplePluginAdapterClassName = fileNameWithoutScriptExtension
            .kebabCaseToPascalCase()
            .asJavaIdentifier() + "Plugin"
        // ...
            id = packagePrefixed(fileNameWithoutScriptExtension),
            implementationClass = packagePrefixed(simplePluginAdapterClassName),

platforms/core-configuration/kotlin-dsl-provider-plugins/src/main/kotlin/org/gradle/kotlin/dsl/provider/plugins/precompiled/PrecompiledScriptPlugin.kt 56–76행

파일 이름에서 .gradle.kts 를 뗀 것이 id, 그 이름을 Java 식별자로 바꾸고 Plugin 을 붙인 것이 어댑터 클래스입니다. 어댑터는 generateScriptPluginAdapters 태스크가 만드는 아주 짧은 Plugin 구현으로, apply() 에서 컴파일된 스크립트 클래스를 생성할 뿐입니다(GenerateScriptPluginAdapters.kt 89–100행). 그리고 gradlePlugin { plugins.create(id) { … } } 로 선언해(DefaultPrecompiledScriptPluginsSupport.kt 518–527행), 3절의 pluginDescriptors 가 설명 파일을 쓰게 합니다. 결국 손으로 쓴 플러그인과 똑같은 모양이 됩니다.

예제 3: lab.greeting.gradle.kts 는 무엇이 되나

buildSrc 의 빌드 스크립트는 plugins { `kotlin-dsl` } 와 repositories { gradlePluginPortal() } 뿐입니다(예제 ex/u07-precompiled/project/buildSrc/build.gradle.kts). 스크립트 플러그인과 그것을 거는 루트 빌드 스크립트입니다.

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// 파일 이름 lab.greeting.gradle.kts -> 플러그인 id "lab.greeting"
println("[lab.greeting] script class = " + javaClass.name)

tasks.register("greet") {
    doLast { println("[task] Hello from lab.greeting") }
}

[예제 ex/u07-precompiled/project/buildSrc/src/main/kotlin/lab.greeting.gradle.kts]

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
plugins {
    id("lab.greeting")   // buildSrc/src/main/kotlin/lab.greeting.gradle.kts
}

println("[script] plugin classes = " + plugins.map { it.javaClass.name }.filter { !it.startsWith("org.gradle") })

[예제 ex/u07-precompiled/project/build.gradle.kts]

gradle greet --offline --console=plain 뒤에 buildSrc jar 안을 들여다봅니다(앞머리의 안내 줄과 buildSrc 태스크 목록, BUILD SUCCESSFUL 이하 빌드 끝 줄은 ... 로 줄였습니다).

...
> Task :buildSrc:generateScriptPluginAdapters
> Task :buildSrc:pluginDescriptors
...
> Configure project :
[lab.greeting] script class = Lab_greeting_gradle
[script] plugin classes = [Lab_greetingPlugin]

> Task :greet
[task] Hello from lab.greeting
...
=== generated plugin adapter and descriptor ===
Lab_greetingPlugin.class
Lab_greeting_gradle$$$result$1$1.class
Lab_greeting_gradle$$$result$1.class
Lab_greeting_gradle.class
META-INF/gradle-plugins/
META-INF/gradle-plugins/lab.greeting.properties
implementation-class=Lab_greetingPlugin

run.sh 는 생성된 어댑터의 소스(Lab_greetingPlugin.kt)도 찍는데, 위에서 링크한 GenerateScriptPluginAdapters.kt 89–100행의 틀에 이름만 채운 모양입니다 — apply(target) 에서 Class.forName("Lab_greeting_gradle") 의 생성자를 (target, target) 으로 부를 뿐입니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

정리

개념 한 줄 요약
plugins {} 블록 본문보다 먼저 따로 실행되어 PluginRequestCollector 에 요청만 적는다. 같은 id 를 두 번 적으면 실패
applyPlugins() 모든 요청을 먼저 해석 → 의존성 더하기 → 클래스로더 export·lock → 그다음 요청 순서대로 적용
해석기 사슬 이미 클래스패스에 있나 → 핵심 플러그인 → TestKit → 포함된 빌드 → 저장소. 처음 찾은 것이 이긴다
핵심 플러그인 org.gradle 이름공간(또는 이름공간 없음). 버전·apply false 를 적으면 오류
마커 아티팩트 id:id.gradle.plugin:버전. pluginManagement 저장소, 없으면 플러그인 포털에서 찾는다
설명 파일 META-INF/gradle-plugins/<id>.properties 의 implementation-class. java-gradle-plugin 이 만든다
Kotlin 의 java Gradle 빌드 때 생성된 inline val PluginDependenciesSpec.java = id("org.gradle.java")
DefaultPluginManager 모든 적용이 모이는 곳. 클래스로 두 번 막고, Apply plugin … 빌드 오퍼레이션으로 감싼다
PluginInstantiator abstract 플러그인은 _Decorated 하위 클래스로, 아니면 생성자 주입만
hasPlugin / withPlugin hasPlugin 은 적용 중인 것도 true, withPlugin 은 끝난 뒤에 불린다(9.9.0-M2 부터)
콜백 순서 apply() 끝 → plugins.withType → pluginManager.withPlugin
확장 extensions.create() 가 생성된 클래스로 만들어 이름으로 LinkedHashMap 에. 이름이 겹치면 실패
java 사슬 JavaPlugin → JavaBasePlugin → BasePlugin → LifecycleBasePlugin 외. base 는 규칙, convention 은 관례
자동 적용 help-tasks·build-init·wrapper 등은 ProjectConfigureAction(ServiceLoader)이 모든 프로젝트에 건다
buildSrc 루트 프로젝트보다 먼저 별도 빌드로 돌아 buildSrc[:] 스코프에 실린다. 그래서 "이미 클래스패스에 있다"로 해석
precompiled script plugin a.b.gradle.kts → id a.b, 어댑터 A_bPlugin. 결국 설명 파일 + Plugin 클래스

다음에 볼 것

플러그인의 apply() 가 하는 일의 대부분은 태스크를 등록하는 것이었습니다. LifecycleBasePlugin 은 register("build"), JavaBasePlugin 은 소스 셋마다 register("compileJava"). 그런데 register() 는 태스크를 만들지 않습니다. 언제 만들어지는지, configureEach 는 어떻게 "나중에 생길 태스크"에도 걸리는지, 확장의 Property 를 태스크 입력에 이으면 왜 dependsOn 없이 순서가 생기는지를 8편: 태스크와 지연 설정 — DefaultTask, TaskContainer, Provider에서 봅니다. 5절의 withPlugin 이 기대던 DomainObjectSet.all() 의 원리도 거기서 풀립니다.

앞 연재에도 닮은 것이 있습니다. withPlugin 은 Spring 9편의 @Conditional 과 비슷한 질문("다른 무언가가 있으면 이렇게 설정한다")에 답하지만 방식이 다릅니다. Spring 의 조건은 빈 정의를 등록하는 순간 한 번 평가되고, Gradle 의 withPlugin 은 조건이 나중에 참이 되면 그때 불리는 반응형입니다. 플러그인이 다른 플러그인을 걸며 기능을 쌓는 모양은 httpd 6편의 모듈이 훅을 등록하는 모습과도 닮았습니다. httpd 모듈은 설정 파일의 LoadModule 로 들어오고, Gradle 플러그인은 설명 파일과 클래스로더로 들어온다는 점이 다릅니다.

다른 글