Nodejs 에서 고비용/대용량 연산하기 (성능편)

김태영·2020년 9월 30일
41
post-thumbnail

참조 링크: https://nodejs.org/uk/docs/guides/dont-block-the-event-loop/

Node.js 는 일반적으로 Single Thread (osx/linux 환경 기준으로, ps -ef 로 조회했을 때, 한개의 processId) 로 동작합니다.

이는 비동기 작업을 처리할때, CPU를 full-load 로 사용하게 되는 작업이 진행중일때는,
다른 요청을 막는(block) 현상이 발생됩니다.

이는 성능에 중대한 위협입니다.

이를 확인하기 위한 기준으로, 처리량(throughput) 이 있습니다.

처리량 = 요청 갯수 / 초

즉, 1초 이상 blocking 이 발생할 경우, throughput 이 1 이하로 떨어지게 되며,

사용자 입장에서 - 너무 느림, 또는 동작하지 않음 의 사용자 경험을 갖게 됩니다.

즉, DoS 공격을 받은것 같은 상태가 되는 겁니다.☠️

😱 우리가 알아야 할 놀랍고 중요한 사실

우리가 개발을 하면서 알아야 할 중요한 문제가 있습니다.

한개의 Promise가 결과값을 resolve 하기 전 까지는 Promise 자체가 프로세스를 점유합니다!

이는 상당히 심각한 문제로, Promise 에서 계산 집약적인 작업, 또는 오래걸리는 작업이 실행되는 동안은, 다른 요청을 막게 되므로, 처리량을 극단적으로 떨어트립니다.

즉, 한 개의 Promise를 수행하는데, 대략, 0.3초가 걸린다면!
요청을 3개만 받아도, 처리량이 1 이하로 떨어지는 개복치 프로그램을 작성하고 있는 것 일 수 있습니다.

⚠️ 속도가 느려지는 것을 고려해야 하는 함수

JSON.parse

JSON.stringify

우리는 obj2 ^ 21 크기 의 객체 를 만들고 문자열에서 JSON.stringify실행 indexOf한 다음 JSON.parse를 실행합니다. JSON.stringify'D 문자열 50메가바이트이다. 객체를 문자열 화하는 데 0.7 초, 50MB 문자열의 indexOf에 0.03 초, 문자열을 구문 분석하는 데 1.3 초가 걸립니다.

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

  • 스트림 API가있는 JSONStream

  • Big-Friendly JSON

    . 아래에 요약 된 이벤트 루프 기반 패러다임을 사용하여 표준 API의 비동기 버전뿐만 아니라 스트림 API가 있습니다.

🚫 사용하지 않아야 하는 동기 API

  • 암호화 :

    • crypto.randomBytes

      (동기 버전)

    • crypto.randomFillSync

    • crypto.pbkdf2Sync

    • 또한 암호화 및 암호 해독 루틴에 큰 입력을 제공 할 때주의해야합니다.

  • 압축:

    • zlib.inflateSync
    • zlib.deflateSync
  • 파일 시스템:

    • 동기 파일 시스템 API를 사용하지 마십시오. 예를 들어, 액세스하는 파일이 NFS 와 같은 분산 파일 시스템

      에있는 경우 액세스 시간이 크게 다를 수 있습니다.

  • 아동 과정 :

    • child_process.spawnSync
    • child_process.execSync
    • child_process.execFileSync

문제에 대한 시뮬레이션, 해결책 찾기

문제 상황 <LOOP 자체가 커서 오래걸리는 연산>

const n = 10000000
let sum = 0

let eventLoopCount = 0
setInterval(() => {
  eventLoopCount++
}, 10)

console.time('calc')
for (let i = 0; i < n; i++) { sum += i }
const avg = sum / (n - 1)
console.timeEnd('calc')

console.log('avg: ' + avg, 'eventLoopCount', eventLoopCount)
 node sniffets/normalLoop.js
calc: 20.160ms
avg: 5000000 eventLoopCount 0

아주 간단한 연산의 경우, nodejs가 최적화를 진행하므로, 성능 차이가 비교적 크게 나타날 수 있습니다.

🚬 해결책: 파티셔닝

갯수가 늘어나면서 오래걸리는 작업의 경우, 중간중간 다른 작업이 진행 될 수 있도록 허용한다!

이는, Blocking을 막기 위해, 루프를 돌면서 setImmediate 를 통해 우선순위를 뒤로 미루는 방식으로 작동합니다.

const n = 1000000

let eventLoopCount = 0
setInterval(() => {
  eventLoopCount++
}, 10)

function asyncAvg (n, avgCB) {
  // Save ongoing sum in JS closure.
  let sum = 0
  function help (i, cb) {
    sum += i
    if (i === n) {
      cb(sum)
      return
    }

    // "Asynchronous recursion".
    // Schedule next operation asynchronously.
    setImmediate(help.bind(null, i + 1, cb))
  }

  // Start the helper, with CB to call avgCB.
  help(1, function (sum) {
    const avg = sum / n
    avgCB(avg)
  })
}
console.time('calc')
asyncAvg(n, function (avg) {
  console.timeEnd('calc')
  console.log('avg: ' + avg, 'eventLoopCount', eventLoopCount)
})
 node sniffets/partitionedLoop.js
