22. 02. 07 자바스크립트) 프로미스와 async, await

divedeepp·2023년 2월 7일
0

JavaScript

목록 보기
11/11

콜백(callback)

브라우저나 node 등 자바스크립트 호스트 환경이 제공하는 여러 함수를 사용하면 비동기(asynchronous) 동작을 스케줄링할 수 있다. 즉, 원하는 때에 동작이 시작되도록 할 수 있다.

setTimeout은 스케줄링에 사용되는 가장 대표적인 함수이다.

실무에서 만날 수 있는 비동기 동작은 다양하다. 스크립트나 모듈을 로딩하는 것 또한 비동기 동작 중 하나 이다. src에 있는 스크립트를 읽어오는 함수를 예시로 비동기 동작 처리가 어떻게 일어나는지 살펴보자.

아래 예시에서 함수 loadScript(src)<script src="...">를 동적으로 만들고, 문서에 추가한다. 브라우저는 자동으로 태그에 있는 스크립트를 불러오고, 로딩이 완료되면 스크립트를 실행한다.

function loadScript(src) {
  // <script> 태그를 만들고 페이지에 태그를 추가한다.
  // 태그가 페이지에 추가되면 src에 있는 스크립트를 로딩하고 실행한다.
  let script = document.createElement('script');
  script.src = src;
  document.head.append(script);
}

// 해당 경로에 있는 스크립트를 불러오고 실행한다.
loadScript('/my/script.js');

이 때 스크립트는 비동기적으로 실행된다. 로딩은 지금 당장 시작되더라도, 실행은 함수가 끝난 후에야 되기 때문이다.

따라서 loadScript 아래에 있는 코드들은 스크립트 로딩이 종료되는 걸 기다리지 않는다.

loadScript('/my/script.js');
// 아래의 코드는 스크립트 로딩이 끝날 때가지 기다리지 않는다.
// ...

스크립트 로딩이 끝나자마자 해당 스크립트를 사용해서 무언가를 해야한다고 가정해보자. 스크립트 안에는 다양한 함수가 정의되어 있고, 이 함수들을 실행하길 원하는 상황이다. 그런데 loadScript를 호출하자마자 내부 함수를 호출하면 원하는 대로 작동하지 않는다.

loadScript('/my/script.js'); // script.js엔 "function newFunction() {…}"이 있다.

newFunction(); // 함수가 존재하지 않는다는 에러가 발생한다!

에러는 브라우저가 스크립트를 읽어올 수 있는 시간을 충분히 확보하지 못했기 때문에 발생한다. 그런데 현재로서는 loadScript에서 스크립트 로딩이 완료되었는지 알 방법이 없다. 언젠가 스크립트가 로드되고 실행도 되겠지만, 그게 다이다. 원하는 대로 스크립트 안의 함수나 변수를 사용하려면 스크립트 로딩이 끝났는지 여부를 알 수 있어야 한다.

따라서, loadScript의 두 번째 인수로 스크립트 로딩이 끝난 후 실행될 함수인 콜백 함수를 추가해보자. 콜백 함수는 나중에 호출할 함수를 의미한다.

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;
  
  script.onload = () => callback(script);
  
  document.head.append(script);
}

새롭게 불러온 스크립트에 있는 함수를 콜백 함수 안에서 호출하면 원하는 대로 외부 스크립트 안의 함수를 사용할 수 있다. 이렇게 두 번째 인수로 전달된 함수는 예시처럼 외부 스크립트를 불러오는 것이 완료되었을 때 등 원하는 동작이 완료되었을 때 실행된다.

loadScript('/my/script.js', function(script) {
  // 콜백 함수는 스크립트 로드가 끝나면 실행된다.
  newFunction(); // 이제 함수 호출이 제대로 동작한다.
  //...
});

이런 방식을 콜백 기반 비동기 프로그래밍이라고 한다. 무언가를 비동기적으로 수행하는 함수는 함수 내 동작이 모두 처리된 후 실행되어야 하는 함수가 들어갈 콜백을 인수로 반드시 제공해야 한다.

콜백 속 콜백

만약 로드해야 될 스크립트가 두 개 있는 경우에는 어떻게 하면 두 스크립트를 순차적으로 불러올 수 있을까? 두 번째 스크립트 로딩은 첫 번째 스크립트의 로딩이 끝난 이후가 되길 원한다면?

가장 자연스러운 해결 방법은 아래와 같이 콜백함수 안에서 두 번째 스크립트를 호출하는 것이다.

loadScript('/my/script.js', function(script) {
  alert(`${script.src}을 로딩했습니다. 이젠, 다음 스크립트를 로딩합시다.`);

  loadScript('/my/script2.js', function(script) {
    alert(`두 번째 스크립트를 성공적으로 로딩했습니다.`);
  });
});

이렇게 중첩 콜백을 만들면 바깥에 위치한 loadScript가 완료된 후, 안 쪽 loadScript가 실행된다. 그런데 이렇게 콜백 안에 콜백을 넣는 것은 수행하려는 동작이 단 몇 개라면 괜찮지만, 동작이 많은 경우에는 유지보수 등 좋지 않다.

에러 핸들링

스크립트 로딩이 실패할 경우, 콜백함수에서 에러를 핸들링할 수 있게 구현해보자.

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;
  
  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error(`${src}를 불러오는 도중에 에러가 발생했습니다.`));
  
  document.head.append(script);
}

loadScript('/my/script.js', function(error, script) {
  if (error) {
    // 에러 처리
  } else {
    // 스크립트 로딩이 성공적으로 끝남
  }
});

이제 loadScript는 스크립트 로딩에 성공하면 callback(null, script)을, 실패하면 callback(error)를 호출한다.

이렇게 에러를 처리하는 방식은 오류 우선 콜백이라 부르고, 흔히 사용되는 패턴이다.

오류 우선 콜백은 다음 관례를 따른다.

  • callback의 첫 번째 인수는 에러를 위해 남겨둔다. 에러가 발생하면 이 인수를 이용해서 callback(err)이 호출된다.
  • 두 번째 인수 혹은 더 많은 인수는 에러가 발생하지 않았을 때를 위해 남겨둔다. 원하는 동작이 성공한 경우에는 callback(null, result1, result2, ...)이 호출된다.

