[모던JS: Core] 비동기 - 프라미스(Promise) 및 async/await (3)

KG·2021년 5월 28일
0

모던JS

목록 보기
21/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

프라미스 API

Promise 클래스에는 5가지 정적 메서드가 있다. 각각의 메서드에 대해 빠르게 살펴보자.

1) Promise.all

여러 개의 프라미스를 동시에 실행시키고 모든 프라미스가 준비될 때까지 기다린다고 해보자. 복수의 URL 요청을 동시에 보내고, 다운로드가 모두 완료된 후 콘텐츠를 처리해야 하는 경우 등이 그러한 상황이다. 이러한 경우에 Promise.all을 사용할 수 있다.

let promise = Promise.all([...promises]);

Promise.all은 요소 전체가 프라미스인 배열(엄밀히 따지면 이터러블 객체이지만, 대개 배열로 처리)을 받고 새로운 프라미스를 반환한다. 배열 안 프라미스가 모두 처리되면 새로운 프라미스가 이행되는데, 배열 안 프라미스의 결과값을 담은 배열이 새로운 프라미스의 result가 된다.

아래 코드에서 Promise.all은 3초후에 처리되고, 반환되는 프라미스의 result는 배열 [1, 2, 3]이 된다.

Promise.all([
  new Promise(resolve => setTimeout(() => resolve(1), 3000)),
  new Promise(resolve => setTimeout(() => resolve(2), 2000)),
  new Promise(resolve => setTimeour(() => resolve(3), 1000))
]).then(console.log);                           

배열 result의 요소 순서는 Promise.all에 전달되는 프라미스의 순서와 상응하다는 점에 주목하자. 첫 번째 프라미스는 3초후에 실행되기에 3개 중에서 가장 늦게 실행되지만, 그럼에도 불구하고 반환되는 결과에는 프라미스가 담긴 순서대로 결과값이 저장된다. 또한 모든 요청이 동시에 요청되기 때문에 총 소요시간은 3초라는 점도 유의하자.

작업해야 할 데이터가 담긴 배열을 프라미스 배열로 매핑하고, 이 배열을 Promise.all로 감싸는 트릭은 자주 사용된다. 다음은 URL이 담긴 배열을 fetch와 매핑시켜 프라미스 배열을 만든 후 Promise.all에 전달하여 처리하는 예시이다.

let urls = [
  'https://api.github.com/users/iliakan',
  'https://api.github.com/users/remy',
  'https://api.github.com/users/jeresig'
];

// url 목록과 fetch 매핑
let requests = urls.map(url => fetch(url));

Promise.all(requests)
  .then(responses => responses.forEach(
    response => console.log(response.json())
  ));

이때 주의해야 할 점이 하나있다. 만약 Promise.all에 전달되는 프라미스 중 하나라도 거부되면, 다른 프라미스 역시 모두 무시된다는 점이다. 그리고 Promise.all이 즉시 무시되면서 배열에 저장된 다른 프라미스의 결과가 이미 있더라도 이는 완전히 사라지게 된다. 프라미스에는 취소라는 개념이 없기 때문에 Promise.all도 프라미스를 취소하지는 않아 정상적인 프라미스라면 처리가 실제로 되기는 하겠지만 결과는 무시된다는 점을 주의하자.

Promise.all([
  new Promise((resolve, reject) => setTimeout(() => resovle(1), 1000)),
  new Promise((resolve, reject) => setTimeout(() => reject(new Error('error')), 2000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))
]).catch(console.log);	// Error: error

2초후에 두 번째 프라미스가 거부되는데, 이때 첫 번째 프라미스는 정상적으로 처리가 되었을테지만 Promise.all 자체가 거부되면서 결과는 무시되고 거부 에러를 발생한다. 이때 발생하는 에러는 두 번째 프라미스에서 발생한 에러이다. 즉 Promise.all의 결과 자체는 발생한 에러가 된다. 따라서 catch에서는 에러를 잡게 된다.

이터러블 객체가 아닌 일반값도 Promise.all(...)에 넘길 수는 있다. 일반 값으로 배열을 구성하여 넘기는 경우에는 요소 그대로 결과 배열로 전달된다.

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

2) Promise.allSettled

ES2020에 추가된 최신 스펙의 문법이다. 구식 브라우저의 경우에는 폴리필이 필요할 수 있다.

기존 Promise.all에서 가장 아쉬웠던 부분은 여러 프라미스 중 하나라도 실패하더라도 바로 Promise.all 자체가 거부된다는 점이었다.

반면 Promise.allSettled는 이름에서도 알 수 있듯이 모든 프라미스가 처리될 때까지 기다린다. 그리고 반환되는 배열은 다음과 같은 요소를 가진다.

  • 성공 시 : { status: "fullfilled", value: result }
  • 에러 시 : { status: "rejected", reason: error }

