안녕하세요 깍돌이 입니다. Timer Phase 에서 Worker ( 테스트 자동화 코드 ) 를 실행하면서

 

느꼈던 문제들 그리고 Node.js의 Event Loop 에 있는 Timer Phase 에 대한 생각 그리고 위의 Worker들을

 

Worker Thread Pool 로 해결이 될지 된다면 어떤방법인지에 대해 생각한 내용들입니다.

 

Node.js Timer Phase 

네 타이머 페이즈에서 해결할수 있었습니다. Event Loop 가 계속 돌면서 타이머 페이즈에 는 계속 등록된 함수들이 있게 되고 ( Worker ) Event Loop 가 돌면서 계속 실행됩니다.  하지만 테스트 플랫폼의 초기 단계이기도 하고 API 테스트의 구조는 비동기 형태로 단순 Request , Response 에 대한 결과를 검증 하기 때문에 큰 문제는 되지않았지만 ( 이것도 케이스가 수천건이 넘어가면 이슈가 생길듯 ) 

 

시나리오의 경우 UI, API 가 있고 현재는 API 를 우선하고있지만서도 스텝과 스텝 사이의 Polling 하는 구조가 많이 있다보니 Event loop 에서 Timer Phase가 점점 무거워집니다. 그렇기 때문에 결국 Timer Phase 도 뭔가 좋은 방법은 아니지 않을까? 라는 생각을 하게 되었습니다.  ( 이번 작업은 UI 를 하지 않았지만 UI 의 경우 Child_Process를 통하여 직접적으로 하나의 프로세스를 더 띄우는 방법을 생각 하고 있었습니다. ) 

 

그리고 테스트 해보고 싶었던 마음에 노드 관련 홈페이지를 기웃 기웃하다 보니 Node.js 공식 홈페이지에 아래와같은 말이 있었습니다. 

 

그리고 앞선 Worker Pool 에 대한 이야기 중 간과해야 될게 있습니다. 

 

현재 작성하고있는 테스트 플랫폼의 Test Case들은 CPU I/O 가 많지 않습니다.(그렇기 때문에 무조건 워커 스레드 풀을 써야하는게 아닌 Timer Phase 에서 처리되는 워커들이 워커 스레드 풀에선 좀 더 나을까?라는 내용에 대한 확인 을 하고 싶기 때문입니다. )   케이스 스텝 사이에 대기(Polling) 하는 텀이 길기 때문에 하나의 Task(작업) 이 일어날때 무거운 작업은 아니라는 점을 중간에 한번 적어보았습니다.  TC의 예시

 

A시나리오

 

               -> A작업이 완료가 됐는지 최대 10분간 확인 ( Polling) -> 10분이 지나도 완료가 안됐을 경우 -> 실패

A 작업 

            ->  10초마다 확인하여 10분(Test Timeout) 전에 작업이 완료되면 다시 다음 B작업 실행 

 

=> 위와같은 형태로 A -> B -> C -> D -> E -> F -> G -> H -> I -> J - > K -> L (실제 케이스는 이거보다 더많습니다. ) 

로 진행됩니다. 하나의 시나리오에 테스트 시간은 약 30분 정도가 됩니다.  Polling하는 시간이 많기 때문에 CPU I/O를 많이 쓰지 않는 케이스에 대한 자동화라는 점을 기억해 주시기 바랍니다.

Don't Block the Event Loop (or the Worker Pool)

https://nodejs.org/en/docs/guides/dont-block-the-event-loop/

이부분에서 핵심내용으로 생각되는 부분들에 대해서 간략하게 몇가지 적어 보자면  

    Node.js는 EventLoop ( Initialization and callbacks ) 에서  JavaScript 코드를 실행하고 파일 I/O 같은 값비싼 작업을 처리하기 위해서 작업자 풀 ( Worker Thread Pool ) 을 제공한다고 합니다.  

 

두가지 타입의 쓰레드가 존재 

첫번째는 Event Loop ( Main Loop, Main Thread , event Thread ) - 전 포스팅에 있던 노드 이벤트 루프

두번째는 Worker Pool ( Thread Pool ) 

Node.js의 경우 Event-Driven 구조이며 관리를 위한 Event Loop 와 비싼 작업을 하는 Worker Pool 이 있다.

 

그 밑에는 O(n) 의 복잡도를 가지는 콜백에 대해서 작은 n이라면 무엇보다 빠르지만 점점 높아질수록 문제가 생김에 대한 내용이 있고 Node.js는 V8 엔진을 사용하기 때문에 일반적인 조작은 매우고속이며 JSON 연산과 regexps에서는 예외가 될수 있음을 설명 합니다.  regexp는 입력 스트링에 따라서 o(2n) 이 가능하기 때문에 이 경우는 Event Loop를 차단합니다. 

