자바스크립트 고급 #7 모듈 시스템 깊이

기초 #7 모듈 에서 ES Modules의 사용법을 봤습니다. 이번 글은 그 안쪽 — 어떻게 동작하는지, CommonJS와 어디가 다른지, 순환 참조에서 무엇이 일어나는지를 정리합니다. 자바스크립트 고급 시리즈의 마지막 글입니다.

두 모듈 시스템 — ESM vs CommonJS #

자바스크립트 생태계에는 두 가지 모듈 시스템이 공존합니다.

ES Modules (ESM)CommonJS (CJS)
문법import / exportrequire / module.exports
로딩정적 (파싱 시점)동적 (런타임)
실행비동기동기
사용처브라우저, 모던 Node옛 Node
표준ECMAScriptNode 자체

ESM은 모던 자바스크립트의 표준이지만, Node 생태계는 오랫동안 CommonJS 였습니다. 그 잔재가 지금도 남아 있고, 두 시스템이 상호 작용할 때 미묘한 함정이 있습니다.

CommonJS — 옛 Node 모듈 #

CJS 형태
// 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를 돌려줍니다.

조건부 require
if (someCondition) {
  const lib = require('./optional');   // 조건에 따라 로드
}

이게 가능한 이유가 동기 + 동적이라 — 코드 어디서든, 어떤 조건에서든 부를 수 있습니다.

ESM — 정적 + 비동기 #

ESM
// math.js
export function add(a, b) { return a + b; }

// main.js
import { add } from './math.js';
add(2, 3);   // 5

import는 함수가 아니라 선언 입니다. 런타임 동작이 아니라 파싱 시점에 해석됩니다.

정적이라는 의미 #

ESM 정적 — 이건 안 됨
if (someCondition) {
  import { lib } from './optional.js';   // ✗ SyntaxError
}

import 선언은 모듈 최상위에만, 조건부로는 못 써요.

조건부 로드가 필요하면 — 기초 #7 에서 본 dynamic import가 답입니다.

dynamic import — 조건부
if (someCondition) {
  const { lib } = await import('./optional.js');
}

이건 함수 호출 형태라 어디서든 가능합니다. Promise를 반환합니다.

ESM의 두 단계 — 파싱 + 실행 #

ESM 모듈이 로드될 때 일어나는 일.

  1. 파싱import 들을 읽어 의존성 그래프 구축
  2. 링킹 — 모든 모듈의 export를 대응하는 import에 연결
  3. 실행 — 위에서부터 코드 실행

이 분리 덕분에 import가 호이스팅됩니다.

import 호이스팅
console.log(add(2, 3));   // OK — import 가 끌어올려짐

import { add } from './math.js';

CommonJS의 require는 함수 호출이라 — 호출되기 전에는 모듈이 로드되지 않습니다. ESM은 파싱 시점에 모든 import가 처리되니, 코드 위치와 관계없이 모듈이 항상 준비됩니다.

Live Binding — ESM의 export는 살아있다 #

ESM의 가장 큰 차이 한 가지. import는 값의 복사가 아니라 살아있는 참조 입니다.

counter.js — ESM
export let count = 0;
export function increment() {
  count++;
}
main.js — ESM
import { count, increment } from './counter.js';

console.log(count);   // 0
increment();
console.log(count);   // 1  ← export된 값이 갱신됨

count는 0의 복사가 아니라 counter.js 안의 count 변수에 대한 살아있는 참조 입니다. counter 안에서 갱신되면 main 에서도 보입니다.

CommonJS는 이렇게 동작하지 않습니다.

counter.js — CJS
let count = 0;
function increment() {
  count++;
}
module.exports = { count, increment };
main.js — CJS
const { count, increment } = require('./counter');

console.log(count);   // 0
increment();
console.log(count);   // 0  ← require 시점의 값이 그대로 고정됨

CJS는 module.exports 객체가 export 된 시점에 그 시점의 값이 디스트럭처링으로 복사됩니다. 이후 변화는 안 보입니다.

이 차이가 두 시스템 코드를 섞을 때 미묘한 버그를 만듭니다.

모듈은 한 번만 실행됨 #

같은 모듈을 여러 곳에서 import 해도 한 번만 실행됩니다.

