이벤트 루프와 워커 풀을 막지 마세요!

누가 이 문서를 읽어야 하나요?

만약 당신이 간단한 스크립트보다 더 복잡한 코드를 작성하길 원하신다면, 해당 문서를 읽는 것은 당신의 애플리케이션의 성능을 더 좋게, 보안을 안전하게 하는데 도움을 줄 것입니다.

해당 문서는 Node.js 서버를 기준으로 작성되었지만, 기본적인 개념은 복잡한 Node.js 애플리케이션에서도 적용됩니다. OS마다 세부 내용은 달라질 수 있으며 해당 문서는 리눅스를 기준으로 작성되었습니다.

요약

Node.js에서는 자바스크립트 코드를 이벤트 루프(초기화와 콜백)에서 실행시키고 파일 I/O와 같은 값비싼 작업은 워커 풀에게 위임합니다. 어떤 경우에는 더 많은 Apache와 같은 서버를 두는 것보다 Node.js가 좋을 정도로 Node.js의 확장성은 뛰어납니다. Node.js 확장성의 핵심은 많은 수의 클라이언트를 단 몇 개의 스레드만으로 처리한다는 점입니다. Node.js가 몇 개의 스레드만으로 잘 처리한다는 것은 곧 스레드를 생성하면서 발생하는 메모리 오버헤드와 컨텍스트 스위칭에서 발생하는 오버헤드를 클라이언트의 요청을 처리하는 데에 사용할 수 있다는 의미이기도 합니다. 반대로 말하면 당신은 애플리케이션이 몇 개의 스레드만으로 제대로 동작하도록 설계해야 한다는 뜻이기도 합니다.

당신의 Node.js 서버가 빠르게 동작할 수 있는 단 하나의 경험칙이 있습니다. Node.js는 클라이언트에 관련된 일을 하는 시간이 "적으면 적을수록" 빠르다.

이 경험칙은 이벤트 루프의 콜백과 워커 풀의 태스크에 관련되어 있습니다.

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

Node.js는 많은 수의 클라이언트를 단 몇 개의 스레드만으로 처리합니다. Node.js에는 2종류의 스레드가 있습니다. 하나는 이벤트 루프(또는 메인 루프, 메인 스레드, 이벤트 스레드 등)이며 다른 하나는 워커 풀(또는 스레드풀)에 있는 k개의 워커들입니다.

이벤트 루프에서 콜백을 실행하거나 워커에서 태스크를 실행할 때 오랜 시간이 걸린다면 우리는 그 스레드를 "막혔다"고 표현합니다. 스레드가 한 클라이언트를 위해 처리하는 동안에는 해당 스레드가 다른 클라이언트들의 요청을 처리할 수 없기 때문입니다. 이것은 우리에게 이벤트 루프나 워커 풀을 막지않아야 하는 2가지 이유를 설명해줍니다.

  1. 성능: 당신이 주기적으로 어떤 종류의 스레드에서든 처리 시간이 오래 걸리는 작업을 실행한다면 서버의 처리량 (요청/초)에 악영향을 끼칠 것입니다.
  2. 보안: 만약 클라이언트에서의 특정한 인풋이 스레드 중 하나를 막는 것이 가능하다면, 악의적인 클라이언트가 해당 인풋을 요청하여 스레드를 의도적으로 막아 결과적으로 다른 클라이언트들의 요청을 처리하지 못하게 됩니다. 이는 Denial of Service 공격으로 이어질 수 있습니다.

Node 빠르게 훑어보기

Node.js는 전체적으로 조율하기 위한 하나의 이벤트 루프와 값비싼 작업을 처리하는 하나의 워커풀로 구성된 Event-Driven Architecture로 설계되어있습니다.

어떤 코드가 이벤트 루프에서 동작하나요?

처음 실행될 때 Node.js 애플리케이션에서는 초기화 단계를 거칩니다. 해당 단계에서는 모듈들을 require하고 이벤트를 위한 콜백을 등록합니다. 그다음 Node.js 애플리케이션은 이벤트 루프에 진입하고 적절한 콜백을 실행하여 클라이언트의 요청에 응답합니다. 해당 콜백은 동기적으로 실행되며 해당 콜백이 완료되고 나서 비동기 요청들이 더 등록될 수 있습니다. 비동기 요청들을 처리하기 위한 콜백 또한 이벤트 루프에서 실행됩니다.

