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. 파일 시스템: fs.FSWatcher()와 명시적으로 동기적인 API를 제외한 모든 파일 시스템 API는 libuv의 스레드 풀을 사용합니다.
  2. CPU 집약적
    1. 암호화: 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)은 패턴에 대해 입력 문자열을 일치시킵니다. 일반적으로 정규 표현식 일치는 입력 문자열 길이 n일 때 --- O(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('유효한 경로')
  } else {
    console.log('유효하지 않은 경로')
  }
  res.sendStatus(200)
})

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

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

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

Anti-REDOS 리소스

다음과 같이 정규식의 안전성을 확인하는 데 사용할 수 있는 몇 가지 도구가 있습니다.

그러나 이러한 도구 중 어느 것도 모든 취약한 정규식을 포착하지는 못합니다.

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

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

이벤트 루프 차단: 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
  • 아래에 설명된 이벤트 루프에서의 분할 패러다임을 사용하여 표준 JSON API의 비동기 버전과 스트림 API를 모두 가진 Big-Friendly JSON

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

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

분할

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

간단한 예로 숫자 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))
  }
  // 평균을 계산하는 CB를 호출하기 위해 도우미를 시작합니다.
  help(1, function (sum) {
    let avg = sum / n
    avgCB(avg)
  })
}
asyncAvg(n, function (avg) {
  console.log('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의 확장성의 비결인 "여러 클라이언트를 위한 하나의 스레드" 철학과 일치합니다.

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

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

작업 시간의 변동 최소화

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

이를 방지하려면 워커 풀에 제출하는 작업 길이의 변동을 최소화해야 합니다. 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 예시는 이벤트 루프에 적합하지 않을 수 있지만 작업자 풀에는 적합합니다.

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

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

작업 분할 피하기

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

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

이 접근 방식의 단점은 이러한 모든 작업자 풀의 작업자가 공간 및 시간 오버헤드를 발생시키고 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에 더 강한 웹 서버를 작성하려면 정상적인 입력과 악의적인 입력 모두에서 이벤트 루프나 워커가 차단되지 않도록 해야 합니다.