← Blog
빌드 & 번들러

패키지 매니저 4종 비교 — node_modules를 만드는 서로 다른 방식

한 줄 요약

자바스크립트 패키지 매니저 4종은 결국 node_modules(의존성)를 어떻게 디스크에 만들고 관리하느냐가 핵심 차이다. npm/yarn classic은 "복사 + 평탄화", pnpm은 "전역 저장소 + 링크", yarn berry는 "node_modules 자체를 없애고 zip + 맵으로 대체".


왜 알아야 했나

  • node_modules를 열어봤을 때 보이는 .pnpm 폴더, 심볼릭 링크, 버전이 박힌 폴더명, .bin 안의 실행 스크립트 등이 왜 이렇게 생겼는지 이해하기 위해.
  • 글로만 읽으면 추상적이라, 실제 디스크에 어떤 파일/폴더가 생기는지 눈으로 확인하며 이해하려고.
  • 프로젝트에서 어떤 패키지 매니저를 선택해야 할지, 그 선택이 디스크/속도/호환성/협업에 어떤 영향을 주는지 판단하기 위해.

핵심 개념

0. 4종 한눈에 비교

도구설치 방식node_modules 구조lock 파일
npm복사flat (평탄화/hoisting)package-lock.json
yarn classic (v1)복사flat (평탄화/hoisting)yarn.lock
pnpm하드링크 + 전역 store중첩 + 심볼릭 링크pnpm-lock.yaml
yarn berry (v2+)PnP (zip + 맵)없음 (.pnp.cjs)yarn.lock

1. npm — 가장 기본

  • 설치하면 모든 패키지가 node_modules평탄하게(flat) 복사된다.
  • 하위 의존성도 최상위로 끌어올려진다(hoisting).
  • 그 결과 유령 의존성(phantom dependency) 문제 발생: package.json에 안 적은 패키지를 require해도 우연히 동작해버림 → 나중에 의존성 버전이 바뀌면 갑자기 깨짐.
my-project/
├── package.json
├── package-lock.json          ← 정확한 버전 잠금
└── node_modules/
    ├── express/
    ├── lodash/
    ├── debug/                 ← express의 하위 의존성인데 최상위로 hoist됨
    └── ... (수백 개 폴더)

2. yarn classic (v1) — npm과 거의 동일

  • 구조는 npm과 본질적으로 같다 (flat + hoisting).
  • lock 파일 이름(yarn.lock)과 포맷만 다름.
  • 유령 의존성 문제도 npm과 동일하게 존재.

3. pnpm — 전역 store + 링크

  • 패키지를 **컴퓨터 한 곳(전역 store)**에 딱 한 벌만 저장하고, 프로젝트엔 링크만 건다.
  • 최상위 node_modules에는 내가 직접 설치한 패키지만 심볼릭 링크로 보임 → 유령 의존성 구조적으로 차단.
  • 같은 (이름+버전)은 디스크에 한 번만 저장 → 디스크 절약 + 빠른 설치.
~/Library/pnpm/store/v10/       ← 전역 저장소 (실제 파일 본체)

my-project/
├── package.json
├── pnpm-lock.yaml
└── node_modules/
    ├── express → .pnpm/express@4.18.0/node_modules/express   (심볼릭 링크)
    └── .pnpm/                  ← 실제 구조가 여기 숨어있음
        ├── express@4.18.0/node_modules/express/   → store로 하드링크
        └── debug@4.3.0/node_modules/debug/
  • 두 종류의 링크 사용:
    • 최상위 node_modules의 패키지 → .pnpm을 가리키는 심볼릭 링크
    • .pnpm 안의 파일 → 전역 store를 가리키는 하드링크

4. yarn berry (v2+) PnP — node_modules가 사라짐

  • PnP(Plug'n'Play) 모드에서는 node_modules를 아예 만들지 않음.
  • 패키지를 zip 그대로 .yarn/cache에 저장 (풀지 않음).
  • .pnp.cjs가 "어떤 패키지가 어느 zip 경로에 있는지" 전부 기록한 거대한 맵. Node의 require를 가로채 위치를 알려줌.
  • 유령 의존성 완전 차단 + 매우 빠름. 단, node_modules를 전제하는 일부 도구와 호환성 문제 가능.
my-project/
├── package.json
├── yarn.lock
├── .pnp.cjs                   ← 의존성 해석 맵
├── .pnp.loader.mjs
└── .yarn/
    └── cache/
        ├── express-npm-4.18.0-abc123.zip   ← 압축된 채로 저장
        └── debug-npm-4.3.0-def456.zip

