Skip to content

이벤트 루프(또는 워커 풀)를 막지 마세요

이 가이드를 읽어야 할까요?

간단한 명령줄 스크립트보다 복잡한 것을 작성하고 있다면, 이것을 읽는 것이 더 높은 성능과 더 안전한 애플리케이션을 작성하는 데 도움이 될 것입니다.

이 문서는 Node.js 서버를 염두에 두고 작성되었지만, 개념은 복잡한 Node.js 애플리케이션에도 적용됩니다. OS별 세부 사항이 다른 경우, 이 문서는 Linux 중심입니다.

요약

Node.js는 이벤트 루프(초기화 및 콜백)에서 JavaScript 코드를 실행하고, 파일 I/O와 같은 비용이 많이 드는 작업을 처리하기 위해 워커 풀을 제공합니다. Node.js는 확장성이 뛰어나며, 때로는 Apache와 같이 더 무거운 접근 방식보다 낫습니다. Node.js의 확장성의 비결은 적은 수의 스레드를 사용하여 많은 클라이언트를 처리한다는 것입니다. Node.js가 더 적은 스레드로도 충분히 처리할 수 있다면, 시스템의 시간과 메모리를 스레드에 대한 공간 및 시간 오버헤드(메모리, 컨텍스트 전환)를 지불하는 대신 클라이언트에 더 많이 사용할 수 있습니다. 하지만 Node.js는 스레드가 적기 때문에, 애플리케이션을 현명하게 사용하도록 구성해야 합니다.

Node.js 서버를 빠르게 유지하는 데 도움이 되는 좋은 규칙은 다음과 같습니다. Node.js는 주어진 시간에 각 클라이언트와 관련된 작업이 "작을" 때 빠릅니다.

이는 이벤트 루프의 콜백과 워커 풀의 작업에 적용됩니다.

왜 이벤트 루프와 워커 풀을 막지 말아야 할까요?

Node.js는 적은 수의 스레드를 사용하여 많은 클라이언트를 처리합니다. Node.js에는 두 가지 유형의 스레드가 있습니다. 하나는 이벤트 루프(메인 루프, 메인 스레드, 이벤트 스레드 등으로도 알려짐)이고, 다른 하나는 워커 풀의 k 워커 풀(스레드 풀이라고도 함)입니다.

스레드가 콜백(이벤트 루프) 또는 작업(워커)을 실행하는 데 오랜 시간이 걸리는 경우, 우리는 그것을 "막혔다"라고 부릅니다. 스레드가 한 클라이언트를 대신하여 막혀 있는 동안에는 다른 클라이언트의 요청을 처리할 수 없습니다. 이는 이벤트 루프나 워커 풀을 막지 않아야 하는 두 가지 동기를 제공합니다.

  1. 성능: 두 유형의 스레드에서 모두 정기적으로 무거운 활동을 수행하면 서버의 처리량(요청/초)이 저하됩니다.
  2. 보안: 특정 입력에 대해 스레드 중 하나가 막힐 수 있는 경우, 악의적인 클라이언트가 이 "악성 입력"을 제출하여 스레드를 막히게 하고 다른 클라이언트에 대한 작업을 수행하지 못하게 할 수 있습니다. 이는 서비스 거부 공격입니다.

Node에 대한 간략한 복습

Node.js는 이벤트 기반 아키텍처를 사용합니다. 오케스트레이션을 위한 이벤트 루프와 비용이 많이 드는 작업을 위한 작업자 풀이 있습니다.

이벤트 루프에서 실행되는 코드는 무엇입니까?

Node.js 애플리케이션은 시작될 때 먼저 초기화 단계를 완료하고 모듈을 require하고 이벤트에 대한 콜백을 등록합니다. 그런 다음 Node.js 애플리케이션은 이벤트 루프에 진입하여 적절한 콜백을 실행하여 들어오는 클라이언트 요청에 응답합니다. 이 콜백은 동기적으로 실행되며 완료 후 처리를 계속하기 위해 비동기 요청을 등록할 수 있습니다. 이러한 비동기 요청에 대한 콜백도 이벤트 루프에서 실행됩니다.

이벤트 루프는 또한 콜백에 의해 이루어진 네트워크 I/O와 같은 비차단 비동기 요청을 수행합니다.

요약하면 이벤트 루프는 이벤트에 등록된 JavaScript 콜백을 실행하고 네트워크 I/O와 같은 비차단 비동기 요청을 수행하는 역할도 합니다.

작업자 풀에서 실행되는 코드는 무엇입니까?

Node.js의 작업자 풀은 일반적인 작업 제출 API를 노출하는 libuv(문서)에서 구현됩니다.

Node.js는 작업자 풀을 사용하여 "비용이 많이 드는" 작업을 처리합니다. 여기에는 운영 체제가 비차단 버전을 제공하지 않는 I/O와 특히 CPU 집약적인 작업이 포함됩니다.

다음은 이 작업자 풀을 사용하는 Node.js 모듈 API입니다.

  1. I/O 집약적
    1. DNS: dns.lookup(), dns.lookupService().
    2. [파일 시스템][/api/fs]: fs.FSWatcher()와 명시적으로 동기적인 API를 제외한 모든 파일 시스템 API는 libuv의 스레드 풀을 사용합니다.
  2. CPU 집약적
    1. Crypto: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair().
    2. Zlib: 명시적으로 동기적인 API를 제외한 모든 zlib API는 libuv의 스레드 풀을 사용합니다.

많은 Node.js 애플리케이션에서 이러한 API는 작업자 풀에 대한 유일한 작업 소스입니다. C++ 애드온을 사용하는 애플리케이션 및 모듈은 작업자 풀에 다른 작업을 제출할 수 있습니다.

완전성을 위해 이벤트 루프의 콜백에서 이러한 API 중 하나를 호출하면 이벤트 루프가 해당 API에 대한 Node.js C++ 바인딩에 들어가 작업자 풀에 작업을 제출할 때 약간의 설정 비용을 지불한다는 점에 주목합니다. 이러한 비용은 작업의 전체 비용에 비해 무시할 만하므로 이벤트 루프에서 오프로딩하는 이유입니다. 이러한 작업을 작업자 풀에 제출할 때 Node.js는 Node.js C++ 바인딩에서 해당 C++ 함수에 대한 포인터를 제공합니다.

Node.js는 다음에 실행할 코드를 어떻게 결정합니까?

추상적으로, 이벤트 루프와 워커 풀은 각각 대기 중인 이벤트와 대기 중인 작업에 대한 큐를 유지합니다.

사실, 이벤트 루프는 실제로 큐를 유지하지 않습니다. 대신, epoll (Linux), kqueue (OSX), 이벤트 포트 (Solaris) 또는 IOCP (Windows)와 같은 메커니즘을 사용하여 운영 체제에 모니터링하도록 요청하는 파일 디스크립터의 모음을 가지고 있습니다. 이러한 파일 디스크립터는 네트워크 소켓, 감시하는 모든 파일 등에 해당합니다. 운영 체제가 이러한 파일 디스크립터 중 하나가 준비되었다고 말하면 이벤트 루프는 이를 적절한 이벤트로 변환하고 해당 이벤트와 관련된 콜백을 호출합니다. 이 프로세스에 대한 자세한 내용은 여기에서 확인할 수 있습니다.

반대로, 워커 풀은 처리할 작업이 항목인 실제 큐를 사용합니다. 워커는 이 큐에서 작업을 팝하고 작업을 수행하며, 완료되면 워커는 이벤트 루프에 대해 "최소한 하나의 작업이 완료되었습니다" 이벤트를 발생시킵니다.

이것이 애플리케이션 설계에 의미하는 바는 무엇입니까?

Apache와 같은 클라이언트당 하나의 스레드 시스템에서는 각 대기 중인 클라이언트에 자체 스레드가 할당됩니다. 한 클라이언트를 처리하는 스레드가 차단되면 운영 체제는 해당 스레드를 중단하고 다른 클라이언트에게 차례를 넘깁니다. 따라서 운영 체제는 소량의 작업이 필요한 클라이언트가 더 많은 작업이 필요한 클라이언트로 인해 불이익을 받지 않도록 합니다.

Node.js는 적은 수의 스레드로 많은 클라이언트를 처리하므로 한 스레드가 한 클라이언트의 요청을 처리하는 동안 차단되면 대기 중인 클라이언트 요청은 스레드가 콜백 또는 작업을 완료할 때까지 차례를 얻지 못할 수 있습니다. 따라서 클라이언트에 대한 공정한 처리는 애플리케이션의 책임입니다. 이는 콜백이나 작업에서 단일 클라이언트에 대해 너무 많은 작업을 수행해서는 안 된다는 의미입니다.

이것이 Node.js가 잘 확장될 수 있는 이유 중 하나이지만 공정한 스케줄링을 보장하는 것은 귀하의 책임이기도 합니다. 다음 섹션에서는 이벤트 루프 및 워커 풀에 대한 공정한 스케줄링을 보장하는 방법에 대해 설명합니다.

이벤트 루프를 차단하지 마세요

이벤트 루프는 각 새 클라이언트 연결을 감지하고 응답 생성을 조율합니다. 들어오는 모든 요청과 나가는 모든 응답은 이벤트 루프를 통과합니다. 즉, 이벤트 루프가 특정 지점에서 너무 오래 머무르면 현재 및 새로운 클라이언트 모두 기회를 얻지 못합니다.

