Skip to main content
Advertisement

모듈 시스템

JavaScript의 모듈 시스템은 코드를 파일 단위로 분리하고 재사용하는 방법을 제공합니다. ESM(ES Module)과 CJS(CommonJS) 두 가지 주요 시스템이 공존합니다.


모듈이 필요한 이유

// 모듈 없이 전역 스코프에 모든 것이 있으면:
// file1.js
var name = 'Alice';
function greet() { return `Hello, ${name}!`; }

// file2.js
var name = 'Bob'; // file1.js의 name을 덮어씀!
console.log(greet()); // Hello, Bob! (의도치 않은 결과)

// 모듈을 사용하면:
// file1.mjs - 자신만의 스코프
const name = 'Alice';
export function greet() { return `Hello, ${name}!`; }

// file2.mjs - 자신만의 스코프
const name = 'Bob';
import { greet } from './file1.mjs';
console.log(greet()); // Hello, Alice! (의도한 결과)
console.log(name); // Bob (이 파일의 name)

ESM(ES Module): import/export

Named Export (이름 있는 내보내기)

// math.mjs
// 개별 선언과 동시에 export
export const PI = 3.14159;
export let version = '1.0';

export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }

export class Vector {
constructor(x, y) {
this.x = x;
this.y = y;
}

add(other) {
return new Vector(this.x + other.x, this.y + other.y);
}
}

// 또는 선언 후 한 번에 export
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;

export { multiply, divide };

// export 시 이름 변경
export { multiply as mul, divide as div };

Named Import (이름 있는 가져오기)

// app.mjs
import { PI, add, Vector } from './math.mjs';

console.log(PI); // 3.14159
console.log(add(2, 3)); // 5
const v = new Vector(1, 2);

// import 시 이름 변경
import { add as sum, subtract as minus } from './math.mjs';
console.log(sum(1, 2)); // 3

// 전체 모듈 네임스페이스로 import
import * as math from './math.mjs';
console.log(math.PI);
console.log(math.add(2, 3));
math.version = '2.0'; // 주의: live binding이므로 변경 가능

Default Export (기본 내보내기)

// user.mjs
// 파일당 하나만 허용
export default class User {
constructor(name, email) {
this.name = name;
this.email = email;
}

toString() {
return `${this.name} <${this.email}>`;
}
}

// 익명 함수도 가능
export default function(data) {
return JSON.stringify(data, null, 2);
}
// default import는 이름을 자유롭게 지정
import User from './user.mjs';
import stringify from './user.mjs'; // 같은 파일, 다른 이름

const user = new User('Alice', 'alice@example.com');
console.log(stringify({ name: 'test' }));

// named와 default를 함께
export default class Component { }
export const version = '1.0';
export function helper() { }

import Component, { version, helper } from './component.mjs';

Re-export (재내보내기)

// utils/index.mjs - 여러 모듈을 한 곳에서 내보내기 (Barrel Export)

// named re-export
export { add, subtract } from './math.mjs';
export { User, createUser } from './user.mjs';
export { fetchData, postData } from './api.mjs';

// 이름 변경하며 re-export
export { default as utils } from './utils.mjs';

// default re-export
export { default } from './main.mjs';

// 전체 re-export
export * from './math.mjs';
export * from './string-utils.mjs';

// 사용
import { add, User, fetchData } from './utils/index.mjs';
// 여러 파일에서 일일이 import하지 않아도 됨

동적 import(): 코드 분할

// 정적 import: 파일 최상위에서만, 항상 로드됨
import { heavyModule } from './heavy.mjs'; // 항상 로드

// 동적 import: 필요할 때만 로드
async function loadFeature() {
const module = await import('./heavy-feature.mjs');
module.initialize();
}

// 조건부 로딩
async function loadLocale(lang) {
const locale = await import(`./locales/${lang}.mjs`);
return locale.default;
}

// 이벤트 기반 로딩 (라우팅)
button.addEventListener('click', async () => {
const { Modal } = await import('./components/Modal.mjs');
const modal = new Modal({ title: '확인', content: '계속하시겠습니까?' });
modal.show();
});

