Promise와 async/await

hangkemiii·2022년 10월 10일
1

Javascript

목록 보기
9/11
post-thumbnail
post-custom-banner

엥 Promise는 약속 아니냐?

계속해서 자바스크립트 공부에 매진하던 중, Promise라는 아주 낯설고도 어색한 친구를 만나게 되었다. 그러면서 이 Promise와 async / await 사이에 연관성이 있다는 사실 또한 알게 되었다. async / await은 기업협업 중 레거시 코드나, 기타 강의에서 많이 봤던 친구들이지만.. 사실 내가 써본 적은 한번도 없었다. 왜 안썼을까? 모르는데 어떻게 써요.. 그래서 오늘 이 두 가지 개념에 대해 확실히 짚고 넘어가 보고자 한다.

Promise?

우선, 우리의 친구 MDN이 정의한 Promise에 대해 살펴보자.

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

위 설명을 보게 되면, Promise는 객체의 일종이고 비동기 작업, 즉 작업 종료 여부에 관계없이 다음 작업을 수행하는 병렬 수행 작업이 맞이할 미래의 완료, 실패와 결과 값을 나타낸다는 뜻인 것 같다. 이렇게만 봐서는 아직은 Promise에 대해 감을 잡긴 어렵다.

우리는 Promise가 등장하기 이전에, 비동기 작업을 처리할 때 콜백 함수를 사용했었다. 그러면 왜 계속 콜백 함수를 사용하지 않고 Promise가 나타나게 되었을까? 그건 바로 콜백 지옥(Callback Hell) 때문이다.

콜백 함수

콜백 함수란, 다른 함수에 인자로 전달되는 함수이며, 외부 함수 내에서 일종의 루틴 또는 동작을 실행하기 위해 호출되는 함수를 뜻한다. 즉, 코드를 통해 명시적으로 호출하는 함수가 아니라, 어떤 이벤트가 발생했을때, 혹은 특정 시점에 도달했을 때에 시스템에서 호출하는 함수이다.

초기의 자바스크립트는 코드의 복잡도가 높지 않아서 콜백 함수를 중첩하는 일이 드물었지만, 현재는 규모가 커졌기 때문에 위 사진처럼 비동기 작업이 많아질 수록 콜백 함수의 nesting이 저렇게 깊고 깊게 들어가게 되는 것이다. 이것을 바로 콜백 지옥이라고 한다.

이를 다른 말로 Pyramid of Doom이라고도 하며, 이러한 프로그래밍은 가독성이 몹시 떨어지고 추후에 코드를 수정할 일이 생길 경우, 이를 몹시 어렵게 한다.

그래서 ES6에서 추가된 개념이 바로 이 Promise인 것이다. Promise는 바로 이 코드의 깊이가 깊어지는 현상을 방지할 수 있다.

Promise 생성

// Promise 객체의 생성
const promise = new Promise((resolve, reject) => {
  // 비동기 작업을 수행한다.

  if (/* 비동기 작업 수행 성공 */) {
    resolve('result');
  }
  else { /* 비동기 작업 수행 실패 */
    reject('failure reason');
  }
});

Promise는 Promise 생성자 함수를 new 연산자와 함께 호출하여 생성한다. 이때, 비동기 처리를 수행할 콜백 함수를 인수로 전달받게 되는데, 이 콜백 함수는 resolve와 reject 함수를 인수로 전달받는다. 여기서 비동기 처리가 성공하게 되면 resolve 함수를 호출하고, 실패하면 reject 함수를 호출하게 되는 것이다.

프로미스는 다음과 같이 현재 비동기 처리가 어떻게 진행되고 있는지에 대한 상태(state) 정보를 갖는다.

프로미스의 상태 정보의미상태 변경 조건
pending비동기 처리가 아직 수행되지 않은 상태프로미스가 생성된 직후 기본 상태
fulfilled비동기 처리가 수행된 상태(성공)resolve 함수 호출
rejected비동기 처리가 수행된 상태(실패)reject 함수 호출