이벤트 루프를 절대 차단하지 않도록 해야 합니다. 즉, 각 JavaScript 콜백이 빠르게 완료되어야 합니다. 물론 이는 await, Promise.then 등에도 적용됩니다.

이를 보장하는 좋은 방법은 콜백의 "계산 복잡도"를 추론하는 것입니다. 콜백이 인수에 관계없이 일정한 단계 수를 취하면 모든 대기 중인 클라이언트에게 공정한 기회를 제공합니다. 콜백이 인수에 따라 다른 단계 수를 취하면 인수의 길이가 얼마나 될지 고려해야 합니다.

예제 1: 상수 시간 콜백.

js
app.get('/constant-time', (req, res) => {
  res.sendStatus(200);
});

예제 2: O(n) 콜백. 이 콜백은 작은 n의 경우 빠르게 실행되고 큰 n의 경우 더 느리게 실행됩니다.

js
app.get('/countToN', (req, res) => {
  let n = req.query.n;
  // 다른 사람에게 기회를 주기 전에 n번 반복
  for (let i = 0; i < n; i++) {
    console.log(`Iter ${i}`);
  }
  res.sendStatus(200);
});

예제 3: O(n^2) 콜백. 이 콜백은 여전히 작은 n의 경우 빠르게 실행되지만 큰 n의 경우 이전 O(n) 예제보다 훨씬 느리게 실행됩니다.

js
app.get('/countToN2', (req, res) => {
  let n = req.query.n;
  // 다른 사람에게 기회를 주기 전에 n^2번 반복
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      console.log(`Iter ${i}.${j}`);
    }
  }
  res.sendStatus(200);
});

얼마나 주의해야 할까요?

Node.js는 JavaScript에 Google V8 엔진을 사용하며, 이는 많은 일반적인 작업에 매우 빠릅니다. 이 규칙의 예외는 아래에서 설명하는 정규 표현식 및 JSON 작업입니다.

그러나 복잡한 작업의 경우 입력을 제한하고 너무 긴 입력을 거부하는 것을 고려해야 합니다. 이렇게 하면 콜백의 복잡성이 크더라도 입력을 제한하여 콜백이 가장 긴 허용 가능한 입력에서 최악의 시간보다 오래 걸릴 수 없도록 할 수 있습니다. 그런 다음 이 콜백의 최악의 비용을 평가하고 해당 실행 시간이 컨텍스트에서 허용 가능한지 여부를 결정할 수 있습니다.

이벤트 루프 차단: REDOS

이벤트 루프를 끔찍하게 차단하는 일반적인 방법 중 하나는 "취약한" 정규 표현식을 사용하는 것입니다.

취약한 정규 표현식 피하기

정규 표현식(regexp)은 입력 문자열을 패턴과 비교합니다. 일반적으로 정규 표현식 일치는 입력 문자열을 한 번만 통과하는 데 걸리는 시간 --- O(n)(여기서 n은 입력 문자열의 길이)으로 생각합니다. 대부분의 경우 한 번의 통과로 충분합니다. 불행히도, 어떤 경우에는 정규 표현식 일치에 입력 문자열을 통과하는 데 지수 횟수가 필요할 수 있습니다. --- O(2^n) 시간. 지수 횟수의 통과는 엔진이 일치를 결정하는 데 x번의 통과가 필요한 경우 입력 문자열에 문자를 하나만 더 추가하면 2*x번의 통과가 필요함을 의미합니다. 통과 횟수는 필요한 시간과 선형적으로 관련되므로 이 평가의 효과는 이벤트 루프를 차단하는 것입니다.

취약한 정규 표현식은 정규 표현식 엔진이 지수 시간을 사용할 수 있어 "악의적인 입력"에 대한 REDOS에 노출될 수 있는 정규 표현식입니다. 정규 표현식 패턴이 취약한지 여부(예: 정규 표현식 엔진이 지수 시간을 사용할 수 있는지 여부)는 실제로 대답하기 어려운 질문이며 Perl, Python, Ruby, Java, JavaScript 등을 사용하는지 여부에 따라 다르지만 다음은 이러한 모든 언어에 적용되는 몇 가지 경험 법칙입니다.

  1. (a+)*와 같은 중첩된 수량자를 피하십시오. V8의 정규 표현식 엔진은 이러한 수량자 중 일부를 빠르게 처리할 수 있지만 다른 수량자는 취약합니다.
  2. (a|a)*와 같이 겹치는 절이 있는 OR를 피하십시오. 다시 말하지만, 이들은 때로는 빠릅니다.
  3. (a.*) \1과 같은 역참조를 사용하지 마십시오. 어떤 정규 표현식 엔진도 선형 시간으로 이러한 역참조를 평가할 수 있다고 보장할 수 없습니다.
  4. 간단한 문자열 일치를 수행하는 경우 indexOf 또는 로컬에 해당하는 것을 사용하십시오. 더 저렴하고 O(n) 이상 걸리지 않습니다.

