DevInsight

개발의 판단과 맥락을 기록하는 곳

DevOps
조회 0약 5분 읽기

삽질 없이 CI를 줄이는 캐시 3종 세트

GitHub Actions에서 의존성 캐시와 빌드 캐시를 도입했는데도 정작 빌드가 느리거나, 캐시가 오히려 잘못된 결과를 재사용하며 깨지는 경험을 해봤다면 이 글이 답이다. cache와 setup-*의 동작 차이, 매트릭스 분할 전략, 캐시 무효화 판단 기준을 함정과 함께 정리해 실패 없이 CI 시간을 단축하는 법을 다룬다.

DevInsight 편집팀 발행

AI 보조 초안과 편집 검수를 거쳐 발행했습니다.

#GitHub Actions#CI#캐시#빌드 최적화#매트릭스#DevOps#cache#의존성 관리

GitHub Actions에 캐시를 붙여놓고도 빌드 시간이 눈에 띄게 줄지 않는다면, 대부분은 캐시를 종류가 아니라 버튼으로 다뤘기 때문이다. actions/cache 한 줄 추가는 시작일 뿐이고, 그 뒤에는 세 가지 결정이 따라야 한다. 무엇을 캐시할지, 어떤 키로 조회할지, 언제 저장하고 버릴지. 여기서 하나라도 어긋나면 캐시는 가속기가 아니라 오염원이 된다.

무엇을 캐시할지부터 갈린다

의존성 캐시와 빌드 캐시를 같은 문제로 보는 시각이 삽질의 절반을 차지한다. setup-node를 비롯한 setup-* 툴들은 옵션 하나로 내부에서 actions/cache를 호출한다. 그래서 setup-node에 cache를 켜두면 actions/cache는 왜 또 필요한지 묻는 사람이 많다. 둘은 저장 경로도 키 규칙도 다르고, node_modules를 어느 쪽이 먼저 복원하느냐에 따라 중복 저장이 일어나기도 한다.

그런데 더 근본적인 차이는 절약하는 시간의 종류다. dependency 캐시가 줄여주는 것은 설치에 걸리는 30초 안팎이고, 컴파일과 번들링에 드는 몇 분은 손도 대지 못한다. 빌드가 느린 게 문제라면 눈길을 돌려야 하는 곳은 빌드 산출물 캐시다. 웹팩이든 esbuild든 번들러는 대부분 자기 캐시 폴더를 남기는데, 이를 별도로 저장하면 두 번째 빌드부터 변환 결과를 재사용한다.

이 둘의 무효화 시점도 다르다. 의존성 캐시는 package-lock.json이 바뀌는 순간 끝이다. 빌드 캐시는 소스 파일 한 글자만 바뀌어도 해당 모듈부터 다시 변환한다. 캐시 키를 짤 때 이 기준을 섞어 쓰면, 아무리 캐시 hit가 나도 정작 다시 계산해야 할 부분만 남겨두는 꼴이 된다.

키는 매트릭스의 축을 모두 포함해야 한다

os별, node 버전별로 매트릭스를 짜놓고 모든 조합이 같은 캐시 키를 쓰는 저장소를 가끔 만난다. 이런 설정에서는 세 job이 같은 키로 같은 경로에 캐시를 쓰려고 경합한다. 마지막에 도착한 job의 캐시가 이기고, 나머지는 다른 플랫폼용 node_modules를 내려받는다.

순수 자바스크립트 의존성이라면 넘어가는 경우가 많다. 하지만 네이티브 바이너리를 포함한 패키지가 들어오는 순간 특정 플랫폼에서만 빌드가 깨진다. '캐시는 hit인데 빌드는 실패'라고 보고되는 버그의 상당수가 여기서 나온다. 캐시 키에는 runner.os, node 버전, 그리고 의존성 해시를 반드시 묶어야 한다. 실행 환경의 기본 런타임 버전이 바뀌는 경우까지 키에 반영하려면 어느 단계에서 버전을 읽어올지도 미리 정해둬야 한다.

key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node${{ matrix.node }}-

여기서 한 가지 함정이 더 있다. hashFiles('**/package-lock.json')가 만능이 아니라는 점이다. npm 레지스트리는 같은 버전 태그에 다른 내용이 올라오는 사고를 막지 못한다. lockfile이 바뀌지 않았는데 실제로는 의존성 내용이 달라진 채 남는 경우가 있고, 그때 오래된 node_modules를 복원해주면 캐시가 문제의 원인이 된다.

이걸 완전히 차단하는 키는 없다. 캐시의 전제가 입력이 같으면 출력도 같다는 신뢰인데, 입력의 감시망 밖에서 바뀌는 것이 존재하기 때문이다. 의존성이 결정적인 job에서는 캐시를 아예 빼고 항상 설치를 수행하는 쪽이 안전하고, 재현 가능한 빌드 결과만 캐시에 맡기는 분리가 실전에서는 더 잘 견딘다. 캐시를 안 쓰는 것도 하나의 전략이다.

완벽한 정합성을 원한다면 커밋 해시를 키에 넣으면 된다. 하지만 그러면 캐시는 매번 miss를 반환하고, 저장 공간만 쌓이는 쓸모없는 서랍이 된다. 결국 문제는 정확성과 적중률 사이 어디에 서 있느냐다. 무결성이 생명인 배포 산출물 캐시는 커밋 해시 수준의 정밀도가 맞고, 재설치가 자유로운 의존성 캐시는 lockfile 해시로 충분하다. 같은 저장소 안에서도 이 두 기준을 섞어 쓰는 게 오히려 정상이다.

