[기술면접] NodeJS는 Single-Thread 인가?

윤후·2022년 6월 27일
2

기술면접

목록 보기
17/28

NodeJs의 동작 원리

이글을 먼저 이해 하기전 JavaScript의 동작 원리에 대한 글을 먼저 읽으면 도움이 될것 같다.

NodeJS란 무엇일까?

NodeJS는 JavaScript를 브라우저 밖에서도 실행할 수 있도록 하는 JavaScript 런타임이다.

NodeJs는 싱글스레드라는 말을 많이 들어봤을 것이다. 싱글 스레드와 멀티스레드에 관한 글은 앞서 포스팅한 여기에서 볼 수 있다.

싱글 스레드 & 멀티 스레드 정리
싱글 스레드는 프로세스 내에서 하나의 스레드가 하나의 요청만을 수행한다. 해당 요청이 수행될 때 다른 요청을 함께 수행할 수 없다. 이를 싱글스레드 블로킹 모델이라고 한다. 진행되고 있는 요청이 예정되어 있는 요청을 블로킹하기 때문이다.

반면 멀티스레드는 스레드 풀에서 실행의 요청만큼 스레드를 매칭하여 작업을 수행한다. 언뜻 보면 멀티스레드가 훨씬 좋아보이지만 멀티스레드는 효율성 측면에서 큰 단점을 가지고 있다. 스레드 풀에 스레드가 늘어날수록 CPU 비용을 소모하고, 만약 요청이 적다면 놀고 있는 스레드가 생기기 때문이다.

NodeJS는 싱글스레드 논블로킹 모델로 구성되어 있다. 하나의 스레드로 동작하지만 비동기 작업을 통해 요청들을 서로 블로킹하지 않는다. 즉, 동시에 많은 요청들을 비동기로 수행함으로써 싱글스레드일지라도 논블로킹이 가능하다. 또한 NodeJS는 클러스팅을 통해 프로세스를 포크하여 멀티스레드인것처럼 사용 될 수 있다. 트래픽에 따라서 프로세스를 포크할 수 있으므로 서버의 확장성이 용이하다는 장점을 갖는다.

NodeJS는 완전한 싱글스레드인가?

NodeJS는 싱글스레드이다. JavaScript를 실행하는 메인스레드는 단 하나이기 때문이다. 하지만 NodeJS가 완전하게 싱글스레드를 기반으로 동작하지는 않는다. 일부 Blocking작업들은 libuv의 스레드 풀에서 수행되기 때문이다.

이벤트 기반이란?
이벤트 기반이란, 이벤트가 발생할 때 미리 지정해둔 작업을 수행하는 방식을 의미한다. NodeJS는 이벤트 리스너에 등록해둔 콜백함수를 실행하는 방식으로 동작한다.

이벤트 기반 모델에서는 이벤트 루프개념을 빼놓을 수 없다. 이벤트에 따라 호출되는 콜백함수를 관리하는 것이 바로 이벤트 루프이다. (NodeJs런타임에서 이벤트 루프와 브라우저 런타임에서 이벤트 루프는 다르긴하다.)

NodeJS의 내부 구조

NodeJS를 크게 나눠봤을 때, 내장 라이브러리와 v8엔진 그리고 libuv로 구성되어 있다. NodeJs의 특성인 이벤트 기반, 논블로킹I/O모델들은 모두 libuv라이브러리에서 구현된다.

NodeJS에서 작성되는 거의 모든 코드들은 콜백함수로 이루어져 있다. 콜백함수들은 linuv내에 위치한 이벤트 루프에서 관리 및 처리된다.

이벤트 루프는 여러개의 페이즈들을 가지고 있으며 해당 페이즈들은 각자만의 큐를 갖는다. 이벤트 루프는 *라운드 로빈방식으로 노드 프로세스가 종료될때까지 일정 규칙에 따라 여러개의 페이즈들을 계속 순회한다. 페이즈들은 각각의 큐들을 관리하고 해당 큐들은 FIFO순서로 콜백함수들을 처리한다.

