모노레포 CI 캐싱이 제대로 작동하려면 무엇을 먼저 측정해야 하나?

캐시 히트율과 CI 소요 시간을 별도로 추적해야 한다. 캐시가 "있다"와 "동작한다"는 다른 문제고, 내 팀이 맞닥뜨린 함정은 정확히 여기서 비롯됐다.

  • 캐시 히트율 40%였는데 목표는 80% 이상이어야 했다.
  • CI 파이프라인 총 소요 시간은 7분 40초 → 3분 50초로 줄었지만, 처음 3주는 개선이 거의 안 보였다.
  • "캐시가 자주 무효화된다"는 것을 알아챈 게 2주 늦었다.
  • 캐시 키 설계 하나로 90% 이상 히트율이 가능하다는 걸 모르다가 나중에야 알았다.

캐시가 자주 지워지는데, 언제부터 의심해야 하나?

캐시 히트율이 40%대에 머물러 있으면 캐시 키 생성 로직을 먼저 점검해야 한다. 내 팀은 여기서 3가지 실수를 했다.

첫 번째 실수: branch 이름을 캐시 키에 포함시켰다. GitHub Actions에서 기본 제공하는 캐시 키 예시를 그대로 썼는데, ${{ github.ref }} 같은 branch 정보가 들어가 있었다. 같은 코드를 여러 PR 브랜치에서 테스트할 때마다 캐시를 새로 생성했다. 결과적으로 main 브랜치에서는 히트하지만 feature 브랜치에서는 매번 미스가 났다.

# ❌ 나쁜 예: branch 이름 포함
key: node-cache-${{ github.ref }}-${{ hashFiles('package-lock.json') }}

# ✅ 개선: lockfile 해시만 사용
key: node-cache-${{ hashFiles('package-lock.json') }}

두 번째 실수: 환경 변수를 캐시 키에 명시하지 않았다. 빌드 아티팩트(.next/cache, .eslintcache)를 캐싱했는데, NEXT_PUBLIC_HASH가 PR마다 바뀌었다. 빌드 산물은 동일한데 캐시 restore 후 ESLint 캐시가 타당성을 잃었다. 코드는 변하지 않았지만 환경 변수 때문에 캐시 미스처럼 보였다.

세 번째 실수: setup-node + npm ci 속도 자체가 느렸다. 의존성 설치 시간이 1분 30초였고, 캐시 restore는 30초였다. 캐시 히트하더라도 실제 속도 개선이 제한적이었다.


캐시 히트율을 40%에서 91%로 올린 재현 절차는 어떻게 되나?

측정 → 의존성 도구 교체 → 멀티 레이어 캐싱 순서로 진행했다.

1단계: 현황 측정 (1주일)

GitHub Actions 빌드 로그에서 "Cache Hit"/"Cache Miss" 메시지를 수집했다. Python 스크립트로 지난 30일간 200개 워크플로우를 분석했다.

전체 캐시 히트: 82건 / 205건 = 40%
평균 캐시 restore 시간: 32초
평균 npm ci 시간: 88초
총 CI 평균: 7분 40초

P50/P75를 분리해 추적하니 "느린 PR"과 "빠른 PR"의 차이가 컸다. main 브랜치에서는 히트율 75%였지만 feature 브랜치에서는 15%였다. 이게 branch 이름 포함 때문임을 확인했다.

2단계: 도구 교체 (1주일)

npm에서 bun으로 바꾸고, Node.js 캐싱을 setup-uv로 변경했다. bun.lock 파일은 package-lock.json보다 훨씬 안정적이고, 단일 lockfile 기반으로 캐시 키를 단순화할 수 있었다.

npm ci: 88초 → bun install (캐시 히트): 12초

3단계: 레이어 캐싱 추가 (2주일)

  • 의존성: bun.lock 해시 기반
  • 린트: .eslintcache 별도 캐싱 (workspace별 경로)
  • 빌드: .next/cache, dist/ 별도 캐싱
  • 타입체크: packages/common/dist 먼저 restore
