Published on

GitHub Actions 캐시 충돌 시 빌드 완전 초기화 전략

Authors

서로 다른 브랜치/런/환경에서 재사용되는 GitHub Actions 캐시가 한 번 오염되면, 테스트는 통과하는데 배포 빌드만 깨지거나, 로컬과 CI 결과가 달라지는 등 디버깅이 매우 어려운 상태로 빠집니다. 특히 actions/cache는 “빨라지는 대신, 잘못 설계하면 오래된 산출물이 계속 살아남는” 구조라서, 캐시 충돌(키 설계 충돌)과 캐시 오염(잘못된 디렉터리 캐싱)이 겹치면 빌드가 쉽게 꼬입니다.

이 글은 다음을 목표로 합니다.

  • 캐시 충돌/오염의 전형적인 증상과 원인 파악
  • 캐시 키 설계로 충돌을 예방하는 방법
  • 이미 꼬인 상태에서 완전 초기화(클린 런) 로 복구하는 방법
  • 언어/빌드 도구별( Node, Python, Gradle 등) 안전한 캐시 범위 가이드

> CI 문제는 종종 인증/권한 이슈와도 함께 나타납니다. AWS 배포 파이프라인에서 OIDC가 섞여 있다면 캐시 문제와 별개로 토큰 오류가 동반될 수 있으니, 필요하면 GitHub Actions OIDC로 AWS 배포 403 해결 가이드도 함께 확인해보세요.

캐시가 꼬였다는 신호: 증상 체크리스트

다음 증상 중 2개 이상이 동시에 나타나면 캐시 충돌/오염을 강하게 의심할 수 있습니다.

  • 같은 커밋인데 재실행(re-run)하면 성공/실패가 번갈아 발생
  • 특정 브랜치에서만 실패(특히 main만 실패하거나 PR만 실패)
  • 의존성 업데이트 후에도 CI가 옛날 버전으로 빌드하는 흔적
  • node-gyp, pip, gradle에서 바이너리/락파일 불일치 오류가 간헐적으로 발생
  • NoSuchMethodError, ClassNotFound, ModuleNotFound 같은 런타임 오류가 CI에서만 발생
  • 빌드 로그에 Cache restored from key:가 찍히는데, 기대한 키가 아닌 restore-keys로 복원됨

핵심은 “캐시가 맞는 것을 가져왔는지”와 “캐시 대상이 안전한지”입니다.

충돌 vs 오염: 원인 분류가 먼저다

1) 캐시 충돌(Cache key collision)

서로 다른 컨텍스트(브랜치, OS, 아키텍처, Node/Python 버전, 빌드 옵션)가 같은 캐시 키를 공유해서 생깁니다.

  • key: deps-${{ hashFiles('**/package-lock.json') }} 처럼 OS/런타임 버전이 빠짐
  • 모노레포에서 여러 패키지가 같은 키를 씀
  • restore-keys가 너무 넓어서(접두사 매칭) 엉뚱한 캐시를 주워옴

2) 캐시 오염(Cache poisoning)

캐시 대상 디렉터리에 빌드 산출물, 임시 파일, 환경 의존 바이너리가 섞여 저장되는 경우입니다.

  • node_modules 자체를 캐싱(특히 네이티브 모듈 포함)
  • Python venv(.venv)를 통째로 캐싱
  • Gradle build/나 Maven target/ 같은 산출물 폴더를 캐싱

일반적으로는 의존성 다운로드 캐시(예: ~/.npm, ~/.cache/pip, ~/.gradle/caches)는 안전한 편이고, 설치 결과물(예: node_modules, venv)은 위험합니다.

1단계: “완전 초기화”를 위한 클린 런 스위치 만들기

캐시가 의심될 때마다 UI에서 캐시를 지우는 건 느리고 번거롭습니다. 가장 현실적인 전략은 워크플로에 클린 런 입력값을 만들어서, 필요할 때 캐시를 강제로 무효화하는 것입니다.

아래 예시는 workflow_dispatchclean을 받으면 캐시 키에 clean-<run_id>를 섞어 항상 미스(cache miss) 가 나도록 합니다.

name: CI

