자바스크립트는 어떻게 약속을 지킬까?


글을 쓰게 된 계기

NHN Cloud FE개발랩에 신입 개발자로 합류한지 어느덧 4개월이 다 되어간다. 현재 NHN Cloud console의 장기적인 목표인 프레임워크 전환을 위한 사전작업의 일환으로 RxJS 의존성 제거 업무를 수행하고 있다.

작업의 핵심은 RxJS Observable을 Promiseasync/await, setTimeout 같은 자바스크립트, 브라우저 표준 스펙에서 제공하는 비동기 처리로 변경한 후에도 동일하게 동작하는 것이다.

업무를 진행하는 데 어려움이 없을 정도의 비동기 처리에 대한 지식은 가지고 있었지만, 자바스크립트가 브라우저에서 비동기 처리(특히 Promise)를 내부적으로 어떻게 처리하는지가 궁금해졌다.

자바스크립트에서 비동기 처리

자바스크립트는 싱글 스레드(Single-threaded) 언어이다. 최초로 등장했을 당시 자바스크립트의 용도는 웹 페이지에 간단하게 동적인 효과를 표현하기 위한 경량 언어였다.
따라서 프로그래밍 난이도가 높은 멀티 스레드(Multi-threaded) 모델을 적용할 이유가 없었다.
하지만 웹 애플리케이션에서 자바스크립트의 역할은 점점 다양해지고 중요해졌다. 특히 싱글 스레드로는 동시성 처리에 한계가 분명했고, 이를 해결하기 위해 선택한 방법이 비동기 처리이다.
비동기 처리가 없다면 사용자는 페이지의 모든 컨텐츠가 로드될 때 까지 텍스트를 입력할수도, 버튼을 누를수도 없었을 것이다.

가장 고전적인 비동기 처리 방법으로 콜백 함수가 있다.

콜백 함수란 다른 함수에 인수로 넘겨준 함수를 의미한다.

브라우저 렌더링 과정에서 필요한 정보들을 서버에 요청하는 것을 콜백 함수로 표현하면 다음과 같다.

function render(function() {
  loadHTML(function() {
    loadCSS(function() {
      loadJS(function() {
        loadAssets() {
          ...
        }
      })
    })
  })
})

위 코드는 콜백 패턴의 비동기 처리에서 발생하는 문제인 콜백 지옥을 보여준다.

비동기적으로 처리해야 하는 task 들은 꼬리 물기식으로 작성하게 된다.
위 코드와 같이 콜백 내부에 콜백이 반복 중첩되다 보니 가독성이 떨어지고 IDE의 도움을 받아도 들여쓰기를 인식하기 힘든 문제가 발생하는데, 이를 콜백 지옥(callback hell) 이라고 한다.

자바스크립트는 더 깔끔한 비동기 처리가 가능하도록 ES6에서 Promise 객체, ES2017(ES8)에서 async/await 키워드를 제공하기 시작했다. 이외에 브라우저에서 제공하는 Web API setTimeout, setInterval등도 존재한다.

Promise에 대해 알아보고 다른 비동기 처리 방식들이 동일하게 동작하는지 확인해보자.

Promise 란?

Promise는 JavaScript에서 비동기 처리를 위해 ES6부터 도입된 ECMAScript 표준 빌트인 객체다.

Promise 생성자 함수는 비동기 처리를 수행할 콜백 함수(executor)를 인수로 전달받는데 이 콜백 함수는 resolve, reject 함수를 인수로 전달받는다.