만약 fetch를 사용해 여러 사람의 정보를 가져오고 있다고 해보자. 여러 요청 중 하나가 실패해도 다른 요청 결과는 여전히 유의미해야 할 때 Promise.allSettled를 사용할 수 있다.

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

Promise.allSettled(urls.map(url => fetch(url)))
  .then(results => {
    results.forEach((result, num) => {
      if (result.status === 'fullfilled') {
        console.log(`${urls[num]}: ${result.value.status}`);
      }
      if (result.status === 'rejected') {
        console.log(`${urls[num]}: ${result.reason}`);
      }
    });
  });

이처럼 Promise.allSettled를 사용하면 각 프라미스의 상태에 따라서 값 또는 에러에 대한 분기처리가 가능하다. 하나의 프라미스가 거부되더라도 나머지는 영향을 받지 않는 것을 확인할 수 있다.

만약 구식 브라우저인 경우에는 다음과 같이 폴리필을 구현할 수 있다. 코드를 보며 Promise.allSettled가 어떤 식으로 구현되었는지 살펴보자.

if (!Promise.allSettled) {
  Promise.allSettled = function (promises) {
    return Promise.all(promises.map(p => Promise.resolve(p).then(value => ({
      status: 'fullfilled',
      value
    }), reason => ({
      status: 'rejected',
      reason
    })))));
  };
}

promises.map을 통해 입력값은 p => Promise.resolve(p) 거쳐 프라미스로 변환된다. 그리고 모든 프라미스에 then 핸들러가 추가된다. 앞서서 then 핸들러는 두 개의 인수를 통해 성공과 실패 시 모두 처리할 수 있음을 살펴보았다. 따라서 각각의 경우에 위에서 보았던 객체와 동일한 형태로 반환하도록 해줌으로써 폴리필을 구현할 수 있다.

3) Promise.race

Promise.racePromise.all과 유사하다. 다만 가장 먼저 처리되는 프라미스의 결과만을 반환한다. 이때 결과는 성공된 결과이든 아니면 거부 처리된 에러이든 구분하지 않는다. 즉 여러 프라미스끼리 경주(race)를 통해 가장 먼저 처리된 프라미스의 결과 또는 에러만을 반환하게 된다. 결과가 반환되면 나머지 프라미스의 결과 또는 에러는 모두 무시한다.

Promise.race([
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),
  new Promise((resolve, reject) => setTimeout(() => reject(new Error('error')), 2000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))
]).then(console.log);	// 1

4) Promise.resolve & Promise.reject

정적메서드 Promise.resolvePromise.reject는 다음 챕터에서 다룰 async/await의 등장 이후 그 쓸모가 많이 줄었기 때문에 근래에는 거의 사용하지 않는다는 점을 알아두자.

Promise.resolve

Promise.resolve(value)는 결과값이 value이행 상태 프라미스를 생성한다. 즉 다음과 동일한 작업을 수행한다.

let promise = new Promise(resolve => resolve(value));

앞서 이야기했듯 근래에는 잘 사용하지 않지만 호환성 이슈로 함수가 프라미스를 반환하도록 해야할 때 사용할 수 있다. 다음과 같이 캐싱 함수에서 결과값을 프라미스로 반환할 때, 이미 캐싱된 값이 있는 경우 Promise.resolve를 써서 이행된 프라미스 객체를 반환하는 경우이다.

let cache = new Map();

function loadCache(url) {
  if (cache.has(url)) {
    return Promise.resolve(cache.get(url));
  }
  
  return fetch(url)
    .then(response => response.text())
    .then(text => {
      cache.set(url, text);
      return text;
    });
}

따라서 loadCache 함수는 항상 프라미스 객체를 반환하기 때문에 프라미스 체이닝을 사용할 수 있다.

Promise.reject

Promise.reject(error)는 결과값이 error인 거부 상태 프라미스를 반환한다. 즉 다음과 동일한 작업을 수행한다.

let promise = new Promise((resolve, reject) => reject(error));

프라미스화

콜백 함수를 받는 함수를 프라미스를 반환하는 함수로 바꾸는 것을 프라미스화(promisification)이라고 한다. 콜백보다 프라미스의 효용성이 더 높다는 것을 앞서서 살펴보았기 때문에 콜백 기반 함수를 프라미스로 변환하는 과정이 필요함을 느낄 수 있다. 콜백 챕터에서 구현했던 loadScript(src, callback) 예시를 통해 프라미스화에 대해 살펴보자.

// 콜백형식의 loadScript 함수
function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;
  
  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error('error'));
  
  document.head.append(script);
}

