[JS] 프로미스(Promises)

/:frontend

비동기 동작

많은 프로그램들은 바깥 세상에 있는 것들과 상호작용을 한다. 네트워크를 통해 무언가 요구할 수도 있고, 하드디스크에서 뭔가 들고올 수도 있다. 필요한 것들을 요청한 후, 이것들을 받을 때까지 기다려야 받은 것에 대한 추가작업이 가능할 것이다.

요청에 대한 기다림을 처리하는 방법에는 크게 두 가지 모델이 있다. **동기 모델(synchronous model)**과 **비동기 모델(asynchronous model)**이다.

동기 모델로 요청을 처리할 경우, 요청에 대한 응답이 올 때까지 뒤의 다른 작업은 기다려야 한다. 이것을 **블로킹(blocking)**이라고 한다. 뒤에 요청에 대한 응답과는 전혀 무관한 일들이 있어도 대기해야 하기 때문에 자원 낭비가 발생할 수 있다.

반면 비동기 모델에서는 요청에 대한 응답이 올 때까지 기다리지 않고 다음 작업을 실행한다. 이후 응답이 도착하면 이벤트를 발생시켜 받은 데이터로 수행해야 할 작업을 계속한다.

자바스크립트가 실행되는 주요 환경인 브라우저와 Node.js에서는 비동기 방식을 사용한다.

Callbacks

비동기 프로그래밍을 구현하는 한 가지 방법으로는 'callback' 함수를 이용하는 방법이 있다.

느린 동작을 수행하는 함수가 콜백 함수를 추가적인 인수로 받게해서, 동작이 끝났을 때 콜백 함수를 결과와 함께 호출하는 방식이다. 예로, setTimeout 함수는 인수로 준 밀리초 이후 함수를 호출한다.

setTimeout(() => console.log('Tick'), 500);

이런 식으로 비동기 동작을 구현하는 것에는 문제점이 있다.

  • 연속적인 비동기 호출을 수행하려고 한다면, 콜백안에 콜백, 또 콜백 안에 콜백이 쌓여 콜백 지옥이 형성될 수 있다.
  • 가장 큰 문제는 에러처리가 곤란하다는 것이다.

Event Loops

비동기적 동작은 '빈 함수 콜 스택'에서 실행된다.

setTimeout 함수를 어떤 함수 안에서 호출했다고 가정해보자. 아마도 호출한 함수는 setTimeout의 콜백 함수가 호출되기 전에 이미 작업을 끝냈을 것이다. 따라서, 콜백이 리턴을 한 후에는 컨트롤이 자신을 스케쥴한 함수 setTimeout으로 다시 돌아가지 않는다. 이런 특성 때문에 에러 처리가 힘들어진다. 아래 예를 보자.

try {
  setTimeout(() => {
    throw new Error('에러!');
  }, 20);
} catch (_) {
  // 에러 처리 안됨
  console.log('에러 발견');
}

콜백에서 에러를 던지지만, 빈 함수 콜 스택에서 처리되기 때문에 에러를 핸들링하지 못한다.

자바스크립트 런타임은 이벤트가 발생한 비동기 프로그램을 처리하기 위해 **'Event Loop(이벤트 루프)'**을 사용한다. 처리할 이벤트가 큐에 들어가고, 이벤트 루프는 가장 오래된 것부터 하나씩 처리한다. 이벤트 루프를 다음과 같이 코드화하면 쉽게 이해할 수 있다.

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

이벤트 루프는 가장 오래된 것부터 처리한다고 했는데... 그럼 위에서 봤던 setTimeout(() => console.log("Tick"), 500)은 뭘까? 0.5초 후에 실행되는 것이 아니였나? 아니다. setTimeout에 넣어주는 지연시간은 보장 시간이 아니라 대기할 최소 시간이다. 0.5초가 지났어도 자신보다 앞에 있는 이벤트들이 전부 처리되어야 실행될 수 있다.

Promises

프로미스는 정해진 장시간 걸리는 기능을 수행하고 나서 성공 여부와, 그에 따라서 결과 혹은 에러를 전달해주는 객체다.

