JavaScript 모듈: ES Modules와 CommonJS의 차이
이 글의 핵심
Node.js 프로젝트에서 require와 import가 섞이면 설정에 따라 한쪽 문법이 동작하지 않아 혼란을 겪기 쉽습니다. 두 모듈 시스템이 로딩 시점과 분석 방식에서 어떻게 다른지, default와 named export 중 무엇을 기본으로 둘지, 순환 참조와 잘못된 경로 같은 흔한 실수를 짚어 모듈 구조를 안정적으로 잡게 합니다.
들어가며
모듈이란?
모듈(Module)은 파일(또는 단위) 단위로 책임을 나누어 다시 가져다 쓰기 쉽게 만든 구조입니다.
모듈을 쓰면 얻는 점: 모듈마다 자기 스코프를 가지므로 전역 변수 이름이 서로 충돌하지 않고, 필요한 것만 export해 여러 파일에서 가져다 쓸 수 있습니다. 관련 코드를 한 파일에 모아 두면 유지보수가 쉬워지고, import 문이 의존 관계를 코드에 드러내 주므로 번들러가 필요한 모듈만 묶어 로드할 수 있습니다.
CommonJS와 ES Modules 한눈에
| 항목 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 문법 | require(), module.exports | import, export |
| 로딩 시점 | 런타임에 경로를 바꿔 require 가능 | 정적 import는 파일 상단에서 분석(트리 쉐이킹에 유리) |
| 대표 환경 | Node.js 레거시·npm 패키지 | 브라우저 표준, Node ("type": "module") |
this (최상위) | module.exports | undefined(엄격 모듈) |
- 새 프로젝트: 가능하면 ESM을 기본으로 하며, 구형 패키지는
import x from 'pkg'가 안 될 때만 동적import()나 interop을 씁니다. - Node에서 혼용: 한 파일이 CJS냐 ESM이냐는
package.json의"type"과 확장자(.cjs/.mjs)로 갈립니다.
표에서 가장 중요한 차이는 “로딩 시점”입니다. CommonJS의 require()는 평범한 함수 호출이라 if 안에서도, 문자열을 조합한 경로로도 부를 수 있고, 호출되는 순간 파일을 동기적으로 읽어 실행합니다. 반면 ES 모듈의 import 선언은 코드가 실행되기 전에 파싱 단계에서 모두 수집되어 의존성 그래프가 먼저 만들어집니다. 그래서 import는 파일 최상위에만 쓸 수 있고 경로도 문자열 리터럴이어야 합니다. 이 제약 덕분에 번들러가 코드를 실행하지 않고도 “어떤 export가 어디서 쓰이는가”를 알아낼 수 있고, 쓰지 않는 export를 버리는 트리 쉐이킹이 가능해집니다.
또 하나의 차이는 값을 공유하는 방식입니다. CommonJS에서 require로 받은 값은 내보낸 시점의 복사본(객체라면 참조의 복사)이지만, ES 모듈의 import는 내보낸 변수에 대한 라이브 바인딩입니다. 모듈 안에서 export let count = 0;을 나중에 증가시키면 import한 쪽에서도 바뀐 값이 보이고, 반대로 import한 쪽에서 count = 1처럼 대입하려 하면 TypeError: Assignment to constant variable.이 납니다. import된 이름은 읽기 전용이기 때문입니다.
ES Modules의 export와 import
export: 내보내기
// math.js
// Named export (여러 개 가능)
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export const PI = 3.14159;
// 한 번에 export
function multiply(a, b) {
return a * b;
}
function divide(a, b) {
return a / b;
}
export { multiply, divide };
// 이름 변경하여 export
function power(a, b) {
return a ** b;
}
export { power as pow };
import: 가져오기
// main.js
// Named import
import { add, subtract, PI } from './math.js';
console.log(add(10, 20)); // 30
console.log(subtract(20, 10)); // 10
console.log(PI); // 3.14159
// 이름 변경하여 import
import { pow as power } from './math.js';
console.log(power(2, 3)); // 8
// 모두 import
import * as math from './math.js';
console.log(math.add(5, 3)); // 8
console.log(math.PI); // 3.14159
// 부작용만 실행 (내보내는 값 없이 초기화 코드만)
import './polyfills.js';
// 다른 모듈에서 다시 내보내기(re-export)
// export { add } from './math.js';
// export { default as User } from './user.js';
default export
// user.js
// Default export (모듈당 1개만)
export default class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
greet() {
console.log(`Hello, ${this.name}!`);
}
}
// 또는
class User {
// ...
}
export default User;
// 함수도 가능
export default function greet(name) {
console.log(`Hello, ${name}!`);
}
// main.js
// Default import (이름 자유롭게)
import User from './user.js';
const user = new User("홍길동", "[email protected]");
user.greet(); // Hello, 홍길동!
// Named + Default 함께
import User, { formatDate, validateEmail } from './user.js';
default export vs named export 선택 가이드
| default export | named export | |
|---|---|---|
| 개수 | 파일당 하나 | 여러 개 |
| 가져올 때 이름 | import MyThing처럼 자유롭게 바꿔 쓰기 쉬움 | import { a, b }처럼 이름이 고정(별칭 as 가능) |
| 자동완성·리팩터 | 이름이 파일마다 달라질 수 있어 추적이 약함 | IDE가 정확히 따라감 |
| 적합한 경우 | React 단일 컴포넌트, “이 파일의 대표 하나” | 유틸 함수 모음, 여러 상수·타입 |
실무 팁: 라이브러리 API는 named가 리팩터링에 유리하며, 앱 코드에서 페이지 단위 컴포넌트 하나만 내보낼 때 default를 쓰는 패턴도 흔합니다. 한 파일에서 default와 named를 섞는 것도 가능합니다.
CommonJS의 module.exports와 require
module.exports
// math.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
const PI = 3.14159;
// 방법 1: 객체로 내보내기
module.exports = {
add,
subtract,
PI
};
// 방법 2: 개별 할당
module.exports.add = add;
module.exports.subtract = subtract;
module.exports.PI = PI;
// 방법 3: exports 축약
exports.add = add;
exports.subtract = subtract;
세 방법을 한 파일에 섞으면 결과가 헷갈립니다. exports는 처음에 module.exports와 같은 객체를 가리키는 변수일 뿐이라, 방법 3처럼 속성을 붙이는 것은 괜찮지만 exports = { add }처럼 재할당하면 지역 변수만 바뀌고 실제로 내보내지는 객체는 그대로 빈 객체로 남습니다. 반대로 방법 1처럼 module.exports에 새 객체를 대입한 뒤에 exports.foo = ...를 하면, exports는 여전히 예전 객체를 가리키므로 foo는 내보내지지 않습니다. 처음 CommonJS를 쓸 때 require 결과가 {}로 나와 당황하는 대표적인 원인이므로, 한 파일에서는 한 가지 방식만 쓰는 것이 좋습니다.
require
// main.js
// 전체 가져오기
const math = require('./math.js');
console.log(math.add(10, 20)); // 30
console.log(math.PI); // 3.14159
// 구조 분해
const { add, subtract } = require('./math.js');
console.log(add(10, 20)); // 30
// 내장 모듈
const fs = require('fs');
const path = require('path');
const http = require('http');
ES Modules vs CommonJS
| 특징 | ES Modules | CommonJS |
|---|---|---|
| 문법 | import/export | require/module.exports |
| 환경 | 브라우저 + Node.js | Node.js |
| 로딩 | 정적 (컴파일 타임) | 동적 (런타임) |
| 비동기 | ✅ | ❌ |
| Tree Shaking | ✅ | ❌ |
| 파일 확장자 | .mjs 또는 package.json | .js |
Interop 요약: ESM에서 CJS 모듈을 가져올 때는 환경에 따라 default로 한 번 감싸진 값이 올 수 있습니다(import pkg from 'cjs-pkg'). 반대로 CJS에서 ESM만 제공하는 패키지는 import()로 비동기 로드하거나, Node 문서의 createRequire를 참고합니다.
Node.js에서 ESM이 CJS 패키지를 import할 때는 module.exports 전체가 default로 들어오고, named import(import { readFile } from 'cjs-pkg')는 Node가 소스를 정적으로 분석해 찾을 수 있는 속성만 허용됩니다. 분석에 실패하면 SyntaxError: Named export 'x' not found. The requested module 'cjs-pkg' is a CommonJS module이 나는데, 에러 메시지가 권하는 대로 import pkg from 'cjs-pkg'; const { x } = pkg;로 바꾸면 해결됩니다. 반대 방향은 최근에 크게 바뀌었습니다. 예전에는 CJS에서 ESM 전용 패키지를 require하면 ERR_REQUIRE_ESM으로 실패했지만, Node.js 22.12와 20.19부터는 최상위 await를 쓰지 않는 ESM이라면 require()로 동기 로드할 수 있습니다. 여러 Node 버전을 지원해야 하는 라이브러리라면 여전히 이 차이를 고려해야 합니다.
브라우저의 type=“module”과 동적 import
type=“module”
<!DOCTYPE html>
<html>
<head>
<title>ES Modules</title>
</head>
<body>
<h1>모듈 테스트</h1>
<script type="module">
// math.js에서 import
import { add, subtract } from './math.js';
console.log(add(10, 20)); // 30
// 동적 import
const button = document.querySelector('#loadBtn');
button.addEventListener('click', async () => {
const module = await import('./heavy-module.js');
module.doSomething();
});
</script>
</body>
</html>
동적 import
// 조건부 로딩
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));
// 코드 스플리팅
button.addEventListener('click', async () => {
const { default: Chart } = await import('./chart.js');
new Chart('#myChart');
});
import()는 모듈의 네임스페이스 객체로 이행되는 Promise를 돌려주므로, default export는 module.default로, named export는 module.add처럼 꺼냅니다. const Chart = await import('./chart.js')처럼 쓰고 new Chart(...)를 호출하면 TypeError: Chart is not a constructor가 나는 것이 가장 흔한 실수입니다. 위의 const math = await loadModule('math')처럼 함수 밖에서 await를 쓰는 최상위 await는 ES 모듈에서만 동작하고, 일반 <script>나 CommonJS 파일에서는 SyntaxError가 납니다. 최상위 await는 그 모듈을 import하는 모든 모듈의 평가를 기다리게 만들기 때문에, 공용 모듈에서 느린 네트워크 요청을 최상위 await로 기다리면 앱 전체의 시작이 늦어진다는 점도 기억해 둘 만합니다.
브라우저의 모듈 스크립트는 일반 스크립트와 동작 방식이 조금 다릅니다. type="module" 스크립트는 자동으로 defer처럼 HTML 파싱이 끝난 뒤 실행되고, 항상 엄격 모드이며, 최상위 변수가 전역(window)에 붙지 않습니다. 또 다른 출처의 모듈을 불러올 때는 CORS 헤더가 필요합니다. 위 HTML 예제에서 #loadBtn 버튼이 없으면 querySelector가 null을 돌려 addEventListener에서 에러가 나므로, 실제로 실행해 보려면 <button id="loadBtn">을 추가해야 합니다.
유틸리티 모듈과 API 클라이언트 모듈 예제
예제 1: 유틸리티 모듈
// 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 (배럴 파일)
export * from './string.js';
export * from './array.js';
// 또는 선택적으로
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]]
배럴 파일의 export *에는 알아 둘 규칙이 두 가지 있습니다. 첫째, export *는 default export를 다시 내보내지 않습니다. string.js에 export default가 있다면 배럴에서 export { default as stringUtils } from './string.js'처럼 이름을 붙여 따로 내보내야 합니다. 둘째, 두 모듈이 같은 이름을 export *로 내보내면 그 이름은 조용히 제외되고, 가져오는 쪽에서 SyntaxError: The requested module './utils/index.js' contains conflicting star exports for name 'x' 같은 에러가 나거나 undefined가 됩니다. 규모가 작을 때는 편리하지만, 파일이 수백 개가 되면 배럴 하나를 import하는 것만으로 연결된 모든 모듈이 평가되어 테스트와 개발 서버가 느려지므로, 배럴은 외부에 공개하는 패키지 진입점에만 두는 팀도 많습니다.
예제 2: API 클라이언트
// 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
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();
번들러와 빌드: Webpack, Vite
브라우저는 ESM을 네이티브로 로드할 수 있지만, 실제 프로덕션에서는 다음 이유로 번들러를 씁니다.
- 여러 파일·npm 패키지를 한(또는 몇) 개의 JS 파일으로 묶기
- 난독화·압축, 코드 스플리팅(동적
import단위로 청크 분리) - TypeScript·JSX, CSS import 등 전처리
Webpack
- 역할: 진입점(
entry)부터 의존성 그래프를 따라 모듈을 묶는 도구입니다. 로더(CSS, TS 등)와 플러그인으로 확장합니다. - 특징: 설정이 세밀하게 가능하며, 대규모 레거시 프로젝트에서 여전히 많이 씁니다.
- 개발 서버:
webpack-dev-server로 HMR(핫 리로드) 등을 구성합니다.
Vite
- 역할: 개발 시 네이티브 ESM으로 빠르게 서빙하며, 프로덕션 빌드는 Rollup 기반으로 묶는 방식이 일반적입니다.
- 특징: 초기 설정이 단순하며, Vue/React 템플릿과 궁합이 좋습니다.
import분석이 자연스러워 DX(개발 경험)가 가볍습니다.
선택 가이드 (요약)
| 상황 | 추천 |
|---|---|
| 새 SPA, 빠른 프로토타입 | Vite부터 검토 |
| 기존 Webpack 설정·플러그인에 깊이 의존 | Webpack 유지 또는 점진적 마이그레이션 |
| 라이브러리만 배포 | Rollup/tsup 등도 선택지 |
동적 import()는 Webpack/Vite 모두에서 별도 청크로 떼어 내는 데 자주 사용됩니다.
Node.js에서 ESM 켜기: package.json 설정
package.json 설정
{
"name": "my-project",
"version": "1.0.0",
"type": "module"
}
또는 파일 확장자를 .mjs로 사용:
// math.mjs
export function add(a, b) {
return a + b;
}
// main.mjs
import { add } from './math.mjs';
console.log(add(10, 20));
"type": "module"을 넣는 순간 그 패키지의 모든 .js 파일이 ESM으로 해석되므로, 기존 CommonJS 파일은 즉시 깨집니다. 설정 파일(.eslintrc.js, jest.config.js 등)에서 require is not defined in ES module scope, you can use import instead 에러가 나면 이 때문이며, 해당 파일의 확장자를 .cjs로 바꾸면 그 파일만 CommonJS로 남길 수 있습니다. 또 Node.js의 ESM은 CommonJS와 달리 경로 확장자를 추측하지 않고 디렉터리의 index.js도 자동으로 찾지 않습니다. import './utils'는 ERR_MODULE_NOT_FOUND로 실패하고 './utils/index.js'처럼 정확히 적어야 합니다. TypeScript로 작성할 때 .ts 파일 안에서 import './math.js'처럼 .js 확장자를 쓰라는 안내가 나오는 것도 이 규칙 때문입니다.
순환 참조, 경로 실수, 중복 default export
실수 1: 순환 참조
순환 참조 자체는 ES 모듈에서 허용됩니다. 문제는 평가 도중에 아직 초기화되지 않은 값에 접근할 때 생깁니다. main.js가 a.js를 import하면, a.js는 먼저 b.js를 평가하고, b.js는 다시 a.js를 import하지만 a.js는 이미 평가 중이므로 건너뜁니다. 이때 b.js의 최상위 코드가 a를 사용하면(예: console.log(a), export const b = a + 'B') ReferenceError: Cannot access 'a' before initialization이 납니다. 아래 예제처럼 서로 import만 하고 함수 안에서 나중에 쓰는 경우라면 에러는 나지 않지만, 누군가 최상위에서 값을 쓰도록 바꾸는 순간 터지는 시한폭탄이 됩니다. CommonJS에서는 같은 상황에서 에러 대신 아직 채워지지 않은 빈 module.exports 객체를 받아 undefined가 조용히 흘러가므로 원인을 찾기가 더 어렵습니다.
// ⚠️ 순환 참조 (최상위에서 서로의 값을 쓰면 에러)
// a.js
import { b } from './b.js';
export const a = 'A';
// b.js
import { a } from './a.js';
export const b = 'B';
// ✅ 해결: 구조 개선
// common.js
export const a = 'A';
export const b = 'B';
// a.js
import { b } from './common.js';
// b.js
import { a } from './common.js';
실수 2: 잘못된 경로
// ❌ 확장자 누락 (브라우저)
import { add } from './math'; // 에러!
// ✅ 확장자 포함
import { add } from './math.js';
// Node.js는 확장자 생략 가능 (CommonJS)
const math = require('./math'); // OK
실수 3: export default 여러 개
// ❌ default는 1개만
export default function add() {}
export default function subtract() {} // SyntaxError
// ✅ named export 사용
export function add() {}
export function subtract() {}
모듈 시스템 요약
- ES Modules:
export: 내보내기import: 가져오기- Named export: 여러 개
- Default export: 1개
- CommonJS:
module.exports: 내보내기require(): 가져오기- Node.js 기본
- 브라우저:
<script type="module">- 동적 import:
await import()
- 패턴:
- 배럴 파일:
index.js - API 클라이언트
- 유틸리티 모듈
- 배럴 파일:
다음 단계
관련 글
- Node.js 모듈 시스템
- C++20 Modules
- C++ 기존 프로젝트를 Module로 전환 | 단계별 마이그레이션 [#24-2]
- JavaScript 클래스
- TypeScript 시작하기 | 설치, 설정, 기본 문법
- JavaScript Modules | ES Modules and CommonJS Explained
자주 묻는 질문 (FAQ)
Q. 브라우저에서 import { add } from './math'가 실패하는 이유는 무엇인가요?
A. 브라우저의 ES 모듈은 번들러나 Node.js처럼 경로를 추측해 주지 않아서, './math.js'처럼 확장자까지 포함한 정확한 경로가 필요합니다. 또 <script type="module">로 불러야 import 문을 쓸 수 있고, file://로 HTML을 직접 열면 CORS 제한으로 모듈 로딩이 막히므로 로컬 서버로 띄워야 합니다. Vite나 webpack을 쓰는 프로젝트에서 확장자를 생략해도 되는 것은 번들러가 경로를 해석해 주기 때문입니다.