5. node의 모듈 탐색 규칙 (walking up the tree)

Node가 require('react')를 만나면, 현재 파일 폴더부터 부모로 한 칸씩 올라가며 각 단계의 node_modules를 검사하고, 가장 먼저 찾은 걸 쓴다.

Button.js (src/components/) 에서 import 'react' 하면:
1. src/components/node_modules/react   → 없음
2. src/node_modules/react              → 없음
3. project/node_modules/react          → 있다! 사용
   (없으면 계속 위로... 최상위까지 못 찾으면 Cannot find module 에러)
  • "가장 가까운 node_modules가 이긴다" → 같은 라이브러리의 다른 버전 충돌이 자연스럽게 해결됨.
  • 매 탐색마다 폴더 존재 여부를 디스크에 묻는 비용(stat) 발생 → 큰 프로젝트에서 누적되면 느려짐.
  • npm/pnpm은 이 규칙을 따르고, yarn berry PnP는 이 탐색을 .pnp.cjs 맵 조회로 대체해서 없앰.

6. pnpm store 내부 구조 (CAS)

전역 store(~/Library/pnpm/store/v10) 안에는 세 폴더가 있다:

  • files/ — 실제 파일 본체. 파일을 이름이 아니라 "내용의 해시값"으로 저장(CAS, content-addressable storage). 해시 앞 두 글자(00, 01...)로 폴더를 나눠 분산. 내용이 같으면 파일도 하나만 저장 → 패키지/버전이 달라도 동일 파일(LICENSE 등)은 중복 제거. (버전 단위가 아닌 파일 단위 중복 제거)
  • index/ — 패키지 단위 "조립 설명서". "react@18.2.0은 package.json=해시X, index.js=해시Y..." 식의 파일명→해시 매핑. (비유: files=부품 창고, index=부품표/BOM)
  • projects/ — 어떤 프로젝트가 store의 무엇을 참조 중인지 추적하는 장부. pnpm store prune이 고아 파일을 골라내는 근거.

7. pnpm store 위치 결정 기준

  • OS 기본 경로: macOS ~/Library/pnpm/store, Linux ~/.local/share/pnpm/store, Windows %LOCALAPPDATA%\pnpm\store.
  • 드라이브(파일시스템) 단위: 하드링크는 같은 파일시스템 내에서만 동작 → 프로젝트가 다른 드라이브에 있으면 그 드라이브에 별도 store가 생김.
  • 버전 폴더: 경로 끝의 v10처럼 store 포맷 버전이 붙음. pnpm 메이저 업데이트로 구조 바뀌면 v11로 새로 생김.
  • 수동 설정 가능: pnpm config set store-dir /path 또는 .npmrcstore-dir=....

8. Node 모듈 캐시 (import 반복 시 동작)

  • Node에는 **require/import 캐시(module cache)**가 있다.
  • 100개 파일이 전부 import React from 'react'를 해도, react 모듈은 프로세스당 딱 한 번만 디스크에서 읽히고 실행됨. 나머지는 메모리 캐시에서 같은 객체를 반환.
  • (그래서 한 프로젝트 안에서 React의 Context/상태가 일관되게 동작함)
  • 위치 탐색(resolution): 첫 1회는 발생, 이후 캐시. 그 첫 탐색 비용을 yarn berry PnP가 맵 조회로 줄여줌.

9. Zero-Install (yarn berry)

  • 이름은 "설치 0번"이지만 정확히는 "git clone 직후 install 없이 바로 실행 가능".
  • yarn berry의 의존성은 .yarn/cachezip 몇 개 + .pnp.cjs 하나로 압축됨 → 파일 수가 적어 git에 그대로 커밋 가능.
  • 동료가 git clone만 하면 yarn install 없이 바로 yarn dev 실행. CI도 설치 단계 생략.
  • 장점: 즉시 실행, 온보딩/CI 속도↑, 버전 박제로 "내 컴퓨터에선 됨" 문제↓, npm 레지스트리 다운돼도 설치 가능.
  • 단점: git 저장소 용량 증가(zip 관리 필요), PnP 호환성 이슈를 떠안음.
  • npm/pnpm은 풀린 node_modules(수만 파일)를 커밋해야 해서 현실적으로 불가 → zip으로 묶는 PnP라서 가능한 전략.

10. yarn berry의 nodeLinker (PnP만 있는 게 아니다)

nodeLinker는 yarn berry의 설정 옵션(.yarnrc.yml)으로, "의존성을 디스크에 어떤 방식으로 배치할지"를 고르는 스위치다. yarn은 의존성을 받아오고 버전을 잠그는 일(yarn.lock, .yarn/cache)은 그대로 하되, 디스크에 펼치는 마지막 단계만 골라 끼울 수 있다.

