[JavaScript] 자바스크립트의 비동기 처리

서동경·2023년 2월 13일
0
post-thumbnail

🍀 비동기(Asynchronous)

비동기를 알아보기전에 동기란 무엇인지 알아보자. 동기란 순서대로 한번에 하나의 작업을 수행한다는 것이다. 자바스크립트 엔진은 싱글 스레드, 즉 하나의 콜스텍만 가지고 있다. 그러므로 엔진 자체는 한번에 여러 개의 작업은 처리하지 못하고 동기로 동작한다.

비동기는 동기의 반대말로 한번에 여러가지 작업을 동시에 수행한다는 의미이다. 비동기 처리를 사용하면 호출부의 실행결과를 기다리지 않아도 되고 때문에 속도면에서 유리하다. 자바스크립트는 웹페이지에서 사용되고 빠른 반응이 필요하기 때문에, 대부분 비동기 처리를 수반한다. 그렇다면 어떻게 비동기 처리를 가능하게 할까? 자바스크립트는 Web API의 도움을 받아 비동기 처리를 구현하고, 이러한 비동기 처리 메커니즘을 이벤트 루프라고 부른다. (자세한 내용은 "자바스크립트 동작 원리 → 이벤트 루프" 포스팅 참고.)

🌱 비동기 처리

🧩 콜백 함수

콜백 함수를 호출하는 함수(고차 함수)에 비동기적 처리를 포함시키면 해당 고차 함수가 실행이 완료되지 않더라도 자바스크립트는 다음 코드를 읽어들일 수 있다.

콜백 함수를 갖는, 그 자체로 비동기적인 함수는 대표적으로 setTimeout, setInterval 등이 있다.

  • setTimeout(callback, delay): 콜백 함수(callback)를 일정한 시간(delay) 뒤에 실행시키는 함수

  • setInterval(callback, interval): 콜백 함수(callback)를 일정한 시간 간격(interval)을 두고 반복해서 실행하는 함수

비동기로 동작하는 함수는 Call Stack으로 바로 들어가서 실행되지 않고, Web API를 거쳐 콜백 함수를 Queue에서 대기시킨다. 대기 중인 콜백 함수의 실행은 Call Stack이 비어있는 상태여야만 비로소 Call Stack으로 넘어가서 완료된다.

다음은 콜백 함수를 이용한 비동기 처리 예제이다.

function fetchData(callback) {
  setTimeout(function() {
    const data = "fetch data";
    callback(data);
  }, 1000);
}

fetchData(function(data) {
  console.log(data);
});

console.log("hi")

/*
output:
hi
fetch data
*/

fetchData 함수는 콜백 함수를 인자로 가지는 고차 함수이다. 내부에 1000ms 이후에 data의 value인 "fetch data"를 출력하는 setTimeout 함수를 가지고 있기 때문에 당연히 비동기적으로 동작한다. 아래에서 fetchData 함수를 먼저 호출하여 실행하지만, 그 결과값을 기다리지 않고 아래 코드을 실행하여 "hi"가 먼저 출력된 후 "fetch data"가 출력되는 것을 확인할 수 있다.


한편 이러한 콜백 함수를 통한 비동기 처리는 코드가 복잡해짐에 따라 너무 많은 중첩문을 가지게 될 수 있고, 이는 코드의 가독성을 떨어뜨리고 유지보수가 어렵게 만든다. 아래는 이러한 "콜백 지옥"을 표현한 예제이다.

function fetchData(callback) {
  setTimeout(function() {
    const data = "fetch data";
    callback(data);
  }, 1000);
}

fetchData(function(data) {
  console.log("Data received:", data);
  fetchData(function(data) {
    console.log("Data received:", data);
    fetchData(function(data) {
      console.log("Data received:", data);
      fetchData(function(data) {
        console.log("Data received:", data);
      });
    });
  });
});

/*
output: 
Data received: fetch data
Data received: fetch data
Data received: fetch data
Data received: fetch data
*/

이와 같이 과도한 들여쓰기로 인해 콜백 지옥이 발생한다. 이러한 콜백 지옥을 피하기 ES6에서 Promise가 등장했다.

🧩 Promise