/**
  * Creates a new Promise.
  * @param executor A callback used to initialize the promise. This callback is passed two arguments:
  * a resolve callback used to resolve the promise with a value or the result of another promise,
  * and a reject callback used to reject the promise with a provided reason or error.
*/
new <T>(executor: (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void): Promise<T>;

Promise는 비동기 처리 상태와 처리 결과를 관리하는 객체로서 비동기 처리가 어떻게 진행되고 있는지 다음과 같은 상태 정보를 가진다.

상태 정보 의미 상태 변경 조건
pending 비동기 처리가 아직 수행되지 않은 상태 Promise가 생성된 직후 기본 상태
fulfilled 비동기 처리가 성공한 상태 resolve 함수 호출
rejected 비동기 처리가 실패한 상태 reject 함수 호출

또한 Promise의 모든 메서드(정적, 인스턴스)의 반환 값은 Promise이므로 가독성 높은 메서드 체이닝이 가능하다. promises

이미지 출처: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#description

아래 코드의 실행 결과를 보면 Promise 비동기 처리 간의 순서는 FIFO 라고 추측할 수 있다.

Promise.resolve().then(() => console.log('first promise'))
Promise.resolve().then(() => {
    console.log('second promise')
    Promise.resolve().then(() => console.log('first inner promise'))
})
Promise.resolve().then(() => console.log('third promise'))

// === output ===
// first promise
// second promise
// third promise
// first inner promise

Promise와 async/await

다른 비동기 처리 방법인 async/await 키워드도 동일하게 동작할까?

function getPromise(word) {
  return new Promise((res) => res(word));
}

function testPromise() {
  console.log('testPromise start');

  const result = getPromise('promise object');

  result.then(console.log);
  console.log('testPromise end');
}

async function testAsyncAwait() {
  console.log('testAsyncAwait start');

  const result = await getPromise('async/await keyword');

  console.log(result);
  console.log('testAsyncAwait end');
}

testPromise()
// === output ===
// testPromise start
// testPromise end
// promise object

testAsyncAwait()
// === output ===
// testAsyncAwait start
// async/await keyword
// testAsyncAwait end

Promise와 처리 결과가 다르다. 그 이유는 ECMAScript 표준 문서에서 확인할 수 있다.
async await

async/await 키워드의 구현 스펙을 요약하면 AsyncFunction Awaited Fulfilled/Rejected로 정의된 핸들러 onFulfilled, onRejected가 생성되고, PerformPromiseThen 추상 함수를 실행한다.

PerformPromiseThen에서 Promise의 상태에 따라 핸들러를 실행시키는데, 핸들러는 현재 running execution context 를 pending 시켜서 execution context stack를 비우고 비동기 코드(asyncContext)를 running execution context로 만들어서 우선적으로 실행시킨다.
asyncContext가 종료되면 pending 시켜두었던 context를 다시 running execution context로 원복시킨다. AsyncFunction Awaited Fulfilled

위 과정을 거치면서 비동기로 호출한 getPromise가 종료되고 Promise의 반환 값이 전달될 때까지 동기처럼 동작한 것이다.

async/await 키워드도 결국 Promise를 기반으로 동작하고 있다는 사실을 알게 되었다.

그렇다면 브라우저에서 기본적으로 제공하는 setTimout등의 비동기 함수는 Promise와 어떻게 동작할까?

Promise와 setTimeout

코드 실행 결과를 살펴보자.

function sleep(ms) {
  const delay = Date.now() + ms;
  let now = null;
  do {
    now = Date.now();
  } while (now < delay);
}

Promise.resolve().then(() => console.log('first promise'));
setTimeout(() => {
  console.log('first setTimeout');
  Promise.resolve().then(() => console.log('first inner promise'));
}, 0);

sleep(5000) // 5초 지연

setTimeout(() => console.log('second setTimeout'), 0);
Promise.resolve().then(() => console.log('second promise'));

// === output ===
// first promise
// second promise
// first setTimeout
// first inner promise
// second setTimeout

뭔가 이상하다.. setTimeout 함수에 지연시간을 0ms로 주었는데 Promise보다 나중에 실행되고 있다.
HTML 표준 문서에 의하면 타이머 함수의 nesting level이 5보다 클 때 최소 지연시간은 4ms로 설정된다고 되어있다. 하지만 위 예시는 nesting level이 1이기 때문에 지연시간은 0으로 설정되었을 것이다. 네트워크 속도 이슈 등으로 발생하는 오차를 보완하도록 직접 시간을 지연시킨 sleep도 무용지물이다.

그렇다면 다르게 동작하는 원인을 브라우저 내부에서 Promise가 어떻게 처리되는지 확인해보면 찾을 수 있지 않을까?

브라우저 내부에서 Promise의 처리

먼저 브라우저 환경에서 자바스크립트 코드의 실행에 사용되는 요소들을 간단하게 살펴보자. (여기서는 구글의 V8 엔진을 기준으로 설명하려고 한다) browser work

  • V8 엔진: 자바스크립트 코드를 평가하여 객체 등의 동적 데이터가 할당되는 memory heap, context의 실행을 담당하는 하나의 call stack(= execution context stack)을 가진다.
  • event loop: pending 상태인 자바스크립트 task와 microtask를 execution context stack에 push하여 실행시킨다. 다음 loop iteration 이전에 렌더링이나 페인팅이 필요하면 먼저 수행한다.
  • task queue: program 실행, 이벤트 콜백, interval or timeout 콜백, XHR 등이 쌓이는 큐
  • microtask queue: Promise, Mutation Observer API의 콜백들이 쌓이는 큐

내부 구조를 알아보니 Promise와 timer 함수들은 서로 다른 queue에 등록되고 있었다.

HTML 표준에 따르면 event loop는 실행 중인 context가 종료되면 다음 task를 실행하기 전에 microtask queue에 pending 상태인 context가 존재하는지 먼저 확인한다.
microtask queue에 pending 상태인 context가 존재하면 microtask queue가 모두 빌 때까지 모두 실행시킨다. (context 실행 중에 microtask queue에 새로 추가되는 context까지 모두 실행시키며 종료될 때까지 기다린다)

위 코드의 내부 동작을 가시화하면 아래와 같다.

먼저 같은 전역 레벨에 있는 모든 context들이 각 queue에 저장된다. code_running1

이후 event loop는 microtask queue의 pending 상태인 모든 context를 call stack에 push하여 실행시킨다. code_running2

microtask queue에 있는 모든 context가 실행이 되었다면 task queue를 확인한다.
이때 실행된 context 내부에서 새로운 Promise 콜백이 microtask queue에 저장됨에 따라 event loop는 다음 loop에서 microtask queue의 context를 먼저 실행시킨다. code_running3

microtask queue가 비었으므로 task queue의 context를 call stack으로 push하여 실행시킨다. code_running4

마치며

처음에는 단순히 Promise를 조금 더 알아보는 것을 목적으로 자료조사와 학습을 시작했다. 파고들다 보니 자바스크립트에서 비동기 처리의 필요성이 대두된 역사, 브라우저와 자바스크립트 엔진의 비동기 처리 방식 등 많은 내용을 학습하게 되었다.

Promise나 async/await 같이 개발자가 사용하기 편한 도구의 내부 동작을 학습함으로써 더 자신 있게 사용할 수 있게 되었고, 간혹 잘 이해되지 않던 복잡한 비동기 코드 실행 순서도 정립되었다.

이 글에서는 언급하지 않았지만, agent에 따라 3종류의 event loop가 존재한다거나, 브라우저에서 애니메이션을 최적화하기 위해 사용하는 requestAnimationFrame() Web API가 event loop의 렌더링 과정에서 호출된다는 사실 등 더 복잡하고 많은 내용이 있다. 비동기 처리에 관심이 생겼다면 함께 읽어보길 추천한다.

참고