sbyeol3/articles

[번역] await이 코드를 느리게 하는 방식

Opened this issue · 0 comments

원문 : How return await can slow down your code

리턴하기 전에 프로미스를 await하는 것은 여러분의 코드를 느리게 만듭니다.

요약: ESLint no-return-await rule을 사용하세요

큰 스케일의 어플리케이션 개발의 주요 패러타임은 고수준의 언어로 프로그래밍을 하는 것입니다. 고수준의 언어는 개발을 빠르게 하는 대신 런타임에서 속도가 느리다는 트레이드오프가 있습니다. 우리의 경우에서는 그 언어로 자바스크립트를 선택한 것이죠.

여러분은 아마 "진짜 성능이 중요했다면, Rust로 코드를 짰겠지" 라고 생각하실 수도 있지만 그렇게 자바스크립트가 느린 건 아닙니다. NodeJs 는 대부분의 상황에서 개발과 런타임 속도 충분히 빠릅니다. Techempower 벤치마크 결과 또한 NodeJs가 충분히 좋은 결과를 보여주는 것을 알려줍니다.

Web API같은 여러분의 코드를 1퍼센트라도 성능을 향상시킬 수 있다면, 초당 9천개의 요청을 처리할 경우 800개를 추가로 처리할 수 있다는 뜻이죠. 그리고 그건 오늘의 성능 으로 연결됩니다.

리턴하기 전에 프로미스를 await하기

다른 비동기 함수 호출을 감싸고 기다리도록 함수를 짜는 것은 비동기 자바스크립트 프로그래밍에서 아주 흔한 패턴입니다.

async function example() {
  const server = await createServer();
  const endpoints = await createEndpoints(server);

 return await startServer(server, endpoints);
}

보시다시피 위의 함수는 return 문 전에 await를 실행합니다. 그리고 그 부분이 까다롭게 만드는 부분입니다.
문제를 깊게 설명하기 전에 이것이 얼마나 느리게 만드는지 보여주는 간단한 벤치마크를 만들어보겠습니다.

benchmark