콜백 헬(callback hell)

콜백 기반 비동기 처리는 한 개 혹은 두 개의 중첩 호출이 있을 경우에는 쓸만하다. 하지만 비동기 동작이 많아지면 아래와 같은 코드 작성이 불가피해진다.

loadScript('1.js', function(error, script) {

  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('2.js', function(error, script) {
      if (error) {
        handleError(error);
      } else {
        // ...
        loadScript('3.js', function(error, script) {
          if (error) {
            handleError(error);
          } else {
            // 모든 스크립트가 로딩된 후, 실행 흐름이 이어진다. (*)
          }
        });

      }
    })
  }
});

보기만해도 어지럽다. 이렇게 깊은 중첩 코드가 만들어내는 패턴을 콜백 지옥(callback hell) 혹은 멸망의 피라미드(pyramid of doom)이라고 부른다.

비동기 동작이 하나씩 추가될 때마다 중첩 호출이 만들어내는 피라미드는 오른쪽으로 점점 커진다. 따라서 이런 코딩 방식은 좋지 않다.

이제 각 동작을 독립적인 함수로 만들어 위와 같은 문제를 완화해 보자.

loadScript('1.js', step1);

function step1(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('2.js', step2);
  }
}

function step2(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('3.js', step3);
  }
}

function step3(error, script) {
  if (error) {
    handleError(error);
  } else {
    // 모든 스크립트가 로딩되면 다른 동작을 수행한다. (*)
  }
};

새롭게 작성한 코드는 각 동작을 분리해 최상위 레벨의 함수로 만들었기 때문에 깊은 중첩이 없다. 이렇게 작성하면 동작상의 문제는 없다. 하지만 코드의 연결성이 떨어져 보이고, 가독성이 떨어진다.


프로미스(promise)

  • 제작 코드(producing code) : 원격에서 스크립트를 불러오는 것 같은 시간이 걸리는 일을 한다.
  • 소비 코드(consuming code) : 제작 코드의 결과를 기다렸다가 이를 소비한다. 이 때 소비 주체인 함수는 여럿이 될 수 있다.
  • 프로미스(promise) : 제작 코드와 소비 코드를 연결해 주는 특별한 자바스크립트 객체이다. 프로미스는 시간이 얼마나 걸리든 상관없이 약속한 결과를 만들어 내는 제작 코드가 준비되었을 때, 모든 소비 코드가 결과를 사용할 수 잇도록 해준다.

promise 객체는 아래와 같은 문법으로 만들 수 있다.

let promise = new Promise(function(resolve, reject) {
  // excutor 제작 코드
});

new Promise에 전달되는 함수는 executor(실행자, 실행 함수)라 부른다. executornew Promise가 만들어질 때 자동으로 실행되는데, 결과를 최종적으로 만들어내는 제작 코드를 포함한다.

executor의 인수 resolvereject는 자바스크립트에서 자체 제공하는 콜백이다. 따라서, 개발자는 resolvereject를 신경 쓰지 않고, executor 안의 코드만 작성하면 된다.

대신 executor에서는 결과를 즉시 얻든지 늦게 얻든지 상관없이, 상황에 따라 인수로 넘겨준 콜백 중 하나를 반드시 호출해야 한다.

  • resolve(value) : 일이 성공적으로 끝난 경우 그 결과를 나타내는 value와 함께 호출
  • reject(error) : 에러 발생 시 에러 객체를 나타내는 error와 함께 호출

요약하면 다음과 같다. executor는 자동으로 실행되는데 여기서 원하는 일이 처리된다. 처리가 끝나면 executor는 처리 성공 여부에 따라 resolvereject를 호출한다.

new Promise 생성자가 반환하는 프로미스 객체는 다음과 같은 숨김 프로퍼티를 갖는다.

  • state : 처음에는 pending 상태였다가 resolve가 호출되면 fulfilled, reject가 호출되면 rejected로 변한다.
  • result : 처음에는 값이 undefined 였다가 resolve(value)가 호출되면 value로, reject(error)가 호출되면 error로 변한다.

정리하면 executor는 아래 그림과 같이 프로미스 객체의 상태를 둘 중 하나로 변화시킨다.

프로미스 생성자와 간단한 executor 함수로 만든 예시를 살펴보자. setTimeout을 이용해서 executor 함수는 약간의 시간이 걸리도록 구현했다.

let promise = new Promise(function(resolve, reject) {
  // 프로미스가 만들어지면 executor 함수는 자동으로 실행된다.
  
  // 1초 뒤에 일이 성공적으로 끝났다는 신호가 전달되면서 프로미스의 result 프로퍼티는 완료가 된다.
  setTimeout(() => resolve("완료"), 1000);
});

executor가 실행된 지 1초 후, resolve("완료")가 호출되고 결과가 만들어진다. 이 때 프로미스 객체의 상태는 아래와 같이 변한다.

위 예시를 통해서 우리가 알 수 있는 것은 두 가지이다.

  • executornew Promise에 의해 자동으로 그리고 즉각적으로 호출된다.
  • executor는 인자로 resolvereject 함수를 받는다. 이 함수들은 자바스크립트 엔진이 미리 정의한 함수이므로 개발자가 따로 만들 필요가 없다. 다만, resolvereject 중 하나는 반드시 호출해야 한다.

이처럼 일이 성공적으로 처리되었을 때의 프로미스는 fulfilled promise(약속이 이행된 프로미스)라고 부른다.

이번에는 executor가 에러와 함께 약속한 작업을 거부하는 경우에 대해 살펴보자.

let promise = new Promise(function(resolve, reject) {
  // 1초 뒤에 에러와 함께 실행이 종료되었다는 신호를 보낸다.
  setTimeout(() => reject(new Error("에러 발생")), 1000);
});

1초 후 reject가 호출되면 프로미스의 상태가 rejected로 변한다.

