[번역] Node.js 개요: 아키텍처, API, 이벤트 루프, 동시성

Minjeong Koo·2022년 10월 31일
42

좋은 아티클 번역

목록 보기
6/8
post-thumbnail

원문: https://exploringjs.com/nodejs-shell-scripting/ch_nodejs-overview.html!

4.1  Node.js 플랫폼

  • 4.1.1 Node.js 전역 변수
  • 4.1.2 Node.js 내장 모듈
  • 4.1.3 Node.js 함수의 다양한 스타일

4.2 Node.js 이벤트 루프

  • 4.2.1 실행을 완료하면 코드가 더 간단해집니다
  • 4.2.2 Node.js 코드가 싱글 스레드에서 실행되는 이유가 뭘까요?
  • 4.2.3 실제 이벤트 루프에는 여러 단계가 있습니다
  • 4.2.4 Next-tick 태스크와 마이크로 태스크
  • 4.2.5 태스크를 직접 스케줄링하는 다양한 방법 비교
  • 4.2.6 Node.js 앱은 언제 종료되나요?

4.3 libuv: Node.js용 비동기 I/O 등을 처리하는 크로스 플랫폼 라이브러리

  • 4.3.1 libuv가 비동기 I/O를 처리하는 방식
  • 4.3.2 libuv가 블로킹 I/O를 처리하는 방식
  • 4.3.3 I/O를 넘어선 libuv 기능

4.4 사용자 코드로 메인 스레드 탈출하기

  • 4.4.1 워커 스레드
  • 4.4.2 클러스터
  • 4.4.3 자식 프로세스

4.5 이 챕터의 출처

  • 4.5.1 감사의 인사

이 챕터에서는 Node.js의 작동 방식을 간략히 설명합니다.

  • 아키텍처가 어떤 모습인지
  • API가 어떻게 구성되어 있는지
    • 전역 변수 및 내장 모듈의 몇 가지 주요 내용 소개
  • 이벤트 루프를 통해 싱글 스레드에서 자바스크립트를 실행하는 방법
  • Node.js에서 동시성 자바스크립트를 위한 옵션

4.1 Node.js 플랫폼

아래 다이어그램은 Node.js의 전체적인 구조를 보여줍니다.

Node.js 앱에서 사용할 수 있는 API는 다음과 같이 구성됩니다.

  • ECMAScript 표준 라이브러리(언어의 일부)
  • 언어에 포함되지 않는 고유한 Node.js API
    • 일부 API는 전역 변수를 통해 제공됩니다.
      • 특히 fetchCompressionStream과 같은 크로스 플랫폼 웹 API가 이 범주에 속합니다.
      • 하지만 일부 Node.js 전용 API도 전역적입니다(예: process)
    • 나머지 Node.js API는 내장 모듈(예: 'node:path'(파일 시스템 경로를 처리하기 위한 함수 및 상수) 및 'node:fs'(파일 시스템과 관련된 기능)를 통해 제공됩니다.

Node.js API는 부분적으로 자바스크립트로 구현되며 일부는 C++로 구현됩니다. 후자는 운영 체제와 인터페이스하는 데 필요합니다.

Node.js는 내장된 V8 자바스크립트 엔진(Google의 Chrome 브라우저에서 사용되는 엔진과 동일)을 통해 자바스크립트를 실행합니다.

4.1.1 Node.js 전역 변수

다음은 Node의 전역 변수에 대한 몇 가지 주요 내용입니다.

  • crypto는 웹과 호환되는 crypto API에 대한 액세스를 제공합니다.
  • console은 브라우저의 동일한 전역 변수(console.log() 등)와 많이 겹칩니다.
  • fetch()Fetch broswer API를 사용할 수 있게 해줍니다.
  • process에는 process class의 인스턴스가 포함되어 있으며 커맨드 라인 매개변수, 표준 입력, 표준 출력 등에 접근할 수 있습니다.
  • structuredClone()은 개체 복제를 위한 브라우저와 호환되는 함수입니다.
  • URL은 URL을 처리하기 위한 클래스로 브라우저와 호환됩니다.

뿐만 아니라 이 장 전체에서 더 많은 전역 변수에 대해 설명하도록 하겠습니다.

4.1.1.1 전역 변수 대신 모듈 사용하기

다음 내장 모듈은 전역 변수에 대한 대안을 제공합니다.

  • 'node:console'은 전역 변수 console의 대안입니다.
console.log("Hello!");

import { log } from "node:console";
log("Hello!");
  • 'node:process'는 전역 변수 process의 대안입니다:
console.log(process.argv);

import { argv } from "node:process";
console.log(process.argv);

원칙적으로 모듈을 사용하는 것이 전역 변수를 사용하는 것보다 깔끔합니다. 그러나 전역 변수 consoleprocess를 사용하는 것이 널리 사용되는 일반적인 패턴이므로, 모듈을 사용하는 방법은 일반적인 패턴에서 벗어난다는 단점이 있습니다.

4.1.2 Node.js 내장 모듈

대부분의 Node API는 모듈을 통해 제공됩니다. 아래는 자주 사용되는 몇 가지 항목입니다(알파벳순)

  • 'node:assert/strict': Assertion은 조건이 충족되었는지 확인하고, 충족되지 않은 경우 오류를 보고하는 함수입니다. 애플리케이션 코드 및 단위 테스트에 사용할 수 있습니다. 다음은 이 API를 사용하는 예입니다.
import * as assert from 'node:assert/strict';
assert.equal(3 + 4, 7);
assert.equal('abc'.toUpperCase(), 'ABC');

assert.deepEqual({prop: true}, {prop: true}); // 깊은 비교
assert.notEqual({prop: true}, {prop: true}); // 얕은 비교

모듈 'node:module'에는 모든 내장 모듈의 지정자가 있는 배열을 반환하는 함수 'built in Modules()'가 포함되어 있습니다.

import * as assert from 'node:assert/strict';
import {builtinModules} from 'node:module';

// 내부 모듈 제거(이름이 밑줄로 시작하는 모듈)
const modules = builtinModules.filter(m => !m.startsWith('\_'));
modules.sort();
assert.deepEqual(
  modules.slice(0, 5),
  [
      'assert',
      'assert/strict',
      'async_hooks',
      'buffer',
      'child_process',
  ]
);

4.1.3 Node.js 함수의 다양한 스타일

이 섹션에서는, 다음과 같은 import를 사용합니다

import * as fs from "node:fs";

Node의 함수는 세 가지 스타일로 제공됩니다. 내장 모듈 'node:fs'를 예로 살펴보겠습니다.

앞서 살펴본 세 가지 예시에서는 유사한 기능을 가진 함수들에 대한 명명 규칙을 볼 수 있습니다.

  • 콜백 기반 함수의 기본 이름은 fs.readFile()입니다.
  • Promise 기반 버전은 이름은 같지만 다른 모듈에 있습니다: fsPromises.readFile()
  • 동기 버전의 이름은 기본 이름에 "Sync" 접미사를 더한 것입니다: fs.readFileSync()

이 세 가지 스타일이 어떻게 동작하는지 자세히 살펴보겠습니다.

4.1.3.1 동기 함수

동기 함수는 굉장히 간단합니다. 즉시 값을 반환하고 예외로 오류를 발생시킵니다.

try {
  const result = fs.readFileSync("/etc/passwd", { encoding: "utf-8" });
  console.log(result);
} catch (err) {
  console.error(err);
}

4.1.3.2 Promise 기반 함수

Promise 기반 함수는 이행 상태는 결과와 함께 충족(fulfilled)되거나 에러와 함께 거부(rejected)되는 Promise를 리턴합니다.

import * as fsPromises from "node:fs/promises"; // (A)

try {
  const result = await fsPromises.readFile("/etc/passwd", {
    encoding: "utf-8",
  });
  console.log(result);
} catch (err) {
  console.error(err);
}

A행의 모듈 지정자를 참고하세요. Promise 기반 API는 다른 모듈에 있습니다.

Promise는 "성급한 프로그래머를 위한 자바스크립트"에 더 자세히 설명되어 있습니다.

4.1.3.3 콜백 기반 함수

콜백 기반 함수는 결과와 에러를 마지막 매개변수인 콜백에 전달합니다.

fs.readFile("/etc/passwd", { encoding: "utf-8" }, (err, result) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(result);
});

이 스타일은 Node.js 문서에 자세히 설명되어 있습니다.

4.2 Node.js 이벤트 루프