이벤트 루프는 또한 네트워크 I/O처럼 콜백에 의해 계속 요청되는 논블로킹 비동기 요청들도 처리하게 됩니다.

요약하자면 이벤트 루프에서는 이벤트를 위해 등록된 자바스크립트 콜백을 실행하고 네트워크 I/O와 같은 논블로킹 비동기 요청들 또한 처리할 수 있어야 합니다.

어떤 코드가 워커 풀에서 동작하나요?

Node.js에서 워커 풀은 libuv(docs)로 구현되어 있으며 libuv는 일반적인 작업 요청 API를 노출하고 있습니다.

Node.js는 워커 풀을 "값비싼" 작업을 처리하는 경우에 이용합니다. 해당 작업에는 CPU-intensive한 작업들뿐만 아니라 OS에서 논블로킹을 지원하지 않는 I/O의 경우도 포함됩니다.

아래 목록은 워커 풀을 사용하게 하는 Node.js 모듈 API입니다.

  1. I/O-intensive
    1. DNS: dns.lookup(), dns.lookupService().
    2. File System: fs.FSWatcher()와 libuv의 스레드 풀을 명백하게 동기적으로 사용하는 경우를 제외한 모든 파일 시스템 API.
  2. CPU-intensive
    1. Crypto: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair().
    2. Zlib: libuv의 스레드 풀을 명백하게 동기적으로 사용하는 경우를 제외한 모든 zlib API.

대부분의 Node.js 애플리케이션의 경우, 워커 풀을 사용하는 방법은 위의 API 목록이 유일합니다. C++ add-on을 사용하는 애플리케이션이나 모듈의 경우 다른 작업을 워커 풀에서 처리할 수 있습니다.

이벤트 루프의 콜백에 의해 위 목록 중 하나의 API가 호출되었을 때 이벤트 루프에서는 해당 API를 위해 Node.js C++ 바인딩에 들어가고 워커 풀에 작업을 요청하므로 셋업을 함에 있어 약간의 리소스가 사용될 수 있습니다. 하지만 이 리소스는 작업을 위한 전체 비용에 비하면 무시할 정도이며 이것이 이벤트 루프가 offloading한 이유입니다. Node.js는 워커 풀에 이러한 작업을 요청할 때 Node.js C++ 바인딩에서 해당하는 C++ 함수에 대한 포인터를 함께 제공합니다.

Node.js에서는 다음에 실행될 코드를 어떻게 결정하나요?

추상적으로 말하자면 이벤트 루프와 워커 풀에서는 각각 대기 중인 이벤트와 대기 중인 작업을 관리하기 위한 큐를 가지고 있습니다.

하지만 실제로는 이벤트 루프는 큐를 가지고 있지 않습니다. 그 대신 이벤트 루프는 OS에게 모니터링을 요청하는 File descriptor들의 콜렉션을 가지고 있으며 이는 epoll (Linux), kqueue (OSX), event ports (Solaris), IOCP (Windows)와 같은 메커니즘으로 동작합니다.

이 파일 디스크립터들은 그것이 모니터링하고 있는 모든 네트워크 소켓, 모니터링 중인 파일 등과 작용합니다. OS에서 파일 디스크립터가 준비되었다고 알리면 이벤트 루프에서는 이를 적절한 이벤트로 번역 후에 해당 이벤트에 관련된 콜백을 호출합니다. 이 과정에 대해 더 자세히 알고 싶으시다면 여기를 클릭하세요.

이와 반대로 워커 풀에서는 진짜로 큐를 사용하여 처리할 작업의 입출입을 관리합니다. 하나의 워커는 하나의 작업을 해당 큐에서 pop해서 처리하며 작업이 완료되면 "최소한 하나의 작업은 끝났음" 이벤트를 이벤트 루프에 보냅니다.

애플리케이션 디자인에서 어떤 의미를 가지나요?