지금까지 배운 내용을 요약해보자. executor는 보통 시간이 걸리는 일을 수행한다. 일이 끝나면 resolvereject 함수를 호출하는데, 이 때 프로미스 객체의 상태가 변화한다. 이행(resolved 혹은 거부(rejected) 상태의 프로미스는 처리된(settled) 프로미스라고 부른다. 반면, 처리되지 않은 프로미스는 대기 상태의(pending) 프로미스라고 부른다.

프로미스는 성공 또는 실패 둘 중 하나만 한다. executorresolvereject 중 하나를 반드시 호출해야 한다. 이때 변경된 상태는 더 이상 변하지 않는다. 즉, 처리가 끝난 프로미스에 resolvereject를 호출하면 무시된다.

let promise = new Promise(function(resolve, reject) {
  resolve("완료");

  reject(new Error("…")); // 무시됨
  setTimeout(() => resolve("…")); // 무시됨
});

이렇게 executor에 의해 처리가 끝난 프로미스는 결과 혹은 에러만 가질 수 있다. 또, resolvereject는 인수를 1개만 받거나 아무것도 받지 않고, 그 이외의 인수는 무시한다.

then, catch, finally

프로미스 객체는 executor와 소비 함수를 이어주는 역할을 한다. 소비 함수는 then, catch, finally 메서드를 사용해서 등록되고, 결과나 에러를 받을 수 있다.

또, 프로미스 객체의 state, result 프로퍼티는 숨김 프로퍼티이므로 개발자가 직접 접근할 수 없다. 대신에 then, catch, finally 메서드를 사용해서 접근 가능하다.

then 메서드의 문법은 다음과 같다. then 메서드의 첫 번째 인수는 프로미스가 이행되었을 때 실행되는 함수이고, 여기서 실행 결과를 받는다. then 메서드의 두 번째 인수는 프로미스가 거부되었을 때 실행되는 함수이고, 여기서 에러를 받는다.

promise.then(
  function(result) { ... }
  function(result) { ... }
);

아래 예시는 성공적으로 이행된 프로미스와 프로미스가 거부된 경우에 따라 어떻게 반응하는지 보여준다.

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => resolve("완료"), 1000);
});

// resolve 함수는 then의 첫 번째 함수를 실행한다.
promise.then(
  result => alert(result), // 1초 후 "완료!"를 출력
  error => alert(error) // 실행되지 않음
);

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => reject(new Error("에러 발생!")), 1000);
});

// reject 함수는 .then의 두 번째 함수를 실행한다.
promise.then(
  result => alert(result), // 실행되지 않음
  error => alert(error) // 1초 후 "Error: 에러 발생!"을 출력
);

작업이 성공적으로 처리된 경우만 다루고 싶다면 then에 인수를 하나만 전달하면 된다.

let promise = new Promise(resolve => {
  setTimeout(() => resolve("완료!"), 1000);
});

promise.then(alert); // 1초 뒤 "완료!" 출력

에러가 발생한 경우만 다루고 싶다면 catch 메서드를 사용하면 된다.

let promise = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error("에러 발생!")), 1000);
});

// .catch(f)는 promise.then(null, f)과 동일하게 작동한다
promise.catch(alert); // 1초 뒤 "Error: 에러 발생!" 출력

finally 메서드를 사용해서 이행이나 거부 상태에 상관없이 프로미스가 처리되면 항상 함수를 실행하게 할 수 있다. 쓸모 없어진 로딩표시를 멈추는 경우와 같이 결과 어떻든 마무리가 필요할 때 유용하다.

new Promise((resolve, reject) => {
  /* 시간이 걸리는 어떤 일을 수행하고, 그 후 resolve, reject를 호출함 */
})
  // 성공·실패 여부와 상관없이 프라미스가 처리되면 실행됨
  .finally(() => 로딩 인디케이터 중지)
  .then(result => result 혹은 err => error)

또, finally 메서드는 프로미스의 성공이나 실패 여부를 몰라도 되기 때문에 인수가 없고, 자동으로 다음 핸들러(메서드)에 결과와 에러를 전달한다.

new Promise((resolve, reject) => {
  setTimeout(() => resolve("결과"), 2000)
})
  .finally(() => alert("프라미스가 준비되었습니다."))
  .then(result => alert(result)); // <-- .then에서 result를 다룰 수 있음

new Promise((resolve, reject) => {
  throw new Error("에러 발생!");
})
  .finally(() => alert("프라미스가 준비되었습니다."))
  .catch(err => alert(err)); // <-- .catch에서 에러 객체를 다룰 수 있음

프로미스가 대기 상태 일 때, then / catch / finally 메서드는 프로미스가 처리되길 기다린다. 프로미스가 처리 상태가 된다면 핸들러가 즉각 실행된다.

프로미스로 어떻게 비동기 동작을 처리하는지 예시

앞 서 콜백으로 구현한 스크립트 로딩에 사용되는 함수를 프로미스로 구현해보자.

function loadScript(src) {
  return new Promise(function(resolve, reject) {
    let script = document.createElement('script');
    script.src = src;

    script.onload = () => resolve(script);
    script.onerror = () => reject(new Error(`${src}를 불러오는 도중에 에러가 발생함`));

    document.head.append(script);
  });
}

let promise = loadScript("https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.js");

promise.then(
  script => alert(`${script.src}을 불러왔습니다!`),
  error => alert(`Error: ${error.message}`)
);

promise.then(script => alert('또다른 핸들러...'));

프로미스를 사용한 코드와 콜백 기반 코드의 차이점은 아래와 같다.

  • 프로미스
    • 흐름이 자연스럽고 유연한 코드를 작성할 수 있다. loadScript(src)로 스크립트를 읽고, 결과에 따라 그 다음 무엇을 하지에 대한 코드를 작성하면 된다(then).
    • 프로미스에 원하는 만큼 then을 호출할 수 있다.
  • 콜백
    • loadScript(src, callback)를 호출할 때, 함께 호출할 callback 함수가 준비되어 있어야 한다. loadScript를 호출하기 이전에 호출 결과로 무엇을 할지 미리 알고 있어야 한다.
    • 콜백은 하나만 가능하다.