정규 표현식이 취약한지 확실하지 않은 경우 Node.js는 일반적으로 취약한 정규 표현식과 긴 입력 문자열에 대해서도 일치를 보고하는 데 어려움이 없다는 점을 기억하십시오. 지수 동작은 불일치가 있지만 Node.js가 입력 문자열을 통해 여러 경로를 시도할 때까지 확신할 수 없을 때 트리거됩니다.

REDOS 예시

다음은 서버를 REDOS에 노출시키는 취약한 정규 표현식의 예입니다.

js
app.get('/redos-me', (req, res) => {
  let filePath = req.query.filePath;
  // REDOS
  if (filePath.match(/(\/.+)+$/)) {
    console.log('valid path');
  } else {
    console.log('invalid path');
  }
  res.sendStatus(200);
});

이 예제의 취약한 정규 표현식은 (나쁜!) Linux에서 유효한 경로를 확인하는 방법입니다. "/a/b/c"와 같이 "/"로 구분된 이름의 시퀀스인 문자열과 일치합니다. 규칙 1을 위반하므로 위험합니다. 이중으로 중첩된 수량자가 있습니다.

클라이언트가 ///.../\n (100개의 '/'와 정규 표현식의 "."와 일치하지 않는 줄 바꿈 문자)으로 filePath를 쿼리하면 이벤트 루프는 사실상 영원히 걸려 이벤트 루프를 차단합니다. 이 클라이언트의 REDOS 공격으로 인해 다른 모든 클라이언트는 정규 표현식 일치가 완료될 때까지 차례를 얻지 못합니다.

이러한 이유로 복잡한 정규 표현식을 사용하여 사용자 입력을 검증하는 것을 경계해야 합니다.

안티 REDOS 리소스

다음과 같이 정규 표현식의 안전성을 검사하는 도구가 있습니다.

그러나 이들 중 어느 것도 모든 취약한 정규 표현식을 잡지는 못합니다.

또 다른 접근 방식은 다른 정규 표현식 엔진을 사용하는 것입니다. Google의 매우 빠른 RE2 정규 표현식 엔진을 사용하는 node-re2 모듈을 사용할 수 있습니다. 그러나 RE2는 V8의 정규 표현식과 100% 호환되지 않으므로 node-re2 모듈을 교체하여 정규 표현식을 처리하는 경우 회귀가 있는지 확인하십시오. 그리고 특히 복잡한 정규 표현식은 node-re2에서 지원되지 않습니다.

URL 또는 파일 경로와 같이 "명확한" 것을 일치시키려는 경우 정규 표현식 라이브러리에서 예제를 찾거나 npm 모듈 (예 : ip-regex)을 사용하십시오.

이벤트 루프 차단 : Node.js 핵심 모듈

여러 Node.js 핵심 모듈에는 다음과 같은 동기식으로 비용이 많이 드는 API가 있습니다.

이러한 API는 상당한 계산 (암호화, 압축)을 포함하거나 I/O (파일 I/O)가 필요하거나 잠재적으로 둘 다 (자식 프로세스) 필요하므로 비용이 많이 듭니다. 이러한 API는 스크립팅 편의를 위한 것이지만 서버 컨텍스트에서 사용하기 위한 것은 아닙니다. 이벤트 루프에서 실행하면 일반적인 JavaScript 명령어보다 완료하는 데 훨씬 오래 걸려 이벤트 루프를 차단합니다.

서버에서는 이러한 모듈에서 다음 동기식 API를 사용해서는 안됩니다.

  • 암호화 :
    • crypto.randomBytes (동기식 버전)
    • crypto.randomFillSync
    • crypto.pbkdf2Sync
    • 또한 암호화 및 해독 루틴에 큰 입력을 제공하는 데주의해야합니다.
  • 압축 :
    • zlib.inflateSync
    • zlib.deflateSync
  • 파일 시스템 :
    • 동기식 파일 시스템 API를 사용하지 마십시오. 예를 들어 액세스하는 파일이 NFS와 같은 분산 파일 시스템에있는 경우 액세스 시간이 크게 다를 수 있습니다.
  • 자식 프로세스 :
    • child_process.spawnSync
    • child_process.execSync
    • child_process.execFileSync

이 목록은 Node.js v9를 기준으로 상당히 완전합니다.

이벤트 루프 차단: JSON DOS

JSON.parseJSON.stringify는 잠재적으로 비용이 많이 드는 또 다른 작업입니다. 입력 길이 n에 대해 O(n)이지만, n이 클 경우 예상보다 오래 걸릴 수 있습니다.

