JavaScript Modules | ES Modules and CommonJS Explained
이 글의 핵심
From first principles to production: ESM vs CJS, named vs default exports, interop, code splitting, and how bundlers fit into modern apps.
Introduction
A module splits code into reusable units with explicit boundaries.
Why modules:
- ✅ Reuse: write once, import many times
- ✅ Namespaces: fewer global name collisions
- ✅ Maintainability: colocate related code
- ✅ Dependencies: load only what you need
CommonJS vs ES Modules at a glance
| CommonJS (CJS) | ES Modules (ESM) | |
|---|---|---|
| Syntax | require(), module.exports | import, export |
| Load time | require at runtime (path can vary) | Top-level import is static (great for tree shaking) |
| Typical use | Legacy Node and npm packages | Browser standard; Node with "type": "module" |
Top-level this | module.exports | undefined (module scope is strict) |
- New projects: prefer ESM; fall back to dynamic
import()or interop for older packages. - Node: whether a file is CJS or ESM is determined by
package.json"type"and extensions (.cjs/.mjs).
1. ES Modules
export
// math.js
// Named exports (many allowed)
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export const PI = 3.14159;
// Export list
function multiply(a, b) {
return a * b;
}
function divide(a, b) {
return a / b;
}
export { multiply, divide };
// Rename on export
function power(a, b) {
return a ** b;
}
export { power as pow };
import
// main.js
// Named imports
import { add, subtract, PI } from './math.js';
console.log(add(10, 20)); // 30
console.log(subtract(20, 10)); // 10
console.log(PI); // 3.14159
// Rename on import
import { pow as power } from './math.js';
console.log(power(2, 3)); // 8
// Namespace import
import * as math from './math.js';
console.log(math.add(5, 3)); // 8
console.log(math.PI); // 3.14159
// Side-effect import (initialization only)
import './polyfills.js';
// Re-export from another module
// export { add } from './math.js';
// export { default as User } from './user.js';
Default export
// user.js
// One default export per module
export default class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
greet() {
console.log(`Hello, ${this.name}!`);
}
}
// Or
class User {
// ...
}
export default User;
// Functions work too
export default function greet(name) {
console.log(`Hello, ${name}!`);
}
// main.js
// Default import (local name is yours)
import User from './user.js';
const user = new User("홍길동", "[email protected]");
user.greet(); // Hello, 홍길동!
// Default + named together
import User, { formatDate, validateEmail } from './user.js';
Choosing default vs named
| default export | named export | |
|---|---|---|
| Count | One per file | Many |
| Import name | Can rename freely (import MyThing) | Names are fixed (use as for aliases) |
| Refactors / IDE | Easier to drift per file | Stable, easier to track |
| Good for | Single React component, “one main thing” | Utility collections, constants, types |
Tip: libraries often favor named exports; app code sometimes uses default for a page component. Mixing both in one file is valid.
2. CommonJS (Node.js)
module.exports
// math.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
const PI = 3.14159;
// Option 1: export an object
module.exports = {
add,
subtract,
PI
};
// Option 2: attach properties
module.exports.add = add;
module.exports.subtract = subtract;
module.exports.PI = PI;
// Option 3: exports shorthand
exports.add = add;
exports.subtract = subtract;
require
// main.js
// Whole module
const math = require('./math.js');
console.log(math.add(10, 20)); // 30
console.log(math.PI); // 3.14159
// Destructuring
const { add, subtract } = require('./math.js');
console.log(add(10, 20)); // 30
// Built-ins
const fs = require('fs');
const path = require('path');
const http = require('http');
ESM vs CJS
| ES Modules | CommonJS | |
|---|---|---|
| Syntax | import / export | require / module.exports |
| Runtimes | Browsers + Node | Node (legacy default) |
| Loading | Static (parse time) | Dynamic (runtime) |
| Async loading | ✅ (import()) | ❌ |
| Tree shaking | ✅ | ❌ |
| Extensions | .mjs or "type" in package.json | .js (CJS default) |
Interop: importing CJS from ESM may wrap the namespace under default depending on tooling. Pure-ESM packages consumed from CJS may need import() or Node’s createRequire.
3. Modules in the browser
type="module"
<!DOCTYPE html>
<html>
<head>
<title>ES Modules</title>
</head>
<body>
<h1>모듈 테스트</h1>
<script type="module">
import { add, subtract } from './math.js';
console.log(add(10, 20)); // 30
// Dynamic import
const button = document.querySelector('#loadBtn');
button.addEventListener('click', async () => {
const module = await import('./heavy-module.js');
module.doSomething();
});
</script>
</body>
</html>
Dynamic import()
// Conditional loading
async function loadModule(moduleName) {
if (moduleName === 'math') {
const math = await import('./math.js');
return math;
}
}
const math = await loadModule('math');
console.log(math.add(5, 3));
// Code splitting
button.addEventListener('click', async () => {
const { default: Chart } = await import('./chart.js');
new Chart('#myChart');
});
4. Practical examples
Example 1: Utility modules
// utils/string.js
export function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function truncate(str, maxLength) {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength) + '...';
}
export function slugify(str) {
return str
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '');
}
// utils/array.js
export function chunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
export function unique(arr) {
return [...new Set(arr)];
}
export function shuffle(arr) {
const copy = [...arr];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
// utils/index.js (barrel)
export * from './string.js';
export * from './array.js';
// Or selective re-exports
export { capitalize, truncate } from './string.js';
export { chunk, unique } from './array.js';
// main.js
import { capitalize, chunk } from './utils/index.js';
console.log(capitalize("hello")); // Hello
console.log(chunk([1, 2, 3, 4, 5], 2)); // [[1, 2], [3, 4], [5]]
Example 2: API client
// api/client.js
const BASE_URL = 'https://api.example.com';
export class APIClient {
constructor(apiKey) {
this.apiKey = apiKey;
}
async request(endpoint, options = {}) {
const url = `${BASE_URL}${endpoint}`;
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
...options.headers
};
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
}
get(endpoint) {
return this.request(endpoint, { method: 'GET' });
}
post(endpoint, data) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
put(endpoint, data) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
delete(endpoint) {
return this.request(endpoint, { method: 'DELETE' });
}
}
export default APIClient;
// api/users.js
import APIClient from './client.js';
export class UserAPI {
constructor(apiKey) {
this.client = new APIClient(apiKey);
}
async getUsers() {
return await this.client.get('/users');
}
async getUser(id) {
return await this.client.get(`/users/${id}`);
}
async createUser(userData) {
return await this.client.post('/users', userData);
}
async updateUser(id, userData) {
return await this.client.put(`/users/${id}`, userData);
}
async deleteUser(id) {
return await this.client.delete(`/users/${id}`);
}
}
// main.js
import { UserAPI } from './api/users.js';
const api = new UserAPI('your-api-key');
async function main() {
try {
const users = await api.getUsers();
console.log(users);
const newUser = await api.createUser({
name: "홍길동",
email: "[email protected]"
});
console.log("생성됨:", newUser);
} catch (error) {
console.error("에러:", error);
}
}
main();
Bundlers and builds: Webpack, Vite
Browsers can load ESM natively, but production apps usually use a bundler because:
- Bundle many files and npm packages into one (or few) assets
- Minify, split chunks (often aligned with dynamic
import()) - Transpile TypeScript/JSX, process CSS imports, etc.
Webpack
- Role: walk the dependency graph from entry and emit bundles; extend with loaders and plugins.
- Fit: very configurable; still common for large legacy codebases.
- Dev:
webpack-dev-server, HMR, etc.
Vite
- Role: dev server uses native ESM for speed; production builds often use Rollup under the hood.
- Fit: minimal config, great DX with Vue/React templates;
importanalysis feels natural.
Quick chooser
| Situation | Pick |
|---|---|
| New SPA, fast prototype | Start with Vite |
| Deep investment in Webpack plugins | Keep Webpack or migrate gradually |
| Publishing a library | Rollup / tsup, etc. |
Dynamic import() typically becomes a separate chunk in both Webpack and Vite.
5. ES modules in Node.js
package.json
{
"name": "my-project",
"version": "1.0.0",
"type": "module"
}
Or use the .mjs extension:
// math.mjs
export function add(a, b) {
return a + b;
}
// main.mjs
import { add } from './math.mjs';
console.log(add(10, 20));
6. Common pitfalls
Pitfall 1: circular dependencies
// ❌ Circular
// a.js
import { b } from './b.js';
export const a = 'A';
// b.js
import { a } from './a.js';
export const b = 'B';
// ✅ Fix structure
// common.js
export const a = 'A';
export const b = 'B';
// a.js
import { b } from './common.js';
// b.js
import { a } from './common.js';
Pitfall 2: wrong paths
// ❌ Missing extension (browser)
import { add } from './math'; // Error!
// ✅ Include extension
import { add } from './math.js';
// Node may resolve extensionless paths (CommonJS)
const math = require('./math'); // OK
Pitfall 3: multiple defaults
// ❌ Only one default
export default function add() {}
export default function subtract() {} // SyntaxError
// ✅ Use named exports
export function add() {}
export function subtract() {}
Summary
Key takeaways
- ES Modules:
export/import, named vs default - CommonJS:
module.exports/require()(Node legacy default) - Browser:
<script type="module">,await import()for lazy chunks - Patterns: barrel
index.js, layered API clients, shared utils
Next steps
- JavaScript 에러 처리
- JavaScript 디자인 패턴
- TypeScript 시작하기
Related posts
- Node.js 모듈 시스템 | CommonJS와 ES Modules 완벽 가이드
- C++20 Modules 완벽 가이드 | 헤더 파일을 넘어서
- C++20 Modules
- C++ 기존 프로젝트를 Module로 전환 | 단계별 마이그레이션 [#24-2]
- JavaScript 클래스 | ES6 Class 문법 완벽 정리