Next + PM2 배포 과정 이해하기

별이·2025년 11월 20일

Next.js 무중단 배포를 GitHub Actions 환경에서 직접 구성해보면서, 자연스럽게 여러 가지 기본적인 궁금증이 생겼다. 특히 “CI에서 캐시는 왜 쓰는 거지?”, “어떤 캐시가 효과가 있고 어떤 건 의미가 없지?” 같은 부분은 실제로 적용해보지 않으면 감이 잘 오지 않는다.

배포 자동화를 처음 구성할 때는 인터넷에 떠도는 스크립트나 AI가 만들어준 예시들을 그대로 가져다 쓰기 쉽지만, 결국 내 프로젝트의 구조와 환경에 맞는 선택을 할 수 있어야 한다는 걸 이번에 깊이 느꼈다.

그래서 배포 파이프라인을 직접 구현하면서, 과정 중에 생겼던 작은 궁금증들—pnpm 설치 방식, pnpm store 캐싱, .next/cache 캐싱, lockfile 전략 등—을 하나씩 검증해보며 정리해 보았다.

이번 글은 그 과정에서 이해한 내용과, 실제로 적용했을 때 어떤 효과가 있었는지 기록한 것이다.

GHA에서 pnpm를 설치하는 두가지 방법

1. npm을 이용하여 전역으로 pnpm을 설치하는 방법

  - name: Install pnpm
		  run: npm install -g pnpm

이 방법은 절대절대 권장하지 않는다. npm을 통해서 pnpm을 설치하면 다음과 같은 문제가 있다.

  • 느리다. npm자체가 느리다.

    • npm의 의존성 알고리즘 자체가 느리다.
      • registry에서 pnpm 패키지를 다운로드해야하고,
      • pnpm cli내부 sub-dependencies를 처리하고,
      • global node_modules 경로 생성/검증 하고
      • 모든 dependency를 node_modules에 풀어서 설치해야하고
      • 실행 파일을 /usr/local/bin/pnpm 같은 곳에 symlink해야한다.
    • 이 과정을 매 빌드마다 반복해야 하니 느릴수 밖에 없다.
    • 특히, CI는 매번 컨테이너가 초기화된 상태라서 global npm node_modules이 없고 의존성이 많으면 더 느려진다.
  • pnpm-store 캐시의 연동이 좋지 않다.

    • “npm istall -g pnpm" 방식은 GitHub Actions의 toolcache와 연동되지 않는다.
    • 그래서 actions/cache로 ~/pnpm-store를 직접 캐시 설정할 수는 있지만,
    • pnpm 버전 / 경로 / store 경로 등을 전부 우리가 직접 맞춰줘야 하고,
    • pnpm/action-setup 처럼 깔끔하게 자동 연동되지 않는다.
    • 실무에서 보면, 관리 비용에 비해 얻는 이점이 적어서 사실상 캐시 활용이 좋지 않다고 보는게 좋다.
    • 일반적으로 npm 전역 설치 경로는 OS / Node /NVM 버전에 따라서 매번 달라지고 github actions은 당연히 이를 예측할수가 없기 때문이다. 💡 **Toolcache란,** CI에서 필요한 실행 파일(Node, pnpm 등)을 한 번 받아두고 다음 워크플로우에서 빠르게 재사용하기 위한 저장소이다. pnpm/action-setup같은 액션은 pnpm 바이너리를 이 toolcache에 넣어두고, 필요할 때마다 꺼내 쓰기 때문에 설치 속도가 매우 빠르다.

2. pnpm/action-setup을 사용하는 방법

  - name: Setup pnpm
    uses: pnpm/action-setup@v4
    with:
      version: 9
      run_install: false

이 방법은, github actions와 pnpm 공식문서에서도 권장하는 방식이다. 사실 위에서 npm -g pnpm을 쓰지 않아야 하는 이유에서 모든 정답이 나와있긴하지만, 다시한번 정리해보면,

  • pnpm/action-setup은 github이 관리하는 경로(ToolCache)에 pnpm을 설치한다.
    • GitHub actions가 이 pnpm 바이너리 위치를 정확히 알고 있기 때문에, 같은 버전의 pnpm을 다시 설치할 필요 없이 바로 재사용할 수 있다.
  • pnpm/action-setup은 실제로는 “설치”를 하지 않는다.
    • GHA의 toolcache에서 해당 버전의 pnpm 바이너리가 있는지 확인한다.
    • 있다면, 그대로 꺼내쓰고
    • 없으면, 공식 서버에서 압축된 pnpm 바이너리 하나를 다운받는다.
    • 압축을 해제하고 PATH에 폴더를 추가하기 한다.

