npm deep dive 정리

npm deep dive 책을 읽으며 내용 정리한 글입니다.

npm: node_modules 평탄화 구조

npm install 실행 시 실제 의존 트리 구조와는 별개로 node_modules는 평탄화된 구조를 가짐

→ 의존성 트리에서 불필요한 중복을 줄이고, 가능한 한 패키지를 루트 수준에 배치하기 위해

.
└─ node_modules
   ├─ A(@2.0.0)
   ├─ B(@2.0.0)
   ├─ C(@1.0.0)
   ├─ D(@1.0.0)
   └─ E(@2.0.0)

버전 충돌 발생 시에는 하위에 별도 node_modules를 만들어 중복 설치하는 방식으로 해결

이 구조의 부작용으로 유령 의존성 문제가 발생함

유령 의존성이란?

package.jsondependencies 혹은 devDependencies에 명시되지 않은 의존성을 실제 코드에서 사용 가능한 경우를 의미. 평탄화 구조 때문에 npm이 상위 폴더에서 모듈을 탐색하면서 생김.


Yarn: PnP

Yarn은 이 문제를 해결하기 위해 PnP 방식을 도입

PnP는 node_modules 폴더를 아예 생성하지 않고, .pnp.cjs 파일을 통해 별도의 내부 스토리지에서 패키지를 직접 참조

→ npm은 폴더를 반복적으로 순회하며 모듈을 찾는 방식이 비효율적이고, 명시하지 않은 패키지도 사용할 수 있다는 단점이 있는데, PnP 모드로 탐색을 단일 경로로 한정하면 두 가지 문제를 모두 피할 수 있음

또한 설치마다 레지스트리를 조회할 필요 없이 글로벌 캐시 폴더에 저장 → 속도 향상, 디스크 공간 절약

PnP를 끄려면 .yarnrc.yml 파일에 아래 설정을 추가

# .yarnrc.yml
nodeLinker: node-modules

Zero Install

레지스트리를 방문하지 않고도 필요한 의존성을 설치할 수 있는 오프라인 설치 모드

.yarnrc.yml 파일에 아래 설정을 추가

enableGlobalCache: false

→ 글로벌 캐시에 있던 압축 파일이 해당 프로젝트의 .yarn/cache 폴더에 저장됨

.yarn 폴더 전체를 버전 관리에 포함하면 소스코드를 pull하는 것만으로도 레지스트리 없이 의존성 설치 가능

단, 기존에는 레지스트리가 패키지를 제공했으나 zero install에서는 git이 그 역할을 대신하기 때문에 git 저장소에 가해지는 부담이 큼


pnpm: 하드 링크 기반 설치 구조

pnpm은 평탄화 대신 하드 링크 방식을 사용

각 패키지가 설치하는 하위 의존성을 node_modules가 아닌 전역 저장소(~/.pnpm)에 저장하고, 각 프로젝트의 node_modules는 여기에 대한 링크만 생성

./node_modules
├── .pnpm
   ├── react@18.3.1
      └── node_modules/react
└── react -> .pnpm/react@18.3.1/node_modules/react

하드 링크란 파일 시스템의 기능 중 하나로, 동일한 데이터를 가리키는 여러 개의 파일 경로를 만드는 방식. 어느 경로로 접근하든 동일한 파일에 도달.

pnpm은 이를 이용해 동일한 패키지를 여러 프로젝트가 공유하게 함

→ 여러 패키지에서 동일한 패키지를 참조할 때 복사본을 여러 개 생성하는 대신 .pnpm에 있는 단일 패키지를 참조하므로 성능 향상

평탄화되지 않은 node_modules

pnpm은 평탄화된 node_modules 구조를 피하기 위해 다음 전략을 채택

  • 프로젝트의 ./node_modules에는 선언된 직접 의존성만 위치
  • 이 패키지들의 실제 파일은 .pnpm에 하드 링크로 연결
  • 직접 의존성에 필요한 패키지의 원본은 ./node_modules/.pnpm에 평탄화해서 설치
  • 각 패키지의 의존성은 .pnpm에 하드 링크로 참조

⇒ 유령 의존성 참조 방지 + 각 패키지가 필요한 의존성을 하드 링크를 통해 빠르게 찾을 수 있음

npm vs pnpm의 node_modules 차이

항목npmpnpm
폴더 명명 방식패키지명패키지명@버전
패키지 내용 위치폴더 바로 하위node_modules 폴더 하위
구조평탄화비평탄화 (의존성 트리 반영)
유령 의존성발생 가능방지
디스크 효율낮음 (중복 설치)높음 (하드 링크 재활용)

CommonJS vs ESModule

CommonJS

Node.js의 기본 모듈 시스템. 각 모듈은 독립된 실행 영역을 가지며, exports 객체로 외부에 기능을 공개

// sum.js
exports.sum = function (a, b) {
  return a + b;
};

// main.js
const { sum } = require("./sum");
console.log(sum(2, 3));

