Published on

GitHub Actions 캐시로 Node.js CI 2배 빠르게, 실패 디버깅

Authors

서버리스 CI 환경에서 Node.js 프로젝트가 느려지는 가장 흔한 이유는 간단합니다. 매 실행마다 npm ci 또는 pnpm install 이 사실상 처음부터 다시 돌기 때문입니다. GitHub Actions는 러너가 매번 새로 뜨는 구조라서, 캐시를 제대로 잡지 않으면 의존성 설치가 전체 파이프라인 시간을 지배합니다.

하지만 캐시는 양날의 검입니다. 설치가 빨라지는 대신, 캐시가 꼬이면 특정 브랜치에서만 테스트가 깨지거나, 로컬에서는 재현이 안 되는 “유령 실패”가 생깁니다. 이 글에서는 Node.js CI를 2배 이상 빠르게 만드는 캐시 구성 패턴과, 실패할 때 원인을 좁혀가는 디버깅 방법을 함께 다룹니다.

캐시의 2가지: 패키지 매니저 캐시 vs node_modules

GitHub Actions에서 Node.js 설치를 최적화할 때 캐시는 크게 두 층으로 나뉩니다.

  1. 패키지 매니저 다운로드 캐시
  • npm의 다운로드 캐시 디렉터리, Yarn의 cache, pnpm store 등을 저장
  • 장점: 안전하고 재현성이 좋음
  • 단점: node_modules 생성은 여전히 필요
  1. node_modules 자체 캐시
  • 장점: 매우 빠름
  • 단점: OS, Node 버전, 네이티브 모듈, postinstall 스크립트, lockfile 변화에 취약

실무에서는 보통 1)만으로도 체감이 큽니다. 2)는 모노레포나 대형 레거시에서 “정말 필요할 때”만 신중히 적용하는 편이 안전합니다.

가장 쉬운 정답: actions/setup-node의 내장 캐시

actions/setup-node는 패키지 매니저 캐시를 자동으로 잡아줍니다. 핵심은 lockfile을 키로 사용해 “의존성이 바뀌면 캐시도 바뀌도록” 만드는 것입니다.

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

      - run: npm ci
      - run: npm test

pnpm 예시

pnpm은 store 캐시가 특히 효과적입니다.

name: ci

on:
  push:
  pull_request:

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

      - run: pnpm install --frozen-lockfile
      - run: pnpm test

이 방식의 장점은 캐시 키를 직접 설계할 필요가 거의 없고, lockfile 기반으로 안전하게 굴러간다는 점입니다.

“2배 빨라지는” 지점: 설치 단계 시간 측정부터

캐시를 적용했는데도 빨라지지 않는다면, 먼저 시간을 “측정”해야 합니다. 설치 단계가 실제 병목인지부터 확인하세요.

- name: Install with timing
  run: |
    start=$(date +%s)
    npm ci
    end=$(date +%s)
    echo "npm ci took $((end-start)) seconds"

설치가 20초인데 전체 파이프라인이 10분이면, 캐시보다 테스트 샤딩, 빌드 아티팩트 캐시, e2e 병렬화가 먼저입니다.

캐시가 원인일 때 나타나는 전형적인 실패 패턴

캐시가 문제를 만들면 증상이 꽤 비슷하게 반복됩니다.

1) 네이티브 모듈이 깨짐

sharp, bcrypt, canvas, better-sqlite3 같은 네이티브 모듈은 OS, libc, Node ABI에 민감합니다. 캐시된 결과물이 런너 환경과 안 맞으면 이런 형태로 터집니다.

  • ELFCLASS64 관련 에러
  • Module did not self-register
  • node-gyp 빌드 산출물 불일치

대응은 두 갈래입니다.

  • 안전한 방향: node_modules 캐시는 하지 말고 패키지 매니저 캐시만 사용
  • 꼭 해야 한다면: 캐시 키에 OS, Node 버전, 아키텍처를 반드시 포함

2) lockfile은 같지만 실제 설치 결과가 달라짐

npm ci는 lockfile을 강제하지만, postinstall 스크립트나 optional dependency 조건에 따라 결과가 달라질 수 있습니다. 특히 모노레포에서 workspace 설정이 바뀌면 캐시가 “겉보기로는 유효”한데 내부 상태가 달라집니다.

3) 특정 브랜치만 깨짐, 재실행하면 통과

캐시 히트 여부에 따라 결과가 바뀌는 경우입니다. 재실행으로 통과하면 거의 캐시/레이스/플레이키 테스트 중 하나입니다.

디버깅 1단계: 캐시 히트 여부를 로그로 고정

캐시가 히트했는지, 미스였는지부터 명확히 해야 합니다. actions/cache를 직접 쓴다면 출력 변수를 확인합니다.

- name: Cache npm
  id: cache-npm
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

- name: Print cache result
  run: |
    echo "cache-hit=${{ steps.cache-npm.outputs.cache-hit }}"

cache-hittrue일 때만 실패한다면, 캐시 오염 가능성이 매우 큽니다.

디버깅 2단계: “캐시 없이 한 번”을 쉽게 만들기

실무에서는 PR에서 캐시를 끄고 비교 실행하는 기능이 있으면 진단 속도가 확 올라갑니다. 예를 들어 워크플로 입력값으로 캐시를 끄는 토글을 둡니다.

