await 은 어떻게 동기적으로 동작할까? 코드 뜯어보며 살펴보기

#Javascript

2025.03.02

프론트엔드 개발을 하다보면 비동기처리를 수도 없이 만난다. 이때 우리는 await 을 사용한다. await 을 사용하면 비동기 코드의 결과를 기다렸다가 동기적으로 처리할 수 있다.

물론 엄밀히 말하면 완전히 동기적인 것은 아니다. 이벤트 루프 관점에서 봤을 땐 비동기 작업을 수행시켜놓고 다른 코드를 처리하고 있기 때문이다. 하지만 함수 내부에서 비동기 처리를 수행하는 코드 부분만 봤을 땐 동기적으로 동작한다고 볼 수 있다.

비동기처리의 결과를 기다렸다가 반환하는 동작은 Promise 로도 구현이 가능하지만 then 이 중첩될 경우 callback hell 이 될 수 있다. 그렇다고 해서 비동기 처리 결과를 외부 스코프로 자연스럽게 빼내어 선형적 코드 구조를 만들기도 어렵다. 바로 이 점이 가독성 측면에서 await 이 해결해준 핵심 문제다.

하지만 이러한 await 의 편리함에도 불구하고, 내부동작 원리는 나에게 블랙박스처럼 여겨졌다. Promise 가 비동기 작업을 처리해주는 것은 알겠는데, await 은 어떻게 그 Promise 가 완료되는 시점을 감지하고 그 결과값을 변수에 할당할 수 있는 것일까? 이 의문을 풀기 위해 내부 구현을 들여다볼 필요가 있다.

따라서 이번 글의 주제는, await 을 ES8 이전의 자바스크립트로 어떻게 구현했는가? 이다. 특히 다음 두 가지 기능을 중점적으로 살펴보려고 한다:

이 글에선 Babel 로 트랜스파일링 된 await 을 직접 살펴보고 내부 동작원리를 이해한다. 여러분이 비동기함수, 콜백, Promise 를 알고 있다면 충분히 이해할 수 있을 것이다.

generator

먼저 generator 함수에 대해 간단히 살펴보겠다.

async/await 이 ES8(ES2017)에 정식으로 도입되기 전, 개발자들은 generatorPromise 를 조합하여 유사한 기능을 구현하고 있었다.

generator 는 ES6(ES2015)에서 도입된 함수로, caller와 callee 간 양방향 통신을 가능하게 한다. 일반 함수와 달리 generator 는 실행 도중 멈추고 다시 시작할 수 있는 특별한 함수다.

function* generatorFunction() {
  yield 1;
  yield 2;
  yield 3;
}
 
const generator = generatorFunction();
 
console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: 2, done: false }
console.log(generator.return('hello')); // { value: 'hello', done: true }

generator 의 핵심 특징은 다음과 같다

function* generatorFunction() {
  const x = yield 1;
  return x + 10;
}
 
const generator = generatorFunction();
console.log(generator.next());
console.log(generator.next(10));

이렇게 generator를 이용해 중단-재개 메커니즘을 구현할 수 있다.

babel 환경 설정

위에서 awaitPromisegenerator 를 통해 구현할 수 있다고 언급했다. 실제로 Babel 과 같은 트랜스파일러는 async/await 구문을 ES5 호환 코드로 변환할 때 generator 기반 접근 방식을 사용한다.

따라서 기존 await 을 사용한 코드를 ES5 코드로 변경하여 await 이 어떻게 동작하는지 살펴볼것이다.

awaitPromisegenerator 로 구현된 코드로 변경하려면, await 은 없고 generator, Promise 는 존재하는 자바스크립트 버전으로 트랜스파일링 해야 한다.

generator 기능의 브라우저 지원 정보를 살펴보면, 크롬 버전 39부터 기본 generator 기능을 지원하고 있다. 그리고 generatorreturn 메서드는 크롬 버전 50부터 지원한다.

await 은 크롬 버전 55부터 지원한다.

