CI

캐시를 켰는데 캐시가 0개였다

  • #AI
  • #Claude Code
  • #CI
  • #GitHub Actions
  • #Gradle

문제: 캐시를 도입했는데 아무 일도 안 일어났다

CI를 줄이려고 Gradle 캐시를 붙였다. 워크플로는 초록불이고, 로그도 정상이고, 액션도 제대로 돌았다.

그런데 레포 Actions 캐시 목록이 0개였다.

이 글은 그걸 두 번 고치고 나서야 채워진 이야기다. 그리고 두 번 다 원인은 “설정을 틀리게 썼다”가 아니라 “기본값이 이 레포에서는 성립하지 않는다” 였다.

1차 원인: PR의 github.ref는 브랜치 이름이 아니다

첫 실행 후 로그를 열었더니 원인이 그대로 찍혀 있었다.

Cache is read-only: will not save state for use in subsequent builds.

읽기 전용이라 저장을 안 한다는 뜻이다. 내가 쓴 조건은 이거였다.

cache-read-only: ${{ github.ref != 'refs/heads/dev' }}

의도는 “dev 브랜치에서만 캐시를 쓰고, PR에서는 읽기만 하자” 였다. PR이 검증 안 된 산출물로 공용 캐시를 오염시키지 않게 하려는 것이다. 의도 자체는 맞다.

문제는 이 조건이 항상 참이라는 점이었다.

  • 이 워크플로는 pull_request 이벤트로만 돈다
  • PR의 github.ref는 브랜치명이 아니라 refs/pull/<n>/merge
  • 따라서 'refs/heads/dev'절대 같아질 수 없다

즉 “dev일 때만 쓰기 허용”이 실제로는 “어떤 경우에도 쓰기 금지” 였다.

결국 아무도 캐시를 채우지 못해 캐싱 도입이 통째로 무의미했다.

조건을 지웠다. 이제 PR 실행이 캐시를 채울 것이다.

2차 원인: 조건을 지워도 기본값이 막고 있었다

여전히 0개였다.

cache-read-only 입력을 아예 제거했는데도 캐시가 안 생긴다. 이번엔 액션의 소스(action.yml)를 열었다.

cache-read-only 기본값 = ref_name != 레포 기본브랜치

입력을 안 주면 액션이 알아서 같은 판단을 한다. 그리고 그 기본값이 이 레포에서는 성립하지 않았다.

  • 이 레포의 기본 브랜치 — main
  • 그런데 main에는 빌드 워크플로가 없다 (개발은 dev 기준)
  • CI는 pull_request로만 돌아 ref_name이 항상 <n>/merge

두 조건이 겹치면 결론은 하나다.

어느 워크플로도 기본값으로는 캐시를 쓸 수 없어 캐싱이 계속 무의미했다.

내가 명시적으로 쓴 조건과 액션의 기본값이 우연히 같은 실수를 하고 있었다. 그래서 1차 수정이 아무 효과가 없었다.

해법: 캐시를 채울 주체를 명시적으로 지정한다

먼저 GitHub 캐시의 스코프 규칙을 알아야 한다. 어떤 실행이 어떤 캐시를 읽을 수 있는지가 정해져 있다.

  • 현재 브랜치가 만든 캐시
  • 기본 브랜치가 만든 캐시
  • PR이라면 그 PR의 base 브랜치가 만든 캐시

여기서 답이 나온다. PR의 base는 dev이므로, dev push가 채운 캐시는 base가 dev인 PR이 읽을 수 있다.

그래서 역할을 나눴다.

워크플로 설정 이유
deploy-dev.yml cache-read-only: false 명시 유일한 seed 주체
ci.yml (PR) 기본값(read-only) 유지 읽기만
deploy-stg.yml 기본값 유지 써봐야 아무도 못 읽음

각 선택에 근거가 있다.

PR이 캐시를 쓰지 않는 이유 — PR 캐시는 refs/pull/<n>/merge 스코프라 그 PR 재실행에서만 쓸 수 있다. 다른 PR은 못 읽는다. 그런데 PR마다 캐시를 쓰면 10GB LRU 한도에서 공용 항목을 밀어낸다. 쓰는 사람만 있고 읽는 사람이 없는 캐시가 진짜 유용한 캐시를 쫓아내는 구조다.

stg가 쓰지 않는 이유stgdev의 형제 브랜치다. base가 dev인 PR은 stg 캐시를 읽을 수 없다. 저장해봐야 쿼터만 소모한다.

