자바스크립트 프로미스: 소개

약속은 지연된 계산과 비동기 계산을 간소화합니다. 약속은 아직 완료되지 않은 작업을 나타냅니다.

Jake Archibald
Jake Archibald

개발자 여러분, 웹 개발 역사상 중요한 순간을 맞이할 준비를 하세요.

[드럼롤 시작]

JavaScript에 프로미스가 도입되었습니다.

[불꽃이 터지고 반짝이는 종이가 위에서 비처럼 내려오자 관중들이 열광합니다.]

이때 다음 카테고리 중 하나에 속하게 됩니다.

  • 주변에서 사람들이 환호하지만 무슨 이유인지 잘 모르겠습니다. '약속'이 무엇인지 잘 모르는 경우도 있습니다. 어깨를 으쓱하지만 반짝이는 종이의 무게가 어깨를 짓누르고 있습니다. 그렇다면 걱정하지 마세요. 이 문제를 왜 신경 써야 하는지 파악하는 데 시간이 오래 걸렸습니다. 처음부터 시작하는 것이 좋습니다.
  • 공기를 주먹으로 칩니다. 때마침이군요. 이전에 이러한 Promise를 사용해 봤지만 모든 구현에 약간 다른 API가 있다는 점이 불편합니다. 공식 JavaScript 버전의 API는 무엇인가요? 용어부터 시작하는 것이 좋습니다.
  • 이미 알고 있었던 내용이고, 이를 처음 알게 된 것처럼 흥분하는 사람들을 비웃습니다. 잠시 동안 우월감을 느낀 후 API 참조로 바로 이동하세요.

브라우저 지원 및 폴리필

Browser Support

  • Chrome: 32.
  • Edge: 12.
  • Firefox: 29.
  • Safari: 8.

Source

완전한 약속 구현이 없는 브라우저를 사양 준수하도록 하거나 다른 브라우저 및 Node.js에 약속을 추가하려면 폴리필(2k gzipped)을 확인하세요.

무슨 일이야?

JavaScript는 단일 스레드이므로 두 개의 스크립트를 동시에 실행할 수 없습니다. 차례로 실행해야 합니다. 브라우저에서 JavaScript는 브라우저마다 다른 여러 항목과 스레드를 공유합니다. 그러나 일반적으로 JavaScript는 페인팅, 스타일 업데이트, 사용자 작업 처리 (예: 텍스트 강조 표시, 양식 컨트롤과 상호작용)와 동일한 대기열에 있습니다. 이러한 항목 중 하나에서 활동이 발생하면 다른 항목이 지연됩니다.

인간은 멀티스레드입니다. 여러 손가락을 사용해 입력할 수 있고, 운전하면서 동시에 대화를 나눌 수 있습니다. 처리해야 하는 유일한 차단 함수는 재채기입니다. 재채기하는 동안에는 모든 현재 활동이 일시중지되어야 합니다. 특히 운전 중 대화를 나누려고 할 때는 매우 성가신 일입니다. 코드가 비정상적으로 작동해서는 안 됩니다.

이 문제를 해결하기 위해 이벤트와 콜백을 사용했을 것입니다. 이벤트는 다음과 같습니다.

var img1 = document.querySelector('.img-1');

img1.addEventListener('load', function() {
  // woo yey image loaded
});

img1.addEventListener('error', function() {
  // argh everything's broken
});

전혀 우스꽝스럽지 않습니다. 이미지를 가져와 리스너를 몇 개 추가하면 이러한 리스너 중 하나가 호출될 때까지 JavaScript가 실행을 중지할 수 있습니다.

안타깝게도 위의 예에서는 이벤트를 수신 대기하기 전에 이벤트가 발생할 수 있으므로 이미지의 'complete' 속성을 사용하여 이 문제를 해결해야 합니다.

var img1 = document.querySelector('.img-1');

function loaded() {
  // woo yey image loaded
}

if (img1.complete) {
  loaded();
}
else {
  img1.addEventListener('load', loaded);
}

img1.addEventListener('error', function() {
  // argh everything's broken
});