*라운드 로빈
그룹 내에 있는 모든 요소들을 합리적인 순서에 입각하여 뽑는 방법으로서, 대개 리스트의 맨 위에서 아래로 가며 하나씩 뽑고, 끝나면 다시 맨 위로 돌아가는 식으로 진행된다. 쉽게 말해 라운드 로빈은 "기회를 차례대로 받기"라고 이해해도 좋다.

컴퓨터 운영에서 컴퓨터 자원을 사용할 수 있는 기회를 프로그램 프로세스들에게 공정하게 부여하기 위한 한 벙법으로서, 각 프로세스에게 기회를 주고 또 그다음 프로세스에게 하는 식으로 돌아가며 기회를 부여하는 운영방식이 있는데, 이를 흔히 라운드 로빈 프로세스의 스케줄링이라고 부른다.

논블로킹I/O

NodeJs에서의 논 블로킹 I/O모델은 input과 output이 관련된 작업(http, Database CRUD, third party api, filesystem)등의 블로킹 작업들을 백그라운드에서 수행하고 이를 비동기 콜백함수로 이벤트 루프에 전달하는 것을 말한다.

I/O들은 OS커널 혹은 libuv내의 스레드 풀에서 담당한다. libuv는 OS커널에서 어떤 비동기 작업들을 지원해주는지 알고 있기 때문에 작업 종류에 따라 커널 혹은 스레드 풀로 분기하게 된다. 작업이 오나료되면 이벤트 루프에게 이를 알려주고 이벤트 루프에 콜백함수로 등록된다. libuv의 스레드 풀은 커널이 지워안하는 작업들을 수행한다. 위에서 NodeJs가 완전한 싱글스레드는 아니라고 했었던 부분이 libuv의 스레드 풀은 멀티스레드로 이루어져 있기 때문이다. 여기서 이뤄지는 작업의 대표적인 예로는 파일 시스템이 있다. 스레드 풀도 마찬가지로 해당 작업을 수행하면 이벤트 루프에 콜백함수를 전달한다.

Libuv

libuv란 C++로 작성된 NodeJs가 사용하는 비동기 I/O라이브러리다. 이는 사실 운영체제의 커널을 추상화한 Wrapping 라이브러리고 커널이 어떤 비동기 API를 지원하는지 알고 있다.

다시말해 우리가 libuv에게 파일 읽기와 같은 비동기 작업을 요청하면 libuv는 이 작업을 커널이 지원하는지 확인한다. 만약 지원한다면 libuv가 대신 커널에게 비동기적으로 요청했다가 응답이 오면 그 응답을 우리에게 전달해준다.

만약 요청한 작업을 커널이 지원하지 않는다면 어떻게 될까?
바로 자신만의 워커스레드가 담긴 스레드 풀을 사용한다.

libuv는 기본적으로 4개의 스레드를 가지는 스레드 풀을 생성한다. 물론 uv_thredpool이라는 환경변수를 설절해 최대 128개까지 스레드 개수를 늘릴 수도 있다. 만약 우리가 요청한 작업을 커널이 지원하지 않는다면 libuv는 커널을 호출하는 대신 이 스레드 풀에게 작업을 맡겨버리게 된다.

그리고 스레드 풀에 있던 스레드가 작업을 완료하면 libuv가 우리에게 요청한 작업이 완료되었다고 친절하게 알려준다.

정리

  • libuv는 운영체제의 커널을 추상화해서 비동기 API를 지원한다.
  • libuv는 커널이 어떤 비동기 API를 지원하고 있는지 알고 있다.
  • 만약 커널이 지원하는 비동기 작업을 libuv에게 요청하면 libuv는 대신 커널에게 이 작업을 비동기적으로 요청해준다.
  • 만약 커널이 지원하지 않는 비동기 작업을 libuv에게 요청하면 libuv는 내부에 가지고 있는 스레드 풀에게 이 작업을 요청해준다.