# .yarnrc.yml
nodeLinker: pnp        # 또는 node-modules, 또는 pnpm
동작node_modules특징
pnp (기본값)Plug'n'Play. zip + .pnp.cjs없음가장 빠르고 엄격, 호환성 마찰, Zero-Install 가능
node-modules전통적 방식. zip을 풀어 폴더 생성있음 (평탄)호환성 최고, 절약·엄격 이점 포기. PnP가 안 맞는 도구(RN 등)의 탈출구
pnpmpnpm처럼 링크 구조있음 (링크)유령 의존성 차단 + 절약, PnP 호환성 부담 줄임

스펙트럼으로 보면:

node-modules ───────── pnpm ───────── pnp
(호환성 최고,        (균형:          (속도·엄격
 절약·엄격 없음)     절약+호환성)     최고, 호환성 마찰)

11. "yarn berry의 nodeLinker: pnpm" vs "진짜 pnpm"

디스크에 펼치는 결과물(레이아웃)은 비슷하지만, 그 외 주변부(엔진)는 전부 다르다. 비유: 같은 가구 배치법을 따라 했어도 집을 지은 건설사가 다른 셈.

비슷한 점 (레이아웃):

  • 둘 다 store에서 링크로 node_modules 구성
  • 둘 다 직접 의존성만 최상위 + 하위 의존성 격리 → 유령 의존성 차단
  • 둘 다 디스크 절약 지향

다른 점 (엔진 전부):

항목yarn berry (nodeLinker: pnpm)진짜 pnpm
의존성 해석기yarn resolver, yarn.lockpnpm resolver, pnpm-lock.yaml
다운로드 캐시.yarn/cachezip전역 store의 CAS(해시 파일)
store 구조yarn 방식files/index/projects (CAS)
CLIyarn add/installpnpm add/install
설정 파일.yarnrc.yml.npmrc
중복 제거 단위패키지/버전 단위(zip)개별 파일 단위(CAS 해시) — 더 촘촘
성숙도비교적 신생 옵션이 방식의 원조, 오래 검증됨

가장 본질적 차이는 store와 중복 제거 방식:

  • 진짜 pnpm: 내용 해시로 저장(CAS) → 서로 다른 패키지라도 내용 같은 개별 파일까지 중복 제거. 가장 촘촘.
  • yarn pnpm 모드: 다운로드 캐시는 여전히 yarn의 zip. 레이아웃만 pnpm을 닮았고, 저장·중복제거 엔진은 yarn 것.

"겉모습은 pnpm, 속(엔진)은 yarn".


12. yarn berry의 하드링크는 zip을 가리키나? (No)

하드링크는 어떤 경우에도 zip을 직접 가리키지 않는다. 모드별로 다름:

  • PnP 모드: 하드링크 자체가 거의 안 쓰임. .yarn/cache에 zip이 그냥 놓여 있고, .pnp.cjs가 "express는 이 zip의 이 경로"라고 텍스트 경로로 매핑(링크 아님).
    • ⚠️ .pnp.cjslinkType: "HARD"OS 하드링크가 아님. "이 패키지는 자체 복사본을 갖는다(soft link 아님)"는 yarn 내부의 논리적 표시일 뿐.
  • nodeLinker: pnpm 모드: 하드링크 등장. 단, zip을 푼 실제 파일을 가리킴.

왜 zip을 못 가리키나? 하드링크는 "같은 파일 데이터(inode)에 이름표를 더 다는 것"인데, zip 안의 index.js는 독립 파일이 아니라 zip 한 덩어리의 일부라 inode가 없음 → 물리적으로 하드링크 불가.

[PnP 모드]
node_modules 없음
.yarn/cache/express.zip   ← zip 그대로 (안 풂)
.pnp.cjs ──(텍스트 경로)──> zip 안의 위치   ※ 하드링크 아님

[nodeLinker: pnpm 모드]
zip을 풀어 store에 실제 파일 생성
node_modules/express ──(하드링크)──> 풀린 실제 파일   ※ zip 아님

핵심: zip 유지(PnP)와 하드링크 절약(pnpm식)은 양립 불가. zip을 유지하려면 하드링크를 포기하고 경로 맵으로 가야 하고, 하드링크를 쓰려면 zip을 풀어야 함. yarn berry는 이 둘을 모드로 분리.


13. yarn pnpm 모드의 zip은 낭비인가? (이중 저장 문제)