서버가 JSON 객체, 특히 클라이언트에서 온 객체를 조작하는 경우, 이벤트 루프에서 작업하는 객체 또는 문자열의 크기에 주의해야 합니다.

예제: JSON 차단. 크기가 2^21인 객체 obj를 생성하고 JSON.stringify를 사용하여 문자열로 변환하고, 해당 문자열에서 indexOf를 실행한 다음 JSON.parse를 사용하여 다시 객체로 변환합니다. JSON.stringify된 문자열의 크기는 50MB입니다. 객체를 문자열로 변환하는 데 0.7초, 50MB 문자열에서 indexOf를 실행하는 데 0.03초, 문자열을 파싱하는 데 1.3초가 걸립니다.

js
let obj = { a: 1 };
let niter = 20;
let before, str, pos, res, took;
for (let i = 0; i < niter; i++) {
  obj = { obj1: obj, obj2: obj }; // 각 반복마다 크기가 두 배로 증가
}
before = process.hrtime();
str = JSON.stringify(obj);
took = process.hrtime(before);
console.log('JSON.stringify took ' + took);
before = process.hrtime();
pos = str.indexOf('nomatch');
took = process.hrtime(before);
console.log('Pure indexof took ' + took);
before = process.hrtime();
res = JSON.parse(str);
took = process.hrtime(before);
console.log('JSON.parse took ' + took);

비동기 JSON API를 제공하는 npm 모듈이 있습니다. 예를 들어 다음을 참조하십시오.

  • 스트림 API를 제공하는 JSONStream
  • 스트림 API와 아래에 설명된 이벤트 루프 분할 패러다임을 사용하는 표준 JSON API의 비동기 버전을 제공하는 Big-Friendly JSON

이벤트 루프를 차단하지 않고 복잡한 계산 수행

이벤트 루프를 차단하지 않고 JavaScript에서 복잡한 계산을 수행하려는 경우, 분할 또는 오프로딩의 두 가지 옵션이 있습니다.

분할

계산을 분할하여 각 계산이 이벤트 루프에서 실행되지만 정기적으로 다른 보류 중인 이벤트에 양보(차례를 넘겨줌)하도록 할 수 있습니다. JavaScript에서는 아래 예제 2에서와 같이 진행 중인 작업의 상태를 클로저에 쉽게 저장할 수 있습니다.

간단한 예로, 숫자 1부터 n까지의 평균을 계산하려는 경우를 가정해 보겠습니다.

예제 1: 분할되지 않은 평균, 비용은 O(n)

js
for (let i = 0; i < n; i++) sum += i;
let avg = sum / n;
console.log('avg: ' + avg);

예제 2: 분할된 평균, 각 n개의 비동기 단계는 O(1)의 비용이 듭니다.

js
function asyncAvg(n, avgCB) {
  // 진행 중인 합계를 JS 클로저에 저장합니다.
  let sum = 0;
  function help(i, cb) {
    sum += i;
    if (i == n) {
      cb(sum);
      return;
    }
    // "비동기 재귀".
    // 다음 작업을 비동기적으로 예약합니다.
    setImmediate(help.bind(null, i + 1, cb));
  }
  // avgCB를 호출하는 CB로 헬퍼를 시작합니다.
  help(1, function (sum) {
    let avg = sum / n;
    avgCB(avg);
  });
}
asyncAvg(n, function (avg) {
  console.log('avg of 1-n: ' + avg);
});

이 원칙을 배열 반복 등에 적용할 수 있습니다.

오프로딩

더 복잡한 작업을 해야 하는 경우, 파티셔닝은 좋은 선택이 아닙니다. 파티셔닝은 이벤트 루프만 사용하며, 여러분의 머신에서 거의 확실하게 사용 가능한 다중 코어의 이점을 얻을 수 없기 때문입니다. 이벤트 루프는 클라이언트 요청을 오케스트레이션해야 하며, 자체적으로 이를 수행해서는 안 된다는 점을 기억하십시오. 복잡한 작업의 경우, 작업을 이벤트 루프에서 워커 풀로 옮기십시오.

오프로드 방법

작업을 오프로드할 대상 워커 풀에는 두 가지 옵션이 있습니다.

  1. C++ 애드온을 개발하여 내장된 Node.js 워커 풀을 사용할 수 있습니다. 이전 버전의 Node에서는 NAN을 사용하여 C++ 애드온을 빌드하고, 최신 버전에서는 N-API를 사용하십시오. node-webworker-threads는 JavaScript만으로 Node.js 워커 풀에 액세스하는 방법을 제공합니다.
  2. Node.js I/O 테마 워커 풀이 아닌 계산 전용 워커 풀을 직접 생성하고 관리할 수 있습니다. 이를 수행하는 가장 간단한 방법은 자식 프로세스 또는 클러스터를 사용하는 것입니다.

