컨텐츠 바로가기

[Node.js] Understanding the Node.js Event Loop (번역) #2

http://sweeper.egloos.com/3195982


이 글은 [Node.js] Understanding the Node.js Event Loop (번역) #1에서 이어지는 글이다.


5. Task Queue

자바스크립트는 싱글 쓰레드, 이벤트-드리븐에 기반한 언어이다.
이 말인즉슨, 이벤트에 리스너를 붙일 수 있고, 이벤트가 실행되어야 할 때 리스너가 제공했던 콜백을 수행할 수 있음을 의미한다.

"setTimeout", "http.get", "fs.readFile" 등을 호출할 때마다, 노드는 이 동작들을 다른 쓰레드에게 위임하여, V8 엔진이 노드 코드를 계속 수행할 수 있도록 해 준다. 또한 노드는 특정 카운터가 0으로 수렴되거나 I/O가 완료되면 콜백 함수를 호출해 준다.

콜백 함수들은 다른 태스크를 태스크 큐에 추가할 수 있으며, 콜백 함수들은 또 다른 태스크를... 계속해서 태스크 큐에 추가할 수 있다.
이런 방식으로 인해, 파일을 읽는 도중 수신한 서버 요청을 처리할 수 있으며, 읽은 컨텐츠에 기반하여, 다른 작업을 블로킹시키지 않으면서 http call을 처리할 수도 있다.

"노드는 I/O 오퍼레이션을 다른 쓰레드로 보낸다. 그렇기에 V8 엔진은 계속해서 코드를 실행시킬 수 있다"

그러나 노드는 하나의 메인 쓰레드와 하나의 콜스택만 가지고 있기에, 또 다른 요청을 처리하는 도중에 파일을 모두 읽었다는 이벤트(비동기 완료 이벤트)가 발생하여도 다른 일을 처리중이던 스택이 모두 빌 때까지 완료 콜백의 실행은 대기해야만 한다.

콜백이 실행되기까지 기다리는 림보를 태스크 큐(또는 이벤트 큐, 메시지 큐)라고 한다.
콜백은 이전에 실행중인 태스크가 완료될 때 무한히 실행되는 루프에서 호출되기에, 이 루프를 이벤트 루프라고 하는 것이다.

이전에 나왔던 아래 예제에서 우리는 다음의 것들을 살펴볼 수 있다.

  1. 'use strict'
  2.  
  3. const express = require('express');
  4. const superagent = require('superagent');
  5. const app = express();
  6.  
  7. app.get('/', sendWeatherOfRandomCity);
  8.  
  9. function sendWeatherOfRandomCity (request, response) {  
  10.     getWeatherOfRandomCity(request, response);
  11.     sayHi();
  12. }
  13.  
  14. const CITIES = [  
  15.   'london',
  16.   'newyork',
  17.   'paris',
  18.   'budapest',
  19.   'warsaw',
  20.   'rome',
  21.   'madrid',
  22.   'moscow',
  23.   'beijing',
  24.   'capetown',
  25. ];
  26.  
  27. function getWeatherOfRandomCity (request, response) {  
  28.     const city = CITIES[Math.floor(Math.random() * CITIES.length)];

  29.     superagent.get(`wttr.in/${city}`)
  30.     .end((err, res) => {
  31.         if (err) {
  32.             console.log('O snap');
  33.             return response.status(500).send('There was an error getting the weather, try looking out the window');
  34.         }
  35.         const responseText = res.text;
  36.         response.send(responseText);
  37.         console.log('Got the weather');
  38.     });
  39.  
  40.     console.log('Fetching the weather, please be patient');
  41. }
  42.  
  43. function sayHi () {  
  44.   console.log('Hi');
  45. }
  46.  
  47. app.listen(3000);
  1. express 객체가 '/'를 수신할 때 호출되는 핸들러를 등록했다. (line : 7)
  2. 3000 번 포트에 대한 리스닝을 시작했다 (line : 48)
  3. 스택은 비어 있고, 'request' 이벤트를 대기중이다.
  4. request가 왔을 때, express는 제공된 핸들러 "sendWeatherOfRandomCity"를 호출했다.
  5. "sendWeatherOfRandomCity"가 스택에 추가되었다.
  6. "getWeatherOfRandomCity"가 호출되었고, 스택에 추가되었다. (line : 10)
  7. "Math.floor"와 "Math.random"이 호출되었고, 스택에 추가되었다가 빠졌다. 그 결과는 city 변수에 할당되었다. (line : 28)
  8. "superagent.get"은 "wttr.in/%{city}" 인자와 함께 호출되었고, 핸들러는 "end" 이벤트로 설정되었다. (line 30, 31)
  9. "http://wttr.in/${city}"로의 http request가 백그라운드 쓰레드로 전달되었고, 노드 코드 실행은 계속된다
  10. "Fetching the weather, please be patient"가 콘솔에 로깅되었고, "getWeatherOfRandomCity" 함수는 반환되었다 (line : 41~42)
  11. "sayHi" 함수가 호출되었고, "Hi"가 콘솔에 로깅되었다 (line : 11 -> 45)
  12. "sendWeatherOfRandomCity" 함수가 반환되고 스택에서 빠져 스택은 비어 있다.
  13. "http://wttr.in/${city}"의 응답을 기다리는 중...
  14. 응답이 오자마자, "end" 이벤트가 실행된다.
  15. "end" 이벤트로 넘겼던 anonymous handler가 호출되고, 해당 closure의모든 변수가 스택에 추가된다. (line : 31)
    (express, superagent, app, CITIES, request, response, city)
    (해당 closure 함수의 scope가 "sendWeatherOfRandomCity"이기 때문)
  16. "response.send()"가 200 또는 500의 결과와 함께 호출되고, 이는 또다시 백그라운드 쓰레드로 처리가 보내진다. 따라서 response stream이 노드 코드의 실행을 블로킹시키진 않는다. 
  17. "response.send()"의 완료 통지와 관계없이 anonymous handler는 반환되고 스택에서 제거된다.
  18. 이후 어느 시점에 "response.send()"가 완료될 것이다.

이로써 이전에 언급했던 "setTimeout(someFunction, 0)"이 동작하는 방식에 대해 이해할 수 있게 되었다.
counter가 0(zero)에 도달하더라도, 현재 스택이 모두 처리되고, 태스크 큐가 완전히 비어지게 될 때까지 someFunction이 실행이 대기되는 것이다.


6. Microtasks and Macrotasks

지금까지의 설명이 충분치 않다면, 태스크 큐를 2개로 분리해 살펴보도록 하자.
(하기의 내용에 다음 글이 참 괜찮더라 : "Tasks, Microtasks, Queues and Schedules")
  • Microtasks
       ex)
       : process.nextTick
       : Promises
       : Object.observe
       : MutationObserver

  • Macrotasks
       ex)
       : setTimeout
       : setInterval
       : setImmediate
       : I/O (ex. "fs.readFile", "http.get")
       : UI Rendering

다음의 예제 코드를 살펴보자.

  1. console.log('script start');
  2.  
  3. const interval = setInterval(() => {  
  4.     console.log('setInterval');
  5. }, 0);
  6.  
  7. setTimeout(() => {  
  8.     console.log('setTimeout 1');

  9.     Promise.resolve()
  10.     .then(() => {
  11.         console.log('promise 3');
  12.     })
  13.     .then(() => {
  14.         console.log('promise 4');
  15.     })
  16.     .then(() => {
  17.         setTimeout(() => {
  18.         console.log('setTimeout 2');
  19.         Promise.resolve().then(() => {
  20.             console.log('promise 5');
  21.         }).then(() => {
  22.             console.log('promise 6');
  23.         }).then(() => {
  24.             clearInterval(interval);
  25.         })
  26.     }, 0)
  27.   })
  28. }, 0);
  29.  
  30. Promise.resolve()
  31. .then(() => {  
  32.     console.log('promise 1');
  33. })
  34. .then(() => {
  35.     console.log('promise 2');
  36. });

위 코드의 결과는 다음과 같다.

  1. script start
  2.  
  3. promise1  
  4. promise2
  5.  
  6. setInterval  
  7. setTimeout1
  8.  
  9. promise3  
  10. promise4
  11.  
  12. setInterval  
  13. setTimeout2
  14. setInterval
  15.  
  16. promise5  
  17. promise6

WHATVG 스펙에 따르면, 한 번의 이벤트 루프 싸이클에서 정확하게 하나의 Macrotask가 실행되어야 한다.
Macrotask가 일을 완료하고 나면, 실행 가능한 모든 Microtasks들이 동일 싸이클 내에서 실행된다.
즉, 하나의 Macrotask 사이 사이에 쌓인 Microtasks들이 모두 실행되는 것이다.

Microtasks들이 실행되는 동안, 더 많은 Microtasks들을 Microtasks 큐에 추가할 수 있으며, 이들은 Microtasks 큐가 완전히 빌 때까지 실행된다.

아래 그림은 위의 설명을 조금 더 명확하게 해 줄 것이다.


이를 위 예제에 대입시켜 보면, 다음과 같다.

Cycle #1

  1. "Run script"라는 최초 (Macro)태스크가 스케쥴링 되며, 태스크 큐가 비어있었기에 바로 실행된다.
  2. "script start"가 콘솔에 로깅되었다. (line : 1)
  3. "setInterval"이 (Macro)태스크로써, 스케쥴링 된다. (line : 4)
  4. "setTimeout 1"이 (Macro)태스크로써, 스케쥴링 된다. (line : 8)
  5. "Promise.resolve 1" (line : 31)의 2개의 "then" 절이 Microtask로 스케쥴된다.
  6. 스택은 비어 있고, microtasks 들이 실행된다.
이 상태에서의 (Macro)태스크 큐 : "Run script", "setInterval", "setTImeout 1"

Cycle #2

  1. Microtasks 큐는 비어 있고, "setInterval"의 핸들러가 실행된다.
  2. 또 다시 "setInterval"이 "setTimeout 1" 바로 다음 위치에 태스크로써 스케쥴링된다.
이 상태에서의 태스크 큐 : "setInterval", "setTimeout 1", "setInterval"

Cycle #3

  1. Microtasks 큐는 비어 있고, "setTimeout 1"의 핸들러가 실행된다.
  2. "promise 3"과 "promise 4"가 Microtask로써 스케쥴링된다.
  3. "promise 3"과 "promise 4"의 핸들러가 실행되고, "setTimeout 2"가 태스크로써 스케쥴링된다.
이 상태에서의 태스크 큐 : "setTimeout 1", "setInterval", "setTimeout 2"

Cycle #4

  1. Microtasks 큐는 비어 있고, "setInterval" 핸들러가 실행된다.
  2. 또 다시 "setInterval"이 "setTimeout 2" 바로 다음 위치에 태스크로써 스케쥴링된다.
이 상태에서의 태스크 큐 : "setInterval", "setTimeout 2", "setInterval"

  1. "setTimeout 2"의 핸들러가 실행되고, "promise 5", "promise 6"이 Microtasks로써 스케쥴링된다.

이제 "promise 5", "promise 6"의 핸들러가 실행되어야 하고, interval을 클리어한다.

위의 예제를 다음과 같이 process.nextTick을 이용해 작성할 수도 있다.

  1. console.log('script start');
  2.  
  3. const interval = setInterval(() => {  
  4.     console.log('setInterval')
  5. }, 0);
  6.  
  7. setTimeout(() => {  
  8.     console.log('setTimeout 1');
  9.     process.nextTick(() => {
  10.         console.log('nextTick 3');
  11.         process.nextTick(() => {
  12.             console.log('nextTick 4');
  13.             setTimeout(() => {
  14.                 console.log('setTimeout 2');
  15.                 process.nextTick(() => {
  16.                     console.log('nextTick 5');
  17.                     process.nextTick(() => {
  18.                         console.log('nextTick 6');
  19.                         clearInterval(interval);
  20.                    })
  21.                })
  22.            }, 0)
  23.        })
  24.     })
  25. })
  26.  
  27. process.nextTick(() => {  
  28.     console.log('nextTick 1');
  29.     process.nextTick(() => {
  30.         console.log('nextTick 2');
  31.     })
  32. });


7. Tame the async beast!

지금까지 살펴 봤듯이, 노드에서 어플리케이션을 작성할 때, 노드의 모든 힘을 다 끌어내고 메인 쓰레드가 블로킹되지 않게 하기 위해 태스크 큐이벤트 루프 둘 다 제대로 관리하고 신경써줘야 한다.

이벤트 루프 개념이 처음에는 파악하기 힘든 개념일 수 있지만, 일단 이해하게 되면 이벤트 루프 없는 노드는 상상하기 힘들다.
콜백 지옥으로 이어질 수 있는 연속 실행 전달 스타일은 얼핏 추해 보이지만, 우리에겐 "Promises"가 있고, 머지않아 async/await도 구현이 될 것이다.

노드와 V8 엔진이 장기적인 비동기 실행을 어떻게 처리하는지 알게 되면, 목적에 맞게 적절히 사용할 수 있을 것이다.


덧글|신고