namalzi

← 글 목록

Gradle 소스 파헤치기 11: 빌드 캐시와 설정 캐시 — 결과를 다시 쓰는 두 가지 방법

Gradle · 게시

콘솔의 FROM-CACHE와 Reusing configuration cache.는 소스의 어디서 나올까요. 실행 엔진의 스텝 사슬에 끼어 있는 ResolveMutableCachingStateStep과 BuildCacheStep, MD5로 만드는 캐시 키에 무엇이 들어가는지, @CacheableTask와 캐시하지 않는 이유 목록, 키가 파일 이름이 되는 로컬 디렉터리 캐시와 tar.gz 항목, 그리고 작업 그래프를 통째로 직렬화했다가 첫 빌드에서조차 다시 읽어 실행하는 설정 캐시, 환경 변수 읽기를 바이트코드에서 가로채는 핑거프린트와 문제 보고서까지 기준 커밋의 소스와 도커 예제로 확인합니다.

이 글에서 다루는 것

"Gradle 소스 파헤치기" 연재의 열한 번째 글입니다. 10편에서는 태스크 하나가 실행 엔진의 스텝 사슬을 지나며 UP-TO-DATE로 건너뛰어지는 길을 봤습니다. UP-TO-DATE는 "지난번 실행과 비교해 바뀐 것이 없다"는 뜻이라, 비교 상대가 한 번뿐입니다. 이번 글은 그보다 멀리 내다보는 두 장치, 빌드 캐시와 설정 캐시를 봅니다. 둘 다 "전에 한 일을 다시 하지 않는다"는 목적은 같지만, 저장하는 것도, 사슬에 끼는 자리도 전혀 다릅니다.

2편의 마지막 그림 "2편 지도"로 돌아가면, 이 글은 E 구역(실행 단계)의 맨 아래 칸 "빌드 캐시·설정 캐시"를 확대합니다. 다만 설정 캐시는 실행 단계가 아니라 설정 단계 전체를 건너뛰는 장치라서, C 구역(빌드 생명주기)의 DefaultBuildTreeLifecycleController에도 손을 뻗습니다.

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

항목 내용
대상 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 hello --configuration-cache --build-cache 한 번에 두 캐시가 끼는 자리. 먼저 DefaultConfigurationCache가 설정 캐시 항목을 고르며 핑거프린트를 검사한다. 맞는 항목이 없거나 무효면 초기화·설정 단계(settings·build 스크립트 실행, 작업 그래프 계산)를 거쳐 작업 그래프를 직렬화해 .gradle/configuration-cache/에 저장하고, 저장한 그래프를 다시 읽는다. 재사용이면 곧바로 저장한 그래프를 읽는다. 읽을 때 태스크 객체는 새로 만들어진다. 그다음 실행 단계에서 노드마다 DefaultTaskNodeExecutor가 실행 엔진을 부르고, ResolveMutableCachingStateStep이 캐시 키를 계산하며, BuildCacheStep이 키를 파일 이름으로 해 ~/.gradle/caches/build-cache-1/에서 꺼내거나, 실행하고 넣는다. 노란 상자가 디스크에 남는 것입니다. 설정 캐시는 빌드 트리에 하나, 빌드 캐시는 태스크마다 한 번씩 쓰입니다.

둘을 나란히 놓으면 이렇습니다.

빌드 캐시 설정 캐시
저장하는 것 태스크 하나의 출력 파일 묶음 작업 그래프 전체(태스크 객체와 그 필드 값)
건너뛰는 것 태스크 액션 실행 초기화·설정 단계 전체
열쇠 태스크 구현 + 입력의 해시 (태스크마다) 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

gradle.properties 3–6행

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

subprojects/core/src/main/java/org/gradle/internal/service/scopes/ExecutionBuildServices.java 188–210행에서 줄임

빌드 캐시가 쓰는 칸은 둘입니다. 196행 ResolveChangesStep 바로 아래의 ResolveMutableCachingStateStep(197행)이 캐시 키를 계산하고 "캐시해도 되는가"를 정합니다. 그리고 SkipUpToDateStep과 StoreExecutionStateStep 아래의 BuildCacheStep(201행)이 실제로 캐시에서 꺼내거나 넣습니다. 두 스텝 모두 생성자에 buildCacheController를 받습니다.