결과적으로 “채우는 곳 하나, 읽는 곳 여럿” 구조가 됐다. dev에 머지가 한 번 일어나면 캐시가 생기고, 그 이후 PR부터 복원 효과가 나타난다.

이 실패가 조용한 이유

이 문제의 성질을 정리하면 이렇다.

  • CI가 초록불이다 — 캐시는 없어도 빌드는 된다. 그냥 느릴 뿐이다
  • 로그에 에러가 없다Cache is read-only는 INFO 레벨 안내문이지 경고가 아니다
  • 설정은 문법적으로 맞다 — YAML도 유효하고 액션도 그 입력을 이해한다
  • 의도도 맞다 — “PR은 읽기만”은 좋은 정책이다

테스트로도, 리뷰로도, CI 결과로도 안 잡힌다. 잡으려면 레포 Actions 캐시 목록을 사람이 직접 열어봐야 한다.

성능 개선 작업의 공통 함정이 여기 있다. 개선이 작동하지 않아도 기능은 정상이다. 그래서 “적용했다”와 “작동한다” 사이가 벌어져도 아무도 모른다.

그래서 이 작업에서 배운 건 이거다. 캐시·인덱스·풀 같은 “있으면 빨라지는” 장치는 도입 후 반드시 실물을 확인해야 한다. 로그가 아니라 결과물을 본다.

  • 캐시 — 레포 Actions 캐시 목록에 항목이 생겼는가
  • 인덱스 — EXPLAIN이 실제로 그 인덱스를 타는가
  • 커넥션 풀 — 풀 지표가 실제로 재사용을 보여주는가

곁다리로 나온 것들

캐시를 켜면서 함께 손본 것도 있다. 이쪽은 순조로웠다.

  • clean 제거 — 러너는 매번 새로 뜨므로 지울 게 없다. 로그상 9개 모듈 전부 UP-TO-DATE였고, 오히려 빌드 캐시를 무효화하고 있었다
  • Gradle 호출 1회로 병합bootJartest를 따로 부르며 기동·설정 단계를 중복 지불(약 12초)하던 것
  • 중복 실행 취소concurrency + cancel-in-progress. 같은 PR에 새 커밋을 밀어도 이전 CI가 끝까지 돌고 있었다. 최근 100회 중 32회가 이런 중복이었고 한 PR은 10회였다

마지막 항목이 사실 가장 컸다. 러너 시간의 3분의 1을 이미 의미 없어진 커밋 검증에 쓰고 있었다. 캐시 튜닝보다 “안 해도 될 일을 안 하기” 가 먼저였던 셈이다.

안 켠 것

configuration-cache는 켜지 않았다.

  • 이유 — 당시 sonarqube 플러그인 버전이 비호환
  • 판단 — 플러그인 상향 후 별도 검증 필요
  • 조치 — 커밋 메시지에 명시

안 한 이유를 적어두지 않으면 다음 사람은 “왜 이건 안 켰지”부터 다시 조사한다. 이미 조사해서 안 된다는 걸 알았다면 그 결과도 산출물이다.

남는 교훈

두 번 고친 순서를 다시 보면 이렇다.

시도 진단 결과
1 내가 쓴 조건이 항상 참 맞음. 고쳤지만 여전히 0개
2 액션 기본값도 같은 판단을 함 진짜 원인

1차 진단은 틀리지 않았다. 그 조건은 실제로 버그였다. 다만 같은 결과를 만드는 원인이 두 겹이었고, 위 껍질을 벗기니 아래에 똑같이 생긴 게 하나 더 있었다.

여기서 얻은 습관이 하나 있다. 설정을 지웠는데 동작이 안 바뀌면, 그 설정의 기본값을 확인한다. 명시적 설정을 제거한다는 건 “아무것도 안 한다”가 아니라 “라이브러리의 판단에 맡긴다” 는 뜻이다. 그 판단이 내 환경에서 뭘 의미하는지는 문서가 아니라 액션의 action.yml을 직접 열어봐야 나왔다.

그리고 하나 더. 이 레포처럼 기본 브랜치와 실제 개발 브랜치가 다른 구조는 도구의 기본값을 자주 배신한다. main을 기본으로 두고 dev에서 개발하는 건 흔한 구성인데, 많은 CI 도구가 “기본 브랜치 = 신뢰할 수 있는 브랜치 = 캐시를 채우는 곳”으로 가정한다. 그 가정이 깨지는 레포에서는 기본값에 기대지 말고 명시해야 한다.