2022년 4월 25일

프라미스 체이닝

콜백 챕터에서 언급한 문제를 다시 집어봅시다. 스크립트를 불러오는 것과 같이 순차적으로 처리해야 하는 비동기 작업이 여러 개 있다고 가정해 봅시다. 어떻게 해야 이런 상황을 코드로 풀어낼 수 있을까요?

프라미스를 사용하면 여러 가지 해결책을 만들 수 있습니다.

이번 챕터에선 프라미스 체이닝(promise chaining)을 이용한 비동기 처리에 대해 다루도록 하겠습니다.

프라미스 체이닝은 아래와 같이 생겼습니다.

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;

});

프라미스 체이닝은 result.then 핸들러의 체인(사슬)을 통해 전달된다는 점에서 착안한 아이디어입니다.

위 예시는 아래와 같은 순서로 실행됩니다.

  1. 1초 후 최초 프라미스가 이행됩니다. – (*)
  2. 이후 첫번째 .then 핸들러가 호출됩니다. –(**)
  3. 2에서 반환한 값은 다음 .then 핸들러에 전달됩니다. – (***)
  4. 이런 과정이 계속 이어집니다.

result가 핸들러 체인을 따라 전달되므로, alert 창엔 1, 2, 4가 순서대로 출력됩니다.

프라미스 체이닝이 가능한 이유는 promise.then을 호출하면 프라미스가 반환되기 때문입니다. 반환된 프라미스엔 당연히 .then을 호출할 수 있습니다.

한편 핸들러가 값을 반환할 때엔 이 값이 프라미스의 result가 됩니다. 따라서 다음 .then은 이 값을 이용해 호출됩니다.

초보자는 프라미스 하나에 .then을 여러 개 추가한 후, 이를 체이닝이라고 착각하는 경우가 있습니다. 하지만 이는 체이닝이 아닙니다.

예시:

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;
});

예시의 프라미스는 하나인데 여기에 등록된 핸들러는 여러 개 입니다. 이 핸들러들은 result를 순차적으로 전달하지 않고 독립적으로 처리합니다.

그림으로 표현하면 다음과 같습니다. 프라미스 체이닝을 묘사한 위 그림과 비교해 보세요.

동일한 프라미스에 등록된 .then 모두는 동일한 결과(프라미스의 result)를 받습니다. 따라서 위 예시를 실행하면 얼럿 창엔 전부 1이 출력됩니다.

이런 식으로 한 프라미스에 여러 개의 핸들러를 등록해서 사용하는 경우는 거의 없습니다. 프라미스는 주로 체이닝을 해서 씁니다.

프라미스 반환하기

.then(handler)에 사용된 핸들러가 프라미스를 생성하거나 반환하는 경우도 있습니다.

이 경우 이어지는 핸들러는 프라미스가 처리될 때까지 기다리다가 처리가 완료되면 그 결과를 받습니다.

예시:

