저는 현재 간단한 React 라이브러리를 만들면서 Yarn berry를 사용하고 있었고 package.json에서 type을 module로 설정하여 ESM으로 개발을 하고있었습니다. 그러다 문득 yarn berry는 require을 monkey-patch해서 cjs에서만 돌아가는 줄 알았는데 어째서 esm으로도 돌아갈까 하는 의문이 들었습니다. 그와 관련해서 리서치한 내용과 자바스크립트의 모듈 시스템에 대한 글을 작성해보려고 합니다.
Javascript Module System에 대한 얘기는 예전에 봤던 FEConf에서 발표한 영상을 많이 참고해서 작성하였습니다.
자바스크립트는 처음에 단순히 웹페이지의 동작을 부여하는 용도로만 사용되었습니다. 그리고 웹페이지가 그렇게 많은 상호작용이 들어가지 않았기 때문에 큰 스크립트가 필요하지도 않았죠. 그래서 한동안 모듈시스템이 따로 없었습니다. 하지만 시간이 지나면서 스크립트의 크기가 점점 커지고 기능도 복잡해졌습니다. 웹사이트 자체가 하나의 어플리케이션으로 동작하기 시작했죠. 그렇게 되면서 코드를 모듈 단위로 구성해주어 관심사를 분리해주어야 했습니다. 모듈단위로 나누게되면 모듈 재사용도 가능하게 되고 유지보수가 상당히 용이해지죠. 그래서 Node.js가 나오면서 Common.js라는 모듈시스템이 나오게 되었습니다.
// add.js
function add(a, b) {
return a + b;
}
module.exports = add;
// main.js
const add = require('./calc.js');
위와 같은 코드는 Node.js를 해보신 분들은 자주 접할 수 있는데요. 이러한 Common.js 덕분에 파일 단위로 개발하고 모듈 재사용이 가능해졌습니다. Node.js에서도 이 방식으로 채택하여 CommonJS로 모듈을 관리할 수 있습니다. Node.js가 흥행하면서 CommonJS가 표준처럼 인식되었지만 사실은 CommonJS는 언어의 표준이 아닙니다. 그렇기 때문에 Deno.js나 브라우저는 기본적으로 CommonJS 모듈을 지원하지 않습니다. 또한 CommonJS에는 문제점들이 있었습니다.
require은 하나의 함수입니다. 그렇기 때문에 어디서나 사용할 수 있었죠 예시를 들어보겠습니다.
if(operator === "+" ) {
calc = require('add');
}
if(operator === "-") {
calc = require('minus');
}
calc(a, b)
간단한 예제라 한눈에 들어오지만 코드가 복잡해지면 코드가 어떤 의존성을 갖는지 파악하기가 어려워집니다. 가독성도 떨어지지만 진짜 문제는 이 때문에 CommonJS는 Tree Shaking이 불가능합니다.
** Tree shaking이란?
나무를 흔들어서 죽은 나뭇잎들을 떨어뜨리듯, 코드를 빌드할 때도 실제로 쓰지 않는 코드들을 제외한다는 뜻
Top-level await이 적용되어 있지 않기 때문에 async 함수 안에서만 await을 사용할 수 있습니다.
CommonJS에 이러한 문제점으로 자바스크립트는 ECMAScript Modules(ESM)을 표준으로 잡았습니다.
function add(a, b) {
return a + b;
}
export default add;
// main.js
import add from "add.js"
위의 코드처럼 import/export로 모듈을 관리할 수 있습니다. 위의 CommonJS의 문제점들을 모두 해결하였습니다.
정리해보면 다음과 같습니다.
보통 React를 개발할 때 이런식으로 컴포넌트를 불러옵니다.
import Component from "Component"
그러면 Node.js는 CommonJS 모듈 시스템 환경에서 돌아가는데 우리는 어떻게 위와 같이 불러올 수 있을까요?
그것은 Typescript나 Babel, SWC가 트랜스파일링을 해주기 때문입니다. 실제로 코드는 아래와 같이 변형되어 사용됩니다.(babel 기준)
"use strict";
var _Component = _interopRequireDefault(require("Component"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
애초에 저 방식은 표준과도 맞지 않습니다. 표준은 확장자명을 적어주어야 합니다. 하지만 트랜스파일링을 통해 저 방식 역시 돌아가게 되는 것입니다.
실제로 CommonJS에서 트랜스파일링이 없으면 import를 할 때 다음과 같은 에러가 발생합니다.
그러면 Node.js에서는 ESM을 못쓰는걸까요?
아닙니다. Node.js 12 부터는 ESM으로 개발을 할 수 있습니다.
package.json에 { "type": "module" }을 추가해주면 ESM환경으로 돌아가게 됩니다.
다만 type을 적지 않을 때, 기본값은 commonjs로 동작을 합니다.
Yarn berry에 대한 설명은 여기를 참고해주세요.
CommonJS에 require은 하나의 함수입니다. 그렇기 때문에 require를 재정의 하는 것이 가능합니다. 다른 함수로 덮어씌우는 것이죠. 이것을 활용하는게 yarn berry입니다.
yarn berry는 node_modules에서 탐색하는 것이 아니라 .yarn/cache의 zip파일에서 해당 의존성을 가져옵니다. 그렇기 때문에 require을 재정의할 필요가 있죠.
실제로 .pnp.cjs를 보면 아래와 같이 Monkey Patch를 진행합니다.
require$$0.Module._load = function(request, parent, isMain) {
if (!enableNativeHooks)
return originalModuleLoad.call(require$$0.Module, request, parent, isMain);
if (isBuiltinModule(request)) {
try {
enableNativeHooks = false;
return originalModuleLoad.call(require$$0.Module, request, parent, isMain);
} finally {
enableNativeHooks = true;
}
}
...
하지만 ESM의 import는 명령어이기 때문에 재정의 할 수 없는데 yarn berry를 사용할 수 없는걸까요?
실제로 코드를 돌려보면 yarn berry를 사용해도 esm으로 동작합니다. 어떻게 가능한 것일까요?
여기를 들어가보면 아시겠지만 아직 실험버전인 기능입니다. 그렇기 때문에 불안정하고 node.js에서도 yarn berry에서도 권장하지는 않습니다.
우선 loader에 대한 설명을 보겠습니다.
To customize the default module resolution, loader hooks can optionally be provided via a --experimental-loader ./loader-name.mjs argument to Node.js.
When hooks are used they apply to each subsequent loader, the entry point, and all import calls.
기본 모듈을 커스터마이즈 하기 위해서, "--experimental-loader ./loader-name.mjs"를 node.js에 넘겨주는 것을 통해 선택적으로 loader hooks가 제공된다.
hooks를 사용하면 후속 로더, entry point 및 모든 import 호출에 적용됩니다.
yarn을 통해 node.js를 실행하게 되면 내부적으로 --experimental-loader이라는 옵션을 넣어서 import를 후킹할 수 있게됩니다. 그렇기 때문에 ESM에서도 에러 없이 import 호출로 .yarn/cache에 접근할 수 있었던 것이죠.
실제로 yarn berry가 사용하는 훅은 resolve입니다.
실제 yarn berry 코드에서도 보면
/// berry/packages/plugin-pnp/sources/index.ts
async function setupScriptEnvironment(project: Project, env: {[key: string]: string}, makePathWrapper: (name: string, argv0: string, args: Array<string>) => Promise<void>) {
const pnpPath = getPnpPath(project);
let pnpRequire = `--require ${quotePathIfNeeded(npath.fromPortablePath(pnpPath.cjs))}`;
if (xfs.existsSync(pnpPath.esmLoader))
pnpRequire = `${pnpRequire} --experimental-loader ${pathToFileURL(npath.fromPortablePath(pnpPath.esmLoader)).href}`;
...
nodeOptions = nodeOptions ? `${pnpRequire} ${nodeOptions}` : pnpRequire;
env.NODE_OPTIONS = nodeOptions;
env.NODE_OPTIONS에 해당 옵션을 추가해주고 커맨드를 execute 할 때 NODE_OPTIONS를 추가하는 것을 볼 수 있습니다.
/// berry/packages/yarnpkg-pnpify/sources/commands/RunCommand.ts
async execute() {
let {NODE_OPTIONS} = process.env;
NODE_OPTIONS = `${NODE_OPTIONS || ``} --require ${JSON.stringify(dynamicRequire.resolve(`@yarnpkg/pnpify`))}`.trim();
const {code} = await execUtils.pipevp(this.commandName, this.args, {
cwd: npath.toPortablePath(this.cwd),
stderr: this.context.stderr,
stdin: this.context.stdin,
stdout: this.context.stdout,
env: {...process.env, NODE_OPTIONS},
});
return code;
}
그리고 loader로 설정된 파일은 .pnp.loader.mjs이고 이 파일에서 resolve함수가 정의되어 있습니다.
pnpEsmLoader: `.pnp.loader.mjs` as Filename,
그러면 .pnp.loader.mjs의 resolve 함수에 log를 찍어봅시다.
이렇게 log를 찍고 yarn을 통해 실행해보면
이렇게 후킹되는 것을 확인할 수 있습니다.
Javascript의 모듈시스템과 yarn berry에서 import를 어떻게 수행하는지 알아봤습니다. cjs 같은 경우에는 yarn berry가 require을 monkey patching을 하여 가져오지만 esm은 import를 monkey patching하는 것이 불가능합니다. 그래서 커맨드를 실행할 때 --experimental-loader ./pnp.loader.mjs라는 명령어를 추가해 import를 후킹하여 원하는 의존성을 가져옵니다. 아직 node.js에서 제공하는 loader hooks 기능은 정식버전도 아니라서 불안정합니다. 제 생각에는 yarn berry를 사용하고 계시다면 우선 cjs로 개발을 하다가 점진적으로 esm으로 바꾸는 것이 좋을 것 같습니다.
읽어주셔서 감사합니다!