그래서 regexp를 이용한 redos에 대한 샘플이 있지만 ( 테스트 자동화에서는 정규식을 많이 사용하지도 않으며.. 내부 백오피스 에서 사용하기 때문에 일단 제외 해도 될거 같습니다. ) 

결론은 regexp 를 조심해서 써야 한다 이런내용

 

Node.js의 Event Loop를 차단하는 코어 모듈 

Encryption

Compression

File System

Child Process ( 이건 추후에 쓸 예정인데 유심히 봐야될듯 ) 

 

위의 API 들은 상당한 계산을 잠재적으로 사용하기 때문에 많은 비용이 발생 하기 때문에 서버를 구성한다면 아래의 API 들을 사용하면 안된다. ( express를 예로 들면 express Router에서 쓰지 말라는 의미입니다. ) 

  • 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

API 자동화에서는 CryptoJS를 사용하는데 확인해봐야 될거 같습니다.  파일시스템의 NFS는 NAS같은 류를 이야기 하는거 같습니다.  대부분 동기적인 작업 및 CPU I/O가 순간적으로 많이 발생되는 API 들이라고 생각 됩니다.

 

해당 밑에는JSON DOS ( JSON 에 대한 이야기니 넘어 가겠습니다. ) 

Complex calculations without blocking the Event Loop

이제 뭔가 나왔습니다. 이벤트 루프를 막지 않고 복잡한 계산 에 대해서 파티셔닝과 오프로드 2가지 에 대해서 설명합니다.  이부분은 간략하게 적고 직접 테스트하면서 결과를 적어 보겠습니다.

 

첫번째

C++ 애드온을 개발하여 내장된 Node.js Worker Pool 을 직접 쓴다가 있습니다. 

node-webworekr-thread 를 이용하여 Node.js Worker Pool 에 액세스 하는 js를 사용합니다.

 

두번째

직접 Node.js I/O 테마 워커 풀 대신 계산 전용 워커 풀을 직접 만드는 방법 이다. 방법으로는 Child_Process or Cluster 를 이용한다. 

 

사실 Worker Thread 에 대한 내용을 읽어 보면 CPU I/O 에 대한 이야기가 많다.  왜냐면 해당 작업이 무거워지면 Event Loop 를 막는 경우가 생길거고 이로 인해서 Node.js 전체가 느려지는 현상이 발생하기 때문이다.  서버를 이용한다면 대용량 트래픽을 받을 경우 문제가 생길수 있다 그렇기 때문에 이와같은 형태의 설명들이 많았고 아마 지금 글을 쓰고 있는 저처럼 모든 시나리오의 스텝의 사이가 CPU I/O 가 아닌 대기(Polling) 로 이루어진 케이스가 있을 거라곤 생각을 못했을 것이다.

 

지금 포스팅을 하면서도 트리거 시스템을 이렇게 구성하는게 과연 맞는 것인가? 에 대한 의문이 들지만 나와 유사한 작업을 한 사람도 볼수 없었고 블로그 포스팅도 없었기 때문에 그냥 부딪혀 보면서 포스팅 하고 운영해보다가 더 좋은 개선될 내용이 있다면 다시 포스팅해서 Node.js로 완벽한 트리거 시스템을 구현해 보고자 한다.

 

서론이 쓸데 없이 길었던거 같긴한데 포스팅을 하게되면 이해없이는 쓸수 없기 때문에 서론을 쓰게되면 많은 이해가 된 상태로 작업을 할수 있게 됩니다.

 

해당 테스트는 많으면 2포스팅 적으면 1포스팅으로 하려고 하며 트리거 시스템의 기본 베이스 서버는 Express.js를 쓰기 때문에 Express.js를 가지고 작성하며 Express.js의 기본적인 내용은 다 알고 있다고 생각하고 자잘한 설명은 제외하겠습니다. ( Express.js는 블로그 검색하면 내용이 워낙 많으니까요 ) 

 

https://www.nearform.com/blog/learning-to-swim-with-piscina-the-node-js-worker-pool/

 

 

Express.js

const express = require('express')
const app = express()
const port = 8880

const jsWorker = require('./express_worker');

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.get('/latency',(req,res)=>{

  let st_time = performance.now();
  let ed_time = performance.now();

  let total_time = ed_time-st_time;
  res.send(`latency Time - ${total_time}`);
})

app.get('/express-polling-worker',(req,res)=>{

  let uuid = createStepUUID();
  setTimeout(async()=>{
    try {
      jsWorker(uuid);
    } catch (error) {
      console.error(error);
    }
  },0);
  res.send('Start Polling Worker');
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})


