Node.js 내부 탐색

게시 됨: 2022-03-10
요약 ↬ Node.js는 웹 개발자에게 흥미로운 도구입니다. 높은 수준의 동시성을 통해 웹 개발에 사용할 도구를 선택하는 사람들의 주요 후보가 되었습니다. 이 기사에서는 Node.js를 구성하는 요소에 대해 배우고, 의미 있는 정의를 제공하고, Node.js의 내부가 서로 상호 작용하는 방식을 이해하고, GitHub에서 Node.js용 프로젝트 저장소를 탐색합니다.

Ryan Dahl이 2009년 11월 8일 European JSConf에서 Node.js를 소개한 이후로 기술 산업 전반에 걸쳐 널리 사용되었습니다. Netflix, Uber 및 LinkedIn과 같은 회사는 Node.js가 많은 양의 트래픽과 동시성을 견딜 수 있다는 주장을 신뢰합니다.

기본 지식으로 무장한 Node.js의 초급 및 중급 개발자는 "그냥 런타임일 뿐이에요!"라고 많은 어려움을 겪습니다. "이벤트 루프가 있습니다!" "Node.js는 JavaScript와 같은 단일 스레드입니다!"

이러한 주장 중 일부는 사실이지만 Node.js 런타임에 대해 더 깊이 파고들어 JavaScript 실행 방법을 이해하고 실제로 단일 스레드인지 확인하고 마지막으로 핵심 종속성인 V8과 libuv 간의 상호 연결을 더 잘 이해할 것입니다. .

전제 조건

  • JavaScript에 대한 기본 지식
  • Node.js 시맨틱( require , fs )에 대한 지식

Node.js란 무엇입니까?

많은 사람들이 Node.js에 대해 믿었던 것을 가정하고 싶을 수 있습니다. Node.js에 대한 가장 일반적인 정의는 이것이 JavaScript 언어의 런타임 이라는 것입니다. 이것을 고려하기 위해 우리는 무엇이 이러한 결론에 이르렀는지 이해해야 합니다.

Node.js는 종종 C++와 JavaScript의 조합으로 설명됩니다. C++ 부분은 컴퓨터에 연결된 하드웨어에 액세스할 수 있도록 하는 저수준 코드를 실행하는 바인딩으로 구성됩니다. JavaScript 부분은 JavaScript를 소스 코드로 사용하고 V8 엔진이라는 인기 있는 언어 인터프리터에서 실행합니다.

이러한 이해를 바탕으로 Node.js를 JavaScript와 C++를 결합하여 브라우저 환경 외부에서 프로그램을 실행하는 고유한 도구로 설명할 수 있습니다.

그러나 실제로 런타임이라고 부를 수 있습니까? 이를 확인하기 위해 런타임이 무엇인지 정의해 보겠습니다.

StackOverflow에 대한 그의 답변 중 하나에서 DJNA는 런타임 환경을 "프로그램을 실행하는 데 필요한 모든 것이지만 이를 변경할 도구는 없는 것"으로 정의합니다. 이 정의에 따르면 코드를 실행하는 동안 발생하는 모든 일(어떠한 언어로든)이 런타임 환경에서 실행되고 있다고 자신 있게 말할 수 있습니다.

다른 언어에는 자체 런타임 환경이 있습니다. Java의 경우 JRE(Java Runtime Environment)입니다. .NET의 경우 CLR(공용 언어 런타임)입니다. Erlang의 경우 BEAM입니다.

그럼에도 불구하고 이러한 런타임 중 일부에는 이에 의존하는 다른 언어가 있습니다. 예를 들어 Java에는 JRE가 이해할 수 있는 코드로 컴파일되는 프로그래밍 언어인 Kotlin이 있습니다. Erlang에는 Elixir가 있습니다. 그리고 우리는 .NET Framework로 알려진 CLR에서 모두 실행되는 .NET 개발을 위한 많은 변형이 있다는 것을 알고 있습니다.

이제 우리는 런타임이 프로그램이 성공적으로 실행될 수 있도록 제공되는 환경임을 이해하고 V8 및 C++ 라이브러리 호스트를 통해 Node.js 애플리케이션을 실행할 수 있다는 것을 알고 있습니다. Node.js 자체는 라이브러리를 엔터티로 만들기 위해 모든 것을 함께 묶는 실제 런타임이며 Node.js가 무엇으로 빌드되었는지에 관계없이 JavaScript라는 하나의 언어만 이해합니다.

