Published on

GitHub Actions node_modules 캐시 미스 완전 정복

Authors

서로 다른 브랜치/PR에서 CI가 잘 돌던 어느 날부터 npm ci(또는 yarn install, pnpm install)가 매번 풀로 실행되며 시간이 폭증하는 경우가 있습니다. 로그를 보면 캐시는 저장되는 것 같은데 다음 실행에서 계속 미스가 나고, cache-hit도 기대처럼 나오지 않습니다.

문제는 대부분 “캐시가 없다”가 아니라 캐시 키가 매번 달라지거나, 저장한 경로와 실제로 쓰는 경로가 다르거나, node_modules 자체를 캐싱하는 전략이 패키지 매니저 동작과 충돌하는 데서 시작합니다. 이 글은 node_modules 캐시 미스의 원인을 끝까지 추적하는 체크리스트와, 실제로 안정적으로 빨라지는 권장 구성(특히 npm/pnpm/yarn별)을 제공합니다.

관련해서 캐시 전반의 원인 분류가 필요하면 다음 글도 함께 보면 좋습니다.

1) 먼저 결론: node_modules를 캐시해야 할까?

많은 팀이 “node_modules 폴더를 그대로 저장/복원”하는 방식으로 시작합니다. 하지만 이 방식은 다음 이유로 불안정하거나 비효율적일 수 있습니다.

  • OS/아키텍처 차이로 바이너리 모듈이 깨짐(예: linuxmacos 혼용)
  • Node 버전 변경 시 네이티브 애드온 재빌드 필요
  • 패키지 매니저가 권장하는 캐시 경로가 따로 있음
  • npm cinode_modules를 지우고 재설치하는 성격이라, 복원해도 다시 갈아엎을 수 있음

그래서 실무에서 가장 재현성이 좋은 전략은 보통 다음 중 하나입니다.

  • npm: npm 캐시 디렉터리를 캐시(~/.npm) + npm ci
  • pnpm: pnpm store를 캐시 + pnpm install --frozen-lockfile
  • yarn: yarn cache(또는 .yarn/cache)를 캐시 + yarn install --immutable

단, 모노레포/워크스페이스/특수한 빌드(예: Next.js 빌드 캐시까지 포함)에서는 node_modules 캐시가 체감상 더 빠를 때도 있습니다. 이 글은 “node_modules 캐시를 쓰는 경우”까지 포함해 미스 원인을 완전 정복하는 데 초점을 둡니다.

2) 캐시가 “안 먹는다”의 정확한 의미부터 분리하기

GitHub Actions 캐시는 크게 두 단계입니다.

  1. Restore: 키로 캐시를 찾아 지정한 경로에 복원
  2. Save: 잡 종료 시점에 지정한 경로를 아카이브로 저장

여기서 흔한 착각은 다음입니다.

  • “저장 로그가 있으니 다음에도 복원될 것이다” → 키가 달라지면 복원은 실패합니다.
  • cache-hitfalse면 캐시가 전혀 없다” → restore-keys로 부분 매칭 복원은 됐는데 정확히는 false일 수 있습니다.

따라서 진단은 항상 , restore-keys, path, 실제 설치 경로, 작업 디렉터리를 함께 봐야 합니다.

3) 원인 1: 캐시 키가 매 실행마다 변한다

3.1 hashFiles 대상이 흔들리는 경우

가장 안전한 키 재료는 락파일입니다.

  • npm: package-lock.json
  • pnpm: pnpm-lock.yaml
  • yarn classic: yarn.lock
  • yarn berry: yarn.lock + .yarnrc.yml + .yarn/cache 정책

문제는 다음처럼 해시 대상이 “예상보다 자주” 바뀌는 경우입니다.

  • 락파일이 자동 포맷팅/정렬로 미세 변경
  • 모노레포에서 루트 락파일이 아닌 서브패키지 락파일이 변경
  • hashFiles('**/package-lock.json')가 여러 개를 합쳐 해시 → 일부 패키지 변경만으로도 전체 키 변경

권장 패턴은 모노레포라도 의도한 락파일만 정확히 집어넣는 것입니다.

- name: Cache node_modules
  uses: actions/cache@v4
  with:
    path: |
      node_modules
    key: ${{ runner.os }}-node-${{ steps.node.outputs.node-version }}-npm-${{ hashFiles('package-lock.json') }}

여기서도 주의할 점이 있습니다. steps.node.outputs.node-version을 쓰려면 actions/setup-nodeid를 주고 output을 확인해야 합니다.

- name: Setup Node
  id: node
  uses: actions/setup-node@v4
  with:
    node-version: '20'

3.2 브랜치/커밋 SHA를 키에 섞는 실수