Apache와 같은 하나의 스레드에서 하나의 클라이언트를 처리하는 디자인에서는 각각의 대기 중인 클라이언트는 해당 스레드에 할당됩니다. 만약 하나의 스레드가 하나의 클라이언트에 의해 막히게 되면 OS에서 이를 중단시키고 다른 클라이언트가 사용할 수 있게 합니다. OS는 적은 양의 작업이 필요한 클라이언트가 더 많은 양의 작업이 필요한 클라이언트에 의해 피해 보지 않도록 조처를 한다는 의미입니다.

Node.js는 많은 클라이언트를 몇 개의 스레드로 처리해야 하므로 만약 스레드가 한 클라이언트의 요청에 의해 막힌다면 그 뒤에 대기 중인 클라이언트들의 요청은 스레드가 해당 콜백이나 작업을 끝낼 때까지 대기해야 합니다. 클라이언트에 대한 공정한 처리는 당신의 애플리케이션의 책임입니다. 이는 하나의 콜백이나 작업을 요청하는 클라이언트에 너무 많은 시간을 사용하지 않는 것이 좋다는 의미입니다.

이것은 Node.js가 확장성이 좋은 이유 중 하나이지만 한편으로는 당신에게 공정한 스케줄링을 보장해야 한다는 책임이 주어진다는 의미이기도 합니다. 밑의 섹션들을 통해 이벤트 루프와 워커 풀에서 어떻게 공정한 스케줄링을 보장할 수 있는지 알아보겠습니다.

이벤트 루프를 막지마세요

이벤트 루프는 새로운 클라이언트 연결을 모니터링하고 응답 생성을 조율합니다. 모든 들어오는 요청들과 나가는 응답들은 이벤트 루프를 거치게 됩니다. 만약 이벤트 루프가 특정 지점에서 지나치게 오래 있게 된다면 다른 모든 대기 중인 클라이언트와 들어오게 될 클라이언트가 순서를 보장받지 못할 수 있음을 의미합니다.

당신은 이벤트 루프가 막히지 않는 것을 확실히 하는 것이 좋습니다. 다른 말로 하면, 당신의 자바스크립트 콜백은 빠른 시간 내에 완료되어야 합니다. 이는 awaitPromise.then 등에서도 통용됩니다.

이벤트 루프가 막히지 않는 것을 보장하는 좋은 방법은 콜백의 "시간 복잡도"를 유추해보는 것입니다. 만약 콜백이 인자를 얼마나 받는 것과 상관없이 상수의 시간이 소요된다면, 모든 대기 중인 클라이언트에게 공정하게 시간이 주어질 것입니다. 인자에 따라 다른 시간이 콜백에서 소요된다면 그것이 얼마나 길어질지 생각해보는 것이 좋습니다.

예시 1: 상수 시간의 콜백.

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

예시 2: O(n) 콜백. n이 작다면 빠르게 동작하고 n이 커질수록 느리게 동작할 것입니다.

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) 예시보다 훨씬 매우 느리게 동작할 것입니다.

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는 자바스크립트를 위해 구글의 V8 엔진을 사용하는데 이는 대부분의 연산에서 매우 빠르게 동작합니다. (정규 표현식과 JSON 연산에서는 예외인데 이에 대해서는 밑에서 다루도록 하겠습니다.)

그러나 복잡한 작업의 경우 인풋을 받고나서 너무 긴 인풋의 경우 거절하는 것을 고려하는 것이 좋습니다. 이렇게 한다면 아무리 콜백이 큰 복잡도를 가진다고 하더라도 당신이 설정한 최악의 시간 복잡도 이상은 받지 않는다는 것을 보장할 수 있습니다. 해당 콜백의 최악의 경우의 비용을 평가하고 그것이 당신의 기준에 들어오는지 계산할 수 있게 됩니다.

이벤트 루프 블로킹: REDOS

이벤트 루프를 심각할 정도로 피해를 입히는 가장 흔한 방법은 "취약한" 정규 표현식을 이용하는 것입니다.

취약한 정규 표현식을 피할 것

