ESM vs CJS 모듈 시스템
Node.js와 번들러 환경에서 CJS/ESM 공존이 만드는 실전 이슈와 해결 전략
읽는 데 45분
- #javascript
- #esm
- #commonjs
- #node.js
- #bundler
- #modules
이 문서의 목차
적용 환경: Node.js 18+, 모던 번들러 (Webpack 5, Vite, esbuild)
프로젝트에 새 패키지를 설치하고 import했는데 "Cannot use import statement outside a module" 에러가 뜹니다. 검색해서 package.json에 "type": "module"을 추가했더니 이번에는 기존 코드에서 "require is not defined in ES module scope" 에러가 터집니다. 하나를 고치면 다른 곳이 깨지는 상황이 반복됩니다.
이 문제의 근원에는 JavaScript의 두 가지 모듈 시스템이 있습니다. Node.js가 만든 CommonJS(CJS)와 ECMAScript 표준으로 정의된 ES Modules(ESM)가 그것입니다. 이 둘은 단순히 require vs import 문법만 다른 것이 아니라, 모듈을 로딩하는 메커니즘 자체가 근본적으로 다릅니다. 로딩 방식이 다르다는 것은 번들러 동작, tree-shaking, 패키지 배포 전략까지 연쇄적으로 영향을 미친다는 뜻이며, 이를 이해하지 못하면 에러 메시지 앞에서 시행착오를 반복할 수밖에 없습니다.
이 문서에서는 두 모듈 시스템의 설계 철학부터 실전에서 마주치는 상호 운용성 문제, 그리고 번들러별 처리 방식과 마이그레이션 전략까지 체계적으로 다룹니다.
JavaScript는 원래 모듈 시스템이 없는 언어였습니다. 브라우저에서 <script> 태그로 파일을 불러오면 모든 코드가 전역 스코프를 공유했고, 파일 간 의존성을 관리할 방법이 없었습니다. 이 한계를 극복하기 위해 서로 다른 시기에, 서로 다른 목적으로 두 가지 모듈 시스템이 등장했습니다.
- 2009CommonJS 등장Node.js와 함께 서버 사이드 모듈 시스템 도입
- 2015ES Modules 표준화ECMAScript 2015(ES6)에서 import/export 문법 정의
- 2019Node.js ESM 지원Node.js 13.2에서 플래그 없이 사용 가능, 14에서 안정화
- 2024ESM 우선 생태계주요 라이브러리들이 ESM-only로 전환 가속
CommonJS는 2009년 초 서버 사이드 JavaScript의 모듈 시스템을 표준화하기 위해 시작된 프로젝트입니다. 원래 ServerJS라는 이름으로 출발했으며, 같은 해 Node.js가 이 명세를 채택하면서 사실상 표준으로 자리잡았습니다. 서버 환경에서 파일 시스템 기반으로 모듈을 동기적으로 로딩하도록 설계되었으며, require()와 module.exports가 핵심 API입니다. 서버에서는 파일을 디스크에서 즉시 읽을 수 있으므로 동기 로딩이 자연스러운 선택이었습니다.
ES Modules는 2015년 ECMAScript 표준의 일부로 정의되었습니다. 브라우저와 서버 모두에서 동작하는 공식 표준을 목표로 했기 때문에, 네트워크 지연이 있는 브라우저 환경을 고려하여 비동기 로딩을 기본으로 설계되었습니다. import와 export 키워드가 언어 문법 수준에서 지원되며, 정적 분석이 가능하다는 점이 CJS와의 가장 큰 차이입니다.
표면적인 문법 차이부터 확인한 뒤, 그 이면에 있는 설계 차이를 살펴보겠습니다.
// 모듈 가져오기
const express = require('express');
const { readFile } = require('fs');
// 모듈 내보내기
module.exports = { app, router };
// 또는 개별 내보내기
exports.helper = function() { /* ... */ };// 모듈 가져오기
import express from 'express';
import { readFile } from 'fs';
// 모듈 내보내기
export { app, router };
// 또는 개별 내보내기
export function helper() { /* ... */ }문법 차이만 보면 단순한 키워드 교체처럼 보이지만, 내부 동작에는 세 가지 근본적인 차이가 있습니다.
CJS의 require()는 호출 시점에 대상 파일을 즉시 읽고 실행합니다. 함수 호출이므로 코드 어디에서든 조건부로 사용할 수 있습니다. 반면 ESM은 파싱(parsing), 인스턴스화(instantiation), 평가(evaluation)라는 세 단계를 거쳐 비동기적으로 모듈을 로딩합니다. 이 비동기 특성 덕분에 ESM에서는 require()로는 불가능한 top-level await를 사용할 수 있습니다.
// CJS: 조건부 require 가능 (동기)
if (process.env.NODE_ENV === 'development') {
const devTools = require('./dev-tools');
devTools.setup();
}
// ESM: top-level await 가능 (비동기)
const config = await fetch('/api/config').then(r => r.json());
export default config;ESM의 import 선언은 모듈 최상위에만 위치할 수 있고, import 경로에 변수를 사용할 수 없습니다. 이러한 정적 특성 덕분에 번들러가 코드를 실행하지 않고도 의존성 그래프를 완전히 파악할 수 있으며, 이것이 바로 tree-shaking의 기반이 됩니다. 사용하지 않는 export를 빌드 시점에 제거하여 최종 번들 크기를 줄이는 것은 ESM의 정적 구조가 있어야만 가능한 최적화입니다.
// ❌ ESM에서 불가능: 동적 경로
import something from `./${dynamicPath}`; // SyntaxError
// ✅ ESM에서 동적 import가 필요하면 import() 사용
const module = await import(`./${dynamicPath}`);CJS는 require()가 일반 함수이므로 이러한 제약이 없습니다. 조건문 안에서, 반복문 안에서, 변수로 구성한 경로로 자유롭게 모듈을 불러올 수 있습니다. 그러나 이 유연함이 정적 분석을 불가능하게 만들고, tree-shaking 효과를 제한하는 원인이 됩니다.
이 차이는 실제 디버깅 상황에서 혼란을 일으킬 수 있습니다. CJS는 require() 시점에 모듈의 export 값을 복사합니다. 원본 모듈에서 값이 변경되어도 이미 복사된 값에는 영향이 없습니다. 반면 ESM은 export된 변수에 대한 라이브 참조(live binding)를 유지하므로, 원본이 변경되면 import한 쪽에서도 변경된 값을 볼 수 있습니다.
// counter.js (CJS)
let count = 0;
function increment() { count++; }
module.exports = { count, increment };
// main.js
const { count, increment } = require('./counter');
console.log(count); // 0
increment();
console.log(count); // 0 (원본이 바뀌어도 복사된 값은 그대로)// counter.mjs (ESM)
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 (원본 변경이 즉시 반영)Node.js는 .js 파일을 만나면 이것이 CJS인지 ESM인지 판단해야 합니다. 이 판단 기준이 바로 package.json의 "type" 필드와 파일 확장자의 조합입니다.
package.json "type" | .js 해석 | .mjs | .cjs |
|---|---|---|---|
"module" | ESM | ESM | CJS |
"commonjs" (기본값) | CJS | ESM | CJS |
.mjs와 .cjs 확장자는 package.json의 "type" 필드와 무관하게 항상 각각 ESM과 CJS로 해석됩니다. 따라서 프로젝트 전체가 ESM 기반이더라도, 특정 설정 파일 하나만 CJS로 작성해야 할 때 .cjs 확장자를 사용할 수 있습니다.
my-esm-project
- package.json
src/
- index.js
- utils.js
- eslint.config.cjs
- jest.config.cjs
위 구조에서 package.json에 "type": "module"이 설정되어 있으므로 src/ 내부의 .js 파일들은 ESM으로 해석됩니다. 한편 ESLint나 Jest 설정 파일은 CJS를 요구하는 경우가 있어 .cjs 확장자로 분리한 형태입니다.
type 필드를 생략하면 CJS가 기본값
package.json에 "type" 필드가 없으면 모든 .js 파일이 CJS로 해석됩니다. ESM을 사용하려면 반드시 "type": "module"을 명시하거나, .mjs 확장자를 사용해야 합니다. 기존 프로젝트에 이 필드를 추가할 때는 모든 .js 파일의 해석 방식이 바뀌므로 주의가 필요합니다.
두 모듈 시스템이 공존하는 현실에서 가장 빈번하게 마주치는 문제가 상호 운용성입니다. ESM에서 CJS를 import하는 것과 CJS에서 ESM을 import하는 것은 난이도가 완전히 다릅니다.
ESM에서 CJS 모듈을 가져오는 것은 대부분 문제없이 동작합니다. Node.js가 CJS 모듈의 module.exports 객체를 ESM의 default export로 자동 래핑하기 때문입니다.
// lodash는 CJS로 배포된 패키지
import lodash from 'lodash'; // ✅ default import로 전체 가져오기
import { debounce } from 'lodash'; // ✅ Node.js가 named export를 정적 분석으로 추출다만 모든 CJS 패키지에서 named import가 보장되지는 않습니다. Node.js는 CJS 모듈의 named export를 정적 분석으로 추출하려 시도하지만, module.exports가 동적으로 구성되는 경우에는 이 분석이 실패할 수 있습니다. 그런 경우에는 default import로 전체 객체를 가져온 뒤 구조 분해하는 방식이 안전합니다.
// named import가 동작하지 않는 경우
import pkg from 'some-cjs-package';
const { specificFunction } = pkg;반대 방향은 오랫동안 까다로운 문제였습니다. CJS의 require()는 동기 함수인데 ESM은 비동기로 로딩되므로, 원래는 require()로 ESM을 불러오는 것이 불가능했습니다. 이 경우 비동기 import()를 사용해야 했습니다.
// CJS 파일에서 ESM을 불러오는 전통적인 방법
async function loadESModule() {
const { myFunction } = await import('./es-module.mjs');
myFunction();
}
loadESModule();그러나 Node.js 22부터 이 제약이 크게 완화되었습니다. top-level await을 사용하지 않는 동기적 ESM 모듈이라면 require()로 직접 불러올 수 있게 된 것입니다. 이 기능은 Node.js 22.12.0에서 플래그 없이 기본 활성화되었고, LTS 라인인 20.19.0에도 백포트되어 현재는 stable 상태입니다.
// Node.js 22+ : top-level await이 없는 ESM 모듈이라면 require() 가능
const { myFunction } = require('./es-module.mjs'); // ✅ 동작top-level await이 있는 모듈은 여전히 require() 불가
require()는 반환값을 동기적으로 제공해야 하므로, top-level await을 포함한 ESM 모듈은 require()로 불러올 수 없습니다. 이 경우 ERR_REQUIRE_ASYNC_MODULE 에러가 발생하며, 비동기 import()를 사용해야 합니다. 다만 실제 npm 생태계에서 top-level await을 사용하는 패키지는 극소수(1% 미만)이므로, 대부분의 ESM 패키지는 require()로 불러올 수 있습니다.
이러한 변화 덕분에, ESM-only로 배포된 패키지를 CJS 프로젝트에서 사용하는 장벽이 크게 낮아졌습니다. Node.js 22 이상을 사용한다면 대부분의 경우 기존 동기 코드 흐름을 유지하면서 ESM 패키지를 require()로 직접 불러올 수 있습니다.
CJS 환경에서 당연하게 사용하던 전역 변수 다섯 가지—__dirname, __filename, require, module, exports—는 ESM에서 모두 존재하지 않습니다. 이들은 CJS의 모듈 래퍼가 주입하는 변수이기 때문에, ESM의 다른 모듈 구조에서는 제공되지 않습니다.
특히 __dirname과 __filename은 파일 경로를 다룰 때 빈번하게 사용되므로, ESM으로 마이그레이션할 때 가장 먼저 부딪히는 문제 중 하나입니다.
const path = require('path');
// __dirname, __filename이 전역에서 바로 사용 가능
const configPath = path.join(__dirname, 'config.json');
console.log(__filename); // /app/src/index.jsimport { fileURLToPath } from 'node:url';
import path from 'node:path';
// import.meta.url로부터 파일 경로를 추출
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const configPath = path.join(__dirname, 'config.json');import path from 'node:path';
// Node.js 20.11.0부터 import.meta에 직접 제공
const configPath = path.join(import.meta.dirname, 'config.json');
console.log(import.meta.filename); // /app/src/index.jsNode.js 20.11.0에서 추가된 import.meta.dirname과 import.meta.filename은 기존의 번거로운 fileURLToPath 변환 과정을 완전히 대체합니다. Node.js 20 이상을 사용하는 프로젝트라면 이 방식을 채택하는 것이 가장 깔끔합니다.
프론트엔드 개발에서는 번들러가 모듈 시스템의 차이를 추상화해주기 때문에, Node.js에서 직접 겪는 것만큼 문제가 두드러지지는 않습니다. 그러나 번들러마다 CJS와 ESM을 처리하는 전략이 다르므로, 특정 패키지에서 에러가 발생했을 때 원인을 파악하려면 이 차이를 알아야 합니다.
| 번들러 | CJS 처리 | ESM 처리 | Tree-shaking | 특징 |
|---|---|---|---|---|
| Webpack 5 | 네이티브 지원 | 네이티브 지원 | ESM에서만 완전 지원 | 레거시 호환성이 가장 뛰어남 |
| Vite | Rolldown으로 사전 변환 | 네이티브 활용 | ESM 기반 최적화 | 개발 서버에서 native ESM 활용 |
| esbuild | 네이티브 지원 | 네이티브 지원 | ESM에서 지원 | 빌드 속도가 압도적 |
Vite는 개발 서버에서 브라우저의 native ESM을 직접 활용하는 전략을 취합니다. 이 때문에 CJS로만 배포된 패키지는 의존성 사전 번들링(pre-bundling) 단계에서 Rolldown을 통해 ESM으로 변환됩니다. 대부분의 경우 이 변환이 자동으로 처리되지만, 일부 패키지에서 문제가 발생하면 optimizeDeps 설정으로 해결해야 할 수 있습니다.
Vite에서 CJS 패키지 문제 해결
Vite 개발 서버에서 특정 CJS 패키지가 정상 동작하지 않으면, vite.config.ts의 optimizeDeps.include에 해당 패키지를 명시적으로 추가하여 사전 번들링을 강제할 수 있습니다.
// vite.config.ts
export default defineConfig({
optimizeDeps: {
include: ['problematic-cjs-package']
}
});Webpack 5는 CJS와 ESM 모두를 네이티브로 지원하므로 호환성 문제가 거의 없습니다. 다만 tree-shaking은 ESM으로 작성된 코드에서만 완전하게 동작하며, CJS 모듈에 대해서는 제한적입니다. 라이브러리 선택 시 ESM 빌드를 제공하는 패키지를 우선하는 것이 번들 크기 최적화에 유리합니다.
라이브러리를 배포할 때는 CJS 환경과 ESM 환경 모두에서 사용할 수 있도록 dual package 형태로 제공하는 것이 일반적입니다. package.json의 "exports" 필드를 사용하면 환경에 따라 다른 진입점을 지정할 수 있습니다.
{
"name": "my-library",
"type": "module",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}"exports" 필드의 "import" 조건은 ESM 환경에서, "require" 조건은 CJS 환경에서 각각 해당 파일을 진입점으로 사용합니다. "main"과 "module" 필드는 "exports"를 지원하지 않는 구버전 번들러를 위한 폴백입니다.
1단계: 소스 코드를 ESM으로 작성
소스 코드는 ESM 문법(
import/export)으로 작성합니다. CJS 빌드는 빌드 도구가 자동으로 생성합니다.2단계: 빌드 도구로 CJS/ESM 양쪽 출력
tsup, unbuild, rollup 같은 도구를 사용하여
.mjs(ESM)와.cjs(CJS) 파일을 각각 생성합니다.bash # tsup을 사용하는 경우 npx tsup src/index.ts --format cjs,esm --dts3단계: package.json에 exports 필드 설정
조건부 export를 통해 환경에 맞는 파일이 로딩되도록 설정합니다.
Dual Package Hazard
같은 패키지가 CJS와 ESM으로 동시에 로드되면, 모듈 인스턴스가 두 개 생성됩니다. 패키지 내부에 싱글톤 패턴이나 전역 상태가 있다면 이 상태가 공유되지 않아 예기치 않은 버그가 발생할 수 있습니다. 이를 방지하려면 패키지를 가능한 stateless하게 설계하거나, ESM 진입점과 CJS 진입점이 동일한 내부 상태를 참조하도록 구성해야 합니다.
새 프로젝트는 ESM으로 시작하는 것이 자연스럽지만, 기존 CJS 프로젝트를 마이그레이션할 때는 단계적 접근이 필요합니다.
| 상황 | 권장 방향 |
|---|---|
| 새 프로젝트 시작 | ESM |
| 라이브러리/패키지 개발 | Dual package (conditional exports) |
| 레거시 Node.js 애플리케이션 | 기능 추가 시 점진적 전환 |
| 브라우저 타겟 프로젝트 | ESM (번들러가 처리) |
전환이 필요하다고 판단되면 아래 단계를 따릅니다.
package.json에 type 필드 추가
"type": "module"을 추가합니다. 이 시점부터 프로젝트 내 모든.js파일이 ESM으로 해석됩니다. CJS로 유지해야 하는 설정 파일이 있다면.cjs확장자로 변경합니다.json { "type": "module" }require/module.exports를 import/export로 변환
모든
require()호출을import선언으로,module.exports를export로 변환합니다. 도구를 활용하면 대규모 변환도 효율적으로 처리할 수 있습니다.bash # cjs-to-esm 변환 도구 사용 예 npx cjstoesm "src/**/*.js"__dirname, __filename 대체
CJS 전용 전역 변수를 ESM 호환 코드로 교체합니다. Node.js 20.11.0 이상이라면
import.meta.dirname을 사용하고, 그보다 낮은 버전을 지원해야 한다면fileURLToPath(import.meta.url)패턴을 적용합니다.테스트 환경 대응
Jest는 ESM 지원이 아직 실험 단계이므로, ESM 전환 시 Vitest로의 마이그레이션을 함께 검토하는 것이 현실적입니다. Vitest는 Vite 기반으로 동작하여 ESM을 네이티브로 지원합니다.
bash npm install -D vitest
모든 것을 한 번에 전환하기보다, 새로 작성하는 코드부터 ESM으로 작성하고 기존 코드는 수정할 때 함께 전환하는 방식이 위험 부담을 줄이는 효과적인 전략입니다.
핵심 정리
- CJS는 동기·동적, ESM은 비동기·정적입니다. 이 근본적 차이가 tree-shaking, top-level await, 상호 운용성 문제의 원인입니다
- package.json의
"type"필드가.js파일의 해석 방식을 결정합니다."type": "module"을 명시하면 ESM, 생략하거나"commonjs"이면 CJS로 해석됩니다 - CJS에서 ESM을 불러오려면 비동기
import()를 사용해야 합니다. 동기require()로는 구조적으로 불가능합니다 - 라이브러리를 배포한다면
"exports"필드로 dual package를 제공하여 두 환경 모두에서의 호환성을 확보하는 것이 좋습니다 - 새 프로젝트는 ESM으로 시작하고, 기존 프로젝트는 점진적으로 전환하는 전략이 가장 현실적입니다
관련 문서
글쓴이 mirunamu00



