프라미스가 나오기 이전에는 콜백으로 비동기성을 다뤘습니다. 하지만 콜백으로 프로그램의 비동기성을 표현하고 동시성을 다루면 순차성과 믿음성이 결여되는 중요한 결함이 있습니다.
그리고 제어의 역전이라는 치명적인 문제가 있습니다.
var x, y = 2;
console.log(x + y); // NaN
우리는 x + y 연산을 할 때 당연히 x, y 모두 값이 세팅되어 있다고 가정합니다. 이를 더 체계적인 용어로 바꾸면 '귀결됐다'고 할 수 있습니다.
하지만 위의 경우에는 x는 아직 값이 세팅되지 않았습니다. 이를 비동기의 입장으로 보자면, y는 지금값인 반면에 x는 나중값입니다. x 값을 세팅하기 위한 동작이 진행중이라면 이 세팅이 끝나고 x + y 해야지만 우리가 의도한 값이 출력됩니다.
function add(x, y) {
return Promise.all([x, y])
.then((values) => values[0] + values[1]);
}
add(fetchX(), fetchY())
.then((sum) => {
console.log(sum);
})
위의 코드는 일반적인 프라미스 사용법을 나타냅니다. Promise.all은 배열을 인자로 받으며 이 배열안에 있는 모든 프라미스들이 전부 실행 완료되어야만 then 이후의 로직을 실행합니다. 이 때, then 절의 인자로는 실행 완료한 반환 값들이 배열로 들어가게 됩니다.
add 함수는 프라미스를 반환하는 함수인데 만약 이 과정 중에 에러가 발생하는 어떻게 될까요?
add(fetchX(), fetchY())
.then(
// 이룸 함수 (resolve)
(sum) => {
console.log(sum);
},
// 버림 함수 (reject)
(err) => {
console.log(err);
}
)
이런 구조 덕에 프라미스 자체는 시간 독립적이고 내부 결괏값에 상관없이 예측 가능한 방향으로 구성할 수 있습니다. 또한, 프라미스는 일단 귀결(세팅)된 후에는 상태가 그대로 유지되며 몇 번이든 필요할 때마다 꺼내 쓸 수 있습니다.
프라미스를 규정하는 방법은 then()메서드를 가진, thenable이라는 객체 또는 함수를 정의하여 판별하는 것으로 규정되었습니다. thenable에 해당하는 값은 무조건 프라미스 규격에 맞다고 간주하는 것입니다.
이렇게 어떤 값을 타입을 그 형태를 보고 짐작하는 타입 체크를 일반적인 용어로 덕 타이핑이라고 합니다. '오리처럼 보이는 동물이 꽥꽥 소리를 낸다면 오리가 분명하다'는 것입니다.
콜백이 가지는 치명적인 단점이 제어의 역전으로 인한 믿음성의 결여라고 말했습니다. 프라미스 패턴은 우리에게 믿음을 선사해줍니다.
콜백만 사용한 코드의 믿음성 문제를 뒤짚어보겠습니다. 콜백을 넘긴 이후 일어날 수 있는 경우는 다음과 같습니다.
프라미스는 이러한 문제에 해결책을 제시합니다.
콜백을 이용한 비동기 작업에서 어떨 때는 의도한 대로, 어떨 때는 순서가 역전된 상태로 끝나 결국 경합 조건에 이르게 되는 현상을 종종 목격할 수 있습니다.
프라미스의 then()은 프라미스가 이미 귀결된 이후라 해도 항상 비동기적으로만 부릅니다. 그러므로, setTimeout같은 꼼수를 쓸 필요가 없습니다.
방금 전과 비슷한 경우입니다. 프라미스의 then()에 등록한 콜백은 새 프라미스가 생성되면서 resolve(), reject() 중 어느 한 쪽은 자동 호출하도록 스케줄링됩니다. 이렇게 스케줄링된 두 콜백은 다음 비동기 시점에 예상대로 실행될 것입니다.즉, 프라미스가 귀결되면 then()에 등록된 콜백들이 그 다음 비동기 기회가 찾아왔을 때 순서대로 실행되며 어느 한 콜백 내부에서 다른 콜백의 호출에 영향을 주거나 지연시킬 일은 있을 수 없습니다.
doSomething.then(() => {
p.then(() => {
console.log("C");
})
console.log("A");
})
doSomething.then(() => {
console.log("B");
})
// A B C
여기서 프라미스 작동 원리 덕분에 "C"가 끼어들어 "B"를 앞지를 가능성은 없습니다.
하지만, 이렇게 여러 프라미스에 걸친 콜백의 순서/스케줄링에 의존하는 것은 이해하기 쉽지 않고 애매한 경우가 있으므로 가능한 한 피하는 것이 좋습니다.
프라미스는 정의상 단 한 번만 귀결됩니다. 어떤 이유로 프라미스 생성 코드가 이룸 혹은 버림 콜백을 여러 차례 호출하려고 하면 프라미스는 오직 최초의 귀결만 취하고 이후의 시도는 조용히 무시합니다.
프라미스 귀결 값은 딱 하나뿐입니다.
명시적인 값으로 귀결되지 않으면 그 값으로 undefined가 세팅됩니다. 여기서 주의할 점은 resolve와 reject 함수를 부를 때 인자를 여러 개 넘겨도 두 번째 이후 인자는 그대로 무시합니다. 값을 여러 개 넘기고 싶다면 배열이나 객체로 꼭 감싸야 합니다.
어떤 이유로 프라미스를 버리면 그 값은 버림 콜백 (reject)으로 전달됩니다.
하지만 프라미스가 생성 중 또는 귀결을 기다리는 도중 언제라도 자바스크립트 (이하 JS) 에러가 나면 예외를 잡아 주어진 프라미스를 강제로 버립니다. 프라미스는 JS 에러조차도 비동기적으로 바꾸어 경합 조건을 상당히 줄입니다.
Promise.resolve()는 프라미스가 아닌 thenable 값을 주면 일단 그 값을 풀어보고 최종적으로 프라미스가 아닌 것 같은 구체적인 값이 나올 때까지 계속 풀어봅니다.
프라미스든 즉시값이든 thenable한 값이 아니든 Promise.resolve()에 건네면 이 값으로 이루어진 프라미스를 반환받게 되는 것입니다. 이것은 경합 조건 같은 부수 효과를 생각하지 않고 의도한 대로 로직이 비동기적으로 동작한다는 믿음을 줄 수 있습니다.
비동기적으로 동작하기 위해 프라미스에 내재된 두 가지 작동 방식은 다음과 같습니다.
var p = Promise.resolve(21);
p.then((v) => {
console.log(v); // 21
throw Error;
return v * 2; // 절대 실행 안됨
})
.then(
(v) => { // 실행 안됨
console.log(v); // 42
return v * 2;
},
(err) => {
console.log("error 발생!");
return "error!!";
}
)
.then((v) => {
console.log(v); // error!!
})
then으로 계속 연쇄하면서 인자로 들어오는 값은 이전의 프라미스의 이룸 함수에서 반환하는 값이 전달되는 것을 알 수 있습니다.
만약, 프라미스 연쇄의 어느 단계에서 문제가 발생하면 모두 잡아서 바로 그 지점부터 리셋하여 다시 연쇄를 정상 가동시킵니다.
1단계에서 에러가 나면 2단계의 버림 함수가 실행되고 3단계에서 받는 v 값은 2단계의 버림 함수에서 반환한 값이 됩니다.
프라미스의 then()을 부를 때 만약 이룸 처리기만 넘기거나 버림 처리기만 넘기면 정의되지 않은 처리기는 기본 처리기로 대체됩니다. 이룸 처리기의 기본 처리는 그대로 값을 return하는 것이고 버림 처리기의 기본 처리는 받은 error 객체를 그대로 throw하는 것입니다.
여기서, 왜 try ... catch 구문을 사용하지 않냐고 물어보는 개발자가 있을 수 있습니다.
하지만, 아쉽게도 try ... catch 구문은 동기적으로만 사용할 수 있으므로 비동기 코드 패턴에서는 무용지물입니다.
비동기 시퀀스는 주어진 시점에 단 한 개의 비동기 작업만 가능합니다. 하지만 2개 이상의 단계를 동시에 움직이는 방법이 있는데 그것이 바로 all API입니다.
var p1 = ajax("url1");
var p2 = ajax("url2");
Promise.all([p1, p2])
.then((msgs) => ajax(`url3?${msgs.join(',')}`))
.then((msg) => {
console.log(msg);
})
Promise.all은 프라미스 인스턴스들이 담긴 배열 하나를 인자로 받고 호출 결과로 반환된 프라미스는 이룸 메시지 (msg)을 수신합니다. 이 메시지는 배열에 나열한 순서대로 프라미스들을 통과하면서 얻어진 이룸 메시지의 배열입니다.
Promise.all이 반환한 메인 프라미스는 자신의 하위 프라미스들이 모두 이루어져야 이루어질 수 있습니다. 만약 하나의 프라미스라도 버려지면 전체 프라미스도 곧바로 버려지며 다른 프라미스 결과도 덩달아 무효가 됩니다.
프람리스마다 항상 버림/에러 처리기를 붙여넣도록 습관화하여야 합니다. 특히, Promise.all이 내어준 프라미스는 더더욱 잊으면 안됩니다.
Promise.all이 여러 프라미스를 동시에 편성하여 모두 이루어진다는 전제로 작동한다면, 결승선을 통과한 최초의 프라미스만 인정하고 나머지는 무시해야 할 때도 있습니다.
Promise.reace는 인자로 Promise.all과 마찬가지로 하나 이상의 프라미스, thenable, 즉시값 등이 포함된 배열 인자 1개를 받습니다. 여기서 가장 먼저 이루어지는 프라미스가 발생할 시 나머지 프라미스는 무시합니다. 그리고 하나라도 버려지는 프라미스가 있으면 전체 프라미스는 버려집니다.
시퀀스 에러 처리
프라미스는 설계 상 한계 탓에 프라미스 연쇄에서 에러가 나면 그냥 조용히 묻혀버리기 쉽습니다. 만약, 에러 처리기가 없는 프라미스 연쇄에서 에러가 나면 나중에 어딘가에서 감지될 때까지 연쇄를 따라 죽 하위를 전파됩니다.
그리고 만약
var p = foo(42)
.then(p2)
.then(p3);
위와 같은 프라미스 연쇄가 있을 시 p가 가리키는 대상은 p3 호출 후 반환된 마지막 프라미스입니다.
또한, 프라미스 연쇄는 각 단계에서 자신의 에러를 감지하여 처리할 방법 자체가 없으므로 p 자체에 에러 처리기를 달아놓으면 연쇄 어디에서 에러가 나도 이를 받아 처리할 수 있습니다.
일단 프라미스를 생성하여 이룸/버림 처리기를 등록하면, 도중에 작업 자체를 의미없게 만드는 일이 발생하더라도 외부에서 프라미스 진행을 멈출 방법은 없습니다.
콜백식 비동기 작업 연쇄와 프라미스 연쇄의 움직이는 코드 조각이 얼마나 되는지만 보면 아무래도 프라미스가 처리량이 더 많고 그래서 속도 역시 약간 더 느린 게 사실입니다. 하지만 같은 수준의 믿음성을 보장하기 위해 프라미스에서 간단히 몇 가지 장치만으로 해결했던 것과 콜백에서 임기 응변식 코드를 덕지덕지 발라야 했던 것을 생각하여야 합니다.
콜백식 시퀀스에 비해 프라미스는 약간 느릴 수 있지만 그 대신 믿음성, 예측성, 조합성과 같은 장점을 고루 누릴 수 있습니다.