점프 후 더! 아래에서 계속 읽기 ↓

Node.js의 내부 구조

node index.js 명령을 사용하여 명령줄에서 Node.js 프로그램(예: index.js )을 실행하려고 하면 Node.js 런타임을 호출하는 것입니다. 언급한 대로 이 런타임은 V8과 libuv라는 두 개의 독립적인 종속성으로 구성됩니다.

핵심 Node.js 종속성
핵심 Node.js 종속성(큰 미리보기)

V8은 Google에서 만들고 유지 관리하는 프로젝트입니다. JavaScript 소스 코드를 가져와 브라우저 환경 외부에서 실행합니다. node 명령을 통해 프로그램을 실행하면 Node.js 런타임에 의해 소스 코드가 실행을 위해 V8로 전달됩니다.

libuv 라이브러리에는 운영 체제에 대한 낮은 수준의 액세스를 가능하게 하는 C++ 코드가 포함되어 있습니다. 네트워킹, 파일 시스템에 쓰기 및 동시성과 같은 기능은 JavaScript 코드를 실행하는 Node.js의 일부인 V8에서 기본적으로 제공되지 않습니다. 라이브러리 세트를 통해 libuv는 Node.js 환경에서 이러한 유틸리티 등을 제공합니다.

Node.js는 두 라이브러리를 함께 묶어서 고유한 솔루션이 되는 접착제입니다. 스크립트를 실행하는 동안 Node.js는 제어를 언제 어떤 프로젝트에 전달할지 이해합니다.

서버 측 프로그램을 위한 흥미로운 API

JavaScript의 역사를 조금만 공부하면 브라우저의 페이지에 일부 기능과 상호 작용을 추가하기 위한 것임을 알 수 있습니다. 그리고 브라우저에서 페이지를 구성하는 DOM(문서 개체 모델)의 요소와 상호 작용합니다. 이를 위해 집합적으로 DOM API라고 하는 API 세트가 존재합니다.

DOM은 브라우저에만 존재합니다. 그것은 페이지를 렌더링하기 위해 구문 분석되는 것이며 기본적으로 HTML로 알려진 마크업 언어로 작성됩니다. 또한 브라우저는 창에 존재하므로 JavaScript 컨텍스트에서 페이지의 모든 개체에 대한 루트 역할을 하는 window 개체입니다. 이 환경을 브라우저 환경이라고 하며 JavaScript를 위한 런타임 환경입니다.

Node.js API는 일부 기능에 대해 libuv를 호출합니다.
Node.js API는 libuv와 상호 작용합니다(큰 미리 보기).

Node.js 환경에는 페이지나 브라우저 같은 것이 없습니다. 이는 전역 창 개체에 대한 지식을 무효화합니다. 우리가 가지고 있는 것은 운영 체제와 상호 작용하여 JavaScript 프로그램에 추가 기능을 제공하는 API 세트입니다. 이러한 Node.js용 API( fs , path , buffer , events , HTTP 등)는 Node.js용으로만 존재하며 Node.js(자체 런타임)에서 제공하므로 Node.js용으로 작성된 프로그램을 실행할 수 있습니다.

실험: fs.writeFile 이 새 파일을 만드는 방법

V8이 브라우저 외부에서 JavaScript를 실행하도록 생성되었고 Node.js 환경에 브라우저와 동일한 컨텍스트 또는 환경이 없는 경우 파일 시스템에 액세스하거나 HTTP 서버를 만드는 것과 같은 작업을 어떻게 해야 할까요?

예를 들어, 현재 디렉토리의 파일 시스템에 파일을 쓰는 간단한 Node.js 애플리케이션을 살펴보겠습니다.

 const fs = require("fs") fs.writeFile("./test.txt", "text");

표시된 대로 파일 시스템에 새 파일을 쓰려고 합니다. 이 기능은 JavaScript 언어에서 사용할 수 없습니다. Node.js 환경에서만 사용할 수 있습니다. 이것은 어떻게 실행됩니까?

이를 이해하기 위해 Node.js 코드 베이스를 살펴보겠습니다.

Node.js용 GitHub 리포지토리로 이동하면 srclib 라는 두 개의 기본 폴더가 표시됩니다. lib 폴더에는 모든 Node.js 설치에 기본적으로 포함되는 멋진 모듈 세트를 제공하는 JavaScript 코드가 있습니다. src 폴더에는 libuv용 C++ 라이브러리가 포함되어 있습니다.

