Published on

GitLab CI 캐시 안 먹을 때 - 키·경로·권한 점검

Authors

서론

GitLab CI에서 캐시(cache)는 파이프라인 시간을 체감적으로 줄여주는 핵심 기능입니다. 하지만 현실에서는 cache restored 대신 cache not found가 반복되고, 매번 의존성을 새로 받거나 빌드를 처음부터 다시 하면서 CI가 느려집니다. 더 답답한 점은 “어제는 됐는데 오늘은 안 됨” 같은 비결정적 증상이 자주 나타난다는 것입니다.

GitLab CI 캐시가 “안 먹는다”는 말은 보통 아래 중 하나를 의미합니다.

  • 캐시가 저장되지 않음(업로드 실패)
  • 저장은 됐는데 복원되지 않음(키/스코프 불일치)
  • 복원은 됐는데 실제로 효과가 없음(경로가 틀렸거나 권한 문제로 쓰기 실패)

이 글에서는 원인을 키(Key), 경로(Paths), 권한/Runner 설정(Permissions)으로 나눠 점검합니다. 또한 캐시와 아티팩트(artifacts)의 역할 구분, 로그에서 확인해야 할 포인트, 그리고 실전에서 자주 쓰는 키 설계 패턴까지 함께 다룹니다.

참고로 캐시 무효화/미스 문제는 CI 플랫폼을 가리지 않고 비슷한 패턴을 보입니다. GitHub Actions 사례가 궁금하다면 GitHub Actions 캐시 미스 원인 7가지와 해결도 같이 보면 원인 분류에 도움이 됩니다.

1) 먼저 로그로 “저장/복원” 어느 쪽 문제인지 확정

캐시는 두 단계입니다.

  1. Restore(복원): Job 시작 시 캐시를 내려받아 경로에 풀어줌
  2. Save(저장): Job 종료 시 지정 경로를 압축해 캐시 스토리지로 업로드

GitLab Runner 로그에서 아래 문구를 찾으세요(표현은 러너 버전/실행기에 따라 조금 다릅니다).

  • 복원 성공: Restoring cacheSuccessfully extracted cache
  • 복원 실패: Cache file does not exist / Cache not found
  • 저장 성공: Saving cacheCreated cache / Uploading cacheUploaded
  • 저장 실패: WARNING: ... permission denied / 403 / 500 / no such file or directory

문제 해결 순서는 일반적으로 저장 실패 → 복원 실패 → 효과 없음 순으로 접근하는 게 빠릅니다. 저장이 안 되면 복원도 될 수 없기 때문입니다.

2) Key 점검: 캐시 미스의 70%는 키 설계 문제

GitLab CI 캐시 키는 “이 Job이 어떤 캐시를 공유할지”를 결정합니다. 키가 매번 바뀌면 캐시는 존재해도 항상 미스가 납니다.

2.1 키가 매 파이프라인마다 바뀌는 패턴

아래 변수들은 값이 자주 바뀌므로 키에 넣으면 캐시가 사실상 무효화됩니다.

  • CI_PIPELINE_ID, CI_JOB_ID (매번 변경)
  • CI_COMMIT_SHA (커밋마다 변경)
  • 브랜치가 자주 갈라지는 feature 브랜치에 CI_COMMIT_REF_NAME을 그대로 사용

예: 이런 키는 “항상 새 캐시”를 만듭니다.

cache:
  key: "$CI_PIPELINE_ID"
  paths:
    - node_modules/

2.2 브랜치 스코프 vs 공유 스코프를 의도적으로 선택

  • 브랜치별로 캐시를 분리하고 싶다: CI_COMMIT_REF_SLUG
  • 기본 브랜치(main) 캐시를 여러 브랜치에서 재사용하고 싶다: 고정 키 + fallback_keys

GitLab의 fallback_keys는 “정확히 매칭되는 캐시가 없으면 대체 키로 찾아보기”에 유용합니다.

cache:
  key:
    prefix: "node"
    files:
      - package-lock.json
  fallback_keys:
    - "node-main"
  paths:
    - .npm/
  • files: 기반 키는 지정 파일들의 해시로 키를 만들기 때문에, 락파일이 바뀌지 않으면 캐시가 유지됩니다.
  • fallback_keys를 통해 feature 브랜치에서 먼저 브랜치 키를 찾고, 없으면 main 캐시를 가져오는 식의 전략을 만들 수 있습니다.

2.3 키에 OS/아키텍처/이미지 정보를 포함해야 하는 경우

Docker executor를 쓰면서 Job 이미지가 바뀌거나, 러너가 혼합(amd64/arm64)인 경우 캐시를 공유하면 오히려 문제를 만들 수 있습니다(바이너리 호환성, native module 등).

이럴 땐 키에 환경 정보를 포함해 캐시를 분리하세요.

cache:
  key: "node-${CI_RUNNER_EXECUTABLE_ARCH}-${CI_JOB_IMAGE}"
  paths:
    - node_modules/

다만 CI_JOB_IMAGE는 문자열이 길고 특수문자가 포함될 수 있으니, 실제로는 이미지 태그만 따로 변수로 두거나 slug 처리하는 편이 안전합니다.