레이아웃은 pnpm과 거의 같지만, 저장 효율은 다르다. zip은 버려지는 중간 산물이 아니라 yarn의 다운로드 캐시 본체라 계속 남는다.

zip의 역할:

  • 재설치 시 네트워크 불필요(store 날려도 zip에서 다시 풂)
  • 버전 잠금의 물리적 보증
  • (구조적으로) Zero-Install 전환 가능성

문제 = 이중 저장: 같은 패키지가 디스크에 두 형태로 존재.

.yarn/cache/express.zip          ← 압축된 형태 (1)
글로벌 store/express의 풀린 파일   ← 풀린 형태 (2)
항목진짜 pnpmyarn (nodeLinker: pnpm)
다운로드 캐시CAS 풀린 파일이 겸함.yarn/cache의 zip (별도)
하드링크 원본CAS 풀린 파일글로벌 store에 따로 풂
같은 패키지 저장 형태한 벌압축본 + 해제본 두 벌
파일 단위 중복 제거(CAS)있음(촘촘)없음(패키지/버전 단위)

진짜 pnpm은 CAS의 풀린 파일이 "다운로드 캐시 + 하드링크 원본" 역할을 동시에 해서 한 벌이면 충분. yarn pnpm 모드는 둘이 분리돼 압축본+해제본을 중복 보관 → 약간 더 무겁고, CAS의 파일 단위 중복 제거도 없음.

결론: "동일한데 zip만 쓸데없이 생긴 거냐"의 정답은 — 레이아웃·사용 경험은 거의 동일하지만 저장 효율은 동일하지 않다. zip은 "yarn이 포기 못 하는 자기 캐시 형식"이고 그게 pnpm 대비 비효율로 나타남. 순수 pnpm식 이점만 원하면 그냥 진짜 pnpm이 더 깔끔.


14. PnP의 메모리 스파이크 — 빌드 OOM 사례 (카카오페이)

출처: 카카오페이 기술 블로그 "Yarn Berry에서 pnpm으로 패키지 매니저 전환기"

상황: 일부 SSR 서비스의 도커 이미지 빌드 단계에서 OOM(Out of Memory)으로 배포 실패. 원인은 build 초기에 Yarn PnP가 의존성 데이터를 메모리에 로드하는 그 짧은 순간에 메모리 한도를 초과한 것. 평소엔 멀쩡하다가 시작하는 찰나에만 봉우리처럼 치솟음(스파이크).

왜 PnP에서 터지나 (메커니즘):

  • PnP는 "전부 미리, 한 번에" 올린다. PnP는 node_modules가 없고 .pnp.cjs라는 거대한 맵(어떤 패키지가 어느 zip의 어느 경로에 있는지 전부 기록)으로 모듈을 해석. require에 즉시 답하려면 이 맵을 통째로 메모리에 펼쳐놔야 함. 의존성이 많을수록 맵도 크고 메모리도 큼.

    • 비유: "건물 들어가기 전에 전체 층별 안내도를 통째로 외워버림". 안내도가 두꺼울수록 머리(메모리)를 많이 씀.
  • Worker가 여러 개라 그게 곱해진다 (핵심). Next.js·번들러는 빌드를 빨리 하려고 **다수의 Worker(별도 프로세스)**를 띄움. Worker는 메모리를 공유하지 않아 각자 .pnp.cjs를 파싱해 메모리에 올림.

    .pnp.cjs가 200MB라면Worker 1개: 200MBWorker 8개: 200MB × 8 = 1.6GB  ← 빌드 시작 순간 동시에! → OOM
    
    • 그래서 메모리 차트에서 평균은 멀쩡한데 최대값(피크)만 비정상적으로 높음.
    • 비유: 안내원 8명을 동시 출근시켰는데 8명이 각자 두꺼운 안내도를 동시에 통째로 암기 → 순간 두뇌 용량 바닥.

왜 pnpm은 안 터지나:

  • pnpm은 심링크 기반 표준 node_modules라 Node.js 기본 모듈 해석 방식을 그대로 따름(현재 위치에서 위로 거슬러 올라가며 탐색).
  • 미리 통째로 외워둔 맵이 없음. 실제 import/require가 호출되는 시점에 해당 모듈 경로만 해석 → 필요한 것만, 필요할 때, 조금씩 → Worker가 많아도 메모리 평탄.
    • 비유: 안내도를 외우지 않고, 찾을 때마다 벽의 표지판을 보고 한 걸음씩.
[Yarn PnP]
빌드 시작 → Worker 8개 동시 생성 → 각자 .pnp.cjs 전체를 메모리 파싱
→ (맵 크기 × Worker 수) 만큼 순간 폭발 → OOM
"전체 지도를 8명이 동시에 통째로 암기"