이렇게 하면 리슨할 기회가 있기 전에 오류가 발생한 이미지는 포착되지 않습니다. 안타깝게도 DOM에서는 이를 수행할 수 있는 방법을 제공하지 않습니다. 또한 하나의 이미지를 로드합니다. 이미지 세트가 언제 로드되었는지 알아야 한다면 더 복잡해집니다.

이벤트가 항상 최선의 방법은 아닙니다.

이벤트는 동일한 객체(keyup, touchstart 등)에서 여러 번 발생할 수 있는 작업에 적합합니다. 이러한 이벤트에서는 리스너를 연결하기 전에 발생한 일에 관해 크게 신경 쓰지 않아도 됩니다. 하지만 비동기 성공/실패의 경우 다음과 같은 것이 좋습니다.

img1.callThisIfLoadedOrWhenLoaded(function() {
  // loaded
}).orIfFailedCallThis(function() {
  // failed
});

// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // all loaded
}).orIfSomeFailedCallThis(function() {
  // one or more failed
});

약속은 이와 동일한 작업을 하지만 더 나은 이름을 사용합니다. HTML 이미지 요소에 프로미스를 반환하는 'ready' 메서드가 있는 경우 다음과 같이 할 수 있습니다.

img1.ready()
.then(function() {
  // loaded
}, function() {
  // failed
});

// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
  // all loaded
}, function() {
  // one or more failed
});

가장 기본적으로 약속은 다음을 제외하고 이벤트 리스너와 약간 유사합니다.

  • 약속은 한 번만 성공하거나 실패할 수 있습니다. 두 번 성공하거나 실패할 수 없으며 성공에서 실패로 또는 그 반대로 전환할 수도 없습니다.
  • 약속이 성공 또는 실패한 후 나중에 성공/실패 콜백을 추가하면 이벤트가 더 일찍 발생했더라도 올바른 콜백이 호출됩니다.

이는 비동기 성공/실패에 매우 유용합니다. 사용 가능한 정확한 시간보다는 결과에 반응하는 데 더 관심이 있기 때문입니다.

Promise 용어

도메닉 데니콜라님이 이 도움말의 초안을 교정했으며 용어 사용에 대해 'F'를 주었습니다. 선생님은 나를 억울하게도 100번 국가와 운명을 필사하게 하고 부모님께 걱정스러운 편지를 썼습니다. 하지만 여전히 용어를 혼동하는 경우가 많습니다. 다음은 기본적인 내용입니다.

약속은 다음과 같습니다.

  • fulfilled: 약속과 관련된 작업이 성공했습니다.
  • 거부됨 - 약속과 관련된 작업이 실패했습니다.
  • 대기 중 - 아직 처리 또는 거부되지 않음
  • settled - 처리됨 또는 거부됨

사양에서는 then 메서드가 있다는 점에서 약속과 유사한 객체를 설명하기 위해 thenable이라는 용어도 사용합니다. 이 용어는 전 잉글랜드 축구 감독 테리 베네벌스를 연상시키므로 최대한 사용하지 않겠습니다.

JavaScript에 프로미스가 도착했습니다.

Promise는 다음과 같은 라이브러리 형식으로 한동안 사용되어 왔습니다.

위의 약속과 JavaScript 약속은 Promises/A+라는 공통의 표준화된 동작을 공유합니다. jQuery 사용자인 경우 이와 유사한 지연된 함수가 있습니다. 그러나 지연은 Promise/A+를 준수하지 않으므로 약간 다르고 덜 유용하므로 주의해야 합니다. jQuery에도 Promise 유형이 있지만 이는 지연의 하위 집합일 뿐이며 동일한 문제가 있습니다.

약속 구현은 표준화된 동작을 따르지만 전반적인 API는 다릅니다. JavaScript 프로미스는 API에서 RSVP.js와 유사합니다. 약속을 만드는 방법은 다음과 같습니다.

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, then…

  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});

promise 생성자는 두 매개변수(resolve 및 reject)가 있는 콜백이라는 하나의 인수를 사용합니다. 콜백 내에서 비동기 작업을 실행한 다음 모든 작업이 완료되면 resolve를 호출하고 그렇지 않으면 reject를 호출합니다.