프로미스 체이닝(promise chaining)

프로미스 체이닝은 아래와 같이 생겼다. resultthen 메서드의 체인을 통해 전달된다.

new Promise(function(resolve, reject) {
  setTimeout(() => resolve(1), 1000);
}).then(function(result) {
  alert(result); // 1
  return result * 2;
}).then(function(result) {
  alert(result); // 2
  return result * 2;
}).then(function(result) {
  alert(result); // 4
  return result * 2;
});

프로미스 체이닝이 가능한 이유는 promise.then(handler)을 호출하면 프로미스가 반환되기 때문이다. 반환된 프로미스에는 당연히 then(handler) 메서드를 또 호출할 수 있다. then(handler) 메서드의 인수인 핸들러가 값을 반환하면 해당 반환값이 then 메서드가 반환한 프로미스의 result가 된다.

참고로 아래 예시는 프로미스 체이닝이 아니므로 주의해야 한다. 아래 예시의 then 메서드들은 result를 순차적으로 전달하지 않고 독립적으로 처리한다.

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => resolve(1), 1000);
});

promise.then(function(result) {
  alert(result); // 1
  return result * 2;
});

promise.then(function(result) {
  alert(result); // 1
  return result * 2;
});

promise.then(function(result) {
  alert(result); // 1
  return result * 2;
});

프로미스 반환하기

then(handler) 메서드에 사용되는 핸들러가 프로미스를 생성하거나 반환하는 경우도 있다. 이 경우에 이어지는 핸들러는 프로미스가 처리될 때까지 기다리다가 처리가 완료되면 그 결과를 받는다.

new Promise(function(resolve, reject) {
  setTimeout(() => resolve(1), 1000);
}).then(function(result) {
  alert(result); // 1
  return new Promise((resolve, reject) => {
    setTimeout(() => reslove(result * 2), 1000);
  });
}).then(function(result) { // (**)
  alert(result); // 2
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(result * 2), 1000);
  });
}).then(function(result) {
  alert(result); // 4
});

위 예시에서 첫 번째 then 메서드는 1을 출력하고 프로미스를 반환한다. 1초 후에 해당 프로미스가 이행되고, 그 결과는 두 번째 then 메서드로 전달된다. 이후 반복된다.

loadScript 예시 개선하기

지금까지 배운 기능을 사용해서 프로미스를 사용해 정의한 loadScript를 개선해보자.

loadScript("/article/promise-chaining/one.js")
  .then(script => loadScript("/article/promise-chaining/two.js"))
  .then(script => loadScript("/article/promise-chaining/three.js"))
  .then(script => {
    // 스크립트를 정상적으로 불러왔기 때문에 스크립트 내의 함수를 호출할 수 있다.
    one();
    two();
    three();
  });

위 예시에서 loadScript를 호출할 때마다 프로미스가 반환되고, 다음 then은 해당 프로미스가 이행되었을 때 실행된다. 스크립트들은 이런 과정을 거쳐 순차적으로 로드된다.

프로미스 체인에 더 많은 비동기 동작을 추가할 수도 있는데, 추가 작업이 많아져도 코드가 오른쪽으로 길어지지 않고 아로래만 증가해서 멸망의 피라미드가 만들어지지 않는다.

아래와 같이 각 loadScriptthen을 바로 붙일 수도 있다.

loadScript("/article/promise-chaining/one.js").then(script1 => {
  loadScript("/article/promise-chaining/two.js").then(script2 => {
    loadScript("/article/promise-chaining/three.js").then(script3 => {
      // 여기서 script1, script2, script3에 정의된 함수를 사용할 수 있다.
      one();
      two();
      three();
    });
  });
});

위 방식을 사용해도 동일한 동작을 수행하지만, 코드가 오른쪽으로 길어져 멸망의 피라미드가 만들어진다. 위 방식이 단점만 있는 것은 아니다. 중첩 함수에서 외부 스코프에 접근할 수 있기 때문에, 가장 깊은 곳에 있는 중첩 콜백에서 script1, script2, script3 변수 모두에 접근할 수 있다.

fetch와 프로미스 체이닝 응용하기

프론트엔드단에서는 네트워크 요청 시에 프로미스를 자주 사용한다.

fetch 메서드를 사용해서 원격 서버에서 사용자 정보를 가져오는 예시를 살펴보자.

let promise = fetch(url);

위 코드를 실행하면 url에 네트워크 요청을 보내고 프로미스를 반환한다. 원격 서버가 헤더와 함께 응답을 보내면 프로미스는 response 객체와 함께 이행된다. 그런데 이때 response 전체가 완전히 다운로드되기 전에 프로미스는 이행 상태가 된다.

응답이 완전히 종료되고, 응답 전체를 읽으려면 response.text() 메서드를 호출해야 한다. response.text() 메서드는 원격 서버에서 전송한 텍스트 전체가 다운로드되면, 해당 텍스트를 result 값으로 갖는 이행된 프로미스를 반환한다.

fetch('/article/promise-chaining/user.json')
  // 원격 서버가 응답하면 .then 아래 코드가 실행된다.
  .then(function(response) {
    // response.text()는 응답 텍스트 전체가 다운로드되면
    // 응답 텍스트를 새로운 이행 프라미스로 만들고, 이를 반환한다.
    return response.text();
  })
  .then(function(text) {
    // 원격에서 받아온 파일의 내용
    alert(text); // {"name": "Violet-Bora-Lee", "isAdmin": true}
  });

또, response.json() 메서드를 쓰면 원격에서 받아온 데이터를 읽고 JSON으로 파싱할 수 있다.

// 위 코드와 동일한 기능을 하지만, response.json()은 원격 서버에서 불러온 내용을 JSON으로 변경한다.
fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => alert(user.name)); // Violet-Bora-Lee, 이름만 성공적으로 가져옴

예시를 더 살펴보자. 아래 예시는 깃허브에 요청을 보내서 사용자 프로필을 불러오고 아바타를 출력한다.

