문제: 캐시를 도입했는데 아무 일도 안 일어났다
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가 쓰지 않는 이유 — stg는 dev의 형제 브랜치다. 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회로 병합 —
bootJar와test를 따로 부르며 기동·설정 단계를 중복 지불(약 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 도구가 “기본 브랜치 = 신뢰할 수 있는 브랜치 = 캐시를 채우는 곳”으로 가정한다. 그 가정이 깨지는 레포에서는 기본값에 기대지 말고 명시해야 한다.