Render에서 배포가 터졌다. 에러 메시지는 짧고 명확했지만 원인은 생각보다 다양했다.

ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile" because
pnpm-lock.yaml is not up to date with <ROOT>/apps/legal_audit_web/package.json

Note that in CI environments this setting is true by default.
If you still need to run install in such cases, use "pnpm install --no-frozen-lockfile"

Failure reason:
  specifiers in the lockfile don't match specifiers in package.json:
* 1 dependencies were added: @ios26_design_system/svelte-inertia@^1.0.0

로컬에서는 잘 됐는데 CI에서만 죽는 전형적인 패턴이다. 원인과 해결법을 기록해둔다.


CI에서 frozen-lockfile이 자동 활성화되는 이유

pnpm install은 CI 환경을 자동 감지해서 --frozen-lockfile을 기본으로 켠다. CI 감지 로직은 ci-info 패키지 기반이다.

// pnpm이 내부적으로 사용하는 CI 감지 코드
exports.isCI = !!(
  env.CI ||                    // Travis CI, CircleCI, GitLab CI, Appveyor
  env.CONTINUOUS_INTEGRATION || // Travis CI, Cirrus CI
  env.BUILD_NUMBER ||           // Jenkins, TeamCity
  env.RUN_ID ||                 // TaskCluster
  exports.name ||
  false
)

Render는 빌드 시 CI=true 환경변수를 설정하기 때문에 pnpm install이 자동으로 frozen 모드로 동작한다.

--frozen-lockfile이 켜지면 pnpm은 pnpm-lock.yamlpackage.json의 specifier가 완전히 일치해야만 설치를 진행한다. 불일치가 있으면 lockfile을 업데이트하지 않고 바로 에러로 종료한다.

환경frozen-lockfile 기본값
로컬 개발 (CI 없음)false — lockfile 자동 업데이트
CI 환경 (CI=true)true — 불일치 시 즉시 실패

로컬에서는 pnpm install이 알아서 lockfile을 갱신해주니까 문제를 못 느끼다가, CI에서 터지는 구조다.


원인 1: package.json에 패키지 추가했는데 lockfile 커밋 누락

이번에 겪은 케이스가 바로 이거다. package.json에 새 패키지를 추가하고 pnpm install까지 실행했지만, 업데이트된 pnpm-lock.yaml을 커밋에 포함시키지 않았다.

# 로컬에서 한 작업
$ pnpm add @ios26_design_system/svelte-inertia
# package.json 업데이트됨, pnpm-lock.yaml 업데이트됨

# 근데 커밋할 때
$ git add package.json
$ git commit -m "feat: add ios26 design system"
# pnpm-lock.yaml 누락!

GitHub에 올라간 상태: package.json은 새 패키지가 있는데 pnpm-lock.yaml은 예전 버전 그대로.

해결: lockfile 업데이트 후 함께 커밋한다.

$ pnpm install
$ git add pnpm-lock.yaml package.json
$ git commit -m "chore: update lockfile for @ios26_design_system/svelte-inertia"

원인 2: 로컬 pnpm 버전과 CI pnpm 버전 불일치

pnpm은 버전마다 lockfile 형식이 다르다. 로컬에서 pnpm v8로 생성한 lockfile을 CI가 pnpm v9로 읽으려 하면 에러가 난다.

ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE  Cannot perform a frozen
installation because the version of the lockfile is incompatible with this
version of pnpm

에러 메시지가 미묘하게 다르다. OUTDATED_LOCKFILE이 아니라 FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE이면 버전 불일치 문제다.

해결: package.jsonpackageManager 필드로 버전을 고정한다.

{
  "packageManager": "pnpm@9.0.0"
}

이걸 설정하면 Corepack이 CI에서도 동일한 버전을 사용하도록 강제한다. 또는 CI 설정 파일에서 명시적으로 버전을 지정한다.

# GitHub Actions 예시
- uses: pnpm/action-setup@v4
  with:
    version: 9.0.0

원인 3: pnpm v8 → v9 업그레이드로 lockfile 형식 변경

pnpm v9는 peer dependency 처리 방식이 바뀌면서 lockfile 형식 자체가 v9로 올라갔다. 기존 v6/v8 형식의 lockfile은 v9에서 frozen 모드로 읽을 수 없다.

# lockfile을 v9 형식으로 재생성
$ rm pnpm-lock.yaml
$ pnpm install
$ git add pnpm-lock.yaml
$ git commit -m "chore: regenerate lockfile for pnpm v9"

아니면 --fix-lockfile 옵션으로 기존 lockfile을 마이그레이션한다.

$ pnpm install --fix-lockfile

원인 4: monorepo에서 특정 workspace의 package.json만 변경

monorepo 구조에서는 루트 pnpm-lock.yaml이 모든 workspace의 의존성을 관리한다. apps/web/package.json을 바꿨는데 루트 lockfile을 업데이트 안 하면 같은 에러가 난다.

ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile" because
pnpm-lock.yaml is not up to date with <ROOT>/apps/web/package.json

이번 케이스도 monorepo(apps/legal_audit_web/) 구조였다. apps/legal_audit_web/package.json에 패키지를 추가했는데 루트의 pnpm-lock.yaml이 그걸 반영 안 하고 있었다.

루트에서 pnpm install을 실행해야 모든 workspace의 lockfile을 갱신한다.

# workspace 루트에서 실행
$ cd /path/to/monorepo-root
$ pnpm install
$ git add pnpm-lock.yaml

Render 배포 로그에서 진단하는 방법

Render는 배포 실패 시 로그가 짧게 나온다. Render MCP나 대시보드에서 빌드 로그를 확인하면 정확한 실패 지점을 알 수 있다.

==> Running build command 'bundle config set deployment true &&
    bundle install &&
    pnpm install &&
    pnpm build &&
    bundle exec rails db:migrate RAILS_ENV=production'...

ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile"...

Failure reason:
  specifiers in the lockfile don't match specifiers in package.json:
* 1 dependencies were added: @ios26_design_system/svelte-inertia@^1.0.0

==> Build failed 😞

Failure reason 아래에 어떤 패키지가 불일치인지 정확히 나온다. pnpm 8.5.1 이후부터 이 메시지가 추가됐다.


빠른 해결법 vs 올바른 해결법

급할 때 --no-frozen-lockfile을 buildCommand에 넣고 싶은 유혹이 있다.

# render.yaml — 임시방편 (권장 안 함)
buildCommand: >-
  pnpm install --no-frozen-lockfile &&
  pnpm build

이렇게 하면 CI가 매번 최신 버전으로 패키지를 resolve해서 재현 불가능한 빌드가 만들어진다. lockfile을 쓰는 이유가 없어진다.

올바른 해결법:

  1. 로컬에서 pnpm install 실행
  2. pnpm-lock.yaml을 반드시 커밋에 포함
  3. PR 리뷰 시 pnpm-lock.yaml 변경 확인

재발 방지: pre-commit hook으로 lockfile 동기화 확인

.husky/pre-commit에 lockfile 검증을 추가하면 로컬에서 미리 잡을 수 있다.

#!/bin/sh
# .husky/pre-commit

# package.json 변경 시 pnpm-lock.yaml도 변경됐는지 확인
if git diff --cached --name-only | grep -q "package.json"; then
  if ! git diff --cached --name-only | grep -q "pnpm-lock.yaml"; then
    echo "⚠️  package.json이 변경됐는데 pnpm-lock.yaml이 스테이징되지 않았습니다."
    echo "   pnpm install 후 pnpm-lock.yaml을 커밋에 포함해주세요."
    exit 1
  fi
fi

정리

에러 메시지원인해결
specifiers don't matchpackage.json 변경 후 lockfile 커밋 누락pnpm install 후 lockfile 함께 커밋
lockfile is incompatiblepnpm 버전 불일치 (로컬 vs CI)packageManager 필드로 버전 고정
lockfile needs updatespnpm v8→v9 lockfile 형식 변경pnpm install --fix-lockfile 후 커밋
monorepo workspace 에러workspace package.json 변경 후 루트 lockfile 미갱신루트에서 pnpm install

pnpm의 frozen-lockfile은 재현 가능한 빌드를 위한 장치다. 에러가 나면 lockfile을 bypass하는 게 아니라 lockfile을 올바른 상태로 만드는 게 맞다.

관련 포스트: Rails + SolidQueue Render 배포 삽질