DevInsight

개발하며 겪은 판단을 적어요

DevOps
조회 5약 5분 읽기

캐시 적중률 98%인데 배포판은 왜 자꾸 구버전일까

의존성 캐시와 빌드 캐시, 매트릭스 전략은 CI 실행 시간을 반으로 줄이지만 무효화 기준을 잘못 잡으면 오래된 패키지가 그대로 빌드를 통과시키는 예상 밖의 사고로 돌아온다. 이 글은 캐시가 실제로 빌드를 깨뜨리는 구체적인 상황들을 짚고, 잠금 파일·해시 입력·매트릭스 키 설계에서 무효화 시점을 정확히 읽는 원칙을 정리해 캐시를 안전하게 쓰는 기준을 제시한다.

DevInsight에서 발행해요

#GitHub Actions#CI/CD#캐시 전략#빌드 최적화#의존성 캐시#매트릭스 전략#캐시 무효화#DevOps

최종 본문입니다.


"캐시 적중률 98%"라는 문장이 회고 보드에 붙어 있다면, 그 숫자를 뒤집어 읽을 때다. 적중률은 정확성을 재지 않는다. 옛 결과를 얼마나 무겁게 재사용했는지만 센다. 그 옛 결과가 여전히 정답이라는 보장은 어디에도 없다. 배포판이 자꾸 구버전으로 나가는 사고는 대부분 이 지점에서 시작한다.

적중률이 높을수록 오래된 채로 남는다

의존성 캐시를 도입한 파이프라인에서 배포판이 한 주씩 늦게 나가는 일은 드물지 않다. 키가 lock 파일을 보지 않을 때가 전형적이다. actions/cache 키가 package.json 해시만 읽으면 package-lock.json이 갱신돼도 키는 그대로다. 복원 단계에서 지난주 node_modules가 통째로 올라온다. 여기서 npm ci를 돌린다면 화는 막는다. ci는 lock 기준으로 트리를 처음부터 다시 세우니까 캐시가 재현성을 망가뜨리지 않는다. 진짜 위험은 install 단계를 생략하거나 부분 복원을 그대로 이어 쓰는 설정이다. 그런 파이프라인은 새 의존성을 이미 설치된 것으로 치부하고, 초록불을 켠 채 구버전 의존성을 배포한다. 실패는 로그에 흔적이 없다. 테스트도 통과한다. 그냥 두 번에 한 번 꼴로 "예전 것"이 나간다.

이런 사고는 발견이 늦다. 실행 요약에 뜨는 건 초록 배지와 Cache hit 표시 하나뿐이니, 문제는 다음 배포와 비교해서야 겉으로 드러난다. 그 사이 캐시는 "잘 돌아가는 최적화"로 포장되어 몇 주를 더 버틴다. 적중률이 가장 반가워 보일 때가 가장 의심스러운 시점이다. 캐시 히트란 결국 "어제의 답을 오늘 그대로 썼다"는 뜻이니까.

lock이 키에 없으면 보이지 않는 구버전이 배포된다

캐시의 대상도 나눠서 볼 일이다. node_modules 통째로 저장하는 방식과 ~/.npm 같은 패키지 저장소를 저장하는 방식은 성격이 아예 다르다. npm 캐시는 내용 주소 기반이고 lock 파일에 integrity가 박혀 있어서 조금 stale해도 엉뚱한 버전이 흘러들기 어렵다. node_modules 스냅샷에는 그런 안전장치가 없다. 대상이 후자라면 키를 꽉 쥐고 있어야 한다.

키는 의존성 트리를 완전히 결정하는 파일만 해시에 넣어야 한다. package.json, lock 파일, .npmrc 정도면 충분하다. 여기에 CI 스크립트 해시까지 얹으면 적중률만 곤두박질친다. 해시가 자주 바뀌는 파일을 넣는 것도 같은 실수다. package.json의 description 필드만 바뀌어도 키가 갈리고, 결과적으로 캐시는 거의 히트하지 않는다. 반대로 키에서 빠지는 파일이 실제 트리를 바꾸면 lock 갱신이 조용히 묻힌다.

주의할 대목이 하나 더 있다. lock 파일 자체는 키에 들어 있는데, 그 lock이 실제로 준수되지 않는 경우다. 저장소에 lock이 없는 상태로 시작해 누군가 npm install로 새 의존성을 받았다면, git에 올라온 lock과 실행 시점의 트리는 이미 어긋나 있다. 키는 어긋나지 않은 원본 lock을 보고 히트 판정을 내린다. 설치 결과물만 새 것인 채로 캐시된다. 이 경우 npm ci는 오히려 골치를 더 키운다. lock이 틀어져 있으면 ci 자체가 실패하니까. 먼저 ci가 늘 통과하도록 만드는 게 캐시 설계보다 앞선 일이다.

