포인트3 테크니컬 하우스

Event Driven Non-Blocking I/O Model

양재승
양재승May 6, 2024
5 min read|

🔨 JavaScript 의 single thread

JavaScript는 싱글 스레드를 기반으로 실행된다. 즉 코드를 실행하는 Call Stack 이 단 하나라는 의미이다.

‘싱글 스레드’ 에 의한 장단점은 극명하다.

  • 장점

직관적인 코드 실행 동작 과정과 Dead-Lock 과 같이 복잡하고 디버깅 하는데에 많은 노력이 필요한 버그에 부담이 줄어든다는 것이다.

여담으로 교수님들이 종종 말씀하시길 Dead-Lock 문제는 ‘군대에서 탄피 찾기’ 또는 ‘크리스마스 명동 사거리 교통정리’ 에 비유된다. 모든 팀원들을 동원하여 low level 에서부터 문제가 발생하는 지점을 하나하나 샅샅이 찾고 해결해야한다. 자칫 외부 엔지니어를 고용하는 경우도 허다하다는 이야기도 들려주셨다. 그만큼 코드단에서 단순히 해결될 단순한 문제가 아니란 뜻이다.

반대로 이렇게 무시무시한 이슈에 대한 부담이 덜어진다는 것은 개발자 입장에선 꽤나 큰 장점이 된다.

  • 단점

단점 또한 명확하다. 효율 문제이다.

식당에서 일하는 종업원이 한명일 때와 두명일 때 식당의 회전율은 단순히 생각해서 2배이다. 프로그램 또한 같다. 함수를 처리하는 스레드가 많을수록 실행 속도는 증가할 것이다.

또한 스레드가 극단적으로 정말 하나라면 여러가지 문제점도 발생한다. 함수가 몇초 후에 동작하도록 설정하는 setTimeout() 함수나, 유저의 반응을 기다리는 event 함수가 Call Stack 에 막혀있다고 생각해보자. 이러한 경우에는 프로그램 자체가 반응을 기다리고 멈춰있어야 한다. 일하는 종업원이 음식을 만드느라 손님을 받지 못하는 꼴이 되는 것이다.

그렇다면 JavaScript 는 위와같은 싱글스레드에서 나타나는 의도하지 않은 idle 상태 이슈와 효율성 문제를 어떻게 해결했을까?

⚙️ V8의 등장과 Node.js

JavaScript 는 원래 철저하게 브라우저 종속적인 언어였다. 웹 브라우저만이 유일하게 JS의 사용처였고, 런타임 환경이었다.

그러던 중 구글에서 새로운 JS 엔진인 V8 을 탑재한 크롬 브라우저를 출시하게 되면서 더욱 빠르게 코드를 실행할 수 있었고 c++ 기반의 런타임 환경으로 더이상 브라우저 종속에서 해방되어 확장성을 갖추게 된다.

바로 이 V8 엔진을 기반으로 Node.Js 프래임워크가 탄생했다.

Node.js 는 단일 스레드에서 돌아가지만 비동기 이벤트 기반(Event Driven)Non-blocking I/O Model 이다.

단순하게 생각하면 단일 스레드라는 용어와 비동기, Non-blocking 용어는 개념상으로 서로 상충지만, 이 두 가지 키워드가 싱글 스레드에서 생기는 문제점들을 해결한 방법이다.

Node.js 의 구성 환경과 실행 순서를 통해 이해해보자.

실행환경

Web API: 브라우저에서 제공되는 API로, setTimeout, Http request methods(ajax), DOM 이벤트 등 비동기 함수들을 처리함.

Callback Queue: 이벤트가 발생한 후 호출되어야 할 콜백 함수들이 대기하고 있는 공간. 이벤트 루프가 정해준 순서대로 대기하며 Task queue 라고도 함.

Event Loop: 이벤트 발생 시 호출할 콜백 함수들을 관리하며, 호출된 콜백함수의 실행 순서를 결정함.

실행 순서

console.log("violet pay")

setTimeout(() => {
	consloe.log("화이팅~~!");
}, 0);

console.log("Point 3");
coffeescript

다음 예제 코드의 출력 결과를 예측해보자.

일반적인 console 함수는 Call Stack 에 올라와 순차적으로 수행되고 종료된다. 우리가 주목할 점은 비동기 함수이다.

setTimeout 함수의 설정 값이 0초 더라도 비동기 함수를 사용했으므로 libuv 라이브러리가 관리하는 Thread Pool 로 들어가 Web API 를 통해 요청하여 내부적으로 처리한 후 결과만(Event)을 CallBack Queue 로 다시 보낸다.

즉 WebAPI -> Callback(Task) Queue -> Call Stack 순으로 이동한다. 여기서 중요한 점은 Callback Queue 에 대기하고 있는 콜백함수는 Call Stack 이 비어있을 때만 Event loop 가 Call Stack 으로 콜백함수를 옮긴다. 따라서 "화이팅~~!" 이 마지막에 출력되는것이다.

violet pay

Point 3

화이팅~~!
html

이 과정을 이를 한 문장으로 정리해보자.

Node.js 는 싱글 스레드이지만, Web API 에 비동기 함수처리를 요청하여 결과만을 call back 하는 방식으로 동작한다.

Node.js의 비동기 함수의 처리 과정은 이해했고, 조금 더 구체적으로 비동기 함수들이 처리되는 방식을 이해해보자.

Web API 를 통해 넘긴 비동기 함수들은 어떻게 처리되는 것일까?

💡Event-Driven(이벤트 기반) Non-Blocking I/O Model

비동기 함수들의 처리를 이해하기 위해서는 ‘이벤트 기반’ 키워드를 이해하면 된다.

