Case 1: 비동기와 then, catch
같은 시간의 타이머를 가진 Promise 객체 A가 있다고 가정
const a = new Promise((resolve, reject)= {
setTimeout(() = {
console.log('Promise A resolved!')
resolve()
},300)
setTimeout(() = {
console.log('Promise A rejected!')
reject()
}, 300)
})
아래와 같이 then
과 catch
를 chaining을 할 때 결과는?
a.then(() = {
console.log('Yes, it\\'s resolved')
}).catch(() = {
console.log('No, it\\'s rejected')
})
Promise A resolved!
와Promise A rejected!
가 순서대로 출력되고, then과 catch도 순서대로 실행된다.Promise A resolved! Promise A rejected! Yes, it's resolved No, it's rejected
Promise A resolved!
가 출력되고 then만 실행된다.Promise A resolved! Yes, it's resolved
Promise A resolved!
가 출력되고, then이 실행된다. 이후Promise A rejected!
가 출력되지만 catch는 실행되지 않는다.Promise A resolved! Yes, it's resolved Promise A rejected!
정답은 3번
해설
- setTimeout은 대표적인 비동기 함수
- 비동기 프로세스와 이벤트 루프에 의해 setTimeout이 처리되는 순서대로 호출된다.
- 위 예시에서는 Promise A를 resolve 처리한 함수가 먼저 호출되었다.
- Promise A가 resolved 됨에 따라 then이 호출된다.
- 이후에 Promise A를 reject하는 함수가 호출되었으나, Promise A는 이미 상태가
pending
상태에서fullfilled
상태가 되었으므로 Promise의 상태가 변경되지 않는다. → 따라서 catch 함수는 실행되지 않는다.

→ 만약 rejected되는 함수의 타이머 시간을 줄이면 어떻게 될까?
const a = new Promise((resolve, reject)= {
setTimeout(() = {
console.log('Promise A resolved!')
resolve()
},300)
setTimeout(() = {
console.log('Promise A rejected!')
reject()
}, 100)
})
a.then(() = {
console.log('Yes, it\\'s resolved')
}).catch(() = {
console.log('No, it\\'s rejected')
})
결과는 아래와 같다.
Promise A rejected!
No, it's rejected
Promise A resolved!
만약 then 함수 안에서 a를 console.log로 찍어보면?
const a = new Promise((resolve, reject)= {
setTimeout(() = {
console.log('Promise A resolved!')
resolve()
},300)
setTimeout(() = {
console.log('Promise A rejected!')
reject()
}, 300)
})
a.then(() = {
console.log('Yes, it\\'s resolved')
console.log('printed a: ', a) // a 출력
}).catch(() = {
console.log('No, it\\'s rejected')
})
fullfilled된 상태의 a가 출력된다.

resolved된 Promise A 객체를 reject()
호출 후에 확인해보면?
const a = new Promise((resolve, reject)= {
setTimeout(() = {
console.log('Promise A resolved!')
resolve()
},300)
setTimeout(() = {
console.log('Promise A rejected!')
reject()
console.log('printed a: ', a) // a 출력
}, 300)
})
a.then(() = {
console.log('Yes, it\\'s resolved')
}).catch(() = {
console.log('No, it\\'s rejected')
})
역시나 fullfilled
상태의 Promise가 출력된다. 즉 이미 상태가 결정된 Promise는 다른 상태로 변경되지 않는다.

Case2: then chain의 error catch 도달 범위
실습 환경: javascript playground
https://playcode.io/javascript
아래와 같은 Promise 객체가 있고, 여러 개의 then
chain이 있다고 가정
const promiseA = new Promise((resolve, reject) = {
setTimeout(() = {
console.log('Promise resolving . . .')
resolve('123')
}, 300)})
promiseA
.then((data) = {
console.log('resolved Data: ', data)
throw new Error('The first Error')
})
.then(() = {
throw new Error('The second Error!')
.catch(e = console.error(e))
})
.then(() = {
throw new Error('The third Error!')
})
그리고 두번째 then chain 부분에 catch chain을 추가
const promiseA = new Promise((resolve, reject) = {
setTimeout(() = {
console.log('Promise resolving . . .')
resolve('123')
}, 300)
})
promiseA
.then((data) = {
console.log('resolved Data: ', data)
throw new Error('The first Error')
})
.then(() = {
throw new Error('The second Error!')
})
.catch(e = console.error(e)) // catch 문 추가!
.then(() = {
throw new Error('The third Error!')
})
과연 어떤 에러 문구가 출력이 될까?
정답
The first Error
문구가 먼저 출력된다.

Promise resolving . . .
resolved Data: 123
Error: The first Error
그리고 다시 catch 문을 추가해보자. 기존 catch문 바로 아래에 추가
const promiseA = new Promise((resolve, reject) = {
setTimeout(() = {
console.log('Promise resolving . . .')
resolve('123')
}, 300)
})
promiseA
.then((data) = {
console.log('resolved Data: ', data)
throw new Error('The first Error')
})
.then(() = {
throw new Error('The second Error!')
})
.catch(e = console.error(e))
.catch(e = console.error(e)) // 두번째 catch 블록
.then(() = {
throw new Error('The third Error!')
})
이번에는 두번째 catch 블록에서 The second Error!
가 출력이 될까?
→ 안된다.

원인) 이미 상태가 결정된 Promise는 다른 상태로 전이되지 않는다는 특성 때문
따라서 첫번재 then에서 발생한 에러에 의해 then이 반환한 Promise는 rejected 상태가 되고, 체이닝에서 가장 첫번째 catch 블록으로 이동하여 콜백이 실행된다. 해당 catch 블록에서는 더이상 다른 Promise를 반환하지 않으므로 두번째 .then으로 Promise가 실행되지 않는다.
그렇다면 catch 블록 안에서 또 에러를 발생시켜보면?
promiseA
.then((data) = {
console.log('resolved Data: ', data)
throw new Error('The first Error')
})
.then(() = {
throw new Error('The second Error!')
})
.catch(e = {
console.error('Here is the first catch block')
throw new Error(`Passed Error: ${e} from the first catch block`) // nested Error throwing!
})
.catch(e = console.error(e))
.then(() = {
throw new Error('The third Error!')
})
이번에는 두번째 catch 블록에서 에러 문구가 출력되는 것을 확인할 수 있다.

이번에는 두번째 catch 블록을 지우고 catch 블록에서 resolve 상태의 Promise를 리턴해보자. 그리고 catch 문에는 then 체인이 걸려있다.
promiseA
.then((data) = {
console.log('resolved Data: ', data)
throw new Error('The first Error')
})
.then(() = {
throw new Error('The second Error!')
})
.catch(e = {
console.error(e)
return Promise.resolve() // resolved 된 Promise 객체 반환
})
.then(() = {
console.log('This is the thrid then block')
throw new Error('The third Error!')
})

세번째 then 체인이 실행되는 것을 확인할 수 있다.
rejected 상태의 Promise는 어떨까? 해당 Promise 객체를 반환하는 다음 체이닝에 catch 블록이 있다고 가정해본다.
const promiseA = new Promise((resolve, reject) = {
setTimeout(() = {
console.log('Promise resolving . . .')
resolve('123')
}, 300)})
promiseA
.then((data) = {
console.log('resolved Data: ', data)
throw new Error('The first Error')
})
.then(() = {
throw new Error('The second Error!')
})
.catch(e = {
console.error(e)
return Promise.reject('rejected Promise') // rejected 된 Promise 객체 반환
})
.then(() = {
console.log('This is the thrid then block')
throw new Error('The third Error!')
})
.catch(e = {
console.error(e) // 에러 문구 출력
})
rejected Promise의 에러 문구 내용이 마지막 catch 블록에서 실행되는 것을 확인할 수 있다.

이제 여러 개의 catch 블록과 여러 개의 then 순서를 뒤바꾸면 코드가 매우 흥미로워진다.
promiseA
.then((data) = {
console.log('resolved Data: ', data)
throw new Error('The first Error')
})
.catch(e = console.error(e))
.catch(e = {
console.error(e)
return Promise.reject('rejected Promise')
})
.then(() = {
console.log('This is the second then block')
throw new Error('The second Error!')
})
.then(() = {
console.log('This is the thrid then block')
throw new Error('The third Error!')
})
.catch(e = {
console.error(e)
})
실행 결과는 어떻게 될까?

여기서 알 수 있는 중요한 사실은 catch가 catch 끼리만 체이닝이 되는 것이 아니라, catch 문이 실행되고 나서 resolved된 Promise가 반환되고, 다음 then 체인 블록으로 이동한다는 것이다.
→ The first Error
를 출력한 첫 번째 catch 블록이 실행되고 나서, 다음 catch 문이 아닌 then 체인 블록으로 이동한 것을 확인할 수 있다.
summary
요약하자면 이렇다.
- Promise 체이닝 과정에서 에러가 발생하여 Promise 상태가 rejected되면 가장 가까운 catch 체이닝으로 이동한다.
- catch 내부에서 에러가 발생하여 Promise가 rejected 상태가 되면 다음 catch로 이동한다.
- 이동한 catch에서 반환되는 Promise 객체의 상태에 따라 다음 체이닝으로 이동한다. → resolved(정확히는 fulfilled 상태)이면 then, rejected이면 catch
Promise의 상태 전이는 아래 MDN 문서의 이미지를 참고할 수 있다.

이는 분명 프로그래밍 언어 관점에서 직관적인 동작 방식으로 보인다. 개인적으로는 일반적인 프로그래밍 언어의 try-catch
문의 동작과 goto
문이 떠오르는 대목
하지만 체이닝이 길어지고 순서가 뒤바뀌면 코드의 실행 순서를 파악하기 어렵다는 것이 Promise 체이닝의 단점이며, 이는 async/await
문법이 추가된 배경으로 생각할 수 있다.
따라서 만약 Promise 체이닝이 길어지는 상황이라면 async/await
문법으로 변환하거나, 코드 상으로 명확하게 실행 순서를 확인할 수 있도록 then과 catch 영역을 구분하여 체이닝을 하는 것이 Best Practice로 보여진다.
const promiseA = new Promise((resolve, reject) = {
setTimeout(() = {
console.log('Promise resolving . . .')
resolve('123')
}, 300)})
promiseA
// then zone
.then((data) = {
console.log('resolved Data: ', data)
throw new Error('The first Error')
})
.then(() = {
console.log('This is the second then block')
throw new Error('The second Error!')
})
.then(() = {
console.log('This is the thrid then block')
throw new Error('The third Error!')
})
// catch zone
.catch(e = console.error(e))
.catch(e = {
console.error(e)
return Promise.reject('rejected Promise')
})
.catch(e = {
console.error(e)
})

하지만 실전에서는 catch zone에서 여러 catch 체이닝을 쓰기보다는 체이닝 과정에서 에러가 발생하면 Error 케이스를 구분하여 한 개의 catch 체이닝으로 해결하는 경우가 더 일반적인 쓰임새.
이제 위 글에서 확인해야 할 부분이 있다. 현재 글에서는 resolved과 fulfilled를 혼용해서 사용한 부분이 있다.
resolved Promise와 fulfilled Promise는 같은 말인가?
아니다.
fulfilled Promise는 이미 상태가 fulfilled로 결정된 Promise를 말하고, resolved된 promise는 pending 상태에서 어떤 다른 상태로 변경되었음을 의미한다.
따라서 Promise가 rejected가 되어도 resolved Promise다.
MDN 문서에 아래와 같은 설명이 있다.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
The States and fates document from the original Promise proposal contains more details about promise terminology. Colloquially, "resolved" promises are often equivalent to "fulfilled" promises, but as illustrated in "States and fates", resolved promises can be pending or rejected as well.
Case 3: 중첩 Promise
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
위 문서 예시 코드로 아래와 같은 코드가 있다.
new Promise((resolveOuter) = {
resolveOuter(
new Promise((resolveInner) = {
setTimeout(resolveInner, 1000);
}),
);
});
- 첫번째 Promise는 생성되마자 resolved 처리된다.
- 하지만 상태는 결정되지 않는다.
- 내부 Promise 상태에 따라 outer Promise의 상태가 결정
- 1초 후에 내부 Promise가 fulfilled 상태로 resolved 되면서 외부 Promise도 fulfilled가 된다.
어떻게 사용할 수 있을까?
- 순차적으로 비동기 작업을 수행할 때 유용하다.
function stepOne() {
return new Promise((resolve) = {
setTimeout(() = {
console.log("Step One Complete");
resolve();
}, 1000);
});
}
function stepTwo() {
return new Promise((resolve) = {
setTimeout(() = {
console.log("Step Two Complete");
resolve();
}, 1000);
});
}
stepOne().then(() = {
return stepTwo();
}).then(() = {
console.log("All steps complete");
});
- stepOne 호출 1초 후에 Promise가 fulfilled 되면서 다음 then 체인으로 넘어간다.
- then이 반환하는 Promise는 pending 상태로 있다가 fulfillment handler가 실행되면서 다음 상태를 기다린다.
- fulfillment handler 는 stepTwo를 호출하고 있고, stepTwo는 1초 후에 fulfilled 되는 Promise를 반환한다.
- 첫번째 then의 Promise는 stepTwo가 반환하는 Promise의 상태를 기다렸다가 fulfilled로 resolved 되면 then의 Promise도 fulfilled 상태가 된다.
- 이후 다음 then으로 이동하여
All steps complete
라는 문구가 콘솔에 출력된다.
결과

더 응용한다면, 사용자가 선택한 옵션들에 따라 순차적으로 데이터를 fetch하고 싶을 때에도 사용 가능하다.
function fetchData(option) {
return new Promise((resolve) = {
setTimeout(() = {
resolve(`Data for ${option}`);
}, 1000);
});
}
function processOption(option) {
return new Promise((resolve) = {
fetchData(option).then((data) = {
console.log(data);
resolve();
});
});
}
processOption("Option 1").then(() = {
return processOption("Option 2");
}).then(() = {
console.log("All options processed");
});
결과

하지만 위와 같이 작성한다면 fetch 로직이 조금만 더 길어질 때 가독성에 악영향을 줄 것이다.
이럴 때에는 async/await
를 사용하는 것이 더 효과적일 수 있다.
function fetchData(option) {
return new Promise((resolve) = {
setTimeout(() = {
resolve(`Data for ${option}`);
}, 1000);
});
}
async function processOption(option) {
const data = await fetchData(option);
console.log(data);
}
async function runOptions() {
await processOption("Option 1");
await processOption("Option 2");
console.log("All options processed");
}
runOptions();
참고
https://github.com/domenic/promises-unwrapping/blob/master/docs/states-and-fates.md
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise