NodeJS

setImmediate(), setTimeout() 그리고 process.nextTick()

iKay 2019. 2. 8. 22:45
반응형

이벤트 루프(Event Loop)에서 많이 나왔던 함수인 setImmediate( )와 setTimeout( ) 그리고 process.nextTick( )에 대해 더 알아보고자 한다. 



setImmediate( )와 setTimeout( )


setImmediate( )와 setTimeout( )은 비슷하지만, 언제 호출되느냐에 따라 다르게 행동한다. 


    • setImmediate( )는 현재 poll phase가 끝났을 때 실행된다. 
    • setTimeout( )은 최소 임계점이 지난 후 스크립트가 실행되도록 스케줄한다.  

타이머가 실행되는 순서는 호출되는 콘텍스트 시점에 따라 다르다. 만약 둘 다 메인 모듈에서 호출되면, 프로세스의 성능에 따라 호출되는 시점이 다르다. (이것은 머신의 다른 애플리케이션에 의해 영향을 받을 수 있다. ) 


예를 들어, 아래처럼 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 사이클에 넣으면, immediate 콜백이 항상 먼저 실행된다. 


// timeout_vs_immediate.js
const fs = require('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 사이클에 스케줄 됐다면 얼마나 많은 타이머가 있는 것에 상관없이 setTimeout( )이 언제나 먼저 실행된다는 것이다.  



process.nextTick( )


process.nextTick( )이 비동기 API의 부분임에도 불구하고, 이벤트 루프 다이어그램에 표시되지 않았다. 왜냐하면 process.nextTick( )은 기술적으로 이벤트 루프의 부분이 아니기 때문이다. 대신, nextTickQueue는 현재 이벤트 루프의 phase와 관계없이 현재 연산이 완료된 후 처리된다. 여기서 연산(operation)은 근본적인 C/C++핸들러로부터의 변화, 그리고 실행되야 할 Javascript를 다루는 것으로 정의된다[각주:1].


이전 포스팅의 내용인 이벤트 루프 다이어그램을 보면, 주어진 phase에서 어느 시점에서든 process.nextTick( )를 호출할 때  process.nextTick( )을 지나간 모든 콜백은 다음 번 이벤트 루프가 시작되기 전에 끝이 난다. 그런데 이것은 좋지 못한 상황을 만들 수 있다. 왜냐하면, 재귀적인 process.nextTick( ) 호출을 만듦으로서 I/O를 굶주리게 할 수 있어 이벤트 루프가 poll phase로 가는 것을 막을 수 있다. 



왜 이러한 동작이 허용될까? 


API는 언제나 비동기이여야 한다는 철학 디자인을 갖고 있기 때문이다. 아래 예제 코드를 보자.

 

function apiCall(arg, callback) {
if (typeof arg !== 'string')
return process.nextTick(callback,
new TypeError('argument should be string'));
}


이 예제는 argument가 올바른지 검사하고 올바르지 않다면 콜백에 에러를 넘긴다. process.nextTick( )의 argument로서 콜백이 뒤에 넘겨진 argument들이 콜백의 argument로서 넘겨지도록  최근에 API가 업데이트돼 중첩된 함수를 사용할 필요가 없다. 


하고자 하는 것은 에러를 유저에게 되넘기는 것이지만, 나머지 코드가 실행된 후에 에러가 되넘겨지게된다. process.nextTick( )을 사용함으로서 apiCall( )이 언제나 나머지 코드를 실행하고, 이벤트 루프가 진행되기 전 콜백을 실행하는 것을 보장할 수 있다. 그렇게 되기 위해서 JS 콜 스택은 풀도록 허용되고 즉시 주어진 콜백을 수행하도록 하는데, 그 콜백은 RangeError: Maximun call stack size exceeded from v8데 도달하지 않고 process.nextTick( )을 재귀적으로 호출을 할 수 있게끔 허용한다. 


이 철학은 잠재적인 문제를 일으킬 수 있는데, 다음 예제를 보자. 


let bar;


// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) { callback(); }


// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
// since someAsyncApiCall has completed, bar hasn't been assigned any value
console.log('bar', bar); // undefined
});


bar = 1;


비동기 특징을 갖도록 someAsyncApiCall( )이 정의돼있지만 사실은 동기적으로 동작한다. someAsyncApiCall( )이 호출될 때 이 함수에 의해 실행되는 콜백은 이벤트 루프의 같은 phase에 있게된다. 왜냐하면 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( )에 넣어줘야 한다. 이러한 방식을 통해 이벤트 핸들러를 원하는대로 설정할 수 있다.  


 

중복제거


이벤트 루프의 timers와 check phase에서, 다수의 immediate와 timer에 있어서 C로부터 Javascript로 단일 변환이 있다.[각주:2] 이 중복제거는 최적화의 한 가지 형태로, 예상치못한 부작용을 초래할 수 있다. 아래 코드를 보자.  


// dedup.js
const foo = [1, 2];
const bar = ['a', 'b'];


