본문 바로가기

[자바스크립트] Node.js 이벤트 루프

들어가며

여행을 떠나보면, 언젠가 왜 이 여행을 하려 했을까 하는 생각이 들 때가 있었습니다. 공부를 할 때도 마찬가지였습니다. Node.js를 활용해서 개발을 하고 있는데, 왜 Node.js를 활용하는지 문득 궁금했습니다. Node.js의 특징, 장단점, 그리고 다른 언어와의 차이를 알아보며 Node.js에 대해 이해해야겠다고 생각했습니다. 아래의 글은 Node.js 이벤트 루프(Event Loop) 샅샅이 분석하기 글을 통해 더 자세히 살펴보실 수 있습니다. 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

Node.js란? 

흔히 Node.js를 싱글 스레드 논 블로킹이라고 합니다. Node.js는 하나의 스레드로 동작하지만 I/O 작업이 발생한 경우 이를 비동기적으로 처리할 수 있습니다. 분명 하나의 스레드는 하나의 실행 흐름만을 가지고 있고 파일 읽기와 같이 기다려야 하는 작업을 실행하면 그 작업이 끝나기 전에는 아무것도 할 수 없어야만 합니다. 그러나 Node.js는 하나의 스레드만으로 여러 비동기 작업들을 블로킹 없이 수행할 수 있고 그 기반에는 이벤트 루프가 존재합니다. 

 

 

 

이벤트 루프를 이해하기 위해서 Node.js가 어떻게 구성되어 있는지를 알아야 합니다. Node.js는 C++로 작성된 런타임이고 그 내부에 V8 Engine을 가지고 있습니다. 그 덕분에 크롬과 같은 브라우저에서 실행하던 자바스크립트를 로컬에서 실행할 수 있습니다. 그런데 그 내부에는 V8 Engine 말고도 libuv라는 라이브러리가 존재합니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

Libuv

libuv란 C++로 작성된, Node.js가 사용하는 비동기 I/O 라이브러리입니다. 이는 사실 운영체제의 커널을 추상화한 Wrapping 라이브러리로 커널이 어떤 비동기 API를 지원하는지 알고 있습니다. 

 

 

 

 

다시 말해 우리가 libuv에게 파일 읽기와 같은 비동기 작업을 요청하면 libuv는 이 작업을 커널이 지원하는지 확인합니다. 만약 지원한다면 libuv가 대신 커널에게 비동기적으로 요청했다가 응답이 오면 그 응답을 우리에게 전달해줍니다. 만약 요청한 작업을 커널이 지원하지 않는다면 어떻게 할까요? 바로 자신만의 워커 스레드가 담긴 스레드 풀을 사용합니다. 

libuv는 기본적으로 4개의 스레드를 가진 스레드 풀을 생성합니다. 물론 uv_threadpool이라는 환경 변수를 설정해 최대 128개까지 스레드 개수를 늘릴 수도 있습니다. 만약 우리가 요청한 작업을 커널이 지원하지 않는다면 libuv는 커널을 호출하는 대신 이 스레드 풀에게 작업을 맡겨버립니다. 

 

 

 

그리고 스레드 풀에 있던 스레드가 작업을 완료하면 libuv가 우리에게 요청한 작업이 완료되었다고 친절하게 알려줍니다. 

 

 

실제로 node를 실행하면 그 아래의 여러 개의 node 스레드가 존재하는 것을 확인할 수 있습니다.

 

 

즉, 정리하면 다음과 같습니다.

  • libuv는 운영체제의 커널을 추상화해서 비동기 API를 지원한다.
  • libuv는 커널이 어떤 비동기 API를 지원하고 있는지 알고 있다. 
  • 만약 커널이 지원하는 비동기 작업을 libuv에게 요청하면 libuv는 대신 커널에게 이 작업을 비동기적으로 요청해준다.
  • 만약 커널이 지원하지 않는 비동기 작업을 libuv에게 요청하면 libuv는 내부에 가지고 있는 스레드 풀에게 이 작업을 요청해준다.

 

 

지금까지 libuv라는 비동기 I/O 라이브러리가 존재하고 Node.js가 이를 내부적으로 이용한다는 사실을 살펴봤습니다. 이벤트 루프가 싱글 스레드이고, 블로킹 I/O 관련을 처리하는 백그라운드는 멀티 스레드로 이루어져 있습니다. I/O 관련 블로킹 작업은 OS 커널 혹은 libuv thread pool에서 처리하여 이벤트루프가 blocking되지 않게 합니다. input과 output이 관련된 작업(http, DB, CRUD, filesystem) 등의 블로킹 작업들을 백그라운드(OS 커널 혹은 libuv의 thread pool)에서 수행하고, 이를 비동기 콜백 함수로 이벤트 루프에 전달합니다. 블로킹을 모두 다른 곳으로 넘겼으니 논 블로킹입니다. 

 

스레드 풀을 사용하는 메서드는 다음과 같습니다.

 

  • I/O-intensive
    • DNS: dns.lookup(), dns.lookupService().
    • File System: All file system APIs except fs.FSWatcher() and those that are explicitly synchronous use libuv's threadpool.
  • CPU-intensive
    • Crypto: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair().
    • Zlib: All zlib APIs except those that are explicitly synchronous use libuv's threadpool.

 

그러나 싱글 스레드인 이벤트 루프에서 blocking적인 코드를 작성하면 완전 blocking이 되므로 주의해야 합니다. 따라서 이런 장점을 살리려면 백그라운드에서 처리되는 작업 이외에 이벤트 루프에서 도는 싱글 스레드에서는 동기적인 코드를 자제해야 합니다. 이벤트 루프가 도는 곳은 아무 생각 없이 코드를 작성해온 바로 그곳입니다. 

 

만약 CPU 작업량이 많은 코드를 싱글 스레드인 이벤트 루프에서 실행할 경우 다른 자바스크립트 코드를 Block하게 만들어 다른 작업이 중단된 것처럼 보일 수 있습니다. 그럼 어떤 것을 쓰지 말아야 할지, 공식문서에 따르면 다음과 같습니다. 

 

 

  • Encryption:
    • crypto.randomBytes (synchronous version)
    • crypto.randomFillSync
    • crypto.pbkdf2Sync
    • You should also be careful about providing large input to the encryption and decryption routines.
  • Compression:
    • zlib.inflateSync
    • zlib.deflateSync
  • File system:
    • Do not use the synchronous file system APIs. For example, if the file you access is in a distributed file system like NFS, access times can vary widely.
  • Child process:
    • child_process.spawnSync
    • child_process.execSync
    • child_process.execFileSync

 

 

그렇다면 도대체 libuv와 이벤트 루프는 어떠한 관계가 있고 그래서 Node.js는 어떻게 싱글 스레드로 논블로킹 비동기 작업을 지원하는 것일까요? 

 

Node.js는 I/O 작업을 자신의 메인 스레드가 아닌 다른 스레드에 위임함으로써 싱글 스레드로 논 블로킹 I/O를 지원합니다. 다르게 말하면 Node.js는 I/O 작업을 libuv에게 위임함으로써 논 블로킹 I/O를 지원하고 그 기반에는 이벤트 루프가 있습니다. 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

출처: https://sjh836.tistory.com/149

 

Node.js 이벤트 루프

