오래 전, Javascript는 오로지 브라우저에서 간단한 처리를 위해 태어났다.
따라서 js에는 import도, export도 존재하지 않았다.
그러다 commonjs라는 자바스크립트 모듈에 대한 표준을 정의한 사람 덕분에 우리는 오늘날 require
, import
등의 문법(혹은 함수)를 사용해 모듈화된 패키지를 이용할 수 있게 되었다.
이제 이 녀석을 우리는 줄여서 cjs
라고 한다.
cjs
는 매우 훌륭했지만 웹 환경이 성장하며 mjs
라 불리우는, esmodule
이라는 것이 도입되었고, 이제 자바스크립트에 도입되었다.
사실 대부분의 환경에서는 대다수의 외부 라이브러리들이 mjs
를 지원하지 않아 선택지가 많지 않지만, 만약 가능하다면 mjs
를 사용하는 것이 더 좋을 것이다.
cjs
도 이미 훌륭한데 어째서 mjs
가 더 좋다고 생각하는지는 동의하지 않는 분들도 많을 것이다. 또 사실 얼마 전까지는 나도 별로 중요한 문제는 아니라고 생각하였다.
그런데 우연히 간단한 코드에서 치명적인 문제를 발견하여 문제의 원인을 분석하던 중 cjs
의 재미있는 특징을 발견하였다.
그럼 지금부터 내가 왜 mjs
가 cjs
보다 좋을 수 있다고 생각했는지에 대한 이유를 말해 보겠다.
mjs하고 esm, es module등의 단어가 혼재되어 있는데 각각은 사실상 같은 의미, es module을 의미한다고 이해해 주면 될 것 같다.
최근 자바스크립트 기반의 프로젝트를 수행하면서 코드를 정돈하고, 기능 단위로 묶어 모듈화를 하던 중 당황스런 에러를 마주했다.
당시 마주했던 문제는 이미 해결해서 동일한 문제를 일으킬 수 있는 재현코드를 사용해 글을 쓸 것이다.
이 문제를 일으키는 코드는 다음과 같다.
// 폴더 구조
// .
// ├── lib
// │ ├── index.ts
// │ └── inner
// │ ├── conflict.ts
// │ └── index.ts
// └── main.ts
// main.ts
import { calltest } from "./lib/index.js";
console.log(calltest)
// lib/index.ts
export { calltest, test } from "./inner/index.js"
// lib/inner/index.ts
export function test() {
return 1;
}
export { calltest } from "./conflict.js";
// lib/inner/conflict.ts
import { test } from "../index.js";
export const calltest = test()
구조는 다음과 같다.
main.ts
에서 lib/index.js
를 불러 온다.lib/index.js
는 lib/inner/index.js
를 불러온다.lib/inner/index.js
는 test
를 export 한다.lib/inner/index.js
는 lib/inner/conflict.js
를 불러온다.lib/inner/conflict.js
는 부모 디렉터리에서 index.js
, 즉 lib/inner/index.js
를 불러온다.lib/inner/conflict.js
에서 lib/inner/index.js
의 test
함수를 이용해 calltest
라는 상수를 정의한다.main.ts
에서 calltest
를 출력한다.이 과정에서는 문제될 것이 없어 보인다.
하지만 이 코드는 실행시 위와 같은 에러를 내뿜는다.
그 이유는 calltest
를 정의하는 순간, test
는 이미 정의가 되었을 것이라 생각한 것과 다르게 아직 정의가 되지 않은 상태, 즉 undefined
이기 때문이다.
그런데 이를 정말 어렵게 만드는 것은 아래 코드이다.
// lib/inner/conflict.ts
import { test } from "./index.js";
export const calltest = test()
위의 lib/inner/conflict.ts
에서 단 한글자, ../index.js
가 아닌 ./index.js
를 이용하였다.
놀랍게도 그러면 이 문제는 해결된다.
이 문제는 생각보다 매우 복잡하고, cjs
의 구현상의 특징 때문에 일어나는 문제인데, 이런 문제는 추적하기도 어렵고 원인을 분석하기는 더 어렵다.
이는 매우 근원적인, require가 어떤 방식으로 패키지를 불러오는지가 원인이 되는데 이를 자세히 분석해 볼 예정이다.
commonjs는 스크립트 호출 과정에 따라 일정한 약속을 통해 모듈을 유사하게 구현하는 것이다.
따라서 commonjs에 의하면 위의 코드들을 컴파일해 아래와 같은 코드를 내보내게 된다.
// main.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
Object.defineProperty(exports, "[whoami]", {value : "main.js"});
const lib = require("./lib/index.js");
console.log(lib.calltest);
// lib/index.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });Object.defineProperty(exports, "[whoami]", {value : "lib/index.js"});
exports.test = exports.calltest = void 0;
var lib_inner = require("./inner/index.js");
Object.defineProperty(exports, "calltest", { enumerable: true, get: function () { return lib_inner.calltest; } });
Object.defineProperty(exports, "test", { enumerable: true, get: function () { return lib_inner.test; } });
// lib/inner/index.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
Object.defineProperty(exports, "[whoami]", {value : "lib/inner/index.js"})
exports.calltest = exports.test = void 0;
function test() {
return 1;
}
exports.test = test;
var lib_conflict = require("./conflict.js");
Object.defineProperty(exports, "calltest", { enumerable: true, get: function () { return lib_conflict.calltest; } });
// lib/inner/conflict.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
Object.defineProperty(exports, "[whoami]", {value : "lib/inner/conflict.js"})
exports.calltest = void 0;
const index = require("../index.js");
exports.calltest = (0, index.test)();
여기서 exports에
whoami
변수는 원래 설정되지 않는다, 또 설명의 편의를 위해 실제 컴파일된 코드에 약간의 수정을 가했다.
그러면 이제 순서대로 코드를 따라가서 제일 마지막 줄
exports.calltest = (0, index.test)();
이 호출되는 과정을 한번 생각해 보자.
과정을 나타내면 다음과 같을 것이다.
1 ) main > const lib = require("lib/index.js")
2 ) lib > > test = undefined
3 ) lib > > calltest = undefined
4 ) lib > > const lib_inner = require("lib/inner/index.js")
5 ) lib/inner > > > test = undefined
6 ) lib/inner > > > test = function() { return 1 }
7 ) lib/inner > > > const lib_conflict = require("lib/inner/conflict.js")
8 ) lib/conflict > > > > const lib = require("lib/index.js")
9 ) lib/conflict > > > > calltest = lib.test()
10 ) lib/inner > > > test = lib_conflict.calltest
11 ) lib > > test = lib_inner.test
12 ) lib > > calltest = lib_inner.calltest
13 ) main > console.log(lib.calltest)
여기서 가장 중요한 것은 lib/conflict
에서 불러온 모듈은 lib
로
이 과정은 순서대로 이루어지므로 lib/inner
에서는 test
에 함수가 설정되었지만,
lib
에서 test
가 설정되려면 일단 lib/inner
가 끝나야 하기 때문이다.
하지만 lib/conflict
에서 lib/inner
를 가져오면 lib/inner
에는 이미 test
가 설정되어 있으므로 문제가 발생하지 않는다.
즉, 진짜로 js에서는 모듈이 (정확히는 commonjs) 순서대로 로딩되기에 모듈의 심볼에 undefined인 순간이 존재할 수 있다.
commonjs를 제외한 대개의 import 과정은 위처럼 순서대로 이뤄지지 않는다.
대게 symbol을 미리 구성하고, symbol간의 의존관계를 파악해, 모듈 로더에서 이런 순서를 자동으로 맞춰주는 것이 일반적이다.
즉 위의 작업은 일반적인(commonjs가 아닌 모듈 로더)에서는 아래와 같이 구성되어야 한다.
1 ) main > const lib = require("lib/index.js")
2 ) lib > > test = undefined
3 ) lib > > calltest = undefined
4 ) lib > > const lib_inner = require("lib/inner/index.js")
5 ) lib/inner > > > test = undefined
6 ) lib/inner > > > test = function() { return 1 }
7 ) lib/inner > > > const lib_conflict = require("lib/inner/conflict.js")
8 ) lib/conflict > > > > const lib = require("lib/index.js")
여기까지는 정상적으로 동작되었지만, lib.test는 아직 undefined 이다.
lib.test는 11번째 줄을 참조하면 lib_inner.test에 의존한다.
lib_inner.test는 6번째 줄에서 이미 설정되었다.
따라서 lib.test의 의존성 순서에 따라 11번째 줄을 미리 실행한다.
11 ) lib > > test = lib_inner.test
9 ) lib/conflict > > > > calltest = lib.test()
10 ) lib/inner > > > test = lib_conflict.calltest
12 ) lib > > calltest = lib_inner.calltest
13 ) main > console.log(lib.calltest)
하지만 commonjs는 이러한 재배치 없이, 정말 함수가 실행되는 순서처럼 실행되므로 이런식으로 특정 모듈의 값이 undefined인 순간이 존재할 수 있다.
즉, 이와 같이 모듈의 자식 요소가 부모의 요소에 접근할 때 이런 문제는 빈번히 발생할 수 있다.
따라서 commonjs에선 index.js 에서 어떤 모듈을 export할 지에 대한 순서가 매우매우매우 중요하다.
다음과 같은 이유로 commonjs 에서는 실패하던 코드가 esmodule에서는 성공적으로 통과한다.
이는 esmodule은 이러한 순서를 자동으로 인식해 위의 작업을 수행해 주기 때문이다.
이는 어째서 esmodule이 단순히 문법적으로 보기 좋은 것 만이 아닌 더 큰 의미를 지니는지에 대한 대답이 될 수 있다.
많은 테크 블로그의 글에서 이야기하는 ESM에서는 비동기적으로 의존성 그래프를 만든다 라고 하는 의미가 바로 이런 문제를 해결하는 것들이다.
node 환경은 처음이고 처음 내가 js를 배웠던 시기는 commonjs가 유행을 타기 전이였다.
그래서 commonjs는 처음이였고 이러한 문제가 있을 수도 있다는 것은 상상조차 하지 못했다.
특히 typescript에서 cjs 컴파일을 하는 것이다 보니 더더욱 이 문제에 대한 원인을 분석하는 것이 힘들었다.
개인적으로 이러한 문제는 매우 치명적이고, 반드시 수정해야 하는 문제라고 생각되는데 안타깝게도 node.js 생태계는 es module에 아직 익숙하지 않은 것 같다.
많은 주변 사람들이 js의 변화무쌍함에 고통을 토로하고는 하는데, 이번 문제를 분석하면서 변화무쌍함의 원인이 언어로서의 첫 단추가 애매하게 끼워진 탓이 크다고 느껴졌다.
그래서 새로운 것을 계속 배워야 하는 프로그래머들 중에서도 유난히 정신없이 돌아가는 js 생태계는 앞으로는 더 정신없이 새로운 것이 나올 수도 있다는 생각이 문뜩 들기도 한다.