모든 클라이언트에 대해 단순히 자식 프로세스를 생성해서는 안 됩니다. 자식을 생성하고 관리하는 속도보다 클라이언트 요청을 더 빠르게 받을 수 있으며, 서버가 포크 폭탄이 될 수 있습니다.

오프로딩의 단점 오프로딩 접근 방식의 단점은 통신 비용 형태의 오버헤드가 발생한다는 것입니다. 이벤트 루프만이 애플리케이션의 "네임스페이스"(JavaScript 상태)를 볼 수 있습니다. 워커에서는 이벤트 루프 네임스페이스의 JavaScript 객체를 조작할 수 없습니다. 대신, 공유하려는 모든 객체를 직렬화 및 역직렬화해야 합니다. 그러면 워커는 이러한 객체의 자체 복사본에서 작동하고 수정된 객체(또는 "패치")를 이벤트 루프로 반환할 수 있습니다.

직렬화 관련 사항은 JSON DOS 섹션을 참조하십시오.

오프로딩을 위한 몇 가지 제안 사항

CPU 집약적인 작업과 I/O 집약적인 작업은 특성이 상당히 다르므로 구분하는 것이 좋습니다.

CPU 집약적인 작업은 해당 워커가 스케줄링될 때만 진행되며, 워커는 머신의 논리 코어 중 하나에 스케줄링되어야 합니다. 논리 코어가 4개 있고 워커가 5개 있는 경우, 이러한 워커 중 하나는 진행할 수 없습니다. 결과적으로 이 워커에 대한 오버헤드(메모리 및 스케줄링 비용)를 지불하지만 아무런 수익을 얻지 못합니다.

I/O 집약적인 작업은 외부 서비스 제공자(DNS, 파일 시스템 등)를 쿼리하고 응답을 기다리는 작업을 포함합니다. I/O 집약적인 작업이 있는 워커가 응답을 기다리는 동안에는 다른 할 일이 없으며 운영 체제에서 스케줄링을 해제하여 다른 워커가 요청을 제출할 기회를 줄 수 있습니다. 따라서 I/O 집약적인 작업은 관련 스레드가 실행되지 않는 동안에도 진행됩니다. 데이터베이스 및 파일 시스템과 같은 외부 서비스 제공자는 많은 보류 중인 요청을 동시에 처리하도록 고도로 최적화되었습니다. 예를 들어 파일 시스템은 충돌하는 업데이트를 병합하고 최적의 순서로 파일을 검색하기 위해 많은 보류 중인 쓰기 및 읽기 요청 세트를 검사합니다.

Node.js 워커 풀과 같이 하나의 워커 풀에만 의존하는 경우, CPU 바운드 및 I/O 바운드 작업의 서로 다른 특성이 애플리케이션 성능을 저해할 수 있습니다.

이러한 이유로 별도의 계산 워커 풀을 유지하는 것이 좋습니다.

오프로딩: 결론

임의의 길이의 배열의 요소를 반복하는 것과 같은 간단한 작업의 경우, 파티셔닝이 좋은 선택일 수 있습니다. 계산이 더 복잡한 경우, 오프로딩이 더 나은 접근 방식입니다. 통신 비용, 즉 이벤트 루프와 워커 풀 간에 직렬화된 객체를 전달하는 오버헤드는 여러 코어를 사용하는 이점으로 상쇄됩니다.

그러나 서버가 복잡한 계산에 크게 의존하는 경우, Node.js가 정말 적합한지 생각해 봐야 합니다. Node.js는 I/O 바운드 작업에 탁월하지만, 비용이 많이 드는 계산에는 최상의 선택이 아닐 수 있습니다.

오프로딩 접근 방식을 취하는 경우, 워커 풀을 차단하지 않는 방법에 대한 섹션을 참조하십시오.

워커 풀을 차단하지 마십시오

Node.js는 k개의 워커로 구성된 워커 풀을 가지고 있습니다. 위에서 논의한 오프로딩 패러다임을 사용하는 경우, 별도의 계산 워커 풀을 가질 수 있으며, 동일한 원칙이 적용됩니다. 어느 경우든, k가 동시에 처리할 수 있는 클라이언트 수보다 훨씬 작다고 가정해 보겠습니다. 이는 Node.js의 확장성의 비결인 "많은 클라이언트를 위한 하나의 스레드" 철학과 일치합니다.

위에서 논의한 바와 같이, 각 워커는 워커 풀 대기열에서 다음 작업으로 진행하기 전에 현재 작업을 완료합니다.

