Node.js 이벤트 루프
이벤트 루프란 무엇인가?
이벤트 루프는 Node.js가 가능한 한 시스템 커널로 작업을 오프로드함으로써 기본적으로 단일 JavaScript 스레드가 사용됨에도 불구하고 논블로킹 I/O 작업을 수행할 수 있도록 하는 것입니다.
대부분의 최신 커널은 멀티 스레드이므로 백그라운드에서 여러 작업을 처리할 수 있습니다. 이러한 작업 중 하나가 완료되면 커널은 Node.js에 알려 적절한 콜백이 결국 실행되도록 폴 큐에 추가될 수 있도록 합니다. 이 내용은 이 주제의 뒷부분에서 더 자세히 설명하겠습니다.
이벤트 루프 설명
Node.js가 시작되면 이벤트 루프를 초기화하고, 제공된 입력 스크립트를 처리하거나(이 문서에서 다루지 않는 REPL로 들어감), 비동기 API 호출을 하거나, 타이머를 예약하거나, process.nextTick()을 호출한 다음 이벤트 루프 처리를 시작합니다.
다음 다이어그램은 이벤트 루프의 작업 순서에 대한 단순화된 개요를 보여줍니다.
┌───────────────────────────┐
┌─>│ 타이머 │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ 보류 중인 콜백 │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ 유휴, 준비 │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ 들어오는: │
│ │ 폴 │<─────┤ 연결, │
│ └─────────────┬─────────────┘ │ 데이터 등 │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ 확인 │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ 종료 콜백 │
└───────────────────────────┘
TIP
각 상자는 이벤트 루프의 "단계"로 지칭됩니다.
각 단계에는 실행할 콜백의 FIFO 큐가 있습니다. 각 단계는 고유한 방식으로 특별하지만 일반적으로 이벤트 루프가 주어진 단계에 들어갈 때 해당 단계에 특정 작업을 수행한 다음 큐가 소진되거나 최대 콜백 수가 실행될 때까지 해당 단계의 큐에 있는 콜백을 실행합니다. 큐가 소진되거나 콜백 제한에 도달하면 이벤트 루프는 다음 단계로 이동하는 식으로 진행됩니다.
이러한 작업 중 일부는 더 많은 작업을 예약할 수 있고 폴 단계에서 처리된 새로운 이벤트가 커널에 의해 큐에 추가되므로 폴 이벤트가 처리되는 동안 폴 이벤트가 큐에 추가될 수 있습니다. 결과적으로 장시간 실행되는 콜백은 타이머의 임계값보다 훨씬 오래 폴 단계가 실행되도록 할 수 있습니다. 자세한 내용은 타이머 및 폴 섹션을 참조하십시오.
TIP
Windows와 Unix/Linux 구현 간에 약간의 불일치가 있지만 이 시연에서는 중요하지 않습니다. 가장 중요한 부분은 여기에 있습니다. 실제로 7~8단계가 있지만 Node.js가 실제로 사용하는 가장 중요한 단계는 위에 있는 단계입니다.
단계 개요
- 타이머: 이 단계에서는
setTimeout()
및setInterval()
에 의해 예약된 콜백을 실행합니다. - 보류 중인 콜백: 다음 루프 반복으로 지연된 I/O 콜백을 실행합니다.
- 유휴, 준비: 내부적으로만 사용됩니다.
- 폴: 새로운 I/O 이벤트를 검색합니다. I/O 관련 콜백(닫기 콜백, 타이머에 의해 예약된 콜백 및
setImmediate()
를 제외한 거의 모든 콜백)을 실행합니다. 노드는 적절한 경우 여기서 차단됩니다. - 확인:
setImmediate()
콜백이 여기서 호출됩니다. - 닫기 콜백: 일부 닫기 콜백, 예를 들어
socket.on('close', ...)
입니다.
이벤트 루프의 각 실행 사이에 Node.js는 비동기 I/O 또는 타이머를 기다리고 있는지 확인하고 아무것도 없는 경우 정상적으로 종료합니다.
단계 상세 정보
타이머
타이머는 사람이 실행되기를 원하는 정확한 시간이라기보다는 제공된 콜백이 실행될 수 있는 임계값을 지정합니다. 타이머 콜백은 지정된 시간이 지난 후 예약될 수 있는 한 최대한 빨리 실행됩니다. 그러나 운영 체제 스케줄링 또는 다른 콜백 실행으로 인해 지연될 수 있습니다.
TIP
기술적으로는 폴 단계에서 타이머 실행 시기를 제어합니다.
예를 들어 100ms 임계값 후에 실행되도록 시간 초과를 예약하고 스크립트가 95ms가 걸리는 파일을 비동기적으로 읽기 시작한다고 가정해 보겠습니다.
const fs = require('node:fs')
function someAsyncOperation(callback) {
// 완료하는 데 95ms가 걸린다고 가정합니다.
fs.readFile('/path/to/file', callback)
}
const timeoutScheduled = Date.now()
setTimeout(() => {
const delay = Date.now() - timeoutScheduled
console.log(`${delay}ms have passed since I was scheduled`)
}, 100)
// 완료하는 데 95ms가 걸리는 someAsyncOperation을 수행합니다.
someAsyncOperation(() => {
const startCallback = Date.now()
// 10ms가 걸리는 작업을 수행합니다...
while (Date.now() - startCallback < 10) {
// 아무것도 하지 않습니다.
}
})
이벤트 루프가 폴 단계에 들어갈 때 비어 있는 큐가 있습니다(fs.readFile()
이 완료되지 않음). 따라서 가장 빠른 타이머의 임계값에 도달할 때까지 남은 ms 수를 기다립니다. 기다리는 동안 95ms가 지나고 fs.readFile()
이 파일 읽기를 완료하고 완료하는 데 10ms가 걸리는 콜백이 폴 큐에 추가되어 실행됩니다. 콜백이 완료되면 큐에 더 이상 콜백이 없으므로 이벤트 루프는 가장 빠른 타이머의 임계값이 도달했음을 확인한 다음 다시 타이머 단계로 돌아가 타이머의 콜백을 실행합니다. 이 예에서는 타이머가 예약된 시점과 콜백이 실행된 시점 사이의 총 지연 시간이 105ms임을 확인할 수 있습니다.
TIP
폴 단계가 이벤트 루프를 정체시키지 않도록 방지하기 위해 libuv (Node.js 이벤트 루프와 플랫폼의 모든 비동기 동작을 구현하는 C 라이브러리)에는 더 많은 이벤트를 폴링하기 전에 중지하는 하드 최대값(시스템 종속적)도 있습니다.
보류 중인 콜백
이 단계에서는 TCP 오류 유형과 같은 일부 시스템 작업에 대한 콜백을 실행합니다. 예를 들어 TCP 소켓이 연결을 시도할 때 ECONNREFUSED
를 수신하면 일부 *nix 시스템은 오류를 보고하기 위해 대기하기를 원합니다. 이는 보류 중인 콜백 단계에서 실행되도록 대기열에 추가됩니다.
폴링
폴링 단계에는 두 가지 주요 기능이 있습니다.
- I/O를 위해 차단하고 폴링해야 하는 시간을 계산한 다음
- 폴링 대기열의 이벤트를 처리합니다.
이벤트 루프가 폴링 단계에 진입하고 예약된 타이머가 없으면 다음 두 가지 중 하나가 발생합니다.
폴링 대기열이 비어 있지 않은 경우 이벤트 루프는 대기열이 소진되거나 시스템 종속 하드 제한에 도달할 때까지 콜백 대기열을 동기적으로 반복하여 실행합니다.
폴링 대기열이 비어 있는 경우 다음 두 가지 중 하나가 더 발생합니다.
setImmediate()
로 스크립트가 예약된 경우 이벤트 루프는 폴링 단계를 종료하고 예약된 스크립트를 실행하기 위해 확인 단계로 진행합니다.스크립트가
setImmediate()
로 예약되지 않은 경우 이벤트 루프는 콜백이 대기열에 추가될 때까지 기다린 다음 즉시 실행합니다.
폴링 대기열이 비어지면 이벤트 루프는 시간 임계값에 도달한 타이머가 있는지 확인합니다. 하나 이상의 타이머가 준비되면 이벤트 루프는 해당 타이머의 콜백을 실행하기 위해 타이머 단계로 다시 래핑됩니다.
확인
이 단계를 통해 폴링 단계가 완료된 직후에 콜백을 실행할 수 있습니다. 폴링 단계가 유휴 상태가 되고 setImmediate()
로 스크립트가 대기열에 추가된 경우 이벤트 루프는 기다리지 않고 확인 단계로 진행할 수 있습니다.
setImmediate()
는 실제로 이벤트 루프의 별도 단계에서 실행되는 특수 타이머입니다. 폴링 단계가 완료된 후 실행될 콜백을 예약하는 libuv API를 사용합니다.
일반적으로 코드가 실행되면 이벤트 루프는 결국 폴링 단계에 도달하여 들어오는 연결, 요청 등을 기다립니다. 그러나 setImmediate()
로 콜백이 예약되어 있고 폴링 단계가 유휴 상태가 되면 폴링 이벤트 대기 대신 종료하고 확인 단계로 진행됩니다.
close 콜백
소켓 또는 핸들이 갑자기 닫히면(예: socket.destroy()
), 이 단계에서 'close'
이벤트가 발생합니다. 그렇지 않으면 process.nextTick()
을 통해 발생합니다.
setImmediate()
vs setTimeout()
setImmediate()
와 setTimeout()
은 비슷하지만 호출되는 시점에 따라 다르게 동작합니다.
setImmediate()
는 현재 폴링 단계가 완료되면 스크립트를 실행하도록 설계되었습니다.setTimeout()
은 최소 ms 임계값이 경과한 후에 스크립트가 실행되도록 예약합니다.
타이머가 실행되는 순서는 호출되는 컨텍스트에 따라 달라집니다. 둘 다 메인 모듈 내에서 호출되면 타이밍은 프로세스의 성능에 의해 제한됩니다(이는 머신에서 실행 중인 다른 애플리케이션의 영향을 받을 수 있음).
예를 들어 I/O 사이클(즉, 메인 모듈) 내에 있지 않은 다음 스크립트를 실행하면 두 타이머가 실행되는 순서는 프로세스의 성능에 의해 제한되므로 비결정적입니다.
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
그러나 두 호출을 I/O 사이클 내로 이동하면 즉시 콜백이 항상 먼저 실행됩니다.
// timeout_vs_immediate.js
const fs = require('node:fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
setTimeout()
보다 setImmediate()
를 사용하는 주요 이점은 I/O 사이클 내에서 예약된 경우 타이머 수에 관계없이 setImmediate()
가 항상 모든 타이머 전에 실행된다는 것입니다.
process.nextTick()
process.nextTick()
이해하기
process.nextTick()
은 비동기 API의 일부임에도 불구하고 다이어그램에 표시되지 않았음을 눈치채셨을 것입니다. 이는 process.nextTick()
이 기술적으로 이벤트 루프의 일부가 아니기 때문입니다. 대신 nextTickQueue
는 이벤트 루프의 현재 단계와 관계없이 현재 작업이 완료된 후 처리됩니다. 여기서 작업은 기본 C/C++ 핸들러에서 전환하고 실행해야 하는 JavaScript를 처리하는 것으로 정의됩니다.
다이어그램을 다시 살펴보면 주어진 단계에서 process.nextTick()
을 호출할 때마다 process.nextTick()
에 전달된 모든 콜백은 이벤트 루프가 계속되기 전에 해결됩니다. 이는 재귀적인 process.nextTick()
호출을 만들어 I/O를 "굶기게"하여 이벤트 루프가 폴링 단계에 도달하는 것을 방지하므로 일부 나쁜 상황을 만들 수 있습니다.
왜 허용될까요?
왜 이런 것이 Node.js에 포함되었을까요? 그 이유는 API가 필요하지 않더라도 항상 비동기식이어야 한다는 설계 철학의 일부입니다. 다음 코드 스니펫을 예로 들어 보겠습니다.
function apiCall(arg, callback) {
if (typeof arg !== 'string') return process.nextTick(callback, new TypeError('argument should be string'))
}
이 스니펫은 인수를 확인하고 올바르지 않으면 오류를 콜백으로 전달합니다. API는 콜백에 전달된 인수를 콜백에 대한 인수로 전파하도록 허용하기 위해 최근에 업데이트되었으므로 함수를 중첩할 필요가 없습니다.
우리는 사용자에게 오류를 다시 전달하고 있지만 사용자의 나머지 코드가 실행되도록 허용한 후에만 전달합니다. process.nextTick()
을 사용하면 apiCall()
이 항상 사용자의 나머지 코드 뒤, 이벤트 루프가 진행되기 전에 콜백을 실행하도록 보장합니다. 이를 위해 JS 호출 스택은 언와인드한 다음 제공된 콜백을 즉시 실행할 수 있어 사람이 v8에서 RangeError: Maximum call stack size exceeded
에 도달하지 않고 process.nextTick()
을 재귀적으로 호출할 수 있습니다.
이러한 철학은 잠재적으로 문제가 있는 상황을 초래할 수 있습니다. 다음 스니펫을 예로 들어 보겠습니다.
let bar
// 비동기 서명을 가지고 있지만 콜백을 동기적으로 호출합니다.
function someAsyncApiCall(callback) {
callback()
}
// 콜백은 `someAsyncApiCall`이 완료되기 전에 호출됩니다.
someAsyncApiCall(() => {
// someAsyncApiCall이 완료되지 않았으므로 bar에 아무런 값이 할당되지 않았습니다.
console.log('bar', bar) // undefined
})
bar = 1
사용자는 someAsyncApiCall()
이 비동기 서명을 갖도록 정의하지만 실제로는 동기적으로 작동합니다. 호출되면 someAsyncApiCall()
에 제공된 콜백은 someAsyncApiCall()
이 실제로 비동기적으로 아무 작업도 수행하지 않으므로 이벤트 루프의 동일한 단계에서 호출됩니다. 결과적으로 콜백은 스크립트가 완료될 때까지 아직 해당 범위에 변수가 없을 수도 있지만 bar를 참조하려고 합니다.
콜백을 process.nextTick()
에 배치하면 스크립트가 완료될 수 있어 콜백이 호출되기 전에 모든 변수, 함수 등이 초기화될 수 있습니다. 또한 이벤트 루프가 계속되지 않도록 하는 이점도 있습니다. 이벤트 루프가 계속되기 전에 사용자에게 오류를 알리는 것이 유용할 수 있습니다. 다음은 process.nextTick()
을 사용한 이전 예제입니다.
let bar
function someAsyncApiCall(callback) {
process.nextTick(callback)
}
someAsyncApiCall(() => {
console.log('bar', bar) // 1
})
bar = 1
다음은 또 다른 실제 예입니다.
const server = net.createServer(() => {}).listen(8080)
server.on('listening', () => {})
포트만 전달되면 포트가 즉시 바인딩됩니다. 따라서 'listening'
콜백이 즉시 호출될 수 있습니다. 문제는 .on('listening')
콜백이 그 때까지 설정되지 않았을 수 있다는 것입니다.
이 문제를 해결하기 위해 'listening'
이벤트는 스크립트가 완료될 수 있도록 nextTick()
에 대기열에 추가됩니다. 이를 통해 사용자는 원하는 이벤트 핸들러를 설정할 수 있습니다.
process.nextTick()
vs setImmediate()
사용자 입장에서 보면 유사한 두 가지 호출이 있지만, 이름이 혼란스럽습니다.
process.nextTick()
은 같은 단계에서 즉시 실행됩니다.setImmediate()
은 이벤트 루프의 다음 반복 또는'tick'
에서 실행됩니다.
본질적으로 이름이 바뀌어야 합니다. process.nextTick()
은 setImmediate()
보다 더 즉시 실행되지만 이는 과거의 인공물이며 변경될 가능성이 낮습니다. 이러한 전환을 수행하면 npm의 패키지 중 상당 부분이 손상됩니다. 매일 더 많은 새 모듈이 추가되고 있으며 이는 매일 기다릴수록 더 많은 잠재적 손상이 발생한다는 의미입니다. 혼란스럽지만 이름 자체는 변경되지 않습니다.
TIP
개발자는 모든 경우에 추론하기 더 쉽기 때문에 setImmediate()
를 사용하는 것이 좋습니다.
왜 process.nextTick()
을 사용해야 할까요?
두 가지 주요 이유가 있습니다.
사용자가 오류를 처리하고, 불필요한 리소스를 정리하거나, 이벤트 루프가 계속되기 전에 요청을 다시 시도할 수 있도록 허용합니다.
때로는 콜 스택이 해제된 후 이벤트 루프가 계속되기 전에 콜백을 실행해야 합니다.
한 가지 예는 사용자의 기대를 충족하는 것입니다. 간단한 예:
const server = net.createServer()
server.on('connection', conn => {})
server.listen(8080)
server.on('listening', () => {})
listen()
이 이벤트 루프의 시작 부분에서 실행되고, 리스닝 콜백이 setImmediate()
에 배치된다고 가정합니다. 호스트 이름이 전달되지 않는 한 포트 바인딩이 즉시 수행됩니다. 이벤트 루프가 계속되려면 폴 단계에 도달해야 하며, 이는 리스닝 이벤트 전에 연결 이벤트가 발생할 수 있도록 연결이 수신될 가능성이 0이 아님을 의미합니다.
또 다른 예는 EventEmitter
를 확장하고 생성자 내에서 이벤트를 내보내는 것입니다.
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
constructor() {
super()
this.emit('event')
}
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
console.log('이벤트가 발생했습니다!')
})
스크립트가 사용자가 해당 이벤트에 콜백을 할당하는 지점까지 처리되지 않기 때문에 생성자에서 즉시 이벤트를 내보낼 수 없습니다. 따라서 생성자 자체 내에서 process.nextTick()
을 사용하여 생성자가 완료된 후 이벤트를 내보내도록 콜백을 설정할 수 있으며 이는 예상되는 결과를 제공합니다.
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
constructor() {
super()
// 핸들러가 할당되면 nextTick을 사용하여 이벤트를 내보냅니다.
process.nextTick(() => {
this.emit('event')
})
}
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
console.log('이벤트가 발생했습니다!')
})