이벤트 루프는 Node.js가 여러 비동기 작업을 관리하기 위한 구현체입니다. console.log("Hello World")와 같은 동기 작업이 아니라 file.readFile('test.txt', callback)과 같은 비동기 작업들을 모아서 관리하고 순서대로 실행할 수 있게 해주는 도구이며 위와 같이 구성되어 있습니다.

 

우선 nextTickQueue와 microTaskQueue는 이벤트 루프의 일부가 아닙니다. 따라서 아래에서 설명하는 내용에 해당되지 않습니다. 비록 이벤트 루프를 구성하지는 않지만 Node.js의 비동기 작업 관리를 도와주는 것들로 아래에서 더 자세하게 다룹니다. 

 

각 박스는 특정 작업을 수행하기 위한 페이즈(Phase)를 의미합니다. Node.js의 이벤트 루프의 페이즈는 아래와 같이 구성되어 있습니다. 

 

  • Timer Phase
  • Pending Callbacks Phase
  • Idle, Prepare Phase
  • Poll Phase
  • Check Phase
  • Close Callbacks Phase

 

페이즈 전환 순서 또한 그림에 나타난 것처럼 Timer Phase -> Pending Callbacks Phase -> Idle, Prepare Phase -> Poll Phase -> Check Phase -> Close Callbacks Phase -> Timer Phase 순을 따릅니다. 이렇게 한 페이즈에서 다음 페이즈로 넘어가는 것을 틱(Tick)이라고 부릅니다.

 

각 페이즈는 자신만의 큐를 하나씩 가지고 있는데, 큐에는 이벤트 루프가 실행해야 하는 작업들이 순서대로 담겨있습니다. Node.js가 페이즈에 진입을 하면 큐에서 자바스크립트 코드(예를 들면 콜백)를 꺼내서 하나씩 실행합니다. 만약 큐에 있는 작업들을 다 실행하거나, 시스템의 실행 한도에 다다르면 Node.js는 다음 페이즈로 넘어갑니다.  

 

 

따라서 위 그림처럼 Poll, Check, Close 페이즈가 관리하는 큐에 console.log 콜백이 쌓여있고 Node.js가 Poll Phase부터 Check Phase, Close Callbacks Phase 순으로 차례대로 실행합니다. 즉, 출력 결과는 아래와 같습니다.

 

1
2
3
4

 

이때 이벤트 루프가 Node.js의 비동기 실행을 도와주는 것과 별개로 싱글 스레드이므로 한 번에 하나의 페이즈에만 진입해 한 번에 하나의 작업만 수행할 수 있다는 점을 명심해야 합니다. Poll Phase 작업을 처리하면서 Check Phase의 작업을 동시에 처리하거나 Poll Phase의 작업을 한 번에 여러 개씩 처리하는 것은 불가능합니다. 

 

정리하면 아래와 같습니다.

  • 이벤트 루프는 Node.js가 비동기 작업을 관리하기 위한 구현체다.
  • 이벤트 루프는 총 6개의 페이즈로 구성되어 있으며 한 페이즈에서 다음 페이즈로 넘어가는 것을 틱이라고 한다.
  • 각 페이즈는 자신만의 큐를 관리한다.
  • Node.js는 순서대로 페이즈를 방문하면서 큐에 쌓인 작업을 하나씩 실행한다.
  • 페이즈의 큐에 담긴 작업을 모두 실행하거나 시스템의 실행 한도에 다다르면 Node.js는 다음 페이즈로 넘어간다. 
  • 이벤트 루프가 살아있는 한 Node.js는 이벤트 루프를 반복한다.

 

 

 

 

여기서 주의해야 하는 점은 큐에 3개의 작업이 담겼다고 해서 항상 3개의 작업만 처리하고 다음 페이즈로 넘어가는 것은 아니라는 것입니다. 다음과 같은 데이터베이스 호출 코드가 있다고 해보겠습니다.

 

db.query("SELECT * FROM EVENT_LOOP", (err, data) => {
		console.log(data);
});

 

SELECT * FROM EVENT_LOOP라는 쿼리를 날렸다면 언젠간 데이터베이스는 쿼리 결과를 응답해 줄 거고 우리는 그 결과를 콜백을 통해 받아볼 수 있습니다. 위 코드의 경우 그 콜백은 쿼리 결과를 출력하는 함수가 됩니다.

 

 

 

 

앞에서 말했듯 이벤트 루프는 비동기 작업들을 관리합니다. 위에서 말한 콜백 또한 하나의 비동기 작업이므로 어떤 페이즈가 관리하는 큐에 담겨 있습니다. A 페이즈가 관리하는 Q 큐에는 F가 하나 담겨있습니다. 그리고 Q에는 오직 F 작업 하나만 담겨 있다고 해보겠습니다.

 

  • Node.js가 열심히 이벤트 루프를 돌다가 A 페이즈에 진입한다.
  • Node.js는 Q를 확인한다.
  • Q에서 F를 꺼내서 실행한다. 
  • Node.js는 Q를 확인한다.
  • 큐가 비어있으니 Node.js는 다음 페이즈로 넘어간다.

 

위 케이스에서는 큐에 1개의 작업만 있었고 Node.js는 1개의 작업만 실행하고 다음 페이즈로 넘어갔습니다. 그러나 항상 Node.js가 페이즈에 진입할 시점에 큐에 담겨있던 작업들만 실행하고 다음 페이즈로 넘어가는 것은 아닙니다. 만약 위 코드가 아래 코드처럼 변한다면 어떻게 될까요?

 

 

db.query("SELECT * FROM EVENT_LOOP", (err, data) => {
		db.query("SELECT * FROM SPRING_BOOT", (err2, data2) => {
				console.log("Hello World~");
		});
		console.log(data);
});

 

이번에는 SELECT * FROM EVENT_LOOP라는 쿼리만 날리는 것이 아니라 이 쿼리의 응답이 왔을 때 SELECT * FROM SPRING_BOOT라는 새로운 쿼리를 날립니다. 처음 실행되는 콜백을 F1이라고 하고 두 번째 콜백을 F2라고 해보겠습니다. 이해하기 쉽게 바꾸면 아래와 같이 됩니다.

 

 

const F2 = (err, data) = {
		console.log("Hello World~");
}
const F1 = (err, data) => {
		db.query("SELECT * FROM SPRING_BOOT", F2);
		console.log(data);
}
db.query("SELECT * FROM EVENT_LOOP", F1);

 

 

방금 봤던 예제와 똑같이 페이즈 A가 있었고 이 페이즈는 큐 Q를 관리합니다. 그리고 그 큐에는 F1 작업 하나가 담겨있습니다.

 

  • Node.js가 열심히 이벤트 루프를 돌다가 A 페이즈에 진입합니다.
  • Node.js는 Q를 확인합니다.
  • Q에서 F1를 꺼내서 실행합니다.
    • SELECT * FROM SPRING_BOOT라는 새로운 쿼리를 날립니다.
    • 콘솔에 SELECT * FROM EVENT_LOOP 결과를 출력합니다. 

 

 