https://github.com/pnpm/action-setup?utm_source=chatgpt.com
https://pnpm.io/continuous-integration?utm_source=chatgpt.com

pnpm store를 왜 캐시할까?

      - 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 store
  • 각 프로젝트의 node_modules안에는 실제 패키지 파일이 아니라 하드 링크/심볼릭 링크만 둔다.

pnpm Intall을 하면 무슨일이 일어날까?

pnpm install 을 하게 되면 다음과 같은 과정이 진행된다.

  1. 먼저, pnpm store에서 해당 버전의 패키지가 있는지 확인한다.
  2. 있다면, 링크만 건다. (다운로드를 하지 않는다. 왜? 이미 전역에 설치되어있어서)
  3. 없다면, 새로 다운로드해서 store에 넣고, 링크를 건다.

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 옵션으로 조금 더 안정성 있게 최적화 하기

pnpm install 을 운영환경에서 사용할때 쓰는 주요 옵션이 두가지가 있다. 하나씩 알아보자

1. —frozen-lockfile

이 옵션은 배포 환경에서 “예측 가능한 설치”를 강제로 보장한다.

lockfile과 package.json이 조금이라도 달라지면 의존성이 “미묘하게 다른 버전”으로 설치 될 수 있다. 기본적으로 pnpm은 lockfile이 package.json과 맞지 않으면 lockfile을 수정하려고 시도하기 때문이다. 이렇게 되면, 로컬에서는 되는데, CI환경에서는 되지 않거나 어제는 되는데 오늘은 안되는 문제가 발생할 수 있다.

그렇기 때문에, 운영/CI 환경에서는 파일 수정 금지, 새로운 diff 발생 금지, 잠재적 충돌 금지가 필요하다.
—fronze-lockfile은 lockfile을 절대 수정하지 말라는 의미이므로 CI에서 변조될 가능성을 제거하여 안정성과 예측 가능성을 확보하는게 좋다.

2. —prefer-offline

💡 값이 true이면, 캐시된 데이터에 대한 최신 여부(staleness) 검사는 건너뛰지만, 캐시에 없는 데이터는 서버에 요청됩니다. 완전한 오프라인 모드를 강제로 사용하려면 `--offline` 옵션을 사용하세요.

-prefer-offline 옵션은 설치 과정에서 캐시 우선 전략을 적용한다. 즉, 이미 존재하는 캐시된 패키지는 최신 여부 확인 없이 즉시 사용하고, 캐시에 없는 항목만 remote registry에서 다운로드한다.
이를 통해 다음과 같은 장점을 얻을 수 있다:

  • 네트워크 장애에 영향을 덜 받는다.
  • 레지스트리 요청을 최소화하여 설치 속도가 빨라진다.
  • 네트워크 이슈로 인한 CI 빌드 실패를 줄일 수 있다.

(최근 AWS 네트워크 이슈로 빌드가 막혀서 고생한 적이 있었는데, 이 옵션을 썼다면 야근을 피할 수 있었을지도… 😭)

.next/cache가 빌드 속도를 빠르게 하는 방법

Next.js 프로젝트를 CI 환경(GitHub Actions)에서 빌드하다 보면 가장 아까운 시간이 있다.

매번 새 컨테이너에서 ‘처음부터 다시’ 빌드하는 시간이다. 로컬에서는 빠른데 CI만 가면 빌드가 느려지는 이유도 여기에 있다. 그런데 사실 Next.js는 이미 자체 캐시(.next/cache)를 통해 빌드를 빠르게 할 수 있는 구조를 가지고 있다. 단지 CI에서 새로운 컨테이너이기 빌드하기 때문에 이 캐시를 재사용하지 못하는것 뿐이다.

Next.js의 빌드는 크게 다음 3단계로 이루어진다.

  1. TS/JS 트랜스파일 + 번들링 (SWC 기반)
  2. 페이지/컴포넌트 의존성 분석 (modules graph)
  3. 이미지, 폰트 등 정적 자산 처리

이때 .next/cache에는 이전 빌드에서 계산된 다양한 결과가 저장된다.

1. SWC 트랜스파일 캐시

  • Next.js는 Babel 대신 SWC를 사용한다.
  • 각 TS/JS 파일을 컴파일한 결과를 .next/cache/swc에 저장한다.
  • 코드가 바뀌지 않은 파일은 절대 다시 컴파일하지 않는다.

2. 의존성 그래프 캐시

  • "어떤 페이지가 어떤 파일을 가져다 쓰는지" 분석한 그래프가 저장된다.
  • 파일이 변경되지 않았다면 다시 계산하지 않는다.