프로미스는 Promise 생성자를 통해 생성할 수 있다.

let promise = new Promise(function (resolve, reject) {
  // executor
});

Promise 생성자 함수는 비동기 작업을 수행할 콜백 함수를 인자로 받는다. 이 콜백 함수는 'executor'라고 부르고 resolvereject 콜백 함수를 인자로 갖는다. 각각은 일이 성공적으로 끝났을 때, 일이 실패했을 때 사용된다. 두 콜백 중 하나는 무조건 executor에서 실행되야 한다.

프로미스는 new Promise가 만들어질 때 executor가 자동으로 실행된다. 불필요한 네트워크 통신을 줄이려면 이 점에 유의하자.

프로미스는 아래와 같은 state를 속성으로 가진다.

  • pending: 초기 상태
  • fullfilled: resolve 호출 시
  • rejected: reject 호출 시

'fullfilled'되거나 'rejected'된 프로미스는 'settled' 프로미스라고 한다.

또한 result라는 속성을 가진다. 처음 값 undefined에서 resolve(value)가 호출되면 value로, reject(error)가 호출되면 error로 변한다.

아래는 두 가지 상태를 실험해볼 간단한 프로미스를 구현했다.

function resolveOrReject(flag) {
  return new Promise((resolve, reject) => {
    if (flag === 1) {
      setTimeout(() => resolve('성공!'), 1000);
    } else {
      setTimeout(() => reject(new Error('으아 에러다')), 1000);
    }
  });
}

flag가 1 이면, resolve("성공!")이 1초후 실행되어 프로미스의 state는 'fullfilled', result"성공!"이 된다. 아니라면, reject(new Error("으아 에러다"))이 1초후 실행되어 프로미스의 state는 'rejected', resulterror가 된다.

이렇게 구현된 프로미스 객체는 then, catch, finally 메서드를 통해 후속 처리할 수 있다. 이 메서드들은 모두 프로미스를 반환하기 때문에 서로 연결이 가능해진다.

then

then은 프로미스에서 가장 중요하고 기본이 되는 메서드다. 두가지 인자를 받을 수 있다. 첫 번째는 프로미스가 resolve 되었을 때 결과를 받아 실행되는 함수, 두 번째는 프로미스가 reject 되었을 때 에러를 받아 실행하는 함수다.

resolveOrReject(1).then(
  (res) => console.log(res),
  () => console.log(res),
);
// -> 성공!
resolveOrReject(0).then(
  (res) => console.log(res),
  () => console.log(res),
);
// -> Error: 으아 에러다

then에 인자를 하나만 전달하면 성공적으로 처리된 경우만 다룬다.

catch

.catch(errorHandling)사용 시, .then(null, errorHandling)같이 에러가 발생한 경우에 대해서만 다룬다. 둘다 똑같이 작동하지만, then(f1).catch(f2)가 아닌 .then(f1, f2)로 구현하면 f1에서 발생한 에러를 잡지 못한다는 차이가 발생한다.

finally

.finally(f) 호출은 프로미스가 처리되면(resolve/reject) f가 항상 실행된다. 이 메서드에서는 프라미스가 resolve 되었는지, reject 되었는지 알 수 없다. 성공 혹은 실패 여부에 상관없는 보편적 동작을 취하기 때문이다. finally 핸들러는 자동으로 다음 헨들러에 결과와 에러를 전달한다. 아래 예시를 보자.

resolveOrReject(1)
  .finally(() => console.log('사이에 끼기'))
  .then((res) => console.log(res));
// -> 사이에 끼기
// -> 성공!

resultfinally를 거쳐 then에 전달되는 것을 볼 수 있다.

프로미스 체이닝

콜백으로 비동기 함수의 처리 결과를 비동기 함수로 처리해야 하는 경우, 함수 중첩으로 인해 콜백 지옥이 나타날 수 있다. 프로미스는 후속 처리 메소드를 통해 여러 개의 프로미스를 연결해 사용하는 방법으로 이 문제를 해결할 수 있다.

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 new Promise((resolve, reject) => {
      setTimeout(() => resolve(result * 2), 1000);
    });
  })
  .then(function (result) {
    alert(result); // 4
  });

