JavaScript Modules | ES Modules and CommonJS Explained

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)
Syntaxrequire(), module.exportsimport, export
Load timerequire at runtime (path can vary)Top-level import is static (great for tree shaking)
Typical useLegacy Node and npm packagesBrowser standard; Node with "type": "module"
Top-level thismodule.exportsundefined (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 exportnamed export
CountOne per fileMany
Import nameCan rename freely (import MyThing)Names are fixed (use as for aliases)
Refactors / IDEEasier to drift per fileStable, easier to track
Good forSingle 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 ModulesCommonJS
Syntaximport / exportrequire / module.exports
RuntimesBrowsers + NodeNode (legacy default)
LoadingStatic (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; import analysis feels natural.

Quick chooser

SituationPick
New SPA, fast prototypeStart with Vite
Deep investment in Webpack pluginsKeep Webpack or migrate gradually
Publishing a libraryRollup / 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

  1. ES Modules: export / import, named vs default
  2. CommonJS: module.exports / require() (Node legacy default)
  3. Browser: <script type="module">, await import() for lazy chunks
  4. Patterns: barrel index.js, layered API clients, shared utils

Next steps

  • JavaScript 에러 처리
  • JavaScript 디자인 패턴
  • TypeScript 시작하기

  • Node.js 모듈 시스템 | CommonJS와 ES Modules 완벽 가이드
  • C++20 Modules 완벽 가이드 | 헤더 파일을 넘어서
  • C++20 Modules
  • C++ 기존 프로젝트를 Module로 전환 | 단계별 마이그레이션 [#24-2]
  • JavaScript 클래스 | ES6 Class 문법 완벽 정리