2.4 캐시 정책(policy) 확인: pull만 하고 push를 안 하는 실수

policy: pull은 복원만 하고 저장을 하지 않습니다. “복원도 안 되는데?”라면 처음 캐시를 만들 Job이 없어서 그럴 수 있습니다.

cache:
  key: "gradle"
  paths:
    - .gradle/wrapper/
    - .gradle/caches/
  policy: pull-push
  • 최초 생성이 필요하면 pull-push가 기본적으로 안전합니다.
  • 특정 Job에서만 캐시를 갱신하고 싶다면, 빌드 Job은 pull, 의존성 설치 Job만 pull-push로 제한하기도 합니다.

3) Path 점검: “복원은 됐는데 느리다/효과 없다”의 핵심

캐시가 복원되었는데도 매번 의존성을 다시 받는다면, 대개 캐시 경로가 실제로 쓰는 경로와 다르거나, 작업 디렉터리 기준 상대경로가 틀린 경우입니다.

3.1 GitLab 캐시 paths는 프로젝트 디렉터리 기준 상대경로

paths:는 기본적으로 $CI_PROJECT_DIR 기준입니다. 절대경로(/root/.npm)를 넣으면 러너/실행기에 따라 무시되거나 기대와 다르게 동작할 수 있습니다.

권장 패턴은 “툴의 캐시 디렉터리를 프로젝트 내부로 옮기는 것”입니다.

Node.js (npm) 예시

image: node:20

variables:
  NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"

cache:
  key:
    files:
      - package-lock.json
  paths:
    - .npm/

before_script:
  - node -v
  - npm -v

install:
  stage: build
  script:
    - npm ci
  • node_modules/ 자체를 캐시하는 것보다 .npm/(다운로드 캐시)를 캐시하는 편이 안정적입니다.
  • npm ci는 락파일 기반으로 재현 가능한 설치를 수행하므로 캐시 효율이 좋습니다.

Gradle 예시

image: gradle:8-jdk17

variables:
  GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"

cache:
  key:
    files:
      - gradle/wrapper/gradle-wrapper.properties
      - build.gradle
      - settings.gradle
  paths:
    - .gradle/wrapper/
    - .gradle/caches/

build:
  script:
    - gradle --no-daemon build

GRADLE_USER_HOME을 프로젝트 내부로 고정하면, 러너 환경이 달라도 경로가 일관되어 캐시가 잘 맞습니다.

3.2 캐시로 넣으면 안 되는 것(또는 신중해야 하는 것)

  • 빌드 산출물 전체(dist/, build/)를 캐시로 쓰는 것: 아티팩트/배포 산출물과 역할이 겹치고, 캐시 오염 위험이 큼
  • 테스트 결과/로그: 캐시 부피만 커지고 가치가 낮음
  • node_modules/처럼 OS/아키텍처 의존이 큰 디렉터리: 러너가 섞이면 지옥이 열릴 수 있음

캐시는 “다시 생성 가능하지만 비용이 큰 중간 결과물(다운로드/컴파일 캐시)” 위주로 잡는 것이 정석입니다.

3.3 캐시 압축/용량 이슈로 저장이 스킵되는 경우

캐시가 너무 크면 업로드 시간이 늘고, 스토리지 정책에 따라 실패할 수 있습니다. 로그에 archive too large, timeout, 413(프록시/스토리지 제한) 류가 보이면 캐시 대상을 줄이거나 키를 더 세분화해야 합니다.

대용량 업로드 제한 문제는 API 업로드에서도 흔한데, 원리 자체는 비슷합니다(제한을 넘으면 실패). 이런 유형의 접근법이 궁금하면 OpenAI Responses API 413 에러 업로드 용량 제한과 청크 전략처럼 “나눠서 저장/전송” 전략을 떠올리면 도움이 됩니다.

4) Permission/Runner 점검: 캐시가 “저장되지 않는” 진짜 이유

키와 경로가 완벽해도, 러너가 캐시를 저장할 권한/설정이 없으면 매번 미스가 납니다.

4.1 러너의 캐시 백엔드 설정 확인(S3/GCS/Azure 등)

Self-managed GitLab Runner는 캐시를 로컬 디스크에만 두지 않고, 보통 S3 같은 원격 캐시 스토리지를 붙입니다. 이때 아래가 흔한 실패 원인입니다.

  • S3 버킷 권한 부족(put/get/list)
  • KMS 암호화 권한 부족
  • 버킷 정책에서 prefix 제한
  • 엔드포인트/리전 설정 오류

증상은 대개 로그에 403 Forbidden, AccessDenied, SignatureDoesNotMatch 등으로 드러납니다.

4.2 Docker executor에서 파일 소유권/권한 꼬임

컨테이너 안에서 생성된 캐시 디렉터리가 root 소유로 만들어지고, 다음 Job에서 다른 UID로 실행되면서 쓰기 실패하는 케이스가 있습니다.

대응 방법:

  • 캐시 디렉터리를 만들고 권한을 명시적으로 부여
  • 컨테이너 실행 유저를 고정