logger.js
console.log('logger 모듈 로드');
export const logger = { log: (m) => console.log(m) };
둘 다 import
// a.js
import { logger } from './logger.js';

// b.js
import { logger } from './logger.js';

// main.js
import './a.js';
import './b.js';
// 'logger 모듈 로드' 가 한 번만 출력됨

이 동작이 모듈 내 싱글톤 패턴의 기반입니다. 한 번 만든 객체를 여러 곳에서 공유할 때 안전하게 동작합니다.

순환 참조 — 두 모듈이 서로 import #

두 모듈이 서로를 참조하면 어떻게 될까요.

a.js
import { b } from './b.js';
export const a = 'A';
console.log('a.js 로드:', b);
b.js
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의 동작 단계:

  1. main이 a.js를 import — a.js 파싱 시작
  2. a.js가 b.js를 import — b.js 파싱 시작
  3. b.js가 a.js를 import — 이미 로딩 중이라 그대로 진행 (a의 binding은 비어있음)
  4. b.js의 본문 실행 — console.log('b.js 로드:', a) — a가 아직 undefined
  5. b.js 끝, a.js 본문 실행 — b는 이미 ‘B’

핵심: 순환 참조 시 import 한 변수는 살아있는 참조라 나중에 채워질 수 있지만, 모듈 본문 실행 시점에는 비어있을 수 있다.

이 함정을 피하는 가장 좋은 방법은 순환 참조를 만들지 않는 것입니다. 발생하면 의존 구조를 다시 보세요. 한 모듈을 둘로 쪼개거나 공통 의존을 빼내는 게 보통의 답입니다.

CommonJS의 순환 참조 — 더 위험 #

CJS는 동작 자체가 다릅니다.

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 에서만 잘 동작 합니다.

ESM — tree shaking 가능
// 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.jsontype 필드 #

ESM 프로젝트
{
  "type": "module"
}

type: "module" 이면 .js가 ESM으로 해석. 없으면 CJS.

확장자로 구분 #

  • .mjs — 항상 ESM
  • .cjs — 항상 CJS
  • .jspackage.jsontype 따라

한쪽에서 다른 쪽 import #

ESM에서 CJS 모듈을 import 하는 건 거의 가능합니다 (default import).

ESM 에서 CJS 사용
import lodash from 'lodash';   // CJS 패키지

CJS에서 ESM을 쓰는 건 더 까다로워요 — 보통 dynamic import가 필요합니다.

CJS 에서 ESM 사용
async function load() {
  const { default: chalk } = await import('chalk');
}

chalk 같은 ESM-only 라이브러리가 늘어나서 옛 CJS 코드베이스가 종종 막히는 부분입니다.

import.meta — 모듈 자기 정보 #

ESM 모듈은 자기 자신에 대한 정보를 가집니다.

import.meta
console.log(import.meta.url);   // 모듈 자기 URL

// Vite 같은 환경
console.log(import.meta.env);   // 환경 변수

CJS 에는 __filename, __dirname이 있는데 — ESM 에서는 import.meta.url로 같은 정보를 만들어 씁니다.

ESM 에서 __filename 만들기
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. 클로저와 스코프 — 함수가 환경을 끌고 다님 (#1)
  2. this 바인딩 — 호출 방식이 결정 (#2)
  3. 프로토타입 체인 — 클래스의 진짜 정체 (#3)
  4. 이벤트 루프 — 마이크로 vs 매크로 태스크 (#4)
  5. 메모리 모델 — 도달 가능성과 GC (#5)
  6. Symbol과 Proxy — 메타프로그래밍 도구 (#6)
  7. 모듈 시스템 — ESM vs CJS, 순환 참조 (이번 글)

여기까지 잡으면 — 자바스크립트가 왜 그렇게 동작하는지에 대한 거의 모든 질문에 답할 수 있게 됩니다. 다음 실전 시리즈에서는 이 모든 도구를 묶어 바닐라 자바스크립트로 동적 웹 페이지를 만드는 단계로 갑니다. 라이브러리 없이 DOM,이벤트,fetch,로컬 스토리지를 직접 다루며 작은 앱 한 개를 빌드합니다.

X