3. 이미지 최적화 캐시

  • next/image가 처리한 최적화 이미지가 다시 계산되지 않는다.

즉, Next.js는 “안 바뀐 파일은 처리하지 않는 방식”으로 빌드 시간을 단축한다. 하지만 CI 환경에서는 매번 새로운 컨테이너가 뜨기 때문에, 이 캐시가 전혀 사용되지 않는다. 그래서 actions/cache로 .next/cache 자체를 캐싱하는 이유이다.

.next/cache의 캐싱 전략

.next/cacheactions/cache로 저장해 빌드 시간을 단축하는 것은 좋은 방법이다. 하지만 여기서 자연스럽게 이런 의문이 생길 수 있다. “Next의 캐시는 언제 무효화해야 하는게 좋을까? 코드는 늘 바뀌니까 `src//*`도 key에 넣어야 하는 거 아닌가?”**

결론부터 말하자면, src//* 은 key에 절대 넣으면 안 된다. Key는 아래와 같이 빌드 환경 자체가 달라지는 요소들만 포함**하는 것이 좋다.

  • pnpm-lock.yaml (또는 yarn.lock, package-lock.json)
  • next.config.*
  • tsconfig.json
  • OS 정보(runner.os) 등..

즉, 환경이 바뀌어 캐시가 재사용될 수 없는 경우만 key로 관리하는게 좋다.

src/**/*를 key에 넣으면 안 될까?

  • 코드가 한 줄만 바뀌어도 src/**/* 의 해시가 전체적으로 바뀐다.
    • 즉 매 커밋마다 캐시 key가 새로 생성되며,
    • 이전 .next/cache를 한 번도 재사용하지 못함
    • 결과적으로 매번 “최초 빌드”와 다를바 없는 빌드 시간이 발생한다.