기존 JavaScript의 throw와 마찬가지로 Error 객체로 거부하는 것이 관례이지만 필수는 아닙니다. Error 객체의 이점은 스택 트레이스를 캡처하여 디버깅 도구를 더 유용하게 만든다는 것입니다.

이 약속을 사용하는 방법은 다음과 같습니다.

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

then()는 두 개의 인수(성공 사례의 콜백과 실패 사례의 콜백)를 사용합니다. 둘 다 선택사항이므로 성공 또는 실패 케이스에 대해서만 콜백을 추가할 수 있습니다.

JavaScript 프로미스는 DOM에서 'Futures'로 시작하여 'Promises'로 이름이 바뀌었고, 마침내 JavaScript로 이동했습니다. DOM이 아닌 JavaScript에 이러한 함수를 포함하는 것이 좋습니다. Node.js와 같은 브라우저 외 JS 컨텍스트에서 사용할 수 있기 때문입니다 (핵심 API에서 이러한 함수를 사용하는지는 다른 문제입니다).

이는 JavaScript 기능이지만 DOM은 이를 사용합니다. 실제로 비동기 성공/실패 메서드가 있는 모든 새 DOM API는 약속을 사용합니다. 이는 이미 할당량 관리, 글꼴 로드 이벤트, ServiceWorker, 웹 MIDI, 스트림 등에서 적용되고 있습니다.

다른 라이브러리와의 호환성

JavaScript promises API는 then() 메서드가 있는 모든 항목을 프로미스와 유사한 것으로 취급합니다 (또는 프로미스 용어로 thenable 한숨). 따라서 Q 프로미스를 반환하는 라이브러리를 사용해도 괜찮습니다. 새 JavaScript 프로미스와 잘 작동합니다.

하지만 앞서 말씀드렸듯이 jQuery의 지연은 약간… 도움이 되지 않습니다. 다행히 표준 약속으로 전송할 수 있으며, 최대한 빨리 전송하는 것이 좋습니다.

var jsPromise = Promise.resolve($.ajax('/whatever.json'))

여기서 jQuery의 $.ajax는 지연된 작업을 반환합니다. then() 메서드가 있으므로 Promise.resolve()는 이를 JavaScript 프로미스로 변환할 수 있습니다. 그러나 지연된 함수가 콜백에 여러 인수를 전달하는 경우도 있습니다. 예를 들면 다음과 같습니다.

var jqDeferred = $.ajax('/whatever.json');

jqDeferred.then(function(response, statusText, xhrObj) {
  // ...
}, function(xhrObj, textStatus, err) {
  // ...
})

반면 JS 약속은 첫 번째 약속을 제외한 모든 약속을 무시합니다.

jsPromise.then(function(response) {
  // ...
}, function(xhrObj) {
  // ...
})

다행히도 일반적으로 원하는 결과를 얻거나 적어도 원하는 결과에 액세스할 수 있습니다. 또한 jQuery는 Error 객체를 거부로 전달하는 관례를 따르지 않습니다.

복잡한 비동기 코드가 간소화됨

좋습니다. 코딩을 시작해 보겠습니다. 예를 들어 다음을 수행하려고 합니다.

  1. 로드를 나타내는 스피너 시작
  2. 스토리의 JSON을 가져와서 제목과 각 챕터의 URL을 가져옵니다.
  3. 페이지에 제목 추가
  4. 각 챕터 가져오기
  5. 페이지에 스토리 추가
  6. 스피너 중지

…하지만 진행 중에 문제가 발생한 경우 사용자에게 알려야 합니다. 이 시점에서 스피너도 중지해야 합니다. 그렇지 않으면 계속 회전하면서 어지러워지고 다른 UI에 충돌합니다.

물론 JavaScript를 사용하여 스토리를 전달하지는 않습니다. HTML로 제공하는 것이 더 빠르기 때문입니다. 하지만 이 패턴은 API를 처리할 때 매우 일반적입니다. 여러 데이터를 가져온 후 모두 완료되면 작업을 실행합니다.

먼저 네트워크에서 데이터를 가져오는 작업을 처리해 보겠습니다.

XMLHttpRequest 약속 처리

