GitHub Actions 로컬 캐시로 빌드 속도 최적화하기
GitHub Actions 로컬 캐시로 빌드 속도 최적화하기
moseoh
github-actions ci-cd

GitHub Actions 로컬 캐시로 빌드 속도 최적화하기

moseoh · 2025년 11월 07일

들어가며

저는 GitHub Actions를 self-hosted runner로 구축하기 위해 Actions Runner Controller(이하 ARC)를 사용했습니다. ARC는 Kubernetes에서 GitHub Actions runner를 자동으로 관리해주는 도구로, 트래픽에 따라 runner를 자동으로 스케일링하고 통합 관리할 수 있어서 선택했습니다.

ARC를 배포할 때 DinD, Kubernetes 등의 모드가 있었는데, ARC가 Kubernetes 기반이니까 당연히 Kubernetes 모드를 사용하는 게 맞다고 생각했어요. 각 빌드가 독립적인 Pod에서 실행되는 완전히 격리된 클린한 환경이라는 점도 마음에 들었고요.

그런데 GitHub Actions로 CI를 구축하고 나서 뭔가 이상했습니다. 로컬에서는 2번째 빌드부터 8초 만에 끝나는데, CI에서는 매번 2분 가까이 걸리는 거예요. 분명히 같은 코드를 빌드하는데 왜 이렇게 느릴까요?

또하나의 문제는 Docker 이미지를 빌드할 때마다 base 이미지를 처음부터 다운로드하면서 Docker Hub의 rate limit까지 걸렸다는 점입니다. 회사에서는 Docker Pro 라이센스를 결제해서 임시로 해결했지만, 이건 근본적인 해결책이 아니었어요.

구체적인 문제들:

  • 로컬 빌드: 2번째부터 8초 / CI 빌드: 매번 1분 30초 이상
  • Docker base 이미지를 매번 다운로드하면서 rate limit 발생
  • GitHub Actions의 원격 캐시를 사용하면 오히려 더 느려짐 (네트워크 업로드/다운로드 때문) 알고 보니 ARC Kubernetes 모드는 매번 새로운 Pod을 생성하면서 캐시를 전혀 활용하지 못하는 구조였습니다.

이번 글에서는 CI 빌드 시간을 평균 70% 단축시킨 로컬 캐시 최적화 과정을 공유하겠습니다.

Local 빌드가 빠른 이유

최적화 방법을 찾기 전에, 먼저 로컬에서 빌드가 왜 빠른지 이해해야 했습니다.

앱 빌드

# 첫번쨰 빌드
BUILD SUCCESSFUL in 59s
# 두번쨰 빌드
BUILD SUCCESSFUL in 8s

두 번째 빌드부터는 캐시 덕분에 8초 만에 끝납니다. 무엇이 캐시되는 걸까요?

캐시 대상:

  • JDK (~/runner/_work/_tool)
  • Gradle Wrapper 및 의존성 (~/.gradle)
  • 빌드 결과물 ({project_root}/build{project_root}/.gradle)

도커 빌드

# 첫번째 빌드
Building 78.2s (22/22) FINISHED
# 두번째 빌드
Building 7.6s (21/21) FINISHED

Docker도 마찬가지로 레이어 캐싱 덕분에 엄청나게 빨라집니다.

캐시 대상:

  • Base 이미지 다운로드
  • 각 빌드 단계별 레이어
  • 최종 이미지 빌드 로컬에서는 별도 설정 없이도 알아서 캐시가 잘 동작합니다. 대부분의 빌드 도구들이 기본적으로 캐시를 사용하도록 설계되어 있거든요. 문제는 CI 환경에서 이 캐시들을 어떻게 유지하느냐였습니다.

Self-Hosted 최적화

가장 먼저 시도한 건 Self-Hosted Runner였습니다. 직접 관리하는 서버에서 실행하면 로컬처럼 캐시를 유지할 수 있을 것 같았거든요.

앱 빌드

첫 시도 결과:

Image

