개요
회사 프로젝트를 유지보수하다보면, 분리한 자바스크립트 모듈 파일을 호출하기 위해 크게 두가지 방식을 사용하는 것을 확인할 수 있었습니다. 크게 관심 갖지 않고 다른 파일에서 쓰는 방식을 따라 쓰곤 했는데, A 프로젝트의 모듈을 B 프로젝트에 이식하려니 충돌이 발생했습니다.
이를 해결하는 과정에서 자바스크립트 모듈 정의 방식인 ES Module(ESM)과 CommonJS(CJS) 방식을 이해할 수 있었습니다.
이 글에서는 두 모듈의 개념과 특징을 확인하고 Node.js 환경에서 왜 두 문법을 혼용하게 되었는지, 그리고 이 과정에서 어떤 문제가 발생하는지에 대해서 정리해 볼 예정입니다!
ES Module (= ESM)
ECMAScript 표준에서 정의된 모듈 시스템입니다. 자바스크립트 언어 스펙 자체에 포함되어 있습니다.
특징
- 정적 구조, 트리쉐이킹 지원 : `import`/ `export`가 스코프 최상단에 고정되고 번들러가 빌드 시점에 코드 구조를 완전히 파악할 수 있으므로 컴파일 시 사용되지 않는 코드를 제거하는 트리쉐이킹을 지원합니다.
- 비동기 로딩: HTML 파싱을 막지 않고 병렬로 다운로드되며 문서 파싱이 끝난 뒤 실행됩니다.
- 확장자: 보통 `.js`, Node.js에서 파일 단위로 ESM임을 명시하고 싶을 경우 `.mjs`를 사용합니다.
- 브라우저 / Node.js 모두 공식 지원합니다.
정의, 호출 방법
1. named export (권장)
이름이 있는 여러 export를 하는 방식입니다. 리팩토링과 자동 import 시 안전하기 때문에 많은 프로젝트에서 선호하는 방식입니다.
// module1.js (정의)
export function sum(a, b) {
return a + b;
}
export function minus(a, b) {
return a - b;
}
// main.js (호출)
import { sum, minus } from './module1.js';
import { sum as sumAlias } from './module1.js'; // 별칭으로 불러오고 싶은 경우
console.log(sum(1, 2));
2. default export
모듈에서 대표로 하나만 export할 때 `default`를 붙입니다. 보통 모듈의 핵심 기능이 하나일 때 사용합니다.
파일당 1개만 가능하고, 호출 시 `{}`를 붙이지 않습니다. 별도로 별칭 문법을 쓰지 않아도 import 시 이름을 자유롭게 변경 가능합니다.
// module2.js (정의)
export default function multiply(a, b) {
return a * b;
}
// main.js (호출)
import multiplyModule from './module2.js'; // default export 이므로 별칭 가능
console.log(multiplyModule(1, 2));
3. 혼용
다른 export들과 `default export`를 함께 쓸 수 있지만, 한 파일에 `default export`는 하나여야 합니다.
`default export`는 `{}` 없이, 일반 `export`는 `{}` 와 함께 사용합니다.
// module3.js (정의)
export default function multiply(a, b) {
return a * b;
}
export function sum(a, b) {
return a + b;
}
// main.js (호출)
import multiplyModule, {sum} from './module3.js';
Common JS (CJS)
Node.js 초기부터 사용된 모듈 시스템입니다. ECMAScript 표준이 아니라 Node.js 환경에서 만들어진 모듈 방식입니다.
특징
- 동적 로딩: `require()`를 코드 실행 중에 호출할 수 있기 때문에 런타임 시점에 어떤 모듈을 불러올지 결정할 수 있습니다.
- 동기 로딩: `require()` 호출 시 모듈을 동기적으로 로드합니다. 서버 환경(Node.js)에서는 문제가 되지 않지만 브라우저 환경에서는 성능 문제가 될 수 있습니다.
- 트리쉐이킹 제한됨: `require()`가 실행 시점에 동적으로 호출될 수 있기 때문에 번들러가 빌드 단계에서 모듈 의존성을 완전히 분석하기 어렵습니다.
const 수학모듈 = require("./math");
수학모듈.덧셈(1,2);
// 수학모듈을 불러와서 덧셈밖에 사용하지 않았지만, 곱셈과 나눗셈까지도 사용할 지 모른다고 판단
- 확장자: `.js`
- Node.js에서는 별도 설정이 없으면 기본적으로 CommonJS 방식이 사용됩니다.
- 문법: `require`, `module.exports`
정의, 호출 방법
// module.js (정의)
function add(a, b) {
return a + b;
}
module.exports = { add }
// main.js (호출)
const { add } = require('./math.js');
console.log(add(1, 2));
ESM vs CJS
| 구분 | ESM | CommonJS |
| 표준 여부 | ECMAScript 공식 표준 | Node.js 자체 방식 |
|---|---|---|
| 모듈 로딩 방식 | 정적(compile-time) | 런타임 로딩(run-time) |
| 문법 | import/export | require/module.exports |
| 브라우저 지원 | 기본 지원 | 지원되지 않음(번들 필요) |
| 트리쉐이킹 | 가능 | 제한됨(정적 분석 어려움) |
| Node.js 기본 | `package.json`내부에 `type: "module"` 선언해주거나 `.mjs` 확장자로 모듈 필요 | 디폴트 모듈 시스템 |
CJS가 Node.js의 기본 방식이라고 하는데, React나 Vue.js를 사용하는 프론트엔드 사용자들은 export, import를 사용한 모듈 사용에 더 익숙하다는 점에 의문을 느낄 수 있습니다.
Node12 이상부터는 `package.json`에서 `type : "module"`을 선언하여 모듈 호출 방식을 ESM으로 하겠다고 명시해주는 경우가 많기 때문입니다.
그렇다면 왜 Node.js에서는 아예 ESM으로 모듈 호출 방식을 변경하지는 않은 걸까요? 그리고 방식을 변경하면 CJS 모듈을 호출할 수 없을까요? 혹은 방식을 변경하지 않는다면 ESM을 호출할 수 없을까요?
결론부터 말씀드리자면
- 아예 ESM으로 호출 방식을 변경하지 않은 이유 → 기존 npm과의 호환성을 위해
- ESM 환경에서도 CJS 모듈을 import 할 수 있음. 그러나 충돌 발생 가능
- CJS 환경에서도 동적 import 등을 통해 ESM을 사용할 수 있지만 충돌 발생 가능
ESM 에서도 CJS 모듈 호출 가능?
ESM 모드에서 CommonJS 문법을 사용할 수는 없습니다.
// ❌ ESM에서 사용 불가
const fs = require('fs');
// ✔ ESM 방식
import fs from 'fs';
하지만 ESM에서도 CJS 문법으로 선언한 파일의 모듈을 호출해 사용하는 건 가능합니다. Node.js가 호환성을 위해 CJS의 export 모듈을 호출할 수 있도록 변환해주는 interop을 제공하고 있기 때문입니다.
ESM & CJS
등장 배경
초기 JavaScript에는 모듈 개념이 존재하지 않았습니다.
브라우저에서는 다음과 같은 방식으로 스크립트를 불러왔습니다.
<script src="a.js"></script>
<script src="b.js"></script>
이 방식은 모든 코드가 전역 스코프를 공유하기 때문에 의존성 관리가 어렵고 충돌이 자주 발생했습니다.
그래서 Node 개발자들이 만든 방식이 CommonJS입니다. Node는 이걸 10년 넘게 기본으로 사용하고 있었는데, ECMAScript 2015에서 ESM(ES Modules)이 표준으로 추가됩니다.
문제는 이미 Node 생태계에는 수백만 개의 CommonJS 패키지가 존재했다는 점입니다. 만약 Node.js가 CJS를 완전히 버리고 ESM만 지원한다면 기존 npm 패키지 대부분이 동작하지 않게 됩니다. 따라서 구조가 다른 두 방식을 모두 지원하기 위해 등장한 매커니즘이 interop였습니다.
Interop
코드로 확인해보겠습니다. 변환 전의 CJS 코드가 있습니다.
//module.js (CJS)
module.exports = function test() {
console.log("hello");
}
ESM에서는 다음처럼 사용할 수 있습니다.
import test from "./module.js";
test();
Node.js는 내부적으로 CommonJS 모듈을 ESM에서 사용할 수 있도록 호환 로직을 제공합니다.
이 과정을 CommonJS interop이라고 합니다.
번들러(Webpack, Rollup, esbuild 등)는 Node.js의 CommonJS 실행 방식을 흉내내기 위해 wrapper 구조를 생성해 ESM에서도 CJS의 모듈이 실행되도록 변환합니다.
//CJS가 실행되는 Node.js의 wrapper
(function (exports, require, module, __filename, __dirname) {
// module code
});
//번들러가 흉내낸 wrapper 내부의 CJS동작
var module = { exports: {} };//감싸서 export하기 위한 객체
(function(module, exports) {
module.exports = function test() {
... 모듈 내부 로직
};
})(module, module.exports);
export default module.exports;
마지막 줄의 `export default module.exports;`에서 CJS의 `module.exports`값을 ESM의 `default export`로 변환하고 있는 것을 확인할 수 있습니다.
모듈의 충돌 문제
문제는 모듈의 형태가 항상 동일하지 않다는 점입니다.
예를 들어 CommonJS 모듈을 import 할 때 번들러는 다음과 같은 helper 함수를 생성할 수 있습니다.
// ESM import와 호환시키는 helper
function __toESM(mod) { // import한 cjs 모듈을 ESM 형태로 변환
return mod && mod.__esModule
? mod // 이미 { default: function } 형태의 ESM 객체인 경우 그대로 사용
: { default: mod }; // CJS형태인 경우 ESM형태로 만들기 위해 default로 감싸줌
}
var test = __toESM(require("./module"));
//결과
test.default() ; // 정상
test() // 에러! - TypeError: test is not a function
이러한 이유로 import한 결과는 `function`일 수도, `{ default: function }`일수도 있게 됩니다.
따라서 `test.default()`가 정상일수도, `test()`가 정상일수도 있게 되는 문제가 발생합니다.
TypeScript / Babel 옵션이 존재하는 이유
Babel 은 이 문제 때문에 옵션을 제공합니다. `esModuleInterop` 또는 `allowSyntheticDefaultImports` 옵션을 활성화하면
import test from "module"
을 자동으로
var _module = require("module");
var test = _module.default ?? _module;
test();
형태로 맞춰줍니다. default가 있으면 사용하고, 없으면 (`{default : function}`으로 리턴되지 않았다면) 바로 사용하도록 하는거죠. 따라서 개발자는 모듈의 내부 형식을 신경쓰지 않고 동일한 방식으로 호출할 수 있습니다.
그래도 자주 나는 에러
require is not defined
ESM환경으로 설정된 (package.json에서 `type : "module"`) 경우 아래 에러가 날 수 있습니다.
const fs = require("fs"); //ReferenceError: require is not defined
해결 : CommonJS 타입의 모듈이라도 import로 불러올 수 있으니 아래 방식으로 불러오세요.
import fs from "fs";
__dirname / __filename 없음
CommonJS에서는 `__dirname`, `__filename` 변수가 자동 생성되지만 ESM에서는 아닙니다. 따라서 ESM에서 import한 CommonJS 모듈 내부에 해당 변수를 사용하고 있다면 `ReferenceError: __dirname is not defined` 에러가 발생합니다.
ESM에서는 경로를 얻기 위해 `import.meta.url`를 사용하기 때문에 아래처럼 수정할 수 있습니다.
import { fileURLToPath } from "url";
import { dirname } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
//이제 __filename, __dirname을 CommonJS처럼 경로, 파일명으로 사용할 수 있습니다.
CommonJS에서 ESM import 불가
CommonJS환경에서
// CommonJS
const module =require("./esm.js");//Error[ERR_REQUIRE_ESM]
해결
(async () => {
const module = await import("./esm.js");
})();
default export 꼬임 (interop 문제)
//module.js
module.exports = function test(){...}
import test from "./module"; // commonJS의 module.exports를 default export로 자동변환
test(); // 정상 (test === function)
하지만 Babel / Webpack 환경에서는 번들러가 다르게 변환할 수 있습니다. 특정 환경에서는 아래처럼 test를 객체로 변환합니다.
import * as test from "./module";
console.log(test); // {default : [Function: test]}
test(); // 에러 - TypeError: test is not a function
따라서 안전하게 변환하기 위해서 CommonJS로 선언한 모듈을 ESM으로 수정하는게 가장 좋습니다. 하지만 정말 부득이하게 모듈을 수정할 수 없다면, import하는 곳에서 interop하는 방식을 사용해 볼 수 있습니다.
import test from "./module.js";
test?.default?.() ?? test(); // 방법1 : interop
정리
JavaScript에는 CommonJS(CJS) 와 ES Module(ESM) 이라는 두 가지 모듈 시스템이 존재합니다.
CJS는 Node.js에서 먼저 등장한 모듈 방식이고, 이후 ECMAScript 표준으로 ESM이 추가되었습니다.
하지만 이미 npm 생태계에 많은 CJS 패키지가 존재했기 때문에 Node.js는 두 방식을 모두 지원하고 있습니다. 이 과정에서 ESM과 CJS 사이의 호환 처리를 interop이라고 부르며, 번들러나 런타임이 내부적으로 모듈 구조를 맞춰주는 역할을 합니다.
다만 두 시스템의 구조가 다르기 때문에 default export 처리나 import 방식에서 예상치 못한 오류가 발생할 수 있으며, 가능하다면 프로젝트 내부 모듈은 ESM 방식으로 통일하는 것이 가장 안정적인 방법입니다.
출처
https://nodejs.org/api/esm.html
https://nodejs.org/api/modules.html
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/module
https://babeljs.io/docs/babel-plugin-transform-modules-commonjs

'자바스크립트 > Node.js' 카테고리의 다른 글
| 사이트도 업데이트를 하나요? 최신버전 안내하기 (3) | 2025.08.21 |
|---|