의존성 락 파일, 배포할 때마다 버전이 바뀌는 문제 해결하기

의존성 락 파일

배포할 때마다 프로덕션 서버에 깔리는 패키지 버전이 로컬과 미묘하게 달라지는 이유가 뭘까요? 대부분은 package.json에 적힌 ^1.2.0, ~1.2.0 같은 버전 범위가 설치 시점마다 다른 최신 패치·마이너 버전을 끌어오기 때문이고, 이 문제는 의존성 락 파일을 정확히 커밋하고 npm ci 같은 설치 명령을 쓰면 대부분 해결됩니다. 락 파일만 있다고 끝나는 건 아니고, 어떻게 만들고 어떻게 설치하느냐에 따라 재현 가능한 빌드가 되기도 하고 안 되기도 합니다. 이 글에서는 npm 기준으로 락 파일이 실제로 무엇을 고정하는지, 그리고 락 파일이 있어도 결과가 달라지는 구체적인 케이스를 짚어보겠습니다.

배포할 때마다 의존성 버전이 바뀌는 이유가 뭘까요?

npm의 시맨틱 버저닝 규칙에서 ^1.2.0은 1.x.x 범위 내 최신 버전을, ~1.2.0은 1.2.x 범위 내 최신 패치를 허용합니다. package.json만 보고 npm install을 실행하면 설치 시점의 레지스트리 상태에 따라 매번 다른 버전이 선택될 수 있습니다.

문제는 이게 직접 의존성에만 해당하지 않는다는 점입니다. A 패키지가 B를 ^2.0.0으로 의존하면, B의 마이너 업데이트 하나가 조용히 들어와 런타임 동작이 바뀔 수 있습니다. CI에서는 통과했는데 며칠 뒤 재배포 시 실패하는 경우 대부분 이 경로입니다.

package.json과 락 파일이 하는 역할 차이

package.json은 “이 정도 범위의 버전이면 괜찮다”는 의도를 적어두는 파일이고, package-lock.json(또는 yarn의 yarn.lock, pnpm의 pnpm-lock.yaml)은 실제로 설치됐던 정확한 버전과 무결성 해시(SRI)를 통째로 기록하는 파일입니다. npm 7 이상에서는 lockfileVersion 2 또는 3 형식을 쓰는데, 이 버전부터 전체 의존성 트리가 packages 필드 하나에 평탄화되어 저장됩니다.

아래는 package-lock.json에서 실제로 버전을 고정하는 부분의 형태입니다.

"node_modules/lodash": {
  "version": "4.17.21",
  "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
  "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4/GtE+VYfHFYFDPxA/BbSZoBUqm0zqIcSXV5mvxi..."
}

터미널에서 npm 패키지 설치 명령어를 실행하는 화면

resolved는 실제로 받아온 tarball 주소, integrity는 해시값입니다. 이 두 값이 있어야 나중에 같은 파일을 다시 설치했을 때 바이트 단위로 같은지 검증할 수 있습니다. 락 파일 관련 공식 스펙과 필드 설명은 npm ci 공식 문서에서 확인하실 수 있습니다.

락 파일이 있어도 결과물이 달라지는 경우가 있습니다

락 파일을 커밋했다고 해서 무조건 안전한 건 아닙니다. 실무에서 자주 겪는 케이스를 정리하면 아래와 같습니다.

상황 원인 결과
npm install로 배포 package.json이 락 파일보다 우선시돼 새 버전 설치 가능 로컬과 배포 서버 버전 불일치
락 파일 미커밋 .gitignore에 lock 파일 포함 매 배포마다 새로 resolve
lockfileVersion 불일치 로컬은 npm 10, CI는 npm 6 락 파일 재작성되며 버전 흔들림
native addon 포함 (node-gyp) OS·아키텍처별로 바이너리 컴파일 필요 락 파일이 같아도 빌드 결과물은 다름
private registry 미러링 지연 resolved 주소의 패키지가 삭제/변경됨 특정 시점 이후 설치 자체가 실패

