bbangjobbangjo
Published on
Last updated:

Nodejs top-level scope !== global

Nodejs-toplevel

친구랑 이야기하다가 문득 궁금해진 주제. node our.js를 실행했을 때 node는 our.js를 어떻게 해석하고 컴파일하고 실행하는지 살펴본다. v8 이야기는 나중에!

👀 Run console.log(this === global);

궁금증은 아주 간단한 js 코드로부터 시작한다. (node 인터프리터는 top-level scope가 global이므로 아래의 모든 코드는 node our.js로 실행해야 한다. )

// our.js
console.log(this === global) // false

흠 ... 🤔 당연히 true가 나올 줄 알아서 좀 놀랬다. 이 결과의 이유는 Nodejs Docs에 설명되어 있다.

global

Added in: v0.1.27

  • Object The global namespace object.

In browsers, the top-level scope is the global scope. This means that within the browser var something will define a new global variable. In Node.js this is different. The top-level scope is not the global scope; var something inside a Node.js module will be local to that module.

nodejs 표준(=CommonJS의 명세)은 requiremodule.exports이다. 즉 node our.js를 실행하면, Nodejs는 이를 독립적인 실행 영역이 있는 module로 여기고 이를 v8로 컴파일 한 후 실행한다. 다음 코드를 실행해보자.

// our.js
function a() {
  return a.caller
}
console.log(a().toString())

/* Result
function (exports, require, module, __filename, __dirname) {
    function a() {
        return a.caller
    }
    console.log(a().toString())
}
*/

image

신기하다... Nodejs는 참 신기하다...

이유는 node our.js를 실행하면, Nodejs는 이를 독립적인 실행 영역이 있는 module로 여기고 이를 v8로 컴파일 한 후 실행하기 때문이다. 내부적으로 어떻게 our.js 코드를 module로 만드는지 살펴보자.

🔍 Internal

js에서 에러가 발생하면 콜 스택에 있는 함수가 모두 출력되기 때문에 디버깅할 때 용이하다. 일부러 에러를 발생시켜서 코드를 실행하는 단계를 따라가보자.

// our.js
ErrorCode

/* Result
ErrorCode
^

ReferenceError: ErrorCode is not defined
    at Object.<anonymous> (our.js:1:1)
    at Module._compile (node:internal/modules/cjs/loader:1101:14)
    at Object.Module._extensions..js (node:internal/modules/cjs/loader:1153:10)
    at Module.load (node:internal/modules/cjs/loader:981:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
    at node:internal/main/run_main_module:17:47
*/

대략 살펴보면 run_main -> Module._load -> Module.load -> Object.Module._extensions..js -> Module._compile 의 과정을 거치는 것 같다. 코드는 모두 github에 공개되어 있으니 참고하면서 분석하면 될 것 같다.

분석에 사용한 버전은 16.13.2 이다.

$ node -v
v16.13.2

internal/main/run_main_module:

...
require('internal/modules/cjs/loader').Module.runMain(process.argv[1]);

Module.runMain()을 실행한다. process.argv[1]node our.js 를 실행하기 때문에 ['node', 'our.js']에서 1번 인덱스에 있는 our.js이다. 값은 절대경로로 들어간다.

internal/modules/run_main:

...
function executeUserEntryPoint(main = process.argv[1]) {
  const resolvedMain = resolveMainPath(main);
  const useESMLoader = shouldUseESMLoader(resolvedMain);
  if (useESMLoader) {
    runMainESM(resolvedMain || main);
  } else {
    // Module._load is the monkey-patchable CJS module loader.
    Module._load(main, null, true);
  }
}

여기서 useESMLoader 인지 확인하는데, 지금 우리의 경우에서는 false이므로 our.js를 인자로 주고 Module._load()를 실행하게 된다. 위의 에러코드에서도 확인할 수 있다.

internal/modules/cjs/loader:

Module._load = function(request, parent, isMain) {
 let relResolveCacheIdentifier;
 ...

 const filename = Module._resolveFilename(request, parent, isMain);
 ...

 // Don't call updateChildren(), Module constructor already does.
 const module = cachedModule || new Module(filename, parent);
 ...

 if (isMain) {
   process.mainModule = module;
   module.id = '.';
 }
 ...

 if (parent !== undefined) {
   relativeResolveCache[relResolveCacheIdentifier] = filename;
 }
 ...

 let threw = true;

 try {
   module.load(filename);
   threw = false;
 } finally {
   ...
 }

 return module.exports;
};

run_main에서 이 함수를 호출할 때 다음과 같이 호출했다.

Module._load(main /*process.argv[1]*/, null, true)

const module = cachedModule || new Module(filename, parent); 에서 우리가 입력한 파일이름을 바탕으로 새로운 module 인스턴스를 생성한다. isMain은 true이기 때문에 process.mainModule에 새로 생성한 module 인스턴스를 넣는다. 이를 통해 global 스코프에 있는 process에서 module에 접근할 수 있다.

결과적으로 module.load(filename); 를 호출하게 된다.

internal/modules/cjs/loader:

Module.prototype.load = function(filename) {
  ...

  const extension = findLongestRegisteredExtension(filename);
  ...

  Module._extensions[extension](this, filename);
  ...
};

filename에서 확장자를 뽑고, Module._extensions[extension](this, filename)를 호출한다. node our.js를 실행했기 때문에 extension은 js 이다.

internal/modules/cjs/loader:

Module._extensions['.js'] = function(module, filename) {
  // If already analyzed the source, then it will be cached.
  const cached = cjsParseCache.get(module);
  let content;
  if (cached?.source) {
    content = cached.source;
    cached.source = undefined;
  } else {
    content = fs.readFileSync(filename, 'utf8');
  }
  ...

  module._compile(content, filename);
};