기본적으로 Node.js는 모든 자바스크립트를 싱글 스레드(메인 스레드)에서 실행합니다. 메인 스레드는 이벤트 루프(자바스크립트 청크를 실행하는 루프)를 지속적으로 실행합니다. 각 청크는 콜백이며 협조적으로 예약된 태스크로 간주될 수 있습니다. 첫 번째 태스크에는 Node.js를 시작하는 코드(모듈 또는 표준 입력에서 오는 코드)가 포함되어 있습니다. 다른 태스크들은 보통 다음과 같은 이유로 나중에 추가됩니다.

  • 수동으로 태스크를 추가하는 코드
  • 파일 시스템, 네트워크 소켓 등을 포함한 I/O(입력 또는 출력)
  • 기타 등등

이벤트 루프의 대략적인 첫 모습은 다음과 같습니다.

즉, 메인 스레드는 다음과 유사한 코드를 실행합니다.

while (true) {
  // 이벤트 루프
  const task = taskQueue.dequeue(); // 블록들
  task();
}

이벤트 루프는 태스크 큐에서 콜백을 가져와 메인 스레드에서 실행합니다. 태스크 큐(task queue)가 비어 있는 경우 기본 스레드를 중단(block)합니다.

다음 두 가지 주제에 대해서는 나중에 살펴보겠습니다.

  • 이벤트 루프를 종료하는 방법
  • 싱글 스레드에서 실행되는 자바스크립트의 한계를 극복하는 방법

이 루프를 이벤트 루프라고 하는 이유는 무엇일까요? 이벤트에 대한 응답으로 많은 태스크가 추가되기 때문입니다. 예를 들어 입력 입력 데이터를 처리할 준비가 되었을 때 운영 체제에서 보낸 이벤트들이 있습니다.

콜백은 태스크 큐에 어떻게 추가될까요? 다음은 흔하게 가능성이 있는 경우입니다.

  • 자바스크립트 코드는 나중에 실행되도록 태스크를 큐에 추가할 수 있습니다.
  • event emitter(이벤트 소스)가 이벤트를 발생시키면 이벤트 리스너의 호출이 태스크 큐에 추가됩니다.
  • Node.js API의 콜백 기반 비동기 태스크는 다음 패턴을 따릅니다.
    • 우리는 무엇가를 요청하고, 우리에게 결과를 보고할 수 있는 콜백함수를 Node.js에게 요청합니다.
    • 결국 태스크는 기본 스레드 또는 외부 스레드(나중에 자세히 설명)에서 실행됩니다.
    • 이 태스크가 완료되면 콜백 호출이 태스크 큐에 추가됩니다. 다음 코드는 실행 중인 비동기 콜백 기반 태스크를 보여줍니다. 파일 시스템에서 텍스트 파일을 읽습니다.
import * as fs from "node:fs";

function handleResult(err, result) {
  if (err) {
    console.error(err);
    return;
  }
  console.log(result); // (A)
}

fs.readFile("reminder.txt", "utf-8", handleResult);
console.log("AFTER"); // (B)

출력은 다음과 같습니다.

AFTER
Don’t forget!

fs.readFile() 은 다른 스레드에서 파일을 읽는 코드를 실행합니다. 이 경우 코드 실행은 성공하고 이 콜백을 태스크 큐에 추가합니다.

() => handleResult(null, "Don’t forget!");

4.2.1 완료까지 실행은 코드를 더 간단하게 만듭니다

Node.js가 자바스크립트 코드를 실행하는 방법에 대한 중요한 규칙은 다음과 같습니다. 각 태스크는 다른 태스크가 실행되기 전에 완료("완료될 때까지 실행")됩니다. 이전 예제에서 알 수 있습니다. 줄 B의 'AFTER'는 결과가 줄 A에 기록되기 전에 기록됩니다. 이는 handleResult() 호출로 태스크가 실행되기 전에 초기 태스크가 완료되기 때문입니다.

완료까지 실행한다는 것은 태스크의 수명이 겹치지 않기 때문에, 백그라운드에서 공유 데이터가 변경되는 것에 대해 걱정할 필요가 없다는 것을 의미합니다. 그래서 Node.js 코드가 간소화됩니다. 다음 예는 그것을 보여줍니다. 간단한 HTTP 서버를 구현합니다.

// server.mjs
import * as http from "node:http";

let requestCount = 1;
const server = http.createServer((_req, res) => {
  // (A)
  res.writeHead(200);
  res.end("This is request number " + requestCount); // (B)
  requestCount++; // (C)
});
server.listen(8080);