foo.forEach(num => {
setImmediate(() => {
console.log('setImmediate', num);
bar.forEach(char => {
process.nextTick(() => {
console.log('process.nextTick', char);
});
});
});
});


$ node dedup.js
setImmediate 1
setImmediate 2
process.nextTick a
process.nextTick b
process.nextTick a
process.nextTick b


이 메인 쓰레드는 두 개의 setImmediate( ) 이벤트를 더하고, 이 이벤트는 두 개의 process.nextTick( ) 이벤트를 더한다. 이벤트 루프가 check phase에 도달할 때, setImmediate( )에 의해 생성된 두 개의 이벤트가 현재 만들어 졌음을 볼 수 있다. 첫 번째 이벤트가 잡히고 처리되어 setImmediate를 출력하며, 두 이벤트를 nextTickQueue에 더한다.


중복제거 때문에 이벤트 루프는 nextTickQueue에 아이템이 있는지 확인하기 위해 C/C++ 레이어로 즉각 되돌리지 않는다. 대신 현재 남아  있는 setImmediate( ) 이벤트를 계속해서 처리한다. setImmediate( ) 이벤트를 처리한 후, 두 개의 이벤트가 nextTickQueue에 더해지고 총 그래서 네 개의 이벤트가 더해지게 된다. 


이 시점에서, 이전에 더해진 모든 setImmediate( ) 이벤트는 처리되어있다. nextTickQueue가 이제 검사되고, 이벤트가 FIFO 순서로 처리된다. nextTickQueue가 비었을 떄, 이벤트 루프는 현재 phase에서 모든 연산이 완료됐다고 간주하고 다음 phase로 변환한다. 




process.nextTick( ) 과 setImmediate( )


얼핏 생각하기엔 두 개의 호출이 비슷해 보이지만, 그 이름이 혼란을 야기한다. 


    • process.nextTick( )은 같은 phase에서 즉시 발생한다. 
    • setImmediate( )는 이어지는 반복이나 이벤트 루프의 'tick'에서 발생한다. 

 사실은 이 둘의 이름은 바뀌어야 맞다. process.nextTick( )가 setImmediate( ) 보다 더 즉시 발생하기 때문이다. 그러나 이 둘을 바꿀 수는 없다. 왜냐하면 대다수의 npm 패키지가 망가질 수도 있기 때문이고, 매일 새로운 모듈이 더해지고 있으므로 잠재적으로 더 많은 npm 패키지가 깨질 것이다. 따라서 이 둘의 이름이 혼란을 줄 지라도 이름이 서로 바뀌지는 않을 것이다. 


모든 경우에 setImmediate( )를 사용하기를 추천하는데, 사용하기 쉽고 browser 등의 다양한 환경에서 호환이 더 잘 되기 때문이다.  



proces.nextTick( )을 사용하는 이유는 무엇인가?



크게 두 가지 이유가 있다. 


  1. 이벤트 루프가 진행되기 전에 에러를 핸들하고, 필요 없는 자원을 해제하며 재요청 처리를 할 수 있다. 
  2. 이벤트 루프가 진행되기 전에 콜 스택이 풀린 후 콜백을 실행할 수 있다. 
아래 예를 보자. 


const server = net.createServer();
server.on('connection', (conn) => { });


server.listen(8080);
server.on('listening', () => { });


listen( )은 이벤트 루프 시작 시점에 실행된다. 그러나 listening 콜백은 setImmediate( )에 놓여진다. hostname을 넘기지 않으면 port를 바인딩 하는 것은 즉시 일어난다. 이벤트 루프를 진행하기 위해 poll phase를 거쳐야만 하는데, 이것은 listening 이벤트 전 connection 이벤트가 발생하게 함으로서 connection을 할 수도 있다는 것을 의미한다. 


또 다른 예제는 EventEmitter로 부터 상속받고 생성자 안에서 이벤트를 호출하기 원하는 함수 생성자를 실행하는 것이다.

 

const EventEmitter = require('events');
const util = require('util');


function MyEmitter() {
EventEmitter.call(this);
this.emit('event');
}
util.inherits(MyEmitter, EventEmitter);


const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});


생성자로 부터 이벤트를 즉시 발생시킬 수 없는데, 이벤트에 대한 콜백을 할당한 시점에 스크립트가 처리되지 않았기 때문이다. 그래서 생성자가 끝난 후 콜백이 이벤트를 발생시키도록 설정하기 위해 process.nextTick( )을 사용해 위 문제를 아래처럼 해결할 수 있다. 


const EventEmitter = require('events');
const util = require('util');


function MyEmitter() {
EventEmitter.call(this);


// use nextTick to emit the event once a handler is assigned
process.nextTick(() => {
this.emit('event');
});
}
util.inherits(MyEmitter, EventEmitter);


const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});









  1. 이 부분 번역이 매끄럽지 못하다. 문장의 의미를 정확히 파악할 필요가 있다. [본문으로]
  2. 이 문장의 번역이 매끄럽지 못하다. 수정할 필요가 있다. [본문으로]
반응형