CommonJS의 특징:

  • 모듈 로딩은 동기적으로 한 번에 하나씩 처리
  • require()로 불러오며, 런타임 시점에 평가
  • 인수로 전달하는 모듈 경로를 동적으로 할당 가능
  • 순환 참조 발생 시, 캐시된 모듈 반환으로 무한 루프 방지

순환 참조

A 모듈이 B 모듈을 참조하고, B 모듈이 다시 A 모듈을 참조하는 경우 모듈 간 무한 루프가 발생할 수 있음

require()는 동기적으로 모듈을 한 번에 하나씩 처리하고, 모듈을 캐싱해두기 때문에 순환 참조가 발생해도 이미 캐싱된 모듈을 반환하여 무한 로딩을 방지

단, 모듈 참조 순서에 따라 전혀 다른 값을 반환할 수도 있으므로 주의 필요

모듈 래퍼

Node.js는 각 모듈마다 고유한 스코프를 제공하기 위해 모듈 래퍼를 사용. 외부에서 모듈을 사용할 때마다 클로저가 생성되어 메모리 비용이 발생

또한 require()의 동적 특성 때문에 빌드 시점에 실제로 사용될 모듈을 알 수 없어 트리 셰이킹이 어려움

→ Rollup, Webpack 같은 번들러는 동적 require() 호출을 하나의 클로저로 통합하거나, 동적 특성을 차단하는 플러그인을 통해 정적 분석을 가능하게 함

CommonJS의 한계

CommonJS의 구조적 한계로 인해 ESModule이 등장

  • 동기적 로딩: 브라우저 환경에서 모듈 로딩 중 블로킹 발생 → 성능 저하
  • 프리로딩 불가: 로딩 최적화 어렵고 병렬 처리 불가
  • 트리 셰이킹 및 최적화 어려움: require()의 동적 특성으로 빌드 시점에 사용 여부 판별 불가
  • 메모리 이슈: 모듈 래퍼로 인한 클로저 생성 비용
  • 브라우저 호환성 문제: 브라우저에서 기본 지원하지 않음

ESModule

브라우저와 Node.js 모두 지원하는 정적 모듈 시스템

  • import/export 키워드 사용
  • 정적 분석 가능 (빌드 시점에 의존성 파악)
  • 트리 셰이킹 및 번들 최적화 용이
// sum.mjs
export const sum = (a, b) => a + b;

// main.mjs
import { sum } from "./sum.mjs";
console.log(sum(2, 3));

내부적으로 모듈 레코드 라는 구조체를 생성하여 각 모듈의 값을 메모리에 연결해 처리

ESModule의 동작 방식

세 단계를 거쳐 모듈을 처리

  1. 모듈 파싱: JS 엔진이 소스를 해석해 모듈 레코드를 생성. import 문을 분석해 의존성 그래프를 구성
  2. 모듈 인스턴스화: export된 값들이 메모리에 할당되고, 모듈 간 참조가 연결
  3. 모듈 평가: 코드가 실제로 실행되어 export된 값들이 최종 결과값을 가짐

코드를 분석해 AST로 파싱 → import문으로 의존성 그래프(모듈 레코드) 생성 → 필요한 모듈의 메모리 주소 설정 → 코드 실행

CommonJS에서 사용 불가한 기능 (ESModule 환경)

ESModule에서는 CommonJS 전용 기능을 사용할 수 없음

  • require() 함수, exports 객체, module.exports 객체
  • __filename, __dirnameimport.meta.filename, import.meta.dirname으로 대체
  • require.resolve(), NODE_PATH 환경변수
  • require.cache (ESModule은 자체적으로 캐시를 관리하기 때문)

모듈 해석 알고리즘

Node.js가 모듈을 로드하고 의존성을 해결하는 과정. 모듈 시스템에 따라 방식이 다름

하위 경로 가져오기 / 내보내기 (Subpath Import/Export)

package.jsonimports, exports 필드를 통해 모듈 경로를 명시적으로 매핑할 수 있음

내보내기 (exports)

{
  "name": "my-package",
  "exports": {
    ".": "./src/index.js",
    "./style.css": "./src/index.css"
  }
}
import myPackage from "my-package"; // → my-package/src/index.js
import "my-package/style.css"; // → my-package/src/index.css

가져오기 (imports) — 관습적으로 # 접두어를 사용

{
  "imports": {
    "#dep": {
      "node": "dep-node-native",
      "default": "./dep-polyfill.js"
    }
  }
}

→ 개발 환경에 따라 적합한 파일을 조건부로 불러와야 할 때 유용

CommonJS의 모듈 해석

  1. 내장 모듈 확인: require()가 요청한 모듈이 Node.js 내장 모듈인지 확인
  2. 파일 해석: 상대/절대 경로를 기반으로 파일을 탐색
  3. 디렉터리 해석: index.js 또는 package.jsonmain 필드 확인
  4. node_modules 폴더 탐색: 상위 디렉터리로 거슬러 올라가며 node_modules 탐색

ESModule의 모듈 해석

  • URL 기반 해석: 파일 경로도 URL로 해석하며, .js, .mjs확장자를 반드시 포함해야 함
  • exports 필드 우선: package.jsonexports 필드를 먼저 확인한 뒤 경로를 파악
  • node_modules 기반 패키지 해석은 CommonJS와 동일하게 동작