정규 표현식은 인풋의 문자열에서 해당하는 패턴을 찾습니다. 우리는 보통 한 번의 문자열 인풋은 O(n) (n은 문자열의 길이)의 시간이 한 번만 발생될 것이라 생각합니다. 대부분의 경우는 정말 한 번만 발생하지만 어떤 정규 표현식 요청은 지수적인 증가 O(2^n)로 이어질 수 있습니다. 지수적으로 증가한다는 것은 x번만으로 끝날 수 있는 요청이 인풋 문자열에 단 하나의 문자를 더하는 것만으로 2*x의 요청으로 늘어날 수 있음을 의미합니다. 요청의 숫자는 소요되는 시간과 선형적인 관계가 있으므로 이는 이벤트 루프를 막는 결과로 이어질 것입니다.

취약한 정규 표현식은 "악의적인 인풋"에 의해 지수 시간을 소요하게 되어 REDOS로 이어지게 할 수 있습니다. 당신의 정규 표현식이 취약한지(지수 시간이 소요될 수 있는지)에 대한 답을 하는 것은 쉽지 않으며 어떤 언어(Perl, Python, Ruby, Java, JavaScript 등)를 사용하는지에 따라 달라집니다. 그렇지만 어떤 언어에서든 적용할 수 있는 몇 개의 경험칙이 있습니다.

  1. (a+)*와 같은 중첩된 한정자를 사용하는 것을 피하십시오. V8의 정규 표현식 엔진은 이들 중 몇 개는 빠르게 처리할 수 있으나 대부분에 취약합니다.
  2. (a|a)*와 같이 공통 부분을 가지는 절간에 OR하는 것을 피하십시오. 1번에서처럼 어떤 경우에 한해 빠를 수 있습니다.
  3. (a.*) \1처럼 역참조하는 것을 피하십시오. 정규 표현식 엔진이 이것을 선형 시간 내에 완료할 수 있을지 장담할 수 없습니다.
  4. 만약 간단한 문자열간에 비교를 한다면 indexOf나 local equivalent를 사용하십시오. 더 저렴하며 어떤 경우에도 O(n)의 시간을 초과하지 않을 것입니다.

당신의 정규 표현식이 취약한지 아닌지 확신할 수 없다면 Node.js는 일반적으로 취약한 정규 표현식이나 긴 문자열 인풋의 경우에도 문제 보고를 하지 않는다는 것을 기억하세요. 지수적으로 증가하는 문제는 불일치가 발생하였을 때 시작될 수 있지만 Node.js는 인풋 문자열에 대한 충분한 시도를 하기 전에 확신할 수 없습니다.

REDOS 예시

취약한 정규 표현식이 서버에 어떻게 REDOS를 하는지에 대한 예시가 있습니다.

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);
});

위의 취약한 정규 표현식 예시는 리눅스에서 유효한 경로인지 체크하는 (좋지않은) 방법입니다. 이는 "/a/b/c"와 같이 "/"로 시작하는 모든 문자열에 대해 패턴을 체크하게 되는데 이는 규칙 1(중첩된 한정자를 피하십시오.)을 위반하였으므로 위험합니다.

만약 클라이언트가 파일경로 ///.../\n (100개의 /와 \n으로 끝나 정규 표현식의 "."에 매치되지 않게 됨)를 요청하게 되면 이벤트 루프에서는 무한 루프에 빠지게 되면서 막히게 됩니다. 이러한 클라이언트의 REDOS 공격은 대기 중인 모든 클라이언트가 정규 표현식 처리가 끝날 때까지 기다리게 합니다.

이러한 이유로, 유저의 인풋을 유효성 검사할때 복잡한 정규 표현식을 조심히 사용하는 것이 좋습니다.

REDOS를 방어하는 방법

다음과 같은 당신의 정규 표현식이 안전한지 검사해주는 도구들이 있습니다.

  • safe-regex
  • rxxr2. 하지만 이러한 도구가 모든 취약한 정규 표현식을 막아주는 것은 아닙니다.

또다른 방법으로는 다른 정규 표현식 엔진을 사용하는 것이 있습니다. 구글의 겁나 빠른 RE2 정규 표현식 엔진을 사용하는 node-re2모듈을 사용할 수 있습니다. 그러나 조심하시길. RE2는 V8 정규 표현식과 100% 호환되는 것이 아니므로 node-re2 모듈로 변경하게 된다면 회귀 검사를 해야합니다. 특별하게 복잡한 정규 표현식들은 node-re2에 의해 지원되지 않습니다.

