왜 NodeJS는 싱글 스레드인거지?

@Changmin · November 12, 2024 · 8 min read

Prerequisite :

본 글에서 언급되지만, 직접적인 설명은 하지 않는 것들입니다.

  • 비동기/동기
  • Blocking/Non-Blocking
  • Promise, async/await

첫 번째 NodeJS 스터디 주제는 "싱글 스레드" 입니다. Javascript를 접하면서 항상 들었던 말은 "JS는 싱글 스레드 기반의 언어" 라는 것인데요. 저는, 싱글 스레드 기반이라는 것을 그냥 아 그렇구나 하고 추가적인 학습을 하진 않았어요.

NodeJS가 동작하는 방식을 이해하지 않은 채로 코드를 작성하다보니 왜 콜백 함수를 사용하는지, Promise나 async/await 를 사용하면 어떻게 처리되는지 이해하기가 더 어려워졌습니다. 그래서 이 주제를 선정해 학습을 진행했습니다.

NodeJS 특징

NodeJS를 접한 사람이라면 특징에 대해서 정말 많이 이야기 들었을거에요.

  1. Single Thread 기반
  2. Event Driven 아키텍처
  3. Non-Blocking I/O 모델

근데 여기서 한 가지 의문이 생기게 되었어요. 싱글 스레드 기반인데 어떻게 Non-Blocking I/O를 제공한다는 걸까요? 여기서 등장하는 것이 "이벤트 루프"입니다.

Event Loop

Event Loop 는 NodeJS의 가장 핵심 기능인데요. 일단, 우리가 NodeJS라는 녀석이 싱글 스레드라 불리는 이유는 이 이벤트루프가 메인스레드이면서 싱글스레드로 동작하기 때문입니다.

[출처]: [빨간색소년: nodejs의 내부 동작 원리](https://sjh836.tistory.com/149?source=postpage-----bb68434027a3--------------------------------)_

실제로 위의 그림을 보면, libuv 라는 것 안에 이벤트루프가 존재하는 것을 볼 수 있습니다. NodeJS가 싱글 스레드이면서 Non-Blocking I/O를 제공할 수 있는 이유는 libuv 라는 라이브러리 덕분입니다.

libuv

C언어 기반으로 작성된 라이브러리로, 비동기 I/O를 지원합니다. 커널의 비동기 API로 지원할 수 없는 작업을 비동기 처리할 수 있도록 별도의 Thread Pool을 가지고 있습니다.

그럼 Thread Pool이 존재하는거니까 싱글 스레드가 아니지 않냐고 이야기할 수도 있지만, 아까 말했듯 NodeJS는 하나의 이벤트루프로만 동작을 합니다. 즉, 싱글 스레드로 이벤트루프가 동작하기 때문에, 싱글 스레드 기반인거죠.

비동기 작업 처리 과정

그럼 어떻게 비동기 작업들이 처리되는지 순서대로 확인해보겠습니다.

  1. 요청이 들어오면 Event Loop는 해당 요청이 Blocking I/O 작업인지를 판단
  2. 커널의 Non-Blocking I/O 지원을 받을 수 있는 작업이면 커널의 인터페이스로 해당 요청을 처리한 뒤, Event Queue에 해당 작업의 Callback을 등록
  3. 커널의 Non-Blocking I/O 지원을 받을 수 없는 작업이라면 libuv 내 존재하는 Thread Pool에서 Worker Thread를 선택해 해당 작업을 넘김. 이후, 작업이 완료되면 Event Queue로 해당 작업의 Callback을 등록
  4. Event Loop는 주기적으로 call stack이 비어있는지 확인 후 Event Queue에 실행 대기중인 Callback이 존재한다면 call stack으로 이동시켜 Main Thread에 의해 실행될 수 있게 함

구체적인 예시

libuv의 역할을 이해하기 위해, 두 가지 예시로 살펴보겠습니다.

  1. 파일 시스템 예시

예를 들어, 파일을 읽어오는 작업을 수행한다고 가정해보겠습니다. 파일 시스템에 접근하여 데이터를 가져오는 작업은 시간이 걸릴 수 있으며, JavaScript 코드가 이 작업이 완료될 때까지 기다려야 한다면 비효율적입니다. 여기서 libuv가 개입하여, 메인 스레드가 기다리지 않고 다른 작업을 수행할 수 있도록 도와줍니다.

fs.readFile()을 호출하면, Node.js는 이 작업을 libuv로 전달합니다. libuv는 이 작업을 Thread Pool로 넘겨 처리하며, 이때 메인 스레드는 다른 작업을 계속 수행할 수 있습니다. 작업이 완료되면, libuv는 Event Queue에 콜백을 등록해, Event Loop가 그 콜백을 실행하도록 합니다.

  1. 네트워크 요청 예시

HTTP 요청도 비슷하게 처리됩니다. 예를 들어, 외부 API 서버에 데이터를 요청하는 경우를 생각해보겠습니다. 일반적으로 네트워크 요청은 상대적으로 시간이 많이 걸리기 때문에, 요청을 보낸 후 그 응답을 기다리는 동안 메인 스레드가 중단된다면 다른 작업을 수행할 수 없습니다.

네트워크 요청이 발생하면, libuv는 해당 요청을 비동기로 처리하여 응답을 기다리는 동안 메인 스레드가 다른 코드나 이벤트를 처리할 수 있게 합니다. 요청이 완료되면 마찬가지로 Event Queue에 콜백이 등록되어, Event Loop가 그 콜백을 실행합니다.

이와 같은 방식으로, libuv는 파일 읽기, 네트워크 요청, 타이머 설정 등 다양한 비동기 작업을 Thread Pool 또는 OS의 비동기 API를 통해 처리합니다. 덕분에 Node.js는 싱글 스레드 기반임에도 효율적으로 비동기 작업을 수행할 수 있습니다.

마무리하며

Node.js의 비동기 처리 방식은 처음에는 복잡하게 느껴질 수 있지만, 핵심은 이벤트 루프와 libuv의 역할을 이해하는 것입니다. 이 두 가지 요소 덕분에 Node.js는 싱글 스레드의 한계를 극복하고, 높은 성능과 효율성을 제공하는 서버 환경을 구축할 수 있게 된거죠.

우리는 왜 NodeJS가 싱글 스레드 기반이라고 불리는지 확실하게 이해했습니다. 더 나아가, 이를 활용해 적절한 비동기 처리를 통한 성능 향상이나 개선을 하며 코드를 작성할 수 있기를 바랍니다.

@Changmin
Hello :) I'm Changmin