JS가 돌아가는 실행환경
JavaScript는 싱글 스레드 기반으로 동작하며, 비동기 작업을 처리하기 위해 이벤트 루프를 활용하는 특징이 있다. 학부 시절에는 이벤트 루프라는 개념만 알고 있었지, 이벤트 루프가 실제로 동작하는 Node.js나 브라우저 환경의 차이에 대해서는 잘 알지 못했다.
보통 Node.js가 뭐냐
라고 물으면, JavaScript 런타임 또는 브라우저 없이도 JavaScript를 실행할 수 있는 환경
이라는 정도로 정리했었다.
한 때는 Node.js를 직접 다룰 일이 없을 거라고 생각했다. 하지만 webpack이 사용하기 시작하면서, Node.js 환경에 대해 자연스럽게 접하게 되었다. webpack이 Node.js 환경에서 실행되기 때문이다. 그리고 곧 그것이, webpack의 기본이 commonjs작성되어 있다는 이유이기도 하다.
브라우저의 Event Loop / Node.js의 Event Loop
브라우저를 크롬
이라고 가정한다면, nodejs와 브라우저는 같은 v8엔진이 있다. 또한, 각각의 event loop들은 풀고자하는 근본적인 문제에서는 벗어나지 않자만, UI를 위한 목적인가, 서버에서 실행되기 위한 목적인가
에 따라 구현에는 차이는 있다.
브라우저 같은 경우는 Web apis를 본다면, dom과 관련된 api를 지원하지만, Node.js의 경우는 dom관련 api가 없고, 애초에 Web apis라는 이름이 아닌, libuv라이브러리가 Node.js환경에서 Web APIs의 역할을 대체한다. 또한, event loop가 해당 libuv안에 존재한다.
libuv는 c로 작성되어있는데, 그 중, src/unix/
디렉토리의 코드들은 libuv가 다양한 비동기 작업들을 관리하기 위해 이벤트 루프를 구현하고 있음을 잘 보여주고있다. 그 중, 작업이 남아있는한 반복 실행하며, 이를 통해 이벤트 루프가 지속적으로 실행되는것을 볼 수도 있다.
또 다른 차이를 말한다면, 서로 다른 작업큐와 실행 명령어에서의 차이가 있다. 브라우저 런타임의 이벤트루프의 경우에는 MicroTask
, MacroTask
이렇게 두 개의 대기열이 있다. 그리고 js의 코드는 몇가지가 여기에 각각 처리된다. 우선순위의 경우에는 MicroTask, 그다음 MacroTask 처리가 된다.
그래서 Promise.then은 MicroTask
, setTimeout은 MacroTask
에서 각각 실행되기 때문에 위와 같은 순서가 나오게 되는 것이다. 반면, Node.js를 본다면, 기존 브라우저의 MacroTask가 더 세분화 되어서, Timers, Pending, Poll, Check, Close
라는 5가지 유형의 매크로 작업이 존재한다. 즉, 복잡한 작업 관리를 위해서, 세부적으로 구분된 이벤트 루프가 존재한다는 것이다. 그리고 각 단계를 라운드 로빈 방식
으로 순환을 하며, 각 단계를 넘어가는 동작(또는 준비하는 과정)을 Tick
이라고 한다.
Tick 1 → Timers 작업
Tick 2 → Pending 작업
Tick 3 → Poll 작업
Tick 4 → Check 작업
Tick 5 → Close 작업
그리고 다시 Tick 1부터 순환
이런경우 결과를 보면 Node.js의 MicroTask작업이 먼저 처리가 되고, 이후 libuv의 이벤트루프동작에서 timers의 할당받은 시간에 처리가 된다면 타이머1,2가 console출력 이후, Polling Queue의 작업이 처리가 된다. 이번에는 setTimeout하나를 20ms로 바꿔보겠다.
이렇게 timers queue에 20ms라는 조건으로 변경을 하니, timers 작업의 타이머1
만 먼저실행되고 Polling 작업을 한 뒤, 다시 Tick 1부터 순환해서 타이머2
가 마지막에 출력된 것을 볼 수 있다.
Event Loop에 속하지 않는 두 개의 queue
Node.js에는 NextTickQueue
와 MicroTaskQueue
가 있는데, NextTickQueue의 우선순위가 더 높다. 그리고 이 두개의 queue는 libuv와 분리되어있다.
이렇게 분리되어있는 상황에서, NextTickQueue와 MicroTaskQueue의 우선순위, 그리고 MacroTask의 세분화 된 큐의 동작과정과 우선순위에서는 다음의 파이프라인으로 진행된다.
nextTick의 경우에는 각 단계가 끝난 직후 실행된다. 현재 시작단계도 포함하기 때문에 현재 Tick에서 처음으로 NextTickQueue
가 먼저 비워진다. 이후 Timers 단계에서 setTimeout
, 그리고 Check 단계
에서 setImmediate가 출력된다. 그래서 결과는 2 -> 3 -> 1 -> 4
이다.
해당경우에도, 5 -> 6 -> 1 -> 4 -> 3 -> 2
로 실행이 되는데, 다음과 같다.
- Timers 단계에서 타이머 콜백이 실행
- 해당 타이머 콜백에서
process.nextTick
과setImmediate
가 예약 NextTickQueue는 항상 현재 틱에서 우선적으로 처리
되므로, process.nextTick이 실행- 이후 Timers 단계가 끝나고 Check 단계로 넘어가, setImmediate가 실행
결론적으로, process.nextTick은 항상 현재 이벤트 루프 틱에서 우선 실행되며, setImmediate는 Timers 단계가 끝난 후 Check 단계에서 실행된다.
os가 영향을 줄 수 있는 setTimeout과 setImmediate의 순서 변화
공식문서를 보면 운영체제의 미세한 차이로 callback실행이 지연될 수 있다고 하는데 실제로 간단한 코드로 테스트를 해봤다. 서로 다른 Phase에서 실행되는 setTimeout과 setImmediate로 계속 node를 실행했고,
setTimeout의 경우가 사실 Timers큐에 들어가서 먼저 console이 출력될 수 밖에 없었다고 생각했지만, 몇십 번의 한번 꼴로,setImmediate -> setTimeout
로 실행되는 것을 볼 수 있었다.