Babel 에선 targets 속성을 통해 트랜스파일링될 자바스크립트 버전을 설정할 수 있다. generatorreturn 메서드까지 필요하므로 크롬 51 버전을 타겟으로 설정했다. 이렇게 하면 generatorPromise 는 사용 가능하지만 아직 await 이 구현되지 않은 환경으로 코드가 변환된다.

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "51"
        }
      }
    ]
  ]
}

뜯어보기

function findUserById(id) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('waited 1 sec');
      resolve({
        id: id,
        name: 'user' + id,
      });
    }, 1000);
  });
}
 
async function run() {
  const user = await findUserById(1);
  console.log(user);
}
 
run();
 
// waited 1 sec
// { id: 1, name: 'user1' }

fineUserById 함수는 1초 뒤 유저 객체를 반환하는 비동기 함수다. await 을 사용하여 비동기 처리 결과를 user 에 담아 출력한다.

트랜스파일링 된 결과는 다음과 같다.

'use strict';
 
function asyncGeneratorStep(n, t, e, r, o, a, c) {
  try {
    var i = n[a](c),
      u = i.value;
  } catch (n) {
    return void e(n);
  }
  i.done ? t(u) : Promise.resolve(u).then(r, o);
}
function _asyncToGenerator(n) {
  return function () {
    var t = this,
      e = arguments;
    return new Promise(function (r, o) {
      var a = n.apply(t, e);
      function _next(n) {
        asyncGeneratorStep(a, r, o, _next, _throw, 'next', n);
      }
      function _throw(n) {
        asyncGeneratorStep(a, r, o, _next, _throw, 'throw', n);
      }
      _next(void 0);
    });
  };
}
function findUserById(id) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        id: id,
        name: 'user' + id,
      });
    }, 1000);
  });
}
function run() {
  return _run.apply(this, arguments);
}
function _run() {
  _run = _asyncToGenerator(function* () {
    const user = yield findUserById(1);
    console.log(user);
  });
  return _run.apply(this, arguments);
}
run();

한번 천천히 살펴보자. 실행 흐름대로 짚어봤다.

function run() {
  return _run.apply(this, arguments);
}
function _run() {
  _run = _asyncToGenerator(function* () {
    const user = yield findUserById(1);
    console.log(user);
  });
  return _run.apply(this, arguments);
}
run();

가장 먼저, _run 부분이다. 잘 보면, 선언된 generator 함수에 우리가 사용했던 코드와 함께 awaityield 로 변경된 것을 볼 수 있다. 이 부분에서 알아야 할 것은, _run 변수에 generator 를 인자로 넣는 _asyncToGenerator 를 할당하고 실행한다는 것이다.

function _asyncToGenerator(n) {
  return function () {
    var t = this,
      e = arguments;
    return new Promise(function (r, o) {
      var a = n.apply(t, e);
      function _next(n) {
        asyncGeneratorStep(a, r, o, _next, _throw, 'next', n);
      }
      function _throw(n) {
        asyncGeneratorStep(a, r, o, _next, _throw, 'throw', n);
      }
      _next(void 0);
    });
  };
}

n 은 비동기 결과를 받아 출력하는 generator 함수다. _asyncToGeneratorPromise 객체를 반환하는데, 콜백에서 _next, _throw 라는 함수를 정의한 뒤 _next(void 0)을 호출한다.

return new Promise(function (r, o) {
  var a = n.apply(t, e);
  function _next(n) {
    asyncGeneratorStep(a, r, o, _next, _throw, 'next', n);
  }
  function _throw(n) {
    asyncGeneratorStep(a, r, o, _next, _throw, 'throw', n);
  }
  _next(void 0);
});

_next(void 0) 으로 호출하는 함수는 바로 위에서 정의된 _next(n) 이다. void 0undefined 를 넘기는 하나의 방식이기 때문에 단순히 빈 인자를 넘겨준다고 보면 된다. _next(n) 안에선 asyncGeneratorStep 함수가 실행된다.

