- Published on
GitHub Actions 동시 실행 경합으로 캐시 깨질 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 PR, 같은 브랜치에 연속 푸시, 혹은 매트릭스 잡이 동시에 돌 때 갑자기 빌드가 불안정해지고 캐시 관련 에러가 튀어나오는 경험이 있습니다. 특히 Node.js 의존성 캐시, Gradle, Maven, Python pip, Docker 레이어 캐시처럼 “한 번만 잘 저장되면 이후가 빨라지는” 캐시는 동시 실행 경합에 취약합니다.
문제의 핵심은 간단합니다. 여러 잡이 같은 캐시 키로 동시에 저장(save)하려고 시도하면, 한쪽은 저장에 실패하거나(이미 생성됨), 더 나쁜 경우 부분적으로 생성된 캐시를 다른 실행이 복원(restore)해 빌드가 깨지는 형태로 나타납니다. GitHub Actions 캐시는 기본적으로 원자적 트랜잭션처럼 동작하지 않으며, “동일 키에 대해 저장은 1회만 성공”하는 제약과 네트워크/업로드 타이밍이 맞물리면 체감상 “캐시가 깨졌다”로 보이게 됩니다.
이 글에서는 동시 실행 경합이 만들어내는 증상, 로그에서의 단서, 그리고 경합을 구조적으로 제거하는 방법을 코드와 함께 정리합니다.
어떤 증상이면 캐시 경합을 의심해야 하나
다음과 같은 패턴이 반복되면 캐시 경합 가능성이 큽니다.
- 같은 커밋인데 어떤 실행은 성공, 어떤 실행은 실패(비결정적)
- 실패한 실행에서만 의존성 설치/빌드 단계가 이상하게 느림 혹은 파일 누락
- 캐시 액션 로그에 아래와 유사한 메시지가 섞여 나옴
Cache already exists. Skipping save.Failed to save: ...또는 업로드 타임아웃- 복원은 됐는데 이후 단계에서 lockfile/메타데이터 불일치
특히 Node 생태계에서는 node_modules 자체를 캐시할 때 이런 문제가 더 잘 드러납니다. 여러 OS/아키텍처, Node 버전이 섞인 매트릭스에서 같은 키를 공유하면 바이너리 애드온이 꼬이거나(예: node-gyp), 플랫폼별 파일이 섞여서 깨질 수 있습니다.
캐시 키 설계 자체가 잘못된 경우도 많습니다. 캐시 키/복원 전략이 안 맞는 케이스는 아래 글에서 먼저 점검해보면 좋습니다.
왜 동시 실행이 “캐시 깨짐”으로 보이나
GitHub Actions의 캐시는 대략 이런 흐름으로 동작합니다.
- 잡 시작 시
restore가 캐시 키를 기준으로 아카이브를 받아 푼다 - 잡 종료 시(또는 액션이 실행될 때)
save가 같은 키로 캐시를 업로드한다 - 동일 키는 한 번만 저장되는 성격이 강하다(나머지는 스킵/실패)
동시 실행 경합이 생기면 다음 시나리오가 가능합니다.
- 실행 A와 실행 B가 거의 동시에 시작
- 둘 다 캐시 미스 상태로 진행(키가 아직 없음)
- 둘 다 의존성을 설치하고, 거의 동시에 캐시 업로드를 시도
- A가 먼저 저장 성공
- B는
Cache already exists로 저장 스킵
여기까지만 보면 “괜찮아 보이는데?” 싶지만, 현실에서는 다음 변수가 끼어듭니다.
- 업로드가 완전히 끝나기 전에 다른 실행이 restore를 시도하는 타이밍 문제
- 캐시 대상 경로에 동시 쓰기가 일어나며(예: 같은 워크스페이스/툴 캐시), 아카이브에 들어갈 파일 집합이 실행마다 달라짐
- 키가 너무 넓어서(예:
main브랜치 고정 키) 서로 다른 커밋/의존성 상태가 같은 캐시를 공유
결과적으로 “어떤 실행은 이상한 캐시를 복원해서 실패”처럼 보이는 현상이 생깁니다.
재현하기 쉬운 나쁜 예시
아래는 의도치 않게 경합을 만드는 전형적인 패턴입니다.
- 캐시 키가 브랜치 고정(커밋/lockfile 변화 반영 안 함)
- 매트릭스 잡이 같은 키를 공유
node_modules같은 비결정적 결과물을 그대로 캐시
name: ci
on:
push:
branches: [ main ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- uses: actions/cache@v4
with:
path: |
node_modules
key: node-modules-main
- run: npm ci
- run: npm test
이 설정은 다음 문제가 동시에 존재합니다.
node-modules-main키는 너무 고정적이라 커밋이 바뀌어도 같은 캐시를 씀- Node 20과 Node 22가 같은 캐시 키를 공유(플랫폼/런타임 차이를 무시)
npm ci는 lockfile 기준으로 설치하지만,node_modules캐시는 lockfile과 불일치하면 오히려 독이 될 수 있음
해결 전략 1: 캐시 키를 “충분히 좁게” 만들기
경합을 줄이는 가장 쉬운 방법은 키 충돌 자체를 줄이는 것입니다.
원칙:
- OS, 아키텍처, 런타임 버전(Node/Java/Python), 패키지 매니저 버전, lockfile 해시를 키에 포함
- 브랜치 고정 키를 피하고, lockfile 변경을 키에 반영
Node 예시(권장 패턴 중 하나):
- uses: actions/cache@v4
with:
path: |
~/.npm
key: npm-${{ runner.os }}-node-${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node-${{ matrix.node }}-
여기서 포인트는 node_modules가 아니라 패키지 매니저 캐시 디렉터리(예: ~/.npm)를 캐시한다는 점입니다. 이 방식은 경합이 발생해도 “설치 다운로드가 빨라지는” 정도의 이득을 주면서, 플랫폼별 바이너리/트리 구조 꼬임을 줄입니다.
추가로 Node 런타임 혼용/모듈 시스템 이슈가 빌드 불안정성을 키우기도 합니다. 캐시 문제처럼 보이지만 실제론 런타임/모듈 로딩 문제인 경우도 있어 아래 글이 도움이 됩니다.
해결 전략 2: 동시 실행 자체를 제어하기(concurrency)
키를 잘 만들어도, 같은 PR에 연속 푸시가 들어오면 동일한 lockfile 해시를 가진 실행들이 동시에 돌 수 있습니다. 이때는 워크플로 수준에서 동시 실행을 제한하는 게 가장 확실합니다.
예를 들어 PR 단위로 하나만 실행되게 만들면 “같은 PR에서 같은 키 저장을 두 번 시도”하는 상황이 크게 줄어듭니다.
name: ci
on:
pull_request:
concurrency:
group: ci-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "run tests"
group는 “서로 배타적으로 실행될 단위”를 정의합니다.cancel-in-progress: true는 새 커밋이 오면 이전 실행을 취소하여 캐시 저장 경쟁을 더 줄입니다.
주의할 점:
- 브랜치 푸시 워크플로라면
github.ref기반으로 그룹을 잡는 게 일반적입니다. - 릴리즈/배포처럼 취소되면 안 되는 워크플로는
cancel-in-progress를 신중히 사용해야 합니다.
해결 전략 3: save를 단일 잡으로 몰아주기(읽기/쓰기 분리)
캐시 경합의 본질은 “여러 잡이 동시에 저장”하는 것입니다. 그러면 저장은 딱 한 번만 하도록 설계를 바꾸면 됩니다.
패턴:
- 여러 테스트 잡은 캐시를
restore만 수행 - 빌드가 성공한 뒤 마지막에 한 잡이 대표로
save
GitHub Actions에서 캐시 액션은 보통 restore/save가 한 액션 안에서 일어나지만, 구성에 따라 저장 조건을 걸어 사실상 “대표 잡만 저장”하게 만들 수 있습니다.
예시(대표 잡만 저장하도록 조건 추가):
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- name: Restore npm cache
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- run: npm ci
- run: npm test
- name: Save cache only on shard 1
if: ${{ matrix.shard == 1 }}
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
설명:
- 모든 샤드는 같은 키로 복원해서 다운로드 비용을 줄입니다.
- 저장은 샤드 1만 시도하므로 동시 저장 경합이 사라집니다.
다만 이 예시는 actions/cache@v4를 두 번 호출하므로, 실제로는 “첫 호출에서 이미 저장까지 해버리는지”를 로그로 확인해야 합니다. 구성/버전에 따라 restore/save 동작이 달라질 수 있어, 필요하면 액션의 입력 옵션과 로그를 기준으로 분리 전략을 조정하세요.
해결 전략 4: 캐시 대상 경로를 안전하게 바꾸기
캐시가 깨지는 것처럼 보일 때, 실제 원인은 “캐시로 묶은 경로가 원래 캐시하면 안 되는 것”인 경우가 많습니다.
안전한 편(대체로 권장):
- npm:
~/.npm - yarn:
~/.cache/yarn - pnpm: pnpm store 경로
- pip:
~/.cache/pip - gradle:
~/.gradle/caches
주의(상황에 따라 위험):
node_modules- 빌드 산출물 디렉터리 전체(특히 OS/아키텍처 영향을 받는 바이너리 포함)
- 워크스페이스 전체를 통째로 캐시
또한 모노레포에서는 패키지별 lockfile이 여러 개일 수 있습니다. hashFiles('**/package-lock.json')처럼 광범위 해시를 쓰면, 하나만 바뀌어도 전체 키가 바뀌어 캐시 효율이 떨어질 수 있습니다. 반대로 너무 좁게 잡으면 다른 패키지 변경이 반영되지 않아 불일치가 발생합니다. 프로젝트 구조에 맞춰 범위를 조정하세요.
해결 전략 5: 실패한 캐시를 빠르게 무력화(버전 접두사)
이미 깨진 캐시가 존재하면, 아무리 설정을 고쳐도 계속 복원되어 고통이 이어질 수 있습니다. 이때는 키에 “버전 접두사”를 넣어 캐시를 강제로 새로 만들면 됩니다.
key: v2-npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
v1에서 v2로 올리는 것만으로 기존 캐시를 무시하고 새 캐시를 만들 수 있어, 장애 대응 시 매우 유용합니다.
디버깅 체크리스트(로그로 바로 확인)
- 같은 키로 동시에 저장을 시도하는 잡이 있는가
- 워크플로 실행 탭에서 동일 커밋/PR의 병렬 잡을 확인
- 캐시 키에 OS/런타임/lockfile 해시가 포함돼 있는가
restore-keys가 너무 넓어 “엉뚱한 캐시”를 주워오지 않는가- 캐시 대상 경로가 결정적(deterministic)인가
- 플랫폼별 파일이 섞일 가능성이 있는지 점검
- 연속 푸시로 같은 그룹의 실행이 중복되는가
concurrency로 취소/직렬화를 적용할지 결정
마무리: 경합은 설정으로 없애는 게 정답
GitHub Actions 캐시 경합은 “운이 나쁘면 생기는” 문제가 아니라, 동시에 저장할 수 있는 구조가 남아있는 한 언젠가 다시 터집니다. 해결의 우선순위는 보통 다음이 효율적입니다.
- 1순위: 키를 좁히고(환경/lockfile 반영) 캐시 대상을 안전한 경로로 변경
- 2순위:
concurrency로 같은 PR/브랜치의 중복 실행을 취소 또는 직렬화 - 3순위: 저장을 대표 잡 하나로 몰아 write 경합 제거
- 4순위: 깨진 캐시는 키 버전 접두사로 즉시 무력화
캐시가 “안 먹는다” 문제와 “깨진다/불안정하다” 문제는 원인이 겹치는 구간이 많습니다. 키/복원 전략을 다시 점검할 때는 아래 글도 함께 보면 빠르게 정리됩니다.