Back_End/Node.js

[ 백엔드 공부하기 : Node.js ] NodeJS의 기본 동작 원리와 이벤트 루프, 브라우저 환경을 벗어난 JS 실행.

안다미로 : Web3 & D.S 2024. 12. 6. 22:41

 

 

 

 

[ 백엔드 공부하기 : Node.js ] 

NodeJS의 기본 동작 원리와 이벤트 루프, 브라우저 환경을 벗어난 JS 실행.

 


 

∇백엔드_NodeJS :

NodeJS의 기본 동작 원리 * 이벤트 루프 * 브라우저 바깥에서의 JS 실행.

목   차

1. 기본 동작 원리
2. 결론정리

 

 


 

◎ Node.JS 

           √ NodeJS의 기본적인 "컨셉"은 JS의 실행 원리와 비슷합니다.

           √ NodeJS의 공식문서에서는 [ "비동기 이벤트 주도 JavaSccript 런타임" 으로써 NodeJS는 

                 확장성 있는 네트워크 애플리케이션을 만들 수 있도록 설계되었습니다. ] 라고 소개되어 있습니다.

 

           √ 한마디로 정리하면,

               Node.js는 JavaScript를 브라우저 바같에서도 실행 가능하도록 하는 JavaScript의 런타임"!!

 

 

 


 

 

Ⅰ. 기본 동작 원리.


 ◆ Node는 "싱글 스레드로 이뤄진 이벤트 기반 async & non-blocking js - 런타임" 입니다.           

   

     ◎ Node의 내부적인 설계가 주로

          '파일 단위'를 "모듈 단위"로 나눌 수 있고,  특정 모듈은 기능 단위가 아닌 "시나리오 단위" 로 구성되었다는 점에서

          "객체지향적인 설계(OOP)" 를 넘어서 "관점 지향적인 설계 방식(Aspect Oriented Programming)" 이라고 합니다.

 

 

 

     1 ) Node.js 의 내부 구조.

              ① JS로 이루어진 라이브러리 부분.

              ② JS-C++ 을 연결해주기 위한 "Binding"

              ③ 거의 C++  로 이루어진 V8엔진 부분

              ④ C++로 구성된 이벤트 루프의 핵심 부분인 libuv

 

 

 

     2 ) standard library & node bindings

           ○ " Node Standard Library" 는 JS 언어로 만들어진 라이브러리입니다.

                 ==>> 이를 C/C++ 로 바인딩하는 역할이 'Node Bindings" 입니다. 

 

[ 공식레포 ]

             

         ∇ file I/O를 도와주는 모듈 "fs"를 살펴보면

 

         ∇ InternalBinding이 무엇인가?

               : JS와 같은 고수준의 언어(코드)는 직접적으로 OS system call을 할 수 없으며,

                 (= 정확하게는 태생적으로 고수준언어는 저수준 언어를 이해하기 쉽게 바인딩 되어 있는 상태이기 때문)

                 이를 위해 저수준의 code로 NativeModule을 컨트롤 해야 합니다.

                  == 내부의 C/C++ 코드를 바인딩한다고 보면 된다고 합니다.

 

 

         ∇ 결론적으로 보면 const binding = internalBinding('fs'); 에서 바인딩 되는 것은

                  node -> src -> node_file.cc 이며, 실제 저수준의 구현 function & method를 확인할 수 있습니다.

 

 

         ∇ 가령 fs module의 디렉토리를 만드는 method "mkdir"는  MKDir이라는 function이 바인딩 되어있는 것을 확인

               가능하고 저수준 코드에서도 확인 가능합니다.

 

 

◇ 실제 우리가 작성한 JS 코드가 node runtime에서는 

       아래 사진과 같은 흐름으로 libuv에 도달하게 됩니다.

 

libuv 도달 과정

 

 