node server.mjs를 통해 이 코드를 실행합니다. 코드가 시작되고 HTTP 요청을 기다립니다. 이제 웹 브라우저를 사용하여 http://localhost:8080으로 이동할 수 있습니다. 해당 HTTP 리소스를 다시 로드할 때마다 Node.js는 A 에서 시작하는 콜백을 호출합니다. 현재 값인 변수 requestCount(줄 B)가 포함된 메시지를 제공하고 이를 증가시킵니다(줄 C).

콜백의 각 호출은 새로운 태스크이며 변수 requestCount는 태스크 간에 공유됩니다. 실행이 완료되기 때문에 읽고 업데이트하기 쉽습니다. 동시에 실행중인 다른 태스크들이 없기 때문에, 다른 태스크과 동기화할 필요가 없습니다.

4.2.2 Node.js 코드가 싱글 스레드에서 실행되는 이유는 무엇입니까?

Node.js 코드가 기본적으로 단일 스레드(이벤트 루프 포함)에서 실행되는 이유는 무엇일까요? 여기에는 두 가지 이점이 있습니다.

  • 이미 살펴본 바와 같이, 스레드가 하나만 있는 경우 태스크 간에 데이터를 공유하는 것이 더 간단합니다.

  • 기존의 다중 스레드 코드에서는 완료까지 가장 오래 걸리는 태스크가 끝날 때까지 현재 스레드를 차단합니다. 이러한 태스크 예시로는 파일 읽기 또는 HTTP 요청 처리가 있습니다. 매번 스레드를 새로 만들어야 하기 때문에, 이런 태스크들을 많이 수행하는 것은 비용이 많이 듭니다. 이벤트 루프를 사용하면 태스크 당 비용이 더 저렴합니다. 특히 각 태스크가 별 효과를 거두지 못하는 경우에는 더욱 그렇습니다. 이것이 이벤트 루프 기반 웹 서버가 스레드 기반 웹 서버보다 높은 로드를 처리할 수 있는 이유입니다.

Node의 비동기 테스크 중 일부가 메인 스레드가 아닌 다른 스레드에서 실행되고 태스크 큐를 통해 자바스크립트로 다시 보고된다는 점을 고려할 때, Node.js는 실제로 단일 스레드가 아닙니다. 대신 단일 스레드를 사용하여 (메인 스레드에서) 동시에 비동기적으로 실행되는 태스크를 조정합니다.

이것으로 이벤트 루프에 대한 첫 번째 고찰을 마칩니다. 피상적인 설명으로 충분하다면 이 섹션의 나머지 부분을 생략해도 됩니다. 자세한 내용은 계속 읽어보세요.

4.2.3 실제 이벤트 루프에는 여러 단계가 있습니다

실제 이벤트 루프에는 여러 단계에서 읽는 여러 태스크 큐가 있습니다(GitHub 저장소 nodejs/node에서 일부 자바스크립트 코드를 확인 가능). 다음 다이어그램은 이러한 단계 중 가장 중요한 단계를 보여줍니다.


다이어그램에 표시된 이벤트 루프 단계는 무엇을 할까요?

  • "timers" 단계는 다음을 통해 큐에 추가된 시간이 지정된 태스크(timed tasks)를 호출합니다.

    • setTimeout(task, delay=1) 은 밀리세컨드 지연(delay) 후 콜백 태스크(task)를 실행합니다.
    • setInterval(task, delay=1) 콜백 태스크(task)를 반복적으로 실행하고, 밀리세컨드 동안 지연(delay)하는 일시 중지가 지속됩니다.
  • "poll" 단계는 I/O 이벤트를 검색 및 처리하고 큐에서 I/O 관련 태스크를 실행합니다.

  • "check" 단계("즉시 단계")는 다음을 통해 예약된 태스크를 실행합니다.

    • setImmediate(task) 는 가능한 한 빨리 콜백 태스크를 실행합니다("poll" 단계 후 "immediately").

각 단계는 큐가 비어 있거나 최대 수의 태스크가 처리될 때까지 실행됩니다. "poll"을 제외한 각 단계는 실행 중에 추가된 태스크를 처리하기 전에 다음 차례까지 대기합니다.