특히 node-gyp로 네이티브 모듈을 빌드하는 패키지(예: bcrypt, sharp)는 락 파일이 버전을 고정해도 컴파일 결과가 OS·CPU 아키텍처에 따라 달라집니다. 이런 경우엔 락 파일만으로는 재현 가능한 빌드를 완전히 보장하지 못하고, Docker 이미지처럼 실행 환경 자체를 고정하는 방법을 함께 써야 합니다.

이 명령으로 설치 방식부터 통일해 보세요

가장 먼저 바꿔야 할 건 배포 스크립트의 설치 명령입니다. npm install은 락 파일이 있어도 package.json과 어긋나면 락 파일을 갱신해버리지만, npm ci는 락 파일을 유일한 진실 소스로 취급하고 불일치가 있으면 아예 에러를 내고 멈춥니다.

# 로컬에서 락 파일까지 함께 갱신할 때
npm install

# CI/배포 서버에서 재현 가능한 빌드가 필요할 때
npm ci

npm ci를 실행하면 우선 기존 node_modules를 통째로 삭제한 뒤, 락 파일에 적힌 버전만 그대로 설치합니다. 이 과정에서 package.json과 락 파일 버전이 어긋나면 다음과 같은 에러로 즉시 실패합니다.

소프트웨어 의존성 버전 관리 및 락 파일 개념 일러스트

npm ERR! `npm ci` can only install packages when your package.json and
npm ERR! package-lock.json or npm-shrinkwrap.json are in sync.

이 에러가 CI에서 뜨는 건 오히려 좋은 신호입니다. 로컬에서 package.json만 고치고 락 파일 갱신을 깜빡했다는 뜻이라, 배포되기 전에 잡아내는 셈입니다. Docker를 쓰신다면 레이어 캐시를 위해 package*.json을 먼저 복사한 뒤 npm ci를 실행하는 순서를 지켜주시는 게 좋습니다.

COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .

이렇게 하면 소스 코드만 바뀌었을 때는 의존성 설치 레이어가 캐시에서 재사용되고, 락 파일이 바뀔 때만 다시 설치가 돌아갑니다.

npm ci를 썼는데도 왜 결과가 다른가요?

npm ci로 넘어갔는데도 배포 환경마다 결과가 다르다면, 원인은 대개 락 파일 바깥에 있습니다. 첫 번째는 Node.js 버전 자체가 서버마다 다른 경우로, package.jsonengines 필드에 버전을 명시해도 npm은 기본적으로 이를 강제하지 않습니다. .nvmrc나 Docker 베이스 이미지 태그로 Node 버전을 고정해야 합니다.

두 번째는 모노레포에서 워크스페이스별로 락 파일 관리가 어긋나는 경우입니다. npm 워크스페이스는 루트에 락 파일 하나만 두는 게 원칙인데, 하위 패키지에 개별 락 파일이 남아있으면 어느 쪽이 우선인지 혼동이 생깁니다. 재현 가능한 빌드라는 개념 자체에 대한 배경은 Reproducible Builds 프로젝트의 정의 문서에 잘 정리돼 있으니, 왜 이 문제가 npm만의 이슈가 아니라 빌드 시스템 전반의 오래된 과제인지 참고하실 만합니다.

세 번째는 레지스트리 자체의 변화입니다. 사설 레지스트리를 미러링해서 쓰는 경우 특정 버전이 내려가거나 재배포(republish)되면, resolved 주소는 같아도 실제 받아오는 파일이 달라질 수 있습니다. 이럴 땐 npm 대신 아티팩트 저장소(예: Verdaccio, Nexus)에 패키지를 캐시해두는 방식이 더 안전합니다.

락 파일을 커밋하고 npm ci로 설치를 통일하는 것만으로도 배포마다 버전이 흔들리는 문제의 상당 부분은 사라집니다. 다만 네이티브 모듈, Node 버전, 모노레포 구조, 레지스트리 안정성까지 함께 고정해야 진짜 의미의 재현 가능한 빌드에 가까워진다는 점은 기억해 두시는 게 좋습니다. 오늘 배포 스크립트에서 npm install을 쓰고 계셨다면, npm ci로 바꾸고 락 파일이 .gitignore에 걸려 있지 않은지부터 확인해 보시길 권해드립니다.

FastAPI 파일 업로드, 대용량은 스트리밍과 임시파일로 처리하세요

Leave a Comment