자바스크립트 고급 #7 모듈 시스템 깊이
기초 #7 모듈 에서 ES Modules의 사용법을 봤습니다. 이번 글은 그 안쪽 — 어떻게 동작하는지, CommonJS와 어디가 다른지, 순환 참조에서 무엇이 일어나는지를 정리합니다. 자바스크립트 고급 시리즈의 마지막 글입니다.
두 모듈 시스템 — ESM vs CommonJS #
자바스크립트 생태계에는 두 가지 모듈 시스템이 공존합니다.
| ES Modules (ESM) | CommonJS (CJS) | |
|---|---|---|
| 문법 | import / export | require / module.exports |
| 로딩 | 정적 (파싱 시점) | 동적 (런타임) |
| 실행 | 비동기 | 동기 |
| 사용처 | 브라우저, 모던 Node | 옛 Node |
| 표준 | ECMAScript | Node 자체 |
ESM은 모던 자바스크립트의 표준이지만, Node 생태계는 오랫동안 CommonJS 였습니다. 그 잔재가 지금도 남아 있고, 두 시스템이 상호 작용할 때 미묘한 함정이 있습니다.
CommonJS — 옛 Node 모듈 #
// math.js
function add(a, b) { return a + b; }
module.exports = { add };
// main.js
const { add } = require('./math');
add(2, 3); // 5
require는 함수 호출 입니다. 런타임에 동기적으로 모듈을 읽고 실행해서 module.exports를 돌려줍니다.
if (someCondition) {
const lib = require('./optional'); // 조건에 따라 로드
}이게 가능한 이유가 동기 + 동적이라 — 코드 어디서든, 어떤 조건에서든 부를 수 있습니다.
ESM — 정적 + 비동기 #
// math.js
export function add(a, b) { return a + b; }
// main.js
import { add } from './math.js';
add(2, 3); // 5
import는 함수가 아니라 선언 입니다. 런타임 동작이 아니라 파싱 시점에 해석됩니다.
정적이라는 의미 #
if (someCondition) {
import { lib } from './optional.js'; // ✗ SyntaxError
}import 선언은 모듈 최상위에만, 조건부로는 못 써요.
조건부 로드가 필요하면 — 기초 #7 에서 본 dynamic import가 답입니다.
if (someCondition) {
const { lib } = await import('./optional.js');
}이건 함수 호출 형태라 어디서든 가능합니다. Promise를 반환합니다.
ESM의 두 단계 — 파싱 + 실행 #
ESM 모듈이 로드될 때 일어나는 일.
- 파싱 —
import들을 읽어 의존성 그래프 구축 - 링킹 — 모든 모듈의 export를 대응하는 import에 연결
- 실행 — 위에서부터 코드 실행
이 분리 덕분에 import가 호이스팅됩니다.
console.log(add(2, 3)); // OK — import 가 끌어올려짐
import { add } from './math.js';CommonJS의 require는 함수 호출이라 — 호출되기 전에는 모듈이 로드되지 않습니다. ESM은 파싱 시점에 모든 import가 처리되니, 코드 위치와 관계없이 모듈이 항상 준비됩니다.
Live Binding — ESM의 export는 살아있다 #
ESM의 가장 큰 차이 한 가지. import는 값의 복사가 아니라 살아있는 참조 입니다.
export let count = 0;
export function increment() {
count++;
}import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 ← export된 값이 갱신됨
count는 0의 복사가 아니라 counter.js 안의 count 변수에 대한 살아있는 참조 입니다. counter 안에서 갱신되면 main 에서도 보입니다.
CommonJS는 이렇게 동작하지 않습니다.
let count = 0;
function increment() {
count++;
}
module.exports = { count, increment };const { count, increment } = require('./counter');
console.log(count); // 0
increment();
console.log(count); // 0 ← require 시점의 값이 그대로 고정됨
CJS는 module.exports 객체가 export 된 시점에 그 시점의 값이 디스트럭처링으로 복사됩니다. 이후 변화는 안 보입니다.
이 차이가 두 시스템 코드를 섞을 때 미묘한 버그를 만듭니다.
모듈은 한 번만 실행됨 #
같은 모듈을 여러 곳에서 import 해도 한 번만 실행됩니다.
console.log('logger 모듈 로드');
export const logger = { log: (m) => console.log(m) };// a.js
import { logger } from './logger.js';
// b.js
import { logger } from './logger.js';
// main.js
import './a.js';
import './b.js';
// 'logger 모듈 로드' 가 한 번만 출력됨
이 동작이 모듈 내 싱글톤 패턴의 기반입니다. 한 번 만든 객체를 여러 곳에서 공유할 때 안전하게 동작합니다.
순환 참조 — 두 모듈이 서로 import #
두 모듈이 서로를 참조하면 어떻게 될까요.
import { b } from './b.js';
export const a = 'A';
console.log('a.js 로드:', b);import { a } from './a.js';
export const b = 'B';
console.log('b.js 로드:', a);ESM은 이런 순환을 처리할 수 있도록 설계됐습니다. 다만 시점에 따라 값이 비어있을 수 있습니다.
a.js 로드: B ← b가 먼저 끝나서 값이 있음
b.js 로드: undefined ← a 의 export 가 아직 평가 안 됨ESM의 동작 단계:
- main이 a.js를 import — a.js 파싱 시작
- a.js가 b.js를 import — b.js 파싱 시작
- b.js가 a.js를 import — 이미 로딩 중이라 그대로 진행 (a의 binding은 비어있음)
- b.js의 본문 실행 —
console.log('b.js 로드:', a)— a가 아직 undefined - b.js 끝, a.js 본문 실행 — b는 이미 ‘B’
핵심: 순환 참조 시 import 한 변수는 살아있는 참조라 나중에 채워질 수 있지만, 모듈 본문 실행 시점에는 비어있을 수 있다.
이 함정을 피하는 가장 좋은 방법은 순환 참조를 만들지 않는 것입니다. 발생하면 의존 구조를 다시 보세요. 한 모듈을 둘로 쪼개거나 공통 의존을 빼내는 게 보통의 답입니다.
CommonJS의 순환 참조 — 더 위험 #
CJS는 동작 자체가 다릅니다.
// a.js
const { b } = require('./b.js');
console.log('a.js:', b);
module.exports.a = 'A';
// b.js
const { a } = require('./a.js');
console.log('b.js:', a);
module.exports.b = 'B';CJS의 require는 모듈이 부분적으로 실행된 상태에서도 그때까지의 module.exports를 반환합니다.
b.js: undefined ← a.js 가 첫 줄까지만 실행되고 module.exports 비어있음
a.js: { b: 'B' }ESM도 비슷한 함정이 있지만, CJS는 동기 실행이라 눈에 보이는 값 수준에서 어긋나요. ESM의 live binding이 미세하게 더 우호적입니다.
Tree Shaking — ESM 만의 이점 #
번들러(Vite, webpack, Rollup) 가 사용하지 않는 export를 결과물에서 제거하는 최적화. 정적 분석이 가능한 ESM 에서만 잘 동작 합니다.
// math.js
export function add() { /* ... */ }
export function subtract() { /* ... */ }
export function multiply() { /* ... */ }
// main.js
import { add } from './math.js';
add(2, 3);
// 빌드 결과에 subtract, multiply 가 빠짐
CJS는 require가 동적이라 어떤 export가 실제로 쓰일지 빌드 타임에 알기 어렵습니다. ESM으로 작성된 라이브러리가 번들 크기에 더 우호적인 이유입니다.
Node의 ESM/CJS 혼용 #
Node 18+ 에서는 두 시스템을 모두 지원하지만, 한 프로젝트 안에서 섞으면 미묘합니다.
package.json의 type 필드
#
{
"type": "module"
}type: "module" 이면 .js가 ESM으로 해석. 없으면 CJS.
확장자로 구분 #
.mjs— 항상 ESM.cjs— 항상 CJS.js—package.json의type따라
한쪽에서 다른 쪽 import #
ESM에서 CJS 모듈을 import 하는 건 거의 가능합니다 (default import).
import lodash from 'lodash'; // CJS 패키지
CJS에서 ESM을 쓰는 건 더 까다로워요 — 보통 dynamic import가 필요합니다.
async function load() {
const { default: chalk } = await import('chalk');
}chalk 같은 ESM-only 라이브러리가 늘어나서 옛 CJS 코드베이스가 종종 막히는 부분입니다.
import.meta — 모듈 자기 정보 #
ESM 모듈은 자기 자신에 대한 정보를 가집니다.
console.log(import.meta.url); // 모듈 자기 URL
// Vite 같은 환경
console.log(import.meta.env); // 환경 변수
CJS 에는 __filename, __dirname이 있는데 — ESM 에서는 import.meta.url로 같은 정보를 만들어 씁니다.
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);Node 20+ 에서는 import.meta.dirname, import.meta.filename을 직접 제공합니다.
마무리 #
이번 글에서 정리한 내용:
- ESM은 정적 + 비동기, CJS는 동적 + 동기
- ESM의 import는 호이스팅됨 (파싱 시 처리)
- ESM은 live binding — export가 갱신되면 import도 보임
- CJS는 require 시점의 값을 복사
- 같은 모듈은 한 번만 실행 (싱글톤 기반)
- 순환 참조 — ESM도 시점에 따라 비어있을 수 있음, 가능한 피하기
- Tree shaking은 ESM의 정적 구조 덕분에 가능
- Node에서 두 시스템 혼용 —
type: "module"/.mjs/.cjs import.meta로 모듈 자기 정보
고급 시리즈를 마치며 #
7편에서 다룬 자바스크립트의 안쪽 동작들:
- 클로저와 스코프 — 함수가 환경을 끌고 다님 (#1)
- this 바인딩 — 호출 방식이 결정 (#2)
- 프로토타입 체인 — 클래스의 진짜 정체 (#3)
- 이벤트 루프 — 마이크로 vs 매크로 태스크 (#4)
- 메모리 모델 — 도달 가능성과 GC (#5)
- Symbol과 Proxy — 메타프로그래밍 도구 (#6)
- 모듈 시스템 — ESM vs CJS, 순환 참조 (이번 글)
여기까지 잡으면 — 자바스크립트가 왜 그렇게 동작하는지에 대한 거의 모든 질문에 답할 수 있게 됩니다. 다음 실전 시리즈에서는 이 모든 도구를 묶어 바닐라 자바스크립트로 동적 웹 페이지를 만드는 단계로 갑니다. 라이브러리 없이 DOM,이벤트,fetch,로컬 스토리지를 직접 다루며 작은 앱 한 개를 빌드합니다.