이전 포스팅에서 비동기성을 표현하고 동시성을 관리하기 위해 콜백을 사용하는 것과 관련하여 두 가지 주요 범주의 결함을 확인했었다. (순서와 믿음의 결여)
가장 먼저 제어의 역전(신뢰성)을 생각해보자.
이전에는 서드파티 라이브러리함수와 같은 다른 파트에게 콜백함수를 전달해주고 이것이 잘 작동하기만을 바라는 방법밖에 없었다. 그런데 만약 우리가 이런 제어권을 다시 되찾아 올 수 있다면 어떨까? 프로그램의 진행을 다른 파트에게 넘겨주는 대신, 개발자가 작업이 언제 완료되었는지 알 수만 있고 그 이후에 무엇을 할지를 결정할 수 있다면 어떨까?
이 패러다임으로 인해 나타난 것이 Promise 인 것이다.
You don't Know JS에서 보여줬던 예시상황
햄버거 가게에 가서 치즈버거를 주문하고 결제한다. 그러나 종종 치즈버거는 바로 나오지 못할수도 있기 때문에 캐셔는 우리에게 주문번호가 적혀있는 영수증을 준다. 이 주문번호는 최종적으로 내가 치즈버거를 받을 수 있도록 보증하는 Promise(약속)인 것이다.
그래서 나는 즐거운 마음으로 영수증을 손에 쥐고 순서를 기다리면서 다른일을 할 수도 있다.
기다리다보면 결국 캐셔가 나의 번호를 부르면 영수증에 건내주고 그 대가로 치즈버거를 받을 수 있다.그런데... 치즈버거가 다 떨어져 받지 못하는 실패의 상황이 있을 수 있다. 슬프겠지만 잠시 감정은 숨겨두고, 우리는 future values이 성공 또는 실패로 나뉜다는 것을 알 수 있었다.
다음의 예시를 봐보자.
function add(xPromise, yPromise) {
return Promise.all([xPromise, yPromise]).then(function (values) {
return values[0] + values[1]
});
}
add(fetchX(), fetchY()).then(function (sum) {
console.log(sum)
});
여기에는 두 계층의 프로미스가 존재한다.
먼저 fetchX()와 fetchY()를 직접 호출하여 이들의 반환 값(프로미스)을 add()에 전달한다. 두 프로미스는 원래 값은 지금 아니면 나중에 준비되겠지만 각각의 프로미스들은 시점과는 상관없이 동일한 결과를 정규화한다. 그래서 시간 독립적(time-independent) 방식으로 X와 Y값에 대해 추론할 수 있는 것이다.
그다음 계층으로 add()가 만들어 반환한 프로미스로 then()을 호출하고 대기(pending)한다. 이후에 add()작업이 완료되면 덧셈을 마친 future value이 준비되어 출력할 수 있게 된다. (여기서 add()안에 X와 Y의 미래 값을 기다리기 위한 로직이 숨겨져있다.)
add(fetchX(), fetchY()).then(
// fullfillment handler
function (sum) {
console.log(sum)
},
// rejection handler
function (err) {
console.error(err)
}
)
프로미스를 사용하면 then 함수에 첫번째 인자로 fulfillment함수(resolve()로 생각해도 무방할 것 같다.), 두번째 인자로 rejection함수(마찬가지로 reject())를 넘겨받는다. 치즈버거의 예시상황과 마찬가지로 성공과 실패에 대해서 다루고자 하는 것인데 X 또는 Y를 가져오는 동안에 오류가 발생할 경우 rejected되면서 두번째 인자로 전달된 콜백 에러 핸들러가 프로미스로부터 거부 값을 수신하게 된다. (반대로 fulfilled의 경우에는 첫번째인자로)
프로미스는 시간 의존적인(time-dependent) 상태를 외부로부터 캡슐화하기 때문에 프로미스 자체는 시간 독립적(time-independent)이게 되면서 타이밍 또는 결과에 상관없이 예측 가능한 방식으로 구성할 수 있다.
게다가 프로미스는 한번 귀결되면 그, 프로미스는 영원히 유지(불변성, immutable)되기 때문에 필요할때마다 꺼내 쓸 수 있다.
이전 포스팅에서 우리가 봤던 콜백에서 일어날 수 있는 경우들이다.
때때로 동일한 task가 어떠한 경우에는 동기적으로 수행되고 어떠한 경우에는 비동기적으로 수행되서 발생하는 경우이다.
프로미스는 then()을 호출할 때 이미 해당 task가 귀결되었더라도 그 때 제공하는 콜백은 항상 비동기식으로 호출되도록 설계되어 있기 때문에 너무 일찍 부르는 경우는 발생하지 않는다.
(그래서 setTimeout(..,0)와 같은 작업들이 더이상 필요하지 않는다.)
또한 프로미스가 귀결되면 then()에 등록된 모든 콜백들이 그 다음 비동기 기회가 찾아왔을 때 순서대로 호출되며 이러한 콜백 중 하나에서 발생하는 어떤 것도 다른 콜백의 호출에 영향을 미치거나 지연시킬 수 없다.
p.then(function() {
p.then(function() {
console.log('C');
});
console.log('A');
});
p.then(function() {
console.log('B');
});
// A B C
즉, 다음과 같은 코드에서 C가 B보다 먼저 호출될일은 없다는 것이다.
먼저 한번도 콜백을 호출하지 않는 경우이다.
기본적으로 프로미스는 fulfill, reject 콜백이 프라미스에 모두 등록된 상태라면 프라미스 귀결 시 둘 중 하나는 반드시 호출된다. 그런데 만약 중간에 네트워크 상태가 좋지않거나 로직이 잘못 작성되어있는 경우 등등, 여러가지 이유로 프로미스가 귀결되지 않는 경우가 발생할 수도 있다.
이 경우 race라고 불리는 더 높은 수준의 추상화를 사용하면 된다. 다음과 같이 Promise.race()
를 사용하면 어느 한쪽으로 귀결되지 않는 상황을 해결할 수 있게 된다. 그렇기 때문에 무조건 한번은 불리게 되는 것이다.
function timeoutPromise(delay) {
return new Promise( function(res, rej) {
setTimeout(function() {
reject('타임아웃!')
}, delay)
})
};
Promise.race([
foo(),
timeoutPromise(3000)
]).then(
function() {
// foo가 제시간안에 fullfilled 된 경우
},
function(err) {
// foo가 rejected 되었거나 시간안에 끝내지 못한 경우
// 'err'을 확인하여 원인 파악
}
)
다음은 너무 많이 호출되는 경우이다.
프로미스는 정의상 단 한번만 호출되도록 되어있다. 어떤 이유로든 resolve 또는 reject가 여러번 호출되거나 둘다 호출하려고 하면 최초의 귀결만 취하고 이후의 시도는 조용히 무시된다.
어떤 이유로 프로미스를 거부되면 그 값은 rejection callback에 전달된다. 또한 프로미스의 생성 또는 귀결을 기다리는 도중 TypeError 와 ReferenceError와 같은 JS 실행 에러가 발생하더라도 예외를 포착하여 거부시켜버린다.
var p = new Promise(function(resolve, reject) {
foo.bar(); // foo는 정의되어있지 않아 error가 발생한다!
resolve(42); // 여기까지 도달할 수 없다 :(
})
p.then(
function fulfilled() {
// 실행되지 않는다.
},
function rejected(err) {
// 'foo.bar()' 에서 발생한 에러로 'err'는 타입 에러일 것이다.
}
)
정리하면 Promise는 callback에서 가졌던 순서, 믿음 문제를 보장하는 고마운 존재이며 JS/DOM에 추가되는 대부분의 새로운 비동기 API는 Promise를 기반으로 구축되는 만큼 Promise를 믿고 사용해도 좋을 것 같다.
You Don't Know JS: Async & Performance
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise