더 이상 async / await을 어려워하고 싶지 않다

surinkwon·2023년 12월 21일
0

JavaScript를 사용하면서 헷갈렸던 것을 고르라고 하면 고민 없이 비동기 처리를 가장 먼저 말할 것이다. 그중에서도 Promise가 무엇인지 이해가 잘되지 않았다. 시간이 지나면서 경험적으로 알게 되었지만, 여전히 명확하게 말하기는 어려운 것 같다. 또 Promise와 함께 빼놓을 수 없는 것이 async/await 문법이다. 멋모르고 사용할 때는 잘 사용하고 있는지 파악하기도 힘들었고, 동작하지 않으면 어떤 걸 잘못해서 동작하지 않는지도 몰랐다.

그래서 이번 기회에 애매하게 알고 있던 Promise 및 비동기 문법을 정리 해보고자 한다.

콜백 지옥과 Promise의 등장

JavaScript를 사용하는 사람들이라면 누구나 한 번쯤은 ‘콜백 지옥’이라는 단어를 들어봤을 것이다. (콜백함수에 류가 아도겐하는 짤과 함께…) 비동기 처리 결과를 해당 비동기 처리 함수의 외부에서 바로 사용할 수 없기 때문에 비동기 작업 이후 실행할 로직을 내부로 넘겨주는 과정에서 콜백 지옥이 생긴다. 비동기 결과를 가지고 다시 비동기 작업을 해야 할 경우 콜백 지옥은 더 깊어지고 코드를 파악하기도 어려워진다. 이뿐만 아니라 콜백 함수로 비동기를 처리하는 방법에서는 에러 처리가 힘들다는 문제도 있다.(더 자세한 내용은 모던 자바스크립트 Deep Dive 참고)

이러한 전통적인 비동기 처리의 문제를 해결하고자 ES6에서 Promise가 등장했다.

Promise 객체란

그렇다면 Promise 객체는 무엇일까? MDN애서는 다음과 같이 나와있다.

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

미래의 어떤 시점에 결과를 제공하겠다는 '약속'(프로미스)을 반환합니다.*

설명된 것처럼 Promise는 당장 사용할 수 있는 값이 아니라, 비동기 작업 이후 사용할 수 있는 결과를 반환할 것임을 나타내는 객체이다. Promise 생성자는 resolvereject 인수를 전달할 실행 함수를 매개변수로 받는다.

const promise = new Promise((resolve, reject) => {});
console.log(promise);

위 코드를 실행해보면 다음과 같이 나온다.

이를 통해 알 수 있듯이 Promise 객체는 상태와 결과를 가진다. 위에서는 아무 것도 resolve 하거나 reject하지 않았기 때문에 결과 값이 undefined이며 pending 상태이다.

실행 함수 내부에서 resolve하면 상태는 연산이 성공적으로 완료되었다는 fulfilled가 되고, reject하면 연산이 실패했다는 rejected가 된다.

Promise.then

Promise에 결과 값이 존재하기는 하지만 이를 위처럼 단순히 변수로 받아서 사용할 수는 없다. 그러면 결과 값을 사용하려면 어떻게 해야 할까?

바로 then메서드를 사용하면 된다.

then은 두 개의 매개변수를 받을 수 있다. 처음 매개변수는 Promise가 resolve되었을 때 호출되는 핸들러 함수이며, 두 번째 매개변수는 Promise가 reject 되었을 때 호출되는 핸들러 함수이다.

new Promise((resolve, reject) => {
  resolve("resolve 결과");
  reject("reject 결과");
}).then(
  (resolvedResult) => {
    console.log(resolvedResult + " 이행됨");
  },
  (rejectedResult) => {
    console.log(rejectedResult + " 거부됨");
  }
);

// 실행 결과: resolve 결과 이행됨
// resolve() 부분을 주석처리하고 실행하면 'reject 결과 거부됨'이 찍힌다.

이처럼 then 메서드를 통해 이행 및 거부 결과를 받아서 처리할 수 있다. 또 then에서 반환되는 값은 Promise 객체이기 때문에 then을 체이닝할 수도 있다.

핸들러 함수에서의 return 값은 then에서 반환되는 Promise의 결과값이 된다. 만약 반환하는 값이 없다면 undefined가 결과 값이 된다.

Promise.catch

Promise가 거부되었을 때 이행할 핸들러 함수를 등록하는 메서드이다.

then 메서드를 통해 이행 및 거부 시 행할 동작을 모두 처리할 수 있기 때문에 사실 catch를 사용하지 않아도 된다. 하지만 일반적으로는 catch를 이용해서 에러가 발생하거나, Promise가 거부되었을 때 작업을 수행한다.

catch역시 then처럼 반환값은 Promise 객체이다. 그런데 이 때 Promise는 rejected 될 Promise가 아니라 resolved 될 Promise이다. reject하는 새로운 Promise를 만들어서 반환하거나, 내부 로직에서 에러가 발생하지 않는 이상 반환값은 곧 이행할 Promise이다.

Promise.reject("거부")
  .catch((result) => result + " 첫 catch에서 반환")
  .then((result) => console.log(result + " then에서 받음"))
  .catch((result) => console.log(result + " 두 번째 catch에서 받음"));

위 코드의 실행 결과는 거부 첫 catch에서 반환 then에서 받음 이다. 첫 번째 catch에서 값을 반환했을 때 그 다음 catch로 이어지는 것이 아니라 then에서 받은 것을 확인할 수 있으며 이를 통해 반환된 Promise가 이행 상태임을 알 수 있다.

이 이외에도 promise.finally를 통해서 이행, 거부 상관 없이 항상 실행할 작업을 등록할 수 있다.

then, catch 동작

thencatch의 동작을 정리해보면