이전 버전과의 호환 가능한 방식으로 가능한 경우 이전 API는 프로미스를 사용하도록 업데이트됩니다. XMLHttpRequest가 가장 유력한 후보이지만 그동안 GET 요청을 실행하는 간단한 함수를 작성해 보겠습니다.

function get(url) {
  // Return a new promise.
  return new Promise(function(resolve, reject) {
    // Do the usual XHR stuff
    var req = new XMLHttpRequest();
    req.open('GET', url);

    req.onload = function() {
      // This is called even on 404 etc
      // so check the status
      if (req.status == 200) {
        // Resolve the promise with the response text
        resolve(req.response);
      }
      else {
        // Otherwise reject with the status text
        // which will hopefully be a meaningful error
        reject(Error(req.statusText));
      }
    };

    // Handle network errors
    req.onerror = function() {
      reject(Error("Network Error"));
    };

    // Make the request
    req.send();
  });
}

이제 사용해 보겠습니다.

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
})

이제 XMLHttpRequest를 수동으로 입력하지 않고도 HTTP 요청을 할 수 있습니다. XMLHttpRequest의 불편한 카멜케이스를 볼 일이 줄어들수록 내 삶이 더 행복해질 테니 정말 좋습니다.

체이닝

then()로 끝나는 것이 아닙니다. then를 연결하여 값을 변환하거나 추가 비동기 작업을 차례로 실행할 수 있습니다.

값 변환

새 값을 반환하여 간단하게 값을 변환할 수 있습니다.

var promise = new Promise(function(resolve, reject) {
  resolve(1);
});

promise.then(function(val) {
  console.log(val); // 1
  return val + 2;
}).then(function(val) {
  console.log(val); // 3
})

실제 예를 들어 다음으로 돌아가 보겠습니다.

get('story.json').then(function(response) {
  console.log("Success!", response);
})

응답은 JSON이지만 현재 일반 텍스트로 수신되고 있습니다. JSON responseType를 사용하도록 get 함수를 변경할 수도 있지만 약속 영역에서 해결할 수도 있습니다.

get('story.json').then(function(response) {
  return JSON.parse(response);
}).then(function(response) {
  console.log("Yey JSON!", response);
})

JSON.parse()는 단일 인수를 사용하고 변환된 값을 반환하므로 바로가기를 만들 수 있습니다.

get('story.json').then(JSON.parse).then(function(response) {
  console.log("Yey JSON!", response);
})

사실 getJSON() 함수를 아주 쉽게 만들 수 있습니다.

function getJSON(url) {
  return get(url).then(JSON.parse);
}

getJSON()는 여전히 URL을 가져온 다음 응답을 JSON으로 파싱하는 약속을 반환합니다.

비동기 작업 큐에 추가

then를 연결하여 비동기 작업을 순차적으로 실행할 수도 있습니다.

then() 콜백에서 무언가를 반환하면 약간 마법과 같습니다. 값을 반환하면 다음 then()가 이 값으로 호출됩니다. 하지만 약속과 유사한 항목을 반환하면 다음 then()가 이를 기다리며 약속이 처리 (성공/실패)될 때만 호출됩니다. 예를 들면 다음과 같습니다.

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  console.log("Got chapter 1!", chapter1);
})

여기서는 요청할 URL 집합을 제공하는 story.json에 비동기 요청을 한 다음 그 중 첫 번째 URL을 요청합니다. 이때 약속이 간단한 콜백 패턴과 확실히 차별화됩니다.

챕터를 가져오는 바로가기 메서드를 만들 수도 있습니다.

var storyPromise;

function getChapter(i) {
  storyPromise = storyPromise || getJSON('story.json');

  return storyPromise.then(function(story) {
    return getJSON(story.chapterUrls[i]);
  })
}

// and using it is simple:
getChapter(0).then(function(chapter) {
  console.log(chapter);
  return getChapter(1);
}).then(function(chapter) {
  console.log(chapter);
})

getChapter가 호출될 때까지 story.json를 다운로드하지 않지만 다음에 getChapter가 호출될 때는 스토리 약속을 재사용하므로 story.json는 한 번만 가져옵니다. Promises를 사용해 보세요.

오류 처리