여기에 더해 매트릭스와 캐시의 조합이 용량을 어떻게 소비하는지도 알아야 한다. os 3종에 node 버전 2개면 매트릭스 하나당 캐시 6개가 생긴다. 키가 바뀌는 커밋마다 이 수치가 다시 쌓인다고 보면, 저장소당 10GB 한도는 생각보다 빨리 닿는다. 캐시가 정리되는 속도보다 쌓이는 속도가 빠르면 오래된 항목을 손으로 지우는 작업이 주기적인 업무가 된다.

저장 시점과 크기라는 또 다른 축

캐시를 저장하는 것 자체에도 비용이 든다. 수백 MB를 압축해 업로드하고 다운로드하는 데 20초에서 1분 가까이 걸리는 경우가 흔하다. 하루에 수십 번 도는 개발 브랜치라면 감수할 만하지만, 일회성 배포 브랜치에서 매번 저장을 시도하는 것은 낭비다. 저장 단계를 끄거나 조건을 달아야 하는 이유다.

여기서 규칙 하나를 정해두면 판단이 쉬워진다. 잡 전체가 한 자리 수 분 이하로 끝나는 구조라면 캐시 복원 시간이 절약분을 역전하는 지점이 금방 온다. 캐시가 의미를 갖는 최소 조건은 잡 전체 시간 대비 절약분이 저장 비용을 넘기는 것인데, 이건 수치로 따져야 감이 생긴다.

restore-keys를 쓰면 이 저장 비용의 일부를 줄일 수 있다. 정확히 일치하는 키가 없을 때 가장 가까운 이전 캐시를 복원해주는 기능인데, 주 단위 폴백 키를 걸어두면 의존성이 조금 바뀌었을 때 전체 재설치 대신 부분 갱신으로 넘어간다. 다만 복원된 node_modules와 lockfile 사이의 차이를 보정하는 비용이 복원 이득보다 커지면 폴백 키를 아예 지우는 편이 낫다. 이건 저장소마다 실측으로 갈린다.

캐시 hit 여부로 설치 단계를 통째로 건너뛰는 패턴도 흔하다. actions/cache가 남겨주는 cache-hit 출력값을 조건으로 쓰면, 정확히 hit일 때만 설치를 생략하니 최적처럼 보인다. 하지만 설치를 생략하는 순간 lockfile과 실제 상태의 불일치를 보정할 기회도 함께 사라진다. 의존성 캐시에서는 hit여도 설치 무결성만 확인하는 가벼운 단계를 하나 남겨두는 편이 안전하다.

캐시 격리 단위도 짚고 넘어가야 한다. GitHub Actions의 캐시는 브랜치 단위로 분리된다. PR 브랜치에서 만든 캐시는 같은 브랜치에서만 재사용되고, 기본 브랜치의 캐시는 읽기만 가능하다. 그래서 신규 브랜치의 첫 실행은 언제나 콜드 스타트다. '캐시를 넣었는데 왜 자꾸 miss하지'라는 의문의 상당수가 이 동작을 모른 데서 온다.

또 잊기 쉬운 것이 총량 제한이다. 캐시는 저장소당 10GB 상한이 있고, 키가 바뀔 때마다 이전 캐시가 쌓인다. 캐시가 줄어들지 않거나 정리 동작이 이상하다 싶으면 한도 소진을 먼저 의심하는 게 순서다.

캐시가 빨라진 것처럼 보이는 순간이 제일 위험하다

캐시가 동작하기 시작하면 잡이 눈에 띄게 빨라진다. 그러면서 배포도 잦아진다. 문제는 그 배포물이 낡은 상태를 품고 있을 가능성이 있다는 점이고, 속도 지표로는 절대 드러나지 않는다. 빌드 시간이 줄었으니 지표상으로는 완벽하기 때문이다.

그래서 캐시 검증은 도입 전에 해야 한다. 캐시 없이 전체 파이프라인이 통과하는 것을 먼저 확인하고, 캐시를 붙인 뒤에도 동일한 결과가 나오는지 다시 확인한다. 이후에는 캐시 무효화 강제 실행을 주기적으로 넣는다. 키의 해시 입력에 의미 없는 파일 하나를 추가해서 돌리는 것인데, 이 실행이 캐시 없는 첫 실행과 같은 출력을 내면 캐시가 입력을 제대로 따라가고 있다는 뜻이다. 같은 출력이 아니라면 캐시는 이미 잘못된 결과를 재사용하고 있는 것이다. 매번 파일을 건드리기 번거롭다면 키 앞머리의 캐시 버전 숫자를 하나 올려서 돌리는 방법도 있다.

성과 측정도 같은 기준으로 해야 한다. 첫 실행과 두 번째 실행의 로그를 나란히 두고, 캐시 복원 단계가 hit인지 miss인지부터 확인한다. actions/cache가 찍어주는 복원 상태 표시에 시간까지 함께 나오기 때문에, 아무리 캐시 hit가 나도 실제로 줄어든 건 5초인데 저장에 40초가 걸린다면 전략이 반대로 흐르고 있는 것이다. 캐시는 수단이지 목표가 아니다.

캐시는 성능 작업이 아니라 정합성 작업이다. 무엇을 먼저 다루든 상관없지만, 결과가 변하지 않는다는 검증을 건너뛰면 캐시 도입은 문제를 옮기는 일에 그친다. 빌드가 느렸던 문제가 빌드가 틀린 결과를 재사용하는 문제로 바뀔 뿐이다. 배포가 빨라지면 반가운 일이지만, 캐시 덕분에 빨라진 것이라면 그 빨라짐의 정체를 한 번은 의심해봐야 한다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

DevOps 전체 보기

이전 글

나의 첫 JWT 도입은 참사였다

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독