다음과 같은 키는 “캐시를 매번 새로 만드는” 지름길입니다.

  • ${{ github.sha }}
  • ${{ github.run_id }}
  • ${{ github.run_number }}

이 값들은 실행마다 바뀝니다. 캐시 키는 의존성 단위로만 바뀌게 설계해야 합니다.

3.3 Node 버전이 미묘하게 달라서 키가 달라지는 경우

로컬에서는 Node 20.x로 쓰는데 CI는 20.11.0 같은 고정 버전을 쓰거나, 반대로 CI는 20인데 실제로는 러너 업데이트로 20.12로 바뀌는 경우가 있습니다.

  • 바이너리 애드온이 있으면 Node 마이너 변경도 영향이 큼
  • 키에 Node 버전을 넣으면 캐시 적중률이 떨어질 수 있음

실무 팁:

  • 네이티브 모듈이 많으면 Node 버전을 키에 포함
  • 순수 JS 위주면 Node 버전을 키에 포함하지 않고 OS 정도만 포함(단, 재현성/안전성과 트레이드오프)

4) 원인 2: path가 실제로 설치되는 위치와 다르다

4.1 working-directory 때문에 다른 위치에 설치됨

모노레포에서 다음처럼 설치를 서브디렉터리에서 실행하면, node_modules도 그 디렉터리에 생깁니다.

- name: Install
  working-directory: apps/web
  run: npm ci

이때 캐시 path를 루트 node_modules로 잡으면 매번 미스처럼 보입니다. 올바른 설정은 다음처럼 맞춰야 합니다.

- name: Cache node_modules (web)
  uses: actions/cache@v4
  with:
    path: apps/web/node_modules
    key: ${{ runner.os }}-web-npm-${{ hashFiles('apps/web/package-lock.json') }}

4.2 actions/checkoutpath 옵션 사용 시

체크아웃을 서브폴더로 받으면 작업 경로가 달라집니다.

- uses: actions/checkout@v4
  with:
    path: repo

이 경우 node_modulesrepo/node_modules 아래에 생길 수 있습니다. 캐시 path도 동일하게 맞춰야 합니다.

4.3 ~ 사용과 절대경로 혼동

actions/cache는 경로를 러너 파일시스템 기준으로 해석합니다. ~/.npm 같은 홈 디렉터리는 보통 잘 동작하지만, 디버깅을 위해 실제 경로를 출력해 확인하는 습관이 좋습니다.

- name: Debug paths
  run: |
    pwd
    ls -la
    echo "HOME=$HOME"
    npm config get cache

5) 원인 3: node_modules를 복원해도 설치 단계에서 지워진다

특히 npm에서 npm ci는 설계상 node_modules를 정리하고 락파일 기준으로 재설치합니다. 즉,

  • 캐시로 node_modules를 복원
  • 그 다음 npm ci 실행
  • npm cinode_modules를 지우고 새로 만듦

이러면 “캐시가 복원됐는데도 설치가 오래 걸리는” 현상이 생깁니다. 이 경우는 캐시 미스라기보다 캐시 전략이 맞지 않는 것입니다.

해결책은 보통 두 가지입니다.

  1. node_modules 대신 npm 캐시를 캐시
  2. npm ci 대신 npm install로 바꾸되, 재현성/일관성 리스크를 감수

대부분의 CI에서는 1번이 정답입니다.

6) 권장 구성: 패키지 매니저별 “안전한 캐시” 설정

6.1 npm: setup-node의 캐시 기능 사용

actions/setup-node는 npm 캐시를 공식 지원합니다. node_modules를 직접 캐시하는 것보다 안정적입니다.

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는 모노레포에서 특히 중요합니다.
  • npm 캐시가 적중되면 다운로드/압축해제 비용이 크게 줄어듭니다.

6.2 pnpm: store 캐시 + pnpm/action-setup

pnpm은 store를 캐시하는 것이 정석입니다.

- 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

6.3 yarn: berry(2+)는 .yarn/cache 중심으로

yarn berry는 프로젝트 내부 캐시(.yarn/cache)를 활용하는 구조가 많습니다. 이 경우엔 다음을 점검하세요.

  • .yarn/cache가 Git에 커밋되는 Zero-Install인지
  • 커밋하지 않는다면 Actions 캐시로 .yarn/cache를 저장할지

예시(캐시로 처리):

- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'yarn'
    cache-dependency-path: yarn.lock

- run: yarn install --immutable
- run: yarn test

7) 그래도 node_modules를 캐시해야 한다면: 실패하지 않는 체크리스트