Image

  • 첫 번째 빌드: 2m 10s
  • 두 번째 빌드: 40s 분명히 개선되긴 했지만 로컬만큼 빠르진 않았습니다. 뭔가 더 할 수 있을 것 같았어요.

Workflow 실행 과정:

  • Set up job: Actions 다운로드 및 준비 (매번 실행)

  • Checkout code: 최신 코드 불러오기 (매번 실행)

  • Setup Java: JDK 준비

    • 첫 번째: “Downloading Java 21.0.9+10.0.LTS”
    • 두 번째: “Resolved Java 21.0.9+10.0.LTS from tool-cache”
    • 경로: ~/actions-runner/_work/_tool
  • Setup Gradle: Gradle 설정 준비

  • Build with Gradle: 실제 빌드 수행

  • Post Setup Gradle: 빌드 캐시를 원격에 업로드 ← 여기가 문제! 로그를 자세히 보니 Post Setup Gradle 단계에서 30초 이상을 소모하고 있었습니다. GitHub Actions의 원격 캐시에 빌드 결과를 업로드하는 과정이었는데, 이게 오히려 병목이 되고 있었어요.

원격 캐시 비활성화

어차피 Self-Hosted Runner는 호스트의 ~/.gradle을 그대로 사용하는데, 굳이 원격 캐시에 업로드할 필요가 있을까요? 바로 비활성화했습니다.

actions.yaml
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-disabled: true # 원격 캐시 비활성화

결과:

Image

Image

  • 첫 번째 빌드: 1m 33s (30초 단축!)
  • 두 번째 빌드: 43s 원격 캐시 업로드만 제거했는데도 첫 빌드가 30초나 빨라졌습니다. 두 번째 빌드는 여전히 비슷했어요.

빌드 결과물 캐싱

로컬 환경을 다시 떠올려봤습니다. 로컬에서 8초 만에 끝나는 이유는 build 폴더가 그대로 남아있기 때문이었죠. Gradle은 이전 빌드 결과를 보고 대부분의 task를 스킵합니다.

Self-Hosted Runner에서도 마찬가지로 build 폴더를 보존하면 되지 않을까요?

actions.yaml
- name: Setup ENV
run: |
# runner.tool_cache에서 _tool 부분을 제거하여 base path 추출
RUNNER_WORK_PATH=$(dirname ${{ runner.tool_cache }})
echo "CACHE_PATH=$RUNNER_WORK_PATH/_cache/${{ github.repository }}" >> $GITHUB_ENV
- name: Cache Gradle build outputs
uses: self-hosted-actions/cache@v4 # 직접 만든 로컬 캐시 wrapper
with:
path: |
build/
.gradle/
key: gradle-build-${{ hashFiles('**/*.gradle*', 'src/**/*.java') }}
restore-keys: |
gradle-build-${{ hashFiles('**/*.gradle*') }}-
local-cache-path: ${{ env.CACHE_PATH }}/gradle

여기서 사용한 self-hosted-actions/cache는 GitHub 공식 actions/cache를 래핑해서 로컬 저장 기능을 추가한 커스텀 액션입니다. 원격 캐시 대신 Runner 호스트의 디스크에 직접 저장하도록 만들었어요.

최종 결과:

BUILD SUCCESSFUL in 7s
23 actionable tasks: 1 executed, 22 up-to-date

드디어 로컬과 동일한 속도가 나왔습니다!

Image

  • 첫 번째 빌드: 1m 33s
  • 두 번째 빌드: 43s (Gradle wrapper 캐시)
  • 세 번째 빌드: 26s (build 캐시) 1m 33s → 26s, 약 72% 단축되었습니다.

도커 빌드

Docker 빌드도 비슷한 문제가 있었습니다.

Image

Image

  • 첫 번째 빌드: 1m 36s
  • 두 번째 빌드: 1m 43s 호스트의 Docker 데몬을 사용하니 당연히 캐시가 될 줄 알았는데, 전혀 개선되지 않았어요. 왜일까요?

문제 원인:

Host Docker Daemon
├── buildx-builder 컨테이너 (BuildKit) ← 여기서 빌드
│ └── 빌드 결과는 컨테이너 내부에만 존재
└── 일반 이미지들

docker/build-push-action은 내부적으로 BuildKit을 사용하는데, 빌드가 끝나면 builder 컨테이너도 사라지면서 캐시도 함께 날아갑니다.

해결 방법은 간단합니다. 로컬 캐시를 명시적으로 설정해주면 됩니다.

actions.yaml
- name: Setup ENV
run: |
RUNNER_WORK_PATH=$(dirname ${{ runner.tool_cache }})
echo "CACHE_PATH=$RUNNER_WORK_PATH/_cache/${{ github.repository }}" >> $GITHUB_ENV
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: testbuild:${{ github.sha }}
cache-from: type=local,src=${{ env.CACHE_PATH }}/docker
cache-to: type=local,dest=${{ env.CACHE_PATH }}/docker

결과:

Image

Image

  • 첫 번째 빌드: 1m 42s
  • 두 번째 빌드: 33s 1m 42s → 33s, 약 68% 단축되었습니다.

로그를 보면 CACHED가 잔뜩 찍히는 걸 볼 수 있어요.

#10 CACHED
#11 [build 2/10] WORKDIR /home/gradle/project
#11 CACHED
#12 [build 4/10] COPY gradlew gradlew.bat ./
#12 CACHED

캐시 디렉토리를 확인해보니 데이터가 잘 저장되어 있었습니다.

