Next.js 무중단 배포를 GitHub Actions 환경에서 직접 구성해보면서, 자연스럽게 여러 가지 기본적인 궁금증이 생겼다. 특히 “CI에서 캐시는 왜 쓰는 거지?”, “어떤 캐시가 효과가 있고 어떤 건 의미가 없지?” 같은 부분은 실제로 적용해보지 않으면 감이 잘 오지 않는다.
배포 자동화를 처음 구성할 때는 인터넷에 떠도는 스크립트나 AI가 만들어준 예시들을 그대로 가져다 쓰기 쉽지만, 결국 내 프로젝트의 구조와 환경에 맞는 선택을 할 수 있어야 한다는 걸 이번에 깊이 느꼈다.
그래서 배포 파이프라인을 직접 구현하면서, 과정 중에 생겼던 작은 궁금증들—pnpm 설치 방식, pnpm store 캐싱, .next/cache 캐싱, lockfile 전략 등—을 하나씩 검증해보며 정리해 보았다.
이번 글은 그 과정에서 이해한 내용과, 실제로 적용했을 때 어떤 효과가 있었는지 기록한 것이다.
- name: Install pnpm
run: npm install -g pnpm
이 방법은 절대절대 권장하지 않는다. npm을 통해서 pnpm을 설치하면 다음과 같은 문제가 있다.
느리다. npm자체가 느리다.
/usr/local/bin/pnpm 같은 곳에 symlink해야한다.pnpm-store 캐시의 연동이 좋지 않다.
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
run_install: false
이 방법은, github actions와 pnpm 공식문서에서도 권장하는 방식이다. 사실 위에서 npm -g pnpm을 쓰지 않아야 하는 이유에서 모든 정답이 나와있긴하지만, 다시한번 정리해보면,
https://github.com/pnpm/action-setup?utm_source=chatgpt.com
https://pnpm.io/continuous-integration?utm_source=chatgpt.com
- name: Get pnpm store directory
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
많은 개발자들이 pnpm을 사용하는 가장 큰 이유 중 하나는 설치 속도가 빠르기 때문이다. 실제로 동일한 환경에서 여러 번 사용해보면 npm보다 설치 속도가 확실히 빠른데, 이는 pnpm이 내부적으로 완전히 다른 방식으로 패키지를 관리하기 때문이다.
pnpm은 패키지를 프로젝트마다 중복해서 설치하지 않고, “전역 스토어 + 프로젝트별 링크” 구조를 사용한다.
pnpm install 을 하게 되면 다음과 같은 과정이 진행된다.
pnpm의 node_modules는 “가짜 node_modules” 구조로, 실제 패키지 파일은 전역 store에 있고 node_modules에는 하드링크/심볼릭 링크만 존재한다. 그래서 파일을 들여다 보면 다음과 같이 표시된다.

반면, npm으로 설치한 경우 실제 패키지 파일을 그대로 복사해서 두기때문에, 프로젝트 수가 많아진다면, 같은 패키지가 디스크에 여러 번 중복 될 수도 있고, 프로젝트가 커지면, 매우 느려질 수 있다.