매트릭스 키는 환경을 함께 새겨야 한다

매트릭스 변수를 키에서 빼먹는 실수가 가장 흔하고 가장 비싸다. OS와 Node 버전이 서로 다른 잡들이 같은 키를 쓰면 한쪽에서 저장한 node_modules를 다른 쪽이 복원한다. 네이티브 모듈은 그 자리에서 깨진다. sharp, esbuild, node-gyp 계열이 대표적이다. 에러는 빌드 중간에 갑자기 등장해서 "어제까지 됐는데"라는 대사를 낳는다. macos 러너의 결과물이 ubuntu에서 복원된 사례는 금방 찾을 수 있다.

key: ${{ runner.os }}-${{ matrix.node-version }}-dep-${{ hashFiles('**/package-lock.json') }}

이 정도면 규칙의 뼈대는 다 들어 있다. 변하는 환경(OS, 버전)과 의존성 트리의 결정자(lock)만 키에 남긴다. 여기다 restore-keys를 얹으면 함정이 하나 더 생긴다. restore-keys는 키가 빗나갔을 때 이전 캐시를 자동으로 찾아준다. 편리한 만큼 무뚝뚝하다. lock이 바뀌어 새 키가 만들어져도 fallback이 열흘 전 캐시를 집어 올린다. ci를 쓰는 워크플로라면 여기서 다시 방패가 되지만, install을 그대로 쓰는 설정은 의존성이 영영 갈라진다. 마이너 버전 업데이트가 며칠씩 배포에서 빠지는 시나리오가 이 지점에서 반복된다. fallback을 원치 않으면 restore-keys를 아예 두지 않거나, 값에 명시적 버전 숫자를 붙여서 원할 때만 이전 캐시를 허락하게 만든다.

이왕 매트릭스를 정리하는 김에 의존성 레이어 분리까지 함께 보자. 캐시는 빌드 단계마다 다른 대상과 키를 써야 한다. 의존성 설치와 번들 빌드는 서로 무관한 산출물이라 같은 캐시에 섞으면 어느 한쪽의 변화가 전체 적중률을 끌어내린다. 설치 단계는 lock 기준 키로, 빌드 단계는 소스 해시 기준 키로 따로 저장한다. 둘이 섞이면 캐시 저장 횟수는 늘고 쓸모는 줄어드는, 지키기 어려운 절충이 된다.

빌드 캐시는 산출물을 통째로 믿는다

번들러 증분 캐시(.next/cache, esbuild, tsc incremental)를 통째로 저장하는 설정도 자주 보인다. 키가 소스 해시와 무관하면 캐시가 "아무것도 안 바뀌었다"고 착각한다. 중간 산출물이 복원되어 그대로 아티팩트로 승격된다. 깨진 상태의 증분 캐시가 복원되면 다음 빌드에서 알 수 없는 파싱 에러로 고생하는 경우도 있다. 이런 캐시는 키보다 라이프사이클 규칙이 중요하다. 빌드가 성공한 뒤에만 저장하고, 소스 해시가 바뀌면 폐기한다. 실패한 빌드의 산출물은 저장하지 않는다.

여기서 자주 놓치는 게 있다. 증분 캐시의 키에 입력 파일 목록을 일일이 다는 건 비용 대비 이득이 별로 없다. 대신 소스 트리 해시 하나만 넣고, 폐기 조건은 잡 수준에서 다루는 편이 낫다. 예컨대 콜드 빌드 job을 주기적으로 돌려 증분 캐시를 몰아내는 방식이 관리 부담이 적다.

캐시 크기 제한 때문에 오래된 키가 밀려나는 경우도 있다. 저장소 단위 캐시 한도에 걸리면 적중률이 갑자기 0이 되고, 평소 40초 걸리던 빌드가 5분으로 되돌아간다. 그날의 실패를 캐시 탓으로 진단하려면 복원 단계 로그에서 Cache not found 여부를 먼저 확인하는 게 순서다. 이진 산출물을 저장하는 작업은 압축 효율도 따져야 한다. node_modules는 tarball로 묶어도 수백 MB를 훌쩍 넘는데, actions/cache의 저장 한도인 10GB와 러너별 복원 시간 사이에서 계산이 필요하다. 캐시가 아끼는 시간보다 업로드·복원 시간이 큰 구조라면, 캐시를 쓰기보다 그냥 깨끗하게 설치하는 편이 낫다.

