namalzi

← 글 목록

Gradle 소스 파헤치기 4: 서비스 레지스트리 — Gradle 안의 DI 컨테이너

Gradle · 게시

태스크 생성자에 @Inject를 붙이면 FileSystemOperations가 들어오는 것은 누구 덕분일까요. 스코프마다 하나씩 만들어 부모를 따라 서비스를 찾는 DefaultServiceRegistry, @Provides 메서드로 서비스를 선언하는 규칙과 장식(decorator), 처음 찾을 때 만드는 게으른 싱글턴, 서비스가 제 스코프에 있는지 보는 ServiceScopeValidator, 모듈 57개가 스코프마다 서비스를 보태는 GradleModuleServices, 그리고 abstract 게터를 채운 _Decorated 클래스까지, 레지스트리를 빌드 밖에서 직접 써 보고 태스크 생성 스택을 찍어 소스와 함께 확인합니다.

이 글에서 다루는 것

"Gradle 소스 파헤치기" 연재의 네 번째 글입니다. 3편에서는 gradle 명령이 데몬을 찾거나 띄워 빌드를 부탁하고, 데몬이 명령 사슬을 거쳐 빌드를 시작하는 데까지 봤습니다. 그 데몬 안에서 일하는 객체들, 예를 들어 파일 해시를 기억하는 캐시, 태스크를 만드는 팩토리, 의존성을 해석하는 서비스는 서로를 어떻게 찾을까요. 이번 글은 그 답인 서비스 레지스트리를 읽습니다.

2편의 지도로 돌아가 보면, 2편은 상태의 계층(빌드 프로세스 → 세션 → 빌드 트리 → 빌드 → 프로젝트)과 그것에 1:1로 짝지어진 서비스 스코프를 그렸습니다. 이번 글은 그 그림에서 "스코프마다 하나씩 있는 서비스 상자"를 열어 봅니다. 상자는 어떻게 생겼고, 상자끼리는 어떻게 이어지며, 내가 쓴 태스크 클래스는 그 상자에서 어떻게 서비스를 받는지가 이 글의 질문입니다.

항목 내용
대상 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();

platforms/core-runtime/build-state/src/main/java/org/gradle/internal/buildprocess/BuildProcessState.java 46–57행

빌더에 주는 것은 네 가지입니다. 어느 스코프인지(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편). 읽을 것은 마지막 줄입니다. 대괄호 하나가 "자기 서비스, 그다음 부모들"입니다. 풀어서 그리면 이렇습니다.

서비스 레지스트리의 부모 사슬. ① project services(Project 스코프)의 부모는 ② Build-scoped services, 그 부모는 ③ build tree services, 그 부모는 ④ build session services다. build session services는 부모가 둘로, 첫째가 ⑤ services for Gradle user home dir(UserHome), 둘째가 ⑦ cross session services(CrossBuildSession)다. 두 부모 모두 ⑥ Global services를 부모로 두고, Global services의 부모는 logging services와 native services 둘이다. 번호는 서비스를 찾는 순서입니다. 노란 상자는 빌드가 끝나도 데몬 안에 남는 레지스트리입니다.

왜 상자를 여럿으로 나눴나

1편에서 봤듯이 Gradle 데몬은 여러 빌드를 차례로 받는 오래 사는 프로세스입니다. 그러니 객체마다 수명이 다릅니다. 파일 해시 캐시처럼 빌드가 끝나도 남겨 둬야 다음 빌드가 빨라지는 것이 있고, 이번 빌드의 프로젝트 모델처럼 빌드가 끝나면 반드시 버려야 하는 것이 있습니다. 레지스트리를 스코프마다 따로 두면 "빌드가 끝났다 = Build 레지스트리를 닫는다"가 되고, 그 안의 서비스가 한꺼번에 정리됩니다. 자식은 부모의 서비스를 쓸 수 있지만 부모는 자식을 모르므로, 오래 사는 서비스가 곧 사라질 객체를 붙잡는 일도 구조로 막힙니다.

@ServiceScope의 설명 주석이 이 규칙을 그대로 적어 둡니다. "서비스는 같은 스코프와 그 아래 스코프의 서비스에게 보이고, 위 스코프에게는 보이지 않는다"(ServiceScope.java 26–38행).

헷갈리기 쉬운 곳

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

