프로미스 사용하기

프로미스 사용하기 (Using promises)

Promise는 비동기 연산의 최종 완료 또는 실패를 나타내는 객체다. 대부분의 사람들은 이미 생성된 프로미스의 소비자(consumer)이므로, 이 가이드는 어떻게 만드는지 설명하기 전에 반환된 프로미스의 소비를 먼저 설명한다.

본질적으로 프로미스는 함수에 콜백을 전달하는 대신 콜백을 붙이는 반환된 객체다. createAudioFileAsync()라는 함수를 상상해 보자. 이 함수는 구성 레코드와 두 개의 콜백 함수(오디오 파일이 성공적으로 생성되면 호출되는 것, 오류가 발생하면 호출되는 것)가 주어지면 사운드 파일을 비동기적으로 생성한다.

createAudioFileAsync()를 사용하는 코드는 다음과 같다.

function successCallback(result) {
  console.log(`Audio file ready at URL: ${result}`);
}

function failureCallback(error) {
  console.error(`Error generating audio file: ${error}`);
}

createAudioFileAsync(audioSettings, successCallback, failureCallback);

만약 createAudioFileAsync()가 프로미스를 반환하도록 다시 쓰여졌다면, 대신 여기에 콜백을 붙일 것이다.

createAudioFileAsync(audioSettings).then(successCallback, failureCallback);

이 관례에는 여러 가지 이점이 있다. 각각을 살펴보자.

출처: Using promises - JavaScript | MDN

본문

체이닝 (Chaining)

흔한 요구는 두 개 이상의 비동기 연산을 연달아 실행하는 것이다. 각 후속 연산은 이전 연산이 이전 단계의 결과로 성공할 때 시작된다. 옛날에는 여러 비동기 연산을 연속으로 하는 것이 고전적인 **콜백 지옥(callback hell)**을 초래했다.