중간에 쿼리를 하나 더 실행한다는 점만 빼면 아까와 다른 점이 없습니다. 만약 콘솔에 SELECT * FROM EVENT_LOOP의 결과를 출력하던 중 SELECT * FROM SPRING_BOOT 쿼리의 응답이 온다면 어떻게 될까요? 마침 이 쿼리의 콜백을 다루는 페이즈가 A였다면 이 쿼리의 콜백 즉, F2는 Q로 들어가게 됩니다. 그러면 아래와 같은 일이 벌어질 수 있습니다.

 

 

 

  • Node.js가 열심히 이벤트 루프를 돌다가 A 페이즈에 진입한다.
  • Node.js는 Q를 확인한다.
  • Q에서 F1를 꺼내서 실행한다. 
    • SELECT * FROM SPRING_BOOT라는 새로운 쿼리를 날린다. 

 

 

  • Q에서 F1를 꺼내서 실행한다.
    • 콘솔에 쿼리 결과를 출력하기 전에 SELECT * FROM SPRING_BOOT의 응답이 와서 F2 콜백을 Q에 추가한다. 
    • 콘솔에 SELECT * FROM EVENT_LOOP 결과를 출력한다.

 

 

  • Node.js는 Q를 확인한다.
  • Q에서 F2를 꺼내서 실행한다.
    • 콘솔에 Hello World~를 출력한다.
  • Node.js는 Q를 확인한다.
  • 큐가 비어있으니 Node.js는 다음 페이즈로 넘어간다.

 

 

 

이 예제에서는 처음 예제와는 다르게 Node.js가 페이즈 A에 진입할 때는 큐에 1개의 작업만 있었지만 실제로 2개의 작업을 수행하고 다음 페이즈로 넘어갔습니다. 큐에서 작업을 꺼내 하나하나씩 실행하다 보면 큐에 새로운 작업이 들어올 수 있습니다. 위에서 보인 예제처럼 I/O에 대한 콜백이 될 수도 있고, 커널이 새로운 작업을 스케줄링해줄 수도 있습니다. 같은 페이즈 A에서 실행한 작업의 콜백이 A의 큐로 들어갈 수도 있고 이전 페이즈인 B에서 예전에 실행했던 작업의 콜백이 A의 큐로 들어갈 수도 있습니다. 

 

여기서 핵심은 각 페이즈에서 실행한 작업이 또 다른 작업을 스케줄링하거나, 이전에 처리했던 작업의 이벤트가 커널에 의해 큐에 추가될 수 있다는 것입니다. 극단적으로는 큐에 한 개의 작업밖에 없었지만 Node.js는 그 큐에 계속 추가되는 작업들을 처리하느라 다른 페이즈로 이동하지 못할 수도 있습니다. 하지만 페이즈는 시스템의 실행 한도의 영향을 받기 때문에 쌓인 작업을 처리하다가 포기하고 다음 페이즈로 넘어갑니다. Node.js가 한 페이즈에 영원히 갇히는 일은 발생하지 않습니다.

 

하지만 페이즈가 아닌 nextTickQueue의 경우 시스템의 실행 한도의 영향을 받지 않기 때문에 Node.js가 영원히 갇혀 다음 페이즈로 이동하지 못할 수도 있습니다. 

 

정리하면 아래와 같습니다.

 

  • Node.js는 페이즈에 진입해 큐에 쌓인 작업을 처리한다.
  • 쌓인 작업을 처리하던 중 이전 페이즈에서 실행했던 작업의 콜백이나 커널이 스케줄링한 새로운 작업이 큐에 추가될 수 있다. 
  • Node.js가 큐에 계속 추가되는 작업을 처리하느라 다음 페이즈로 넘어가지 못할 수 있다. 단, 페이즈는 시스템의 실행 한도의 영향을 받으므로 Node.js가 한 페이즈에 영원히 갇히는 일은 없다.

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

Node.js 실행과 이벤트 루프 흐름

지금까지 이벤트 루프를 구성하는 페이즈와 큐는 무엇인지, Node.js가 여러 페이즈를 돌아다니면서 무슨 일을 하는지 간단하게 알아봤습니다. 각 페이즈는 자신들이 관심 있어하는 작업들만 자신의 큐에서 관리합니다. 예를 들어 Timer Phase는 이름 그대로 타이머에 관한 비동기 작업들을 관리합니다. Close Callback Phase는 Close Callback과 관련된 비동기 작업들만 관리합니다.

 

각 페이즈를 하나하나 살펴보기 전에 우리가 실제로 node someScript.js를 실행했을 때 어떤 일이 벌어지는지, 코드 실행과 이벤트 루프는 어떤 관련이 있는지부터 알아보겠습니다.

 

우리가 node someScript.js를 실행하면 Node.js는 우선 이벤트 루프를 만듭니다. 그리고 이벤트 루프 바깥에서 someScript.js를 실행합니다. 처음부터 끝까지 someScript를 실행하고 나면 Node.js는 그제야 이벤트 루프를 확인합니다. 만약 이벤트 루프에 남은 작업이 있다면 Node.js는 이벤트 루프에 진입해 반복하며 작업을 실행합니다. 이벤트 루프에 남은 작업이 없다면 Node.js는 process.on('exit', callback)을 실행하고 이벤트 루프를 종료합니다.

 

말로는 잘 이해가 되지 않으니 코드를 보면서 다시 이해해보겠습니다.

 

 

// test.js
console.log("Hello World");

 

 

node test.js를 실행하면 Node.js는 우선 이벤트 루프를 생성합니다.

 

 

 

생성한 이벤트 루프에 진입하지 않고 이벤트 루프 바깥에서 test.js를 처음부터 끝까지 차례대로 실행합니다. 다시 말해 이벤트 루프를 만들어 놓고 Timer Phase에 진입하기 전에 test.js를 처음부터 끝까지 실행합니다. 위 코드에서는 Hello World라는 문자열이 출력됩니다.

 

 

test.js를 처음부터 끝까지 실행했으므로 이벤트 루프가 살아있는지 확인합니다. 다시 말해 이벤트 루프에 남아있는 작업이 있는지 확인합니다. test.js의 경우 그 어떤 비동기 호출도 하지 않았으므로 이벤트 루프에는 남아있는 작업이 없습니다. 

 

 

 

따라서 process.on('exit')의 콜백을 실행하고 이벤트 루프를 종료한 뒤에 프로그램을 종료합니다. 반대로 비동기 작업이 있는 코드를 생각해보겠습니다.

 

 

 

// test.js
setTimeout(() => console.log("Async Hello World"), 1000);
console.log("Hello World");

 

위 코드에는 setTimeout 이라는 비동기 작업이 추가되었습니다. 뒤에서 더 자세하게 이야기하지만 setTimeout의 비동기 작업은 Timer Phase가 관리합니다.

 

 

node test.js를 실행하면 Node.js는 우선 이벤트 루프를 생성합니다.

 

 

생성한 이벤트 루프에 진입하지 않고 이벤트 루프 바깥에서 test.js를 처음부터 끝까지 차례대로 실행합니다. 다시 말해 이벤트 루프를 만들어 놓고 Timer Phase에 진입하기 전에 test.js를 처음부터 끝까지 실행합니다. 우선 setTimeout이 호출되면서 Timer Phase에 Async Hello World라는 문자열을 1초 뒤에 출력하는 콜백을 Timer Phase에 등록합니다. 그리고 Hello World라는 문자열을 출력합니다. 

 

 

test.js를 처음부터 끝까지 실행했으므로 이벤트 루프가 살아있는지 확인합니다. 다시 말해 이벤트 루프에 남아있는 작업이 있는지 확인합니다. test.js의 경우 setTimeout으로 Timer Phase에 등록한 작업이 있으므로 이벤트 루프에 진입합니다. 

 

 

먼저 이벤트 루프의 Timer Phase에 진입합니다. Node.js는 Timer Phase가 관리하는 타이머들을 보면서 실행할 준비가 되었는지 확인합니다. 우리는 1초 뒤에 Async Hello World라는 문자열을 출력하기로 했으므로 아직 실행할 준비가 안 되었다고 합시다. 실행할 수 있는 작업이 없으므로 Node.js는 다음 페이즈로 이동합니다.

 

 

Node.js는 Timer Phase를 지나 Pending Callbacks Phase, Idle, Prepare Phase, Poll Phase, Check Phase, Close Callbacks Phase를 차례대로 방문합니다. 이 페이즈들에서도 실행할 수 있는 작업이 없으므로 Node.js는 Loop Alive까지 아무런 작업 없이 도달합니다. 

 

 

다시 한 번 이벤트 루프가 살아있는지 확인합니다. 1초가 지나지 않아 실행할 수 있는 작업은 없지만 아직 실행하지 못한 작업이 있으므로 이벤트 루프는 살아있습니다. 따라서 다시 Timer Phase로 진입합니다.

 

 

Node.js가 이벤트 루프를 열심히 돌다가 1초가 지나 이전에 setTimeout으로 등록해뒀던 콜백이 이제 실행할 준비가 되었습니다. Node.js는 Timer Phase가 관리하는 큐에서 콜백을 꺼내서 실행합니다. 그 결과로 Async Hello World가 출력됩니다. Timer Phase에는 더 이상 남아있는 작업이 없으므로 다음 페이즈로 이동합니다.

 

실제로 위와 같은 상황에서 Node.js는 1초가 지날 때까지 이벤트 루프를 무한 반복하지 않습니다. 실제로는 Poll Phase에서 이벤트 루프를 반복해도 실행할 수 있는 작업이 없는 것을 인지하고 1초가 지나 setTimeout 콜백을 실행할 수 있을 때까지 대기합니다. 1초가 지나 타이머의 콜백을 실행할 수 있게 되면 그제야 다음 페이즈로 이동합니다. 자세한 내용은 Poll Phase에서 살펴보겠습니다. 

 

 

Node.js는 이벤트 루프의 페이즈들을 하나하나 방문합니다. 물론 실행할 작업이 없기 때문에 큐를 확인하고 바로 다음 페이즈로 넘어갑니다. 그러다가 Loop Alive에 도달하면 이벤트 루프가 살아있는지 확인합니다. 이제는 실행할 작업이 하나도 없으므로 process.on('exit')의 콜백을 실행하고 이벤트 루프를 종료한 뒤에 프로그램을 종료합니다. 

 

이제 Node.js가 우선 이벤트 루프를 만들고 이벤트 루프 바깥에서 코드를 실행한다는 의미를 이해할 수 있을 것입니다. 이벤트 루프 바깥에서 어떤 일이 벌어지는지 확인했으니 이제 이벤트 루프 안에서 어떤 일이 벌어지는지 확인해보겠습니다. 지금까지의 내용을 정리하면 아래와 같습니다. 

 

 

  • Node.js는 코드를 실행하기 전에 우선 이벤트 루프를 생성한다.
  • Node.js는 이벤트 루프 바깥에서 코드를 처음부터 끝까지 실행한다.
  • 이벤트 루프가 살아있는지 확인하고 진입하거나 Exit Callbacks을 실행하고 프로그램을 종료한다.
  • 이벤트 루프에 진입하면 페이즈를 차례대로 돌면서 실행할 수 있는 작업을 실행한다.
  • 매 반복마다 이벤트 루프가 살아있는지 확인하고 죽었다면 Exit Callbacks을 실행하고 프로그램을 종료한다. 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

이벤트 루프의 여러 페이즈

이벤트 루프는 말했듯이 여러 페이즈로 구성되어있고 페이즈마다 관심 있어하는 작업들이 다 다릅니다. 즉, 비동기 작업의 종류마다 작업이 담기는 페이즈가 달라지며 그에 따라 실행 순서가 달라집니다. 각 페이즈가 어떤 작업들을 다루는지, 어떻게 관리하는지 확인해보겠습니다.

 

 

Timer Phase

Timer Phase는 말 그대로 setTimeout이나 setInterval과 같은 함수가 만들어 내는 타이머들을 다룹니다. 엄밀하게 말하면 Timer Phase가 관리하는 큐에 콜백을 직접 담지는 않습니다. 

 

Timer Phase는 setTimeout이 호출되었을 때 타이머의 콜백을 큐에 저장하지 않습니다. 그 대신 콜백을 언제 실행할 지에 정보가 담긴 타이머를 Timer Phase가 관리하는 min-heap에 넣습니다. 만약 Poll Phase에서 setTimeout을 3번 호출했다면 Timer Phase의 min-heap에 3개의 타이머가 저장되어 있습니다. 그리고 타이머를 실행할 준비가 되면(시간이 되면) 타이머가 가리키고 있는 콜백을 호출합니다.

 

최소 힙(min heap)은 데이터를 완전 이진트리 형태로 관리하면서 최댓값 또는 최솟값을 찾아내는데 효율적인 자료구조입니다. 최대 힙(max heap)은 최댓값을 찾아내는데 최적화되어있고 최소 힙(min heap)은 최솟값을 찾아내는데 최적화되어있습니다. 이러한 특성 덕분에 최소 힙을 사용하면 실행할 수 있는 가장 이른 타이머를 손쉽게 찾을 수 있습니다.
여러 블로그에서 Timer Phase는 타이머만 관리하고 Poll Phase에서 콜백이 실행된다고 기술했습니다. 하지만 이는 사실이 아닙니다. Timer Phase에서 타이머를 검사하고 실행도 합니다. 실제 예제는 아래 코드에서 알아보겠습니다. 이에 대한 Github Issue도 존재합니다.

 

이벤트 루프에서 Node.js는 현재 페이즈가 관리하는 작업들만 실행할 수 있습니다. 따라서 Node.js는 Timer Phase에서만 타이머를 검사합니다. 즉, Node.js가 Timer Phase에 진입해야만 타이머들이 실행될 기회를 얻습니다. 따라서 우리가 Poll Phase에서 setTimeout(fn, 1)을 호출한다 해도 Node.js는 정확히 1ms 뒤에 콜백이 실행됨을 보장하지 않습니다. Timer Phase에 진입하는데 1초가 걸린다면 타이머의 콜백을 실행하는 데는 1ms가 아니라 1초 이상이 걸리게 됩니다. 

 

즉, 현재 시간을 now라고 했을 때 setTimeout(fn, delay)는 now + delay에 fn이 실행됨을 보장하지 않습니다. 적어도 now + delay 이후에 fn이 실행됨을 보장합니다.

 

또한 Timer Phase는 큐에 있는 모든 작업을 실행하거나 시스템의 실행 한도에 다다르면 다음 페이즈로 넘어갑니다. 

 

즉, Timer Phase에 대한 내용을 정리하면 아래와 같습니다.

 

  • Timer Phase는 min-heap을 이용해서 타이머를 관리합니다. 이 덕분에 실행 시간이 가장 이른 타이머를 효율적으로 찾을 수 있습니다.
  • Timer Phase는 setTimeout(fn, 1000) 을 호출했다고 하더라도 정확하게 1s가 지난 후에 fn이 호출됨을 보장하지 않습니다. 1s가 흐르기 전에 실행되지 않는 것을 보장합니다. 다르게 말하면 1초 이상의 시간이 흘렀을 때 fn이 실행됨을 보장합니다.
  • 큐에 있는 모든 작업을 실행하거나 시스템의 실행 한도에 다다르면 다음 페이즈인 Pending Callbacks Phase로 넘어갑니다. 
  • 이제 Timer Phase가 타이머를 어떻게 관리하는지 더 자세하게 살펴보겠습니다. 

 

 