node_modules 캐시는 다음 조건에서 상대적으로 성공 확률이 높습니다.

  • 단일 OS만 사용(예: ubuntu-latest만)
  • Node 버전 고정
  • 네이티브 모듈이 적거나, 재빌드가 필요 없는 구성
  • npm ci가 아닌 pnpm install처럼 복원한 구조를 최대한 재사용하는 설치 방식

7.1 restore-keys로 “부분 매칭”을 허용

락파일이 바뀌면 정확 매칭은 실패합니다. 그래도 비슷한 캐시를 가져와 일부라도 재사용하고 싶다면 restore-keys를 추가합니다.

- uses: actions/cache@v4
  with:
    path: node_modules
    key: ${{ runner.os }}-node20-nm-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node20-nm-

주의: 부분 매칭은 “깨진 의존성”을 만들 수도 있습니다. 설치 단계에서 정합성을 보장하는 커맨드(npm ci 등)를 함께 써야 합니다.

7.2 캐시가 실제로 복원됐는지 검증

캐시 액션 출력만 믿지 말고, 복원 직후 디렉터리 크기/파일 수를 확인하면 원인 파악이 빨라집니다.

- name: After restore check
  run: |
    if [ -d node_modules ]; then
      echo "node_modules exists"
      du -sh node_modules || true
    else
      echo "node_modules missing"
    fi

8) 자주 놓치는 함정들(실전에서 많이 터짐)

8.1 캐시 저장 타이밍: 실패한 잡은 캐시를 저장하지 않는다

테스트/빌드가 중간에 실패하면 actions/cache의 저장 단계가 실행되지 않는 경우가 많습니다. 즉,

  • 첫 실행에서 캐시를 만들려고 했는데
  • 빌드가 실패해서 캐시가 저장되지 않음
  • 다음 실행에서도 당연히 캐시 미스

해결법:

  • 의존성 설치 직후에 캐시 저장이 보장되도록 액션을 배치(대부분은 자동이지만, 실패 흐름을 점검)
  • 또는 캐시 생성용 워크플로우(예: nightly warm-up) 분리

8.2 PR에서 권한 제한으로 캐시 접근이 꼬이는 경우

포크에서 오는 PR은 보안상 토큰 권한이 제한됩니다. 이때 캐시가 기대와 다르게 동작할 수 있습니다.

  • 내부 레포 PR에서는 잘 됨
  • 포크 PR에서는 캐시 미스가 반복

이 경우는 워크플로우 트리거(pull_request vs pull_request_target)와 보안 정책을 함께 검토해야 합니다.

8.3 모노레포에서 “루트 1개 키”로 모든 패키지를 묶어버림

hashFiles('**/package-lock.json')로 키를 만들면, 어느 한 앱만 바뀌어도 전체 캐시가 무효화됩니다.

  • 앱별로 키/캐시를 분리
  • 또는 변경된 패키지만 설치/테스트하도록 워크플로우 최적화

9) 최종 점검: 10분 안에 원인 좁히는 순서

  1. 키에 실행마다 변하는 값이 들어갔는지 확인(github.sha 등)
  2. hashFiles 대상이 정확한 락파일인지 확인(모노레포 경로 포함)
  3. path가 실제 설치 위치와 같은지 확인(working-directory, checkout path 점검)
  4. 복원 직후 du -sh로 실제로 파일이 들어왔는지 확인
  5. 설치 커맨드가 복원된 node_modules를 지워버리는지 확인(특히 npm ci)
  6. 가능하면 node_modules 대신 패키지 매니저 캐시로 전환(setup-node cache)

10) 예시: “안정적으로 빨라지는” 실무용 워크플로우

아래는 npm 기준으로, node_modules 캐시 대신 npm 캐시를 활용하는 구성입니다. 캐시 미스가 나도 설치는 정상 동작하고, 캐시가 먹으면 체감 속도가 크게 개선됩니다.

name: ci

on:
  push:
    branches: [ main ]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: package-lock.json

      - name: Install
        run: npm ci

      - name: Build
        run: npm run build

      - name: Test
        run: npm test

필요하다면 Next.js의 빌드 캐시(.next/cache)까지 추가로 캐시해 빌드 시간을 더 줄일 수 있지만, 그 경우에도 키/경로 설계를 별도로 잡아야 합니다.


node_modules 캐시 미스는 “캐시가 고장”이라기보다, 대부분 키 설계/경로 불일치/설치 커맨드 특성 중 하나로 설명됩니다. 위 체크리스트대로 한 단계씩 분해하면, 로그 몇 줄로도 원인을 빠르게 특정할 수 있고, 최종적으로는 패키지 매니저가 권장하는 캐시 전략으로 안정적인 속도 개선을 얻을 수 있습니다.