// 폴리필 조건부 로딩
if (!window.IntersectionObserver) {
await import('intersection-observer'); // 필요할 때만
}

// Promise 반환 (await 없이도 사용 가능)
import('./analytics.mjs').then(({ track }) => {
track('page_view', { path: location.pathname });
});

// 여러 모듈 병렬 동적 로드
const [chartLib, mapLib] = await Promise.all([
import('chart.js'),
import('leaflet')
]);

import.meta

// 현재 모듈의 메타데이터 접근
console.log(import.meta.url); // file:///path/to/current/module.mjs

// 현재 파일 기준 상대 경로 해결
const dataUrl = new URL('./data.json', import.meta.url);
const response = await fetch(dataUrl);

// Node.js에서 __dirname 대체
import { fileURLToPath, URL } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = fileURLToPath(new URL('.', import.meta.url));

// Vite, Webpack 등 번들러 환경 변수
console.log(import.meta.env.MODE); // 'development' or 'production'
console.log(import.meta.env.VITE_API_URL);

// import.meta.resolve (Node.js 20.6+)
const resolvedPath = import.meta.resolve('./utils.mjs');

CJS(CommonJS): require/module.exports

Node.js의 전통적인 모듈 시스템입니다.

// math.js (CJS)
const PI = 3.14159;

function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }

// 한 번에 내보내기
module.exports = { PI, add, subtract };

// 또는 개별로
module.exports.PI = PI;
module.exports.add = add;

// exports는 module.exports의 별칭
exports.multiply = (a, b) => a * b;
// 주의: exports = {...}는 참조를 바꾸므로 안됨!
// app.js (CJS)
const { PI, add } = require('./math.js');
const math = require('./math.js');

console.log(PI);
console.log(add(1, 2));
console.log(math.subtract(5, 3));

// default export 패턴
// user.js
class User { }
module.exports = User; // 클래스 자체를 내보냄

// 사용
const User = require('./user.js');
const user = new User();

// 내장 모듈
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');

CJS 특성

// CJS는 동기적, 런타임에 평가됨
const moduleName = getModuleName(); // 동적으로 경로 결정 가능
const module = require(moduleName); // OK

// require는 캐싱됨
const a = require('./counter.js');
const b = require('./counter.js');
console.log(a === b); // true (같은 인스턴스)

// counter.js
let count = 0;
module.exports = {
increment() { count++; },
getCount() { return count; }
};

// 사이드 이펙트: 두 파일이 같은 counter 인스턴스 공유
const c1 = require('./counter.js');
const c2 = require('./counter.js');
c1.increment();
console.log(c2.getCount()); // 1 (같은 인스턴스!)

ESM vs CJS 차이점

특성ESMCJS
문법import/exportrequire/module.exports
로딩정적 (컴파일 타임 분석)동적 (런타임)
실행비동기 가능항상 동기
스코프모듈 스코프모듈 스코프
this (최상위)undefinedmodule.exports
확장자.mjs 또는 "type": "module".cjs 또는 기본
브라우저네이티브 지원번들러 필요
// package.json으로 타입 설정
// { "type": "module" } → .js 파일이 ESM으로 처리
// { "type": "commonjs" } → .js 파일이 CJS으로 처리 (기본)

// 혼용 가능
// file.mjs → 항상 ESM
// file.cjs → 항상 CJS
// file.js → package.json의 type 필드 따름

트리 쉐이킹(Tree Shaking)

번들러가 사용되지 않는 코드를 제거하는 최적화 기법입니다.

// utils.mjs
export function usedFunction() { return 'used'; }
export function unusedFunction() { return 'unused'; } // 이것은 번들에서 제거됨

// app.mjs
import { usedFunction } from './utils.mjs';
// unusedFunction은 import하지 않으므로 번들에서 제거

// 트리 쉐이킹이 가능한 이유: ESM의 정적 분석
// import 문은 항상 파일 최상위에, 조건부/동적 import 불가 → 번들러가 사전 분석 가능