$ du -sh $CACHE_PATH/docker/*
133M blobs
4.0K index.json
4.0K ingest
4.0K oci-layout

ARC type=dind

Self-Hosted에서 캐시 최적화 원리를 파악했으니, 이제 본래 목표였던 ARC로 넘어갈 차례입니다.

처음부터 저는 유연한 빌드 환경 관리를 위해 ARC를 사용하려고 했습니다. 트래픽에 따라 Runner를 자동으로 스케일링하고, Kubernetes 위에서 통합 관리하는 게 목표였거든요. Self-Hosted Runner는 이 과정에서 캐시 최적화 원리를 파악하기 위한 실험 단계였습니다.

이제 Self-Hosted에서 배운 캐시 전략을 ARC DinD 모드에 적용해보겠습니다.

앱 빌드

최적화 전:

Image

Image

  • 첫 번째 빌드: 1m 48s
  • 두 번째 빌드: 1m 59s 역시나 캐시가 전혀 작동하지 않았습니다. DinD 모드의 동작 방식을 이해해야 했어요.

DinD 모드 특징:

  • ARC Runner Pod이 항상 대기 중
  • Job이 들어오면 해당 Pod에서 작업 수행
  • 작업이 끝나면 Pod은 삭제됨 Pod이 삭제되면 그 안의 모든 데이터가 사라집니다. 캐시를 유지하려면 PVC(PersistentVolumeClaim)를 사용해서 데이터를 보존해야 했어요.

캐시 경로 설정

Self-Hosted에서 캐시했던 것들을 다시 떠올려봅시다.

  • JDK (~/runner/_work/_tool)
  • Gradle (~/.gradle)
  • 빌드 결과물 ({project_root}/build{project_root}/.gradle) ~/runner/_work/_tool는 GitHub Actions context 변수인 ${{ runner.tool_cache }}로 접근할 수 있습니다.

문제는 ~/.gradle인데요, 이 경로를 보면 빌드 로그에서 GRADLE_USER_HOME 환경변수로 설정되어 있는 걸 볼 수 있어요.

Run ./gradlew build --no-daemon
shell: /usr/bin/bash -e {0}
env:
GRADLE_USER_HOME: /home/runner/.gradle # Gradle 홈 경로

저는 관리를 편하게 하기 위해 Gradle 홈도 tool_cache 아래로 옮기기로 했습니다.

actions.yaml
- name: Setup ENV
run: |
# Gradle 홈을 runner.tool_cache 아래로 변경
echo "GRADLE_USER_HOME=${{ runner.tool_cache }}/gradle" >> $GITHUB_ENV
# 캐시 경로 설정
RUNNER_WORK_PATH=$(dirname ${{ runner.tool_cache }})
echo "CACHE_PATH=$RUNNER_WORK_PATH/_cache/${{ github.repository }}" >> $GITHUB_ENV

이제 캐시할 경로는 두 가지입니다:

  • /home/runner/_work/_tool (JDK, Gradle)
  • /home/runner/_work/_cache (빌드 결과물, Docker 레이어)

PVC 생성

pvc.yaml
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: arc-runner-tool
namespace: arc-runners
spec:
accessModes:
- ReadWriteMany # 여러 Pod이 동시에 읽기/쓰기
storageClassName: nfs-client # NFS 사용
resources:
requests:
storage: 50Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: arc-runner-cache
namespace: arc-runners
spec:
accessModes:
- ReadWriteMany
storageClassName: nfs-client
resources:
requests:
storage: 100Gi

여기서 ReadWriteMany를 사용한 이유는 여러 Runner Pod이 동시에 같은 캐시를 공유하기 위해서입니다. 그래서 NFS 스토리지 클래스를 선택했어요.

ARC Helm Chart 설정

values.yaml
volumeMounts:
- name: tool
mountPath: /home/runner/_work/_tool
- name: cache
mountPath: /home/runner/_work/_cache
volumes:
- name: tool
persistentVolumeClaim:
claimName: arc-runner-tool
- name: cache
persistentVolumeClaim:
claimName: arc-runner-cache

최종 결과:

  • 첫 번째 빌드: 1m 48s
  • 두 번째 빌드: 32s 1m 48s → 32s, 약 82% 단축되었습니다.

빌드 로그도 로컬과 동일하게 up-to-date를 보여줍니다.

BUILD SUCCESSFUL in 11s
23 actionable tasks: 1 executed, 22 up-to-date

도커 빌드

Docker 빌드는 Self-Hosted에서 사용했던 로컬 캐시 설정을 그대로 적용하면 됩니다. 이미 ${{ env.CACHE_PATH }}/docker 경로에 PVC를 마운트했으니까요.

actions.yaml
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: testbuild:${{ github.sha }}
cache-from: type=local,src=${{ env.CACHE_PATH }}/docker
cache-to: type=local,dest=${{ env.CACHE_PATH }}/docker

결과:

Image

Image

  • 첫 번째 빌드: 3m 4s (중간에 Dockerfile 변경으로 수행시간이 조금 길어졌습니다.)
  • 두 번째 빌드: 39s 로그에서도 CACHED가 잘 보입니다.
#14 [stage-1 2/7] WORKDIR /app
#14 CACHED
#15 [stage-1 6/7] COPY --from=builder /workspace/build/libs/application/ ./
#15 CACHED
#16 [builder 2/10] WORKDIR /workspace
#16 CACHED

ARC type=kubernetes (불가능)

“최신 방법이니까 좋겠지”라는 생각으로 처음 선택했던 Kubernetes 모드였습니다. 하지만 결론부터 말하면 로컬 캐시를 사용할 수 없는 구조였어요.

Kubernetes 모드의 동작 방식

Runner Pod (항상 실행 중)
└─ tool, cache PVC를 마운트할 수 있음
└─ GitHub에서 job 수신
└─ Kubernetes API로 새로운 Job Container Pod 생성
Job Container Pod (workflow 실행 중에만 존재)
└─ work volume만 마운트됨
└─ tool, cache PVC는 마운트되지 않음 ❌

핵심 문제:

Runner Pod의 values.yaml에서 아무리 volume을 정의해도, 실제 빌드가 실행되는 Job Container Pod에는 전달되지 않습니다.

values.yaml
# Runner Pod에만 적용됨
volumes:
- name: tool
persistentVolumeClaim:
claimName: arc-runner-tool
- name: cache
persistentVolumeClaim:
claimName: arc-runner-cache

실제로 Job Container Pod을 확인해보면:

$ kubectl get pod -n arc-runners <job-container-pod> -o yaml | grep -A 20 "volumes:"
volumes:
- name: work
persistentVolumeClaim:
claimName: arc-runners-xxx-work
# tool과 cache가 없음!

/home/runner/k8s/index.js 스크립트가 Job Container Pod을 생성할 때, containerMode.kubernetesModeWorkVolumeClaim에 정의된 work volume만 전달하도록 하드코딩되어 있었습니다.

Workflow에서 container.volumes를 사용해도 마찬가지였어요.

actions.yaml
container:
image: gradle:8.14.3-jdk21
volumes:
- tool:/_tool # Runner의 volume을 참조하려 했으나
- cache:/_cache # Job Container Pod에 전달되지 않음

왜 이런 설계인가?

ARC Repository의 이슈들을 찾아보니 많은 사람들이 같은 문제를 겪고 있었습니다. 하지만 maintainer들은 이것이 의도된 동작이라고 명확히 밝혔어요.

Kubernetes 모드는 각 job을 완전히 격리된 환경에서 실행하는 것이 목적입니다. 보안과 재현성을 위해 job 간 상태를 공유하지 않는 거죠.

결론

로컬 캐시를 활용하려면 DinD 모드를 사용해야 합니다. DinD 모드에서는 Runner와 Job Container가 같은 Pod 내에서 실행되므로 volume이 자연스럽게 공유됩니다.

처음에 “완전히 격리된 클린한 환경”에 끌려 선택했던 Kubernetes 모드는 제 목적에는 맞지 않는 방식이었습니다.

마치며

GitHub Actions CI 빌드 시간을 평균 70% 단축시킨 여정을 돌아보니, 결국 빌드 도구들의 캐시 메커니즘과 각 환경의 특성을 정확히 이해하는 것이 가장 중요했습니다.

최종 결과 정리:

환경최적화 전최적화 후단축률
Self-Hosted 앱 빌드1m 33s26s72%
Self-Hosted 도커 빌드1m 42s33s68%
ARC DinD 앱 빌드1m 48s32s82%
ARC DinD 도커 빌드3m 4s39s79%

핵심 교훈:

  1. 원격 캐시가 항상 빠른 건 아닙니다: GitHub Actions의 원격 캐시는 네트워크에 의존하기 때문에 환경에 따라 오히려 느릴 수 있습니다. 제 경우에는 네트워크 업로드/다운로드가 병목이 되어 로컬 캐시가 훨씬 효율적이었어요.
  2. 완벽한 격리 vs 성능: Kubernetes 모드는 완전히 격리된 환경을 제공하지만 캐시를 공유할 수 없고, DinD는 격리 수준은 낮지만 캐시를 공유해서 성능을 낼 수 있습니다. 제 경우엔 빌드 성능이 더 중요했어요. 모든 장점을 다 가질 순 없고, 무엇을 우선순위로 둘 것인가의 trade-off를 고려해야 합니다.
  3. 빌드 도구의 캐시 동작을 이해하기: Gradle, Docker BuildKit 등 각 빌드 도구가 어떻게 캐시를 사용하는지 이해하면, CI 환경에서도 같은 방식으로 캐시를 구성할 수 있습니다.

운영 시 주의사항

이번 최적화는 빌드 간 캐시를 공유하기 때문에, Kubernetes 모드처럼 완전히 격리된 빌드 환경을 보장하지 않습니다. 운영 환경에서는 이 점을 주의해야 합니다.

  • 여러 Runner가 캐시를 동시에 덮어쓰거나 잘못된 캐시를 참조할 수 있음
  • 캐시 키 설정을 더 세밀하게 조정하거나, 필요시 캐시를 무효화하는 전략 필요 저는 현재 이 설정으로 운영 중이지만, 혹시 모를 문제를 대비해 빌드 결과를 면밀히 관찰하고 있습니다. 완벽한 시스템은 아니지만, CI 대기 시간이 70% 줄어드니 개발 경험이 확실히 좋아졌습니다. 앞으로도 지속적으로 모니터링하며 개선해나갈 예정입니다.