이 글에서 다루는 것
"Gradle 소스 파헤치기" 연재의 열한 번째 글입니다. 10편에서는 태스크 하나가 실행 엔진의 스텝 사슬을 지나며 UP-TO-DATE로 건너뛰어지는 길을 봤습니다. UP-TO-DATE는 "지난번 실행과 비교해 바뀐 것이 없다"는 뜻이라, 비교 상대가 한 번뿐입니다. 이번 글은 그보다 멀리 내다보는 두 장치, 빌드 캐시와 설정 캐시를 봅니다. 둘 다 "전에 한 일을 다시 하지 않는다"는 목적은 같지만, 저장하는 것도, 사슬에 끼는 자리도 전혀 다릅니다.
2편의 마지막 그림 "2편 지도"로 돌아가면, 이 글은 E 구역(실행 단계)의 맨 아래 칸 "빌드 캐시·설정 캐시"를 확대합니다. 다만 설정 캐시는 실행 단계가 아니라 설정 단계 전체를 건너뛰는 장치라서, C 구역(빌드 생명주기)의 DefaultBuildTreeLifecycleController에도 손을 뻗습니다.
답하려는 질문은 네 가지입니다.
- 빌드 캐시는 실행 엔진의 어디에 끼어 있고, 캐시 키에는 무엇이 들어가는가.
UP-TO-DATE와는 무엇이 다른가 - 캐시 항목은 디스크에 어떤 모양으로 있고, 다른 폴더에 복사한 프로젝트도 왜
FROM-CACHE가 되는가 - 설정 캐시는 무엇을 저장하고, 빌드 생명주기의 어디에 끼는가
- 설정 캐시는 언제 버려지고(무효화), 실행 중에
project를 만지면 왜 실패하는가
| 항목 | 내용 |
|---|---|
| 대상 | Gradle Build Tool |
| 버전 | 9.10.0 개발판 (master) |
| 커밋 | 07ecbf9ead (2026-10-24) |
| 주로 읽는 클래스 | execution/steps/ResolveMutableCachingStateStep·BuildCacheStep, caching/impl/DefaultCachingStateFactory, tasks/execution/DefaultTaskCacheabilityResolver, caching/internal/controller/DefaultBuildCacheController, caching/local/internal/DirectoryBuildCache, packaging/impl/TarBuildCacheEntryPacker, cc/impl/DefaultConfigurationCache·ConfigurationCacheAwareBuildTreeWorkController·ConfigurationCacheKey, fingerprint/ConfigurationCacheFingerprintWriter·…Checker, problems/ConfigurationCacheProblems |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 빌드 캐시 쪽은 platforms/core-execution/(execution, build-cache, build-cache-local, build-cache-packaging)에, 설정 캐시 쪽은 거의 다 platforms/core-configuration/configuration-cache/(Kotlin)에 있습니다.
configuration-cache 모듈 하나만 해도 src/main에 Kotlin 파일이 114개, 17,634줄이고, 그 가운데 핑거프린트 폴더(fingerprint/)만 3,292줄입니다(git ls-files로 센 값, 주석 포함). 원격 캐시(HTTP), 프로젝트 격리(Isolated Projects), Tooling API 모델 캐시도 같은 코드에 엮여 있습니다. 이 글은 gradle <태스크> 한 번이 지나는 큰 줄기만 골랐고, 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적었습니다.
먼저 알아 둘 말
작업 단위(unit of work)와 스텝 사슬은 10편에서 봤습니다. 실행 엔진은 태스크를 UnitOfWork로 감싸 받고, 스텝 객체 스무 개 남짓이 서로를 감싼 사슬에 통과시킵니다. 이 글의 빌드 캐시는 그 사슬의 두 칸입니다.
핑거프린트(fingerprint)는 이 글에서 두 가지 뜻으로 나옵니다. 빌드 캐시 쪽은 10편의 그것, 곧 태스크의 입력 파일들을 "경로를 어떻게 볼지"(@PathSensitive)까지 반영해 줄인 해시입니다. 설정 캐시 쪽은 "설정 단계가 읽은 바깥 입력의 목록과 그때의 값"입니다(8절). 이름이 같아 헷갈리기 쉬우니 절마다 어느 쪽인지 밝힙니다.
빌드 캐시(build cache)는 태스크의 출력 파일을 저장해 두는 곳입니다. 입력이 같으면 출력도 같다는 가정 아래, 입력으로 만든 열쇠(캐시 키)로 출력 묶음을 찾아 그대로 풀어 놓습니다. 콘솔에 > Task :compileJava FROM-CACHE가 찍히면 이 길로 왔다는 뜻입니다. 기본은 꺼져 있고 --build-cache나 gradle.properties의 org.gradle.caching=true로 켭니다.
캐시 키(cache key)는 태스크의 구현과 입력을 해시로 줄인 32자리 16진수입니다. 같은 키면 같은 출력이라고 믿습니다.
캐시 항목(cache entry)은 키 하나에 묶인 출력 파일 묶음입니다. 로컬 캐시에서는 키를 이름으로 한 파일 하나이고, 그 안은 gzip으로 누른 tar입니다(5절).
설정 캐시(configuration cache)는 설정 단계의 결과, 곧 태스크들과 그 설정값이 들어 있는 작업 그래프를 통째로 저장해 두는 곳입니다. 다음 빌드에서 바깥 입력이 바뀌지 않았으면 settings·build 스크립트를 아예 실행하지 않고 저장해 둔 그래프로 바로 실행 단계에 들어갑니다. 기본은 꺼져 있고 --configuration-cache나 org.gradle.configuration-cache=true로 켭니다.
직렬화(serialization)는 메모리의 객체 그래프를 바이트로 쓰는 것입니다. 설정 캐시는 자바의 Serializable을 쓰지 않고, Gradle이 타입마다 만든 코덱(codec, 쓰고 읽는 짝)으로 객체를 씁니다(7절).
1. 두 캐시는 지도의 어디에 있나
먼저 두 캐시가 gradle 명령 한 번의 어디에 끼는지 한 장으로 봅니다.
노란 상자가 디스크에 남는 것입니다. 설정 캐시는 빌드 트리에 하나, 빌드 캐시는 태스크마다 한 번씩 쓰입니다.
둘을 나란히 놓으면 이렇습니다.
| 빌드 캐시 | 설정 캐시 | |
|---|---|---|
| 저장하는 것 | 태스크 하나의 출력 파일 묶음 | 작업 그래프 전체(태스크 객체와 그 필드 값) |
| 건너뛰는 것 | 태스크 액션 실행 | 초기화·설정 단계 전체 |
| 열쇠 | 태스크 구현 + 입력의 해시 (태스크마다) | Gradle 판 + 요청한 태스크 이름 + 몇몇 옵션의 해시 (빌드마다) |
| 다시 쓸지 판단 | 키가 같으면 항상 같은 출력이라고 믿는다 | 열쇠로 찾은 항목의 핑거프린트(설정 단계가 읽은 입력)를 하나하나 다시 확인한다 |
| 기본 위치 | ~/.gradle/caches/build-cache-1/ (여러 프로젝트가 같이 쓴다) |
프로젝트의 .gradle/configuration-cache/ |
| 켜는 법 | --build-cache, org.gradle.caching=true |
--configuration-cache, org.gradle.configuration-cache=true |
| 주로 읽는 코드 | platforms/core-execution/ (Java) |
platforms/core-configuration/configuration-cache/ (Kotlin) |
이 블로그를 빌드하는 포털 저장소(Gradle 9.5.1 래퍼)에는 gradle.properties가 없어서 둘 다 꺼져 있습니다. 반대로 Gradle 저장소 자신은 둘 다 켜 두었습니다.
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.configuration-cache.parallel=true
org.gradle.configuration-cache.entries-per-key=2
1편에서 기준 커밋을 빌드할 때 1,245개 태스크 가운데 114개가 FROM-CACHE였던 것이 이 첫 줄(과 Gradle 팀의 원격 캐시) 덕분입니다.
헷갈리기 쉬운 곳: 이름은 비슷해도 서로 모른다
두 캐시는 서로를 부르지 않습니다. 설정 캐시를 켜도 태스크는 매번 실행 엔진을 지나고, 빌드 캐시가 켜져 있으면 거기서 따로 키를 계산합니다. 반대로 빌드 캐시만 켜면 settings·build 스크립트는 매번 실행됩니다. 둘을 같이 켰을 때 "설정 단계도 건너뛰고, 태스크 실행도 건너뛰는" 효과가 나는 것은 서로 다른 두 장치가 다른 단계에서 각자 일했기 때문입니다.
2. 빌드 캐시 — 스텝 사슬 속 두 칸
10편에서 본 가변(mutable) 파이프라인을 다시 꺼냅니다. 태스크는 이 파이프라인을 지납니다.
Step<IdentityContext, WorkspaceResult> mutablePipeline =
new AssignMutableWorkspaceStep<>(
// ... HandleStaleOutputsStep, LoadPreviousExecutionStateStep, CaptureMutableStateBeforeExecutionStep 등 ...
new ResolveChangesStep<>(changeDetector,
new ResolveMutableCachingStateStep<>(buildCacheController, emitBuildCacheDebugLogging,
new MarkSnapshottingInputsFinishedStep<>(
new SkipUpToDateStep<>(
new StoreExecutionStateStep<>(
new BuildCacheStep<>(buildCacheController, deleter, fileSystemAccess, outputChangeListener,
new ResolveInputChangesStep<>(
// ... CaptureOutputsAfterExecutionStep, RemovePreviousOutputsStep, TimeoutStep 등 ...
new ExecuteStep.Mutable(buildOperationRunner
)))))))))))))))))))));
빌드 캐시가 쓰는 칸은 둘입니다. 196행 ResolveChangesStep 바로 아래의 ResolveMutableCachingStateStep(197행)이 캐시 키를 계산하고 "캐시해도 되는가"를 정합니다. 그리고 SkipUpToDateStep과 StoreExecutionStateStep 아래의 BuildCacheStep(201행)이 실제로 캐시에서 꺼내거나 넣습니다. 두 스텝 모두 생성자에 buildCacheController를 받습니다.
파란 두 칸이 빌드 캐시입니다. 키 계산은 UP-TO-DATE 판단보다 먼저, 꺼내기는 그보다 뒤에 있습니다.
BuildCacheStep.execute()는 짧습니다.
@Override
public AfterExecutionResult execute(UnitOfWork work, C context) {
return context.getCachingState().fold(
cachingEnabled -> executeWithCache(work, context, cachingEnabled.getCacheKeyCalculatedState().getKey()),
cachingDisabled -> executeWithoutCache(work, context)
);
}
앞 스텝이 만들어 둔 CachingState(캐싱 상태)가 "켜짐"이면 키를 들고 executeWithCache()로, "꺼짐"이면 아무것도 하지 않고 다음 스텝으로 넘깁니다. 이 스텝은 판단을 하지 않습니다. 판단은 위의 ResolveMutableCachingStateStep이 다 해 둡니다. 그리고 판단하는 스텝이 SkipUpToDateStep보다 앞에 있으므로, 키와 "캐시하지 않는 이유"는 UP-TO-DATE로 끝나는 빌드에서도 계산되어 로그에 남습니다(예제 1의 8번).
executeWithCache()는 먼저 꺼내 보고(tryLoadingFromCache), 없으면 executeAndStoreInCache()로 실행한 뒤 넣습니다.
AfterExecutionResult result = executeWithoutCache(cacheableWork.work, context);
try {
result.getExecution().ifSuccessfulOrElse(
executionResult -> storeInCacheUnlessDisabled(cacheableWork, cacheKey, result, executionResult),
failure -> LOGGER.debug("Not storing result of {} in cache because the execution failed", cacheableWork.getDisplayName())
);
실행이 성공했을 때만 넣습니다. 실패한 태스크의 반쯤 쓴 출력은 캐시에 들어가지 않습니다. 꺼내는 데 성공하면 96행에서 Execution.skipped(FROM_CACHE, work)라는 결과를 만듭니다. 콘솔의 FROM-CACHE 글자는 이 결과값에서 나옵니다.
헷갈리기 쉬운 곳: 캐시에서 꺼낸 뒤에도 실행 기록은 남는다
StoreExecutionStateStep이 BuildCacheStep의 바깥에 있습니다. 그래서 캐시에서 꺼냈든 실제로 실행했든, 돌아오는 길에 실행 기록(10편의 executionHistory)이 저장됩니다. 그 기록에는 캐시 키도 들어갑니다.
protected R executeMutable(MutableUnitOfWork work, C context) {
R result = delegate.execute(work, context);
work.getHistory()
// ...
.map(executionOutputState -> new DefaultAfterExecutionState(
((BuildCacheKeyInternal) cacheKeyCalculatedState.getKey()).getHashCodeInternal(),
// ...
.ifPresent(afterExecutionState -> history.store(
FROM-CACHE로 출력을 받은 다음 빌드에서 같은 태스크가 UP-TO-DATE가 되는 것, 그리고 3절에서 볼 "지난 키 다시 쓰기"가 이 기록 덕분입니다.
3. 캐시 키는 무엇으로 만드나
키를 만드는 곳은 DefaultCachingStateFactory.calculateCacheKey()입니다. 무엇을 어떤 순서로 해시에 넣는지가 한눈에 보입니다(덩어리마다 앞에 붙은 logger.warn("Appending …") 줄은 마지막 하나만 남기고 줄였습니다).
public HashCode calculateCacheKey(BeforeExecutionState beforeExecutionState) {
final Hasher cacheKeyHasher = Hashing.newHasher();
// ...
beforeExecutionState.getImplementation().appendToHasher(cacheKeyHasher);
// ...
additionalImplementation.appendToHasher(cacheKeyHasher);
// ...
cacheKeyHasher.putString(propertyName);
valueSnapshot.appendToHasher(cacheKeyHasher);
// ...
cacheKeyHasher.putString(propertyName);
cacheKeyHasher.putHash(fingerprint.getHash());
// ...
logger.warn("Appending output property name to build cache key: {}", propertyName);
cacheKeyHasher.putString(propertyName);
// ...
return cacheKeyHasher.hash();
}
다섯 덩어리를 차례로 넣습니다.
- 구현(implementation) — 태스크 클래스.
ClassImplementationSnapshot은 클래스 이름과 그 클래스를 읽은 클래스로더의 해시를 함께 넣습니다(ClassImplementationSnapshot.java35–39행). 같은 이름의 클래스라도 플러그인 jar가 바뀌면 키가 바뀝니다. - 추가 구현 — 태스크 액션마다 하나.
TaskExecution.visitImplementations()가 태스크 클래스 다음에getTaskActions()의 구현을 하나씩 넘깁니다(TaskExecution.java332–339행).doLast { }로 액션을 더하면 키도 바뀝니다. - 입력 값 —
@Input속성의 이름과 값. - 입력 파일 —
@InputFile·@InputFiles속성의 이름과 10편의 파일 핑거프린트 해시. - 출력 속성 이름 — 출력의 값(경로)이 아니라 이름만 넣습니다.
Hashing.newHasher()의 기본 해시 함수는 MD5입니다(Hashing.java 43–56행). 그래서 키는 128비트, 16진수 32자리입니다.
출력 경로가 키에 없으므로, 입력만 같으면 다른 폴더의 프로젝트도 같은 키를 얻습니다(예제 1의 6번).
줄인 로그 줄이 전부 logger.warn인 것이 눈에 띕니다. 이 logger는 생성자로 받는 것이고, 부르는 쪽 AbstractResolveCachingStateStep은 org.gradle.caching.debug가 켜졌을 때만 진짜 로거를, 아니면 아무것도 하지 않는 NOPLogger를 넘깁니다.
Logger logger = emitDebugLogging
? LOGGER
: NOPLogger.NOP_LOGGER;
CachingStateFactory cachingStateFactory = new DefaultCachingStateFactory(logger);
HashCode cacheKey = calculateCacheKey(context, beforeExecutionState, cachingStateFactory);
// ...
if (!buildCache.isEnabled()) {
cachingDisabledReasonsBuilder.add(BUILD_CACHE_DISABLED_REASON);
}
checkIfWorkIsCacheable(work, context, cachingDisabledReasonsBuilder);
평소에는 조용하다가, -Dorg.gradle.caching.debug=true를 주면 경고 수준으로 키의 재료가 하나하나 찍힙니다. 왜 캐시가 안 맞는지 따질 때 쓰는 스위치입니다. 또 하나 볼 것은 78행입니다. 키는 캐시를 쓰든 안 쓰든 먼저 계산하고, 쓰지 않을 이유는 그 뒤에 따로 모읍니다. 빌드 캐시가 꺼져 있어도 키가 실행 기록에 남는 이유입니다.
헷갈리기 쉬운 곳: 바뀐 것이 없으면 키를 다시 계산하지 않는다
가변 파이프라인의 ResolveMutableCachingStateStep은 키 계산을 한 번 더 감쌉니다.
@Override
protected HashCode calculateCacheKey(C context, BeforeExecutionState beforeExecutionState, CachingStateFactory cachingStateFactory) {
return getPreviousCacheKeyIfApplicable(context)
.orElseGet(() -> super.calculateCacheKey(context, beforeExecutionState, cachingStateFactory));
}
/**
* Return cache key from previous build if there are no changes.
*/
private static Optional<HashCode> getPreviousCacheKeyIfApplicable(MutableChangesContext context) {
return context.getChanges()
.flatMap(changes -> context.getPreviousExecutionState()
.filter(__ -> changes.getChangeDescriptions().isEmpty())
.map(PreviousExecutionState::getCacheKey));
}
앞 스텝 ResolveChangesStep이 "지난 실행과 달라진 것이 하나도 없다"고 했으면, 2절에서 본 실행 기록에 적힌 지난번 키를 그대로 씁니다. 처음에는 이것을 모르고 --build-cache -Dorg.gradle.caching.debug=true를 같은 폴더에서 다시 돌렸다가, Build cache key … 한 줄만 나오고 Appending … 줄이 하나도 나오지 않아 한참 들여다봤습니다. 키의 재료를 보려면 입력을 바꾸거나 새 폴더에서 돌려야 합니다(예제 1의 7·8번).
4. 캐시해도 되나 — @CacheableTask와 이유 목록
키가 있다고 다 캐시하지는 않습니다. 태스크는 스스로 "캐시해도 된다"고 밝혀야 합니다. 가장 흔한 방법이 태스크 클래스에 @CacheableTask를 다는 것이고, 이 어노테이션은 태스크를 만들 때 cacheIf 조건 하나로 바뀝니다.
// Enabled caching if task type is annotated with @CacheableTask
if (taskClassInfo.isCacheable()) {
task.getOutputs().cacheIf("Annotated with @CacheableTask", Specs.SATISFIES_ALL);
}
빌드 스크립트에서 outputs.cacheIf { true }라고 직접 적는 것과 같은 일을 어노테이션이 해 주는 셈입니다. 그다음 판단은 DefaultTaskCacheabilityResolver가 합니다. 앞부분이 핵심입니다.
if (cacheIfSpecs.isEmpty()) {
if (task.getClass().isAnnotationPresent(DisableCachingByDefault.class)) {
// ... @DisableCachingByDefault 의 because 문구를 이유로 돌려준다 ...
}
return Optional.of(task.getReasonNotToTrackState()
.map(s -> new CachingDisabledReason(CachingDisabledReasonCategory.NOT_CACHEABLE, "Task is untracked because: " + s))
.orElse(CACHING_NOT_ENABLED));
}
if (!taskProperties.hasDeclaredOutputs()) {
return Optional.of(NO_OUTPUTS_DECLARED);
}
cacheIf가 하나도 없으면 거기서 끝납니다. 클래스에 @DisableCachingByDefault가 있으면 그 because 문구가, 추적하지 않는(untracked) 태스크면 그 이유가, 둘 다 아니면 "Caching has not been enabled for the task"(34행의 CACHING_NOT_ENABLED)가 이유가 됩니다. 이것이 캐시의 기본값이 "안 한다" 라는 뜻입니다. 그 뒤로 출력 선언이 없음, 출력이 다른 태스크와 겹침(overlapping outputs), 추적하지 않는 태스크, 파일 트리 출력, cacheIf 조건 불만족, doNotCacheIf 조건 만족을 차례로 봅니다(69–109행). 3절의 calculateCachingState()가 여기에 "빌드 캐시가 꺼져 있음", "검증 실패"를 더해 이유 목록을 만들고, 목록이 비어 있어야만 캐싱 상태가 "켜짐"이 됩니다.
빨간 칸이 "캐시하지 않음"으로 가는 길입니다. 아무것도 하지 않은 태스크는 왼쪽 길로 갑니다 — 캐시의 기본값이 "안 한다"인 이유입니다. 예제 1의 문구는 아래 출력 그대로입니다.
왜 기본값이 "안 한다"일까요. 소스 주석이 이유를 적어 두지는 않았으니 제 해석입니다. 캐시에서 꺼낸 출력이 실제로 실행한 것과 다르면 빌드가 조용히 틀립니다. 입력을 빠짐없이 선언했는지는 Gradle이 알 수 없으므로, 태스크를 만든 사람이 책임지고 밝히게 한 것입니다. 또 파일을 한 번 복사하는 정도의 태스크는 캐시에서 꺼내는(압축을 푸는) 비용이 다시 실행하는 비용보다 클 수 있습니다. 실제로 Gradle의 Copy 태스크에는 @DisableCachingByDefault(because = "Not worth caching")가 붙어 있습니다(Copy.java 73행). 반대로 JavaCompile에는 @CacheableTask가 붙어 있습니다(JavaCompile.java 96행).
예제 1: @CacheableTask 하나 차이
이 예제는 기준 커밋(07ecbf9ead)을 그대로 빌드한 Gradle 9.10.0 개발판으로 도커 컨테이너(Eclipse Temurin JDK 25)에서 실행했습니다. 같은 일(입력 파일을 읽어 출력 파일을 쓴다)을 하는 태스크 둘을 만들고, 하나에만 @CacheableTask를 달았습니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
plugins {
java
}
// 빌드 캐시에 결과를 넣어도 된다고 밝힌 태스크
@CacheableTask
abstract class Shout : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.RELATIVE) // 파일의 절대 경로 대신 상대 경로만 키에 넣는다
abstract val source: RegularFileProperty
@get:Input
abstract val suffix: Property<String>
@get:OutputFile
abstract val target: RegularFileProperty
@TaskAction
fun run() {
println("Shout: really running (suffix=${suffix.get()})")
target.get().asFile.writeText(source.get().asFile.readText().trim().uppercase() + suffix.get() + "\n")
}
}
// ... 같은 일을 하지만 @CacheableTask 를 달지 않은 Whisper 태스크 ...
tasks.register<Shout>("shout") {
source = layout.projectDirectory.file("message.txt")
// -Psuffix=… 로 바꿀 수 있는 입력 값
suffix = providers.gradleProperty("suffix").orElse("!")
target = layout.buildDirectory.file("shout.txt")
}
// ... whisper 등록, 그리고 src/main/java/lab/Hello.java 하나 ...
[예제 ex/u11-build-cache/project/build.gradle.kts에서 줄임]
로컬 캐시 위치는 settings.gradle.kts의 buildCache { local { directory = "/tmp/build-cache" } }로 눈에 잘 띄는 곳에 옮겼습니다. gradle shout whisper compileJava --build-cache --info의 결과에서 캐시에 관한 줄만 걸러 냈고, 스크립트 접근자(accessor)와 플러그인 해석에 관한 줄 몇 개는 ...로 줄였습니다(run.sh의 1번).
=== 1. first run with --build-cache (--info, filtered) ===
Caching disabled for Kotlin DSL script compilation (Settings/TopLevel/stage1) because:
Build cache is disabled
Using local directory build cache for the root build (location = /tmp/build-cache, remove unused entries = after 7 days).
Projects loaded. Root project using build file '/tmp/p/build.gradle.kts'.
...
Build cache key for Kotlin DSL script compilation (Project/TopLevel/stage1) is b219c9e4f07f28481d14a8aad1f2dd3c
Stored cache entry for Kotlin DSL script compilation (Project/TopLevel/stage1) with cache key b219c9e4f07f28481d14a8aad1f2dd3c
...
Build cache key for Kotlin DSL script compilation (Project/TopLevel/stage2) is 6b9b6f24570bc266e91a848f2a3e106c
Stored cache entry for Kotlin DSL script compilation (Project/TopLevel/stage2) with cache key 6b9b6f24570bc266e91a848f2a3e106c
All projects evaluated.
> Task :shout
Build cache key for task ':shout' is 21885f24cbec3a70e646062095bf75f7
Shout: really running (suffix=!)
Stored cache entry for task ':shout' with cache key 21885f24cbec3a70e646062095bf75f7
> Task :whisper
Caching disabled for task ':whisper' because:
Caching has not been enabled for the task
Whisper: really running
> Task :compileJava
Build cache key for task ':compileJava' is 8ce58e82517d138d1dbb808c172759ab
Stored cache entry for task ':compileJava' with cache key 8ce58e82517d138d1dbb808c172759ab
BUILD SUCCESSFUL in 1m 45s
3 actionable tasks: 3 executed
읽을 것이 셋 있습니다.
:shout와:compileJava(JavaCompile은@CacheableTask입니다)는 키를 얻고 실행 뒤Stored cache entry,:whisper는 4절의CACHING_NOT_ENABLED문구 그대로 캐시하지 않았습니다. 이 줄들은AbstractResolveCachingStateStep의logCacheKey()·logDisabledReasons()(109–126행)와BuildCacheStep의 175행이 찍습니다.- 빌드 스크립트 컴파일도 빌드 캐시를 탑니다. Kotlin DSL 스크립트 컴파일은 실행 엔진에 작업 단위로 요청됩니다(
KotlinScriptEvaluator.kt307–308행). 실행 엔진은 태스크 전용이 아니라는 뜻입니다. - 그런데 맨 첫 줄, settings 스크립트의 첫 단계는 "Build cache is disabled" 입니다.
--build-cache를 줬는데도요. 빌드 캐시를 어디에 둘지는 바로 그 settings 스크립트의buildCache {}가 정하므로, settings 스크립트를 컴파일하는 순간에는 아직 진짜 컨트롤러가 없습니다. 루트 빌드의 컨트롤러는 처음에 아무 일도 하지 않는NoOpBuildCacheController로 시작하고(LifecycleAwareBuildCacheControllerFactory.java124–126행), settings 스크립트를 읽은 뒤(출력에서Projects loaded.보다 앞)Using local directory build cache …줄과 함께 진짜 것으로 바뀝니다. 그 줄은 컨트롤러를 만드는AbstractBuildCacheControllerFactory의 194행이 찍습니다.
이 예제는 두 번 돌려 봤는데, 두 번 다 :shout의 키가 21885f24…로 같았습니다. 입력도, Gradle도 같으니 당연한 결과지만, 키에 시각이나 컨테이너마다 다른 값이 섞이지 않는다는 것을 눈으로 확인한 셈입니다. 시간(1m 45s)은 실행마다 다릅니다.
5. 캐시 항목을 꺼내고 넣는 길
BuildCacheStep이 부르는 BuildCacheController의 구현은 DefaultBuildCacheController입니다(빌드 캐시가 꺼져 있으면 NoOpBuildCacheController — isEnabled()가 false이고 load()는 늘 빈 값입니다). 꺼내기(load(), 114–121행)는 loadLocal()이 찾으면 그대로 돌려주고, 없을 때만 loadRemoteAndStoreResultLocally()로 갑니다. 넣기는 이렇습니다.
@Override
public void store(BuildCacheKey key, CacheableEntity entity, Map<String, FileSystemSnapshot> snapshots, Duration executionTime) {
if (!local.canStore() && !remote.canStore()) {
return;
}
tmp.withTempFile(((BuildCacheKeyInternal) key).getHashCodeInternal(), file -> {
packExecutor.pack(file, key, entity, snapshots, executionTime);
remote.maybeStore(key, file);
local.maybeStore(key, file);
});
}
DefaultBuildCacheController.java 151–161행
- 꺼낼 때는 로컬 먼저, 없으면 원격에서 받아 임시 파일에 두고 풀며, 받았으면 로컬에도 넣습니다(131–149행). 다음번에는 로컬에서 끝납니다.
- 넣을 때는 출력을 임시 파일 하나로 한 번만 포장하고, 그 파일을 원격과 로컬에 차례로 넘깁니다.
--offline이면 원격은 처음부터 꺼집니다(AbstractBuildCacheControllerFactory103–106행의 경고Remote build cache is disabled when running with --offline.).
원격 캐시는 이 예제에서 쓰지 않았습니다(오프라인). 구조만 보여 주려고 그렸습니다.
로컬 캐시 — 키가 곧 파일 이름
로컬 캐시 DirectoryBuildCache에서 항목의 위치를 정하는 메서드는 세 줄입니다.
private File getCacheEntryFile(HashCode key) {
return new File(persistentCache.getBaseDir(), key.toString());
}
색인 파일도 데이터베이스도 없습니다. 캐시 디렉터리 바로 아래에 키를 이름으로 한 파일이 있으면 캐시에 있는 것입니다. 디렉터리를 정하지 않으면 build-cache-1이라는 이름으로 Gradle 사용자 홈의 캐시 폴더 아래에 둡니다(DirectoryBuildCacheServiceFactory.java 46–47행, 79–83행). 사용자 홈에 있으므로 같은 기계의 여러 프로젝트가 한 캐시를 같이 씁니다.
넣을 때는 임시 파일을 Files.move(…, StandardCopyOption.ATOMIC_MOVE)로 키 이름에 원자적으로 옮기고, 같은 이름이 이미 있어서 나는 FileAlreadyExistsException은 무시합니다(DirectoryBuildCache.java 149–162행). 임시 파일은 일부러 캐시 디렉터리 안에 만듭니다(생성자의 53–54행 주석: 같은 파일 시스템이어야 언제나 원자적으로 옮길 수 있다). 여러 데몬이 같은 캐시를 쓰더라도, 다른 프로세스는 다 쓴 파일만 보거나 아예 못 봅니다. 이미 있는 파일을 무시하는 것은 키가 같으면 내용도 같다고 믿기 때문입니다. markAccessed()는 마지막으로 쓴 시각을 적어, 오래 안 쓴 항목을 지우는 정리(기본 7일 — 예제 출력의 remove unused entries = after 7 days)에 씁니다.
항목의 속 — gzip으로 누른 tar
포장기는 TarBuildCacheEntryPacker를 GZipBuildCacheEntryPacker로 감싼 것입니다(BuildCacheServices.java 127–137행).
tar 안의 이름 규칙은 두 상수가 정합니다.
private static final String METADATA_PATH = "METADATA";
private static final Pattern TREE_PATH = Pattern.compile("(missing-)?tree-([^/]+)(?:/(.*))?");
맨 앞에 출처 정보 METADATA를 쓰고(129행 packMetadata), 출력 속성마다 tree-<속성 이름>으로 파일이나 폴더를 넣습니다. 출력이 없었던 속성은 missing-tree-<이름>입니다. 꺼낼 때(177–219행)는 이 이름에서 속성 이름을 떼어 지금 태스크의 같은 속성이 가리키는 경로에 풉니다. tar 안에 절대 경로가 없으니 다른 폴더의 프로젝트에도 풀 수 있습니다. METADATA의 내용은 OriginMetadataFactory.createWriter()가 빌드 ID, 작업 단위의 타입과 이름, 캐시 키, 만든 시각, 원래 실행에 걸린 시간을 Properties 파일 형식으로 씁니다(OriginMetadataFactory.java 44–56행). 캐시에서 꺼낼 때는 이 executionTime(원래 실행에 걸린 시간)이 결과에 그대로 실립니다(BuildCacheStep 102행).
예제 1 이어서: 디렉터리와 항목, 그리고 다시 꺼내기
run.sh의 2·3번은 캐시 디렉터리를 보고, :shout의 항목을 tar로 풀어 봅니다.
=== 2. the cache directory: one file per cache key ===
-rw------- 1 root root 468 Nov 7 04:44 21885f24cbec3a70e646062095bf75f7
-rw------- 1 root root 5823 Nov 7 04:44 6b9b6f24570bc266e91a848f2a3e106c
-rw------- 1 root root 1182 Nov 7 04:44 8ce58e82517d138d1dbb808c172759ab
-rw------- 1 root root 3796 Nov 7 04:44 b219c9e4f07f28481d14a8aad1f2dd3c
-rw-r--r-- 1 root root 17 Nov 7 04:44 build-cache.lock
-rw-r--r-- 1 root root 0 Nov 7 04:44 gc.properties
=== 3. inside the entry of :shout ===
key = 21885f24cbec3a70e646062095bf75f7
-rw-r--r-- 0/0 313 2026-11-07 04:44 METADATA
-rw-r--r-- 0/0 19 2026-11-07 04:44 tree-target
--- METADATA ---
#Generated origin information
#Sat Nov 07 04:44:38 UTC 2026
buildCacheKey=21885f24cbec3a70e646062095bf75f7
buildInvocationId=e7el7wus2bdqtpveoz3j7min5a
creationTime=1794026678269
executionTime=67
gradleVersion=9.10.0-20261107025045+0000
identity=\:shout
type=org.gradle.api.internal.tasks.execution.TaskExecution
- 파일 넷의 이름이 예제 1 출력의 키 넷(스크립트 컴파일 둘,
:shout,:compileJava)과 정확히 같습니다. 파일 크기(몇 바이트)와 시각은 실행마다 조금씩 달랐지만, 이름(키)은 다시 돌려도 같았습니다. tar tzvf가 그대로 읽힙니다. 안에는METADATA와, 출력 속성target의tree-target둘뿐입니다.type은 태스크 클래스(Shout)가 아니라TaskExecution입니다. 실행 엔진이 받는 작업 단위가 태스크를 감싼TaskExecution이기 때문입니다(10편).gradleVersion은createWriter()의 기본 여섯 키에는 없고,OriginMetadataFactory를 만들 때 넘긴additionalProperties가 더한 것입니다(BuildCacheServices.java93–96행). 시각,executionTime,buildInvocationId는 실행마다 다릅니다.
4번은 clean으로 build/를 지운 뒤 같은 명령을 다시 돌린 결과입니다.
=== 4. clean, then the same build again ===
> Task :shout FROM-CACHE
Loaded cache entry for task ':shout' with cache key 21885f24cbec3a70e646062095bf75f7
> Task :whisper
Whisper: really running
> Task :compileJava FROM-CACHE
Loaded cache entry for task ':compileJava' with cache key 8ce58e82517d138d1dbb808c172759ab
3 actionable tasks: 1 executed, 2 from cache
Shout: really running이 없습니다. 태스크 액션은 불리지 않았고 출력 파일만 풀렸습니다. :whisper는 캐시하지 않았으니 다시 실행됐습니다.
5번이 UP-TO-DATE와 빌드 캐시의 차이를 가장 잘 보여 줍니다. clean 없이 입력 값을 바꿨다가 되돌립니다.
=== 5. change an input, then change it back (no clean) ===
> Task :shout
Shout: really running (suffix=?)
HELLO BUILD CACHE?
> Task :shout FROM-CACHE
HELLO BUILD CACHE!
-Psuffix=?로 돌리면 입력이 바뀌었으니 실행되고(새 키로 저장), 다시 기본값 !로 돌리면 이번에도 입력이 "지난번"(?)과 다르므로 UP-TO-DATE는 될 수 없습니다. 하지만 빌드 캐시에는 ! 때의 키가 아직 있으므로 FROM-CACHE입니다. UP-TO-DATE는 지난 한 번과만 비교하고, 빌드 캐시는 키가 남아 있는 모든 과거와 비교합니다. 브랜치를 오가며 빌드할 때 빌드 캐시가 힘을 쓰는 이유입니다.
6번은 같은 프로젝트를 /tmp/p2라는 다른 폴더에 새로 복사해 돌린 것입니다. 실행 기록(.gradle/)도 build/도 없는 상태입니다.
=== 6. the same project copied to another directory ===
> Task :shout FROM-CACHE
> Task :compileJava FROM-CACHE
2 actionable tasks: 2 from cache
3절의 키 재료에 출력 경로가 없고, Shout의 입력 파일은 @PathSensitive(RELATIVE)라 상대 경로 message.txt만 들어가기 때문입니다. 이것을 재배치 가능(relocatable)하다고 합니다. CI와 개발자 PC가 원격 캐시를 나눠 쓸 수 있는 바탕입니다.
헷갈리기 쉬운 곳: @PathSensitive를 빼면 기본은 절대 경로다
@PathSensitive의 설명 주석은 "태스크가 이 어노테이션 없이 파일 속성을 선언하면 기본값은 PathSensitivity.ABSOLUTE"라고 적어 둡니다(PathSensitive.java 31행). 그러면 입력 파일의 절대 경로가 핑거프린트에, 따라서 키에 들어가서, 같은 PC 안이라도 폴더가 다르면 키가 달라집니다. 직접 쓰는 @CacheableTask에는 @PathSensitive를 함께 다는 것이 좋습니다. 7번 출력에서 그 차이가 보입니다.
=== 7. what goes into the key (-Dorg.gradle.caching.debug=true, fresh copy) ===
> Task :shout FROM-CACHE
Appending implementation to build cache key: Build_gradle$Shout_Decorated@b06e37f55f149f6cf2411b2664e28f71
Appending additional implementation to build cache key: Build_gradle$Shout_Decorated@b06e37f55f149f6cf2411b2664e28f71
Appending input value fingerprint for 'suffix' to build cache key: e6bb36e3d3215fc6588af49335006ae6
Appending input file fingerprints for 'source' to build cache key: 4915c002f8de048dcf75ff8e8660b0fc - RELATIVE_PATH{/tmp/p3/message.txt='message.txt' / 45ddde6bec0517e02debeff607fb5696}
Appending output property name to build cache key: target
Build cache key for task ':shout' is 21885f24cbec3a70e646062095bf75f7
=== 8. the same command once more (inputs unchanged) ===
> Task :shout UP-TO-DATE
Build cache key for task ':shout' is 21885f24cbec3a70e646062095bf75f7
- 3절 코드의 다섯 덩어리가 그 순서대로 찍혔습니다. 구현은
Build_gradle$Shout_Decorated, 곧 빌드 스크립트 클래스 안의Shout를 Gradle이 감싼_Decorated하위 클래스(4편)이고,@뒤가 클래스로더 해시입니다. 추가 구현이 같은 이름인 것은@TaskAction메서드가 그 클래스에 있기 때문입니다. - 입력 파일 줄의
RELATIVE_PATH{/tmp/p3/message.txt='message.txt' / …}가 핵심입니다. 실제 경로는/tmp/p3/…이지만 키에는'message.txt'만 들어갔고, 그래서 키가/tmp/p에서 만든 것과 같은21885f24…입니다. - 8번은 같은 폴더에서 한 번 더 돌린 것입니다.
UP-TO-DATE인데도 키 줄이 찍혔고(2절: 키 계산이SkipUpToDateStep보다 앞),Appending줄은 하나도 없습니다(3절: 바뀐 것이 없으면 지난 키를 그대로 쓴다).org.gradle.caching.debug가 켜져 있으면 키 줄도 경고 수준으로 찍혀서--info없이 보입니다(AbstractResolveCachingStateStep110–111행). - 7번에서 태스크 머리 줄(
FROM-CACHE)이Appending줄보다 먼저 찍힌 이유는 확인하지 않았습니다. 소스에서는 키 계산(ResolveMutableCachingStateStep)이 꺼내기(BuildCacheStep)보다 앞입니다.
6. 설정 캐시 — 작업 그래프를 통째로
이제 설정 캐시입니다. 이 장치는 실행 엔진이 아니라 5편의 빌드 생명주기에 끼어듭니다. 끼어드는 방식은 서비스 레지스트리(4편)에서 구현을 바꿔 등록하는 것입니다.
if (modelParameters.isVintage) {
// ...
add(BuildTreeLifecycleControllerFactory::class.java, BarrierAwareBuildTreeLifecycleControllerFactory::class.java)
// ...
} else if (modelParameters.isConfigurationCache) {
// ...
add(BuildTreeLifecycleControllerFactory::class.java, ConfigurationCacheBuildTreeLifecycleControllerFactory::class.java)
"Vintage"는 설정 캐시가 꺼진 길입니다(2편의 "헷갈리기 쉬운 곳"). 켜지면 빌드 트리 생명주기 컨트롤러를 만드는 공장이 ConfigurationCacheBuildTreeLifecycleControllerFactory로 바뀌고, 이 공장은 작업 컨트롤러 하나만 바꿔 끼웁니다.
private
fun createController(applyCaching: Boolean, targetBuild: BuildLifecycleController, workExecutor: BuildTreeWorkExecutor, finishExecutor: BuildTreeFinishExecutor): BuildTreeLifecycleController {
if (!applyCaching) {
return vintageFactory.createController(targetBuild, workExecutor, finishExecutor)
}
val workPreparer = vintageFactory.createWorkPreparer(targetBuild)
val workController = ConfigurationCacheAwareBuildTreeWorkController(
workPreparer,
// ... workExecutor, taskGraph, cache 등 ...
)
// ...
val finisher = ConfigurationCacheAwareFinishExecutor(finishExecutor, cache)
return DefaultBuildTreeLifecycleController(targetBuild, workController, modelCreator, finisher, stateTransitionControllerFactory)
}
마지막 줄의 DefaultBuildTreeLifecycleController는 5편에서 본 그 클래스 그대로입니다. 설정 캐시가 꺼졌을 때는 VintageBuildTreeWorkController가 들어갈 자리에(VintageBuildTreeLifecycleControllerFactory.kt 66행) ConfigurationCacheAwareBuildTreeWorkController가 들어가고, 이 객체가 실제 설정 작업(workPreparer — vintage 것을 그대로 받는다)을 감싸서 "할지 말지"를 정합니다. 생명주기 자체를 고치지 않고 부품 하나를 감싸는 구조라, 10편의 스텝 사슬이나 Tomcat 8편의 밸브와 닮았습니다.
createRootBuildController()(60–68행)는 이 컨트롤러를 만들기 전에 cache.initializeCacheEntry()를 부릅니다. settings 스크립트 한 줄 실행하기 전에, 저장해 둔 항목을 쓸지 이미 정한다는 뜻입니다.
작업 컨트롤러의 두 갈래
ConfigurationCacheAwareBuildTreeWorkController.scheduleAndRunRequestedTasks()(49–62행)는 두 줄로 요약됩니다. loadAndRun()이 먼저 저장된 그래프를 읽어 실행해 보고, 안 되면(null) scheduleStoreAndRun()으로 설정 → 저장 → 실행합니다. 그런데 scheduleStoreAndRun()의 "실행"이 우리 생각과 다릅니다.
val executionResult: ExecutionResult<Void>? = workGraph.withNewWorkGraph { graph ->
val outcome = cache.scheduleRequestedTasks(graph) { workPreparer.scheduleRequestedTasks(graph, taskSelector) }
// ...
if (outcome is BuildTreeConfigurationCache.ScheduleOutcome.Stored && !buildModelParameters.isModelBuilding) {
// CC miss, graph has been successfully stored. We don't try to execute the graph directly but store it first, discard, and then reload.
// We don't want to fold the code below here so the "live" graph can be garbage collected before execution.
null
} else {
// ...
return storeAndReload(scheduleTaskSelectorPostProcessing)
}
private fun storeAndReload(scheduleTaskSelectorPostProcessing: BuildTreeWorkGraphBuilder?): ExecutionResult<Void> {
cache.finalizeCacheEntry()
buildRegistry.resetModels()
return workGraph.withNewWorkGraph { graph ->
val (finalizedGraph, workGraphRestorationFailed) = cache.loadRequestedTasks(graph, scheduleTaskSelectorPostProcessing)
// ...
workExecutor.execute(finalizedGraph)
ConfigurationCacheAwareBuildTreeWorkController.kt 82–116행에서 줄임
87–88행의 주석이 요점입니다. "캐시를 못 찾았고 그래프를 저장했으면, 그래프를 바로 실행하지 않는다. 먼저 저장하고, 버리고, 다시 읽는다." 그래서 첫 빌드조차 finalizeCacheEntry()로 항목을 확정하고, resetModels()로 방금 설정한 프로젝트 모델을 버린 다음, 방금 쓴 파일에서 그래프를 다시 읽어 실행합니다. 단, 86행의 조건대로 이것은 저장에 성공했을 때(Stored)이고 Tooling API의 모델 요청이 아닐 때입니다. 설정 단계에서 생긴 문제로 항목을 버리거나(NotStored), 읽기 전용 모드(SkipStore)이거나, 모델을 만드는 요청이면 설정한 그래프를 그대로 실행합니다(91–92행).
평소의 두 갈래는 모두 ⑥을 지납니다. 저장에 성공한 빌드에서 실행 단계가 보는 태스크는 파일에서 되살린 것입니다(점선은 예외).
왜 이렇게 번거롭게 할까요. 주석에 적힌 이유는 메모리입니다. 설정에 쓴 "살아 있는" 그래프를 실행 전에 놓아 가비지 컬렉션이 거둘 수 있게 하려는 것입니다(88행). 여기에 제 해석을 하나 보태면, 저장한 그래프로만 실행하면 첫 빌드와 다음 빌드가 똑같은 객체 상태로 실행됩니다. 설정 단계의 메모리에만 있고 파일에 쓰이지 않은 무언가에 태스크가 기대고 있다면, 그 문제가 두 번째 빌드가 아니라 첫 빌드에서 바로 드러납니다.
예제 2: 첫 빌드부터 태스크가 다른 객체다
설정 단계와 실행 단계에서 태스크 객체의 System.identityHashCode()를 찍어 봤습니다.
// 이 글을 위해 쓴 예제입니다 (Gradle 의 소스가 아닙니다)
println("build script: configuring project '${project.name}'")
// 설정 단계에서 환경 변수를 읽는다 — 설정 캐시는 이 읽기를 기록해 둔다
val greeting = System.getenv("GREETING") ?: "hello"
tasks.register("hello") {
println("hello task: configured, instance ${System.identityHashCode(this)}")
val message = greeting // 설정 단계에서 값으로 만들어 둔다
doLast {
println("hello task: running, instance ${System.identityHashCode(this)} -> $message")
}
}
// ... doLast 안에서 project.name 을 읽는 bad 태스크 — 9절 ...
[예제 ex/u11-configuration-cache/project/build.gradle.kts에서 줄임]
settings.gradle.kts에는 println("settings script: evaluated") 한 줄을 넣었습니다. run.sh가 같은 컨테이너에서 차례로 돌린 결과입니다.
=== 0. without the configuration cache ===
...
Starting a Gradle Daemon (subsequent builds will be faster)
Shared read-only dependency cache is an incubating feature.
settings script: evaluated
> Configure project :
build script: configuring project 'u11-configuration-cache'
hello task: configured, instance 1408746995
> Task :hello
hello task: running, instance 1408746995 -> hello
BUILD SUCCESSFUL in 29s
1 actionable task: 1 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.10.0-20261107025045+0000/userguide/configuration_cache_enabling.html
=== 1. --configuration-cache, first run ===
Calculating task graph as no cached configuration is available for tasks: hello
settings script: evaluated
> Configure project :
build script: configuring project 'u11-configuration-cache'
hello task: configured, instance 55274733
> Task :hello
hello task: running, instance 1180832621 -> hello
BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed
Configuration cache entry stored.
=== 2. --configuration-cache, second run ===
Reusing configuration cache.
> Task :hello
hello task: running, instance 1359404247 -> hello
BUILD SUCCESSFUL in 1s
1 actionable task: 1 executed
Configuration cache entry reused.
(첫 실행의 Welcome to Gradle … 안내는 ...로 줄였습니다. Shared read-only dependency cache is an incubating feature.는 이 연재의 도커 이미지가 쓰는 읽기 전용 의존성 캐시 때문에 찍히는 줄입니다. 숫자와 시간은 실행마다 다릅니다.)
- 0번(설정 캐시 꺼짐) 은 설정할 때와 실행할 때의 번호가
1408746995로 같습니다. 같은 태스크 객체입니다. 마지막 줄의 "Consider enabling configuration cache…"는 설정 캐시가 꺼져 있고 명시적으로 끄지도 않았을 때 붙는 권유입니다(BuildTreeModelControllerServices.kt119행의ConfigurationCachePromoHandler). 설정 캐시의 기본값은 꺼짐입니다(StartParameterInternal.java42행). - 1번(첫 빌드) 은 settings·build 스크립트가 모두 실행됐는데도, 설정할 때(
55274733)와 실행할 때(1180832621)의 번호가 다릅니다. 위 코드의 "저장하고, 버리고, 다시 읽는다"가 그대로 보입니다. 첫 줄Calculating task graph as no cached configuration is available for tasks: hello는 8절에서 볼determineBasicCacheAction()의 문구입니다. - 2번 은 settings와 build 스크립트의
println이 하나도 없습니다. 설정 단계를 통째로 건너뛰었습니다. 그런데도-> hello가 찍혔습니다.message는 설정 단계에서 만든 값이고, 람다(doLast)가 붙잡은 그 값이 파일에 쓰였다가 되살아난 것입니다. - 마지막 줄
Configuration cache entry stored./reused.는 빌드가 다 끝날 때ConfigurationCacheProblems가 찍습니다(9절).
헷갈리기 쉬운 곳: "설정 단계에서 만든 값"과 "설정 단계의 코드"는 다르다
doLast 안에서 greeting 대신 System.getenv("GREETING")을 불렀다면, 그것은 실행할 때마다 도는 코드이니 매번 새 값을 읽습니다. 반대로 위 예제처럼 설정 단계에서 읽어 변수에 담으면, 2번처럼 재사용될 때는 저장해 둔 값이 쓰입니다. 환경 변수가 바뀌면 어떻게 될까요. 저장된 값이 낡아지는 것을 설정 캐시는 "핑거프린트"로 막습니다(8절).
7. 무엇을 저장하나 — 키, 항목, 코덱
열쇠: 요청한 태스크와 몇몇 옵션
설정 캐시의 열쇠는 ConfigurationCacheKey가 만듭니다.
fun Hasher.putCacheKeyComponents() {
putString(GradleVersion.current().version)
// ...
if (buildActionRequirements.isRunsTasks) {
putAll(startParameter.requestedTaskNames)
putAll(startParameter.excludedTaskNames)
}
putBoolean(startParameter.isOffline)
// ...
putBoolean(encryptionConfiguration.isEncrypting)
putHash(encryptionConfiguration.encryptionKeyHashCode)
// ...
}
Gradle 판, 요청한 태스크 이름(hello), --offline 같은 옵션을 MD5로 해시해 짧은 문자열로 만듭니다(40–44행). 빌드 스크립트의 내용은 열쇠에 없습니다. gradle hello와 gradle build는 다른 열쇠이지만, 스크립트를 고쳐도 열쇠는 그대로입니다. 스크립트가 바뀌었는지는 열쇠로 찾은 항목의 핑거프린트가 따로 확인합니다(8절). 빌드 캐시가 "입력 전부를 열쇠에 녹이는" 방식이라면, 설정 캐시는 "열쇠로 후보를 찾고, 입력은 하나하나 다시 확인하는" 방식입니다. 설정 단계가 읽을 입력은 실행해 보기 전에는 알 수 없으니 열쇠에 미리 녹일 수가 없기 때문입니다.
열쇠 끝부분의 encryptionKeyHashCode도 눈여겨볼 만합니다. 설정 캐시 파일은 기본으로 암호화됩니다(DefaultEncryptionService.kt의 내부 옵션 org.gradle.internal.configuration-cache.encryption 기본값 true, 44행). 다만 전부는 아니고, 아래에서 볼 StateType 열거형에서 encryptable이 참인 것(작업 그래프 work.bin, 핑거프린트 등)만이고 entry.bin·candidates.bin·classloaderscopes.bin은 암호화하지 않습니다. 태스크 입력에 비밀번호 같은 값이 들어갈 수 있으니 디스크에 평문으로 두지 않으려는 것으로 보입니다(소스 주석에 적힌 이유는 아닙니다). 암호화 키는 환경 변수 GRADLE_ENCRYPTION_KEY로 주지 않으면 Gradle 사용자 홈의 캐시(cc-keystore)에 만들어지는 키 저장소에 있고(EncryptionKind.select()), 그 키의 해시가 열쇠에 들어갑니다. 이 연재의 예제 컨테이너는 매번 새 사용자 홈을 쓰므로, 아래 예제의 디렉터리 이름(열쇠)은 컨테이너를 새로 띄울 때마다 달라졌습니다.
항목: 열쇠 폴더와 UUID 폴더
ConfigurationCacheRepository는 빌드 트리 범위의 캐시 폴더 configuration-cache(276–277행 — 루트 프로젝트의 .gradle/configuration-cache/) 아래에 열쇠나 항목 ID를 이름으로 한 폴더를 만들고(326–327행), 상태 종류마다 <종류 소문자>.bin 파일을 둡니다(337–339행). 열쇠 하나에 항목이 몇 개까지 딸릴 수 있는지는 org.gradle.configuration-cache.entries-per-key이고 기본값은 1입니다(StartParameterInternal.java 55행). Gradle 저장소가 이것을 2로 둔 것을 1절에서 봤습니다. 새 항목의 ID는 initializeCacheEntry()가 무작위 UUID로 정합니다(DefaultConfigurationCache.kt 214–224행).
예제 2의 3번(아래 8절)까지 돌린 뒤 .gradle/configuration-cache를 들여다본 결과입니다.
=== 4. what is on disk ===
.gradle/configuration-cache
.gradle/configuration-cache/7z43g5hhkp3l9ngwtlgt2fs3n
.gradle/configuration-cache/7z43g5hhkp3l9ngwtlgt2fs3n/candidates.bin
.gradle/configuration-cache/9d73552e-903b-4a85-b2c4-d1369b0add3f
.gradle/configuration-cache/configuration-cache.lock
.gradle/configuration-cache/f1de74b3-e639-495a-8174-5d5911b348d8
.gradle/configuration-cache/f1de74b3-e639-495a-8174-5d5911b348d8/buildfingerprint.bin
.gradle/configuration-cache/f1de74b3-e639-495a-8174-5d5911b348d8/classloaderscopes.bin
.gradle/configuration-cache/f1de74b3-e639-495a-8174-5d5911b348d8/entry.bin
.gradle/configuration-cache/f1de74b3-e639-495a-8174-5d5911b348d8/.globals.work.bin
.gradle/configuration-cache/f1de74b3-e639-495a-8174-5d5911b348d8/projectfingerprint.bin
.gradle/configuration-cache/f1de74b3-e639-495a-8174-5d5911b348d8/.strings.work.bin
.gradle/configuration-cache/f1de74b3-e639-495a-8174-5d5911b348d8/_.work.bin
.gradle/configuration-cache/f1de74b3-e639-495a-8174-5d5911b348d8/work.bin
.gradle/configuration-cache/gc.properties
7z43g5…가 열쇠 폴더이고, 안의candidates.bin이 이 열쇠에 딸린 항목 ID 목록입니다(ConfigurationCacheCandidateEntries).f1de74b3-…가 지금 쓰는 항목입니다.StateType열거형(ConfigurationCacheState.kt111행~)의 이름이 그대로 파일 이름입니다.work.bin이 작업 그래프,buildfingerprint.bin·projectfingerprint.bin이 8절의 핑거프린트,entry.bin이 항목의 세부 정보입니다..strings·.globals·_.work.bin은ConfigurationCacheRepository끝부분의 함수들(relatedStateFileFor,sharedObjectsFileFor)이 만드는 딸린 파일입니다.- 비어 있는
9d73552e-…는 환경 변수를 바꾸기 전(1번)의 항목입니다. 열쇠당 항목이 1개라 새 항목이 들어오면서 내용이 지워진 것으로 보입니다(폴더가 남는 이유까지는 확인하지 않았습니다).
폴더와 파일 이름은 위 4. what is on disk 출력 그대로입니다. 열쇠와 UUID 는 실행마다 다릅니다.
코덱: 태스크를 필드째 쓴다
work.bin에 태스크를 쓰는 곳은 TaskNodeCodec입니다.
suspend fun WriteContext.writeTask(task: TaskInternal) {
withDebugFrame({ task.path }) {
val taskType = GeneratedSubclasses.unpackType(task)
val taskName = task.name
writeClass(taskType)
writeProjectRef(task.project)
writeString(taskName)
// ...
withDebugFrame({ taskType.name }) {
withTaskOf(taskType, task, userTypesCodec) {
writeUpToDateSpec(task)
writeCacheabilitySpecs(task)
writeReasonNotToTrackState(task)
writeOnlyIfSpec(task)
beanStateWriterFor(task.javaClass).run {
writeStateOf(task)
writeRegisteredPropertiesOf(task)
}
writeTaskActions(task)
// ...
writeRequiredServices(task)
writeTaskLoggingListeners(task)
}
}
}
}
- 클래스, 프로젝트, 이름으로 "어떤 태스크인가"를 쓰고, 4절의
cacheIf조건(writeCacheabilitySpecs)과onlyIf, 필드 상태(writeStateOf), 태스크 액션(writeTaskActions— 예제의doLast람다와 그것이 붙잡은message), 필요한 빌드 서비스까지 씁니다.GeneratedSubclasses.unpackType()으로_Decorated를 벗긴 원래 클래스를 쓰는 것도 보입니다. - 읽는 쪽
readTask()(146–173행)는 154행의createTask(...)로 태스크를 새로 만들고 같은 순서로 채웁니다. 예제 2의 번호가 달랐던 이유입니다. - 필드 상태는 리플렉션으로 씁니다. 대상은
static·transient가 아닌 선언된 필드이고(AbstractTask의 내부 필드 대부분과 몇몇 예외 필드는 뺍니다), 이름순으로 정렬합니다(BeanSchema.kt141–152행). 필드 값마다 타입에 맞는 코덱(Property,FileCollection,List, 사용자 클래스 …)이 붙습니다. 코덱 묶음은platforms/core-configuration/core-serialization-codecs/와graph-serialization/에 있습니다.
이 구조에서 설정 캐시의 규칙이 자연스럽게 나옵니다. 태스크가 들고 있는 것은 전부 파일에 쓸 수 있어야 하고, 실행할 때는 파일에서 되살린 것만 써야 합니다. Project 객체는 빌드 전체의 모델에 이어져 있어 통째로 쓸 수 없으니, 실행 중에 project를 찾는 태스크는 규칙을 어긴 것입니다(9절).
8. 캐시를 버리는 조건 — 핑거프린트
이제 "저장된 값이 낡았는지"를 어떻게 아는지 봅니다. 출발점은 7절의 initializeCacheEntry()가 부르는 determineBasicCacheAction()입니다.
when (val checkedFingerprint = entrySelector.selectEntry()) {
is CheckedFingerprint.NotFound -> {
val description = formatBootstrapSummary(
"%s as no cached configuration is available for %s",
// ...
Store.withDescription(description)
}
is CheckedFingerprint.Invalid -> {
val description = formatBootstrapSummary(
"%s as configuration cache cannot be reused because %s.",
// ...
Store.withDescription(description)
}
is CheckedFingerprint.Valid -> {
when (val invalid = checkedFingerprint.invalidProjects) {
null -> {
val description = StructuredMessage.forText("Reusing configuration cache.")
logBootstrapSummary(description)
Load(checkedFingerprint.entryId).withDescription(description)
}
DefaultConfigurationCache.kt 494–521행에서 줄임
예제 2의 첫 줄들이 모두 여기서 나왔습니다. 항목이 없으면 NotFound → "… as no cached configuration is available for tasks: hello", 핑거프린트가 맞지 않으면 Invalid → "… cannot be reused because <이유>.", 맞으면 Load → "Reusing configuration cache."입니다. (이 분기 앞에는 --refresh-dependencies, --write-locks 같은 옵션이면 핑거프린트를 보지도 않고 Store로 가는 분기들이 있습니다, 457–491행.)
핑거프린트는 설정 단계가 "읽은 것"의 기록
핑거프린트는 설정 단계가 진행되는 동안 쌓입니다. DefaultConfigurationCache.runWorkThatContributesToCacheEntry()(560–567행)가 설정 작업을 감싸 cacheFingerprintController.maybeStartCollectingFingerprint()로 기록을 시작하고, 끝나면 멈춥니다. 무엇을 기록하는지는 ConfigurationCacheFingerprint의 하위 클래스들이 보여 줍니다. 읽은 파일(InputFile), 폴더의 목록(DirectoryChildren), ValueSource(providers.environmentVariable(…) 같은 Provider), 시스템 속성(UndeclaredSystemProperty), 환경 변수(UndeclaredEnvironmentVariable), Gradle 속성, init 스크립트 …(fingerprint/ConfigurationCacheFingerprint.kt). 빌드 스크립트 파일 자체도 그 클래스를 불러올 때 InputFile로 기록됩니다(ConfigurationCacheFingerprintWriter.onScriptClassLoaded(), 532–536행).
그런데 예제 2의 빌드 스크립트는 Gradle API가 아니라 JDK의 System.getenv()를 그냥 불렀습니다. Gradle은 이것을 어떻게 알았을까요. 답은 바이트코드 고쳐 쓰기입니다. Gradle은 빌드 스크립트의 컴파일된 클래스를 쓰기 전에 바이트코드를 고쳐(계측, instrumentation) System.getenv 호출을 자기 메서드 호출로 바꿉니다.
} else if (name.equals("getenv")) {
if (descriptor.equals(RETURN_STRING_FROM_STRING)) {
// System.getenv(String) -> String
mv._LDC(binaryClassNameOf(className));
mv._INVOKESTATIC(INSTRUMENTED_TYPE, "getenv", RETURN_STRING_FROM_STRING_STRING);
return true;
ASM으로 메서드 본문을 다시 쓰면서, System.getenv(String)을 만나면 부른 클래스의 이름을 하나 더 얹어(_LDC) Instrumented.getenv(String, String)을 부르게 바꿉니다. 바뀐 쪽은 이렇습니다.
// Called by generated code.
public static String getenv(String key, String consumer) {
String value = System.getenv(key);
envVariableQueried(key, value, consumer);
return value;
}
진짜 값을 읽어 돌려주는 것은 같고, 그 사이에 "누가 무엇을 읽어 무엇을 얻었다"를 알립니다. Spring 10편의 프록시가 객체를 감싸 호출을 가로챈다면, 이쪽은 호출하는 쪽의 바이트코드를 고쳐 JDK의 정적 메서드 호출까지 가로챕니다. 알림은 InstrumentedInputAccessListener.envVariableQueried()(88–93행)를 거쳐 핑거프린트 기록기로 갑니다.
fun envVariableRead(key: String, value: String?, consumer: String?) {
if (isInputTrackingDisabled()) {
return
}
addEnvVariableToFingerprint(key, value, consumer)
}
addEnvVariableToFingerprint()(389–393행)가 부르는 sink().envVariableRead()(909–913행)는 같은 이름을 처음 읽었을 때 한 번만 UndeclaredEnvironmentVariable(key, value)를 씁니다. 예제의 1번 빌드에서는 GREETING이 없었으니 값 null이 기록됩니다. 다음 빌드에서 ConfigurationCacheFingerprintChecker가 그 기록을 하나씩 다시 확인합니다.
is ConfigurationCacheFingerprint.UndeclaredEnvironmentVariable -> input.run {
ifOrNull(System.getenv(key) != value) {
text("environment variable ").reference(key).text(" has changed")
}
}
지금 값을 읽어 기록한 값과 다르면 이유 문장을 만듭니다. 이 문장이 위 determineBasicCacheAction()의 %s에 들어갑니다.
노란 상자가 7절 목록의 buildfingerprint.bin입니다. 입력이 하나라도 다르면 항목 전체를 다시 만듭니다.
예제 2 이어서: 환경 변수를 바꾸면
=== 3. the environment variable changes ===
Calculating task graph as configuration cache cannot be reused because environment variable 'GREETING' has changed.
settings script: evaluated
> Configure project :
build script: configuring project 'u11-configuration-cache'
hello task: configured, instance 1707233178
> Task :hello
hello task: running, instance 266303648 -> bonjour
BUILD SUCCESSFUL in 2s
1 actionable task: 1 executed
Configuration cache entry stored.
GREETING=bonjour gradle hello --configuration-cache로 돌렸습니다. 첫 줄의 이유 문장이 위 코드 두 군데(Checker의 text(...), determineBasicCacheAction()의 "%s as configuration cache cannot be reused because %s.")를 이어 붙인 그대로입니다. 설정 단계가 다시 돌았고, 새 값 bonjour가 쓰였고, 새 항목이 저장됐습니다(7절 목록의 f1de74b3-…).
클라이언트에서 준 환경 변수가 데몬의 System.getenv()에 보이는 것은 데몬의 명령 사슬에 있는 ApplyClientEnvironmentVariables 덕분입니다(3편의 ⑩).
헷갈리기 쉬운 곳: 실행 중에 읽는 것은 기록하지 않는다
기록기의 envVariableRead() 첫 줄은 isInputTrackingDisabled()면 돌아갑니다. 또 같은 클래스의 isExecutingWork()(318행)처럼 "지금 태스크를 실행 중인가"를 보는 검사가 곳곳에 있습니다. 핑거프린트는 설정 단계가 읽은 것만의 기록입니다. 태스크 액션 안에서 읽는 환경 변수는 매번 실행 때 읽히므로 캐시를 버릴 이유가 없습니다. 거꾸로, 설정 단계에서 읽는 값은 하나하나가 캐시를 버릴 이유가 됩니다. 설정 단계에서 바깥 것을 덜 읽을수록 설정 캐시가 잘 맞습니다.
9. 문제 보고서 — 실행 중에 project를 만지면
예제의 bad 태스크는 doLast 안에서 project.name을 읽습니다. Task.getProject()는 이렇습니다.
public Project getProject() {
taskExecutionAccessChecker.notifyProjectAccess(this);
return project;
}
subprojects/core/src/main/java/org/gradle/api/internal/AbstractTask.java 250–253행
값을 돌려주기 전에 검사기에 알립니다. 검사기 notifyProjectAccess()는 "지금이 실행 시점인가"를 보고, 그렇다면 onProjectAccess("Task.project", …)로 문제를 알립니다(TaskExecutionAccessCheckers.kt 45–49행, 111–112행). 설정 캐시가 켜져 있으면 검사기는 ConfigurationTimeBarrierBased이고, "설정 시점의 장벽을 이미 지났는가"(!configurationTimeBarrier.isAtConfigurationTime)로 판단합니다. "이 태스크가 실행 중인가"(task.state.executing)로 보는 TaskStateBased는 설정 캐시가 꺼졌을 때 쓰입니다(BuildModelControllerServices.kt 139–142행). 알림은 DefaultConfigurationCacheProblemsListener.onProjectAccess()에서 "invocation of 'Task.project' at execution time is unsupported with the configuration cache." 문장을 단 문제가 되고(ConfigurationCacheProblemsListener.kt 113–147행), ConfigurationCacheProblems.onExecutionTimeProblem()으로 갑니다.
override fun onExecutionTimeProblem(problem: PropertyProblem) {
val severity = when {
isWarningMode -> ProblemSeverity.Deferred
cacheAction == SkipStore || degradationDecision.shouldDegrade -> ProblemSeverity.SuppressedSilently
else -> ProblemSeverity.Interrupting
}
onProblem(problem, severity)
}
기본은 Interrupting이고, onProblem()은 이 심각도면 예외를 던집니다(309–313행). 그래서 태스크가 그 자리에서 실패합니다. --configuration-cache-problems=warn을 주면 isWarningMode라 Deferred(나중에 모아 경고)가 됩니다.
검사기는 getProject()라는 문 앞에 서 있습니다. 설정 단계에서 부르는 project는 문제가 아닙니다.
예제 2 이어서: gradle bad --configuration-cache
=== 5. a task that touches project at execution time ===
Calculating task graph as no cached configuration is available for tasks: bad
settings script: evaluated
> Configure project :
build script: configuring project 'u11-configuration-cache'
> Task :bad FAILED
1 problem was found storing the configuration cache.
- Task `:bad` of type `org.gradle.api.DefaultTask`: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
See https://docs.gradle.org/9.10.0-20261107025045+0000/userguide/configuration_cache_requirements.html#config_cache:requirements:use_project_during_execution
See the complete report at file:///tmp/p/build/reports/configuration-cache/cfbleachwswu3am44cg9ajjub/10jmbtidz96ck7xtcv2rl5a10/configuration-cache-report.html
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':bad' (registered in build file 'build.gradle.kts').
> Invocation of 'Task.project' by task ':bad' at execution time is unsupported with the configuration cache.
* Try:
// ... (--stacktrace, --info, --scan, help 안내 네 줄) ...
BUILD FAILED in 1s
1 actionable task: 1 executed
Configuration cache entry discarded with 1 problem.
(exit code 1)
=== 6. the same failing build again ===
Calculating task graph as no cached configuration is available for tasks: bad
Configuration cache entry discarded with 1 problem.
([Incubating] Problems report … 한 줄과 * Try: 아래 네 줄, 그리고 run.sh가 5번 끝에 ls로 찍은 보고서 폴더 목록은 줄였습니다. 보고서 경로의 첫 이름은 설정 캐시 열쇠라 컨테이너를 새로 띄울 때마다 달라졌고(7절의 암호화 키 때문), 두 번째 이름은 보고서 내용의 해시(CommonReport.kt의 reportHash())라 다시 돌렸을 때도 10jmbtidz…로 같았습니다.)
- 문제 문장은 두 벌입니다. 목록의 "invocation of 'Task.project' at execution time is unsupported…"는 문제 묶음용 문장이고, 태스크 실패 메시지 "Invocation of 'Task.project' by task ':bad' at execution time…"은 예외용 문장입니다. 둘 다
ConfigurationCacheProblemsListener.kt의onTaskExecutionAccessProblem()(130–141행)에 나란히 적혀 있습니다. - 보고서 경로
build/reports/configuration-cache/<열쇠>/…/configuration-cache-report.html의<열쇠>자리는 7절의 설정 캐시 열쇠입니다(ConfigurationCacheProblems.kt475–477행outputDirectoryFor()). 보고서에는 문제 목록과 함께 설정 단계가 읽은 입력("build configuration inputs", 416행 주석)도 실립니다. - 마지막 줄
Configuration cache entry discarded with 1 problem.은 이번 빌드가 저장했던 항목을 버렸다는 뜻입니다. 6번에서 같은 빌드를 다시 돌리자Reusing이 아니라 다시 "no cached configuration is available"이 나왔습니다. 실제로 버려졌습니다.
헷갈리기 쉬운 곳: 항목은 이미 저장된 뒤에 버려진다
6절에서 본 대로, bad가 실행된 시점은 항목을 저장하고 다시 읽은 뒤입니다. 그러니 실행 중에 생긴 문제로 항목을 버리려면 한 번 더 손을 써야 합니다. 그 일을 빌드 끝의 ConfigurationCacheAwareFinishExecutor가 합니다.
override fun finishBuildTree(failures: List<Throwable>): RuntimeException? {
cache.finalizeCacheEntry()
return delegate.finishBuildTree(failures)
}
finalizeCacheEntry()는 빌드 한 번에 두 번 불리는 셈입니다(6절의 storeAndReload()에서 한 번, 여기서 한 번). 두 번째에는 커밋할 것이 없지만, problems.shouldDiscardEntry가 참이면 후보 목록에서 그 항목을 지웁니다(DefaultConfigurationCache.kt 393–396행). 콘솔의 마지막 문장은 그 뒤 빌드가 끝날 때(beforeComplete) 찍힙니다.
when {
// ...
cacheAction == Store && discardStateDueToProblems -> log("Configuration cache entry discarded with {}.", problemCountString)
// ...
cacheAction == Store && !hasProblems -> log("Configuration cache entry stored.")
// ...
cacheAction is Load && !hasProblems -> log("Configuration cache entry reused.")
// ...
}
ConfigurationCacheProblems.kt 504–521행에서 줄임
예제에서 본 세 문장 stored., reused., discarded with 1 problem.이 모두 이 when 안에 있습니다. 문장이 빌드 맨 끝(BUILD SUCCESSFUL 아래)에 찍히는 것도 이 때문입니다.
bad를 고치는 방법은 설정 단계에서 값을 만들어 두는 것입니다. val name = project.name을 doLast 바깥(설정 블록)에서 읽고 람다 안에서는 name만 쓰면, 6절의 message처럼 값이 파일에 쓰였다가 되살아납니다. 고친 코드는 따로 돌려 보지 않았습니다. 6절의 hello 태스크가 같은 모양입니다.
이 글에서 뺀 것
- 원격 빌드 캐시(HTTP)와 그 오류 처리:
platforms/core-execution/build-cache-http/,build-cache/…/controller/service/OpFiringRemoteBuildCacheServiceHandle.java - 불변(immutable) 파이프라인의 캐시 스텝
ResolveImmutableCachingStateStep(아티팩트 변환, 스크립트 컴파일 등이 쓰는 쪽):ExecutionBuildServices.java171–186행 - 오래된 캐시 항목 정리(LRU. 로컬 빌드 캐시는 기본 7일):
build-cache-core/…/DirectoryBuildCacheServiceFactory.java103–112행,ConfigurationCacheRepository.kt286–296행 - 설정 캐시의
Update동작(프로젝트 격리에서 일부 프로젝트만 다시 설정):DefaultConfigurationCache.kt243–248행, 523–531행 - 설정 캐시와 Tooling API 모델(
loadOrCreateModel,IntermediateModelController):DefaultConfigurationCache.kt313–375행 - 병렬 저장·읽기(
org.gradle.configuration-cache.parallel)와 문자열 중복 제거:DefaultConfigurationCacheIO.kt418–449행 - 빌드 서비스(
BuildService)와 흐름 액션(FlowAction)의 직렬화:ConfigurationCacheState.kt636–705행 - 설정 캐시와 맞지 않는 태스크를 알리는
notCompatibleWithConfigurationCache()와 "우아한 후퇴"(graceful degradation):DefaultConfigurationCacheDegradationController.kt
정리
| 개념 | 한 줄 요약 |
|---|---|
| 빌드 캐시의 자리 | 가변 파이프라인의 두 칸. ResolveMutableCachingStateStep이 키와 이유를 정하고, BuildCacheStep이 꺼내거나 넣는다 |
| 순서 | 키 계산은 SkipUpToDateStep보다 앞, 꺼내기는 StoreExecutionStateStep보다 안쪽. 꺼낸 뒤에도 실행 기록이 남는다 |
| 캐시 키 | 구현(클래스@클래스로더 해시) + 액션 + 입력 값 + 입력 파일 핑거프린트 + 출력 속성 이름을 MD5. 바뀐 것이 없으면 지난 키를 다시 쓴다 |
@CacheableTask |
태스크를 만들 때 cacheIf 하나가 된다. cacheIf가 없으면(@DisableCachingByDefault·untracked가 아닐 때) "Caching has not been enabled for the task" |
| 로컬 캐시 | ~/.gradle/caches/build-cache-1/<키> 파일 하나. 임시 파일을 원자적으로 옮겨 넣는다 |
| 캐시 항목 | gzip으로 누른 tar. METADATA + tree-<출력 속성>. 절대 경로가 없어 다른 폴더에도 풀린다 |
UP-TO-DATE와의 차이 |
UP-TO-DATE는 지난 한 번과, 빌드 캐시는 남아 있는 모든 키와 비교한다 |
@PathSensitive |
없으면 ABSOLUTE. 재배치 가능한 키를 원하면 RELATIVE 등을 단다 |
| 설정 캐시의 자리 | 서비스 등록을 바꿔 작업 컨트롤러를 ConfigurationCacheAwareBuildTreeWorkController로 감싼다 |
| 저장 후 다시 읽기 | 첫 빌드도 (저장에 성공하면) 그래프를 저장하고, 모델을 버리고, 파일에서 다시 읽어 실행한다. 태스크 객체가 바뀐다 |
| 설정 캐시 열쇠 | Gradle 판 + 요청한 태스크 + 옵션의 해시. 스크립트 내용은 열쇠가 아니라 핑거프린트가 맡는다 |
| 저장 형식 | .gradle/configuration-cache/<항목 UUID>/work.bin 등. 태스크는 필드째 코덱으로, 작업 그래프·핑거프린트는 기본 암호화 |
| 핑거프린트 | 설정 단계가 읽은 파일·환경 변수·속성의 기록. System.getenv는 바이트코드를 고쳐 가로챈다 |
| 문제 보고서 | 실행 중 Task.project는 검사기가 잡아 태스크를 실패시키고, 빌드 끝에 항목을 버리고 HTML 보고서를 쓴다 |
다음에 볼 것
여기까지 열한 편으로 gradle build 한 번이 클라이언트에서 데몬으로, 설정 단계를 지나 실행 엔진과 두 캐시까지 가는 길을 따라왔습니다. 마지막으로 남은 것은 거의 모든 빌드 스크립트에 있는 한 줄, implementation("…")입니다. 그 한 줄이 객체가 되고, 구성(configuration)에 담기고, 그래프로 해석되어 저장소에서 받은 jar 파일 목록이 되기까지를 12편: 의존성 해석 — implementation 한 줄이 jar 파일이 되기까지, 그리고 연재를 마치며에서 봅니다. 8절에서 본 ConfigurationCacheFingerprint의 하위 클래스 가운데 동적 버전·변하는 모듈(ChangingDependencyResolutionValue)도 있는데, 이것도 그 글과 이어집니다.
앞 연재에서 닮은 것을 다뤘습니다. Spring 27편의 @Cacheable은 메서드 인자로 키를 만들어 반환값을 저장합니다. 빌드 캐시는 그 생각을 태스크로 옮긴 것인데, 키에 "구현"(클래스와 클래스로더)까지 넣는다는 점이 다릅니다. 코드가 바뀌면 결과도 바뀔 수 있기 때문입니다. Spring 16편의 AOT는 컨테이너 설정을 빌드 시점에 미리 계산해 두는 장치로, "설정 결과를 저장해 두고 다음 시작에서는 계산을 건너뛴다"는 설정 캐시와 같은 방향입니다. Tomcat 11편은 재시작 때 세션을 직렬화해 파일에 두는데, Serializable이 아닌 속성은 조용히 버립니다. 설정 캐시는 같은 상황을 문제 보고서로 크게 알린다는 점이 대조적입니다. Tomcat 13편의 Jasper가 JSP를 컴파일한 결과를 두고 다시 쓰는 것은 이 글의 예제 1에서 본 "스크립트 컴파일 결과의 캐시"와 닮았습니다.