function createStepUUID() {
  let d = new Date().getTime();
  if (
    typeof performance !== 'undefined' &&
    typeof performance.now === 'function'
  ) {
    d += performance.now(); // use high -precision timer if available
  }
  return '깍돌이-exps-xxxx-4xxx-yxxx'.replace(/[xy]/g, function(c) {
    var r = (d + Math.random() * 16) % 16 | 0;
    d = Math.floor(d / 16);
    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
  })
 }

 

Express_Worker.js

const { setTimeout } = require('timers/promises')

module.exports = (uuid)=>{
  return new Promise(async(resolve,reject)=>{
    try {
      console.warn(`Start UUID ${uuid}`);
      await setTimeout(1000000);
      console.warn(`Close UUID ${uuid}`);
      resolve(true);

    } catch (error) {
      reject('ERROR');
    }
  })
}

 

기본 베이스 코드입니다.

latency 는 해당 컨트롤러에서 코드를 읽고 res.send까지의 시간을 던져 줍니다.

express-polling-worker 는 Timer Phase 에 Timer 를 계속 추가합니다. 위의 코드 베이스로 테스트하겠습니다. 

시간초는 해당 컨트롤러에서 작업이 없기 때문에 보기 편하게 

 

let total_time = ((ed_time-st_time)*1000).toFixed(4);

으로 살짝 보정을 하겠습니다.  ( 사실 express 내에서 하는건 의미가없을 수도 있습니다. node.js의 블록킹이 걸리면 아래 GIF와 같이 코드가 실행되는시간이 오래 걸리는게 아니라 그냥 대기 ( 로딩 ) 시간이 생기기 때문입니다. )

express 내에서 찍어본건 요청이 왔을때 서버 내에서 시간을 한번 체크해보려고 했습니다.

 

Timer Phase 1만개 케이스 Polling

눈치 채셨을 수도 있지만 순식간에 1만개의 함수를 Timer Phase 에 넣었지만 넣고 있던 순간에 잠깐 느려질뿐 넣고 나서는 크게 느리지 않는걸 볼수 있습니다.  해당 이유는 워커에서 실제로 폴링을 한다면  상태 체크 -> 다음함수 또는 다시 상태 체크 의 경우를 해야되는데 하고있지 않기 때문입니다. 해당 코드를 추가하겠습니다.

 

module.exports = (uuid)=>{
  return new Promise(async(resolve,reject)=>{
    try {
      console.warn(`Start UUID ${uuid}`);

      let retry = 0 ;
      let loopCondition = 240;

      // 2400초 = 40분
      
      let closeCondition = true;
      while(loopCondition>=retry && closeCondition){

        let status = await getStatus(loopCondition);

        console.warn(`테스트 상태 체크 ${uuid}  = ${status}`);
        // true 면 반복문 종료 
        if(status==='Y'){
          closeCondition = false;
        }
        await setTimeout(5000);
        retry++;
      }

      console.warn(`Close UUID ${uuid}`);

      resolve(true);
    } catch (error) {
      reject('ERROR');
    }
  })
}

// 30분에 테스트 종료 
const getStatus = (loopCondition)=>{
  return new Promise(async(resolve,reject)=>{
    try {
      // 상태를 가져옵니다.
      let retrunValue='N';

      if(loopCondition===180){
        retrunValue='Y';
      }
      resolve(retrunValue);
    } catch (error) {
      reject(error);      
    }
  })
}

 

Timer Phase 1만개 케이스 Polling

서버에서 계속 동작을 하는 케이스로 추가하였습니다. 워커가 돌기 전 

 

폴링하면서 상태를 체크하는 코드에서 CPU가 과도하게 올라갑니다.

 

1만개의 워커를 사용시 최대 CPU 47%까지 사용하며 레이턴시 ( 서버내에서 실행할 경우 ) 는 큰 변화가 없었습니다.

아마 측정을 FE단에서 요청하고 돌려받는 시간을 재야 할거 같네요 마지막으로 위에 코드에 수정을 하나 하겠습니다.

 

서버쪽과 클라 쪽 입니다.

 

일반적으로는 FE에서는 9의 차이가 나타나지만

 

 

스레드 실행 후 1734 값이 나오는걸 알수 있습니다. 

=> 순간적으로 Timer Phase 에 꽉차기 때문에 병목 현상이 발생한다는 뜻입니다.   ( Event Loop 가 많은 양의 Fn을 Timer Phase 에 등록 하면서 생기는 현상 ) 

1. 케이스가 추가 될경우 병목 현상이 발생할수 있음

2. 추가된 케이스가 계속 수행되면서 CPU 사용량이 매우 높아짐 

 

이제 여기서 Node.js에서 말하는 Child_Process와 Cluster를 이용하여 처리해보려고합니다. 가장 유명한건 Cluster모듈이고 ( pm2 에서 많이 쓰고잇죠 )  해당 Cluster 모듈로 구현된 Node.js Piscina 라는 모듈을 사용해보려고 합니다.

 