이제 클라이언트 요청을 처리하는 데 필요한 작업 비용에 변동이 있을 것입니다. 일부 작업은 빠르게 완료될 수 있고(예: 짧거나 캐시된 파일 읽기 또는 적은 수의 임의 바이트 생성), 다른 작업은 더 오래 걸립니다(예: 더 크거나 캐시되지 않은 파일 읽기 또는 더 많은 임의 바이트 생성). 목표는 작업 시간의 변동을 최소화하는 것이어야 하며, 이를 위해 작업 파티셔닝을 사용해야 합니다.

작업 시간의 변동 최소화

워커의 현재 작업이 다른 작업보다 훨씬 더 비싼 경우, 다른 보류 중인 작업을 수행할 수 없게 됩니다. 즉, 비교적 긴 각 작업은 완료될 때까지 워커 풀의 크기를 효과적으로 1씩 줄입니다. 이는 바람직하지 않습니다. 왜냐하면 어느 시점까지는 워커 풀에 있는 워커가 많을수록 워커 풀 처리량(작업/초)이 커지고, 따라서 서버 처리량(클라이언트 요청/초)이 커지기 때문입니다. 상대적으로 비싼 작업을 가진 한 클라이언트는 워커 풀의 처리량을 감소시키고, 결과적으로 서버의 처리량을 감소시킵니다.

이를 방지하려면 워커 풀에 제출하는 작업의 길이 변동을 최소화해야 합니다. I/O 요청(DB, FS 등)에 의해 액세스되는 외부 시스템을 블랙 박스로 취급하는 것이 적절하지만, 이러한 I/O 요청의 상대적인 비용을 인식하고 특히 오래 걸릴 것으로 예상되는 요청은 제출하지 않아야 합니다.

두 가지 예는 작업 시간의 가능한 변동을 보여줍니다.

변동성 예시: 장시간 실행되는 파일 시스템 읽기

서버가 일부 클라이언트 요청을 처리하기 위해 파일을 읽어야 한다고 가정해 보겠습니다. Node.js 파일 시스템 API를 참조한 후 단순성을 위해 fs.readFile()을 사용하기로 했습니다. 그러나 fs.readFile()은 (현재) 분할되지 않습니다. 전체 파일을 포함하는 단일 fs.read() 작업을 제출합니다. 일부 사용자는 더 짧은 파일을 읽고 다른 사용자는 더 긴 파일을 읽는 경우, fs.readFile()은 작업자 풀 처리량에 해로운 작업 길이의 상당한 변동성을 초래할 수 있습니다.

최악의 시나리오의 경우, 공격자가 서버가 임의의 파일을 읽도록 설득할 수 있다고 가정합니다(이는 디렉터리 순회 취약점입니다). 서버가 Linux에서 실행 중인 경우 공격자는 매우 느린 파일인 /dev/random을 지정할 수 있습니다. 실제로 /dev/random은 무한히 느리며, /dev/random에서 읽도록 요청받은 모든 작업자는 해당 작업을 완료하지 못합니다. 그러면 공격자는 각 작업자에 대해 하나씩 k개의 요청을 제출하고 작업자 풀을 사용하는 다른 클라이언트 요청은 진행되지 않습니다.

변동성 예시: 장시간 실행되는 암호화 작업

서버가 crypto.randomBytes()를 사용하여 암호화 방식으로 안전한 임의 바이트를 생성한다고 가정합니다. crypto.randomBytes()는 분할되지 않습니다. 요청한 만큼 많은 바이트를 생성하는 단일 randomBytes() 작업을 만듭니다. 일부 사용자는 더 적은 바이트를 만들고 다른 사용자는 더 많은 바이트를 만드는 경우, crypto.randomBytes()는 작업 길이의 또 다른 변동성 원인이 됩니다.

작업 분할

가변적인 시간 비용이 드는 작업은 작업자 풀의 처리량을 저해할 수 있습니다. 작업 시간의 변동성을 최소화하기 위해 각 작업을 유사한 비용의 하위 작업으로 최대한 분할해야 합니다. 각 하위 작업이 완료되면 다음 하위 작업을 제출하고, 최종 하위 작업이 완료되면 제출자에게 알립니다.

fs.readFile() 예제를 계속 진행하려면 fs.read()(수동 분할) 또는 ReadStream(자동 분할)을 대신 사용해야 합니다.

CPU 바운드 작업에도 동일한 원칙이 적용됩니다. asyncAvg 예제는 이벤트 루프에 적합하지 않을 수 있지만 작업자 풀에는 적합합니다.

작업을 하위 작업으로 분할하면 더 짧은 작업은 적은 수의 하위 작업으로 확장되고 더 긴 작업은 더 많은 수의 하위 작업으로 확장됩니다. 더 긴 작업의 각 하위 작업 사이에서 해당 작업이 할당된 작업자는 다른 더 짧은 작업의 하위 작업을 수행할 수 있으므로 작업자 풀의 전체 작업 처리량이 향상됩니다.

