namalzi

← 글 목록

Gradle 소스 파헤치기 12: 의존성 해석 — implementation 한 줄이 jar 파일이 되기까지, 그리고 연재를 마치며

Gradle · 게시

build.gradle.kts의 implementation 한 줄은 소스에서 무엇이 될까요. 생성된 접근자가 부르는 DependencyHandler.add(), 선언·해석·소비로 역할이 나뉜 구성(configuration), 파일 목록을 달라고 할 때까지 미뤄지는 해석, DependencyGraphBuilder가 그래프를 넓혀 가는 반복문, 같은 라이브러리의 두 버전이 부딪칠 때 높은 쪽을 고르는 LatestModuleConflictResolver와 strictly·force가 그 결과를 바꾸는 자리, 캐시를 먼저 보고 저장소로 가는 두 번의 탐색과 --offline·읽기 전용 캐시까지 기준 커밋의 소스와 도커 예제로 확인하고, 12편 연재를 정리합니다.

이 글에서 다루는 것

"Gradle 소스 파헤치기" 연재의 열두 번째이자 마지막 글입니다. 11편에서는 태스크의 결과를 빌드 캐시로, 설정 단계의 결과를 설정 캐시로 다시 쓰는 두 가지 길을 봤습니다. 이번 글은 지금까지 미뤄 둔 마지막 구역, implementation("…") 한 줄이 컴파일러에게 건네는 jar 파일 목록이 되기까지를 따라갑니다.

2편 지도에서 이 구역은 F 의존성 해석이었고, 다른 구역과 점선으로 이어져 있었습니다. 의존성 해석은 초기화·설정·실행 가운데 어느 한 단계에 묶여 있지 않기 때문입니다. 선언은 설정 단계(D 구역)에서 하고, 해석은 누군가 파일 목록을 처음 달라고 할 때 — 보통 실행 단계(E 구역)에서 — 일어납니다. 이 글에서 그 점선이 실제로 어디를 지나는지 스택으로 확인합니다.

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

항목 내용
대상 Gradle Build Tool
버전 9.10.0 개발판 (master)
커밋 07ecbf9ead (2026-10-24)
주로 읽는 클래스 dsl/dependencies/DefaultDependencyHandler, configurations/DefaultConfiguration·ConfigurationRoles, ivyservice/DefaultConfigurationResolver·ResolutionExecutor, resolveengine/graph/builder/DependencyGraphBuilder·ModuleResolveState·EdgeState, resolveengine/graph/selectors/SelectorStateResolver, resolveengine/LatestModuleConflictResolver, ivyresolve/RepositoryChainComponentMetaDataResolver·CachingModuleComponentRepository

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 이 글의 클래스는 거의 다 platforms/software/dependency-management/ 한 서브프로젝트에 있습니다. 이 서브프로젝트 하나에 main 소스 파일이 천 개가 넘습니다(변형 고르기, 아티팩트 변환, 의존성 잠금, 검증, Ivy 저장소…). 이 글은 큰 줄기만 골랐고, 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적었습니다.

먼저 알아 둘 말

모듈(module)과 컴포넌트(component)는 저장소에 올라간 라이브러리 하나를 가리킵니다. group:name(예: org.apache.commons:commons-lang3)까지가 모듈이고, 버전까지 붙인 group:name:version이 컴포넌트 하나입니다. 소스에서도 ModuleIdentifier와 ModuleComponentIdentifier로 나뉩니다.

구성(configuration)은 의존성을 담는 이름 붙은 바구니입니다. implementation, compileClasspath, runtimeClasspath가 모두 구성입니다. 빌드 스크립트의 configurations { } 블록에서 보던 그것이고, 소스의 클래스는 DefaultConfiguration입니다. 구성은 다른 구성을 물려받을(extendsFrom) 수 있습니다.

해석(resolution)은 구성에 담긴 선언을 실제 컴포넌트의 그래프로, 다시 파일 목록으로 바꾸는 일입니다. 선언에는 "commons-text 1.12.0이 필요하다"만 있고, commons-text가 무엇을 끌고 오는지, 그 jar가 디스크 어디에 있는지는 해석해야 압니다.

메타데이터(metadata)는 라이브러리가 "나는 무엇에 의존한다"를 적어 둔 파일입니다. Maven 저장소에서는 POM(.pom, XML)이 기본이고, Gradle로 출판한 라이브러리에는 Gradle 모듈 메타데이터(.module, JSON)가 함께 올라가기도 합니다.

변형(variant)과 속성(attribute): 컴포넌트 하나가 쓰임새에 따라 여러 얼굴을 갖는 것을 변형이라고 합니다. "컴파일할 때 쓰는 것(API)", "실행할 때 쓰는 것(runtime)"처럼요. 각 변형에는 org.gradle.usage=java-api 같은 속성이 붙어 있고, 해석하는 쪽 구성도 원하는 속성을 들고 있어서 둘을 맞춰 변형을 고릅니다.

의존성 캐시(dependency cache)는 내려받은 메타데이터와 jar를 모아 두는 곳, ~/.gradle/caches/modules-2/입니다. 11편의 빌드 캐시(태스크 결과)와는 다른 캐시입니다.

1. 큰 그림: 선언 → 그래프 → 파일

먼저 전체를 한 장으로 보겠습니다. implementation(…) 한 줄이 jar 목록이 되기까지는 세 칸입니다.

의존성 해석의 큰 그림. build.gradle.kts의 implementation 한 줄이 ① 설정 단계에서 DefaultDependencyHandler.add()를 거쳐 Dependency 객체가 되어 구성 implementation에 담긴다. 구성들은 implementation·compileOnly에서 compileClasspath로, implementation·runtimeOnly에서 runtimeClasspath로 이어지는 나무를 이룬다. 여기까지는 아무것도 해석하지 않는다. 누군가 파일 목록을 달라고 할 때(보통 실행 단계에서 compileJava의 입력 지문을 뜰 때) ② 그래프 해석(Resolve dependencies of :compileClasspath, DependencyGraphBuilder와 충돌 해결)이 메타데이터(POM·.module, 저장소와 의존성 캐시)를 읽으며 일어나고, ③ 파일 해석(Resolve files of configuration)이 고른 변형의 jar를 찾아 javac의 -classpath가 될 jar 파일 목록을 만든다. 파란 칸이 이 글의 2·4~7절, 노란 칸이 3·7절입니다. 점선이 2편 지도에서 F 구역을 점선으로 그린 이유입니다.

  1. 선언(2절) — 설정 단계에서 빌드 스크립트가 돌 때 Dependency 객체를 만들어 구성에 넣기만 합니다. 네트워크도 디스크도 건드리지 않습니다.
  2. 그래프 해석(4~6절) — 루트(이 프로젝트의 구성)에서 출발해 메타데이터를 읽으며 "누가 누구에게 의존하는가"의 그래프를 넓히고, 같은 모듈의 버전이 여럿이면 하나를 고릅니다. 빌드 오퍼레이션 이름은 Resolve dependencies of :compileClasspath입니다.
  3. 파일 해석(4·7절) — 그래프에서 고른 변형마다 실제 파일(jar)을 캐시에서 찾거나 내려받습니다. 빌드 오퍼레이션 이름은 Resolve files of configuration ':compileClasspath'입니다.

2와 3이 나뉘어 있다는 것이 이 글에서 여러 번 나옵니다. 6절에서는 그래프는 멀쩡히 만들어졌는데 jar가 없어 컴파일이 실패하는 모습을 실제로 봅니다.

2. implementation 한 줄이 Dependency 객체가 되기까지

생성된 접근자는 add() 한 줄입니다

Kotlin DSL에서 implementation("…")을 쓸 수 있는 것은 6편에서 본 생성된 접근자(accessor) 덕분입니다. java 플러그인이 implementation 구성을 만들면, Gradle이 그 이름의 확장 함수를 만들어 스크립트 컴파일에 넣어 줍니다. 그 함수의 모양을 정하는 템플릿이 이것입니다.

                    fun DependencyHandler.`$kotlinIdentifier`(dependencyNotation: Any): Dependency? =
                        add("$stringLiteral", dependencyNotation)

platforms/core-configuration/kotlin-dsl/src/main/kotlin/org/gradle/kotlin/dsl/accessors/AccessorFragments.kt 229–230행

$kotlinIdentifier와 $stringLiteral 자리에 구성 이름이 들어갑니다. 그러니 implementation("org.apache.commons:commons-text:1.12.0")은 dependencies.add("implementation", "org.apache.commons:commons-text:1.12.0")과 똑같습니다. Groovy DSL의 implementation '…'도 결국 같은 add()에 닿습니다(Groovy 쪽은 DynamicAddDependencyMethods가 없는 메서드 호출을 받아 구성 이름으로 바꿉니다).

DefaultDependencyHandler.add()

dependencies { } 블록의 this, 즉 DependencyHandler의 실제 클래스는 DefaultDependencyHandler입니다.

    @Override
    @SuppressWarnings("rawtypes")
    public Dependency add(String configurationName, Object dependencyNotation, @Nullable Closure configureClosure) {
        return doAdd(configurationContainer.getByName(configurationName), dependencyNotation, configureClosure);
    }

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/dsl/dependencies/DefaultDependencyHandler.java 115–119행

먼저 이름으로 구성을 찾습니다(getByName — 없으면 여기서 실패합니다). 그다음 doAdd()가 표기(notation)의 종류에 따라 길을 나눕니다.

    private Dependency doAdd(Configuration configuration, Object dependencyNotation, @Nullable Closure configureClosure) {
        if (dependencyNotation instanceof Configuration) {
            throw new GradleException("Adding a Configuration as a dependency is no longer allowed as of Gradle 8.0.");
        } else if (dependencyNotation instanceof ProviderConvertible<?>) {
            return doAdd(configuration, ((ProviderConvertible<?>) dependencyNotation).asProvider(), configureClosure);
        } else if (dependencyNotation instanceof ProviderInternal<?>) {
            // ... (버전 카탈로그의 번들이면 하나씩 풀어 넣는다)
            return doAddProvider(configuration, provider, configureClosure);
        } else if (dependencyNotation instanceof Provider<?>) {
            return doAddProvider(configuration, (Provider<?>) dependencyNotation, configureClosure);
        } else {
            return doAddRegularDependency(configuration, dependencyNotation, configureClosure);
        }
    }

    private Dependency doAddRegularDependency(Configuration configuration, Object dependencyNotation, Closure<?> configureClosure) {
        Dependency dependency = create(dependencyNotation, configureClosure);
        configuration.getDependencies().add(dependency);
        return dependency;
    }