위 코드를 실행해보면 결과가 체인에 따라 전달되는 것을 볼 수 있다. promise.then을 호출하면 프로미스가 반환되기 때문에 체이닝이 가능하다.

비동기 동작은 항상 프라미스를 반환하도록 하는 것이 좋다. 나중에 체인 확장이 필요한 경우 손쉽게 체인을 확장할 수 있기 떄문이다.

프라미스 API

프라미스는 다섯 가지 정적 메소드를 가진다.

Promise.all

여러 프라미스를 동시에 실행시키고 모든 프라미스가 준비될 때까지 기다릴 때 사용할 수 있다.

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

Promise.all은 요소 전체가 프로미스인 배열(iterable)을 받고 새로운 프라미스를 반환한다. 새로운 프로미스의 result는 배열 안 프라미스의 결괏값을 담은 배열이다.

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

프로미스 중 하나라도 거부되면, Promise.all이 반환하는 프로미스는 가장 먼저 reject한 프로미스의 에러와 함께 바로 거부된다. 나머지 프로미스는 처리되기는 하지만 그 결과는 무시된다.

Promise.allSettled

모든 프로미스가 처리될 때까지 기다린다. Promise.all과 다르게 여러 요청 중 하나가 실패해도 다른 요청 결과를 그대로 가지고 있는다.

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

반환되는 배열의 결과는 다음 두 가지를 가질 수 있다.

  • 응답이 성공할 경우: {status:"fulfilled", value:result}
  • 응답이 실패할 경우: {status:"rejected", reason:error}

각 프로미스의 상태와 값 혹은 에러를 받을 수 있다.

Promise.race

Promise.all과 비슷하지만, 가장 먼저 처리되는 프로미스의 결과(혹은 에러)를 반환하는 것이 다르다.

let promise = Promise.race(iterable);

가장 빨리 처리된 프로미스가 등장하는 순간부터는 다른 프로미스에서 에러가 나던 결과가 나던 무시된다.

Promise.resolve/reject

Promise.resolve는 결괏값이 value인 resloved 프로미스를 생성한다. 호환성을 위해 함수가 프로미스를 반환하도록 해야할 때 사용할 수 있다. 아래 예시를 보자

let cache = new Map();

function loadCached(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;
    });
}

loadCached는 URL을 대상으로 fetch를 호출하고, 그 결과를 캐시한다. 나중에 동일한 URL로 사용할 때 다시 fetch하지 않게 할 수 있는데, 이떄 Promise.resolve를 사용해서 캐시된 내용을 프로미스로 만들어 반환값이 항상 프로미스로 되게한다. 따라서 then, catch 같은 후속 처리를 할 수 있다.

Promise.reject는 똑같지만 결괏값이 에러인 rejected 프로미스를 반환한다.

마이크로태스크 큐

모든 프라미스 동작은 **'마이크로태스크 큐'**라고 불리는 내부 PromiseJobs 큐에 들어가서 처리되기 때문에 프라미스 핸들링은 항상 비동기로 처리된다.

let promise = Promise.resolve();
promise.then(() => console.log(1));
console.log(2);
// -> 2
// -> 1

마이크로태스크 큐는 먼저 들어온 작업을 먼저 실행하고, 마이크로태스크 큐에 있는 작업은 실행할 것이 아무것도 남아있지 않을 때만 실행된다. 따라서 .then/catch/finally 핸들러는 항상 현재 코드가 종료되고 난 후에 호출된다.

마이크로태스크는 코드를 사용해서만 만들 수 있는데, 주로 프라미스를 사용해 만든다. 프라미스와 함께 쓰이는 .then/catch/finallyawait가 마이크로태스크가 된다. 이 외에도 표준 API인 queueMicrotask(func)를 사용하면 함수를 마이크로태스크 큐에 넣어 처리할 수 있다.

참조

Eloquent JavaScript
ko.javascript.info
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise
https://developer.mozilla.org/ko/docs/Web/JavaScript/EventLoop https://poiemaweb.com/es6-promise