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

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

🍀 비동기(Asynchronous)

동기란 순서대로 한번에 하나의 작업을 수행하는 것이다. 그러므로 비동기란 한번에 여러가지 작업을 동시에 수행한다는 의미이다.

🌱 JS의 비동기 처리 방식

자바스크립트 하나의 콜스택을 갖기 때문에 엔진 자체는 동기 처리만 가능하다. 그렇다면 어떻게 비동기 처리를 가능하게 할까? 자바스크립트는 Web API의 도움을 받아 비동기 처리를 구현하고, 이러한 비동기 처리 메커니즘을 이벤트 루프라고 부른다. (자세한 내용은 "자바스크립트 동작 원리 → 이벤트 루프" 포스팅 참고)

🌱 비동기 처리의 장점

비동기 처리를 사용하면 호출부의 실행결과를 기다리지 않아도 되고 때문에 코드의 처리 속도를 줄여 성능을 향상시킬 수 있다.

🌱 비동기 처리의 특징 & 처리 방식

비동기 코드는 Callstack이 빈 경우에 처리된다. 즉 다른 코드가 모두 실행이 완료된 뒤에 가장 후순위로 처리된다. 비동기 동작을 핸들링할 때 이러한 특성을 이해하지 않고 코드를 작성한다면 아래와 같은 문제를 겪을 수 있다.


function fetchData() {
  let value;

  setTimeout(() => {
    const data = "fetch data";
  }, 1000);  // 서버에서 데이터를 가지고 오는 과정이라 가정
  return data;
}

console.log(fetchData()); // undefined

그러므로 이러한 비동기 동작에 특징을 고려한 처리가 필요한데, 이는 콜백 함수, Promise, async/await 방식으로 가능하다.

🧩 콜백 함수

아래는 콜백 함수를 이용한 예제로, fetchData 함수가 완료될 때 데이터를 반환하는 것이 아니라 콜백 함수가 호출되도록 변경하면 비동기 결과에 대한 핸들링이 가능해진다.

function fetchData(callback) {
  setTimeout(() => {
    const data = "fetch data";
    callback(data); // 데이터를 받은 후에 콜백 호출
  }, 1000);  // 서버에서 데이터를 가지고 오는 과정이라 가정
}

fetchData(function(data) {
  console.log(data); // "fetch data"가 출력됨
});

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

function fetchData(callback) {
  setTimeout(function() {
    const data = "fetch data"; // 서버에서 데이터를 가지고 오는 과정이라 가정
    callback(data);
  }, 1000);
}

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

/*
output: 
Data received 1: fetch data
Data received 2: fetch data
Data received 3: 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 1:", data);
    return fetchData();
  })
  .then(function(data) {
    console.log("Data received 2:", data);
    return fetchData();
  })
  .then(function(data) {
    console.log("Data received 3:", data);
    return fetchData();
  })
  .catch(function(error) {
    console.error(error);
  });

/*
output: 
Data received 1: fetch data
Data received 2: fetch data
Data received 3: fetch data
*/

콜백 지옥의 들여쓰기가 없어져 가독성은 한결 나아졌지만 코드는 여전히 길고 Promise.resolve를 사용하는 것은 번거롭다. 이러한 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 1:", data1);
    const data2 = await fetchData();
    console.log("Data received 2:", data2);
    const data3 = await fetchData();
    console.log("Data received 3:", data3);
  } catch (error) {
    console.error(error);
  }
}

fetchDataAsync();

/*
output: 
Data received 1: fetch data
Data received 2: fetch data
Data received 3: fetch data
*/

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

profile
개발 공부💪🏼

0개의 댓글