빌드 캐시가 쓰는 스텝 두 칸. LoadPreviousExecutionStateStep과 CaptureMutableStateBeforeExecutionStep이 입력 핑거프린트를 잡고(10편), ResolveChangesStep이 지난 실행과의 차이를 구한 다음, ① ResolveMutableCachingStateStep이 캐시 키를 계산하고 캐시하지 않을 이유를 모은다. SkipUpToDateStep은 바뀐 것이 없으면 UP-TO-DATE로 끝낸다. 바뀐 것이 있으면 StoreExecutionStateStep(끝난 뒤 실행 기록을 남긴다)을 지나 ② BuildCacheStep이 캐시를 쓸 수 있으면 load부터 한다. 캐시에 있으면 출력 파일을 풀어 놓고 FROM-CACHE로 끝난다. 없거나 캐시가 꺼져 있으면 ResolveInputChangesStep, RemovePreviousOutputsStep 등을 지나 ExecuteStep이 태스크 액션을 실행하고, 돌아오는 길에 ③ 성공했으면 출력을 묶어 캐시에 store한다. 파란 두 칸이 빌드 캐시입니다. 키 계산은 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)
        );
    }

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/BuildCacheStep.java 72–78행

앞 스텝이 만들어 둔 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())
            );

BuildCacheStep.java 145–150행

실행이 성공했을 때만 넣습니다. 실패한 태스크의 반쯤 쓴 출력은 캐시에 들어가지 않습니다. 꺼내는 데 성공하면 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(

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/StoreExecutionStateStep.java 45–56행에서 줄임

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

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/caching/impl/DefaultCachingStateFactory.java 49–84행에서 줄임

다섯 덩어리를 차례로 넣습니다.

  1. 구현(implementation) — 태스크 클래스. ClassImplementationSnapshot은 클래스 이름과 그 클래스를 읽은 클래스로더의 해시를 함께 넣습니다(ClassImplementationSnapshot.java 35–39행). 같은 이름의 클래스라도 플러그인 jar가 바뀌면 키가 바뀝니다.
  2. 추가 구현 — 태스크 액션마다 하나. TaskExecution.visitImplementations()가 태스크 클래스 다음에 getTaskActions()의 구현을 하나씩 넘깁니다(TaskExecution.java 332–339행). doLast { }로 액션을 더하면 키도 바뀝니다.
  3. 입력 값 — @Input 속성의 이름과 값.
  4. 입력 파일 — @InputFile·@InputFiles 속성의 이름과 10편의 파일 핑거프린트 해시.
  5. 출력 속성 이름 — 출력의 값(경로)이 아니라 이름만 넣습니다.

Hashing.newHasher()의 기본 해시 함수는 MD5입니다(Hashing.java 43–56행). 그래서 키는 128비트, 16진수 32자리입니다.

캐시 키에 들어가는 것. ① 구현(태스크 클래스 이름 @ 클래스로더 해시), ② 추가 구현(태스크 액션마다 하나), ③ 입력 값(@Input 속성 이름 + 값의 해시), ④ 입력 파일(@InputFile 속성 이름 + 핑거프린트 해시, 경로를 얼마나 넣을지는 @PathSensitive가 정하고 없으면 ABSOLUTE로 절대 경로까지), ⑤ 출력 속성 이름(값이 아니라 이름만)이 차례로 MD5 Hasher(DefaultCachingStateFactory.calculateCacheKey)에 들어가 32자리 16진수 캐시 키가 되고, 키가 캐시 디렉터리의 파일 이름이 된다. 실행 시각과 출력 파일 내용은 넣지 않는다. 출력 경로가 키에 없으므로, 입력만 같으면 다른 폴더의 프로젝트도 같은 키를 얻습니다(예제 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);

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/AbstractResolveCachingStateStep.java 74–87행에서 줄임

평소에는 조용하다가, -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));
    }

platforms/core-execution/execution/src/main/java/org/gradle/internal/execution/steps/ResolveMutableCachingStateStep.java 44–58행

앞 스텝 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);
        }

subprojects/core/src/main/java/org/gradle/api/internal/project/taskfactory/AnnotationProcessingTaskFactory.java 57–60행

빌드 스크립트에서 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);
        }

subprojects/core/src/main/java/org/gradle/api/internal/tasks/execution/DefaultTaskCacheabilityResolver.java 52–71행에서 줄임

