node_modules를 두 번 지운 뒤에야 패키지 매니저를 다시 봤다
패키지 매니저 선택은 설치 속도, 디스크 사용량, 유령 의존성이라는 세 축이 서로 맞물려 움직인다. 단독 저장소에선 대충 선택해도 문제가 드물지만, 모노레포로 넘어가면 hoisting 방식 차이가 CI 시간과 디스크 폭발로 그대로 드러난다. npm·pnpm·yarn이 각각 의존성을 저장하고 공유하는 구조를 비교하고, 팀 규모와 모노레포 성숙도라는 실제 조건에 맞춰 도구를 고르는 판단 흐름을 정리했다.
DevInsight에서 발행해요
node_modules를 두 번 지운 뒤에야 패키지 매니저를 다시 보게 됐다. 첫 번째는 알 수 없는 의존성 누락 때문이었고, 두 번째는 스토리지 정리 스크립트가 저질렀다. 같은 폴더를 두 번 지우는 동안 나는 "왜 npm인가"에 답하지 못했다. 수백 개의 의존성 정책이 "기본값이라서" 하나로 굳어 있었다.
패키지 매니저 선택은 세 축 위에서 움직인다. 설치 속도, 디스크 사용량, 유령 의존성. 셋은 따로 놀지 않는다. 하나를 줄이는 결정이 다른 축을 비대하게 만든다. 단독 저장소라면 어느 축이 아파도 견딜 만하다. 여러 패키지를 한 저장소로 묶고 팀이 커지기 시작하는 순간 균형이 깨진다.
유령 의존성은 "지금 안 깨져 있다"는 사실이 위험을 키운다
npm과 yarn classic은 같은 전략을 공유한다. hoisting, 의존성을 루트 node_modules로 평평하게 끌어올려 중복 설치를 줄이는 방식이다. 부작용은 유령 의존성(phantom dependency)이다. package.json에 선언하지 않은 패키지를 require했는데도 코드가 돈다. 리뷰도, 빌드도, 테스트도 잡지 못한다.
구조가 무너지는 시점은 의존성 그래프가 바뀔 때다. 하위 의존성이 하나 빠지면 그 접근은 ReferenceError가 된다. 메이저 업데이트가 하위 패키지의 API를 바꾸면 호환 파탄이 난다. 로컬은 멀쩡한데 CI에서만 죽는다. 새 팀원이 clone을 받아 install을 실행하면 깔끔하게 재현된다. 그때부터 그 코드는 "우리만의 통상"으로 굳어진다. 원인을 찾는 데 며칠이 걸린다.
이건 개인의 습관 문제가 아니다. 호이스팅은 구조적 결정이라 코드를 아무리 고쳐도 다음 저장소에서 같은 패턴이 반복된다. 검증 방법은 두 가지다. npm ls로 의존성 트리를 읽어 선언과 실제 참조의 불일치를 뒤지거나, 애초에 접근이 불가능한 구조로 옮기는 것. 후자만이 현실적인 해결이다. 전자는 이미 터진 사고의 사후 확인일 뿐이다.
pnpm의 심링크는 격리를 목표로 설계됐다
pnpm은 글로벌 스토어에 패키지를 한 번만 저장하고 하드 링크로 참조한다. node_modules에는 실제 파일 대신 심링크만 놓인다. 같은 머신에서 받는 패키지가 많아질수록 디스크 절약이 누적된다. 스토어가 캐시되면 설치 속도도 빨라진다. CI에서도 스토어를 캐시로 넘기면 잡는 시간이 눈에 띄게 줄어든다. 그런데 가장 큰 체감 차이는 이 장점들이 아니다. 선언하지 않은 의존성으로 require가 즉시 실패한다는 것.
이주 첫날 나는 이 동작을 버그로 판단했다. npm에서 멀쩡히 돌던 코드가 왜 죽는가. 실은 죽은 게 아니라 그동안 감춰졌던 접근들이 터져나온 것이다. 고치는 과정에서 절반은 다른 패키지로 연결해야 할 참조였고, 절반은 진짜로 선언을 빼먹은 의존성이었다. 이주 자체가 유령 의존성 감사가 된다. 새로 들어온 도구가 가르쳐주는 식으로.
호이스팅이 없다는 점은 모노레포에서 더 빛난다. npm은 호이스팅 결과를 최상위에 모은다. 패키지 A가 패키지 B의 의존성을 우연히 require하는 사고가 경계를 넘어 벌어진다. 사고가 나면 어떤 선언이 빠졌는지 추적해야 하는데, 원인은 대부분 호이스팅 결과에 따라 달라진다. pnpm은 패키지마다 선언분만 노출하므로 이 추적 자체가 사라진다. 모노레포에서 호이스팅 방식 차이는 곧바로 CI 시간과 디스크 폭발이라는 숫자로 드러난다. 각 패키지가 같은 하위 의존성을 제각각 받아 쌓으면 설치 시간은 더해지고 저장 공간은 복사본으로 채워진다.
pnpm도 공짜는 아니다. 심링크를 견디지 못하는 도구들이 있다. Electron 계열, 일부 네이티브 모듈, node_modules를 직접 여는 도구들이 대표적이다. 그럴 때는 .npmrc에 node-linker=hoisted를 넣으면 호이스팅 구조로 되돌릴 수 있다. 격리 이점은 희석되지만 호환성은 살아난다. 예외가 몇 개 안 되면 유지 보수 관점에서 합리적인 타협이다.
yarn은 이름은 같고 전략은 갈린다
yarn classic(1.x)은 npm과 같은 호이스팅이라 유령 의존성 문제를 그대로 물려받는다. yarn Berry(2+)는 기본 모드가 PnP(Plug'n'Play)다. node_modules를 만들지 않고 .pnp.cjs에 의존성 위치를 기록한다. 설치가 빠르고 저장소가 가볍다. 대신 이 세상의 도구 대부분이 node_modules가 실제로 존재한다고 가정한다. 네이티브 모듈, 일부 번들러, node_modules를 직접 읽는 분석 도구에서 PnP는 자주 걸린다. node-modules 링커로 되돌릴 수 있지만, 그건 PnP를 선택한 이유를 절반 포기하는 일이다.
yarn을 고른다면 호환성 관리가 개발 일정에 포함된다는 사실부터 받아들여야 한다. 장점을 노리고 들어왔다가 호환성 문제만 골라 만나는 패턴이 흔하다. Berry의 신규 도입은 검증 예산이 넉넉할 때 시도할 일이다.
판단 흐름은 팀 규모와 저장소 성숙도가 정한다
세 도구의 구조를 비교하는 일과, 팀에 맞는 도구를 고르는 일은 다른 문제다. 갈림길은 둘로 나뉜다.
한 명이 관리하는 단독 저장소라면 어떤 도구를 써도 실패가 드물다. 이주 비용이 더 큰 리스크다. 그냥 npm을 유지해도 틀리지 않는다. 서비스가 여러 개로 늘고 팀원이 늘수록 격리와 저장 효율이 돈이 된다. 이 시점의 기본 선택지가 pnpm이다.
두 번째 조건은 모노레포의 성숙도다. 의존성 정리가 안 된 채 쌓인 저장소에서 pnpm으로 이주하면 죽는 패키지가 많다. 하루 만에 이주가 불가능해 보이는 지경까지 간다. 이때는 격리를 한 번에 다 키지 말고 호이스팅 링커 상태에서 시작해 조금씩 조이는 편이 낫다. 이주를 포기하라는 뜻이 아니라 실패 모드를 분산하라는 뜻이다. 이주 첫 머지는 설치 자체가 아니라, 이주로 드러난 미선언 의존성을 고치는 일이어야 한다.
검증은 수치로 한다. 이주 전후의 CI 설치 시간, du -sh node_modules로 재는 용량, 이주 중 수정한 package.json의 개수가 셋이다. 이주 후 전체 테스트 스위트가 같은 결과를 내는지도 확인해야 한다. 세 수치가 모두 나아졌다면 선택은 정당했던 것이다.
나중에 node_modules가 또 사라진다면 지우기 전에 구조부터 봐라. npm ls --all의 한 줄이 다음 선택을 갈라놓는다. 기준은 단 하나다. "선언한 것만 쓸 수 있게 강제하는가."
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
ESLint Flat Config 마이그레이션 실패 일지와 살아남는 체크리스트
ESLint 9의 flat config로 넘어가면서 extends가 사라지고, 플러그인 호환성 문제, VS Code ESLint 확장과의 설정 불일치, 글로벌 변수 선언 방식 변화 등 현장에서 마주치는 장애물을 해결 순서대로 정리한다. 삽질을 줄이는 실전 체크리스트. 2025년 4월, ESLint 9가 정식 릴리스되면서 파일은 deprecated 경고를 넘어 아예 무시되기 시작했다.
Decap CMS 유저가 Sveltia로 넘어올 때 가장 많이 묻는 질문
Netlify CMS의 진정한 후계자로 불리는 Sveltia CMS는 Svelte로 처음부터 새로 작성된 Git 기반 헤드리스 CMS다. 번들 300KB, GraphQL 즉시 로딩, 모바일 완벽 지원, 다국어 내장 등으로 기존 Decap CMS의 고질적 문제를 해결하며 2.6k 스타를 기록 중이다.
당신의 마크다운, 지금도 조용히 깨지고 있다
CommonMark, GFM, 그리고 수많은 마크다운 변종 사이에서 같은 파일이 매번 다르게 렌더링되는 이유를 파헤치고, 제로 디펜던시로 처음부터 다시 만든 렌더러가 이 문제를 어떻게 해결하려 하는지 살펴본다.
이전 글
토큰이 흐르는 동안 화면은 왜 산산조각나는가
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.