doSomething(function (result) {
  doSomethingElse(result, function (newResult) {
    doThirdThing(newResult, function (finalResult) {
      console.log(`Got the final result: ${finalResult}`);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

프로미스를 사용하면 **프로미스 체인(promise chain)**을 만들어 이를 달성한다. 콜백이 함수에 전달되는 대신 반환된 프로미스 객체에 붙기 때문에 프로미스의 API 설계는 이것을 훌륭하게 만든다.

여기가 마법이다: then() 함수는 원본과 다른 새 프로미스를 반환한다.

const promise = doSomething();
const promise2 = promise.then(successCallback, failureCallback);

이 두 번째 프로미스(promise2)는 doSomething()의 완료뿐 아니라, 당신이 전달한 successCallback 또는 failureCallback의 완료도 나타낸다 — 그것들은 프로미스를 반환하는 다른 비동기 함수일 수 있다. 그런 경우 promise2에 추가된 콜백은 successCallback이나 failureCallback이 반환한 프로미스 뒤에 대기열에 오른다.

참고: 가지고 놀 수 있는 동작하는 예제를 원한다면 다음 템플릿을 사용해 프로미스를 반환하는 어떤 함수든 만들 수 있다.

function doSomething() {
  return new Promise((resolve) => {
    setTimeout(() => {
      // 프로미스 완료 전에 할 다른 일들
      console.log("Did something");
      // 프로미스의 fulfillment 값
      resolve("https://example.com/");
    }, 200);
  });
}

구현은 아래 "Creating a Promise around an old callback API" 절에서 논의한다.

이 패턴으로 더 긴 처리 체인을 만들 수 있다. 각 프로미스는 체인의 한 비동기 단계의 완료를 나타낸다. 또한 then의 인수는 선택 사항이며, catch(failureCallback)then(null, failureCallback)의 축약이다. 그래서 오류 처리 코드가 모든 단계에서 같다면 체인 끝에 그것을 붙일 수 있다.

doSomething()
  .then(function (result) {
    return doSomethingElse(result);
  })
  .then(function (newResult) {
    return doThirdThing(newResult);
  })
  .then(function (finalResult) {
    console.log(`Got the final result: ${finalResult}`);
  })
  .catch(failureCallback);

이것을 화살표 함수로 표현한 것도 볼 수 있다.

doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => {
    console.log(`Got the final result: ${finalResult}`);
  })
  .catch(failureCallback);

참고: 화살표 함수 표현식은 암묵적 반환을 가질 수 있다. 그래서 () => x() => { return x; }의 축약이다.

doSomethingElsedoThirdThing은 어떤 값이든 반환할 수 있다 — 만약 프로미스를 반환하면 그 프로미스는 먼저 settle될 때까지 기다려지고, 다음 콜백은 프로미스 자체가 아니라 fulfillment 값을 받는다. 프로미스가 항상 undefined로 resolve되더라도 then 콜백에서 항상 프로미스를 반환하는 것이 중요하다. 이전 핸들러가 프로미스를 시작했지만 반환하지 않았다면 그 settle을 더 이상 추적할 방법이 없고, 프로미스는 "floating(떠 있는)"이라고 말한다.

doSomething()
  .then((url) => {
    // fetch(url) 앞에 `return` 키워드가 빠져 있다.
    fetch(url);
  })
  .then((result) => {
    // result는 undefined입니다. 이전 핸들러에서 아무것도 반환하지 않았기 때문입니다.
    // fetch() 호출의 반환 값을 알 방법도, 성공했는지조차 알 방법도 없습니다.
  });

fetch 호출의 결과(프로미스)를 반환함으로써 완료를 추적하고 완료 시 그 값을 받을 수 있다.

doSomething()
  .then((url) => {
    // `return` 키워드 추가됨
    return fetch(url);
  })
  .then((result) => {
    // result는 Response 객체
  });

경쟁 조건(race condition)이 있으면 floating 프로미스는 더 나빠질 수 있다 — 마지막 핸들러의 프로미스가 반환되지 않으면 다음 then 핸들러가 일찍 호출되고, 그것이 읽는 값은 불완전할 수 있다.

const listOfIngredients = [];

doSomething()
  .then((url) => {
    // fetch(url) 앞에 `return` 키워드가 빠져 있다.
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        listOfIngredients.push(data);
      });
  })
  .then(() => {
    console.log(listOfIngredients);
    // fetch 요청이 아직 완료되지 않았으므로 listOfIngredients는 항상 []입니다.
  });

따라서 경험칙으로, 연산이 프로미스를 만날 때마다 그것을 반환하고 처리를 다음 then 핸들러로 미루라.

const listOfIngredients = [];

doSomething()
  .then((url) => {
    // fetch 호출 앞에 `return` 키워드가 이제 포함됨.
    return fetch(url)
      .then((res) => res.json())
      .then((data) => {
        listOfIngredients.push(data);
      });
  })
  .then(() => {
    console.log(listOfIngredients);
    // listOfIngredients는 이제 fetch 호출의 데이터를 담는다.
  });

더 나은 방법으로, 중첩된 체인을 단일 체인으로 평평하게 만들 수 있다. 이는 더 단순하고 오류 처리를 더 쉽게 만든다. 자세한 내용은 아래 Nesting 절에서 논의된다.

doSomething()
  .then((url) => fetch(url))
  .then((res) => res.json())
  .then((data) => {
    listOfIngredients.push(data);
  })
  .then(() => {
    console.log(listOfIngredients);
  });

async/await를 사용하면 더 직관적이고 동기 코드를 닮은 코드를 작성하는 데 도움이 된다. 아래는 async/await를 사용한 같은 예제다.

async function logIngredients() {
  const url = await doSomething();
  const res = await fetch(url);
  const data = await res.json();
  listOfIngredients.push(data);
  console.log(listOfIngredients);
}

프로미스 앞의 await 키워드를 제외하면 코드가 정확히 동기 코드처럼 보이는 것에 주목하라. 유일한 절충 중 하나는 await 키워드를 잊기 쉽다는 것인데, 이는 타입 불일치(예: 프로미스를 값으로 사용하려 할 때)가 있을 때만 잡힐 수 있다.

async/await는 프로미스 위에 구축된다 — 예를 들어 doSomething()은 이전과 같은 함수이므로 프로미스에서 async/await로 바꾸는 데 리팩터링이 거의 필요 없다. async/await 문법에 대해 더 읽으려면 async functions 및 await 참조를 보라.

참고: async/await는 일반 프로미스 체인과 같은 동시성 의미론을 가진다. 하나의 async 함수 안의 await는 전체 프로그램을 멈추지 않고, 그것의 값에 의존하는 부분만 멈춘다. 그래서 await가 보류 중인 동안에도 다른 async 작업은 계속 실행될 수 있다.

오류 처리 (Error handling)

앞에서 doom의 피라미드에서 failureCallback이 세 번 나타나는 것을 기억할 것이다. 프로미스 체인에서는 끝에 한 번만 나타난다.

doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => console.log(`Got the final result: ${finalResult}`))
  .catch(failureCallback);

예외가 있으면 브라우저는 체인 아래로 .catch() 핸들러 또는 onRejected를 찾는다. 이것은 동기 코드가 동작하는 방식을 매우 많이 모델링했다.

try {
  const result = syncDoSomething();
  const newResult = syncDoSomethingElse(result);
  const finalResult = syncDoThirdThing(newResult);
  console.log(`Got the final result: ${finalResult}`);
} catch (error) {
  failureCallback(error);
}

이 비동기 코드와의 대칭은 async/await 문법에서 절정에 이른다.

async function foo() {
  try {
    const result = await doSomething();
    const newResult = await doSomethingElse(result);
    const finalResult = await doThirdThing(newResult);
    console.log(`Got the final result: ${finalResult}`);
  } catch (error) {
    failureCallback(error);
  }
}

프로미스는 던져진 예외와 프로그래밍 오류까지 포함해 모든 오류를 잡음으로써 콜백 피라미드의 근본적인 결함을 해결한다. 이것은 비동기 연산의 함수형 구성에 필수적이다. 모든 오류는 이제 체인 끝의 catch() 메서드가 처리하며, async/await를 사용하지 않는 try/catch는 거의 사용할 필요가 없어야 한다.

중첩 (Nesting)

listOfIngredients와 관련된 위의 예제에서, 첫 번째는 다른 then() 핸들러의 반환 값 안에 하나의 프로미스 체인을 중첩했고, 두 번째는 완전히 평평한 체인을 사용한다. 단순한 프로미스 체인은 중첩 없이 평평하게 유지하는 것이 좋다. 중첩은 부주의한 구성의 결과일 수 있기 때문이다.

중첩은 catch 문장의 범위를 제한하기 위한 제어 구조다. 구체적으로 중첩된 catch는 그 범위와 그 아래의 실패만 잡고, 중첩된 범위 밖의 체인 위쪽의 오류는 잡지 않는다. 올바르게 사용하면 오류 복구에 더 큰 정밀도를 준다.

doSomethingCritical()
  .then((result) =>
    doSomethingOptional(result)
      .then((optionalResult) => doSomethingExtraNice(optionalResult))
      .catch((e) => {}),
  ) // 선택 단계가 실패하면 무시; 계속 진행
  .then(() => moreCriticalStuff())
  .catch((e) => console.error(`Critical failure: ${e.message}`));

여기서 선택 단계는 중첩되어 있다 — 중첩은 들여쓰기가 아니라 단계 주위의 바깥쪽 () 괄호의 배치에 의해 생긴다.

내부의 오류 무시 catch 핸들러는 doSomethingOptional()doSomethingExtraNice()의 실패만 잡고, 그 후 코드는 moreCriticalStuff()로 재개된다. 중요한 것은 doSomethingCritical()이 실패하면 그 오류는 마지막(바깥쪽) catch에만 잡히고, 내부 catch 핸들러에게 삼켜지지 않는다.

async/await에서 이 코드는 다음과 같다.

async function main() {
  try {
    const result = await doSomethingCritical();
    try {
      const optionalResult = await doSomethingOptional(result);
      await doSomethingExtraNice(optionalResult);
    } catch (e) {
      // 선택 단계의 실패를 무시하고 진행.
    }
    await moreCriticalStuff();
  } catch (e) {
    console.error(`Critical failure: ${e.message}`);
  }
}

참고: 정교한 오류 처리가 없다면 중첩된 then 핸들러가 거의 필요하지 않을 것이다. 대신 평평한 체인을 사용하고 오류 처리 로직을 끝에 두라.

catch 뒤에 체이닝 (Chaining after a catch)

실패, 즉 catch 뒤에 체이닝하는 것이 가능하다. 이는 체인에서 어떤 동작이 실패한 후에도 새 동작을 수행하는 데 유용하다. 다음 예를 읽어보자.

doSomething()
  .then(() => {
    throw new Error("Something failed");

    console.log("Do this");
  })
  .catch(() => {
    console.error("Do that");
  })
  .then(() => {
    console.log("Do this, no matter what happened before");
  });

이것은 다음 텍스트를 출력한다.

Do that
Do this, no matter what happened before

참고: "Something failed" 오류가 거부를 일으켰기 때문에 "Do this" 텍스트는 표시되지 않는다.

async/await에서 이 코드는 다음과 같다.

async function main() {
  try {
    await doSomething();
    throw new Error("Something failed");
    console.log("Do this");
  } catch (e) {
    console.error("Do that");
  }
  console.log("Do this, no matter what happened before");
}

Promise 거부 이벤트 (Promise rejection events)

프로미스 거부 이벤트가 어떤 핸들러로도 처리되지 않으면 그것은 호출 스택의 맨 위로 올라가고, 호스트가 그것을 표면화해야 한다. 웹에서 프로미스가 거부될 때마다 두 이벤트 중 하나가 전역 범위로 보내진다(일반적으로 이것은 window이거나, 웹 워커에서 사용된다면 Worker 또는 다른 워커 기반 인터페이스다). 두 이벤트는:

  • unhandledrejection — 프로미스가 거부되었지만 사용 가능한 거부 핸들러가 없을 때 보내진다.
  • rejectionhandled — 이미 unhandledrejection 이벤트를 일으킨 거부된 프로미스에 핸들러가 붙을 때 보내진다.

두 경우 모두 이벤트(PromiseRejectionEvent 타입)는 멤버로, 거부된 프로미스를 나타내는 promise 속성과 프로미스가 거부되는 데 주어진 이유를 제공하는 reason 속성을 가진다.

이것들은 프로미스에 대한 폴백 오류 처리를 제공하고 프로미스 관리 문제를 디버깅하는 데 도움을 준다. 이 핸들러들은 컨텍스트별로 전역이므로, 출처와 관계없이 모든 오류가 같은 이벤트 핸들러로 간다.

Node.js에서 프로미스 거부 처리는 약간 다르다. Node.js의 unhandledRejection 이벤트(이름의 대문자 차이에 주목)에 대한 핸들러를 추가해 처리되지 않은 거부를 포착한다. 다음과 같이:

process.on("unhandledRejection", (reason, promise) => {
  // "promise"와 "reason" 값을 검사하는 코드를 여기에 추가
});

Node.js의 경우, (그렇지 않으면 발생할) 기본 동작인 콘솔에 오류가 기록되는 것을 막기 위해 process.on() 리스너를 추가하는 것만으로 충분하다. 브라우저 런타임의 preventDefault() 메서드에 해당하는 것이 필요 없다.

그러나 그 process.on 리스너를 추가하면서 그 안에 거부된 프로미스를 처리하는 코드도 없으면, 그것들은 그냥 바닥에 떨어져 조용히 무시될 것이다. 이상적으로는 그 리스너 안에 각 거부된 프로미스를 검사해 그것이 실제 코드 버그로 인한 것이 아닌지 확인하는 코드를 추가해야 한다.

구성 (Composition)

비동기 연산을 동시에 실행하는 데 사용할 수 있는 네 가지 구성 도구가 있다: Promise.all(), Promise.allSettled(), Promise.any(), Promise.race().

연산을 같은 시간에 시작하고 모두 끝날 때까지 기다릴 수 있다.

Promise.all([func1(), func2(), func3()]).then(([result1, result2, result3]) => {
  // result1, result2, result3 사용
});

배열의 프로미스 중 하나가 거부되면 Promise.all()은 반환된 프로미스를 즉시 거부한다. 다른 연산은 계속 실행되지만 그 결과는 Promise.all()의 반환 값을 통해서는 사용할 수 없다. 이는 예상치 못한 상태나 동작을 일으킬 수 있다. Promise.allSettled()는 resolve 전에 모든 연산이 완료되도록 보장하는 또 다른 구성 도구다.

이 메서드들은 모두 프로미스를 동시에 실행한다 — 일련의 프로미스들이 동시에 시작되고 서로 기다리지 않는다. 영리한 JavaScript를 사용해 순차적 구성도 가능하다.

[func1, func2, func3]
  .reduce((p, f) => p.then(f), Promise.resolve())
  .then((result3) => {
    /* result3 사용 */
  });

이 예제에서는 비동기 함수들의 배열을 프로미스 체인으로 reduce한다. 위 코드는 다음과 동등하다.

Promise.resolve()
  .then(func1)
  .then(func2)
  .then(func3)
  .then((result3) => {
    /* result3 사용 */
  });

이것은 함수형 프로그래밍에서 흔한, 재사용 가능한 compose 함수로 만들 수 있다.

const applyAsync = (acc, val) => acc.then(val);
const composeAsync =
  (...funcs) =>
  (x) =>
  funcs.reduce(applyAsync, Promise.resolve(x));

composeAsync() 함수는 임의 개수의 함수를 인수로 받고, 구성 파이프라인을 통과할 초기 값을 받는 새 함수를 반환한다.

const transformData = composeAsync(func1, func2, func3);
const result3 = transformData(data);

순차 구성은 async/await로 더 간결하게 할 수도 있다.

let result;
for (const f of [func1, func2, func3]) {
  result = await f(result);
}
/* 마지막 결과(즉 result3) 사용 */

그러나 프로미스를 순차적으로 구성하기 전에 그것이 정말 필요한지 고려하라 — 한 프로미스의 실행이 다른 프로미스의 결과에 의존하지 않는 한, 프로미스들이 서로 불필요하게 차단하지 않도록 동시에 실행하는 것이 항상 더 낫다.

취소 (Cancellation)

프로미스 자체에는 취소를 위한 일급 프로토콜이 없다. 그러나 일반적으로 AbortController를 사용해 기본 비동기 연산을 직접 취소할 수 있을 수 있다.

오래된 콜백 API 주위에 Promise 만들기 (Creating a Promise around an old callback API)

프로미스는 그 생성자를 사용해 처음부터 만들 수 있다. 이것은 오래된 API를 감싸기 위해서만 필요해야 한다.

이상적인 세계에서는 모든 비동기 함수가 이미 프로미스를 반환할 것이다. 불행히도 일부 API는 여전히 성공 및/또는 실패 콜백을 옛 방식으로 전달할 것을 기대한다. 가장 명백한 예는 setTimeout() 함수다.

setTimeout(() => saySomething("10 seconds passed"), 10 * 1000);

옛 스타일 콜백과 프로미스를 섞는 것은 문제가 있다. saySomething()이 실패하거나 프로그래밍 오류를 포함하면 아무것도 그것을 잡지 않는다. 이것은 setTimeout()의 설계에 내재된 것이다.

다행히 setTimeout()을 프로미스로 감쌀 수 있다. 모범 사례는 콜백을 받는 함수를 가능한 가장 낮은 수준에서 감싼 다음, 그것들을 직접 다시 호출하지 않는 것이다.

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(10 * 1000)
  .then(() => saySomething("10 seconds"))
  .catch(failureCallback);

프로미스 생성자는 프로미스를 수동으로 resolve 또는 reject할 수 있게 해주는 executor 함수를 받는다. setTimeout()은 실제로 실패하지 않으므로 이 경우 reject는 생략했다. executor 함수가 어떻게 동작하는지에 대한 자세한 정보는 Promise() 참조를 보라.

타이밍 (Timing)

마지막으로, 등록된 콜백이 언제 호출되는지에 대한 더 기술적인 세부 사항을 살펴보자.

보장 (Guarantees)

콜백 기반 API에서 콜백이 언제, 어떻게 호출되는지는 API 구현자에게 달려 있다. 예를 들어 콜백은 동기적으로 또는 비동기적으로 호출될 수 있다.

function doSomething(callback) {
  if (Math.random() > 0.5) {
    callback();
  } else {
    setTimeout(() => callback(), 1000);
  }
}

위 설계는 소위 "state of Zalgo"를 초래하므로 강력히 권장되지 않는다. 비동기 API를 설계하는 맥락에서 이것은 콜백이 어떤 경우에는 동기적으로, 다른 경우에는 비동기적으로 호출되어 호출자에게 모호성을 만든다는 뜻이다. 이 용어가 처음 정식으로 제시된 Designing APIs for Asynchrony 문서를 보라. 이 API 설계는 부작용을 분석하기 어렵게 만든다.

let value = 1;
doSomething(() => {
  value = 2;
});
console.log(value); // 1 or 2?

반면 프로미스는 제어의 역전(inversion of control)의 한 형태다 — API 구현자가 콜백이 언제 호출되는지 제어하지 않는다. 대신 콜백 대기열을 유지하고 콜백을 언제 호출할지 결정하는 작업이 프로미스 구현에 위임되며, API 사용자와 API 개발자 모두 자동으로 강력한 의미론적 보장을 얻는다. 여기에는 다음이 포함된다.

  • then()으로 추가된 콜백은 JavaScript 이벤트 루프의 현재 실행이 완료되기 전에는 절대 호출되지 않는다.
  • 이 콜백들은 프로미스가 나타내는 비동기 연산의 성공이나 실패 이후에 추가되어도 호출된다.
  • then()을 여러 번 호출해 여러 콜백을 추가할 수 있다. 그것들은 삽입된 순서대로 하나씩 차례로 호출된다.

놀라움을 피하기 위해 then()에 전달된 함수는 이미 resolve된 프로미스와 함께라도 절대 동기적으로 호출되지 않는다.

Promise.resolve().then(() => console.log(2));
console.log(1);
// 기록: 1, 2

전달된 함수는 즉시 실행되는 대신 **마이크로태스크 대기열(microtask queue)**에 놓인다. 이는 그것을 만든 함수가 종료된 후, JavaScript 실행 스택이 비었을 때, 제어가 이벤트 루프로 반환되기 직전에 나중에 실행된다는 뜻이다. 즉 꽤 빨리 실행된다.

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(0).then(() => console.log(4));
Promise.resolve()
  .then(() => console.log(2))
  .then(() => console.log(3));
console.log(1); // 1, 2, 3, 4

태스크 대기열 vs 마이크로태스크 (Task queues vs. microtasks)

프로미스 콜백은 마이크로태스크로 처리되는 반면, setTimeout() 콜백은 태스크 대기열로 처리된다.

const promise = new Promise((resolve, reject) => {
  console.log("Promise callback");
  resolve();
}).then((result) => {
  console.log("Promise callback (.then)");
});

setTimeout(() => {
  console.log("event-loop cycle: Promise (fulfilled)", promise);
}, 0);

console.log("Promise (pending)", promise);

위 코드는 다음을 출력한다.

Promise callback
Promise (pending) Promise {<pending>}
Promise callback (.then)
event-loop cycle: Promise (fulfilled) Promise {<fulfilled>}

자세한 내용은 Tasks vs. microtasks를 참조하라.

프로미스와 태스크가 충돌할 때 (When promises and tasks collide)

프로미스와 태스크(예: 이벤트나 콜백)가 예측할 수 없는 순서로 발화하는 상황에 부딪히면, 프로미스가 조건부로 생성될 때 상태를 확인하거나 프러미스를 균형 맞추기 위해 마이크로태스크를 사용하는 것이 도움이 될 수 있다.

마이크로태스크가 이 문제를 해결하는 데 도움이 될 것 같다면, queueMicrotask()로 함수를 마이크로태스크로 대기열에 넣는 방법을 배우려면 microtask 가이드를 보라.

더 알아보기