cacheIf가 하나도 없으면 거기서 끝납니다. 클래스에 @DisableCachingByDefault가 있으면 그 because 문구가, 추적하지 않는(untracked) 태스크면 그 이유가, 둘 다 아니면 "Caching has not been enabled for the task"(34행의 CACHING_NOT_ENABLED)가 이유가 됩니다. 이것이 캐시의 기본값이 "안 한다" 라는 뜻입니다. 그 뒤로 출력 선언이 없음, 출력이 다른 태스크와 겹침(overlapping outputs), 추적하지 않는 태스크, 파일 트리 출력, cacheIf 조건 불만족, doNotCacheIf 조건 만족을 차례로 봅니다(69–109행). 3절의 calculateCachingState()가 여기에 "빌드 캐시가 꺼져 있음", "검증 실패"를 더해 이유 목록을 만들고, 목록이 비어 있어야만 캐싱 상태가 "켜짐"이 됩니다.

태스크를 캐시해도 되는지 정하는 순서. 태스크 클래스에 @CacheableTask 가 있으면 태스크를 만들 때 cacheIf("Annotated with @CacheableTask")가 걸린다(빌드 스크립트의 outputs.cacheIf { true }와 같은 일). DefaultTaskCacheabilityResolver 는 앞에서부터 처음 걸린 이유 하나를 돌려준다. cacheIf 가 하나도 없으면 @DisableCachingByDefault 의 because 문구(예: Copy — Not worth caching), 추적하지 않는 태스크면 그 이유, 둘 다 아니면 Caching has not been enabled for the task 가 이유가 되어 캐시하지 않는다. cacheIf 가 있으면 출력 선언 없음, 출력이 다른 태스크와 겹침, 추적하지 않음, 파일 트리 출력, cacheIf 불만족, doNotCacheIf 만족을 차례로 본다. 그 뒤 calculateCachingState()가 빌드 캐시가 꺼져 있음과 검증 실패를 이유로 더한다. 이유 목록이 비었으면 캐싱이 켜지고(예제 1의 :shout·:compileJava — Stored cache entry …), 하나라도 있으면 캐시하지 않는다(예제 1의 :whisper — Caching has not been enabled for the task). 빨간 칸이 "캐시하지 않음"으로 가는 길입니다. 아무것도 하지 않은 태스크는 왼쪽 길로 갑니다 — 캐시의 기본값이 "안 한다"인 이유입니다. 예제 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의 키가 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행

캐시 항목을 꺼내고 넣는 순서. ① BuildCacheStep이 DefaultBuildCacheController에 load(키)를 부르면 ② 컨트롤러가 로컬 DirectoryBuildCache에 키 이름의 파일이 있는지 본다. 로컬에 있으면 ③ GZip+Tar 포장기가 출력 자리에 풀고 FROM-CACHE가 된다. 로컬에 없으면 ④ 원격 캐시(있으면)에서 받고, 받았으면 로컬에도 넣는다. 둘 다 없으면 태스크를 실행한다. ⑤ 실행 뒤 BuildCacheStep이 store(키, 출력 스냅숏)를 부르면 ⑥ 포장기가 METADATA와 tree-속성이름 항목으로 묶고, ⑦ push가 켜져 있으면 원격에 넣고, ⑧ 로컬에는 임시 파일을 키 이름으로 옮긴다. 원격 캐시는 이 예제에서 쓰지 않았습니다(오프라인). 구조만 보여 주려고 그렸습니다.

로컬 캐시 — 키가 곧 파일 이름

로컬 캐시 DirectoryBuildCache에서 항목의 위치를 정하는 메서드는 세 줄입니다.

    private File getCacheEntryFile(HashCode key) {
        return new File(persistentCache.getBaseDir(), key.toString());
    }

platforms/core-execution/build-cache-local/src/main/java/org/gradle/caching/local/internal/DirectoryBuildCache.java 174–176행

색인 파일도 데이터베이스도 없습니다. 캐시 디렉터리 바로 아래에 키를 이름으로 한 파일이 있으면 캐시에 있는 것입니다. 디렉터리를 정하지 않으면 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-([^/]+)(?:/(.*))?");

platforms/core-execution/build-cache-packaging/src/main/java/org/gradle/caching/internal/packaging/impl/TarBuildCacheEntryPacker.java 94–95행

맨 앞에 출처 정보 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

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

6. 설정 캐시 — 작업 그래프를 통째로

