- 이 프로젝트에서는 Javascript
Promise
동작 방식을 이해하기 위해, 이와 동일한 기능을 수행하는 커스텀 클래스MyPromise
를 직접 구현해 보았다.
- 핵심만 알고자하면 세부 구현은 건너 뛰고 2. Promise 내부 동작 방식 🏗️만 읽어도 충분하다.
- 만약 README.md 파일 형식으로 읽기 힘들다면, Promise 직접 만들어서 이해하자 해당 링크에서 더 편하게 해당 프로젝트 README를 읽을 수 있다.
- 비동기 처리에는 무조건
async/await
패턴만 사용하다가, Javascript의 대표적 비동기 처리 방식인 (1) 콜백 패턴 (2) Promise 패턴 (3) async/await 패턴각각의 장단점을 알고 써야하지 않을까
하는 생각이 불현듯 들었다. (이 강을 건너지 말았어야 했다…) - 시중의 자바스크립트 자습서, 유명 블로그 포스트를 뒤져가며 각 패턴의 장단점을 텍스트 형태로 학습했지만, 코드로 옮기질 않으니 도통 와닿지 않는다.
직접 Promise를 구현해보면, 콜백 패턴의 한계를 Promise가 어떻게 해결하려 했는지 이해할 수 있지 않을까?
라는 단촐한 생각에서 출발한 프로젝트를 소개한다.
- 하단은
Promise
를 직접 작성한custom Promise
인MyPromise
세부 코드에 대한 설명이다. 이해가 되지 않는 부분이 있다면 펼쳐 세부 구현 확인해보자.
- custom Promise인 MyPromise를 구현하기 전에 우선 Javascript에서 Promise가 어떻게 구현되어 있는지 살펴보자. - Javascript에서는 `new` 키워드와 함께 `Promise 생성자 함수`를 호출하여 `Promise 객체`를 생성할 수 있다. ```javascript const promise = new Promise((resolve, reject) => { const value = 'value' if( /* 비동기 처리 성공 시 */) { resolve(value) } else { /* 비동기 처리 실패 시 */ reject(value) } }) ```
- 이렇게 생성된 promise 객체는
[[PromiseState]]
와[[PromiseResult]]
상태값을 가진다. - 이 외에도 객체에는 비동기 후속처리를 위한 메서드인
then
,catch
,finally
가 포함된다.
class MyPromise {
#state = STATE.PENDING
#result
#thenCbs = []
#catchCbs = []
#finallyCbs = []
constructor(cb) {
try {
cb(this.#onFulfilled, this.#onRejected)
} catch (e) {
this.#onRejected(e)
}
#onFulfilled(result) {...} // promise의 resolve 함수
#onRejected(result) {...} // promise의 reject 함수
}
```
- Javascript에서는 `then`, `catch`, `finally` 함수를 통해 비동기 후속처리와 관련된 콜백 함수들을 `Promise` 객체에 등록할 수 있다. - `then`은 비동기 처리가 성공했을 때 호출 할 성공 처리 콜백 함수인 `onFulfilled`와 실패 시 호출할 실패 처리 콜백함수인 `onRejected`를 인자로 받는다. - `catch`는 비동기 처리 실패 콜백 함수만을 인자로 받는다. 콜백 함수의 인자로 실패 원인에 대한 값을 받는다. - `finally`는 비동기 성공, 처리 실패 여부와 관계 없이 실행할 콜백 함수를 인자로 받는다. 콜백 함수에서는 별도의 인자를 받지 않는다.
```javascript
then(onFulfilled)
then(onFulfilled, onRejected)
then(
(result) => { /* fulfillment handler */ },
(reason) => { /* rejection handler */ },
)
catch(onRejected)
catch((reason) => {
// rejection handler
})
finally(onFinally)
finally(() => {
// Code that will run after promise is settled (fulfilled or rejected)
})
```
```javascript
class MyPromise {
...
#thenCbs = []
#catchCbs = []
then(thenCb, catchCb) {
if (thenCb != undefined) this.#thenCbs.push(thenCb);
if (catchCb != undefined) this.#catchCbs.push(catchCb);
}
catch(cb) {
this.then(undefined, cb)
}
finally(cb) {
this.then(
(result) => {
cb()
return result
},
(result) => {
cb()
throw result
}
)
}
}
```
-
Javascript에서는
Promise
성공시resolve
, 실패시reject
함수를 호출한다.```javascript const promise = new Promise((resolve, reject) => { const value = 'value' if( /* 비동기 처리 성공 시 */) { resolve(value) } else { /* 비동기 처리 실패 시 */ reject(value) } }) ```
-
resolve
함수를 호출하면,Promise
상태가fulfilled
로 변경된다. 이후,then(onFulfilled)
메서드에 의해 등록된onFulfilled
콜백 함수들이 등록 순서대로 수행된다. 이 때,Promise
결과값이onFulfilled(result)
인자로 사용된다. -
resolve
함수를 호출하면,Promise
상태가rejected
로 변경된다. 이후,then(onFulfilled, onRejected)
메서드에 의해 등록된onRejected
콜백 함수들이 등록 순서대로 수행된다. 이 때,Promise
결과값이onRejected(result)
인자로 사용된다.
```javascript
class MyPromise {
...
#thenCbs = []
#catchCbs = []
#runCallbacks() {
if (this.#state === STATE.FULFILLED) {
this.#thenCbs.forEach((callback) => {
callback(this.#result)
})
this.#thenCbs = [] // 여러 then 내 thenCbs 재호출 방지
}
if (this.#state === STATE.REJECTED) {
this.#catchCbs.forEach((callback) => {
callback(this.#result)
})
this.#catchCbs = [] // 여러 then 내 catchCbs 재호출 방지
}
}
#onFulfilled(result) {
if (this.#state !== STATE.PENDING) return // 동일 then 내 resolve 재호출 방지
this.#result = result
this.#state = STATE.FULFILLED
this.#runCallbacks()
}
#onRejected(result) {
if (this.#state !== STATE.PENDING) return // 동일 then 내 reject 재호출 방지
this.#result = result
this.#state = STATE.REJECTED
this.#runCallbacks()
}
}
```
- Javascript에서
promise
후속처리 메서드인then
,catch
,finally
는 언제나 새로운promise
를 생성해 반환해준다. 이처럼 후속처리 메서드가 항상promise
를 반환된다는 약속을 지키기 때문에, 개발자는promise
후속 처리 메서드들을 체이닝해 사용할 수 있다.- 후속 처리 메서드의 콜백함수가 (1)
promise
를 반환하는 경우, 해당promise
를 그대로 반환한다. - 반면, (2) 콜백함수가 promise가 아닌 값을 반환하면, 해당값을
resolve
또는reject
함수로 감싸주면promise
형태로 반환된다.
- 후속 처리 메서드의 콜백함수가 (1)
-
앞서 만든 MyPromise의
then
,catch
,finally
메서드가 항상 promise를 반환하도록 변경해주자. -
우선, 메서드를 변경하기 전에
MyPromise
의onFulfilled
와onRejected
를 생성자 함수인MyPromise
의this
에 바인딩해주어야 한다.class MyPromise { #thenCbs = [] #catchCbs = [] #state = STATE.PENDING #result // promise chaining을 위해 this 바인딩 수행 #onFulfilledBind = this.#onFulfilled.bind(this) #onRejectedBind = this.#onRejected.bind(this) constructor(cb) { try { cb(this.#onFulfilledBind, this.#onRejectedBind) } catch (e) { this.#onRejected(e) } } }
-
then
메서드에서MyPromise 생성자 함수
를 호출하고 그 결과 생성된MyPromise 인스턴스
를 반환하도록 수정해보자.thenCbs
와catchCbs
배열에 콜백 함수를 추가하는 코드도resolve
,reject
함수로 처리 결과를 wrapping 해준다.- 이 때,
then
메서드에서 인자를 하나만 받는 경우를 대비하여,undefined
에 따른 분기 처리를 해주어야 에러가 발생하지 않는다.
class MyPromise { ... then(thenCb, catchCb) { return new MyPromise((resolve, reject) => { this.#thenCbs.push((result) => { if (thenCb == undefined) { // then(undefined, catchCb) 처리 resolve(result) return } try { resolve(thenCb(result)) } catch (e) { reject(e) // then 내부에서 에러가 있으면 바로 rejected 상태로 변경됨. } }) this.#catchCbs.push((result) => { if (catchCb == undefined) { // then(thenCb) 처리 reject(result) return } try { resolve(catchCb(result)) } catch (e) { reject(e) } }) }) } } }
-
catch
메서드와finally
메서드는 return 문만 추가하여promise
를 리턴하도록 해주면 된다.class MyPromise { ... catch(cb) { return this.then(undefined, cb) } finally(cb) { return this.then( (result) => { cb() return result }, (result) => { cb() throw result } ) } }
-
이와 같은 형태로
MyPromise
의 비동기 후속 처리 메서드인then
,catch
,finally
가 모두MyPromise
를 리턴하게 해주면Promise
체이닝 테스트 코드를 모두 통과할 수 있다.
-
Javascript에서 비동기 처리를 위해
Promise
내부에 등록된 콜백함수들은microtask queue
에 들어가 차례를 기다린다. -
이후 Javascript
Event loop
에 의해 콜스택이 비어있는 경우,microtask queue
에 대기중인 콜백 함수들이call stack
으로 이동되어 실행된다.microtask queue
의 우선순위는event queue(= callback queue, task queue)
의 우선순위보다 높다.
-
지금까지는 비동기 처리에 대한 고려 없이
MyPromise
코드를 작성하였다. -
비동기 처리 코드가 성공하여 Javascript의
Promise
에서resolve
,reject
를 호출하였을 때,microtask queue
에 콜백 함수들이 등록되는 과정을 mocking 해보자. -
콜백 함수들을 실제 실행하는
runCallbacks
함수 내부 코드를queueMicrotask()
함수로 감싸주면 간단하게microtask queue
에 콜백 함수들을 등록해줄 수 있다.#runCallbacks() { queueMicrotask(() => { if (this.#state === STATE.FULFILLED) { this.#thenCbs.forEach((callback) => { callback(this.#result) }) this.#thenCbs = [] // 여러 then 내 thenCbs 재호출 방지 } if (this.#state === STATE.REJECTED) { this.#catchCbs.forEach((callback) => { callback(this.#result) }) this.#catchCbs = [] // 여러 then 내 catchCbs 재호출 방지 } if (this.#state !== STATE.PENDING) { this.#finallyCbs.forEach((callback) => { callback() }) this.#finallyCbs = [] } }) }
-
Javascript
Promise
을 콜백 패턴의syntactic sugar + alpha
라고 볼 수 있다. 기존 콜백 패턴에서 지원하지 않던 비동기 코드 후속 처리에 사용할 수 있는 편리한static methods
를 지원해주기 때문이다.static method 기능 Promise.resolve(value) {state: fulfilled, result: value} 형태의 Promise 객체 반환 Promise.reject(value) {state: rejected, result: value} 형태의 Promise 객체 반환 Promise.all(Iterable) Promise를 요소로 갖는 배열을 인자로 받음. (1) Promise 배열 내의 Promise가 모두 fulfilled되거나 (2) 그 중 하나라도 rejected 된 경우 함수를 종료하고, 처리 결과를 배열에 담아 반환 Promise.allSettled(Iterable) Promise를 요소로 갖는 배열을 인자로 받음. Promise 배열 내의 Promise가 모두 settled 되면 함수를 종료하고, 처리 결과를 배열에 담아 반환. Promise.race(Iterable) Promise를 요소로 갖는 배열을 인자로 받음. Promise 배열 내의 Promise 중 하나라고 settled 되면 함수 종료함. 가장 먼저 settled가 된 Promise만 반환. Promise.any(Iterable) Promise를 요소로 갖는 배열을 인자로 받음. (1) Promise 배열 내의 Promise가 모두 rejected 되거나 (2) 그 중 하나라도 fulfilled 된 경우 함수를 종료하고, 처리 결과를 배열에 담아 반환
MyPromise
에도static methods
를 추가해보자.-
resolve
,reject
는Promise
를 차용해 간략히 구현하자.class MyPromise { ... static resolve(result) { return new Promise((resolve) => { resolve(result) }) } static reject(result) { return new Promise((resolve, reject) => { reject(result) }) } }
-
all
메서드에서는 각Promise
가fulfilled
될 때마다completedPromises
의 수와 비교하여,promises
배열이 모두 수행되었는지 확인해준다. 만약, 하나라도reject
된 경우, 바로 종료할 수 있도록,catch
메서드에reject
를 등록해준다.class MyPromise { ... static all(promises) { const results = [] let completedPromises = 0 return new MyPromise((resolve, reject) => { for (let i = 0; i < promises.length; i++) { const promise = promises[i] promise .then((result) => { completedPromises++ results[i] = result // 모든 promise 결과값이 나오면 수행 if (completedPromises === promises.length) { resolve(results) } }) .catch(reject) } }) } }
-
allSettled
메서드에서는 처리 결과 배열의 내부Promise
가onFulfilled
의 경우, {status, result},onRejected
된 경우 {status, reason} 형태여야함에 유의해 코드를 작성한다.class MyPromise { ... static allSettled(promises) { const results = [] let completedPromises = 0 return new MyPromise((resolve, reject) => { for (let i = 0; i < promises.length; i++) { const promise = promises[i] promise .then((result) => { results[i] = { status: STATE.FULFILLED, result } }) .catch((reason) => { results[i] = { status: STATE.REJECTED, reason } }) .finally(() => { completedPromises++ if (completedPromises === promises.length) { resolve(results) } }) } }) } }
-
race
메서드는 가장 먼저 처리된Promise
만 반환됨에 유의해 코드를 작성한다.class MyPromise { ... static race(promises) { return new MyPromise((resolve, reject) => { promises.forEach((promise) => { promise.then(resolve).catch(reject) }) }) } }
-
any
메서드는all
메서드와 반대로 동작한다.class MyPromise { ... static any(promises) { const errors = [] let rejectedPromises = 0 return new MyPromise((resolve, reject) => { for (let i = 0; i < promises.length; i++) { const promise = promises[i] promise.then(resolve).catch((result) => { rejectedPromises++ errors[i] = result // 모든 promise 결과값이 나오면 수행 if (rejectedPromises === promises.length) { reject(new AggregateError(errors, "ALl promises were rejected")) } }) } }) } }
-
- Promise를 직접 코드로 작성해보니, 이제야 Promise가 무엇인지 와닿는다. 코드를 치며 이해하게된 Promise의 내부 동작 방식을 간단히 요약해보았다.
💡 Promise = 비동기 처리를 위해 state, result, 여러 callback 배열을 관리하는 객체
Promise 후속 처리 메서드 then, catch, finally를 통해 등록한 콜백함수는 Promise 내부에 배열 형태로 저장되고, Promise state가 변경되면 해당 상태에 따라 선택적으로 forEach문을 통해 callback 배열을 수행한다. 각 콜백의 인자로 result가 들어간다. 이 때, 콜백 함수들은 microtask queue에 등록된다.
Promise 코드를 보면, 후속 처리 메서드는 모두 Promise를 반환한다. 덕분에 개발자는 Promise 메서드 체이닝을 수행할 수 있다.
Promise는 비동기 처리를 쉽게 할 수 있도록 Promise 동시 처리가 가능한 all, allSettled, race, any와 같은 static methods를 지원한다.
- 이제 Promise 내부 동작 방식에 대해 파악했으니, ES6에서 Promise를 도입함으로써 해결하고자 했던 문제가 무엇인지에 대해 짚어보자.
- Javascript는
single thread
언어다. 즉, 단 하나의call stack
을 가지고 동작한다. 비동기 처리 방식은 필연적으로 다중 쓰레드가 필요하기 때문에, Javascript 환경에서는 코드의 동기적 실행만 가능하다. - 하지만 모든 코드를 동기적으로 수행할 경우,
blocking
으로 인한 성능 저하가 발생할 수 밖에 없다. 서버에서 응답을 받아오는 등 시간이 오래 걸리는 선행 task가 끝나야 이후 task를 수행할 수 있기 때문이다. - 이를 해결하기 위해 Javascript는 웹 브라우저나 Node.js 실행 환경의
Web API
와Event loop
의 힘을 빌려 비동기 처리를 수행한다. - 하지만 비동기 방식을 도입함에 따라
non-blocking
으로 인해 실행 순서가 보장되지 않는다는 태생적인 문제가 발생했다. - 이러한 상황 속에서 Javascript는 비동기에서 함수의 실행 순서를 보장하기 위해 여러 장치를 도입하였으며, 그 중 대표적인 패턴이 (1) callback (2) Promise (3) async/await 이다.
-
콜백 패턴은 Javascript에서 비동기 코드의 실행 순서를 보장하기 위해 사용하는 전통적인 장치이다.
- 비동기 처리 함수 내에서 비동기 결과가 나온 이후 콜백 함수를 호출해 비동기 후속 처리를 수행한다.
-
콜백 패턴은 비동기 처리 코드가 중첩될 경우, 콜백 헬이 발생해 가독성이 저해되는 문제를 안고 있다.
// 비동기 함수 const get = (url, callback) => { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.send() xhr.onload = () => { if (xhr.status === 200) { callback(JSON.parse(xhr.response)) } else { console.error(`${xhr.status} ${xhr.statusText}`) } } } // callback hell get('/step1', a => { get(`/step2/${a}`, b => { get(`/step3/${b}`, c => { get(`/ step4 / ${c}`, d => { console.log(d); }) }) }) })
-
Javascript에서 에러는 호출자 방향으로 전달된다.
- 동기적 작업에서는 에러가 발생하면, 이를 처리할
try… catch…
절을 만날때까지call stack
을 거슬러 올라가서(bubbling up the call stack) 예외가 처리된다. - 반면, 비동기 작업에서는 호출자가
call stack
에 존재하지 않기 때문에try… catch…
를 통해 호출자에게 예외를 전달 할 수 없다는 태생적 한계가 있다.
try { setTimeout(() => { throw new Error("Error!") }, 5000) } catch (e) { console.error(e) // error catch 불가 }
- 동기적 작업에서는 에러가 발생하면, 이를 처리할
-
앞서 살펴본 콜백 패턴의 (1) 가독성 저하 (2) 에러 처리 이슈를 해결하기 위해 ES6에 도입된 비동기 처리 장치가 바로
Promise
이다. -
우선,
Promise
는 중첩된 콜백을 선형에 가까운 프라미스 체인으로 바꾸어 가독성을 향상 시켜준다.- 직접 구현한
MyPromise
코드에서 살펴볼 수 있듯, ✅ Promise 후속처리 메서드는 항상 Promise를 반환하기 때문에 Promise 메서드 체이닝이 가능하다. - 즉,
then
,catch
,finally
메서드를 통해 콜백 함수를 연이어 등록할 수 있기 때문에 비동기 중첩으로 인한 콜백헬이 발생하지 않는다.
myPromise() .then((message) => { console.log("Success case1: ", message) }) .then((message) => { console.log("Success case2: ", message) }) .catch((error) => { console.log(error.name, error.message) }) .finally(() => { consoel.log('End') })
- 직접 구현한
-
Promise
는 비동기 작업의 태생적 한계인 에러 처리의 어려움을catch
메서드를 통해 해결한다.Promise
기반 비동기 작업은 예외를then(thenCb, catchCb)
의catchCb
에 전달한다.Promise
체이닝에서 발생한 에러는catch()
를 만날 때까지 체인을 따라 내려간다(trickling down the chain).- 이 때,
then()
메서드 내부에서 동기적throw
문으로 발생된Error
객체까지도catch()
메서드에 의해 처리할 수 있다.
- 이 때,
-
[더 나아가기] Q. Promise then(onSuccess, onFailure)와 then(onSuccess).catch(onFailure)는 무엇이 다를까?
promise.then(f, f) vs promise.then(f).catch(f) 는 무엇이 다를까?
catch(onFailure)
를 사용하는 경우,then
메서드 내부에서 발생한reject
에 대한 예외 처리가 가능하다. 즉,- 따라서, 내가 잠재적으로 처리하고 싶은 명확한 failure가 있다면,
promise.then(oSuccess, onFailure)
를 쓰는 것이 좋다. - 반면
promise.catch(onFailure)
는 개발자가 예측하지 못한 경우를 포함한 모든 에러를 처리할 수 있다.
- 정리하면, Promise는 Javascript 비동기 작업의 실행 순서를 보장하기 위해 전통적으로 사용하던 callback 패턴의 가독성과 에러 처리 부분을 개선한 비동기 처리 패턴이다.
- 더 나아가 ✅ Promise는 이 외에도 다양한 static method들을 제공하여 간단히 비동기 후속 처리를 할 수 있도록 도와준다. 이러한 맥락에서 Promise = callback 패턴의 syntatic sugar + alpha 라고 정리해 볼 수 있다.
- 하지만 인간의 욕심은 끝이 없다… 개발자들은 비동기 처리 패턴이 마치 동기 코드 수준의 가독성을 가지길 원했고, 이러한 요구 하에
async/await
패턴이 등장하게 된다.async/await
패턴은Generator
를 통해 구현되어 있어try… catch…
에 의한 비동기 에러 처리도 가능하다.