DefaultDependencyHandler.java 172–198행

문자열은 맨 아래 doAddRegularDependency()로 갑니다. create()로 Dependency 객체를 만들고, 구성의 의존성 집합에 add() 하는 것이 전부입니다. 반대로 Provider(버전 카탈로그의 libs.xxx가 그렇습니다)는 doAddProvider()로 가서 addLater()로 넣습니다. 값을 꺼내지 않고 "나중에 넣을 것"으로 적어 두는 것으로, 8편의 지연 설정과 같은 생각입니다. 그래서 이 경우 add()는 null을 돌려줍니다.

create()는 DefaultDependencyFactory.createDependency()를 부르고, 이것이 표기 파서(DependencyNotationParser)에게 넘깁니다. 파서는 문자열·맵·Project·FileCollection 같은 표기 종류마다 변환기를 하나씩 들고 있습니다. 문자열 변환기(DependencyStringNotationConverter.java 58–69행)는 group:name:version을 콜론으로 나누고, 버전 문자열을 StrictVersionParser로 한 번 더 읽고(1.0!! 같은 "엄격한 버전" 표기를 알아보려는 것입니다 — 6절의 strictly와 같은 것), instantiator.newInstance(wantedType, …)로 객체를 만듭니다. wantedType은 DependencyNotationParser에서 DefaultExternalModuleDependency.class로 정해져 있습니다(DependencyNotationParser.java 56–57행).

예제 1: 돌려받은 객체와 구성들의 역할

java 플러그인만 건 프로젝트에서 implementation(…)이 돌려준 객체를 찍고, java 플러그인이 만든 구성들의 역할(3절)도 함께 찍었습니다(ex/u12-configurations). 이 예제는 기준 커밋(07ecbf9ead)을 그대로 빌드한 Gradle 9.10.0 개발판으로 도커 컨테이너(Eclipse Temurin JDK 25)에서 실행했습니다. 이 글의 다른 예제도 모두 같습니다.

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

repositories { mavenCentral() }

dependencies {
    // implementation(...) 은 생성된 접근자 — 속은 dependencies.add("implementation", ...) 다
    val dep = implementation("org.apache.commons:commons-text:1.12.0")
    println("implementation(...) returned ${dep?.javaClass?.name}")
    println("  group=${dep?.group} name=${dep?.name} version=${dep?.version}")
}

// java 플러그인이 만든 구성들의 역할(선언·해석·소비)과 extendsFrom 관계를 찍는다
tasks.register("roles") {
    val rows = listOf(
        "implementation", "compileOnly", "runtimeOnly",
        "compileClasspath", "runtimeClasspath", "apiElements", "runtimeElements",
    ).map { name ->
        val c = configurations.getByName(name)
        // ... (c.isCanBeDeclared, c.isCanBeResolved, c.isCanBeConsumed, c.extendsFrom, c.javaClass.simpleName 을 한 줄로)
    }
    doLast { rows.forEach { println(it) } }
}
// ... resolveImplementation 태스크는 3절에서 ...

gradle roles --offline --console=plain -q의 출력입니다.

implementation(...) returned org.gradle.api.internal.artifacts.dependencies.DefaultExternalModuleDependency_Decorated
  group=org.apache.commons name=commons-text version=1.12.0
implementation    declare=true  resolve=false consume=false extendsFrom=[]  (DefaultDependencyScopeConfiguration_Decorated)
compileOnly       declare=true  resolve=false consume=false extendsFrom=[]  (DefaultDependencyScopeConfiguration_Decorated)
runtimeOnly       declare=true  resolve=false consume=false extendsFrom=[]  (DefaultDependencyScopeConfiguration_Decorated)
compileClasspath  declare=false resolve=true  consume=false extendsFrom=[compileOnly, implementation]  (DefaultResolvableConfiguration_Decorated)
runtimeClasspath  declare=false resolve=true  consume=false extendsFrom=[implementation, runtimeOnly]  (DefaultResolvableConfiguration_Decorated)
apiElements       declare=false resolve=false consume=true  extendsFrom=[]  (DefaultConsumableConfiguration_Decorated)
runtimeElements   declare=false resolve=false consume=true  extendsFrom=[implementation, runtimeOnly]  (DefaultConsumableConfiguration_Decorated)

첫 줄의 클래스 이름이 DefaultExternalModuleDependency_Decorated입니다. 4편에서 본 _Decorated 하위 클래스, 즉 Instantiator가 ASM으로 만든 클래스입니다. 의존성 하나도 태스크처럼 Gradle의 객체 생성 장치를 지나 만들어집니다. 그리고 이 객체는 버전 문자열 1.12.0만 들고 있습니다. commons-text가 무엇을 끌고 오는지는 아직 아무도 모릅니다.

implementation 한 줄이 Dependency 객체가 되기까지. build.gradle.kts 의 implementation("org.apache.commons:commons-text:1.12.0")은 생성된 접근자(AccessorFragments.kt 229–230행)라서 dependencies.add("implementation", …)와 같다. DefaultDependencyHandler.add()가 configurationContainer.getByName("implementation")으로 구성을 찾고(없는 구성이면 여기서 실패), doAdd()가 표기의 종류로 길을 나눈다. Configuration 을 넘기면 GradleException 이다(Gradle 8.0부터 허용하지 않음). Provider 나 ProviderConvertible(버전 카탈로그의 libs.xxx)은 doAddProvider()를 거쳐 addLater()로 넣고 add()는 null 을 돌려준다. 문자열 같은 그 밖의 표기는 doAddRegularDependency()로 가서, create()가 DefaultDependencyFactory 와 DependencyNotationParser 를 거쳐 DependencyStringNotationConverter 로 콜론을 나누고 버전을 StrictVersionParser 로 한 번 더 읽는다. 그 결과 DefaultExternalModuleDependency_Decorated(group=org.apache.commons, name=commons-text, version=1.12.0)가 만들어져 implementation 구성의 의존성 집합에 들어간다. 이 절의 코드를 한 길로 이었습니다. 초록 칸의 클래스 이름과 값은 예제 1의 실제 출력입니다. 여기까지는 아무것도 내려받지 않습니다.

헷갈리기 쉬운 곳

3. 구성 — 의존성을 담는 바구니, 역할 셋

예제 1의 출력에서 구성마다 declare·resolve·consume 세 값이 달랐습니다. 구성 하나가 할 수 있는 일은 셋입니다.

예전 Gradle에서는 구성 하나가 셋 모두를 할 수 있었고, 그래서 "담는 바구니"와 "해석하는 바구니"를 헷갈리기 쉬웠습니다. 지금은 역할을 미리 정한 조합이 ConfigurationRoles에 있습니다.

    public static final ConfigurationRole CONSUMABLE = createNonDeprecatedRole("Consumable", true, false, false);
    // ...
    public static final ConfigurationRole RESOLVABLE = createNonDeprecatedRole("Resolvable", false, true, false);
    // ... (RESOLVABLE_DEPENDENCY_SCOPE, CONSUMABLE_DEPENDENCY_SCOPE — 둘 다 @Deprecated)
    public static final ConfigurationRole DEPENDENCY_SCOPE = createNonDeprecatedRole("Dependency Scope", false, false, true);

subprojects/core/src/main/java/org/gradle/api/internal/artifacts/configurations/ConfigurationRoles.java 50–80행

세 불리언의 순서는 (consumable, resolvable, declarable)입니다(같은 파일 85행의 createNonDeprecatedRole 매개변수). 예제 1의 클래스 이름 DefaultDependencyScopeConfiguration·DefaultResolvableConfiguration·DefaultConsumableConfiguration이 이 셋에 하나씩 맞습니다. 셋 다 DefaultConfiguration의 하위 클래스입니다.

java 플러그인이 만드는 구성 나무

