npm ci와 npm install의 차이는 속도가 아니라 락 파일을 대하는 태도다. npm install은 필요하면 package-lock.json을 고쳐 쓰고, npm ci는 한 글자도 안 고치고 안 맞으면 멈춘다.
그래서 CI에서는 npm ci가 맞다. 커밋된 락 파일과 다른 트리가 설치되는 일이 구조적으로 불가능하기 때문이다.
대신 조용히 발목을 잡는 설정이 하나 있다. NODE_ENV=production이 먼저 잡혀 있으면 devDependencies가 설치되지 않는다. 빌드 도구가 거기 있으면 설치는 성공하고 빌드가 실패한다.
한 줄로 갈리는 지점
npm install은 "package.json을 만족하는 트리를 만든다"에 가깝고, npm ci는 "락 파일에 적힌 트리를 그대로 재현한다"에 가깝다.
그 차이가 다섯 가지 동작으로 나온다. npm 공식 문서가 명시한 내용이다.
- 락 파일이 반드시 있어야 한다.
package-lock.json이나npm-shrinkwrap.json이 없으면npm ci는 동작하지 않는다. package.json과 락 파일이 안 맞으면 에러로 끝난다. 락 파일을 고쳐서 맞추지 않는다.node_modules가 있으면 먼저 지운다. 남아 있던 것 위에 덧설치하지 않는다.package.json도 락 파일도 절대 쓰지 않는다. 설치가 사실상 얼어 있다.- 패키지 하나만 추가할 수 없다. 프로젝트 전체 단위로만 설치한다.
반대로 npm install은 node_modules나 package.json을 바꾸는 작업이면 락 파일을 자동으로 다시 생성한다. (문서)
왜 CI에서 npm install이 위험한가
락 파일의 목적은 어디서 설치하든 같은 트리가 나오게 하는 것이다. 팀원과 CI가 같은 의존성을 받게 하고, node_modules를 커밋하지 않고도 과거 상태로 돌아갈 수 있게 한다. 그래서 문서도 이 파일을 저장소에 커밋하라고 전제한다.
그런데 CI에서 npm install을 쓰면 이 전제가 흔들린다.
- 누군가
package.json만 고치고 락 파일을 안 올렸다 → CI가 알아서 새 트리를 만들고 통과한다 - 로컬과 CI에 설치된 트리가 달라진다 → "내 PC에서는 되는데"가 생긴다
- CI가 락 파일을 고쳤는데 그건 커밋되지 않는다 → 다음 빌드도 또 새로 푼다. 고친 것과 반영된 것이 다른 구조는 이런 식으로 조용히 반복된다
npm ci라면 첫 번째 줄에서 멈춘다. 어긋남을 실패로 드러내는 것이 CI에 필요한 성질이다.
조용히 실패하는 설정 — NODE_ENV=production
이게 실제로 사람을 오래 붙잡는다.
npm ci에는 설치 트리에서 뺄 의존성 종류를 정하는 omit 설정이 있다. 기본값이 이렇다.
omit
기본값: NODE_ENV=production 이면 'dev'
그 외에는 비어 있음
환경변수 하나로 기본 동작이 바뀐다. CI 파이프라인 앞단에서 NODE_ENV=production을 전역으로 잡아 두면, npm ci가 devDependencies를 빼고 설치한다.
TypeScript·webpack·vite 같은 빌드 도구는 보통 devDependencies에 있다. 결과는 이렇게 된다.
npm ci → 성공
npm run build → 실패 (빌드 도구가 없다)
설치 단계는 초록불이고 빌드 단계에서 터진다. 에러 메시지도 "명령을 찾을 수 없다" 류라 설치를 의심하지 않게 된다.
해결은 둘 중 하나다.
NODE_ENV=production을 빌드가 끝난 뒤, 실행 단계에서만 설정한다- 빌드 단계에서 명시적으로 dev를 포함한다
npm ci --include=dev
npm run build
실행 이미지에서만 dev를 빼고 싶으면 빌드와 실행을 단계로 나눈다. 빌드 단계는 전부 설치하고, 실행 단계에서만 --omit=dev로 설치한다.
스크립트도 기본으로 돈다
ignore-scripts의 기본값은 false다. 즉 npm ci도 패키지의 설치 스크립트를 실행한다.
"ci는 락 파일대로만 까니까 안전하다"는 락 파일의 버전 이야기지, 그 버전이 설치 중에 무엇을 실행하는지와는 별개다. 의존성에 설치 스크립트가 있으면 CI에서 그대로 돈다.
로컬에서는 무엇을 쓰나
용도가 다르다.
| 상황 | 쓰는 것 |
|---|---|
| 의존성을 추가·삭제·업그레이드한다 | npm install (락 파일이 갱신되고, 그걸 커밋한다) |
| 클론 직후, 또는 브랜치를 옮겼다 | npm ci (락 파일 그대로 깨끗하게) |
| CI·배포 빌드 | npm ci |
락 파일을 바꾸는 사람은 install, 락 파일을 믿는 사람은 ci로 나누면 헷갈리지 않는다.
npm ci가 node_modules를 지우고 시작한다는 점도 로컬에서는 장점이다. 브랜치를 오가다 쌓인 찌꺼기가 섞여 "어제는 됐는데"가 생기는 걸 막는다.
FAQ
npm ci가 락 파일과 package.json이 안 맞는다는 에러로 멈춰요
정상 동작이다. 누군가 package.json만 고치고 락 파일을 안 올린 것이다. 로컬에서 npm install로 락 파일을 갱신해 커밋하면 풀린다. CI에서 npm install로 바꿔 우회하면 문제를 덮는 것이다.
package-lock.json과 npm-shrinkwrap.json은 뭐가 다른가요
형식과 역할은 같다. 차이는 배포다. package-lock.json은 패키지로 배포되지 않고, npm-shrinkwrap.json은 배포에 포함된다. 둘 다 있으면 shrinkwrap이 우선한다. 애플리케이션이라면 package-lock.json이면 충분하다.
npm ci가 더 빠른가요
빠른 경우가 많지만 그게 핵심은 아니다. node_modules를 지우고 시작하므로 캐시가 없는 환경에서는 오히려 매번 전부 받는다. 고르는 기준은 속도가 아니라 락 파일을 고칠 권한을 줄 것이냐다.
확인한 것과 확인하지 못한 것
이 글의 동작은 npm 공식 문서(v11)에서 확인했다. npm ci의 다섯 가지 동작(락 파일 필수, 불일치 시 에러, node_modules 선삭제, 파일을 쓰지 않음, 개별 추가 불가), omit 기본값이 NODE_ENV=production일 때 dev가 되는 것, ignore-scripts 기본값 false, 락 파일이 커밋 대상이라는 것, 그리고 shrinkwrap과의 배포 차이가 그렇다.
확인하지 못한 것은 속도 수치다. 환경과 캐시에 따라 크게 달라서 "몇 배 빠르다" 같은 숫자는 넣지 않았다. 문서도 속도를 차이점으로 내세우지 않는다.
NODE_ENV는 npm 버전에 따라 동작이 달라져 왔다. 이 글은 v11 문서 기준이다. 오래된 npm을 쓴다면 해당 버전 문서에서 omit·production 설정을 따로 확인하는 편이 정확하다.
'웹개발' 카테고리의 다른 글
| HTTPS 리다이렉트 무한 루프 — 앱이 원래 프로토콜을 모르기 때문이다 (0) | 2026.09.28 |
|---|---|
| 프론트 검증은 UX지 보안이 아니다 — 서버 검증 누락이 남긴 것 (0) | 2026.09.28 |
| 차단했는데 12시간을 더 쓴다 — 세션 인증에는 무효화가 없다 (0) | 2026.09.21 |
| 메서드 이름이 트랜잭션을 결정한다 — Spring AOP 일괄 트랜잭션의 함정 (0) | 2026.09.17 |
| Spring readOnly 트랜잭션을 리더로 보내면 생기는 read-after-write 문제 (0) | 2026.09.13 |
댓글