Ch3. 프라미스
- 전 시간에는 콜백의 문제점에 대해서 살펴보았다. 이제 해결 방안을 궁리해보자
- 먼저 제어의 역전을 되역전시켜보자. 프로그램의 진행을 다른 파트에 넘겨주지 않고도 개발자가 언제 작업이 끝날지 알 수 있고 그다음에 무슨 일을 해야 할지 스스로 결정할 수 있는 체계가 바로 프라미스다.
3.1 프라미스란
3.1.1 미랫값
- 패스트 푸드점을 예로 들어서 설명한다.
- 주문을 하면 바로 치즈 버거가 나오는 게 아니라 주문 번호가 적힌 영수증을 준다. 이것이 하나의 약속이다.
- 기다리면서 다른 일을 할 수 있다. 아직 치즈 버거를 받지 못했지만 마치 있는 것 처럼 행동한다.
- 점원이 내 번호를 부르면 영수증을 보여주고 치즈 버거를 받는다.
- 만약 치즈 버거 재료가 다 떨어져서 못받는다면, 어처구니가 없지만 치즈 버거를 받는 것에 실패한 것이다.
- 결국 치즈 버거 세트를 주문한 결과는 치즈 버거를 받던가, 아니면 받지 못하던가 둘 중 하나다.
지금값과 나중값
- 숫자 계산 등 어떤 값을 내는 코드를 짤 때 우리는 그 값이 '지금' 존재하는 구체적인 값이라는 가정을 한다. (사실 가정이 아니라 자연스레 그렇게 인식한다.)
var x,
y = 2
console.log(x + y) // NaN. x는 아직 세팅 전
- 위 예제에서 + 연산자가 x,y 값을 감지하다가 모두 귀결된 후 덧셈 연산을 해주리라 기대하는 건 무리다.
- 만약 두 문 중 한쪽이 아직 실행 중이면 둘의 관계는 어떻게 받아들여야 할까. 1번 문이 끝나고 나서 2번 문이 실행되는 조건이면 1번 문이 '지금' 바로 끝나 순조롭게 모든것이 흘러가던지, 1번 문이 끝나지 않아 번이 실패하던지 둘 중 하나다.
- 230 페이지 코드 감상
- 요점만 정리하면, '지금'과 '나중'을 모두 일관적으로 다루려면 둘 다 '나중'으로 만들어 모든 작업을 비동기화하면 된다.
프라미스 값
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에 전달한다. 두 프라미스 속의 원래 값은 지금 또는 나중에 준비되겠지만 시점에 상관없이 각 프라미스가 같은 결과를 내게끔 정규화한다. 덕분에 미랫값 두개를 시간 독립적으로 추론할 수 있다.
- add()가 만들어 반환한 프라미스로, then을 호출하고 대기한다. add가 끝나면 덧셈을 마친 미랫값이 준비되어 콘솔에 출력되는데, X,Y의 미랫값을 기다리는 로직은 add 안에 있다.
- 치즈 버거 세트 주문처럼 프라미스는 이룸 아닌 버림으로 귀결될 수 있다. 항상 귀결 값을 프로그램이 결정짓는 fulfillment 프라미스와 다르게 reject는 로직에 따라 직접 세팅되거나 런타임 예외에 의해 암시적으로 생겨나기도 한다.
- 프라미스 then 함수는 fulfillment를 첫번째 인자로, rejection 함수르 두 번째 인자로 각각 넘겨받는다.
add(fetchX(), fetchY()).then(
// fulfill
function (sum) {
console.log(sum)
},
function (err) {
console.error(err)
}
)
- 프라미스는 시간 의존적인 상태를 외부로부터 캡슐화하기 때문에 프라미스 자체는 시간 독립적이고 그래서 타이밍 또는 내부 결괏값에 상관없이 예측 가능한 방향으로 구성할 수 있다. 또한, 프라미스는 일단 귀결된 후에는 상태가 그대로 유지되며 몇 번이든 필요할 때마다 꺼내 쓸 수 있다.
3.1.2 완료 이벤트
- 프라미스의 귀결은 비동기 작업의 여러 단계를 '흐름 제어' 하기 위한 체계라 볼 수 있다.
- 다음 단계로 진행할 수 있게끔 완료 상태를 알림 받을 방법이 필요해지는 데, 전통적인 자바스크립트 사고 방식에서는 알림 자체를 하나의 이벤트로 보고 리스닝 한다.
foo(x) {
// 시간이 좀 걸리는 일
}
foo(42);
on(foo, '완료') {
// 다음 단계로 넘어감
}
on(foo, '에러') {
// 뭔가 잘못되어 오류남!
}
- foo()를 호출한 뒤 2개의 이벤트 리스너를 설정한다. 실상 foo 호출부에서 이벤트를 받아 어떻게 처리할지 알 길이 없으니 관심사가 분리된다. 자바스크립트 코드로 표현하면 다음과 같다.
function foo(x) {
// 시간이 제법 걸리는 일
// 이벤트 구독기를 생성하여 반환한다.
return listener;
}
var evt = foo(42);
evt.on('completion', function() {
// 다음 단계로 갈 수 있다.
})
evt.on('failure', function(err) {
// foo에서 뭔가 잘못됐다!
})
- foo는 이벤트 구독기를 생성하여 반환하도록 되어있고 여기에 호출부 코드는 두 이벤트 처리기를 각각 등록한다. foo에 콜백 함수를 넘겨주는 대신 foo가 evt라는 이벤트 구독기를 반환하고 여기에 콜백 함수를 넣는다. 전에 콜백은 그 자신이 제어의 역전이라고 했는데, 결국 콜백 패턴을 뒤집는다는건 제어의 역전의 역전, 즉 우리가 바라던대로 제어권을 호출부에 되돌려놓게 되는 것이다.
- 이렇게 되면 여러 파트로 나뉘어진 코드가 이벤트를 리스닝하면서 foo 완료 시 독립적으로 알림을 받아 이후 단계를 진행하게 된다.
프라미스 이벤트
- 위에서 말한 이벤트 구독기는 프라미스와 유사하다.
function foo(x) {
// 시간이 좀 걸리는 일
// 프라미스 생성하여 반환
return new Promise(function(resolve, reject) {
// 결과적으로 resolve, reject 중 하나를 호출하게 된다.
})
}
var p = foo(42);
bar(p)
baz(p)
// 아래와 같이 할 수도 있다.
// 프라미스 p를 bar 함수의 실행 이후를 제어하기 위해 프라미스를 사용한다.
// 이 경우 성공시에만 bar를 호출하고 그 외엔 barError를 호출한다.
// 어쨌거나 foo가 반환한 프라미스 p로 다음에 벌어질 일을 제어할 수 있다.
function bar() {
// bar 작업 작성
}
function barError() {
// bar는 실행되지 않아 에러가 난다.
}
var p = foo(42);
p.then(bar, barError);
3.2 데어블 덕 타이핑
- 이게 진짜 프라미스인지 아닌지 확인할 수 있는 방법이 뭐가 있을까. 물론 Promise 객체를 통해 생성된 객체는 프라미스지만 외부 라이브러리나 프레임워크 중에는 고유한 방법으로 구현한 프라미스를 사용할 가능성도 있다.
- 결론은 진짜 프라미스는 then 메서드를 가진, '데너블'이라는 객체 또는 함수를 정의하여 판별하는 것으로 규정되었다. 데너블에 해당하는 값은 무조건 프라미스 규격에 맞다고 간주하는 것이다.
- 그럼 기존에 then이라는 메서드가 있는 그냥 단순 객체가 있을수도 있는데 그런것도 프라미스인가? 라는 질문이 있을수 있는데 자바스크립트 엔진이 그렇게 해석한다. 그래서 문 닫은 라이브러리들도 있다.
3.3 프라미스 믿음
- 아래는 전에 살펴봤던 콜백을 넘긴 이후 일어날 수 있는 일이다.
- 너무 일찍 콜백을 호출
- 너무 늦게 콜백을 호출 (또는 호출 안함)
- 너무 적게, 아니면 너무 많이 콜백을 호출
- 필요한 환경/인자를 정상적으로 콜백에 전달 못함
- 발생 가능한 에러/예외를 무시함
- 프라미스 특성은 위의 모든 일들에 대해 유용하고 되풀이하여 쓸 수 있는 해결책을 제시하게끔 설계되었다.
3.3.1 너무 빨리 호출
- 같은 작업인데 어떨때는 동기적으로 어떨때는 비동기적으로 끝나서 문제되는 코드인지 확인하는 문제다.
- 프라미스는 바로 이루어져도 프라미스 정의상 동기적으로 볼 수가 없다. 그래서 then을 호출하면 프라미스가 이미 귀결되었다고 해도 then에 건넨 콜백은 항상 비동기적으로만 부른다.
3.3.2 너무 늦게 호출
- 위에서 설명한 이유와 비슷하다. 프라미스 then에 등록한 콜백은 새 프라미스가 생성되면서 resolve, reject 중 어느 한쪽은 자동 호출하도록 스케줄링된다.
- 프라미스가 귀결되면 then에 등록된 콜백들이 그 다음 비동기 기회가 찾아왔을 때 순서대로 실행되며 어느 한 콜백 내부에서 다른 콜백의 호출에 영향을 주거나 지연시킬 일은 없다.
프라미스 스케줄링의 기벽
- 별개의 두 프라미스에서 연쇄된 콜백 사이이 상대적인 실행 순서는 장담할 수 없다.
var p3 = new Promise(function(resolve, reject) {
resolve('B')
});
var p1 = new Promise(function(resolve, reject) {
resolve(p3)
});
p2 = new Promise(function(resolve, reject) {
resolve('A')
});
p1.then(function(v) {
console.log(v)
});
p2.then(function(v) {
console.log(v)
});
// A B
- p1은 즉시값으로 귀결되지 않고 다른 프라미스 p3으로 귀결되고 p3은 다시 B로 귀결된다. 이때 p3은 p1로, 비동기적으로 풀리므로 p1 콜백은 p2 콜백보다 비동기 잡 큐에서 후순위로 밀리게 된다.
- 이런 문제가 싫다면처음부터 다중 콜백의 순서가 문제를 일으키지 않는 방향으로 코딩하는 편이 바람직하다.
3.3.3 한번도 콜백 호출 안함
- 우선 프라미스 스스로 귀결 사실을 알리지 못하게 막을 방도는 없다. fulfill, reject 콜백이 프라미스에 모두 등록된 상태라면 프라미스 귀결 시 둘 중 하나는 반드시 부른다.
- 물론 콜백 자체에 자바스크립트 에러가 나면 결과가 이상하게 나오겠지만 콜백은 호출된다.
- 하지만 프라미스 스스로 어느 쪽으로도 귀결되지 않으면? 이럴 상황일지라도 경합이라는 상위 수준의 추상화를 이용하면 프라미스로 해결할 수 있다.
function timeoutPromise(delay) {
return new Promise( function(res, rej) {
setTimeout(function() {
reject('타임아웃!')
}, delay)
})
};
Promise.race([
foo(),
timeoutPromise(3000)
]).then(
function() {
// foo가 제시간에 이루어짐
},
function(err) {
// foo가 제시간이 못마쳤거나 버려짐
// 'err'을 조사하여 원인 파악
}
)
3.3.4 너무 가끔, 너무 종종 호출
- 콜백의 호출 횟수는 당연히 한 번이다. 너무 가끔이라는 뜻은 결국 0번이라는 뜻이니 위와 같다.
- 너무 종종호출하는 경우는 간단하다. 프라미스는 정의상 단 한번만 귀결된다. 프라미스 생성 코드가 resolve, reject중 하나 또는 모두를 여러번 호출하려고 하면 프라미스는 오직 최초 귀결만 취하고 이후는 조용히 무시한다.
3.3.5 인자/환경 전달 실패
- 프라미스 귀결값은 딱 하나뿐이다. resolve, reject 함수를 부를때 인자를 여러 개 넘겨도 두번째 이후 인자는 그대로 무시한다.
3.3.6 에러/예외 삼키기
- 어떤 이유로 프라미스를 버리면 그 값은 버림 콜백에 전달된다. 또는 프라미스 생성, 또는 귀결 기다리는 도중 자바스크립트 에러가 나면 그 에러를 잡아 주어진 프라미스를 버린다.
var p = new Promise(function(resolve, reject) {
foo.bar();
resolve(42);
})
p.then(
function fulfilled() {
// 실행되지 않는다.
},
function rejected(err) {
// 'foo.bar()' 줄에서 에러가 나므로 'err'는 타입 에러일 것이다.
}
)
3.3.7 미더운 프라미스?
- 프라미스는 콜백을 완전히 없애기 위한 장치가 아니다. 단지 프라미스는 콜백을 넘겨주는 위치만 달리할 뿐이다. 근데 이런 프라미스에 대한 동작이 믿을만 한 것일까
- 의문의 해결책은 이미 프라미스에 구현되어 있다. 그 주인공은 Promise.resolve 함수다.
- 즉시값, 프라미스, 데너블이 아닌 값을 Promise.resolve에 건네면 이 값으로 이루어진 프라미스를 손에 넣게 된다.
- 데너블을 인자로 받는다면 데너블 아닌 값이 발견될 때까지 풀어봐서 믿을만한 진짜 순종 프라미스를 리턴한다.
연쇄 흐름
- 프라미스는 장난감 블록 같아서 여러 개를 길게 늘어놓으면 일련의 비동기 단계를 나타낼 수 있다. 비결은 프라미스에 내재된 다음 두 가지 작동 방식이다.
- 프라미스에 then을 부를때마다 생성하여 반환하는 새 프라미스를 계속 연쇄할 수 있다.
- then의 이룸 콜백 함수가 반환한 값은 어떤 값이든 자동으로 연쇄된 프라미스의 이룸으로 세팅된다.
- 책의 예제처럼 단계별로 어떤 값을 꼭 전달해야 할 필요는 없다. 반환 값이 명시적이지 않으면 암시적으로 undefined가 할당되며 프라미스가 서로 연쇄되는 방식은 변함이 없다. 따라서 프라미스의 귀결은 그 다음 단계로의 진행을 신호한다.
3.4.1 용어 정의: 귀결, 이룸, 버림
- 확실히 의미를 알아야 할 용어들을 살펴보자.
var p = new Promise(function(X, Y) {
// X는 이룸
// Y는 버림
})
- 콜백 2개를 넘기는데, 첫번째는 보통 프라미스가 이루어졌음을, 두번째는 항상 프라미스가 버려졌음을 표시하는 용도로 쓴다.
- 두번째 인자는 이해하기 쉽다. reject는 이름만 봐도 무슨 의미인지 안다.
- 그런데 첫번째 인자는 조금 애매하다. 다른 프라미스 책에는 resolve라고 씌여있는 경우가 많다. 하지만 프라미스 이룸 전용으로 이 인자를 사용할 의도였다면 resolve 보단 fulfill이라고 해야 더 정확하지 않을까.
var fulfilledPr = Promise.resolve(42);
var rejectedPr = Promise.reject('허걱');
- Promise.resolve는 주어진 값으로 귀결된 프라미스를 생성한다. 예제의 42는 평범한 값이므로 프라미스 fulfilledPr은 42란 값과 함께 이루어진다.
3.5 에러처리
- 프라미스 버림이 어떻게 합리적인 에러 처리를 할 수 있게 해주는지 상세한 부분을 살펴보자
- try catch는 동기적으로만 사용 가능하므로 비동기 코드 패턴에서는 무용지물이다.
function foo() {
setTimeout( function() {
baz.bar()
}, 1000)
}
try {
foo();
} catch (err) {
// 이거 실행 안됨
console.error(err)
}
- 위 코드에서 error가 걸리지 않고 그냥 bar 함수가 undefined라고 나온다.
- try catch를 비동기로 사용하려면 어떻게든 실행 환경의 추가 지원이 꼭 필요한데, 이 문제는 4장 제너레이터에서 다룬다.
function foo(cb) {
setTimeout( function() {
try {
var x = baz.bar();
cb(null, x);
} catch (err) {
cb(err)
}
}, 1000)
}
foo(function(err, val) {
if (err) {
console.error(err)
} else {
console.log(val)
}
})
- 위 코드에서 foo 함수에 전달한 콜백은 첫 번째 인자 err를 통해 에러 신호를 감지할 것이다. err가 있으면 에러가 난거고 없으면 문제가 없었다는 뜻이다.
- 하지만 이런 형태는 여러 개를 조합하면 문제가 심각해진다. 수준이 제각각인 에러 우선 콜백이 if 문이 여기저기 널린 상태로 생긴다면 결국 콜백 지옥이나 다름 없다.
var p = Promise.reject('허걱');
p.then(
function fullfilled() {
// 실행되지 않는다.
},
function rejected(err) {
console.log(err) // 허걱
}
)
- 위 코드는 정상적으로 에러를 잡지만 다른 경우를 보자
var p = Promise.resolve(42);
p.then(
function fulfilled(msg) {
console.log(msg.toLowerCase())
},
function rejected(err) {
// 실행되지 않는다.
}
)
- msg에서 문법 오류가 발생했는데 왜 검지되지 않았을까. 이유는 이 에러 처리기의 소속은 프라미스 p고 이미 p는 이루어진 상태라서 그렇다. p는 불변값이기 때문에 에러 알림은 오직 p.then만이 반환한 프라미스만이 가능한데 여기서는 이 프라미스를 포착할 방법이 없다.
- 프라미스 API를 잘못 사용해서 프라미스 생성 과정에서 에러가 나면 그 결과는 바로 예외를 던진다.
3.4.1 절망의 구덩이
프로그래밍 언어는 대부분 개발자가 사고를 치면 '절망의 구덩이에 빠져 혹독한 대가를 치르는 방향으로 설계되어 있어서 제대로 실행하려면 정신을 바짝 차리고 열심히 코딩하여야 한다'
- 제프 앳우드
오히려 프로그램이 예상대로 처리되도록 '성공의 구덩이'를 기본적으로 파놓고 프로그램 실행을 실패하도록 만들어야 한다는 말이다.
프라미스 에러 처리는 분명히 '절망의 구덩이' 방식으로 설계되어 있다. 그래서 기본적으로 에러가 나도 프라미스 상태에 따라 무시할 수 있다고 보기 때문에 개발자가 깜빡 잊고 상태 감지를 하지 않으면 조용히 에러는 생을 마감한다.
사라진/ 버려진 프라미스의 에러를 잡으로면 반드시 프라미스 연쇄 끝부분에 catch를 써야 한다고 주장하는 개발자들이 있다.
var p = Promise.resolve(42);
p.then(
function fulfilled(msg) {
console.log(msg.toLowerCase())
},
function rejected(err) {
// 실행되지 않는다.
}
).catch(handleErrors)
- 하지만 handleErrors에서 에러가 난다면? 이건 누가 잡을까. 그리고 또 catch가 반환한 프라미스는 누가 잡을까?
3.6 프라미스 패턴
3.6.1 Promise.all([])
- 비동기 시퀀스는 주어진 시점에 단 한 개의 비동기 작업만 가능하다. 2개 이상의 단계가 동시에 움직이려면 어떻게 해야 할까?
- 복수의 병렬/동시 작업이 끝날 때까지 진행하지 않고 대기하는 관문이라는 장치가 있다. 이런 패턴을 구현한 것이 all 메서드이다.
var p1 = request('http://some.url.1');
var p2 = request('http://some.url.2');
Promise.all([p1,p2])
.then(function(msgs) {
// p1, p2가 둘 다 이루어져
// 여기에 메시지가 전달된다.
return request(
'http://some.url.3/?v='+msgs.join(',')
);
})
.then(function(msg) {
console.log(msg)
})
- Promise.all([])은 보통 프라미스 인스턴스들이 담긴 배열 하나를 인자로 받고 호출 결과 반환된 프라미스는 이룸 메시지를 수신한다. 이 메시지는 배열에 나열한 순서대로 프라미스들을 통과하면서 얻어진 이룸 메시지의 배열이다.
- all에 전달하는 배열은 프라미스, 데너블, 즉시값 모두 가능하다. 배열값들은 하나씩 Promise.resolve를 통과하면서 진짜 프라미스임을 보장하고 즉시 값은 해당 값을 지닌 프라미스로 정규화된다.
- all이 반환한 메인 프라미스는 자신의 하위 프라미스들이 모두 이루어져야 이루어질 수 있다. 단 한 개의 프라미스라도 버려지면 Promise.all 프라미스 역시 곧바로 버려지며 다른 프라미스 결과도 덩달아 무효가 된다.
3.6.2 Promise.race([])
- 결승선을 통과한 최초의 프라미스만 인정하는 패턴
- 걸쇠 패턴을 이용한 프라미스로, 배열을 인자로 받는다. all가 마찬가지로 프라미스, 데너블, 즉시값이 포함된 배열을 받을 수 있지만 즉시값은 이미 정해진 값이니 사실 인자로 넣는게 아무 의미가 없다.
- 하나라도 이루어진 프라미스가 있을 경우에 이루어지고 하나라도 버려지는 프라미스가 있어지면 버려진다.
var p1 = request('http://some.url.1');
var p2 = request('http://some.url.2');
Promise.race([p1,p2])
.then(function(msg) {
// p1, p2중 하나는 경합의 승자
return request(
'http://some.url.3/?v='+msg
);
})
.then(function(msg) {
console.log(msg)
})
- 프라미스 하나만 승자가 되기에 이룸값은 배열이 아닌 단일 메시지다.
3.6.3 all([])/race([])의 변형
- all과 race를 변형한 패턴 중에 자주 쓰이는 것들이 있다.
- none - all과 비슷하지만 이룸/버림이 정반대다. 모든 프라미스는 버려져야 하며, 버림이 이룸값이 되고 이룸이 버림값이 된다.
- any - all과 유사하나 버림은 모두 무시하며, 하나만 이루어지면 된다.
- first - any의 경합과 비슷하다. 최초로 프라미스가 이루어지고 난 이후엔 다른 이룸/버림은 간단히 무시한다.
- last - first와 거의 같고 최후의 이룸 프라미스 하나만 승자가 된다는 것만 다르다.
3.6.4 동시 순회
- 프라미스 리스트를 죽 순회하면서 각각에 대해 어떤 처리를 하고 싶은 경우가 있다. 처리 작업이 비동기적이거나 동시 실행될 수 있다면 많은 라이브러리에서 제공하는 비동기 버전의 유틸리티를 쓰면 된다.
- 코드는 분석을 못했습니다. 죄송합니다.
'JavaScript' 카테고리의 다른 글
popstate 이벤트와 Cancelable (0) | 2021.03.18 |
---|---|
자바스크립트 더 나은 개발자가 되기 위한 세가지 (0) | 2020.11.14 |
[You don`t Know JS 정리] ch.2 콜백 (0) | 2020.08.21 |
[You don`t Know JS 정리] ch.1 비동기성 (0) | 2020.08.20 |
[You don`t Know JS 정리] ch.6 작동 위임 (0) | 2020.08.18 |