[pnpm]
빌드 시작 → Worker 8개 → 각자 import 만날 때마다 그 모듈 경로만 해석
→ 필요한 것만 조금씩 → 메모리 평탄
"표지판 보고 그때그때 한 걸음씩"
  • 트레이드오프 관점: 이건 "PnP는 맵 조회로 탐색이 빠르다"는 장점의 이면. 빠르게 답하려고 맵을 통째로 메모리에 들고 있는 설계가, 멀티 Worker + 제한 메모리 환경에선 오히려 폭발 원인이 됨. (카카오페이는 이 외에 Git 부담, PR diff 폭증, IDE SDK 번거로움, ZipFS 읽기전용 디버깅 제약 등도 전환 이유로 들었고, 전환 후 도커 이미지 약 83%↓, 최대 메모리 약 64%↓를 얻음.)

15. 번들러별 Worker(병렬화) 구조 — 어디서 스파이크가 심한가

메모리 스파이크는 "병렬화한다"는 사실보다 **"별도 프로세스로 병렬화(메모리 분리) vs 한 프로세스 내 스레드로 병렬화(메모리 공유)"**에 달렸다.

번들러병렬화 방식메모리 공유PnP 스파이크 취약도
webpack멀티 프로세스 Worker (thread-loader 등, 기본 CPU-1개)❌ 안 함높음 (Worker마다 맵 적재)
Next.js내부 webpack(/Turbopack)에 의존webpack 따름높음 (webpack 기반일 때)
Vite (dev)번들 안 함, 네이티브 ESM 온디맨드낮음
Vite (prod)Rollup/Rolldown엔진 내부 처리중간~낮음
Rspack / Turbopack한 프로세스 내 멀티 스레드 (Rust)✅ 공유낮음
  • webpack: 코어 컴파일은 단일 스레드라, 무거운 작업을 별도 Worker 프로세스 풀(thread-loader 등, 기본 CPU-1개)로 떼어 병렬화. 각 Worker가 독립 프로세스 → PnP면 각자 .pnp.cjs 적재 → N배 메모리. webpack 자체가 대형 프로젝트에서 메모리 많이 먹기로 유명. PnP 스파이크에 가장 취약.
  • Next.js: 빌드 엔진으로 webpack(최근 Turbopack 전환 중) 사용. 카카오페이 사례가 정확히 이 경우 — Next.js의 webpack 기반 멀티 프로세스 빌드 + PnP.
  • Vite: 개발 모드는 번들 자체를 안 하고 네이티브 ESM을 온디맨드 서빙(esbuild가 의존성 프리번들). "필요한 모듈만 그때그때"라 전체를 동시에 메모리에 안 올림. 프로덕션은 Rollup(2026~ Rolldown). webpack식 멀티 프로세스 패턴과 결이 달라 상대적으로 덜 취약.
  • Rspack / Turbopack: webpack 아키텍처를 Rust로 재구현, 한 프로세스 안의 멀티 스레드로 병렬화. 스레드는 메모리를 공유 → 의존성 정보를 한 벌만 올려두고 모든 스레드가 같이 봄 → N배로 안 불어남. 거대 앱에서도 메모리 낮게 유지. 구조적으로 스파이크에 강함.

핵심 구분:

멀티 프로세스(webpack):  맵 × N개 복제 (메모리 분리)   → 스파이크 위험
멀티 스레드(Rspack 등):  맵 1벌 공유 (메모리 공유)     → 스파이크 안전

예시 / 코드

pnpm의 .bin 실행 래퍼 (이미지에서 본 acorn 파일)

#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
# ...
# NODE_PATH를 명시적으로 설정 → 격리 구조에서도 모듈을 정확히 찾게 함
export NODE_PATH="/Users/<user>/projects/A/node_modules/.pnpm/acorn@8.16.0/node_modules/acorn/node_modules:..."
# ...
# .. 를 여러 번 거슬러 .pnpm/acorn@8.16.0/... 의 진짜 실행 파일 실행
exec node "$basedir/../../../../../../acorn@8.16.0/node_modules/acorn/bin/acorn" "$@"

→ 경로에 acorn@8.16.0처럼 버전이 박혀있는 게 pnpm의 특징.

.pnp.cjs 핵심 구조 (yarn berry)