on:
  workflow_dispatch:
    inputs:
      disable_cache:
        description: "Disable cache"
        required: false
        default: "false"

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Optionally clear npm cache dir
        if: ${{ inputs.disable_cache == 'true' }}
        run: |
          rm -rf ~/.npm

      - run: npm ci
      - run: npm test

여기서 포인트는 “캐시를 비활성화한 실행”을 팀이 쉽게 재현할 수 있게 만드는 것입니다.

디버깅 3단계: 캐시 키 설계가 적절한지 점검

캐시 키는 너무 넓으면 오염되고, 너무 좁으면 히트율이 떨어집니다. Node.js CI에서 최소로 포함할 값은 보통 아래입니다.

  • runner.os
  • Node 버전(또는 node-version-file 기반)
  • lockfile 해시

예를 들어 node_modules까지 캐시한다면 키를 더 강하게 잡아야 합니다.

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

여기서 Node 버전을 고정하지 않으면, Node 18에서 만든 node_modules가 Node 20에서 복원되어 깨질 수 있습니다.

디버깅 4단계: 설치 결과를 “검증”하는 체크

캐시가 복원되었을 때 설치가 생략되거나, 설치가 부분적으로만 실행되는 문제가 생길 수 있습니다. 다음 검증을 넣으면 원인 좁히기에 도움이 됩니다.

  • 의존성 트리 확인
  • 네이티브 모듈 확인
  • lockfile과 설치 상태 일치 확인

예시:

- name: Verify install
  run: |
    node -v
    npm -v
    npm ls --depth=0

npm ls가 실패한다면, 캐시 복원으로 node_modules가 불완전한 상태일 가능성이 큽니다.

디버깅 5단계: 셸 스크립트 실패가 “숨겨지는”지 확인

캐시 문제가 아니라, 사실은 스크립트가 실패했는데 파이프라인에서 놓치는 경우도 많습니다. 특히 파이프(|)로 로그를 가공하거나, 어떤 명령 뒤에 || true 같은 패턴이 섞이면 실패가 묻힙니다.

이 부분은 CI 디버깅 전반에 중요하니, 아래 글도 함께 보면 좋습니다.

워크플로에서 설치 및 테스트 단계에 최소한 다음을 적용해 두면 “실패를 실패로” 보이게 만들 수 있습니다.

- name: Run tests safely
  shell: bash
  run: |
    set -euo pipefail
    npm test

캐시 오염을 빠르게 복구하는 실전 팁

1) 캐시 키에 “버전 범프용 salt” 추가

캐시가 꼬였을 때 가장 빠른 해결은 키를 바꿔 새 캐시를 만드는 것입니다.

env:
  CACHE_SALT: v1

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ env.CACHE_SALT }}-${{ hashFiles('package-lock.json') }}

문제가 생기면 CACHE_SALTv2로 올려 즉시 전체를 새로 구축할 수 있습니다.

2) restore-keys를 과하게 쓰지 않기

restore-keys는 부분 일치 캐시를 가져오는데, 이게 “애매하게 맞는 캐시”를 복원해 문제를 만들기도 합니다. 특히 node_modules 캐시에서 restore-keys는 위험합니다.

3) 캐시는 빠르게, 아티팩트는 분리

빌드 산출물(dist, .next, coverage)까지 한 캐시에 섞으면, 원인 파악이 어려워집니다. 의존성 캐시와 빌드 아티팩트 캐시는 분리하세요.

추천 구성: 안전한 캐시 + 병렬 전략

대부분의 팀에 추천하는 기본형은 다음입니다.

  • 패키지 매니저 캐시만 사용
  • Node 버전 고정
  • 설치는 npm ci 또는 pnpm install --frozen-lockfile
  • 테스트는 가능하면 job을 나눠 병렬
name: ci

on:
  pull_request:

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run test:unit

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint

설치가 각 job에서 반복되지만, 캐시가 히트하면 충분히 빠르고, 실패 원인도 job 단위로 분리되어 디버깅이 쉬워집니다.

캐시로 빨라졌는데도 “가끔” 실패한다면

이 단계에서 남는 문제는 보통 아래 셋 중 하나입니다.

  • 플레이키 테스트(시간, 네트워크, 순서 의존)
  • 네이티브 모듈/OS 차이
  • 캐시 키 설계 미흡 또는 restore-keys로 인한 오염

캐시 히트 여부와 실패 여부를 매트릭스로 정리해보면 의외로 빨리 결론이 납니다.

  • 캐시 히트일 때만 실패: 캐시 오염/부적절한 키
  • 캐시 미스일 때도 실패: 테스트/코드/환경 문제
  • 둘 다 랜덤: 플레이키 또는 외부 의존

마무리

GitHub Actions 캐시는 Node.js CI를 체감상 2배 이상 빠르게 만들 수 있는 가장 비용 대비 효과가 큰 최적화입니다. 다만 “빠르게”만 보지 말고, 실패했을 때 원인을 빠르게 좁힐 수 있도록

  • 캐시 히트 여부를 로그로 남기고
  • 캐시를 끄는 비교 실행 경로를 만들고
  • 키에 OS, Node 버전, lockfile을 반영하고
  • 셸 실패가 숨겨지지 않게 set -euo pipefail을 적용

이 네 가지를 기본 장치로 깔아두는 것이 장기적으로 더 큰 생산성을 줍니다.

추가로 CI에서 스크립트 실패가 묻히는 문제를 자주 겪는다면, 위에 소개한 pipefail 관련 글을 함께 참고하면 디버깅 시간이 확 줄어듭니다.