티스토리 뷰

ES6에서 비동기 처리를 위한 패턴으로 Promise라는 객체가 등장했다.

 

현재는 Promise를 활용한 Fetch API나 Axios같은 비동기 통신 라이브러리를 통해

비동기 처리 시점이 명확해지며 ES8에 등장한 async/await 구문으로 더욱더 직관적인 형태로 발전했다.

 

그렇다면 Promise가 생기기 전에는 어떻게 비동기 처리를 했을까?

 

 

콜백 패턴

 

Promise가 등장하기 이전에는 주로 콜백함수를 통해 비동기를 구현했다.

 

비동기 함수가 서버와의 응답을 끝냈을때의 후속 처리를 할 함수를 인자로 받는 형식이다.

 

만약 독자들의 블로그 DB에서 포스트 목록을 가져온다고 생각해 보자.

 

테스트를 진행하기 위해서 필자가 getUserRepository함수를 만들었으며 해당 함수는

success와 fail 콜백 함수를 받는 일반적인 콜백 패턴의 비동기 함수이다.

(※ HTTP Response 응답 상태코드에 따른 후속 처리)

var url = 'https://jsonplaceholder.typicode.com'

const getUserRepository = (url, success, fail) => {
  let request = new XMLHttpRequest();
  request.open('GET', url);
  request.send();

  request.onload = () => {

    if (request.status === 200) {
      success(JSON.parse(request.response));
    } else {
      fail("FAIL!");
    }
  }
}

getUserRepository(`${url}/posts`, console.log, console.warn);

(※ 필자는 테스트를 위한 가짜 API를 제공하는 jsonplaceholder API를 이용했다.)

 

만약 HTTP Response의 상태코드가 200이라면

 Response의 body객체(JSON)를 역직렬화해 JS객체로 만든 뒤 success매개변수에 전달한 console.log함수를 통해

해당 객체를 console에 출력할 것이고 만약 200 이외의 코드를 응답받으면

"FAIL!"이라는 문자열을 console.warn을 통해 console에 경고 메시지를 출력할 것이다.

실제로 위 코드를 브라우저 콘솔에서 실행해 보면 HTTP 통신에 성공하여 응답으로 받은

포스트 100개가 console에 출력된 걸 확인할 수 있다.

getUserRepository(`${url}/postssssss`, console.log, console.warn);

이번에는 "/posts/"를 "/postssssss/"로 변경하여 유효하지 않은 주소로 Request를 보내

의도적으로 HTTP 통신에 실패해 보겠다.

이번에는 404 에러가 발생하고

console에 경고 메시지 FAIL! 이 출력된 걸 확인할 수 있다.

 

지금까지 콜백함수로 보낸 console.log, console.error를 통해 응답 상태에 따른 후속처리를 하도록 해봤다.

 

 

콜백 패턴의 문제점

 

이번에 할 테스트는 조금 복잡하다.

 

일단 getUserRepository함수를 보면 fail매개변수가 없어진 거 외에는 이전과 변경점이 없다.

var url = 'https://jsonplaceholder.typicode.com'
var postTitle = "magnam facilis autem"
var userEmail = "Georgianna@florence.io"

const getUserRepository = (url, success) => {
  let request = new XMLHttpRequest();
  request.open('GET', url);
  request.send();

  request.onload = () => {

    if (request.status === 200) {
      success(JSON.parse(request.response));
    } else {
      console.warning("FAIL!");
    }
  };
};

getUserRepository(`${url}/posts`, (posts)=> {
  let { id } = posts.find(post => post.title === postTitle);
  getUserRepository(`${url}/posts/${id}/comments`, (comments) => {
    let { id } = comments.find(comment => comment.email === userEmail);
    getUserRepository(`${url}/posts/${id}`, (post) => {
      let { body } = post;
      console.log(body);
    })
  })
});