// user.json에 요청을 보낸다.
fetch('/article/promise-chaining/user.json')
  // 응답받은 내용을 json으로 불러온다.
  .then(response => response.json())
  // GitHub에 요청을 보낸다.
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  // 응답받은 내용을 json 형태로 불러온다,
  .then(response => response.json())
  // 3초간 아바타 이미지(githubUser.avatar_url)를 보여준다.
  .then(githubUser => {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => img.remove(), 3000); // (*)
  });

(*)로 표시한 줄에서 아바타가 사라지고 무언가를 더 하고 싶을 때는 어떻게 할까? 프로미스 체인을 확장하려면 아바타가 사라질 때 이행 프로미스를 반환해주어야 한다.

fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  .then(response => response.json())
  .then(githubUser => new Promise(function(resolve, reject) { // (*)
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => {
      img.remove();
      resolve(githubUser); // (**)
    }, 3000);
  }))
  // 3초 후 동작함
  .then(githubUser => alert(`${githubUser.name}의 이미지를 성공적으로 출력하였습니다.`));

(*) 로 표시한 곳의 then 메서드의 핸들러는 이제 setTimeout 안의 resolve를 호출했을 때만 처리상태가 되는 new Promise를 반환한다. 체인의 다음 then 메서드는 해당 프로미스의 처리를 기다린다.

비동기 동작은 항상 프로미스를 반환하도록 하는 것이 좋다. 지금은 당장 체인을 확장할 계획이 없더라도 이렇게 구현해 놓으면 나중에 체인 확장이 필요한 경우에 손쉽게 체인을 확장할 수 있다.

위에서 살펴본 예시를 재사용 가능한 함수 단위로 분리한 코드를 살펴보자.

function loadJson(url) {
  return fetch(url)
    .then(response => response.json());
}

function loadGithubUser(name) {
  return fetch(`https://api.github.com/users/${name}`)
    .then(response => response.json());
}

function showAvatar(githubUser) {
  return new Promise(function(resolve, reject) {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => {
      img.remove();
      resolve(githubUser);
    }, 3000);
  });
}

// 함수를 이용하여 다시 동일 작업 수행
loadJson('/article/promise-chaining/user.json')
  .then(user => loadGithubUser(user.name))
  .then(showAvatar)
  .then(githubUser => alert(`Finished showing ${githubUser.name}`));
  // ...

프로미스와 에러 핸들링

프로미스가 거부되면 제어 흐름이 제일 가까운 rejection 핸들러로 넘어간다. 따라서, 프로미스 체인을 사용하면 에러를 쉽게 처리할 수 있다.

존재하지 않는 주소를 fetch에 넘겨주는 예시를 살펴보자.catch 메서드에서 에러를 처리한다.

fetch('https://no-such-server.blabla') // 거부
  .then(response => response.json())
  .catch(err => alert(err)) // TypeError: failed to fetch (출력되는 내용은 다를 수 있음)

catch 메서드는 첫 번째 핸들러일 필요가 없고, 하나 혹은 여러 개의 then 메서드 뒤에 올 수 있다.

fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  .then(response => response.json())
  .then(githubUser => new Promise((resolve, reject) => {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => {
      img.remove();
      resolve(githubUser);
    }, 3000);
  }))
  .catch(error => alert(error.message));

정상적인 경우라면 catch는 트리거 되지 않는다. 그런데 네트워크 문제나 잘못된 형식의 JSON 등으로 위쪽 프로미스 중 하나라도 거부되면 catch에서 에러를 잡게 된다.

암시적 try catch

프로미스 executor 주위에는 암시적인 try catch가 있다. 예외가 발생하면 암시적 try catch에서 예외를 잡고, 이를 reject처럼 다룬다.

new Promise((resolve, reject) => {
  throw new Error("에러 발생!");
}).catch(alert); // Error: 에러 발생!

// 위 예시는 아래와 같은 동작을 한다.
new Promise((resolve, reject) => {
  reject(new Error("에러 발생!"));
}).catch(alert); // Error: 에러 발생!

executor 뿐만 아니라 프로미스 핸들러에서도 발생한다. then 핸들러 안에서 throw를 사용해서 에러를 던지면, 제어 흐름이 가장 가까운 에러 핸들러로 넘어간다.

new Promise((resolve, reject) => {
  resolve("OK");
}).then((result) => {
  throw new Error("에러 발생!"); // 프로미스가 거부됨
}).catch(alert); // Error: 에러 발생!

throw 문이 만든 에러뿐만 아니라 모든 종류의 에러가 암시적 try catch에서 처리된다.

new Promise((resolve, reject) => {
  resolve("OK");
}).then((result) => {
  blabla(); // 존재하지 않는 함수
}).catch(alert); // ReferenceError: blabla is not defined

다시 던지기

프로미스 체인 마지막의 catchtry catch와 유사한 역할을 한다. then 메서드를 원하는 만큼 사용하다가 마지막에 catch 메서드 하나만 붙이면 앞선 then 메서드에서 발생한 모든 에러를 처리할 수 있다.

일반적으로 try catch에서는 에러를 분석하고, 처리할 수 없는 에러로 판단되면 에러를 다시 던질 때가 있다. 프로미스에도 유사한 일을 할 수 있다.

catch에서 에러가 성공적으로 처리되면 가장 가까운 곳에 있는 then 메서드로 제어 흐름이 넘어가서 실행이 이어진다.

// 실행 순서: catch -> then
new Promise((resolve, reject) => {

  throw new Error("에러 발생!");

}).catch(function(error) {

  alert("에러가 잘 처리되었습니다. 정상적으로 실행이 이어집니다.");

}).then(() => alert("다음 핸들러가 실행됩니다."));

catch 안에서 에러를 처리할 수 없어서 throw를 사용하면 제어 흐름이 가장 가까운 곳에 있는 에러 핸들러로 에러를 다시 던진다.

