[Javascript]Promise

콜백 지옥을 해결해보자




비동기 함수의 문제점

비동기 함수 내부의 비동기로 동작하는 코드는 비동기 함수가 종료된 이후에 완료된다. 따라서 비동기 함수 내부의 비동기로 동작하는 코드에서 처리 결과를 외부로 반환하거나 상위 스코프의 변수에 할당하면 기대한 대로 동작하지 않는다.

let g = 0;

// 비동기 함수인 setTimeout 함수는 콜백 함수의 처리 결과를 외부로 반환하거나
// 상위 스코프의 변수에 할당하지 못한다.
setTimeout(() => {
  g = 100;
}, 0);
console.log(g); // 0

콜백 패턴

자바스크립트에서 비동기 함수의 처리 결과에 대한 후속 처리는 비동기 함수 내부에서 수행해야 한다. 이때, 비동기 함수를 범용적으로 사용하기 위해 비동기 함수에 비동기 처리 결과에 대한 후속 처리를 수행하는 콜백 함수를 전달하는 콜백 패턴을 사용한다.

// GET 요청을 위한 비동기 함수
const get = (url, successCallback, failureCallback) => {
  const xhr = new XMLHttpRequest();
  xhr.open("GET", url);
  xhr.send();

  xhr.onload = () => {
    if (xhr.status === 200) {
      // 서버의 응답을 콜백 함수에 인수로 전달하면서 호출하여 응답에 대한 후속 처리를 한다.
      successCallback(JSON.parse(xhr.response));
    } else {
      // 에러 정보를 콜백 함수에 인수로 전달하면서 호출하여 에러 처리를 한다.
      failureCallback(xhr.status);
    }
  };
};

// id가 1인 post를 취득
// 서버의 응답에 대한 후속 처리를 위한 콜백 함수를 비동기 함수인 get에 전달해야 한다.
get("https://jsonplaceholder.typicode.com/posts/1", console.log, console.error);
/*
{
  "userId": 1,
  "id": 1,
  "title": "sunt aut facere ...",
  "body": "quia et suscipit ..."
}
*/

1. 콜백 지옥

만약에 다음 예제와 같이 비동기 함수의 후속 처리를 수행하는 콜백 함수가 비동기 처리 결과를 가지고 또 다시 비동기 함수를 호출해야 한다면 콜백 함수 호출이 중첩되어 복잡도가 높아지고 가독성이 나빠지는데 이것을 콜백 헬 또는 콜백 지옥이라고 한다.

get("/step1", (a) => {
  get(`/step2/${a}`, (b) => {
    get(`/step3/${b}`, (c) => {
      get(`/step4/${c}`, (d) => {
        console.log(d);
      });
    });
  });
});

2. 에러 처리의 한계

콜백 패턴의 또 다른 문제점은 에러 처리가 어렵다는 것이다.

try {
  setTimeout(() => {
    throw new Error("Error!");
  }, 1000);
} catch (e) {
  // 에러를 캐치하지 못한다
  console.error("캐치한 에러", e);
}

위 예제에서 콜백 함수에서 에러를 발생시켰지만 catch 코드 블록에서 캐치되지 않는다. 그 이유는 위 예제 코드가 다음과 같은 순서로 동작하기 때문이다.

  • try-catch문이 실행되어 call stack에 push 됨
  • setTimeout이 실행되어 콜백 함수가 event loop에 등록 됨
  • try-catch문에서 에러가 발생하지 않았고 다음 코드도 없기때문에 실행 종료되어 call stack에서 pop 됨
  • setTimeout의 콜백 함수가 event loop에 등록된지 1초 뒤에 task queue로 push 됨
  • call stack에 실행중인 컨텍스트가 없으므로 콜백 함수는 event loop에 의해서 call stack으로 이동되고 실행 됨
  • 콜백 함수가 에러를 발생시킴
  • 에러를 캐치할 실행 중인 실행 컨텍스트의 상위 컨텍스트가 없기 때문에 에러를 캐치하지 못하고 종료

에러는 호출자 방향으로 전파된다. 즉, 콜 스택의 아래 방향(살향 즁안 실행 컨텍스트가 푸시되기 직전에 푸시된 상위 실행 컨텍스트 방향)으로 전파된다. 위 예제에서 setTimeout 함수의 콜백 함수를 호출한 것은 setTimeout 함수가 아니고, 상위 컨텍스트가 없기 때문에 에러가 캐치되지 않는 것이다.

프로미스

1. 프로미스의 생성

콜백 함수에서 에러를 처리하기 어렵다는 문제를 극복하기 위해 ES6에서 Promise가 도입되었다. Promise 생성자 함수를 new 연산자와 함께 호출하면 Promise객체를 생성한다. Promise 생성자 함수는 비동기 처리를 수행할 콜백 함수에 resolve와 reject 함수를 인수로 전달 받는다.

// 프로미스 생성
const promise = new Promise((resolve, reject) => {
  // Promise 함수의 콜백 함수 내부에서 비동기 처리를 수행한다.
  if (/* 비동기 처리 성공 */) {
    resolve('result');
  } else { /* 비동기 처리 실패 */
    reject('failure reason');
  }
});

Promise 생성자 함수가 인수로 전달받은 콜백 함수의 내부에서 비동기 처리를 수행하는 것이다. 이때, 비동기 처리가 성공하면 resolve 함수를 호출하고, 실패하면 reject 함수를 호출한다.
만약에 비동기 처리의 결과를 받고 싶다면, resolve 함수에 처리 결과를 인수로 전달하면서 호출하고, 실패 결과는 reject 함수에 인수로 전달하면 된다.

// GET 요청을 위한 비동기 함수
const promiseGet = (url) => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", url);
    xhr.send();

    xhr.onload = () => {
      if (xhr.status === 200) {
        // 성공적으로 응답을 전달받으면 resolve 함수를 호출한다.
        resolve(JSON.parse(xhr.response));
      } else {
        // 에러 처리를 위해 reject 함수를 호출한다.
        reject(new Error(xhr.status));
      }
    };
  });
};

// promiseGet 함수는 프로미스를 반환한다.
promiseGet("https://jsonplaceholder.typicode.com/posts/1");

2. 프로미스의 상태

Promise는 현재 비동기 처리가 어떻게 진행되고 있는지 나타내는 상태 정보를 갖는다.

프로미스의 상태 의미 상태 변경 조건
pending 비동기 처리가 아직 수행되지 않은 상태 프로미스가 생성된 직후 상태
fulfilled 비동기 처리 수행이 완료되고 결과가 성공인 상태 resolve 호출
rejected 비동기 처리 수행이 완료되고 결과가 실패인 상태 reject 호출

이때, 비동기 처리가 완료된 상태인 fulfilled 상태와 rejected 상태를 settled 상태라고 한다. 한 번 pending 상태에서 settled 상태가 되면 다시 pending 상태로 되돌릴 수는 없다. 또 Promise 객체는 처리 상태와 함께 결과 값인 result를 상태로 갖는다. <그림 45-1>

Reference

모던 자바스크립트 deep dive
MDN Promise