Byoo.log

배포 환경이 바뀔 때 생긴 문제들

2026-09-20

업무를 수행하면서, GitHub Actions 워크플로를 수정하는 일은 거의 없지만, 수정하는 과정에서 알게 된 사실들과 어떻게 수정했는지 정리한 기록입니다.

이 글은 그 변경 지점 네 개를 정리한 기록입니다. 각각을 무엇이 바뀌었나 → 뭐가 문제였나 → 원인이 뭐였나 → 어떻게 해결했나 순서로 씁니다.

무엇을 바꿨나 그래서 무엇이 문제가 됐나
① 릴리즈 트리거를 커밋 → 태그 푸시할 때마다 릴리즈가 생겨 버전이 끝없이 쌓임
② 빌드 대상에 윈도우 추가 셸 문법과 캐시 경로가 OS마다 다름
③ 배포처를 자체 서버 → Vercel 잡을 쪼개자 실패 알림이 안 오고, 의존성 설치가 깨짐
④ 러너를 GitHub → 사내 서버 캐시 설정 한 줄이 잡마다 85초를 쓰는 손해로 바뀜

0. 시작 전에: GitHub Actions 용어 정리

GitHub Actions는 **"저장소에 무슨 일이 생기면, 어딘가의 빈 컴퓨터를 하나 켜서, 내가 적어둔 명령을 순서대로 실행해주는 서비스"**입니다. 우선 여섯 개만 알면, 나머지는 나올 때마다 설명하겠습니다.

