혹시 Node.js 스크립트를 실행하면 어떤 방식으로 동작을 하는지 궁금하지 않았나요?
이 글은 이런 궁금증을 가지고 있는 분들에게 조금이나마 도움이 되고자 하는 글이니, 관심이 없으시다면 스킵하셔도 됩니다 :D
먼저, Node.js를 분석하기 위해서 코드를 다운 받아봅시다.
$ git clone https://github.com/nodejs/node.git && cd node
Node.js의 파일 구조는 아래와 같은 모습을 가지고 있습니다.
$ tree -L 1
.
├── AUTHORS
├── BSDmakefile
├── BUILDING.md
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── COLLABORATOR_GUIDE.md
├── CONTRIBUTING.md
├── CPP_STYLE_GUIDE.md
├── GOVERNANCE.md
├── LICENSE
├── Makefile
├── README.md
├── SECURITY.md
├── android-configure
├── benchmark
├── common.gypi
├── configure
├── configure.py
├── deps
├── doc
├── lib # JS sources
├── node.gyp
├── node.gypi
├── src # C++ sources
├── test
├── tools
└── vcbuild.bat
7 directories, 20 files
노드는 크게 2종류의 언어로 이루어져 있는데 lib/
폴더 안에 있는 JavaScript 파일 들과 src/
폴더 밑에 존재하는 많은 양의 C++ 파일 들이 있습니다.
Node.js의 모든 시작 과정은 C++에서 완료되며, main 시작 지점은 src/node_main.cc
입니다.
이 안에서 제일 중요한 부분은 다음과 같습니다.
return node::Start(argc, argv);
이 것은 src/node.cc
의 Start
을 호출하고 command line의 parameter들을 argc
와 argv
로 넘겨줍니다.
첫 번째 단계로 Start()
안에서 런타임 초기화 작업들이 수행되는데 InitializeOncePerProcess
는 환경변수(예.NODE_OPTIONS
)나 CLI parameters(예.--abort-on-uncaught-exception
)를 통해 주어지는 설정 값들을 처리하고 V8을 초기화 해준다. 한 번 이작업이 완료가 되면, 새로운 node 인스턴스들은 libuv default loop를 사용해 초기화 된 후 마침내 아래와 같이 실행되게 됩니다.
NodeMainInstance main_instance(¶ms,
uv_default_loop(),
per_process::v8_platform.Platform(),
result.args,
result.exec_args,
indexes);
result.exit_code = main_instance.Run()
NodeMainInstance::Run()
을 통해 Node를 실행 시 어떤 일들이 수행되는 지를 알 수 있는 길에 좀 더 가까워졌습니다. 새로운 메인 쓰레드 수행 환경이 src/node_main_instance.cc
에서 만들어지는데:
std::unique_ptr<Environment> env = CreateMainEnvironment(&exit_code);
이 Environment
인스턴스가 libuv와 V8을 접근 할 수 있는 Handle들을 가지고 있는 노드 프로세스의 중심 객체입니다.
이 객체를 LoadEnvironment
에 넘기면
LoadEnvironment(env.get());
main thread 수행이 시작된다:
void LoadEnvironment(Environment* env) {
CHECK(env->is_main_thread());
USE(StartMainThreadExecution(env));
}
이 부분에서 C++에서 JavaScript 수행으로 변경이 됩니다.
MaybeLocal<Value> StartMainThreadExecution(Environment* env) {
if (NativeModuleEnv::Exists("_third_party_main")) {
return StartExecution(env, "internal/main/run_third_party_main");
}
std::string first_argv;
if (env->argv().size() > 1) {
first_argv = env->argv()[1];
}
if (first_argv == "inspect" || first_argv == "debug") {
return StartExecution(env, "internal/main/inspect");
}
if (per_process::cli_options->print_help) {
return StartExecution(env, "internal/main/print_help");
}
if (env->options()->prof_process) {
return StartExecution(env, "internal/main/prof_process");
}
// -e/--eval without -i/--interactive
if (env->options()->has_eval_string && !env->options()->force_repl) {
return StartExecution(env, "internal/main/eval_string");
}
if (env->options()->syntax_check_only) {
return StartExecution(env, "internal/main/check_syntax");
}
if (!first_argv.empty() && first_argv != "-") {
return StartExecution(env, "internal/main/run_main_module");
}
if (env->options()->force_repl || uv_guess_handle(STDIN_FILENO) == UV_TTY) {
return StartExecution(env, "internal/main/repl");
}
return StartExecution(env, "internal/main/eval_stdin");
}
StartExecution
의 경우 두번째 인자로 넘겨지는 JS파일들을 읽고, 컴파일하고, 실행하는데 모든 파일은 lib/
폴더 밑에 있는데, 이중 관심있게 봐야하는 곳은 두 곳 입니다.
if (!first_argv.empty() && first_argv != "-") {
return StartExecution(env, "internal/main/run_main_module");
}
if (env->options()->force_repl || uv_guess_handle(STDIN_FILENO) == UV_TTY) {
return StartExecution(env, "internal/main/repl");
}
lib/internal/main/repl.js
와 lib/internal/main/run_main_module.js
두 스크립트 모두 중요한 시작 메소드인 lib/internal/bootstrap/pre_execution.js
안의 prepareMainThreadExecution
을 실행하는데, 이것은 여러 setup 작업들을 수행하는데 이 중 CommonJS와 ES module loader 초기화 역시 수행됩니다.
lib/internal/modules/cjs/loader.js
안의 Module
객체는 CommonJS loaders core이며 initializeCJSLoader
는 runMain
를 통해 lib/internal/modules/run_main.js
안의 executeUserEntryPoint
을 실행해줍니다.
CommonJS module의 경우 Module._load
로 새로운 Module
인스턴스를 생성하고 load
를 호출 해주고 적절한 extension function이 module을 읽어오는데 사용됩니다.
Module._extensions[extension](this, filename);
*.js
extension이 실제 파일을 읽고 컴파일 해줍니다.
const content = fs.readFileSync(filename, 'utf8');
module._compile(content, filename)
module._compile
은 V8의 ScriptCompiler:CompileFunctionInContext
를 호출하고 node module wrapper에 맞는 exports
, require
, module
, __filename
, __dirname
을 넘겨줍니다.
이렇게 불려진 function은 실행이 되고 결과를 리턴해줍니다:
result = compiledWrapper.call(thisValue, exports, require, module,
filename, dirname);
다음으로 확인 해봐야할 부분은 libuv eventloop입니다.
JavaScript를 컴파일하고 실행한 후, node 인스턴스는 event loop를 시작해줍니다.
do {
uv_run(env->event_loop(), UV_RUN_DEFAULT);
per_process::v8_platform.DrainVMTasks(isolate_);
more = uv_loop_alive(env->event_loop());
if (more && !env->is_stopping()) continue;
if (!uv_loop_alive(env->event_loop())) {
EmitBeforeExit(env.get());
}
// Emit `beforeExit` if the loop became alive either after emitting
// event, or after running some callbacks.
more = uv_loop_alive(env->event_loop());
} while (more == true && !env->is_stopping());
uvrunmode UV_RUN_DEFAULT
는 더이상 동작하거나 참조되는 곳이 없을때 까지 event loop를 수행합니다.
libuv는 lifetime에 따라 handle과 request로 구분이 되는데, 오랫동안 수행되는 객체의 경우 handle이라고 할 수 있고 짧은 시간동안 수행되는 operation들을 request라고 할 수 있습니다.
const http = require('http');
const requestHandler = (req, res) => {
res.write('Hello World!');
res.end();
};
const server = http.createServer(requestHandler);
server.listen(8080);
위 예제의 경우 requestHandler
를 libuv request로 볼 수 있습니다. 반면, server
객체의 listen
은 handle이라고 할 수 있습니다.
HTTP server를 멈추지 않는 이상, libuv는 계속해서 실행되고 입력되는 연결들을 처리할 것 입니다.
위 과정들이 Node.js에서 스크립트가 어떻게 수행되는 지를 설명한 내용입니다. 물론, 전체 과정을 상세히 다루는게 아니기 때문에 설명이 부족한 부분이 있을 수 있겠지만, 스크립트가 수행되는 과정에서의 중요한 부분들을 설명해주고 있습니다.
혹시 기회가 된다면 다음 포스트를 통해 추가적인 내용들을 다루어 보겠습니다.