하지만 이번 예제의 getUserRepository호출과 관련된 코드를 보면 머리가 지끈거릴 정도로 복잡해 보인다.

 

천천히 살펴보자.

일단 HTTP 통신 성공 시 총 세 번의 통신이 이루어지는 걸 확인할 수 있다.

(※ getUserRepository함수를 세 번 호출함)

getUserRepository(`${url}/posts`, (posts)=> {
  let { id } = posts.find(post => post.title === postTitle);
  ...
  ...
});

가장 먼저 "/posts/"를 통해 전체 포스트를 가져오고

사용자가 지정한 타이틀과 일치하는 포스트의 id값을 가져온다.

 

그리고 해당 id값을 통해 다시 HTTP 통신을 하는데

이번에는 해당 포스트의 코멘트들을 가져온다.

  ...
  getUserRepository(`${url}/posts/${id}/comments`, (comments) => {
    let { id } = comments.find(comment => comment.email === userEmail);
	...
    ...
  })

그리고 사용자가 설정한 이메일과 일치하는 코멘트의 id값을 가져오고

마지막으로 해당 id값으로 다시 HTTP 통신해

해당 id값의 포스트를 가져온다.

    ...
    ...
    getUserRepository(`${url}/posts/${id}`, (post) => {
      let { body } = post;
      console.log(body);
    })

마지막으로 가져온 포스트의 본문을 디스트럭처링 해 console.log로 console에 출력한다.

예상한 대로 잘 동작해 마지막에 본문을 출력하지만 머리가 지끈거리는 건 변함없다.

getUserRepository(`${url}/posts`, (posts)=> {
  let { id } = posts.find(post => post.title === postTitle);
  getUserRepository(`${url}/posts/${id}/comments`, (comments) => {
    let { id } = comments.find(comment => comment.email === userEmail);
    getUserRepository(`${url}/posts/${id}`,(post) => {
      let { body } = post;
      console.log(body);
    })
  })
});

세 번의 연속적인 호출도 머리가 지끈거리는데 만약 6번 7번 연속적인 통신을 해야 하는 상황과 마주했다고 생각해 보자.

이렇게 연속적으로 비동기 결과를 가지고 또다시 비동기를 호출하는 상황을

콜백 패턴으로 구현했을 때 굉장히 복잡하고 가독성도 떨어진다.

이를 JS에서 "콜백 헬"이라고 부르며 콜백 패턴의 문제점 중 하나로 거론된다.

 

 

Promise

 

위에서 본 콜백 패턴의 문제점을 극복하기 위해서 ES6에 Promise라는 새로운 빌트인 객체가 도입되었다.

Promise객체는 생성 시 인자로 executor라는 콜백 함수를 가지는데

해당 함수 executor는 resolve와 reject 콜백함수를 가지고 있으며 생성시 바로 실행된다.

 

여기서 resolve는 비동기 통신에 성공했을 때 호출되는 함수이며 상태를 "fulfilled"로 변경하고

반대로 통신에 실패했을 때는 reject함수가 호출되며 상태를 "rejected"로 변경한다.

아마 굉장히 똑똑한 사람이 아닌 이상 필자가 지금까지 한 설명으로는 Promise객체에 대해

무슨 느낌인지 감이 안 잡힐 거라고 생각한다.

 

그렇기 때문에 이번에는 글보단 실제 코드로 확인해 보겠다.

 

콜백 패턴과의 비교를 위해 우리가 콜백 패턴에서 사용했던 getUserRepository 비동기 함수를

Promise객체를 반환하는 함수로 변경한 뒤 Promise객체를 생성하고 출력해 봤다.

const getUserRepository = (url) => {
  return new Promise((resolve, reject) => {
    let request = new XMLHttpRequest();
    request.open('GET', url);
    request.send();

    request.onload = () => {

      if (request.status === 200) {
        resolve(JSON.parse(request.response));
      } else {
        reject("FAIL!");
      }
    }
  })
}