4.2.3.1 "poll" 단계

  • 폴링 큐가 비어 있지 않으면, 폴링 단계는 해당 큐를 통과하고 해당 태스크를 실행합니다.

  • 폴링 큐가 비어 있으면 다음을 수행합니다.

    • setImediate() 태스크가 있는 경우, 처리가 "check" 단계로 진행됩니다.
    • 준비된 타이머 태스크가 있는 경우 처리가 "timers" 단계로 진행됩니다.
    • 그렇지 않으면 이 단계는 전체 기본 스레드를 차단하고 새 태스크가 폴링 큐에 추가될 때까지 기다립니다(또는 이 단계가 끝날 때까지, 아래를 참고). 이러한 태스크들은 즉시 처리됩니다.

이 단계가 시스템 종속 시간 제한보다 오래 걸리는 경우, 이 단계는 종료되고 다음 단계가 실행됩니다.

4.2.4 Next-tick 태스크와 마이크로 태스크

각 태스크가 호출된 후,두 단계로 구성된 "하위 루프(sub-loop)"가 실행됩니다.

하위 단계는 다음을 처리합니다.

  • process.nextTick()을 통해 큐에 추가된 Next-tick 태스크
  • queueMicrotask(), Promise 리액션 등을 통해 큐에 추가된 마이크로 태스크

Next-tick 태스크는 Node.js 전용이며 마이크로 태스크는 크로스 플랫폼 웹 표준입니다(MDN의 지원 표 참조)

이 하위 루프는 두 큐가 모두 비워질 때까지 실행됩니다. 실행 중에 추가된 태스크는 즉시 처리됩니다. 실행 중에 추가된 태스크는 즉시 처리되며, 하위 루프는 다음 차례까지 기다리지 않습니다.

4.2.5 태스크를 직접 스케줄링하는 다양한 방법 비교

다음 기능 및 방법을 사용하여 태스크 큐 중 하나에 콜백을 추가할 수 있습니다.

  • 시간이 지정된 태스크 ("timers" 단계)
    • setTimeout() (웹 표준)
    • setInterval() (웹 표준)
  • 시간이 지정되지 않은 태스크 ("check" 단계)
    • setImmediate() (Node.js 전용)
  • 현재 태스크 직후에 실행되는 태스크
    • process.nextTick() (Node.js 전용)
    • queueMicrotask(): (웹 표준)

지연을 통해 태스크 타이밍을 맞출 때 태스크 실행이 가능한 가장 빠른 시간을 지정하는 것이 중요합니다. Node.js는 예정된 태스크가 있는 경우에만 태스크 사이를 확인할 수 있기 때문에, 정확히 예약된 시간에 실행할 수 없습니다. 따라서 태스크가 오래 실행되면 시간이 지정된 태스크가 지연될 수 있습니다.

4.2.5.1 Next-tick 태스크와 마이크로 태스크 vs normal tasks

아래 코드를 확인해보세요.

function enqueueTasks() {
  Promise.resolve().then(() => console.log('Promise reaction 1'));
  queueMicrotask(() => console.log('queueMicrotask 1'));
  process.nextTick(() => console.log('nextTick 1'));
  setImmediate(() => console.log('setImmediate 1')); // (A)
  setTimeout(() => console.log('setTimeout 1'), 0);

  Promise.resolve().then(() => console.log('Promise reaction 2'));
  queueMicrotask(() => console.log('queueMicrotask 2'));
  process.nextTick(() => console.log('nextTick 2'));
  setImmediate(() => console.log('setImmediate 2')); // (B)
  setTimeout(() => console.log('setTimeout 2'), 0);
}

setImmediate(enqueueTasks);

ESM 모듈의 특수성을 피하기 위해 setImmediate()를 사용합니다. 이는 마이크로 태스크에서 실행됩니다. 즉, ESM 모듈의 최상위 레벨에서 마이크로 태스크를 큐에 넣으면 next-tick 태스크 전에 실행됩니다. 이후에 보게 되겠지만 대부분의 다른 상황에서는 다릅니다.

다음은 이전 코드의 출력입니다.

nextTick 1
nextTick 2
Promise reaction 1
queueMicrotask 1
Promise reaction 2
queueMicrotask 2
setTimeout 1
setTimeout 2
setImmediate 1
setImmediate 2

살펴본 결과

  • 모든 next-tick 태스크는 enqueueTasks() 직후에 실행됩니다.

  • Promise 리액션을 포함한 모든 마이크로 태스크가 뒤따릅니다.

  • "timer" 단계는 immediate 단계 바로 다음에 옵니다. 이 때 시간 지정된 태스크를 실행됩니다.

  • immediate ("check") 단계(줄 A 및 줄 B) 동안 immediate 태스크를 추가했습니다. 출력에서 마지막으로 표시됩니다. 즉, 현재 단계에서 실행되지 않고 다음 immediate 단계에서 실행되었습니다.