만약 당신이 "명백한" 것들(URL이나 파일의 경로같은)에 정규 표현식을 사용하려 한다면 regexp library에서 예시를 찾아서 사용하거나 ip-regex같은 npm 모듈을 사용하십시오.

이벤트 루프 블로킹: Node.js 코어 모듈

다음과 같은 몇 개의 Node.js 코어 모듈은 동기적으로 동작하는 값비싼 API를 가지고 있습니다.

이 API들은 상당한 연산(암호화, 압축)이 필요하거나 I/O를 요구하거나(파일 I/O) 잠재적으로 둘 다 필요(자식 프로세스)할 수 있습니다. 이러한 API들은 스크립트의 편의를 위해 있는 것으로 서버 사이드에서 사용하도록 의도되지 않았기 때문입니다. 만약 당신이 이 API들을 이벤트 루프에서 실행한다면 이벤트 루프가 막히게 되면서 일반적인 자바스크립트 명령보다 훨씬 더 긴 시간을 소요할 것입니다.

서버 사이드에서 다음과 같은 동기적인 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는 잠재적으로 매우 비싼 연산입니다. 인풋의 길이에 따른 O(n)의 시간 복잡도를 가지기에 큰 n에서는 놀랄 정도로 긴 시간이 소요됩니다.

만약 서버가 JSON 객체를 다룬다면, 특히 클라이언트에서 온 객체라면, 이벤트 루프가 다루게 될 객체의 크기에 대해 염두해두어야 합니다.

예시: JSON 블로킹. 2^21의 크기를 가지는 obj 객체를 생성하고 JSON.stringify 한 다음, 문자열의 indexOf를 실행하고 그것을 JSON.parse합니다. JSON.stringify된 문자열은 50MB입니다. 객체를 JSON.stringify하는데 0.7초가 소요되고 50MB의 문자열에 indexOf하는데 0.03초 소요되고 문자열을 JSON.parse하는데에 1.3초가 걸리게 됩니다.

var obj = { a: 1 };
var niter = 20;
 
var before, str, pos, res, took;
 
for (var i = 0; i < niter; i++) {
  obj = { obj1: obj, obj2: obj }; // Doubles in size each iter
}
 
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);

npm 모듈이 제공하는 비동기적 JSON API가 있습니다. 예를 들어,

  • JSONStream, 스트림 API를 가지고 있습니다.
  • Big-Friendly JSON, 아래에 설명된 partitioning-on-the-Event-Loop 패러다임을 이용한 비동기 버전의 표준 JSON API와 스트림 API를 가지고 있습니다.

이벤트 루프를 막지 않고 복잡한 계산하는 방법

자바스크립트에서 이벤트 루프를 막지 않고 복잡한 계산을 하는 것을 원한다고 가정해봅시다. 당신에게는 파티셔닝과 오프로딩, 2가지 옵션이 있습니다.

파티셔닝

분할하여 계산하게 되면 분할된 모든 작업을 이벤트 루프에서 실행하지만 다른 대기 중인 이벤트에 대한 소요시간을 평균으로 맞출 수 있습니다. 자바스크립트에서는 밑의 예시 2에서처럼 현재 진행 중인 작업의 상태를 클로저에 저장하는 것은 간단합니다.

간단하게 예를 들어 숫자 1에서부터 n까지의 평균을 계산하고 싶다고 가정해봅시다.

예시 1: 분할되지 않았을때 평균, O(n) 소요

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

예시 2: 분할되었을때 평균, 비동기적으로 분할된 각각의 nO(1) 소요

function asyncAvg(n, avgCB) {
  // 현재 진행 중인 계산의 합을 JS 클로저에 저장.
  var 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) {
    var avg = sum / n;
    avgCB(avg);
  });
}
 
asyncAvg(n, function (avg) {
  console.log('avg of 1-n: ' + avg);
});

이 방법은 배열 순회나 다른 방식에도 적용할 수 있습니다.