libuv 정리
libuv라는 비동기 I/O라이브러리가 존재하고 NodeJs가 이를 내부적으로 이용한다는 사실을 알아봤다. 그렇다면 도대체 libuv와 이벤트 루프는 어떠한 관계가 있고 그래서 NodeJs는 어떻게 싱글스레드로 논블로킹 비동기 작업을 지원하는 걸까?

NodeJs는 I/O작업을 자신의 메인 스레드가 아닌 다른 스레드에 위임함으로써 싱글 스레드로 논 블로킹 I/O를 지원한다. 다르게 말하면 NodeJs는 I/O작업을 libuv에게 위임함으로써 논 블로킹I/O를 지원하고 그 기반에는 이벤트 루프가 있는 것이다.

NodeJs속 이벤트 루프

이벤트 루프는 NodeJs가 어려 비동기 작업을 관리하기 위한 구현체이다.
nextTickQueue와 microTaskQueue는 이벤트 루프의 일부가 아니다. 따라서 아래에서 설명하는 내용에 해당되지 않는다. 비록 이벤트 루프를 구성하지는 않지만 Node.js의 비동기 작업 관리를 도와주는 것들로 아래에서 더 자세하게 다룬다.

각 단계는 실행할 콜백의 FIFO 큐를 가집니다. 각 단계는 자신만의 방법에 제한적이므로 보통 이벤트 루프가 해당 단계에 진입하면 해당 단계에 한정된 작업을 수행하고 큐를 모두 소진하거나 콜백의 최대 개수를 실행할 때까지 해당 단계의 큐에서 콜백을 실행합니다. 큐를 모두 소진하거나 콜백 제한에 이르면 이벤트 루프는 다음 단계로 이동합니다.

이러한 작업이 또 다른 작업을 스케줄링하거나 poll 단계에서 처리된 새로운 이벤트가 커널에 의해 큐에 추가될 수 있으므로 폴링 이벤트를 처리하면서 poll 이벤트를 큐에 추가할 수 있습니다. 그 결과 오래 실행되는 콜백은 poll 단계가 타이머의 한계 시점보다 훨씬 더 오래 실행되도록 할 수 있습니다.

각 박스는 특정 작업을 수행하기 위한 페이즈(Phase)를 의미한다. 그리고 NodeJS의 이벤트 루프는 아래와 같이 구성되어있다.

  • Timer Phase
    setTimeout()과 setInterval() 같은 timer 함수들이 처리된다. 이벤트 루프가 페이즈를 순회하면서 timer 단계에 오면 처리할 수 있는 timer 함수들을 확인하고 콜백함수를 실행한다. timer 함수의 입력된 지연시간은 콜백함수가 실행되는 정확한 값이 아니라 해당 지연시간 이후에 실행된다는 기준시간의 의미인 것이다.

  • Pending Callbacks Phase
    이 단계에서는 다음 루프 반복으로 연기된 I/O 완료 결과가 큐에 담기게 된다. I/O 작업이 완료되면 다음번 루프에 이 단계에 들어와있게 되고, I/O 작업 블록내의 콜백함수들을 poll 단계의 큐로 넘겨준다. 또한 TCP 오류 같은 시스템 작업의 콜백을 실행한다.

  • Idle, Prepare Phase

  • Poll Phase
    poll 단계에서는 I/O와 연관된 콜백(클로즈 콜백, 타이머로 스케줄링된 콜백,setImmediate()를 제외한 거의 모든 콜백)을 실행한다. 또한 timer 단계에서의 실행 시간 제어를 담당한다. 이 단계에서는 poll 큐에 쌓인 콜백함수들을 한도가 넘지 않을때까지 모두 동기적으로 실행한다. 만약 한도가 넘거나, 더이상 실행할 콜백함수가 없을때는 별도의 규칙을 따라, 다음 단계로 넘어가거나 대기한다.

    • check 단계를 검사하여 setImmediate() 가 있는지 확인한다.
    • check 단계에 setImmediate() 있는 경우에는 check 단계로 넘어간다.
    • 만약 없다면, timer 단계에서 실행할 timer 함수가 있는지 확인한다.
    • timer 함수를 실행할 수 있는 시간까지 대기한 후에, timer 단계로 넘어간다. 대기하는 동안에 poll 큐에 콜백함수가 쌓인다면 즉시 실행한다.
  • Check Phase
    setImmediate() 의 콜백함수가 실행된다. 위에서 언급한대로, 이벤트루프가 poll 단계에서 작업을 수행한 뒤, poll 단계가 유휴상태가 되었다면 poll 이벤트를 기다리지 않고 check 단계로 넘어가게 된다.

  • Close Callbacks Phase
    close 이벤트에 따른 콜백함수를 실행한다. socket.on('close', ...) 이벤트에 따른 콜백함수를 예로 들 수 있다.