완료된 하위 작업의 수는 작업자 풀의 처리량에 대한 유용한 메트릭이 아닙니다. 대신 완료된 작업의 수에 관심을 가지십시오.

작업 분할 방지

작업 분할의 목적은 작업 시간의 변동을 최소화하는 것임을 상기하십시오. 짧은 작업과 긴 작업을 구별할 수 있다면 (예: 배열 합산 vs. 배열 정렬), 각 작업 종류에 대해 하나의 워커 풀을 만들 수 있습니다. 짧은 작업과 긴 작업을 별도의 워커 풀로 라우팅하는 것도 작업 시간 변동을 최소화하는 또 다른 방법입니다.

이러한 접근 방식을 선호하는 이유는 작업 분할은 오버헤드(워커 풀 작업 표현 생성 비용 및 워커 풀 큐 조작 비용)를 발생시키고, 분할을 피하면 워커 풀로의 추가 이동 비용을 절약할 수 있기 때문입니다. 또한 작업 분할 시 실수를 방지할 수 있습니다.

이 접근 방식의 단점은 이러한 모든 워커 풀의 워커가 공간 및 시간 오버헤드를 발생시키고 CPU 시간을 두고 서로 경쟁한다는 것입니다. 각 CPU 바운드 작업은 스케줄될 때만 진행된다는 점을 기억하십시오. 따라서 신중한 분석 후에만 이 접근 방식을 고려해야 합니다.

워커 풀: 결론

Node.js 워커 풀만 사용하든 별도의 워커 풀을 유지하든 풀의 작업 처리량을 최적화해야 합니다.

이를 위해 작업 분할을 사용하여 작업 시간의 변동을 최소화하십시오.

npm 모듈의 위험

Node.js 코어 모듈은 다양한 애플리케이션을 위한 빌딩 블록을 제공하지만 때로는 더 많은 것이 필요합니다. Node.js 개발자는 개발 프로세스를 가속화하는 기능을 제공하는 수십만 개의 모듈이 있는 npm 생태계에서 엄청난 이점을 얻습니다.

그러나 이러한 모듈의 대부분은 타사 개발자가 작성했으며 일반적으로 최선을 다한 보증만 제공된다는 점을 기억하십시오. npm 모듈을 사용하는 개발자는 두 가지 사항에 대해 우려해야 하지만 후자는 종종 잊혀집니다.

  1. API를 준수하는가?
  2. API가 이벤트 루프 또는 워커를 차단할 수 있는가? 많은 모듈이 커뮤니티에 해를 끼치도록 API 비용을 표시하지 않습니다.

간단한 API의 경우 API 비용을 추정할 수 있습니다. 문자열 조작 비용은 파악하기 어렵지 않습니다. 그러나 많은 경우 API 비용이 얼마나 될지 불분명합니다.

비용이 많이 들 수 있는 API를 호출하는 경우 비용을 다시 확인하십시오. 개발자에게 문서화를 요청하거나 소스 코드를 직접 검사하십시오 (비용을 문서화하는 PR 제출).

API가 비동기적이라도 각 파티션에서 워커 또는 이벤트 루프에서 얼마나 많은 시간을 소비할지 알 수 없습니다. 예를 들어 위에서 제공된 asyncAvg 예제에서 헬퍼 함수에 대한 각 호출이 숫자 중 하나가 아닌 절반을 합산한다고 가정합니다. 그러면 이 함수는 여전히 비동기적이지만 각 파티션의 비용은 O(1)이 아닌 O(n)이 되어 임의의 n 값에 대해 사용하기에 훨씬 덜 안전합니다.

결론

Node.js에는 두 가지 유형의 스레드가 있습니다. 하나는 이벤트 루프이고 다른 하나는 k개의 워커입니다. 이벤트 루프는 JavaScript 콜백 및 비차단 I/O를 담당하고, 워커는 차단 I/O 및 CPU 집약적인 작업을 포함하여 비동기 요청을 완료하는 C++ 코드에 해당하는 작업을 실행합니다. 두 유형의 스레드는 한 번에 하나의 활동만 수행합니다. 콜백 또는 작업에 시간이 오래 걸리면 해당 작업을 실행하는 스레드가 차단됩니다. 애플리케이션이 차단 콜백 또는 작업을 수행하면 최악의 경우 처리량(클라이언트/초)이 저하되고 최악의 경우 서비스 거부(DoS)가 발생할 수 있습니다.

높은 처리량과 더 나은 DoS 방지 웹 서버를 작성하려면 정상적인 입력과 악성 입력 모두에서 이벤트 루프나 워커가 차단되지 않도록 해야 합니다.