우리는 node run time 덕분에 JS 코드만으로도 C/C++에 쉽게 닿을 수 있고,

              C/C++을 사용한 코딩처럼 할 수 있게 되는 것입니다.

 

 

 

    3 ) libuv

 

           ◆ "이벤트 기반 비동기 처리"를 가능하게 해주는 핵심 부분 입니다. 

                         :: 비동기 I/O를 지원하는 "C언어 Library"로 윈도우, 리눅스 커널을 Wrapping하여 추상화한 구조.

                               - 커널의 비동기 API로 지원할 수 없는 작업을 비동기화 하기 위한 "별도의 Thread Pool"을 가지고 있고

                                    "Event Loop" & "Event Queue"를 관리합니다.

 

            - MKDir은 MKDirpSync 를 호출하고, 이는 내부적으로 env -> event_loop() 라는 argument를 넘깁니다.

 

            - 헤더파일 선언부 [ node -> src -> node_file.h ]를 보면 아래 코드를 볼 수 있습니다.

int MKDirpSync(uv_loop_t* loop,
               uv_fs_t* req,
               const std::string& path,
               int mode,
               uv_fs_cb cb = nullptr);

 

            - 'uv_fs_t' 같은 것을 Watcher 라고 하며, [ node -> deps -> uv -> include -> uv.h ] 에 존재.

 

            -  node는 파일시스템, 네트워킹, 스레드, 프로세스, 이벤트 루프, I/O 폴링, 시스템 워쳐(Watcher), TTY, DNS 과

                 관련된 Watcher 구조체를 확인 가능합니다.

 

           - Watchers가 갖는 구조체 이름은 uv_type_t 로 정의돼 있는데 이름 내에 type이라는 이름 대신

               앞선 목록에 해당하는 각각의 구조체가 적용된다.

 

 

    4 ) libuv 구조 & Event Loop

           ● 커널이 지원하는 비동기 작업을 "libuv"에게 요청하면

                   "libuv"는 대신 커널에게 이 작업을 비동기적으로 요청해줍니다.

 

   

           ● 만약, 커널이 지원하지 않는 비동기 작업을 "libuv"에게 요청하면,

                   "libuv"내부에 가지고 있는 스레드 풀에게 이 작업을 요청해줍니다.

       

 

           ● 우리가 봐야할 점은, "libuv" 는 "Event Loop"를 가지고 있다는 점 입니다.

                 

           ● JS가 브라우저에서 작동할 때 활용되는 Event Loop처럼,   "libuv"-" Event Loop" 도 각 요청을 특성에 맞게

 

                    "커널"이나 "Thread-Pool"에 위임 하고, 실행 대기중인 callbackEvent Queue에 모았다가

                            Main Thread에 의해 실행가능하도록 call stack 으로 옮기는 역할을 합니다.

 

 

        ★ Event Loop Phases

 

                  ● Event Loop는 모든 Callback을  하나의 Event Queue에 담아서 관리하지 않습니다.