// 자동 생성 파일. 핵심은 "패키지 위치를 기록한 거대한 맵"
const RAW_RUNTIME_STATE = '{ \
  "packageRegistryData": [ \
    ["express", [["npm:4.18.0", { \
      "packageLocation": "./.yarn/cache/express-npm-4.18.0-abc123.zip/node_modules/express/", \
      "packageDependencies": [["accepts", "npm:1.3.8"]], \
      "linkType": "HARD" \
    }]]] \
  ] \
}';

module.exports = {
  // require('express') 호출을 가로채 zip 안 경로를 즉시 반환
  resolveRequest(request, issuer) { /* 맵 조회 후 위치 반환 */ },
  setup() { /* Node의 모듈 해석을 이 맵으로 교체 */ },
};

packageLocation폴더가 아니라 zip 내부 경로를 가리키는 게 핵심.

같은 프로젝트, 4가지 디스크 모습 (express 하나만 의존)

[npm / yarn classic]
node_modules/express/        ← 코드 복사본
node_modules/debug/          ← hoist된 하위 의존성 (보임 → 유령 의존성 원인)

[pnpm]
node_modules/express → 링크
node_modules/.pnpm/...       ← 실제 파일은 전역 store 하드링크
(debug는 최상위에 안 보임)

[yarn berry PnP]
node_modules 없음 ❌
.pnp.cjs                     ← 위치 매핑
.yarn/cache/express-*.zip    ← 압축된 채로 보관

자주 쓰는 명령

pnpm store path          # store 위치 확인 (예: ~/Library/pnpm/store/v10)
pnpm store prune         # 아무 프로젝트도 안 쓰는 고아 파일만 정리 (평소 정리용)
pnpm config set store-dir /path/to/store   # store 위치 변경
rm -rf $(pnpm store path)                   # store 통째로 삭제 (강수)

macOS에서 숨겨진 ~/Library 보기

ls -la ~/Library/pnpm/store/     # 터미널은 숨김과 무관하게 접근 가능
# Finder: Cmd + Shift + . (마침표) 로 숨김 토글
# 또는 이동(Go) 메뉴에서 Option 키 누르면 "라이브러리" 나타남

