node_modules 의존성 관리 메커니즘 분석
1. 개요 : 재현 가능한 문제, 유령 의존성
# 로컬 개발 환경
$ npm run dev
Server running at http://localhost:3000
# CI/CD 파이프라인
$ npm ci
$ npm run build
Error: Cannot find module 'some-random-package'
"로컬 환경에서는 동작하지만 CI 환경에서는 실패한다"는 문제는 많은 Node.js 개발자가 마주하는 대표적인 난제다. 이 문제의 근본 원인 중 하나는 유령 의존성(Phantom Dependency), 즉 package.json
에 명시적으로 선언되지 않은 패키지에 코드가 암묵적으로 의존하고 있는 상태다.
이 문제를 직접 재현하고, 그 원인을 node_modules 내부를 들여다보며 분석해 보자.
실험 환경 설정
간단한 프론트엔드 프로젝트를 하나 만들어 보자. styled-components
만 설치하고, 그것의 내부 의존성인 stylis
를 코드에서 직접 사용해 볼 것이다.
# 1. 프로젝트 생성
mkdir dependency-test && cd dependency-test
# 2. package.json 초기화
npm init -y
# 3. styled-components 설치
npm install styled-components
# 4. index.js 파일 생성
touch index.js
package.json
은 다음과 같을 것이다.
{ "dependencies": { "styled-components": "^6.1.11" } }
이제 index.js
에 styled-components
가 아닌 stylis
를 직접 require 해보자.
// index.js
try {
// `package.json`에 없는 'stylis'를 불러온다.
const stylis = require('stylis');
console.log('Phantom dependency "stylis" loaded successfully!');
console.log('Type:', typeof stylis);
} catch (e) {
console.error('Failed to load phantom dependency:', e.message);
}
package.json
에 stylis
는 없다. 당연히 이 코드는 실패해야 한다.
$ node index.js
Phantom dependency "stylis" loaded successfully!
Type: object
놀랍게도 성공한다. 어떻게 이런 일이 가능할까? 그 해답은 node_modules
의 구조에 있다.
2. npm의 호이스팅: 최적화와 그 부작용
현재의 node_modules
구조를 이해하려면, 과거 npm이 어떤 문제를 겪었는지부터 알아볼 필요가 있다.
1. 초기 npm의 한계: 비효율적인 중첩 구조
npm v2까지의 node_modules
는 package.json
의 의존성 트리를 파일 시스템에 그대로 재현하는 중첩 구조를 가졌다.
my-app/
└── node_modules/
├── express@4.17/
│ └── node_modules/
│ └── debug@2.6/
└── request@2.88/
└── node_modules/
└── debug@3.1/
이 방식은 직관적이지만 두 가지 심각한 문제를 야기했다.
- 의존성 중복: 위 예시처럼 debug 패키지가 버전만 다르다는 이유로 여러 번 다운로드되어 디스크 공간을 심각하게 낭비했다.
- 경로 길이 제한: 의존성 트리가 깊어질 경우, Windows 운영체제의 최대 경로 길이(260자) 제한을 초과하여 설치 자체가 실패하는 경우가 빈번했다.
2. 해결책과 새로운 문제: 호이스팅
이를 해결하기 위해 npm v3부터 호이스팅 전략이 도입되었다. 이는 모든 의존성을 가능한 한 node_modules
최상단으로 끌어올려 평면적인 구조로 만드는 방식이다.
우리 프로젝트의 node_modules
를 직접 확인해 보자.
$ ls node_modules | grep stylis
stylis # <-- 바로 여기!
styled-components
의 의존성인 stylis
가 node_modules
최상단에 "끌어올려져" 있다. Node.js의 require는 현재 위치의 node_modules
를 탐색하므로, index.js는 stylis
를 마치 직접 설치한 것처럼 찾을 수 있었던 것이다.
이것이 바로 유령 의존성의 정체다. 호이스팅 최적화가 낳은 의도치 않은 부작용인 셈이다.
3. 실전에서 마주한 유령 의존성 문제들
이 문제는 실제 프로덕션 환경에서 훨씬 더 교묘하고 위험한 형태로 나타난다.
사례 1. 불완전한 패키지 배포 (@emotion/css의 함정)
UI 라이브러리 개발 시, package.json
에는 @emotion/react
만 선언했지만, 코드에서는 @emotion/css
를 사용한 적이 있었다. 로컬에서는 완벽하게 동작했다. npm ls
명령어로 그 이유를 파헤쳐 보자.
$ npm ls @emotion/css
my-library@1.0.0 /path/to/project
└─┬ @emotion/react@11.11.4
└── @emotion/css@11.11.2
@emotion/css
가 @emotion/react
의 의존성이었고, 호이스팅 덕분에 로컬에서 접근 가능했던 것이다. 하지만 이 라이브러리를 사용하는 다른 프로젝트에서는 빌드 시 Module not found: Can't resolve '@emotion/css'
에러가 발생했다.
사례 2. 비결정적 React 버전 문제
더 심각한 사례는 버전 충돌이다. 프로젝트가 react@17을 요구하고, 의존성 중 하나의 라이브러리가 react@18을 요구하는 상황을 가정해 보자. 패키지 매니저의 호이스팅 알고리즘에 따라, node_modules/react
에는 react@18이 위치할 수 있다.
$ yarn why react
├─ sst@npm:2.41.4
│ └─ react@npm:18.2.0 (via npm:^18.2.0)
└─ my-app@workspace:.
└─ react@npm:17.0.2 (via npm:^17.0.2)
두 개의 다른 React 버전이 공존한다. 그렇다면 내 코드는 어떤 버전을 사용하게 될까? 직접 확인해 보자.
$ node -e "console.log(require('react').version)"
18.2.0
package.json
에 17.0.2를 명시했음에도 불구하고, 실제로는 호이스팅된 18.2.0 버전을 사용하고 있었다. 이는 두 버전 간의 호환성 덕분에 "우연히" 동작했을 뿐, 언제 터질지 모르는 잠재적 리스크를 안고 있는 것과 같다.
4. pnpm의 접근법: 심볼릭 링크를 통한 격리
pnpm은 이 문제를 근본적으로 해결하기 위해 심볼릭 링크를 활용한 새로운 node_modules
구조를 제시한다.
pnpm의 동작 원리
최상위 node_modules
에는 package.json
에 명시된 의존성들만 심볼릭 링크로 존재한다. 각 패키지의 실제 위치인 .pnpm
내부 폴더에는, 해당 패키지가 직접 의존하는 패키지들만 다시 심볼릭 링크로 연결된다. 이러한 비평면 의존성 그래프 구조는 Node.js의 모듈 해석 알고리즘이 package.json
에 명시되지 않은 패키지에 접근하는 것을 원천적으로 차단하여 유령 의존성 문제를 해결한다.
또한, pnpm은 콘텐츠 주소 저장소를 사용하여 모든 프로젝트와 공유되는 전역 스토어(~/.pnpm-store)에 패키지 파일의 유일한 복사본을 저장하고, 각 프로젝트의 .pnpm 폴더로는 하드 링크를 생성한다. 이는 디스크 공간을 극적으로 절약하고 설치 속도를 향상시킨다.
pnpm 실험
이론은 이렇다. 이제 우리가 만든 실험 프로젝트에 pnpm을 적용하고, 이 원리가 실제로 어떻게 구현되는지 터미널 명령어로 직접 확인해 보자.
# 기존 node_modules와 lock 파일 삭제 후 pnpm으로 재설치
$ rm -rf node_modules package-lock.json
$ pnpm install
다시 node_modules
를 들여다보자. ls -l
명령어로 링크 정보를 함께 확인한다.
$ ls -l node_modules
total 8
lrwxr-xr-x 1 user staff 58B Jan 8 10:20 .pnpm -> .pnpm/lock.yaml
lrwxr-xr-x 1 user staff 71B Jan 8 10:20 styled-components -> .pnpm/styled-components@6.1.11_react@18.3.1/node_modules/styled-components
결과는 완전히 다르다. 최상위에는 styled-components
로 향하는 심볼릭 링크만 존재한다. 유령 의존성이었던 stylis
는 보이지 않는다. 이 상태에서 다시 코드를 실행해 보자.
$ node index.js
Failed to load phantom dependency: Cannot find module 'stylis'
예상대로 실패하는 것을 확인할 수 있다.
pnpm의 하드 링크 구조 파헤치기
pnpm의 진짜 힘인 디스크 공간 절약 원리를 증명해 보자. ls -i
명령어로 파일의 고유 식별자인 inode 번호를 비교하면, 프로젝트 내 파일과 전역 스토어의 파일이 물리적으로 동일한 파일임을 확인할 수 있다.
# 프로젝트 내 패키지 파일의 inode
$ ls -i node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/package.json
1234567 .../lodash/package.json
# pnpm 전역 스토어 내 동일 파일의 inode
$ ls -i ~/.pnpm-store/v3/files/c9/de.../package.json
1234567 .../package.json
# inode 번호가 동일하다! 이것은 물리적으로 같은 파일이다.
5. Yarn : node_modules의 제거
Yarn은 npm이 가진 문제를 해결하기 위해 등장했으며, 두 번의 큰 변화를 거쳤다. 각각은 JavaScript 의존성 관리의 중요한 패러다임을 제시한다.
Yarn Classic (v1): yarn.lock의 등장과 결정론적 설치
2016년 Yarn이 처음 등장했을 때, 당시 npm(v3/v4)은 두 가지 큰 문제를 안고 있었다.
- 설치 속도: 의존성 설치가 순차적으로 진행되어 매우 느렸다.
- 비결정성:
package-lock.json
이 없던 시절이라, 같은package.json
이라도 설치 시점이나 환경에 따라node_modules
의 구조가 달라져 "내 컴퓨터에선 되는데..." 문제가 빈번했다.
Yarn Classic은 이 문제들을 다음과 같이 해결했다.
yarn.lock
파일: 의존성 트리에 있는 모든 패키지의 정확한 버전과 의존성을 기록하는 잠금(lock) 파일을 도입했다. 이를 통해 어떤 환경에서든 항상 동일한node_modules
구조를 보장하는 결정론적 설치를 구현했다. (이는 나중에 npm의 package-lock.json에 큰 영향을 주었다.)- 병렬 설치 및 캐시: 의존성을 병렬로 다운로드하고, 한 번 받은 패키지는 전역 캐시에 저장하여 설치 속도를 극적으로 향상시켰다.
하지만 Yarn Classic의 node_modules
구성 방식은 npm과 마찬가지로 호이스팅에 기반했다. 따라서 설치 속도와 결정론 문제는 해결했지만, 유령 의존성 문제는 그대로 남아 있었다.
Yarn Modern (v2+): Plug'n'Play(PnP)와 새로운 아키텍처
Yarn 팀은 여기서 한 단계 더 나아가 node_modules
자체가 가진 근본적인 비효율성(수많은 파일 I/O, 비효율적인 모듈 해석 등)을 해결하고자 했다. 그 결과물이 바로 Plug'n'Play(PnP)다.
PnP의 핵심 동작 원리
node_modules
제거: 설치 시node_modules
디렉토리를 생성하지 않는다.- 캐시: 모든 패키지는
.yarn/cache
폴더에.zip
파일 형태로 압축되어 저장된다. .pnp.cjs
매핑 파일 생성: 설치가 끝나면.pnp.cjs
라는 단일 파일이 생성된다. 이 파일에는 "패키지 A는 패키지 B와 C에 의존하며, 각 패키지의 파일은.yarn/cache
내 어떤 zip 파일의 어디에 위치한다"는 정보가 담긴 거대한 의존성 맵이 들어있다.- 모듈 해석 로직 패치: Yarn은 Node.js가 모듈을 찾는 require() 로직을 런타임에 수정(monkey-patching)한다. 수정된 로직은 파일 시스템을 탐색하는 대신,
.pnp.cjs
의 맵을 참조하여 메모리에서 직접 필요한 파일의 위치를 찾아 zip 파일에서 읽어온다.
이 방식은 수십만 개의 파일 I/O 작업을 없애 설치 속도를 극대화하고, package.json에 명시된 의존성만 정확히 찾을 수 있게 하여 유령 의존성 문제를 완벽하게 해결한다.
PnP의 현실적 과제: 생태계 호환성
PnP의 아이디어는 혁신적이지만, node_modules
디렉토리의 존재를 수십 년간 당연하게 여겨온 JavaScript 생태계와의 호환성 문제가 가장 큰 장벽이다.
node_modules
경로를 하드코딩한 도구들: 많은 Webpack 플러그인, ESLint 설정, 빌드 스크립트들이 여전히 path.resolve(__dirname, 'node_modules/some-package')와 같은 코드를 사용하며, PnP 환경에서는 실패한다.- 네이티브 모듈: C++ 바인딩을 사용하는 sharp 같은 네이티브 모듈은 PnP 환경에서 동작시키기 위해 추가적인 설정이나 패치가 필요한 경우가 많다.
- TypeScript 해석: TypeScript 컴파일러가 PnP 환경을 올바르게 이해하도록 추가적인 설정(@yarnpkg/sdks)이 필요하다.
실제로 많은 팀이 PnP의 빠른 속도와 엄격함에 매료되어 도입을 시도했다가, 서드파티 라이브러리와의 호환성 문제를 해결하기 위해 .yarnrc.yml
파일에 끝없이 packageExtensions를 추가하는 유지보수 부담을 이기지 못하고 node-modules
링커로 회귀하기도 한다.
Yarn Modern의 대안: node-modules 링커
Yarn Modern은 PnP가 아닌 npm이나 pnpm과 유사한 node_modules
를 생성하는 옵션도 제공한다. .yarnrc.yml
파일에 nodeLinker: node-modules
를 설정하면, Yarn의 빠른 설치 속도와 워크스페이스 기능 등은 유지하면서 생태계 호환성을 확보할 수 있다.
6. 실전 의존성 관리 전략
1. 의존성 검사 자동화
depcheck와 같은 도구를 CI 파이프라인에 통합하여, 사용되지 않는 의존성이나 package.json
에 누락된 유령 의존성을 자동으로 검출한다.
$ npx depcheck
Missing dependencies:
* stylis: ./index.js
2. overrides를 활용한 버전 충돌 해결
overrides
는 의존성 트리의 특정 패키지 버전을 강제하는 강력한 기능이다. 앞서 3번 섹션에서 다룬 React 버전 충돌 문제를 이 기능으로 직접 해결해 보자.
- 문제 상황: 내 프로젝트는 react@18.3.1을, legacy-design-system은 react@17.0.2를 요구하여 node_modules에 두 버전이 모두 설치된 위험한 상태.
- 해결책: package.json에 overrides 필드를 추가하여, 프로젝트 전체에서 react는 18.3.1 버전만 사용하도록 강제한다.
{
"name": "frontend-override-test",
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"legacy-design-system": "1.0.0"
},
"overrides": {
"react": "18.3.1"
}
}
- 결과 확인: node_modules를 삭제하고 재설치한 후, npm ls react를 실행하면 결과는 다음과 같다.
$ rm -rf node_modules && npm install
$ npm ls react
frontend-override-test@1.0.0 /path/to/project
└── react@18.3.1 deduped
이제 프로젝트에는 단 하나의 React 버전만 존재한다. legacy-design-system도 18.3.1 버전을 바라보게 되어 버전 충돌 문제가 해결되었다.
⚠️ 주의사항: overrides는 매우 강력하지만, 그만큼 책임이 따른다. legacy-design-system이 React 18에서 제거된 API를 사용하고 있었다면, 이 설정으로 인해 런타임 에러가 발생할 수 있다. overrides 적용 후에는 해당 라이브러리가 문제없이 동작하는지 반드시 충분한 테스트를 거쳐야 한다.
3. 패키지 매니저 선택 가이드
- npm: 별도의 설정이 필요 없는 표준적인 환경을 원할 때 적합하다.
- yarn (v1/classic): 모노레포(Workspaces) 지원과 안정성이 검증된 환경이 필요할 때 효과적이다.
- pnpm: 엄격한 의존성 관리와 디스크 공간 절약이 최우선 과제일 때 가장 강력한 솔루션이다.
7. 결론
node_modules
와 상호작용하는 패키지 매니저의 내부 동작을 이해하는 것은 예측 가능하고 안정적인 애플리케이션을 구축하기 위한 필수 역량이다. 호이스팅은 성능 최적화를 가져왔지만, 유령 의존성이라는 비결정적 요소를 남겼다.
현대적인 패키지 매니저인 pnpm은 심볼릭 링크를 통해 이 문제를 해결하며, 결정론적이고 격리된 의존성 관리의 새로운 표준을 제시한다. 프로젝트의 요구사항에 맞는 적절한 도구를 선택하고, depcheck와 같은 보조 도구를 활용하여 의존성을 명시적으로 관리하는 것이 node_modules
와의 안정적인 공존을 위한 핵심 전략이다.