같은 캐시 키에 같은 hit인데 main은 64초, PR은 4.0분입니다. 그리고 PR의 4.0분은 캐시가 아예 없는 대조군 3.9분과 구분되지 않습니다.
캐시가 죽었는지 살았는지는 hit/miss 로그로 알 수 없습니다. hit 이후의 실제 컴파일 시간을 콜드 run과 비교해야 알 수 있습니다.
main과 대조군은 비교의 양 끝입니다. 64초는 복원된 캐시가 실제로 재사용된 시간이고, 3.9분은 캐시 없이 콜드로 컴파일한 시간입니다. PR의 4.0분이 어느 쪽에 붙는지만 보면 됩니다. 둘 중 하나만 있었다면 진단이 안 됐을 겁니다. 콜드만 있으면 "이 프로젝트는 원래 4분짜리군"으로 끝나고, main만 있으면 "PR은 뭔가 더 하니까 느리겠지"로 끝납니다.
캐시는 두 단계를 거칩니다
여기서 "캐시 키가 어긋났다"고 읽으면 방향이 반대가 됩니다. 키는 정확히 맞았습니다. 맞은 것이 문제였습니다.
캐시를 확인하는 곳이 두 군데이기 때문입니다.
단계
주체
판단 기준
이번 결과
1단계
actions/cache
키 문자열이 같은가
같음 → hit, 67MB 복원
2단계
Next.js(webpack)
복원된 .next/cache의 config 지문이 지금과 같은가
다름 → 통째로 폐기
actions/cache는 tarball을 풀어주는 일만 합니다. 그 안의 내용이 이번 빌드에 쓸모 있는지는 알지 못하고, 알 방법도 없습니다. 유효성 판단은 전적으로 키를 짠 사람의 몫입니다.
Next.js는 압니다. webpack의 persistent cache에는 그 캐시를 구울 때의 resolved config(설정 파일이 아니라, 환경변수까지 반영된 빌드 시점의 최종 설정값) 지문이 함께 저장되고, 복원본을 열 때 지금 값과 대조합니다.
그래서 로그에 에러가 한 줄도 없었던 겁니다. 두 단계 모두 명세대로 정확히 동작했습니다. 고장난 부품은 하나도 없고, 문제는 두 단계 사이에 있었습니다.
빌드 입력을 잘 반영한 키처럼 보입니다. 의존성이 바뀌면 갈리고, core 산출물이 바뀌면 갈립니다. 그런데 키에 안 들어간 입력이 하나 있었습니다. PR 빌드와 main 빌드는 basePath가 다릅니다.
PR: SANDBOX_BASE_PATH=/<repo>/pr/<n>/sandbox
main: /<repo>/sandbox
이건 실수가 아니라 필연입니다. PR 프리뷰는 GitHub Pages의 <owner>.github.io/<repo>/pr/<n>/sandbox/ 경로에 배포됩니다. 그 prefix 없이 빌드하면 루트 절대 경로로 나가는 에셋 URL이 전부 404가 나서 프리뷰의 스타일과 JS가 통째로 깨집니다.
앞에서 말한 "지문"의 실체가 이것입니다. Next.js는 config 전체가 아니라 컴파일 결과를 바꾸는 값들만 골라 버전 문자열로 이어 붙이는데, basePath가 그 한 항목입니다.
main이 구운 캐시에는 main의 basePath가 박힌 문자열이 기록돼 있으니, PR이 여는 순간 대조가 어긋납니다 — 한 글자라도 다르면 통째로 버리고, 부분 재사용은 없습니다. 복원한 67MB는 처음부터 순수한 다운로드 낭비였습니다.
이 폐기는 버그가 아니라 안전장치입니다. Next.js가 config 지문을 대조하지 않았다면 결과는 느린 빌드가 아니라 잘못된 배포였을 겁니다. main용 basePath로 컴파일된 에셋이 PR 프리뷰로 나가 링크가 전부 404가 나는데, CI는 4분이 아니라 40초에 끝나서 "최적화가 잘 됐다"는 인상까지 줬을 겁니다. 느린 쪽이 오히려 다행이었습니다.
hit으로 기록되면 저장도 못 합니다
여기서 끝이었으면 "쓸데없이 67MB 받는 낭비" 정도였을 겁니다. 진짜 문제는 두 번째였습니다. actions/cache는 primary key에 hit이 나면 이 로그를 남기고 저장 단계를 건너뜁니다.
text
Cache hit occurred on the primary key, not saving.
캐시 액션 입장에선 합리적입니다. 이미 그 키로 캐시가 있으니 다시 올릴 이유가 없으니까요. 그런데 이 상황에서는 결과가 최악입니다.
PR은 main의 죽은 캐시로 "hit"을 받습니다. → 저장 단계가 건너뛰어집니다. → PR은 자기 basePath에 맞는 캐시를 영원히 만들 수 없습니다. → 다음 푸시에서도 똑같이 죽은 캐시를 받고 콜드 컴파일을 합니다.
CI 러너는 매 run 새 기계라, run 사이에 살아남는 유일한 통로는 캐시 업로드뿐입니다. 콜드 컴파일의 부산물로 러너 디스크에는 PR basePath에 맞는 진짜 캐시가 생기지만, 저장이 스킵되니 러너와 함께 증발합니다. 다음 푸시는 빈 디스크에서 또 main의 캐시를 받습니다.
캐시가 있는데 없는 것보다 나쁜 상태가 됐습니다. 내려받는 시간은 쓰고, 빌드는 매번 콜드로 돌고, 올바른 캐시가 생기는 것까지 막습니다.
main이 멀쩡했던 이유도 여기서 자명해집니다. main은 자기가 구운 것을 자기가 꺼내 씁니다. basePath가 같으니 Next.js의 대조를 통과하고, 그래서 64초입니다. 뒤집어 말하면 miss를 내고 저장할 기회는 사실상 main에게만 있었으니, main만 쓸 수 있는 캐시가 공유 키라는 공용 이름으로 모두에게 배포되고 있던 셈입니다.
예외는 있었습니다. PR이 의존성이나 core 산출물을 바꾸면 키가 main이 점유하지 않은 새 값이 돼 저장이 실행됩니다. 그래서 core를 건드리는 PR은 다음 푸시부터 웜이었고, sandbox나 문서만 고치는 PR만 영원히 콜드였습니다 — 증상이 PR마다 달라 이 버그가 오래 눈에 띄지 않은 이유입니다.
파일이 아닌 입력은 키에서 저절로 빠집니다
그런데 왜 basePath는 키에 없었을까요. 부주의라기보다, 키를 짜는 기본 방식이 원래 놓치는 입력이기 때문입니다.
캐시 키는 관습적으로 hashFiles()로 짭니다. 그런데 hashFiles()는 파일 시스템만 봅니다.SANDBOX_BASE_PATH처럼 환경변수로 들어오는 입력은 빌드 결과를 아무리 좌우해도 여기 잡히지 않습니다. 사람이 손으로 적어 넣지 않는 한 구조적으로 누락되고, 그 누락은 눈에 보이지도 않습니다 — 키 문자열을 아무리 들여다봐도 "빠진 것이 있다"는 사실 자체를 알 수 없기 때문입니다.
같은 이유로, 이 문제는 의존성 캐시에서는 생기지 않습니다. pnpm store의 내용을 정하는 입력은 lockfile 하나뿐이고 그건 파일이니, hashFiles()만으로 충분합니다. 키가 좀 헐거워도 패키지는 content-addressed라 잘못된 것을 집어올 수 없습니다. 문제가 생기는 쪽은 빌드 산출물 캐시입니다. 컴파일 결과에는 그때의 config·환경변수·플래그가 전부 반영돼 있는데, 키에는 보통 lockfile과 소스 해시만 들어갑니다. 이 버그는 그 차이에서 생깁니다.
빠진 입력을 키에 넣습니다
고치는 diff는 허무할 정도로 짧습니다. +17/−1인데 그중 16줄은 근거를 적은 주석이고, 실제로 바뀐 건 이 한 줄입니다.
pr<n> 한 조각을 넣은 게 전부입니다. PR 번호는 basePath를 결정하는 값입니다. 키에 저절로 들어가지 않던 입력(환경변수)을, 키에 직접 적을 수 있는 대리 값으로 바꿔 넣은 셈입니다. 이제 각 PR은 첫 푸시에서 자기 캐시를 굽고, 이후 푸시에서 진짜 워밍 캐시를 씁니다. main은 원래부터 자기가 구운 캐시를 자기가 꺼내 쓰고 있었으니 그대로 둡니다.
restore-keys는 넣지 않았습니다
키를 세분화하면 자연스럽게 "그럼 restore-keys로 접두사만 맞는 캐시라도 복원하자"는 생각이 따라옵니다. 일부러 넣지 않았습니다. 이 저장소에는 이력이 있습니다 — Next.js 모듈 캐시에는 @astryxdesign/core의 resolved export graph가 함께 구워지는데, 접두사만 맞는 캐시를 복원하면 다른 export 형태로 빌드된 것이 들어와 조용히 잘못된 빌드가 나옵니다(#2941의 머지 후 배포가 이 방식으로 깨졌습니다). 이 저장소의 정책은 "캐시 hit률 최대화"가 아니라 **"틀린 빌드를 절대 만들지 않기"**입니다. 키가 바뀌면 콜드 리빌드가 나는 편이 안전합니다.
고쳤더니 hit률이 떨어집니다
이 수정이 제대로 됐다는 신호는 miss입니다. 각 PR의 첫 푸시는 이제 반드시 miss니까요. 그래서 테스트 플랜에 일부러 체크하지 않은 항목을 하나 남겼습니다 — "첫 푸시의 miss가 수정이 작동한다는 증거다. 재푸시에서 컴파일이 64초 수준으로 떨어지는지 확인할 것." 이걸 적어두지 않으면 다음 사람이 hit률 그래프만 보고 "캐시가 안 먹네" 하며 되돌려 놓습니다.
그 항목은 이 PR 자신에서 닫혔습니다. 첫 푸시(run 29182165678)는 nextjs-sandbox-pr3864-… 키로 miss가 나서 4.2분을 컴파일하고 캐시를 저장했고, 28분 뒤 재푸시(run 29182864149)는 그 키를 복원해 60초에 컴파일했습니다 — main의 64초와 같은 수준입니다.
머지 후, 캐시가 걸린 재푸시 기준으로 PR CI 전체 시간은 8분 56초에서 5분 10초가 됐습니다. 다만 23분 간격으로 머지된 병렬화 PR(#3811)의 효과가 섞여 있어, 캐시 수정만의 몫은 따로 떼어 말할 수 없습니다.
남은 한계: 첫 푸시는 여전히 콜드입니다
pr 키에도 한계가 남습니다. 두 번째 푸시부터는 다시 primary hit이라 저장이 스킵되고, 그 PR의 캐시는 첫 푸시 때 만든 것에 머뭅니다. 흔한 해법은 키에 커밋 SHA를 붙여 hit을 없애고(그러면 매번 저장됩니다) 복원은 restore-keys에 맡기는 것입니다. 접두사에 pr<n>과 core 해시를 남기면(nextjs-sandbox-pr<n>-<lock>-<core>-) 여기서도 쓸 수 있고, #2941 사고와도 부딪히지 않습니다. 이번 PR은 저장소에 적힌 no-restore-keys 정책을 열지 않고 그 안에서 고치는 쪽을 택했습니다.
SHA를 붙였더라도 달라지지 않는 것이 하나 있습니다. basePath가 PR마다 다른 한, 첫 푸시는 어떤 키로도 웜이 될 수 없습니다. GitHub Actions 캐시는 브랜치 단위라 PR run이 읽을 수 있는 건 자기 브랜치와 base 브랜치(main)의 캐시뿐입니다. 첫 푸시에는 자기 캐시가 없으니 남는 건 main 캐시 하나인데, 그건 basePath가 달라 쓸 수 없습니다.
그러니 근본 해법은 키 바깥에 있습니다. main 캐시를 PR 빌드에서도 쓸 수 있게 만드는 것 — basePath를 빌드 입력에서 빼는 것입니다. 에셋을 상대 경로로 내보내거나 프리뷰를 서브도메인으로 배포하면 basePath가 어디서나 같아지고, 키를 가를 이유가 사라집니다. 이번 PR은 워크플로 파일만 고치는 키 수정을 택했고, 첫 푸시의 콜드는 고친 게 아니라 감수한 것입니다. 순서만 기억해 두면 됩니다 — "캐시 키를 어떻게 짤까"보다 "이 입력이 정말 달라야 하나"를 먼저 묻는 것입니다.
이 패턴을 다른 저장소에 옮긴다면
캐시 스텝마다 이 네 가지를 확인하면 됩니다.
키에 넣을 것 — 산출물을 바꾸는 입력이 전부 들어갔나. hashFiles()는 파일만 보므로 환경변수·플래그·툴체인 버전은 손으로 넣어야 한다
같은 캐시를 쓰면 안 되는 빌드 — PR과 main, OS, 런타임 버전, matrix 조합처럼 서로 캐시를 나눠 써야 하는 빌드가 키 접두사로 구분되나
restore-keys — 웜을 최대화할지, 정확성을 위해 콜드를 감수할지. 쓴다면 위의 구분이 접두사에도 남아 있나
살아 있는지 — Cache restored successfully 다음 줄의 소요 시간이 그 캐시를 지웠을 때와 유의미하게 다른가. 같으면 그 캐시는 죽어 있다
앞의 셋은 키를 읽어서 확인할 수 있지만, 빠진 입력은 키 문자열을 아무리 들여다봐도 보이지 않습니다. 그래서 마지막 항목이 필요합니다.
마치며
캐시 hit은 "키 문자열이 같았다"는 뜻일 뿐이라, 캐시의 생사는 hit 이후의 컴파일 시간을 콜드 대조군과 비교해야 알 수 있습니다. 이번 사례에서 키는 정확히 맞았지만, Next.js가 basePath 다른 캐시를 열어보고 통째로 버리고 있었고, hit으로 기록된 탓에 올바른 캐시가 저장될 기회조차 없었습니다. basePath가 키에서 빠진 것은 부주의가 아니라 hashFiles()가 파일만 보기 때문입니다. 그래서 키에 PR 번호를 넣어, 각 PR이 자기 캐시를 굽게 했습니다.
여러분의 CI에도 캐시 스텝이 있다면, 지금 가장 최근 run의 로그를 열어보세요. 확인은 5분이면 끝납니다. Cache restored successfully 다음 줄의 빌드 시간이 캐시가 없던 시절과 정말로 다른가요. 비슷하게 "hit인데 안 빨라지는" 캐시를 만나신 적 있다면 댓글로 공유해 주세요.