Ch.45 프로미스
책 『모던 자바스크립트 Deep Dive』 45장을 읽으며 정리한 노트입니다.
자바스크립트에서는 기본적으로 비동기 처리 패턴으로 콜백 함수를 사용한다. 그런데 이 콜백 패턴은 몇가지 단점들이 있는데, 큰 단점으로 2가지를 꼽을 수 있다.
첫번째로는 콜백 헬이다. 콜백 헬이란 비동기 결과값을 사용하기 위해서 콜백 함수를 넘겨주는 패턴에서 콜백이 중첩되면서 가독성이 떨어지는 현상을 말한다. 비동기 함수가 내부 비동기 로직보다 먼저 종료되기 때문에 콜백 함수를 넘겨주지 않으면 비동기 로직을 외부로 꺼낼 방법이 없다. 이렇게 콜백이 중첩되는 콜백 헬은 가독성이 떨어질 뿐 아니라 흐름을 파악하기 어려워 실수를 유발할수도 있다는 단점이 있다.
두번째로는 에러 처리가 곤란하다는 문제가 있다. (이게 좀 더 심각한 문제점이다.) 에러 처리는 보통 아래처럼 try-catch 블록을 사용한다.
function 문제있는비동기함수() {
비동기로직(value => {
throw new Error('심각한 문제'); // 콜백 내부에서 던져짐
});
}
try {
문제있는비동기함수();
} catch (error) {
console.log(`문제 발생! : ${error}`)
}문제는 비동기 함수 내의 비동기 로직은 메인 흐름과 별개로 동작한다는 것이다. 위 코드에서도 내부의 비동기로직()의 성공 여부와는 관계 없이 문제있는비동기함수()는 성공적으로 종료되어 try-catch 블록을 빠져나간다. 따라서 비동기로직() 내부에서 던져지는 심각한문제 에러는 어디에도 캐치되지 않는다.
더 정확히는 콜백 함수가 실행될 때는 콜 스택에 이미 try-catch가 존재하지 않기 때문이다. 에러는 호출자 방향, 즉 콜 스택 아래쪽으로 전파된다. 만약 동기함수였다면 콜 스택이 아래처럼 쌓이기 때문에 에러가 아래로 (3-2-1) 전파되다가 try-catch를 만나서 캐치될 것이다.
3. throw new Error
2. 문제있는동기함수()
1. try-catch
───────────하지만 비동기 함수의 경우 내부 비동기 로직 실행이 완료되기 전에 외부 비동기 함수가 종료되면서 try-catch도 함께 콜 스택에서 제거된다. 콜백은 외부 비동기 함수가 종료되고 콜 스택이 빈 시점에 이벤트 루프에 의해서 실행되므로 에러가 전파되더라도 앞선 콜 스택에 try-catch가 없어 catch 블록에서 캐치되지 않는다.
이 2가지 단점을 포함해서 전통적인 콜백 패턴에는 단점이 꽤 많았기 때문에 ES6에서 프로미스가 도입되었다. 프로미스는 비동기 처리 상태와 처리 결과를 관리하는 객체이다.
프로미스에는 pending / fulfilled / rejected 이렇게 3가지 상태가 있다. pending은 아직 비동기 처리가 완료되지 않은 상태, fulfilled는 비동기 처리가 성공적으로 완료된 상태, rejected는 비동기 처리에 실패하여 종료된 상태를 말한다. 그리고 비동기 로직이 종료된 fulfilled와 rejected 상태를 묶어 settled라고 한다.
처음 프로미스가 생성되었을 때는 pending 상태이다. pending 상태에서 resolve가 호출되면 fulfilled 상태가 되고, reject가 호출되면 rejected 상태가 된다. 그리고 한번 settled된 프로미스의 상태는 변하지 않는다.


프로미스 객체에는 then, catch, finally 이렇게 3가지 후속 처리 메서드가 있다. 후속함수들(심지어 finally까지도!)은 모두 프로미스를 반환하기 때문에 (콜백에서 프로미스를 명시적으로 반환하지 않더라도 암묵적으로 프로미스를 생성해서 반환한다.) 프로미스 체이닝이 가능하다.
then 메서드에는 2개의 인수를 전달하는데, 각각 fulfilled 상태일 때 호출할 콜백과 rejected 상태일 때 호출할 콜백이다.
catch 메서드는 then(undefined, onRejected)의 단축 표현이다. catch도 내부적으로 then을 호출하기 때문에 에러 처리를 할 때 then의 두번째 콜백을 이용하는 것과 catch를 이용하는 것이 거의 동일하게 동작한다. 하지만 then의 두번째 콜백을 사용하는 경우, 첫번째 콜백에서 발생할 수 있는 에러를 감지하지 못한다는 문제가 있다.

반면에 catch를 사용하면 앞서 호출된 then에서 발생한 에러도 함께 캐치할 수 있다.

이런 차이 뿐 아니라 catch를 이용하는 것이 가독성 측면에서도 더 이점이 있다. 따라서 에러 처리는 then 보다는 catch에서 하는 것을 권장한다.
Promise에는 resolve, reject, all, race, allSettled 라는 정적 메서드들이 있다. (책에는 언급이 안됐지만 any도 있음) (각 메서드들의 정확한 스펙은 MDN 참고)
Promise.resolve, Promise.reject는 어떤 값을 resolve하거나 reject하는 프로미스를 생성할 때 사용한다.
Promise.all은 주어진 프로미스를 병렬로 실행하고, 모든 프로미스가 resolve되면 각각의 resolve 값을 모아서 배열로 반환하는 메서드이다. 하나라도 reject되면 에러를 반환한다. resolve 값을 모아 배열로 반환할 때 그 값의 순서는 처음에 전달했던 프로미스의 순서와 동일하다.
Promise.race는 가장 먼저 settled되는 프로미스의 결과를 반환한다. fulfilled든 rejected든 상관없이 가장 빠른 것 기준이다.
Promise.allSettled는 성공/실패와 관계 없이 모든 프로미스가 settled되면 결과 값을 모아 배열로 반환하는 메서드이다. resolve된 프로미스는 상태와 값을 ({status, value}) reject된 프로미스는 상태와 실패 이유({status, reason})를 반환한다.
실행 시간 비교해본 것 정리 (all, race, allSettled가 병렬로 실행되고, 특정 조건들에서 조기종료되는 것을 확인할 수 있음)
fetch 메서드가 기존에 HTTP 요청을 보낼 때 사용하던 XMLHTTPRequest 메서드와 다르게 프로미스를 이용하기 때문에 사용법과 에러 처리 로직이 더 간단하다는 장점이 있다. fetch의 자세한 사용은 MDN 참고
Promise의 콜백은 마이크로 태스크 큐에 저장된다. 마이크로 태스크 큐는 태스크 큐보다 우선순위가 높기 때문에 먼저 실행된다. 따라서 아래 코드를 실행했을 때 출력 순서는 2, 3, 1이 된다.
마지막 업데이트
도움이 되었나요?