var promise = getUserRepository('https://jsonplaceholder.typicode.com/posts');
console.log(promise);

getUserRepository함수를 통해 Promise객체를 생성하고 바로 출력해 보면

이미 벌써 비동기 통신을 마친 상태로 PromiseState가 "fulfilled"이며

PromiseResult에는 HTTP 응답 메시지의 바디값이 담겨있다.

 

이번에는 의도적으로 통신에 실패함으로써 통신에 실패했을 때의 Promise객체를 확인해 보겠다.

var promise = getUserRepository('https://jsonplaceholder.typicode.com/postsssssss');

이번에는 Promise객체를 생성하자마자 통신에 실패해 404 에러가 발생하고

출력된 Promise객체를 보면 PromiseState가 "rejected"상태이며

(※ 의미 그대로 "거부되었다(실패)")

PromiseResult에는 result함수 인자로 보낸 "FAIL"문자열이 있는 걸 확인할 수 있다.

 

여기서 주목할 점은 비동기 통신 성공이든 실패든 resolve, reject함수 둘 중 하나를 호출하게 되는데

해당 함수의 결괏값은 또 다른 Promise객체라는 점이다.

(※ 위 테스트에서 출력한 promise는 resolve(), reject()를 거친 값이다.)

이러한 점은 우리가 아직 진행하지 않은 부분이지만 Promse객체의 후속처리를 할 수 있게 도와주는 then 메서드를

마치 체인처럼 연속적으로 호출할 수 있는 이유이기도 하다.

 

 

fetch API, Promise객체 후속처리

 

Promise객체에는 후속처리를 담당하는 메서드 then, catch, finally가 존재한다.

 

가장 먼저 Promise객체의 핵심인 then에 대해 알아보자

var url = 'https://jsonplaceholder.typicode.com'
var postTitle = "magnam facilis autem"
var userEmail = "Georgianna@florence.io"

getUserRepository(`${url}/posts`, (posts)=> {
  let { id } = posts.find(post => post.title === postTitle);
  getUserRepository(`${url}/posts/${id}/comments`, (comments) => {
    let { id } = comments.find(comment => comment.email === userEmail);
    getUserRepository(`${url}/posts/${id}`,(post) => {
      let { body } = post;
      console.log(body);
    })
  })
});

혹시 아직도 이 코드가 기억나는가?

 

이 코드는 콜백 패턴의 문제점에 대해 설명할 때 나왔던 예제코드이다.

 

이제 이 코드를 우리가 Promise객체 형태에서 어떻게 구현하는지 확인해 보자

var url = 'https://jsonplaceholder.typicode.com'
var postTitle = "magnam facilis autem"
var userEmail = "Georgianna@florence.io"

getUserRepository(`${url}/posts`)
  .then((posts) => {
    const { id } = posts.find(post => post.title === postTitle);
    return getUserRepository(`${url}/posts/${id}/comments`);
  })
  .then((comments) => {
    const { id } = comments.find(comment => comment.email === userEmail);
    return getUserRepository(`${url}/posts/${id}`);
  })
  .then((post) => {
    const { body } = post;
    console.log(body);
  })

콜백 패턴과는 다르게 then이라는 메서드가 보인다.

Promise객체의 then("그 다음에") 메서드는 단어의 의미 그대로 "그 다음에 어떻게 할 건가요?"를 정의한다고 생각하면 쉽다.

하지만 그 다음에 뭘 하려면 이전 값이 필요하지 않은가?

그렇기 때문에 then메서드의 인자인 콜백함수는 인자로 이전 Promise의 결괏값을 인수로 가진다.

(※ 위 예제에서 posts, comments, post)

 

이렇게 연속적으로 then메서드를 호출하는 것을 Promise Chaning이라고 한다.

 

이러한 동작이 가능한 이유는 위에서도 한 번 설명했듯이 마지막 return문을 보면 getUserRepository를 호출하는데

