2024. 12. 28. 18:23ㆍNodejs
1. CommonJS
CommonJS는 Javascript의 초기 모듈 시스템으로 주로 Node.js에서 사용됩니다. 모듈을 가져오거나 내보내기 위해 require와 module.exports를 사용합니다.
CommonJS의 특징
- 동기 로드: 모듈이 호출되는 순간 실행됩니다. 모듈이 로드될 때까지 코드 실행이 중단됩니다.
- 런타임 로드: 프로그램 실행 중 모듈을 로드할 수 있습니다.
- Node.js 중심: Node.js에서 기본적으로 지원되며, 브라우저 환경에서는 직접적으로 사용할 수 없습니다.
CommonJS 방식의 코드
// math.js
module.exports = {
add: (a, b) => a + b,
subtract: (a, b) => a - b
}
// main.js
const math = require('./math.js');
console.log(math.add(1, 2));
2. AMD
AMD는 브라우저에서 비동기적으로 모듈을 로드하기 위해 설계된 모듈 시스템입니다. CommonJS는 서버 중심의 동기 로드 방식으로 브라우저 환경에 적합하지 않았기 때문에, AMD는 비동기 로드를 통해 브라우저에서도 네트워크 요청을 효율적으로 처리할 수 있도록 개발되었습니다. 모듈을 가져오거나 내보내기 위해 define과 require를 사용합니다.
AMD의 특징
- 비동기 로드: 브라우저에서 네트워크 요청과 같은 비동기 로드를 지원합니다.
- 브라우저 친화적: 브라우저 환경에서 동작하도록 설계되었습니다.
AMD 방식의 코드
// math.js
define(function() {
return {
add: function (a, b) {
return a + b;
},
subtract: function (a, b) [
return a - b;
}
}
});
// main.js
require(['math'], function (math) {
console.log(math.add(2, 3));
});
3. ESM (ECMAScript Modules)
ESM은 Javascript의 최신 모듈 시스템으로 브라우저와 Node.js 모두에서 사용할 수 있습니다. ES6(ES2015)에서 도입되었으며, 모듈을 가져오거나 내보내기 위해 import와 export 키워드를 사용합니다.
ESM의 등장배경
CommonJS는 비동기 로드를 지원하지 않아 브라우저 환경에서 사용하기 어려웠고, AMD는 비동기 로드를 지원했지만 문법이 복잡하고 가독성이 떨어지는 문제가 있었습니다. 더불어, 두 모듈 시스템 모두 정적 분석이 불가능해 트리 쉐이킹과 같은 성능 최적화를 어렵게 만들었습니다. 또한, CommonJS와 AMD는 모두 JavaScript 자체의 표준 모듈 시스템이 아닌, 비공식적인 해결책에 불과했습니다. 이러한 한계를 극복하고, 일관적이며 표준화된 방식으로 JavaScript를 확장하기 위해 ESM이 등장하게 되었습니다.
ESM의 특징
- 비동기 로드: 브라우저에서 네트워크 요청과 같은 비동기 로드를 지원합니다.
- 동적 로드: import()를 사용하여 런타임 시 필요한 모듈만 로드할 수 있습니다. 하지만 동적 로드는 런타임에 어떤 모듈이 로드될지 미리 알 수 없기 때문에, 트리 쉐이킹의 대상에서 제외됩니다.
- 정적 분석: 모듈의 의존성이 컴파일 타임에 정적으로 분석됩니다. 때문에 트리 쉐이킹이 용이합니다.
- 범용성: Node.js와 브라우저 모두에서 동작합니다.
ESM 방식의 코드
// math.js
export const add = (a, b) => {
return a + b;
}
// main.js
import { add } from './math.js';
console.log(add(1, 2));
4. ESM 사용하기
ESM은 Node.js 12에서 처음 도입되었으며, 초기에는 실험적으로 제공되었기 때문에 별도의 플래그(--experimental-modules)와 함께 사용해야 했습니다. Node.js 13.2 이상에서는 ESM이 안정적으로 지원되며, 별도의 플래그 없이 사용할 수 있습니다.
ESM을 사용하는 방법에는 2가지가 있는데 첫 번째는 파일 확장자를 .mjs로 변경하는 방식이고, 두 번째는 package.json에 "type": "module" 이라는 프로퍼티를 추가하는 방식입니다. 첫 번째 방식은 프로젝트에서 CommonJS와 ESM을 함께 사용할 때 모듈 시스템을 명확히 구분하기 위해서 사용합니다. 이때 CommonJS는 .js 또는 .cjs를 사용합니다. 두 번째 방식은 프로젝트 내 .js 파일을 모두 ESM으로 동작하도록 설정할 수 있습니다. 이 방식은 프로젝트에서 ESM만을 사용할 때 주로 사용됩니다.
.mjs를 사용하는 방식
ESM 모듈에서는 다른 ESM 모듈을 동일한 문법으로 쉽게 가져올 수 있습니다. 하지만 CommonJS 모듈에서 ESM 모듈을 사용하려면 import()를 사용하여 비동기로 로드해야 합니다. 이는 ESM이 비동기 로드를 기반으로 설계되었기 때문이며, CommonJS의 require는 동기적으로 동작하므로 ESM 모듈과 호환되지 않기 때문입니다.
// math.mjs
export const add = (a, b) => a + b;
// main.mjs
import { add } from './math.mjs';
console.log(add(1, 2));
// main.cjs
(async () => {
const main = await import('./math.mjs');
console.log(add(1, 2));
})();
package.json에 "type": "module"을 추가하는 방식
모든 모듈이 ESM으로 동작하기 때문에 동일한 방식으로 가져와서 사용할 수 있습니다.
// math.js
export const add = (a, b) => a + b;
// main.js
import { add } from './math.js';
console.log(add(1, 2));
위의 코드 예시에서 볼 수 있듯이, ESM을 사용하면 모듈을 가져올 때 파일 확장자를 반드시 명시해야 합니다. 기존 CommonJS에서는 파일 확장자를 생략해도 .js -> .json -> .node 순서로 파일을 자동으로 탐색했기 때문에 문제가 없었습니다. 그러나 ESM은 정적 분석과 모듈 로드 최적화를 위해 명확한 파일 경로를 요구합니다. 이는 브라우저와 Node.js 간의 일관된 동작을 보장하기 위한 설계이기도 합니다.
5. CommonJS에서 ESM으로 전환했을 때 겪는 문제
Directory Import를 지원하지 않습니다.
Directory Import는 CommonJS에서 폴더 아래에 index.js가 존재할 경우, 폴더 이름만으로 모듈을 가져올 수 있는 기능을 말합니다. 예를 들어, CommonJS에서는 다음과 같은 코드가 가능했습니다.
// math/index.js
module.exports = {
add: (a, b) => a + b,
}
// main.js
const math = require('math');
하지만, ESM에서는 이러한 방식이 기본적으로 지원되지 않습니다. 예를 들어, ESM에서 다음과 같은 코드는 "Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import..."과 같은 에러가 발생합니다.
// math/index.js
export const add = (a, b) => a + b;
// main.js
import { add } from './math';
이 문제를 해결하려면 Custom Loader를 작성해야 합니다. Custom Loader는 ESM에서 모듈 로드 과정을 커스터마이징하기 위해 제공되는 기능입니다. Custom Loader는 resolve와 load라는 두 가지 주요 훅을 사용하여 모듈의 경로를 결정하고 이를 실행한 가능한 코드로 변환하는 과정을 제어할 수 있습니다. 자세한 내용은 공식 문서(customization hooks, resolve & load)에서 확인할 수 있습니다.
간단하게 설명하면 resolve 함수는 모듈의 위치 결정하기 위한 함수이고, load 함수는 모듈의 내용을 처리하기 위한 함수입니다. 그래서 resolve는 위와 같이 Directory Import를 처리하거나 외부 리소스로 리디렉션 하는 등의 처리를 할 수 있고, load는 JSON, Typescript 등을 Javascript 코드로 변환하거나 압축 해제, 트랜파일링 등을 처리할 수 있습니다.
아래의 코드는 Directory Import를 지원하는 간단한 예제입니다. 이 코드는 Directory Import를 처리할 수 있는 방법을 보여주기 위한 단순화된 코드로, 실제 사용 시 여러 문제를 포함할 수 있습니다. 예를 들어, JSON 파일 로드 처리, 외부 의존성을 가져올 때의 충돌 등을 추가로 고려해야 합니다.
// directory-import-resolver.js
import fs from "fs/promises";
import path from "path";
export async function resolve(specifier, context, nextResolve) {
const { parentURL } = context;
if (parentURL) {
const parentPath = new URL(parentURL).pathname;
const currentPath = path.resolve(path.dirname(parentPath), specifier);
if (!currentPath.endsWith(".js") && !currentPath.endsWith(".mjs")) {
const indexJSPath = path.join(currentPath, "index.js");
await fs.access(indexJSPath);
return nextResolve(indexJSPath, context);
}
}
return nextResolve(specifier, context);
}
- specifier: 모듈을 가져올 때 지정한 식별자입니다. (예: import myModule from './module.js';의 경우 specifier는 './module.js'입니다.)
- context: 현재 모듈 로드와 관련된 메타데이터를 포함합니다. (예: context.parentURL은 모듈을 요청한 부모 모듈의 URL을 나타내며, 상대 경로 해석 시 유용합니다.)
- nextResolve: Node.js의 기본 resolve 동작을 실행하는 함수입니다. Custom Loader에서 기본 동작을 유지하면서 추가적인 처리(예: Directory Import)를 구현할 때 유용합니다.
작성한 Custom Loader는 Node.js 실행 시 플래그를 사용해 전달해야 합니다. Node.js 버전에 따라 사용하는 플래그가 다르며, 플래그에 전달하는 파일 경로는 상대 경로로 작성해야 합니다.
- 20.x.x 미만
node --loader ./directory-import-resolver.js src/main.js
- 20.x.x 이상 (--loader 플래그도 지원하고 있지만 "(node:1413) ExperimentalWarning: `--experimental-loader` may be removed in the future; instead use `register()`"라는 경고를 출력합니다.)
// register-hooks.js
import { register } from "node:module";
import { pathToFileURL } from "node:url";
register("./directory-import-resolver.js", pathToFileURL("./"));
node --import ./register-hooks.js src/main.js
JSON 파일을 로드할 때 파일 확장자를 명시하고 assert문을 추가해야 합니다.
CommonJS에서는 JSON 파일을 require를 사용해 모듈처럼 가져올 수 있으며, 파일 확장자를 생략해도 됩니다. 예를 들어, CommonJS에서는 다음과 같은 코드가 가능했습니다.
// data.json
{
some: "...",
foo: {
bar: {
...
}
}
}
// main.js
const data = require('./data');
하지만 ESM에서는 파일 확장자를 반드시 명시해야 하며, 파일 확장자를 포함해 JSON 파일을 로드하더라도 "TypeError [ERR_IMPORT_ASSERTION_TYPE_MISSING]"와 같은 에러가 발생하거나 "(node:92349) ExperimentalWarning: Importing JSON modules is an experimental feature and might change at any time"와 같은 경고가 출력됩니다.
// data.json
{
some: "...",
foo: {
bar: {
...
}
}
}
// main.js
import data from './data';
// Error [ERR_MODULE_NOT_FOUND]: Cannot find module...
import data from './data.json';
// TypeError [ERR_IMPORT_ASSERTION_TYPE_MISSING]: Module "file:///Users/iseongmin/Documents/programming/javascript/esm-test-js/src/test.json" needs an import assertion of type "json"
import data from './data.json' assert { type: "json" };
// (node:92349) ExperimentalWarning: Importing JSON modules is an experimental feature and might change at any time
이 문제를 해결하기 위해, 앞서 언급한 것처럼 Custom Loader를 작성해야 합니다. 아래의 코드는 JSON 파일을 처리하기 위해 load 함수를 사용합니다. 다만, 이 코드는 특정 문제를 해결하는 데 초점을 맞춘 단순화된 예제이며, 실제 사용 시 다른 문제가 발생할 수 있습니다.
// json-import-resolver.js
import path from "path";
import fs from "fs";
export async function resolve(specifier, context, nextResolve) {
if (!path.extname(specifier) && context.parentURL) {
const resolvedPath = path.resolve(
path.dirname(new URL(context.parentURL).pathname),
`${specifier}.json`
);
const currentPath = new URL(`file://${resolvedPath}`).href;
return nextResolve(currentPath, context, nextResolve);
}
return nextResolve(specifier, context, nextResolve);
}
export async function load(url, context, nextLoad) {
if (url.endsWith(".json")) {
const jsonContent = await fs.readFileSync(new URL(url), "utf-8");
return {
format: "module",
shortCircuit: true,
source: `export default ${jsonContent};`,
};
}
return nextLoad(url, context, nextLoad);
}
- url: resolve 함수가 반환한 파일 URL이 전달됩니다. (예: file:///path/to/test.json)
- context: 모듈 로드에 관한 추가적인 메타데이터를 포함합니다.
- format: Node.js가 이 URL의 모듈 형식을 어떻게 해석할지를 지정합니다. (예를 들어, "module" 또는 "commonjs".)
- parentURL: 이 모듈을 요청한 부모 모듈의 URL.
- nextLoad: Node.js의 기본 load 동작을 호출하기 위한 함수입니다. 이 함수를 호출하면 Node.js의 기본 모듈 로딩 로직에 위임됩니다.
아래와 같이 main.js 코드를 작성하고 register-hooks에 추가한 후 실행해보면 JSON 파일의 내용을 출력하는 것을 확인할 수 있습니다.
import data from './data';
console.log(data);
// register-hooks.js
import { register } from "node:module";
import { pathToFileURL } from "node:url";
register("./json-import-resolver.js", pathToFileURL("./"));
node --import ./register-hooks.js src/main.js
6. 결론
CommonJS에서 ESM으로 전환하는 것은 매우 까다로운 작업입니다. 앞서 언급한 문제들은 Custom Loader를 통해 해결할 수 있지만, 이렇게 하면 ESM의 주요 장점인 트리 쉐이킹의 이점을 온전히 누릴 수 없습니다. 그럼에도 불구하고 ESM으로 전환해야 하는 이유는 무엇일까요?
아직까지 CommonJS를 사용한다고 해서 당장 큰 문제가 발생하지는 않습니다. 그러나 Node.js는 ESM을 공식적으로 기본 모듈 시스템으로 채택했으며, 많은 라이브러리들이 점차 ESM을 지원하거나 Pure ESM으로 전환하고 있는 추세입니다.
따라서 결국에는 ESM을 사용해야만 하는 시기가 오지 않을까요? 그때가 되면 이미 복잡하고 거대해진 프로젝트를 ESM으로 전환하려면 더 많은 시간과 노력이 필요할 수 있습니다. 지금부터라도 새로운 코드와 모듈에서 점진적으로 ESM을 도입하고 조금씩 전환해보는 것이 좋지 않을까 생각합니다.
'Nodejs' 카테고리의 다른 글
슬랙으로 RSS 알림 보내기 (1) - 개요 (0) | 2025.02.15 |
---|---|
모듈 시스템 - 타입스크립트에서 ESM 사용하기 (0) | 2025.01.02 |
Yarn Workspaces로 모노레포 관리하기 (0) | 2024.12.16 |
패키지 매니저 - pnpm (1) | 2024.12.15 |
패키지 매니저 - Yarn (2) | 2024.12.15 |