before_script:
  - mkdir -p .npm
  - chmod -R u+rwX,g+rwX .npm

4.3 Protected branch/Tag와 캐시 접근 스코프

GitLab에는 “보호된(protected) 브랜치/태그” 개념이 있고, 러너도 protected 전용으로 동작하도록 설정할 수 있습니다. 이때 다음 상황이 발생합니다.

  • main(Protected)에서 만든 캐시를 feature(Non-protected)에서 못 읽음
  • 반대로 feature에서 만든 캐시를 main에서 못 읽음

해결은 두 가지 방향입니다.

  • 러너를 protected/unprotected 용도로 분리하고 캐시도 분리(키 prefix 분리)
  • 정책적으로 protected 캐시를 공유하지 않도록 설계(보안상 더 안전)

4.4 동일 파이프라인 내 “작업 디렉터리”가 달라지는 경우

모노레포에서 cd apps/web로 이동해 설치하는데 캐시 paths를 루트 기준으로 잡아 놓으면, 캐시가 복원되어도 실제 설치 경로와 안 맞아 효과가 없습니다.

cache:
  key:
    files:
      - apps/web/package-lock.json
  paths:
    - apps/web/.npm/

web_install:
  script:
    - cd apps/web
    - npm ci

5) 캐시 vs artifacts: 목적이 다르면 설정도 달라야 한다

  • cache: 다음 Job/다음 파이프라인에서 “재사용”할 중간 결과(의존성 다운로드 캐시 등)
  • artifacts: Job 결과물을 “보관/전달” (테스트 리포트, 빌드 산출물, 다음 스테이지 전달)

빌드 산출물을 다음 스테이지(예: deploy)로 넘기려면 캐시가 아니라 artifacts가 맞습니다. 캐시로 넘기면 키 충돌/오염으로 이상한 버전이 섞일 수 있습니다.

6) 바로 써먹는 체크리스트(키·경로·권한)

6.1 Key

  • CI_PIPELINE_ID, CI_JOB_ID 같은 매번 바뀌는 값을 키에 넣지 않았나?
  • 락파일 기반(key:files)으로 키를 만들고 있나?
  • 아키텍처/이미지 혼합 환경이면 캐시를 분리했나?
  • policypull만 되어 있어 캐시가 생성되지 않는 구조는 아닌가?

6.2 Path

  • paths가 실제로 사용되는 캐시 디렉터리와 일치하나?
  • 절대경로 대신 프로젝트 내부 경로로 고정했나?
  • 모노레포라면 서브디렉터리 기준으로 정확히 잡았나?
  • 캐시 크기가 과도하지 않나(업로드 실패/시간 초과 유발)?

6.3 Permission/Runner

  • 원격 캐시 스토리지(S3 등) 권한이 충분한가?
  • protected/unprotected 러너 분리로 캐시 접근이 막히지 않았나?
  • 컨테이너 내 생성 파일의 소유권/권한이 다음 Job과 충돌하지 않나?

7) 예시: “캐시가 안 먹는” .gitlab-ci.yml을 개선하기

문제 있는 예(커밋마다 키가 바뀌고 node_modules를 캐시):

build:
  image: node:20
  cache:
    key: "$CI_COMMIT_SHA"
    paths:
      - node_modules/
  script:
    - npm install
    - npm run build

개선 예(락파일 기반 키 + 다운로드 캐시 + 경로 고정):

build:
  image: node:20
  variables:
    NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - .npm/
    policy: pull-push
  script:
    - npm ci
    - npm run build

이렇게 바꾸면 다음 효과가 납니다.

  • 커밋이 바뀌어도 락파일이 동일하면 캐시 재사용
  • OS 의존성이 큰 node_modules 대신 다운로드 캐시로 안정성 향상
  • 경로가 프로젝트 내부로 고정되어 러너 환경 차이 감소

결론

GitLab CI 캐시가 안 먹을 때는 “캐시가 없어서”가 아니라, 대개 키가 의도치 않게 자주 바뀌거나, 캐시 경로가 실제 사용 경로와 다르거나, 러너/스토리지 권한 때문에 저장 단계에서 실패하는 경우입니다.

해결의 핵심은 (1) 로그로 저장/복원 어느 단계가 실패하는지 확정하고, (2) 키를 락파일 중심으로 안정화하며, (3) 캐시 디렉터리를 프로젝트 내부로 고정하고, (4) 러너 권한과 protected 스코프를 점검하는 것입니다.

캐시 문제는 “원인 하나만 고치면 끝”이라기보다, 키·경로·권한이 동시에 맞아야 재현성 있게 해결됩니다. 위 체크리스트대로 하나씩 지워나가면, 캐시 미스의 대부분은 빠르게 정리됩니다.

추가로 캐시/재검증(revalidate) 충돌처럼 ‘캐시가 있는데도 기대대로 동작하지 않는’ 문제를 다뤄본 경험이 있다면, 웹앱 쪽 사례인 Next.js ISR 500 - revalidate·캐시 충돌 해결도 캐시 사고방식(무효화, 스코프, 일관성)을 확장하는 데 도움이 됩니다.