헷갈렸던 점

  • "store에도 실제 파일이 쌓이면 npm처럼 용량 많이 먹는 거 아냐?" → 실제 파일이 쌓이는 건 맞지만 (이름+버전) 조합당 딱 1벌만 저장. npm은 프로젝트 수만큼 복제. 하드링크는 복사본이 아니라 "같은 데이터를 가리키는 또 다른 이름표"라 추가 용량 거의 0. 프로젝트가 많을수록 pnpm이 압도적으로 절약.

  • "하드링크인데 캐시를 비운다는 게 무슨 말?" → pnpm엔 저장소가 두 종류. ① CAS(files/ 등) = 하드링크로 참조되는 실데이터(라이브 저장소). ② 메타데이터 캐시 = 레지스트리 응답(버전 목록, tarball URL 등) 저장. "캐시 비우기"는 보통 ②를 뜻하고, ①은 pnpm store prune으로 고아만 정리.

  • "같은 라이브러리 다른 버전이면 둘 다 설치하나? pnpm만 재사용?" → 동작 깨지면 안 되니 npm도 pnpm도 두 버전 모두 설치. "재사용 vs 재설치" 차이는 버전 다양성이 아니라 같은 (이름+버전)이 여러 프로젝트에서 쓰일 때 드러남. 버전이 다르면 그냥 별개 항목으로 각각 저장.

  • "파일마다 react import하면 매번 다시 읽어?" → 아니. Node 모듈 캐시 덕분에 프로세스당 1번만 읽고 실행. 패키지 매니저 공통. 첫 위치 탐색만 PnP가 더 빠름.

  • "yarn berry는 zip 만들 때 오래 걸리겠네?" → 레지스트리에서 이미 .tgz(압축)로 받음. npm/pnpm은 그걸 풀어서 펼치고(압축 해제 비용), yarn berry는 자기 .zip으로 재포장(풀어서 펼치는 과정 없음). "압축 때문에 느림"이 아니라 수만 개 작은 파일을 안 풀어서 오히려 빠른 쪽.

  • "zip을 안 풀고 보는 원리? 그럼 세상 모든 zip이 그래도 되는 거 아냐?" → zip엔 **중앙 디렉터리(목차)**가 있어 "어떤 파일이 어디에 몇 바이트로 있는지" 색인됨. 필요한 파일만 해당 위치로 점프해 그 조각만 해제. yarn berry는 Node의 fs 함수에 패치를 걸어 zip 안을 투명하게 읽음(zipfs). 기술적으론 대부분 가능하고 실제로 .jar/.apk/.docx/.xlsx/.epub이 전부 zip임. 다만 트레이드오프: 반복 접근이 잦으면 매번 푸는 CPU 비용 > 한 번 풀어두기, 그리고 node_modules를 전제하는 도구 호환성 문제. 그래서 "가끔 부분만 읽는" 용도엔 zip 유지가 유리.

  • "@ 붙은 폴더 vs 안 붙은 폴더?"@scope(네임스페이스). @sveltejs 자체는 패키지가 아니고, 그 안의 kit이 진짜 패키지(@sveltejs/kit). 이름 충돌 방지 + 조직별 묶음 + @types/는 TS 타입 정의 관례. 슬래시 때문에 폴더가 한 단계 더 생길 뿐 동작 차이는 없음.

  • "pnpm은 한 프로젝트만 하면 npm과 차이 없는 거네?" → 디스크 절약은 멀티 프로젝트에서 빛나지만, 유령 의존성 차단재설치 속도는 단일 프로젝트에서도 유효. "어차피 하나니까 npm" 이 항상 맞진 않음.

  • "yarn berry는 무조건 PnP(node_modules 없음)인 거 아냐?" → 아님. nodeLinker 옵션으로 pnp(기본) / node-modules(전통 방식) / pnpm(링크 방식) 중 고를 수 있음. PnP가 특정 도구와 안 맞으면 node-modules로 탈출 가능.

  • "yarn berry의 하드링크는 zip을 가리키나?" → 아님. 하드링크는 inode 있는 풀린 실제 파일만 가리킴(zip 안 파일은 inode 없음). PnP 모드는 애초에 하드링크 안 쓰고 .pnp.cjs의 텍스트 경로로 zip 내부를 가리킴. nodeLinker: pnpm 모드는 zip을 푼 뒤 그 실제 파일을 하드링크. .pnp.cjslinkType: "HARD"는 OS 하드링크가 아니라 yarn 내부의 논리적 표시.

  • "yarn pnpm 모드 = 진짜 pnpm인데 zip만 쓸데없이 생긴 거 아냐?" → 레이아웃·사용 경험은 거의 같지만 저장 효율은 다름. yarn은 다운로드 캐시(zip)와 하드링크 원본(풀린 파일)이 분리돼 압축본+해제본 이중 보관. 진짜 pnpm은 CAS 한 곳이 둘을 겸하고 파일 단위 중복 제거까지 함. "겉모습은 pnpm, 속 엔진은 yarn." 순수 pnpm 이점만 원하면 진짜 pnpm이 더 깔끔.

  • "왜 PnP는 빌드할 때 메모리가 폭발(OOM)하는데 pnpm은 안 그래?" → PnP는 require에 즉시 답하려고 .pnp.cjs(전체 의존성 맵)를 통째로 메모리에 적재. 빌드 시 다수 Worker(별도 프로세스)가 각자 맵을 올려 (맵 크기 × Worker 수)로 순간 폭발. pnpm은 표준 node_modules라 import 시점에 필요한 경로만 해석 → 조금씩만 올려 평탄. PnP의 "맵 조회로 빠름"이라는 장점의 이면.

  • "내가 아는 번들러(webpack/vite/rspack)도 Worker 여러 개 뜨던데 다 스파이크 나?" → 아님. 별도 프로세스로 병렬화하면(메모리 분리) 맵이 N배 복제돼 위험, 한 프로세스 내 스레드로 병렬화하면(메모리 공유) 맵 1벌만 공유돼 안전. webpack은 멀티 프로세스(thread-loader)라 PnP와 만나면 취약(Next.js 사례). Rspack·Turbopack은 Rust 기반 멀티 스레드라 메모리 공유로 안전. Vite(dev)는 번들 자체를 안 해 취약도 낮음.


실무 적용

  • 신규 프로젝트 기본값: 무난하면 npm(Node에 기본 포함, 설정 없이 어디서나 동작).
  • 모노레포 / 디스크 절약 / 빠른 설치 / 엄격한 의존성: pnpm (요즘 가장 인기, 전환 비용 낮음).
  • 레거시 유지보수: yarn classic (신규엔 굳이 권할 이유 적음 — npm과 사실상 대등).
  • 최신 워크플로우(Zero-Install 등) 적극 도입 + 호환성 이슈 감당 가능: yarn berry.