Timer Phase의 타이머 관리

현재 시간을 now라고 하겠습니다. setTimeout(fn, delay)가 실행되면 Node.js는 타이머를 min-heap에 저장합니다. 이때 setTimeout을 호출한 시간을 registeredTime이라고 하겠습니다.

 

Node.js가 Timer Phase에 진입하면 min-heap에서 타이머를 하나 꺼냅니다. 그리고 그 타이머에 대해서 now - registeredTime >= delay 조건을 검사합니다. 만약 만족한다면 타이머를 실행할 준비가 되었으므로 타이머의 콜백을 실행합니다. 그리고 다시 min-heap에서 타이머를 꺼내서 검사합니다. 만약 조건이 성립하지 않는다면 남은 타이머들을 검사하지 않고 다음 페이즈로 넘어갑니다. 그 이유는 min-heap이 타이머를 오름차순으로 관리해 검사할 필요가 없기 때문입니다. 

 

 

그림으로 다시 살펴보겠습니다. Node.js가 Timer Phase에 진입하면 heap으로부터 가장 이른 타이머를 요청합니다. min-heap은 O(1)로 가장 이른 타이머를 반환합니다. 위 예시에서는 타이머 B가 됩니다. 

 

타이머 B를 받은 Node.js는 타이머를 현재 실행할 수 있는지 확인합니다. now(18) - registeredTime(10) >= delay(5) 이므로 타이머를 실행할 수 있습니다. 따라서 Node.js는 Heap에서 타이머 B를 제거하고 타이머 B와 연결된 콜백을 실행합니다.

 

 

 

이어서 Node.js는 다시 Heap에게 가장 이른 타이머를 요청합니다. 이번에는 registeredTime + delay가 가장 작은 타이머 A를 반환합니다. 

 

타이머 A를 받은 Node.js는 타이머를 현재 실행할 수 있는지 확인합니다. now(18) - registeredTime(20) >= delay(5)가 성립하지 않으므로 타이머를 실행할 수 없습니다. 따라서 Node.js는 타이머 A를 실행하지 않습니다. 가장 이른 타이머를 실행할 수 없으므로 Heap에 존재하는 모든 타이머를 실행할 수 없음이 당연합니다. 따라서 Node.js는 Heap에게 더 이상 타이머를 요청하지 않고 다음 페이즈로 넘어갑니다. 이는 min-heap 특성 덕분입니다.

 

예를 들어 delay가 50, 150, 200, 500, 3000인 5개의 타이머(A, B, C, D, E)를 0초에 등록했다고 해보겠습니다. 코드로 보면 아래와 같습니다.

 

// 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가 Timer Phase에 30에 진입했다고 해보겠습니다. Node.js는 먼저 min-heap에서 A를 꺼내서 검사합니다. A의 delay는 50으로 now(30) - registeredTime(0) >= delay(50)이 성립하지 않으므로 A의 콜백을 실행하지 않습니다. 이때 Node.js는 오름차순의 특성 덕분에 뒤에 있는 B, C, D, E를 검사할 필요가 없습니다. A를 실행할 수 없다면 뒤에 있는 타이머들은 당연히 실행할 수 없기 때문입니다. 따라서 Node.js는 다음 페이즈로 넘어갑니다. 

 

 

일부 타이머를 실행할 수 있는 경우

만약 Node.js가 Timer Phase에 50에 진입했다고 해보겠습니다. Node.js는 먼저 min-heap에서 A를 꺼내서 검사합니다. now(170) - registeredTime(0) >= delay(50) 이므로 A의 콜백을 실행하고 A의 타이머를 heap에서 제거합니다. 

 

그리고 heap에서 B 타이머를 꺼내서 검사합니다. now(170) - registeredTime(0) >= delay(150) 이므로 B 타이머의 콜백 또한 실행하고, heap에서 제거하고 타이머 C를 검사합니다. now(170) - registeredTime(0) >= delay(200)은 false이므로 C의 콜백을 실행하지 않고 다음 페이즈로 넘어갑니다.

 

 

모든 타이머를 실행할 수 있음에도 불구하고 다음 페이즈로 넘어가는 경우

이때 Timer Phase는 시스템의 실행 한도에 영향을 받는다는 것을 주의해야 합니다. 실행할 수 있는 타이머가 남아있다고 해도 시스템 실행 한도에 다다르면 다음 페이즈로 넘어갑니다. 예를 들어 시스템의 실행 한도를 3이라고 해보겠습니다. Node.js가 Timer Phase에 1000에 진입했다고 했을 때 Node.js는 타이머 D까지 실행할 수 있음에도 불구하고 타이머 A, B, C까지만 실행하고 다음 페이즈로 넘어갑니다.

 

 

 

Pending Callbacks Phase

이 페이즈는 pending_queue에 담기는 콜백들을 관리합니다. 이 큐에 담기는 콜백들은 이전 이벤트 루프 반복에서 수행되지 못했던 I/O 콜백들입니다. 

 

Timer Phase에서 말헀듯이 대부분의 페이즈는 시스템의 실행 한도의 영향을 받습니다. 시스템의 실행 한도 제한에 의해 큐에 쌓인 모든 작업을 실행하지 못하고 다음 페이즈로 넘어갈 수도 있습니다. 이때 처리하지 못하고 넘어간 작업들을 쌓아놓고 실행하는 페이즈입니다. 에러 핸들러 콜백 또한 pending_queue로 들어오게 됩니다. 

 

 

 

Idle, Prepare Phase

이 페이즈들은 Node.js의 내부적인 관리를 위한 페이즈로 자바스크립트를 실행하지 않습니다. 공식 문서에서도 별다른 설명이 없고 코드의 직접적인 실행에 영향을 미치지 않습니다.

 

 

Poll Phase 

이 페이즈는 새로운 I/O 이벤트를 다루며 watcher_queue의 콜백들을 실행합니다. watcher_queue에는 I/O에 대한 거의 모든 콜백들이 담깁니다. 쉽게 말하면 setTimeout, setImmediate, close 콜백 등을 제외한 모든 콜백이 여기서 실행된다고 생각하면 됩니다. 예를 들면 아래와 같은 콜백들이 실행됩니다. 

 

  • 데이터베이스에 쿼리를 보낸 후 결과가 왔을 때 실행되는 콜백
  • HTTP 요청을 보낸 후 응답이 왔을 때 실행되는 콜백
  • 파일을 비동기로 읽고 다 읽었을 때 실행되는 콜백

 

Poll Phase가 콜백을 관리하는 방법

우선, Poll Phase가 어떻게 새로운 I/O 이벤트에 대한 콜백을 다루는지부터 알아보겠습니다. 위에서 Poll Phase가 watcher_queue에 담긴 콜백들을 관리한다고 했습니다. 하지만 I/O 이벤트는 타이머와 달리 큐에 담긴 순서대로 I/O 작업이 완료되어 콜백 또한 차례대로 실행된다는 보장이 없습니다. 데이터베이스에 A, B 쿼리를 순서대로 날려도 응답은 B, A 순서로 올 수도 있습니다. A를 B보다 먼저 실행하기 위해 A 응답이 올 때까지 B 콜백 처리를 미루는 것은 말도 안 되는 일입니다. 큐에 담긴 순서와 무관하게 B를 먼저 실행하는 것이 당연합니다. 또한 실행 시간을 가지고 있던 타이머와 달리 I/O 이벤트는 event loop 혼자서는 언제 완료되었는지 알 수 없습니다. 이런 문제를 해결하기 위해 Poll Phase는 단순한 콜백 큐를 사용하지 않습니다. 

 