filename을 통해 파일 내용을 읽어오고 content에 저장한다. 그리고 module._compile(content, filename);를 호출한다.

internal/modules/cjs/loader:

Module.prototype._compile = function(content, filename) {
  ...

  const compiledWrapper = wrapSafe(filename, content, this);
  ...

  const dirname = path.dirname(filename);
  const require = makeRequireFunction(this, redirects);
  let result;
  const exports = this.exports;
  const thisValue = exports;
  const module = this;
  ...

  if (inspectorWrapper /* false now */ ) {
    ...

  } else {
    result = ReflectApply(compiledWrapper, thisValue,
                          [exports, require, module, filename, dirname]);
  }
  return result;
};

가장 먼저 contentfilename을 통해 수상한 이름의 wrapSafe를 호출한다.

wrapSafe:

function wrapSafe(filename, content, cjsModuleInstance) {
  if (patched) {
    const wrapper = Module.wrap(content);
    return vm.runInThisContext(wrapper, {
      filename,
      lineOffset: 0,
      displayErrors: true,
      importModuleDynamically: async (specifier) => {
        const loader = asyncESM.esmLoader;
        return loader.import(specifier, normalizeReferrerURL(filename));
      },
    });
  }
  try {
    return vm.compileFunction(content, [
      'exports',
      'require',
      'module',
      '__filename',
      '__dirname',
    ], {
      filename,
      importModuleDynamically(specifier) {
        const loader = asyncESM.esmLoader;
        return loader.import(specifier, normalizeReferrerURL(filename));
      },
    });
  } catch (err) {
    ...

  }
}

patched가 true라면 Module.wrap을 호출하고 wrapping 된 함수를 vm에서 실행한 후 그 결과를 반환하고, 아니라면 컴파일하는 식이다.

~/node/out/Release$ ./node ../../test.js
patched: false
hello world

node internal 디버깅하는 법을 잘 몰라서, 그냥 node 패치해서 patched 값 출력하도록 했다. 그러니 false가 나옴.. 이유는 알아봐야겠다. 어쨋든 지금의 경우에는 try catch 로직을 탄다는 뜻이다.

wrap:

let wrap = function (script) {
  return Module.wrapper[0] + script + Module.wrapper[1]
}

const wrapper = ['(function (exports, require, module, __filename, __dirname) { ', '\n});']

글 초반부 코드에서 봤던 찾았다! 생각보다 허무하게 만들어주고 있었다. ㅋㅋㅋㅋ 그냥 파일 내용(코드)을 string concat 해서 wrapper 로 만들고 이를 vm 인스턴스에서 실행한다.

마지막으로 Module._compile 내부적으로 선언했던 exports, require, module, filename, dirname을 vm compile 인자로 넘겨준다.

// our.js
function a() {
  return a.caller
}
console.log(a().toString())

/* Result
function (exports, require, module, __filename, __dirname) {
    function a() {
        return a.caller
    }
    console.log(a().toString())
}
*/

이제야 our.js를 실행했을 때 저런 결과가 나오는지 알게 되었다 !!!

🤔 require in global ??

좀 더 생각해볼만 한 게 있다. 평소 nodejs를 사용하다보면 require('fs'), require('express') 등등 require()을 정~~말 자주 사용한다. require() 함수를 지정해준 적이 없는데 어떻게 사용할 수 있을까? 대부분의 경우는 global 객체에 선언되어 있어서 사용할 수 있다 ! 가 정답이지만 require의 경우는 다르다. 아래 코드를 직접 실행해보자.

console.log('require' in Object.getOwnPropertyNames(global)) // false

그럼 global에도 없는 함수를 어떻게 호출하고 사용할 수 있었던 걸까? 그 답이 위에 있다. Nodejs가 our.js를 실행하면 nodejs 표준(=CommonJS의 명세)에 따라 코드를 모듈화 하게 되고, 그 과정에서 우리의 코드는 다음과 같은 형태로 변하게 된다.

function (exports, require, module, __filename, __dirname) {
    // our code
}

our code 코드에서 exports, require 등의 파라미터에 접근이 가능하다. 그리고 저 값들은 Module._compile에 선언되어 있다. 즉 우리가 평소 사용하는 require() 함수는 Module._compile에 정의되어 있는 require 함수 인 것이다 !!!!

image

진짜 신기하다 ㅋ!ㅋ!ㅋ!ㅋ!ㅋ!ㅋ!

그래서 우리는 require를 다음과 같은 방식으로도 접근할 수 있다.

function a() {
  return a.caller
}
console.log(a().arguments[1] == require) // true

마무리

결론: node는 코드를 실행할 때 모듈화해주고, 이를 직접 확인해볼 수 있다.

주의: 내가 분석한 건 cjs, 즉 CommonJS 모듈 기반 코드이다. 하지만, 요즘 node 버전에서는 ES module 지원이 되고 이게 클라, 서버 모두의 JS 모듈 표준이다. ps. ES module을 사용하면서 엄청 오래된 패키지를 사용하면 import할 수 없는 이유가 이 때문이다.

오랜만에 큰 의미는 없지만 도파민은 터지는 공부하니까 재밌다. 가끔 이런 걸 뒤적뒤적하는 것만으로도 내가 사용하는 언어, 생태계에 더 관심이 간다. 기여하고 싶다는 생각도 들고.

Reference

🚀 https://github.com/nodejs/node

🚀 https://nodejs.org/en/docs/