해당 함수는 Promise객체를 호출하지 않는가?

Promise객체는 누구든 기본적으로 then 메서드를 가지고 있다.

똑같이 동작하는 함수를 Promise로 리팩터링 해보았다.

 

확실히 Promise객체가 가독성이 더 뛰어나지 않은가??

 

사실 Promise도 똑같이 콜백 함수를 사용하는 형태이지만

따로 비동기 객체가 존재함으로써 메서드를 통해 각 단계마다 확실히 분리되어 있는 걸 확인할 수 있다.

이러한 부분은 가독성 면에서 큰 차이를 만들어낸다.

getUserRepository(`${url}/posts`)
  .then((posts) => {
    const { id } = posts.find(post => post.title === postTitle);
    return getUserRepository(`${url}/posts/${id}/comments`);
    ...
    ...
  })

방금 예제에서 사용한 코드를 다시 보면

then메서드의 인자로 하나의 콜백 함수만 전달한 걸 확인할 수 있다.

 

만약 비동기 통신에 실패한다면 어떻게 될까?

 

이번에도 테스트를 위해 의도적으로 잘못된 url로 Request를 보내보겠다.

getUserRepository(`${url}/posts`)
  .then((posts) => {
    ...
    return getUserRepository(`${url}/posts/${id}/commentsssss`);
    ...
    ...
  })

당연히 예상한 대로 404 에러가 발생하며 getUserRepository에서 설정했던 "FAIL"이 출력된다.

만약 비동기 통신에 실패했을 때 또 다른 후속 처리가 필요하다면 어떻게 해야 할까?

 

then메서드는 사실 두 개의 콜백 메서드를 받는다.

이전 예제에서는 단 하나의 콜백 함수만 인자로 전달했기 때문에 통신에 성공했을 때 실행될 onfulfilled함수만 지정됐지

통신에 실패했을 때 실행될 onrejected함수는 자동으로 undefined가 되며 통신 실패 시의 후속 처리가 없는 구조가 되었다.

이번에는 onrejected에게도 콜백 함수를 전달해 보도록 하겠다.

const getUserRepository = (url) => {
  return new Promise((resolve, reject) => {
    let request = new XMLHttpRequest();
    request.open('GET', url);
    request.send();

    request.onload = () => {

      if (request.status === 200) {
        resolve(JSON.parse(request.response));
      } else {
        reject("FAIL");
      }
    }
  })
}

var url = 'https://jsonplaceholder.typicode.com'
var postTitle = "magnam facilis autem"
var userEmail = "Georgianna@florence.io"

getUserRepository(`${url}/postsssss`)
  .then(posts => console.log(posts), (error) => console.log(error));

이번에는 비동기 통신에 성공 시 resolve의 인자로 보낸 값(JSON.parse(request.response))이 출력되고 반대로

비동기 통신에 실패했을 때는 reject의 인자로 보낸 값("FAIL")이 출력되도록 했다.

유효하지 않은 주소로 요청을 보냈기에 요청이 실패되었고 "FAIL"이 출력된 걸 확인할 수 있다.


참고로 현재 예제는 간단한 테스트를 위해 필자가 직접 Promise객체를 만든 상태라서

성공시나 실패 시 응답 객체의 상태가 많이 부실하다.

(※ FetchAPI, Axios 같은 경우는 따로 커스터마이징 된 Promise객체가 있다.)

const fetchAPI = fetch(`${url}/posts`).then(res => console.log(res))

간단하게 예시로 FetchAPI를 사용하여 비동기 통신했을 때 응답 결과를 살펴보면

필자가 만든 Promise객체는 단순하게 JSON.parse를 통해 응답결과를 역직렬화해 출력했지만

FetchAPI를 통한 응답결과는 Response객체로 body에 해당 데이터가 담겨있으며

이외에는 통신과 관련된 다양한 어트리뷰트들이 존재하는 걸 확인할 수 있다.