new Promise(function(resolve, reject) {

  setTimeout(() => resolve(1), 1000);

}).then(function(result) {

  alert(result); // 1

  return new Promise((resolve, reject) => { // (*)
    setTimeout(() => resolve(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

});

예시에서 첫 번째 .then1을 출력하고 new Promise(…)를 반환((*))합니다.
1초 후 이 프라미스가 이행되고 그 결과(resolve의 인수인 result * 2)는 두 번째 .then으로 전달됩니다. 두 번째 핸들러((**))는 2를 출력하고 동일한 과정이 반복됩니다.

따라서 얼럿 창엔 이전 예시와 동일하게 1, 2, 4가 차례대로 출력됩니다. 다만 얼럿 창 사이에 1초의 딜레이가 생깁니다.

이렇게 핸들러 안에서 프라미스를 반환하는 것도 비동기 작업 체이닝을 가능하게 해줍니다.

loadScript 예시 개선하기

지금까지 배운 기능을 사용해 이전 챕터에서 프라미스를 사용해 정의한 loadScript(스크립트를 순차적으로 불러줌)를 개선해 봅시다.

loadScript("/article/promise-chaining/one.js")
  .then(function(script) {
    return loadScript("/article/promise-chaining/two.js");
  })
  .then(function(script) {
    return loadScript("/article/promise-chaining/three.js");
  })
  .then(function(script) {
    // 불러온 스크립트 안에 정의된 함수를 호출해
    // 실제로 스크립트들이 정상적으로 로드되었는지 확인합니다.
    one();
    two();
    three();
  });

화살표 함수를 사용하면 다음과 같이 코드를 줄일수도 있습니다.

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은 이 프라미스가 이행되었을 때 실행됩니다. 이후에 다음 스크립트를 로딩하기 위한 초기화가 진행됩니다. 스크립트는 이런 과정을 거쳐 순차적으로 로드됩니다.

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

한편, 아래와 같이 각 loadScript.then을 바로 붙일 수도 있습니다.

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

이렇게 .then을 바로 붙여도 동일한 동작(스크립트 세 개를 순차적으로 불러오는 작업)을 수행합니다. 하지만 코드가 ‘오른쪽으로’ 길어졌네요. 콜백에서 언급한 문제와 동일한 문제가 발생했습니다.

프라미스를 이제 막 배우기 시작해 체이닝에 대해 잘 모른다면 위와같이 코드를 작성할 수 있습니다. 그러나 대개 체이닝이 선호됩니다.

중첩 함수에서 외부 스코프에 접근할 수 있기 때문에 .then을 바로 쓰는 게 괜찮은 경우도 있습니다. 위 예제에서 가장 깊은 곳에 있는 중첩 콜백은 script1, script2, script3 안에 있는 변수 모두에 접근할 수 있습니다. 이런 예외 상황이 있다는 정도만 알아두도록 합시다.

thenable

핸들러는 프라미스가 아닌 thenable이라 불리는 객체를 반환하기도 합니다. .then이라는 메서드를 가진 객체는 모두 thenable객체라고 부르는데, 이 객체는 프라미스와 같은 방식으로 처리됩니다.

‘thenable’ 객체에 대한 아이디어는 서드파티 라이브러리가 ‘프라미스와 호환 가능한’ 자체 객체를 구현할 수 있다는 점에서 나왔습니다. 이 객체들엔 자체 확장 메서드가 구현되어 있겠지만 .then이 있기 때문에 네이티브 프라미스와도 호환 가능합니다.

아래는 thenable 객체 예시입니다.

class Thenable {
  constructor(num) {
    this.num = num;
  }
  then(resolve, reject) {
    alert(resolve); // function() { 네이티브 코드 }
    // 1초 후 this.num*2와 함께 이행됨
    setTimeout(() => resolve(this.num * 2), 1000); // (**)
  }
}

new Promise(resolve => resolve(1))
  .then(result => {
    return new Thenable(result); // (*)
  })
  .then(alert); // 1000밀리 초 후 2를 보여줌

자바스크립트는 (*)로 표시한 줄에서 .then 핸들러가 반환한 객체를 확인합니다. 이 객체에 호출 가능한 메서드 then이 있으면 then이 호출됩니다. thenresolvereject라는 네이티브 함수를 인수로 받고(executor과 유사함), 둘 중 하나가 호출될 때까지 기다립니다. 위 예시에서 resolve(2)는 1초 후에 호출됩니다((**)). 호출 후 결과는 체인을 따라 아래로 전달됩니다.

이런 식으로 구현하면 Promise를 상속받지 않고도 커스텀 객체를 사용해 프라미스 체이닝을 만들 수 있습니다.

fetch와 체이닝 함께 응용하기

프론트 단에선, 네트워크 요청 시 프라미스를 자주 사용합니다. 이에 관련된 예시를 살펴봅시다.

예시에선 메서드 fetch를 사용해 원격 서버에서 사용자 정보를 가져오겠습니다. fetch엔 다양한 선택 매개변수가 있는데 자세한 내용은 별도의 챕터에서 다루기로 하고, 여기선 기본 문법만 사용해 보겠습니다.

let promise = fetch(url);

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

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

아래 코드를 실행하면 user.json에 요청이 가고 서버에서 해당 텍스트를 불러옵니다.

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, 이름만 성공적으로 가져옴

불러온 사용자 정보를 가지고 무언가를 더 해보겠습니다.

GitHub에 요청을 보내 사용자 프로필을 불러오고 아바타를 출력해 보는 것같이 말이죠.

// 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(githubUser)를 호출했을 때((**)) 만 처리상태가 되는 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}`));
  // ...

요약

.then 또는 .catch, .finally의 핸들러(어떤 경우도 상관없음)가 프라미스를 반환하면, 나머지 체인은 프라미스가 처리될 때까지 대기합니다. 처리가 완료되면 프라미스의 result(값 또는 에러)가 다음 체인으로 전달됩니다.

이를 그림으로 나타내면 아래와 같습니다.

과제

아래 두 코드 조각을 보세요. 두 코드는 동일하게 동작할까요? 모든 상황을 고려하여 두 코드 조각이 동일하게 동작할지 아닐지를 판단해 보세요.

promise.then(f1).catch(f2);

비교하기

promise.then(f1, f2);

두 코드는 다르게 동작합니다.

f1에서 에러가 발생하면 아래 코드에서는 .catch에서 에러가 처리됩니다.

promise
  .then(f1)
  .catch(f2);

하지만 아래 코드에선 f1에서 발생한 에러를 처리하지 못합니다.

promise
  .then(f1, f2);

then 핸들러에서 에러가 발생하면 체인 아래로 전달됩니다. 위 코드에는 f1 아래에 이어지는 체인이 없네요.

.then은 결과나 에러를 다음 .then이나 catch에 전달합니다. 첫 번째 코드 조각엔 catch가 있지만 두 번째 코드 조각엔 이어지는 체인이 전혀 없기 때문에 에러가 발생한 경우 이 에러를 처리하지 못한다는 차이가 생깁니다.

튜토리얼 지도

댓글

댓글을 달기 전에 마우스를 올렸을 때 나타나는 글을 먼저 읽어주세요.
  • 추가 코멘트, 질문 및 답변을 자유롭게 남겨주세요. 개선해야 할 것이 있다면 댓글 대신 이슈를 만들어주세요.
  • 잘 이해되지 않는 부분은 구체적으로 언급해주세요.
  • 댓글에 한 줄짜리 코드를 삽입하고 싶다면 <code> 태그를, 여러 줄로 구성된 코드를 삽입하고 싶다면 <pre> 태그를 이용하세요. 10줄 이상의 코드는 plnkr, JSBin, codepen 등의 샌드박스를 사용하세요.