Node.js에서 ES 모듈(import/export) 사용하기
자바스크립트 생태계에서 모듈 시스템은 꽤 오랜 기간 혼란스러운 주제였는데요.
Node.js가 처음부터 채택한 CommonJS의 require와 ES6에서 도입된 import가 공존하면서, 개발자들은 환경에 따라 두 가지 방식을 오가야 했습니다.
const express = require("express");
const app = express();
import express from "express";
const app = express();
위 두 코드는 동일하게 ExpressJS 라이브러리를 불러와 서버 객체를 생성하고 있지만, 사용하는 모듈 시스템이 다릅니다. Node.js 초기에는 ES 모듈을 쓰려면 Babel 같은 트랜스파일러가 필수였지만, 버전 13.2부터 ES 모듈이 정식 지원되기 시작했고, 새로 시작하는 프로젝트에서는 ES 모듈을 선택하는 경우가 많아졌습니다. 물론 npm에는 아직도 CommonJS 기반 패키지가 훨씬 많기 때문에, 실무에서는 두 모듈 시스템을 함께 다뤄야 하는 상황이 대부분입니다.
이 글에서는 Node.js에서 ES 모듈을 적용하는 두 가지 방법을 살펴보고, CommonJS와의 호환성 문제가 require(esm) 지원으로 어떻게 해소되고 있는지까지 함께 알아보겠습니다.
CommonJS와 ES 모듈의 문법 자체에 대해서는 아래 포스팅을 참고 바랍니다.
기존 CommonJS 코드
먼저 비교를 위해 CommonJS 방식으로 간단한 예제를 작성해보겠습니다.
아래 time 모듈은 현재 시간을 ISO 문자열로 리턴하는 now() 함수를 내보내고 있습니다.
exports.now = function () {
return new Date().toISOString();
};
이 모듈을 불러와서 실행하는 테스트 파일도 만들어 봅니다.
const { now } = require("./time");
console.log("Now:", now());
실행해보면 예상대로 잘 작동합니다.
$ node time.test.js
Now: 2020-05-23T21:43:28.000Z
방법 1: 파일 확장자로 ES 모듈 적용
Node.js에서 ES 모듈을 사용하는 첫 번째 방법은 파일 확장자를 .js 대신 .mjs로 바꾸는 것입니다.
기존 프로젝트에서 일부 파일만 ES 모듈로 전환하고 싶을 때 가장 간편한 방법이죠.
위 예제를 ES 모듈로 바꿔보겠습니다.
export function now() {
return new Date().toISOString();
}
import { now } from "./time.mjs";
console.log("Now:", now());
여기서 한 가지 주의할 점이 있습니다.
import 구문에서 "./time.mjs"처럼 확장자를 반드시 포함해야 한다는 건데요.
CommonJS에서는 require("./time")처럼 확장자를 생략해도 Node.js가 알아서 찾아줬지만, ES 모듈에서는 브라우저의 동작 방식에 맞춰 확장자를 명시해야 합니다.
확장자 없이 "./time"이라고 쓰면 이런 에러를 만나게 됩니다.
$ node time.test.mjs
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/project/time'
imported from /project/time.test.mjs
처음 ES 모듈을 사용할 때 가장 흔히 겪는 실수이니 기억해두면 좋겠습니다.
방법 2: package.json으로 ES 모듈 적용
두 번째 방법은 package.json에서 프로젝트 전체를 ES 모듈로 설정하는 것입니다.
파일 확장자를 일일이 .mjs로 바꾸지 않아도 되니, 새 프로젝트를 시작하거나 기존 프로젝트를 한꺼번에 전환할 때 적합합니다.
package.json에 "type": "module"을 추가하면 됩니다.
{
"type": "module"
}
이렇게 설정하면 .js 파일도 ES 모듈로 인식되므로, 확장자를 .mjs로 바꿀 필요가 없습니다.
export function now() {
return new Date().toISOString();
}
import { now } from "./time.js";
console.log("Now:", now());
$ node time.test.js
Now: 2020-05-23T22:21:20.000Z
참고로 "type": "module" 설정을 한 프로젝트에서 일부 파일만 CommonJS로 유지하고 싶다면, 해당 파일의 확장자를 .cjs로 바꾸면 됩니다.
반대의 경우(.mjs)와 정확히 대칭적인 구조라서 기억하기 쉽죠.
package.json type | .js 파일 | .mjs 파일 | .cjs 파일 |
|---|---|---|---|
| 미설정 (기본값) | CommonJS | ES 모듈 | CommonJS |
"module" | ES 모듈 | ES 모듈 | CommonJS |
CJS와 ESM의 호환성 문제
Node.js에서 두 모듈 시스템이 공존하면서 꽤 골치 아픈 호환성 문제가 있었습니다.
ES 모듈에서 CommonJS를 import하는 것은 가능했지만, 반대로 CommonJS에서 ES 모듈을 require()로 불러오는 것은 불가능했거든요.
// ✅ ES 모듈에서 CommonJS 불러오기 — 가능
import { something } from "./cjs-module.cjs";
// ❌ CommonJS에서 ES 모듈 불러오기 — 에러!
const { something } = require("./esm-module.mjs");
이 비대칭 때문에 패키지 개발자들은 상당히 곤란한 상황에 놓였습니다.
ESM으로 마이그레이션하고 싶어도 CommonJS를 쓰는 사용자들이 require()로 불러올 수 없게 되니, CJS와 ESM 두 벌을 동시에 배포하는 이른바 “dual package” 방식을 유지해야 했죠.
package.json의 exports 필드로 조건부 진입점을 설정하는 식이었는데, 설정이 복잡할 뿐 아니라 같은 패키지가 CJS와 ESM 양쪽으로 중복 로드되는 “dual package hazard” 문제도 있었습니다.
require(esm): 호환성 문제의 해결
이 오래된 문제가 드디어 해결되었는데요.
Node.js v22.12.0부터 CommonJS에서 ES 모듈을 require()로 직접 불러오는 기능이 플래그 없이 안정적으로 지원되기 시작했습니다.
v20.19.0까지 백포트되어 현재 쓰이는 LTS 버전에서도 바로 쓸 수 있고요.
// CommonJS 파일에서 ES 모듈을 바로 require() 할 수 있습니다
const { now } = require("./time.mjs");
console.log("Now:", now());
이 변화가 갖는 의미가 큰데요.
패키지 개발자는 CJS/ESM 이중 배포를 유지할 필요 없이 ESM 하나로 통일할 수 있게 되었고, 사용자 입장에서는 기존 CommonJS 코드를 건드리지 않고도 ESM 전용 패키지를 그대로 require()할 수 있게 되었으니까요.
한 가지 제약이 있다면 top-level await를 사용하는 ES 모듈은 require()로 불러올 수 없다는 점입니다.
require()는 동기적으로 동작하는데 top-level await는 비동기 실행을 필요로 하기 때문이죠.
하지만 실제로 top-level await를 사용하는 패키지는 npm 상위 5,000개 중 약 0.02%에 불과해서 대부분은 문제없이 require(esm)을 쓸 수 있습니다.
Bun: 모듈 호환성 고민 없는 런타임
자바스크립트의 모듈 시스템 호환성 문제를 아예 근본적으로 피하는 방법도 있습니다. Bun은 처음부터 CommonJS와 ES 모듈을 같은 파일 안에서 자유롭게 섞어 쓸 수 있도록 설계되었는데요.
// Bun에서는 같은 파일에서 require와 import를 함께 쓸 수 있습니다
import { readFile } from "fs/promises";
const express = require("express");
Node.js에서는 이런 코드가 바로 에러가 나지만, Bun에서는 아무 설정 없이 잘 동작합니다.
.mjs/.cjs 확장자를 신경 쓸 필요도 없고, package.json에 "type": "module"을 넣을지 말지 고민할 필요도 없죠.
모듈 시스템 호환성 때문에 스트레스받고 있다면, Bun으로 자바스크립트 런타임을 바꾸는 것도 고려해볼 만합니다.
마치며
이번 포스팅에서는 Node.js에서 ES 모듈을 사용하는 두 가지 방법(.mjs 확장자와 package.json의 "type": "module")을 살펴보고, require(esm) 지원으로 CJS/ESM 간의 호환성 문제가 어떻게 해소되고 있는지까지 알아보았습니다.
2020년만 해도 Node.js의 모듈 시스템은 꽤 혼란스러운 상태였지만, require(esm)의 안정화를 거치면서 이제는 ES 모듈로의 전환이 한결 수월해졌습니다.
새 프로젝트라면 "type": "module"로 시작하시는 거나 Bun을 한 번 써보시는 것을 추천드리고, 기존 프로젝트도 Node.js 버전만 올려주면 CJS/ESM 호환성 걱정 없이 조금씩 전환할 수 있으니 참고 바라겠습니다.
This work is licensed under
CC BY 4.0