CI에서 의존성 캐시를 안전하게 사용하는 방법
CI에서 의존성 캐시를 안전하게 사용하는 방법
CI cache는 dependency와 중간 결과를 다시 내려받지 않게 하는 성능 보조물이다. 운영체제·CPU·runtime·package manager·lock file·cache schema가 달라지면 key도 달라져야 한다. 부분 일치 cache는 현재 lock file로 다시 검증하고 cache miss에서도 build가 성공해야 한다. Credential과 배포 artifact는 cache에 넣지 않으며, fork PR이 base branch cache를 읽을 수 있고 오염된 cache가 이후 명령 실행으로 이어질 수 있다는 신뢰 경계를 고려한다.
목차
- #Cache는 Dependency의 원본이 아니다
- #무엇을 Cache할지 먼저 구분하기
- #Cache Key를 입력의 함수로 만들기
- #Lock File Hash만으로 부족한 경우
- #restore-keys는 Hit가 아니라 후보 복원이다
- #node_modules보다 Download Cache가 안전한 이유
- #Cache Hit 뒤에도 설치 명령을 실행하기
- #Monorepo와 Matrix Build의 범위 나누기
- #Fork PR과 Cache의 읽기 범위
- #Cache Poisoning을 막는 신뢰 경계
- #Cache와 Artifact를 구분하기
- #Cache Miss와 손상을 정상 경로로 만들기
- #용량 제한과 Eviction 운영하기
- #CI Cache 효과 측정하기
- #실패 조건을 포함한 테스트
- #구현 체크리스트
- #마무리
- #관련 노트
- #참고 자료
Cache는 Dependency의 원본이 아니다
CI runner가 매번 깨끗한 환경에서 시작하면 package registry에서 같은 archive를 반복해서 내려받는다. Cache는 이 비용을 줄인다.
flowchart LR
A[Checkout] --> B[Restore cache]
B --> C[Install from lock file]
C --> D[Test and build]
D --> E{Exact key existed?}
E -->|No| F[Save cache]
E -->|Yes| G[Finish]여기서 dependency의 원본은 lock file과 package registry다. Cache는 없거나 오래됐거나 손상될 수 있는 복제본이다.
lock file 어떤 dependency를 써야 하는가
registry 검증된 package를 어디서 받을 것인가
cache 이미 받은 byte를 재사용할 수 있는가
Cache가 있어야만 성공하는 build는 숨은 dependency를 가진다. 새 runner, eviction, cache incident 때 바로 실패한다. Clean install을 주기적으로 실행해 cache가 correctness 조건이 아닌지 확인해야 한다.
Cache가 있든 없든 같은 lock file에서 같은 dependency graph를 설치하고 test해야 한다.
무엇을 Cache할지 먼저 구분하기
Package manager에는 내려받은 archive를 보관하는 경로와 현재 프로젝트에 펼친 dependency tree가 따로 있다.
| 종류 | Node 예 | 역할 | 기본 선택 |
|---|---|---|---|
| Download cache | ~/.npm |
package archive와 metadata | 우선 고려 |
| Installed tree | node_modules |
OS/runtime에 펼친 결과 | 신중히 사용 |
| Build cache | compiler/framework cache | 변환 중간 결과 | 별도 key |
| Credential | .npmrc, token file |
registry 인증 | 절대 제외 |
| Release artifact | binary, bundle | 배포할 결과 | artifact 저장소 |
Download cache를 복원한 뒤 npm ci를 실행하면 lock file과 integrity를 다시 확인하면서 필요한 package만 꺼낸다. node_modules 전체를 복원하고 설치를 생략하면 platform, lifecycle script, optional dependency 차이를 놓치기 쉽다.
Framework cache도 dependency cache와 섞지 않는다.
- name: Restore package downloads
uses: actions/cache@v5
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Restore compiler cache
uses: actions/cache@v5
with:
path: .cache/compiler
key: compiler-v3-${{ runner.os }}-${{ github.sha }}
restore-keys: |
compiler-v3-${{ runner.os }}-
Action major는 예시다. 발행과 실제 적용 시 지원 runner를 확인하고 검토한 commit SHA로 고정한다.
Cache Key를 입력의 함수로 만들기
좋은 key는 cache 결과를 결정하는 입력을 포함한다.
cache schema
+ operating system
+ CPU architecture
+ runtime version
+ package manager version
+ lock file hash
+ relevant build option
가상의 Node matrix build라면 다음처럼 구성할 수 있다.
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
node: [20, 22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- name: Resolve npm major
id: npm
shell: bash
run: echo "major=$(npm --version | cut -d. -f1)" >> "$GITHUB_OUTPUT"
- name: Restore npm download cache
uses: actions/cache@v5
with:
path: ~/.npm
key: npm-v2-${{ runner.os }}-${{ runner.arch }}-node${{ matrix.node }}-npm${{ steps.npm.outputs.major }}-${{ hashFiles('package-lock.json') }}
npm-v2는 cache schema epoch다. Directory 구성이나 install 정책이 바뀌었을 때 v3으로 올려 이전 cache를 명시적으로 버린다.
Commit SHA를 dependency key에 매번 넣으면 거의 모든 실행이 miss가 된다. Dependency 입력의 변경을 나타내는 lock hash를 사용한다. 반대로 key가 단순히 npm-linux라면 서로 다른 graph가 같은 cache를 가리킨다.
Lock File Hash만으로 부족한 경우
Lock file이 같아도 설치 결과에 영향을 주는 환경이 있다.
- OS와 CPU architecture
- Node, Python, Java 같은 runtime version
- package manager major version
- native compiler와 libc
- production/dev dependency option
- patch file과 workspace configuration
Native addon이 있는 node_modules를 macOS와 Linux가 공유하면 load에 실패할 수 있다. Download cache는 platform 간 공유 가능성이 더 높지만 package manager의 저장 format을 확인해야 한다.
Python wheel도 interpreter ABI와 platform에 따라 다르다.
pip-v1-Linux-X64-python3.13-<requirements-lock-hash>
Hash 대상이 존재하지 않으면 hashFiles 결과가 빈 문자열일 수 있다. 모든 branch가 같은 넓은 key를 쓰게 되므로 lock file 존재를 먼저 검증한다.
- name: Verify lock file
shell: bash
run: test -s package-lock.json
restore-keys는 Hit가 아니라 후보 복원이다
정확한 key가 없을 때 restore-keys는 prefix에 맞는 최근 cache를 찾는다.
key: npm-v2-Linux-X64-node22-lock-a81f
restore-keys: |
npm-v2-Linux-X64-node22-
npm-v2-Linux-X64-
Lock hash가 다른 cache를 복원할 수 있다는 뜻이다.
flowchart TD
A[Exact key lookup]
B[Exact cache]
C[Prefix candidate]
D[Empty cache]
E[Install verifies current lock]
A -->|hit| B --> E
A -->|miss| C --> E
A -->|no candidate| D --> E부분 일치는 설치를 건너뛸 근거가 아니다. Download cache라면 현재 lock에 없는 archive가 남아 있어도 package manager가 필요한 것만 사용한다. Installed tree나 실행 가능한 build output이라면 stale 파일이 섞일 위험이 훨씬 크다.
복원 범위를 너무 넓히면 다른 runtime과 OS cache까지 가져와 오히려 느리거나 위험하다. 가장 구체적인 prefix부터 두고 각 fallback이 호환되는 이유를 설명할 수 있어야 한다.
node_modules보다 Download Cache가 안전한 이유
다음 workflow는 exact hit일 때 설치를 건너뛴다.
- id: modules-cache
uses: actions/cache@v5
with:
path: node_modules
key: modules-${{ hashFiles('package-lock.json') }}
- if: steps.modules-cache.outputs.cache-hit != 'true'
run: npm ci
Key에 OS, architecture, Node ABI가 없다. Install script가 생성한 binary와 symlink, file permission도 오래된 상태일 수 있다.
더 안전한 기본은 package manager download cache만 보관하고 설치는 항상 실행하는 것이다.
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npm test
Setup action의 built-in cache가 어떤 path와 key를 쓰는지도 문서를 확인한다. 편리한 추상화가 신뢰 경계를 없애지는 않는다.
Cache Hit 뒤에도 설치 명령을 실행하기
cache-hit output은 primary key의 정확한 일치를 의미한다. 부분 복원은 exact hit가 아니다. 하지만 exact hit라도 download cache를 썼다면 install은 수행한다.
- name: Install exactly from lock
run: npm ci
- name: Verify dependency graph
run: npm ls --all
Install 명령은 현재 manifest와 lock file 일치를 확인하고 integrity hash를 검증한다. 현재 platform의 optional dependency를 선택하고 installed tree에서 불필요한 package를 제거하며 필요한 binary link를 구성한다.
Cache 때문에 검증을 생략하지 않는다. 성능이 부족하다면 download와 install/compile 시간을 분리 측정한다. 병목이 native compile이라면 compiler cache를 별도로 설계할 수 있다.
Monorepo와 Matrix Build의 범위 나누기
Monorepo 전체 lock file 하나를 쓴다면 root hash가 모든 workspace graph를 대표할 수 있다. Workspace별 lock file이라면 변경 영향 범위를 나눈다.
strategy:
matrix:
workspace:
- services/api
- services/worker
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: |
package-lock.json
${{ matrix.workspace }}/package-lock.json
Root workspace 설정과 local patch도 dependency 입력이라면 hash에 포함한다. Workspace 이름은 외부 event 문자열이 아닌 고정 matrix에서 가져온다.
Matrix에서는 runner.arch와 실제 toolchain version도 key에 포함한다.
key: gradle-v2-${{ runner.os }}-${{ runner.arch }}-java${{ matrix.java }}-${{ hashFiles('build.gradle.kts', 'gradle-wrapper.properties') }}
Cross-OS archive 기능은 archive를 다른 OS에서 풀 수 있게 할 뿐 안에 든 binary 호환성을 보장하지 않는다. Container job이라면 같은 Linux runner 위에서도 Debian glibc와 Alpine musl 결과가 다를 수 있다.
Fork PR과 Cache의 읽기 범위
GitHub 공식 문서에 따르면 pull request workflow는 조건에 따라 base branch cache를 복원할 수 있고 fork에서 온 PR도 base branch cache를 읽을 수 있다. Cache path에 secret을 두면 PR을 열 수 있는 사용자가 내용을 꺼낼 수 있다는 뜻이다.
다음을 cache하면 안 된다.
.npmrc와 package registry token- cloud credential directory
- SSH private key와 credential helper state
- decrypted configuration
- signing key와 provisioning profile
- test 중 생성된 session이나 cookie
# 위험한 예
path: |
~/.npm
~/.config
.env
Cache path는 가장 좁게 지정한다.
path: ~/.npm
Package manager download cache 안에도 authenticated URL metadata가 남을 수 있다. 사용하는 registry client의 저장 format과 credential file 분리를 확인한다.
Cache Poisoning을 막는 신뢰 경계
복원된 cache는 서명되거나 검증된 실행 artifact가 아니다. 공격자가 쓸 수 있는 cache에서 executable, compiler plugin, generated script를 복원한 뒤 privileged workflow가 실행하면 code execution으로 이어질 수 있다.
flowchart LR
A[Untrusted workflow]
B[Writable shared cache]
C[Trusted deploy workflow]
D[Restore executable]
E[Credential theft]
A --> B --> C --> D --> EGitHub는 low-trust trigger의 default branch cache 쓰기를 제한하지만 workflow에서도 경계를 명확히 한다.
- Untrusted PR은 base cache를 restore-only로 사용한다.
- Trusted
pushworkflow만 shared cache를 갱신한다. - Restored binary를 검증 없이 실행하지 않는다.
- Dependency는 lock file과 registry integrity로 다시 검증한다.
- Deploy job은 cache가 아니라 해당 build의 artifact를 받는다.
pull_request_target에서 PR code를 checkout해 실행하지 않는다.
Restore-only 의도를 표현할 수 있다.
- name: Restore dependency downloads
uses: actions/cache/restore@v5
with:
path: ~/.npm
key: npm-v2-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-v2-${{ runner.os }}-
실제 workflow에서는 action을 검토한 commit SHA로 고정한다.
Cache와 Artifact를 구분하기
Cache와 artifact는 모두 job 사이에 파일을 보관하지만 목적이 다르다.
| 구분 | Cache | Artifact |
|---|---|---|
| 목적 | 다음 실행 속도 개선 | 실행 결과 전달·보존 |
| 없어졌을 때 | 다시 생성 | 해당 run 결과가 사라짐 |
| 선택 방식 | key와 prefix | run/job이 명시적으로 업로드 |
| 대표 내용 | package download | test report, release bundle |
| 배포 근거 | 부적합 | checksum/provenance와 함께 사용 |
Build bundle을 dependency cache에 넣고 “가장 최근 prefix”로 찾으면 다른 commit 결과를 가져올 수 있다.
- name: Upload tested bundle
uses: actions/upload-artifact@v4
with:
name: web-bundle-${{ github.sha }}
path: dist
Deploy는 test한 동일 byte를 받아야 한다. Artifact action version도 적용 시점에 확인하고 commit SHA로 고정한다.
Cache Miss와 손상을 정상 경로로 만들기
Cache를 못 받으면 registry에서 설치한다.
flowchart TD
A[Restore attempt]
B[Current lock install]
C[Test]
D[Save best effort]
A -->|hit| B
A -->|miss or unavailable| B
B --> C --> D주기적으로 cache를 전혀 쓰지 않는 build를 실행한다.
on:
schedule:
- cron: "17 3 * * 1"
jobs:
clean-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- run: npm ci
- run: npm test
일정은 예시이며 GitHub Actions cron의 time zone을 확인한다. Cache action 자체를 넣지 않아 숨은 dependency를 발견한다.
용량 제한과 Eviction 운영하기
Branch와 PR마다 unique key를 만들면 저장 공간을 빠르게 소진한다. Eviction이 잦아지면 default branch cache도 밀릴 수 있다.
- Commit SHA 전체를 dependency key에 넣지 않는다.
- Lock hash처럼 의미 있는 변경에만 새 cache를 만든다.
- 닫힌 PR의 cache를 정리한다.
- Cache 크기와 last access를 확인한다.
- Generated output cache는 dependency cache와 분리한다.
gh cache list --limit 100
삭제 자동화는 repository 권한과 PR ref를 정확히 검증해야 한다. 외부 event 값을 shell command에 직접 연결하지 않는다.
CI Cache 효과 측정하기
Job 전체 시간이 아니라 단계별 시간을 본다.
| 지표 | Cold | Exact hit | Prefix restore |
|---|---|---|---|
| cache download | 측정 | 측정 | 측정 |
| dependency install | 측정 | 측정 | 측정 |
| compile | 측정 | 측정 | 측정 |
| total job | 측정 | 측정 | 측정 |
| transferred bytes | 측정 | 측정 | 측정 |
Cache archive가 너무 크면 download와 압축 해제가 clean install보다 길 수 있다. Hit rate가 높아도 비용이 줄지 않는다면 path가 과도한지 본다.
Security와 correctness 지표도 확인한다.
- Clean build 성공률
- Lock mismatch 실패 수
- Cache save permission warning
- Cache storage와 eviction
- Dependency integrity verification 실패
실패 조건을 포함한 테스트
구현 체크리스트
마무리
CI cache의 목적은 build를 성립시키는 것이 아니라 같은 byte를 다시 내려받는 비용을 줄이는 것이다. Dependency 정의는 lock file에 있고 cache는 언제든 없어질 수 있는 복제본이다.
Cache key에는 결과를 바꾸는 OS, architecture, runtime, package manager, lock file, schema version을 포함한다. restore-keys로 받은 부분 일치 cache는 최신 dependency라는 뜻이 아니므로 현재 lock file로 다시 설치하고 검증해야 한다.
속도만큼 신뢰 경계도 중요하다. Fork PR이 base cache를 읽을 수 있으며 오염된 executable cache가 privileged workflow에서 실행될 수 있다. Secret은 cache에서 제외하고 low-trust workflow는 restore-only로 제한하며 배포 결과는 특정 run의 artifact와 digest로 전달한다.
좋은 cache는 exact hit가 많다는 사실보다 세 가지 성질로 평가할 수 있다. 없어도 build되고, 오래돼도 검증을 통과해야 사용하며, 신뢰하지 않은 실행이 privileged pipeline으로 넘어가지 않는다.