4.2.5.2 해당 단계에서 next-tick 태스크 및 마이크로 태스크 큐에 추가

다음 코드는 next-tick 단계에서 next-tick 태스크를 큐에 넣고, 마이크로 태스크 단계에서 마이크로 태스크를 큐에 넣으면 어떻게 되는지 살펴봅니다.

setImmediate(() => {
    setImmediate(() => console.log('setImmediate 1'));
    setTimeout(() => console.log('setTimeout 1'), 0);

    process.nextTick(() => {
        console.log('nextTick 1');
        process.nextTick(() => console.log('nextTick 2'));
    });

queueMicrotask(() => {
    console.log('queueMicrotask 1');
    queueMicrotask(() => console.log('queueMicrotask 2'));
    process.nextTick(() => console.log('nextTick 3'));
    });
});

출력은 다음과 같습니다.

nextTick 1
nextTick 2
queueMicrotask 1
queueMicrotask 2
nextTick 3
setTimeout 1
setImmediate 1

살펴본 결과

  • next-tick 태스크가 먼저 실행됩니다.

  • "nextTick 2" 는 next-tick 단계에서 큐에 추가되고 즉시 실행됩니다. next-tick 큐가 비어 있는 경우에만 실행이 계속됩니다.

  • 마이크로 태스크도 마찬가지입니다.

  • 마이크로 태스크 단계에서 "nextTick 3"을 큐에 추가하고 실행 루프를 next-tick 단계로 되돌립니다. 이러한 하위 단계는 두 큐가 모두 비워질 때까지 반복됩니다. 그래야 실행이 다음 global 단계로 이동합니다. 먼저 "timers" 단계("setTimeout 1")를 수행하고, immediate 단계("setImmediate 1")를 수행합니다.

4.2.5.3 event loop 단계 중단 시키기

다음 코드는 이벤트 루프 단계를 중단시킬 수 있는 태스크 종류를 탐색합니다(무한 재귀를 통해 실행되지 않도록 방지)

import * as fs from "node:fs/promises";

function timers() {
  // OK
  setTimeout(() => timers(), 0);
}
function immediate() {
  // OK
  setImmediate(() => immediate());
}

function nextTick() {
  // I/O 중단 시키기
  process.nextTick(() => nextTick());
}

function microtasks() {
  // I/O 중단 시키기
  queueMicrotask(() => microtasks());
}

timers();
console.log("AFTER"); // 항상 로깅
console.log(await fs.readFile("./file.txt", "utf-8"));

"timers" 단계와 immediate 단계는 해당 단계에서 큐에 있는 태스크를 실행하지 않습니다. 그것이 timers()immediate()가 "poll" 단계에서 다시 전달하는 fs.readFile()가 중단되지 않는 이유입니다(Promise 리액션도 있지만 여기서는 무시합시다)

next-tick 태스크와 마이크로 태스크가 예약되는 방식 때문에 nextTick()microtasks() 모두 마지막 줄의 출력을 차단합니다.

4.2.6 Node.js 앱은 언제 종료되나요?

이벤트 루프의 각 반복이 끝날 때 Node.js는 종료할 시간인지 확인합니다. 미해결된 시간 지정된 태스크에 대한 timeout 카운트를 유지합니다.

  • setImmediate(), setInterval() 또는 setTimeout()을 통해 시간이 지정된 태스크를 예약하면 참조 횟수가 늘어납니다.
  • 예약된 태스크를 실행하면 참조 횟수가 줄어듭니다.

만약 이벤트 루프 반복이 끝날 때 참조 횟수가 0이면 Node.js가 종료됩니다.

다음 예시에서 확인할 수 있습니다.

function timeout(ms) {
  return new Promise((resolve, _reject) => {
    setTimeout(resolve, ms); // (A)
  });
}

await timeout(3_000);

Node.js는 timeout()에 의해 반환된 약속이 이행될 때까지 기다립니다. 이유가 뭘까요? A 줄에서 예약한 태스크가 이벤트 루프를 활성 상태로 유지하기 때문입니다.

반면에 Promise를 생성해도 참조 횟수는 증가하지 않습니다.

function foreverPending() {
  return new Promise((_resolve, _reject) => {});
}
await foreverPending(); // (A)

