commonjs에서 모듈 불러오기에서 undefined 값을 읽는 문제

김성현·2022년 2월 26일
1
post-thumbnail

오래 전, Javascript는 오로지 브라우저에서 간단한 처리를 위해 태어났다.
따라서 js에는 import도, export도 존재하지 않았다.

그러다 commonjs라는 자바스크립트 모듈에 대한 표준을 정의한 사람 덕분에 우리는 오늘날 require, import 등의 문법(혹은 함수)를 사용해 모듈화된 패키지를 이용할 수 있게 되었다.

이제 이 녀석을 우리는 줄여서 cjs라고 한다.

cjs는 매우 훌륭했지만 웹 환경이 성장하며 mjs 라 불리우는, esmodule 이라는 것이 도입되었고, 이제 자바스크립트에 도입되었다.

사실 대부분의 환경에서는 대다수의 외부 라이브러리들이 mjs를 지원하지 않아 선택지가 많지 않지만, 만약 가능하다면 mjs를 사용하는 것이 더 좋을 것이다.

cjs도 이미 훌륭한데 어째서 mjs가 더 좋다고 생각하는지는 동의하지 않는 분들도 많을 것이다. 또 사실 얼마 전까지는 나도 별로 중요한 문제는 아니라고 생각하였다.

그런데 우연히 간단한 코드에서 치명적인 문제를 발견하여 문제의 원인을 분석하던 중 cjs의 재미있는 특징을 발견하였다.

그럼 지금부터 내가 왜 mjscjs보다 좋을 수 있다고 생각했는지에 대한 이유를 말해 보겠다.

mjs하고 esm, es module등의 단어가 혼재되어 있는데 각각은 사실상 같은 의미, es module을 의미한다고 이해해 주면 될 것 같다.

이해할 수 없는 에러, 어째서 함수가 undefined 일 수 있지?

최근 자바스크립트 기반의 프로젝트를 수행하면서 코드를 정돈하고, 기능 단위로 묶어 모듈화를 하던 중 당황스런 에러를 마주했다.

당시 마주했던 문제는 이미 해결해서 동일한 문제를 일으킬 수 있는 재현코드를 사용해 글을 쓸 것이다.

이 문제를 일으키는 코드는 다음과 같다.

// 폴더 구조
// .
// ├── 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()

구조는 다음과 같다.

  1. main.ts에서 lib/index.js를 불러 온다.
  2. lib/index.jslib/inner/index.js를 불러온다.
  3. lib/inner/index.jstest를 export 한다.
  4. lib/inner/index.jslib/inner/conflict.js를 불러온다.
  5. lib/inner/conflict.js는 부모 디렉터리에서 index.js, 즉 lib/inner/index.js를 불러온다.
  6. lib/inner/conflict.js에서 lib/inner/index.jstest 함수를 이용해 calltest라는 상수를 정의한다.
  7. 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는 스크립트 호출 과정에 따라 일정한 약속을 통해 모듈을 유사하게 구현하는 것이다.

따라서 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할 지에 대한 순서가 매우매우매우 중요하다.

ESModule이 훌륭한 이유

다음과 같은 이유로 commonjs 에서는 실패하던 코드가 esmodule에서는 성공적으로 통과한다.

이는 esmodule은 이러한 순서를 자동으로 인식해 위의 작업을 수행해 주기 때문이다.

이는 어째서 esmodule이 단순히 문법적으로 보기 좋은 것 만이 아닌 더 큰 의미를 지니는지에 대한 대답이 될 수 있다.

많은 테크 블로그의 글에서 이야기하는 ESM에서는 비동기적으로 의존성 그래프를 만든다 라고 하는 의미가 바로 이런 문제를 해결하는 것들이다.


node 환경은 처음이고 처음 내가 js를 배웠던 시기는 commonjs가 유행을 타기 전이였다.

그래서 commonjs는 처음이였고 이러한 문제가 있을 수도 있다는 것은 상상조차 하지 못했다.

특히 typescript에서 cjs 컴파일을 하는 것이다 보니 더더욱 이 문제에 대한 원인을 분석하는 것이 힘들었다.

개인적으로 이러한 문제는 매우 치명적이고, 반드시 수정해야 하는 문제라고 생각되는데 안타깝게도 node.js 생태계는 es module에 아직 익숙하지 않은 것 같다.

많은 주변 사람들이 js의 변화무쌍함에 고통을 토로하고는 하는데, 이번 문제를 분석하면서 변화무쌍함의 원인이 언어로서의 첫 단추가 애매하게 끼워진 탓이 크다고 느껴졌다.

그래서 새로운 것을 계속 배워야 하는 프로그래머들 중에서도 유난히 정신없이 돌아가는 js 생태계는 앞으로는 더 정신없이 새로운 것이 나올 수도 있다는 생각이 문뜩 들기도 한다.

profile
수준 높은 기술 포스트를 위해서 노력중...

0개의 댓글