pnpm을 사용한다면, 로컬 환경에서 특히, 여러 프로젝트가 pnpm의 store를 공유할 수 있고 store가 커질수록 install 속도도 상승해서 pnpm의 장점이 극대화 된다.
반면, GitHub Actions의 CI 환경에서는 매 실행마다 컨테이너가 초기화되기 때문에, 로컬처럼 여러 프로젝트가 하나의 pnpm store를 공유하는 구조는 사용할 수 없다.
다만, actions/cache로 pnpm store 자체를 복원해주면 로컬과 거의 동일하게 pnpm의 빠른 설치 속도를 그대로 가져올 수 있다. 실제로 이 구성을 적용해본 결과, 설치 시간이 평균 14초에서 4~5초로 감소해서 약 10초 이상의 개선 효과를 확인할 수 있었다.
pnpm install 을 운영환경에서 사용할때 쓰는 주요 옵션이 두가지가 있다. 하나씩 알아보자
이 옵션은 배포 환경에서 “예측 가능한 설치”를 강제로 보장한다.
lockfile과 package.json이 조금이라도 달라지면 의존성이 “미묘하게 다른 버전”으로 설치 될 수 있다. 기본적으로 pnpm은 lockfile이 package.json과 맞지 않으면 lockfile을 수정하려고 시도하기 때문이다. 이렇게 되면, 로컬에서는 되는데, CI환경에서는 되지 않거나 어제는 되는데 오늘은 안되는 문제가 발생할 수 있다.
그렇기 때문에, 운영/CI 환경에서는 파일 수정 금지, 새로운 diff 발생 금지, 잠재적 충돌 금지가 필요하다.
—fronze-lockfile은 lockfile을 절대 수정하지 말라는 의미이므로 CI에서 변조될 가능성을 제거하여 안정성과 예측 가능성을 확보하는게 좋다.
-prefer-offline 옵션은 설치 과정에서 캐시 우선 전략을 적용한다. 즉, 이미 존재하는 캐시된 패키지는 최신 여부 확인 없이 즉시 사용하고, 캐시에 없는 항목만 remote registry에서 다운로드한다.
이를 통해 다음과 같은 장점을 얻을 수 있다:
(최근 AWS 네트워크 이슈로 빌드가 막혀서 고생한 적이 있었는데, 이 옵션을 썼다면 야근을 피할 수 있었을지도… 😭)
Next.js 프로젝트를 CI 환경(GitHub Actions)에서 빌드하다 보면 가장 아까운 시간이 있다.
매번 새 컨테이너에서 ‘처음부터 다시’ 빌드하는 시간이다. 로컬에서는 빠른데 CI만 가면 빌드가 느려지는 이유도 여기에 있다. 그런데 사실 Next.js는 이미 자체 캐시(.next/cache)를 통해 빌드를 빠르게 할 수 있는 구조를 가지고 있다. 단지 CI에서 새로운 컨테이너이기 빌드하기 때문에 이 캐시를 재사용하지 못하는것 뿐이다.
Next.js의 빌드는 크게 다음 3단계로 이루어진다.
이때 .next/cache에는 이전 빌드에서 계산된 다양한 결과가 저장된다.
.next/cache/swc에 저장한다.next/image가 처리한 최적화 이미지가 다시 계산되지 않는다.즉, Next.js는 “안 바뀐 파일은 처리하지 않는 방식”으로 빌드 시간을 단축한다. 하지만 CI 환경에서는 매번 새로운 컨테이너가 뜨기 때문에, 이 캐시가 전혀 사용되지 않는다. 그래서 actions/cache로 .next/cache 자체를 캐싱하는 이유이다.
.next/cache를 actions/cache로 저장해 빌드 시간을 단축하는 것은 좋은 방법이다. 하지만 여기서 자연스럽게 이런 의문이 생길 수 있다. “Next의 캐시는 언제 무효화해야 하는게 좋을까? 코드는 늘 바뀌니까 `src//*`도 key에 넣어야 하는 거 아닌가?”**
결론부터 말하자면, src//* 은 key에 절대 넣으면 안 된다. Key는 아래와 같이 빌드 환경 자체가 달라지는 요소들만 포함**하는 것이 좋다.
pnpm-lock.yaml (또는 yarn.lock, package-lock.json)next.config.*tsconfig.json즉, 환경이 바뀌어 캐시가 재사용될 수 없는 경우만 key로 관리하는게 좋다.
src/**/*를 key에 넣으면 안 될까?src/**/* 의 해시가 전체적으로 바뀐다..next/cache를 한 번도 재사용하지 못함그렇기 때문에 src/*/을 key에 넣어버리면 .next/cache 캐싱은 사실상 의미가 없어지게 된다.
.next/cache를 찾지 못함 → 처음부터 빌드.next/cache를 생성next build 실행 → Next가 내부에서 이렇게 판단함:| 파일 | 캐시 속 해시 | 현재 소스 | 처리 |
|---|---|---|---|
| Button.tsx | v1 | v2 | 재컴파일 |
| Table.tsx | v1 | v1(변경 없음) | 캐시 재사용 |
즉, 캐시가 A 시점이더라도, Next는 내부적으로 모든 파일의 해시를 대조한다.
그래서 변경된 파일은 반드시 다시 컴파일된다. 캐시 때문에 잘못된 빌드가 나오는 일은 절대 없다.
여기서도 똑같다.
.next 폴더는 항상 최신 코드 기준으로 생성됨.next/cache를 Actions 캐시에 넣어서 빌드 속도를 줄이기 시작했을 때 가장 먼저 든 의문은 “코드는 계속 바뀌는데, 이렇게 오래된 캐시를 계속 써도 되는 걸까?”였다. 초기 빌드 이후 커밋이 쌓이면 캐시와 실제 코드의 차이가 점점 벌어질 텐데, 그게 빌드 결과물에 영향을 주거나 오히려 캐시를 무의미하게 만들지 고민이 있었다.
정확도 측면에서는 아무 문제도 없다. 오래된 캐시를 가져오더라도 Next.js는 매 빌드마다 변경된 파일과 의존성을 다시 확인한다. 캐시가 있으면 “안 바뀐 파일”만 스킵할 뿐이고, 바뀐 파일은 캐시를 무시하고 다시 컴파일한다. 그래서 캐시가 오래된 상태라도 잘못된 빌드가 나온 적은 단 한 번도 없었다.
영향이 생기는 부분은 속도였다. 변경된 코드가 많아질수록 Next가 “이건 캐시 쓰면 안 된다”라고 판단하는 영역이 늘어나기 때문에, 빌드 속도는 점점 레거시한 캐시를 가져올수록 약간씩 느려진다. 그래도 “캐시가 완전히 없는 빌드”와 비교하면 여전히 차이가 크다. 즉, 오래된 캐시라도 어느 정도 성능 이득은 계속 유지된다.
그래서 지금은 환경이 바뀌는 요소들(패키지 lockfile, next.config, tsconfig, OS 등)만 key에 넣고, 코드 변경으로는 key를 갱신하지 않는다. 코드를 key에 넣으면 캐시를 아예 못 쓸 수 있기 때문에, 대신 빌드 속도가 체감상 많이 느려졌다고 판단되는 시점이나 큰 리팩터링(?) 이후나 오래된 캐시를 너무 오래 썼다고 느껴질 때만, 수동으로 캐시를 날려 새로 쌓는방법을 채택했다.
만약 코드 변경이 잦은 환경이라면, 일정 주기로 캐시를 초기화하는 전략도 가능하다. 결국 프로젝트 특성과 배포 주기에 맞춰 캐시 갱신 주기를 선택하면 된다.
결론적으로, .next/cache를 캐싱한 뒤로 빌드 시간이 평균 50초~1분 정도 줄었다. 단순한 설정 변경만으로 이 정도 효과가 난 건 꽤 인상적이었다.
PM2에는 fork 모드와 cluster 모드라는 두 가지 실행 방식이 있다.
그리고 이 둘을 이해하려면 먼저 “Node.js가 어떻게 동작하는가?”를 알아야 한다. Next.js를 운영 환경에 배포할 때 PM2를 선택한 이유도 여기와 관련돼 있다. 우리 환경에서는 서버가 재부팅되거나 Node 프로세스가 죽는 일이 종종 있었는데, PM2는 이런 상황에서도 프로세스 감시와 자동 재시작을 간단한 명령어로 해결할 수 있어서 가장 간단한 해결책이였다.
Node는 기본적으로 “단일 스레드”로 동작한다. Next.js를 배포하면 결국 Node 런타임 위에서 하나의 서버 프로세스가 떠 있는 구조이고, 이 프로세스가 모든 요청을 처리한다. 사용자가 늘어나면 이 단일 프로세스가 모든 요청을 감당해야 하기 때문에 병목이 생길 수 있다.
PM2의 Fork 모드는 이 기본 구조 그대로이다. 말 그대로, 단일 Node 프로세스를 하나 실행하는 것이 전부다. 개인 프로젝트나 아주 작은 트래픽 환경에서는 Fork모드만으로 충분하다고 생각한다.
운영 환경이라면, 조금 다른 옵션을 고려해봐야한다고 생각한다. 아무리 사용자 수가 적다고 해도, 단일 스레드 서버는 장애 대응이나 성능 면에서 한계가 있다. PM2가 제공하는 Cluster 모드는 이런 문제를 해결하기 위한 기능이다.
클러스터 모드는 다음과 같이 동작한다.
즉, Cluster 모드는 Node의 단일 스레드 한계를 넘기 위한 PM2의 스케일링 전략이다. 운영 환경에서 안정성을 확보하려면 대부분 Cluster 모드를 사용한다.
여기서 중요한 개념이 바로 “인스턴스 수”이다. 인스턴스는 몇 개의 node 프로세스를 띄울 것인가를 의미한다.
그리고 이 인스턴스 개수는 CPU 코어 수를 초과할 수 없다. 예를 들어 EC2의 t3.micro는 vCPU가 2개 이기 때문에 PM2 클러스터 모드에서 최대 인스턴스 수는 2개가 된다.
꼭 그렇지는 않다. 나도 운영 환경에서 고민했던 부분이 바로 이 지점이었다.
인스턴스를 늘릴수록 요청 분산은 좋아지지만, 각 Node 프로세스가 메모리를 잡아먹기 때문에 비용이 증가한다. 특히 EC2처럼 리소스가 한정된 머신에서는 메모리 낭비로 이어질 수 있다. 예를 들어, 내가 만들었던 골프장 직원용 어드민 서비스는 트래픽이 폭발적으로 증가할 일이 없었기 때문에 최대 인스턴스 수를 2개까지만 두고 Cluster모드로 안정성을 확보했다.