Promise는 자바스크립트의 비동기 처리에 사용되는 객체로, 내용은 실행되었지만 결과를 아직 반환하지 않은 객체이다. 서버에 요청한 데이터를 가져올 때, 그 처리 여부를 확인하기 위해 주로 사용한다.

Promise 역시 매개 변수로 콜백 함수를 가진다. 콜백 함수는 Microtask Queue라는 대기열을 거쳐 Call Stack이 비어져있을 때 실행된다. 즉 비동기로 동작한다.

new Promise(function(resolve, reject){}) 형태로 Promise를 생성한다. 콜백 함수의 인자는 resolve, rejected 두 가지이다.

호출 즉시 Pending State가 된다. 콜백 함수 내부에서 resolve 메서드를 실행하면 Fullfilled State가 된다. Fullfilled State일 때는 then 메서드를 통해 Promise의 비동기 처리 결과값을 반환받을 수 있다. 반면 reject 메서드를 실행하면 Rejected State가 된다. Rejected State일 때는 catch 메서드를 통해 Promise의 비동기 처리 결과값을 반환받을 수 있다.

정리하면 다음과 같다.

🔎 Promise의 3가지 상태

  • Pending State

    : new Promise(callback)를 통해 메서드를 생성하는 때의 초기 상태인 대기 상태이다.

  • Fulfilled State

    : resolve 메서드를 실행했을 때, 즉 이행 상태이다.

  • Rejected State

    : reject 메서드를 실행했을 때, 즉 실패 상태이다.

🔎 Promise를 제공하는 메서드

resolve와 reject는 Promise 콜백 함수의 매개 변수인 동시에 Promise를 제공하는 메서드이다.

  • Promise.resolve

    : 이행 상태일 때 처리 결과값을 가지는 Promise를 제공하는 메서드이다.
    : 인자로 Fullfilled State일 때의 처리 결과값을 정의할 수 있다.
    : 인자를 굳이 사용하지 않고 비동기 작업의 완료 여부를 나타내는 의미로 사용할 수 있다.

  • Promise.reject

    : 실패 상태일 때 처리 결과값을 가지는 Promise를 제공하는 메서드이다.
    : 인자로 Rejected State일 때의 처리 결과값을 정의할 수 있다.
    : 인자를 굳이 사용하지 않고 비동기 작업의 완료 여부를 나타내는 용도로 사용할 수 있다.

🔎 Promise를 소비하는 메서드

Promise는 결과를 반환하지는 않기 때문에 Promise를 소비하는 메서드로 결과를 얻는다.

  • Promise.then

    : Fullfiled State가 되면 해당 메서드를 실행하여 처리 결과값을 반환받을 수 있다.
    : 두 개의 콜백 함수를 인자로 가질 수 있다. Fullfilled State일 때 실행될 콜백 함수를 첫 번째 인자에, Rejected State일 때 실행될 콜백 함수를 두 번째 인자에 정의할 수 있다. 인자를 사용하지 않으면 Fullfilled State가 되어도 처리 결과값을 반환받을 뿐 어떤 동작을 하지는 않는다.

  • Promise.catch

    : Rejected State가 되면 해당 메서드를 실행하여 처리 결과값을 반환받을 수 있다.
    : Rejected State일 때 실행될 콜백 함수를 인자에 정의할 수 있다.

Promise.then이 두 번째 콜백 함수까지 사용할 경우 Promise.catch의 역할을 대신할 수 있지만 Promise.catch를 사용하는 것이 가독성 측면에서는 유리하다.

📌 Promise 메서드 체인

Promise 메서드 체인은 여러 개의 Promise 메서드 호출을 연속적으로 연결하여 사용하는 방식이다.
Promise 체인에서는 then() 메소드를 사용하여 이전에 반환된 Promise 객체에 대한 처리를 한다. then의 첫번째 인자와 두번째 인자를 통해, 성공적으로 처리될 경우와 처리되지 않을 경우 실행할 콜백 함수를 정의할 수 있다.
추가로 catch() 메서드를 통해 이전 Promise 체인에서 발생한 오류를 처리하고, finally() 메서드를 Promise 체인의 마지막에 호출하여 마무리 작업을 하는 것도 가능하다.


