Gradle ValueSource API 이해하기

2025년 8월 10일 · 읽는 시간 10

thumbnail

프로젝트 규모가 커지면서 Gradle 빌드 시간이 점점 길어지고 있나요? 특히 IDE에서 프로젝트를 동기화할 때마다 오래 기다려야 한다면, Configuration phase에서 실행되는 코드를 점검해볼 필요가 있습니다.

이 글에서는 Gradle 6.1부터 도입된 ValueSource API가 왜 필요한지, 그리고 Configuration Cache와 어떻게 연동되는지 살펴보겠습니다.

Gradle 빌드의 두 단계 이해하기

먼저, Gradle 빌드는 크게 두 단계로 나뉩니다.

  1. Configuration Phase: 태스크 그래프를 구성하는 단계
  2. Execution Phase: 실제로 태스크를 실행하는 단계

문제는 Configuration phase가 모든 빌드에서 실행된다는 점입니다. IDE에서 프로젝트를 동기화할 때도, 단순히 ./gradlew tasks를 실행할 때도 말이죠.

흔히 저지르는 실수

많은 개발자들이 빌드 스크립트에서 이런 코드를 작성합니다.

tasks.register('generateBuildInfo') {
    def gitBranch = 'git rev-parse --abbrev-ref HEAD'.execute().text.trim()
    def gitCommit = 'git rev-parse --short HEAD'.execute().text.trim()
    
    doLast {
        println "Branch: ${gitBranch}"
        println "Commit: ${gitCommit}"
    }
}

이 코드의 문제점은 태스크 등록 시점에 실행된다는 점인데, doLast 블록이 아닌 태스크 설정 블록에서 Git 명령어가 실행됩니다. 그리고 프로젝트 동기화, 빌드, 심지어 help 태스크 실행 시에도 매번 실행된다는 문제가 있습니다. 마지막으로 Configuration Cache를 사용할 수 없습니다.

이러한 이유로 ValueSource API를 소개해보려고 합니다.

ValueSource API

ValueSource는 Gradle 6.1에서 도입된 외부 소스 접근을 위한 인터페이스입니다.

public interface ValueSource<T, P extends ValueSourceParameters> {
    @Nullable
    T obtain();
}

ValueSource는 obtain() 내부의 파일 읽기나 환경 변수 접근이 build input으로 기록되지 않고, Gradle이 언제 obtain()을 호출할지 결정할 수 있는 제어된 실행을 제공해줍니다. 또한 lazy evaluation을 위한 Provider 패턴과 자연스럽게 통합됩니다.

valuesource description

일반적으로 파일이나 환경변수가 바뀔 때마다 Gradle이 자동으로 캐시를 무효화하는 것이 아니라, obtain() 메서드가 반환하는 실제 값이 바뀔 때만 캐시 무효화가 된다는 것을 알 수 있습니다. 즉, 환경 변수가 변경되어도 obtain()의 반환값이 동일하면 캐시가 유지된다는 것을 알 수 있습니다.

기본 구현

자주 사용되는 예제인 Git 브랜치 정보를 가져오는 ValueSource를 구현해보겠습니다.

import org.gradle.api.provider.ValueSource
import org.gradle.api.provider.ValueSourceParameters
import org.gradle.process.ExecOperations
import javax.inject.Inject
import java.io.ByteArrayOutputStream

abstract class GitBranchValueSource : ValueSource<String, ValueSourceParameters.None> {
    @get:Inject
    abstract val execOperations: ExecOperations
    
    override fun obtain(): String? {
        val output = ByteArrayOutputStream()
        return try {
            execOperations.exec {
                commandLine("git", "rev-parse", "--abbrev-ref", "HEAD")
                standardOutput = output
                isIgnoreExitValue = true
            }
            output.toString().trim().takeIf { it.isNotEmpty() }
        } catch (e: Exception) {
            null
        }
    }
}

태스크에서 사용하기

tasks.register("printBuildInfo") {
    val gitBranch = providers.of(GitBranchValueSource::class.java) {}
    
    // inputs로 등록하여 up-to-date 체크 활성화
    inputs.property("branch", gitBranch)
    
    doLast {
        // 이 시점에 처음으로 obtain() 호출
        println("Current branch: ${gitBranch.get()}")
    }
}

Gradle 7.5+ Provider API?

추가로, Gradle 7.5부터 providers.exec()가 추가되었지만, ValueSource와는 다른 특성이 있습니다.

// 간단한 경우 - providers.exec() 사용
val gitBranch = providers.exec {
    commandLine("git", "rev-parse", "--abbrev-ref", "HEAD")
}.standardOutput.asText

// 태스크에서 사용
tasks.register("showBranch") {
    doLast {
        println(gitBranch.get())
    }
}
특성 ValueSource (6.1+) providers.exec() (7.5+)
도입 버전 Gradle 6.1 Gradle 7.5
복잡한 로직 ✅ obtain() 메서드에서 처리 ❌ 출력만 캡처
에러 처리 ✅ try-catch로 세밀한 제어 ⚠️ 제한적
파라미터 ✅ ValueSourceParameters 지원 ❌ 미지원
캐싱 제어 ✅ 반환값 기반 캐싱 ⚠️ 단순 캐싱

