package-lock.json과 npm ci, 설치 결과를 고정하는 법
package.json에는 허용할 버전 범위만 적혀 있어서 설치할 때마다 다른 버전이 골라질 수 있다.
lockfile이 무엇을 기록하는지, 배포에서는 왜 npm install 대신 npm ci를 쓰는지 설명한다.
이 글의 목차package.json의 ^는 범위를 뜻한다8
Node.js 프로젝트에서 npm install을 처음 실행하면 package-lock.json이라는 파일이 생긴다.
열어 보면 수천 줄이 넘을 때도 있어서, 저장소에 올려야 하는지 .gitignore에 넣어야 하는지부터 헷갈린다.
이 파일은 저장소에 함께 올리는 것이 맞다.
이유를 알려면 package.json이 패키지 버전을 어떻게 적는지부터 봐야 한다.
package.json의 ^는 범위를 뜻한다
패키지를 하나 추가하면 package.json에 ^1.2.3처럼 버전 앞에 ^가 붙은 값이 적힌다.
npm은 기본 설정으로 버전 앞에 이 기호를 붙여 저장한다.
버전 번호는 보통 점으로 나눈 세 자리다.
호환을 깨는 변경이면 첫째 자리를, 호환되는 기능 추가면 둘째 자리를, 호환되는 버그 수정이면 셋째 자리를 올린다.
이 규칙을 시맨틱 버저닝(semantic versioning)이라고 한다.
^1.2.3은 첫째 자리가 1인 범위에서 1.2.3 이상이면 무엇이든 받겠다는 뜻이다.
오늘 설치하면 1.2.3이 깔려도, 다음 달에는 그사이 나온 1.3.0이 깔릴 수 있다.
범위로 적는 것은 버그 수정과 보안 패치를 매번 손으로 올리지 않고도 받기 위해서다.
대신 같은 package.json이라도 설치하는 날에 따라 결과가 달라진다.
내가 고르지 않은 패키지가 더 많다
범위를 빼고 정확한 버전만 적으면 해결될까.
그래도 문제가 남는다.
내가 고른 패키지도 저마다 다른 패키지를 필요로 하기 때문이다.
직접 고른 패키지 아래로 간접 의존성이 여러 단계 이어지는 구조를 의존성 트리(dependency tree)라고 한다.
간접 의존성의 범위는 그 패키지의 package.json이 정하므로, 내 쪽에 정확한 버전을 적어도 그 아래는 여전히 범위 안에서 골라진다.
시맨틱 버저닝은 패키지를 만든 사람이 지키기로 한 약속이다.
실수로 호환을 깨는 변경이 둘째 자리 버전으로 나오기도 해서, 명세에는 그럴 때 호환을 되살린 새 버전을 곧바로 내라는 수습 방법까지 적혀 있다.
lockfile은 그때 고른 결과를 적어 둔다
패키지가 실제로 설치되는 곳은 node_modules 폴더다.
package-lock.json은 npm이 이 폴더나 package.json을 바꿀 때마다 자동으로 만들어지고, 그때 만든 트리를 정확히 기록한다.
기록하는 것은 트리 전체의 정확한 버전, 패키지를 받아 온 위치, 그리고 파일마다의 무결성 값이다.
무결성 값은 브라우저가 외부 스크립트를 검사할 때 쓰는 하위 리소스 무결성(SRI)과 같은 형식의 해시다.
파일 내용이 조금만 달라도 값이 달라지므로, 받은 파일이 기록할 때와 같은지 확인할 수 있다.
npm의 package-lock.json 문서는 이 파일을 저장소에 커밋하라고 권한다.
그래야 팀원과 배포 환경, CI(변경이 올라올 때마다 자동으로 빌드하고 테스트하는 환경)가 똑같은 의존성을 설치한다.
package.json이 무엇이 필요한지를 적는다면, lockfile은 그때 정확히 무엇을 골랐는지를 적는다.
npm이 처음부터 lockfile을 기본으로 남긴 것은 아니다.
2017년 npm 5부터 lockfile이 기본 동작이 되었다.
npm 팀은 개발자마다 다른 버전을 설치해서 생기는 드리프트(drift)를 그 이유로 들었다.
lockfile이 있어도 npm install은 기록을 고칠 수 있다
lockfile이 있으면 npm install도 늘 기록대로 설치할 것 같지만, 꼭 그렇지는 않다.
npm install은 lockfile의 버전이 package.json의 범위를 만족할 때 lockfile대로 설치한다.
둘이 맞지 않으면 package.json을 기준으로 새 버전을 골라 lockfile을 고쳐 쓴다.
개발할 때는 이것이 맞는 동작이다.
패키지를 추가하거나 범위를 바꾸면 lockfile도 새 요구사항에 맞게 바뀌어야 한다.
배포에서는 사정이 다르다.
이미 검토한 트리를 그대로 다시 만들어야 하는데, 설치 단계가 알아서 새 답을 내면 검토하지 않은 버전이 운영에 들어간다.
배포에서는 npm ci를 쓴다
npm ci는 npm install과 비슷한 설치 명령인데, npm의 npm ci 문서는 테스트 환경, CI, 배포처럼 자동화된 곳에서 이 명령을 쓰라고 안내한다.
둘은 lockfile을 다루는 방식이 다르다.
npm ci는 lockfile이 반드시 있어야 하고, package.json과 맞지 않으면 lockfile을 고치는 대신 오류를 내고 멈춘다.
이미 있는 node_modules는 지운 뒤 처음부터 설치한다.
설치하는 동안 package.json과 lockfile에는 아무것도 쓰지 않는다.
npm 문서는 이를 사실상 얼어붙은 설치라고 표현한다.
요구 버전만 바꾼 상태로 CI 설치를 시도하면 어떻게 되는지 보여 준다.
요구사항
1.x설치 기록
1.0.0요구사항과 일치
요구사항
2.x설치 기록
1.0.0자동 수정하지 않음
요구사항
2.x검토한 설치 기록
2.0.0요구사항과 일치
npm ci는 요구사항과 lockfile이 다르면 기록을 고치는 대신 실패한다. 두 파일을 함께 검토해 맞춰야 한다.
요구사항과 설치 기록이 맞다. CI는 기록된 트리를 설치할 수 있다.
예시 패키지 하나의 버전 비교다. 실제 설치를 실행하지 않으며 OS·CPU·빌드 도구까지 같은 결과를 보장하지 않는다.
| 상황 | 개발 중 npm install | CI와 배포의 npm ci |
|---|---|---|
| 목적 | 새 요구사항을 계산하고 기록한다 | 검토한 트리를 다시 만든다 |
| 두 파일이 다를 때 | lockfile을 고쳐 쓴다 | 파일을 그대로 두고 실패한다 |
| 실패의 의미 | 고쳐야 작업을 이어 갈 수 있다 | 예상하지 않은 변경을 막았다 |
npm ci가 이 오류로 멈췄다면 대개 package.json만 바뀌고 lockfile은 함께 올라오지 않은 경우다.
개발 환경에서 npm install로 lockfile을 갱신하고, 바뀐 내용까지 검토해서 다시 올리면 된다.
lockfile 없이 배포하면 생기는 일
lockfile을 쓰지 않는 빌드에서 흔히 보이는 장애가 있다.
소스 코드는 그대로인데 새로 띄운 서버만 시작하지 못하는 경우다.
원인을 따라가 보면 빌드가 설치하면서 간접 의존성의 새 버전을 골랐고, 그 버전이 실행 환경과 맞지 않았다.
이럴 때 할 일은 두 가지다.
먼저 문제가 없던 조합으로 되돌려 서비스를 살리고, 그다음 lockfile과 npm ci로 같은 차이가 운영에 닿기 전에 멈추게 한다.
lockfile로도 고정되지 않는 것
lockfile은 npm이 고른 패키지 트리를 기록할 뿐, 빌드 환경 전체를 고정하지는 못한다.
Node.js와 npm 자체의 버전, 운영체제와 CPU 종류, 컨테이너의 바탕 이미지는 따로 맞춰야 한다.
네이티브 모듈도 조심해야 한다.
C나 C++로 된 부분을 설치하면서 그 기계에서 컴파일하는 패키지는, 같은 버전이라도 기계에 따라 결과물이 달라질 수 있다.
리뷰에서 lockfile을 보는 법
실무에서는 lockfile을 저장소에 올리고, CI와 배포에서는 npm ci로 설치한다.
의존성을 바꾼 커밋에는 lockfile 변경이 함께 들어간다.
lockfile이 저장소에 있으면 의존성 트리가 어떻게 바뀌었는지도 커밋의 변경 내역(diff)으로 보인다.
패키지 하나를 올렸는데 lockfile에서 수십 줄이 바뀌었다면, 그 아래에서 함께 바뀐 패키지가 그만큼 있다는 뜻이다.
그 파일에 적힌 패키지 대부분은 직접 고른 적이 없어도 운영에서 내 코드와 함께 실행된다.
의존성 업데이트를 따로 묶어 올리고 테스트를 거쳐 받아들이면, 언제 무엇이 바뀌었는지 기록이 남는다.