node_modules 깊이 들여다보기: 의존성 지옥에서 살아남는 법

2025년 1월 8일
node_modulesnpmyarnpnpmphantom-dependencypackage-manager

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
  1. Content-addressable storage: ~/.pnpm-store에 모든 패키지 저장
  2. 하드 링크: store에서 node_modules/.pnpm으로
  3. 심볼릭 링크: .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의 장점

  1. 설치 속도: 파일 복사/링크 불필요
  2. 디스크 공간: zip 압축으로 더 작음
  3. Zero-installs: .yarn 폴더를 git에 커밋 가능
  4. 완벽한 의존성 격리: 유령 의존성 불가능

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": "*"
  
  # ... 수십 개의 패치

매번 새 패키지를 설치할 때마다:

  1. 설치 실패
  2. 에러 메시지 구글링
  3. GitHub 이슈 확인
  4. packageExtensions 추가
  5. 다시 설치

결국 팀 회의 끝에:

# .yarnrc.yml
nodeLinker: node-modules  # 😢 포기

PnP가 동작하지 않는 이유들

  1. 파일 시스템 가정
// 많은 패키지들이 이렇게 동작
const pkgPath = require.resolve('some-package');
const pkgDir = path.dirname(pkgPath);
const assetPath = path.join(pkgDir, '../assets/file.png');
  1. 동적 require
// PnP는 정적 분석이 필요한데...
const plugin = require(`./plugins/${pluginName}`);
  1. 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 생태계의 축복이자 저주다. 하지만 그 구조를 이해하면, 의존성 지옥에서 살아남을 수 있다.

핵심 교훈:

  1. 유령 의존성을 조심하라

    • 로컬에서 되는 건 우연일 수 있다
    • 특히 메이저 버전이 다른 경우 시한폭탄
  2. 명시적으로 선언하라

    • package.json에 없으면 쓰지 마라
    • overrides/resolutions로 버전 충돌 제어
  3. 도구를 이해하라

    • npm, yarn, pnpm의 차이를 알고 선택
    • 각각의 트레이드오프를 이해
  4. 미래를 주시하되 현실을 인정하라

    • PnP는 미래지만 아직은 시기상조
    • 생태계가 준비될 때까지 기다려도 늦지 않다

마지막으로, node_modules가 3GB가 되었다고 좌절하지 마라. 그것은 당신만의 문제가 아니다. 우리 모두의 문제다.


"node_modules를 제대로 이해하는 순간, 의존성은 더 이상 지옥이 아니다."