event loop가 n개의 열린 소켓을 가지고 있고 n개의 완료되지 않은 요청이 있다고 하겠습니다. 이 n개의 소켓에 대해 소켓과 메타 데이터를 가진 watcher를 관리하는 큐가 watcher_queue입니다. 그리고 각 watcher는 FD(File Descriptor)를 가지고 있습니다. 이 FD는 네트워크 소켓, 파일 등등을 가리킵니다.

 

운영 체제가 FD가 준비되었다고 알리면 event loop는 이에 해당하는 watcher를 찾을 수 있고 watcher가 맡고 있던 콜백을 실행할 수 있습니다. 

 

Poll Phase Blocking

Node.js가 Poll Phase에 진입했을 때 기다리고 있는 I/O 요청이 없거나, 아직 응답이 오지 않았다면 어떻게 할까요? 그동안 살펴본 Timer Phase, Pending Callbacks Phase에서는 큐에 실행할 수 있는 작업이 없다면 다음 페이즈로 넘어갔습니다. 하지만 Poll Phase에서는 조금 다르게 동작합니다.

 

페이즈 자신이 관리하는 큐만 확인하고 다음 페이즈로 넘기는 다른 페이즈들과는 달리 Poll Phase는 조금 더 영리하게 동작합니다. Node.js가 다음 페이즈로 이동해 다시 Poll Phase로 올 때까지 실행할 수 있는 작업이 있는지를 고려합니다. 

 

Poll Phase에 진입해 콜백들을 실행해 watcher_queue가 비게 된다면, 또는 처음부터 watcher_queue가 비어있었다면 event_loop는 Poll Phase에서 잠시 대기할 수 있습니다. 이때 대기하는 시간(timeout)은 아래 여러 조건에 의해 결정됩니다. 

 

 

 

  • 만약 이벤트 루프가 끝났다면 timeout은 0입니다. 즉, 다음 페이즈로 바로 넘어갑니다.
  • 만약 처리해야 할 비동기 작업이 없다면 timeout은 0입니다. 다르게 말하면 당장 처리해야 하는(I/O 요청이 끝난) 비동기 처리가 없고 기다리고 있는 비동기 요청도 없다면 다음 페이즈로 바로 넘어갑니다.
  • 만약 idle_handles에 남아있는 핸들러가 있다면 timeout은 0입니다. 즉시 다음 페이즈로 넘어갑니다.
  • 만약 pending_queue에 남아있는 작업이 있다면 timeout은 0입니다. 즉시 다음 페이즈로 넘어갑니다.
  • 만약 남아있는 close handlers가 있다면 timeout은 0입니다. 즉시 다음 페이즈로 넘어갑니다.
  • 만약 남아있는 타이머가 없다면 timeout은 -1입니다. 즉, 무한정 기다립니다. 
  • 만약 남아있는 타이머가 있다면
    • 그 타이머를 즉시 실행할 수 있다면 timeout은 0입니다. 즉시 다음 페이즈로 넘어갑니다.
    • 그 타이머를 즉시 실행할 수 없다면 실행할 수 있을 때까지 대기해야 하는 시간이 timeout이 됩니다. 타이머를 실행할 수 있을 때까지 기다리고 다음 페이즈로 넘어갑니다. 

 

 

 

간단하게 요약하면 아래와 같습니다.

 

  • 이벤트 루프가 종료되었다면 바로 다음 페이즈로 넘어갑니다.
  • 만약 Close Callbacks Phase, Pending Callbacks Phase에서 실행할 작업이 있다면 바로 다음 페이즈로 넘어갑니다.
  • 만약 Timer Phase에서 즉시 실행할 수 있는 타이머가 있다면 바로 다음 페이즈로 넘어갑니다. 
  • 만약 Timer Phase에서 즉시 실행할 수 있는 타이머는 없지만 n초 후에 실행할 수 있는 타이머가 있다면 n초 기다린 후 다음 페이즈로 넘어갑니다. 

 

Check Phase

이 페이즈는 오직 setImmediate의 콜백만을 위한 페이즈입니다. setImmediate가 호출되면 Check Phase의 큐에 담기고 Node.js가 Check Phase에 진입하면 차례대로 실행됩니다. 공식 문서에서 setImmediate와 process.nextTick의 차이에 주목하고 있습니다. 정리하면 아래와 같습니다.

 

  • process.nextTick은 같은 페이즈에서 호출한 즉시 실행된다.
  • setImmediate는 다음 틱에서 실행된다. 정확히는 Node.js가 틱을 거쳐 Check Phase에 진입하면 실행된다. 

 

따라서 동작만 보면 process.nextTick은 즉시 실행되고 setImmediate는 다음 틱에서 실행됩니다. 공식 문서에서도 두 이름은 바뀌어야 한다고 이야기했습니다. 하지만 이미 많은 모듈들이 process.nextTick과 setImmediate의 뒤바뀐 동작에 의존해 동작하고 있어 이름을 바꾸지 못했다고 공식 문서에서 이야기하고 있습니다.

 

 

Close Callbacks Phase

socket.on('close', () => {}); 과 같은 close 이벤트 타입의 핸들러를 처리하는 페이즈입니다. 정확하게는 uv_close()를 부르면서 종료된 핸들러의 콜백들을 처리하는 페이즈입니다.

 

 

 

nextTickQueue, microTaskQueue

nextTickQueue와 microTaskQueue는 앞에서 말했듯이 이벤트 루프의 일부가 아닙니다. 정확히는 libuv에 포함되어 있지 않고 Node.js에 구현되어 있습니다. 따라서 이벤트 루프의 페이즈와 상관없이 동작합니다. 

 

nextTickQueue는 process.nextTick()의 콜백을 관리하며 microTaskQueue는 Resolve 된 프라미스 콜백을 가지고 있습니다. nextTickQueue와 microTaskQueue는 현재 페이즈와 상관없이 지금 수행하고 있는 작업이 끝나면 그 즉시 바로 실행합니다. nextTickQueue는 microTaskQueue보다 높은 우선순위를 가지므로 더 먼저 실행됩니다.

 

Promise.resolve().then(() => console.log('resolve'))
process.nextTick(() => console.log('nexTick'))
/*
nexTick
resolve
*/

 

다른 페이즈들과 다르게 nextTickQueue와 microTaskQueue는 시스템의 실행 한도의 영향을 받지 않습니다. 따라서 Node.js는 큐가 비워질 때까지 콜백들을 실행합니다.

 

const fn = () => {
    process.nextTick(fn)
}
​
setTimeout(() => {
    console.log("Timer")
},0 )
​
fn()

 

따라서 위와 같은 코드가 실행되면 영원히 Timer는 출력되지 않습니다.

 

 

nextTickQueue와 microTaskQueue의 동작 변화

사실 nextTickQueue와 microTaskQueue, 그리고 여러 페이즈 간의 동작 순서는 Node.js의 버전에 따라 다릅니다. 정확히는 Node v11.0.0을 기점으로 달라졌습니다. 지금까지 설명한 동작 방식은 Node 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를 검사했습니다. 즉, 매 틱마다 검사했습니다.

 

