그 async, 꼭 써야 하니?

­가은·2023년 10월 24일
96
post-thumbnail

부스트캠프 두 번째 미션 진행 중, 코드 리뷰에서 async, await, promise에 관한 내용들이 눈에 띄었다.

뜨끔했다...

사실 async, await에 관한 내용들은 여러 번 학습했었다.
하지만 정작 사용할 때 100% 알고 사용하는 느낌은 아니었다.
그냥 Promise 값을 얻기 위해 await을 쓰고, await을 사용하기 위해 async를 붙이는 식.
항상 개념적인 부분만 얕게 이해하고 넘어갔던 것 같다.
지금이라도 제대로 공부해봐야 할 것 같아서 블로그의 첫 주제로 골랐다.

아래는 내 코드의 await 사용 현황이다.

꽤나 많이 사용하고 있다.
async / await을 학습한 후 필요없는 코드를 삭제해보자.
여기서는 사용법이나 기본 정보보다는 역할에 초점을 맞출 것이다.




🍞 목표

async, await의 역할을 정확히 이해하고,
코드에서 필요하지 않은 async, await을 삭제하자


🍞 async

먼저 MDN에 나와있는 async의 정의를 살펴보자.

async function 선언은 AsyncFunction객체를 반환하는 하나의 비동기 함수를 정의합니다. 비동기 함수는 이벤트 루프를 통해 비동기적으로 작동하는 함수로, 암시적으로 Promise를 사용하여 결과를 반환합니다. 그러나 비동기 함수를 사용하는 코드의 구문과 구조는, 표준 동기 함수를 사용하는것과 많이 비슷합니다.

그렇다면 비동기란 무엇일까?


🍞 비동기

먼저 자바스크립트 엔진은 하나의 실행 컨텍스트 스택을 갖는다. (실행 컨텍스트 스택에 관해서는 여기서 자세히 설명하지 않겠다.)
함수가 실행되려면 함수 실행 컨텍스트 스택이 실행 컨텍스트 스택에 푸시되어야 한다.
자바스크립트에서는 동시에 2개 이상의 함수를 동시에 실행할 수 없다는 뜻이다.

이렇게 한 번에 하나의 태스크만 실행할 수 있는 방식을 싱글 스레드 (single thread) 방식이라고 한다.
싱글 스레드 방식은 한 번에 하나의 태스크만 실행할 수 있기 때문에, 한 가지 태스크에 시간을 오래 할애하는 경우도 발생한다.
이렇게 처리에 시간이 걸리는 태스크를 실행할 때 작업이 중단되는 현상을 블로킹 (blocking)이라고 한다.

아래 그림의 경우, foo 함수와 bar 함수는 sleep 함수의 실행이 종료될 때까지 호출되지 못하고 블로킹된다.

출처: 자바스크립트 딥다이브 ch42. 비동기 프로그래밍

이와 같이 현재 실행 중인 태스크가 종료될 때까지 다음 태스크가 대기하는 방식을 동기 (synchronous) 처리라고 부른다.

그렇다면 비동기 처리가 어떤 것인지는 예상해볼 수 있겠다.
비동기 (asynchronous) 처리는 현재 실행 중인 태스크가 종료되지 않은 상태여도 곧바로 다음 태스크를 실행하는 방식이다.
비동기 처리방식으로 동작하는 함수를 사용한다면 아래와 같이 태스크를 블로킹하지 않고 곧바로 실행시킨다.

출처: 자바스크립트 딥다이브 ch42. 비동기 프로그래밍

비동기에 대해 알았으니, 다시 MDN의 async 정의로 돌아가보자.

async function 선언은 AsyncFunction객체를 반환하는 하나의 비동기 함수를 정의합니다. 비동기 함수는 이벤트 루프를 통해 비동기적으로 작동하는 함수로, 암시적으로 Promise를 사용하여 결과를 반환합니다. 그러나 비동기 함수를 사용하는 코드의 구문과 구조는, 표준 동기 함수를 사용하는것과 많이 비슷합니다.

비동기 함수는 Promise를 사용하여 결과를 반환한다.
그렇다면 Promise는 뭘까?


🍞 Promise

MDN의 정의를 살펴보자.

Promise 객체는 비동기 작업이 맞이할 미래의 완료 또는 실패와 그 결과 값을 나타냅니다.