페이즈 전환 순서 또한 그림에 나타난 것처럼 Timer Phase -> Pending Callbacks Phase -> Idle, Prepare Phase -> Poll Phase -> Check Phase -> Close Callbacks Phase -> Timer Phase 순을 따른다. 이렇게 한 페이즈에서 다음 페이즈로 넘어가는 것을 틱(Tick)이라고 부른다.

각 페이즈는 자신만의 큐를 하나씩 가지고 있는데, 이 큐에는 이벤트 루프가 실행해야 하는 작업들이 순서대로 담겨있다. Node.js가 페이즈에 진입을 하면 이 큐에서 자바스크립트 코드(예를 들면 콜백)를 꺼내서 하나씩 실행한다. 만약 큐에 있는 작업들을 다 실행하거나, 시스템의 실행 한도에 다다르면 Node.js는 다음 페이즈로 넘어간다.

따라서 위 그림처럼 Poll, Check, Close 페이즈가 관리하는 큐에 console.log 콜백이 쌓여있고 Node.js가 Poll Phase부터 Check Phase, Close Callbacks Phase 순으로 차례대로 실행한다.

이때 이벤트 루프가 Node.js의 비동기 실행을 도와주는 것과 별개로 싱글 스레드이므로 한번에 하나의 페이즈에만 진입해 한번에 하나의 작업만 수행할 수 있다는 점을 명심해야 한다. Poll Phase 작업을 처리하면서 Check Phase의 작업을 동시에 처리하거나 Poll Phase의 작업을 한번에 여러 개씩 처리하는 것은 불가능하다.

정리

  • 이벤트 루프는 Node.js가 비동기 작업을 관리하기 위한 구현체다.
  • 이벤트 루프는 총 6개의 페이즈로 구성되어 있으며 한 페이즈에서 다음 페이즈로 넘어가는 것을 틱이라고 한다.
  • 각 페이즈는 자신만의 큐를 관리한다.
  • Node.js는 순서대로 페이즈를 방문하면서 큐에 쌓인 작업을 하나씩 실행한다.
  • 페이즈의 큐에 담긴 작업을 모두 실행하거나 시스템의 실행 한도에 다다르면 Node.js는 다음 페이즈로 넘어간다.
  • 이벤트 루프가 살아있는 한 Node.js는 이벤트 루프를 반복한다.

예시

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

위의 코드는 어느것이 먼저 실행될지 알 수 없다. 만약 저 코드 블락을 실행할 때, 이벤트 루프가 돌고 있는 시점이 timer단계라면 setTimeout()의 콜백함수가 먼저 실행될 것이다. 하지만 timer 단계를 지났다면 setImmediate()의 콜백함수가 먼저 실행될 것이다.

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

하지만 I/O블락 내에서는 setImmediate()가 항상 먼저 실행된다. I/O의 콜백함수는 poll단계에서 실행된다. poll단계에서는 항상 check단계의 setImmmediate()가 있는지를 체크하고, 만약 있으면 check단계로 넘어가기 때문이다.

Reference
JavaScript와 NodeJs 그리고 싱글 스레드
NodeJs 동작원리
NodeJs 샅샅히 분석하기

profile
궁금한걸 찾아보고 공부해 정리해두는 블로그입니다.

0개의 댓글