// 실행 순서: catch -> catch
new Promise((resolve, reject) => {

  throw new Error("에러 발생!");

}).catch(function(error) { // (*)

  if (error instanceof URIError) {
    // 에러 처리
  } else {
    alert("처리할 수 없는 에러");

    throw error; // 에러 다시 던지기
  }

}).then(function() {
  /* 여기는 실행되지 않는다. */
}).catch(error => { // (**)

  alert(`알 수 없는 에러가 발생함: ${error}`);
  // 반환값이 없음 => 실행이 계속됨

});

처리되지 못한 거부

아래 예시처럼 프로미스 체인 끝에 catch가 없어 에러를 처리하지 못하면 무슨 일이 발생할까?

new Promise(function() {
  noSuchFunction(); // 존재하지 않는 함수를 호출하기 때문에 에러가 발생함
})
  .then(() => {
    // 성공상태의 프라미스를 처리하는 핸들러. 한 개 혹은 여러 개가 있을 수 있음
  }); // 끝에 .catch가 없음!

에러가 발생하면 프로미스는 거부상태가 되고, 실행 흐름은 가장 가까운 rejection 핸들러로 넘어간다. 그런데 위 예시에서는 예외를 처리할 핸들러가 없어서 에러가 갇혀버린다. 이런 식으로 코드에 처리하지 못한 에러가 남게 되면 실무에서는 끔찍한 일이 발생할 수 있다.

자바스크립트 엔진은 프로미스 rejection을 추적하다가 위와 같은 상황이 발생하면 전역 에러를 생성한다. 브라우저 환경에서는 이런 에러를 unhandledrejection 이벤트로 처리할 수 있다.

window.addEventListener('unhandledrejection', function(event) {
  // unhandledrejection 이벤트엔 두 개의 특수 프로퍼티가 있다.
  alert(event.promise); // [object Promise] - 에러를 생성하는 프라미스
  alert(event.reason); // Error: 에러 발생! - 처리하지 못한 에러 객체
});

new Promise(function() {
  throw new Error("에러 발생!");
}); // 에러를 처리할 수 있는 .catch 핸들러가 없음

브라우저 환경에서 에러가 발생했는데 catch가 없으면 unhandledrejection 핸들러가 트리거 된다. unhandledrejection 핸들러는 에러 정보가 담긴 event 객체를 받기 때문에 이 핸들러 안에서 원하는 작업을 할 수 있다.

대개 이런 에러는 회복할 수 없다. 개발자로서 할 수 있는 최선의 방법은 사용자에게 문제 상황을 알리고, 가능하다면 서버에 에러 정보를 보내는 것이다.


프로미스 API

Promise 클래스에는 5가지 정적 메서드가 있다.

Promise.all

여러 개의 프로미스를 동시에 실행시키고 모든 프로미스가 준비될 때까지 기다린다고 해보자.

복수의 URL로 동시에 요청을 보내고, 다운로드가 모두 완료된 후에 콘텐츠를 처리할 때 사용할 수 있다.

let promise = Promise.all(promisesArr);

Promise.all은 요소 전체가 프로미스인 이터러블 객체(대개는 배열임)을 받고, 새로운 프로미스를 반환한다.

배열 안의 프로미스가 모두 처리되면 새로운 프로미스가 이행되는데, 배열 안 프로미스의 결과값을 담은 배열이 새로운 프로미스의 result가 된다.

아래 예시에서 Propmise.all은 3초 후에 처리되고, 반환된는 프로미스의 result[1, 2, 3] 이다.

Promise.all([
  new Promise(resolve => setTimeout(() => resolve(1), 3000)), // 1
  new Promise(resolve => setTimeout(() => resolve(2), 2000)), // 2
  new Promise(resolve => setTimeout(() => resolve(3), 1000))  // 3
]).then(alert); // 프라미스 전체가 처리되면 1, 2, 3이 반환.

프로미스의 결과인 result 배열의 요소 순서는 Promise.all에 전달되는 프로미스 순서와 상응한다. 즉, 첫 번째 프로미스가 가장 늦게 이행되더라도 처리 결과는 배열의 첫 번째 요소에 저장된다.

작업해야 할 데이터가 담긴 배열을 프로미스 배열로 맵핑하고, 이 배열을 Promise.all로 감싸는 때에 많이 사용한다.

URL이 담긴 배열을 fetch 메서드로 처리하는 예시를 살펴보자.

let urls = [
  'https://api.github.com/users/iliakan',
  'https://api.github.com/users/Violet-Bora-Lee',
  'https://api.github.com/users/jeresig'
];

// fetch를 사용해 url을 프라미스로 매핑
let requests = urls.map(url => fetch(url));

// Promise.all은 모든 작업이 이행될 때까지 기다림
Promise.all(requests)
  .then(responses => responses.forEach(
    response => alert(`${response.url}: ${response.status}`)
  ));

깃허브 유저네임이 담긴 배열을 사용해서 사용자 정보를 가져오는 예시를 살펴보자. 참고로,id를 기준으로 장바구니 목록을 불러올 때도 사용할 수 있다.

let names = ['iliakan', 'Violet-Bora-Lee', 'jeresig'];

let requests = names.map(name => fetch(`https://api.github.com/users/${name}`));

Promise.all(requests)
  .then(responses => {
    // 모든 응답이 성공적으로 이행됨
    for(let response of responses) {
      alert(`${response.url}: ${response.status}`);
    }

    return responses;
  })
  // 응답 메시지가 담긴 배열을 response.json()로 매핑해, 내용을 읽음
  .then(responses => Promise.all(responses.map(r => r.json())))
  // JSON 형태의 응답 메시지는 파싱 되어 배열 'users'에 저장됨
  .then(users => users.forEach(user => alert(user.name)));

Promise.all에 전달되는 프로미스 중 하나라도 거부되면, Promise.all이 반환하는 프로미스는 에러와 함께 바로 거부된다.

아래 예시에서 2초 후 두 번째 프라미스가 거부되면서 Promise.all 전체가 거부되고, .catch가 실행된다. 거부 에러는 Promise.all 전체의 결과가 된다.

Promise.all([
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),
  new Promise((resolve, reject) => setTimeout(() => reject(new Error("에러 발생!")), 2000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))
]).catch(alert); // Error: 에러 발생!