그럼 이번에는 "콜백 지옥문"을 Promise로 사용하여 수정해보겠다.

function fetchData() {
  return new Promise(function(resolve, reject) {
    setTimeout(function() {
      const data = "fetch data";
      resolve(data);
    }, 1000);
  });
}

fetchData()
  .then(function(data) {
    console.log("Data received:", data);
    return fetchData();
  })
  .then(function(data) {
    console.log("Data received:", data);
    return fetchData();
  })
  .then(function(data) {
    console.log("Data received:", data);
    return fetchData();
  })
  .then(function(data) {
    console.log("Data received:", data);
  })
  .catch(function(error) {
    console.error(error);
  });

/*
output: 
Data received: fetch data
Data received: fetch data
Data received: fetch data
Data received: fetch data
*/

출력 결과는 동일하고 콜백 지옥도 사라졌다. 근데 이번엔 "then 지옥"이 형성되었다. 들여쓰기는 없어졌지만 코드는 더 길어졌다. 다행히도 then 지옥은 ES8에 등장한 async/await를 사용하여 해결할 수 있다.

🧩 async/await

최신 문법인 asyncawait를 사용하면 콜백 지옥이나 then 지옥을 모두 해결할 수 있다.

🔎 async

일반적인 함수 선언 앞에 async를 붙여주면 AsyncFunction 객체를 사용하는 하나의 비동기 함수를 정의할 수 있다.

이 비동기 함수는 항상 Promise를 반환한다.


asyncPromise.resolve 메서드를 대체할 수 있다.

function getNumber() {
  return Promise.resolve(10);
}

async function getNumberAsync() {
  return 10;
}

console.log(func()); // Promise { 1 }
console.log(asyncFunc()); // Promise { 1 }

async를 사용하면 항상 Promise를 반환하기 때문에Promise.resolve를 통해 Fullfilled State의 결과값을 반환받는 번거로움을 감수할 필요가 없다.

🔎 await

await 연산자는 async로 선언된 함수 내부에서만 사용 가능하다.

이 연산자는 Promise를 처리하고 그 결과를 기다린다. 이는 비동기 방식을 동기 방식으로 사용할 수 있도록 해준다. 즉 Promise.then의 기능에 결과를 기다리는 기능까지 추가된 것이다.

async 함수 내부 await 뒤에 위치하는 코드는 결과가 반환될 때까지 기다리지만, async 함수 외부의 코드는 영향받지 않고 그대로 실행된다.


awaitPromise.then 메서드를 대신 사용하여 비동기 처리를 할 수 있다.

// Promise.then을 사용하는 경우
function addOne(number) {
  return Promise.resolve(number + 1);
}

function addTwo(number) {
  return Promise.resolve(number + 2);
}

addOne(10)
  .then(addTwo)
  .then(console.log); // 13

// async/await를 사용하는 경우
async function addOneAsync(number) {
  return number + 1;
}

async function addTwoAsync(number) {
  return number + 2;
}

async function sum() {
  const number = await addOneAsync(10);
  const result = await addTwoAsync(number);
  console.log(result); // 13
} 

그렇다면 "then 지옥문"을 await로 수정해보겠다.

function fetchData() {
  return new Promise(function(resolve, reject) {
    setTimeout(function() {
      const data = "fetch data";
      resolve(data);
    }, 1000);
  });
}

async function fetchDataAsync() {
  try {
    const data1 = await fetchData();
    console.log("Data received:", data1);
    const data2 = await fetchData();
    console.log("Data received:", data2);
    const data3 = await fetchData();
    console.log("Data received:", data3);
    const data4 = await fetchData();
    console.log("Data received:", data4);
  } catch (error) {
    console.error(error);
  }
}

fetchDataAsync();

/*
output: 
Data received: fetch data
Data received: fetch data
Data received: fetch data
Data received: fetch data
*/

Promise.then 대신 await를 사용한 코드로, 똑같은 출력을 확인할 수 있다. 무엇보다 Promise 메서드 체인이 없기 때문에 조금 더 직관적인 코드 작성이 가능하다.

profile
개발 공부💪🏼

0개의 댓글