ES 모듈 이전에 JavaScript에는 내장 모듈 시스템이 없었습니다. 올바른 순서의 script 태그, 캡슐화를 흉내 내는 IIFE 패턴, 그리고 10년간의 Node.js의 CommonJS가 있었습니다. ES2015에서 표준화되어 이제 모든 곳에서 지원되는 ES 모듈이 진정한 해결책입니다. 이 가이드는 명명 내보내기 vs 기본 내보내기의 기초부터 동적 임포트, 트리 쉐이킹, 그리고 대규모 코드베이스를 유지 관리 가능하게 하는 패턴까지 모든 것을 다룹니다.
명명 내보내기 vs 기본 내보내기
모듈에서 내보내는 방법은 두 가지입니다. 차이점과 트레이드오프를 이해하는 것이 나머지 모든 것의 기반입니다:
// utils/currency.js
// Named exports — explicit names, multiple per file
export function formatPrice(amount, currency = 'USD') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}
export function parseCents(cents) {
return cents / 100;
}
export const DEFAULT_CURRENCY = 'USD';// Importing named exports — names must match
import { formatPrice, parseCents } from './utils/currency.js';
// You can rename on import
import { formatPrice as fmt } from './utils/currency.js';
// Import the entire namespace as an object
import * as Currency from './utils/currency.js';
Currency.formatPrice(49.99); // works// components/ProductCard.js
// Default export — one per file, no name required
export default function ProductCard({ product }) {
return `<div class="card">${product.name}</div>`;
}// Importing a default export — you choose the name
import ProductCard from './components/ProductCard.js';
import Card from './components/ProductCard.js'; // same thing, different name배럴 파일 — 유용하지만 위험할 수 있음
배럴 파일은 여러 모듈에서 다시 내보내는 index.js로, 소비자에게 더 깔끔한 임포트 경로를 제공합니다. 대규모 프로젝트 어디서나 볼 수 있습니다:
// utils/index.js — the barrel
export { formatPrice, parseCents, DEFAULT_CURRENCY } from './currency.js';
export { validateEmail, validatePhone } from './validation.js';
export { debounce, throttle } from './timing.js';
export { fetchWithRetry, buildQueryString } from './http.js';// Consumer — clean import path, no need to know the file structure
import { formatPrice, debounce, fetchWithRetry } from './utils/index.js';
// or with bundlers that resolve index.js automatically:
import { formatPrice, debounce, fetchWithRetry } from './utils';위험 요소: 배럴 파일은 트리 쉐이킹을 망칠 수 있습니다. 40개 모듈을 다시 내보내는 배럴에서 함수 하나를 임포트하면, 일부 번들러는 사용하지 않는 것까지 40개 전부를 가져옵니다. 해결책은 배럴에 넣는 항목을 선택적으로 하고, 번들러가 package.json의 sideEffects: false를 지원하는지 확인하는 것입니다.
동적 import() — 필요 시 코드 분할
정적 임포트(import x from '...')는 번들 시간에 해결됩니다. 동적 import()는 런타임에 느리게 모듈을 로드하는 함수로, Promise를 반환합니다. 라우트 기반 코드 분할을 구현하는 방법입니다:
// Load a heavy chart library only when the user navigates to the analytics page
async function loadAnalyticsDashboard() {
const { Chart } = await import('chart.js');
const { buildDashboardData } = await import('./analytics/data.js');
const data = await buildDashboardData();
const canvas = document.getElementById('dashboard-chart');
new Chart(canvas, { type: 'bar', data });
}
// Attach to a route change
router.on('/analytics', loadAnalyticsDashboard);// Feature flags — only load a module if the feature is enabled
async function initFeature(featureName) {
const flags = await getFeatureFlags();
if (flags[featureName]) {
const module = await import(`./features/${featureName}.js`);
module.init();
}
}Webpack, Rollup, Vite 모두 동적 import()를 이해하며 로드된 모듈을 자동으로 별도 청크로 분할합니다. 결과: 더 작은 초기 번들, 더 빠른 첫 로드.
import.meta — 모듈 메타데이터
import.meta는 현재 모듈에 대한 메타데이터를 제공하는 객체입니다. 가장 유용한 속성은 import.meta.url — 현재 모듈 파일의 URL입니다:
// Get the directory of the current module (Node.js)
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Now you can do path resolution the same way you would with CommonJS
const configPath = join(__dirname, '../config/app.json');// In Vite projects, import.meta.env gives you environment variables
const apiBase = import.meta.env.VITE_API_BASE_URL;
const isProd = import.meta.env.PROD; // boolean
// import.meta.glob — Vite-specific, import multiple files at once
const modules = import.meta.glob('./pages/*.js');
for (const path in modules) {
const module = await modules[path]();
console.log(path, module);
}ES 모듈 vs CommonJS
오래된 Node.js 코드베이스와 일부 npm 패키지에서는 여전히 CommonJS(require())를 볼 수 있습니다. 사람들을 걸려들게 하는 차이점 빠른 참조:
// CommonJS (CJS) — still common in Node.js
const express = require('express');
const { join } = require('path');
module.exports = { myFunction };
module.exports.default = MyClass;
// ES Modules (ESM) — the standard
import express from 'express';
import { join } from 'path';
export { myFunction };
export default MyClass;주요 차이점: ES 모듈은 정적으로 분석 가능합니다(코드 실행 전에 임포트가 해결됨), 반면 CommonJS는 런타임에 실행됩니다. ESM은 기본적으로 async 로딩을 사용하고, CJS는 동기적입니다. Node.js에서 .mjs 확장자 또는 package.json의 "type": "module"이 있는 파일은 ESM으로 처리됩니다.
import할 수 있습니다(Node.js가 이를 처리), 하지만 CommonJS 파일에서 ESM 모듈을 require()할 수는 없습니다. 필요하다면 동적 import() 호출을 사용하세요 — 양쪽 모듈 시스템에서 동작합니다.트리 쉐이킹과 명명 내보내기
트리 쉐이킹은 임포트했지만 사용하지 않는 코드를 제거하는 번들러 프로세스입니다. 명명 내보내기와 정적 임포트 구문이 필요합니다 — 동적 임포트와 CommonJS는 트리 쉐이킹이 훨씬 어렵습니다. 트리 쉐이킹이 잘 되는 모듈 작성법:
// ✅ Tree-shakeable — bundler can see exactly what's exported
export function formatDate(date) { /* ... */ }
export function parseDate(str) { /* ... */ }
export function addDays(date, n) { /* ... */ }
// If you only import formatDate, parseDate and addDays are excluded from the bundle
import { formatDate } from './utils/dates.js';// ❌ Harder to tree-shake — the whole object is one export
const dateUtils = {
formatDate: (date) => { /* ... */ },
parseDate: (str) => { /* ... */ },
addDays: (date, n) => { /* ... */ }
};
export default dateUtils;
// Even if you only use dateUtils.formatDate, the whole object may be bundlednpm에 게시되는 라이브러리에서 트리 쉐이킹 지원은 사람들이 추천하는 라이브러리와 피하는 라이브러리의 차이입니다. MDN 모듈 가이드에서 이를 가능하게 하는 전체 정적 분석 규칙을 다룹니다.
브라우저에서의 네이티브 ES 모듈
브라우저에서 번들러 없이 ES 모듈을 직접 사용할 수 있습니다. script 태그에 type="module"을 추가하면 됩니다:
<!-- index.html -->
<!-- type="module" enables ES Module semantics -->
<!-- deferred by default — runs after HTML is parsed -->
<script type="module" src="./app.js"></script>// app.js — can use import/export directly
import { initRouter } from './router.js';
import { setupAuth } from './auth.js';
import { loadDashboard } from './dashboard.js';
async function main() {
await setupAuth();
initRouter();
await loadDashboard();
}
main();모듈 스크립트는 기본적으로 항상 지연되며 엄격 모드로 실행됩니다. 각 모듈은 여러 다른 모듈에서 임포트되더라도 한 번만 실행됩니다 — 브라우저가 모듈 인스턴스를 캐시합니다. 모듈에 공유 상태가 있을 때 알아두면 좋습니다.
실제 리팩토링 예제
전형적인 utils 파일이 어떻게 통제 불능으로 커지고 모듈로 제대로 분리되는지 보여줍니다:
// Before: one giant utils.js with everything
export function formatPrice(amount) { /* ... */ }
export function validateEmail(email) { /* ... */ }
export function debounce(fn, ms) { /* ... */ }
export function fetchWithRetry(url, opts) { /* ... */ }
export function parseCSV(text) { /* ... */ }
export function buildQueryString(params) { /* ... */ }
// After: split by concern
// utils/
// currency.js → formatPrice, parseCents
// validation.js → validateEmail, validatePhone
// timing.js → debounce, throttle
// http.js → fetchWithRetry, buildQueryString
// csv.js → parseCSV, formatCSV
// index.js → re-exports all of the above (optional barrel)관심사별로 분리하면 각 파일이 단일 책임을 가지고, 테스트 작성과 찾기가 쉬워지며, 번들러가 파일 레벨에서 트리 쉐이킹을 할 수 있습니다. 배럴 파일은 단일 임포트 경로를 선호하는 소비자를 위해 임포트를 깔끔하게 유지합니다.
유용한 도구
모듈형 JavaScript를 빌드할 때 JS 포맷터는 임포트 블록을 깔끔하고 일관성 있게 유지합니다. 최종 번들에 무엇이 있는지 이해하려면 Webpack Bundle Analyzer나 Rollup의 시각화 도구가 어느 모듈이 공간을 차지하는지 보여줍니다. TC39 ECMAScript 모듈 명세에서 엣지 케이스를 이해해야 할 때 정확한 해결 의미론을 확인할 수 있습니다.
마무리
ES 모듈은 현대 JavaScript 아키텍처의 기반입니다. 유틸리티 함수에는 명명 내보내기를 선호하세요 — 명시적이고, 트리 쉐이킹 가능하며, IDE 친화적입니다. 동적 import()를 사용해 무거운 코드를 초기 번들에서 분리하세요. 대규모 코드베이스에서 배럴 파일을 주의하세요. 그리고 새 Node.js 프로젝트에서 아직 CommonJS를 사용하고 있다면, 이제 이전할 때입니다 — 생태계가 완전히 ESM으로 전환했습니다.