오프로딩

만약 이보다 더 복잡한 계산이 필요하다면 파티셔닝은 좋은 방법이 아닐 수 있습니다. 파티셔닝은 이벤트 루프만 이용하기에 (매우 높은 확률로 당신 컴퓨터에 있는) 여러 개의 코어를 이용하는 이점을 얻을 수 없습니다. 언제나 명심하세요. 이벤트 루프는 클라이언트의 요청을 조율하는 역할이지 직접 실행하는 역할이 아님을. 더 복잡한 작업을 하기 위해서는 이벤트 루프의 작업을 워커 풀로 옮겨야합니다.

어떻게 오프로드하나요?

어떤 워커 풀에 작업을 오프로드할 것인지에 대한 2가지 옵션이 있습니다.

  1. C++ addon을 이용하여 설치되어 있는 Node.js의 워커 풀을 이용하는 방법이 있습니다. Node의 이전 버전에서는 NAN을 이용하여 C++ addon을 빌드하고 최신 버전에서는 N-API을 이용합니다. node-webworker-threads는 자바스크립트로만 Node.js 워커 풀에 접근할 수 있는 방법을 제공합니다.
  2. Node.js의 I/O 특화 워커 풀을 이용하기 보다 당신이 직접 계산에 특화된 워커 풀을 생성하고 관리할 수 있습니다. 가장 직관적인 방법은 자식 프로세스클러스터를 이용하는 것입니다.

모든 클라이언트마다 자식 프로세스를 전부 생성하지 않는 것이 좋습니다. 자식을 생성하고 관리하는 것보다 클라이언트의 요청을 더 빨리 받게 되면서 서버가 포크 폭탄이 되어버릴 수 있습니다.

오프로딩의 부정적인 측면

오프로딩 방식의 단점은 커뮤니케이션 비용의 형태로 오버헤드 발생이 야기될 수 있다는 점입니다. 오직 이벤트 루프만이 애플리케이션의 "네임스페이스" (자바스크립트 상태)를 볼 수 있습니다. 이벤트 루프의 네임스페이스에 있는 자바스크립트 객체를 워커로부터 조작할 수 없습니다. 대신에 이를 위해서는 공유하고자 하는 객체를 직렬화, 역직렬화 과정을 통해 공유하게 됩니다. 그 후 워커는 복사된 객체를 이용하여 연산하고 변경한 객체 (또는 "패치")를 이벤트 루프에 반환합니다.

직렬화 문제에 대해서는 JSON DOS 섹션을 확인해주세요.

오프로딩을 위한 몇 가지 제언

CPU-intensive 작업과 I/O-intensive 작업은 매우 다른 특성을 가지고 있으므로 당신은 이 둘을 구분하고 싶을 것입니다.

CPU-intensive 작업은 처리를 위한 워커가 스케줄링되었을 때만 진행되어야 하며 워커는 반드시 컴퓨터의 logical cores 중 하나에 스케줄링되어야 합니다. 만약 4개의 논리적 코어를 가지고 있을때 5개의 워커가 있다면 이 워커들 중 하나는 작동하지 않게 됩니다. 이는 해당 워커에 대한 메모리와 스케줄링에 대한 오버헤드가 발생하면서도 어떤 결과도 얻지 못 하는 결과로 이어집니다.

I/O-intensive 작업에는 외부 서비스 제공자(DNS, 파일 시스템 등)에게 요청하는 것과 이에 대한 응답을 기다리는 것까지 포함됩니다. I/O-intensive 작업을 하는 워커가 응답을 기다리는 동안에 해당 워커는 아무것도 할게 없으므로 OS에 의해 스케줄링에서 제거되어버리고 다른 워커에게 해당 요청을 넘기게 됩니다. 그러므로 I/O-intensive 작업은 관련된 스레드가 작동 중이 아니더라도 진행 중인 상태여야 합니다. 데이터베이스나 파일 시스템과 같은 외부 서비스 제공자들은 많은 수의 대기 중인 요청을 동시에 처리하는 것에 매우 특화되어 왔습니다. 예를 들어 파일 시스템은 머지하기 위해 충돌적으로 업데이트하고 파일을 읽으려고 하는 대기 중인 큰 크기의 쓰기와 읽기 요청을 최적화된 순서로 처리할 수 있습니다. (예시 슬라이드)