앞에서 본 바와 같이 then()는 성공을 위한 인수 하나와 실패를 위한 인수 하나 (또는 약속 언어로 표현하면 처리 및 거부)를 사용합니다.

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.log("Failed!", error);
})

catch()도 사용할 수 있습니다.

get('story.json').then(function(response) {
  console.log("Success!", response);
}).catch(function(error) {
  console.log("Failed!", error);
})

catch()는 특별한 것이 없으며 then(undefined, func)의 슈가일 뿐이지만 더 읽기 쉽습니다. 위의 두 코드 예시는 동일하게 작동하지 않습니다. 후자는 다음과 같습니다.

get('story.json').then(function(response) {
  console.log("Success!", response);
}).then(undefined, function(error) {
  console.log("Failed!", error);
})

차이는 미묘하지만 매우 유용합니다. 프로미스 거부는 거부 콜백 (또는 이에 상응하는 catch())을 사용하여 다음 then()로 건너뜁니다. then(func1, func2)를 사용하면 func1 또는 func2가 호출되며 둘 다 호출되지는 않습니다. 그러나 then(func1).catch(func2)를 사용하면 체인의 별도 단계이므로 func1가 거부하면 둘 다 호출됩니다. 다음을 가져옵니다.

asyncThing1().then(function() {
  return asyncThing2();
}).then(function() {
  return asyncThing3();
}).catch(function(err) {
  return asyncRecovery1();
}).then(function() {
  return asyncThing4();
}, function(err) {
  return asyncRecovery2();
}).catch(function(err) {
  console.log("Don't worry about it");
}).then(function() {
  console.log("All done!");
})

위의 흐름은 일반적인 JavaScript try/catch와 매우 유사합니다. 'try' 내에서 발생하는 오류는 즉시 catch() 블록으로 이동합니다. 플로우 차트로 표시하면 다음과 같습니다. 플로우 차트가 좋으니까요.

실행되는 약속의 경우 파란색 선을, 거부되는 약속의 경우 빨간색 선을 따릅니다.

JavaScript 예외 및 약속