calc: 165139.864ms
avg: 5000000 eventLoopCount 16481

이 예제에서는 성능차이가 극단적으로 나타납니다. 20ms vs 165139ms

무려, 8256배 느립니다.😹

다만, 10ms 에 한번씩 외부 이벤트 루프(setInterval)이 동작하는 것을 확인할 수 있습니다.

대략 16,514 회 실행되었어야 하는데, 16481 회 실행되었습니다. 기대한것에 비해, 0.2% 정도의 오차밖에 나지 않습니다.

이 방식도 어디엔가 쓸모가 있겠죠.

이걸 실제로 적용하기엔 성능이 너무 슬픕니다.

다른 해결책을 찾아 보도록 하겠습니다.

🥉해결책: 오프로드(offload) - Worker Pool 이용

하위 프로세스(child process)를 이용하는 방식입니다. 하위 프로세스를 만들기 위한 overload 가 좀 있는 편입니다.

직접 구현할 수도 있지만 손이 많이 갑니다. 이미 잘 작성된 외부 라이브러리를 이용해 보겠습니다.

$ npm i workerpool
const workerpool = require('workerpool')
const pool = workerpool.pool()

let eventLoopCount = 0
setInterval(() => {
  eventLoopCount++
}, 10)

function calcAvg (n) {
  let sum = 0
  for (let i = 0; i < n; i++) { sum += i }
  const avg = sum / (n - 1)
  return avg
}

console.time('calc')
pool.exec(calcAvg, [100000000]).then(avg => {
  console.timeEnd('calc')
  console.log('avg: ' + avg, 'eventLoopCount', eventLoopCount)
})
node sniffets/workerPoolLoop.js
calc: 249.350ms
avg: 50000000 eventLoopCount 22

나쁘지 않네요!

속도는 대략 13배정도 느려 졌지만, 여전히 300ms 이내로 참아 줄만한 속도입니다.

또한 외부 이벤트도 거의 blocking 되지 않고 있습니다.

24번 정도 실행 되길 기대했는데, 22번 실행됩니다.

🥈 해결책: 오프로드(offload) - Thread Pool 이용

연산 자체가 복잡한 경우, 여러 코어를 사용한다!

Node.js 10 버전에서 실험 기능으로 도입되었던 worker_threads 가 Node.js 12 버전부터는 stable 한 상태로 변경되어, 사용을 고려해 볼 수 있습니다.

const {
  Worker, isMainThread, parentPort, workerData
} = require('worker_threads')

let eventLoopCount = 0
setInterval(() => {
  eventLoopCount++
}, 10)

if (isMainThread) {
  const calcAvg = function calcAvg (script) {
    return new Promise((resolve, reject) => {
      const worker = new Worker(__filename, {
        workerData: script
      })
      worker.on('message', resolve)
      worker.on('error', reject)
      worker.on('exit', (code) => {
        if (code !== 0) { reject(new Error(`Worker stopped with exit code ${code}`)) }
      })
    })
  }
  module.exports = calcAvg
  const n = 10000000
  console.time('calc')

  calcAvg(n).then(avg => {
    console.timeEnd('calc')
    console.log('avg: ' + avg, 'eventLoopCount', eventLoopCount)
  })
} else {
  const n = workerData
  let sum = 0
  for (let i = 0; i < n; i++) { sum += i }
  const avg = sum / (n - 1)

  parentPort.postMessage(avg)
}

Node.js 10 버전에서 사용해보기 위해, —experimental-worker flag를 이용해봅니다.

 node --experimental-worker sniffets/workerLoop.js
calc: 80.591ms
avg: 5000000 eventLoopCount 7

꽤 놀라운 결과입니다.

계산 시간은 그렇게 많이 (그래도 4배 수준이지만..) 늘어나지 않았고,

이벤트 루프도 정상적으로 실행되었습니다.

숫자가 더 커지면 어떻게 될까요?

node sniffets/normalLoop.js                      
calc: 130.112ms
avg: 50000000 eventLoopCount 0
node --experimental-worker sniffets/workerLoop.js
calc: 189.253ms
avg: 50000000 eventLoopCount 15

훌륭합니다.

🥇 똑똑한 프로그램 작성

오래 걸릴 일을 만들지 않는것

const n = 10000000
let sum = 0

let eventLoopCount = 0
setInterval(() => {
  eventLoopCount++
}, 10)

console.time('calc')
const N = n-1
const sum = ((1 + N) * N) / 2 
const avg = sum / N
console.timeEnd('calc')

console.log('avg: ' + avg, 'eventLoopCount', eventLoopCount)
node sniffets/smartLoop.js
calc: 0.215ms
avg: 5000000 eventLoopCount 0

역시 가장 똑똑한 방법은, 오래 걸릴 일을 만들지 않는것 입니다.

위 알고리즘은, 아주 유명한 가우스 알고리즘 입니다.


저희는 위워크 홍대점에서 일하고 있습니다. 포지션 무관, 합류에 관심이 있으시다면 지원서를 제출해 주세요. (경력/학력/성별 무관)

profile
두손컴퍼니 개발팀장

1개의 댓글

comment-user-thumbnail
2020년 11월 10일

와우 👏👏

답글 달기