만약 단 하나의 워커 풀(예를 들어 Node.js 워커 풀)에만 의존한다면 CPU 의존적인 작업과 I/O 의존적인 작업 간의 서로 다른 특징으로 인해 애플리케이션 성능의 손해가 생길 수 있습니다.

이러한 이유로 당신은 분리된 계산 특화 워커 풀을 유지하는 것이 좋을 수 있습니다.

오프로딩: 결론

임의적으로 긴 배열의 요소를 순회해야하는 것과 같은 간단한 작업을 하는 경우에는 파티셔닝이 좋은 옵션이 될 수 있습니다. 계산이 이보다 더 복잡해진다면 오프로딩이 더 좋은 전략입니다. 이벤트 루프와 워커 풀 간에 직렬화된 객체를 주고받는 데에 발생하는 오버헤드와 같은 커뮤니케이션 비용은 다수의 코어를 사용하는 이득으로 상쇄될 수 있기 때문입니다.

그러나 서버가 복잡한 계산에 매우 의존적이라면 Node.js를 사용하는 것 자체가 좋은 선택인지에 대해 다시 생각해보는 것이 좋을 수도 있습니다. Node.js는 I/O 의존적인 작업에 특화되어 있기에 값비싼 계산에 대해서는 최선의 선택이 아닐 수도 있습니다.

오프로딩 전략을 선택하신다면 워커 풀을 막지마세요 섹션을 참고해주세요.

워커 풀을 막지마세요

Node.js는 k개의 워커로 구성된 워커 풀을 가지고 있습니다. 만약 위에서 이야기한 오프로딩 전략을 사용 중이라면 분리된 계산 특화 워커 풀을 가지고 있을 것이고 같은 방법이 적용됩니다. 어떤 경우든간에, k의 수가 동시에 처리해야 하는 클라이언트의 수보다 훨씬 적다고 가정해봅시다. Node.js의 확장성의 핵심인 "다수의 클라이언트를 처리하는 하나의 스레드" 철학은 여기에서도 지켜집니다.

위에서 이야기한 것처럼, 각 워커는 워커 풀 큐의 다음 작업에 도달하기 위해서는 현재 작업을 먼저 완료해야 합니다.

이번 경우에는 클라이언트 요청을 처리해야하는 작업의 비용간에 차이가 있다고 가정합니다. 어떤 작업(짧거나 캐싱된 파일을 읽거나 적은 수의 임의의 바이트를 생성하는 경우)은 빠르게 처리될 것이고 다른 작업들(크거나 캐싱되지 않은 파일을 읽거나 더 많은 임의의 바이트를 생성하는 경우)은 더 오래 걸릴 것입니다. 목표가 작업 소요 시간간의 편차를 최소화하는 것이라면 작업 파티셔닝을 이용하여 이를 달성할 수 있습니다.

작업 소요 시간간의 편차를 최소화하기

한 워커의 현재 작업이 다른 작업들보다 훨씬 더 비싸다면 해당 워커는 다른 대기 중인 작업을 처리할 수 없을 것입니다. 다른 말로 하면, 상대적으로 긴 작업은 해당 작업이 끝날 때까지 워커 풀의 전체 크기를 줄이게 됩니다. 이것은 바람직하지 않은 상황인데, 어느 정도까지는 워커 풀에 워커가 더 많이 있을수록 워커 풀의 처리량(작업/초)이 증가하므로 워커 풀에 워커가 더 많다는 것은 서버 처리량(클라이언트 요청/초)의 증가로 이어집니다. 한 클라이언트의 상대적으로 비싼 작업은 워커 풀의 처리량을 감소시키게 되고 이는 서버의 처리량 감소로 이어지게 됩니다.

이를 피하기 위해선 워커 풀에 위임하는 작업 길이간의 편차를 최소화하는 것이 좋습니다. 외부 시스템 접근에 대한 I/O 요청(DB, 파일 시스템 등)은 블랙 박스로 다뤄지는 것이 적절하므로 당신은 이러한 I/O 요청의 상대적인 비용에 대해 인지하고 있어야 하며 특별히 길 것이라고 예상되는 요청을 피할 수 있어야 합니다.