on:
  push:
  pull_request:
  workflow_dispatch:
    inputs:
      clean:
        description: "Ignore caches and run clean build"
        required: false
        default: "false"

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Decide cache bust token
        id: bust
        run: |
          if [ "${{ github.event.inputs.clean }}" = "true" ]; then
            echo "token=clean-${{ github.run_id }}" >> $GITHUB_OUTPUT
          else
            echo "token=normal" >> $GITHUB_OUTPUT
          fi

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

      - name: Cache npm download cache
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: |
            npm-${{ runner.os }}-node20-
            ${{ steps.bust.outputs.token }}-
            ${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            npm-${{ runner.os }}-node20-

      - name: Install
        run: npm ci

      - name: Test
        run: npm test

이 방식의 장점:

  • 캐시가 꼬였을 때 버튼 한 번으로 완전 초기화 가능
  • 평소에는 정상적으로 캐시 사용
  • “캐시를 쓰지 않았을 때 성공한다”는 사실 자체가 원인 규명에 결정적 힌트

2단계: 캐시 키 설계 원칙(충돌 방지)

캐시 키는 “정확히 같을 때만 재사용”되도록 설계해야 합니다. 다음 요소를 최소로 포함하세요.

필수 포함 요소

  • OS: ${{ runner.os }} (Linux/Windows/macOS)
  • 런타임 버전: Node/Python/Java/Go 등
  • 락파일 해시: hashFiles('**/package-lock.json'), poetry.lock, requirements.txt, gradle.lockfile
  • 모노레포라면 패키지 경로/워크스페이스 구분자

restore-keys는 “정말 필요할 때만”

restore-keys는 접두사 매칭이라, 의도치 않은 캐시를 가져올 수 있습니다. 성능을 조금 포기하더라도 정확한 키 매칭만 사용하는 편이 안정적일 때가 많습니다.

  • 빌드가 자주 꼬이는 프로젝트라면 restore-keys를 제거하거나 범위를 좁히세요.

3단계: 무엇을 캐시할 것인가(오염 방지)

아래는 실전에서 안전/위험으로 분류한 가이드입니다.

Node.js

  • 안전(권장): ~/.npm, ~/.cache/yarn, ~/.pnpm-store
  • 주의: node_modules (네이티브 모듈, postinstall 스크립트, OS 차이)

가능하면 actions/setup-node의 내장 캐시(cache: npm|yarn|pnpm)를 우선 사용하고, 추가 캐시는 “다운로드 캐시”까지만.

Python

  • 안전(권장): ~/.cache/pip, Poetry의 다운로드 캐시
  • 주의: .venv, site-packages 전체

pip는 다운로드 캐시만으로도 큰 이득을 봅니다. venv 캐싱은 재현성/오염 리스크가 큽니다.

Java/Gradle

  • 안전(권장): ~/.gradle/caches, ~/.m2/repository
  • 주의: build/, target/

Gradle은 캐시가 잘못되면 증상이 난해해지는데, 이때는 “캐시 초기화 + daemon 끄기”가 빠른 진단 루트가 됩니다.

4단계: 이미 꼬였을 때의 “완전 초기화” 체크리스트

클린 런 스위치로도 해결이 안 되거나, 원인을 더 명확히 하려면 아래를 순서대로 적용합니다.

1) 캐시를 아예 저장하지 않기(1회성)

문제 재현/격리를 위해, 특정 런에서는 캐시 저장을 끄는 게 좋습니다.

- name: Cache pip (disabled when clean)
  if: ${{ github.event.inputs.clean != 'true' }}
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-${{ runner.os }}-py312-${{ hashFiles('**/requirements.txt') }}

2) 도구별 클린 명령 강제 실행

  • Node: npm ci(= clean install), 필요 시 rm -rf node_modules package-lock.json은 최후의 수단
  • Python: pip cache purge(진단용), venv 재생성
  • Gradle: ./gradlew clean --no-daemon + 필요 시 rm -rf ~/.gradle/caches(진단용)

3) 동시성(Concurrency)로 인한 레이스 제거

같은 브랜치에서 여러 워크플로가 동시에 돌며 캐시를 서로 덮어쓰면 상태가 불안정해질 수 있습니다.

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

4) 캐시 대상 경로가 워크스페이스를 침범하는지 확인

캐시 경로에 ${{ github.workspace }} 하위(예: ./.cache, ./dist)를 넣는 순간, 빌드 산출물/임시 파일이 섞이기 쉽습니다. 가능하면 홈 디렉터리(~) 기반의 다운로드 캐시만 사용하세요.

5단계: “캐시가 원인”임을 로그로 증명하는 방법

캐시 문제는 감으로 때려맞히면 오래 걸립니다. 아래처럼 키/복원 여부를 로그로 남기세요.

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

- name: Print cache result
  run: |
    echo "cache-hit=${{ steps.cache.outputs.cache-hit }}"
  • cache-hit=true인데 빌드가 실패 → 캐시 오염/충돌 가능성 증가
  • cache-hit=false에서만 성공 → 캐시가 원인일 확률 매우 높음

실전 예시: 모노레포에서 충돌을 막는 키 구성

모노레포에서 packages/apipackages/web가 다른 의존성을 갖는데, 락파일이 루트 하나로만 관리되면 키가 같아져 충돌이 날 수 있습니다. 이때는 워크스페이스 식별자를 키에 포함하세요.

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

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

또는 락파일을 패키지별로 분리할 수 있다면(pnpm-lock.yaml + workspace) 그게 가장 깔끔합니다.

운영 팁: “캐시 초기화”를 정기 작업으로 만들기

캐시는 시간이 지날수록 누적되고, 특정 시점의 깨진 아카이브가 계속 재사용될 수 있습니다. 다음 운영 팁을 추천합니다.

  • 월 1회 또는 큰 마이그레이션(런타임 업그레이드) 시점에 clean=true로 클린 빌드 수행
  • 런타임 버전 업(Node 18→20, Python 3.11→3.12 등) 시 키에 버전 반영 필수
  • 실패가 간헐적이면, 원인 추적 전 동시성 제한(concurrency) 부터 걸기

장애 분석 관점에서는 “원인 후보를 빠르게 줄이는” 접근이 중요합니다. 이런 사고방식은 서비스 프로세스가 재시작 루프에 빠질 때 체크리스트로 좁혀가는 방식과도 유사합니다. 필요하면 systemd 서비스가 계속 재시작될 때 진단 체크리스트처럼, 단계별로 변수를 제거하는 접근을 참고해도 좋습니다.

결론: 캐시를 ‘성능 기능’이 아니라 ‘상태 관리’로 다뤄라

GitHub Actions 캐시는 빌드를 빠르게 하지만, 잘못 쓰면 CI를 “상태를 가진 시스템”으로 만들어 재현성을 망칩니다. 따라서 전략은 명확합니다.

  • 다운로드 캐시만 캐싱하고(오염 방지)
  • 키에 OS/런타임/락파일/워크스페이스를 포함해(충돌 방지)
  • clean 스위치로 언제든 완전 초기화 가능한 경로를 마련하고
  • 필요하면 restore-keys를 과감히 줄여 안정성을 우선한다

이 4가지만 지켜도, “캐시 때문에 가끔 빌드가 꼬이는” 유형의 문제는 대부분 빠르게 종결됩니다.