ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 비동기 처리 - 모던 자바스크립트 | 김민준
    Javascript/ECMAScript 2015 (ES6) 2020. 6. 25. 23:19
    반응형

    [출처 : https://learnjs.vlpt.us]

    비동기 처리 다루기

    자바스크립트의 동기적 처리와 비동기 처리에 대해서 알아봅시다.

    만약 작업을 동기적으로 처리한다면 작업이 끝날 때까지 기다리는 동안 중지 상태가 되기 때문에 다른 작업을 할 수 없습니다. 그리고 작업이 끝나야 비로소 그 다음 예정된 작업을 할 수 있죠. 하지만 이를 비동기적으로 처리를 한다면 흐름이 멈추지 않기 때문에 동시에 여러 가지 작업을 처리할 수도 있고, 기다리는 과정에서 다른 함수도 호출할 수 있습니다.

     

    그러면, 한번 코드를 보고 이해해봅시다.

     

    연산량이 많은 작업을 처리하는 함수를 만들어보겠습니다.

    function work() {
      const start = Date.now();
      for (let i = 0; i < 1000000000; i++) {}
      const end = Date.now();
      console.log(end - start + 'ms');
    }
    
    work();
    console.log('다음 작업');

    여기서 Date.now 는 현재 시간을 숫자 형태로 가져오는 자바스크립트 내장 함수입니다. 위 work 함수는, 1,000,000,000 번 루프를 돌고, 이 작업이 얼마나 걸렸는지 알려줍니다.

     

    지금은 work() 함수가 호출되면, for 문이 돌아갈 때는 다른 작업은 처리하지 않고 온전히 for 문만 실행하고 있습니다.

    만약 이 작업이 진행되는 동안 다른 작업도 하고 싶다면 함수를 비동기 형태로 전환을 해주어야하는데요, 그렇게 하기 위해서는 setTimeout 이라는 함수를 사용해주어야합니다.

     

    코드를 다음과 같이 수정해보세요.

    function work() {
      setTimeout(() => {
        const start = Date.now();
        for (let i = 0; i < 1000000000; i++) {}
        const end = Date.now();
        console.log(end - start + 'ms');
      }, 0);
    }
    
    console.log('작업 시작!');
    work();
    console.log('다음 작업');

    setTimeout 함수는 첫번째 파라미터에 넣는 함수를 두번째 파라미터에 넣은 시간(ms 단위)이 흐른 후 호출해줍니다. 지금은 두번째 파라미터에 0을 넣었습니다. 따라서, 이 함수는 바로 실행이 됩니다. 0ms 이후에 실행한다는 의미이지만 실제로는 4ms 이후에 실행됩니다 (참고). 이렇게 setTimeout 을 사용하면 우리가 정한 작업이 백그라운드에서 수행되기 때문에 기존의 코드 흐름을 막지 않고 동시에 다른 작업들을 진행 할 수 있습니다.

     

    결과물을 보면, 작업이 시작 되고 나서, for 루프가 돌아가는 동안 다음 작업도 실행되고, for 루프가 끝나고 나서 몇 ms 걸렸는지 나타나고 있습니다.

     

    그렇다면, 만약에 work 함수가 끝난 다음에 어떤 작업을 처리하고 싶다면 어떻게 해야 할까요? 이럴 땐, 콜백 함수를 파라미터로 전달해주면 됩니다. 콜백 함수란, 함수 타입의 값을 파라미터로 넘겨줘서, 파라미터로 받은 함수를 특정 작업이 끝나고 호출을 해주는 것을 의미합니다.

    function work(callback) {
      setTimeout(() => {
        const start = Date.now();
        for (let i = 0; i < 1000000000; i++) {}
        const end = Date.now();
        console.log(end - start + 'ms');
        callback();
      }, 0);
    }
    
    console.log('작업 시작!');
    work(() => {
      console.log('작업이 끝났어요!')
    });
    console.log('다음 작업');

    다음과 같은 작업들은 주로 비동기적으로 처리하게 됩니다.

     

    · Ajax Web API 요청 : 만약 서버쪽에서 데이터를 받와아야 할 때는, 요청을 하고 서버에서 응답을 할 때 까지 대기를 해야 되기 때문에 작업을 비동기적으로 처리합니다.

    · 파일 읽기 : 주로 서버 쪽에서 파일을 읽어야 하는 상황에는 비동기적으로 처리합니다.

    · 암호화/복호화 : 암호화/복호화를 할 때에도 바로 처리가 되지 않고, 시간이 어느정도 걸리는 경우가 있기 때문에 비동기적으로 처리합니다.

    · 작업 예약 : 단순히 어떤 작업을 몇초 후에 스케쥴링 해야 하는 상황에는, setTimeout 을 사용하여 비동기적으로 처리합니다.

     

    비동기 작업을 다룰 때에는 callback 함수 외에도 Promise, 그리고 async/await 라는 문법을 사용하여 처리 할 수 있습니다. 이번 챕터에서는 이에 대하여 알아보게 됩니다.

     

     

    Promise

    프로미스는 비동기 작업을 조금 더 편하게 처리 할 수 있도록 ES6 에 도입된 기능입니다. 이전에는 비동기 작업을 처리 할 때에는 콜백 함수로 처리를 해야 했었는데요, 콜백 함수로 처리를 하게 된다면 비동기 작업이 많아질 경우 코드가 쉽게 난잡해지게 되었습니다.

     

    한번 숫자 n 을 파라미터로 받아와서 다섯번에 걸쳐 1초마다 1씩 더해서 출력하는 작업을 setTimeout 으로 구현해봅시다.

    function increaseAndPrint(n, callback) {
      setTimeout(() => {
        const increased = n + 1;
        console.log(increased);
        if (callback) {
          callback(increased);
        }
      }, 1000);
    }
    
    increaseAndPrint(0, n => {
      increaseAndPrint(n, n => {
        increaseAndPrint(n, n => {
          increaseAndPrint(n, n => {
            increaseAndPrint(n, n => {
              console.log('끝!');
            });
          });
        });
      });
    });

    코드 읽기가 복잡하죠? 이런 식의 코드를 Callback Hell (콜백지옥) 이라고 부릅니다.

     

    비동기적으로 처리해야 하는 일이 많아질수록, 코드의 깊이가 계속 깊어지는 현상이 있는데요, Promise 를 사용하면 이렇게 코드의 깊이가 깊어지는 현상을 방지 할 수 있습니다.

     

    Promise 만들기

    Promise 는 다음과 같이 만듭니다.

    const myPromise = new Promise((resolve, reject) => {
      // 구현..
    })

    Promise 는 성공 할 수도 있고, 실패 할 수도 있습니다. 성공 할 때에는 resolve 를 호출해주면 되고, 실패할 때에는 reject 를 호출해주면 됩니다. 지금 당장은 실패하는 상황은 고려하지 않고, 1초 뒤에 성공시키는 상황에 대해서만 구현을 해보겠습니다.

    const myPromise = new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve(1);
      }, 1000);
    });
    
    myPromise
      .then(n => {
        console.log(n);
      });

    resolve 를 호출 할 때 특정 값을 파라미터로 넣어주면, 이 값을 작업이 끝나고 나서 사용 할 수 있습니다. 작업이 끝나고 나서 또 다른 작업을 해야 할 때에는 Promise 뒤에 .then(...) 을 붙여서 사용하면 됩니다.

     

    이번에는, 1초뒤에 실패되게끔 해봅시다.

    const myPromise = new Promise((resolve, reject) => {
      setTimeout(() => {
        reject(new Error());
      }, 1000);
    });
    
    myPromise
      .then(n => {
        console.log(n);
      })
      .catch(error => {
        console.log(error);
      });

    실패하는 상황에서는 reject 를 사용하고, .catch 를 통하여 실패했을시 수행 할 작업을 설정 할 수 있습니다.

     

    이제, Promise 를 만드는 함수를 작성해봅시다.

    function increaseAndPrint(n) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          const value = n + 1;
          if (value === 5) {
            const error = new Error();
            error.name = 'ValueIsFiveError';  // 에러의 이름을 지정할 수 있다.
            reject(error);
            return;
          }
          console.log(value);
          resolve(value);
        }, 1000);
      });
    }
    
    increaseAndPrint(0)
      .then((n) => {
        console.log('result: ', n);
      })

    여기까지만 보면, 결국 함수를 전달하는건데, 뭐가 다르지 싶을수도 있습니다. 하지만, Promise 의 속성 중에는, 만약 then 내부에 넣은 함수에서 또 Promise 를 리턴하게 된다면, 연달아서 사용 할 수 있습니다. 다음과 같이 말이죠.

    function increaseAndPrint(n) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          const value = n + 1;
          if (value === 5) {
            const error = new Error();
            error.name = 'ValueIsFiveError';
            reject(error);
            return;
          }
          console.log(value);
          resolve(value);
        }, 1000);
      });
    }
    
    increaseAndPrint(0)
      .then(n => {
        return increaseAndPrint(n);
      })
      .then(n => {
        return increaseAndPrint(n);
      })
      .then(n => {
        return increaseAndPrint(n);
      })
      .then(n => {
        return increaseAndPrint(n);
      })
      .then(n => {
        return increaseAndPrint(n);
      })
      .catch(e => {
        console.error(e);
      });

    위 코드는 이렇게 정리를 할 수 있습니다.

    function increaseAndPrint(n) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          const value = n + 1;
          if (value === 5) {
            const error = new Error();
            error.name = 'ValueIsFiveError';
            reject(error);
            return;
          }
          console.log(value);
          resolve(value);
        }, 1000);
      });
    }
    
    increaseAndPrint(0)
      .then(increaseAndPrint)
      .then(increaseAndPrint)
      .then(increaseAndPrint)
      .then(increaseAndPrint)
      .then(increaseAndPrint)
      .catch(e => {
        console.error(e);
      });

    Promise 를 사용하면, 비동기 작업의 개수가 많아져도 코드의 깊이가 깊어지지 않게 됩니다.

     

    하지만, 이것도 불편한점이 있긴 합니다. 에러를 잡을 때 몇번째에서 발생했는지 알아내기도 어렵고 특정 조건에 따라 분기를 나누는 작업도 어렵고, 특정 값을 공유해가면서 작업을 처리하기도 까다롭습니다. 다음 섹션에서 배울 async/await 을 사용하면, 이러한 문제점을 깔끔하게 해결 할 수 있습니다.

     

     

    async/await

    async/await 문법은 ES8에 해당하는 문법으로서, Promise 를 더욱 쉽게 사용 할 수 있게 해줍니다.

     

    기본적인 사용법을 알아봅시다.

    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    async function process() {
      console.log('안녕하세요!');
      await sleep(1000); // 1초 대기하고
      console.log('반갑습니다!');
    }
    
    process();

    async/await 문법을 사용할 때에는, 함수를 선언 할 때 함수의 앞부분에 async 키워드를 붙여주세요. 그리고 Promise 를 만드는 함수의 앞부분에 await 을 넣어주면 해당 프로미스가 끝날때까지 기다렸다가 다음 작업을 수행 할 수 있습니다.

     

    위 코드에서는 sleep 이라는 함수를 만들어서 파라미터로 넣어준 시간 만큼 기다리는 Promise 를 만들고, 이를 process 함수에서 사용해주었습니다.

     

    함수에서 async 를 사용하면, 해당 함수는 결과값으로 Promise 를 반환하게 됩니다. 따라서 다음과 같이 코드를 작성 할 수 있습니다.

    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    async function process() {
      console.log('안녕하세요!');
      await sleep(1000); // 1초 대기하고
      console.log('반갑습니다!');
    }
    
    process().then(() => {
      console.log('작업이 끝났어요!');
    });

    async 함수에서 에러를 발생 시킬때에는 throw 를 사용하고, 에러를 잡아낼 때에는 try/catch 문을 사용합니다.

    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    async function makeError() {
      await sleep(1000);
      const error = new Error();
      throw error;
    }
    
    async function process() {
      try {
        await makeError();
      } catch (e) {
        console.error(e);
      }
    }
    
    process();

     

    이번에는, 비동기 함수를 몇개 더 만들어보겠습니다.이번에는, 비동기 함수를 몇개 더 만들어보겠습니다.

    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    const getDog = async () => {
      await sleep(1000);
      return '멍멍이';
    };
    
    const getRabbit = async () => {
      await sleep(500);
      return '토끼';
    };
    const getTurtle = async () => {
      await sleep(3000);
      return '거북이';
    };
    
    async function process() {
      const dog = await getDog();
      console.log(dog);     // "멍멍이"
      const rabbit = await getRabbit();
      console.log(rabbit);  // "토끼"
      const turtle = await getTurtle();
      console.log(turtle);  // "거북이"
    }
    
    process();

     

    현재 위 코드에서는 getDog 는 1초, getRabbit 은 0.5초, getTurtle 은 3초가 걸리고 있습니다. 이 함수들을 process 함수에서 연달아서 사용하게 되면서, process 함수가 실행되는 총 시간은 4.5초가 됩니다.

     

    지금은 getDog -> getRabbit -> getTurtle 순서대로 실행이 되고 있는데요, 하나가 끝나야 다음 작업이 시작하고 있는데,

    동시에 작업을 시작하고 싶다면 다음과 같이 Promise.all 을 사용해야합니다.

    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    const getDog = async () => {
      await sleep(1000);
      return '멍멍이';
    };
    
    const getRabbit = async () => {
      await sleep(500);
      return '토끼';
    };
    const getTurtle = async () => {
      await sleep(3000);
      return '거북이';
    };
    
    async function process() {
      const results = await Promise.all([getDog(), getRabbit(), getTurtle()]);
      console.log(results);  // ["멍멍이", "토끼", "거북이"]
    }
    
    process();

    만약에 여기서 배열 비구조화 할당 문법을 사용한다면 각 결과값을 따로 따로 추출해서 조회 할 수 있습니다.

    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    const getDog = async () => {
      await sleep(1000);
      return '멍멍이';
    };
    
    const getRabbit = async () => {
      await sleep(500);
      return '토끼';
    };
    const getTurtle = async () => {
      await sleep(3000);
      return '거북이';
    };
    
    async function process() {
      const [dog, rabbit, turtle] = await Promise.all([
        getDog(),
        getRabbit(),
        getTurtle()
      ]);
      console.log(dog);     // "멍멍이"
      console.log(rabbit);  // "토끼"
      console.log(turtle);  // "거북이"
    }
    
    process();

    Promise.all 를 사용 할 때에는, 등록한 프로미스 중 하나라도 실패하면, 모든게 실패 한 것으로 간주합니다.

     

    이번에는 Promise.race 라는 것에 대해서 알아봅시다.

    이 함수는 Promise.all 과 달리, 여러개의 프로미스를 등록해서 실행했을 때 가장 빨리 끝난것 하나만의 결과값을 가져옵니다.

    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    const getDog = async () => {
      await sleep(1000);
      return '멍멍이';
    };
    
    const getRabbit = async () => {
      await sleep(500);
      return '토끼';
    };
    const getTurtle = async () => {
      await sleep(3000);
      return '거북이';
    };
    
    async function process() {
      const first = await Promise.race([
        getDog(),
        getRabbit(),
        getTurtle()
      ]);
      console.log(first);  // "토끼"
    }
    
    process();

    Promise.race 의 경우엔 가장 다른 Promise 가 먼저 성공하기 전에 가장 먼저 끝난 Promise 가 실패하면 이를 실패로 간주합니다.

     

    따라서, 현재 위의 코드에서 getRabbit 에서 에러를 발생시킨다면 에러를 잡아낼 수 있지만, getTurtle 이나 getDog 에서 발생한 에러는 무시됩니다.

    반응형

    댓글

Luster Sun