이벤트 기반이란 이벤트가 발생할 때 미리 지정해둔 작업을 수행하는 방식을 의미한다. Node.js 는 이벤트 리스너에 등록해둔 콜백 함수를 실행하는 방식으로 동작한다. 이를 모니터링하는 것이 바로 Event loop 이다. 여기에서 Event loop 는 브라우저 런타임과 다르다.

Node.js 의 구성요소

Node.js 는 내장 코어 라이브러리와 V8 Engine, libuv 로 구성되어있다. Node.js의 특성인 이벤트 주도, 논플로킹 입출력 모델들은 전부 libuv 라이브러리에서 구현된다.

우리가 작성하는 Node.js의 거의 모든 코드는 콜백 함수로 이뤄져 있다. 콜백 함수들은 libuv 내 위치한 Event Loop 에서 관리된다. 이 Event Loop 는 브라우저 런타임의 이벤트 루프와 달리 여러 개의 페이즈(Phase)를 갖고 있다. 해당 페이즈들은 각각의 큐(Queue)를 가진다. 즉 이벤트 루프 안에서도 각각의 큐의 페이즈를 통해 순서에 맞게 코드가 실행되는 것이다.

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

정리하면, 우리가 위에서 살펴봤던 비동기 함수들은 Call Back 방식으로 처리되고, 이 Call Back 함수들을 Event Loop 에서 관리하는 것이다.

Event Loop 의 Call Back 처리

Event loop 는 이벤트 큐에 등록된 콜백 함수를 하나씩 순서대로  처리한다. 즉 Web API 를 통해 비동기적으로 완료된 Event 들을 동기적으로 실행한다.

여러가지 입출력 작업들은 OS 커널 혹은 libuv 내의 스레드 풀에서 담당하고. libuv의 스레드 풀은 커널이 지원하지 않는 작업들을 수행한다.

libuv는 OS 커널에서 어떤 비동기 작업을 지원하는지 알고있다. 그래서 작업 종류에 따라 커널 혹은 스레드 풀로 분기한다.  작업이 완료되면 이벤트 루프에게 이를 알리고, 이벤트 루프에 콜백 함수로 등록된다. 

💡
Thread Pool(Sub Thread)

Node.js 는 기본적으로 Thread(Worker) Pool 이라는 또 다른 스레드가 존재한다. 다중 스레드를 지원하는 오늘날의 OS 환경하에서, libuv 에서 관리 및 생성한다.

Thread Pool 은 Non-Blocking 처리를 지원하지 않는 OS 파일 입출력, CPU 집약적 연산 등을 비동기적으로 처리하는데 이용된다.

Thread Pool 은 위임된 작업들을 자체 큐를 활용하여 처리하고 완료시 완료이벤트를 Event Loop 에게 알린다. 즉 자체적으로 트랜잭션을 수행한 후 수행 결과만을 Event loop 로 알려주는 것이다.

🧹정리

정리해보자. Node.js 는 Single Thread 이며 이벤트 주도 Non-Blocking I/O Model 을 지원하는데, 그 방법은 비동기 함수와 수행 시간이 오래걸리는 I/O 작업들은 Web API 를 통해 요청하고 수행 결과만을 받아와 내부적으로는 지정한 call back 함수만을 수행한다.

이때 call back 함수들과 비동기 함수들의 스케줄링은 libuv 의 Event loop 가 담당한다.

🤙일반 함수의 비동기 처리(번외)

Node.js 에서의 비동기함수 처리를 알아보았는데, Blocking 함수가 아닌 일반 함수를 비동기함수로 처리하려면 어떻게 해야될까?

여기서 나오는 키워드가 바로 Pomise, async/await 이다.

Promise

Promise의 3가지 상태(status)

  • 대기(pending) : 비동기 처리 로직이 아직 완료되지 않은 상태
  • 이행(fulfilled) : 비동기 처리가 완료되어 프로미스가 결과 값을 반환해준 상태(완료 상태)
  • 거부(rejected) : 비동기 처리가 실패하거나 오류가 발생한 상태

  • 대기(Pending)

아래와 같이 new Promise( ) 메서드를 호출하면 대기 상태가 된다.

new Promise();
javascript

이때 메서드를 호출하면서 콜백 함수를 선언할 수 있는데, 함수의 인자는 resolve, reject가 있다.

const promise = new Promise(function(resolve, reject){
    ..
});

 Arrow Function const promise = new Promise((resolve, reject) =>(){
    ..
});
javascript

  • 이행(Fulfilled)

promise 콜백 함수의 인자 resolve를 실행(성공)하면 이행(fulfilled) 상태가 된다.

new Promise((resolve, reject) => {
  resolve();
});
javascript

promise가 이행 상태가 되면 then() 을 이용하여 처리 값을 받을 수 있다.

const promise = new Promise((resolve, reject) => {
  resolve("성공");
});

promise.then((message) => {
  alert(message);
});
javascript

  • 실패(Rejected)

promise 의 콜백함수의 인자 reject를  호출하면 실패(reject) 상태가 된다.

const promise = new Promise((resolve, reject) => {
  reject("에러");
});

promise.then().catch((err) => {
  alert(err);
});
javascript

참고

https://tech.cloudmt.co.kr/2023/08/04/230804/, 

https://medium.com/@vdongbin/node-js-%EB%8F%99%EC%9E%91%EC%9B%90%EB%A6%AC-single-thread-event-driven-non-blocking-i-o-event-loop-ce97e58a8e21

https://mniyunsu.github.io/node-loop/, https://nodejs.org/docs/latest/api/