위 코드에서는 아래와 같은 순서로 진행됩니다.

 

  1. Node.js가 Timer Phase에 진입
  2. 우선 Timer Phase에 있는 큐를 확인하고 console.log(1) 실행
  3. process.nextTick과 Promise.resolve를 호출해 nextTickQueue와 microTaskQueue에 콜백을 등록
  4. Node.js는 다시 Timer Phase에 있는 큐를 확인하고 console.log(2)를 실행
  5. Node.js는 다시 Timer Phase에 있는 큐를 확인하고 비어있으므로 다음 페이즈로 넘어가려 함
  6. Pending Callbacks Phase에 진입하기 전 우선순위가 높은 nextTickQueue부터 확인
  7. console.log(3) 실행
  8. nextTickQueue가 비었음을 확인하고 우선순위가 낮은 microTaskQueue 확인
  9. console.log(4) 실행
  10. microTaskQueue가 비었음을 확인하고 Pending Callbacks Phase로 이동

 

하지만 Node v11.0.0 이후에는 현재 실행하고 있는 작업이 끝나면 즉시 실행하도록 변경되었습니다.

 

위 코드는 아래와 같은 순서로 실행된다.

  1. Node.js가 Timer Phase에 진입
  2. 우선 Timer Phase에 있는 큐를 확인하고 console.log(1) 실행
  3. process.nextTick과 Promise.resolve를 호출해 nextTickQueue와 microTaskQueue에 콜백을 등록
  4. 현재 실행하고 있는 작업이 끝났으므로 Node.js는 nextTickQueue와 microTaskQueue에 작업이 있음을 확인 -> Timer Phase의 큐를 확인하지 않고 우선순위가 높은 nextTickQueue 부터 확인
  5. console.log(3) 출력
  6. Node.js는 nextTickQueue가 비었음을 확인하고 우선순위가 낮은 microTaskQueue 확인
  7. console.log(4) 출력
  8. microTaskQueue가 비었음을 확인하고 다시 Node.js는 Timer Phase에 있는 큐를 확인하고 console.log(2) 실행
  9. 현재 실행하고 있는 작업이 끝났으므로 Node.js는 nextTickQueue와 microTaskQueue에 작업이 있음을 확인 -> Timer Phase의 큐가 비었음을 확인하고 Pending Callbacks Phase로 이동

 

 

 

 

예제

setTimeout(() => {
    console.log("setTimeout")
}, 0)
setImmediate(() => {
    console.log("setImmediate")
})

 

대부분의 블로그에서 나오는 예제 코드로 실행할 때마다 출력 결과가 달라지는 예제입니다.

 

❯ node test.js
setImmediate
setTimeout
❯ node test.js
setTimeout
setImmediate

 

분명 이벤트 루프의 시작은 Timer Phase이고 setTimeout(fn, 0)이라 Timer Phase서 바로 실행되어야 할 것 같은데 실제 실행 결과는 그렇지 않습니다. 

 

우선, setTimeout(fn, 0)은 사실 0ms 뒤에 콜백을 실행하라는 뜻이 아닙니다. 실제로는 1ms 뒤에 콜백을 실행하라는 뜻과 같습니다. 즉, setTimeout(fn, 0) == setTimeout(fn, 1)입니다. 따라서 만약 Timer Phase에 진입했을 때 1ms 이상의 시간이 흘렀다면 콜백이 실행될 수 있고 만약 1ms 이상의 시간이 흐르지 않았다면 콜백이 실행되지 않고 다음 페이즈로 넘어가게 됩니다. 

 

 

 

위에서 살펴봤듯이 이벤트 루프의 진입점은 Timer Phase입니다. 그리고 Node.js는 이벤트 루프를 먼저 생성하고 이벤트 루프 바깥에서 코드를 다 실행하고 이벤트 루프에 진입합니다. 만약 운이 좋아서 이벤트 루프를 생성하고 진입하는데 1ms도 안 걸렸다면 Timer Phase의 콜백은 실행될 수 없습니다. 따라서 Check Phase에 진입해 setImmediate를 먼저 출력하고 다시 Timer Phase에 진입해 setTimeout을 출력합니다.

 

만약 이벤트 루프를 생성하고 진입하는데 1ms 이상의 시간이 걸렸다면 이벤트 루프에 진입하자마자 Timer Phase의 콜백을 실행할 수 있습니다. 따라서 먼저 setTimeout을 출력하고 Check Phase에 진입해 setImmediate를 출력합니다.

 

그래서 매 실행마다 결과가 달라질 수 있는 것입니다. 

 

 

 

 

 

궁금증

1. 이벤트 루프는 libuv에 포함되어 있는 건가요?

  1. libuv의 공식 홈페이지 설명을 보면 아래와 같이 소개되어 있습니다.

libuv is cross-platform support library which was originally written for Node.js. It’s designed around the event-driven asynchronous I/O model.

 

즉, libuv는 비동기 입출력, 이벤트 관련 기능을 지원하기 위해 OS 커널을 추상화한 라이브러리라고 생각할 수 있습니다.
그래서 이벤트 루프는 특정 라이브러리라기보다는 비동기 처리를 하는 방법 중 하나이고 libuv가 이를 구현했다고 생각하면 편합니다.

 

출처 : https://dontbesatisfied.tistory.com/4

 

 

위 사진을 보면 알 수 있듯이 Node.js는 비동기 처리를 libuv에게 위임하는데 libuv는 내부적으로 여러 Phase를 나누어서 비동기 처리를 하고 이 방법이 이벤트 루프라고 생각하면 될 것 같습니다.

 

 

Spring WebFlux 또한 요청을 처리하기 위해 이벤트 루프를 사용하는 것을 알 수 있습니다.

 

 

 

 

 

 

 

2. 이벤트 루프의 각 Phase에서는 비동기 처리 로직을 libuv에 위임하고 libuv에서 요청을 처리한 이후 콜백을 해당 Phase 큐에 담아주는 걸까요?

이 부분에 대해서는 다른 글이나 공식 문서에서도 자세한 내용이 없어서 확실하지는 않지만 제가 이해한 바는 아래와 같습니다.
Node.js는 V8 엔진을 기반으로 돌아가기가 때문에 js 코드의 실행 또한 V8 엔진이 맡게 됩니다. 따라서 이벤트 루프가 생성되고 전체 코드 실행이 완료되면 기다리고 있는 비동기 로직들이 이벤트 루프의 각 페이즈의 큐에 담기게 됩니다.

 

예를 들어서 main.js에서 구글에 HTTP 요청을 하나 보냈다면 Poll Phase에 HTTP 응답을 기다리는 무언가가 큐에 담기게 됩니다. 구글로부터 HTTP 응답이 오면 libuv는 FD(File Descriptor)를 통해서 응답이 왔다는 사실을 알게 되고 이 FD와 매칭 되는 콜백을 Poll Phase의 큐에서 찾아서 실행하게 됩니다. 이때 콜백은 결국 js 코드가 되므로 libuv가 이 콜백을 V8 엔진을 사용해서 실행한다고 생각하면 됩니다. 실제로 libuv는 이벤트 루프 안에 v8::Isolate, v8::Context 등을 가지고 있는데 이것을 사용해서 콜백 코드를 실행한다고 생각하면 됩니다.

 