생성된 직후의 프로미스는 기본적으로 pending 상태이며, 이후 비동기 처리가 수행되면 결과에 따라 다음과 같이 프로미스의 상태가 변경된다.

  • 비동기 처리 성공: resolve 함수를 호출해 프로미스를 fulfilled 상태로 변경한다.

  • 비동기 처리 실패: reject 함수를 호출해 프로미스를 rejected 상태로 변경한다.

이처럼 프로미스의 상태는 resolve 또는 reject 함수를 호출하는 것으로 결정된다.

프로미스의 후속 처리 메서드

위처럼 프로미스의 비동기 처리 상태가 변화하면, 예를 들어 fulfilled 상태가 되면 프로미스의 처리 결과를 가지고 무언가를 해야 하고, rejected 상태가 되면 에러 처리를 해야 한다. 이를 위해 프로미스는 후속 메서드 then, catch, finally를 제공한다. 프로미스의 비동기 처리 상태가 변화하면 후속 처리 메서드에 인수로 전달한 콜백 함수가 선택적으로 호출된다.

1. Promise.prototype.then

then 메서드는 두 개의 콜백 함수를 인수로 전달받는다.

  • 첫 번째 콜백 함수는 프로미스가 fulfilled 상태가 되면 호출한다. 이때 콜백 함수는 프로미스의 비동기 처리 결과를 인수로 전달받는다.

  • 두 번째 콜백 함수는 프로미스가 rejected 상태가 되면 호출된다. 이때 콜백 함수는 프로미스의 에러를 인수로 전달받는다.

// fulfilled
new Promise(resolve => resolve('fulfilled'))
	.then(v => console.log(v), e => console.error(e)); // fulfilled

// rejected
new Promise((_, reject) => reject(new Error('rejected')))
	.then(v => console.log(v), e => console.error(e)); // Error: rejected

then 메서드는 언제나 프로미스를 반환한다.

2. Promise.prototype.catch

catch 메서드는 한 개의 콜백 함수를 인수로 전달받고, 프로미스가 rejected 상태인 경우에만 호출된다.

// rejected
new Promise((_, reject) => reject(new Error('rejected')))
	.catch(e => console.log(e)); // Error: rejected

catch 메서드 역시 언제나 프로미스를 반환한다.

3. Promise.prototype.finally

finally 메서드는 한 개의 콜백 함수를 인수로 전달받고, 프로미스의 성공 또는 실패와 관계없이 무조건 한 번 호출된다. 그렇기 때문에 프로미스의 상태와 상관없이 공통적으로 수행해야 할 처리 내용이 있을 때 유용하게 사용된다. finally 메서드도 언제나 프로미스를 반환한다.

new Promise(() => {})
	.finally(() => console.log('finally')); // finally

그럼 async/await은 뭔데?

async/await은 프로미스를 기반으로 동작하지만, 프로미스의 then/catch/finally 후속 처리 메서드에 콜백 함수를 전달해서 비동기 처리 결과를 후속 처리할 필요 없이 마치 동기 처리처럼 프로미스를 사용할 수 있다. 말 그대로 Promise의 상위 호환인 격이다.

const fetch = require('node-fetch');

async function fetchTodo() {
  const url = 'https://jsonplacholder.typicode.com/todos/1';
  
  const response = await fetch(url);
  const todo = await response.json();
  console.log(todo);
  // { userId: 1, id: 1, title: 'delectus aut autem', completed: false }
}

fetchTodo();

1. async 함수

async 함수는 async 키워드를 사용해 정의하며 언제나 프로미스를 반환한다. await 키워드는 반드시 async 함수 내부에서 사용해야 하며, async 함수가 명시적으로 프로미스를 반환하지 않더라도 async 함수는 암묵적으로 반환값을 resolve 하는 프로미스를 반환한다.