이제 설정 캐시입니다. 이 장치는 실행 엔진이 아니라 5편의 빌드 생명주기에 끼어듭니다. 끼어드는 방식은 서비스 레지스트리(4편)에서 구현을 바꿔 등록하는 것입니다.

        if (modelParameters.isVintage) {
            // ...
            add(BuildTreeLifecycleControllerFactory::class.java, BarrierAwareBuildTreeLifecycleControllerFactory::class.java)
            // ...
        } else if (modelParameters.isConfigurationCache) {
            // ...
            add(BuildTreeLifecycleControllerFactory::class.java, ConfigurationCacheBuildTreeLifecycleControllerFactory::class.java)

platforms/core-configuration/configuration-cache/src/main/kotlin/org/gradle/internal/cc/impl/BuildTreeModelControllerServices.kt 127–145행에서 줄임

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

platforms/core-configuration/configuration-cache/src/main/kotlin/org/gradle/internal/cc/impl/ConfigurationCacheBuildTreeLifecycleControllerFactory.kt 74–97행에서 줄임

마지막 줄의 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행).

설정 캐시가 낀 작업 컨트롤러의 흐름. ① initializeCacheEntry()가 키로 후보 항목을 찾고 핑거프린트를 검사한다. 맞는 항목이 있으면 Load(Reusing configuration cache.)로 곧장 ⑥으로 간다. 없거나 무효면 Store(Calculating task graph as …)로, ② 평소처럼 settings·build 스크립트를 실행하고 작업 그래프를 만들며 읽은 입력을 핑거프린트로 기록하고, ③ saveWorkGraph()가 work.bin에 직렬화하고, ④ finalizeCacheEntry()가 핑거프린트와 entry.bin을 커밋하고, ⑤ resetModels()가 설정한 모델을 버린다. ⑥ loadWorkGraph()가 work.bin을 읽어 태스크를 새로 만들고, ⑦ workExecutor.execute()로 실행 단계(10편의 실행 엔진)에 들어간다. 점선: 저장하지 않았거나 Tooling 모델 요청이면 ②에서 설정한 그래프를 그대로 ⑦로 실행한다. 평소의 두 갈래는 모두 ⑥을 지납니다. 저장에 성공한 빌드에서 실행 단계가 보는 태스크는 파일에서 되살린 것입니다(점선은 예외).

왜 이렇게 번거롭게 할까요. 주석에 적힌 이유는 메모리입니다. 설정에 쓴 "살아 있는" 그래프를 실행 전에 놓아 가비지 컬렉션이 거둘 수 있게 하려는 것입니다(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.는 이 연재의 도커 이미지가 쓰는 읽기 전용 의존성 캐시 때문에 찍히는 줄입니다. 숫자와 시간은 실행마다 다릅니다.)

헷갈리기 쉬운 곳: "설정 단계에서 만든 값"과 "설정 단계의 코드"는 다르다

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)
        // ...
    }

platforms/core-configuration/configuration-cache/src/main/kotlin/org/gradle/internal/cc/impl/ConfigurationCacheKey.kt 53–102행에서 줄임

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

설정 캐시가 디스크에 남기는 모양. 열쇠는 ConfigurationCacheKey 가 Gradle 판, 요청한 태스크 이름(hello), 제외한 태스크, --offline 같은 옵션, 암호화 키 해시 등으로 만들고 MD5 로 짧은 문자열로 바꾼다. 빌드 스크립트의 내용은 들어가지 않는다. 예제 2의 .gradle/configuration-cache/ 안에는 열쇠 폴더 7z43g5hhkp3l9ngwtlgt2fs3n/ 가 있고, 그 안의 candidates.bin 이 이 열쇠에 딸린 항목 ID 목록이다(열쇠당 항목 수 기본 1). 그 ID 가 가리키는 항목 폴더 f1de74b3-e639-…/(UUID)에는 work.bin(작업 그래프, 암호화), buildfingerprint.bin·projectfingerprint.bin(8절의 핑거프린트), entry.bin(항목의 세부 정보), classloaderscopes.bin, 그리고 딸린 파일 .strings.work.bin·.globals.work.bin·_.work.bin 이 있다. 비어 있는 9d73552e-903b-…/ 는 환경 변수를 바꾸기 전(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)
                }
            }
        }
    }

platforms/core-configuration/core-serialization-codecs/src/main/kotlin/org/gradle/internal/serialize/codecs/core/TaskNodeCodec.kt 100–133행에서 줄임

이 구조에서 설정 캐시의 규칙이 자연스럽게 나옵니다. 태스크가 들고 있는 것은 전부 파일에 쓸 수 있어야 하고, 실행할 때는 파일에서 되살린 것만 써야 합니다. 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;

platforms/core-runtime/classpath/src/main/java/org/gradle/internal/classpath/transforms/AdhocInterceptors.java 185–190행

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

