Skip to content

perf(ci): PR 검증 4개 job이 같은 debug variant 를 각자 컴파일 — build cache 가 전역 off #996

Description

@1hyok

현황

PR 하나에 8개 workflow / 10개 job이 뜨고, 그중 넷이 같은 commit의 같은 debug variant를 각자 처음부터 컴파일한다.

job Gradle 작업 실측
CodeQL (java-kotlin) assembleDebug + domain classes 10분
Compose Preview Screenshot validateScreenshotTest ×4 모듈 (docker) 8분
Static Analysis / android-lint lintDebug 7분
Unit Test koverXmlReportCi (= testDebugUnitTest 전 모듈) 7분

(실측: PR #991 fix/724-view-state run)

산출물 공유가 안 되는 이유는 두 겹이다.

  1. gradle.properties:33 org.gradle.caching=false — Gradle build cache가 저장소 전역으로 꺼져 있다. ~/.gradle/caches/build-cache-1이 애초에 채워지지 않으므로 job 간은 물론 run 간 재사용도 0이다.
  2. PR job은 모두 cache-read-only: true — cache poisoning 방지 목적이라 유지해야 한다. 다만 (1) 때문에 읽을 build cache 자체가 없다.

org.gradle.caching=false의 주석은 비활성화 근거를 이렇게 적고 있다.

Studio/JDK 업데이트나 빌드 중 OOM 등으로 input 해시는 같지만 산출물이 손상된 cache 엔트리가 hit되는 사례가 있어 비활성화.

이 실패 모드는 장기 존속하는 로컬 Gradle User Home에서 발생한다. CI 러너는 매 run이 새 VM이고 GUH는 actions cache에서 복원되는 불변 스냅샷이라 같은 조건이 아니다. 로컬 기본값은 그대로 두고 CI에서만 켜는 것이 가능하다.

범위

  • gradle.properties의 org.gradle.caching=false는 로컬 기본값으로 유지한다. 주석의 우려는 로컬에 그대로 유효하다.
  • CI에서만 build cache를 켠다 (--build-cache 플래그 또는 CI 전용 init script). 로컬 개발자 빌드 동작은 바뀌지 않아야 한다.
  • develop push에서 build cache를 채운다. 현재 push에서 Gradle을 돌리며 cache-read-only: false인 job은 CodeQL(assembleDebug)과 Gradle Dependency Submission이다.
  • PR job은 cache-read-only: true를 유지한 채 읽기만 한다. PR이 cache에 쓰지 못하는 경계는 보안 요구이므로 완화하지 않는다.
  • 변경 전후 러너 소요를 같은 PR에서 실측해 기록한다. 단축이 확인되지 않으면 되돌린다.

완료 조건

  • 로컬 ./gradlew 기본 동작에서 build cache가 여전히 비활성이다 (org.gradle.caching 기본값 불변).
  • develop push run 이후의 PR run에서 컴파일 task가 FROM-CACHE로 보고된다.
  • PR 전체 러너 소요 합계가 실측으로 감소한다. 수치를 이 이슈에 남긴다.
  • PR job의 cache-read-only: true가 유지된다.
  • 캐시 hit 상태에서도 unit test·lint·screenshot·CodeQL 결과가 cache 미사용 때와 동일하다.

관련

근거

Activity

  1. self-assigned this
    on Aug 24, 2026
  2. 1hyok commented on Aug 24, 2026

    @1hyok
    CollaboratorAuthor

    #1000 에서 --build-cache 를 CI 호출에 넣었다. 그 PR 의 CI 에서는 단축이 나오지 않는다 — 현재 develop 캐시가 --build-cache 없이 만들어져 caches/build-cache-1 이 비어 있고, PR job 은 cache-read-only: true 라 채우지도 않는다.

    실효는 머지 뒤 develop push 의 CodeQL(assembleDebug, cache-read-only: false)이 캐시를 한 번 채운 다음 PR 부터다. 그 시점에 확인해 여기에 수치를 남긴다.

    단축이 확인되지 않으면 --build-cache 를 되돌린다.

  3. 1hyok commented on Aug 24, 2026

    @1hyok
    CollaboratorAuthor

    정정 — CodeQL 은 --build-cache 예외다

    #1000 에서 codeql.yml 에도 --build-cache 를 넣었는데 잘못이다. #1014 로 되돌린다.

    codeql.yml 의 해당 자리 주석이 조건을 적고 있었다.

    Java/Kotlin extraction observes compiler invocations, so a clean manual build is the contract.

    CodeQL Java 추출기는 컴파일러 호출을 가로채 소스를 database 에 담는다. compile task 가 FROM-CACHE 로 넘어가면 컴파일러가 실행되지 않아 추출이 비고, verify-codeql-database.mjs 가 실패한다. #1000·#1001·#1002 연속 머지로 develop push CodeQL 이 cancel-in-progress 에 전부 취소돼 아직 발현되지는 않았다.

    그 결과 이 이슈의 전제가 깨졌다

    이 이슈는 "develop push 의 CodeQL(assembleDebug, cache-read-only: false)이 캐시를 채우고 PR 이 읽는다" 를 전제로 했다. develop push 에서 debug 컴파일 캐시를 채울 수 있는 워크플로를 전수 확인하니 codeql.yml 이 유일했다.

    workflow push 대상 cache-read-only gradlew
    codeql.yml develop, main pull_request 일 때만 true assembleDebug
    release-distribution.yml main false assembleRelease (debug 무관)

    따라서 #1014 머지 후에는 캐시를 채우는 경로가 없어 lint·unit-test·screenshot 에 남은 --build-cache 가 항상 miss 한다. 지금 상태의 실효는 0 이다.

    남은 선택지

    1. develop push 전용 워밍 경로 신설 — assembleDebug 를 --build-cache 로 돌려 캐시만 채우는 job. 러너 시간을 추가로 쓰므로 PR 쪽 절감분과 비교해 판단해야 한다.
    2. --build-cache 를 전부 되돌린다 — 채울 경로가 없으면 플래그만 남아 오해를 부른다.
    3. PR job 의 cache-read-only: true 완화 — 채택하지 않는다. cache poisoning 방지 경계다.

    어느 쪽이든 실측이 먼저다. 이 이슈는 실효가 확인될 때까지 열어 둔다.

  4. reopened this on Aug 24, 2026
  5. 1hyok commented on Aug 24, 2026

    @1hyok
    CollaboratorAuthor

    정정 — CodeQL 추출은 아직 깨지지 않았다

    앞선 코멘트에서 --build-cache 가 CodeQL 추출을 무력화한다고 단정했는데 실측으로 확인하니 그 단정은 틀렸다.

    • develop push 가 캐시를 채운 뒤의 PR CodeQL run 에서 FROM-CACHE 가 118건 찍힌다.
    • 그럼에도 Verify analyzed sources in the CodeQL database 스텝은 success 다 — 추출이 정상 동작 중이다.
    • 실패한 fix/952-receiver-count-dash 의 CodeQL 은 캐시가 아니라 Hilt 문제였다 (@HiltAndroidApp base class must extend Application. Found: kotlin.Any → :app:kspDebugKotlin FAILED).

    캐시 히트가 컴파일 task 전체를 덮지 않아 추출에 필요한 컴파일러 호출이 남아 있는 것으로 보인다. 다만 그것은 언제 발현될지 모르는 상태에 기대는 구조 이므로, 워크플로 주석이 명시한 계약("clean manual build is the contract")을 지키는 #1014 되돌림은 그대로 유지한다. 성격만 "긴급 차단" 에서 "예방" 으로 바뀐다.

    이 이슈의 전제가 깨졌다는 앞선 코멘트의 나머지 내용(캐시를 채우는 경로가 codeql.yml 하나뿐이라 되돌림 후 실효가 0 이 된다)은 그대로 유효하다.

  6. 1hyok commented on Aug 24, 2026

    @1hyok
    CollaboratorAuthor

    PR #1037 로 머지됐다. 머지 후 실측 결과를 남긴다 — 완료 조건은 충족하지만, 워밍 워크플로 자체의 기여는 아직 실측되지 않았다. 갈라서 적는다.

    1. PR run 에서 FROM-CACHE 가 찍힌다 ✅

    머지 직후 PR run 의 Run Unit Tests 에서:

    run 브랜치 FROM-CACHE
    32719292011 develop 143건
    32718982723 test/1030 148건

    완료 조건 «develop push run 이후의 PR run 에서 컴파일 task 가 FROM-CACHE 로 보고된다» 는 충족된다.

    2. 다만 그 hit 은 워밍이 준 것이 아니다 ⚠️

    복원된 키를 추적하면 setup-java-Linux-x64-gradle-3b1d9c72… 이고, 생성 시각이 09:36 — 워밍 워크플로가 도입되기 전, CodeQL push 가 --build-cache 로 돌던 시절 저장한 엔트리다.

    첫 워밍 run(32719009735)은 success 로 끝났지만 캐시를 저장하지 못했다.

    Basic caching restored entry with key `setup-java-Linux-x64-gradle-3b1d9c72…`. Save was skipped.
    

    setup-gradle 은 exact match 로 복원하면 저장을 건너뛴다. 그래서 이번 run 이 --build-cache 로 채운 build-cache-1 항목은 버려졌다.

    3. 그럼 워밍은 언제 실제로 채우는가

    캐시 키는 빌드 스크립트 해시(**/*.gradle* 등)로 갈린다. 키가 바뀌면 워밍이 exact miss → restore-keys 폴백으로 이전 캐시를 이어받아 빌드 → 새 키로 저장 성공. 그 시점부터 그 키의 공급자는 워밍 하나다(#1014 로 CodeQL 은 더 이상 캐시를 채우지 않는다).

    이 키가 얼마나 자주 갈리는지는 실측이 있다 — 오늘 하루 develop 스코프에만 5개가 생겼다 (08:17, 09:17, 09:20, 09:28, 09:36). PR 을 머지할 때마다 모듈 build.gradle.kts 가 바뀌므로 20분~1시간 간격이다.

    4. 남은 한계와 판단

    빌드 스크립트가 안 바뀌는 동안 소스만 바뀌면 build cache 가 갱신되지 않아 hit rate 이 서서히 떨어진다. Actions 캐시가 같은 키를 덮어쓰지 못하는 구조적 제약이라, 강제로 갱신하려면 cache-write-only: true(복원 포기 → 워밍이 매번 전체 빌드) + 이전 엔트리 삭제(actions: write) 조합이 필요하다. 워밍 job 소요가 10분 → 15~20분으로 늘고, cancel-in-progress: false 인 상태에서 삭제가 겹치면 워밍끼리 서로의 캐시를 지우는 경합이 생긴다.

    지금은 되돌리지 않는다. 되돌림 조건은 «단축이 확인되지 않으면» 인데 FROM-CACHE 는 실제로 찍히고 있고, 워밍은 키가 갈리는 다음 develop push 부터 그 공급을 이어받는 구조다. 다음 키 변경 이후의 워밍 run 에서 Save was skipped 가 사라지고 저장이 성공하는지를 확인해 이 이슈에 추가로 남긴다. 그때도 저장되지 않으면 위 조합을 후속으로 검토한다.

  7. 1hyok commented on Aug 24, 2026

    @1hyok
    CollaboratorAuthor

    앞 코멘트에 틀린 서술이 있어 정정한다. 그리고 워밍 run 2건이 끝나 실측을 보탠다.

    정정 — restore-keys 폴백은 없다

    앞에서 «키가 바뀌면 restore-keys 폴백으로 이전 캐시를 이어받아 빌드» 라고 적었는데 사실이 아니다. actions/setup-java 의 gradle 캐시는 restoreKeys 를 두지 않는다.

    No restoreKeys is set, to start with a clear cache after dependency update.

    키 해시 입력은 다음 7종이다.

    **/*.gradle*
    **/gradle.properties
    **/gradle-wrapper.properties
    buildSrc/**/Versions.kt
    buildSrc/**/Dependencies.kt
    gradle/*.versions.toml
    **/versions.properties
    

    저장 스킵 조건은 matchedKey === primaryKey — exact match 로 복원했을 때다.

    따라서 실제 동작은 이렇다.

    빌드 스크립트 복원 저장
    바뀜 (새 키) miss — 빈 GUH 로 시작 성공
    그대로 (같은 키) exact hit 스킵

    즉 build cache 는 빌드 스크립트가 바뀔 때마다 통째로 리셋된다. 오늘 develop 스코프에 키가 5개 생겼다는 것은 리셋이 5번 있었다는 뜻이고, 그 직후 첫 PR run 들이 빈 캐시를 만난다는 뜻이다. 워밍의 가치는 바로 그 자리에 있다.

    실측 — 워밍 2건 모두 저장 0건

    run 결과 소요 캐시
    32719009735 success 10.8분 Entries: 1 restored, 0 saved
    32719287425 success 11.4분 Entries: 1 restored, 0 saved

    두 push 사이에 빌드 스크립트가 바뀌지 않아 키가 같았고, 그래서 둘 다 Save was skipped 였다. 11분씩 러너를 쓰고 아무것도 저장하지 못했다.

    남은 결함 — 설계는 맞고 트리거가 틀렸다

    키가 바뀌는 push 에서는 워밍이 제 역할을 한다(위 표의 첫 행). 문제는 키가 안 바뀌는 push 에서도 도는 것이다. 그 run 은 저장이 원천적으로 불가능하므로 순수 낭비다. develop push 는 PR 을 머지할 때마다 나므로 빈도가 높다 — 오늘 10:52~10:56 사이에만 3회였다.

    트리거를 캐시 키 입력과 같은 paths 로 좁히면 정확히 해결된다. 후속으로 올린다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions