Node.js?
Node.js 공식 문서와 위키 백과를 보면 Node.js에 대한 설명이 아래와 같이 나와 있습니다.
Node.js는 비동기 이벤트 주도 자바스크립트 런타임입니다.
단일 스레드 이벤트 루프를 통해 높은 처리 성능을 가집니다.
다른 건 차치하고 단일 스레드 이벤트 루프를 통해 높은 처리 성능을 보인다고 합니다. 여기서 한 가지 의문이 생깁니다. 약 10초 정도 걸리는 I/O 작업 요청을 받으면, 단일 스레드 환경에서는 해당 I/O 작업을 끝마치기까지 다른 요청을 처리할 수 없을 것입니다. (스레드가 한 개니까요) 그런데 어떻게 Node.js는 단일 스레드로 높은 처리 성능을 갖는 걸까요? 이 질문은 이렇게 바꿀 수 있습니다. 이벤트 루프는 어떠한 방식으로 동작하길래 단일 스레드로도 높은 처리량을 갖는 걸까요?
Node.js의 구조와 libuv의 세부 동작을 알아본 뒤 해당 질문에 대한 답을 해보겠습니다.
Node.js 구조
Node.js의 대략적인 구조는 위 사진과 같습니다. 여기에서 우리가 집중해야 할 모듈은 V8 Engine과 libuv입니다.
V8 Engine
V8은 Javascript 엔진입니다. 이 엔진은 Javascript 코드를 실행하는 역할을 합니다. Node.js는 V8 엔진을 내장하고 있어 JS 코드를 실행할 수 있습니다.
libuv
libuv는 비동기 I/O에 중점을 둔 라이브러리입니다. kernel을 추상화한 Wrapping 라이브러리로 kernel이 어떤 비동기 API를 지원하는지 알고 있습니다. 만약 커널 단에서 지원해 주는 작업이라면 커널을 통해 해당 작업을 비동기적으로 수행하고 지원하지 않는다면 libuv가 내부적으로 갖고 있는 Thread pool의 thread를 사용하여 해당 작업을 수행합니다. 이러한 libuv는 event loop 방식을 기반으로 작성되어 있습니다.
Node.js에서 libuv는 I/O 작업과 타이머 이벤트(setTimeout(), setImmediate(), ...)와 같은 이벤트를 관리하고 처리하는 역할을 합니다.
Node.js Binding
Node.js는 JS와 C/C++ 코드 간의 상호 작용을 하기위해 Node.js Binding을 사용합니다. 즉, JS 코드에서 C/C++ 코드를 호출하거나 C/C++ 코드에서 JS를 호출하기 위해 사용됩니다.
Node.js Core Library
Node.js는 JS로 구현된 코어 라이브러리를 제공합니다. 이 라이브러리에는 파일 시스템 액세스, 네트워크 통신, HTTP 서버 및 클라이언트, 이벤트 핸들링, 스트림 처리, 암호화 및 보안 기능 등 다양한 모듈이 포함되어 잇습니다. 이러한 코어 라이브러리는 Node.js 개발자가 애플리케이션을 개발하고 실행하는 데 필수적인 도구와 기능을 제공합니다.
정리하자면 다음과 같습니다.
V8 Engine은 js 코드를 실행합니다. 이때, I/O 작업과 같은 동작은 libuv를 통해서 처리되며 libuv는 **event loop 방식으로 동작합니다.
** event loop
컴퓨터 과학에서 이벤트 루프는 프로그램에서 이벤트나 메시지를 기다리고 또, 전달하는 프로그래밍 구성/디자인 패턴입니다. 간단히 말해, event loop는 이벤트를 기다리고 전달하는 어떠한 구조체를 뜻한다고 생각하면 됩니다.
Libuv 구조
위 사진을 보면 libuv 안에 있는 event loop를 확인할 수 있습니다. Node.js에서 말하는 이벤트 루프는 바로 이 libuv의 이벤트 루프를 뜻합니다.
어떠한 비동기 요청을 받았을 때 libuv의 동작은 다음과 같습니다.
1. 커널 단에서 지원해 주는 비동기 작업이라면 커널을 통해 해당 작업을 처리하고, 지원해 주지 않는 비동기 작업이라면 워커 스레드를 통하여 작업을 처리한다. (워커 스레드는 libuv가 사전에 생성해 놓은 thread pool에 있는 thread를 뜻합니다. uv_threadpool 이라는 환경 변수를 통해 개수를 변경할 수 있으며 default는 4개입니다.)
2. 언젠가 비동기 작업이 끝나면 이벤트 루프의 적절한 Queue에 콜백이 담긴다.
3. event loop는 순차적으로 큐를 순회하며 큐에 담긴 콜백들을 처리한다.
이때 event loop는 단일 스레드로 동작합니다. 이러한 이유로 Node.js가 단일 스레드 이벤트 루프 방식으로 동작한다고 말하는 것입니다. 덧붙이면, V8 엔진(js를 실행하는 엔진)과 이벤트 루프는 동일한 단일 스레드로 동작하지만 Node.js 전체가 단일 스레드인 것은 아닙니다. (워커 스레드가 있으므로)
event loop 구조
event loop는 위 사진에서 보이듯 여러 가지 Phase로 이루어져 있습니다. 각 페이즈는 자신만의 큐(대기열)를 가지고 있습니다. 이 큐에는 이벤트 루프가 실행해야 하는 작업이 순서대로 담겨있습니다.
Node.js의 메인 스레드가 어떠한 Phase에 진입하면 해당 Phase가 갖고 있는 큐에 있는 작업을 실행합니다. 단, "최대 콜백 수"라는 것이 있어서 큐에 있는 작업을 무한정 실행하는 것이 아니고, 최대 콜백 수 만큼 수행하면 큐에 남은 작업이 있더라도 다음 Phase로 넘어갑니다.
순회 순서는 timer -> pending callbacks -> idle, prepare -> poll -> check -> close -> timer... 로 반복됩니다. 한 페이즈에서 다음 페이즈로 넘어가는 것을 틱(Tick)이라고 합니다. 이벤트 루프가 싱글 스레드로 동작한다는 점을 명심해야 합니다. poll 페이즈 작업을 처리하면서 check의 작업을 동시에 처리하는 등의 동작은 불가능합니다.
중간 정리
- 이벤트 루프는 Node.js의 비동기 작업을 처리할 수 있도록 하는 libuv 속의 구현체다.
- 이벤트 루프는 6개의 페이즈로 구성되어 있으며, 한 페이즈에서 다음 페이즈로 넘어가는 것을 틱(Tick)이라고 한다.
- 각 페이즈는 자신만의 큐를 가지고 있다.
- Node.js는 순서대로 페이즈를 방문하면서 큐에 쌓인 작업을 하나씩 실행한다. (싱글 스레드이므로)
- 큐에 쌓인 작업을 모두 처리한 경우 혹은 최대 콜백 수에 도달한 경우 다음 페이즈로 넘어간다.
이제 각 페이즈에 대해 좀 더 자세히 알아봅시다.
Timer Phase
Timer 페이즈는 말 그대로 setTimeout이나 setInterval 같은 함수가 만들어 내는 타이머들을 다룹니다. Timer 페이즈의 큐는 단순한 큐가 아닙니다. 이는 min-heap으로 이루어져 있습니다. min-heap으로 이루어진 이유는 타이머가 들어온 순서가 중요한 것이 아니라 실행되는 시점이 중요하기 때문입니다.
예를 들어, 아래와 같은 코드가 작성되어 있을 때 console.log("Second")가 먼저 실행될 것입니다. Timer 페이즈의 대기열에 더 빨리 들어갔다고 더 빨리 실행되는 것이 아닙니다. 실행되는 시점에 따라 실행 순서가 정해지기 때문입니다. 따라서 Timer 페이즈의 대기열은 min-heap으로 구성되어 있습니다.
setTimeout(() => { console.log("First") }, 100)
setTimeout(() => { console.log("Second") }, 1)
Timer 페이즈의 동작은 다음과 같습니다.
setTimeout(fn, delay)이 실행되면 Node.js는 타이머를 min-heap에 저장합니다. 이때 setTimeout()을 호출한 시간을 registeredTime, 현재 시간을 now라고 가정하겠습니다. Node.js가 Timer 페이즈에 진입하면 min-heap에서 타이머 하나를 꺼냅니다. 그리고 그 타이머에 대해 "now - registeredTime >= delay" 조건을 검사합니다. 만약 조건을 만족한다면 해당 타이머의 콜백을 실행하고 만족하지 않는다면 다음 페이즈로 넘어갑니다. (참고로 now는 min-heap에서 하나 꺼낼 때마다 매번 갱신되는 것이 아니라 Timer 페이즈의 맨 처음에 단 한 번만 갱신합니다. 따라서 now가 다시 갱신되려면 한 바퀴 돌아서 다시 Timer 페이즈에 도달해야 합니다.)
여러 예시를 살펴보겠습니다.
delay가 50, 150, 200, 500, 3000인 5개의 타이머를 0초에 등록했다고 생각해 봅시다.
const A = setTimeout(fn, 50)
const B = setTimeout(fn, 150)
const C = setTimeout(fn, 200)
const D = setTimeout(fn, 500)
const E = setTimeout(fn, 3000)
그렇다면 타이머는 min-heap에 아래와 같이 저장되어 있을 것입니다. (실제로는 이진 트리 구조를 가질 테지만 편의를 위해 단순히 오름차순으로 정렬되어 있다고 생각합시다)
먼저 Node.js가 30초에 Timer Phase에 진입했다고 생각해 봅시다. Node.js는 먼저 min-heap에서 A를 꺼내서 검사합니다. A의 delay는 50이므로 "now(30) - registeredTime(0) >= delay(50)"이 성립하지 않습니다. 따라서 A의 콜백을 실행하지 않습니다. 이때 타이머들은 min-heap으로 오름차순 정렬되어 있으므로 뒤의 타이머들은 당연히 위 조건을 충족하지 못할 테니 검사하지 않아도 됩니다. 다음 페이즈, 즉 pending callbacks phase로 넘어갑니다.
이번에는 Node.js가 180초에 Timer Phase에 진입했다고 생각해 봅시다. Node.js는 먼저 min-heap에서 A를 꺼내서 검사합니다.
now(180) - registeredTime(0) >= delay(50)이 성립하므로 A의 콜백을 수행합니다. 그 다음
now(180) - registeredTime(0) >= delay(150)이 성립하므로 B의 콜백을 수행합니다. 그 다음
now(180) - registeredTime(0) >= delay(200)이 성립하지 않으므로 C의 콜백을 수행하지 않고 다음 페이즈로 넘어갑니다.
이번에는 최대 콜백 수가 1개이며, Node.js가 180초에 Timer Phase에 진입했다고 생각해 봅시다. Node.js는 먼저 min-heap에서 A를 꺼내서 검사합니다.
now(180) - registeredTime(0) >= delay(50)이 성립하므로 A의 콜백을 수행합니다. 그다음 B의 성립 여부를 확인해야 하는데, 이미 최대 콜백 수를 채웠으믈 검사하지 않고 다음 페이즈로 넘어갑니다.
Pending callbacks Phase
이 페이즈는 이전 이벤트 루프 반복에서 수행되지 못했던 I/O 콜백을 수행합니다. 이 큐에 담기는 콜백들은 이전 이벤트 루프 반복에서 수행되지 못했던 I/O 콜백들입니다.
위 Timer Phase에서 말했듯 대부분의 페이즈는 "최대 콜백 수"의 영향을 받습니다. Pending callbacks Phase는 원래는 실행되어야 하는데, 최대 콜백 수 제한에 의해 실행되지 못한 작업들을 쌓아놓고 실행하는 페이즈입니다.
그리고, 에러 핸들러 콜백 또한 pending_queue로 들어오게 됩니다. *nix는 TCP 단에서 ECONNREFUSED를 받으면 pending_queue에 에러 핸들러를 추가합니다.
Idle, prepare Phase
이 페이즈들은 Node.js의 내부적인 관리를 위한 페이즈로 자바스크립트를 실행하지 않습니다. 공식 문서에서도 별다른 설명이 없고 코드의 직접적인 실행에 영향을 미치지 않습니다.
Poll Phase
대부분의 I/O 콜백들이 이 페이즈에서 실행됩니다. 쉽게 말해 setTimeout(), setImmediate(), close() 콜백 등을 제외한 모든 콜백이 여기서 실행된다고 생각하면 됩니다. 예를 들어, 아래와 같은 콜백들이 해당 페이즈에서 실행됩니다.
- DB에 쿼리를 보낸 후 결과가 왔을 때 실행되는 콜백
- HTTP 요청을 보낸 후 응답이 왔을 때 실행되는 콜백
- 파일을 비동기로 읽고 다 읽었을 때 실행되는 콜백
- ...
Node.js가 poll 페이즈에 진입했을 때 기다리고 있는 I/O 요청이 없거나, 아직 응답이 오지 않았다면 어떻게 행동할까요? 그동안 살펴본 Timer Phase, Pending callbacks Phase에서는 큐에 실행할 수 있는 작업이 없다면 즉시 다음 페이즈로 넘어갔습니다. 하지만 poll 페이즈는 조금 다르게 동작합니다.
poll 페이즈 queue에 있는 콜백들을 전부 실행하여 queue가 비게 된다면, 또는 queue가 애초에 비어있었다면 event loop는 poll 페이즈에서 잠시 대기할 수 있습니다. 이때 대기하는 시간은 아래 여러 조건에 의해 결정됩니다. 다시 말해, poll 페이즈는 Node.js가 다음 페이즈로 이동해 다시 poll 페이즈로 돌아올 때까지 실행할 수 있는 작업이 있는지를 고려합니다.
- close callback phase, pending callbacks 페이즈에서 실행할 작업이 있다면 즉시 다음 페이즈(check phase)로 넘어간다.
- 만약 timer 페이즈에서 즉시 실행할 수 있는 타이머가 있다면 즉시 다음 페이즈로 넘어간다.
- 만약 timer 페이즈에서 n초 후 실행할 수 있는 타이머가 있다면 n 초를 기다린 후 다음 페이즈로 넘어간다.
Check Phase
이 페이즈는 오직 setImmediate의 콜백만을 위한 페이즈입니다. setImmediate가 호출되면 Check Phase의 큐에 담기고 Node.js가 Check 페이즈에 진입하면 큐에 담긴 작업을 쌓인 순서대로 실행합니다.
Close callbacks Phase
소켓이나 핸들이 갑자기 닫히면(e.g., socket.destroy()) 이 단계에서 'close' 이벤트가 발생하게 됩니다. 그렇지 않은 경우에는 process.nextTick()으로 실행됩니다.
그 외의 것(nextTickQueue, microTaskQueue)
사실 페이즈는 위에서 배운 것이 거의 전부이지만 Node.js에는 nextTickQueue와 microTaskQueue라는 것이 있습니다. 이 nextTickQueue와 microTaskQueue는 이벤트 루프의 일부가 아닙니다. 정확히는 libuv에 포함되어 있지 않고 Node.js에 구현되어 있습니다. 이들은 이벤트 루프의 일정한 페이즈에서 실행되지 않고, 현재 페이즈와 상관없이 지금 수행하고 있는 작업이 끝나면 그 즉시 실행됩니다.
nextTickQueue는 process.nextTicke(cb)로 등록된 콜백을 관리하고, microTaskQueue는 Resolve된 Promise 콜백을 관리합니다. 그리고 nextTickQueue가 microTaskQueue보다 높은 우선순위를 가지므로 nextTickQueue의 작업이 microTaskQueue의 작업보다 항상 먼저 실행됩니다. 다음 코드 결과를 참고해 주세요.
Promise.resolve().then(() => console.log('resolve'))
process.nextTick(() => console.log('nextTick'))
// 실행 결과
// nextTick
// resolve
중요한 특징으로는 다른 페이즈들과는 달리 nextTickQueue와 microTaskQueue는 "최대 콜백 수"가 없다는 것입니다. 따라서 Node.js는 해당 큐가 비워질 때까지 콜백을 실행합니다. 그러므로, 아래 코드가 실행되면 영원히 "Timer"는 출력되지 않습니다. (Timer가 출력되려면 nextTickQueue에 담긴 콜백이 전부 실행되어야 하는데 재귀적으로 영원히 nextTickQueue에 작업이 쌓이므로 Timer가 영원히 출력되지 않음)
const fn = () => { process.nextTick(fn) }
setTimeout(() => { console.log("Timer") }, 0)
fn()
nextTickQueue, microTaskQueue 동작의 변화
사실 nextTickQueue와 microTaskQueue에 담긴 작업들과 여러 페이즈 간의 동작 순서는 Node.js 버전에 따라 다릅니다. 정확하게 말하면 Node.js v11.0.0을 기점으로 다릅니다.
setTimeout(() => {
console.log(1)
process.nextTick(() => {
console.log(3)
})
Promise.resolve().then(() => console.log(4))
}, 0)
setTimeout(() => {
console.log(2)
}, 0)
Node V11.0.0 이전에는 한 페이즈에서 다음 페이즈로 넘어가기 전에 nextTickQueue와 microTaskQueue에 담긴 작업들을 수행했습니다. 즉, 매 Tick 마다 검사했습니다. 따라서 위 코드는 V11.0.0 이전 버전에서는 다음과 같은 순서로 실행됩니다.
1. Node.js가 Timer 페이즈에 진입
2. Timer 페이즈의 큐를 확인하고 console.log(1) 실행
3. process.nextTick과 Promise.resolve를 호출하여 nextTickQueue와 microTaskQueue에 콜백을 등록
4. Node.js는 Timer 페이즈 큐에 남아있는 작업을 확인하고 console.log(2) 실행
5. Node.js는 Timer 페이즈 큐에 남아있는 작업을 확인 => 비어있으므로 다음 페이즈로 넘어가려 함
6. pending callbacks 페이즈에 진입하기 전 nextTickQueue와 microTaskQueue를 확인
7. nextTickQueue에 있는 console.log(3) 실행
8. nextTickQueue가 비어있으므로 microTaskQueue 확인
9. microTaskQueue에 있는 console.log(4) 실행
10. microTaskQueue가 비어있으므로 이제 다음 페이즈였던 Pending Callbacks 페이즈로 이동
=> 1 2 3 4
Node.js V11.0.0 이후부터는 현재 실행하고 있는 작업이 끝나면 즉시 실행하도록 변경되었습니다. 따라서 V11.0.0 이후 버전에서는 다음과 같은 순서로 실행됩니다.
1. Node.js가 Timer 페이즈에 진입
2. 우선 TImer 페이즈에 있는 큐를 확인하고 console.log(1)을 실행
3. process.nextTick과 Promise.resolve를 호출하여 nextTickequeue와 microTaskQueue에 콜백 등록
4. 현재 실행하고 있는 작업이 끝났으므로 Node.js는 nextTickequeue와 microTaskQueue를 확인
5. nextTickequeue에 있는 console.log(3) 출력
6. nextTickequeue가 비어있으므로 microTaskQueue를 확인
7. console.log(4) 출력
8. microTaskQueue가 비었음을 확인하고 다시 Node.js는 Timer 페이즈 큐에 있는 console.log(2) 실행
9. 현재 실행하고 있는 작업이 끝났으므로 Node.js는 nextTickQueue와 microTaskQueue에 작업이 있는지 확인
10. 둘 다 비어있으므로 다음 페이즈인 Pending Callbacks 페이즈로 이동
=> 1 3 4 2
정리하면 Node.js v11.0.0 미만 버전에서는 한 페이즈에서 다음 페이즈로 넘어갈 때 nextTickQueue와 microTaskQueue를 검사하고, Node.js v11.0.0 이상 버전에서는 현재 실행하고 있는 작업이 끝나면 즉시 큐를 검사하고 실행합니다. 이렇게 순서가 변경된 이유는 브라우저와의 일관성 때문입니다. 브라우저는 Node.js v11.0.0 이상 버전에서 하는 것처럼 동작하고 있었기 때문에 동작의 일관성을 맞추기 위해 변경한 것입니다.
Node.js의 동작
Node.js에서 아래 코드는 다음과 같은 순서로 동작합니다.
// DB에서 값을 가져온 뒤
// 해당 값을 콘솔에 찍는 함수
function getData() {
db.query("SELECT * FROM SOME_TABLE", (data) => {
console.log(`SELECT 결과 ${data}`);
})
}
// 호출
getData();
1. V8 엔진이 js 코드를 해석하고 실행합니다.
2. getData() 함수를 호출할 때 V8 엔진은 이를 libuv로 넘깁니다.
3. libuv는 OS의 커널이 제공하는 API를 사용하여 해당 작업을 비동기로 처리합니다.
4. 언젠가 비동기 작업이 끝나면 libuv의 event loop 중 poll Phase로 해당 콜백을 실행하라는 신호가 옵니다.
5. event loop를 순회하다가 poll phase에 도달하면 메인 스레드는 콜백console.log(`SELECT 결과 ${data}`)을 실행하기 위해 메인 스택에 해당 콜백을 싣습니다.
6. 메인 스택에 어떤 작업이 쌓이면 메인 스레드는 즉시 메인 스택에 쌓인 작업을 처리합니다.
7. 메인 스택에 쌓인 console.log(`SELECT 결과 ${data}`)을 실행합니다.
8. 콘솔에 해당 결과가 찍힙니다.
추가 정보
setTimeout() vs setImmediate()
우선 setTimeout()은 ms 단위의 최소 임곗값이 지난 후에 스크립트가 실행되도록 예약합니다. setImmediate()는 poll phase 다음에 있는 check phase에서 즉시 처리됩니다. 요약하면 두 가지 차이가 있는 것입니다.
1. setTimeout()은 time phase에서 처리되지만, setImmediate()는 check phase에서 처리된다.
2. setTimeout()은 넘겨받은 ms 이후에 처리되지만, setImmediate()는 즉시 처리된다.
아래 코드를 보면 "setTimeout"이 먼저 출력될 것 같지만 실제로는 먼저 출력되기도 하고 나중에 출력되기도 합니다. 왜 그럴까요?
setTimeout(() => { console.log("setTimeout") }, 0)
setImmediate(() => { console.log("setImmediate") })
분명 event loop의 시작은 timer phase입니다. 따라서 "setTimeout"이 항상 먼저 출력될 것 같지만 실상 그렇지 않습니다. 그 이유는 setTimeout(fn, ms)에서 ms가 1 미만이면 자동으로 ms가 1초 바뀌기 때문입니다.
// /lib/internal/timers.js
// https://github.com/nodejs/node/blob/main/lib/internal/timers.js#L162
class Timeout {
// Timer constructor function.
// The entire prototype is defined in lib/timers.js
constructor(callback, after, args, isRepeat, isRefed) {
after *= 1; // Coalesce to number or NaN
if (!(after >= 1 && after <= TIMEOUT_MAX)) {
if (after > TIMEOUT_MAX) {
process.emitWarning(`${after} does not fit into` +
' a 32-bit signed integer.' +
'\nTimeout duration was set to 1.',
'TimeoutOverflowWarning');
}
after = 1; // Schedule on next tick, follows browser behavior
}
위 코드는 실세 Node.js의 setTimeout() 소스입니다. 만약 after가 1보다 작은 경우 1로 설정하는 것을 볼 수 있습니다. 즉, setTimeout(fn, 0) == setTimeout(fn, 1)입니다. 따라서 만약 Timer 페이즈에 진입했을 때 1ms 이상의 시간이 흘렀다면 "setTimeout"이 출력되지만 1ms 이상의 시간이 흐르지 않았다면 콜백이 실행되지 않고 다음 페이즈로 넘어가게 되기 때문에 "setTimeout"이 항상 먼저 출력되지는 않는 것입니다.
이벤트 루프는 어떠한 방식으로 동작하길래 단일 스레드로도 높은 처리량을 갖는 걸까요?
이벤트 루프는 비동기 작업을 OS에게 오프로드하거나 thread pool의 thread에게 오프로드합니다. 그리고 해당 결과를 싱글 스레드로 처리합니다. 이러한 구조로 이벤트 루프가 동작하고 Node.js는 이러한 이벤트 루프 방식의 도움으로 높은 처리량을 갖습니다.
정리
Node.js는 비동기 이벤트 주도 자바스크립트 런타임입니다. 이벤트 루프 방식을 사용하여 비동기 작업을 효율적으로 처리합니다.
'프로그래밍 기초' 카테고리의 다른 글
MVC 패턴에서의 validation 처리 (0) | 2023.09.10 |
---|---|
statement와 expression / literal과 variable, constant (0) | 2023.09.08 |
AWS S3에 파일 업로드: pre-signed URL (0) | 2023.08.28 |
GraphQL 맛보기 (0) | 2023.08.24 |
SOLID 원칙 (0) | 2023.08.17 |