Published on

GitHub Actions에서 node_modules 캐시가 안 먹힐 때

Authors

서버리스 CI 환경에서 npm ci 또는 pnpm install 이 매번 처음부터 도는 것만큼 허무한 일이 없습니다. GitHub Actions의 캐시는 분명 설정했는데 로그에는 계속 Cache not found for input keys 같은 문구가 뜨고, 빌드 시간은 줄지 않습니다.

이 글은 node_modules 캐싱이 왜 자주 실패하는지(혹은 성공해도 빨라지지 않는지)를 원리부터 짚고, 실제로 효과가 큰 캐시 설계로 고쳐 쓰는 방법을 정리합니다. 특히 node_modules 자체를 캐시할지, 패키지 매니저의 스토어를 캐시할지, 모노레포에서는 키를 어떻게 잡을지까지 한 번에 정리합니다.

관련해서 캐시 기본 점검(키·경로·권한) 자체가 필요하면 먼저 아래 글을 참고해도 좋습니다.

또한 캐시라는 주제는 CI뿐 아니라 도커 빌드에서도 동일한 원리로 적용됩니다.

캐시가 “안 먹히는” 것처럼 보이는 대표 증상

대부분 아래 셋 중 하나입니다.

  1. 진짜로 캐시 miss
    • 키가 매번 바뀌거나, 경로가 틀리거나, 워크스페이스가 달라서 저장은 됐는데 복원이 안 됨
  2. 캐시는 hit인데 설치가 여전히 느림
    • node_modules 캐시는 OS·Node 버전·아키텍처에 민감해서 재사용성이 낮음
    • postinstall 스크립트가 매번 실행되거나, 바이너리 모듈이 재빌드됨
  3. 캐시 용량/정책 때문에 자주 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: keyrestore-keys 로 캐시를 찾아 지정한 path 를 복원
  • save: job 성공 시점에 동일 keypath 를 업로드

여기서 자주 터지는 포인트는 다음입니다.

  • 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

포인트:

  • keyrunner.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이 같은 캐시를 두고 경쟁하지 않도록 키를 설계

디버깅 체크리스트(로그로 바로 확인)

  1. restore 단계에서 Cache not found 인가, Cache restored 인가
  2. 저장 단계에서 Cache saved successfully 가 찍히는가(실패하면 다음 run도 miss)
  3. key 가 커밋마다 바뀌는 요소를 포함하는가
    • 예: ${{ github.sha }} 를 넣으면 매번 miss가 정상
  4. path 가 실제로 존재하는가
    • 설치 전에 캐시 복원하려면 해당 경로가 없어도 복원은 되지만, 오타면 영원히 miss
  5. OS 매트릭스를 돌린다면 키에 OS를 포함했는가
  6. 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 시간을 줄이는 쪽으로 안정적으로 수렴합니다.