모듈 시스템 — CommonJS에서 ESM까지
ESM의
import는 값의 라이브 바인딩인데, CommonJS의require()는 값의 복사입니다. 같은 변수를 export했는데 왜 동작이 다른 걸까요?
두 모듈 시스템은 로딩 방식, 바인딩 방식, 정적 분석 가능 여부가 다릅니다. 이 차이가 트리 쉐이킹 가능 여부와 순환 참조 동작까지 결정합니다.
왜 모듈 시스템이 필요한가
자바스크립트가 브라우저에서만 쓰이던 시절에는 전역 스코프에 모든 변수가 올라갔습니다. 파일이 많아지면 이름 충돌이 생기고, 의존 관계를 파악하기도 어려웠습니다.
모듈 시스템이 해결하는 문제는 크게 세 가지입니다.
- **스코프 격리 **: 각 파일이 독립된 스코프를 가짐
- ** 의존성 명시 **: 어떤 파일이 어떤 파일에 의존하는지 코드에서 드러남
- ** 재사용성 **: 필요한 기능만 가져다 쓸 수 있음
CommonJS — Node.js의 표준
Node.js가 등장하면서 서버 사이드에서 모듈 시스템이 필요해졌고, CommonJS가 채택됐습니다.
// math.js — 모듈 내보내기
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
module.exports = { add, subtract };
// app.js — 모듈 가져오기
const { add, subtract } = require('./math');
console.log(add(1, 2)); // 3
CommonJS의 핵심 특징
- ** 동기적 로딩 **:
require()를 호출하면 해당 파일을 즉시 읽고 실행합니다 - ** 런타임 평가 **: 조건문 안에서도
require()를 호출할 수 있습니다 - ** 값의 복사 **: export된 값은 복사본입니다 (원본이 바뀌어도 가져온 쪽은 변하지 않음)
- ** 캐싱 **: 한 번 로딩된 모듈은 캐시에 저장되어 다시
require()해도 재실행되지 않습니다
// 조건부 로딩이 가능한 건 CommonJS의 특징
if (process.env.NODE_ENV === 'development') {
const devTools = require('./dev-tools');
devTools.init();
}
ESM (ES Modules) — 언어 표준
ES2015에서 드디어 자바스크립트 언어 자체에 모듈 시스템이 도입됐습니다.
// math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// 또는 default export
export default function multiply(a, b) {
return a * b;
}
// app.js
import multiply, { add, subtract } from './math.js';
ESM의 핵심 특징
- **정적 구조 **:
import/export는 반드시 최상위 스코프에 위치합니다 - ** 비동기 로딩 **: 브라우저에서 모듈을 네트워크로 가져올 수 있습니다
- ** 라이브 바인딩 **: export된 값이 원본과 연결되어 있어서, 원본이 바뀌면 import한 쪽도 바뀝니다
- ** 싱글톤 보장 **: 같은 모듈을 여러 곳에서 import해도 한 번만 실행됩니다
// counter.js
export let count = 0;
export function increment() {
count++;
}
// app.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 — 라이브 바인딩이므로 원본 변경이 반영됨
named export vs default export
// named export — 이름을 맞춰야 함
export const PI = 3.14;
import { PI } from './constants.js';
// default export — 이름을 자유롭게 지을 수 있음
export default function greet() { /* ... */ }
import hello from './greet.js'; // 아무 이름이나 가능
// 둘 다 동시에 사용 가능
import React, { useState, useEffect } from 'react';
핵심 차이는, named export는 이름을 맞춰야 하고, default export는 import 시 이름을 자유롭게 지을 수 있다는 점입니다. 한 모듈당 default export는 하나만 가능합니다.
CommonJS vs ESM 비교
| 구분 | CommonJS | ESM |
|---|---|---|
| 문법 | require() / module.exports | import / export |
| 로딩 방식 | 동기 | 비동기 (정적 분석 가능) |
| 평가 시점 | 런타임 | 파싱 타임에 구조 확정 |
| 바인딩 | 값의 복사 | 라이브 바인딩 (참조) |
| 조건부 로딩 | 가능 | import()로만 가능 (동적 import) |
| 트리 쉐이킹 | 어려움 | 가능 |
| 환경 | Node.js 기본 | 브라우저 + Node.js |
동적 import — import()
ESM의 정적 구조를 보완하는 게 동적 import()입니다. Promise를 반환하므로 비동기로 모듈을 가져옵니다.
// 조건부로 모듈 로딩
const button = document.getElementById('admin-btn');
button.addEventListener('click', async () => {
const { AdminPanel } = await import('./AdminPanel.js');
AdminPanel.render();
});
// React에서 코드 스플리팅
const LazyComponent = React.lazy(() => import('./HeavyComponent'));
코드 스플리팅의 핵심이 바로 이 동적 import()입니다. 초기 번들에 포함하지 않고, 필요한 시점에 모듈을 비동기로 로드합니다.
순환 참조 — 실무에서 만나는 함정
두 모듈이 서로를 import하면 순환 참조가 발생합니다.
// a.js
import { b } from './b.js';
export const a = 'A';
console.log('a.js에서 b:', b);
// b.js
import { a } from './a.js';
export const b = 'B';
console.log('b.js에서 a:', a);
CommonJS에서의 순환 참조
에러가 나지 않습니다. 대신 아직 실행이 끝나지 않은 모듈의 불완전한 export 객체 를 반환합니다. 그래서 예상치 못한 undefined를 받을 수 있습니다.
ESM에서의 순환 참조
라이브 바인딩 덕분에 조금 낫지만, 초기화 순서에 따라 ReferenceError가 발생할 수 있습니다. 변수 선언 전에 접근하면 TDZ(Temporal Dead Zone)에 걸리기 때문입니다.
해결 방법
- **의존 관계 리팩토링 **: 공통 부분을 별도 모듈로 분리
- ** 지연 접근 **: 함수 안에서 import 값을 사용 (호출 시점에는 이미 초기화 완료)
- ** 의존성 방향 통일 **: 단방향 의존 관계 유지
트리 쉐이킹 — 안 쓰는 코드 제거
트리 쉐이킹은 번들러가 사용하지 않는 export를 최종 번들에서 제거하는 최적화 기법입니다.
// utils.js — 3개의 함수를 export
export function formatDate(d) { /* ... */ }
export function formatCurrency(n) { /* ... */ }
export function formatName(name) { /* ... */ }
// app.js — formatDate만 사용
import { formatDate } from './utils.js';
// 번들러가 formatCurrency, formatName을 제거함
트리 쉐이킹이 잘 되려면
- **ESM 사용 **: 정적 분석이 가능해야 합니다
- ** 사이드 이펙트 없음 **:
package.json의"sideEffects": false설정 - **barrel export 주의 **:
index.js에서 모든 걸 re-export하면 트리 쉐이킹이 비효율적일 수 있습니다
// package.json
{
"sideEffects": false
}
// 또는 특정 파일만 사이드 이펙트가 있다고 명시
{
"sideEffects": ["*.css", "./src/polyfills.js"]
}
번들러와의 관계
Webpack, Rollup, Vite 같은 번들러는 모듈 시스템 위에서 동작합니다.
- Webpack: CommonJS와 ESM 둘 다 지원. 자체 모듈 시스템으로 변환
- Rollup: ESM 기반. 트리 쉐이킹에 강점
- Vite: 개발 시 네이티브 ESM 활용, 빌드 시 Rollup 사용
Vite가 개발 서버에서 빠른 이유는 번들링 없이 브라우저의 네이티브 ESM을 활용하기 때문입니다. 파일이 바뀌면 해당 모듈만 다시 요청하면 되니까요.
Node.js에서 ESM 사용하기
Node.js에서 ESM을 사용하는 방법은 두 가지입니다.
- 파일 확장자를
.mjs로 변경 package.json에"type": "module"추가
// package.json
{
"type": "module"
}
이렇게 하면 .js 파일이 ESM으로 해석됩니다. CommonJS를 쓰려면 .cjs 확장자를 사용해야 합니다.
ESM에서 __dirname, __filename 대체
ESM에서는 __dirname과 __filename이 제공되지 않습니다.
// CommonJS
console.log(__dirname);
console.log(__filename);
// ESM 대체
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
주의할 점
순환 참조의 증상이 시스템마다 다르다
CommonJS에서 순환 참조가 발생하면 에러 없이 불완전한 export 객체가 반환되어 예상치 못한 undefined를 받습니다. ESM에서는 라이브 바인딩 덕분에 조금 낫지만, 초기화 순서에 따라 TDZ로 인한 ReferenceError가 발생할 수 있습니다.
barrel export(index.js)가 트리 쉐이킹을 방해할 수 있다
index.js에서 모든 것을 re-export하면, 번들러가 사용하지 않는 모듈을 제거하기 어려워질 수 있습니다. sideEffects: false 설정과 함께 사용해야 효과적입니다.
ESM에서 __dirname이 없다
ESM 환경에서는 __dirname과 __filename이 제공되지 않습니다. import.meta.url과 fileURLToPath로 대체해야 합니다.
정리
| 항목 | CommonJS | ESM |
|---|---|---|
| 문법 | require() / module.exports | import / export |
| 로딩 | 동기 | 비동기 (정적 분석 가능) |
| 바인딩 | 값의 복사 | 라이브 바인딩 (참조) |
| 트리 쉐이킹 | 어려움 | 가능 |
| 조건부 로딩 | require() 직접 가능 | import()로만 가능 |
| 순환 참조 시 | 불완전한 객체 반환 | TDZ로 ReferenceError 가능 |