MDN에 따르면 Promise를 사용하면 비동기 메서드에서도 동기 메서드처럼 값을 반환할 수 있다.
하지만 최종 결과를 반환하는 것이 아니라, 미래의 어떤 시점에 결과를 제공하겠다는 '약속'을 반환한다.

하지만 Promise는 최종 결과를 반환하는 것이 아니므로, 결과를 반환받기 위해 무언가 단계를 더 거쳐야 할 것이다.
비유하자면, async가 Promise라는 포장지로 감싸진 선물을 보낸 것이다.
선물을 보고싶다면 포장지를 뜯어야 한다.

포장지인 Promise를 걷어내고 선물을 얻기 위해 할 수 있는 방법 중 하나가 await 사용이다.

🍞 await

<자바스크립트 딥다이브>의 설명에 따르면, await는 Promise가 settled 상태가 될 때까지 대기하다가 settled 상태가 되면 Promise가 resolve한 처리 결과를 반환한다.
여기서 settled과 resolve란 무엇일까?

잠깐 Promise에 대한 설명으로 돌아가보자면, Promise는 아래 세 가지 중 하나의 상태를 가진다.

  • 대기 (pending): 비동기 처리가 아직 수행되지 않은 상태
  • 이행 (fulfilled): 비동기 처리가 수행된 상태 (성공)
  • 거부 (rejected): 비동기 처리가 수행된 상태 (실패)

생성 직후의 Promise는 pending 상태인데, 비동기 처리가 수행되면 상태가 변경된다.
비동기 처리에 성공한다면 resolve 함수를 호출하고 fulfilled 상태가 된다.
비동기 처리에 실패한다면 reject 함수를 호출하고 rejected 상태가 된다.
여기서 resolve, reject는 Promise에서 인수로 전달받은 콜백함수이다.

그림으로 정리해보자면 아래와 같다.

출처: <자바스크립트 딥다이브> ch45. 프로미스

그림에서 오른쪽에 있는 상태, 즉 fulfilled 또는 rejected 상태를 settled 상태라고 한다.

위의 정보를 바탕으로, 'await는 Promise가 settled 상태가 될 때까지 대기하다가 settled 상태가 되면 Promise가 resolve한 처리 결과를 반환한다'라는 설명을 풀어서 해석해보자.
await는 Promise의 비동기 처리가 성공 혹은 실패할 때까지 대기하다가, 성공했을 때 resolve 함수를 호출한 결과를 반환하고, 실패했을 때 reject 함수를 호출한 결과를 반환한다.

이렇게 대략적으로 async에 대한 정의를 시작으로 비동기, Promise, await에 대해 알아보았다.
그렇다면 가장 중요한 포인트에 대해 다시 생각해보자.


🍞 그렇다면 언제 async / await를 사용할까?

이것저것 말이 길었지만, 결론적으로 async / await는 Promise를 활용하여 비동기 처리를 동기 처리처럼 구현할 수 있는 도구이다.
그렇다면 아래와 같은 경우에 async / await을 사용하면 좋겠다.

  • 무언가를 순차적으로 실행시키고 싶을 때
  • fetch와 같은 Promise를 반환하는 함수를 사용할 때
  • Promise 체이닝, 콜백함수같은 다른 방법의 문제점을 보완하기 위해

🍞 과도한 async / await 사용의 문제점

Promise가 처리될 때까지 함수의 실행을 중단하므로, 병렬로 실행되어도 될 코드들이 순차적으로 실행될 수 있다.
즉 async / await을 사용함으로써 작업이 더 느려질 수 있다. (이 경우 Promise.all로 해결되는 부분도 있다.)

단지 Promise를 처리하기 위해 생각없이 async / await를 덕지덕지 붙였다가는 성능 저하, 메모리 사용량 증가 등의 문제가 발생할 수 있다.


🍞 코드 수정

그럼 학습한 내용을 바탕으로 다시 내 코드를 읽어본 후 수정해보자.