용어 뜻 비유
워크플로(workflow) .github/workflows/*.yml 파일 하나. 자동화 한 벌 요리 레시피 한 장
잡(job) 워크플로 안의 큰 단위. 잡 하나당 컴퓨터 하나를 새로 받음 레시피 안의 "반죽 만들기", "굽기"
스텝(step) 잡 안에서 실행되는 명령 한 줄 "밀가루 200g 넣기"
러너(runner) 실제로 명령이 돌아가는 컴퓨터 주방
트리거(trigger) 언제 워크플로를 돌릴지 정하는 조건. on: 아래에 적음 요리를 시작하는 신호
액션(action) 남이 만들어둔 스텝 묶음. uses:로 가져다 씀 사서 쓰는 완제품 소스

여기서 뒤에 나올 변경 ④를 이해하기 위한 사전 전제는 아래와 같습니다.

러너는 기본적으로 "매번 새로 만들어지는 빈 컴퓨터"다.

runs-on: ubuntu-latest라고 쓰면, GitHub이 잡을 실행할 때마다 우분투가 깔린 가상 머신을 새로 하나 띄우고, 잡이 끝나면 통째로 버립니다. 어제 설치한 node_modules도, 방금 전 잡이 받아둔 파일도 남아 있지 않습니다.

그래서 캐시가 필요합니다. 잡이 끝나기 전에 "이 폴더 좀 보관해줘"라고 맡겨두면, 다음 잡이 시작할 때 그 폴더를 내려받아 복원해줍니다. 매번 새 컴퓨터라는 전제 때문에 생긴 장치입니다.


1. 변경 ① — 릴리즈 트리거를 커밋에서 태그로

배경

Electron 데스크톱 앱을 배포하는 파이프라인입니다. 사용자가 쓰는 PC는 대부분 윈도우이고, 개발은 맥에서 합니다.

기술 스택은 Nextron(Next.js + Electron)입니다. Electron은 웹 기술(HTML/CSS/JS)로 데스크톱 앱을 만드는 프레임워크로, 안에 크롬 브라우저 하나가 통째로 들어 있습니다.

문제: 푸시할 때마다 릴리즈가 쌓인다

이 파이프라인은 제가 처음 맡아 작성했습니다. 첫 구성은 단순했습니다.

  • 트리거가 main 브랜치 push — main에 커밋이 올라가면 워크플로가 돈다
  • 버전을 package.json에서 읽어 태그를 만듦 — 그 버전으로 릴리즈를 생성한다

처음 푸시했을 때는 의도대로 릴리즈가 만들어지는 것까지 확인했습니다. 문제는 그 다음부터였습니다. 푸시할 때마다 릴리즈가 하나씩 생기다 보니, 오타 하나 고친 커밋에도 릴리즈가 남았습니다. 버전을 올리지 않고 커밋을 두 번 하면 같은 태그의 릴리즈를 덮어쓰기까지 했습니다.

며칠 만에 릴리즈 목록은 의미 없는 항목으로 채워졌고, 목록만 봐서는 실제로 내보낸 버전이 어느 것인지 알 수 없는 상태가 됐습니다.

원인: 릴리즈 시점을 커밋이 정하고 있었다

원인은 릴리즈 시점을 사람이 아니라 커밋이 정하고 있었다는 점입니다. main에 뭔가 올라가는 것과 "이 버전을 내보내겠다"는 판단은 전혀 다른 일인데, 둘이 묶여 있으니 버전 관리가 성립하지 않았습니다.

해결: 태그를 트리거로

name: Release Build
 
on:
  push:
    tags:
      - "v*" # v0.2.14 처럼 v로 시작하는 태그를 push했을 때만
  workflow_dispatch: # 그리고 GitHub UI에서 수동 실행
 
permissions:
  contents: write # 릴리즈를 만들려면 쓰기 권한이 필요하다
 
env:
  NODE_VERSION: "22"

이렇게 하면 릴리즈가 의도적인 행동이 됩니다. 코드를 아무리 푸시해도 아무 일도 안 일어나고, git tag v0.2.14 && git push --tags를 했을 때만 빌드가 돕니다.

workflow_dispatch는 "GitHub 웹 화면의 버튼으로도 실행할 수 있게 해줘"라는 뜻입니다. 태그를 잘못 밀었을 때 다시 돌릴 수단이 필요해서 같이 넣었습니다.

permissions: contents: write도 짚고 갑니다. 워크플로가 저장소에 뭔가를 쓰려면(릴리즈 생성 포함) 이 권한이 필요합니다. 기본 토큰(GITHUB_TOKEN)의 권한은 저장소·조직 설정에 따라 다른데, 2023년 2월 이후 만들어진 저장소는 기본이 읽기 전용이라 이걸 안 적으면 403 Forbidden이 납니다.

덤: 태그 이름만으로 정식/시험 릴리즈 구분

버전 번호를 붙이는 국제 관례인 SemVer(1.2.3처럼 세 자리로 쓰는 방식)에는 v1.2.3-beta처럼 뒤에 하이픈과 꼬리표를 붙이면 "정식 이전 버전"이라는 관례가 있습니다. 이걸 그대로 신호로 썼습니다.

prerelease: ${{ contains(github.ref_name, '-') }}

github.ref_name은 트리거된 태그 이름(v0.2.14)입니다. 하이픈이 들어 있으면 true가 되어 GitHub이 그 릴리즈를 prerelease(사전 배포)로 표시합니다.

  • v0.2.14 → 정식 릴리즈
  • v0.2.14-test → 시험용. 목록에 "Pre-release" 딱지가 붙어 정식 릴리즈와 구분됨

별도 설정 파일이나 입력값 없이, 태그를 어떻게 이름 짓느냐만으로 동작이 갈립니다.


2. 변경 ② — 빌드 대상에 윈도우를 추가

무엇이 바뀌었나

개발은 맥에서 하는데 사용자 PC는 윈도우입니다. 그래서 맥과 윈도우를 둘 다 빌드해야 했습니다.

문제: OS마다 달라지는 것이 많았다

OS 두 개에서 같은 빌드를 돌릴 때 보통은 **매트릭스(matrix)**를 씁니다. "이 잡을 목록의 값마다 하나씩 복제해서 돌려줘"라는 기능입니다.

# 흔히 이렇게 씁니다
strategy:
  matrix:
    os: [macos-14, windows-latest]
runs-on: ${{ matrix.os }}

한 벌만 쓰면 되니 간결해 보입니다. 그런데 실제로 맞춰보니 OS별로 달라지는 항목이 많았습니다.

항목 macOS Windows
기본 셸 bash PowerShell (pwsh)
경로 구분자 / \
Electron 캐시 위치 ~/Library/Caches/electron ~\AppData\Local\electron\Cache
산출물 .dmg .exe

매트릭스로 묶으면 잡 안에 if: matrix.os == 'windows-latest' 분기가 계속 늘어납니다.

해결 1: 잡을 두 개로 나눴다

그래서 매트릭스를 쓰지 않고 잡을 두 벌 적었습니다.

jobs:
  build-mac:
    runs-on: macos-14
    # ...
 
  build-win:
    runs-on: windows-latest
    # ...

중복이 조금 생기지만 중복보다 분기가 더 비싼 경우라고 판단했습니다. 한 벌로 합치는 것보다 두 벌을 나란히 두는 쪽이 읽기도 고치기도 편했습니다.

해결 2: 윈도우 잡에 shell: bash를 명시

**셸(shell)**은 터미널에 친 명령을 해석해 실행해주는 프로그램입니다. 명령어 문법이 셸마다 다릅니다.

리눅스·맥 러너의 기본 셸은 bash지만, 윈도우 러너의 기본 셸은 PowerShell(정확히는 PowerShell 7, 명령어로는 pwsh)입니다. 그래서 아무 생각 없이 bash 문법을 쓰면 깨집니다.

버전을 뽑아 다음 스텝에 넘기는 이 코드가 대표적입니다.

- name: Extract version
  id: version
  shell: bash # ← 윈도우 잡에는 이 한 줄이 반드시 필요하다
  run: |
    echo "app_version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"

$GITHUB_OUTPUT은 스텝끼리 값을 넘기는 통로입니다. key=value 형식으로 써두면 뒤 스텝에서 ${{ steps.version.outputs.app_version }}으로 꺼내 쓸 수 있습니다.

문제는 $변수 문법이 bash 것이라는 점입니다. PowerShell에서 환경변수는 $env:GITHUB_OUTPUT으로 씁니다. 여기서 한 단계 더 들어가면 인코딩 문제까지 나옵니다. 윈도우에는 PowerShell이 두 종류 있습니다.

  • PowerShell 7 (pwsh) — 러너의 기본값. 파일 출력 인코딩이 UTF-8이라 >>를 그냥 써도 됩니다.
  • Windows PowerShell 5.1 (shell: powershell) — 예전 버전. 파일 출력 기본 인코딩이 UTF-8이 아니라 UTF-16입니다. 그대로 쓰면 러너가 읽지 못하는 파일이 만들어집니다.
# Windows PowerShell 5.1에서는 인코딩을 명시해야 한다
"app_version=$VERSION" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append

저는 그냥 shell: bash를 명시하는 쪽을 택했습니다. GitHub의 윈도우 러너에는 Git for Windows가 딸려 오고, shell: bash를 쓰면 거기 포함된 bash가 실행됩니다. 그러면 맥 잡과 완전히 같은 코드를 쓸 수 있습니다. OS 차이를 하나 줄이는 선택입니다.

해결 3: 캐시 경로를 OS별로 분리

Electron 앱에는 크롬 브라우저와 Node.js 런타임이 통째로 들어갑니다. 이 덩어리가 electron 패키지를 설치할 때 인터넷에서 내려받는 Electron 바이너리이고, OS·아키텍처별로 100MB 안팎입니다. 러너는 매번 빈 컴퓨터라 이걸 실행할 때마다 새로 받습니다.

캐시 없이 돌리면 빌드 시간의 상당 부분이 그냥 다운로드 대기입니다.

그런데 흔히 쓰는 actions/setup-node의 캐시 옵션은 이걸 캐시해주지 않습니다.

- uses: actions/setup-node@v4
  with:
    node-version: "22"
    cache: npm # 이 한 줄

cache: npm이 캐시하는 것은 npm의 전역 캐시 디렉터리입니다. 리눅스·맥에서는 ~/.npm, 윈도우에서는 %LocalAppData%\npm-cache입니다. npm이 레지스트리에서 받아온 패키지 압축 파일을 모아두는 곳이죠.

Electron 바이너리는 여기 없습니다. Electron은 npm으로 설치될 때 postinstall 스크립트가 GitHub 릴리즈에서 별도로 바이너리를 받아 전혀 다른 위치에 둡니다.

그래서 캐시를 두 층으로 깔았습니다.

- uses: actions/setup-node@v4
  with:
    node-version: ${{ env.NODE_VERSION }}
    cache: npm # ① npm 패키지 캐시
 
- name: Cache Electron binaries # ② Electron 바이너리 캐시
  uses: actions/cache@v4
  with:
    path: |
      ~/Library/Caches/electron
    key: electron-mac-${{ hashFiles('package-lock.json') }}
    restore-keys: electron-mac-
 
- name: Install dependencies
  run: npm ci # install이 아니라 ci

그리고 윈도우 잡의 같은 스텝은 이렇게 됩니다.

- name: Cache Electron binaries
  uses: actions/cache@v4
  with:
    path: |
      ~\AppData\Local\electron\Cache
    key: electron-win-${{ hashFiles('package-lock.json') }}
    restore-keys: electron-win-

경로 구분자가 /와 \로 다른 것은 물론이고, 디렉터리 구조 자체가 다릅니다. 맥은 Caches/electron, 윈도우는 electron/Cache로 순서까지 뒤집혀 있습니다. 매트릭스로 묶었다면 이 스텝만으로도 if 분기가 하나 더 생겼을 겁니다.

캐시 키에 -mac- / -win-을 넣은 것도 중요합니다. 캐시는 키 문자열로만 구분되기 때문입니다. 두 OS가 같은 키를 쓰면 먼저 끝난 잡이 그 키를 차지하고, 캐시는 한 번 만들어지면 내용을 바꿀 수 없어서 나중 잡의 저장은 무시됩니다. 그러면 다음 실행부터 두 OS가 모두 그 하나의 캐시를 복원하게 됩니다. 맥 잡이 저장한 캐시를 윈도우 잡이 복원하면, 쓸 수 없는 파일이 엉뚱한 경로에 풀립니다.

참고: key와 restore-keys의 차이

캐시 설정에서 가장 헷갈리는 부분이라 짚고 갑니다.

  • key(정확히 일치): 이 키와 똑같은 캐시가 있으면 복원합니다. 없으면 잡이 끝날 때 이 키로 새로 저장합니다.
  • restore-keys(앞부분 일치): key가 안 맞았을 때의 대비책입니다. 여기 적은 문자열로 시작하는 캐시 중 가장 최근 것을 복원합니다.

hashFiles('package-lock.json')은 그 파일 내용을 해시로 바꾼 값이라, 의존성이 하나라도 바뀌면 키가 달라집니다.

restore-keys가 없으면 패키지 하나만 추가해도 캐시가 완전히 미스가 되어 Electron 바이너리 100MB를 처음부터 다시 받습니다. 하지만 패키지 하나 바뀐다고 Electron 버전까지 바뀌는 건 아닙니다. restore-keys: electron-win-을 넣어두면 직전 캐시를 일단 가져다 쓰고, 정말 바뀐 것만 새로 받습니다.

한 줄 요약 — key는 "정확히 이것", restore-keys는 "없으면 비슷한 거라도".


3. 변경 ③ — 배포처를 자체 서버에서 Vercel로

무엇이 바뀌었나

웹 서비스 쪽은 원래 러너 자체가 배포 대상 서버였습니다. 사내 서버에 러너를 설치해두고, 그 서버 위에서 빌드한 결과물을 배포 디렉터리로 복사하는 방식이었습니다. 백업 만들고, 디렉터리 비우고, 파일 복사하고, 권한 바꾸고, PM2(Node 프로세스 관리 도구)로 재시작하고, 마지막에 헬스체크(정상적으로 떴는지 확인하는 요청)를 보내는 100줄 넘는 스텝들이었습니다.

이걸 Vercel CLI 몇 줄로 바꿨습니다.

- name: Vercel 환경 가져오기
  run: vercel pull --yes --environment=preview --git-branch=${{ github.ref_name }} --token=${{ secrets.VERCEL_TOKEN }}
 
- name: Preview 빌드
  run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
 
- name: Vercel 배포
  id: deploy
  # vercel deploy는 배포 URL을 출력하므로, 뒤 잡에서 쓰도록 output으로 담아둔다
  run: echo "url=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})" >> "$GITHUB_OUTPUT"

Vercel의 Git 연동 자동 배포를 쓰지 않고 굳이 CLI로 돌린 이유는, 타입체크와 테스트를 통과한 커밋만 배포되게 하기 위해서입니다. 배포 주도권을 CI가 쥐는 구조입니다.

이때 잡도 검증 → 배포 → 알림 세 개로 쪼갰습니다. 그리고 러너를 self-hosted(사내 서버)에서 ubuntu-latest로 바꿨습니다. 배포 대상 서버 위에서 실행될 이유가 사라졌으니까요.

여기서 문제가 세 개 나왔습니다.

문제 1: 잡을 쪼개니 실패 알림이 어긋났다

잡 하나였을 때는 알림 조건을 if: success() / if: failure() / if: cancelled() 세 갈래로 쓰고 있었습니다. 그런데 잡을 셋으로 나누는 순간 이게 의도대로 동작하지 않습니다.

원인은 skipped가 failure가 아니라는 점입니다.

검증 잡이 실패하면 배포 잡은 실행되지 않습니다. 이때 배포 잡의 상태는 failure가 아니라 **skipped**입니다. 잡 단위로 if: failure()를 쓰면 이 구분이 뭉개집니다.

그래서 상태 함수 대신 각 잡의 결과를 직접 비교하는 형태로 바꿨습니다.

notify:
  needs: [verify, deploy]
  if: always() # 앞 잡이 실패해도 알림 잡은 무조건 실행
  steps:
    - name: 성공 알림
      if: needs.verify.result == 'success' && needs.deploy.result == 'success'
      # ...
 
    - name: 실패 알림
      # verify 실패 시 deploy는 'skipped'가 되므로, 명시적 'failure'만 알림
      if: needs.verify.result == 'failure' || needs.deploy.result == 'failure'
      # ...

if: always()가 핵심입니다. 이게 없으면 앞 잡이 실패했을 때 알림 잡도 같이 스킵되어, 실패했을 때 실패 알림이 오지 않습니다.

needs.<잡이름>.result에 들어갈 수 있는 값은 success / failure / cancelled / skipped 네 가지입니다. 네 가지가 있다는 걸 알고 나서야 조건을 제대로 쓸 수 있었습니다. 참고로 always()는 워크플로가 취소됐을 때도 실행되므로, 취소는 알리고 싶지 않다면 if: !cancelled()가 더 맞습니다.

문제 2: NODE_ENV: production이 devDependencies를 날린다

기존 운영 배포 워크플로에 이렇게 적혀 있었습니다.

env:
  NODE_ENV: "production"

"운영 배포니까 production이 맞겠지"라는 자연스러운 발상입니다. 그런데 npm ci가 이 값을 봅니다. NODE_ENV=production이면 npm ci는 devDependencies를 설치하지 않습니다.

문제는 CI에서 필요한 도구들 — TypeScript 컴파일러, 번들러(코드를 배포용 파일로 묶어주는 도구), 테스트 러너 — 이 전부 devDependencies에 있다는 점입니다.

이 워크플로는 설치 명령에 --production=false를 붙여 그 동작을 막아두고 있었습니다. 동작은 했지만, 본질과 상관없는 값 하나 때문에 우회 옵션을 달고 있는 모양이었습니다. 워크플로를 다시 쓰면서 NODE_ENV 줄과 우회 옵션을 함께 걷어냈습니다.

배포 결과물이 운영용인 것과, 빌드하는 동안 어떤 도구가 필요한지는 별개의 문제입니다.

문제 3: 사내 패키지 인증이 두 단계였다

사내 공용 패키지를 GitHub Packages(조직 전용 비공개 npm 저장소)에서 받아 쓰고 있었습니다. npm ci가 인증 오류로 실패해서, 먼저 토큰을 주입했습니다.

- name: Install dependencies
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 추가
  run: npm ci

이것만으로는 부족합니다. 토큰은 있는데, 그 토큰에 패키지를 읽을 권한이 없기 때문입니다.

permissions:
  contents: read
  packages: read # 이게 있어야 GitHub Packages를 읽을 수 있다

여기서 같이 알아둘 게 있습니다. 워크플로에 permissions 블록을 한 번이라도 적으면, 거기 적지 않은 권한은 전부 제거됩니다. 필요한 걸 빠짐없이 적어야 합니다.

즉 토큰 주입과 권한 부여는 별개의 두 단계이고, 둘 다 해야 동작합니다.


4. 변경 ④ — 러너를 GitHub에서 사내 서버로

무엇이 바뀌었나

한 달 전 self-hosted → ubuntu-latest로 옮겼던 러너를, 다시 self-hosted로 되돌렸습니다.

 verify:
   name: 검증
-  runs-on: ubuntu-latest
+  runs-on: self-hosted

이유는 기술적인 게 아니었습니다. GitHub 호스티드 러너의 과금이 차단되어 잡이 아예 시작되지 않았습니다.

문제 1: 안 도는 걸 이틀 동안 몰랐다

배포 워크플로들은 7월 13일에 옮겼는데, 워크플로 하나를 ubuntu-latest에 남겨뒀습니다. 그게 이틀 동안 실행되지 않았습니다.

원인은 단순했습니다. 실패 알림은 "잡이 실행되다가 실패했을 때" 발송되는 건데, 애초에 잡이 시작을 못 했으니 알림도 없었던 겁니다.

교훈 — 실패 알림은 "돌다가 죽는 경우"만 잡습니다. 아예 안 도는 경우는 별도로 감시해야 합니다.

문제 2: Node 셋업 한 스텝이 85초

러너를 옮기고 나서, 로그를 보니 Node 셋업 스텝 하나가 85초를 먹고 있었습니다. Node를 설치하는 데 85초가 걸릴 리는 없습니다.

원인: 0장의 전제가 깨졌다

러너는 기본적으로 "매번 새로 만들어지는 빈 컴퓨터"다.

self-hosted 러너에서는 이게 기본적으로 성립하지 않습니다. self-hosted 러너는 우리가 직접 켜둔 서버에 에이전트 프로그램을 설치해놓은 것입니다. 잡이 끝나도 그 서버는 계속 켜져 있고, 파일도 그대로 남습니다. 저는 이걸 상주 러너라고 부르고 있습니다.

(--ephemeral 옵션으로 잡 하나마다 러너를 폐기하도록 구성할 수도 있습니다. 다만 서버에 그냥 설치해서 쓰는 기본 구성은 이렇게 파일이 계속 쌓입니다.)

그러면 무슨 일이 벌어질까요?

npm 전역 캐시 디렉터리가 이미 로컬 디스크에 채워져 있습니다. 어제 돌린 잡이 남겨놓은 것이거든요.

그런데 cache: npm은 그런 사정을 모릅니다. 자기 할 일을 성실히 합니다. GitHub의 캐시 저장소에서 압축본을 네트워크로 내려받아서, 이미 올바른 내용이 들어 있는 로컬 디렉터리 위에 덮어씁니다.

프로젝트가 커지면서 그 디렉터리는 1.5GB가 되어 있었습니다. 85초의 정체가 이것이었습니다.

구분 GitHub 러너 (ubuntu-latest) 상주 러너 (self-hosted)
잡 시작 시 npm 캐시 상태 비어 있음 이미 채워져 있음
cache: npm이 하는 일 네트워크로 받아와 채움 네트워크로 받아와 덮어씀
효과 다운로드 시간 절약 85초 순수 낭비

같은 설정이 한 환경에서는 최적화이고 다른 환경에서는 손해입니다. 설정이 틀린 게 아니라, 설정이 기대던 전제가 바뀐 것입니다.

해결: 캐시를 지우고, 대신 다른 걸 캐시

cache: npm을 지웠습니다.

  - name: Node 셋업
+   # cache: 'npm' 미사용 — 상주 러너의 ~/.npm 은 로컬 디스크에 warm 하고,
+   # node_modules 를 별도 캐싱하므로 1.5GB ~/.npm 을 GitHub 캐시에서 받는 왕복이 순수 낭비였음
    uses: actions/setup-node@v5
    with:
      node-version-file: '.nvmrc'
-     cache: 'npm'

주석을 길게 단 이유가 있습니다. 이건 일반적인 권장 사항의 반대라서, 아무 설명 없이 지워두면 나중에 누군가(저 자신 포함) "왜 캐시를 안 쓰지?" 하며 되돌릴 수 있다고 봤습니다.

그리고 정말 재사용 가치가 있는 것으로 교체했습니다.

- name: node_modules 캐시
  # lockfile + node 버전이 그대로면 hit → npm ci 스킵. verify/deploy 두 잡이 같은 캐시 공유
  id: cache-deps
  uses: actions/cache@v4
  with:
    path: node_modules
    key: ${{ runner.os }}-nodemodules-${{ hashFiles('.nvmrc', 'package-lock.json') }}
 
- name: 의존성 설치
  if: steps.cache-deps.outputs.cache-hit != 'true' # 캐시가 맞으면 아예 건너뜀
  run: npm ci

차이가 핵심입니다.

  • cache: npm은 설치 재료를 캐시합니다. 복원해도 npm ci는 여전히 돌아야 합니다.
  • 이건 설치 결과(node_modules)를 캐시합니다. 복원되면 npm ci 자체를 건너뜁니다.

같은 논리를 한 군데 더 적용했습니다. 배포에 쓰는 Vercel CLI를 매번 전역 설치하고 있었는데, 상주 러너에는 어제 설치한 게 그대로 남아 있습니다.

  - name: Vercel CLI 설치
-   run: npm i -g vercel@54
+   # 상주 러너엔 전역 설치가 남아있으므로 이미 v54면 재설치 생략
+   run: vercel --version 2>/dev/null | grep -q '54\.' || npm i -g vercel@54

A || B는 "A가 실패하면 B를 실행"이라는 셸 문법입니다. 버전 확인이 성공하면 설치를 건너뛰고, 실패하면 설치합니다.

덤: 이때 정리한 캐시 키 설계

캐시를 다시 깔면서 성격이 다른 두 개가 생겼는데, 키 설계를 정반대로 했습니다.

node_modules — 환경을 구분하지 않고, 대비책도 두지 않는다

path: node_modules
key: ${{ runner.os }}-nodemodules-${{ hashFiles('.nvmrc', 'package-lock.json') }}
# restore-keys 없음

키에 환경(dev/staging/prod) 구분이 없습니다. npm ci의 결과물은 락파일과 Node 버전에만 좌우되기 때문입니다. 개발이든 운영이든 설치되는 패키지는 완전히 같으니 세 워크플로가 같은 캐시를 나눠 씁니다.

hashFiles에 .nvmrc(Node 버전을 적어둔 파일)도 넣었습니다. node_modules 안에는 Node 버전에 맞춰 컴파일된 네이티브 모듈이 있을 수 있어서, Node 메이저 버전이 바뀌면 깨지기 때문입니다.

restore-keys도 없습니다. 앞에서는 넣으라고 했는데 여기선 뺐습니다. node_modules는 부분 복원의 이득이 없기 때문입니다. restore-keys로 복원되면 cache-hit이 'true'가 아니라서(정확히 key가 맞았을 때만 'true'입니다) npm ci가 그대로 돌고, npm ci는 시작할 때 node_modules를 지웁니다. 내려받은 수백 MB가 곧바로 삭제되는 셈입니다.

빌드 캐시 — 환경별로 나누고, 매번 갱신한다

path: .vercel/cache
key: ${{ runner.os }}-vercelbuild-dev-${{ github.sha }}
restore-keys: |
  ${{ runner.os }}-vercelbuild-dev-

이쪽은 dev / staging / prod 세 벌을 따로 뒀습니다. 빌드 캐시는 빌드 시점의 환경 변수가 결과물에 박혀 있기 때문입니다. API 주소는 개발용과 운영용이 다른데 이게 빌드 결과에 문자열로 들어갑니다. 개발 환경 캐시를 운영 빌드가 가져다 쓰면 운영 사이트가 개발 API를 호출할 수 있습니다.

github.sha(현재 커밋 해시)가 들어가서 이 키는 매번 반드시 미스가 납니다. 일부러 그렇게 한 것이고, 이 패턴을 **롤링 캐시(rolling cache)**라고 부릅니다.

  1. key는 항상 미스 → restore-keys가 발동해 가장 최근 빌드 캐시를 가져옴
  2. 그걸 기반으로 증분 빌드 (바뀐 부분만 다시 빌드)
  3. 잡이 끝나면 새 커밋 해시 키로 다시 저장

"항상 최신 상태를 이어받되, 항상 최신 상태로 갱신"됩니다. 빌드 캐시처럼 점점 나아지는 성격의 캐시에 맞는 형태입니다.

두 사례에서 뽑아낸 기준입니다.

질문 판단
무엇이 결과를 바꾸는가? 그것만 키에 넣는다. 락파일, Node 버전, 환경 이름. 커밋 해시는 롤링 캐시일 때만
오래된 캐시를 섞어 써도 안전한가? 안전하면 restore-keys로 대비책을 둔다. 이득이 없으면(= node_modules) 두지 않는다
환경마다 결과가 다른가? 다르면 키에 환경 이름을 넣는다. 안 넣으면 잘못된 결과물이 배포될 수 있다

5. 마치며

네 번의 변경을 늘어놓고 보니 공통점이 있었습니다.

설정 자체가 틀려서 생긴 문제는 없었습니다. cache: npm은 문서에서 권장하는 방식이고, if: failure()도 잡이 하나일 때는 맞는 조건입니다.

문제는 전부 그 설정이 기대던 전제가 바뀐 것이었습니다.

설정 이 전제 위에 서 있었다 무엇이 그 전제를 깼나
cache: npm 러너는 매번 새 컴퓨터다 상주 러너로 이전
if: failure() 잡이 하나다 잡을 셋으로 분리
NODE_ENV: production 설치는 배포용이다 빌드 도구가 devDependencies에 있음

그리고 이런 문제는 대체로 실패로 표시되지 않습니다. 잡이 아예 시작되지 않고, 캐시가 85초를 더 씁니다. 워크플로 화면은 성공 상태입니다.

그래서 CI 설정을 만질 때는 이 설정이 어떤 전제 위에 서 있고, 그 전제가 지금도 유효한지를 먼저 확인하게 됐습니다. 배포 방식이나 실행 환경을 바꿀 때 바꾸는 대상만 보지 않고, 그 위에 얹혀 있던 설정들을 한 번 훑어보는 편입니다.

그리고 한눈에 이해되지 않는 결정에는 주석으로 이유를 남깁니다. 나중에 누군가 원래대로 되돌리지 않도록요. 4장에서 cache: 'npm'을 지우며 붙였던 주석 두 줄이 그런 경우입니다.