이 글에서 다루는 것
"Gradle 소스 파헤치기" 연재의 일곱 번째 글입니다. 6편에서는 build.gradle.kts 한 파일이 컴파일되어 실행되기까지를 봤습니다. Kotlin DSL 도 Groovy DSL 도 buildscript {}·plugins {} 블록을 떼어 내 먼저 실행하고, 본문은 그다음에 돈다는 것까지였습니다. 이번 글은 그 사이, 먼저 실행된 plugins {} 블록이 모은 요청이 실제 Plugin.apply() 호출이 되기까지를 따라갑니다.
2편의 지도(2편 지도 그림)로 돌아가면, 이 글은 "D 설정 단계" 구역의 가운데 상자 플러그인 적용(plugins {} → apply()) 을 확대합니다. 왼쪽 상자(빌드 스크립트 실행)는 6편이, 오른쪽 상자(태스크 등록·Provider)는 8편이 맡습니다.
답하려는 질문은 네 가지입니다.
plugins { java }와plugins { id("org.example.foo") version "1.0" }은 소스에서 어떤 길로 갈리는가. 플러그인을 어디서 찾는가- 플러그인 id(
java)는 어떻게 클래스(JavaPlugin)가 되는가 apply()는 누가, 어떤 순서로 부르는가. 같은 플러그인을 두 번 걸면 어떻게 되는가.withPlugin은 정확히 언제 불리는가java하나를 걸었는데 왜 플러그인이 아홉 개나 걸리고,java { }·sourceSets { }같은 블록이 생기는가
| 항목 | 내용 |
|---|---|
| 대상 | 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));
}
주석 네 개가 그대로 단계 이름입니다. 순서가 중요합니다.
- 해석(Resolve) — 요청을 하나도 빠짐없이 먼저 해석합니다. 이때는 "어디서 가져올지"와 "스크립트 클래스패스에 무엇을 더할지"만 정합니다.
apply false인 요청은 해석만 하고 적용 목록(pluginApplyActions)에는 넣지 않습니다. - 의존성 더하기(Configure the resolution) — 저장소에서 받아야 하는 플러그인은 마커 아티팩트 의존성을 스크립트 클래스패스(
buildscript { dependencies { classpath(…) } }와 같은 곳)에 더합니다. - 클래스로더 확정(Perform resolution & configure the classloader) —
getInstrumentedScriptClassPath()가 그 클래스패스를 실제로 해석해 jar 파일들을 얻고, 이 스크립트의 클래스로더 스코프에export한 뒤lock()합니다. 잠근 뒤에는 이 스코프에 jar 를 더 넣을 수 없습니다. - 적용(Apply) — 그제서야 요청 순서대로
ApplyAction.apply()를 부릅니다.
파란 상자가 이 글에서 읽는 구간입니다. ① 은 6편, ⑤ 의 본문 실행도 6편의 몫입니다.
왜 "다 해석하고 나서 적용"일까요. 소스 주석이 이유를 직접 적어 두지는 않았지만, 코드의 모양에서 읽을 수 있는 것이 둘 있습니다. 첫째, 모든 요청의 의존성을 스크립트 클래스패스 한 곳에 더한 뒤 한 번에 해석하므로, 플러그인 두 개가 같은 라이브러리의 다른 버전을 원해도 의존성 해석의 충돌 해결(12편)이 한 번에 정리합니다. 둘째, 적용(플러그인 클래스를 로드하고 apply() 를 부르는 것)은 클래스로더 스코프를 lock() 한 뒤에만 일어납니다. 플러그인 코드가 도는 동안 그 클래스로더의 클래스패스가 바뀌지 않는다는 약속을 순서로 지키는 셈입니다.
헷갈리기 쉬운 곳
plugins {}안에서는 아무 코드나 쓸 수 없습니다. 블록이 본문보다 먼저, 따로 실행되기 때문입니다(6편). 이 글의 관점에서 보면, 블록이 하는 일은PluginRequestCollector에 요청을 적는 것뿐입니다. 소스의 주석도 이 클래스를 "plugins {} 블록의 진짜 위임 대상(The real delegate of the plugins {} block)"이라고 부릅니다(PluginRequestCollector.java44–49행).- 같은 id 를 블록에 두 번 적으면 해석도 하기 전에 실패합니다. 요청을 모을 때 id 로 묶어 둘 이상이면
Plugin with id '…' was already requested at line N을 던집니다(PluginRequestCollector.java79–89행). 반면 본문에서apply(plugin = …)로 두 번 거는 것은 조용히 넘어갑니다(4절). requests.isEmpty()여도export·lock은 합니다(86–90행).plugins {}블록이 없는 스크립트도 이 메서드를 지나며 클래스로더가 확정됩니다. Kotlin DSL 쪽의closeTargetScopeOf()가 빈 요청과target = null로 이 메서드를 부르는 것이 그 용도입니다(KotlinScriptEvaluator.kt276–284행).
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());
}
바로 위의 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);
}
// ... 버전을 적었으면 이미 실린 버전과 같은지 확인 ...
"이미 클래스패스에 있다"의 클래스패스는 부모 클래스로더 스코프입니다(classLoaderScope.getParent() 의 export 클래스로더, 139–140행). 루트 프로젝트 스크립트라면 부모는 buildSrc 의 스코프이므로, buildSrc 에서 만든 플러그인은 여기서 찾습니다(8절). 하위 프로젝트라면 부모는 루트 프로젝트의 스코프라서, 루트의 plugins {} 에서 받아 둔 플러그인을 다시 받지 않고 씁니다.
처음 "찾았다"고 답한 해석기가 이깁니다. 앞에 있는 해석기가 뒤의 것을 가린다는 뜻이기도 합니다.
핵심 플러그인: 이름공간만 보고 거른다
@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));
}
이름공간이 없거나 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 로 모듈을 직접 지정한 경우 ...
}
}
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:
- 첫 번째는
CorePluginResolver.validate()의 문장 그대로입니다(57–59행). 바깥의Error resolving plugin …은DefaultPluginRequestApplicator.resolvePluginRequest()가 감싼 것입니다(192–195행). - 두 번째의 세 줄이 사슬의 순서 그대로입니다.
Gradle Core Plugins는CorePluginResolver.getDescription()(85–87행),Included Builds는 포함된 빌드 해석기(CompositeBuildPluginResolverContributor.java44행과 111행),Plugin Repositories는ArtifactRepositoriesPluginResolver입니다(48행, 그리고handleNotFound()159–168행이 저장소 목록을 붙입니다).NoopPluginResolver와 TestKit 해석기는 줄을 남기지 않았습니다. - 마커 좌표
lab.missing:lab.missing.gradle.plugin:1.0이 위에서 본 규칙대로 만들어졌습니다. 저장소 이름Gradle Central Plugin Repository는gradlePluginPortal()이 붙이는 이름입니다(DefaultRepositoryHandler.java52행).pluginManagement를 적지 않아서 포털 하나만 찾았습니다.
헷갈리기 쉬운 곳
--offline인데 "Searched in … Plugin Repository" 라고 나옵니다. 네트워크로 찾아본 것이 아니라 오프라인이라 캐시에서 찾지 못한 것인데, 메시지는 저장소 목록만 보여 줍니다. 오프라인 여부는 메시지에 따로 나오지 않습니다.id("java")와id("org.gradle.java")는 같습니다. 둘 다CorePluginResolver가 찾습니다.org.gradle이름공간이지만 없는 이름(id("org.gradle.foo"))이면CorePluginResolver는 오류를 던지지 않고 "not a core plugin" 이라는 이유만 남긴 채 다음 해석기로 넘깁니다(46–49행). 이름공간이 없는id("foo")도 핵심 플러그인으로 먼저 찾아본 뒤 다음 해석기로 갑니다.
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);
}
}
이 .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 → 클래스의 대응표는 어디에도 모여 있지 않고, 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행
읽을 것이 넷입니다.
- 컨텍스트 클래스로더를 플러그인의 것으로 바꿉니다(169행). 플러그인 안에서
ServiceLoader처럼 컨텍스트 클래스로더를 쓰는 라이브러리가 플러그인 jar 를 보게 하려는 것입니다.finally에서 되돌립니다. addPluginInternal()이null이면 아무것도 하지 않습니다. 이미 걸린 플러그인이라는 뜻입니다(아래).- 실제 적용은 빌드 오퍼레이션(build operation — 이름 붙은 작업 단위로, 시간을 재고 기록을 남깁니다. 5편)으로 감쌉니다. 이름은
Apply plugin <id 또는 클래스 이름>입니다(318–329행). userCodeApplicationContext.apply()는 "지금 도는 코드가 어느 플러그인의 것인가"를 기록합니다. 나중에 경고나 오류가 났을 때 "플러그인 X 가 한 일"이라고 알려 주는 데 씁니다(소스의 커밋 기록에 따르면 2026-08 에 플러그인별 시간 측정도 여기로 옮겨졌습니다).
같은 플러그인을 두 번 막는 장치는 짧습니다.
@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(…) 콜백이 불립니다). 주석이 말하듯 "적용이 끝난 뒤에야 더한다" — 콜백은 플러그인이 해 놓은 일 위에 무언가를 쌓으려는 것이기 때문입니다.
번호는 예제 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)로 쓰기 때문에 찍히는 줄입니다. 빌드 결과와는 관계없습니다.
- 스택을 아래에서 위로 읽으면 이 글의 길 그대로입니다. 6편의 평가기 사슬(
LifecycleProjectEvaluator→BuildScriptProcessor) → Kotlin 해석기의applyPluginsTo263행(1절의 인용) →PluginRequestsHandler45행 →applyPlugins135행(4단계 "Apply the plugins") →ApplyAction.apply161행 →ClassPathPluginResolution.applyTo49행 →DefaultPluginManager.apply146행 →doApply177–178행(빌드 오퍼레이션) →addPlugin212행(target.applyImperative) →ImperativeOnlyPluginTarget53행(cast.apply(target)). 줄 번호가 이 글의 인용과 맞습니다. 그림의 ①~⑥ 과도 같습니다. ClassPathPluginResolution이 보입니다.lab.hello는 저장소가 아니라 "이미 클래스패스에 있다" 로 해석되었다는 증거입니다(2절의AlreadyOnClasspathPluginResolver, 8절).- 클래스 이름이
lab.HelloPlugin_Decorated입니다.HelloPlugin을abstract로 선언했기 때문에PluginInstantiator가 생성된 하위 클래스를 썼습니다. - 자기
apply()안에서hasPlugin("lab.hello")가 이미true입니다.plugins맵에는 적용을 시작할 때 넣기 때문입니다(addPluginInternal()114행). - 같은 예제를
--info로 한 번 더 돌리면 해석 단계의 기록Resolved plugin [id: 'org.gradle.java-gradle-plugin'],Resolved plugin [id: 'lab.hello'],Resolved plugin [id: 'org.gradle.java']세 줄이 남습니다(resolvePluginRequest()191행의LOGGER.info). 첫 줄은 buildSrc 빌드 자신의plugins {}입니다. buildSrc 도 따로 설정되는 빌드라서 같은 길을 먼저 한 번 지납니다.
헷갈리기 쉬운 곳
- 본문의
apply(plugin = "lab.hello")는 아무 일도 하지 않았습니다. 출력에[apply]줄이 한 번만 있고, 아래 7절의 빌드 오퍼레이션 기록에도Apply plugin lab.hello는 하나뿐입니다.addPluginInternal()이 클래스로 이미 있는 것을 보고null을 돌려준 결과입니다. 오류도 경고도 없으니, 두 번 걸어 설정을 "다시 하려는" 의도라면 통하지 않습니다. - 빌드 스크립트의
plugins {}는 프로젝트마다의PluginManager에 겁니다. 루트에서java를 걸어도 하위 프로젝트에는 걸리지 않습니다. 하위 프로젝트에서 다시 해석할 때 저장소에 가지 않을 뿐입니다(2절 "부모 클래스로더 스코프").
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절).
withPlugin("java-base") 는 java 가 아직 적용 중일 때 불립니다. 그 순간 hasPlugin("java") 는 true 지만 withPlugin("java") 는 아직입니다.
withType이withPlugin보다 먼저 찍혔습니다.addPlugin()에서pluginContainer.pluginAdded()(218행)가adder.run()(223행)보다 앞이기 때문입니다.JavaBasePlugin은 id 없이 클래스로 걸렸는데도withPlugin("java-base")가 불렸습니다.JavaPlugin은apply(JavaBasePlugin.class)로 겁니다. 그래도 adder 는 기다리는 id 마다plugin.isAlsoKnownAs(id)를 묻고,DefaultPluginRegistry의 구현이 그 id 의 설명 파일을 찾아 같은 클래스인지 비교합니다(DefaultPluginRegistry.java258–266행). id 와 클래스가 3절의 설명 파일로 양쪽에서 이어져 있는 셈입니다.[withPlugin java]는JavaPlugin.apply()가 끝난 뒤, 본문([script] body starts)보다 앞입니다. 모두plugins {}블록의 적용 단계 안에서 일어났습니다.
헷갈리기 쉬운 곳
- 9.8.2 에서는
hasPlugin의 답이 달랐을 수 있습니다. 9.8.2 의findPlugin()은pluginsForId(id)를 거쳤고,pluginsForId는 처음 만들 때plugins(시작한 것)를 훑었습니다. 그래서 누가withPlugin("java")를 먼저 걸어 두면 그 집합이 미리 만들어져,java적용 중에hasPlugin("java")를 물으면false가 나왔습니다 — 위 예제가 바로 그 상황입니다. 기준 커밋에서는 2026-08~09 의 수정(커밋ec15e5d3db1,b413eeee725, 둘 다v9.9.0-M2에 들어 있습니다)으로hasPlugin은plugins를,withPlugin은 끝난 것만 보도록 나뉘었습니다. 이 차이는 9.8.2 태그의 소스로 확인한 것이고, 9.8.2 배포판으로 돌려 보지는 않았습니다. project.plugins의 순서는 적용이 "끝난" 순서입니다. 예제의 목록(7절)에서BasePlugin이LifecycleBasePlugin뒤에 있습니다.BasePlugin이 먼저 시작했지만 그 안에서 건LifecycleBasePlugin이 먼저 끝났기 때문입니다(pluginAdded()가apply()뒤에 불리므로).
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));
}
이름이 열쇠이고, 값과 함께 공개 타입(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 { } 를 그냥 쓸 수 있는 이유는 Kotlin DSL 이 plugins {} 적용이 끝난 뒤의 확장 목록(extensionsSchema)을 보고 hello 라는 타입 안전 접근자를 만들어 본문을 컴파일하기 때문입니다. 접근자 생성은 6편에서 봤습니다. plugins {} 대신 본문에서 apply(plugin = "lab.hello") 로만 걸었다면 컴파일 시점에는 hello 가 없으니 the<HelloExtension>() 이나 configure<HelloExtension> { } 로 써야 합니다 — plugins {} 블록을 권하는 큰 이유 하나가 이것입니다.
헷갈리기 쉬운 곳
- 같은 이름으로 두 번
create하면 실패합니다(위 38–41행). 플러그인을 두 번 걸어도 4절의 장치 때문에apply()가 한 번만 돌므로 보통은 문제가 없지만, 서로 다른 플러그인이 같은 이름의 확장을 쓰려 하면 여기서 부딪칩니다. - 확장의 값은
Property로 두는 것이 좋습니다. 위 예제에서 플러그인은convention("Hello from lab.hello")만 정해 두고, 본문이 나중에 값을 바꿨는데도 태스크는 바뀐 값(Hi from build.gradle.kts)을 찍었습니다. 플러그인의apply()는 사용자의 설정보다 먼저 돌기 때문에, 값을 그 자리에서 읽어 버리면 사용자가 바꾼 값을 못 봅니다. 지연 설정은 8편에서 봅니다.
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행).
파란 상자가 이 절에서 인용한 플러그인, 회색은 이름과 하는 일만 적은 플러그인입니다. 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'"
- id 로 건 것은 id 가, 클래스로 건 것은 클래스 이름이 오퍼레이션 이름에 들어갑니다(
DefaultPluginManager319–325행).org.gradle.java는plugins {}가 id 로,JavaBasePlugin은JavaPlugin이 클래스로 걸었기 때문입니다. - 이 목록은 시작 순서, 앞의
project.plugins목록은 끝난 순서입니다.JavaPlugin이 trace 에서는JavaBasePlugin앞,plugins목록에서는 맨 뒤(KotlinScriptBasePlugin 바로 앞)인 이유입니다. JvmTestSuitePlugin은JavaBasePlugin도 겁니다. 그러나 이미 걸려 있으니 trace 에JavaBasePlugin은 한 번만 나옵니다(4절의 두 번 막기).
왜 이렇게 잘게 쪼갰을까요. 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 만 걸고 규칙만 빌려 쓸 수 있습니다.
헷갈리기 쉬운 곳
java를 걸어도compileJava는 아직 "등록"만 되어 있습니다.register()로 등록하므로, 이번 빌드가 그 태스크를 원하지 않으면 객체조차 만들어지지 않습니다. 등록과 실현(realize)의 차이는 8편에서 봅니다.JavaPlugin의 첫 줄은java-platform과 함께 걸리는 것을 막습니다.hasPlugin으로 묻기 때문에 순서에 기댑니다 —java-platform을 나중에 걸면 이 검사는 지나가고, 그쪽 플러그인이 반대 방향으로 검사합니다(JavaPlatformPlugin쪽은 소스로 따라가지 않았습니다).
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();
}
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 플러그인이 걸려 있어서 태스크가 있습니다.
노란 마디가 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),
파일 이름에서 .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) 으로 부를 뿐입니다.
- 어댑터 이름이
LabGreetingPlugin이 아니라Lab_greetingPlugin입니다.kebabCaseToPascalCase()는 첫 글자와 하이픈 뒤 글자(-x)만 대문자로 바꾸고(PrecompiledScriptPlugin.kt290–299행), 점은asJavaIdentifier()가_로 바꿉니다(303–307행). 처음에LabGreetingPlugin.kt를 찾도록run.sh를 썼다가 아무것도 나오지 않아 소스를 보고 고쳤습니다. 스택이나 오류 메시지에서…_gradle,…Plugin같은 이름을 만나면 이 규칙을 떠올리면 됩니다. _Decorated가 아닙니다. 생성된 어댑터는abstract가 아닌 보통 클래스라서 4절의PluginInstantiator가 클래스 생성 없이 만들었습니다. 예제 2의HelloPlugin_Decorated와 비교하면 그 분기가 눈에 보입니다.kotlin-dsl은 첫 빌드가 무겁습니다. 이 예제는 3~4분 걸렸고(검토 때 다시 돌린 값은 3분 19초, 대부분 buildSrc 의 Kotlin 컴파일과 접근자 생성),java-gradle-plugin만 쓴 예제 2는 2분쯤(다시 돌린 값 1분 50초)이었습니다(둘 다 새 데몬, 새 사용자 홈에서 잰 값이라 실행마다 다릅니다).
헷갈리기 쉬운 곳
- buildSrc jar 는 모든 프로젝트 스코프의 조상입니다. 그래서 buildSrc 가 바뀌면 그 아래 모든 빌드 스크립트의 클래스패스가 바뀝니다(그것이 6편의 스크립트 컴파일 캐시에 어떤 영향을 주는지는 이 글에서 재 보지 않았습니다). 플러그인을 여러 빌드가 나눠 쓰거나 buildSrc 를 따로 떼고 싶으면 포함된 빌드(
includeBuild("build-logic"))로 옮기는 방법이 있고, 그러면 2절 사슬의Included Builds해석기가 찾습니다. 포함된 빌드 쪽 해석은 이 글에서 따라가지 않았습니다(CompositeBuildPluginResolverContributor). - 루트의
plugins { id("…") version "…" apply false }는 해석만 해서 jar 를root-project[:]스코프에 실어 두고 적용은 하지 않습니다(1절의request.isApply()). 하위 프로젝트는 부모 스코프에서 그 플러그인을 찾으므로, 버전을 적지 않고id("…")만 써도 됩니다. 버전을 적으면AlreadyOnClasspathPluginResolver가 같은 버전인지 확인하고 다르면 실패합니다(86–99행).
이 글에서 뺀 것
pluginManagement { resolutionStrategy { eachPlugin { useModule(…) } } }로 요청을 바꾸는 곳:pluginResolutionStrategy.applyTo(),DefaultPluginRequestApplicator.java101행- 자동 적용 플러그인(
--scan의 Develocity 등):subprojects/core/src/main/java/org/gradle/plugin/management/internal/autoapply/AutoAppliedPluginHandler.java - 플러그인 버전 기록(
PluginVersionTracker)과 "already on the classpath with a different version" 오류:AlreadyOnClasspathPluginResolver.java86–99행 - 규칙 기반(rule source) 플러그인과
RuleBasedPluginTarget:subprojects/core/src/main/java/org/gradle/api/internal/plugins/RuleBasedPluginTarget.java - settings 플러그인과 init 스크립트 플러그인(
SettingsScopeServices·BuildScopeServices의ImperativeOnlyPluginTarget):SettingsScopeServices.java112행,BuildScopeServices.java705행 - 선언형 DSL(declarative DSL)의 플러그인 적용:
platforms/core-configuration/declarative-dsl-provider/src/main/kotlin/org/gradle/internal/declarativedsl/settings/PluginsInterpretationSequenceStep.kt - precompiled script plugin 의
plugins {}블록 따로 컴파일하기(compilePluginsBlocks,extractPrecompiledScriptPluginPlugins):PrecompiledScriptPlugins.kt61–86행 - buildSrc 빌드가 어떤 태스크로 클래스패스를 만드는지(
BuildSrcBuildListenerFactory.Listener):BuildSrcBuildListenerFactory.java55–94행
정리
| 개념 | 한 줄 요약 |
|---|---|
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 플러그인은 설명 파일과 클래스로더로 들어온다는 점이 다릅니다.