function asyncGeneratorStep(n, t, e, r, o, a, c) {
  try {
    var i = n[a](c),
      u = i.value;
  } catch (n) {
    return void e(n);
  }
  i.done ? t(u) : Promise.resolve(u).then(r, o);
}

여기서부터 중요하다. n 은 맨 처음에 정의된 generator 함수, a"next" 문자열, cvoid 0 이므로 undefined 이다.

try 문 내부의 var i = n[a](c)generator["next"]() 이다. 즉, generator 함수의 next() 메서드를 실행하고 있다.

_run = _asyncToGenerator(function* () {
  const user = yield findUserById(1);
  console.log(user);
});

그 결과로 generator 함수가 실행되는데, 첫 번째 next() 이므로 findUserById 까지 실행되고 중단된다. 이때부터 비동기 처리가 시작된다.

function asyncGeneratorStep(n, t, e, r, o, a, c) {
  try {
    var i = n[a](c),
      u = i.value;
  } catch (n) {
    return void e(n);
  }
  i.done ? t(u) : Promise.resolve(u).then(r, o);
}

이후 다시 asyncGeneratorStep 로 돌아와 보면, u = i.valueu 변수에 yield 의 오른쪽 문(함수)의 참조를 담는다. 그러니까 findUserById 의 참조가 담긴 것이다.

이후 i.done 을 검증한다. 위에서 실행된 next() 는 첫 번째 next() 이므로 i.donefalse, 따라서 Promise.resolve(u).then(r, o) 가 실행된다.

unext() 의 결과로 받은 findUserById 의 참조인데, 이것을 Promise 객체에 넣어 비동기 함수가 끝날 때까지 명시적으로 기다릴 수 있도록 한다.

r_asyncToGenerator_next 함수다.

여기가 포인트다. 비동기 호출(u)을 Promise.resolve() 로 감싼 뒤, 호출이 완료되면 _next 가 실행되도록 만들었다. 이때 u(findByUserId) 의 결과는 then 의 인자로 넘어가기 때문에, findByUserId 의 결과는 r(_next) 의 인자에 담겨 전달되는 것을 알 수 있다.

그리고 다시 코드를 따라가다보면 _next 는 다시 asyncGeneratorStep 를 실행시키는 것을 알 수 있다.

function asyncGeneratorStep(n, t, e, r, o, a, c) {
  try {
    var i = n[a](c),
      u = i.value;
  } catch (n) {
    return void e(n);
  }
  i.done ? t(u) : Promise.resolve(u).then(r, o);
}

아까와 동일하게 next 가 실행된다. 하지만 이번엔 두 번째 next 이다.

_run = _asyncToGenerator(function* () {
  const user = yield findUserById(1);
  console.log(user);
});

첫 번째 next()yield 에서 중지되었으니, 이제 const user = ~ 부터 시작이다. 그런데 잠깐, 아까 _next(findByUserId 결과) 처럼 비동기 함수의 결과를 넘겨주었던 것을 기억하는가? 그 값이 next() 의 인자로 전달되면서 yield ~ 부분이 비동기 결과로 치환된다. 그리고 const user 에 값이 할당된다. 이후 나머지 코드가 실행된다.

두 번째 next()i.done = true 이므로 t 즉, resolve 함수가 실행되어 해당 값을 반환하고 정상적으로 종료한다.

핵심은 비동기 처리를 기다리는 건 Promise 다. 이때 generator 는 비동기 함수를 호출하고, Promise 가 끝날 때까지 기다렸다가 이후 나머지 코드를 실행하는 역할이다.

만약 제레레이터가 없다면 Pending 상태의 Promise 가 반환될 것이다.

결론적으로, await 은 내부적으로

await 이 어떻게 내부적으로 비동기 처리 결과를 기다리는지, 그리고 어떻게 그 결과를 외부 스코프로 빼내 비동기 코드를 선형적으로 작성할 수 있도록 하는지 알 수 있었다.

추가로 generator 또한 ES5 이전 코드로 바꿔볼 수 있다. 이 부분은 추후 포스팅에서 다뤄볼 예정이다.

참고