- Published on
GitHub Actions에서 node_modules 캐시가 안 먹힐 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스 CI 환경에서 npm ci 또는 pnpm install 이 매번 처음부터 도는 것만큼 허무한 일이 없습니다. GitHub Actions의 캐시는 분명 설정했는데 로그에는 계속 Cache not found for input keys 같은 문구가 뜨고, 빌드 시간은 줄지 않습니다.
이 글은 node_modules 캐싱이 왜 자주 실패하는지(혹은 성공해도 빨라지지 않는지)를 원리부터 짚고, 실제로 효과가 큰 캐시 설계로 고쳐 쓰는 방법을 정리합니다. 특히 node_modules 자체를 캐시할지, 패키지 매니저의 스토어를 캐시할지, 모노레포에서는 키를 어떻게 잡을지까지 한 번에 정리합니다.
관련해서 캐시 기본 점검(키·경로·권한) 자체가 필요하면 먼저 아래 글을 참고해도 좋습니다.
또한 캐시라는 주제는 CI뿐 아니라 도커 빌드에서도 동일한 원리로 적용됩니다.
캐시가 “안 먹히는” 것처럼 보이는 대표 증상
대부분 아래 셋 중 하나입니다.
- 진짜로 캐시 miss
- 키가 매번 바뀌거나, 경로가 틀리거나, 워크스페이스가 달라서 저장은 됐는데 복원이 안 됨
- 캐시는 hit인데 설치가 여전히 느림
node_modules캐시는 OS·Node 버전·아키텍처에 민감해서 재사용성이 낮음- postinstall 스크립트가 매번 실행되거나, 바이너리 모듈이 재빌드됨
- 캐시 용량/정책 때문에 자주 evict
- 저장은 되지만 캐시가 너무 커서 금방 축출되거나 업로드 시간이 설치 시간만큼 듦
따라서 목표는 단순히 hit를 만드는 게 아니라, 재사용성이 높고 업로드/다운로드 비용이 낮은 캐시를 만드는 것입니다.
결론부터: node_modules 보다 “패키지 매니저 캐시”가 정답인 경우가 많다
node_modules 디렉터리는 다음 이유로 캐시 효율이 떨어집니다.
- 플랫폼 의존성:
node-gyp,sharp,esbuild같은 바이너리 의존성이 OS/아키텍처/Node ABI에 따라 달라짐 - 파일 수가 너무 많음: 압축/업로드/다운로드 자체가 오래 걸림
- 락파일과의 관계가 애매: 락파일이 조금만 바뀌어도 전체를 새로 받아야 하는데, 캐시는 덩치가 커서 손해
반면 npm 캐시, pnpm store, yarn cache는 다음 장점이 있습니다.
- 다운로드한 tarball을 재사용하므로 설치가 빨라짐
- 파일 구조가 비교적 안정적이고 재사용성이 높음
- 캐시 크기가
node_modules보다 작고 효율적
즉, “node_modules 캐시 완전 정복”의 핵심은 역설적으로 node_modules 를 꼭 캐시하지 않아도 된다는 점입니다.
GitHub Actions 캐시 동작 원리(실패 지점 포함)
actions/cache 는 크게 두 단계입니다.
- restore:
key와restore-keys로 캐시를 찾아 지정한path를 복원 - save: job 성공 시점에 동일
key로path를 업로드
여기서 자주 터지는 포인트는 다음입니다.
path가 실제 존재하지 않거나, 워크스페이스 기준 경로가 아님key가 너무 자주 변함(예: 커밋 SHA 포함)- 모노레포에서 패키지별 락파일/경로를 제대로 반영하지 않음
- Node 버전, OS, 패키지 매니저 버전이 키에 포함되지 않아 “hit는 되지만 깨짐”
추천 패턴 1: setup-node 내장 캐시를 먼저 써라
actions/setup-node 는 패키지 매니저 캐시를 자동으로 잡아주는 기능이 있습니다. 먼저 이걸로 해결되는 경우가 많습니다.
npm 예시
name: ci
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
- run: npm ci
- run: npm test
핵심은 cache-dependency-path 입니다. 모노레포거나 락파일이 루트에 없으면 반드시 지정해야 합니다.
pnpm 예시
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9'
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
cache-dependency-path: 'pnpm-lock.yaml'
- run: pnpm install --frozen-lockfile
- run: pnpm test
pnpm은 node_modules 보다 pnpm store 캐시가 체감이 큽니다.
추천 패턴 2: actions/cache 로 직접 설계(모노레포 포함)
내장 캐시로 부족하면 직접 actions/cache 를 씁니다. 이때는 “키 설계”가 전부입니다.
npm 캐시 디렉터리 캐싱
npm 캐시 위치는 보통 ~/.npm 입니다.
- name: Restore npm cache
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node20-
- run: npm ci
포인트:
key에runner.os와 Node 메이저 버전을 포함해 플랫폼 불일치를 방지hashFiles('package-lock.json')로 의존성 변경에만 캐시가 갈리도록 설계restore-keys를 둬서 락파일이 바뀌어도 “가까운 캐시”를 가져오게 함
pnpm store 캐싱(가장 추천)
pnpm store 경로는 버전/설정에 따라 달라질 수 있어, 명령으로 경로를 받아 캐시하는 편이 안전합니다.
- name: Get pnpm store path
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-node20-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-node20-
- run: pnpm install --frozen-lockfile
이 방식은 node_modules 를 통째로 들고 다니지 않으면서도 설치 시간을 크게 줄입니다.
node_modules 캐시는 언제 쓰나(그리고 어떻게 안전하게 쓰나)
그래도 node_modules 캐시가 의미 있는 경우가 있습니다.
- 의존성이 크고, 네이티브 모듈이 거의 없고, 단일 OS/Node로만 CI를 돌리는 경우
npm ci대신npm install로 운영 중이며, lockfile이 안정적이고 설치가 결정적일 때- 프론트엔드 번들링이 매우 크고,
postinstall비용이 큰데 이를 줄여야 할 때
다만 다음을 지키지 않으면 “hit인데 깨지는” 상황이 잦습니다.
- 키에
runner.os, Node 버전(가능하면 메이저), 패키지 매니저 버전, lockfile 해시를 포함 - 모노레포면 패키지별 lockfile 또는 루트 lockfile을 정확히 지정
- 캐시 경로는 워크스페이스 기준으로 정확히
node_modules 캐시 예시(단일 패키지)
- uses: actions/cache@v4
with:
path: |
node_modules
key: nm-${{ runner.os }}-node20-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
nm-${{ runner.os }}-node20-npm-
- run: npm ci
주의: 이 구성에서는 npm ci 가 캐시된 node_modules 를 그대로 쓰지 않고 정리해버리는 경우가 있습니다. 즉, node_modules 캐시는 설치 커맨드와의 궁합이 중요합니다. npm ci 는 재현성을 위해 node_modules 를 지우고 다시 설치하는 성격이 강합니다.
그래서 실무적으로는 node_modules 캐시를 하더라도, 대개는 npm 캐시(~/.npm) 또는 pnpm store 캐시가 더 낫습니다.
“캐시 hit인데도 느린” 진짜 원인들
1) postinstall이 시간을 다 먹는다
캐시가 복원돼도 postinstall 이 매번 실행되면 시간이 줄지 않습니다. 예를 들어 Playwright, Cypress, Sharp, Prisma 등은 설치 시 추가 다운로드/빌드가 일어납니다.
대응:
- 가능한 경우 해당 도구의 자체 캐시 경로도 함께 캐싱
- CI에서는 불필요한 다운로드를 끄는 환경 변수를 적용
예를 들어 Playwright는 브라우저 바이너리 캐시가 핵심입니다.
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-${{ runner.os }}-
2) 모노레포에서 hashFiles 범위가 틀렸다
모노레포에서 루트에 락파일이 없거나, 패키지별 락파일을 쓰는데도 hashFiles('package-lock.json') 만 잡으면 키가 고정되거나 엉뚱하게 바뀝니다.
예시:
- pnpm 워크스페이스: 보통 루트
pnpm-lock.yaml하나가 진실의 원천 - npm workspaces: 루트
package-lock.json가 전체를 반영하는지 확인 필요
필요하면 이렇게 범위를 넓힙니다.
key: pnpm-${{ runner.os }}-node20-${{ hashFiles('pnpm-lock.yaml', 'packages/**/package.json') }}
단, package.json 까지 포함하면 의존성 변경이 아닌 버전/스크립트 변경에도 캐시가 갈릴 수 있으니 팀 정책에 맞춰 조절합니다.
3) 캐시 업로드/다운로드가 설치 시간만큼 든다
node_modules 는 파일이 너무 많아 캐시 전송 자체가 병목이 됩니다. 이 경우 “캐시를 켰더니 더 느려졌다”가 가능합니다.
대응:
node_modules대신 패키지 매니저 캐시로 전환- 캐시 경로를 최소화
- 병렬 job이 많다면 각 job이 같은 캐시를 두고 경쟁하지 않도록 키를 설계
디버깅 체크리스트(로그로 바로 확인)
- restore 단계에서
Cache not found인가,Cache restored인가 - 저장 단계에서
Cache saved successfully가 찍히는가(실패하면 다음 run도 miss) key가 커밋마다 바뀌는 요소를 포함하는가- 예:
${{ github.sha }}를 넣으면 매번 miss가 정상
- 예:
path가 실제로 존재하는가- 설치 전에 캐시 복원하려면 해당 경로가 없어도 복원은 되지만, 오타면 영원히 miss
- OS 매트릭스를 돌린다면 키에 OS를 포함했는가
- Node 버전을 바꾸는 브랜치/PR이라면 키에 Node 메이저를 포함했는가
실전 템플릿: 빠르고 안전한 Node CI 캐시 구성
아래는 “가장 흔한 성공 조합”입니다. pnpm 기준으로 store 캐시를 쓰고, 추가로 테스트 도구 캐시를 붙입니다.
name: ci
on:
pull_request:
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9'
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Get pnpm store path
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-node20-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-node20-
- run: pnpm install --frozen-lockfile
- run: pnpm test
여기서 더 최적화하고 싶다면 다음을 추가로 고려합니다.
- 빌드 산출물 캐시:
next build라면.next/cache를 캐시 - ESLint/TS 빌드 캐시: 도구별 캐시 디렉터리 확인 후 캐시
예를 들어 Next.js는 아래가 체감이 큽니다.
- uses: actions/cache@v4
with:
path: .next/cache
key: nextcache-${{ runner.os }}-node20-${{ hashFiles('pnpm-lock.yaml', 'next.config.*', 'tsconfig.json') }}
restore-keys: |
nextcache-${{ runner.os }}-node20-
정리: “캐시 hit”가 아니라 “시간 단축”을 목표로
node_modules캐시는 재사용성이 낮고 전송 비용이 커서, 많은 팀에서 기대만큼 효과가 안 납니다.- 대신
npm의~/.npm, pnpm의 store 같은 패키지 매니저 캐시를 우선 캐싱하세요. - 키는 최소한
runner.os, Node 메이저, lockfile 해시를 포함하고, 모노레포면cache-dependency-path또는hashFiles범위를 정확히 잡으세요. - hit인데 느리면 postinstall과 도구별 바이너리 캐시(예: Playwright)를 의심하세요.
이 원칙대로 구성하면 “캐시가 안 먹힌다”는 상태에서 벗어나, PR당 수분 단위로 CI 시간을 줄이는 쪽으로 안정적으로 수렴합니다.