이 글에서 다루는 것
"Gradle 소스 파헤치기" 연재의 열두 번째이자 마지막 글입니다. 11편에서는 태스크의 결과를 빌드 캐시로, 설정 단계의 결과를 설정 캐시로 다시 쓰는 두 가지 길을 봤습니다. 이번 글은 지금까지 미뤄 둔 마지막 구역, implementation("…") 한 줄이 컴파일러에게 건네는 jar 파일 목록이 되기까지를 따라갑니다.
2편 지도에서 이 구역은 F 의존성 해석이었고, 다른 구역과 점선으로 이어져 있었습니다. 의존성 해석은 초기화·설정·실행 가운데 어느 한 단계에 묶여 있지 않기 때문입니다. 선언은 설정 단계(D 구역)에서 하고, 해석은 누군가 파일 목록을 처음 달라고 할 때 — 보통 실행 단계(E 구역)에서 — 일어납니다. 이 글에서 그 점선이 실제로 어디를 지나는지 스택으로 확인합니다.
답하려는 질문은 네 가지입니다.
implementation("org.apache.commons:commons-text:1.12.0")은 소스에서 무엇이 되고, 어디에 담기는가.implementation과compileClasspath는 무슨 사이인가- 해석은 언제, 누구의 부탁으로 시작되는가. 설정 단계에서 파일 목록을 달라고 하면 무슨 일이 생기는가
- 의존성 그래프는 어떻게 만들고, 같은 라이브러리의 두 버전이 부딪치면 무엇을 고르는가.
strictly와force는 그 판단의 어디에 끼는가 - 메타데이터(POM·
.module)와 jar는 어디서 오는가.--offline과 의존성 캐시는 그 길의 어디인가
| 항목 | 내용 |
|---|---|
| 대상 | 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 목록이 되기까지는 세 칸입니다.
파란 칸이 이 글의 2·4~7절, 노란 칸이 3·7절입니다. 점선이 2편 지도에서 F 구역을 점선으로 그린 이유입니다.
- 선언(2절) — 설정 단계에서 빌드 스크립트가 돌 때
Dependency객체를 만들어 구성에 넣기만 합니다. 네트워크도 디스크도 건드리지 않습니다. - 그래프 해석(4~6절) — 루트(이 프로젝트의 구성)에서 출발해 메타데이터를 읽으며 "누가 누구에게 의존하는가"의 그래프를 넓히고, 같은 모듈의 버전이 여럿이면 하나를 고릅니다. 빌드 오퍼레이션 이름은
Resolve dependencies of :compileClasspath입니다. - 파일 해석(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)
$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);
}
먼저 이름으로 구성을 찾습니다(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가 무엇을 끌고 오는지는 아직 아무도 모릅니다.
이 절의 코드를 한 길로 이었습니다. 초록 칸의 클래스 이름과 값은 예제 1의 실제 출력입니다. 여기까지는 아무것도 내려받지 않습니다.
헷갈리기 쉬운 곳
implementation(…)이Dependency?를 돌려주는 이유는 위의Provider길 때문입니다. 버전 카탈로그(libs.commons.text)를 넘기면null이 옵니다. 돌려받은 값으로 무언가를 하는 코드는 문자열 표기에서만 동작합니다.- 구성을 의존성으로 넣는 옛 방식(
implementation(configurations["foo"]))은doAdd()의 첫 분기에서 바로 예외입니다. 메시지 그대로 "Gradle 8.0부터 허용되지 않습니다". 대신extendsFrom을 씁니다(3절).
3. 구성 — 의존성을 담는 바구니, 역할 셋
예제 1의 출력에서 구성마다 declare·resolve·consume 세 값이 달랐습니다. 구성 하나가 할 수 있는 일은 셋입니다.
- 선언(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);
세 불리언의 순서는 (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절에서 변형을 고를 때 쓰입니다.
의존성은 왼쪽에 담고, 해석은 오른쪽에서 합니다. 실선 화살표는 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 변형) 결과가 다를 수 있으니, 해석은 언제나 목적(속성)을 가진 구성에서 합니다.
헷갈리기 쉬운 곳
apiElements가 아무것도 물려받지 않습니다. 출력에서extendsFrom=[]입니다.java플러그인만 걸면api구성이 없으니, 다른 프로젝트가 이 프로젝트를 컴파일할 때 보는 변형에는 이 프로젝트의 의존성이 실리지 않습니다.implementation은 내 컴파일·실행과 남의 실행(runtimeElements)에만 실립니다.api구성은java-library플러그인이withApi()를 불러 만들고, 그때apiElements가api를 물려받게 됩니다(DefaultJvmFeature.java 261–271행).annotationProcessor는 예외입니다. 위 발췌에서 줄인annotationProcessor는 선언과 해석을 둘 다 하는resolvableDependencyScopeLocked로 만들어지고, 그래서@SuppressWarnings("deprecation")이 붙어 있습니다(316–317행). 역할 나누기가 아직 다 끝나지 않은 자리입니다.
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였습니다). 아래에서 위로 읽으면 이렇습니다.
DefaultPlanExecutor의 워커 스레드가compileJava노드를 집어(9편),DefaultTaskNodeExecutor가 실행 엔진에 넘깁니다.- 엔진의 스텝 사슬(10편)을 지나
CaptureMutableStateBeforeExecutionStep이 태스크를 실행하기 전에 입력 파일의 지문을 뜹니다. UP-TO-DATE인지 판단하려면 입력이 무엇인지 알아야 하니까요. compileJava의classpath입력 속에compileClasspath구성이 들어 있습니다(DefaultConfiguration은 그 자체로FileCollection입니다 — 143행의extends AbstractFileCollection). 지문을 뜨려고 그 내용을 방문(visitContents)하는 순간resolveGraphIfRequired()가 불립니다.
즉 해석을 시작한 것은 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)를 해석한 것입니다.
⑥이 다음 예제의 오류를 만드는 자리, ⑧이 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행). 이미 만든 그래프와 구성의 내용이 어긋나지 않게 하려는 장치입니다.
헷갈리기 쉬운 곳
getState()는 해석을 일으키지 않습니다. 예제에서state를 찍어도beforeResolve는 불리지 않았습니다. 반면files,iterator(),resolve(),incoming.resolutionResult의 값 꺼내기처럼 내용을 보는 호출은 해석을 일으킵니다. 태스크에는 구성(또는Provider)을 입력으로 넘기고doLast안에서 꺼내면, 이 예제처럼 해석이 실행 단계로 밀립니다.- 해석한 결과는 빌드 안에서 한 번만 씁니다. 다음 빌드는 다시 해석합니다(대신 7절의 의존성 캐시 덕분에 내려받지는 않습니다). 설정 캐시를 켜면 사정이 달라지지만, 그것은 11편의 범위입니다.
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 요청 |
노드는 "컴포넌트"가 아니라 "컴포넌트의 변형"입니다. 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();
}
}
}
}
재귀가 아니라 큐를 쓰는 반복문입니다. 루트 노드를 큐에 넣고 시작해서, 노드 하나를 꺼내 나가는 의존(간선)을 모으고(visitOutgoingDependenciesAndCollectEdges), resolveEdges()로 그 간선들의 대상을 정합니다. 대상이 정해져 새로 그래프에 붙은 노드가 다시 큐에 들어갑니다. 큐가 비면 미뤄 둔 충돌을 하나 풀고 다시 돕니다.
resolveEdges()는 세 단계입니다(210–222행).
performSelectionSerially()— 간선마다 대상 모듈의 버전을 (다시) 고릅니다. 6절의 충돌 해결이 여기서 일어납니다.maybeDownloadMetadataInParallel()— 고른 컴포넌트 가운데 메타데이터를 "싸게" 얻을 수 없는 것(원격에서 받아야 하는 것)이 둘 이상이면 병렬로 받습니다. 주석대로 "내려받기는 I/O라서 워커 수보다 많이" 돌립니다(289행).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, 속성 맞추기 규칙)는 그것만으로 한 편이 필요해 이 글에서는 여기까지만 봅니다.
헷갈리기 쉬운 곳
- 그래프 해석에는 jar가 필요 없습니다. 지금까지 읽은 것은 메타데이터뿐입니다. jar는 그래프가 다 만들어진 뒤 파일 해석(
Resolve files of configuration)에서 찾습니다. 6절 끝에서 이 차이가 실패로 드러납니다. - 노드가 다시 처리될 수 있습니다. 버전 선택이 바뀌면(6절) 진 쪽 노드의 간선이 걷히고 이긴 쪽에 다시 붙습니다(
ModuleResolveState.changeSelection()의restartIncomingEdges()). 그래서 "노드 하나는 한 번만 큐에 들어간다"고 단정할 수 없습니다.
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);
}
}
새 요청이 올 때마다 모인 요청 전부를 놓고 다시 고릅니다(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;
}
고른 컴포넌트에 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;
}
// ...
"기본 버전"(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);
// ...
조상(여기서는 루트)이 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);
}
}
}
그래프에서 commons-lang3를 가리키는 모든 요청을 3.12.0 요청으로 바꿔 씁니다(useTarget). 요청이 모두 3.12.0이니 역시 충돌이 없습니다. 차이를 정리하면 이렇습니다.
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);
}
두 바퀴입니다. 첫 바퀴는 모든 저장소에 대해 "로컬"만 봅니다. 하나라도 찾으면 거기서 끝입니다. 아무 데서도 못 찾았을 때에만 둘째 바퀴로 원격을 봅니다. 저장소마다 붙는 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을 읽게 됩니다.
노란 칸의 캐시는 내려받은 결과를 다음 빌드와 다른 프로젝트가 다시 씁니다. 빨간 칸은 --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만 있습니다.
헷갈리기 쉬운 곳
--offline은 캐시를 막지 않습니다. 막는 것은 원격 접근뿐이고, 캐시에 있는 것은 유효 기간이 지났어도 그대로 씁니다(위의 캐시 정책). 캐시에 버전 목록이 없으면 동적 버전(1.+)은No cached version listing for … available for offline mode.로 실패합니다(StartParameterResolutionOverride.java135–138행).mavenLocal()은 이 "로컬"이 아닙니다.mavenLocal()은~/.m2/repository를 가리키는 저장소 하나이고, 그 저장소에도 캐시 층과 두 바퀴 탐색이 똑같이 적용됩니다.~/.gradle/caches/modules-2와~/.m2는 별개입니다.
이 글에서 뺀 것
- 변형 고르기와 속성 맞추기(호환·선호 규칙):
platforms/software/dependency-management/…/internal/component/model/GraphVariantSelector.java,…/internal/component/resolution/failure/ - 아티팩트 변환(artifact transform — 받은 jar를 다른 형태로 바꿔 쓰기):
…/api/internal/artifacts/transform/ - 기능(capability) 충돌:
…/resolveengine/graph/conflicts/DefaultCapabilitiesConflictHandler.java—traverseGraph()의 다른 한쪽 - 플랫폼과 BOM(
platform(…), 버전 정렬), 가상 플랫폼:…/resolveengine/graph/builder/VirtualPlatformState.java - 의존성 잠금(
dependencyLocking)과 검증(verification-metadata.xml):ResolutionExecutor.resolveGraph()의DependencyLockingGraphVisitor,…/ivyservice/ivyresolve/verification/ - 동적 버전·변하는 모듈의 캐시 기간(
cacheDynamicVersionsFor):…/ivyservice/resolutionstrategy/DefaultCachePolicy.java - 버전 카탈로그(
libs.versions.toml)가 접근자가 되는 길:platforms/software/dependency-management/…/catalog/ - 실행 계획을 만들 때 구성의 태스크 의존만 가볍게 계산하는 길(
resolveBuildDependencies):DefaultConfigurationResolver.java121–128행 - Ivy 저장소와
ivy.xml:…/ivyservice/ivyresolve/parser/IvyXmlModuleDescriptorParser.java
정리
| 개념 | 한 줄 요약 |
|---|---|
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 여섯 구역의 지도로 다시 폈습니다. 마지막으로 열두 편을 그 지도 위에 겹쳐 봅니다.
이 글은 오른쪽 아래의 초록 칸이었습니다. 두 화살표가 들어오는 것이 2편 지도의 점선이 뜻하던 것입니다.
터미널의 gradle은 빌드를 하지 않습니다. 작은 클라이언트가 데몬을 찾아 요청을 보내고(3편), 데몬은 요청마다 세션과 빌드 트리를 새로 열어 그 층마다 서비스 레지스트리를 둡니다(2·4편). 생명주기 컨트롤러가 상태를 하나씩 옮기며(5편) settings와 빌드 스크립트를 실행하고(6편), 스크립트의 plugins {}가 플러그인을 걸고(7편), 플러그인은 태스크를 등록만 합니다(8편). implementation(…)도 이때 구성에 담기기만 합니다(이 글). 요청한 태스크에서 실행 계획이 만들어지면(9편) 워커 스레드가 노드를 집어 실행 엔진에 넣고, 엔진은 입력 지문을 뜨다가 — 그 순간 의존성이 해석되고(이 글) — 지난번과 같으면 UP-TO-DATE로(10편), 캐시에 있으면 FROM-CACHE로(11편) 끝내고, 아니면 태스크 액션을 부릅니다.
열두 편을 지나며 되풀이해 나온 생각은 셋이었습니다.
- 미루기. 태스크는 등록만 하고 필요할 때 만들고(8편),
Provider는 값을 꺼낼 때 계산하고(8편), 의존성은 파일 목록이 필요할 때 해석합니다(이 글). 설정 단계를 짧게 해야 데몬을 둔 보람이 있기 때문입니다. 그리고 미뤄 둔 것을 한꺼번에 들여다보는 곳이 10편의 입력 지문 스텝이었습니다. - 다시 쓰기. 데몬은 JVM과 클래스로더를(3편), 실행 기록은 지난번 지문을(10편), 빌드 캐시와 설정 캐시는 결과를(11편), 의존성 캐시는 내려받은 파일을(이 글) 다시 씁니다. 다시 쓰려면 "같은가"를 판단해야 하고, Gradle 소스의 많은 부분이 그 판단(지문, 캐시 키, 호환성 검사)에 쓰입니다.
- 층과 사슬. 상태는 층(프로세스 > 세션 > 빌드 트리 > 빌드 > 프로젝트)으로, 처리 단계는 사슬(데몬의 명령 사슬, 실행기 사슬, 평가기 사슬, 실행 엔진의 스텝 사슬)로 쌓았습니다. 처음 보는 Gradle 기능의 소스도 "어느 층의 서비스인가", "어느 사슬의 몇 번째 고리인가"를 물으면 어디부터 볼지 짐작할 수 있습니다.
앞의 연재들과 비교하면
이 블로그는 앞서 Spring(27편), Apache httpd(16편), Tomcat(16편)을 읽었고, 같은 때에 nginx와 MariaDB 연재도 쓰였습니다. Gradle은 빌드 도구지만 소스를 열어 보면 그 연재들과 닮은 곳이 많았습니다.
- 서비스 레지스트리는 Spring의 IoC 컨테이너와 닮았습니다. 부모·자식 컨테이너, 생성자 주입, 프록시처럼 하위 클래스를 만들어 끼우는 것까지(4편과 Spring 3편·5편). 다만 Spring은 컨테이너 하나를 오래 켜 두고, Gradle은 스코프마다 따로 두고 빌드마다 아래쪽 층을 새로 켭니다. 이 글의 "후보 여럿 가운데 하나를 고른다"도 Spring 5편의
@Autowired후보 고르기와 모양이 같지만, 기준이@Primary·이름이 아니라 버전의 높낮이입니다. - 단계 사슬은 Tomcat의 밸브, httpd의 훅과 같은 생각입니다. 실행 엔진의 스텝이 다음 스텝을 감싸서 부르는 모양은 Tomcat 8편의
getNext().invoke()와 같고, 정해 둔 자리에 남의 코드를 끼우는 것은 httpd 6편의 훅과 같습니다. - 데몬은 서버 프로세스입니다. 오래 살면서 요청을 받아 처리하고 다음 요청을 기다리고, 바쁘면 새 프로세스가 대신 받습니다(3편). httpd의 MPM이 다룬 "프로세스와 스레드를 어떻게 나누나"를 Gradle은 "데몬 하나 = 동시에 빌드 하나"로 단순하게 풀었습니다. 클래스로더로 플러그인과 스크립트를 떼어 놓는 것은 Tomcat 4편의 웹앱 클래스로더와 닮았습니다.
그리고 이 연재들의 소스는 대부분 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()까지 가는 길 — 는 그 연재에서 다룹니다.