이벤트 루프 다이어그램

 

 

                  ● Event Loop는 위와 같은 "Phases" 라고 부르는 단계들로 구성.

 

 

                    √ 각 단계는 실행할 콜백의 FIFO 큐를 가집니다.

                             - 각 단계는 자신만의 방법에게 제한적이므로, 보통 이벤트 루프가 해당 단계에 진입하면

                                해당 단계에 한정된 작업을 수행하고 큐를 모두 소진하거나 콜백의 최대 개수를 실행할 때까지

                                 해당 단계의 큐에서 콜백을 실행합니다.

 

                             - 큐를 모두 소진하거나 콜백 제한에 이르면 이벤트 루프는 다음 단계로 이동 !

 

 

 

 

                  ● Node.JS가 실행되면 스레드가 생성되고 이벤트 루프가 생성됩니다.

                         - 이벤트 루프는 6개의 페이즈를 라운드 로빈 방식으로 순회하며 동작합니다.

 

 

                   ① Timers

                         - Timer 단계는 Event Loop의 시작 단계입니다. 

                         - 'setTimeout()' , 'setInterval()'  과 같은 '타이머에 관련된'  callback을 처리합니다.

 

                         - 타이머들이 호출 되자마자 Event Queue에 들어가는 것이 아니고

                           내부적으로 'min-heap' 형태로 타이머를 구성하고 있다가 "발동 단계" 가 되면 그때

                            'Event Queue' 로 'callback'을 이동시킵니다.

 

                         - 'min-heap' 는 이진트리 형태인 최소 힙구조 라서 가장 빠른 타이머를 체크할 수 있게 세팅된 자료구조.

 

                         - 타이머 관련된 로직은 해당 페이즈에 진입해야만 "실행될 기회를 얻을 수 있습니다."

 

 

                   ② pending i/o callbacks

                         -  이 단계에서는 'pending_queue'에 들어있는 'callback' 들을 실행합니다.

 

                         - 'pending_queue' 에는 이전 이벤트 루프 반복에서 수행되지 못했던 I/O 콜백

                            이전 루프에서 완료된 'callback ( ex - Network I/O가 끝나고 "응답" 받은 경우 등 ) 

                                 또는 'Error callback' 등이 쌓이게 됩니다.

 

                         - 또한, TCP 오류와 같은 시스템 작업의 콜백 함수도 실행됩니다.

 

 

                   ③ idle, prepare

                         - Node.js 내부용으로만 사용됩니다.

                         - "Event Loop"가 매번 순회할 때마다"  실행되며,  'poll' 을 위한 준비작업을 하는 단계.

 

                   ④ Poll

                           - 이 페이즈는 새로운 I/O 이벤트를 다루며  'watcher_queue' 의 콜백들을 실행합니다.

                                 여기서 "Watcher"는 구조체.

 

                           - 'watcher_queue' 에는 I/O에 대한 거의 모든 콜백들이 담깁니다.

                                   - 'setTimeout' , 'setImmediate', 'close' 콜백 등을 제외한 모든 콜백이 여기서 실행.

 

                           - 만약, Queue 가 비어있지 않다면 배정받은 시간동안 'Queue' 가 모두 소진될 때까지 

                                 모든 'callback'을 'call stack'으로 올려 실행시킵니다.

 

                             ∇별도의 규칙

                                   - check 단계를 탐색하여, setImmediate() 가 있는지 확인.

                                   - 있는 경우, check 단계로 넘어갑니다.

                                   - 없는 경우, timer 단계에서 실행할 timer 함수가 있는지 확인합니다.

                                   - 있는 경우, timer 함수를 실행할 수 있는 시간까지 대기한 후, timer 단계로 넘어갑니다.

                                   - 대기하는 동안 poll 큐에 콜백 함수가 쌓이면 즉시 실행합니다.

 

 

 

                   ⑤ Check

                             - setImmediate() 의 콜백 함수가 실행됩니다. 

                             - setImmediate() 를 사용하여 수행한 callbackEvent Queue에 쌓이고 call stack으로 올라갑니다.

 

                   ⑥ Close callbacks

                             - close  이벤트에 따른 콜백 함수를 실행합니다. 

                             - Close 단계는   'socket.on('close', () => {} )   같은 clost type의 callback을 관리하는 단계.

                             - uv_close() 를 부르면서 종료된 핸들러의 콜백들을 처리하는 페이즈.

 

 

              + nextTickQueue

                           - 'libuv' 에 "포함되어 있지 않고"  Node.js에 구현되어 있으며,

                                이벤트 루프의 페이즈와 상관없이 동작하는 queue입니다. 

 

                           - nextTickQueue 는 process.nextTick 에 의해 생성된 콜백들을 저장하는 큐이며,

                                다른 I/O 작업이나 타이머 작업보다 먼저 처리됩니다.

                              == 즉, 현재 실행중인 스크립트가 완전히 끝나고 다른 이벤트 루프 단계로 넘어가기 전에 실행됩니다.

 

                           - 긴급하게 처리해야 할 작업을 등록할 때 사용되며, 

                                이 큐의 작업이 너무 많으면 I/O 작업이 지연될 수 있습니다.

 

              + microTaskQueue

                           - 이 역시  'libuv' 에 "포함되어 있지 않고"  Node.js에 구현되어 있으며,

                                이벤트 루프의 페이즈와 상관없이 동작하는 queue입니다. 

 

                           - Promise의 Resolve 된 프라미스 콜백을 가지고 있는 큐입니다.

 

                           - 현재 실행 중인 스크립트가 완료된 후, 그리고 이벤트 루프의 다음 단계로 넘어가기 전에 실행되며

                                 이 큐는 nextTickQueue 가 비워진 후에 처리됩니다.

 

 

 

       ● 'Timer Phase'   ->  ' Pending Callbacks Phase '  ->  ' Idle, Prepare Phase '  ->  ' Poll Phase

         ->  '  Check Phase'  ->  ' Close Callbacks Phase '  ->   'Timer Phase ' 의 기본적인 순서를 따르며,

            이 순회구조를 하나의 'tick'이라고 부릅니다.

 

 