platforms/core-runtime/service-registry-impl/src/main/java/org/gradle/internal/service/RelevantMethods.java 75–96행

이름과 어노테이션이 어긋나면 바로 예외입니다. 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);
        }
    }

platforms/core-runtime/service-registry-impl/src/main/java/org/gradle/internal/service/DefaultServiceRegistry.java 172–187행

눈여겨볼 것이 둘 있습니다.

configure 안에서 쓰는 registration.add(...)에는 두 가지가 있습니다. 이미 만든 객체를 넣는 add(타입, 객체)는 FixedInstanceService가 되고, 구현 클래스를 넣는 add(타입, 구현 클래스)는 그 클래스의 생성자를 나중에 부르는 ConstructorService가 됩니다(235–264행). 생성자 매개변수도 @Provides 메서드의 매개변수처럼 서비스에서 찾아 넣습니다. 결국 서비스 하나는 아래 셋 가운데 하나로 포장됩니다.

레지스트리 안의 서비스 종류. ServiceProvider 인터페이스(getService, getAll)를 ManagedObjectServiceProvider가 구현한다. 이것은 getInstance()를 처음 부를 때 객체를 만들고, stop()에서 자기를 쓰는 서비스부터 닫는다. 그 아래 SingletonService가 prepare()로 UNBOUND, BINDING, BOUND 상태를 오간다. SingletonService의 하위 클래스는 이미 만든 객체를 담는 FixedInstanceService와, bind()로 매개변수 서비스를 찾고 createServiceInstance()로 만드는 FactoryService다. FactoryService의 하위 클래스는 @Provides 메서드를 부르는 FactoryMethodService와 생성자를 부르는 ConstructorService다. 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);
    }

platforms/core-runtime/service-registry-impl/src/main/java/org/gradle/internal/service/MethodHandleBasedServiceMethodFactory.java 34–44행

메서드와 그 클래스가 둘 다 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;
    }

platforms/core-runtime/service-registry-impl/src/main/java/org/gradle/internal/service/DefaultServiceRegistry.java 106–121행

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

platforms/core-runtime/service-registry-impl/src/main/java/org/gradle/internal/service/DefaultServiceRegistry.java 412–449행

getProviders()는 타입을 열쇠로 하는 맵 providersByType에서 후보를 꺼냅니다. 이 맵은 서비스를 등록할 때 선언한 타입과 그 상위 타입 전부(Object만 빼고)를 열쇠로 채워 둡니다(collectProvidersForClassHierarchyOf(), 1466–1493행). 그래서 FileSystemOperations로 등록한 서비스를 그 상위 인터페이스로 찾아도 나옵니다. 그리고 후보가 0개면 null(그러면 부모에게 갑니다), 1개면 그것, 둘 이상이면 예외입니다. Spring처럼 @Primary나 이름으로 하나를 고르는 장치가 없습니다(Spring 5편과 비교해 보세요).

같은 맵 때문에 막힌 것이 하나 더 있습니다. 상위 타입을 훑다가 ServiceRegistry가 나오면 예외를 던집니다(1477–1480행). ServiceRegistry는 레지스트리마다 자기 자신이 미리 들어 있는 특별한 서비스이기 때문입니다(OwnServices 생성자, 400–410행). 2절의 표에서 "ServiceRegistry 매개변수는 레지스트리 자신을 받는다"고 한 것이 이것입니다.

서비스 하나를 찾는 순서. 매개변수 타입이 List이면 자기 것과 부모 전부를 돌며 T를 모두 모으고 그 자리에서 만든다. 아니면 자기 서비스의 providersByType 맵(타입과 그 상위 타입이 열쇠)에서 찾는다. 1개면 그것을 돌려주고, 2개 이상이면 ServiceLookupException(Multiple services of type ...)을 던지고, 0개면 첫째 부모의 전체에서, 못 찾으면 둘째 부모에서 찾는다. 끝까지 없으면 find는 null, get은 UnknownServiceException이다. 장식 메서드의 같은 타입 매개변수는 자기 것을 건너뛰고 부모에서만 찾는다. 부모에게 넘어가는 것은 자기 것에서 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;
            }
        }

platforms/core-runtime/service-registry-impl/src/main/java/org/gradle/internal/service/DefaultServiceRegistry.java 680–700행

그다음 누군가 객체를 달라고 하면 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