Promise.all은 대개 프로미스가 요소인 이터러블 객체를 받지만, 요소가 프로미스가 아닐 경우에는 그대로 결과 배열에 전달된다. 이미 결과를 알고 있는 값은 이런 특징을 이용해서 Promise.all에 그냥 전달하면 된다.

Promise.all([
  new Promise((resolve, reject) => {
    setTimeout(() => resolve(1), 1000)
  }),
  2,
  3
]).then(alert); // 1, 2, 3

Promise.allSettled

Promise.all은 프로미스가 하나라도 거절되면 전체를 거절한다. 반면, Promise.allSettled는 모든 프로미스가 처리될 때까지 기다린다. 반환되는 배열은 다음과 같은 요소들을 갖는다.

  • 응답이 성공할 경우 : { status: "fulfilled", value: result }
  • 에러가 발생한 경우 : { status: "rejected", reason: error }

fetch를 사용해서 여러 사람의 정보를 가져온다고 해보자. 여러 요청 중 하나가 실패해도 다른 요청 결과는 여전히 필요하다. 이럴 때 Promise.allSettled를 사용한다.

let urls = [
  'https://api.github.com/users/iliakan',
  'https://api.github.com/users/Violet-Bora-Lee',
  'https://no-such-url'
];

Promise.allSettled(urls.map(url => fetch(url)))
  .then(results => { // (*)
    results.forEach((result, num) => {
      if (result.status == "fulfilled") {
        alert(`${urls[num]}: ${result.value.status}`);
      }
      if (result.status == "rejected") {
        alert(`${urls[num]}: ${result.reason}`);
      }
    });
  });

// 위의 (*)는 아래와 같다.
[
  {status: 'fulfilled', value: ...응답...},
  {status: 'fulfilled', value: ...응답...},
  {status: 'rejected', reason: ...에러 객체...}
]

Promise.race

Promise.all과 비슷하다. 다만 가장 먼저 처리되는 프로미스의 결과나 에러를 반환한다.

Promise.race([
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),
  new Promise((resolve, reject) => setTimeout(() => reject(new Error("에러 발생!")), 2000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))
]).then(alert); // 1

프로미스화(promisification)

콜백을 받는 함수를 프로미스를 반환하는 함수로 바꾸는 것을 프로미스화라고 한다.

기능을 구현 하다 보면 콜백보다는 프로미스가 더 편리하기 때문에 콜백 기반 함수와 라이브러리를 프로미스를 반환하는 함수로 바꾸는 게 좋은 경우가 종종 생긴다.

loadScript 예시를 프로미스화 해보자.

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error(`${src}를 불러오는 도중에 에러가 발생함`));

  document.head.append(script);
}

// 사용법:
// loadScript('path/script.js', (err, script) => {...})

// 프로미스화
let loadScriptPromise = function(src) {
  return new Promise((resolve, reject) => {
    loadScript(src, (err, script) => {
      if (err) reject(err)
      else resolve(script);
    });
  })
}

// 사용법:
// loadScriptPromise('path/script.js').then(...)

loadScriptPromiseloadScript에 모든 일을 위임한다. loadScript의 콜백은 스크립트 로딩 상태에 따라 이행 혹은 거부 상태의 프로미스를 반환한다.

실무에서는 여러 개의 함수를 프로미스화할 일이 생길 수 있으므로, 헬퍼 함수를 만들어보자.

function promisify(f) {
  return function (...args) { // 래퍼 함수를 반환함
    return new Promise((resolve, reject) => {
      function callback(err, result) { // f에 사용할 커스텀 콜백
        if (err) {
          reject(err);
        } else {
          resolve(result);
        }
      }

      args.push(callback); // 위에서 만든 커스텀 콜백을 함수 f의 인수 끝에 추가한다.

      f.call(this, ...args); // 기존 함수를 호출한다.
    });
  };
};

// 사용법:
let loadScriptPromise = promisify(loadScript);
loadScriptPromise(...).then(...);

이러한 방식들도 콜백을 완전히 대체하지는 못한다. 프로미스는 하나의 결과만 가질 수 있지만, 콜백은 여러 번 호출할 수 있기 때문이다. 따라서 프로미스화는 콜백을 단 한 번 호출하는 함수에만 적용하자.


마이크로태스크

프로미스 핸들러 then, catch, finally는 항상 비동기적으로 실행된다. 프로미스가 즉시 이행되더라도, 해당 메서드들 보다 아래에 있는 코드들이 먼저 실행된다.

let promise = Promise.resolve();

promise.then(() => alert("프라미스 성공!"));

alert("코드 종료"); 

위 예시를 실행하면 프로미스가 즉시 실행되어도 코드 종료가 가장 먼저 출력되고, 프로미스 성공이 출력된다. 이는 마이크로태스크 큐 방식 때문이다.

마이크로태스크 큐

비동기 작업을 처리할 때 ECMA에서는 PromiseJobs라는 내부 큐를 명시한다. V8 엔진에서는 이를 마이크로태스크 큐라 부른다.

실행할 것이 아무것도 남아있지 않을 때만 마이크로태스크 큐에 있는 작업이 실행되기 시작한다. 마이크로태스크 큐는 먼저 들어온 작업을 먼저 실행한다.

앞 선 예시에서 코드 종료가 먼저 출력되는 이유가 바로 여기에 있다.

프로미스 성공을 먼저 출력시키려면 어떻게 해야 할까? 방법은 간단하다. then을 사용해서 큐에 넣으면 된다. 즉, 어떤 코드를 then, catch, finally 메서드가 호출된 이후에 실행하고 싶다면 then을 체인에 추가하고 이 안에 코드를 넣으면 된다.

Promise.resolve()
  .then(() => alert("프라미스 성공!"))
  .then(() => alert("코드 종료"));

async와 await

async 함수

함수 앞에 async를 붙이면 해당 함수는 항상 프로미스를 반환한다. 프로미스가 아닌 값을 반환하더라도 이행 상태의 프로미스로 값을 감싸 이행된 프로미스가 반환되도록 한다.

아래 예시의 함수를 호출하면 result가 1인 이행 프로미스가 반환된다.