그렇기 때문에 src/*/을 key에 넣어버리면 .next/cache 캐싱은 사실상 의미가 없어지게 된다.


A/B/C 커밋 시나리오로 보는 실제 동작

1. A 커밋 — 첫 빌드

  • Actions는 .next/cache를 찾지 못함 → 처음부터 빌드
  • 모든 파일을 컴파일하고 .next/cache를 생성
  • 이 캐시가 A라는 키 이름으로 GitHub에 저장

2. B 커밋 — Button.tsx만 수정됨

  1. Actions는 A 캐시를 그대로 가져온다
  2. next build 실행 → Next가 내부에서 이렇게 판단함:
파일캐시 속 해시현재 소스처리
Button.tsxv1v2재컴파일
Table.tsxv1v1(변경 없음)캐시 재사용

즉, 캐시가 A 시점이더라도, Next는 내부적으로 모든 파일의 해시를 대조한다.

그래서 변경된 파일은 반드시 다시 컴파일된다. 캐시 때문에 잘못된 빌드가 나오는 일은 절대 없다.


3. C 커밋 — Table.tsx만 수정됨

여기서도 똑같다.

  • GitHub에서 A 캐시가 내려옴
  • Next는 A 캐시를 참고해서 변경된 파일만 다시 컴파일
  • 최종 .next 폴더는 항상 최신 코드 기준으로 생성됨

오래된 캐시를 계속 써도 문제가 되는지..?

.next/cache를 Actions 캐시에 넣어서 빌드 속도를 줄이기 시작했을 때 가장 먼저 든 의문은 “코드는 계속 바뀌는데, 이렇게 오래된 캐시를 계속 써도 되는 걸까?”였다. 초기 빌드 이후 커밋이 쌓이면 캐시와 실제 코드의 차이가 점점 벌어질 텐데, 그게 빌드 결과물에 영향을 주거나 오히려 캐시를 무의미하게 만들지 고민이 있었다.

정확도 측면에서는 아무 문제도 없다. 오래된 캐시를 가져오더라도 Next.js는 매 빌드마다 변경된 파일과 의존성을 다시 확인한다. 캐시가 있으면 “안 바뀐 파일”만 스킵할 뿐이고, 바뀐 파일은 캐시를 무시하고 다시 컴파일한다. 그래서 캐시가 오래된 상태라도 잘못된 빌드가 나온 적은 단 한 번도 없었다.

영향이 생기는 부분은 속도였다. 변경된 코드가 많아질수록 Next가 “이건 캐시 쓰면 안 된다”라고 판단하는 영역이 늘어나기 때문에, 빌드 속도는 점점 레거시한 캐시를 가져올수록 약간씩 느려진다. 그래도 “캐시가 완전히 없는 빌드”와 비교하면 여전히 차이가 크다. 즉, 오래된 캐시라도 어느 정도 성능 이득은 계속 유지된다.

그래서 지금은 환경이 바뀌는 요소들(패키지 lockfile, next.config, tsconfig, OS 등)만 key에 넣고, 코드 변경으로는 key를 갱신하지 않는다. 코드를 key에 넣으면 캐시를 아예 못 쓸 수 있기 때문에, 대신 빌드 속도가 체감상 많이 느려졌다고 판단되는 시점이나 큰 리팩터링(?) 이후나 오래된 캐시를 너무 오래 썼다고 느껴질 때만, 수동으로 캐시를 날려 새로 쌓는방법을 채택했다.

만약 코드 변경이 잦은 환경이라면, 일정 주기로 캐시를 초기화하는 전략도 가능하다. 결국 프로젝트 특성과 배포 주기에 맞춰 캐시 갱신 주기를 선택하면 된다.

결론적으로, .next/cache를 캐싱한 뒤로 빌드 시간이 평균 50초~1분 정도 줄었다. 단순한 설정 변경만으로 이 정도 효과가 난 건 꽤 인상적이었다.

PM2 클러스터 모드와 Fork모드 그리고 인스턴스는 뭘까?

PM2에는 fork 모드와 cluster 모드라는 두 가지 실행 방식이 있다.

그리고 이 둘을 이해하려면 먼저 “Node.js가 어떻게 동작하는가?”를 알아야 한다. Next.js를 운영 환경에 배포할 때 PM2를 선택한 이유도 여기와 관련돼 있다. 우리 환경에서는 서버가 재부팅되거나 Node 프로세스가 죽는 일이 종종 있었는데, PM2는 이런 상황에서도 프로세스 감시와 자동 재시작을 간단한 명령어로 해결할 수 있어서 가장 간단한 해결책이였다.

Node.js와 PM2의 Fork 모드

Node는 기본적으로 “단일 스레드”로 동작한다. Next.js를 배포하면 결국 Node 런타임 위에서 하나의 서버 프로세스가 떠 있는 구조이고, 이 프로세스가 모든 요청을 처리한다. 사용자가 늘어나면 이 단일 프로세스가 모든 요청을 감당해야 하기 때문에 병목이 생길 수 있다.

PM2의 Fork 모드는 이 기본 구조 그대로이다. 말 그대로, 단일 Node 프로세스를 하나 실행하는 것이 전부다. 개인 프로젝트나 아주 작은 트래픽 환경에서는 Fork모드만으로 충분하다고 생각한다.

PM2의 Cluster 모드와 인스턴스

운영 환경이라면, 조금 다른 옵션을 고려해봐야한다고 생각한다. 아무리 사용자 수가 적다고 해도, 단일 스레드 서버는 장애 대응이나 성능 면에서 한계가 있다. PM2가 제공하는 Cluster 모드는 이런 문제를 해결하기 위한 기능이다.

클러스터 모드는 다음과 같이 동작한다.

  • 하나의 Node.js 서버를 여러 프로세스로 복사해서 실행
  • 요청은 여러 프로세스에 분산 처리
  • CPU 코어 수만큼 인스턴스를 띄우는 것이 기본 권장된다.

즉, Cluster 모드는 Node의 단일 스레드 한계를 넘기 위한 PM2의 스케일링 전략이다. 운영 환경에서 안정성을 확보하려면 대부분 Cluster 모드를 사용한다.

여기서 중요한 개념이 바로 “인스턴스 수”이다. 인스턴스는 몇 개의 node 프로세스를 띄울 것인가를 의미한다.

그리고 이 인스턴스 개수는 CPU 코어 수를 초과할 수 없다. 예를 들어 EC2의 t3.micro는 vCPU가 2개 이기 때문에 PM2 클러스터 모드에서 최대 인스턴스 수는 2개가 된다.

인스턴스를 무조건 코어 수만큼 쓰는 게 좋은걸까?

꼭 그렇지는 않다. 나도 운영 환경에서 고민했던 부분이 바로 이 지점이었다.

인스턴스를 늘릴수록 요청 분산은 좋아지지만, 각 Node 프로세스가 메모리를 잡아먹기 때문에 비용이 증가한다. 특히 EC2처럼 리소스가 한정된 머신에서는 메모리 낭비로 이어질 수 있다. 예를 들어, 내가 만들었던 골프장 직원용 어드민 서비스는 트래픽이 폭발적으로 증가할 일이 없었기 때문에 최대 인스턴스 수를 2개까지만 두고 Cluster모드로 안정성을 확보했다.

profile
프론트엔드 개발자입니다.

0개의 댓글