둘 다 Promise가 이행 또는 거부되었을 때 호출될 핸들러 함수를 등록하는 역할을 한다. Promise가 web APIs의 도움을 받아 비동기적으로 실행되고 결과 값이 셋팅되면 등록된 핸들러 함수가 마이크로 태스크 큐에서 호출되기를 기다린다.

async / await

이제 정말 async / await에 대한 얘기를 해보자.

Promise로 인해 비동기 처리의 흐름을 파악하기 쉬워졌지만 여전히 콜백 함수를 메서드의 매개변수로 전달해야 한다. 그렇지만 async / await을 이용하면 콜백 함수 전달 없이 비동기를 동기처럼 처리할 수 있다.

async 함수

async 함수는 비동기로 동작하는 함수이며 항상 Promise를 반환한다.

async function myAsync() {
  return 1;
}

const value = myAsync();
console.log(value);

위의 코드를 실행하면 다음과 같이 fulfilled 상태의 결과 값이 1인 Promise가 반환 되는 것을 확인할 수 있다. myAsync()에서 반환하는 값이 Promise가 아닌데 Promise로 감싸져서 반환되는 것이다.

await

awaitasync 함수 내부에서만 사용할 수 있는 구문이다. MDN에 따르면 async 함수 내부에서 await을 만나면 해당 async 함수의 실행이 정지가 된다. 이후 await이 기다리는 Promise가 이행되면 해당 결과 값을 반환하고, 거부된다면 해당 결과 값을 throw한다. 만약 await이 기다리는 값이 Promise가 아니라면 이행된 Promise로 변환하고 결과 값을 반환한다. 따라서 await으로 기다려서 가져오는 값은 Promise.then() 으로 넘겨지는 핸들러 함수에서 인자로 받는 값과 같다고 할 수 있다.

async 함수는 첫 번째 await까지는 동기적으로 실행되며 나머지 부분은 마이크로 태스크로 옮겨져 실행된다.

코드로 살펴보기

글로만 읽어서는 잘 모르겠어서 코드를 작성해봤다.

async function get1() {
  console.log("start getting one");

  const one = await new Promise((resolve) => {
    console.log("got one");
    resolve(1);
  });

  console.log("end getting one");

  return one;
}

function get2() {
  console.log("get two");
  return 2;
}

async function get3() {
  console.log("get three");
  return 3;
}

async function getNumbers() {
  console.log("start getting numbers");

  const one = await get1();
  console.log(one);

  const two = await get2();

  const three = get3();
  console.log(three);

  return [one, two, three];
}

getNumbers();
console.log("got all numbers");

위 코드를 실행시키면 다음과 같이 출력 된다.

start getting numbers
start getting one
got one
got all numbers
end getting one
1
get two
get three
Promise { 3 }
  1. getNumbers를 호출하면 "start getting numbers"가 출력 되고, 첫 번째 await을 만나 get1 함수를 호출한다.
  2. get1 이 호출되었으므로 get1 의 실행 문맥으로 옮겨가 내부에서 "start getting one"을 출력하고 다시 await을 만나 Promise가 이행되기를 기다린다.
  3. 생성된 Promise 내에서 "got one"이 출력 되고 1을 결과 값으로 해 Promise가 이행된다.
  4. async 함수는 첫 번째 await 까지만 동기적으로 실행되기 때문에 get1 함수의 나머지 부분과 getNumbers 함수의 나머지 부분은 마이크로 태스크로 옮겨가고, 전역 실행 문맥으로 돌아가 "got all numbers”가 출력 된다.
  5. await은 비동기 함수 내부에서의 동기적인 실행을 보장하므로 getNumbers의 다음 코드들은 get1 이 이행되기 전까지 실행되지 않는다. 따라서 get1 에서 "end getting one"이 출력되고 1을 반환한다.
  6. one에는 get1 에서 이행된 값인 1이 담기고 “1”이 출력된다.
  7. get2가 호출되어 내부에서 "get two”이 출력되고 2를 반환한다. get2는 비동기 함수가 아니지만 await으로 인해 이행된 Promise로 변환되고, 그 결과 값인 2가 two에 담긴다.
  8. get3가 호출되거 내부에서 "get three”가 출력되고 3을 반환한다. await 키워드가 없기 때문에 three에는 결과 값을 3으로 하는, 이행된 Promise 객체가 담긴다.

정리

  • async 함수는 Promise를 반환한다.
  • async 함수는 첫 번째 await까지만 동기적으로 실행되고, 나머지는 마이크로 태스크로서 비동기적으로 실행된다.
  • awaitasync 함수 내부에서의 동기적인 실행을 보장하며, Promise에서 이행된 결과 값을 기다리고 가져온다. 기다리는 것이 Promise가 아니라면 Promise로 감싸서 해당 값을 이행한다.

기타

중간에 마이크로 태스크라는 것이 나왔는데, 이는 이벤트 루프로 관리되는 태스크 중 하나이다. 마이크로 태스크는 마이크로 태스크 큐에서 대기하며, 일반 태스크보다 우선순위가 높아서 먼저 콜스택에 추가된다.

setTimeout(() => {
  console.log("timeout이 나중에");
}, 0);

new Promise(() => {
  console.log("promise가 먼저");
});

// 출력:
// promise가 먼저
// timeout이 나중에

간단한 예제를 보면 setTimeout이 먼저 호출되었음에도 Promise 내부가 먼저 실행되는 것을 확인할 수 있다.

(태스크와 마이크로태스크의 차이를 더 자세하게 알고 싶으면 JavaScript의 queueMicrotask()와 함께 마이크로태스크 사용하기를 참고)

참고자료

profile
함께 일하고 싶은 프론트엔드 개발자가 되고 싶습니다.

0개의 댓글

관련 채용 정보