Published on

GitHub Actions 캐시가 안 먹을 때 키·경로 9분 점검

Authors

서버가 느린 게 아니라 캐시가 한 번도 제대로 복원되지 않는 경우가 생각보다 많습니다. 특히 actions/cache는 “키가 정확히 일치하고, 경로가 정확하고, 저장 시점에 파일이 존재”해야만 효과가 납니다. 이 글은 캐시가 안 먹을 때 가장 흔한 실수를 키(key)경로(path) 중심으로 9분 안에 훑어볼 수 있게 정리한 점검표입니다.

참고로 CI에서 병목을 줄이는 접근은 웹에서도 비슷합니다. 캐시 키 설계나 무효화 전략은 Next.js App Router 렌더링 폭주, RSC 캐시·revalidate로 TTFB 낮추기 같은 글과도 결이 같습니다.

0분: 먼저 로그에서 “무슨 miss” 인지 분리

actions/cache 로그는 대개 아래 중 하나로 갈립니다.

  • Cache not found for input keys: 완전 miss (키/restore-keys/스코프 문제)
  • Cache restored from key: 복원 성공 (그런데 빌드가 다시 다운로드한다면 경로/도구 설정 문제)
  • Cache saved with key: 저장 성공 (다음 런에서 복원되는지 확인)

가장 먼저 해야 할 건 “저장이 안 되는지” vs “저장은 되는데 복원이 안 되는지”를 나누는 겁니다.

1분: 캐시 액션 버전과 기본 형태 확인

최소한 아래 형태로 시작하세요.

- name: Cache dependencies
  uses: actions/cache@v4
  with:
    path: |
      ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-
  • @v4 사용 권장
  • key는 “OS + 의존성 해시” 같이 충돌이 적고 의도대로 무효화되는 형태가 좋습니다.
  • restore-keys는 “정확히 일치하지 않아도 가장 근접한 캐시를 복원”할 때 유효합니다.

2분: key가 매번 바뀌는지(=영원히 miss) 확인

캐시가 안 먹는 1순위는 키가 매번 달라지는 설계입니다.

흔한 실수 1: 커밋 SHA를 key에 넣기

key: ${{ runner.os }}-${{ github.sha }}

이러면 매 커밋마다 새 캐시가 생겨서 사실상 “캐시 없음”과 같습니다.

대신 의존성 파일 기준으로 해시를 잡습니다.

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

흔한 실수 2: hashFiles 패턴이 실제 파일을 못 찾음

예를 들어 pnpm을 쓰는데 package-lock.json을 해시하면 해시가 빈 값이거나 기대와 다르게 나옵니다.

  • npm: package-lock.json
  • yarn classic: yarn.lock
  • pnpm: pnpm-lock.yaml
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}

점검 팁: 해시가 비어 있는지 의심되면 echo로 확인

< > 문자가 본문에 노출되면 MDX에서 오해될 수 있어, 표현은 인라인 코드로만 씁니다.

- name: Debug key material
  run: |
    echo "os=${{ runner.os }}"
    echo "lockhash=${{ hashFiles('**/pnpm-lock.yaml') }}"

3분: restore-keys가 없는/잘못된 경우

restore-keys가 없으면 키가 1글자라도 다르면 무조건 miss입니다.

예를 들어 key는 락파일 해시로 엄격하게 두되, restore-keys로 “이전 버전 캐시라도 일단 복원”하도록 구성하면 체감이 좋아집니다.

- uses: actions/cache@v4
  with:
    path: |
      ~/.pnpm-store
    key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
    restore-keys: |
      ${{ runner.os }}-pnpm-store-

이렇게 하면 락파일이 조금 바뀌어도 가장 최근 캐시가 복원되어 다운로드량이 줄어듭니다.

4분: path가 실제로 존재하는지(저장 시점 포함)

캐시는 “지정한 path에 파일이 있어야” 저장됩니다. 즉, 캐시 저장은 잡 종료 시점에 일어나는데, 그때 해당 경로가 비어 있거나 존재하지 않으면 저장이 스킵될 수 있습니다.

흔한 실수 1: 설치 전에 캐시 경로를 지정했지만 실제 캐시 디렉터리가 다른 곳

도구마다 캐시 위치가 다릅니다.

  • npm: ~/.npm
  • yarn: ~/.cache/yarn 또는 프로젝트 .yarn/cache(berry)
  • pnpm: ~/.pnpm-store(설정에 따라 다름)
  • pip: ~/.cache/pip
  • gradle: ~/.gradle/caches

설치 후 실제 경로를 찍어 확인하는 게 가장 빠릅니다.

- name: Show cache dirs
  run: |
    ls -la ~/.npm || true
    ls -la ~/.pnpm-store || true
    ls -la ~/.cache/pip || true

흔한 실수 2: 상대 경로 사용 시 작업 디렉터리 착각

모노레포에서 working-directory를 바꾸면 상대 경로가 달라집니다.

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

- uses: actions/cache@v4
  with:
    path: node_modules
    key: ...

위 예시는 node_modules가 리포 루트 기준으로 해석되어, 실제 apps/web/node_modules와 불일치할 수 있습니다.

해결은 둘 중 하나입니다.

  • path를 절대/정확한 상대 경로로
  • 또는 캐시 스텝도 동일한 working-directory 컨텍스트에서 실행
- uses: actions/cache@v4
  with:
    path: apps/web/node_modules
    key: ${{ runner.os }}-web-node-${{ hashFiles('apps/web/package-lock.json') }}

5분: 캐시할 대상을 잘못 골랐는지 점검(특히 node_modules)