하지만 만약에 onrejected함수나 onfulfilled함수가 굉장히 복잡한 구조로 되어있다면 어떻게 될까?

getUserRepository(`${url}/posts`)
  .then((posts) => {
    ...
    ...
    return ...
  }, (error) => {
    ...
    ...
    return ...
  })

아마 위 코드처럼 굉장히 복잡한 형태가 될 것이다.

심지어 Promise Chaning형태로 여러 개의 then이 있다고 가정하면 더 복잡하고 가독성이 떨어지는 형태가 될 것이다.

 

이러한 문제를 해결하려면 catch메서드를 사용하면 된다.

getUserRepository(`${url}/posts`)
  .then((posts) => {
    const { id } = posts.find(post => post.title === postTitle);
    return getUserRepository(`${url}/posts/${id}/comments`);
  })
  .then((comments) => {
    const { id } = comments.find(comment => comment.email === userEmail);
    return getUserRepository(`${url}/posts/${id}`);
  })
  .then((post) => {
    const { body } = post;
    console.log(body);
  })
  .catch((error) => {
    console.log(error);
  })

Promise객체의 catch메서드는 then메서드가 실패할 시 해당 catch메서드의 인자로 전달된 콜백이 실행된다.

then메서드의 onrejected매개변수의 인자로 콜백 함수를 보내나

catch메서드를 사용하나 동일한 결과가 나오는 걸 확인할 수 있다.

 

물론 만약에 체이닝마다 다른 후속 처리를 해야 한다면 catch메서드를 사용하는 구조에서는 응답 결과에 따른

조건 분기로 해결해야 한다.

(※ 필자 개인적인 생각이지만 차라리 조건 분기 하는 게 then으로 에러처리를 하는 방식보단 낫다고 생각한다.)

 

마지막으로 finally메서드다.

finally는 단어 의미 그대로 "마지막"에 어떻게 할 건가요?를 정의한다고 생각하면 된다.

getUserRepository(`${url}/postsssss`)
  .then((posts) => {
    const { id } = posts.find(post => post.title === postTitle);
    return getUserRepository(`${url}/posts/${id}/comments`);
  })
  .then((comments) => {
    const { id } = comments.find(comment => comment.email === userEmail);
    return getUserRepository(`${url}/posts/${id}`);
  })
  .then((post) => {
    const { body } = post;
    console.log(body);
  })
  .catch((error) => {
    console.log(error);
  })
  .finally(() => {
    console.log("finish")
  })

 

기존 코드에 finally메서드만 추가한 형태고 모든 비동기 통신이 어떻게 끝나든

finally메서드는 항상 실행되는 걸 확인할 수 있다.

 

이외에 Promise와 관련된 다양한 정적 메서드(all, any ...)가 있다.

정적 메서드와 관련된 내용은 해당 사이트를 참고하기 바란다.

Mdn Web Docs - Promise(Static Methods)

 

Promise - JavaScript | MDN

Promise 객체는 비동기 작업이 맞이할 미래의 완료 또는 실패와 그 결과 값을 나타냅니다.

developer.mozilla.org

 

 

마지막으로..

 

JS비동기하면 가장 핵심적인 주제는 Promise이지 않나 싶다.

왜냐하면 많은 부분들이 Promise와 연관되어 있기 때문이다.

그렇기 때문에 독자분들이 비동기와 관련된 다른 주제를 보게 될 때도 Promise는 아마 항상 등장하게 될 거다.

 

처음 Promise를 접하는 독자분들에겐 다른 주제들보다 한 단계 더 어렵게 느껴질 것이라고 생각한다.

사실 당연한 부분이다. Promise는 냉정하게 어려운 주제이기 때문이다.

 

그럼에도 불구하고 독자분들은 해낼 거라고 생각한다.

사실 해내지 못하면 안 된다. 앞으로도 자주 볼 예정이기 때문이다.

댓글
공지사항