- Published on
- Last updated:
Nodejs top-level scope !== global
친구랑 이야기하다가 문득 궁금해진 주제. node our.js
를 실행했을 때 node는 our.js
를 어떻게 해석하고 컴파일하고 실행하는지 살펴본다. v8 이야기는 나중에!
console.log(this === global)
;
👀 Run 궁금증은 아주 간단한 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의 명세)은 require
과 module.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())
}
*/
신기하다... 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;
};
가장 먼저 content
와 filename
을 통해 수상한 이름의 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
함수 인 것이다 !!!!
진짜 신기하다 ㅋ!ㅋ!ㅋ!ㅋ!ㅋ!ㅋ!
그래서 우리는 require
를 다음과 같은 방식으로도 접근할 수 있다.
function a() {
return a.caller
}
console.log(a().arguments[1] == require) // true
마무리
결론: node는 코드를 실행할 때 모듈화해주고, 이를 직접 확인해볼 수 있다.
주의: 내가 분석한 건 cjs, 즉 CommonJS 모듈 기반 코드이다. 하지만, 요즘 node 버전에서는 ES module 지원이 되고 이게 클라, 서버 모두의 JS 모듈 표준이다. ps. ES module을 사용하면서 엄청 오래된 패키지를 사용하면 import할 수 없는 이유가 이 때문이다.
오랜만에 큰 의미는 없지만 도파민은 터지는 공부하니까 재밌다. 가끔 이런 걸 뒤적뒤적하는 것만으로도 내가 사용하는 언어, 생태계에 더 관심이 간다. 기여하고 싶다는 생각도 들고.