CommonJS는 모듈을 로드하면서 동시에 경로를 찾고, ESModule은 경로를 먼저 찾고 나서 모듈을 로드


듀얼 패키지: CJS + ESM 동시 지원

라이브러리를 배포할 때 두 모듈 시스템을 모두 지원하려면 package.jsonexports 필드에 조건부 내보내기를 설정

{
  "type": "module",
  "exports": {
    "import": "./index.js",
    "require": "./index.cjs"
  }
}
  • "import": ESM으로 로드되는 경우의 진입점
  • "require": CommonJS로 로드되는 경우의 진입점
  • "default": 모든 조건에 일치하는 기본값

전략 1: ESModule 래퍼 사용

패키지를 CommonJS로 작성하고, ESM 이름 내보내기만 담당하는 래퍼 파일을 별도로 추가

// pkg/index.cjs
exports.name = "value";

// pkg/wrapper.mjs
import cjsModule from "./index.cjs";
export const name = cjsModule.name;
export default cjsModule;

require('pkg')import { name } from 'pkg'를 동일하게 사용 가능

전략 2: 상태 격리 (State Isolation)

CJS와 ESM의 진입점을 완전히 분리해 각각 독립적인 모듈로 정의

{
  "type": "module",
  "exports": {
    "import": "./index.mjs",
    "require": "./index.cjs"
  }
}

두 버전이 동시에 애플리케이션에 로드될 수 있으므로, 상태를 공유하려면 공통 CJS 파일을 두고 양쪽이 참조하게 함

// state.cjs
module.exports = {
  /* 공유 상태 */
};

// index.mjs
import state from "./state.cjs";
export { state };

트랜스파일과 폴리필

Babel

최신 JavaScript 문법을 구형 환경에서도 실행 가능하도록 변환하는 대표적인 트랜스파일러

작동 과정:

  1. 파싱: 입력된 소스코드를 읽고 해석해 AST(추상 구문 트리)로 변환
  2. 변환: 각 플러그인이 순차적으로 실행되며 AST를 점진적으로 변환
  3. 출력: 수정된 AST를 다시 코드로 변환해 최종 출력

폴리필

트랜스파일만으로는 아래와 같은 기능을 완벽히 대체할 수 없음

  • Promise, Map, Set 등의 전역 객체
  • Array.prototype.includes, Object.assign 등 새로 추가된 메서드
  • async/await 구문

→ 트랜스파일만으로 해결되지 않는 기능들을 지원하기 위해 동일한 이름으로 낮은 ES 문법만 사용해 구형 브라우저 환경에서도 작동하도록 전역에 생성되는 메서드나 객체를 폴리필(polyfill) 이라고 함

→ 대표적인 폴리필 라이브러리는 core-js

TypeScript vs Babel

항목TypeScript Compiler (tsc)Babel
설정 난이도간단상대적으로 복잡
정적 타입 검사지원미지원
소스맵 지원지원지원
폴리필 제공미제공제공
번들러 연계webpack, rollup 등과 호환풍부한 플러그인 생태계
컴파일 속도타입 검사로 인해 느림상대적으로 빠름

번들러 비교

여러 파일로 구성된 소스를 하나 또는 그 이상의 작은 단위로 합쳐서 제공하는 것을 번들링이라고 함

→ 성능 개선, 파일 크기 최적화, 호환성 문제 해결 등의 이점

모듈 합치는 것 외에도 코드 분할, 트리 셰이킹, 난독화 및 압축 등을 수행

Webpack

모던 JavaScript 애플리케이션을 위한 모듈 번들러

  • 다양한 파일 타입 처리 가능 (.js, .css, .png 등)
  • 개발 환경 지원 (HMR, dev-server)
  • 트리 셰이킹, 코드 압축, 분할 번들링 지원
// webpack.config.js
module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "bundle.[contenthash].js", // contenthash: 파일 내용 기반 해시, 내용 변경 시 파일명 변경
    path: path.resolve(__dirname, "dist"),
  },
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: ["style-loader", "css-loader", "sass-loader"],
      },
    ],
  },
};

Rollup

  • ESM 기반 번들링
  • 정적 분석을 통한 효율적인 트리 셰이킹
  • 다양한 모듈 시스템(CJS, UMD, ESM) 모두 지원

Vite

  • 개발 시 번들링 없음 — ESModule 기반으로 직접 로드
  • 빠른 HMR — 변경된 파일만 새롭게 컴파일해서 로드
  • 사전 번들링으로 CommonJS나 UMD 형식의 모듈을 ESM으로 변환 (개발 모드에서 사전 수행)
  • tsc 대신 esbuild를 사용해 TypeScript를 빠르게 트랜스파일

요약

  • npm: 단순하지만 유령 의존성 가능
  • Yarn(PnP): 탐색 효율 + 유령 의존성 방지
  • pnpm: 하드 링크 기반 효율적 구조
  • ESM/CJS 듀얼 구조로 범용 호환 라이브러리 개발 가능