get(Report) 한 번의 흐름. ① 부르는 쪽이 get(Report)로 prepare()를 부른다. ② bind 단계에서 Report 서비스는 Greeter를 찾기만 한다. ③ List는 bind 단계에서 그 자리에서 get()하므로 Plugin java와 core가 이때 만들어진다. ④ 이어 getInstance()가 createServiceInstance()를 부르고, ⑤ 매개변수 Greeter 장식을 get()하면 ⑥ 장식이 부모의 Greeter를 get()하고 ⑦ 그것이 Clock을 get()한다. Clock, 전역 Greeter, 장식 순서로 만들어지고 ⑧ 마지막에 Report가 만들어져 기억된다. 대괄호는 예제 출력의 로그 줄입니다. 찾기(bind)와 만들기(create)가 나뉘어 있어서 Plugin이 먼저 만들어졌습니다.

헷갈리기 쉬운 곳

닫을 때

    @Override
    public void close() {
        noLongerMutable();
        if (state.compareAndSet(State.STARTED, State.CLOSED)) {
            CompositeStoppable.stoppable(allServices).stop();
        }
    }

platforms/core-runtime/service-registry-impl/src/main/java/org/gradle/internal/service/DefaultServiceRegistry.java 299–305행

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

platforms/core-runtime/service-registry-impl/src/main/java/org/gradle/internal/service/ServiceScopeValidator.java 70–95행

규칙은 둘입니다. 타입에 @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단계의 모호성 오류가 찾을 때 났던 것과 다릅니다. 검사는 등록하는 순간 하므로, 서비스를 한 번도 찾지 않아도 잘못된 스코프는 데몬이 레지스트리를 만들 때 바로 드러납니다. 개발 중에 빨리 터지게 하려는 설계입니다.

ServiceScopeValidator.validateScope()의 판단 순서. 서비스가 레지스트리에 등록되면 ScopedServiceRegistry 에 들어 있는 ServiceScopeValidator 가 알림을 받고, 선언된 타입마다 validateScope()를 돈다. 검사기 자신이거나 ServiceScopeValidatorWorkarounds 목록에 있으면 통과한다. 아니면 타입에 @ServiceScope 가 있는지 본다. 없을 때는 strict(scopeStrictly 로 만든 레지스트리)이면 "is registered in the 'Build' scope but does not declare it" IllegalArgumentException 이 나고(예제 2의 6단계 Unscoped), strict 가 아니면 통과한다. 있을 때는 스코프 목록이 비었으면 "declared with empty scope list" 예외가 나고, 목록에 이 레지스트리의 스코프가 들어 있으면 통과, 없으면 "declares service scope 'Global' but is registered in the 'Build' scope" 예외가 난다(예제 2의 5단계 Clock). 위 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의 것인지 따로 찍어 보지는 않았습니다).

헷갈리기 쉬운 곳

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

platforms/core-runtime/build-state/src/main/java/org/gradle/internal/buildprocess/BuildProcessScopeServices.java 37–44행

모듈 객체를 만들어 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)"로 스코프 여덟 개 모두 부르는 곳이 있는 것을 확인했습니다).

모듈이 서비스를 보태는 길. jar 57개의 META-INF/services/...GradleModuleServices 파일을 DefaultServiceLocator.getAll()이 읽어 클래스를 객체로 만든다. Global 레지스트리를 만드는 BuildProcessScopeServices.configure()가 이 GradleModuleServices 객체들을 Global 서비스로도 등록하고 registerGlobalServices()를 부른다. 이후 UserHome 레지스트리를 만들 때 getAll이나 List 주입으로 다시 꺼내 registerGradleUserHomeServices()를, BuildSession·CrossBuildSession, BuildTree·Build·Settings, Project 레지스트리를 만들 때 각각 자기 스코프의 register 메서드를 부른다. 모듈 객체는 데몬에 한 벌만 있고, 레지스트리가 새로 생길 때마다 불려 나와 자기 몫을 보탭니다.

예제 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개 순서입니다. 배포판과 수가 같습니다.

헷갈리기 쉬운 곳

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초).