Node.js with Piscina (feat. By James Snell

우선 관련 링크입니다.

https://www.nearform.com/blog/learning-to-swim-with-piscina-the-node-js-worker-pool/

해당 링크의 작성자를 유심히 봐야 하는데요 By James Snell 

 

https://github.com/piscinajs/piscina

해당 레포지토리의 메인 컨테이너가 쓴글인걸 알수 있습니다. 그리고 프로젝트들 보시면 알겠지만 Node.js의 개발자이기도 합니다.    해당 포스팅을 이용하여서 위의 테스트한 케이스들에 대해서 Piscina를 적용해보고 결과를 적어 보겠습니다.

해당 글이 2020년도 글이라 현재 많이 바뀐 점이 있습니다. 

runTask X  => run

 

단순하게 바로 테스트 해보겠습니다.

 

const piscina = new Piscina({
  filename: path.resolve(__dirname, 'express_worker')
});


app.get('/express-piscina-worker',async (req,res)=>{


  let loop=10000;
  for(let i=0;i<loop;i++){
      let uuid = createStepUUID();
      console.warn('ppap test - ' + i);
      const result = piscina.run(uuid+'-PPAP');
  }


  res.send('Piscina Worker Start ');
})

Timer Phase 1만개 케이스 Polling ( Node.js with Piscina )

위의 GIF ( without piscina ) 의 비교를 확실히 해보세요 !  같은 코드의 1만건의 테스트 런이 발생했을 때 실제 나의 PC의 CPU 와 웹 FE에서 latency 가 어떤지 비교하면 좋을거 같습니다.

 

1. 1만 TestCase  JS Timer Phase  Worker 실행 

1. 웹화면 지연

setTimer를 이용한 Timer Phase 에 TestCase Worker 를 1만개를 밀어 넣었을 경우 웹화면서 1862 ms 의 지연이 생깁니다. 

2. CPU 사용 률

케이스들의 폴링하면서 상태를 체크할 경우 최대 31% 까지 발생합니다.

* 참고로 제가 사용하는 cpu 입니다.

AMD Ryzen 7 5800X 8-Core Processor 3.80 GHz

 

 

 

1. 1만 TestCase  JS Worker Thread Pool 실행

 

1. 웹화면 지연

Worker Thread Pool 에 1만개의 TestCase를 밀어 넣었을 경우 800ms 가 나옵니다. 

Event Loop을 Blocking 하던 Timer Phase에서 2배의 효율이 발생합니다.

 

2. CPU 사용 률 

보시면 12%를 절대 넘지 않습니다.  

 

뭔가 결과만 보면 Piscina 에서 Worker Thread Pool 을 사용하면서 지연도 2배의 효율이고 CPU 도 오버해서 쓰지 않는거 같습니다.

 

하지만 자세히보면 다른점이 있습니다.  CPU 사용률 체크시 보면 Piscina의 경우 

 

24개의 워커만 실행되는걸 알수 있습니다.  1만대를 실행하였지만 실제로는 워커의 개수만큼인 24개의 워커만 실행되면서 진행한다는 점입니다. Piscina의 구조를 보면 알수 있습니다.

아마 Worker Pool 을 PC의 코어 수 * 1.5 로 만들어 놓고 실행합니다.

 

 

JS timerPhase로 밀어 넣는 경우는 서버의 상태등 아무것도 고려하지않고 밀어 넣기 때문에

 

10000개의 케이스를 전부 Timer Phase에 밀어 넣고 Event Loop 가 돌다가 다시 Timer Phase에 왔을때 실행이 되기 때문에 모든 케이스가 다 실행되며 CPU의 과부하 까지 걸리게 됩니다.

 

그럼 단순하게 여기서 피시나는 24개 뿐 ( Worker Thread )  -> 이것도 제 PC의 CPU 가 16코어이기 때문에 *1.5하여 24개의 스레드가 돌게 되는 겁니다.

 

만약에 스레드가 1초마다 실행하고 종료되는 케이스라면 적절하게 모든 케이스가 추가될건지 확인해보면

 

1만개의 TestCase가 추가된 후 적절하게 테스트 종료 및 대기하던 TestCase 가 다시 시작이 되는 걸 볼수 있습니다. 그런데 이렇게 24개의 워커만 사용해도 10%이상을 쓰지 않는데 좀더 많이 사용할수 없을까? 라는 고민으로 다음 포스팅은 위에서 piscina로 24개 Worker만 사용하는 PC 상태에서를 Core * 1.5배 만큼의 워커를 쓰지 않고 더 쓰는 방법이 있는지와 Piscina의 옵션 하나하나를 설명 하는 포스팅을 하도록 하겠습니다.

 

 

 

 

+ Recent posts