package.json에 선언하지 않은 패키지를 import했는데 코드가 잘 돈다. npm이나 yarn classic을 쓰면 흔한 일이다. 그리고 이게 **유령 의존성(phantom dependency)**이다. 언젠가 조용히 터지는 시한폭탄이다.
왜 동작하는가
npm은 의존성을 루트 node_modules로 평평하게 끌어올린다(flat hoisting). 내가 express만 설치해도, express가 의존하는 lodash가 루트 node_modules에 같이 올라온다.
{ "dependencies": { "express": "^4.18.0" } }import _ from "lodash"; // 동작한다 — express가 끌어온 lodash가 루트에 있으니까문제는 내가 lodash를 선언한 적이 없다는 것이다. express가 다음 버전에서 lodash를 빼거나 버전을 올리면, 내 코드는 아무 경고 없이 깨진다. 의존성 그래프 어딘가의 우연에 기대고 있는 셈이다.
pnpm은 구조로 막는다
pnpm은 끌어올리지 않는다. 직접 선언한 패키지만 루트 node_modules에 심볼릭 링크로 노출하고, 나머지는 .pnpm/ 안에 격리한다.
node_modules/ express -> .pnpm/express@4.18.0/node_modules/express ← 내가 선언한 것만 .pnpm/ express@4.18.0/node_modules/ express/ lodash -> ../../lodash@4/node_modules/lodash ← express는 자기 lodash를 봄 lodash@4/node_modules/lodash/lodash는 루트에 없으니 내 코드에서 import하면 바로 실패한다. 선언하지 않은 건 못 쓴다. 동시에 express는 .pnpm/ 안에서 자기 lodash를 정상적으로 찾으니, Node의 모듈 해석 규칙은 그대로 지켜진다.
(여담으로 .pnpm/ 안의 실제 파일은 글로벌 스토어로의 하드 링크라, 여러 프로젝트가 같은 패키지를 디스크에 한 번만 저장한다. 격리가 디스크 절약까지 덤으로 가져온다.)
막다 보면 만나는 현실 — shamefully-hoist
그런데 세상엔 flat node_modules를 가정하고 만들어진 패키지가 있다. 루트 node_modules에서 특정 패키지를 직접 찾으려 드는데, pnpm 격리 구조에선 그 자리에 없어서 실패한다.
이때 유혹적인 설정이 shamefully-hoist=true다. 이름 그대로 “부끄러운” 호이스팅이다. 켜는 순간 pnpm이 npm처럼 전부 flat하게 풀어버린다. 유령 의존성 방지도 격리도 다 사라진다. 공식 문서도 “마지막 수단(last resort)“이라고 못 박는다.
더 나은 답은 hoist-pattern이다. 전부가 아니라 문제되는 패키지만 콕 집어 호이스팅한다.
hoist-pattern[]=*reflect-metadata*나머지는 격리를 유지하면서 깨지는 패키지만 좁게 풀어준다. shamefully-hoist 한 줄로 다 포기하는 것과, hoist-pattern으로 한 패키지만 양보하는 것. 같은 타협이라도 범위가 다르다.
shamefully-hoist=true가 켜진 프로젝트는, 아직 유령 의존성에 기대는 패키지가 남아있다는 신호다. pnpm을 쓴다고 자동으로 안전해지는 게 아니라, 어떤 패키지가 왜 flat을 요구하는지 찾아 hoist-pattern으로 좁혀가는 일이 남는다.