단순한 명령어를 실행하거나 출력값을 그대로 사용하는 경우에는 providers.exec()를 사용해도 무방해보이나, 복잡한 로직이 필요한 경우, 에러 처리가 중요한 경우, 파라미터화가 필요한 경우 등에는 ValueSource 사용하는 것이 적절해보입니다.

파라미터가 있는 ValueSource

다음 스니펫과 같이 더 복잡한 경우를 위해 ValueSourceParameter 마커 인터페이스를 구현하여 파라미터를 받을 수 있습니다.

interface GitCommandParameters : ValueSourceParameters {
    val gitArgs: ListProperty<String>
    val workingDir: DirectoryProperty
}

abstract class GitCommandValueSource : ValueSource<String, GitCommandParameters> {
    @get:Inject
    abstract val execOperations: ExecOperations
    
    override fun obtain(): String? {
        val output = ByteArrayOutputStream()
        return try {
            execOperations.exec {
                workingDirectory = parameters.workingDir.get().asFile
                commandLine("git", *parameters.gitArgs.get().toTypedArray())
                standardOutput = output
                isIgnoreExitValue = true
            }
            output.toString().trim().takeIf { it.isNotEmpty() }
        } catch (e: Exception) {
            null
        }
    }
}

// 사용 예시
val commitHash = providers.of(GitCommandValueSource::class.java) {
    parameters {
        gitArgs.set(listOf("rev-parse", "--short", "HEAD"))
        workingDir.set(layout.projectDirectory)
    }
}

고려사항

obtain method

다만 주의할 점은 Gradle 공식 문서에서 명시하듯이, Configuration Cache가 활성화되어 있어도, 캐시 유효성 검증을 위해 obtain() 메서드는 매 빌드마다 호출됩니다. 따라서 obtain()에서는 네트워크 요청, 무거운 작업은 피하고 로컬 파일 읽기나 Git 정보 읽기 등 빠른 명령어 실행을 위해 사용해야 합니다.

즉, 매 빌드마다 obtain() 호출하여 반환값을 이전 캐시와 비교하고 값이 변경되었으면 Configuration Cache를 무효화하고 값이 동일하면 Configuration Cache를 재사용하기 때문에, 아래 코드와 같이 5초 이상씩 걸리는 네트워크 요청을 하는 경우 문제가 될 수 있습니다.

abstract class SlowValueSource : ValueSource<String, ValueSourceParameters.None> {
    override fun obtain(): String {
        // 5초 이상 걸리는 네트워크 요청
        return fetchFromRemoteServer()  // 매 빌드마다 5초 걸림
    }
}

ValueSource를 사용할 때 또 다른 주의사항은 Configuration phase에서 직접 .get()을 호출하지 않는 것입니다.

// ❌ 문제가 되는 패턴
val gitBranch = providers.of(GitBranchValueSource::class.java) {}.get()

예를 들어, 저 시점의 main 값이 Configuration Cache에 고정되었고, 이후 Git branch가 main에서 feature/**로 변경되어도 Configuration Cache는 여전히 main을 사용하기에 잘못된 빌드 결과를 만들어낼 수 있습니다.

ValueSource의 설계 의도는 결국 빌드 시작 → 매 빌드마다 obtain() 호출 → 값 비교 → Cache 유효성 판단 (같으면 Cache 재사용, 다르면 Cache 무효화) 인데, Configuration phase에서 .get()을 호출하면 Configuration phase → .get() → 값 고정 → Cache에 저장 이런 흐름으로 가게 됩니다.

또한 Provider 패턴의 핵심은 Lazy Evaluation 이고, Task가 실제로 실행될 때만 obtain()을 호출하게 하려면 다음과 같은 패턴을 사용해야합니다.

// ✅ 올바른 패턴
tasks.register("deploy") {
    val versionProvider = providers.of(VersionValueSource::class.java) {}
    inputs.property("version", versionProvider)  // Provider 그대로 전달

    doLast {
        // Execution phase에서만 평가
        println("Version: ${versionProvider.get()}")
    }
}

정리

ValueSource API는 Provider 패턴을 따르면서도 외부 소스 접근에 특화된 기능을 제공하고, Configuration Cache와도 호환이 되는 Gradle의 API입니다. 특히 Gradle 8.1부터 Configuration Cache가 stable feature가 되면서 더욱 중요해진 것 같습니다.

configuration cache stable

느린 작업은 obtain()에서 피하고, Git 정보(브랜치, 커밋 해시)를 빌드에 포함시키거나, 환경 변수를 읽어 빌드 설정을 결정하거나, CI/CD 환경을 감지하여 빌드 동작을 변경해야 할 때 사용하기에 적절한 API로 생각하며, 프로젝트 내부 값 전달이나 단순한 계산 작업에는 일반 Provider API 로 충분할 것 같습니다.

마지막으로 주의할 점을 당부하자면, Configuration phase에서 .get()을 직접 호출하면 값이 고정되어 Configuration Cache의 이점을 잃게 되므로, 반드시 Provider 형태로 Task에 전달하고 Execution phase에서 resolve하도록 해야 합니다.

참고 자료

About Me
@onseok
배움을 배포하기