이 경우 실행은 A줄에서 대기하는 동안 일시적으로 이 (메인) 태스크를 떠납니다. 이벤트 루프가 끝나면 참조 횟수는 0이고 Node.js가 종료됩니다. 그러나 종료는 성공하지 못합니다. 즉, 종료 코드는 0이 아니라 13입니다("Unfinished Top-Level Await").

타임아웃이 이벤트 루프를 활성 상태로 유지할지 여부를 수동으로 제어할 수 있습니다. 기본적으로 setImmediate(), setInterval()setTimeout()을 통해 예약된 태스크가 미해결 상태인 경우 이벤트 루프를 활성 상태로 유지합니다. 이 함수는 .unref() 메서드가 기본값을 변경하는 Timeout 클래스의 인스턴스를 반환함으로서, 타임아웃이 활성화되어 있어도 Node.js가 종료되지 않도록 합니다. 메소드 .ref()는 기본값을 복원합니다.

Tim Perry는 .unref() 사용 사례를 언급합니다. 그의 라이브러리는 백그라운드 태스크를 반복적으로 실행하기 위해 setInterval()을 사용했습니다. 이 태스크로 인해 애플리케이션을 종료할 수 없었습니다. 그는 .unref()를 통해 문제를 해결했습니다.

4.3 libuv: Node.js용 비동기 I/O(및 그 이상)를 처리하는 크로스 플랫폼 라이브러리


libuv는 많은 플랫폼(Windows, macOS, Linux 등)을 지원하는 C로 작성된 라이브러리입니다. Node.js는 이를 사용하여 I/O 등을 처리합니다.

4.3.1 libuv가 비동기 I/O를 처리하는 방법

네트워크 I/O는 비동기식이며, 현재 스레드를 차단하지 않습니다. 이러한 I/O에는 다음이 포함됩니다.

  • TCP
  • UDP
  • 터미널 I/O
  • 파이프(Unix 도메인 소켓, Windows 명명된 파이프 등)

비동기 I/O를 처리하기 위해 libuv는 네이티브 커널 API를 사용하고 I/O 이벤트(Linux의 epoll, macOS를 포함한 BSD Unix의 kqueue, SunOS의 이벤트 포트, Windows의 IOCP)를 구독합니다. 그런 다음 알림이 발생할 때 알림을 받습니다. I/O 자체를 포함한 이러한 모든 활동은 메인 스레드에서 발생합니다.

4.3.2 libuv가 I/O 차단을 처리하는 방법

파일 I/O 및 일부 DNS 서비스와 같은 일부 기본 I/O API가 차단(비동기 아님)됩니다. libuv는 스레드 풀(소위 "워커 풀")의 스레드에서 이러한 API를 호출합니다. 이를 통해 메인 스레드가 이러한 API를 비동기적으로 사용할 수 있습니다.

4.3.3 I/O를 넘어선 libuv 기능

libuv는 Node.js가 I/O 뿐만 아니라 그 이상의 기능을 제공합니다. 기타 기능은 다음과 같습니다.

  • 스레드 풀에서 태스크 실행
  • 신호 처리
  • 고해상도 클록
  • 스레딩 및 동기화 기본 요소

libuv는 GitHub 저장소 libuv/libuv(uv_run() 함수)에서 소스 코드를 확인할 수 있는 자체 이벤트 루프를 가지고 있습니다.

4.4 사용자 코드로 메인 스레드 탈출


Node.js가 I/O에 반응하도록 유지하려면 메인 스레드 태스크에서 장시간 실행되는 계산을 수행하지 않아야 합니다. 이를 위한 두 가지 옵션이 있습니다.

  • 파티셔닝(Partitioning): 계산을 더 작은 조각으로 분할하고 setImmediate()를 통해 각 조각을 실행할 수 있습니다. 이를 통해 이벤트 루프가 조각 간에 I/O를 수행할 수 있습니다.
    • 장점은 각 조각에서 I/O를 수행할 수 있다는 것입니다.
    • 단점은 여전히 이벤트 루프의 속도를 늦추고 있다는 것입니다.
  • 오프로딩(Offloading): 다른 스레드나 프로세스에서 계산을 수행할 수 있습니다.
    • 단점은 메인 스레드가 아닌 다른 스레드에서 I/O를 수행할 수 없고 외부 코드와의 통신이 더 복잡해진다는 것입니다.
    • 장점은 이벤트 루프의 속도를 늦추지 않고 여러 프로세서 코어를 더 잘 사용할 수 있으며 다른 스레드의 오류가 메인 스레드에 영향을 미치지 않는다는 것입니다.