만약 HTTP 응답 콜백에서 네이버에 HTTP 요청을 보내는 코드가 있다면 아래와 같은 순서로 진행된다고 생각하면 됩니다.

  1. main.js에서 구글에 HTTP 요청 보냅니다.
  2. 네트워크 요청을 보냄에 따라서 FD가 할당되고 libuv는 Poll Phase에서 이 FD를 기다리는 콜백을 큐에 넣습니다.
  3. 구글로부터 HTTP 응답이 오면 libuv는 FD를 통해 응답이 온 것을 감지하고 이 FD에 대응되는 콜백을 Poll Phase의 큐에서 찾아서 V8 엔진으로 이 콜백을 실행합니다.
  4. 이 콜백을 실행하면 네이버에 다시 HTTP 요청을 보내고 FD가 할당되고 이 FD를 기다리는 콜백이 다시 Poll Phase의 큐에 들어갑니다.
  5. 네이버 HTTP 응답이 오면 다시 libuv는 Poll Phase의 큐에서 콜백을 V8 엔진으로 실행합니다.
  6. 이러한 과정이 이벤트 루프에 남아있는 콜백이 없을 때까지 반복됩니다.

.

 

3. Nodejs 이벤트 루프 방식과 브라우저의 이벤트 루프 방식은 차이가 있을까요?

핵심 동작은 비슷할 수 있지만 구현이 달라 세부적인 동작이 다를 수도 있습니다. 실제로 Chrome은 libevent를 사용하고 Node.js는 libuv를 사용합니다. 찾아보니까 아래와 같은 Node.js와 브라우저 비교 글도 있네요.

 

 

 

 

 

 

4. async/await를 사용하면 어느 시점에 어느 부분이 마이크로테스크 큐에 담기나요?

이 부분에 대해 자세히 살펴보겠습니다.

 

 

1. Promise

Promise를 사용할 때 마이크로테스크는 다음과 같은 함수를 실행할 때 생성됩니다.

 

  • Promise.reslove(value).then((value) => { … });
    • reslove(value) 함수 호출 시 마이크로태스크가 생성되어 큐에 추가됩니다.
    • 마이크로태스크 대상은 (value) => { ... } 함수입니다.
  • Promise.reject(error).catch((error) => { … });
    • reject(error) 함수 호출 시 마이크로태스크가 생성되어 큐에 추가됩니다.
    • 마이크로태스크 대상은 (error) => { ... } 함수입니다.

 

 

2. async / await 

async / await는 간단한 예제 코드와 동작 과정을 통해 확인해보겠습니다.

 

예시 코드

  • 다음과 같은 코드를 실행하면 다음과 같은 로그가 순서대로 출력됩니다.
  • Before function! > In function! > After function! > One!
const one = () => Promise.resolve('One!');

async function myFunc () {
    console.log('In function!');
    const res = await one();
    console.log(res);
}

console.log('Before function!');
myFunc();
console.log('After function!');

 

예시 코드 동작 과정

  • async 키워드가 붙은 함수도 호출하면 일단 실행이 됩니다.
  • await 키워드를 만나면 마이크로태스크가 생성됩니다.
  • 이때, 주의할 사항으로 await 키워드 뒤에 오는 함수는 실행되고, 결과가 반환되는 시점에 마이크로태스크가 실행됩니다.
  • await 키워드 하위 코드가 모두 마이크로태스크 대상입니다.

 

 

아래 그림을 통해 예시 코드 동작 과정을 살펴볼 수 있습니다.

 

출처 : https://dev.to/lydiahallie/javascript-visualized-promises-async-await-5gke
출처 : https://dev.to/lydiahallie/javascript-visualized-promises-async-await-5gke
출처 : https://dev.to/lydiahallie/javascript-visualized-promises-async-await-5gke
출처 : https://dev.to/lydiahallie/javascript-visualized-promises-async-await-5gke
출처 : https://dev.to/lydiahallie/javascript-visualized-promises-async-await-5gke

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

마치며

자바스크립트에 대해 공부하면서, 항상 타입 스크립트를 공부하는 것도 좋지만, 먼저 자바스크립트의 기초부터 제대로 알아야겠다고 생각합니다. 이렇게 기초적인 부분을 습득하다 보면, 어느 순간 타입 스크립트로 넘어갔을 때 훨씬 잘 이해할 수 있으리라 믿습니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

출처

 

로우 레벨로 살펴보는 Node.js 이벤트 루프

1년 전, 필자는 setImmediate & process.nextTick의 차이점에 대해 설명하면서 Node.js의 이벤트 루프 구조에 대해 살짝 언급한 적이 있었다. 놀랍게도 독자 분들은 원래 설명하려고 했던 부분보다 이벤트

evan-moon.github.io

 

이벤트 루프와 매크로·마이크로태스크

 

ko.javascript.info

 

마이크로태스크

 

ko.javascript.info

 

Home

yceffort

yceffort.kr

 

Node.js Event-loop Architecture - 1ilsang

Developer who will be a Legend

1ilsang.dev

 

Node.js 이벤트 루프(Event Loop) 샅샅이 분석하기

글에 들어가기에 앞서 Node.js의 이벤트 루프의 경우 공식 문서에 설명이 부족하고 이에 따라 여러 사람들이 각자 나름대로 분석한 글이 많아 무엇이 이벤트 루프의 정확한 동작인지 알기 힘듭니

www.korecmblog.com

 

[Node.js] await은 정말 Non-Blocking일까?

최근 개인적으로 바쁜 일도 끝나고 해서 퇴근 후 스터디를 할까 고민하던 중 Node.js 스터디를 하기로 결심했다. 마침 지인 분께서도 함께 하고 싶다는 말씀을 해주셔서 페이스북 그룹을 통해 스

yorr.tistory.com

 

async & await 완벽 가이드

async & await에 대하여 겉핡기식으로 공부하니 실제 코드를 짤때 이게 왜 되지? 이게 왜 안되지? 같은 상황이 많이 발생했습니다. 따라서 이번에 제대로 공부해 놓기로 마음 먹었습니다. aync & await

kooku.netlify.app

 

자바스크립트와 이벤트 루프 : NHN Cloud Meetup

자바스크립트와 이벤트 루프

meetup.toast.com

 

Javascript Engine & Event Loop 동작 원리

이번 기회에 내가 제일 좋아하는 언어인 자바스크립와 더욱 더 친해지기 위해서, 실제 자바스크립트을 동작시키는 엔진의 내부 구조와 동작 원리에 대해 파헤쳐보고 블로그로 정리해보려고 한

helloinyong.tistory.com

 

Microtask & Macrotask in Javascript

 

junhyunny.github.io

 

 

Row level Node : js의 동작 방식부터 libuv와 event loop까지

글을 시작함에 앞서, 본 포스트는 libuv가 C++으로만 작성되었다는 등의 잘못된 내용 등 일부를 수정하기는 하였으나 다른 분들의 포스트를 참고하였고, 그대로 가져다 쓴 부분도 적지 않습니다.

darrengwon.tistory.com

 

 

nodejs의 내부 동작 원리 (libuv, 이벤트루프, 워커쓰레드, 비동기)

참조문서https://github.com/nodejs/nodehttps://nodejs.org/ko/docs/guides/event-loop-timers-and-nexttick/https://medium.com/the-node-js-collection/what-you-should-know-to-really-understand-the-node-js-event-loop-and-its-metrics-c4907b19da4c이벤트루

sjh836.tistory.com