node_modules 의존성 관리 메커니즘 분석

node_modulesnpmyarnpnpmphantom-dependencypackage-manager

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.jsstyled-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.jsonstylis는 없다. 당연히 이 코드는 실패해야 한다.

$ node index.js
Phantom dependency "stylis" loaded successfully!
Type: object

놀랍게도 성공한다. 어떻게 이런 일이 가능할까? 그 해답은 node_modules의 구조에 있다.

2. npm의 호이스팅: 최적화와 그 부작용

현재의 node_modules 구조를 이해하려면, 과거 npm이 어떤 문제를 겪었는지부터 알아볼 필요가 있다.

1. 초기 npm의 한계: 비효율적인 중첩 구조

npm v2까지의 node_modulespackage.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의 의존성인 stylisnode_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)은 두 가지 큰 문제를 안고 있었다.

  1. 설치 속도: 의존성 설치가 순차적으로 진행되어 매우 느렸다.
  2. 비결정성: 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의 핵심 동작 원리

  1. node_modules 제거: 설치 시 node_modules 디렉토리를 생성하지 않는다.
  2. 캐시: 모든 패키지는 .yarn/cache 폴더에 .zip 파일 형태로 압축되어 저장된다.
  3. .pnp.cjs 매핑 파일 생성: 설치가 끝나면 .pnp.cjs라는 단일 파일이 생성된다. 이 파일에는 "패키지 A는 패키지 B와 C에 의존하며, 각 패키지의 파일은 .yarn/cache 내 어떤 zip 파일의 어디에 위치한다"는 정보가 담긴 거대한 의존성 맵이 들어있다.
  4. 모듈 해석 로직 패치: 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와의 안정적인 공존을 위한 핵심 전략이다.