여기서 사용하는 모든 코드는 [arthur-place/the-cost-of-return-await](https://github.com/arthur-place/the-cost-of-return-await) 깃허브 저장소에 있습니다.

메인 함수인 work()setImmediate 함수가 호출되자마자 resolve되는 프로미스를 반환합니다. 그리고 다른 3개의 함수가 있는데 이 함수들은 동일한 결과를 만들지만 하나는 비동기이며 await을 사용합니다. 다른 하나는 그냥 비동기이고 마지막 하는 단순히 return 문만 사용합니다.

// function work(): Promise<any>;

async function doWait() {
  return await work();
}

async function dontWait() {
  return work();
}

function justReturn() {
  return work();
}

실제로는 자바스크립트가 더 이전의 EcamScript 버전으로 트랜스파일되는데 그 점이 우리가 하고자 하는 것입니다.

빌드 대상과 생성된 코드

간단한 테스트를 만들어 동일한 소스 파일을 ES3에서 ES2022까지 트랜스파일했습니다. 아래에 각 결과물들의 차이점이 있습니다.

Babel(Regenerator Runtime)
function doWait() {
  return _doWait.apply(this, arguments);
}

function _doWait() {
  _doWait = (0, _asyncToGenerator2['default'])(
    /*#__PURE__*/ _regenerator['default'].mark(function _callee() {
      return _regenerator['default'].wrap(function _callee$(_context) {
        while (1) {
          switch ((_context.prev = _context.next)) {
            case 0:
              _context.next = 2;
              return work();

            case 2:
              return _context.abrupt('return', _context.sent);

            case 3:
            case 'end':
              return _context.stop();
          }
        }
      }, _callee);
    })
  );
  return _doWait.apply(this, arguments);
}

function dontWait() {
  return _dontWait.apply(this, arguments);
}

function _dontWait() {
  _dontWait = (0, _asyncToGenerator2['default'])(
    /*#__PURE__*/ _regenerator['default'].mark(function _callee2() {
      return _regenerator['default'].wrap(function _callee2$(_context2) {
        while (1) {
          switch ((_context2.prev = _context2.next)) {
            case 0:
              return _context2.abrupt('return', work());

            case 1:
            case 'end':
              return _context2.stop();
          }
        }
      }, _callee2);
    })
  );
  return _dontWait.apply(this, arguments);
}
ES5 (Tslib Awaiter and Tslib Generator)
function doWait() {
  return __awaiter(this, void 0, void 0, function () {
    return __generator(this, function (_a) {
      switch (_a.label) {
        case 0:
          return [4 /*yield*/, work()];
        case 1:
          return [2 /*return*/, _a.sent()];
      }
    });
  });
}

function dontWait() {
  return __awaiter(this, void 0, void 0, function () {
    return __generator(this, function (_a) {
      return [2 /*return*/, work()];
    });
  });
}
ES6 (Tslib Awaiter)
function doWait() {
  return __awaiter(this, void 0, void 0, function* () {
    return yield work();
  });
}

function dontWait() {
  return __awaiter(this, void 0, void 0, function* () {
    return work();
  });
}
ES2017 (Native)
async function doWait() {
  return await work();
}
async function dontWait() {
  return work();
}

제 컴퓨터는 아래의 스펙사항을 가지고 있습니다.

  • Node v16.14.0
  • NVM 0.39.1
  • i5 9600K @5ghz OC
  • 32GB @ 32mmMhz
  • 1TB SSD PCIe 4.0

벤치마크 결과

image

아마 여러분들은 Es6가 왜 이렇게 느린지 궁금해하실 수도 있겠습니다. 저의 간단한 실험결과는 tslib generator 폴리필(__generator)은 Node 17 native function*yield보다 빠르다는 것을 보여줍니다. 그렇지만 이건 또 다른 얘기죠.

return await이 잘못된걸까?

Babel, ES5, ES6으로 트랜스파일된 것들은 각기 다른 성능을 보여줄 것이라고 예상됩니다. 각각 다른 폴리필과 ECMAScript 버전을 사용하기 때문에 명백한 일입니다.
간단히 말해서, await은 말 그대로 기다리고 있습니다. 평가된 결과를 리턴하기 전에 프로미스가 끝나기를 기다리는 것이죠.
또한 기다리고 있는 결과 자체는 프로미스와 같은 대상일 것이라고 예상하므로 await(promise)await(non-promise)는 동일한 동작을 하게 되는 겁니다.
NodeJS Event Loop는 이미 지정된 코드 라인이 현재의 iteration이 끝날 때 실행되도록 스케쥴링을 했습니다. 그런 다음에야 나머지를 실행하고 내부 프로미스나 일들에 대해 기다리게 됩니다.

위의 제 주장을 뒷받침할 수 있는 간단한 방법이 있습니다.

async function withAwait() {
  console.log(1);

  // This will make nodejs wait for the
  // end of the current loop. Because it
  // "expects" that a promise was given
  // in place of 0
  await 0;

  console.log(2);
}

async function withoutAwait() {
  console.log(3);
}

withAwait();
withoutAwait();
$ node example.js
#> 1
#> 3
#> 2

다시 벤치마크 얘기로 돌아가서, 앞서 얘기한 1%가 있습니다. 그 1%는 10,000ops/s에서 100이 될 수도 있고 심지어는 1,000,000 ops/s인 서버에서 10,000이 될 수도 있습니다. 단순히 await 키워드를 잘못 사용함으로써 여러분의 함수가 반복되는 플로우를 여러번 중단된다는 것을 알 수 있죠. return문이 적절하게 사용될 때마다 세이브되는 각 밀리초들이 더해져 초당 더 많은 작업들을 할 수 있게 만듭니다. return await을 피하는 것은 성능을 향상시키는 좋은 방법입니다.

예외 처리

이전에 발했듯이, 프로미스를 리턴하기 전에 기다릴 필요가 없다면, 즉시 반환됩니다.Chain of Responsibility과 비슷하게 말입니다. 즉 리턴하는 것에 책임을 지는 대신 함수의 호출자에게 책임을 넘기게 되는데요. 이런 경우 Try-Catch Blocks을 사용하는 것이 좋습니다.

// Correct usage of `return await`
async function fn() {
  try {
    return await work();
  } catch (err) {
    return handleWorkError(error);
  }
}

위의 예시에서 await 키워드를 제거해본다면 아래와 같습니다.

// return await work();
return work();

함수 내에서 어떤 예외를 직접 throw한다 하더라고 catch 블록은 호출되지 않습니다. work() 함수가 리턴하는 프로미스는 fn() 호출자에게 자신을 처리하는 책임을 직접 전달해주기 때문입니다. fn() 함수를 호출하는 외부에 try-catch 블록을 두면, 내부 fn() catch 블록은 실행되지 않고 외부에 있는 블록만 실행됩니다.

가장 간단한 해결책

위의 예외사항을 무시하고, 여러분들은 간단히 await을 제거하시면 됩니다. 만약 ESLint를 사용하고 있다면(사용하지 않는다면 사용하세요) no-return-awaitrule을 켜두시면 됩니다.

Zero cost async 스택 추적

여러분들이 좋은 독자라면 아마 no-return-await에 대해 읽었을 것이고 스택 추적을 보존하는 것에 대해서도 보셨을 겁니다. 그렇지 않았더라도 제가 설명드리겠습니다.

요약하자면, 프로미스를 리턴하고 호출자가 예외를 처리하게 되면 스택 추적에 대한 손실(loss)가 발생하는 문제를 맞이하게 됩니다. 간단한 예시를 보여드리겠습니다.

async function foo() {
  await bar();
  return 42;
}

async function bar() {
  await Promise.resolve();
  throw new Error('BEEP BEEP');
}

foo().catch((error) => console.log(error.stack));
$ node index.js
Error: BEEP BEEP
  at bar (index.js:8:9) --> (ONLY SHOWS BAR) <--
  at process._tickCallback (internal/process/next_tick.js:68:7)
  at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
  at startup (internal/bootstrap/node.js:266:19)
  at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)
But as written on the official [v8.dev](https://v8.dev/blog) blog, they solved this problem with something called [zero-cost async stack traces](https://bit.ly/v8-zero-cost-async-stack-traces) and now you can see the exact stack trace in the console:

그러나 v8.dev 공식 블로그에 쓰인 것처럼 zero-cost async stack traces를 통해 이 문제를 해결했으며 여러분들은 이제 콘솔에서 정확한 에러 스택을 확인할 수 있습니다.

$ node index.js

Error: BEEP BEEP
   at bar (index.js:8:9)
   at process._tickCallback (internal/process/next_tick.js:68:7)
   at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
   at startup (internal/bootstrap/node.js:266:19)
   at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)
   at async foo (index.js:2:3)

제가 모든 것을 설명하진 않았으니 자세한 건 official blog post를 참고해주세요.

끝!

여러분들이 제 글을 통해 새로운 것을 배워 코드를 더 좋게 만들 수 있기를 바랍니다.