캐시를 비웠을 때만 실패하는 PR

이 모든 사고는 캐시가 있을 때만 통과하는 빌드에서 온다. 검증도 그래서 캐시를 뺀 상태에서 해야 한다. 주기적으로 키에 날짜를 주입해 전체를 콜드 빌드시키는 job을 하나 넣어라. 월 1회면 충분하다. 통과 여부보다 더 유용한 건 산출물 비교다. 콜드 빌드의 아티팩트와 평소 아티팩트의 해시가 다르면 어느 쪽이 stale인지 쫓아가야 한다. 대개 어렵지 않다. 산출물 안의 패키지 버전 문자열이나 번들 헤더를 찍어서 예상 버전과 대조하면 답이 바로 나온다. 이 비교를 매주 돌리면 캐시가 나빠진 시점을 commit 단위로 좁힐 수 있다.

이 검증을 처음 도입할 때 마주하는 반응이 있다. "캐시 때문에 시간 2분을 아끼는데, 검증에 20분을 쓰는 게 말이 되느냐"는 질문이다. 여기엔 두 가지 대답이 가능하다. 아끼는 시간은 매 실행 반복되고, 깨진 검증은 한 번만 터져도 그 이상을 되돌린다. 비용을 계산한다면 캐시 도입 전후의 빌드 시간을 2주씩 재서 아낀 시간을 평균으로 환산하고, 거기에 오배포로 유실한 시간을 대조하는 편이 공정하다. 검증 job은 빌드가 아니라 진단이므로 실패해도 배포를 막지 않게 구성하는 게 상식적이다.

적중률은 기분 좋은 숫자일 뿐이다. 다운로드 시간이 반으로 줄었다면 그건 성공이다. 다만 그 숫자를 정확성의 근거로 끌어쓰면 안 된다. 지표를 고르라면 캐시 적중률보다 "캐시를 비웠을 때만 실패하는 PR이 존재하는가"를 고르는 편이 낫다. 그 질문에 아직 "없다"고 답을 못 낸다면 키에 넣는 정보를 줄이는 쪽이 안전하다. 키가 가진 정보가 적을수록 틀릴 구석도 적다. 시작은 lock 파일 하나에서다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

DevOps 전체 보기
DevOps

이미지를 얇게 만들수록 빌드가 비대해지는 역설

Next.js/Node.js 컨테이너를 multi-stage 빌드와 standalone 출력으로 최적화하다 보면 용량은 80% 줄었는데 빌드 시간은 되레 늘고, 캐시 무효화로 매 배포마다 의존성을 새로 내려받는 경우가 생긴다. 이 글은 무엇을 줄여야 하고 무엇은 절대 건드리면 안 되는지를 가르는 기준을 결정 메모 형식으로 정리하고, 최적화가 역효과를 내는 지점을 짚는다.

#Docker#Next.js#Node.js#multi-stage 빌드
DevOps

INFO, WARN, ERROR만으로는 부족하다

console.log에서 JSON 로그로 가는 건 시작일 뿐이다. 로그 레벨을 모호하게 정의하면 알람이 무의미해지고, 스키마 없이 쌓은 로그는 검색조차 불가능하다. 마스킹을 미루면 개인정보가 로그 플랫폼에 그대로 노출된다. 급증하는 로그 비용도 간과할 수 없다. 이 글은 로그 레벨 기준, 공통 필드, 마스킹, 비용 거버넌스 등 구조화 로깅 도입 전에 반드시 정해야 할 결정들을 기록한다.

#구조화로깅#로그레벨#observability#DevOps
DevOps

프론트엔드 개발자가 배포에서 벗어나는 순간

Vercel이 'Develop. Preview. Ship.'으로 압축한 것은 단순한 마케팅 문구가 아니다. 로컬 개발부터 프로덕션 배포까지 원클릭으로 연결하는 경험은 프론트엔드 개발 문화를 재정의하고 있다. 이 글에서는 Vercel이 만들어낸 배포의 투명화와 그 이면에 있는 기술적 트레이드오프, 그리고 팀이 겪는 현실적인 도전을 짚어본다.

#Vercel#프론트엔드#배포#CI/CD

이전 글

만들어 둔 인덱스를 쿼리가 외면하는 순간들, EXPLAIN으로 사인을 읽는 법

다음 글

웹훅은 믿지 말고, 시그니처로 검증하라

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독