lib 폴더를 살펴보고 fs.js 파일을 살펴보면 인상적인 JavaScript 코드로 가득 차 있음을 알 수 있습니다. 1880행에서 exports 문을 볼 수 있습니다. 이 명령문은 fs 모듈을 가져와서 액세스할 수 있는 모든 것을 내보내고 writeFile 이라는 함수를 내보내는 것을 볼 수 있습니다.

function writeFile( (함수가 정의된 위치)을 검색하면 1303행으로 이동합니다. 여기서 함수가 4개의 매개변수로 정의되어 있음을 알 수 있습니다.

 function writeFile(path, data, options, callback) { callback = maybeCallback(callback || options); options = getOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'w' }); const flag = options.flag || 'w'; if (!isArrayBufferView(data)) { validateStringAfterArrayBufferView(data, 'data'); data = Buffer.from(data, options.encoding || 'utf8'); } if (isFd(path)) { const isUserFd = true; writeAll(path, isUserFd, data, 0, data.byteLength, callback); return; } fs.open(path, flag, options.mode, (openErr, fd) => { if (openErr) { callback(openErr); } else { const isUserFd = false; writeAll(fd, isUserFd, data, 0, data.byteLength, callback); } }); }

1315번과 1324번 줄에서 우리는 몇 가지 유효성 검사 후에 writeAll 이라는 단일 함수가 호출되는 것을 볼 수 있습니다. 동일한 fs.js 파일의 1278행에서 이 함수를 찾습니다.

 function writeAll(fd, isUserFd, buffer, offset, length, callback) { // write(fd, buffer, offset, length, position, callback) fs.write(fd, buffer, offset, length, null, (writeErr, written) => { if (writeErr) { if (isUserFd) { callback(writeErr); } else { fs.close(fd, function close() { callback(writeErr); }); } } else if (written === length) { if (isUserFd) { callback(null); } else { fs.close(fd, callback); } } else { offset += written; length -= written; writeAll(fd, isUserFd, buffer, offset, length, callback); } }); }

이 모듈이 자체 호출을 시도하고 있다는 점도 흥미롭습니다. 1280번째 줄에서 fs.write 를 호출하는 것을 볼 수 있습니다. write 기능을 찾으면 약간의 정보를 찾을 수 있습니다.

write 기능은 571행에서 시작하여 약 42행을 실행합니다. 우리는 이 함수에서 반복되는 패턴을 봅니다: 594번과 612번 줄에서 볼 수 있듯이 binding 모듈에서 함수를 호출하는 방식입니다. binding 모듈의 함수는 이 함수뿐만 아니라 내보낸 거의 모든 함수에서 호출됩니다. fs.js 파일 파일에서. 뭔가 매우 특별해야 합니다.

binding 변수는 파일 맨 위에 있는 줄 58에서 선언되며 해당 함수 호출을 클릭하면 GitHub의 도움으로 일부 정보가 표시됩니다.

바인딩 변수 선언
바인딩 변수 선언(큰 미리보기)

internalBinding 함수는 loaders라는 모듈에서 찾을 수 있습니다. loaders 모듈의 주요 기능은 모든 libuv 라이브러리를 로드하고 Node.js가 있는 V8 프로젝트를 통해 연결하는 것입니다. 이 작업을 수행하는 방법은 다소 마술적이지만 더 자세히 알아보기 위해 fs 모듈에서 호출하는 writeBuffer 함수를 자세히 볼 수 있습니다.

이것이 libuv와 연결되는 위치와 V8이 들어오는 위치를 살펴봐야 합니다. loaders 모듈의 맨 위에 있는 좋은 문서에 다음과 같이 나와 있습니다.

 // This file is compiled and run by node.cc before bootstrap/node.js // was called, therefore the loaders are bootstraped before we start to // actually bootstrap Node.js. It creates the following objects: // // C++ binding loaders: // - process.binding(): the legacy C++ binding loader, accessible from user land // because it is an object attached to the global process object. // These C++ bindings are created using NODE_BUILTIN_MODULE_CONTEXT_AWARE() // and have their nm_flags set to NM_F_BUILTIN. We do not make any guarantees // about the stability of these bindings, but still have to take care of // compatibility issues caused by them from time to time. // - process._linkedBinding(): intended to be used by embedders to add // additional C++ bindings in their applications. These C++ bindings // can be created using NODE_MODULE_CONTEXT_AWARE_CPP() with the flag // NM_F_LINKED. // - internalBinding(): the private internal C++ binding loader, inaccessible // from user land unless through `require('internal/test/binding')`. // These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL() // and have their nm_flags set to NM_F_INTERNAL. // // Internal JavaScript module loader: // - NativeModule: a minimal module system used to load the JavaScript core // modules found in lib/**/*.js and deps/**/*.js. All core modules are // compiled into the node binary via node_javascript.cc generated by js2c.py, // so they can be loaded faster without the cost of I/O. This class makes the // lib/internal/*, deps/internal/* modules and internalBinding() available by // default to core modules, and lets the core modules require itself via // require('internal/bootstrap/loaders') even when this file is not written in // CommonJS style.

여기서 배우는 것은 Node.js 프로젝트의 JavaScript 섹션에 있는 binding 개체에서 호출된 모든 모듈에 대해 src 폴더의 C++ 섹션에 동일한 모듈이 있다는 것입니다.

fs 둘러보기에서 이를 수행하는 모듈이 node_file.cc 에 있음을 알 수 있습니다. 모듈을 통해 액세스할 수 있는 모든 기능은 파일에 정의되어 있습니다. 예를 들어, 2258행에 writeBuffer 가 있습니다. C++ 파일에서 해당 메소드의 실제 정의는 1785행에 있습니다. 또한 파일에 실제 쓰기를 수행하는 libuv 부분에 대한 호출은 1809행과 1809행에서 찾을 수 있습니다. 1815, 여기서 libuv 함수 uv_fs_write 가 비동기적으로 호출됩니다.

이 이해에서 우리는 무엇을 얻을 수 있습니까?

다른 많은 해석 언어 런타임과 마찬가지로 Node.js의 런타임도 해킹될 수 있습니다. 더 큰 이해와 함께 우리는 소스를 살펴보는 것만으로는 표준 분포로 불가능한 일을 할 수 있습니다. 일부 함수가 호출되는 방식을 변경하기 위해 라이브러리를 추가할 수 있습니다. 그러나 무엇보다도 이러한 이해는 더 많은 탐구를 위한 토대입니다.

Node.js는 단일 스레드입니까?

libuv 및 V8에 있는 Node.js는 브라우저에서 실행되는 일반적인 JavaScript 엔진에 없는 몇 가지 추가 기능에 액세스할 수 있습니다.

브라우저에서 실행되는 모든 JavaScript는 단일 스레드에서 실행됩니다. 프로그램 실행의 스레드는 프로그램이 실행되는 CPU 상단에 있는 블랙박스와 같습니다. Node.js 컨텍스트에서 일부 코드는 기계가 수행할 수 있는 만큼의 스레드에서 실행될 수 있습니다.

이 특정 주장을 확인하기 위해 간단한 코드 조각을 살펴보겠습니다.

 const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test.txt", "test", (err) => { If (error) { console.log(err) } console.log("1 Done: ", Date.now() — startTime) });

위의 스니펫에서 현재 디렉터리의 디스크에 새 파일을 만들려고 합니다. 소요 시간을 확인하기 위해 스크립트의 시작 시간을 모니터링하는 약간의 벤치마크를 추가했습니다. 이 벤치마크는 파일을 생성하는 스크립트의 지속 시간(밀리초)을 제공합니다.

위의 코드를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

Node.js에서 단일 파일을 생성하는 데 걸린 시간 결과
Node.js에서 단일 파일을 만드는 데 걸리는 시간(큰 미리보기)
 $ node ./test.js -> 1 Done: 0.003s

이것은 매우 인상적입니다: 단 0.003초.

하지만 정말 흥미로운 일을 해보자. 먼저 새 파일을 생성하는 코드를 복제하고 위치를 반영하도록 로그 문의 번호를 업데이트하겠습니다.

 const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test1.txt", "test", function (err) { if (err) { console.log(err) } console.log("1 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test2.txt", "test", function (err) { if (err) { console.log(err) } console.log("2 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test3.txt", "test", function (err) { if (err) { console.log(err) } console.log("3 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test4.txt", "test", function (err) { if (err) { console.log(err) } console.log("4 Done: %ss", (Date.now() — startTime) / 1000) });

이 코드를 실행하려고 하면 마음을 사로잡는 무언가를 얻게 될 것입니다. 내 결과는 다음과 같습니다.

여러 파일을 만드는 데 걸린 시간 결과
한 번에 많은 파일 만들기(큰 미리보기)

먼저 결과가 일관되지 않음을 알 수 있습니다. 둘째, 시간이 증가했음을 알 수 있습니다. 무슨 일이야?

저수준 작업 위임

Node.js는 우리가 지금 알고 있는 것처럼 단일 스레드입니다. Node.js의 일부는 JavaScript로 작성되고 나머지는 C++로 작성됩니다. Node.js는 브라우저 환경에서 친숙한 이벤트 루프와 호출 스택의 동일한 개념을 사용합니다. 즉, Node.js의 JavaScript 부분은 단일 스레드입니다. 그러나 운영 체제와 대화해야 하는 저수준 작업은 단일 스레드가 아닙니다.

저수준 작업은 libuv를 통해 OS에 위임됩니다.
Node.js 저수준 작업 위임(큰 미리보기)

호출이 libuv를 위한 것으로 Node.js에서 인식되면 이 작업을 libuv에 위임합니다. 작동 시 libuv는 일부 라이브러리에 대한 스레드가 필요하므로 필요할 때 Node.js 프로그램을 실행할 때 스레드 풀을 사용합니다.

기본적으로 libuv에서 제공하는 Node.js 스레드 풀에는 4개의 스레드가 있습니다. 스크립트 상단에서 process.env.UV_THREADPOOL_SIZE 를 호출하여 이 스레드 풀을 늘리거나 줄일 수 있습니다.

 // script.js process.env.UV_THREADPOOL_SIZE = 6; // … // …

파일 만들기 프로그램은 어떻게 되나요?

파일을 생성하기 위해 코드를 호출하면 Node.js가 코드의 libuv 부분에 도달하는 것으로 보입니다. 이 부분은 이 작업을 위한 스레드 전용입니다. libuv의 이 섹션은 파일에서 작업하기 전에 디스크에 대한 몇 가지 통계 정보를 얻습니다.

이 통계 확인을 완료하는 데 시간이 걸릴 수 있습니다. 따라서 스레드는 통계 확인이 완료될 때까지 일부 다른 작업에 대해 해제됩니다. 검사가 완료되면 libuv 섹션은 사용 가능한 스레드를 차지하거나 스레드가 사용할 수 있게 될 때까지 기다립니다.

우리는 4개의 호출과 4개의 스레드만 가지고 있으므로 돌아다닐 수 있는 스레드가 충분합니다. 유일한 질문은 각 스레드가 작업을 처리하는 속도입니다. 스레드 풀로 만드는 첫 번째 코드는 결과를 먼저 반환하고 코드를 실행하는 동안 다른 모든 스레드를 차단합니다.

결론

이제 Node.js가 무엇인지 이해했습니다. 우리는 그것이 런타임이라는 것을 압니다. 런타임이 무엇인지 정의했습니다. 그리고 Node.js에서 제공하는 런타임을 구성하는 요소에 대해 자세히 살펴보았습니다.

우리는 먼 길을 왔습니다. 그리고 GitHub의 Node.js 리포지토리에 대한 간단한 둘러보기에서 여기에서 수행한 것과 동일한 프로세스에 따라 관심이 있을 수 있는 모든 API를 탐색할 수 있습니다. Node.js는 오픈 소스이므로 확실히 소스에 뛰어들 수 있지 않습니까?

Node.js 런타임에서 발생하는 몇 가지 낮은 수준에 대해 설명했지만 우리가 모든 것을 알고 있다고 가정해서는 안 됩니다. 아래 리소스는 지식을 구축할 수 있는 몇 가지 정보를 가리킵니다.

  • Node.js 소개
    공식 웹사이트인 Node.dev는 Node.js와 해당 패키지 관리자를 설명하고 그 위에 구축된 웹 프레임워크를 나열합니다.
  • "JavaScript & Node.js", 노드 초심자 책
    Manuel Kiessling의 이 책은 브라우저의 JavaScript가 Node.js의 JavaScript와 동일하지 않다고 경고한 후 Node.js를 훌륭하게 설명합니다. 둘 다 같은 언어로 작성되었음에도 불구하고 말입니다.
  • Node.js 시작하기
    이 초보자용 책은 런타임에 대한 설명을 넘어선 것입니다. 패키지와 스트림에 대해 배우고 Express 프레임워크로 웹 서버를 생성합니다.
  • LibUV
    이것은 Node.js 런타임의 지원 C++ 코드에 대한 공식 문서입니다.
  • V8
    JavaScript로 Node.js를 작성할 수 있게 해주는 JavaScript 엔진의 공식 문서입니다.