1. Promise.all 사용 방법 수정

  • 수정 전

    export const getPressDatas = async () => {
      const pressDatas: { [key: string]: PressDataTypes } = {};
    
      await Promise.all(
        entirePressIds.flat().map(async (pressId) => {
          pressDatas[pressId] = await getNewListViewContents(pressId);
        })
      );
    
      return pressDatas;
    };
    
    // getNewsListViewContents 함수는 pressId에 관련된 데이터를 Promise 형태로 리턴한다. 
    // entirePressIds는 pressId들로 이루어진 배열이다.
    • getPressDatas에서는 {pressId1: {...}, pressId2: {...}}와 같이 pressId를 key로, 관련 데이터를 value로 하는 객체를 리턴한다.

    • 관련 데이터는 getNewListViewContents에서 받아오며, 해당 함수는 Promise를 반환한다.

    • 왜 이렇게 짠건지 이해할 수 없는 코드이다. map을 사용했지만 콜백함수의 리턴값이 존재하지 않고, 사용되지 않는 값에 Promise.all이 적용되어 있다. Promise.all을 삭제해도 똑같이 동작한다.


  • 수정 후

    export const getPressDatas = async () => {
       const flatEntirePressIds = entirePressIds.flat();
       const pressDatas: { [key: string]: PressDataTypes } = {};
       const newsListViewContentsPromises: Promise<PressDataTypes>[] = [];
    
       flatEntirePressIds.forEach((pressId) => {
         newsListViewContentsPromises.push(getNewListViewContents(pressId));
       });
    
       const newsListViewContents = await Promise.all(newsListViewContentsPromises);
    
       flatEntirePressIds.forEach((pressId, idx) => {
         pressDatas[pressId] = newsListViewContents[idx];
       });
    
       return pressDatas;
    };
    
    • 순서가 중요한 부분이 아니기 때문에, 반복문을 돌면서 하나씩 await하는 것보다 반복문을 끝낸 이후 Promise.all하는 것이 낫다고 판단했다.

    • 먼저 Promise 형태로 된 데이터를 배열에 저장해둔 후, 해당 배열에 await Promise.all을 적용했다.


2. 필요없는 async / await 삭제

  • 수정 전

    const getHTML = async (page: number) => {
       try {
         return await axios.get(
           `/naver-news/main/list.naver?mode=LPOD&mid=sec&sid1=001&sid2=140&oid=001&isYeonhapFlash=Y&page=${page}`
         );
       } catch (error) {
         console.error(error);
       }
    };
    
    export const getYonhapNewsTitles = async (page: number) => {
       const htmlResult = await getHTML(page);
       const newsTitles: string[] = [];
    
       const $ = cheerio.load(htmlResult?.data);
       const $bodyList = $("#main_content > div.list_body.newsflash_body > ul > li");
    
       $bodyList.each(function () {
         const title = $(this).find("a.nclicks\\(cnt_flashart\\) > strong").text();
         newsTitles.push(title);
       });
    
       return newsTitles;
    };
    • getHTML 함수는 axios로 데이터를 받아온다.

    • getYonhapNewsTitles 함수는 getHTML의 리턴값을 이용하여 로직을 수행한다.

    • getHTML, getYonhapNewsTitles 둘 다 async / await이 적용되어 있다.

  • 수정 후

    const getHTML = (page: number) => { // async 삭제
       try {
         return axios.get( // await 삭제
           `/naver-news/main/list.naver?mode=LPOD&mid=sec&sid1=001&sid2=140&oid=001&isYeonhapFlash=Y&page=${page}`
         );
       } catch (error) {
         console.error(error);
         throw error; // 추가된 코드
       }
    };
    
    export const getYonhapNewsTitles = async (page: number) => {
       const htmlResult = await getHTML(page);
       const newsTitles: string[] = [];
    
       const $ = cheerio.load(htmlResult?.data);
       const $bodyList = $("#main_content > div.list_body.newsflash_body > ul > li");
    
       $bodyList.each(function () {
         const title = $(this).find("a.nclicks\\(cnt_flashart\\) > strong").text();
         newsTitles.push(title);
       });
    
       return newsTitles;
    };
    • getYonhapNewsTitles에서 await getHTML(page)와 같이 사용하고 있기 때문에, getHTML에서 Promise 형식으로 값을 리턴해줘도 무방하다.

    • getHTML의 리턴값이 undefined가 되는 것을 방지하기 위해 throw error를 추가해주었다.


