GitLab CI 파이프라인을 처음 구성할 때는 잘 동작하다가, 프로젝트가 커지고 잡(job)이 늘어나면 어느 순간 빌드 시간이 10분을 넘어가기 시작한다. 원인을 들여다보면 대부분 매 잡마다 의존성을 새로 내려받고, 컴파일 결과물을 반복해서 다시 만들며, 잡 사이에 산출물을 넘기지 못해 같은 일을 여러 번 반복하고 있다. GitLab CI에는 이 문제를 정면으로 해결하는 두 가지 도구가 있다. 바로 캐시(cache)와 아티팩트(artifacts)다.
문제는 이 둘의 역할이 미묘하게 겹쳐 보여서, 실무에서 캐시로 넘겨야 할 것을 아티팩트로 넘기거나 그 반대로 쓰면서 오히려 파이프라인을 느리고 불안정하게 만드는 경우가 흔하다는 점이다. 이 글에서는 캐시와 아티팩트의 정확한 차이, 캐시 키(key) 설계, 잡 간 산출물 전달, 그리고 캐시가 “적중하지 않는” 흔한 함정까지 실제 .gitlab-ci.yml 설정과 함께 정리한다.
캐시와 아티팩트는 무엇이 다른가
둘의 가장 큰 차이는 목적과 수명이다. 캐시는 “다음에도 재사용하면 빨라지는, 없어도 무방한 것”을 위한 저장소다. 대표적으로 node_modules, .m2, pip 캐시처럼 의존성 다운로드 결과가 여기 해당한다. 캐시는 최선의 노력(best-effort)으로 복원되며, 없으면 그냥 처음부터 다시 만들면 그만이다.
반면 아티팩트는 “잡이 만들어낸 결과물이며, 다음 스테이지 잡이 반드시 넘겨받아야 하는 것”이다. 빌드 스테이지에서 만든 컴파일 산출물, 테스트 리포트, 커버리지 결과가 여기 해당한다. 아티팩트는 파이프라인 실행 결과에 종속되고, GitLab UI에서 다운로드할 수 있으며, 만료 정책을 갖는다.
- 캐시: 파이프라인·잡 간 재사용 목적, 없어도 되는 것, 러너 로컬 또는 분산 스토리지에 보관
- 아티팩트: 잡 산출물의 명시적 전달, 다음 스테이지가 의존, GitLab 서버에 업로드
stages:
- build
- test
# 의존성은 캐시로, 빌드 결과물은 아티팩트로
build-app:
stage: build
image: node:20
cache:
key:
files:
- package-lock.json # lock 파일 해시가 캐시 키
paths:
- .npm/ # npm 캐시 디렉터리 (다운로드 재사용)
script:
- npm ci --cache .npm --prefer-offline
- npm run build
artifacts:
paths:
- dist/ # 빌드 결과물은 다음 스테이지로 전달
expire_in: 1 hour
run-tests:
stage: test
image: node:20
# dist/ 는 build-app 아티팩트로 자동 전달됨 (별도 다운로드 불필요)
script:
- npm ci --cache .npm --prefer-offline
- npm test
핵심 원칙은 이렇다. 다운로드·설치 캐시는 cache로, 스테이지 간 결과물은 artifacts로. 이 구분만 지켜도 파이프라인의 절반 이상은 정돈된다.
캐시 키 설계: 적중률이 전부다
캐시의 효과는 전적으로 캐시 적중률(hit rate)에 달려 있다. 키를 너무 세분화하면 매번 새 캐시가 생성되어 적중하지 않고, 너무 광범위하면 오래된 캐시가 재사용되어 이상한 빌드 결과가 나온다. 가장 안정적인 방식은 락 파일의 해시를 키로 쓰는 것이다.
cache:
key:
files:
- package-lock.json
- yarn.lock
paths:
- .npm/
- node_modules/
이렇게 하면 락 파일이 변하지 않는 한 같은 캐시가 재사용되고, 의존성이 바뀌어 락 파일 해시가 달라지면 자동으로 새 캐시가 만들어진다. 브랜치별로 캐시를 나누고 싶다면 prefix를 조합한다.
cache:
key:
files:
- package-lock.json
prefix: "$CI_COMMIT_REF_SLUG" # 브랜치별 캐시 분리
paths:
- node_modules/
또 하나 중요한 것은 캐시 policy다. 기본값은 pull-push로, 잡 시작 시 캐시를 내려받고 종료 시 다시 올린다. 하지만 캐시를 읽기만 하는 잡(예: 테스트 잡)에서는 굳이 업로드할 필요가 없다. 이때 policy: pull로 지정하면 업로드 단계를 생략해 잡 종료가 빨라진다.
.node-cache: &node-cache
key:
files: [package-lock.json]
paths: [node_modules/]
build:
stage: build
cache:
<<: *node-cache
policy: pull-push # 캐시 생성 담당
script: [npm ci, npm run build]
test:
stage: test
cache:
<<: *node-cache
policy: pull # 읽기만 → 업로드 생략
script: [npm test]
아티팩트로 스테이지 간 결과물 넘기기
아티팩트의 기본 동작은 편리하지만, 방치하면 스토리지를 잡아먹고 파이프라인을 느리게 만든다. 첫째 규칙은 expire_in을 반드시 설정하는 것이다. 스테이지 간 전달용 아티팩트는 파이프라인이 끝나면 쓸모없으므로 짧게 잡는다. 릴리스 산출물처럼 오래 보관해야 하는 것만 길게 둔다.
build:
stage: build
script:
- make build
artifacts:
name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA"
paths:
- build/bin/
exclude:
- build/bin/**/*.tmp # 임시 파일은 제외해 업로드 용량 절감
expire_in: 30 minutes
when: on_success
테스트·커버리지 리포트는 별도의 리포트 아티팩트 타입으로 넘기면 GitLab의 MR 화면에 결과가 직접 표시된다. 이건 단순 파일 전달을 넘어 리뷰 경험을 개선한다.
test:
stage: test
script:
- pytest --junitxml=report.xml --cov=app --cov-report=xml
artifacts:
reports:
junit: report.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
expire_in: 1 week
특정 잡이 이전 스테이지의 아티팩트를 필요로 하지 않는다면 dependencies: []로 명시적으로 차단하는 것이 좋다. 그렇지 않으면 GitLab은 이전 모든 스테이지의 아티팩트를 자동으로 내려받아 잡 시작 시간이 불필요하게 늘어난다.
lint:
stage: test
dependencies: [] # 아티팩트 다운로드 생략 → 시작 시간 단축
script:
- npm run lint
분산 캐시: 러너가 여러 대일 때
캐시는 기본적으로 러너 로컬 디스크에 저장된다. 러너가 한 대라면 문제없지만, 오토스케일링으로 러너 인스턴스가 계속 바뀌거나 여러 대를 운영하면 방금 만든 캐시가 다른 러너에서는 존재하지 않아 적중률이 급락한다. 해결책은 S3 같은 오브젝트 스토리지에 캐시를 저장하는 분산 캐시(distributed cache)다.
# /etc/gitlab-runner/config.toml
[[runners]]
name = "docker-runner"
executor = "docker"
[runners.cache]
Type = "s3"
Shared = true # 러너 간 캐시 공유
[runners.cache.s3]
ServerAddress = "s3.amazonaws.com"
BucketName = "gitlab-ci-cache"
BucketLocation = "ap-northeast-2"
AuthenticationType = "iam" # IAM 역할 사용, 키 하드코딩 회피
분산 캐시는 네트워크 다운로드/업로드 시간이 추가되므로, 캐시 용량이 작을 때는 오히려 로컬보다 느릴 수 있다. node_modules처럼 수백 MB에 달하는 캐시라면 압축 후 전송해도 재빌드보다 빠르지만, 수 MB짜리 캐시라면 S3 왕복이 이득을 상쇄한다. 캐시 크기와 네트워크 비용을 함께 저울질해야 한다.
캐시가 적중하지 않는 흔한 함정
"분명 캐시를 설정했는데 매번 처음부터 다시 받는다"는 문제는 GitLab CI 사용자가 가장 자주 겪는 좌절이다. 원인은 대개 다음 중 하나다.
- 캐시 경로가 프로젝트 디렉터리 밖에 있음: 캐시
paths는 반드시$CI_PROJECT_DIR하위여야 한다. 홈 디렉터리의~/.cache같은 경로는 캐시되지 않는다. 도구가 밖에 캐시를 두면 프로젝트 안으로 경로를 재지정해야 한다. - 매 파이프라인마다 키가 바뀜: 키에
$CI_COMMIT_SHA처럼 커밋마다 변하는 값을 넣으면 절대 적중하지 않는다. 락 파일 해시나 브랜치 슬러그를 써야 한다. - policy가 잘못됨: 캐시를 생성해야 하는 잡에
policy: pull을 걸어두면 아무도 캐시를 올리지 않는다.
# pip 캐시를 프로젝트 안으로 재지정하는 예시
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache" # 기본은 ~/.cache/pip (캐시 불가)
install:
cache:
key:
files: [requirements.txt]
paths:
- .pip-cache/
- .venv/
script:
- python -m venv .venv
- source .venv/bin/activate
- pip install -r requirements.txt
캐시 동작을 검증할 때는 잡 로그의 Restoring cache와 Saving cache 메시지를 확인하는 것이 가장 확실하다. "Successfully extracted cache"가 보이면 적중한 것이고, 매번 "Creating cache"만 반복된다면 키 설계를 다시 봐야 한다.
마무리
GitLab CI 최적화의 출발점은 캐시와 아티팩트의 역할을 정확히 구분하는 것이다. 다운로드·설치 결과처럼 없어도 되는 것은 캐시로, 스테이지 간 반드시 넘겨야 하는 산출물은 아티팩트로 다룬다. 캐시 키는 락 파일 해시 기반으로 잡아 적중률을 확보하고, 읽기 전용 잡은 policy: pull로 업로드를 생략한다. 아티팩트는 expire_in과 dependencies로 낭비를 통제한다. 다만 모든 최적화에는 비용이 있다. 분산 캐시는 네트워크 왕복 시간을 더하고, 과도한 캐시 경로는 압축·전송 오버헤드를 낳는다. 캐시 크기가 작다면 그냥 재빌드하는 편이 빠를 수도 있으니, 잡 로그의 캐시 복원 시간과 재빌드 시간을 직접 비교해보고 결정하는 것이 정답이다.
자주 묻는 질문
Q. 캐시와 아티팩트를 같은 디렉터리에 함께 걸어도 되나요?
A. 권장하지 않습니다. 같은 경로를 캐시와 아티팩트 양쪽에 지정하면 복원 순서(아티팩트가 캐시보다 나중에 복원)에 따라 예상치 못한 파일 덮어쓰기가 발생할 수 있습니다. 역할을 나눠 서로 다른 경로를 쓰는 것이 안전합니다.
Q. node_modules를 캐시하면 되는데 왜 npm ci를 또 실행하나요?
A. 캐시는 최선의 노력이라 항상 복원된다는 보장이 없기 때문입니다. 캐시가 없을 때도 잡이 정상 동작하려면 설치 명령이 필요합니다. 다만 npm ci --prefer-offline처럼 캐시가 있으면 네트워크를 최소화하는 옵션을 조합하면 캐시 적중 시 설치가 매우 빨라집니다.
Q. 아티팩트 만료 시간을 짧게 잡으면 디버깅이 어렵지 않나요?
A. 스테이지 간 전달용 아티팩트는 짧게, 릴리스 산출물이나 실패 시점의 로그·스크린샷은 when: on_failure와 함께 길게 보관하는 이원화가 실용적입니다. 실패한 파이프라인의 디버깅 정보만 오래 남기고 성공한 중간 산출물은 빠르게 정리하면 스토리지와 디버깅을 모두 만족할 수 있습니다.