tech.junha.kr

[Node js의 특징] 싱글스레드의 NodeJS가 서버사이드에서 쓰일 수 있는 이유

0. 왜 싱글 스레드야?

스레드는 프로세스 내에서 실행되는 실행 단위를 말한다. 그럼 스레드가 많으면 좋은 게 아닐까? 브라우저도 그렇고, Node도 그렇고, 왜 싱글 스레드를 사용할까? 이유는 바로 **쉬.워.서.**다. 만약 자바스크립트가 멀티 쓰레드로 실행되는 언어였다면 웹페이지에서 발생하는 동시성 문제에 대해 해결해야 했다. 실제로 멀티 쓰레드로 구현된 서비스에서는 이 동시성 문제에 대해 정말 많은 신경을 쓴다. 하지만 자바스크립트는 단일 쓰레드로 실행되므로 인해 교착 상태와 같은 다중 쓰레드 환경에서 발생할 수 있는 복잡한 시나리오를 신경 쓸 필요가 없으며 비동기 처리를 통해 쉽게 여러 요청을 처리할 수 있다. 실제로 구글의 Chrome 브라우저 마저도 기존 웹 페이지에서 엄청난 동시성 문제를 일으킬 수 있다는 이유로 단일 웹 사이트 페이지의 자바스크립트 코드가 동시에 실행되는 것을 허용하지 않는다.

1. Why Asynchronous, Non-Blocking IO

그렇다면 왜 non-blocking IO가 NodeJS의 특장점인지 알아보자. 우선, CPU 코어의 활용을 극대화할 수 있는지를 이해하려면 먼저 IO의 특성과 blocking, non-blocking하는 코드들에 대한 이해가 필요하다.

1) IO in general

IO는 Input/Output의 줄임말이다. IO 의미는 범위에 따라 다른 작업을 뜻할 수도 있다. 예를 들어, 메모리, 즉 RAM과 그 상위 메모리 계층간의 데이터 교환 혹은 디스크 - 하드디스크 및 SSD - 와 네트워크를 통한 데이터 교환을 의미한다. 어떤 IO가 blocking하는지, non-blocking하는지는 주로 디스크 혹은 네트워크로부터 데이터를 가져오려고 할 때 프로그래밍 언어 혹은 런타임이 데이터가 도착하기를 대기하는지에 따라 구분된다.

2) Blocking IO

Block은 막는다는 뜻으로 blocking IO는 직역하여 IO 작업시 프로그램의 실행을 막는다는 뜻으로 해석될 수 있다. 실제로 blocking하는 프로그래밍 언어들이나 런타임들은 디스크나 네트워크를 통한 데이터가 레지스터까지 도달하는 동안 그 줄에서 명렁을 더 실행하지 않고 대기한다. 낮은 레벨 관점에서 보면, 이 상태의 CPU 코어는 busy-waiting 한다. 실제로 어떤 명령을 수행하고 있는 것이 아니라, 실행 의미가 없는 idle 명령을 계속 반복하고 있는 것이다. 자동차로 치면, 공회전하고 있는 것이다. 결과적으로 IO와 상관 없는 작업들도 IO 작업이 병목이 되어 CPU 사이클을 낭비하게 된다.

사실 blocking IO가 항상 CPU를 idle 상태로 공회전 시키는 것은 아니다. IO 작업은 컴퓨터가 처리하는 작업들 중 가장 오래 걸리는 작업들 중 하나이기 때문에, 소프트웨어 개발자들, 운영체제 개발자들뿐만 아니라 CPU 아키텍트와 엔지니어들도 IO 성능을 향상시키기 위해 노력한다. IO 작업들로 인해 병목이 되는 작업들의 성능을 조금이라도 향상시키기 위해 하드웨어적으로는 하이퍼스레딩, OS와 소프트웨어적으로는 멀티프로세싱이나 멀티스레딩이 개발되었으며, 덕분에 최신 CPU에 최신 소프트웨어를 구동했을 때 물려있는 작업을 두고 노는 일은 거의 없다.

3) Non-Blocking IO & Asynchronous programming

Non-blocking IO 작업을 막지 않는다. 즉, IO 작업을 요청한 후 결과를 기다리지 않고 다른 작업을 수행한다. 이는 실제로는 IO 작업을 요청한 후 다른 작업에 코어를 양보하는 것처럼 달성된다. 양보를 한 작업과 양보받은 작업 둘 다 일단 작업이 시작되고 나서는 서로의 진행 상황과 상태 값들에 접근할 수 없다. 즉 두 작업은 비동기적으로 수행되므로, non-blocking IO는 비동기 작업을 허용하는 런타임에서만 달성될 수 있으며, 그 개념과 장단점 또한 다수 공유한다.