// CJS는 트리 쉐이킹이 어려움
const utils = require('./utils'); // 런타임에 평가되므로 번들러가 사전 분석 불가
utils.used();

사이드 이펙트 제어

// package.json
{
"sideEffects": false, // 모든 파일이 순수 (사이드 이펙트 없음)
"sideEffects": ["*.css", "./src/polyfills.js"] // 특정 파일만 사이드 이펙트
}

// 사이드 이펙트가 있는 코드 예시 (import만으로 효과 발생)
// polyfills.js
Array.prototype.at ??= function(i) { /* polyfill */ };

// CSS 모듈
import './global.css'; // CSS 적용이 사이드 이펙트

브라우저와 Node.js에서 모듈 사용

브라우저

<!-- type="module"로 ESM 활성화 -->
<script type="module" src="./app.mjs"></script>

<!-- 인라인 모듈 -->
<script type="module">
import { Component } from './component.mjs';
const app = new Component(document.getElementById('app'));
</script>

<!-- 모듈은 기본적으로 defer (DOM 파싱 후 실행) -->
<!-- 모듈은 항상 strict mode -->
<!-- 모듈은 한 번만 로드 (캐싱) -->

<!-- Import Maps: 브라우저에서 베어 임포트 지원 -->
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.skypack.dev/lodash",
"react": "https://esm.sh/react@18"
}
}
</script>

<script type="module">
import _ from 'lodash'; // import map 사용
import React from 'react';
</script>

Node.js

// Node.js에서 ESM 사용 방법 3가지:

// 1. 파일 확장자 .mjs
// my-module.mjs
export const greeting = 'Hello from ESM';

// 2. package.json에 "type": "module"
// { "type": "module" }
// 이제 .js 파일이 ESM으로 처리

// 3. .mts (TypeScript)

// Node.js 22+: CJS에서 ESM import 가능
const { foo } = await import('./esm-module.mjs');

// ESM에서 CJS require 불가 (대신 createRequire 사용)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const cjsModule = require('./cjs-module.js');

실전: 모듈 구조 설계

src/
├── features/
│ ├── user/
│ │ ├── user.model.mjs
│ │ ├── user.service.mjs
│ │ ├── user.api.mjs
│ │ └── index.mjs ← Barrel export
│ └── product/
│ ├── product.model.mjs
│ └── index.mjs
├── shared/
│ ├── utils/
│ │ ├── string.mjs
│ │ ├── date.mjs
│ │ └── index.mjs
│ └── constants.mjs
└── app.mjs
// features/user/index.mjs (Barrel)
export { User } from './user.model.mjs';
export { UserService } from './user.service.mjs';
export * from './user.api.mjs';

// app.mjs
import { User, UserService } from './features/user/index.mjs';
import { formatDate } from './shared/utils/index.mjs';

고수 팁

1. 순환 의존성(Circular Dependencies) 처리

// a.mjs
import { b } from './b.mjs';
export const a = 'A';
console.log(b); // undefined! (b.mjs가 a.mjs를 먼저 로드하려 함)

// 해결책: 공통 모듈로 분리하거나 lazy import 사용
async function getB() {
const { b } = await import('./b.mjs');
return b;
}

2. 모듈 싱글톤 패턴

// singleton.mjs
let instance = null;

export function getInstance() {
if (!instance) {
instance = createExpensiveObject();
}
return instance;
}

// ESM은 자동으로 한 번만 실행됨 (캐싱)
// 단순화된 버전:
export const config = await loadConfig(); // Top-level await
// 이 모듈을 import하면 항상 같은 config 인스턴스

3. 조건부 폴리필 로딩

// 필요한 폴리필만 동적으로 로드
const polyfills = [];

if (!('structuredClone' in globalThis)) {
polyfills.push(import('core-js/stable/structured-clone'));
}

if (!('at' in Array.prototype)) {
polyfills.push(import('core-js/stable/array/at'));
}

await Promise.all(polyfills);
// 이제 폴리필이 모두 로드됨
Advertisement