먼저 위 함수를 프라미스화 해보자. 앞서서 이미 콜백 기반을 프라미스 기반으로 옮겨보았는데 그 기억을 떠올려 다시 한번 다듬어보자.

let loadScriptPromise = function(src) {
  return new Promise((resolve, reject) => {
    loadScript(src, (err, script) => {
      if (err) reject(err);
      else resolve(script);
    });
  })
}

앞서 구현했던 것과는 모양새가 좀 다르지만 기능은 동일하다. loadScript 자체를 수정한 것이 아니라, 기존의 loadScript를 프라미스화 한 것이기 때문이다. 새로 구현한 loadScriptPromise는 프라미스 기반 코드와 잘 융화된다.

예시에서 볼 수 있듯이 loadScriptPromise는 기존 함수 loadScript에게 모든 일을 위임하고 있다. loadScript의 콜백은 스크립트 로딩 상태에 따라 이행 혹은 거부 상태의 프라미스를 반환할 것이다.

그러나 실무에서는 함수 하나가 아닌 여러 개의 함수를 프라미스화 해야할 경우가 많다. 따라서 일일이 함수에 프라미스화를 적용하기 보다는 이를 적용할 수 있는 헬퍼 함수를 만들도록 하자.

function promisify(f) {
  return function (...args) {	// 래퍼함수
    return new Promise((resolve, reject) => {
      function callback(err, result) {
        if (err) reject(err);
        else resolve(result);
      }
      
      args.push(callback);
      
      f.call(this, ...args);
    });
  };
};

let loadScriptPromise = promisify(loadScript);
loadScriptPromise(...).then(...);

promisify(f)를 호출하면 프라미스 객체를 반환하는 래퍼함수를 반환한다. 그리고 해당 래퍼함수에서 반환하는 프라미스 객체는 내부에 자체적으로 콜백함수를 가지고 있는데, 이 콜백을 래퍼함수에 전달된 인자(args)에 추가한다. 그리고 기존 함수에서 새로 추가된 인자를 인수로 하여 기존의 함수 f를 호출하는 형식이다.

기존 함수 f를 사용할 때 call 메서드를 사용하는 이유는 위의 예시에는 해당되지 않지만 클래스의 메서드일 경우에는 this에 대한 정보를 소실하기 때문에 이에 대한 연결고리를 마련해주는 것이다. 즉 메서드를 프라미스화 하는 것 까지 고려한 처사이다.

위 예시에서는 프라미스화 할 함수가 인수를 두개(errresult)인 콜백을 받을 것이라 가정하고 작성되었다. 대부분은 이러한 상황인 경우가 많기에 커스텀 콜백은 보통 잘 처리될 수 있다. 그러나 함수 f가 두 개를 초과하는 인수를 가진 콜백을 가진 경우까지 모두 커버하고 싶다면 위에서 구현한 함수를 다음과 같이 바꾸어보자.

// manyArgs라는 인수를 추가 (기본값 false)
function promisify(f, manyArgs = false) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      // results를 배열로 전달
      function callback(err, ...results) {
        if (err) reject(err);
        // manyArg 값에 따라 result 값 전달
        else resolve(manyArgs ? results : results[0]);
      }
      
      args.push(callback);
      
      f.call(this, ...args);
    });
  };
};

f = promisify(f, true);
f(...).then(arrayOfResults => ..., err => ...);

만약 callback(result)와 같이 별도의 err 인수 없이 콜백을 이용하는 형태나 아니면 또 다른 이색적인 콜백의 경우에는 헬퍼 함수를 사용하지 않고 직접 프라미스화를 진행할 수도 있다.

또한 프라미스화를 도와주는 함수를 제공해주는 모듈도 많은데, 대표적으로 es6-promisify라는 라이브러리가 있고, Node.js 호스트 환경에서는 내장 함수 util.promisify를 사용해 손쉽게 프라미스화가 가능하다.

프라미스화는 async/await과 함께 사용하면 더 좋지만 콜백을 완전히 대체할 수는 없다는 것을 주의하자. 프라미스는 하나의 결과만 가질 수 있지만, 콜백은 여러번 호출이 가능하기 때문이다. 따라서 프라미스화는 콜백을 단 한 번 호출하는 함수에만 적용하는 것을 추천한다.

마이크로태스크

프라미스 핸들러 then/catch/finally는 항상 비동기적으로 실행된다. 프라미스가 즉시 이행되더라도 이들은 비동기적으로 실행되기 때문에 다음과 같은 코드 흐름이 이어진다.

// 즉시 이행된 프라미스 (대기시간X)
let promise = Promise.resolve(); 

promise.then(() => console.log('success'));

console.log('finished');

// 출력
// finished
// success