거부는 프로미스가 명시적으로 거부될 때 발생하지만 생성자 콜백에서 오류가 발생하면 암시적으로도 발생합니다.

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse throws an error if you feed it some
  // invalid JSON, so this implicitly rejects:
  resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(function(data) {
  // This never happens:
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

즉, promise 생성자 콜백 내에서 모든 promise 관련 작업을 실행하면 오류가 자동으로 포착되어 거부되는 것이 좋습니다.

then() 콜백에서 발생하는 오류도 마찬가지입니다.

get('/').then(JSON.parse).then(function() {
  // This never happens, '/' is an HTML page, not JSON
  // so JSON.parse throws
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

실제 오류 처리

스토리와 챕터를 사용하여 catch를 사용하여 사용자에게 오류를 표시할 수 있습니다.

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  addHtmlToPage(chapter1.html);
}).catch(function() {
  addTextToPage("Failed to show chapter");
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

story.chapterUrls[0] 가져오기에 실패하면 (예: http 500 또는 사용자가 오프라인 상태) 응답을 JSON으로 파싱하려고 시도하는 getJSON()의 콜백을 비롯한 후속 성공 콜백이 모두 건너뛰어지며 페이지에 chapter1.html을 추가하는 콜백도 건너뜁니다. 대신 catch 콜백으로 이동합니다. 따라서 이전 작업 중 하나라도 실패하면 '장 표시 실패'가 페이지에 추가됩니다.

JavaScript의 try/catch와 마찬가지로 오류가 포착되고 후속 코드가 계속 실행되므로 스피너가 항상 숨겨집니다. 이것이 바로 우리가 원하는 바입니다. 위의 코드는 다음의 비차단 비동기 버전이 됩니다.

try {
  var story = getJSONSync('story.json');
  var chapter1 = getJSONSync(story.chapterUrls[0]);
  addHtmlToPage(chapter1.html);
}
catch (e) {
  addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'

오류에서 복구하지 않고 로깅 목적으로만 catch()를 실행할 수 있습니다. 이렇게 하려면 오류를 다시 발생시키면 됩니다. getJSON() 메서드에서 다음과 같이 할 수 있습니다.

function getJSON(url) {
  return get(url).then(JSON.parse).catch(function(err) {
    console.log("getJSON failed for", url, err);
    throw err;
  });
}

챕터 하나를 가져왔지만 모든 챕터를 가져오고 싶습니다. 그렇게 해 보겠습니다.

동시 로드 및 시퀀싱: 두 가지 모두를 최대한 활용

비동기 방식으로 생각하는 것은 쉽지 않습니다. 문제를 해결하는 데 어려움이 있다면 동기 코드인 것처럼 코드를 작성해 보세요. 이 경우에는 다음과 같습니다.

try {
  var story = getJSONSync('story.json');
  addHtmlToPage(story.heading);

  story.chapterUrls.forEach(function(chapterUrl) {
    var chapter = getJSONSync(chapterUrl);
    addHtmlToPage(chapter.html);
  });

  addTextToPage("All done");
}
catch (err) {
  addTextToPage("Argh, broken: " + err.message);
}

document.querySelector('.spinner').style.display = 'none'

잘 작동합니다. 하지만 동기화되며 항목이 다운로드되는 동안 브라우저가 잠깁니다. 이를 비동기식으로 실행하려면 then()를 사용하여 작업을 차례로 실행합니다.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

하지만 챕터 URL을 순회하여 순서대로 가져오려면 어떻게 해야 할까요? 다음은 작동하지 않습니다.

story.chapterUrls.forEach(function(chapterUrl) {
  // Fetch chapter
  getJSON(chapterUrl).then(function(chapter) {
    // and add it to the page
    addHtmlToPage(chapter.html);
  });
})

forEach는 비동기식 인식을 지원하지 않으므로 챕터가 다운로드된 순서대로 표시됩니다. 이는 기본적으로 Pulp Fiction이 작성된 방식입니다. 펄프 픽션이 아니므로 문제를 해결해 보겠습니다.

시퀀스 만들기

chapterUrls 배열을 일련의 약속으로 변환하려고 합니다. then()를 사용하여 이를 실행할 수 있습니다.

// Start off with a promise that always resolves
var sequence = Promise.resolve();

// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
  // Add these actions to the end of the sequence
  sequence = sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
})

Promise.resolve()는 처음으로 등장하며, 전달된 값으로 확인되는 약속을 만듭니다. Promise의 인스턴스를 전달하면 단순히 반환됩니다. 참고: 이는 일부 구현에서 아직 따르지 않는 사양 변경사항입니다. 약속과 유사한 항목 (then() 메서드가 있음)을 전달하면 동일한 방식으로 처리/거부하는 실제 Promise가 생성됩니다. 다른 값(예: Promise.resolve('Hello'): 이 값으로 처리되는 프로미스를 만듭니다. 위와 같이 값 없이 호출하면 '정의되지 않음'으로 채워집니다.

제공한 값 (또는 정의되지 않음)으로 거부하는 프로미스를 만드는 Promise.reject(val)도 있습니다.

array.reduce를 사용하여 위 코드를 정리할 수 있습니다.

// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // Add these actions to the end of the sequence
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
}, Promise.resolve())

이는 이전 예와 동일하지만 별도의 'sequence' 변수가 필요하지 않습니다. 배열의 각 항목에 대해 reduce 콜백이 호출됩니다. 'sequence'는 처음에는 Promise.resolve()이지만 나머지 호출에서는 'sequence'가 이전 호출에서 반환된 값입니다. array.reduce는 배열을 단일 값으로 축약하는 데 매우 유용합니다. 이 경우 단일 값은 약속입니다.

지금까지의 내용을 요약해 보겠습니다.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  return story.chapterUrls.reduce(function(sequence, chapterUrl) {
    // Once the last chapter's promise is done…
    return sequence.then(function() {
      // …fetch the next chapter
      return getJSON(chapterUrl);
    }).then(function(chapter) {
      // and add it to the page
      addHtmlToPage(chapter.html);
    });
  }, Promise.resolve());
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

이제 동기화 버전의 완전 비동기 버전을 확인할 수 있습니다. 하지만 더 나아질 수 있습니다. 현재 페이지는 다음과 같이 다운로드되고 있습니다.

브라우저는 한 번에 여러 항목을 다운로드하는 데 능숙하므로 챕터를 하나씩 다운로드하면 성능이 저하됩니다. 모든 파일을 동시에 다운로드한 다음 모두 도착하면 처리하는 것이 좋습니다. 다행히 이를 위한 API가 있습니다.

Promise.all(arrayOfPromises).then(function(arrayOfResults) {
  //...
})

Promise.all는 약속 배열을 사용하고 모든 약속이 성공적으로 완료될 때 실행되는 약속을 만듭니다. 전달한 약속과 동일한 순서로 결과 배열 (약속이 처리된 결과)을 가져옵니다.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // Take an array of promises and wait on them all
  return Promise.all(
    // Map our array of chapter urls to
    // an array of chapter json promises
    story.chapterUrls.map(getJSON)
  );
}).then(function(chapters) {
  // Now we have the chapters jsons in order! Loop through…
  chapters.forEach(function(chapter) {
    // …and add to the page
    addHtmlToPage(chapter.html);
  });
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened so far
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

연결에 따라 하나씩 로드하는 것보다 몇 초 더 빠를 수 있으며 첫 번째 시도보다 코드가 적습니다. 챕터는 어떤 순서로든 다운로드할 수 있지만 화면에는 올바른 순서로 표시됩니다.

하지만 인식된 실적은 개선할 수 있습니다. 1장이 도착하면 페이지에 추가해야 합니다. 이렇게 하면 나머지 챕터가 도착하기 전에 사용자가 읽기를 시작할 수 있습니다. 3장이 도착하면 페이지에 추가하지 않습니다. 사용자가 2장이 누락된 것을 인지하지 못할 수 있기 때문입니다. 2장이 도착하면 2장과 3장을 추가할 수 있습니다.

이렇게 하려면 모든 챕터의 JSON을 동시에 가져온 다음 시퀀스를 만들어 문서에 추가합니다.

getJSON('story.json')
.then(function(story) {
  addHtmlToPage(story.heading);

  // Map our array of chapter urls to
  // an array of chapter json promises.
  // This makes sure they all download in parallel.
  return story.chapterUrls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
      // Use reduce to chain the promises together,
      // adding content to the page for each chapter
      return sequence
      .then(function() {
        // Wait for everything in the sequence so far,
        // then wait for this chapter to arrive.
        return chapterPromise;
      }).then(function(chapter) {
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve());
}).then(function() {
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

이제 두 가지 방법의 장점을 모두 누릴 수 있습니다. 모든 콘텐츠를 전송하는 데 걸리는 시간은 동일하지만 사용자가 처음 콘텐츠를 더 빨리 받게 됩니다.

이 사소한 예시에서는 모든 챕터가 거의 동시에 도착하지만, 한 번에 하나씩 표시하는 이점은 더 많고 더 큰 챕터가 있을 때 더 부각됩니다.

Node.js 스타일 콜백 또는 이벤트를 사용하여 위 작업을 실행하면 코드가 약 2배 늘어나지만 더 중요한 것은 따라하기가 쉽지 않다는 점입니다. 하지만 약속은 여기서 끝나지 않습니다. 다른 ES6 기능과 결합하면 훨씬 더 쉽게 사용할 수 있습니다.

보너스 라운드: 확장된 기능

이 도움말을 처음 작성한 이후 Promise를 사용하는 기능이 크게 확장되었습니다. Chrome 55부터 비동기 함수를 사용하면 프로미즈 기반 코드를 동기식인 것처럼 작성할 수 있지만 기본 스레드를 차단하지는 않습니다. 자세한 내용은 비동기 함수 도움말을 참고하세요. 주요 브라우저에서 Promises와 비동기 함수가 모두 광범위하게 지원됩니다. 자세한 내용은 MDN의 Promiseasync 함수 참조를 참고하세요.

교정 및 수정/추천을 제공해 주신 Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans, Yutaka Hirano님께 감사드립니다.

또한 도움말의 다양한 부분을 업데이트해 주신 마티아스 비넨스님께도 감사드립니다.