async/await 완벽 이해

개발 log·2022년 5월 25일
4

JS 지식

목록 보기
25/36
post-thumbnail

asyncawait을 사용하면 그냥 await키워드에서 기다렸다가 다음 문을 실행한다. 정도로만 알고 있었는데 콜 스택과 Promise, 마이크로 태스크 큐 관점에서 드디어 완벽히 이해할 수 있게 되어 글을 정리한다.

이 글을 읽기 전에 제너레이터가 어떻게 동작하는지 알고 읽으면 훨씬 수월할 것이다.

전제 사항

  1. async함수가 동기적으로 동작하는 모습을 확인하기 위해서는 콜 스택에 async함수밖에 없어야 한다.

이는 간단한 예제로도 알 수 있다.

async function A() {
    const a = await Promise.resolve('a')
	console.log(a)
    return a
}
async function B() {
    const b = await Promise.resolve('b')
	console.log(b)
    return b
}

A()
B()
console.log('c')

현재 이 코드를 실행하면 콘솔에는 어떻게 찍힐까?

아마 c -> a -> b 이 순서로 찍힐 것이다.(여기서 ab의 순서는 보장할 수 없다.)

당연하다. async함수에서 기다린다라고 표현할 수 있는 행동은 async함수 내부에서만 발생하는 일이기 때문이다.

그렇다면 이를 우리가 원하는 대로 a -> b -> c 이 순서대로 동작하게 하려면 어떻게 해야할까?

간단하다. 앞서 말한 전제대로 콜 스택에 async함수만 푸시하도록 코드를 수정하면 된다.

async function A() {
    const a = await Promise.resolve('a')
	console.log(a)
    return a
}
async function B() {
    const b = await Promise.resolve('b')
	console.log(b)
    return b
}

async function C() {
    await A()
    await B()
	console.log('c')
}
C()

이렇게 콜 스택에 async함수 하나만 있다면 앞으로는 비동기를 처리해주는 브라우저와 마이크로 태스크 큐의 영역이다.

  1. 콜 스택에 하나만 존재하는 async함수를 실행시킨다.
  2. 내부적으로 async함수는 제너레이터로 변환되는데 제너레이터는 실행시키면 함수 몸체를 실행하는 것이 아니라 제너레이터 객체를 반환한다.
  3. 이렇게 변환된 제너레이터 함수를 Promise 객체의 콜백함수에서 호출하여 제너레이터 객체를 다루는 로직을 작성한다.
  • 제너레이터 객체를 다루는 로직은 아래와 같다.
/*
* gen: 제너레이터 객체
* resolve: 해당 함수를 호출한 `Promise`의 `resolve`함수
* reject: 해당 함수를 호출한 `Promise`의 `reject`함수
* _next: 제너레이터 객체의 `done`이 `true`일때까지 호출 될 `next`
* _throw: 제너레이터 객체의 `done`이 `true`일때까지 호출 될 `throw`
* key: 'next' | 'throw' (info에 할당할 메서드가 `next`인지 `throw`인지 결정
*/
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
  try {
    var info = gen[key](arg);
    var value = info.value;
  } catch (error) {
    reject(error);
    return;
  }
  if (info.done) {
    resolve(value);
  } else {
    Promise.resolve(value).then(_next, _throw);
  }
}

/*
* fn: 제너레이터
*/
function _asyncToGenerator(fn) {
  return function () {
    var self = this,
      args = arguments;
    return new Promise(function (resolve, reject) {
      // gen: 제너레이터 객체
      var gen = fn.apply(self, args);
      function _next(value) {
        asyncGeneratorStep(gen, resolve, reject, _next, _throw, 'next', value);
      }
      function _throw(err) {
        asyncGeneratorStep(gen, resolve, reject, _next, _throw, 'throw', err);
      }
      _next(undefined);
    });
  };
}
  1. 복잡하다.
  2. 굉장히 복잡하다.
  3. 로직을 보면 이렇다 프로미스 객체에서 제너레이터 객체를 만든 뒤 제너레이터 객체의 valuedone을 사용해서 프로미스의 resolvedonetrue일때까지 계속 resolve해주는 것이다.
  4. 현 시점에서 중요한 것은 콜 스택은 비어있다는 것이다.
  5. 즉 프로미스가 resolve되고 마이크로 태스크 큐에 resolve된 결과가 콜스택의 최상단이 된다는 것이다.
  6. 이렇게 되면 당연히 비동기 작업이 끝난 순서대로 콜스택에서 팝되기 때문에 비동기동작이 동기처럼 동작할 수 있는 것이다.

결론

이때까지 왜 async, await이 동기 처리 방식이 아닌 비동기 처리 방식을 동기 처럼 동작하게 한다는 것인지 이해하지 못했는데 콜스택에 async함수만 두고 프로미스 객체를 활용하여 비동기 처리가 끝난 작업을 마이크로태스크큐에 넣으며 비어있는 콜스택에 마이크로태스크큐의 결과물이 순서대로 들어갈테니 비동기 동작을 동기처럼 동작하게 할 수 있는 것이었다.

참고

async await 동작원리

profile
프론트엔드 개발자

0개의 댓글