node_modules를 캐시하면 용량이 커지고(압축/업로드/다운로드 비용), OS/Node 버전/네이티브 모듈 차이로 깨질 수 있습니다. 보통은 패키지 매니저 캐시를 캐시하는 편이 더 안정적입니다.

npm 예시

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

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

- run: npm ci

pnpm 예시(권장 형태)

- uses: pnpm/action-setup@v4
  with:
    version: '9'

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

- uses: actions/cache@v4
  with:
    path: ~/.pnpm-store
    key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
    restore-keys: |
      ${{ runner.os }}-pnpm-

- run: pnpm install --frozen-lockfile

핵심은 “설치 결과물”이 아니라 “다운로드 캐시”를 잡는 겁니다.

6분: 키 스코프(브랜치/PR) 제약으로 인해 miss 나는 경우

GitHub Actions 캐시는 기본적으로 브랜치/PR 컨텍스트에 영향을 받습니다.

  • 다른 브랜치에서 만든 캐시가 현재 브랜치에서 안 보일 수 있음
  • 포크 PR은 보안 정책으로 캐시 접근이 제한될 수 있음

이때는 restore-keys를 넓혀도 해결이 안 됩니다(키 문제가 아니라 스코프 문제).

대응 전략

  • 기본 브랜치(main)에서 워밍업되는 키 형태로 설계
  • PR에서만 필요한 캐시라면 PR 워크플로우 내에서 일관된 키를 사용
  • 포크 PR까지 고려한다면 캐시 의존도를 낮추고, 설치 시간을 줄이는 쪽으로 병행(예: 패키지 미러, 아티팩트 전략)

7분: 여러 Job에서 동시에 같은 key를 저장하려는 경쟁 상태

매트릭스 빌드나 병렬 Job이 동일한 key로 저장을 시도하면, 한쪽만 저장되고 나머지는 “이미 존재”로 스킵될 수 있습니다. 이 자체는 정상 동작이지만, 기대와 다른 결과로 보일 수 있습니다.

해결 방법

  • 저장은 대표 Job 한 개만 하도록 분리
  • 또는 key에 변형을 주되 restore-keys로 공통 prefix를 복원
key: ${{ runner.os }}-pip-${{ matrix.python }}-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
  ${{ runner.os }}-pip-

8분: 캐시가 복원됐는데도 다시 다운로드하는 경우

이 경우는 actions/cache 문제가 아니라 도구가 캐시를 안 쓰는 설정일 가능성이 큽니다.

  • npm이 CI에서 항상 새로 받는 이유: npm ci는 캐시를 활용하지만, 네트워크/레지스트리 설정 문제로 재다운로드가 발생할 수 있음
  • pip가 캐시를 안 쓰는 이유: --no-cache-dir 옵션이 들어가 있음
  • gradle이 캐시를 안 쓰는 이유: GRADLE_USER_HOME을 다른 곳으로 바꿔놓고 캐시는 기본 경로를 잡음

pip 예시: --no-cache-dir 확인

- run: python -m pip install -r requirements.txt

위처럼 설치 옵션에서 캐시를 막는 플래그가 없는지 확인하세요.

gradle 예시: GRADLE_USER_HOME 일치시키기

- name: Cache Gradle
  uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
      ${{ runner.os }}-gradle-

9분: 최종 체크리스트(키·경로 중심)

아래 10개 중 2~3개만 걸려도 캐시는 체감상 “항상 miss”가 됩니다.

  1. actions/cache@v4 사용 중인가
  2. keygithub.sha 같은 매번 변하는 값을 넣지 않았나
  3. hashFiles 패턴이 실제 락파일을 가리키나(패턴 오타/파일명 불일치)
  4. restore-keys가 있어 “근접 캐시”를 복원할 수 있나
  5. path가 실제 캐시 디렉터리와 일치하나(도구별 캐시 위치 확인)
  6. 상대 경로를 쓴다면 working-directory와 일치하나
  7. 저장 시점(잡 종료)에 path가 존재하고 파일이 들어 있나
  8. 캐시 스코프(브랜치/PR/포크) 제약으로 접근이 막힌 상황은 아닌가
  9. 병렬 Job이 동일 key로 저장 경쟁을 하며 기대를 깨고 있진 않나
  10. 복원은 됐는데 도구 옵션이 캐시 사용을 막고 있진 않나

덤: 캐시 점검을 “5줄 로그”로 끝내는 템플릿

캐시가 안 먹을 때는 추측보다 로그가 빠릅니다. 아래를 그대로 넣고 한 번만 돌려보면 대부분 원인이 드러납니다.

- name: Debug cache inputs
  run: |
    echo "pwd=$(pwd)"
    echo "os=${{ runner.os }}"
    echo "key_lock_hash=${{ hashFiles('**/package-lock.json', '**/pnpm-lock.yaml', '**/yarn.lock') }}"
    echo "home=$HOME"
    ls -la ~/.npm || true
    ls -la ~/.pnpm-store || true

마무리

GitHub Actions 캐시는 “키가 안정적이고, 경로가 정확하고, 스코프가 맞고, 저장 시점에 파일이 존재”하면 대부분 해결됩니다. 반대로 말하면 캐시가 안 먹을 때는 거의 항상 키·경로·스코프 셋 중 하나가 어긋난 겁니다.

빌드/배포 파이프라인에서 이런 ‘작은 불일치’가 큰 비용으로 이어지는 패턴은 Git에서도 자주 보입니다. 충돌 해결 반복을 줄이는 쪽은 Git rebase 충돌을 자동 재사용 - rerere 설정법도 함께 참고하면 좋습니다.