// async 함수 선언문 
async function foo(n) { return n; }
foo(1).then(v => console.log(v)); // 1

// async 함수 표현식
const bar = async function (n) { return n; };
bar(2).then(v => console.log(v)); // 2

// async 화살표 함수
const baz = async n => n;
baz(3).then(v => console.log(v)); // 3

// async 메서드
const obj = {
  async foo(n) { return n; }
};
obj.foo(4).then(v => console.log(v)); // 4

// async 클래스 메서드
class MyClass {
  async bar(n) { return n; }
}

const myClass = new MyClass();
myClass.bar(5).then(v => console.log(v)); // 5

2. await 키워드

await 키워드는 프로미스가 settled 상태가 될 때까지 대기하다가 settled 상태가 되면 프로미스가 resolve한 처리 결과를 반환한다. await 키워드는 반드시 프로미스 앞에서 사용해야 한다.

const fetch = require('node-fetch');

const getGithubUserName = async id => {
  const res = await fetch(`https://api.github.com/users/${id}`); // (1)
  const { name } = await res.json(); // (2)
  console.log(name); // Hyeong kyeom Kim
};

getGithubUserName('Kyeom1997');

위 예제에서는 fetch 함수가 수행한 HTTP 요청에 대한 서버의 응답이 도착해서 fetch 함수가 반환한 프로미스가 settled 상태가 될때까지 (1)은 대기하게 된다. 이후 프로미스가 settled 상태가 되면 프로미스가 resolve한 처리 결과가 res 변수에 할당된다.

이처럼 await 키워드는 다음 실행을 일시 중지 시켰다가 프로미스가 settled 상태가 되면 다시 재개하기 때문에, 모든 프로미스에 await 키워드를 사용하는 것은 주의해야 한다. 만일 여러 비동기 처리가 서로 연관이 없이 개별적으로 수행되는 비동기 처리일때는 각 함수에 일일히 await 키워드를 사용하는 것이 아니라 다음과 같이 처리해야 한다.

async function foo() {
  const res = await Promise.all([
    new Promise(resolve => setTimeout(() => resolve(1), 3000)),
    new Promise(resolve => setTimeout(() => resolve(2), 2000)),
    new Promise(resolve => setTimeout(() => resolve(3), 1000)),    
  ]);
  
  console.log(res); // [1, 2, 3]
}

foo(); // 약 3초 소요

에러 처리

async / await에서 에러 처리는 try ... catch 문을 사용할 수 있다. 콜백 함수를 인수로 전달받는 비동기 함수와는 달리 프로미스를 반환하는 비동기 함수는 명시적으로 호출할 수 있기 때문에 호출자가 명확하다.

const fetch = require('node-fetch');

const foo = async () => {
  try {
    const wrongUrl = 'https://wrong.url';
    
    const response = await fetch(wrongUrl);
    const data = await response.json();
    console.log(data);
  } catch (err) {
    console.log(err); // TypeError: Failed to fetch
  }
};

foo();

async 함수 내에서 catch 문을 사용해서 에러 처리를 하지 않으면 async 함수는 발생한 에러를 reject하는 프로미스를 반환한다. 따라서 async 함수를 호출하고 Promise.prototype.catch 후속 처리 메서드를 사용해 에러를 캐치할 수도 있다.

마치며

이렇게 Promise와 async/await에 대해 알아보았다. Promise는 비동기 처리를 할때 사용하던 콜백 함수의 콜백 지옥을 방지하기 위해, async/await은 Promise의 후속 처리 메서드를 사용하지 않고도 동기 처리처럼 프로미스가 처리 결과를 반환할 수 있도록 하기 위해 등장한 개념이다. 실무에 가면 정말 방대한 양들의 비동기 처리를 진행할테니 꼭 알아두고 넘어가야 할 스택들인 것 같다. 오늘도 지식 플러스 1!

profile
Front-End Developer
post-custom-banner

0개의 댓글