implementation과 compileClasspath를 만들고 잇는 곳은 JavaBasePlugin입니다. 소스 세트(main, test)마다 한 번씩 불립니다.

    private void defineConfigurationsForSourceSet(SourceSet sourceSet, RoleBasedConfigurationContainerInternal configurations) {
        // ... (구성 이름들을 소스 세트에서 얻는다)

        Configuration implementationConfiguration = configurations.dependencyScopeLocked(implementationConfigurationName, conf -> {
            conf.setDescription("Implementation only dependencies for " + sourceSetName + ".");
        });

        // ... (compileOnly 도 같은 모양)

        Configuration compileClasspathConfiguration = configurations.resolvableLocked(compileClasspathConfigurationName, conf -> {
            conf.extendsFrom(compileOnlyConfiguration, implementationConfiguration);
            conf.setDescription("Compile classpath for " + sourceSetName + ".");
            jvmPluginServices.configureAsCompileClasspath(conf);
        });

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

implementation은 dependencyScopeLocked(선언만), compileClasspath는 resolvableLocked(해석만)로 만들고, extendsFrom으로 둘을 잇습니다. 같은 메서드의 뒤쪽(322–330행)에서 runtimeOnly와 runtimeClasspath도 같은 모양으로 만들고, runtimeClasspath는 runtimeOnly·implementation을 물려받습니다. 이름 끝의 Locked는 만든 뒤 역할을 바꿀 수 없다는 뜻입니다. configureAsCompileClasspath()는 이 구성이 원하는 속성을 붙입니다 — 라이브러리, API 용도(java-api), 외부 의존성 포함, 표준 JVM(DefaultJvmPluginServices.java 61–66행). 이 속성이 5절에서 변형을 고를 때 쓰입니다.

java 플러그인이 만드는 구성 나무. 왼쪽 묶음은 선언만 하는 구성(DEPENDENCY_SCOPE, declare=true resolve=false consume=false) implementation·compileOnly·runtimeOnly다. 오른쪽 위 묶음은 해석하는 구성(RESOLVABLE) compileClasspath(compileJava의 classpath)와 runtimeClasspath(test·run의 classpath)이고, 오른쪽 가운데 묶음은 소비되는 구성(CONSUMABLE) apiElements·runtimeElements다. compileOnly와 implementation이 compileClasspath로, implementation과 runtimeOnly가 runtimeClasspath와 runtimeElements로 이어진다. implementation을 해석하면 canBeResolved=false 예외가 난다(점선). 의존성은 왼쪽에 담고, 해석은 오른쪽에서 합니다. 실선 화살표는 extendsFrom(화살표를 받는 쪽이 보내는 쪽을 물려받음)이고, 점선은 해석을 시도했을 때 나는 예외입니다.

예제 1(이어서): 선언용 구성을 해석하면

담는 바구니를 직접 해석하면 어떻게 될까요. 예제 1에 태스크를 하나 더 두었습니다.

// 해석할 수 없는 구성(implementation)을 해석하려 하면?
tasks.register("resolveImplementation") {
    val impl = configurations.getByName("implementation")
    doLast { println(impl.files) }
}
* What went wrong:
Execution failed for task ':resolveImplementation' (registered in build file 'build.gradle.kts').
> Resolving dependency configuration 'implementation' is not allowed as it is defined as 'canBeResolved=false'.
  Instead, a resolvable ('canBeResolved=true') dependency configuration that extends 'implementation' should be resolved.

(앞의 implementation(...) returned … 두 줄과 FAILURE: Build failed with an exception. 줄, 끝의 * Try: 안내는 뺐습니다.) 메시지는 canBeResolved가 false인 구성을 해석하려 할 때 DefaultConfiguration.assertIsResolvable()이 던진 것입니다(platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/configurations/DefaultConfiguration.java 1409–1413행). 메시지가 시키는 대로, implementation의 파일이 필요하면 그것을 물려받는 compileClasspath나 runtimeClasspath를 해석해야 합니다. 이 설계는 "무엇을 넣었나"와 "어떤 목적으로 꺼내나"를 떼어 놓습니다. 같은 implementation이라도 컴파일용으로 꺼낼 때(java-api 변형)와 실행용으로 꺼낼 때(java-runtime 변형) 결과가 다를 수 있으니, 해석은 언제나 목적(속성)을 가진 구성에서 합니다.

헷갈리기 쉬운 곳

4. 해석은 언제 일어나나 — 게으른 해석

여기까지 아무것도 해석하지 않았습니다. 그럼 해석은 언제 일어날까요. 답은 DefaultConfiguration에 있습니다. 구성은 해석 결과를 currentResolveState에 담아 두고, 결과가 필요해지면 resolveGraphIfRequired()를 부릅니다(DefaultConfiguration.java 639–660행). 이미 해석했으면(isFullyResolved) 그 결과를 돌려주고, 아니면 해석합니다. 한 빌드에서 구성 하나는 한 번만 해석됩니다(내부용 callAndResetResolutionState()로 상태를 되돌리는 경우는 예외입니다). 중간의 hasMutableState() 검사는 6편에서 본 프로젝트 잠금을 쥔 스레드만 해석할 수 있게 하는 것입니다(쥐지 않았으면 "Resolution of the … was attempted without an exclusive lock" 예외). 실제 해석은 빌드 오퍼레이션 하나로 감싼 resolveGraphInBuildOperation()입니다.

    private ResolverResults resolveGraphInBuildOperation() {
        return configurationServices.getBuildOperationRunner().call(new CallableBuildOperation<ResolverResults>() {
            @Override
            public ResolverResults call(BuildOperationContext context) {
                runDependencyActions();
                dependencyResolutionListeners.getSource().beforeResolve(getIncoming());

                ResolverResults results;
                try {
                    results = resolver.resolveGraph(DefaultConfiguration.this);
                } catch (Exception e) {
                    throw configurationServices.getExceptionMapper().mapFailure(e, "dependencies", displayName.getDisplayName());
                }

                // Make the new state visible in case a dependency resolution listener queries the result, which requires the new state
                currentResolveState.set(Optional.of(results));

                dependencyResolutionListeners.getSource().afterResolve(getIncoming());
                // ...

DefaultConfiguration.java 675–692행

beforeResolve 리스너 → resolver.resolveGraph(this) → 결과 저장 → afterResolve 리스너. 이 빌드 오퍼레이션의 이름이 "Resolve dependencies of " + identityPath입니다(719행). 그러면 resolveGraphIfRequired()는 누가 부를까요. 소스를 거슬러 찾기보다 스택을 찍는 편이 빠릅니다.

예제 2: 누가 해석을 시작하나

compileClasspath에 beforeResolve·afterResolve 리스너를 달고, -DdumpStack을 주면 beforeResolve 안에서 스택을 찍게 했습니다(ex/u12-resolve-timing).

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// ... (plugins { java }, mavenCentral(), implementation("org.apache.commons:commons-text:1.12.0"))

// 해석이 언제, 어느 스레드에서, 누구의 부탁으로 일어나는지 찍는다
configurations.compileClasspath {
    incoming.beforeResolve {
        println(">> beforeResolve '$name' (thread=${Thread.currentThread().name})")
        if (providers.systemProperty("dumpStack").isPresent) {
            Throwable().stackTrace
                .filter { it.className.startsWith("org.gradle") }
                .forEach { println("     at ${it.className.substringAfterLast('.')}.${it.methodName}") }
        }
    }
    incoming.afterResolve {
        println("<< afterResolve '$name': ${resolutionResult.allComponents.map { it.id.displayName }}")
    }
}

println("end of build.gradle.kts: compileClasspath state = ${configurations.compileClasspath.get().state}")

// -Pearly 를 주면 설정 단계에서 파일 목록을 달라고 한다
if (providers.gradleProperty("early").isPresent) {
    println("asking for files during configuration...")
    println("files = ${configurations.compileClasspath.get().files.map { it.name }}")
    // 해석이 끝난 뒤에 의존성을 더하면?
    dependencies.add("implementation", "org.slf4j:slf4j-api:2.0.16")
}

먼저 클래스패스와 상관없는 gradle help -q입니다.

end of build.gradle.kts: compileClasspath state = UNRESOLVED

beforeResolve가 한 번도 찍히지 않았습니다. 아무도 파일 목록을 달라고 하지 않았으니 해석하지 않았습니다. 다음은 gradle compileJava -DdumpStack입니다. 스택은 118줄이라 의미 있는 줄만 남기고 줄였습니다(...가 줄인 자리입니다).

> Configure project :
end of build.gradle.kts: compileClasspath state = UNRESOLVED

> Task :compileJava
>> beforeResolve 'compileClasspath' (thread=Execution worker Thread 2)
     ...
     at DefaultConfiguration.resolveGraphInBuildOperation
     at DefaultConfiguration.lambda$resolveExclusivelyIfRequired$0
     at DefaultCalculatedModelValue.update
     at DefaultConfiguration.resolveExclusivelyIfRequired
     at DefaultConfiguration.resolveGraphIfRequired
     ...
     at ResolutionBackedFileCollection.visitContents
     at AbstractFileCollection.visitStructure
     at DefaultConfiguration.visitContents
     ...
     at DefaultFileCollectionSnapshotter.snapshot
     at DefaultInputFingerprinter$InputCollectingVisitor.visitInputFileProperty
     at TaskExecution.visitMutableInputs
     at DefaultInputFingerprinter.fingerprintInputProperties
     at CaptureMutableStateBeforeExecutionStep.captureExecutionStateWithOutputs
     ...
     at CaptureMutableStateBeforeExecutionStep.execute
     at SkipEmptyMutableWorkStep.executeWithNonEmptySources
     ...
     at LoadPreviousExecutionStateStep.executeMutable
     ...
     at IdentifyStep.execute
     at DefaultExecutionEngine$1.execute
     at DefaultTaskNodeExecutor.doExecute
     ...
     at LocalTaskNode.execute
     ...
     at DefaultPlanExecutor$ExecutorWorker.run
     ...
<< afterResolve 'compileClasspath': [root project 'resolve-timing', org.apache.commons:commons-text:1.12.0, org.apache.commons:commons-lang3:3.14.0]

스레드 이름 뒤의 번호(Thread 2)는 실행마다 다릅니다(같은 예제를 다시 돌렸을 때는 번호 없는 Execution worker였습니다). 아래에서 위로 읽으면 이렇습니다.

즉 해석을 시작한 것은 javac가 아니라 10편의 실행 엔진입니다. 태스크 액션이 돌기 전, 입력 지문을 뜨는 스텝에서 해석이 끝납니다. 같은 실행의 빌드 오퍼레이션 trace(-Dorg.gradle.internal.operations.trace=/tmp/trace)에서 이름만 골라 보면 그래프 해석과 파일 해석이 따로 있습니다.

202:{"displayName":"Resolve dependencies of :compileClasspath", ... "id":217,"parentId":216, ...}
324:{"displayName":"Resolve files of configuration ':compileClasspath'", ... "id":218,"parentId":216, ...}
330:{"displayName":"Resolve dependencies of :annotationProcessor", ... "id":221,"parentId":216, ...}
337:{"displayName":"Resolve files of configuration ':compileClasspath'", ... "id":224,"parentId":223, ...}

(줄 앞의 숫자는 trace 파일의 줄 번호이고, id·시각 같은 값은 실행마다 다릅니다. 각 줄의 details는 길어서 줄였습니다.) 1절 그림의 ②와 ③이 실제로 다른 빌드 오퍼레이션입니다. annotationProcessor 구성도 같은 compileJava의 입력이라 함께 해석됩니다. 337행의 두 번째 Resolve files of configuration ':compileClasspath'는 부모가 다릅니다 — 다시 돌려 부모를 따라가 보니 태스크 액션 쪽 오퍼레이션(Execute compile for :compileJava) 아래였습니다. javac에 넘길 파일을 한 번 더 꺼낸 것이고, 그래프 해석(Resolve dependencies of)은 다시 일어나지 않았습니다. trace에는 이 밖에 Resolve dependencies of classpath도 있는데, 이것은 빌드 스크립트 자신의 클래스패스(classpath 구성, trace에서 "scriptConfiguration":true)를 해석한 것입니다.

해석이 일어나는 호출 순서. ① CaptureMutableStateBeforeExecutionStep이 입력 파일 지문을 뜨려고 compileJava의 classpath 입력을 방문하고, ② 그것이 DefaultConfiguration(compileClasspath)의 visitContents()를 부른다. ③ resolveGraphIfRequired()는 이미 해석했으면 그 결과를 쓰고, 아니면 ④ 빌드 오퍼레이션 Resolve dependencies of :compileClasspath를 연다. ⑤ beforeResolve 리스너 다음 DefaultConfigurationResolver.resolveGraph(this)를 부르고, ⑥ 리졸버는 루트 변형을 만들며 구성을 관찰됨으로 잠근다. ⑦ ResolutionExecutor.resolveGraph(params, 저장소 목록), ⑧ DependencyGraphResolver를 거쳐 DependencyGraphBuilder.resolve()가 traverseGraph로 그래프를 완성하고 ⑨ 노드와 간선을 방문자에게 넘긴다. ⑩ ResolverResults가 돌아오고 ⑪ 파일 해석(Resolve files of configuration)을 거쳐 ⑫ jar 경로들로 지문을 뜬다. ⑥이 다음 예제의 오류를 만드는 자리, ⑧이 5·6절에서 읽을 반복문입니다.

예제 2(이어서): 설정 단계에서 파일을 달라고 하면

-Pearly를 주면 빌드 스크립트 본문에서 files를 부르고, 그 뒤에 의존성을 하나 더 넣습니다.

end of build.gradle.kts: compileClasspath state = UNRESOLVED
asking for files during configuration...
>> beforeResolve 'compileClasspath' (thread=Daemon worker)
<< afterResolve 'compileClasspath': [root project 'resolve-timing', org.apache.commons:commons-text:1.12.0, org.apache.commons:commons-lang3:3.14.0]
files = [commons-text-1.12.0.jar, commons-lang3-3.14.0.jar]
FAILURE: Build failed with an exception.
* Where:
Build file '/tmp/p/build.gradle.kts' line: 34
* What went wrong:
Cannot mutate the dependencies of configuration ':implementation' after the configuration's child configuration ':compileClasspath' was resolved. After a configuration has been observed, it should not be modified.

(끝의 * Try: 안내와 BUILD FAILED in 2s는 뺐습니다. 시간은 실행마다 다릅니다.) 두 가지가 보입니다. 첫째, 해석 자체는 됩니다. 다만 이번에는 설정 단계를 도는 Daemon worker 스레드에서, 그리고 gradle help처럼 클래스패스가 필요 없는 명령에서도 매번 일어납니다. 둘째, 해석한 뒤에는 물려준 구성(implementation)까지 잠깁니다. 그림의 ⑥에서 DefaultConfigurationResolver.resolveGraph()가 루트 변형을 만들면서 finalize(configuration, "resolved")를 부르고(DefaultLocalVariantGraphResolveStateBuilder.java 95행), 그것이 markAsObserved()를 부릅니다(DefaultConfiguration.java 1007–1028행). 이 메서드는 runActionInHierarchy로 자신과 물려준 구성 전부에 "the configuration's child … was resolved" 같은 이유를 적어 둡니다. 그래서 implementation에 "자식 :compileClasspath가 resolved되었다"는 이유가 적히고, 나중에 의존성을 바꾸려 하면 validateMutation()이 그 이유를 꺼내 예외 메시지를 만듭니다(1228–1239행). 이미 만든 그래프와 구성의 내용이 어긋나지 않게 하려는 장치입니다.

헷갈리기 쉬운 곳

5. 그래프 만들기 — DependencyGraphBuilder

resolver.resolveGraph()는 DefaultConfigurationResolver → ResolutionExecutor.resolveGraph()로 이어집니다. ResolutionExecutor는 결과를 받을 방문자(그래프 구조, 실패 모음, 아티팩트)를 준비하고, 컴포넌트를 찾을 리졸버 사슬(ComponentResolversChain)을 만듭니다(platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/ResolutionExecutor.java 443–468행). 같은 빌드 안의 프로젝트(project(":core"))를 찾는 리졸버가 앞에, 저장소의 외부 모듈을 찾는 리졸버가 뒤에 섭니다. 그리고 DependencyGraphResolver를 거쳐 그래프 만들기의 본체인 DependencyGraphBuilder.resolve()에 닿습니다. 이 메서드는 세 줄로 요약됩니다 — traverseGraph()(그래프 넓히기), validateGraph()(검사), assembleResult()(방문자에게 결과 넘기기)(DependencyGraphBuilder.java 162–166행).

그래프를 이루는 것들

traverseGraph()를 읽기 전에 등장인물을 정리합니다. 모두 resolveengine/graph/builder/ 패키지에 있습니다.

클래스 그래프에서 무엇인가 예제에서는
ResolveState 한 번의 해석 전체. 처리할 노드의 큐와 모듈 표를 든다 compileClasspath 해석 한 번
ModuleResolveState 모듈(group:name) 하나. 그 모듈에 대한 요청을 모두 모은다 org.apache.commons:commons-lang3
SelectorState 요청 하나 — "이 모듈의 이 버전 제약" 직접 선언한 3.12.0, commons-text의 POM이 적은 3.14.0
ComponentState 버전까지 정해진 후보 하나 commons-lang3:3.14.0
NodeState 고른 컴포넌트의 변형 하나. 그래프의 노드 commons-lang3의 compile 변형
EdgeState 노드에서 나가는 의존 하나. 그래프의 간선 commons-text 노드 → commons-lang3 요청

그래프 클래스들의 관계. ResolveState(큐·모듈 표·충돌 처리기, pop()·getModule(id))는 모듈마다 ModuleResolveState(group:name 하나, selectors, maybeUpdateSelection())를 가진다. ModuleResolveState는 모인 요청 SelectorState(요청 하나 — 버전 제약, resolve()) 여럿과 버전 후보 ComponentState(group:name:version 하나, evict()) 여럿을 가진다. ComponentState는 고른 변형 NodeState(컴포넌트의 변형 하나, visitOutgoingDependenciesAndCollectEdges()) 여럿을, NodeState는 나가는 간선 EdgeState(computeSelector()) 여럿을 가지며 EdgeState는 가리키는 요청 SelectorState를 가진다. ModuleResolveState는 버전을 고를 때 SelectorStateResolver.selectBest()를, 그것은 충돌이면 LatestModuleConflictResolver.select()를 쓴다. 노드는 "컴포넌트"가 아니라 "컴포넌트의 변형"입니다. NodeState가 VariantGraphResolveState를 필드로 듭니다.

traverseGraph() — 큐가 빌 때까지

    private void traverseGraph(final ResolveState resolveState) {
        resolveState.onMoreSelected(resolveState.getRoot());
        final List<EdgeState> edges = new ArrayList<>();

        ModuleConflictHandler moduleConflictHandler = resolveState.getModuleConflictHandler();
        CapabilitiesConflictHandler capabilitiesConflictHandler = resolveState.getCapabilitiesConflictHandler();

        while (resolveState.peek() != null || moduleConflictHandler.hasConflicts() || capabilitiesConflictHandler.hasConflicts()) {
            if (resolveState.peek() != null) {
                final NodeState node = resolveState.pop();

                if (!node.shouldBuildSubgraph()) {
                    node.removeOutgoingEdges();
                    continue;
                }

                // ... (기능(capability) 충돌이면 먼저 풀어야 하므로 continue)

                // This node is part of the graph and is not in conflict. Process its outgoing dependencies.
                edges.clear();
                node.visitOutgoingDependenciesAndCollectEdges(edges);
                resolveEdges(node, edges, resolveState);
            } else {
                // We have some batched up conflicts. Resolve the first, and continue traversing the graph
                if (moduleConflictHandler.hasConflicts()) {
                    moduleConflictHandler.resolveNextConflict();
                } else {
                    capabilitiesConflictHandler.resolveNextConflict();
                }
            }
        }
    }

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/resolveengine/graph/builder/DependencyGraphBuilder.java 172–208행

재귀가 아니라 큐를 쓰는 반복문입니다. 루트 노드를 큐에 넣고 시작해서, 노드 하나를 꺼내 나가는 의존(간선)을 모으고(visitOutgoingDependenciesAndCollectEdges), resolveEdges()로 그 간선들의 대상을 정합니다. 대상이 정해져 새로 그래프에 붙은 노드가 다시 큐에 들어갑니다. 큐가 비면 미뤄 둔 충돌을 하나 풀고 다시 돕니다.

resolveEdges()는 세 단계입니다(210–222행).

  1. performSelectionSerially() — 간선마다 대상 모듈의 버전을 (다시) 고릅니다. 6절의 충돌 해결이 여기서 일어납니다.
  2. maybeDownloadMetadataInParallel() — 고른 컴포넌트 가운데 메타데이터를 "싸게" 얻을 수 없는 것(원격에서 받아야 하는 것)이 둘 이상이면 병렬로 받습니다. 주석대로 "내려받기는 I/O라서 워커 수보다 많이" 돌립니다(289행).
  3. attachToTargetRevisionsSerially() — 간선을 대상 노드에 붙입니다. 주석은 이 일을 한 스레드에서 하는 이유를 "그래프가 매번 같은 순서로 만들어지게(재현 가능하게)"라고 적었습니다(297–299행).

예제 3: 메타데이터를 읽는 순간의 스택

그래프 한가운데를 보려고 컴포넌트 메타데이터 규칙(ComponentMetadataRule — 모듈의 메타데이터를 읽은 직후 손볼 기회를 주는 규칙)을 하나 걸고, 그 안에서 스택을 찍었습니다(ex/u12-offline-cache). 스택에서는 이 글에 나오는 클래스만 남깁니다.

// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
// 그래프를 만드는 도중, 모듈의 메타데이터(POM·.module)를 읽은 직후에 불리는 규칙
abstract class WhereAmIRule : ComponentMetadataRule {
    override fun execute(context: ComponentMetadataContext) {
        val id = context.details.id
        println("metadata rule: $id")
        if (id.name == "commons-text") {
            val keep = setOf(
                "DefaultConfiguration", "DefaultConfigurationResolver", "ResolutionExecutor",
                // ... (이 글에 나오는 클래스 이름 일곱 개 더)
            )
            Throwable().stackTrace
                .map { it.className.substringAfterLast('.') to it.methodName }
                .filter { it.first in keep }
                .forEach { (c, m) -> println("     at $c.$m") }
        }
    }
}

dependencies {
    components { all<WhereAmIRule>() }
    implementation("org.apache.commons:commons-text:1.12.0")
    implementation("com.google.guava:guava:33.3.1-jre")
    // ... (-Pmissing 일 때만 쓰는 선언, 7절에서)
}
// ... (plugins, repositories, printClasspath·variants 태스크)

gradle printClasspath --offline --info의 출력에서 해당 줄만 골랐습니다.

metadata rule: org.apache.commons:commons-text:1.12.0
     at CachingModuleComponentRepository$LocateInCacheRepositoryAccess.getProcessedMetadata
     at CachingModuleComponentRepository$LocateInCacheRepositoryAccess.estimateMetadataFetchingCost
     at RepositoryChainComponentMetaDataResolver.isFetchingMetadataCheap
     at DependencyGraphBuilder.maybeDownloadMetadataInParallel
     at DependencyGraphBuilder.resolveEdges
     at DependencyGraphBuilder.traverseGraph
     at DependencyGraphBuilder.resolve
     at DependencyGraphResolver.resolve
     at ResolutionExecutor.doResolve
     at ResolutionExecutor.resolveGraph
     at DefaultConfigurationResolver.resolveGraph
     at DefaultConfiguration.resolveGraphInBuildOperation
     at DefaultConfiguration.lambda$resolveExclusivelyIfRequired$0
     at DefaultConfiguration.resolveExclusivelyIfRequired
     at DefaultConfiguration.resolveGraphIfRequired
     at DefaultConfiguration.visitContents
metadata rule: com.google.guava:guava:33.3.1-jre
metadata rule: org.apache.commons:commons-lang3:3.14.0
metadata rule: com.google.guava:failureaccess:1.0.2
metadata rule: com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
metadata rule: com.google.code.findbugs:jsr305:3.0.2
metadata rule: org.checkerframework:checker-qual:3.43.0
metadata rule: com.google.errorprone:error_prone_annotations:2.28.0
metadata rule: com.google.j2objc:j2objc-annotations:3.0.0

그림의 ⑤~⑧이 스택에 그대로 있습니다. traverseGraph → resolveEdges → maybeDownloadMetadataInParallel까지 와서, "이 메타데이터를 얻는 것이 싼가"를 묻다가(isFetchingMetadataCheap) 캐시에서 메타데이터를 꺼낸 것(LocateInCacheRepositoryAccess)이 보입니다. 캐시에 있으니 싸다고 판단되어 병렬 내려받기 없이 넘어갑니다. 규칙이 불린 순서는 큐의 순서입니다. 루트가 직접 선언한 둘(commons-text, guava)이 먼저, 그것들이 끌고 온 것이 뒤에 옵니다.

변형 고르기 맛보기

노드는 컴포넌트의 변형이라고 했습니다. 어떤 변형이 골라졌는지, variants 태스크를 두어 해석 결과의 루트 컴포넌트에서 직접 의존마다 ResolvedDependencyResult.resolvedVariant(ResolvedVariantResult)의 이름과 Usage 속성을 찍었습니다(gradle variants --offline -q).

org.apache.commons:commons-text:1.12.0  ->  variant 'compile' (usage=java-api)
com.google.guava:guava:33.3.1-jre  ->  variant 'jreApiElements' (usage=java-api)

둘 다 compileClasspath가 원한 java-api 용도의 변형이 골라졌지만, 이름이 다릅니다. commons-text는 POM만 있는 라이브러리라 Gradle이 변형을 지어냈고 — POM을 읽을 때 Maven 범위(scope)마다 compile·runtime 같은 구성을 만들고(GradlePomModuleDescriptorBuilder.java 52–58행), JavaEcosystemVariantDerivationStrategy.derive()가 그것으로 라이브러리용 compile·runtime 변형과 sources·javadoc, 플랫폼용 변형 넷까지 여덟 개를 만듭니다(JavaEcosystemVariantDerivationStrategy.java 43–65행), guava는 출판자가 .module 파일에 변형을 직접 적어 두었습니다(jreApiElements — guava는 JRE용과 Android용 변형을 따로 냅니다). .module을 어떻게 찾는지는 7절에서 봅니다. 변형 고르기 자체(GraphVariantSelector, 속성 맞추기 규칙)는 그것만으로 한 편이 필요해 이 글에서는 여기까지만 봅니다.

헷갈리기 쉬운 곳

6. 같은 모듈의 두 버전 — 충돌 해결

이 연재의 도커 이미지에는 일부러 충돌을 일으키는 라이브러리 셋이 받아져 있습니다. commons-text 1.12.0은 commons-lang3 3.14.0을 끌고 오고, 같은 빌드에서 commons-lang3 3.12.0을 직접 선언합니다.

예제 4: dependencies와 dependencyInsight

-Pmode로 같은 빌드의 세 가지 판을 고를 수 있게 했습니다(ex/u12-conflict).

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

repositories { mavenCentral() }

// -Pmode=default|strict|force 로 같은 빌드의 세 가지 판을 고른다
val mode = providers.gradleProperty("mode").getOrElse("default")

dependencies {
    // commons-text 1.12.0 은 commons-lang3 3.14.0 을 끌고 온다
    implementation("org.apache.commons:commons-text:1.12.0")
    if (mode == "strict") {
        // 직접 선언한 쪽이 "이 버전이 아니면 안 된다"고 못 박는다
        implementation("org.apache.commons:commons-lang3") {
            version { strictly("3.12.0") }
        }
    } else {
        // 그냥 낮은 버전을 직접 선언한다
        implementation("org.apache.commons:commons-lang3:3.12.0")
    }
}

if (mode == "force") {
    // 해석 전략으로 모든 commons-lang3 요청을 3.12.0 으로 바꾼다
    configurations.compileClasspath {
        resolutionStrategy.force("org.apache.commons:commons-lang3:3.12.0")
    }
}

tasks.register("printClasspath") {
    val cp = configurations.compileClasspath
    doLast { cp.get().files.forEach { println(it.name) } }
}

먼저 기본 판의 gradle dependencies --configuration compileClasspath --offline --console=plain입니다. 컨테이너에서 처음 돈 명령이라 데몬을 띄우는 줄이 함께 나옵니다.

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.

> Task :dependencies

------------------------------------------------------------
Root project 'conflict'
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
+--- org.apache.commons:commons-text:1.12.0
    \--- org.apache.commons:commons-lang3:3.14.0
\--- org.apache.commons:commons-lang3:3.12.0 -> 3.14.0

(그 아래의 (*) 설명, --scan 안내, BUILD SUCCESSFUL in 1m 1s 같은 줄은 뺐습니다. 시간은 실행마다 다릅니다.) Shared read-only dependency cache is an incubating feature.는 이 이미지가 쓰는 읽기 전용 의존성 캐시 때문에 찍히는 줄입니다(7절). 마지막 줄의 **3.12.0 -> 3.14.0**이 "3.12.0을 요청했지만 3.14.0이 골라졌다"는 뜻입니다. 이유는 dependencyInsight가 알려 줍니다(gradle dependencyInsight --dependency commons-lang3 --configuration compileClasspath --offline --console=plain -q).

org.apache.commons:commons-lang3:3.14.0
  Variant compile:
 Attribute Name                 | Provided | Requested    |
--------------------------------|----------|--------------|
 org.gradle.status              | release  |              |
 org.gradle.category            | library  | library      |
 org.gradle.libraryelements     | jar      | classes      |
 org.gradle.usage               | java-api | java-api     |
 org.gradle.dependency.bundling |          | external     |
 org.gradle.jvm.environment     |          | standard-jvm |
 org.gradle.jvm.version         |          | 25           |
   Selection reasons:
      - By conflict resolution: between versions 3.14.0 and 3.12.0

org.apache.commons:commons-lang3:3.14.0
\--- org.apache.commons:commons-text:1.12.0
     \--- compileClasspath

org.apache.commons:commons-lang3:3.12.0 -> 3.14.0
\--- compileClasspath

(끝의 --scan 안내 한 줄은 뺐습니다.) 표의 Requested 열은 compileClasspath 구성이 들고 있는 속성(3절의 configureAsCompileClasspath()가 붙인 것과, org.gradle.jvm.version처럼 java 플러그인이 따로 붙인 것)이고, Provided가 POM에서 만든 compile 변형의 속성입니다. 그 아래 이유가 **By conflict resolution: between versions 3.14.0 and 3.12.0**입니다. 이 문장을 만드는 코드를 찾아가 보겠습니다.

요청이 모이면 버전을 다시 고른다

5절의 performSelectionSerially()는 간선마다 대상 모듈의 maybeUpdateSelection()을 부릅니다. commons-lang3 모듈(ModuleResolveState)에는 요청이 둘 모입니다. 루트의 직접 선언(3.12.0)과 commons-text의 POM(3.14.0)입니다.

    public void maybeUpdateSelection() {
        if (replaced) {
            // Never update selection for a replaced module
            return;
        }
        if (!rootModule && selectors.checkDeferSelection()) {
            // Selection deferred as we know another selector will be added soon
            return;
        }
        ComponentState newSelected = selectorStateResolver.selectBest(getId(), selectors);
        newSelected.setSelectors(selectors);
        if (selected == null) {
            select(newSelected);
        } else if (newSelected != selected) {
            // ... (선택이 1000번 넘게 바뀌면 높은 버전으로 굳히는 안전장치)
            changeSelection(newSelected);
        }
    }

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/resolveengine/graph/builder/ModuleResolveState.java 475–500행

새 요청이 올 때마다 모인 요청 전부를 놓고 다시 고릅니다(selectBest(getId(), selectors)). 처음 3.12.0을 골랐어도, 3.14.0 요청이 들어오면 다시 골라 선택을 바꿉니다(changeSelection). SelectorStateResolver.selectBest()는 요청마다 후보를 구하고, 후보가 하나면 그것을, 여럿이면 충돌 해결로 넘깁니다(64–95행). 그 충돌 해결이 이것입니다.

    private T resolveConflicts(Collection<T> candidates) {
        // Do conflict resolution to choose the best out of current selection and candidate.
        ConflictResolverDetails<T> details = new DefaultConflictResolverDetails<>(candidates);
        conflictResolver.select(details);
        T selected = details.getSelected();
        if (details.hasFailure()) {
            throw UncheckedException.throwAsUncheckedException(details.getFailure());
        } else {
            ComponentSelectionDescriptorInternal desc = ComponentSelectionReasons.CONFLICT_RESOLUTION;
            selected.addCause(desc.withDescription(new VersionConflictResolutionDetails(candidates)));
        }
        return selected;
    }

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/resolveengine/graph/selectors/SelectorStateResolver.java 226–238행

고른 컴포넌트에 CONFLICT_RESOLUTION 이유를 붙이는데, 설명(VersionConflictResolutionDetails)이 "between versions …"라는 문장을 만듭니다(graph/conflicts/VersionConflictResolutionDetails.java 49행). dependencyInsight의 이유 줄이 여기서 왔습니다. 고르는 일은 conflictResolver가 합니다. 이것을 정하는 DependencyGraphResolver.createModuleConflictResolver()(resolveengine/DependencyGraphResolver.java 145–151행)는 언제나 LatestModuleConflictResolver를 만들고, resolutionStrategy.preferProjectModules()를 켰을 때만 "같은 빌드의 프로젝트를 먼저" 감싸개(ProjectDependencyForcingResolver)를 씌웁니다. LatestModuleConflictResolver는 이름대로 가장 높은 버전을 고릅니다.

    @Override
    public void select(ConflictResolverDetails<T> details) {
        // Find the candidates with the highest base version
        Version baseVersion = null;
        Map<Version, T> matches = new LinkedHashMap<>();
        for (T candidate : details.getCandidates()) {
            Version version = versionParser.transform(candidate.getVersion());
            if (baseVersion == null || versionComparator.compare(version.getBaseVersion(), baseVersion) > 0) {
                matches.clear();
                baseVersion = version.getBaseVersion();
                matches.put(version, candidate);
            } else if (version.getBaseVersion().equals(baseVersion)) {
                matches.put(version, candidate);
            }
        }

        if (matches.size() == 1) {
            details.select(matches.values().iterator().next());
            return;
        }
        // ...

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/resolveengine/LatestModuleConflictResolver.java 41–60행

"기본 버전"(3.14.0-RC1이면 3.14.0)이 가장 높은 후보를 모읍니다. 하나뿐이면 끝이고, 3.14.0과 3.14.0-RC1처럼 기본 버전이 같은 후보가 여럿이면 줄인 뒤쪽 코드가 한정자(qualifier) 없는 정식 버전, release 상태, 스냅숏 순서로 고릅니다. 우리 예제는 3.14.0과 3.12.0이라 앞부분에서 3.14.0으로 끝납니다.

Maven과 다른 점이 여기입니다. Maven은 (Maven 문서에 따르면) 그래프에서 루트에 가까운 선언을 고릅니다. 같은 예제라면 직접 선언한 3.12.0이 이깁니다. Gradle은 거리를 보지 않고 높은 버전을 고릅니다. "내가 직접 적었는데 왜 다른 버전이 들어오지?"라는 놀람은 대개 이 차이에서 옵니다. (Maven 쪽은 이 글에서 Maven 소스로 확인하지 않았습니다.)

DefaultModuleConflictHandler는 무엇을 하나

5절의 traverseGraph()에는 moduleConflictHandler.resolveNextConflict()도 있었습니다. 이름만 보면 위의 버전 충돌을 여기서 풀 것 같지만, 같은 모듈의 버전 고르기는 위에서 본 대로 요청이 모일 때마다 SelectorStateResolver가 바로 합니다. DefaultModuleConflictHandler는 그 모듈이 처음 선택될 때 후보로 등록되고(performSelection()의 registerCandidate, DependencyGraphBuilder.java 248–264행), 주로 모듈과 모듈 사이의 충돌을 줄 세웁니다(registerCandidate(), resolveengine/graph/conflicts/DefaultModuleConflictHandler.java 56–70행).

moduleReplacements는 dependencies.modules { module("a:old") { replacedBy("a:new") } }처럼 "이 모듈은 저 모듈로 대체되었다"는 선언입니다. 둘 다 그래프에 나타나면 충돌로 등록해 두었다가, 큐가 빈 뒤 resolveNextConflict()에서 하나를 고릅니다. 고를 때 쓰는 도구는 같은 LatestModuleConflictResolver입니다 — ResolveState가 충돌 처리기를 만들고(142행), 바로 그 처리기의 리졸버를 SelectorStateResolver에게 넘깁니다(163행).

strictly와 force

-Pmode=strict로 돌린 dependencies입니다(앞의 기본 판과 같은 머리말 줄과 끝의 (*) 설명·--scan 안내는 뺐습니다).

compileClasspath - Compile classpath for source set 'main'.
+--- org.apache.commons:commons-text:1.12.0
    \--- org.apache.commons:commons-lang3:3.14.0 -> 3.12.0
\--- org.apache.commons:commons-lang3:{strictly 3.12.0} -> 3.12.0

이번에는 commons-text가 요청한 3.14.0이 3.12.0으로 바뀌었습니다. dependencyInsight의 첫 줄은 org.apache.commons:commons-lang3:3.12.0 (by ancestor)입니다(속성 표는 기본 판과 같아 뺍니다). 조상이 정한 버전이라는 뜻입니다. 두 곳의 코드가 이것을 만듭니다. 먼저 strictly는 "이 버전 말고는 모두 거절"이라는 제약이 됩니다. 엄격한 버전이 있으면 getRejectionForStrict()가 그 버전의 여집합을 거절 셀렉터로 만듭니다(platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/dependencies/DefaultResolvedVersionConstraint.java 46–57행, complementForRejection은 67–70행). 그리고 이 엄격한 버전은 아래로 물려 내려갑니다. 노드는 조상들이 엄격하게 정한 모듈의 목록(ancestorsStrictVersions)을 들고 다니고, 간선의 셀렉터를 정할 때 이것을 봅니다.

    boolean computeSelector(StrictVersionConstraints ancestorsStrictVersions, boolean deferSelection) {
        boolean ignoreVersion = !isForced() && ancestorsStrictVersions.contains(getTargetModuleIdentifier());
        ComponentSelector componentSelector = dependencyMetadata.getSelector();
        SelectorState newSelector = resolveState.computeSelectorFor(componentSelector, ignoreVersion);
        // ...

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/resolveengine/graph/builder/EdgeState.java 145–148행

조상(여기서는 루트)이 commons-lang3를 엄격하게 정했으니, commons-text 노드에서 나가는 commons-lang3 간선은 버전을 무시한(ignoreVersion) 셀렉터를 받습니다. 남은 버전 제약은 루트의 strictly 3.12.0 하나뿐이니 충돌 자체가 생기지 않습니다. by ancestor라는 이유도 이 간선에서 붙습니다(EdgeState.getMainReason(), 582–584행).

-Pmode=force의 dependencyInsight 첫 줄은 org.apache.commons:commons-lang3:3.12.0 (forced)입니다. force는 전혀 다른 길로 갑니다. 해석 전략이 의존성 치환 규칙을 하나 더 만듭니다(DefaultResolutionStrategy.java 237–241행).

    @Override
    public void execute(DependencySubstitutionInternal details) {
        if (forcedModules == null) {
            return;
        }
        if (details.getRequested() instanceof ModuleComponentSelector) {
            ModuleComponentSelector selector = (ModuleComponentSelector) details.getRequested();
            ModuleIdentifier key = selector.getModuleIdentifier();
            if (forcedModules.containsKey(key)) {
                DefaultImmutableVersionConstraint versionConstraint = new DefaultImmutableVersionConstraint(forcedModules.get(key));
                details.useTarget(newSelector(key, versionConstraint, selector.getAttributes(), selector.getCapabilitySelectors()), ComponentSelectionReasons.FORCED);

            }
        }
    }

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/resolutionstrategy/ModuleForcingResolveRule.java 47–61행

그래프에서 commons-lang3를 가리키는 모든 요청을 3.12.0 요청으로 바꿔 씁니다(useTarget). 요청이 모두 3.12.0이니 역시 충돌이 없습니다. 차이를 정리하면 이렇습니다.

같은 모듈에 대한 두 요청과 세 가지 결과. compileClasspath의 루트가 commons-text:1.12.0과 commons-lang3:3.12.0(요청 A, 직접 선언)에 의존하고, commons-text의 POM이 commons-lang3:3.14.0(요청 B)을 요청한다. 두 요청은 ModuleResolveState(org.apache.commons:commons-lang3)에 모인다. 기본이면 SelectorStateResolver가 LatestModuleConflictResolver로 높은 쪽 3.14.0을 고르고 이유는 By conflict resolution이다. strictly 3.12.0이면 EdgeState.computeSelector()가 B의 버전을 무시해 3.12.0, 이유는 by ancestor다. force 3.12.0이면 ModuleForcingResolveRule이 A·B를 모두 3.12.0으로 바꿔 써서 3.12.0, 이유는 forced다. strictly는 그래프를 따라 내려가는 제약이고, force는 해석 전략에 거는 치환 규칙입니다.

어디에 적나 어떻게 이기나 dependencyInsight
기본 — 높은 버전 (LatestModuleConflictResolver) By conflict resolution: between versions …
strictly 의존성 선언의 버전 제약 아래쪽 간선의 버전을 무시하게 함 (by ancestor)
force 구성의 resolutionStrategy 그 모듈의 모든 요청을 바꿔 씀 (forced)

strictly는 의존성 선언 하나에 붙고 그 아래 그래프에만 영향을 줍니다. force는 구성에 걸어 그 구성의 해석 전체에 영향을 줍니다(예제에서는 compileClasspath에만 걸었습니다). Kafka의 빌드가 바로 이 force를 씁니다 — 연재를 마치며에서 봅니다.

헷갈리기 쉬운 곳 — 그래프는 됐는데 jar가 없다

예제 4의 마지막은 -Pmode=strict로 compileJava를 돌린 것입니다.

* What went wrong:
Execution failed for task ':compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all files for configuration ':compileClasspath'.
   > Could not download commons-lang3-3.12.0.jar (org.apache.commons:commons-lang3:3.12.0): No cached version available for offline mode

(앞의 FAILURE: … 줄과 끝의 * Try: 안내, BUILD FAILED in 3s는 뺐습니다.) 같은 설정으로 dependencies와 dependencyInsight는 멀쩡히 3.12.0을 보여 줬는데, 컴파일은 실패했습니다. 이 이미지를 만들 때 warmup 빌드는 기본 판으로 돌았고, 그래서 3.12.0은 충돌에서 졌기 때문에 jar를 받은 적이 없습니다(캐시에는 3.12.0의 POM만 있습니다 — 그래프를 만들려고 메타데이터는 읽었으니까요). 보고서 태스크는 그래프 해석까지만 하고, compileJava는 파일 해석까지 해야 하니 여기서 갈립니다. 1절의 ②와 ③이 다른 단계라는 것이 실패로 드러난 셈입니다. 기본 판의 compileJava는 BUILD SUCCESSFUL이었고, printClasspath는 commons-text-1.12.0.jar와 commons-lang3-3.14.0.jar를 찍었습니다. 메시지의 No cached version available for offline mode는 7절에서 읽습니다.

7. 메타데이터와 jar는 어디서 오나 — 저장소 사슬과 캐시

5절의 스택에서 메타데이터는 RepositoryChainComponentMetaDataResolver를 거쳐 CachingModuleComponentRepository의 LocateInCacheRepositoryAccess에서 나왔습니다. 이 길을 따라가 봅니다.

캐시를 먼저, 저장소는 나중에

repositories { }에 저장소를 여럿 적으면 그 순서대로 찾는다고 알려져 있습니다. 소스는 조금 더 정확합니다.

    private RepositoryChainModuleResolution findBestMatch(List<ComponentMetaDataResolveState> resolveStates, RepositoryFailureCollector failures) {
        LinkedList<ComponentMetaDataResolveState> queue = new LinkedList<>(resolveStates);

        LinkedList<ComponentMetaDataResolveState> missing = new LinkedList<>();

        // A first pass to do local resolves only
        RepositoryChainModuleResolution best = findBestMatch(queue, failures, missing);
        if (failures.hasFatalError()) {
            return null;
        }
        if (best != null) {
            return best;
        }

        // Nothing found locally - try a remote search for all resolve states that were not yet searched remotely
        queue.addAll(missing);
        missing.clear();
        return findBestMatch(queue, failures, missing);
    }

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/ivyresolve/RepositoryChainComponentMetaDataResolver.java 136–154행

두 바퀴입니다. 첫 바퀴는 모든 저장소에 대해 "로컬"만 봅니다. 하나라도 찾으면 거기서 끝입니다. 아무 데서도 못 찾았을 때에만 둘째 바퀴로 원격을 봅니다. 저장소마다 붙는 ComponentMetaDataResolveState.resolve()가 로컬과 원격을 나눠 부릅니다(ComponentMetaDataResolveState.java 46–66행). 그러니 두 번째 저장소의 캐시에 있는 것을 얻으려고 첫 번째 저장소에 네트워크 요청을 보내지 않습니다.

여기서 "로컬"은 mavenLocal()이 아니라 의존성 캐시입니다. 저장소마다 캐시 층(CachingModuleComponentRepository)이 씌워져 있고, 그 클래스의 설명(ivyservice/ivyresolve/CachingModuleComponentRepository.java 65–73행)이 두 접근을 나눕니다. 로컬 접근은 캐시에서 꺼내고(유효 기간은 해석 전략의 캐시 정책으로 판단), 원격 접근은 실제 저장소에 묻고 결과를 캐시에 넣습니다. 원격에서 메타데이터를 읽는 것은 ExternalResourceResolver입니다. 메타데이터 출처(metadataSources)를 차례로 시도해 처음 나온 것을 씁니다(repositories/resolver/ExternalResourceResolver.java 246–258행).

Maven 저장소(mavenCentral() 포함)의 기본 출처는 POM 하나인데(MavenMetadataSources.setDefaults()가 mavenPom()만 켭니다, DefaultMavenArtifactRepository.java 485–488행), 그 POM 출처를 "다시 보내 주는" 감싸개 RedirectingGradleMetadataModuleMetadataSource로 씌웁니다(DefaultMavenArtifactRepository.java 327–334행). POM을 읽다가 do_not_remove: published-with-gradle-metadata라는 주석을 만나면(PomReader.java 360행) 결과에 "Gradle 메타데이터로 가라" 표시가 붙고, 감싸개가 같은 자리의 .module 파일을 읽어 그것을 씁니다(RedirectingGradleMetadataModuleMetadataSource.java 50–59행). Gradle로 출판하는 라이브러리는 POM 맨 위에 이 주석을 넣어 두어서, Maven은 POM을, Gradle은 .module을 읽게 됩니다.

메타데이터를 찾는 길. 그래프가 commons-text:1.12.0의 메타데이터를 묻으면 RepositoryChainComponentMetaDataResolver가 저장소마다 ComponentMetaDataResolveState를 만든다. 1차로 모든 저장소의 로컬 접근(CachingModuleComponentRepository의 LocateInCacheRepositoryAccess)이 의존성 캐시(~/.gradle/caches/modules-2 먼저, 없으면 GRADLE_RO_DEP_CACHE)를 본다. 못 찾으면 2차로 못 찾은 저장소의 원격 접근(ResolveAndCacheRepositoryAccess)이 ExternalResourceResolver로 메타데이터 출처를 차례로 시도한다. POM을 읽고 published-with-gradle-metadata 표시가 있으면 .module(Gradle 모듈 메타데이터 — 변형과 속성)을 읽는다. --offline이면 원격 접근이 FailedRemoteAccess로 바뀌어 No cached version … available for offline mode로 실패한다. 노란 칸의 캐시는 내려받은 결과를 다음 빌드와 다른 프로젝트가 다시 씁니다. 빨간 칸은 --offline일 때만 있는 길입니다.

--offline은 원격 접근을 실패로 바꿉니다

--offline이 의존성 해석에서 하는 일은 셋입니다. 첫째, 시작 매개변수가 오프라인이면 저장소마다 OfflineModuleComponentRepository로 감쌉니다(ivyservice/ivyresolve/StartParameterResolutionOverride.java 66–70행). 이 감싸개는 원래 저장소를 그대로 두되 getRemoteAccess()만 FailedRemoteAccess로 바꿉니다(115–127행). 로컬(캐시) 접근은 그대로이고, 원격 접근은 무엇을 물어도 실패를 돌려줍니다. 둘째, 해석 전략의 캐시 정책을 오프라인으로 바꿔(ResolutionStrategyFactory.java 118–126행) 캐시 항목의 유효 기간을 따지지 않고 캐시에 있는 결과를 그대로 쓰게 합니다(DefaultCacheExpirationControl.applyOfflineRule(), 166–172행). 셋째, 저장소 전송 계층도 네트워크 대신 OfflineExternalResourceConnector를 받습니다(RepositoryTransportFactory.java 124–126행).

        @Override
        public void resolveComponentMetaData(ModuleComponentIdentifier moduleComponentIdentifier, ComponentOverrideMetadata requestMetaData, BuildableModuleComponentMetaDataResolveResult<ModuleComponentResolveMetadata> result) {
            result.failed(new ModuleVersionResolveException(moduleComponentIdentifier, () -> String.format("No cached version of %s available for offline mode.", moduleComponentIdentifier.getDisplayName())));
        }

StartParameterResolutionOverride.java 140–143행

예제 3에서 -Pmissing을 주면 캐시에 없는 org.slf4j:slf4j-api:2.0.17을 하나 더 선언합니다(이미지에는 2.0.16만 있습니다).

* What went wrong:
Execution failed for task ':printClasspath' (registered in build file 'build.gradle.kts').
> Could not resolve all files for configuration ':compileClasspath'.
   > Could not resolve org.slf4j:slf4j-api:2.0.17.
     Required by:
         root project 'offline-cache'
      > No cached version of org.slf4j:slf4j-api:2.0.17 available for offline mode.

(앞의 FAILURE: … 줄과 끝의 * Try: 안내는 뺐습니다.) 메시지 끝 줄이 위 코드의 문자열 그대로입니다. 이번에는 메타데이터부터 없으니 그래프 해석 단계에서 실패했습니다. 6절의 실패(Could not download commons-lang3-3.12.0.jar … No cached version available for offline mode)는 같은 클래스의 resolveArtifact()(150–153행)에서 나온 것으로, 파일 해석 단계의 실패였습니다.

이 연재의 예제가 네트워크 없이 도는 이유 — 읽기 전용 캐시

이 연재의 예제는 매번 새 컨테이너, 새 ~/.gradle에서 돕니다. 그런데도 --offline으로 commons-text를 찾을 수 있는 것은 이미지에 읽기 전용 의존성 캐시를 넣고 GRADLE_RO_DEP_CACHE 환경 변수로 알려 주었기 때문입니다(1편의 Dockerfile 3단계). 이 환경 변수를 읽는 곳이 DefaultArtifactCaches의 생성자입니다(ivyservice/DefaultArtifactCaches.java 62–68행).

예제마다 첫 실행에 찍히던 Shared read-only dependency cache is an incubating feature.가 64행의 incubatingFeatureUsed()입니다(아직 실험 단계 기능이라는 알림). 이 캐시는 쓰기 캐시와 두 단계로 묶입니다. 메타데이터 캐시를 예로 들면 TwoStageModuleMetadataCache.get()이 쓰기 캐시(~/.gradle/caches/modules-2)를 먼저 보고, 없으면 읽기 전용 캐시를 봅니다(ivyservice/modulecache/TwoStageModuleMetadataCache.java 36–43행). 저장(store)은 쓰기 캐시에만 합니다(같은 파일 30–34행). 예제 3의 run.sh가 확인한 것입니다.

=== GRADLE_RO_DEP_CACHE ===
/opt/gradle-ro-cache
files-2.1
metadata-2.107
=== gradle printClasspath --info (selected lines) ===
Shared read-only dependency cache is an incubating feature.
The read-only dependency cache is enabled 
The GRADLE_RO_DEP_CACHE environment variable was set to /opt/gradle-ro-cache
...
/opt/gradle-ro-cache/modules-2/files-2.1/org.apache.commons/commons-text/1.12.0/66aa90dc099701c4d3b14bd256c328f592ccf0d6/commons-text-1.12.0.jar
/opt/gradle-ro-cache/modules-2/files-2.1/com.google.guava/guava/33.3.1-jre/852f8b363da0111e819460021ca693cacca3e8db/guava-33.3.1-jre.jar
/opt/gradle-ro-cache/modules-2/files-2.1/org.apache.commons/commons-lang3/3.14.0/1ed471194b02f2c6cb734a0cd6f6f107c673afae/commons-lang3-3.14.0.jar
... (guava 가 끌고 온 jar 여섯 개)
=== the writable cache in ~/.gradle/caches/modules-2 ===
metadata-2.107
modules-2.lock
jar files: 0

(가운데 ...는 예제 3의 metadata rule 줄과 스택과 guava의 나머지 jar 여섯 줄을 뺀 자리입니다.) 컴파일 클래스패스의 jar가 모두 /opt/gradle-ro-cache 안을 가리킵니다. 복사하지 않고 그 자리의 파일을 그대로 씁니다. 쓰기 캐시에는 jar가 하나도 없고, 해석 중에 만든 메타데이터 색인(metadata-2.107)과 잠금 파일만 생겼습니다. 경로의 생김새도 보입니다 — files-2.1/<group>/<name>/<version>/<SHA-1>/<파일>. 해시가 경로에 들어 있어 같은 이름의 다른 파일이 섞이지 않습니다. modules·files·metadata 뒤의 숫자는 캐시 배치의 판이고, CacheLayout 열거형이 정합니다(metadata는 8.11-rc-1에서 107로 바뀌었습니다, CacheLayout.java 79행).

같은 캐시에서 메타데이터 파일도 봤습니다. 5절에서 guava만 .module의 변형 이름(jreApiElements)이 나온 이유입니다.

com.google.guava/guava/33.3.1-jre/<sha1>/guava-33.3.1-jre.jar
com.google.guava/guava/33.3.1-jre/<sha1>/guava-33.3.1-jre.module
com.google.guava/guava/33.3.1-jre/<sha1>/guava-33.3.1-jre.pom
org.apache.commons/commons-text/1.12.0/<sha1>/commons-text-1.12.0.jar
org.apache.commons/commons-text/1.12.0/<sha1>/commons-text-1.12.0.pom
guava pom marker:
4:  <!-- do_not_remove: published-with-gradle-metadata -->
commons-text pom marker count: 0

(해시 폴더 이름은 <sha1>로 바꿔 찍었습니다.) guava의 POM 4행에 표시가 있어 .module까지 받았고, commons-text의 POM에는 표시가 없어 POM만 있습니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

정리

개념 한 줄 요약
implementation(…) 생성된 접근자 = dependencies.add("implementation", …). Dependency 객체를 구성에 넣을 뿐
Dependency 객체 문자열 표기를 파서가 DefaultExternalModuleDependency(_Decorated)로 만든다. 버전 문자열만 든다
구성의 역할 선언(DEPENDENCY_SCOPE)·해석(RESOLVABLE)·소비(CONSUMABLE). implementation은 담기만, compileClasspath가 물려받아 해석
게으른 해석 파일 목록을 처음 달라고 할 때 한 번. 보통 실행 엔진이 입력 지문을 뜨는 CaptureMutableStateBeforeExecutionStep에서
해석 뒤 잠금 루트 변형을 만들 때 markAsObserved("resolved") — 물려준 구성까지 바꿀 수 없다
두 단계 그래프 해석(Resolve dependencies of …, 메타데이터만)과 파일 해석(Resolve files of configuration …, jar)
DependencyGraphBuilder 큐에서 노드를 꺼내 간선을 모으고, 버전을 고르고, 메타데이터를 받고, 간선을 붙이는 반복문
노드 컴포넌트가 아니라 컴포넌트의 변형(NodeState). 변형은 구성의 속성과 맞춰 고른다
버전 충돌 모듈에 요청이 모일 때마다 SelectorStateResolver.selectBest() → LatestModuleConflictResolver — 높은 버전
strictly 버전의 여집합을 거절하고, 아래쪽 간선의 버전을 무시하게 한다 — by ancestor
force 해석 전략의 치환 규칙으로 그 모듈의 모든 요청을 바꿔 쓴다 — forced
저장소 탐색 모든 저장소의 캐시를 먼저(1차), 못 찾으면 원격(2차). POM에 표시가 있으면 .module로
--offline 원격 접근을 FailedRemoteAccess로 바꾸고, 캐시는 유효 기간과 상관없이 그대로 쓴다
읽기 전용 캐시 GRADLE_RO_DEP_CACHE. 쓰기 캐시 다음에 보고, 그 자리의 파일을 그대로 쓴다

연재를 마치며

이 글로 1편의 지도에 적어 둔 12편을 모두 썼습니다. 연재의 글 전체입니다.

편 글 한 줄 요약
1 AI와 함께 소스를 읽는 방법과 분석 지도 기준 커밋을 고정해 도커에서 Gradle로 Gradle을 빌드하고, 폴더 지도와 gradle build 한 번의 큰 길을 그렸다
2 전체 지도 — 클라이언트와 데몬, 서비스 스코프, 빌드의 세 단계 프로세스 넷, 프로세스 > 세션 > 빌드 트리 > 빌드 > 프로젝트의 상태 계층과 1:1인 서비스 스코프, 세 단계의 상태 기계
3 gradle 명령에서 데몬까지 — 런처, 래퍼, 데몬 프로토콜 작은 클라이언트가 registry.bin으로 호환되는 데몬을 찾거나 띄우고, 요청은 데몬의 명령 사슬을 지난다
4 서비스 레지스트리 — Gradle 안의 DI 컨테이너 스코프마다 레지스트리가 하나, 부모를 따라 찾고, abstract 게터는 _Decorated 클래스가 채운다
5 빌드 생명주기 — 초기화, 설정, 실행은 어디서 갈리나 실행기 사슬을 지나 DefaultBuildLifecycleController의 상태 전이가 세 단계를 지킨다
6 프로젝트와 빌드 스크립트 — build.gradle.kts가 실행되기까지 평가기 사슬이 스크립트를 부르고, Kotlin DSL은 plugins {}를 먼저 떼어 내 단계를 나눠 컴파일한다
7 플러그인 적용 — plugins {} 블록에서 apply()까지 플러그인 요청을 해석기로 찾아 apply()를 부르고, 플러그인이 다른 플러그인을 사슬로 건다
8 태스크와 지연 설정 — DefaultTask, TaskContainer, Provider register는 필요할 때 만들고, Provider는 값을 만드는 태스크를 함께 들고 다닌다
9 태스크 그래프와 실행 계획 — 무엇을, 어떤 순서로, 몇 개씩 실행 계획이 노드의 순서를 정하고, 워커 리스가 동시에 돌 수 있는 수를 정한다
10 실행 엔진과 증분 빌드 — UP-TO-DATE는 어떻게 정해지나 태스크를 작업 단위로 감싸 스텝 사슬에 넣고, 입력·출력의 지문을 지난번과 비교한다
11 빌드 캐시와 설정 캐시 — 결과를 다시 쓰는 두 가지 방법 태스크 결과는 캐시 키로, 설정 단계의 결과는 작업 그래프째로 저장해 다시 쓴다
12 의존성 해석 — implementation 한 줄이 jar 파일이 되기까지, 그리고 연재를 마치며 (이 글) 구성에 담아 두었다가, 입력 지문을 뜰 때 그래프를 만들고 높은 버전을 골라, 캐시에서 jar를 찾는다

gradle build 한 번의 길, 다시 한 번

1편에서는 gradle build 한 번이 지나는 큰 길을 한 줄로 그렸고, 2편에서는 그것을 A~F 여섯 구역의 지도로 다시 폈습니다. 마지막으로 열두 편을 그 지도 위에 겹쳐 봅니다.

연재 전체의 길. gradle build·gradlew·IDE의 클라이언트가 소켓으로 데몬의 명령 사슬에 닿고(3편), 빌드 생명주기(DefaultBuildTreeLifecycleController·DefaultBuildLifecycleController, 5편)로 들어간다. 서비스 레지스트리와 스코프(Global → UserHome → BuildSession → BuildTree → Build → Project, 4편)가 서비스를 공급한다. 설정 단계(빌드 스크립트 6편 → 플러그인 7편 → 태스크와 Provider 8편)를 지나 실행 계획과 워커 스레드(9편), 실행 엔진 스텝 사슬과 UP-TO-DATE(10편)를 거쳐 태스크 액션(javac·test·jar)이 돈다. 실행 엔진은 빌드 캐시·설정 캐시(11편)와 주고받는다. 의존성 해석(구성 → 그래프 → jar, 12편)은 설정 단계에서 implementation으로 선언되고, 실행 엔진이 입력 지문을 뜰 때 해석된다. 이 글은 오른쪽 아래의 초록 칸이었습니다. 두 화살표가 들어오는 것이 2편 지도의 점선이 뜻하던 것입니다.

터미널의 gradle은 빌드를 하지 않습니다. 작은 클라이언트가 데몬을 찾아 요청을 보내고(3편), 데몬은 요청마다 세션과 빌드 트리를 새로 열어 그 층마다 서비스 레지스트리를 둡니다(2·4편). 생명주기 컨트롤러가 상태를 하나씩 옮기며(5편) settings와 빌드 스크립트를 실행하고(6편), 스크립트의 plugins {}가 플러그인을 걸고(7편), 플러그인은 태스크를 등록만 합니다(8편). implementation(…)도 이때 구성에 담기기만 합니다(이 글). 요청한 태스크에서 실행 계획이 만들어지면(9편) 워커 스레드가 노드를 집어 실행 엔진에 넣고, 엔진은 입력 지문을 뜨다가 — 그 순간 의존성이 해석되고(이 글) — 지난번과 같으면 UP-TO-DATE로(10편), 캐시에 있으면 FROM-CACHE로(11편) 끝내고, 아니면 태스크 액션을 부릅니다.

열두 편을 지나며 되풀이해 나온 생각은 셋이었습니다.

앞의 연재들과 비교하면

이 블로그는 앞서 Spring(27편), Apache httpd(16편), Tomcat(16편)을 읽었고, 같은 때에 nginx와 MariaDB 연재도 쓰였습니다. Gradle은 빌드 도구지만 소스를 열어 보면 그 연재들과 닮은 곳이 많았습니다.

그리고 이 연재들의 소스는 대부분 Gradle로 빌드됩니다. Spring 연재가 읽은 Spring Framework(커밋 f447f3c310)는 Gradle 9.8.0 래퍼로 빌드되고(그 커밋의 gradle/wrapper/gradle-wrapper.properties), 이 블로그 자신도 Gradle 9.5.1 래퍼와 Kotlin DSL로 빌드됩니다.

다음에 읽을 것

다음 연재는 Kafka 소스 파헤치기입니다. Kafka도 Gradle로 빌드됩니다. Kafka 연재가 읽는 커밋(ab1e816411)의 래퍼는 Gradle 9.8.2이고, 루트의 build.gradle에는 이 글 6절에서 읽은 force가 그대로 있습니다.

  configurations.all {
    // zinc is the Scala incremental compiler, it has a configuration for its own dependencies
    // that are unrelated to the project dependencies, we should not change them
    if (name != "zinc") {
      resolutionStrategy {
        force(
          // be explicit about the javassist dependency version instead of relying on the transitive version
          libs.javassist,
          // ensure we have a single version in the classpath despite transitive dependencies
          libs.scalaLibrary,
          libs.scalaReflect,
          libs.jacksonAnnotations
        )
      }
    }
  }

build.gradle 189–204행 (Apache Kafka, 커밋 ab1e816411)

allprojects 블록 안이라 모든 프로젝트의, zinc를 뺀 모든 구성(configurations.all)에 ModuleForcingResolveRule을 걸어, 스칼라 라이브러리와 Jackson 어노테이션이 클래스패스에 한 버전만 들어오게 합니다. 주석이 말하는 "전이 의존성이 있어도 한 버전만"이 바로 6절의 기본 규칙(높은 버전)을 넘어서려는 것입니다. Kafka 연재도 이 Gradle 빌드를 도커에서 돌리는 데서 시작합니다. Kafka 자체 — 레코드 하나가 send()에서 디스크와 복제를 거쳐 poll()까지 가는 길 — 는 그 연재에서 다룹니다.

다른 글