pnpm vs yarn berry — 무엇을 고를까

  • pnpm 장점: ① 진짜 node_modules(링크)를 만들어 호환성 우수(node_modules 전제 도구가 대부분 동작) ② npm과 명령/멘탈모델 유사해 전환 비용 낮음 ③ 실제 파일이 디스크에 있어 IDE/디버거 마찰 적음.
  • yarn berry 장점: ① Zero-Install 가능 ② 디스크에 안 풀어 설치 더 빠를 수 있음 ③ .pnp.cjs 맵으로 의존성 해석이 엄격·결정적.
  • 현재 분위기: 호환성 덕에 실무에서 pnpm이 더 널리 채택. yarn berry는 그 철학에 확신 있고 호환성 이슈 감당 가능한 팀이 선택.

yarn berry를 쓴다면 — nodeLinker 선택

  • 호환성이 최우선(PnP가 도구와 충돌) → node-modules
  • 절약·엄격함도 원하지만 PnP는 부담 → pnpm
  • 최신·최고 성능, 호환성 감당 가능 → pnp(기본)
  • "yarn pnpm 모드 vs 진짜 pnpm" 선택 기준: 이미 yarn 생태계(plugin, constraints, workspace)에 있고 레이아웃만 pnpm식으로 얻고 싶다 → nodeLinker: pnpm. 새로 시작하고 pnpm식 이점을 원한다 → 그냥 진짜 pnpm(CAS 기반 더 촘촘한 절약 + 더 오래 검증). 한 줄: nodeLinker: pnpm은 "yarn 엔진 + pnpm 레이아웃", 진짜 pnpm은 "pnpm 엔진 + pnpm 레이아웃".

npm vs yarn classic — 역사적 차이 (지금은 거의 사라짐)

2016년 yarn 등장 당시 큰 차이였으나 npm이 다 흡수:

차이점2016년 당시지금
lockfileyarn만 있음 (npm 없음 → "내 컴퓨터에선 됨" 문제)둘 다 있음
속도yarn이 병렬로 훨씬 빠름거의 동등
설치 일관성yarn이 결정적둘 다 결정적
무결성 검증yarn이 체크섬 제공둘 다 제공(integrity)
오프라인 캐시yarn이 안정적둘 다 잘 됨
출력 UXyarn이 깔끔취향 차이

→ yarn은 "npm을 이긴 도구"라기보다 "npm을 더 좋게 만든 자극제". 진짜 혁신(유령 의존성 차단·디스크 절약)은 다음 세대(pnpm, yarn berry)가 이어받음.

  • 관련 작업: [[node_modules 구조 이해]] · [[모노레포 / workspace]] · [[CI 빌드 속도 최적화]] · [[의존성 버전 관리 / lockfile]]

더 공부할 것

  • 하드링크 vs 심볼릭 링크의 OS 레벨 차이 (inode, 파일시스템 경계)
  • pnpm workspace / 모노레포 구성 (pnpm-workspace.yaml)
  • yarn berry의 nodeLinker: node-modules 옵션 (PnP 없이 berry 쓰기)
  • yarn berry nodeLinker: pnpm 모드의 글로벌 store 위치/구조 (진짜 pnpm store와 어떻게 다른지)
  • 하드링크와 inode의 관계 — 왜 zip 내부는 하드링크 대상이 될 수 없는지
  • 멀티 프로세스 Worker vs 멀티 스레드의 메모리 모델 차이 (프로세스 메모리 격리 vs 스레드 메모리 공유)
  • webpack thread-loader / Rspack·Turbopack의 Rust 멀티스레드 아키텍처
  • Next.js standalone 빌드의 의존성 tracing과 PnP ZipFS의 비호환 (도커 이미지 크기 영향)
  • .yarn/unplugged — Zero-Install에서도 격리되는 네이티브 의존성(swc, sharp 등)
  • .pnp.cjs가 Node 모듈 해석을 가로채는 구체적 메커니즘
  • npm package-lock.jsonintegrity/resolved 필드 구조
  • CAS(content-addressable storage)와 해시 충돌 처리
  • postinstall 스크립트 보안 이슈 (supply chain attack)

🔗 참고

  • pnpm store path 실제 출력 예: /Users/<user>/Library/pnpm/store/v10
  • 직접 실습 추천: 빈 폴더에서 각각 express 하나만 설치 후 node_modules(또는 .yarn, .pnp.cjs)를 탐색기로 열어보기 — 글보다 훨씬 와닿음.
  • pnpm 공식 문서, yarn (berry) 공식 문서의 PnP / Zero-Install 섹션
  • 카카오페이 기술 블로그 "그때는 맞고 지금은 틀리다. Yarn Berry에서 pnpm으로 패키지 매니저 전환기" (PnP 메모리 스파이크 OOM 사례, 전환 후 도커 이미지 ~83%↓·최대 메모리 ~64%↓) — https://tech.kakaopay.com/post/from-yarn-berry-to-pnpm/