cycle(tick)의 순환 구조

 

 

 

 


 

Ⅱ. 결론 정리.


 

node.js의 동작 정리

 

         1. nodejs 는 JS 코드를 c/c++로 바인딩하고,  V8 api / libuv api를 이용하여

                  core( node > src) 를 구현합니다.

 

         2. V8과 libuv는 각기 별개로 움직이지 않습니다.

                    - nodejs는 하나(싱글스레드) 의 이벤트루프로만 동작합니다. 

                    - JS의 실행은 'Main Thread' 에 의해서만 수행되고 1개의 call stack을 가집니다.

 

         3. call stack 실행은 동기적 blocking 이기 때문에 이를 극복하기 위해 Single Thread 와 궁합이 좋은

                 " 비동기 callback" 프로그래밍 방식인 Event Loop를 추상화 한 libuv library를 사용합니다.

 

         4. node 인스턴스가 생성될 때 start 함수에서 do-while  문으로 uv_run() 이 호출되고 있습니다

                  libuv는 js엔진이 아니며, libuv 내부에 있는 event loop가 파라미터로 넘겨받은

              V8::Isolate, V8::Context 를 이용해 JS 로직을 처리합니다.

 

         5. libuv 내의 'Event Loop' 는 "Main Thread에 상주" 하여 JS 비동기 실행을 합니다.

                요청의 특징에 따라서  '커널 비동기 함수' 또는 'libuv' 내의 'Thread Pool' 에 작업을 "위임" 하며

               'callback' 을 실행하기 위해 'Event Queue'에 적재된 'callback' 을 empty 상태의 'call stack' 으로 이동시킵니다.

 

         6. 'Event Loop' 는 앞서 살펴본 각 페이스 단계의 특성을 지키며 다음 큐로 넘기고 실행합니다.

 

 


 

☆ Node는 왜 싱글 스레드를 고집하는가???

 

1.비동기 I/O 처리: Node.js는 비동기 I/O 모델을 사용하여, 요청을 처리하는 동안 다른 작업을 수행할 수 있습니다.

이는 서버의 성능을 높이고, 많은 클라이언트 요청을 동시에 처리할 수 있게 합니다.

 

2.단순성: 싱글 스레드 모델은 코드의 복잡성을 줄여줍니다.

멀티 스레드 환경에서는 스레드 간의 동기화 문제나 경쟁 조건이 발생할 수 있지만,

싱글 스레드에서는 이러한 문제를 피할 수 있습니다.

 

3.자원 효율성: 스레드를 생성하고 관리하는 데 드는 오버헤드가 줄어들어, 메모리 사용량이 적고 성능이 향상됩니다.

 

4.이벤트 기반 아키텍처: Node.js는 이벤트 루프를 기반으로 하여, 이벤트가 발생할 때만 작업을 수행합니다.

이는 CPU 자원을 효율적으로 사용할 수 있게 해줍니다.