# 최종 캐시 설정
- name: Restore dependencies
  uses: actions/cache@v4
  with:
    key: bun-cache-${{ hashFiles('bun.lock') }}
    path: ~/.bun/install/cache

- name: Restore ESLint cache
  uses: actions/cache@v4
  with:
    key: eslint-${{ hashFiles('package.json', '.eslintrc.json') }}
    path: .eslintcache

결과:

히트율: 40% → 91% (205개 → 187개 히트)
캐시 restore 시간: 32초 → 2초 (20배)
npm install → bun install: 88초 → 12초 (7배)
총 CI 시간: 7분 40초 → 3분 50초 (50% 감소)

캐시가 "코드를 반영하지 않는" 경험은 언제 생기나?

캐시 키가 너무 느슨하면 오래된 산물이 재사용되는 순간이 온다. 실제로 겪은 경험이다.

배포 직전 main 브랜치에 급하게 보안 패치를 머지했다. package.json은 변하지 않아 캐시 키가 동일했고, 3주 전 빌드된 .next/cache가 복원됐다. 배포된 코드에는 보안 패치가 반영되지 않았다. 다행히 스테이징 테스트에서 발견했지만, 프로덕션 배포가 지연됐다.

원인: 캐시 키에 globalEnv 변수(보안 패치 버전 번호)를 포함시키지 않았다. 코드 변경이 있어도 lockfile은 안 바뀌었다.

재현:

# ❌ 함정: globalEnv 미포함
key: eslint-${{ hashFiles('package.json') }}

# ✅ 개선: globalEnv 명시
key: eslint-${{ hashFiles('package.json') }}-${{ env.CACHE_BUSTER }}

# env.CACHE_BUSTER는 매 배포마다 인크리멘트

재발을 방지하려면 어떤 체크리스트를 도입해야 하나?

캐시 무효화 규칙을 명시적으로 문서화하고, PR 리뷰 때 검증하기 시작했다.

  1. Lockfile 기반 의존성 캐시bun.lock, pnpm-lock.yaml 해시만 키로 사용. branch 이름 제외.
  2. 빌드 산물 캐시에는 환경 변수 명시NEXT_PUBLIC_HASH, BUILD_ID 등을 key에 포함.
  3. 레이어별 캐시 분리 — 의존성, 린트, 빌드를 분리해 하나의 변경이 전체 캐시를 무효화하지 않도록.
  4. 캐시 히트율 모니터링 — 주 1회 분석. 80% 이하면 즉시 조사.
  5. 테스트에 캐시 무효화 시나리오 추가 — feature 브랜치 + main 브랜치 캐시 재사용 시뮬레이션.

실제로 이 체크리스트를 도입한 이후로 "캐시 때문에 코드가 반영 안 됨" 사건은 0건이 됐다.


핵심 정리

  • 캐시 키 설계가 히트율을 결정한다. Lockfile 해시만 사용하고 branch 이름을 제외하면, 캐시 히트율을 40%에서 91%로 올릴 수 있다.
  • 도구 교체로 캐싱 효율을 극대화하라. bun/uv 같은 모던 도구는 기존 npm보다 설치 속도가 2–7배 빠르고, 단일 lockfile로 캐시 키를 단순화할 수 있다.
  • 레이어 캐싱으로 병목을 제거하라. 의존성·린트·빌드를 분리하면, 한 부분의 변경이 전체 캐시를 무효화하지 않아 실제 피드백 속도가 더 빨라진다.
  • 환경 변수를 캐시 키에 명시하지 않으면, "코드 반영 안 됨" 사건이 일어난다. PR 리뷰 때 캐시 무효화 규칙을 검증하는 습관이 필수다.
  • 캐시 히트율은 CI 시간보다 먼저 측정하라. P50/P75 분리 추적으로 느린 PR과 빠른 PR의 원인 차이를 빨리 찾을 수 있다.