우리는 이와 비슷한 출력 형태를 앞서 setTimeout과 같은 스케줄링 함수를 다루었을 때 이미 본 적이 있다. 프라미스 핸들러 역시 트리거 시점이 이와 유사하다.

1) 마이크로태스크 큐(Queue)

비동기 작업을 처리하려면 적절한 관리가 필요하다. 이를 위해 ECMA에서는 PromiseJobs라는 내부 큐(Internal Queue)를 명시하고 있는데, V8엔진에서는 이를 보통 마이크로태스크 큐(Microtask Queue)라는 용어로 표현한다.

  • 마이크로태스크 큐는 먼저 들어온 작업을 먼저 실행 (FIFO)
  • 실행할 것이 아무것도 남아있지 않을 때만 마이크로태스크 큐에 있는 작업이 실행

즉 어떤 프라미스가 준비되었을 때, 이 프라미스의 핸들러는 마이크로태스크 큐에 들어간다고 생각하면 된다. 호출시점에서 큐에 들어가기 때문에 이때는 아직 실행되지 않은 상태이다. 현재 코드에서 자유로운 상태가 되었을 때에서야 자바스크립트 엔진은 큐에서 작업을 꺼내 실행한다.

이처럼 프라미스 핸들러는 항상 내부 큐를 통과하게 된다. 여러 개의 프라미스 핸들러를 사용해 만든 체인의 경우, 각 핸들러는 모두 비동기적으로 실행된다. 큐에 들어간 핸들러 각각은 현재 코드가 완료되고, 큐에 적재된 이전 핸들러의 실행이 완료되었을 때 실행된다.

그렇다면 위 코드에서 success를 먼저 출력하고, finished를 나중에 출력하게끔 하려면 어떻게 해야할까? 실행 순서가 중요하다고 판단되는 경우에는 다음과 같이 then을 사용해 순서의 흐름을 직접 지정해주도록 하면 된다.

Promise.resolve()
  .then(() => console.log('success'))
  .then(() => console.log('finished'))

2) 처리되지 못한 거부

이전에 프라미스와 에러 핸들링을 다룰 때 unhandledrejection 이벤트를 언급한 적 있다. 마이크로태스크 큐에 대한 개념을 다루었기 때문에 자바스크립트 엔진이 어떻게 처리되지 못한 거부를 찾는지 알 수 있다.

처리되지 못한 거부는 마이크로태스크 큐 끝에서 프라미스 에러가 처리되지 못할 때 발생한다. 정상적인 경우라면 개발자는 에러가 생길것을 대비하여 프라미스 체인에 catch를 추가해 에러를 처리한다.

그러나 이를 생략했을 경우, 자바스크립트 엔진은 마이크로태스크 큐가 빈 이후에 unhandledrejection 이벤트를 트리거 한다.

let promise = Promise.reject(new Error('error!!'));

// Error: error!!
window.addEventListener('unhandledrejection', event => console.log(event.reason));

그런데 만약 아래와 같이 setTimeout을 이용해 에러를 나중에 처리하면 어떤 일이 생길까?

let promise = Promise.reject(new Error('error!!'));
setTimeout(() => promise.catch(err => console.log('catch!')), 1000);

// Error: error!!
window.addEventListener('unhandledrejection', event => console.log(event.reason));

예시를 실행하면 Error: error!!가 먼저 출력되고 catch!가 나중에 출력되는 것을 확인할 수 있다.

unhandledrejection 이벤트는 마이크로태스크 큐에 있는 작업 모두가 완료되었을 때 생성된다. 엔진은 프라미스들을 검사하고 이 중 하나라도 거부(rejected) 상태이면 unhandledrejection 핸들러를 트리거한다. 때문에 첫번째 줄의 코드가 실행되고나서 마이크로태스크 큐가 비워지게 되는데 이때 에러 처리를 하는 코드가 없기 때문에 unhandledrejection 이벤트가 발생하여 에러메시지가 출력된다.

그리고 1초후에 다시 catch 핸들러가 마이크로태스크 큐에 들어오기 때문에 이번에는 에러 처리를 했다는 문구를 출력하기 하지만 그 이전에 미리 에러 메시지가 출력되는 것이다.

이처럼 자바스크립트 엔진은 내부에서 태스크 큐(Task Queue)를 가지고 있는데 이는 다시 매크로태크스와 마이크로태스크 큐로 구분된다. 이는 이벤트루프와 관련이 깊으므로 추후 이벤트루프에 대해 다룰 때 자세히 살펴보도록 하자. 아래 이미지는 마이크로태스크와 매크로 큐에 대한 대략적인 흐름에 대한 자료이다.

References

  1. https://ko.javascript.info/async
profile
개발잘하고싶다

0개의 댓글