ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 당신이 async await 을 사용할 때 자바스크립트는..
    JavaScript 2023. 10. 14. 22:52

    출처 : 모던 자바스크립트 Deep Dive: 자바스크립트의 기본 개념과 동작 원리 / 이웅모 지음

     

    자바스크립트는 비동기 처리를 할 수 없다

     

    자바스크립트의 실행 컨텍스트까지 공부하고, 자바스크립트 엔진이 어떻게 동작하는 지 아는 사람이라면,

    자바스크립트는 단 하나의 실행 컨텍스트 (콜스택) 을 갖고, 싱글 쓰레드로 동작한다는 사실을 알 것이다.

    C를 공부할 때는 함수를 비동기로 처리하기 위해 멀티 프로세스, 멀티 쓰레드 방식을 이용했다.

    하지만 자바스크립트는 멀티 쓰레드를 지원하지 않으므로, 이를 할 수 없다.

    그럼에도 우리는 자바스크립트에서 비동기 처리를 한다.

    어떻게 된 일일까?

     


     

    자바스크립트의 비동기 처리 방법

     

    자바스크립트는 아주 내 마음에 쏙 드는 방식으로 비동기를 처리한다.

    자바스크립트의 실행 환경이 브라우저든, 노드든 별로 다르지 않은데, 브라우저를 예시로 들어 설명해보자.

    브라우저에는 이벤트 루프와 태스크 큐라는 환경을 제공한다.

     

    1. 콜 스택의 순서에 따라 비동기 함수가 실행되면, 비동기 함수는 콜백 함수를 브라우저에 넘기고 콜 스택에서 팝된다.

    2. 브라우저는 태스크 큐에 콜백 함수를 보관하고, 자바스크립트 엔진은 콜 스택의 순서에 따라 컨텍스트를 실행한다.

    3. 브라우저의 이벤트 루프는 콜 스택이 비어있는 지, 태스크 큐가 차있는 지를 계속 검사하고, 콜 스택이 비면 태스크 큐에 대기중이던 콜백 함수를 콜 스택에 푸쉬한다.

     

    한 마디로, 자바스크립트 엔진은 비동기 함수를 처리하기 위해 멀티 스레드로 동작하는 브라우저, 노드에게 비동기 함수의 스케쥴링을 위임한다.

     

     


     

    자바스크립트의 비동기 처리의 역사

     

    비동기 처리의 원리를 이해하고 나면, 단순히 비동기 함수 내부에 콜백 함수를 선언하는 방식으로 비동기 처리를 하면 될 것 같다.

    하지만 현실은 그렇지 않은데, 비동기 함수 처리 결과에 대한 후속 처리도 또한 콜백 함수를 만들어야 하고, 비동기 처리 결과를 가지고 또 비동기 함수를 호출해야 하는 상황에서는 콜백 함수 호출이 중첩되어 이른바 콜백 헬이 생기게 된다.

     

    자바스크립트는 무려 ES6 나 되서야 이 문제를 해결하기 위한 개념을 도입했는데, 그것이 Promise 이다.

    const promise = new Promise((resolve, reject) => {
      // 비동기 처리 수행
      if (/* success */) {
        resolve('result');
      else {
        reject('failure reason');
      }
    });

    비동기 함수를 만들고 싶으면, 위와 같은 구조의 Promise 객체를 반환하는 함수로 만들면 된다.

    Promise 객체는 또한 좋은 후속 처리 method (then, catch, finally) 를 제공한다.

    따라서 후속 처리를 할 때에도 promise().then().then().catch() 와 같이 깔끔하게 코드를 작성할 수 있고, 에러 처리도 문제 없이 할 수 있다.

    Promise 도 결국 콜백 패턴에서 완전히 자유롭진 않지만, 이만하면 비동기 함수를 처리하는 데 문제가 없다.

     

    제너레이터의 등장

    비동기 처리의 역사에 제너레이터를 빼 놓을 수 없다.

    async / await 를 이미 많이 써 본 사람이라면, 제너레이터를 보고 이렇게 생각할 것이다.

    어? async / await 이랑 매우 비슷한데?

    맞다. async / await 은 ES8 부터 도입된, 제너레이터를 더욱 가독성 좋게 구현할 수 있도록 만든 기능이다.

    따라서 제너레이터를 이해한다면, async / await 은 어떻게 동작하는 지 자연스럽게 이해가 될 것이다.

     

    제너레이터는 함수이다. 그런데 일반 함수와 조금 차이가 있다.

    1. 제너레이터 함수는 함수 호출자에게 함수 실행의 제어권을 양도할 수 있다.

    2. 제너레이터 함수는 함수 호출자와 함수의 상태를 주고받을 수 있다.

    3. 제너레이터 함수를 호출하면 제너레이터 객체를 반환한다.

     

    이게 무슨 말인가?

    제너레이터 함수를 호출하면 일반 함수처럼 코드블록을 실행하는 것이 아니라, 이터러블한 (동시에 이터레이터인) 제너레이터 객체를 생성해서 반환한다.

    function* generatorFunc() {
      try {
        yield 1;
        yield 2;
        yield 3;
      } catch (e) {
        console.error(e);
      }
    }
    
    const generator = generatorFunc();
    console.log(generator.next()); // {value: 1, done: false}
    console.log(generator.next()); // {value: 2, done: false}
    console.log(generator.return('end')); // {value: 'end', done: true}

    제너레이터 객체의 next method 를 호출하면, yield 표현식까지 실행되고 suspend 된다. 이후 필요한 시점에 또 다시 next 를 호출하면 일시 중지된 코드부터 실행을 재개하여 다음 yield 까지 실행되고 또 suspend 된다.

    그렇다면 한번 yield 표현식을 변수로 받아보자.

    이것이 매우 중요한데, 제너레이터 객체의 next method 에 인수를 넣어 전달하면, 제너레이터 함수의 yield 표현식을 할당받는 변수에 할당된다.

    function* generatorFunc() {
      // x = TDZ
      const x = yield 1;
      // x = 10
      // y = TDZ
      const y = yield (x + 10);
      // y = 20
      return x + y;
    }
    
    const generator = generatorFunc(0);
    console.log(generator.next('무시되는 값')); // {value: 1, done: false}
    
    // next method 의 인자 10을 x에 전달
    console.log(generator.next(10)); // {value: 20, done: false}
    
    // 인자 20을 y에 전달
    console.log(generator.next(20)); // {value: 30, done: true}

    슬슬 익숙한 async / await 의 향기가 난다.

    다음 예제를 보면 더 확실해진다. 아예 제너레이터 실행기의 이름을 async 로 정하고 한번 보자.

    const fetch = require('node-fetch');
    
    const async = generatorFunc => {
      const generator = generatorFunc();
      
      const onResolved = arg => {
        const result = generator.next(arg);
        
        return result.done ? result.value : result.value.then(res => onResolved(res));
      };
      return onResolved;
    };
    
    (async(function* fetchTodo() {
      const url = 'https://...';
      
      const response = yield fetch(url);
      const todo = yield response.json();
      console.log(todo);
    })());

    순서대로 보자.

    1. async 는 generatorFunc 를 인수로 받는 함수이다.

    2. async 함수의 인수에 fetchTodo() 를 넣어서 호출한다.

    3. async 가 실행되고, onResolved 를 반환한다.

    4. async 함수 끝의 () 는 onResolved 함수를 즉시 호출한다.

    5. onResolved 함수는 generator.next(arg) 를 호출한다.

    6. fetchTodo 의 첫 번째 yield 문까지 실행된다.

    7. result.done 이 false 이기 때문에, result.value.then() 의 인수에 fetch() 의 반환값의 Response 객체를 전달하면서 onResolved 를 호출한다.

    8. onResolved 는 다시 generator.next(arg) 를 호출하고 이렇게 계속된다...

    9. result.done 이 true 가 되면 result.value 를 그대로 반환하고 종료한다.

     

     


     

    async / await

     

    ES8에서 위의 문법을 간략하게 만든 async / await 을 사용하면서, 우리는 더 이상 제너레이터 실행기를 작성할 필요가 없어졌다.

    위의 예시에서 yield 를 await 으로 바꾸면 다음과 같다.

    const fetch = require('node-fetch');
    
    async function fetchTodo() {
      const url = 'https://...';
      
      const response = await fetch(url);
      const todo = await response.json();
      console.log(todo);
    }
    
    fetchTodo();

    이처럼 표현식은 매우 간단해졌지만, 원리는 위의 제너레이터와 똑같다.

    이를 통해, 콜백 패턴에서 완전히 자유로워진 상태로 비동기 처리를 동기 처리처럼 동작하도록 할 수 있다.

     

     


    만약 우리가 비동기 함수를 직접 만들어야 한다면 Promise 객체를 만들 일이 있을 것이다.

    하지만 그 외에는, promise 나 제너레이터는 이제 안 써도 된다.

    물론 개념은 이해하고 있는 게 좋다.

    async / await 을 사용할 때 마다 내부적으로 어떻게 동작하는 지 생각해 보면서 쓰도록 하자.

    댓글

Designed by Tistory.