3. async 전파 해결

  • 사실 이 문제를 어떻게 불러야할지는 잘 모르겠다. chatgpt가 말하길 async infection, async propagation이라고 부를 수 있다고 한다.

  • 이 용어는 비동기 함수가 호출 체인을 통해 전파되며 다른 함수들도 비동기로 만드는 현상을 말한다.

  • 비동기 함수에서 반환된 Promise를 적절히 처리하려면 호출하는 쪽에서도 해당 Promise를 기다려야 하므로, 비동기성이 코드베이스에 전염된다.

  • 이 현상 때문에 내 코드에서 async / await이 과도하게 사용되었다.

  • 수정 전

    • 이 부분은 수정한 코드가 너무 방대해서 첨부하지 않겠다.

    • 위에서 사용한 getYonhapNewsTitles 함수를 a 함수에서 사용하고, a 함수를 b 함수에서 사용하고, b 함수를 c 함수에서 사용하고... 와 같이 되어서 너무 많은 함수에 async가 붙게 되었다.

    • 이 문제를 해결하기 위해 오래 고민했는데, 단순히 then으로 바꾼다거나 Promise.all을 사용한다고 해결할 수 있는 문제는 아닌 것 같았다.

  • 수정 후

    • 여러 방법을 고민해보고 캠퍼들의 의견도 들어본 결과, 내가 선택한 방법은 아래와 같다.

    • 화면 초기 렌더링 시 getYonhapNewsTitles를 호출한 후 해당 데이터를 store에 저장한다.

    • store의 내용이 변경될 때마다 해당 데이터를 사용하는 컴포넌트를 리렌더링한다.

    • store부분 코드는 아래와 같다. 옵저버 패턴을 적용하고 있다.

     interface StateTypes {
       titles: string[][];
     }
    
     let state: StateTypes = {
       titles: [],
     };
    
     export const yonhapNewsDataStore = createStore(state);
    
     const updateAutoRollingBar = () => { // yonhapNewsDataStore가 갱신되면 실행되는 함수
       const autoRollingBarsContainerLayout = document.getElementById(
         "autoRollingBarsContainerLayout"
       ) as HTMLDivElement;
    
       autoRollingBarsContainerLayout.innerHTML = `<div>${AutoRollingBar({
         page: 1,
         delay: 0,
       })}</div><div>${AutoRollingBar({ page: 2, delay: 1000 })}</div>`;
     }; // AutoRollingBar를 사용하는 곳에서 AutoRollingBar를 리렌더링
    
     yonhapNewsDataStore.subscribe(updateAutoRollingBar);
    • AutoRollingBarsContainer에서 AutoRollingBar를 사용하고 있고, AutoRollingBar에서 store의 데이터를 사용하고 있는 구조이다.

    • 데이터에 변경이 생기면 updateAutoRollingBar 함수를 실행시켜, AutoRollingBarsContainer 내에서 AutoRollingBar를 리렌더링한다.

    • 이 방법으로 AutoRollingBar의 async가 사라지면서 AutoRollingBar를 포함하고있던 컴포넌트부터 async를 모두 삭제할 수 있게 되었다.

    • store를 갱신한 이후로는 비동기가 아닌 동기로 실행되는 것이다.


🍞 코드 수정결과

결과적으로, 아래와 같이 40개 가량의 await를 8개로 줄였다.




🍞 요약

  • async는 비동기 처리를 통해 Promise를 반환함
  • 비동기는 현재 실행 중인 태스크가 종료되기 전에 다음 태스크를 실행하는 방식
  • Promise는 비동기 작업의 상태와 결과값을 가진 포장지 역할
  • await은 Promise의 결과값을 반환함
  • 비동기 처리를 동기처럼 구현하고싶다면 async / await 사용
  • 코드를 순차적으로 실행시키며 성능 이슈가 생길 수 있음

참고 자료

15개의 댓글

comment-user-thumbnail
2023년 10월 24일

서이추요~~^^

1개의 답글
comment-user-thumbnail
2023년 10월 24일

잘 읽었습니다!

1개의 답글
comment-user-thumbnail
2023년 10월 24일

꼼꼼한 리뷰 감사합니다!

1개의 답글
comment-user-thumbnail
2023년 10월 29일

수고하셨습니다 👏

1개의 답글
comment-user-thumbnail
2023년 10월 30일

"1. Promise.all 사용 방법 수정" 의 코드는 길어졌지만 결국 같은 동작을 하는 코드입니다.
순회를 위한 방법들 중 async/await 이 되는게 있고, 안되는게 있습니다.
map 은 async/await 을 순차적으로 처리하지 않고 promise 배열을 바로 리턴하기 때문에 차이가 없어요~

2개의 답글
comment-user-thumbnail
2023년 11월 3일

어차피 Promise로 받아온 값이니까 await 을 쓸 때, 공통으로 쓰이는 곳에서 한번에 처리하는 것 이군요 ㅎㅎ

답글 달기
comment-user-thumbnail
2023년 11월 3일

항상 개념적인 부분만 얕게 dino game 이해하고 넘어갔던 것 같다.

답글 달기