이 글에서 다루는 것
"Gradle 소스 파헤치기" 연재의 네 번째 글입니다. 3편에서는 gradle 명령이 데몬을 찾거나 띄워 빌드를 부탁하고, 데몬이 명령 사슬을 거쳐 빌드를 시작하는 데까지 봤습니다. 그 데몬 안에서 일하는 객체들, 예를 들어 파일 해시를 기억하는 캐시, 태스크를 만드는 팩토리, 의존성을 해석하는 서비스는 서로를 어떻게 찾을까요. 이번 글은 그 답인 서비스 레지스트리를 읽습니다.
2편의 지도로 돌아가 보면, 2편은 상태의 계층(빌드 프로세스 → 세션 → 빌드 트리 → 빌드 → 프로젝트)과 그것에 1:1로 짝지어진 서비스 스코프를 그렸습니다. 이번 글은 그 그림에서 "스코프마다 하나씩 있는 서비스 상자"를 열어 봅니다. 상자는 어떻게 생겼고, 상자끼리는 어떻게 이어지며, 내가 쓴 태스크 클래스는 그 상자에서 어떻게 서비스를 받는지가 이 글의 질문입니다.
- 레지스트리는 어떤 모양이고, 서비스를 찾을 때 어떤 순서로 뒤지는가
- 서비스는 어떻게 선언하고, 언제 만들어지는가. 같은 타입이 둘이면, 서로를 필요로 하면 어떻게 되는가
- 서비스가 엉뚱한 스코프에 등록되는 것을 무엇이 막는가. 플랫폼 모듈들은 어떻게 자기 서비스를 보태는가
- 태스크의
@Inject생성자와abstract게터에는 누가 서비스를 넣는가._Decorated클래스는 무엇인가
| 항목 | 내용 |
|---|---|
| 대상 | Gradle Build Tool |
| 버전 | 9.10.0 개발판 (master) |
| 커밋 | 07ecbf9ead (2026-10-24) |
| 주로 읽는 클래스 | DefaultServiceRegistry, ServiceRegistryBuilder, ServiceRegistrationProvider·@Provides, ServiceScopeValidator, GradleModuleServices, DependencyInjectingInstantiator, AsmBackedClassGenerator |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.
레지스트리의 핵심 DefaultServiceRegistry.java 하나가 1,495줄이고, 사용자 클래스를 만드는 AsmBackedClassGenerator.java는 2,145줄입니다. 이 글은 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
서비스(service)는 Gradle 안에서 오래 쓰이는 객체 하나입니다. "파일 해시를 계산하는 것", "태스크를 만드는 것", "빌드 오퍼레이션을 기록하는 것" 같은 일을 맡습니다. 대개 인터페이스 하나(FileHasher, ObjectFactory …)가 서비스의 타입이고, 다른 서비스는 이 타입으로 그것을 찾습니다. Spring의 빈(bean)과 같은 자리입니다.
서비스 레지스트리(service registry)는 서비스를 담아 두고 타입으로 꺼내 주는 상자입니다. 인터페이스는 ServiceRegistry이고 get(타입), find(타입), getAll(타입)이 거의 전부입니다(ServiceRegistry.java 27–70행). 구현은 DefaultServiceRegistry 하나입니다. Spring의 BeanFactory(Spring 3편)와 같은 자리이고, 소스 곳곳에서는 그냥 services라는 변수로 보입니다.
서비스 스코프(service scope)는 서비스가 얼마나 오래 사느냐에 따라 나눈 칸입니다. 데몬이 살아 있는 동안 내내(Global), 같은 Gradle 사용자 홈(~/.gradle)을 쓰는 동안(UserHome), 명령 한 번 동안(BuildSession), 빌드 하나 동안(Build), 프로젝트 하나 동안(Project) 같은 것입니다. 2편에서 본 상태의 계층과 1:1이고, 스코프 하나에 레지스트리 하나가 만들어집니다.
서비스 제공자(service registration provider)는 레지스트리에 서비스를 보태는 클래스입니다. @Provides를 붙인 createXxx() 메서드가 "이 타입의 서비스는 이렇게 만든다"는 선언입니다. Spring의 @Configuration 클래스와 @Bean 메서드를 떠올리면 가깝습니다. GlobalScopeServices, BuildScopeServices, ProjectScopeServices처럼 이름이 …Services로 끝나는 클래스가 대개 제공자입니다.
장식(decorator)은 부모 레지스트리에 이미 있는 서비스를 받아 감싸서, 같은 타입의 새 서비스로 내놓는 것입니다. 자식 스코프에서 부모의 것을 조금 바꿔 쓰고 싶을 때 씁니다.
생성된 클래스(generated class)는 Gradle이 실행 중에 ASM(바이트코드를 만드는 라이브러리)으로 만드는 하위 클래스입니다. 내가 abstract class Probe : DefaultTask()를 쓰면 Gradle은 Probe_Decorated를 만들어 abstract 게터를 채우고, 실제로는 그 객체를 씁니다. 1편의 예제에서 Build_gradle$Hello_Decorated라는 이름으로 이미 봤습니다.
1. 큰 그림: 스코프마다 레지스트리 하나, 부모를 따라 찾는다
레지스트리는 빌더(ServiceRegistryBuilder)로 만듭니다. 데몬이 시작할 때 가장 먼저 만드는 Global 레지스트리가 이렇습니다.
ServiceRegistryBuilder builder = ServiceRegistryBuilder.builder()
.scopeStrictly(Scope.Global.class)
.displayName("Global services")
.provider(new GlobalScopeServices(longLiving, agentStatus, currentGradleInstallation))
.provider(new BuildProcessScopeServices());
for (ServiceRegistry parent : parents) {
builder.parent(parent);
}
for (ServiceRegistrationProvider serviceProvider : serviceProviders) {
builder.provider(serviceProvider);
}
services = builder.build();
빌더에 주는 것은 네 가지입니다. 어느 스코프인지(scopeStrictly), 이름(displayName), 서비스를 보태는 제공자들(provider), 그리고 부모 레지스트리들(parent)입니다. 프로젝트 하나의 레지스트리도 같은 모양입니다.
public static CloseableServiceRegistry create(
ServiceRegistry buildServices,
ProjectInternal project,
LoggingManagerFactory loggingManagerInternalFactory
) {
return ServiceRegistryBuilder.builder()
.scopeStrictly(Scope.Project.class)
.displayName("project services")
.parent(buildServices)
.provider(new ProjectScopeServices(project, loggingManagerInternalFactory))
.provider(new WorkerSharedProjectScopeServices(project.getProjectDir()))
.build();
}
subprojects/core/src/main/java/org/gradle/internal/service/scopes/ProjectScopeServices.java 118–130행
부모가 buildServices, 곧 이 프로젝트가 속한 빌드의 레지스트리입니다. 세션 레지스트리는 BuildSessionState가, 빌드 트리 레지스트리는 BuildTreeState가, 빌드 레지스트리는 AbstractBuildState가 같은 빌더로 만듭니다. 그렇게 만든 레지스트리들이 부모·자식으로 이어진 사슬이 이 글의 전부라고 해도 됩니다.
예제 1: 프로젝트 레지스트리에서 부모 사슬을 거꾸로 찍어 보기
정말 그렇게 이어져 있는지 빌드 스크립트에서 확인해 봤습니다. 프로젝트의 레지스트리는 내부 API ProjectInternal.getServices()로 꺼낼 수 있습니다. 그리고 DefaultServiceRegistry의 private 필드 allServices는 toString()이 부모 사슬 전체를 찍도록 되어 있습니다(3절에서 그 이유를 봅니다).
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
import org.gradle.api.internal.GradleInternal
import org.gradle.api.internal.project.ProjectInternal
import org.gradle.internal.service.DefaultServiceRegistry
// ...
println("project class = ${project.javaClass.name}")
val projectServices = (project as ProjectInternal).services
println("project registry = $projectServices (${projectServices.javaClass.simpleName})")
// DefaultServiceRegistry 의 private 필드 allServices 의 toString() 이 부모 사슬 전체를 보여 준다
val allServices = DefaultServiceRegistry::class.java.getDeclaredField("allServices").apply { isAccessible = true }
println("lookup chain = ${allServices.get(projectServices)}")
[예제 ex/u04-injection/project/build.gradle.kts에서 줄임]
org.gradle.internal과 ProjectInternal은 Gradle의 내부 API입니다. 소스를 읽으려고 쓴 것이고, 실제 빌드에 쓰면 안 됩니다. 이 예제는 기준 커밋(07ecbf9ead)을 그대로 빌드한 Gradle 9.10.0 개발판으로 도커 컨테이너(Eclipse Temurin JDK 25)에서 실행했습니다. gradle probe --offline --console=plain의 앞부분입니다.
=== gradle probe ===
Welcome to Gradle 9.10.0-20261107025045+0000!
Starting a Gradle Daemon (subsequent builds will be faster)
Shared read-only dependency cache is an incubating feature.
> Configure project :
project class = org.gradle.api.internal.project.DefaultProject_Decorated
project registry = project services (ScopedServiceRegistry)
lookup chain = [project services, [Build-scoped services, [build tree services, [build session services, [[services for Gradle user home dir /root/.gradle, [Global services, [logging services, native services]]], [cross session services, [Global services, [logging services, native services]]]]]]]]
Shared read-only dependency cache … 줄은 이 연재의 이미지가 미리 받아 둔 라이브러리를 읽기 전용 캐시로 붙여 두어서 찍히는 것입니다(1편). 읽을 것은 마지막 줄입니다. 대괄호 하나가 "자기 서비스, 그다음 부모들"입니다. 풀어서 그리면 이렇습니다.
번호는 서비스를 찾는 순서입니다. 노란 상자는 빌드가 끝나도 데몬 안에 남는 레지스트리입니다.
- 프로젝트에서 무엇을 찾으면 자기 것 → Build → BuildTree → BuildSession → UserHome → Global → (logging, native) → CrossBuildSession 순서로 뒤집니다. Global이 두 번 찍힌 것은 두 길이 같은 레지스트리를 가리키기 때문입니다. UserHome 쪽은
GlobalScopeServices가 Global 레지스트리 자신(ServiceRegistry globalServices)을 넘겨 만들고(244–245행), CrossBuildSession 쪽도new CrossBuildSessionState(globalServices, …)로 만듭니다(BuildSessionLifecycleBuildActionExecutor.java65행). 찾는 순서의 규칙은 3절에서 소스로 봅니다. - 첫 줄
project class가DefaultProject_Decorated입니다. 빌드 스크립트의project도 Gradle이 만든 하위 클래스입니다. 6절에서 다시 봅니다.
왜 상자를 여럿으로 나눴나
1편에서 봤듯이 Gradle 데몬은 여러 빌드를 차례로 받는 오래 사는 프로세스입니다. 그러니 객체마다 수명이 다릅니다. 파일 해시 캐시처럼 빌드가 끝나도 남겨 둬야 다음 빌드가 빨라지는 것이 있고, 이번 빌드의 프로젝트 모델처럼 빌드가 끝나면 반드시 버려야 하는 것이 있습니다. 레지스트리를 스코프마다 따로 두면 "빌드가 끝났다 = Build 레지스트리를 닫는다"가 되고, 그 안의 서비스가 한꺼번에 정리됩니다. 자식은 부모의 서비스를 쓸 수 있지만 부모는 자식을 모르므로, 오래 사는 서비스가 곧 사라질 객체를 붙잡는 일도 구조로 막힙니다.
@ServiceScope의 설명 주석이 이 규칙을 그대로 적어 둡니다. "서비스는 같은 스코프와 그 아래 스코프의 서비스에게 보이고, 위 스코프에게는 보이지 않는다"(ServiceScope.java 26–38행).
헷갈리기 쉬운 곳
- 부모가 둘인 레지스트리가 있습니다.
BuildSessionState는.parent(userHomeServices)와.parent(crossBuildSessionServices.getServices())를 차례로 겁니다(BuildSessionState.java55–61행). 데몬 안의 Global 레지스트리도 부모가 logging과 native 둘입니다(DaemonProcessState.java44–56행). 그래서 사슬이 아니라 나무를 위로 올라가는 모양입니다. 부모를 건 순서가 곧 찾는 순서입니다. - 프로젝트 레지스트리의 부모는 Settings가 아니라 Build입니다.
Scope.java의 그림도 Settings와 Project를 Build 아래 형제로 그립니다(33–45행).settings.gradle.kts에서 쓰던 서비스가build.gradle.kts에서 그대로 보일 거라고 생각하면 안 됩니다. - 데몬의 Global 레지스트리에는 데몬 서비스도 들어 있습니다.
DaemonProcessState는DaemonServices(3편의 명령 사슬을 만드는 곳)를 Global 레지스트리에 함께 넣습니다. 주석은 "스코프를 나누는 게 낫지만 많은 곳이 데몬 서비스가 Global에 있다고 가정하므로 옮겨 가는 단계로 합쳐 둔다"고 적습니다(41–43행).
2. 서비스를 선언하는 법 — 제공자와 @Provides
레지스트리에 서비스를 넣는 정식 방법은 제공자 클래스입니다. ServiceRegistrationProvider는 메서드가 하나도 없는 표시용 인터페이스이고, 규칙은 설명 주석에 있습니다(ServiceRegistrationProvider.java 19–139행). 요약하면 이렇습니다.
| 메서드 모양 | 뜻 |
|---|---|
@Provides + 이름이 create… 또는 decorate… + 반환 타입이 있다 |
반환 타입의 서비스를 만드는 팩토리. 매개변수는 의존하는 서비스 |
| 위와 같은데 매개변수 가운데 반환 타입과 같은 것이 있다 | 장식 — 그 매개변수에는 부모 레지스트리의 같은 타입 서비스가 들어온다 |
이름이 configure + void + ServiceRegistration 매개변수 |
등록을 코드로 하는 메서드. registration.add(...)로 서비스를 더한다 |
매개변수 타입이 List<T> |
이 레지스트리와 부모들에 있는 T를 모두 모아 받는다 |
매개변수 타입이 ServiceRegistry |
이 서비스를 가진 레지스트리 자신을 받는다 |
이 규칙을 실제로 집행하는 것이 RelevantMethods입니다. 제공자 클래스의 메서드를 훑어 세 갈래로 나눕니다.
private void addMethod(Method method) {
if (method.getName().equals("configure")) {
if (!method.getReturnType().equals(Void.TYPE)) {
throw new ServiceValidationException(String.format("Method %s.%s() must return void.", type.getName(), method.getName()));
}
add(configurers, method);
} else if (method.getName().startsWith("create") || method.getName().startsWith("decorate")) {
if (method.getAnnotation(Provides.class) == null) {
throw new ServiceValidationException(String.format("Method %s.%s() must be annotated with @Provides.", type.getName(), method.getName()));
}
if (method.getReturnType().equals(Void.TYPE)) {
throw new ServiceValidationException(String.format("Method %s.%s() must not return void.", type.getName(), method.getName()));
}
if (takesReturnTypeAsParameter(method)) {
add(decorators, method);
} else {
add(factories, method);
}
} else if (method.getAnnotation(Provides.class) != null) {
throw new ServiceValidationException(String.format("Non-factory method %s.%s() must not be annotated with @Provides.", type.getName(), method.getName()));
}
}
이름과 어노테이션이 어긋나면 바로 예외입니다. create로 시작하는데 @Provides가 없어도, @Provides를 붙였는데 이름이 create/decorate가 아니어도 실패합니다. 이 결과는 클래스마다 ClassValue에 기억해 두므로(30–35행) 같은 제공자 클래스로 레지스트리를 여러 번 만들어도 리플렉션은 한 번입니다. 프로젝트마다 ProjectScopeServices로 레지스트리를 새로 만드니 의미가 있는 캐시입니다.
DefaultServiceRegistry.addProvider()는 이렇게 나눈 메서드를 받아 서비스 객체로 바꿉니다.
private void findProviderMethods(ServiceRegistrationProvider target, ServiceAccessToken token) {
Class<? extends ServiceRegistrationProvider> type = target.getClass();
RelevantMethods methods = RelevantMethods.getMethods(type);
for (ServiceMethod method : methods.decorators) {
if (parentServices == null) {
throw new ServiceLookupException(String.format("Cannot use decorator method %s.%s() when no parent registry is provided.", type.getSimpleName(), method.getName()));
}
ownServices.add(new FactoryMethodService(this, determineAccessScope(method, token), token, target, method));
}
for (ServiceMethod method : methods.factories) {
ownServices.add(new FactoryMethodService(this, determineAccessScope(method, token), token, target, method));
}
for (ServiceMethod method : methods.configurers) {
applyConfigureMethod(token, method, target);
}
}
눈여겨볼 것이 둘 있습니다.
- 팩토리와 장식은 메서드를 부르지 않고
FactoryMethodService로 포장해 등록만 합니다. 서비스는 아직 하나도 만들어지지 않습니다(3절). configure메서드는 그 자리에서 바로 부릅니다(applyConfigureMethod(), 194–218행). 그 매개변수에 다른 서비스가 필요하면 그 서비스는 이때 만들어집니다.ServiceRegistryBuilder의 설명 주석도 "configure 메서드는 레지스트리를 다 만들기 전에 실행된다"고 적습니다(ServiceRegistryBuilder.java97–98행).
configure 안에서 쓰는 registration.add(...)에는 두 가지가 있습니다. 이미 만든 객체를 넣는 add(타입, 객체)는 FixedInstanceService가 되고, 구현 클래스를 넣는 add(타입, 구현 클래스)는 그 클래스의 생성자를 나중에 부르는 ConstructorService가 됩니다(235–264행). 생성자 매개변수도 @Provides 메서드의 매개변수처럼 서비스에서 찾아 넣습니다. 결국 서비스 하나는 아래 셋 가운데 하나로 포장됩니다.
ServiceProvider 인터페이스를 뺀 나머지는 모두 DefaultServiceRegistry 안의 private 중첩 클래스입니다.
메서드 핸들일까, 리플렉션일까
@Provides 메서드를 부르는 방법은 두 가지가 준비되어 있습니다. DefaultServiceMethodFactory는 메서드 핸들(java.lang.invoke.MethodHandle)을 쓸 수 있으면 그쪽 팩토리를, 아니면 리플렉션 팩토리를 고릅니다(DefaultServiceMethodFactory.java 25–36행). 그런데 메서드 핸들 팩토리를 골랐다고 다 메서드 핸들로 부르지는 않습니다.
@Override
public ServiceMethod toServiceMethod(Method method) {
if (Modifier.isPublic(method.getModifiers()) && Modifier.isPublic(method.getDeclaringClass().getModifiers())) {
try {
return new MethodHandleBasedServiceMethod(method);
} catch (IllegalAccessException ex) {
return new ReflectionBasedServiceMethod(method);
}
}
return new ReflectionBasedServiceMethod(method);
}
메서드와 그 클래스가 둘 다 public일 때만 메서드 핸들입니다(MethodHandles.publicLookup()을 쓰기 때문입니다). 그런데 Gradle의 제공자 메서드는 대부분 protected이거나 접근 제한자가 없습니다. 기준 커밋의 Java 소스에서 Gradle의 @Provides(org.gradle.internal.service.Provides)가 붙은 메서드 648개를 세어 보니 public은 22개, protected가 168개, 나머지 458개는 패키지 전용이었습니다(이름이 같은 Guice·Dagger의 @Provides를 쓰는 예제용 파일 둘은 뺐습니다). 대부분은 리플렉션으로 불린다는 뜻입니다. 서비스마다 한 번만 부르는 메서드이니 속도 차이가 크게 문제 되지는 않을 것입니다(재 보지는 않았습니다).
헷갈리기 쉬운 곳: 장식인지는 이름이 아니라 매개변수가 정합니다
decorate로 시작해야 장식인 것 같지만, RelevantMethods가 보는 것은 "매개변수에 반환 타입과 같은 타입이 있나"뿐입니다(위 코드 88–92행). 그래서 이름이 create…인 장식도 있습니다. 프로젝트 레지스트리의 태스크 팩토리가 그렇습니다.
@Provides
protected ITaskFactory createTaskFactory(ServiceRegistry projectScopeServiceRegistry, ITaskFactory parentFactory, TaskScheme taskScheme) {
return parentFactory.createChild(project, taskScheme.getInstantiationScheme().withServices(projectScopeServiceRegistry));
}
subprojects/core/src/main/java/org/gradle/internal/service/scopes/ProjectScopeServices.java 251–254행
ITaskFactory parentFactory가 반환 타입과 같으니 장식입니다. 이 매개변수에는 Build 레지스트리의 ITaskFactory(BuildScopeServices 490–496행이 만드는 AnnotationProcessingTaskFactory)가 들어오고, 프로젝트 레지스트리는 거기에 "이 프로젝트, 이 프로젝트의 서비스"를 붙인 자식 팩토리를 내놓습니다. ITaskFactory가 @ServiceScope({Scope.Build.class, Scope.Project.class})로 두 스코프를 밝혀 둔 이유입니다. 같은 파일 앞쪽의 createPluginRegistry(PluginRegistry rootRegistry)도 같은 장식입니다(158–168행). 6절의 예제에서 이 팩토리가 태스크를 만드는 것을 스택으로 봅니다.
3. 찾기와 만들기 — 게으른 싱글턴
자기 것, 그다음 부모들
레지스트리의 생성자가 찾는 순서를 정합니다.
public DefaultServiceRegistry(@Nullable String displayName, ServiceRegistry... parents) {
assertAllowedImplementation(getClass());
this.displayName = displayName;
this.ownServices = new OwnServices();
if (parents.length == 0) {
this.parentServices = null;
this.allServices = ownServices;
this.inspector = new ClassInspector();
} else {
this.parentServices = setupParentServices(parents);
this.allServices = new CompositeServiceProvider(ownServices, parentServices);
this.inspector = parents[0] instanceof DefaultServiceRegistry ? ((DefaultServiceRegistry) parents[0]).inspector : new ClassInspector();
}
this.thisAsServiceProvider = allServices;
}
allServices는 CompositeServiceProvider(ownServices, parentServices)입니다. CompositeServiceProvider.getService()는 가진 것들을 앞에서부터 물어 처음 나온 답을 돌려줍니다(1058–1066행). 부모가 여럿이면 parentServices도 부모들을 건 순서대로 묶은 CompositeServiceProvider입니다(123–135행). 예제 1에서 찍은 대괄호 안의 대괄호가 바로 이 객체들의 toString()입니다(1086–1088행). 제공자 설명 주석이 적은 순서와 같습니다. "자기 서비스 → 첫째 부모의 전체 서비스 → 둘째 부모의 전체 서비스 → …. 장식 선언은 자기 서비스를 건너뛰고 부모부터 찾는다"(ServiceRegistrationProvider.java 102–116행).
assertAllowedImplementation()은 이 클래스를 ScopedServiceRegistry 말고는 상속하지 못하게 막습니다(75–83행). 예전에는 레지스트리를 상속해 서비스를 더하는 방식도 있었던 것으로 보이는데, 지금은 "ServiceRegistryBuilder를 쓰라"는 메시지로 막아 둡니다.
자기 것 안에서 찾기
@Override
public @Nullable Service getService(Type type, @Nullable ServiceAccessToken token) {
PersistentArray<ServiceProvider> serviceProviders = getProviders(unwrap(type));
if (serviceProviders.isEmpty()) {
return null;
}
if (serviceProviders.size() == 1) {
return serviceProviders.get(0).getService(type, token);
}
List<Service> services = new ArrayList<Service>(serviceProviders.size());
for (ServiceProvider serviceProvider : serviceProviders) {
Service service = serviceProvider.getService(type, token);
if (service != null) {
services.add(service);
}
}
// ...
Formatter formatter = new Formatter();
formatter.format("Multiple services of type %s available in %s:", format(type), getDisplayName());
for (String description : descriptions) {
formatter.format("%n - %s", description);
}
throw new ServiceLookupException(formatter.toString());
}
getProviders()는 타입을 열쇠로 하는 맵 providersByType에서 후보를 꺼냅니다. 이 맵은 서비스를 등록할 때 선언한 타입과 그 상위 타입 전부(Object만 빼고)를 열쇠로 채워 둡니다(collectProvidersForClassHierarchyOf(), 1466–1493행). 그래서 FileSystemOperations로 등록한 서비스를 그 상위 인터페이스로 찾아도 나옵니다. 그리고 후보가 0개면 null(그러면 부모에게 갑니다), 1개면 그것, 둘 이상이면 예외입니다. Spring처럼 @Primary나 이름으로 하나를 고르는 장치가 없습니다(Spring 5편과 비교해 보세요).
같은 맵 때문에 막힌 것이 하나 더 있습니다. 상위 타입을 훑다가 ServiceRegistry가 나오면 예외를 던집니다(1477–1480행). ServiceRegistry는 레지스트리마다 자기 자신이 미리 들어 있는 특별한 서비스이기 때문입니다(OwnServices 생성자, 400–410행). 2절의 표에서 "ServiceRegistry 매개변수는 레지스트리 자신을 받는다"고 한 것이 이것입니다.
부모에게 넘어가는 것은 자기 것에서 0개일 때뿐입니다. 2개면 부모를 보지 않고 실패합니다.
처음 찾을 때 만든다
찾았다고 바로 만들지는 않습니다. 서비스 객체는 두 단계를 거칩니다. 먼저 prepare()가 "만들 준비"(bind)를 하고,
private Service prepare() {
if (state == BindState.BOUND) {
return this;
}
synchronized (this) {
if (state == BindState.BINDING) {
throw new ServiceValidationException("Cycle in dependencies of " + getDisplayName() + " detected");
}
if (state == BindState.UNBOUND) {
state = BindState.BINDING;
try {
bind();
state = BindState.BOUND;
} catch (RuntimeException e) {
state = BindState.UNBOUND;
throw e;
}
}
return this;
}
}
그다음 누군가 객체를 달라고 하면 getInstance()가 이중 확인 잠금(double-checked locking)으로 한 번만 만듭니다(592–603행).
FactoryService.bind()는 매개변수마다 서비스를 찾아 두기만 합니다(747–800행). 매개변수 타입이 반환 타입과 같으면(장식) owner.parentServices에서만, 아니면 owner.allServices에서 찾습니다. 못 찾으면 Cannot create service of type ... using ... as required service of type ... for parameter #N is not available. 같은 메시지로 실패합니다. createServiceInstance()가 불리면 그제야 찾아 둔 매개변수 서비스들의 get()을 차례로 부르고(그러면 그것들이 줄줄이 만들어집니다) 메서드나 생성자를 부릅니다(803–825행). 만든 객체는 instance 필드에 남으니, 레지스트리 하나 안에서 서비스는 하나뿐인 싱글턴입니다.
설명 주석의 한 줄이 이 설계의 이유입니다. "서비스는 게으르게 만들어지고, 레지스트리가 사는 동안 한 번도 만들어지지 않을 수도 있다"(ServiceRegistrationProvider.java 134행). BuildScopeServices 한 파일에만 @Provides가 58개, 기준 커밋 전체에는 690개가 있습니다(git grep으로 @Provides로 시작하는 줄을 센 값, Java·Kotlin src/main 기준. 같은 이름인 Guice·Dagger의 @Provides 34줄은 뺐습니다). 빌드 하나가 이것을 다 쓰지는 않습니다. Spring의 ApplicationContext는 시작할 때 preInstantiateSingletons()로 싱글턴을 미리 다 만들어 두지만(Spring 4편), Gradle의 레지스트리에는 그런 단계가 없습니다.
예제 2: 레지스트리를 빌드 밖에서 직접 써 보기
레지스트리는 Gradle 배포판의 jar 몇 개에 들어 있는 평범한 Java 코드입니다. 그래서 빌드 없이 javac/java로 직접 써 볼 수 있습니다. 이미지의 /opt/gradle/lib/*를 클래스패스에 두고, 스코프 둘(Global과 Build)을 흉내 낸 작은 프로그램을 짰습니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// ... import 줄임 ...
public class RegistryLab {
// ---- 서비스 타입: 어느 스코프에 사는지 @ServiceScope 로 밝힌다 ----
@ServiceScope(Scope.Global.class)
public interface Clock {
// ... String now() ...
@ServiceScope({Scope.Global.class, Scope.Build.class})
public interface Greeter {
// ... String greet(String name) ...
@ServiceScope({Scope.Global.class, Scope.Build.class})
public interface Plugin {
// ... String id() ...
@ServiceScope(Scope.Build.class)
public interface Report extends Closeable {
// ... String render() ...
// ... @ServiceScope 가 없는 interface Unscoped 와 로그를 찍는 log() 도 줄임 ...
// ---- Global 스코프에 서비스를 보태는 제공자 ----
static class GlobalServices implements ServiceRegistrationProvider {
@Provides
Clock createClock() {
log("create Clock");
return () -> "12:00";
}
@Provides
Greeter createGreeter(Clock clock) {
// ... "Hello, <이름> at 12:00" 을 돌려주는 Greeter ...
@Provides
Plugin createCorePlugin() {
// ... id 가 "core" 인 Plugin ...
}
// ---- Build 스코프에 서비스를 보태는 제공자 ----
static class BuildServices implements ServiceRegistrationProvider {
// 매개변수 타입 == 반환 타입 이면 "장식(decorator)" — 부모의 Greeter 를 받아 감싼다
@Provides
Greeter decorateGreeter(Greeter parent) {
// ... parent 의 결과를 대문자로 바꾸는 Greeter ...
@Provides
Plugin createJavaPlugin() {
// ... id 가 "java" 인 Plugin ...
// List<T> 는 이 레지스트리와 부모들에 있는 T 를 모두 모아 준다
// ServiceRegistry 는 이 서비스를 가진 레지스트리 자신이다
@Provides
Report createReport(Greeter greeter, List<Plugin> plugins, ServiceRegistry owner) {
// ... render() 는 greeter 와 plugins 로 한 줄을 만들고, close() 는 로그를 찍는다 ...
}
// ... main() 은 1절과 같은 빌더로 레지스트리 둘을 만든다:
// ... global = scopeStrictly(Global) + GlobalServices
// ... build = scopeStrictly(Build) + BuildServices + parent(global)
// ... 그리고 아래 출력의 1~8 단계를 차례로 돈다 ...
}
[예제 ex/u04-registry-lab/RegistryLab.java에서 줄임 — 줄인 메서드 본문도 첫 줄에서 log("create …")로 만들어지는 순간을 찍습니다]
ex/u04-registry-lab/run.sh가 javac -cp "/opt/gradle/lib/*" RegistryLab.java 뒤 java로 실행합니다. 1~3단계의 출력입니다.
=== 1. build two registries (nothing is created yet) ===
built: lab build
=== 2. first lookup of Report in the build registry ===
[create Plugin java]
[create Plugin core]
[create Clock]
[create Greeter (global)]
[decorate Greeter]
[create Report, owner = lab build]
render(): HELLO, GRADLE AT 12:00 / plugins: java core
same instance next time? true
=== 3. the same type seen from each registry ===
build.get(Greeter) -> HELLO, X AT 12:00
global.get(Greeter) -> Hello, x at 12:00
global.find(Report) -> null
build.getAll(Plugin) size = 2
- 레지스트리를 둘 만들었는데 아무것도 만들어지지 않았습니다. 1단계와 2단계 사이에
[create …]가 없습니다. get(Report)한 번에 필요한 것들이 줄줄이 만들어졌습니다. 순서를 보면Plugin둘이 먼저이고, 그다음Clock→ 전역Greeter→ 장식 →Report입니다. 이 순서는 아래 그림에서 설명합니다.- 같은
Greeter타입이 Build 레지스트리에서는 장식된 것(대문자), Global에서는 원래 것입니다. 자식에서 부모의 서비스를 덮어쓴 셈입니다. List<Plugin>은 자기 것(java) 다음에 부모 것(core)이 왔습니다. 찾는 순서 그대로입니다.- Global에서
Report는null입니다. 부모는 자식을 모릅니다. owner로 받은 레지스트리는lab build입니다. 서비스를 가진 레지스트리 자신입니다.
대괄호는 예제 출력의 로그 줄입니다. 찾기(bind)와 만들기(create)가 나뉘어 있어서 Plugin이 먼저 만들어졌습니다.
헷갈리기 쉬운 곳
-
List<T>는 bind 때 만들어집니다. 예제에서Plugin이Clock보다 먼저 만들어진 이유입니다. 보통 매개변수는 bind에서 찾기만 하는데, 컬렉션은 찾는 함수getCollectionService()가 후보마다service.get()을 불러 목록을 채웁니다(1158–1167행). "게으르다"는 말이List매개변수에는 반만 맞습니다. -
같은 타입이 둘이어도
build()는 성공합니다. 실패는 그 타입을 처음 찾을 때입니다. 같은 프로그램의 7단계입니다(Clock을 만드는 제공자를 둘 넣었습니다).=== 7. two providers of the same type in one registry === build() succeeded; now get(Clock): ServiceLookupException: Multiple services of type RegistryLab$Clock available in twice: - Service RegistryLab$Clock via RegistryLab$<anonymous>.createOtherClock() - Service RegistryLab$Clock via RegistryLab$GlobalServices.createClock()ServiceRegistryBuilder의 설명 주석도 "자기 서비스 안에서 같은 타입이 두 번 등록되면 찾을 때 모호성 오류가 난다"고 적습니다(ServiceRegistryBuilder.java44–47행). 아무도 찾지 않으면 끝까지 드러나지 않습니다. -
서로를 필요로 하면 바로 실패합니다. A의 bind가 B를 찾고 B의 bind가 다시 A를 찾으면, A는 아직
BINDING이라 위prepare()의 685–687행에서Cycle in dependencies of ... detected가 납니다. Spring은 필드·세터 주입의 순환을 3단계 캐시로 풀어 주지만(Spring 4편), Gradle 서비스는 메서드 매개변수와 생성자로만 받으므로 풀 방법이 없고, 풀지도 않습니다. 이 경우는 소스로만 확인했고 예제로 돌려 보지는 않았습니다. -
처음 찾은 뒤에는 더 넣을 수 없습니다.
get()/find()가 한 번이라도 불리면 상태가INIT에서STARTED로 바뀌고(serviceRequested(), 307–316행), 그 뒤의add()는 거절됩니다. 예제의 4단계입니다.=== 4. adding a service after the first lookup === IllegalStateException: Cannot add services to service registry lab build as it is no longer mutable
닫을 때
@Override
public void close() {
noLongerMutable();
if (state.compareAndSet(State.STARTED, State.CLOSED)) {
CompositeStoppable.stoppable(allServices).stop();
}
}
allServices에는 부모도 들어 있지만, 부모는 ParentServices로 감싸 두었고 이 클래스의 stop()은 아무것도 하지 않습니다(1091–1119행, 주석 "Wraps a parent to ignore stop requests"). 그래서 자기 서비스만 닫힙니다. 서비스 하나를 닫을 때는 같은 레지스트리 안에서 그 서비스를 쓰는 서비스들(bind 때 requiredBy()로 적어 둔 것)을 먼저 닫고 자기를 닫습니다(622–632행). 클래스 설명의 "의존 관계의 역순으로 닫는다"(58행)가 이것입니다. 객체가 Closeable이나 Stoppable이면 close()/stop()이 불리고, 한 번도 만들어지지 않은 서비스는 닫을 것도 없습니다. 예제의 마지막 단계입니다.
=== 8. close the build registry, then the global one ===
[close Report]
build closed; global still works: Hello, y at 12:00
IllegalStateException: lab global has been closed.
빌드가 끝날 때 Build 레지스트리가, 데몬이 끝날 때 Global 레지스트리가 이렇게 닫힙니다.
4. 스코프 검사 — 서비스가 제자리에 있나
레지스트리를 스코프마다 나눴어도, 개발자가 Build 수명의 서비스를 실수로 Global 레지스트리에 등록하면 그 서비스는 빌드가 끝나도 살아남아 지난 빌드의 상태를 들고 다닙니다. 이것을 막는 것이 서비스 타입에 붙이는 @ServiceScope와, 그것을 검사하는 ServiceScopeValidator입니다.
1절의 빌더 코드에 있던 .scopeStrictly(Scope.Global.class)가 스위치입니다. 스코프를 주면 빌더는 DefaultServiceRegistry 대신 ScopedServiceRegistry를 만듭니다(ServiceRegistryBuilder.java 169–180행). ScopedServiceRegistry가 하는 일은 생성자에서 add(new ServiceScopeValidator(scope, strict)) 한 줄뿐입니다(ScopedServiceRegistry.java 41–43행). 검사기도 서비스로 들어가는 것입니다. 검사기는 AnnotatedServiceLifecycleHandler라는 특별한 서비스라서, 레지스트리는 이후 서비스가 등록될 때마다 그것에게 알립니다(OwnServices.add(), 470–477행). 그러면 검사기가 선언된 타입마다 이것을 봅니다.
private void validateScope(Class<?> serviceType) {
if (ServiceScopeValidator.class.isAssignableFrom(serviceType)) {
return;
}
if (ServiceScopeValidatorWorkarounds.shouldSuppressValidation(serviceType)) {
return;
}
Class<? extends Scope>[] serviceScopes = scopeOf(serviceType);
if (serviceScopes == null) {
if (strict) {
failWithMissingScope(serviceType);
}
return;
}
if (serviceScopes.length == 0) {
throw new IllegalArgumentException(String.format("Service '%s' is declared with empty scope list", serviceType.getName()));
}
if (!contains(serviceScopes, scope)) {
throw new IllegalArgumentException(invalidScopeMessage(serviceType, serviceScopes));
}
}
규칙은 둘입니다. 타입에 @ServiceScope가 있으면 그 목록에 이 레지스트리의 스코프가 들어 있어야 하고, strict(= scopeStrictly)이면 @ServiceScope가 아예 없는 것도 실패입니다. Global부터 Project까지 Gradle이 스코프마다 만드는 레지스트리는 모두 scopeStrictly로 만듭니다(git grep으로 보면 src/main에서 scopeStrictly(를 부르는 곳이 10곳이고, 느슨한 scope(Scope…)를 부르는 곳은 없습니다). 예외는 스코프를 아예 주지 않는 레지스트리입니다. 플러그인 검사 도구 PropertyValidationAccess는 "Global services"라는 이름의 레지스트리를 스코프 없이 따로 만들고(PropertyValidationAccess.java 58행), 이런 레지스트리에서는 검사기가 돌지 않습니다.
예제 2의 5·6단계가 이 두 규칙을 일부러 어깁니다. 5단계는 @ServiceScope(Scope.Global.class)인 Clock을 Build 레지스트리에, 6단계는 어노테이션이 없는 Unscoped를 strict한 Build 레지스트리에 넣습니다.
=== 5. a Global-only service registered in a Build registry ===
IllegalArgumentException: The service 'RegistryLab$Clock' declares service scope 'Global' but is registered in the 'Build' scope. Either update the '@ServiceScope()' annotation on 'Clock' to include the 'Build' scope or move the service registration to one of the declared scopes.
=== 6. a service without @ServiceScope in a strict registry ===
IllegalArgumentException: The service 'RegistryLab$Unscoped' is registered in the 'Build' scope but does not declare it. Add the '@ServiceScope()' annotation on 'Unscoped' with the 'Build' scope.
둘 다 build()에서 실패했습니다. 7단계의 모호성 오류가 찾을 때 났던 것과 다릅니다. 검사는 등록하는 순간 하므로, 서비스를 한 번도 찾지 않아도 잘못된 스코프는 데몬이 레지스트리를 만들 때 바로 드러납니다. 개발 중에 빨리 터지게 하려는 설계입니다.
위 validateScope() 코드를 갈림길로 옮겼습니다. 빨간 칸의 문장은 예제 2의 실제 출력입니다(빈 목록 오류는 예제에서 내 보지 않았습니다).
같은 타입이 여러 스코프에 — ObjectFactory
@ServiceScope에 스코프를 여럿 적는 타입도 있습니다. 빌드 스크립트에서 objects로 쓰는 ObjectFactory가 그렇습니다.
@ServiceScope({Scope.Global.class, Scope.BuildTree.class, Scope.Project.class})
public interface ObjectFactory {
subprojects/core-api/src/main/java/org/gradle/api/model/ObjectFactory.java 52–53행
실제로 세 스코프에 하나씩 만드는 메서드가 있습니다(GlobalScopeServices.java 263–279행, BuildTreeScopeServices.java 159–176행, WorkerSharedProjectScopeServices.java 169–172행). 셋 모두 instantiatorFactory.decorate(<그 스코프의 레지스트리>)로 DefaultObjectFactory를 만듭니다. Global과 BuildTree는 메서드 안에서 직접, Project는 ObjectFactoryFactory에 레지스트리를 넘겨 그 안에서 만듭니다(DefaultObjectFactoryFactory.java 57–69행). 차이는 괄호 안의 레지스트리입니다. objects.newInstance(...)로 만든 객체에 어느 레지스트리의 서비스를 주입할지가 여기서 정해집니다. 프로젝트의 objects로 만든 객체는 프로젝트 서비스까지 받을 수 있어야 하니, 프로젝트마다 ObjectFactory가 따로 있어야 합니다. @ServiceScope의 설명 주석이 이것을 "아래 스코프의 인스턴스가 부모의 것을 그 스코프와 자식들에서 덮어쓴다"고 적습니다(ServiceScope.java 33–35행).
예제 1의 빌드 스크립트에서 둘을 견줘 봤습니다.
val buildServices = (gradle as GradleInternal).services
println("gradle.services = $buildServices")
val fromProject = projectServices.get(ObjectFactory::class.java)
val fromBuild = buildServices.get(ObjectFactory::class.java)
println("project.objects === project registry's ObjectFactory? ${project.objects === fromProject}")
println("project registry's ObjectFactory === build registry's? ${fromProject === fromBuild}")
gradle.services = Build-scoped services
project.objects === project registry's ObjectFactory? true
project registry's ObjectFactory === build registry's? false
project.objects는 프로젝트 레지스트리의 것이고, Build 레지스트리(gradle.services)에서 찾은 것과는 다른 객체입니다. Build 스코프에는 ObjectFactory가 없으니 Build 레지스트리는 부모로 올라가 BuildTree의 것을 돌려줬을 것입니다(이 마지막 부분은 소스의 찾는 순서로 추론한 것이고, 객체가 BuildTree의 것인지 따로 찍어 보지는 않았습니다).
헷갈리기 쉬운 곳
- 검사는 "선언한 타입"만 봅니다. 검사기는
registration.getDeclaredTypes(), 곧@Provides메서드의 반환 타입이나add()에 준 타입을 봅니다(64–68행). 구현 클래스는 보지 않습니다. 그래서 반환 타입을 구현 클래스로 적으면 그 클래스에@ServiceScope가 없어 strict 검사에 걸립니다. 이때@ServiceScope가 붙은 상위 타입이 정확히 하나 있으면, 오류 메시지가 "반환 타입을 서비스 타입으로 바꾸라"고 친절하게 안내합니다(97–106행, 159–169행). - 예외 목록이 있습니다.
ServiceScopeValidatorWorkarounds에 검사를 건너뛰는 클래스 이름 15개가 적혀 있습니다(Factory,Serializer,StartParameterInternal등, 25–58행). 설정 캐시와 엮인 사정 같은 이유가 주석으로 달려 있습니다. 모든 서비스가 검사를 받는 것은 아닙니다. @ServiceScope는@Inherited지만 인터페이스로는 물려받지 않습니다. 자바의@Inherited는 상위 클래스의 어노테이션만 물려줍니다. 인터페이스에@ServiceScope를 붙이고 그 인터페이스를 구현한 클래스를 서비스 타입으로 등록하면, 그 클래스에서getAnnotation()은null입니다(검사기의scopeOf(), 183–186행). 위 첫째 항목의 오류가 나는 경로입니다.
5. 모듈이 서비스를 보태는 길 — GradleModuleServices
1편에서 본 대로 Gradle 저장소는 플랫폼과 모듈로 나뉘어 있습니다. 설정 캐시(configuration-cache), 빌드 캐시, 의존성 관리 같은 모듈도 각 스코프에 서비스를 넣어야 하는데, subprojects/core의 BuildScopeServices가 그 모듈들을 전부 알면 의존 방향이 거꾸로 됩니다. 그래서 모듈이 스스로 신고하는 길이 있습니다. 인터페이스 GradleModuleServices에는 스코프 여덟 개마다 registerGlobalServices() … registerProjectServices() 메서드가 하나씩 있습니다(GradleModuleServices.java 29–94행). 모듈은 이 인터페이스를 구현하고(보통 빈 구현 AbstractGradleModuleServices를 물려받아 필요한 메서드만 고칩니다), jar의 META-INF/services/org.gradle.internal.service.scopes.GradleModuleServices 파일에 클래스 이름을 적습니다. subprojects/core의 파일에는 다섯 줄이 있습니다(CoreServices, BuildCacheServices, ExecutionServices, VirtualFileSystemServices, BuildEventServices).
이 파일을 읽는 것은 Global 레지스트리를 만들 때입니다.
public class BuildProcessScopeServices implements ServiceRegistrationProvider {
void configure(ServiceRegistration registration, ClassLoaderRegistry classLoaderRegistry) {
List<GradleModuleServices> servicesProviders = new DefaultServiceLocator(classLoaderRegistry.getRuntimeClassLoader(), classLoaderRegistry.getPluginsClassLoader()).getAll(GradleModuleServices.class);
for (GradleModuleServices services : servicesProviders) {
registration.add(GradleModuleServices.class, services);
services.registerGlobalServices(registration);
}
}
모듈 객체를 만들어 Global 서비스로 등록하고(registration.add(GradleModuleServices.class, services)), Global 몫을 등록하게 합니다. 그 뒤 다른 스코프의 레지스트리를 만들 때는 Global에 있는 이 객체들을 다시 꺼내 자기 몫을 등록하게 합니다. UserHome은 globalServices.getAll(GradleModuleServices.class)로 꺼내고(GradleUserHomeScopeServices.java 126–128행), Project는 configure()의 매개변수를 List<GradleModuleServices>로 받아 2절에서 본 컬렉션 주입으로 꺼냅니다(ProjectScopeServices.java 141–156행). 나머지 스코프도 같은 모양입니다(git grep "register…Services(registration)"로 스코프 여덟 개 모두 부르는 곳이 있는 것을 확인했습니다).
모듈 객체는 데몬에 한 벌만 있고, 레지스트리가 새로 생길 때마다 불려 나와 자기 몫을 보탭니다.
예제 3: 배포판에서 신고한 모듈 세어 보기
ex/u04-injection/run.sh의 끝에서 배포판의 jar를 모두 훑어 이 파일이 든 jar와 적힌 클래스 수를 셌습니다.
=== GradleModuleServices listed in the distribution's jars ===
jars with the file: 57, implementation classes listed: 68
org.gradle.internal.service.scopes.CoreServices
org.gradle.caching.internal.BuildCacheServices
org.gradle.internal.service.scopes.ExecutionServices
org.gradle.internal.service.scopes.VirtualFileSystemServices
org.gradle.internal.build.event.BuildEventServices
아래 다섯 줄은 gradle-core-9.10.0.jar의 파일 내용입니다. 소스 쪽도 세어 보면 src/main/resources에 이 파일이 57개이고, 빈 줄을 빼면 클래스 이름이 68줄입니다. 파일이 많은 플랫폼은 software 14개, core-configuration 12개, jvm 7개, core-runtime 6개 순서입니다. 배포판과 수가 같습니다.
헷갈리기 쉬운 곳
java.util.ServiceLoader가 아닙니다. 파일 이름과 형식은 자바 표준의 서비스 로더와 같지만, 읽는 것은 Gradle이 직접 짠DefaultServiceLocator입니다. 클래스로더마다getResources("META-INF/services/" + 타입 이름)을 부르고, 줄마다 클래스를 읽어 그 타입을 구현했는지 확인합니다(DefaultServiceLocator.java117–140행). Global 레지스트리에서는 런타임 클래스로더와 플러그인 클래스로더 두 곳을 함께 봅니다(위BuildProcessScopeServices39행).CoreServices가CoreBuildSessionServices를 넣습니다. 이름만 보면 세션 레지스트리의 기본 묶음 같지만,BuildSessionState가 직접 넣는 것은BuildSessionScopeServices이고CoreBuildSessionServices는 넣지 않습니다.subprojects/core도 하나의 모듈로서CoreServices.registerBuildSessionServices()에서addProvider(new CoreBuildSessionServices())로 넣습니다(CoreServices.java21–31행).BuildSessionState가build-state모듈에 있어서core를 모르기 때문으로 보입니다.
6. 사용자 코드까지 — @Inject와 _Decorated 클래스
여기까지는 Gradle 내부 객체끼리의 이야기였습니다. 이제 내가 쓴 태스크가 서비스를 받는 길을 봅니다. 공식 API로 태스크에 서비스를 받는 방법은 둘입니다. 생성자에 @Inject를 붙이거나, abstract 게터에 @Inject를 붙입니다. 예제 1의 빌드 스크립트에 두 가지를 다 쓰는 태스크를 넣었습니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// 생성자 주입과 게터 주입을 함께 쓰는 태스크
abstract class Probe @Inject constructor(
private val fs: FileSystemOperations // 생성자 매개변수 — 서비스에서 찾아 넣어 준다
) : DefaultTask() {
@get:Inject
abstract val objects: ObjectFactory // abstract 게터 — 생성된 하위 클래스가 구현한다
init {
// 누가 이 객체를 만드는지 — org.gradle 과 빌드 스크립트의 프레임만 골라 찍는다
println("Probe constructor, called through:")
Throwable().stackTrace
.filter { it.className.startsWith("org.gradle") || it.className.startsWith("Build_gradle") }
.take(16)
.forEach { println(" at ${it.className}.${it.methodName}:${it.lineNumber}") }
}
@TaskAction
fun run() {
println("task class = ${javaClass.name}")
println("superclass = ${javaClass.superclass.name}")
println("fs = ${fs.javaClass.name}")
println("objects = ${objects.javaClass.name}")
// 생성된 하위 클래스가 스스로 선언한 필드와 메서드
println("generated fields = " + javaClass.declaredFields.map { it.name }.sorted())
// ... 메서드 목록도 찍었지만 길어서 줄임 ...
}
}
// ... objects.newInstance() 로 만드는 Greeting 클래스 줄임(6절 뒤쪽에서 봅니다) ...
tasks.register<Probe>("probe")
[예제 ex/u04-injection/project/build.gradle.kts에서 줄임]
gradle probe의 나머지 출력입니다.
Probe constructor, called through:
at Build_gradle$Probe.<init>:20
at Build_gradle$Probe_Decorated.<init>:-1
at org.gradle.internal.instantiation.generator.AsmBackedClassGenerator$InvokeConstructorStrategy.newInstance:2114
at org.gradle.internal.instantiation.generator.AbstractClassGenerator$GeneratedClassImpl$GeneratedConstructorImpl.newInstance:592
at org.gradle.internal.instantiation.generator.DependencyInjectingInstantiator.doCreate:64
at org.gradle.internal.instantiation.generator.DependencyInjectingInstantiator.newInstanceWithDisplayName:50
at org.gradle.api.internal.project.taskfactory.TaskFactory$1.call:88
at org.gradle.api.internal.project.taskfactory.TaskFactory$1.call:82
at org.gradle.internal.UncheckedException.uncheckedCall:103
at org.gradle.api.internal.AbstractTask.injectIntoNewInstance:224
at org.gradle.api.internal.project.taskfactory.TaskFactory.create:82
at org.gradle.api.internal.project.taskfactory.AnnotationProcessingTaskFactory.create:47
at org.gradle.api.internal.tasks.DefaultTaskContainer.createTask:362
at org.gradle.api.internal.tasks.DefaultTaskContainer.access$200:77
at org.gradle.api.internal.tasks.DefaultTaskContainer$TaskCreatingProvider.createDomainObject:760
at org.gradle.api.internal.tasks.DefaultTaskContainer$TaskCreatingProvider.createDomainObject:717
> Task :probe
task class = Build_gradle$Probe_Decorated
superclass = Build_gradle$Probe
fs = org.gradle.api.internal.file.DefaultFileSystemOperations_Decorated
objects = org.gradle.api.internal.model.DefaultObjectFactory
generated fields = [__objects__, _gr_dn_, _gr_map_, _gr_mc_, _gr_owner_]
BUILD SUCCESSFUL in 1m 9s
스택은 위가 안쪽입니다(16개 프레임에서 잘랐고, 출력에서 generated methods 줄은 길어서 뺐습니다). 아래에서부터 읽으면, 등록만 해 둔 태스크를 실제로 만드는 DefaultTaskContainer(8편에서 봅니다) → 2절에서 본 태스크 팩토리 둘(AnnotationProcessingTaskFactory, TaskFactory) → AbstractTask.injectIntoNewInstance → DependencyInjectingInstantiator → AsmBackedClassGenerator$InvokeConstructorStrategy → Probe_Decorated의 생성자 → 내가 쓴 Probe의 생성자입니다. 실행 시간은 실행마다 다릅니다(이 실행은 데몬을 새로 띄워 1분 9초).
생성자 주입은 객체를 만들기 전에, 게터 주입은 처음 읽을 때 일어납니다.
어느 레지스트리를 쓰나 — 프로젝트 레지스트리
2절의 createTaskFactory()를 다시 보면 taskScheme.getInstantiationScheme().withServices(projectScopeServiceRegistry)였습니다. 태스크를 만드는 인스턴스화기에 프로젝트 레지스트리를 쥐여 준 것입니다. 그러니 태스크가 받을 수 있는 서비스는 예제 1에서 찍은 사슬, 곧 프로젝트 → Build → … → Global에 있는 모든 서비스입니다. TaskScheme이 쓰는 것은 instantiatorFactory.decorateScheme(), 곧 "장식하면서 주입하는" 방식입니다(ExecutionGlobalServices.java 217–219행).
생성자 매개변수 채우기
private Object[] addServicesToParameters(Class<?> type, ClassGenerator.GeneratedConstructor<?> constructor, ServiceLookup services, Object[] parameters) {
Class<?>[] parameterTypes = constructor.getParameterTypes();
Type[] genericTypes = constructor.getGenericParameterTypes();
Object[] resolvedParameters = new Object[parameterTypes.length];
int pos = 0;
for (int i = 0; i < resolvedParameters.length; i++) {
Class<?> targetType = parameterTypes[i];
Type serviceType = genericTypes[i];
if (pos < parameters.length) {
// ...
if (parameter == null || targetType.isInstance(parameter)) {
resolvedParameters[i] = parameter;
pos++;
continue;
}
}
Object service = services.find(serviceType);
if (service != null) {
resolvedParameters[i] = service;
continue;
}
TreeFormatter formatter = new TreeFormatter();
formatter.node("Unable to determine constructor argument #" + (i + 1) + ": ");
// ...
생성자 매개변수를 앞에서부터 보면서, 부르는 쪽이 직접 넘긴 인자(tasks.register<T>("이름", 인자…)의 인자)가 타입이 맞으면 그것을 쓰고, 아니면 services.find(타입)으로 레지스트리에서 찾습니다. 직접 넘긴 인자와 서비스를 섞어 받을 수 있는 이유입니다. 둘 다 아니면 실패합니다. 예제에 String 하나를 받는 태스크를 넣고 그것을 실행해 봤습니다.
// 주입할 수 없는 생성자 매개변수(String 은 서비스가 아니다)
abstract class Broken @Inject constructor(val label: String) : DefaultTask()
tasks.register<Broken>("broken")
=== gradle broken (expected to fail) ===
> Configure project :
* What went wrong:
> Could not create task ':broken'.
> Could not create task of type 'Broken'.
> Unable to determine constructor argument #1: missing parameter of type String, or no service of type String.
(grep으로 실패 메시지 줄만 남겼고, 그 뒤에 이어지는 > Run with … 안내 줄들과 BUILD FAILED 줄은 줄였습니다.) "인자로 받지도 않았고, String 타입의 서비스도 없다"는 뜻입니다. 생성자를 고르는 규칙은 InjectUtil.selectConstructor()에 있습니다. 생성자가 하나뿐이고 매개변수가 없으면(그리고 public이나 패키지 전용이면) 그대로 쓰고, 매개변수가 있으면 @Inject가 붙어 있어야 하며, 생성자가 여럿이면 @Inject가 붙은 것이 정확히 하나여야 합니다(InjectUtil.java 36–87행).
스레드 로컬 두 개
스택에 AbstractTask.injectIntoNewInstance와 InvokeConstructorStrategy.newInstance가 있었습니다. 둘 다 같은 기법을 씁니다. 앞의 것은 스레드 로컬 NEXT_INSTANCE에 태스크 이름과 프로젝트를 넣고 팩토리를 부른 뒤 finally에서 지웁니다(AbstractTask.java 221–228행). 뒤의 것이 이렇습니다.
@Override
public Object newInstance(ServiceLookup services, InstanceGenerator nested, @Nullable Describable displayName, Object[] params) throws InvocationTargetException, IllegalAccessException, InstantiationException {
ObjectCreationDetails previous = SERVICES_FOR_NEXT_OBJECT.get();
SERVICES_FOR_NEXT_OBJECT.set(new ObjectCreationDetails(nested, services, displayName, roleHandler));
try {
return constructor.newInstance(params);
} finally {
SERVICES_FOR_NEXT_OBJECT.set(previous);
}
}
생성자를 부르기 직전에 스레드 로컬에 "지금 만드는 객체가 쓸 것"(태스크 이름과 프로젝트, 레지스트리)을 넣고, 끝나면 치웁니다. 사용자가 쓰는 생성자는 DefaultTask()처럼 인자가 없으니, AbstractTask의 생성자는 스레드 로컬에서 프로젝트를 꺼내고(172–178행) 거기서 this.services = project.getServices()로 프로젝트 레지스트리를 잡습니다(198행). 생성된 클래스도 생성 중에는 스레드 로컬에서 레지스트리를 꺼냅니다(getServicesForNext(), 138–140행). 생성자 시그니처를 바꾸지 않고 숨은 인자를 건네는 방법입니다. 스레드 로컬에 값이 없을 때 AbstractTask 생성자가 던지는 메시지가 Task of type '…' has been instantiated directly which is not supported.(183행)입니다. 태스크를 new로 직접 만들 수 없는 이유입니다.
생성된 클래스가 하는 일
abstract 게터 getObjects()를 구현하는 것은 Probe_Decorated입니다. AsmBackedClassGenerator가 바이트코드를 만들 때 남긴 주석이 그 모양을 그대로 보여 줍니다.
@Override
public void applyServiceInjectionToGetter(PropertyMetadata property, @Nullable final Class<? extends Annotation> annotation, MethodMetadata getter) {
// GENERATE public <type> <getter>() { if (<field> == null) { <field> = <services>>.get(<service-type>>); } return <field> }
필드가 비었으면 레지스트리에서 get(서비스 타입)으로 찾아 필드에 넣고, 그다음부터는 필드를 돌려줍니다. 필드 이름은 "__" + 이름 + "__"입니다(propFieldName(), 1952–1954행). 예제가 찍은 generated fields의 __objects__가 바로 그 필드입니다.
그럼 <services>는 무엇일까요. 예제의 두 클래스가 다른 답을 보여 줍니다. 빌드 스크립트에서 objects.newInstance(Greeting::class.java)로 만든 객체의 필드도 찍어 봤습니다.
objects.newInstance = Build_gradle$Greeting_Decorated (providers = DefaultProviderFactory_Decorated)
its generated fields = [__message__, _gr_dn_, _gr_dyn_, _gr_f_, _gr_map_, _gr_mc_, _gr_owner_, _gr_svcs_]
Greeting_Decorated에는 레지스트리를 담는 _gr_svcs_ 필드가 있고, Probe_Decorated에는 없습니다. 생성기는 클래스에 이미 ServiceRegistry를 돌려주는 services 프로퍼티가 있으면 자기 필드를 만들지 않고 그것을 씁니다.
@Override
public boolean claimPropertyImplementation(PropertyMetadata property) {
if (property.getName().equals("services") && property.isReadable() && ServiceRegistry.class.isAssignableFrom(property.getType())) {
hasServicesProperty = true;
return true;
}
return false;
}
태스크는 AbstractTask.getServices()(696행)가 있으니 게터가 this.getServices(), 곧 위에서 본 프로젝트 레지스트리를 부르고(putServiceRegistryOnStack(), 1332–1342행), 그런 프로퍼티가 없는 Greeting은 _gr_svcs_ 필드와 $gradleServices() 메서드를 따로 만듭니다(1763–1767행). DefaultProject도 getServices()가 있는 쪽입니다.
필드 이름은 예제에서 declaredFields로 찍은 실제 이름입니다.
빌드 스크립트의 project가 DefaultProject_Decorated였던 것도 같은 이유입니다. DefaultProject는 abstract 클래스이고, project.repositories, project.configurations, project.objects 같은 게터 상당수가 @Inject abstract입니다(예: getArtifacts()·getRepositories()·getConfigurations(), DefaultProject.java 556–566행). DefaultProject.java에 @Inject가 29번 나옵니다. 빌드 스크립트에서 repositories { … }를 처음 쓰는 순간, 생성된 게터가 프로젝트 레지스트리에서 RepositoryHandler를 꺼내 옵니다. Gradle 자신의 핵심 모델도 사용자 태스크와 똑같은 주입 장치를 씁니다.
왜 생성자 대신 게터인가
생성자 주입만 있으면 서비스를 하나 더 받고 싶을 때 생성자를 바꿔야 하고, 하위 클래스의 생성자도 모두 따라 바뀝니다. abstract 게터는 생성자를 건드리지 않고 "이 타입이 필요하다"를 선언만 하고, 실제 값은 Gradle이 만든 하위 클래스가 처음 읽을 때 가져옵니다. 쓰지 않는 서비스는 찾지도 않습니다. 3절의 게으른 싱글턴과 같은 생각이 사용자 객체까지 이어진 것입니다. Spring이 CGLIB으로 하위 클래스를 만들어 메서드를 가로채는 것(Spring 10편)과 기법은 닮았지만, 목적은 다릅니다. Spring의 프록시는 이미 있는 메서드에 부가 기능(트랜잭션 등)을 끼우고, Gradle의 _Decorated는 비어 있는(abstract) 메서드를 채웁니다.
헷갈리기 쉬운 곳
-
빌드 스크립트의 클래스가 스크립트의 것을 쓰면 안 됩니다. 예제를 처음 짤 때 스택을 찍는 함수를 스크립트 맨 위에
fun으로 두고 태스크 생성자에서 불렀더니, 빌드가 이렇게 실패했습니다.> Could not create task ':probe'. > Could not create task of type 'Probe'. > Class Build_gradle.Probe is a non-static inner class.Kotlin 스크립트에서 클래스가 스크립트 수준의 함수나 값을 쓰면 그 클래스는 스크립트 객체를 붙잡는 내부 클래스가 되고, Gradle은 그런 클래스를 만들지 못합니다. 함수를 클래스 안으로 옮겨서 풀었습니다. 위 예제 코드가 고친 모양입니다.
-
_Decorated만 있는 것이 아닙니다.DefaultInstantiatorFactory는 생성자에서 생성기를 둘 만듭니다. 장식하며 주입하는decorateAndInject(접미사_Decorated)와 주입만 하는injectOnly(접미사$Inject_뒤에 번호)입니다(DefaultInstantiatorFactory.java61–62행,AsmBackedClassGenerator.java179–223행)._Decorated라는 이름을 고정해 둔 것은 IDE 연동이 이 이름에 기대기 때문이라는 TODO 주석이 있습니다(189–190행). 태스크·ObjectFactory로 만든 객체는 장식 쪽입니다(예제가 찍은 이름 그대로). -
주입받을 수 있는 것은 공개 API만이 아닙니다. 태스크의 생성자는 프로젝트 레지스트리에서 찾으므로, 타입만 알면
org.gradle.internal의 서비스도 받아집니다. 하지만 공식 문서가 주입 대상으로 안내하는 것은ObjectFactory,FileSystemOperations,ExecOperations,ProviderFactory같은 공개 서비스입니다(이 목록은 소스가 아니라 사용자 가이드에서 온 것입니다). 내부 서비스는 다음 판에서 이름이 바뀌어도 할 말이 없습니다.
7. Spring의 IoC 컨테이너와 견주면
같은 "DI 컨테이너"라도 생김새가 꽤 다릅니다. 이 글에서 소스로 확인한 Gradle 쪽과, Spring 연재에서 확인한 Spring 쪽을 나란히 놓으면 이렇습니다.
| Gradle 서비스 레지스트리 | Spring BeanFactory/ApplicationContext |
|
|---|---|---|
| 계층 | 스코프마다 레지스트리, 부모가 여럿일 수 있다 | 부모 팩토리 하나(HierarchicalBeanFactory, 3편) |
| 선언 | 제공자 클래스의 @Provides create…() / configure() |
BeanDefinition(@Bean, 컴포넌트 스캔 등) |
| 찾는 열쇠 | 타입뿐 | 이름과 타입 |
| 후보가 둘이면 | 그 레지스트리 안에서는 무조건 오류 | @Primary·@Qualifier·이름 등으로 고른다(5편) |
| 만드는 때 | 처음 찾을 때(게으름) | 시작할 때 non-lazy 싱글턴을 미리(4편) |
| 순환 의존 | BINDING 상태로 감지해 오류 |
필드·세터 주입은 3단계 캐시로 푼다(4편) |
| 수명 검사 | @ServiceScope + ServiceScopeValidator가 등록할 때 검사 |
빈마다 scope를 고르고, 등록할 때 이런 검사는 하지 않는다 |
| 사용자 객체 | _Decorated 하위 클래스가 abstract 게터를 채운다 |
CGLIB 하위 클래스가 메서드를 가로챈다(10편) |
Gradle 쪽이 더 단순하고 엄격합니다. 열쇠는 타입 하나, 모호하면 실패, 스코프가 틀리면 실패입니다. 사용자가 컨테이너를 직접 구성하는 Spring과 달리, Gradle의 레지스트리는 Gradle 개발자만 서비스를 등록하고 사용자는 꺼내 쓰기만 한다는 차이가 이런 선택으로 이어졌다고 보입니다(이 해석은 소스가 직접 말하는 것은 아닙니다).
화살표는 부모를 가리킵니다. Gradle 쪽 계층은 1절의 부모 사슬 그림에서 BuildSession 부분만 떼어 왔습니다.
이 글에서 뺀 것
- 비공개 서비스(
@PrivateService— 같은 제공자 안에서만 보이는 서비스)와 접근 토큰:DefaultServiceRegistry.java189–192행,ServiceAccess.java,ServiceAccessScope.java @Provides(value = {…})로 한 메서드가 여러 타입을 내놓는 경우:FactoryMethodService.serviceTypesOf()(866–869행)- 리스너 서비스(
@StatefulListener,@ListenerService)를 레지스트리가 알아서 이벤트에 붙이는 장치:AnnotatedServiceLifecycleHandler,platforms/core-runtime/messaging/…/internal/event/DefaultListenerManager.java - 생성된 클래스의 나머지 기능 — 확장(
extensions), 관례 매핑(ConventionMapping), Groovy DSL 지원(_gr_mc_,_gr_dyn_필드):AsmBackedClassGenerator.java,AbstractClassGenerator.java - 관리 프로퍼티(
abstract val message: Property<String>를 채우는 것):ManagedObjectFactory.java와AsmBackedClassGenerator의applyManagedStateToGetter()— 8편에서Property와 함께 봅니다 - 워커 프로세스와 함께 쓰는 서비스 묶음(
WorkerSharedProjectScopeServices— 이 글의 프로젝트 레지스트리에도 들어 있습니다)과 워커 쪽 레지스트리: 9편 - 설정 캐시가 서비스를 직렬화하지 않고 다시 찾아 끼우는 길(
ServiceScopeValidatorWorkarounds의 주석에 그 흔적이 있습니다): 11편
기준 커밋의 레지스트리 모듈 네 개(service-lookup, service-provider, service-registry-builder, service-registry-impl)는 9.8.2 출시판과 비교하면 ReflectionBasedServiceMethod.java에서 @SuppressWarnings 한 줄이 빠진 것 말고는 같습니다(git diff v9.8.2 07ecbf9ead로 확인). 레지스트리 자체는 지금 많이 쓰는 9.8.x에서도 이 글과 같다는 뜻입니다. 클래스 생성기(instantiation/generator)는 그사이 조금 바뀌었으니 줄 번호는 이 커밋에만 맞습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| 서비스 레지스트리 | 타입으로 서비스를 꺼내 주는 상자. 구현은 DefaultServiceRegistry 하나, 만드는 것은 ServiceRegistryBuilder |
| 스코프와 레지스트리 | 스코프마다 레지스트리 하나. 프로젝트 → Build → BuildTree → BuildSession → (UserHome, CrossBuildSession) → Global |
| 찾는 순서 | 자기 것 → 첫째 부모 전체 → 둘째 부모 전체. 자기 것에 둘이면 부모를 보지 않고 오류 |
제공자와 @Provides |
create…/decorate… 메서드가 팩토리, 반환 타입과 같은 매개변수가 있으면 장식, configure()는 바로 실행 |
| 게으른 싱글턴 | 등록은 포장만, 처음 찾을 때 bind, 처음 get()할 때 생성. 단 List<T>는 bind 때 만든다 |
| 순환·모호성 | 순환은 BINDING 상태로 감지해 오류, 같은 타입 둘은 찾을 때 오류 |
| 닫기 | 자기 서비스만, 쓰는 쪽부터 닫는다. 부모는 ParentServices가 막는다 |
@ServiceScope |
서비스 타입이 살 스코프. scopeStrictly 레지스트리는 등록하는 순간 검사한다 |
GradleModuleServices |
모듈 57개가 META-INF/services로 신고하고, 레지스트리가 생길 때마다 자기 스코프 몫을 등록한다 |
@Inject 생성자 |
DependencyInjectingInstantiator가 넘긴 인자 다음으로 프로젝트 레지스트리에서 찾는다 |
_Decorated |
ASM으로 만든 하위 클래스. abstract 게터를 "처음 읽을 때 get()하고 필드에 둔다"로 채운다 |
| 스레드 로컬 | 생성자 시그니처를 바꾸지 않고 프로젝트·레지스트리를 건네는 길 |
다음에 볼 것
이번 글로 데몬 안의 바탕이 갖춰졌습니다. 3편에서 데몬이 빌드를 받는 길을, 이번 글에서 그 안의 객체들이 서로를 찾는 길을 봤습니다. 다음은 그 위에서 빌드가 실제로 도는 순서입니다. ExecuteBuild 뒤의 실행기 사슬이 빌드 트리와 빌드를 만들고, DefaultBuildLifecycleController가 설정 로드 → 프로젝트 설정 → 작업 그래프 → 실행을 상태 전이로 지킵니다. 이번 글에서 본 레지스트리들이 바로 그 상태 객체들(BuildSessionState, BuildTreeState, AbstractBuildState)이 만드는 것입니다. 5편: 빌드 생명주기 — 초기화, 설정, 실행은 어디서 갈리나에서 이어집니다.
앞 연재에서 닮은 것을 다뤘습니다. Spring 3편은 부모·자식으로 이어진 BeanFactory 계층을, Spring 5편은 후보가 여럿일 때 하나를 고르는 규칙을 읽었습니다. Gradle은 그 고르는 규칙을 아예 두지 않은 쪽입니다. Spring 10편의 CGLIB 프록시는 이번 글의 _Decorated와 같은 "실행 중에 하위 클래스 만들기"입니다. 스코프마다 따로 사는 레지스트리는 웹앱마다 따로 사는 클래스로더(Tomcat 4편)와 생각이 닮았습니다. 둘 다 부모의 것은 보되 형제와 자식은 모르게 해서, 하나를 버릴 때 나머지가 다치지 않게 합니다.