node_modules 깊이 들여다보기: 의존성 지옥에서 살아남는 법
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'
분명 로컬에서는 잘 돌아갔다. package.json에도 없는 패키지인데 어떻게 로컬에서는 동작했을까?
몇 시간의 디버깅 끝에 알게 된 진실: 유령 의존성(Phantom Dependency).
내가 직접 설치하지 않은 패키지를 마음대로 쓰고 있었던 것이다. 어떻게 이런 일이 가능할까? 답은 node_modules의 구조에 있었다.
2. node_modules의 탄생과 원죄
처음엔 단순했다
my-app/
└── node_modules/
├── express/
│ └── node_modules/
│ ├── body-parser/
│ ├── cookie/
│ └── debug/
└── lodash/
초기 npm(v2까지)은 정직했다. 각 패키지가 자신의 의존성을 자기 폴더 안에 갖고 있었다.
문제: 중복의 지옥
node_modules/
├── package-a/
│ └── node_modules/
│ └── lodash@4.17.21/ # 10MB
├── package-b/
│ └── node_modules/
│ └── lodash@4.17.21/ # 또 10MB
└── package-c/
└── node_modules/
└── lodash@4.17.21/ # 또또 10MB
같은 버전의 lodash가 3번 설치된다. 프로젝트가 커질수록 node_modules는 기하급수적으로 커졌다.
Windows에서는 경로 길이 제한(260자) 때문에 설치조차 안 되는 경우도 있었다:
node_modules/package-a/node_modules/package-b/node_modules/package-c/node_modules/...
3. npm/yarn의 해결책: 호이스팅
Flat한 구조로의 전환
npm v3부터 도입된 호이스팅(Hoisting):
node_modules/
├── express/
├── body-parser/ # express의 의존성이 최상위로!
├── cookie/ # 이것도!
├── debug/ # 이것도!
└── lodash/ # 모두가 공유
이제 lodash는 한 번만 설치된다. 디스크 공간 절약!
호이스팅 알고리즘 간단 구현
function hoistDependencies(dependencies, hoistedModules = new Map()) {
for (const [name, version] of Object.entries(dependencies)) {
// 이미 호이스팅된 패키지가 있는지 확인
if (hoistedModules.has(name)) {
const hoistedVersion = hoistedModules.get(name);
if (hoistedVersion !== version) {
// 버전 충돌! 호이스팅 불가
continue;
}
} else {
// 최상위로 호이스팅
hoistedModules.set(name, version);
}
}
return hoistedModules;
}
하지만 새로운 문제가...
// my-app/index.js
const _ = require('lodash'); // ❌ package.json에 없는데 동작함!
// 왜? express가 lodash를 의존하고 있고, 호이스팅되어 있기 때문
이것이 바로 유령 의존성이다.
4. 유령 의존성: 내가 겪은 실제 사례들
사례 1: @emotion/css의 함정
사내 패키지를 개발하던 중 이런 일이 있었다:
// @company/ui-components/Button.jsx
import { css } from '@emotion/css'; // 잘 동작함
// package.json
{
"dependencies": {
"@emotion/react": "^11.0.0"
// @emotion/css는 없음!
}
}
로컬에서는 완벽하게 동작했다. 왜?
$ npm ls @emotion/css
@company/ui-components@1.0.0
└─┬ @emotion/react@11.0.0
└── @emotion/css@11.0.0 # @emotion/react의 의존성!
배포 후 다른 프로젝트에서 사용하려 하자:
$ npm install @company/ui-components
$ npm run build
❌ Module not found: Can't resolve '@emotion/css'
사례 2: React 버전의 잠재적 시한폭탄
더 교묘한 케이스도 있었다. Serverless Framework에서 SST로 마이그레이션하면서:
// package.json
{
"dependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2",
"sst": "^2.0.0" // SST는 React 18이 필요!
}
}
설치 후 node_modules 구조:
node_modules/
├── react@18.2.0/ # SST가 요구한 React 18이 호이스팅됨!
├── react@17.0.2/ # 우리가 원한 버전은 어디에?
│ └── (nested somewhere)
├── sst/
│ └── (uses react@18)
놀랍게도 앱은 정상 동작했다. 왜?
// 우리 코드
import React from 'react'; // 실제로는 React 18을 import!
// React 17과 18이 대부분 호환되어서 "우연히" 동작
// 하지만 만약 React 18의 새 기능을 실수로 사용한다면?
// 또는 breaking change가 있는 부분을 건드린다면?
이런 상황을 확인하는 방법:
$ yarn why react
├─ sst@npm:2.0.0
│ └─ react@npm:18.2.0 (via npm:^18.0.0)
└─ my-app@workspace:.
└─ react@npm:17.0.2 (via npm:^17.0.2)
# 실제로 어떤 버전을 사용하는지 확인
$ node -e "console.log(require('react').version)"
18.2.0 # 😱
5. 실험: 같은 프로젝트, 다른 구조
실제로 확인해보자. 간단한 프로젝트를 만들고 각 패키지 매니저로 설치해보았다.
// package.json
{
"dependencies": {
"express": "^4.18.0",
"react": "^18.2.0",
"lodash": "^4.17.21"
}
}
npm (v8)
$ npm install
$ tree node_modules -L 1 | wc -l
156 # 최상위에 156개 패키지!
$ du -sh node_modules
242M # 242MB
yarn (v1, classic)
$ yarn install
$ tree node_modules -L 1 | wc -l
154 # npm과 비슷
$ du -sh node_modules
238M # 약간 작음
pnpm
$ pnpm install
$ tree node_modules -L 1 | wc -l
3 # 단 3개!
$ du -sh node_modules
188M # 크기는 왜 작을까?
pnpm의 node_modules를 자세히 보면:
node_modules/
├── .pnpm/ # 실제 패키지들이 여기에
├── express -> .pnpm/express@4.18.0/node_modules/express
├── react -> .pnpm/react@18.2.0/node_modules/react
└── lodash -> .pnpm/lodash@4.17.21/node_modules/lodash
심볼릭 링크다!
6. pnpm의 혁신: 심볼릭 링크와 하드 링크
pnpm의 3단계 구조
node_modules/
├── .pnpm/
│ ├── express@4.18.0/
│ │ └── node_modules/
│ │ ├── express/ # 실제 파일
│ │ ├── body-parser/ # express의 의존성
│ │ └── cookie/
│ └── lodash@4.17.21/
│ └── node_modules/
│ └── lodash/ # 실제 파일
├── express -> .pnpm/express@4.18.0/node_modules/express
└── lodash -> .pnpm/lodash@4.17.21/node_modules/lodash
- Content-addressable storage: ~/.pnpm-store에 모든 패키지 저장
- 하드 링크: store에서 node_modules/.pnpm으로
- 심볼릭 링크: .pnpm에서 최상위 node_modules로
하드 링크 vs 심볼릭 링크
// 하드 링크 확인
const fs = require('fs');
const stats1 = fs.statSync('node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/index.js');
const stats2 = fs.statSync('/home/user/.pnpm-store/v3/files/8a/xxxxx');
console.log(stats1.ino === stats2.ino); // true - 같은 inode!
하드 링크의 장점:
- 디스크 공간 절약 (같은 패키지는 전역에서 하나만 존재)
- 빠른 설치 (복사가 아닌 링크)
유령 의존성 원천 차단
// pnpm으로 설치한 프로젝트에서
const _ = require('lodash'); // ✅ 직접 설치했으므로 OK
const express = require('express'); // ✅ OK
// express의 의존성을 직접 사용하려 하면?
const bodyParser = require('body-parser'); // ❌ Error!
pnpm은 심볼릭 링크로 격리된 구조를 만들어 유령 의존성을 방지한다.
7. Yarn PnP: node_modules 자체를 없애버리기
Plug'n'Play의 아이디어
// .pnp.cjs (Yarn이 생성하는 파일)
module.exports = {
packageRegistryData: [
["lodash", [
["npm:4.17.21", {
packageLocation: "./.yarn/cache/lodash-npm-4.17.21-xxxxx.zip",
packageDependencies: [],
}]
]],
["express", [
["npm:4.18.0", {
packageLocation: "./.yarn/cache/express-npm-4.18.0-xxxxx.zip",
packageDependencies: [
["body-parser", "npm:1.20.0"],
["cookie", "npm:0.5.0"],
],
}]
]]
]
};
node_modules 대신 zip 파일과 매핑 테이블!
PnP의 장점
- 설치 속도: 파일 복사/링크 불필요
- 디스크 공간: zip 압축으로 더 작음
- Zero-installs: .yarn 폴더를 git에 커밋 가능
- 완벽한 의존성 격리: 유령 의존성 불가능
8. 왜 PnP는 아직 주류가 아닐까? - 실제 겪은 호환성 지옥
우리 팀의 PnP 도전기
Yarn 4 + PnP 모드로 프로젝트를 시작했다. 처음엔 모든 게 완벽해 보였다:
# .yarnrc.yml
nodeLinker: pnp
enableGlobalCache: true
# 설치 속도 빨라짐!
$ yarn install
➤ YN0000: ┌ Resolution step
➤ YN0000: └ Completed in 0s 421ms
➤ YN0000: ┌ Link step
➤ YN0000: └ Completed in 0s 312ms # 엄청 빠르다!
그러나 현실은...
문제 1: 많은 도구들이 node_modules를 가정
// webpack.config.js - 수많은 플러그인들이 이런 코드를 가짐
const modulePath = path.resolve(__dirname, 'node_modules/some-package');
// 일부 Babel 플러그인
const presetPath = require.resolve('@babel/preset-env/package.json')
.replace('/package.json', '');
문제 2: Native 모듈 문제
# sharp 같은 native 바인딩을 사용하는 패키지
Error: Could not load the "sharp" module using the darwin-x64 runtime
ERR_DLOPEN_FAILED: dlopen(.../sharp.darwin-x64.node, 0x0001):
tried: '.../sharp.darwin-x64.node' (no such file)
문제 3: Electron, React Native 같은 특수 환경
// Electron의 경우
app.asar/
└── node_modules/ # Electron은 이 구조를 기대함
└── ...
// PnP에서는? 🤷♂️
결국 포기하게 만든 결정타
가장 큰 문제는 생태계의 준비 부족이었다:
# .yarnrc.yml - 호환성을 위한 패치가 점점 늘어남
packageExtensions:
"babel-loader@*":
dependencies:
"@babel/core": "*"
"react-scripts@*":
peerDependencies:
"eslint-config-react-app": "*"
"styled-components@*":
dependencies:
"react-is": "*"
# ... 수십 개의 패치
매번 새 패키지를 설치할 때마다:
- 설치 실패
- 에러 메시지 구글링
- GitHub 이슈 확인
- packageExtensions 추가
- 다시 설치
결국 팀 회의 끝에:
# .yarnrc.yml
nodeLinker: node-modules # 😢 포기
PnP가 동작하지 않는 이유들
- 파일 시스템 가정
// 많은 패키지들이 이렇게 동작
const pkgPath = require.resolve('some-package');
const pkgDir = path.dirname(pkgPath);
const assetPath = path.join(pkgDir, '../assets/file.png');
- 동적 require
// PnP는 정적 분석이 필요한데...
const plugin = require(`./plugins/${pluginName}`);
- Monkey patching
// 일부 도구들이 이런 짓을...
const originalRequire = Module.prototype.require;
Module.prototype.require = function(id) {
// custom logic
return originalRequire.call(this, id);
};
9. 실전 가이드: 의존성 지옥에서 살아남기
1. 유령 의존성 찾기
# depcheck 사용
$ npx depcheck
Unused dependencies
* lodash
Missing dependencies
* @emotion/css # 유령 의존성 발견!
# yarn의 경우
$ yarn dlx @yarnpkg/doctor
2. 버전 충돌 확인
# 중복된 패키지 찾기
$ npm ls --depth=0 | grep deduped
# yarn
$ yarn dedupe --check
# 특정 패키지의 모든 버전 확인
$ npm ls react
my-app@1.0.0
├── react@17.0.2
└─┬ sst@2.0.0
└── react@18.2.0
3. 명시적 의존성 관리
// package.json
{
"dependencies": {
"@emotion/react": "^11.0.0",
"@emotion/css": "^11.0.0" // 명시적으로 추가
},
"overrides": {
// npm/yarn - 특정 버전 강제
"react": "17.0.2"
},
"resolutions": {
// yarn - 더 세밀한 제어
"**/react": "17.0.2"
}
}
4. 패키지 매니저 선택 가이드
npm을 쓰면 좋은 경우:
- 단순한 프로젝트
- 특별한 요구사항 없음
- 기본이 최고
yarn을 쓰면 좋은 경우:
- 모노레포 (workspaces)
- 빠른 설치 속도 필요
- 안정성 중요
pnpm을 쓰면 좋은 경우:
- 디스크 공간 절약 중요
- 엄격한 의존성 관리 필요
- 유령 의존성 원천 차단
Yarn PnP를 시도해볼 만한 경우:
- 그린필드 프로젝트
- 사용하는 모든 도구가 PnP 지원 확인됨
- 팀원 모두가 도전 정신 충만
5. 의존성 시각화와 분석
// analyze-deps.js
const fs = require('fs');
const path = require('path');
function findPhantomDeps(projectRoot) {
const pkg = JSON.parse(
fs.readFileSync(path.join(projectRoot, 'package.json'))
);
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {})
]);
const used = new Set();
// 소스 코드에서 import/require 찾기
function scanFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const importRegex = /(?:import|require)\s*\(?["']([^"']+)["']\)?/g;
let match;
while ((match = importRegex.exec(content))) {
const dep = match[1];
if (!dep.startsWith('.') && !dep.startsWith('/')) {
// npm 패키지인 경우
const pkgName = dep.startsWith('@')
? dep.split('/').slice(0, 2).join('/')
: dep.split('/')[0];
used.add(pkgName);
}
}
}
// 모든 소스 파일 스캔
// ... (실제 구현)
// 유령 의존성 찾기
const phantoms = Array.from(used).filter(dep => !declared.has(dep));
return phantoms;
}
10. 결론: node_modules와 평화롭게 공존하기
# 예전
$ rm -rf node_modules
$ npm install # 기도하며 기다리기
# 지금
$ pnpm install # 하드링크로 빠르게
$ npm ls # 의존성 구조 확인
$ yarn why react # 왜 이 버전인지 확인
$ depcheck # 유령 의존성 체크
node_modules는 JavaScript 생태계의 축복이자 저주다. 하지만 그 구조를 이해하면, 의존성 지옥에서 살아남을 수 있다.
핵심 교훈:
-
유령 의존성을 조심하라
- 로컬에서 되는 건 우연일 수 있다
- 특히 메이저 버전이 다른 경우 시한폭탄
-
명시적으로 선언하라
- package.json에 없으면 쓰지 마라
- overrides/resolutions로 버전 충돌 제어
-
도구를 이해하라
- npm, yarn, pnpm의 차이를 알고 선택
- 각각의 트레이드오프를 이해
-
미래를 주시하되 현실을 인정하라
- PnP는 미래지만 아직은 시기상조
- 생태계가 준비될 때까지 기다려도 늦지 않다
마지막으로, node_modules가 3GB가 되었다고 좌절하지 마라. 그것은 당신만의 문제가 아니다. 우리 모두의 문제다.
"node_modules를 제대로 이해하는 순간, 의존성은 더 이상 지옥이 아니다."