밑에서는 작업 시간에 있어 가능한 예시 두 가지를 설명합니다.

변형된 예시: 긴 시간이 소요되는 파일 시스템 읽기

어떤 클라이언트 요청을 처리하기 위해 서버에서는 반드시 파일을 정해진 순서대로 읽어야 한다고 가정해봅시다. Node.js의 File system API를 훑어본 후에 당신은 간단하게 하기 위해 fs.readFile()를 선택했다고 합시다. 그러나 fs.readFile()는 (현재 기준으로) 분할되어 있지 않아 하나의 fs.read() 작업을 통해 전체 파일을 순회하게 됩니다. 어떤 유저의 작업에는 짧은 파일을 읽고 다른 유저에서는 긴 파일을 읽는다면 fs.readFile()는 작업 길이간의 확연한 차이로 이어지게 되며 이는 곧 워커 풀의 처리량 감소로 이어지게 됩니다.

최악의 경우에는 공격자가 서버를 임의의 파일을 읽게 할 수 있습니다(경로 순회 취약점). 만약 서버가 리눅스라면 공격자는 극심하게 느린 파일(/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 ecosystem에서 많은 도움을 받을 것입니다.

그러나 이러한 모듈의 대부분은 서드 파티 개발자에 의해 작성되었고 일반적으로 어떠한 보장도 되지 않은체 배포된다는 점을 기억해야 합니다. npm 모듈을 사용하는 개발자는 2가지에 대해 생각해야 하지만 이 중 후자는 많은 경우 고려되지 않습니다.

  1. API대로 잘 동작하는가?
  2. API가 이벤트 루프나 워커 루프를 막을 수 있는가? 많은 모듈은 API의 비용에 대해 말하지 않으며 이는 커뮤니티 전체의 손해로 이어집니다.

문자열 조작같은 간단한 API의 경우 비용을 측정하는 것은 어렵지 않습니다. 그러나 대부분의 경우 해당 API가 얼마나 비용이 드는지에 대해 분명히 작성하지 않습니다.

당신이 값비싼 API를 호출하려고 한다면 비용을 더블 체크하세요. 해당 API의 개발자에게 비용에 대해 문서화를 요청하거나 직접 소스 코드를 분석해서 비용에 대한 문서화를 PR할 수도 있습니다.

API가 비동기적으로 처리된다 하더라도 그것이 워커에서 혹은 이벤트 루프의 각 분할간에 얼마나 소요될지는 알 수 없다는 것을 기억하세요. 예를 들어 위의 asyncAvg 예시처럼, 각 헬퍼 함수의 호출 비용의 합은 한 번이 아닌 아닌 전체의 절반이었습니다. 이 함수는 여전히 비동기적이지만 각 분할에 대한 비용은 O(1)이 아닌 O(n)이므로 임의의 n을 사용하는 것에 대해 더 조심하게 됩니다.

결론

Node.js는 이벤트 루프와 k 워커 2가지 종류의 스레드를 가지고 있습니다. 이벤트 루프는 자바스크립트 콜백과 논브로킹 I/O에 대한 책임이 있으며 워커는 C++ 코드로 해당 작업을 실행하여 블로킹 I/O와 CPU-intensive 작업을 포함하는 비동기 요청을 완료합니다. 두 종류의 스레드 모두 한 번에 한 작업보다 많은 일을 하지 않습니다. 만약 콜백이나 작업이 긴 시간이 소요된다면 해당 스레드는 막히게 됩니다. 애플리케이션이 막히는 콜백이나 작업을 생성한다면 이는 최소한 처리량(클라이언트/초)의 감소로 이어지고 최악의 경우 완전한 서비스 거부 상태에 빠지게 될 수 있습니다.

높은 처리량을 내면서 Dos에 대해 보다 안전한 웹 서버를 작성하기 위해서는 양호한 인풋과 악성 인풋을 구분할줄 알아야 하고 이벤트 루프나 워커가 막히지 않게 해야 합니다.

맨 위로