저번 시간에는 콜백 함수에 대해 그리고 비동기 콜백 함수들의 종류에 대해 살펴봤다. 다음으로 콜백 함수의 지옥이라는 것을 살펴보고, 그것을 해결하기 위해 개선된 기능들을 살펴보려고 한다.
들어는 봤나?
콜백 지옥이라고?
위 그림은 아주 유명한 콜백 지옥짤이다. 보는 것처럼 콜백 함수들이 네스팅 된 채로 엄청 연결되어 있다. 물론 이렇게 할 수도 있지만, 지옥이라 불리는 만큼 사람들이 안 쓰는 이유가 있다.
- 현저한 가독성 저하.
- 디버깅의 능률 저하.
그래서 해결사처럼 등장한 개념이 프로미스다.
프로미스는 우리말로 약속을 뜻하는데... 이 약속이 진짜 맞을까?
결론부터 말하자면 맞다! 그 약속. 다만 코드 상에서 약속을 해 주는 것인데.
간단한 생활 예시를 들어서 쉽게 설명해 보겠다.
예를 들어, 내가 유명한 소설가다. 그런데, 다음 신간을 출시해야 한다고 가정해 보자. 그런데 내가 베스트 셀러 작가이다 보니, 내 책에 대한 독자들의 관심이 빗발친다. 그러다 보니, 출간 예정일에 대한 문의가 계속 된다. 그래서 굉장히 고통스러운 나. 이를 위해 독자들과 약속을 하는 것이다. 제가 신간 나오기 전에 메일로 알려드릴 테니 이제 그만 물으셈! 이렇게 말이다. 이렇게 딱 약속을 정하고 독자들에게 안내를 하니, 문의도 줄고 독자들도 계속 기다라지 않아도 되는 1석 2조의 결과를 얻었다. 바로 이런 개념이 프로미스다.
즉, 프로미스는 어떤 함수가 내부에서 바로 실행되지 않고 어떤 약속을 맺어서 그 약속을 추후에 실행시키는 오브젝트라고 보면 되겠다.
자~ 코드로 풀어보면 이렇다.
<script>
let promise = new Promise(function(resolve, reject) {
// executor (출판 코드, '작가')
});
</script>
new Promise에 전달되는 함수는 executor(실행자, 실행 함수) 라고 부른다. executor는 new Promise가 만들어질 때 자동으로 실행되는데, 결과를 최종적으로 만들어내는 출판 코드를 포함합니다. 위 비유에서 '작가’가 바로 executor다.
executor의 인수 resolve와 reject는 자바스크립트에서 자체 제공하는 콜백이다. 개발자는 resolve와 reject를 신경 쓰지 않고 executor 안 코드만 작성한다.
대신 executor에선 결과를 즉시 얻든 늦게 얻든 상관없이 상황에 따라 인수로 넘겨준 콜백 중 하나를 반드시 호출해야 한다.
resolve(value) — 일이 성공적으로 끝난 경우 그 결과를 나타내는 value와 함께 호출
reject(error) — 에러 발생 시 에러 객체를 나타내는 error와 함께 호출
요약하면 다음과 같다. executor는 자동으로 실행되는데 여기서 원하는 일이 처리. 처리가 끝나면 executor는 처리 성공 여부에 따라 resolve나 reject를 호출.
한편, new Promise 생성자가 반환하는 promise 객체는 다음과 같은 내부 프로퍼티를 갖습니다.
*state — 처음엔 "pending"(보류)이었다 resolve가 호출되면 "fulfilled", reject가 호출되면 "rejected"로 변화.
*result — 처음엔 undefined이었다 resolve(value)가 호출되면 value로, reject(error)가 호출되면 error로 변화.
따라서 executor는 아래 그림과 같이 promise의 상태를 둘 중 하나로 변화시킨다.
Promise로 구현된 비동기 함수는 Promise 객체를 반환하여야 한다. Promise로 구현된 비동기 함수를 호출하는 측(promise consumer)에서는 Promise 객체의 후속 처리 메소드(then, catch)를 통해 비동기 처리 결과 또는 에러 메시지를 전달받아 처리한다. Promise 객체는 상태를 갖는다고 하였다. 이 상태에 따라 후속 처리 메소드를 체이닝 방식으로 호출한다. Promise의 후속 처리 메소드는 아래와 같다.
then
then 메소드는 두 개의 콜백 함수를 인자로 전달 받는다. 첫 번째 콜백 함수는 성공(fulfilled, resolve 함수가 호출된 상태) 시 호출되고 두 번째 함수는 실패(rejected, reject 함수가 호출된 상태) 시 호출된다.
then 메소드는 Promise를 반환한다.
catch
예외(비동기 처리에서 발생한 에러와 then 메소드에서 발생한 에러)가 발생하면 호출된다. catch 메소드는 Promise를 반환한다.
<script>
const $result = document.querySelector('.result');
const render = content => { $result.textContent = JSON.stringify(content, null, 2); };
const promiseAjax = (method, url, payload) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.setRequestHeader('Content-type', 'application/json');
xhr.send(JSON.stringify(payload));
xhr.onreadystatechange = function () {
if (xhr.readyState !== XMLHttpRequest.DONE) return;
if (xhr.status >= 200 && xhr.status < 400) {
resolve(xhr.response); // Success!
} else {
reject(new Error(xhr.status)); // Failed...
}
};
});
};
/*
비동기 함수 promiseAjax은 Promise 객체를 반환한다.
Promise 객체의 후속 메소드를 사용하여 비동기 처리 결과에 대한 후속 처리를 수행한다.
*/
promiseAjax('GET', 'http://jsonplaceholder.typicode.com/posts/1')
.then(JSON.parse)
.then(
// 첫 번째 콜백 함수는 성공(fulfilled, resolve 함수가 호출된 상태) 시 호출된다.
render,
// 두 번째 함수는 실패(rejected, reject 함수가 호출된 상태) 시 호출된다.
console.error
);
</script>
위 예제의 비동기 함수 get은 Promise 객체를 반환한다. 비동기 처리 결과에 대한 후속 처리는 Promise 객체가 제공하는 후속 처리 메서드 then, catch, finally를 사용하여 수행한다. 비동기 처리 시에 발생한 에러는 then 메서드의 두 번째 콜백 함수로 처리할 수 있다.
<script>
let isLoading = true;
fetch(myRequest).then(function(response) {
var contentType = response.headers.get("content-type");
if(contentType && contentType.includes("application/json")) {
return response.json();
}
throw new TypeError("Oops, we haven't got JSON!");
})
.then(function(json) { /* process your JSON further */ })
.catch(function(error) { console.log(error); })
.finally(function() { isLoading = false; });
</script>
finally() 메서드는 결과에 관계없이 promise가 처리되면 무언가를 프로세싱 또는 정리를 수행하려는 경우에 유용하다.
자! 이제 여러분이 고대하던 콜백 헬에서 벗어날 기술에 들어왔다.
바로 프로미스 체이닝! 맞다. 말 그대로 연결시키는 것이다. 프라미스를 사용하면 여러 가지 해결책을 만들 수 있다. 이번에는 프로미스 체이닝(promise chaining)을 이용한 비동기 처리에 대해 다루겠다.
프라미스 체이닝은 아래와 같다.
<script>
"use strict";
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;
});
</script>
프라미스 체이닝은 result가 .then 핸들러의 체인(사슬)을 통해 전달된다는 점에서 착안한 아이디어다.
위 예시는 아래와 같은 순서로 실행된다.
1초 후 최초 프라미스가 이행. – (*)
이후 첫번째 .then 핸들러가 호출. –()
2에서 반환한 값은 다음 .then 핸들러에 전달. – (*)
이런 과정이 계속 반복.
result가 핸들러 체인을 따라 전달되므로, alert 창엔 1, 2, 4가 순서대로 출력.
프라미스 체이닝이 가능한 이유는 promise.then을 호출하면 프라미스가 반환되기 때문이다. 반환된 프라미스엔 당연히 .then을 호출할 수 있다.
한편 핸들러가 값을 반환할 때엔 이 값이 프라미스의 result가 된다. 따라서 다음 .then은 이 값을 이용해 호출.
초보자는 프라미스 하나에 .then을 여러 개 추가한 후, 이를 체이닝이라고 착각하는 경우가 있지만 이는 체이닝이 아니다.
자 이제 콜백 지옥의 예시를 어떻게 해결하는 지 과정을 기술하겠다.
2를 곱하는 콜백함수를 두 번 호출해서 8로 만들고 싶어서 다음과 같은 코드를 작성했다.
<script> getDataCallback(2, (err, data) => { if (err) { console.log(err) } else { getDataCallback(data, (err, data) => { //두 번 2를 곱하고 싶어서 콜백함수 다시 호출함 if (err) { console.log(err) } else { console.log(data) //8, 두번째로 2를 곱했으므로 8 } } // console.log(data) )} })//8 </script>
-> 보면 네스팅이 먹힌 것을 볼 수 있다.
<script>
const getDataPromise = (num) => new Promise((resolve, reject) => {
setTimeout(() => {
typeof num === 'number' ? resolve(num*2) : reject(console.log('Number must be provided'))
}, 2000)
})
</script>
-> 앞서 만들었던 콜백함수처럼 if-else문 사용하는 대신 resolve(), reject() 메서드를 각각 사용해서 nesting을 최대한 피한다.
<script>
getDataPromise(2).then((data) => {
getDataPromise(data).then((data) => { //put the data into the getDataPromise() again to multiply twice
console.log(`Promise data: ${data}`)
}, (err) => {
console.log(err)
})
}, (err) => {
console.log(err)
})
</script>
위에서 본 콜백함수 호출 방식보다 훨씬 깔끔하지만 여전히 nesting이 되어 있고, 중복되는 코드가 있다. 이는 프로미스 체인으로 해결할 수 있다.
<script>
getDataPromise('10').then((data) => {
return getDataPromise(data)
}).then((data) => {
return getDataPromise(data)
}).then((data) => {
console.log(data)
}).catch((err) => { //catch: an error handler for all of our promises in the promise chaining
console.log(err)
})
</script>
이처럼 마지막으로 체이닝까지 하면 콜백 지옥에서 벗어나기가 가능하다. 물론 이런 then으로 연결 되는 것도 최대로 줄일 수가 있는데....
이건 바로 뒤에 기술할 async/await다.
다음 편은 바로 async와 await로 돌아오겠다.
--------------- to be continued---------------------------------------------