다음 하위 섹션에서는 오프로드에 대한 몇 가지 옵션에 대해 설명합니다.

4.4.1 워커 스레드(Worker Theread)

워커 스레드는 다음과 같이 몇 가지 차이점이 있는 크로스 플랫폼 웹 Workers API를 구현합니다.

  • 모듈에서 워커 스레드를 가져와야 하며, 웹 워커(Web Worker)는 전역 변수를 통해 액세스됩니다.

  • 워커 내에서 메시지를 듣고 게시하는 태스크는 브라우저에서 전역 객체의 방법을 통해 수행됩니다. Node.js에서는 parentPort를 대신 가져옵니다.

  • 워커의 대부분의 Node.js API를 사용할 수 있습니다. 브라우저에서는 선택이 더 제한적(DOM 등을 사용할 수 없음)입니다.

  • Node.js에서는 브라우저보다 더 많은 개체(클래스가 내부 클래스 JSTransferable을 확장하는 모든 개체)를 전송할 수 있습니다.

한편, 워커 스레드는 실제 스레드입니다. 프로세스보다 가볍고 메인 스레드와 동일한 프로세스로 실행됩니다.

  • 각 워커는 자체 이벤트 루프를 실행합니다.
  • 각 워커는 별도의 전역 변수를 포함하여 고유한 자바스크립트 엔진 인스턴스와 고유한 Node.js 인스턴스를 가집니다.
    • (특히, 각 워커는 자체 자바스크립트 heap을 가지고 있지만, 운영 체제 heap을 다른 스레드와 공유하는 V8 isolate입니다)
  • 스레드 간 데이터 공유는 제한됩니다.
    • Shared Array Buffers를 통해 이진 데이터/숫자를 공유할 수 있습니다.
    • Atomics는 SharedArrayBuffers를 사용할 때 도움이 되는 원자 연산 및 동기화 프리미티브를 제공합니다.
    • Channel Messaging API를 사용하면 양방향 채널을 통해 데이터("메시지")를 전송할 수 있습니다. 데이터가 복제(복사)되거나 전송(이동)됩니다. 후자가 더 효율적이며 몇 가지 데이터 구조에서만 지원됩니다.

자세한 내용은 워커 스레드에 대한 Node.js 문서를 참조하십시오.

4.4.2 클러스터

클러스터는 Node.js 전용 API입니다. 이를 통해 워크로드를 분산하는 데 사용할 수 있는 Node.js 프로세스 클러스터를 실행할 수 있습니다. 프로세스는 완전히 분리되지만 서버 포트를 공유합니다. 채널을 통해 JSON 데이터를 전달하여 통신할 수 있습니다.

프로세스 분리가 필요하지 않은 경우, 보다 가벼운 워커 스레드를 사용할 수 있습니다.

4.4.3 자식 프로세스

자식 프로세스는 또 다른 Node.js 전용 API입니다. 이를 통해 네이티브 명령을 실행하는 새로운 프로세스를 생성할 수 있습니다(종종 네이티브 셸을 통해). 이 API는 §12 "자식 프로세스에서 셸 명령 실행하기"에서 다룹니다.

4.5 이 장의 출처


Node.js 이벤트 루프:

이벤트 루프에 대한 비디오(이 장에 필요한 일부 배경 지식을 새로 고침):

  • Sam Roberts의 "Node’s Event Loop From the Inside Out": 운영 체제가 비동기식 I/O에 대한 지원을 추가한 이유를 설명합니다. 어떤 태스크가 비동기식이고 어떤 태스크가 비동기식인지(그리고 스레드 풀에서 실행해야 함) 등
  • Bryan Hughes의 "The Node.js Event Loop: Not So Single Threaded": 멀티태스킹의 간략한 역사가 포함되어 있습니다(협동 멀티태스킹, 선점형 멀티태스킹, 대칭형 멀티스레딩, 비동기식 멀티태스킹). 프로세스 vs 스레드, 스레드 풀에서와 동기적으로 I/O를 실행 등

libuv:

자바스크립트 동시성:

4.5. 감사의 말

  • 이 챕터를 검토하고 중요한 피드백을 제공해 주신 Dominic Elm 씨에게 깊은 감사를 드립니다.
profile
Software Developer

0개의 댓글