왜 이 글을 쓰나
Node.js 프로젝트를 시작하면 첫 선택지가 생긴다. npm install로 그냥 가는 건지, yarn을 써야 하는 건지, 요즘 다들 pnpm으로 갔다는데 따라가야 하는 건지. 그리고 2024-2025년 들어서는 Bun이라는 새 선택지가 실무에서도 보이기 시작했다.
팀 프로젝트에서는 더 복잡해진다. 누군가는 package-lock.json을 올리고, 누군가는 yarn.lock을 올린다. CI/CD에서는 또 다른 명령어를 쓰고, monorepo 설정을 찾다 보면 패키지 매니저마다 방식이 다르다.
네 가지 패키지 매니저를 실무 기준으로 정리한다. 무엇이 더 낫다는 이야기가 아니라, 상황마다 어떤 선택이 맞는지를 정리한다.
npm 기본 정리
npm(Node Package Manager)은 Node.js에 번들로 포함된 기본 패키지 매니저다. 별도 설치 없이 쓸 수 있다는 점이 가장 큰 강점이다.
기본 명령어
# 의존성 설치 (package.json 기반)
npm install
# 패키지 추가
npm install react
npm install -D typescript # devDependencies
npm install -g nodemon # 글로벌 설치
# 패키지 제거
npm uninstall react
# 스크립트 실행
npm run build
npm run dev
# 프로젝트 초기화
npm init
npm init -y # 기본값으로 바로 생성
package-lock.json 역할
package-lock.json은 설치 당시의 정확한 의존성 트리를 기록한다. package.json에는 "react": "^18.0.0"처럼 범위로 적혀 있지만, lock 파일에는 실제로 설치된 18.2.0이 고정된다.
// package.json — 범위 명시
"dependencies": {
"react": "^18.0.0"
}
// package-lock.json — 정확한 버전 고정
"node_modules/react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-..."
}
lock 파일을 git에 올려야 하는 이유가 여기 있다. 팀원 모두, CI/CD 서버 모두 동일한 버전을 설치하게 된다. lock 파일 없이 npm install을 돌리면 누군가의 환경에서는 18.2.0이, 다른 환경에서는 18.3.0이 설치될 수 있다.
.npmrc 설정
프로젝트 루트 또는 홈 디렉터리에 .npmrc를 두면 npm 동작을 커스터마이징할 수 있다.
# .npmrc
registry=https://registry.npmjs.org/
save-exact=true # ^ 없이 정확한 버전 저장
fund=false # 펀딩 메시지 끄기
audit=false # audit 자동 실행 끄기
engine-strict=true # engines 필드 버전 불일치 시 에러
사내 프라이빗 레지스트리를 쓸 때도 .npmrc에 설정한다:
@mycompany:registry=https://npm.mycompany.com/
//npm.mycompany.com/:_authToken=${NPM_TOKEN}
yarn (Classic & Berry)
yarn classic vs yarn berry 차이
yarn은 두 가지 메이저 버전이 공존한다. yarn 1.x(Classic)와 yarn 2.x 이상(Berry)이다.
| 항목 | yarn classic (1.x) | yarn berry (2.x+) |
|---|---|---|
| 설치 | npm install -g yarn |
yarn set version stable |
| node_modules | 기존 방식 | PnP 기본 (node_modules 없음) |
| lock 파일 | yarn.lock |
yarn.lock |
| 플러그인 시스템 | 없음 | 있음 |
| Zero-installs | 미지원 | .yarn/cache 커밋으로 지원 |
| 호환성 | 높음 | 낮을 수 있음 (PnP 미지원 패키지) |
berry로 업그레이드했다가 PnP 호환성 문제로 고생하는 팀이 있어서, 새 프로젝트가 아니라면 berry 이주는 신중하게 해야 한다.
yarn.lock
yarn.lock은 package-lock.json과 동일한 역할이지만 형식이 다르다.
# yarn.lock 예시
react@^18.0.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#..."
integrity sha512-...
dependencies:
loose-envify "^1.1.0"
npm과 yarn을 혼용하면 두 lock 파일이 동시에 생기는 상황이 발생한다. 팀에서 하나로 통일해야 하는 이유다.
PnP (Plug'n'Play) 개념
yarn berry의 핵심 기능이다. node_modules 디렉터리를 없애고, 패키지 위치를 .pnp.cjs 파일에 맵으로 관리한다.
# 기존 방식
node_modules/
react/
react-dom/
lodash/
... (수만 개 파일)
# PnP 방식
.yarn/
cache/
react-npm-18.2.0-....zip
react-dom-npm-18.2.0-....zip
.pnp.cjs ← 패키지 위치 맵장점은 node_modules 생성 시간이 없어서 설치가 빠르고, .yarn/cache를 git에 올리면 yarn install 없이 바로 실행 가능한 "zero-installs"가 된다. 단점은 webpack, jest 등 일부 도구가 PnP를 인식 못 해서 별도 설정이 필요하다.
pnpm
하드링크 기반 디스크 절약 방식
pnpm의 핵심 차별점은 저장 방식이다. npm과 yarn은 프로젝트마다 패키지를 node_modules에 복사한다. pnpm은 전역 캐시 스토어에 한 번만 저장하고, 각 프로젝트의 node_modules에는 하드링크로 연결한다.
# npm/yarn 방식
~/project-a/node_modules/react/ ← react 파일 복사본
~/project-b/node_modules/react/ ← react 파일 복사본 (중복)
~/project-c/node_modules/react/ ← react 파일 복사본 (중복)
# pnpm 방식
~/.pnpm-store/react@18.2.0/ ← 단 하나의 원본
~/project-a/node_modules/react → 하드링크
~/project-b/node_modules/react → 하드링크
~/project-c/node_modules/react → 하드링크10개 프로젝트에서 같은 버전의 react를 쓴다면, npm/yarn은 10배의 디스크를 쓴다. pnpm은 딱 하나만 저장한다.
속도가 빠른 이유
처음 설치할 때는 npm과 비슷하다. 두 번째 설치부터 차이가 난다. 전역 스토어에 이미 있는 패키지는 다운로드하지 않고 하드링크만 만든다. CI에서 캐시가 있으면 설치 시간이 극적으로 줄어든다.
# pnpm 설치
npm install -g pnpm
# 의존성 설치
pnpm install
# 패키지 추가
pnpm add react
pnpm add -D typescript
pnpm add -g nodemon
# 패키지 제거
pnpm remove react
# 스크립트 실행
pnpm run build
pnpm dev # run 생략 가능
# 전역 스토어 경로 확인
pnpm store path
workspace 지원
pnpm은 monorepo workspace를 네이티브로 지원한다.
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
# 특정 패키지에서 명령 실행
pnpm --filter @myapp/ui build
# 전체 workspace 설치
pnpm install
# workspace 패키지 간 의존성 추가
pnpm add @myapp/ui --filter @myapp/web --workspace
Bun
런타임 + 패키지 매니저 + 번들러 올인원
Bun은 단순한 패키지 매니저가 아니다. JavaScript 런타임(Node.js 대체), 패키지 매니저, 번들러, 테스트 러너를 하나로 통합한 올인원 툴킷이다. 2023년 1.0이 정식 출시됐고, 2024-2025년 들어 채택률이 눈에 띄게 올라가고 있다.
핵심 설계 철학은 속도다. V8 엔진 대신 JavaScriptCore(WebKit의 JS 엔진)를 사용하고, 내부 구현을 Zig로 작성해 C++ 대비 낮은 오버헤드를 확보했다.
설치 및 기본 명령어
# macOS / Linux 설치
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell) — 2024년 추가
powershell -c "irm bun.sh/install.ps1 | iex"
# 의존성 설치
bun install
# 패키지 추가
bun add react
bun add -d typescript # devDependencies
bun add -g nodemon # 글로벌 설치
# 패키지 제거
bun remove react
# 스크립트 실행
bun run build
bun run dev
bun dev # package.json scripts의 dev 직접 실행
# 프로젝트 초기화
bun init
bun.lockb — 바이너리 lock 파일
Bun의 lock 파일은 bun.lockb다. 텍스트가 아닌 바이너리 형식이라 git diff가 사람 눈에는 읽히지 않는다. git에서 읽기 가능한 형태로 확인하려면:
bun bun.lockb # 터미널에 yarn.lock 형식으로 출력
git diff를 텍스트로 보고 싶다면 .gitattributes에 설정을 추가한다:
# .gitattributes
bun.lockb binary diff=lockb2025년 Bun 1.1부터는 bun.lock(텍스트 TOML 형식)도 선택적으로 사용할 수 있다. 팀 협업 시 리뷰 가능성을 원한다면 텍스트 형식을 권장한다.
속도가 빠른 이유
npm 대비 최대 25배 빠른 설치 속도를 공식 벤치마크에서 주장한다. 실제로 중규모 프로젝트(의존성 200300개)에서 npm 30초 → Bun 23초 수준의 차이가 관찰된다.
이유는 세 가지다:
- 네이티브 코드: Zig로 작성된 패키지 설치 로직이 Node.js 기반 npm보다 낮은 오버헤드를 가진다
- 병렬 다운로드 + 심볼릭 링크: 패키지를 병렬로 받으면서 캐시-링크 방식으로 설치한다
- 글로벌 캐시: pnpm과 유사하게 전역 캐시를 활용해 이미 받은 패키지는 재다운로드하지 않는다
node_modules 호환성
Bun은 기존 Node.js 생태계와 호환된다. node_modules 구조를 그대로 사용하기 때문에 npm/yarn/pnpm으로 관리하던 프로젝트를 Bun으로 전환해도 코드 수정 없이 그대로 실행된다.
# 기존 프로젝트를 Bun으로 전환
rm package-lock.json # 또는 yarn.lock, pnpm-lock.yaml
bun install # bun.lockb 생성, node_modules 재설치
런타임으로도 쓸 수 있다:
# Node.js 대신 Bun으로 실행
bun server.js
bun index.ts # TypeScript 트랜스파일 없이 직접 실행
Bun의 단점
빠른 건 사실이지만 트레이드오프가 있다.
- Node.js API 미호환:
child_process,cluster등 일부 Node.js API가 완전히 구현되지 않았다. 복잡한 서버 환경에서 예상치 못한 동작이 발생할 수 있다 - Windows 지원 미성숙: 2024년 공식 Windows 지원이 추가됐지만, macOS/Linux 대비 안정성이 낮다. Windows 메인 개발 환경이라면 신중하게 검토해야 한다
- 생태계 레퍼런스 부족: 트러블슈팅 자료나 팀 내 경험이 npm/pnpm 대비 적다
- 안정성: 아직 1.x 버전 초반이라 마이너 버전 업데이트에서 breaking change가 드물게 발생한다
명령어 비교 테이블
| 작업 | npm | yarn | pnpm | Bun |
|---|---|---|---|---|
| 의존성 설치 | npm install |
yarn |
pnpm install |
bun install |
| 패키지 추가 | npm install react |
yarn add react |
pnpm add react |
bun add react |
| dev 의존성 추가 | npm install -D ts |
yarn add -D ts |
pnpm add -D ts |
bun add -d ts |
| 글로벌 설치 | npm install -g nodemon |
yarn global add nodemon |
pnpm add -g nodemon |
bun add -g nodemon |
| 패키지 제거 | npm uninstall react |
yarn remove react |
pnpm remove react |
bun remove react |
| 스크립트 실행 | npm run dev |
yarn dev |
pnpm dev |
bun dev |
| 프로젝트 초기화 | npm init |
yarn init |
pnpm init |
bun init |
| 패키지 업데이트 | npm update |
yarn upgrade |
pnpm update |
bun update |
| 캐시 정리 | npm cache clean --force |
yarn cache clean |
pnpm store prune |
bun pm cache rm |
| lock 파일 | package-lock.json |
yarn.lock |
pnpm-lock.yaml |
bun.lockb |
성능 비교
설치 속도와 디스크 사용량은 상황에 따라 다르지만, 일반적인 경향은 이렇다.
설치 속도 (캐시 없음 기준)
| 패키지 매니저 | 상대 속도 | 비고 |
|---|---|---|
| npm | 기준 | v7+ 이후 많이 개선됨 |
| yarn classic | npm과 유사 ~ 약간 빠름 | 병렬 다운로드 |
| pnpm | npm 대비 1.5~3x 빠름 | 하드링크 + 효율적 병렬화 |
| Bun | npm 대비 최대 25x 빠름 | 네이티브 코드 기반, 글로벌 캐시 |
디스크 사용량 (프로젝트 5개 기준, 동일 의존성)
| 패키지 매니저 | 디스크 사용량 |
|---|---|
| npm | 5x (각 프로젝트에 복사) |
| yarn classic | 5x (각 프로젝트에 복사) |
| pnpm | ~1.2x (스토어 1개 + 링크) |
| Bun | ~1.5x (글로벌 캐시 + node_modules) |
CI/CD 환경에서 캐시를 활용하면 pnpm과 Bun이 npm보다 빠른 경향이 있다. 로컬 개발 환경에서는 이미 캐시된 상태라면 세 가지 차이가 크지 않다.
lock 파일 비교 및 팀에서 통일해야 하는 이유
| 항목 | package-lock.json | yarn.lock | pnpm-lock.yaml | bun.lockb |
|---|---|---|---|---|
| 생성 도구 | npm | yarn | pnpm | Bun |
| 형식 | JSON | 커스텀 텍스트 | YAML | 바이너리 |
| 의존성 트리 | 전체 포함 | 전체 포함 | 전체 포함 | 전체 포함 |
| 가독성 | 낮음 (거대한 JSON) | 보통 | 높음 | 없음 (바이너리) |
| git diff | 변경 추적 어려움 | 읽기 가능 | 읽기 가능 | 불가 (별도 도구 필요) |
네 lock 파일은 서로 호환되지 않는다. npm으로 설치하면 package-lock.json이, yarn으로 설치하면 yarn.lock이 갱신된다. 두 파일이 동시에 존재하면 어느 것이 기준인지 모호해진다.
팀에서 통일해야 하는 이유:
- 재현 가능한 빌드: 모든 팀원이 동일한 버전을 설치함
- CI/CD 일관성: 빌드 서버가 항상 동일한 결과를 냄
- PR 리뷰: lock 파일 변경이 한 파일에만 나타나야 추적 가능
- 충돌 최소화: 두 lock 파일이 동시에 수정되면 머지 충돌이 두 배
.npmrc 또는 package.json의 engines 필드로 패키지 매니저를 강제할 수 있다:
// package.json
{
"engines": {
"node": ">=18.0.0",
"npm": "please-use-pnpm",
"pnpm": ">=8.0.0"
},
"packageManager": "pnpm@8.15.0"
}
packageManager 필드를 설정하면 corepack이 지정된 버전의 패키지 매니저만 허용한다.
monorepo에서의 선택
workspace 지원은 패키지 매니저 선택에서 monorepo의 핵심 기준이 된다.
| 기능 | npm workspaces | yarn workspaces | pnpm workspaces | Bun workspaces |
|---|---|---|---|---|
| 기본 지원 | v7+ (2020~) | v1부터 | 처음부터 | 1.0부터 |
| 설정 파일 | package.json |
package.json |
pnpm-workspace.yaml |
package.json |
| 패키지 필터 실행 | --workspace 플래그 |
yarn workspace <name> |
--filter 플래그 |
--filter 플래그 |
| 루트 패키지 격리 | 미지원 | 미지원 | 지원 (--filter !root) |
제한적 |
| 호이스팅 제어 | 제한적 | 제한적 | .npmrc로 세밀하게 |
제한적 |
| 유령 의존성 차단 | 없음 | 없음 | 기본 차단 | 없음 |
유령 의존성(phantom dependency) 은 직접 설치하지 않은 패키지를 코드에서 require할 수 있는 문제다. npm/yarn은 node_modules를 호이스팅해서 최상위에 패키지를 올리기 때문에, 의존성 트리 어딘가에 있는 패키지를 직접 설치한 것처럼 쓸 수 있다. pnpm은 이를 구조적으로 막는다. Bun은 npm과 동일하게 호이스팅 방식을 사용해서 유령 의존성 문제가 동일하게 존재한다.
# npm/yarn/Bun node_modules 구조 (호이스팅)
node_modules/
react/ ← my-package의 의존성이지만 루트에서도 접근 가능
my-package/
node_modules/ ← 비어있거나 버전 충돌 시만 여기에
# pnpm node_modules 구조
node_modules/
.pnpm/ ← 실제 패키지들 (격리됨)
my-package/ ← 내가 직접 설치한 것만비교 테이블: 종합 판단
| 항목 | npm | yarn classic | pnpm | Bun |
|---|---|---|---|---|
| 설치 속도 | 보통 | 보통~빠름 | 빠름 | 매우 빠름 |
| 디스크 효율 | 낮음 | 낮음 | 높음 | 보통 |
| 안정성 | 높음 | 높음 | 높음 | 보통 (1.x 초반) |
| 생태계/레퍼런스 | 가장 풍부 | 풍부 | 증가 중 | 성장 중 |
| monorepo 지원 | 기본 수준 | 기본 수준 | 강력 | 기본 수준 |
| 유령 의존성 방지 | 없음 | 없음 | 기본 제공 | 없음 |
| 학습 곡선 | 없음 | 낮음 | 낮음 | 낮음 |
| CI 최적화 | 캐시 설정 필요 | 캐시 설정 필요 | 캐시 효율 좋음 | 캐시 효율 매우 좋음 |
| Windows 지원 | 완전 | 완전 | 완전 | 2024년 추가, 미성숙 |
| 추천 상황 | 빠른 시작, 소규모 팀, 레거시 | npm 불편함 느낄 때 | monorepo, 디스크 절약, 엄격한 의존성 | 속도 최우선, 개인 프로젝트, 실험적 환경 |
삽질 기록
lock 파일 충돌
팀에서 npm과 yarn을 혼용하다가 package-lock.json과 yarn.lock이 동시에 프로젝트에 있었다. CI에서는 npm ci를 쓰고, 로컬에서는 yarn install을 쓰니 yarn.lock만 계속 업데이트됐다. npm이 설치할 때는 package-lock.json을 보기 때문에 두 파일이 다른 버전을 가리키는 상태가 됐다.
결국 CI에서 재현되지 않는 버그가 로컬에서 발생하는 상황이 됐다. 패키지 매니저를 pnpm으로 통일하고, 다른 lock 파일을 모두 지우고 packageManager 필드를 추가하면서 정리됐다.
# 다른 lock 파일 제거
rm package-lock.json yarn.lock
# pnpm으로 재설치
pnpm install
node_modules 꼬임
CI에서 캐시를 잘못 설정해서 node_modules가 이전 배포의 것이 남아있는 상황이 있었다. 새로운 패키지를 추가했는데 CI 빌드는 성공하고 프로덕션에서 Cannot find module 에러가 났다.
node_modules를 캐시할 때는 lock 파일의 해시를 캐시 키로 써야 한다. lock 파일이 바뀌면 캐시를 무효화해야 새 의존성이 설치된다.
# GitHub Actions 예시
- name: Cache pnpm store
uses: actions/cache@v3
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
peer dependency 경고
npm install some-library
# npm WARN ERESOLVE overriding peer dependency
# npm WARN Could not resolve dependency:
# peer react@"^17.0.0" from some-library@2.0.0
# node_modules/some-library
React 18 프로젝트에 React 17을 peerDependency로 명시한 라이브러리를 설치하면 이런 경고가 난다. npm v7+는 peer dependency 충돌을 에러로 처리해서 설치가 막히는 경우가 있다.
해결 방법은 세 가지다. 라이브러리가 업데이트될 때까지 기다리거나, --legacy-peer-deps 플래그로 우회하거나, overrides로 강제 지정한다:
// package.json — npm overrides
{
"overrides": {
"some-library": {
"react": "$react"
}
}
}
// pnpm overrides
{
"pnpm": {
"overrides": {
"some-library>react": "$react"
}
}
}
pnpm의 override 문법이 더 세밀하다. 특정 패키지의 특정 의존성만 타게팅할 수 있다.
pnpm에서 유령 의존성으로 빌드 실패
npm에서 pnpm으로 마이그레이션했을 때, 빌드가 갑자기 실패했다. 코드에서 require('some-transitive-package')를 쓰고 있었는데 package.json에는 없었다. npm에서는 호이스팅 덕분에 설치된 것처럼 쓸 수 있었다.
pnpm은 직접 설치한 패키지만 접근을 허용하기 때문에 이게 에러가 됐다. 고치는 방법은 단순하다. package.json에 명시적으로 추가하면 된다.
pnpm add some-transitive-package
이 에러가 오히려 숨겨진 의존성을 드러내는 좋은 역할을 했다. 직접 쓰는 패키지를 명시적으로 관리하게 됐고, 의존성 트리가 훨씬 명확해졌다.
Bun에서 Node.js API 미호환으로 런타임 에러
속도에 끌려서 기존 Express 서버를 Bun 런타임으로 전환했다가 child_process.fork()를 사용하는 부분에서 런타임 에러가 났다. Bun 1.x에서 child_process는 일부 메서드만 구현돼 있고 fork()는 동작이 달랐다.
패키지 매니저로만 Bun을 쓰고 런타임은 Node.js를 유지하는 방식으로 절충했다. 이 방식이면 설치 속도 이점은 누리면서 런타임 호환성 리스크는 피할 수 있다.
# 패키지 매니저만 Bun 사용, 실행은 Node.js
bun install # 설치는 Bun으로
node server.js # 실행은 Node.js로
마무리
2026년 기준 선택 기준을 단순하게 정리하면:
- 새 프로젝트, 혼자 또는 소규모 팀: npm으로 시작해도 충분하다
- monorepo, 디스크가 부족하거나 CI 최적화가 중요한 경우: pnpm
- 빠른 설치 속도 최우선, 실험적 환경: Bun (패키지 매니저 용도로만 사용도 유효)
- 안정성 최우선, 프로덕션 서버: pnpm 또는 npm
- 기존 yarn 프로젝트 유지: yarn classic 계속 쓰는 게 리스크 없다
Bun은 속도 면에서 확실한 강점이 있고 채택률도 올라가고 있지만, Windows 환경이나 복잡한 Node.js API를 쓰는 서버 프로젝트에서는 아직 검증이 더 필요하다. 패키지 매니저 기능만 따로 쓰는 방식은 충분히 실용적인 선택이다.
선택보다 중요한 건 팀 내에서 하나로 통일하는 것이다. lock 파일 하나, 패키지 매니저 하나. 이게 흔들리면 어떤 매니저를 써도 문제가 생긴다.
관련 도구: Node.js 18+, Bun 1.x, corepack, GitHub Actions
'개발 팁' 카테고리의 다른 글
| TypeScript 실전 팁 — 유틸리티 타입, 타입 가드, 제네릭 완전 정복 (0) | 2026.03.20 |
|---|---|
| cron 표현식 완전 정복 — 스케줄링 실전 치트시트 (1) | 2026.03.20 |
| Nginx 실전 설정 — 리버스 프록시, HTTPS, 캐싱 완전 정복 (0) | 2026.03.20 |
| 웹 성능 최적화 기초 — Lighthouse 점수 올리는 실전 방법 (0) | 2026.03.19 |
| 개발 브랜치 전략 완전 정복 — Git Flow, GitHub Flow, Trunk-based 비교 (0) | 2026.03.19 |