async function f() {
  return 1;
}

f().then(alert); // 1

명시적으로 프로미스를 반환하는 것도 가능하다. 결과는 동일하다.

async function f() {
  return Promise.resolve(1);
}

f().then(alert); // 1

await

await 키워드는 async 함수 안에서만 동작한다. 자바스크립트는 await 키워드를 만나면 프로미스가 처리될 때까지 기다린다. 결과는 그 이후 반환된다.

let value = await promise;

1초 후 이행되는 프로미스를 예시로 사용하여 await의 동작을 살펴보자.

async function f() {
  let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("완료!"), 1000)
  });
  
  let result = await promise;	// 프로미스가 이행될 때까지 기다림
  
  alert(result);	// 완료!
}

f();

함수를 호출하고, 함수 본문이 실행되는 도중에 (*)로 표시한 줄에서 실행이 잠시 중단되었다가 프로미스가 처리되면 실행이 재개된다. 이때 프로미스 객체의 result 값이 변수 result에 할당된다.

await은 말 그대로 프로미스가 처리될 때까지 함수 실행을 기다리게 만든다. 프로미스가 처리되면 그 결과와 함께 실행이 재개된다.

프로미스가 처리되길 기다리는 동안에 엔진이 다른 일을 할 수 있기 때문에, CPU 리소스가 낭비되지 않는다.

awaitpromise.then 보다 좀 더 세련되게 프로미스의 result 값을 얻을 수 있다. 또, 가독성이 좋고 쓰기도 쉽다.

일반 함수에는 await을 사용할 수 없다.

function f() {
  let promise = Promise.resolve(1);
  let result = await promise; // Syntax error
}

프로미스 체이닝으로 구현한 showAvatar 함수를 async / await로 다시 작성해보자.

  • 먼저 then 호출을 await로 바꾼다.
  • function 앞에 async를 붙인다.
async function showAvatar() {

  // JSON 읽기
  let response = await fetch('/article/promise-chaining/user.json');
  let user = await response.json();

  // github 사용자 정보 읽기
  let githubResponse = await fetch(`https://api.github.com/users/${user.name}`);
  let githubUser = await githubResponse.json();

  // 아바타 보여주기
  let img = document.createElement('img');
  img.src = githubUser.avatar_url;
  img.className = "promise-avatar-example";
  document.body.append(img);

  // 3초 대기
  await new Promise((resolve, reject) => setTimeout(resolve, 3000));

  img.remove();

  return githubUser;
}

showAvatar();

코드가 깔끔해지고 읽기도 쉬워졌다. 프로미스를 사용한 예시보다 훨씬 낫다.

await은 최상위 레벨 코드에서는 작동하지 않는다.

// 최상위 레벨 코드에선 문법 에러가 발생함
let response = await fetch('/article/promise-chaining/user.json');
let user = await response.json();

하지만 익명 async 함수로 코드를 감싸면 최상위 레벨 코드에도 await을 사용할 수 있다.

(async () => {
  let response = await fetch('/article/promise-chaining/user.json');
  let user = await response.json();
  ...
})();

또, 클래스의 메서드 이름 앞에 async를 추가하면 async 클래스 메서드를 선언할 수 있다.

class Waiter {
  async wait() {
    return await Promise.resolve(1);
  }
}

new Waiter()
  .wait()
  .then(alert); // 1

에러 핸들링

프로미스가 정상적으로 이행되면 await promise는 프로미스 객체의 result에 저장된 값을 반환한다. 반면 프로미스가 거부되면 마치 throw문처럼 에러가 던져진다.

async function f() {
  await Promise.reject(new Error("에러 발생!"));
}

// 아래 코드와 동일하게 동작한다.
async function f() {
  throw new Error("에러 발생!");
}

실제 상황에서는 프로미스가 거부 되기 전에 약간의 시간이 지체되는 경우가 있다. 이런 경우에는 await가 에러를 던지기 전에 지연이 발생한다.

await가 던진 에러는 throw가 던진 에러를 잡을 때처럼 try catch를 사용해서 잡을 수 있다.

async function f() {
  try {
    let response = await fetch('http://유효하지-않은-주소');
  } catch (err) {
    alert(err); // TypeError: failed to fetch
  }
}

f();

에러가 발생하면 제어 흐름이 catch 블록으로 넘어간다. 여러 줄의 코드를 try로 감싸는 것도 가능하다.

async function f() {

  try {
    let response = await fetch('http://유효하지-않은-주소');
    let user = await response.json();
  } catch(err) {
    // fetch와 response.json에서 발행한 에러 모두를 여기서 잡는다.
    alert(err);
  }
}

f();

try catch가 없으면 async 함수를 호출해서 만든 프로미스가 거부 상태가 된다. catch를 추가하면 거부된 프로미스를 처리할 수 있다.

async function f() {
  let response = await fetch('http://유효하지-않은-주소');
}

// f()는 거부 상태의 프라미스가 됩니다.
f().catch(alert); // TypeError: failed to fetch // (*)

async / await을 사용하면 await가 대기 상태를 처리해주기 때문에 then이 거의 필요하지 않다. 또, catch 대신 try catch를 사용할 수 있다는 장점도 생긴다.

항상 그런것은 아니지만, promise.then 보다 async / await을 사용하는 것이 대개는 더 편리하다. 그런데 문법적 제약 때문에 async 함수 바깥의 최상위 레벨 코드에서는 await을 사용할 수 없다. 그렇기 때문에 관행처럼 then / catch 를 추가해서 최종 결과나 처리되지 못한 에러를 다룬다. 위 예시의 (*)가 그 예이다.

여러 개의 프로미스가 모두 처리되길 기다려야 하는 상황이라면, awaitPromise.all과도 함께 쓸 수 있다.

// 프라미스 처리 결과가 담긴 배열을 기다린다.
let results = await Promise.all([
  fetch(url1),
  fetch(url2),
  ...
]);

참고 문헌

https://ko.javascript.info/async

profile
더깊이

1개의 댓글

comment-user-thumbnail
2023년 6월 20일

gd

답글 달기