Non-blocking IO를 달성하고 나면 위에서 지적된 코어가 놀고 있는 상황이 거의 없어진다. IO에서 데이터를 받아오는 동안 CPU는 공회전하지 않고 다른 작업을 먼저 수행한 다음, 결과가 도착하면 도착하는 대로 그 작업을 다시 수행한다. 이것이 NodeJS가 자랑하는 IO 처리의 효율성 극대화이다. NodeJS는 기본적으로 V8 엔진의 개발 로드맵을 따라가기 때문에 처음 NodeJS가 나왔을 때는 콜백 패턴 또는 EventEmitter를 구독(subscribe)하는 방법을 주로 썼었다. 최신 JS에서는 Promise, async/await, generator, rxjs 및 기타 비동기 라이브러리 등 다양하고 쉬운 방법으로 비동기 로직을 작성하는 것이 가능하며, 이것이 권장된다.

2. Event Loop

Node js는 v8의 등장으로 부터 시작됐다. Event loop은 V8 런타임의 스펙이며, 이는 JS 코드가 어떻게 외부의 자극에 반응하는지를 정의한다. 또한 가벼운 비동기 로직을 작성할 수 있게 하는 원천이기도 하다. 동기 스크립트의 실행이 끝나고 나면 모든 비동기 작업들은 일종의 큐에 들어가며, 작업들이 끝나는 순서대로 큐에서 튀어나온다. 파일 시스템에서 특정 파일을 읽어오는 일, 네트워크 요청 이후 응답을 기다리는 일, 혹은 반대로 새로운 네트워크 연결 요청이 들어오기를 기다리는 일, DB에 쿼리를 보내고 응답을 기다리는 일들 등 IO 작업들이 대부분 요청이 끝나면 큐에 들어간다. Event loop는 이 시점부터 계속 순회하며 큐에 있는 작업들 중 완료된 작업들이 있는지 확인하고, 만약 있다면 여기에 물려있는 콜백들을 실행한다. 일반적인 콜백, Promise의 then 혹은 catch 콜백, await 혹은 yield 이후의 스크립트들이나 이벤트를 구독하고 있는 모든 함수들이 여기에 해당된다.

프론트엔드에서 이벤트의 원천은 사용자의 입력 또는 AJAX를 통한 네트워크 요청으로 한정된다. libuv 라이브러리는 일반적인 비동기 IO 작업을 추상화한 인터페이스를 제공하고, NodeJS는 libuv와 V8의 event loop을 성공적으로 결합하여 JS 스크립트로 좀 더 낮은 레벨의 네트워크 작업이나 디스크에 접근하는 작업 등 일반적인 IO 작업들을 가능하게 했다.

3. Node.js의 특징 요약

1. 싱글스레드: 스레드가 늘어나지 않기 때문에, 다른 언어의 웹 서버보다 IO 관점에서 리소스관리에 효율적이다.

2. 비동기: Node.js는비동기 방식의 함수가 주를 이룬다. (자바스크립트: 동기)

3. 이벤트 기반: 동작이 완료될 경우 실행할 함수를 미리 등록한다.

=> non-blocking IO의 특장점을 이용하기 위해, 싱글 스레드를 택했고 이를 위해 비동기 동작이 필요하여, 비동기 동작을 구현하는 이벤트 기반의 코드를 작성하여 구현한다.

​

Q. Are all Node.js callback functions asynchronous?

A. No. Only I/O is usually asynchronous, but many other callbacks are synchronous. Always check the docs.

Examples of async functions:

  • Async Filesystem access (they have sync counterparts without callbacks, though)
  • Timers (setTimeout)
  • process.nextTick, setImmediate
  • most database connections
  • network connections
  • Promises

Examples of sync callbacks:

NodeJS의 모듈들은 같은 API를 async한 것과 sync한 것 두 종류를 모두 제공하는데, sync한 종류를 사용하는 것은 event loop의 사이클을 막고 해당 작업들이 끝날 때까지 대기한다. fs 모듈의 readFile과 readFileSync같은 메소드들이 그러하다. readFile은 파일을 읽는 동안 대기하지 않고 다음 작업을 수행한 후, 파일 읽기가 끝나면 콜백 작업을 수행한다. 반면 readFileSync는 파일 읽기가 끝날 때까지 NodeJS의 모든 작업을 중단시키고 기다린다.

많은 계산을 요구하는 작업들또한 event loop을 막을 수 있다. 하지만 프로그램이 계산을 해야지 뭘 막나? 그렇다. JS는 애초에 그런 작업들을 하기 위해 만들어진 언어가 아니고 NodeJS 또한 그러하다. NodeJS 또한 그런 작업들을 JS가 아닌 다른 언어로 작성된 모듈이나 스크립트를 실행하는 것으로 대체하기를 권장한다. NodeJS는 오로지 IO에만 집중해야 한다. 암호, 복호 연산이나 행렬 연산 같은 CPU를 많이 사용하는 작업들은 JS 스크립트로 작성되었을 때 아주 비효율적이며, 더욱 치명적인 점은 이 계산이 이루어지는 동안 event loop가 대기한다는 것이다. 직접 다른 언어를 사용해서 그런 작업들을 구현하고 이를 비동기적으로 NodeJS와 연결할 수도 있겠으나, 대부분은 이미 npm에 구현체가 올라와 있을 것이다.

reference

https://blog.appleseed.dev/post/nodejs-non-blocking-io-and-multicore-processing/