platforms/core-runtime/classpath/src/main/java/org/gradle/internal/classpath/Instrumented.java 226–231행

진짜 값을 읽어 돌려주는 것은 같고, 그 사이에 "누가 무엇을 읽어 무엇을 얻었다"를 알립니다. Spring 10편의 프록시가 객체를 감싸 호출을 가로챈다면, 이쪽은 호출하는 쪽의 바이트코드를 고쳐 JDK의 정적 메서드 호출까지 가로챕니다. 알림은 InstrumentedInputAccessListener.envVariableQueried()(88–93행)를 거쳐 핑거프린트 기록기로 갑니다.

    fun envVariableRead(key: String, value: String?, consumer: String?) {
        if (isInputTrackingDisabled()) {
            return
        }
        addEnvVariableToFingerprint(key, value, consumer)
    }

platforms/core-configuration/configuration-cache/src/main/kotlin/org/gradle/internal/cc/impl/fingerprint/ConfigurationCacheFingerprintWriter.kt 382–387행

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

platforms/core-configuration/configuration-cache/src/main/kotlin/org/gradle/internal/cc/impl/fingerprint/ConfigurationCacheFingerprintChecker.kt 279–283행

지금 값을 읽어 기록한 값과 다르면 이유 문장을 만듭니다. 이 문장이 위 determineBasicCacheAction()의 %s에 들어갑니다.

설정 캐시 핑거프린트가 쌓이고 확인되는 길. build.gradle.kts의 System.getenv("GREETING") 호출은 컴파일된 스크립트 클래스에서 Instrumented.getenv()로 바뀌어 있다. 이것이 InstrumentedInputAccessListener.envVariableQueried()를 거쳐 ConfigurationCacheFingerprintWriter.envVariableRead()로 가고, buildfingerprint.bin에 UndeclaredEnvironmentVariable(GREETING, null)로 기록된다. 스크립트 파일, gradle.properties, 읽은 파일, ValueSource, 시스템 속성 같은 다른 입력도 기록기로 들어간다. 다음 빌드에서 ConfigurationCacheFingerprintChecker가 기록을 확인해 모두 같으면 Load, 다르면 environment variable 'GREETING' has changed라는 이유로 Store한다. 노란 상자가 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)
    }

platforms/core-configuration/configuration-cache/src/main/kotlin/org/gradle/internal/cc/impl/problems/ConfigurationCacheProblems.kt 227–234행

기본은 Interrupting이고, onProblem()은 이 심각도면 예외를 던집니다(309–313행). 그래서 태스크가 그 자리에서 실패합니다. --configuration-cache-problems=warn을 주면 isWarningMode라 Deferred(나중에 모아 경고)가 됩니다.

실행 중 project 접근이 문제로 바뀌는 순서. ① 태스크 액션(doLast)이 AbstractTask.getProject()를 부르면 ② AbstractTask가 접근 검사기에 notifyProjectAccess(this)를 알리고 ③ 검사기는 설정 시점이 지났는지 본다. ④ 지났으면 onProjectAccess로 ConfigurationCacheProblems에 실행 중 문제로 기록하고 ⑤ 예외가 던져져 태스크 :bad가 FAILED가 된다. 빌드가 끝날 때 ⑥ DefaultConfigurationCache의 finalizeCacheEntry가 문제가 있으니 항목을 버리고 ⑦ ConfigurationCacheProblems가 HTML 보고서를 쓴다. 검사기는 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…로 같았습니다.)

헷갈리기 쉬운 곳: 항목은 이미 저장된 뒤에 버려진다

6절에서 본 대로, bad가 실행된 시점은 항목을 저장하고 다시 읽은 뒤입니다. 그러니 실행 중에 생긴 문제로 항목을 버리려면 한 번 더 손을 써야 합니다. 그 일을 빌드 끝의 ConfigurationCacheAwareFinishExecutor가 합니다.

    override fun finishBuildTree(failures: List<Throwable>): RuntimeException? {
        cache.finalizeCacheEntry()
        return delegate.finishBuildTree(failures)
    }

platforms/core-configuration/configuration-cache/src/main/kotlin/org/gradle/internal/cc/impl/ConfigurationCacheAwareFinishExecutor.kt 26–29행

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 태스크가 같은 모양입니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
빌드 캐시의 자리 가변 파이프라인의 두 칸. 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에서 본 "스크립트 컴파일 결과의 캐시"와 닮았습니다.

다른 글