태스크가 만들어지며 서비스를 받는 순서. ① TaskFactory가 AbstractTask.injectIntoNewInstance로 스레드 로컬에 프로젝트와 태스크 이름을 둔다. ② DependencyInjectingInstantiator의 newInstanceWithDisplayName(Probe)을 부른다. ③ 인스턴스화기는 생성자 매개변수 FileSystemOperations를 프로젝트 레지스트리에서 find()해 DefaultFileSystemOperations_Decorated를 받는다. ④ InvokeConstructorStrategy의 newInstance(services, ...)를 부르면 ⑤ 스레드 로컬에 services를 두고 ⑥ Probe_Decorated 생성자를 fs와 함께 부른다. ⑦ Probe 생성자와 AbstractTask 생성자가 돈다. 나중에 태스크 액션에서 objects를 처음 읽을 때 ⑧ 생성된 게터가 프로젝트 레지스트리에서 get(ObjectFactory)를 하고 필드에 넣어 둔다. 생성자 주입은 객체를 만들기 전에, 게터 주입은 처음 읽을 때 일어납니다.

어느 레지스트리를 쓰나 — 프로젝트 레지스트리

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) + ": ");
            // ...

platforms/core-configuration/model-core/src/main/java/org/gradle/internal/instantiation/generator/DependencyInjectingInstantiator.java 165–193행

생성자 매개변수를 앞에서부터 보면서, 부르는 쪽이 직접 넘긴 인자(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);
            }
        }

platforms/core-configuration/model-core/src/main/java/org/gradle/internal/instantiation/generator/AsmBackedClassGenerator.java 2109–2118행

생성자를 부르기 직전에 스레드 로컬에 "지금 만드는 객체가 쓸 것"(태스크 이름과 프로젝트, 레지스트리)을 넣고, 끝나면 치웁니다. 사용자가 쓰는 생성자는 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> }

platforms/core-configuration/model-core/src/main/java/org/gradle/internal/instantiation/generator/AsmBackedClassGenerator.java 1093–1095행

필드가 비었으면 레지스트리에서 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;
        }

platforms/core-configuration/model-core/src/main/java/org/gradle/internal/instantiation/generator/AbstractClassGenerator.java 1414–1421행

태스크는 AbstractTask.getServices()(696행)가 있으니 게터가 this.getServices(), 곧 위에서 본 프로젝트 레지스트리를 부르고(putServiceRegistryOnStack(), 1332–1342행), 그런 프로퍼티가 없는 Greeting은 _gr_svcs_ 필드와 $gradleServices() 메서드를 따로 만듭니다(1763–1767행). DefaultProject도 getServices()가 있는 쪽입니다.

생성된 클래스의 모양. AbstractTask는 getServices()로 프로젝트 레지스트리를 돌려준다. DefaultTask를 거쳐 그것을 물려받은 abstract 클래스 Probe에는 abstract 게터 getObjects()가 있고, 생성된 Probe_Decorated가 objects 필드를 두고 getObjects()를 구현해 필드가 비었으면 getServices().get()으로 찾는다. abstract 클래스 Greeting에는 abstract 게터 getMessage()가 있고, 생성된 Greeting_Decorated는 gr_svcs 필드(ServiceLookup)와 message 필드를 두며, $gradleServices()가 필드를, 없으면 스레드 로컬을 돌려준다. 필드 이름은 예제에서 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) 메서드를 채웁니다.

헷갈리기 쉬운 곳

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 서비스 레지스트리에서는 BuildSession 레지스트리의 부모가 둘이다 — 첫째 부모 UserHome 레지스트리와 둘째 부모 CrossBuildSession 레지스트리 — 그리고 둘 다 Global 레지스트리를 부모로 둔다. 찾을 때 열쇠는 타입뿐이고, 한 레지스트리에 후보가 둘이면 오류이며, 서비스는 처음 찾을 때 만든다. 오른쪽 Spring BeanFactory·ApplicationContext 에서는 자식 컨텍스트가 부모 팩토리 하나(HierarchicalBeanFactory)를 둔다. getBean 은 이름이나 타입으로 찾고, 후보가 둘이면 @Primary·@Qualifier·이름으로 고르며, non-lazy 싱글턴은 시작할 때 미리 만든다. 화살표는 부모를 가리킵니다. Gradle 쪽 계층은 1절의 부모 사슬 그림에서 BuildSession 부분만 떼어 왔습니다.

이 글에서 뺀 것

기준 커밋의 레지스트리 모듈 네 개(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편)와 생각이 닮았습니다. 둘 다 부모의 것은 보되 형제와 자식은 모르게 해서, 하나를 버릴 때 나머지가 다치지 않게 합니다.

다른 글