자바스크립트는 비동기 처리를 위한 하나의 패턴으로 콜백 함수를 사용하는데 콜백 헬로 인해 가독성이 나쁘고 에러처리가 곤란하다는 단점으로 인해 한계가 있었다. ES6에서는 이러한 한계를 개선하기 위해 또 다른 패턴으로 프로미스를 도입했다. 프로미스는 콜백 패턴이 가진 단점을 보완하며 비동기 처리 시점을 명확하게 표현할 수 있다는 장점이 있다.
let todos;
const get = url => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
xhr.onload = () => {
if (xhr.status === 200) {
return JSON.parse(xhr.response);
} else {
console.error(`${xhr.status} ${xhr.statusText}`);
}
};
};
get(`https://jsonplaceholder.com/posts/1`);
console.log(todos); //undefined
위의 get 함수는 비동기 함수다. get 함수가 비동기 함수인 이유는 함수 내부의 onload 이벤트 핸들러가 비동기로 동작하기 때문이다.비동기 함수를 호출하면 함수 내부의 비동기로 동작하는 코드가 완료되지 않았다 해도 기다리지 않고 즉시 종료된다. 즉, 비동기 함수 내부의 비동기로 동작하는 코드는 비동기 함수가 종료된 이후에 완료된다.
따라서 비동기 함수 내부의 비동기로 동작하는 코드에서 처리 결과를 외부로 반환하거나 상위 스코프의 변수에 할당하면 기대한 대로 동작하지 않는다.
xhr.onload 이벤트 핸들러는 load 이벤트가 발생하면 일단 태스크 큐에 저장되어 대기하다가, 콜 스택이 비면 이벤트 루프에 의해 콜 스택으로 푸시되어 실행된다.
즉, xhr.onload 이벤트 핸들러가 실행되는 시점에는 콜 스택이 빈 상태여야 하므로 console.log는 이미 종료된 이후다. 만약 get 함수 이후에 console.log가 100번 호출된다 하더라도 결과는 동일하다.
비동기 함수를 범용적으로 사용하기 위해 비동기 함수에 비동기 처리 결과에 대한 후속 처리를 수행하는 콜백 함수를 전달하는 것이 일반적
이다. 필요에 따라 비동기 처리가 성공하면 호출될 콜백 함수와 비동기 처리가 실패하면 호출될 콜백 함수를 전달할 수 있다. 하지만 비동기 함수가 비동기 처리 결과를 가지고 또다시 비동기 함수를 호출해야 한다면 콜백 함수 호출이 중첩되어 복잡도가 높아지는 콜백 헬이 발생할 수 있다.
아래의 콜백 패턴의 코드에서 에러는 호출자 방향으로 전파되는데 비동기 함수는 실행될 때 콜백 함수가 호출되는 것을 기다리지 않고 즉시 종료되어 콜 스택에서 제거되므로 콜백 함수의 실행 컨텍스트의 하위 실행 컨텍스트가 없는 상태로 호출자가 없는 것으로 인식되어 catch 블록에서 error가 정상적으로 catch 되지 않는다.
try {
setTimeout(() => { throw new Error('Error!'); }, 1000);
} catch(e) {
//에러 처리의 한계로 인해 캐치하지 못함
console.error('캐치한 에러', e);
}
const promise = new Promise((resolve, reject) => {
if (/*비동기 처리 성공*/) {
resolve('result');
} else {
reject('failure reason');
}
});
ES6에서 도입된 Promise는 호스트 객체가 아닌 ECMAScript 사양에 정의된 표준 빌트인 객체다.
Promise 생성자 함수는 비동기 처리를 수행할 콜백 함수를 인수로 전달받는데 이 콜백 함수는 resolve와 reject 함수를 인수로 전달받는다.
프로미스를 사용하여 위의 get 함수를 구현하면 다음과 같다.
const promiseGet = url => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
xhr.onload = () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.response));
} else {
reject(new Error(xhr.status));
}
};
});
};
//프로미스를 반환한다.
promiseGet(`https://jsonplaceholder.com/posts/1`);
프로미스는 아래와 같은 상태 정보를 가질 수 있는데 이를 통해 프로미스가 비동기 처리 상태와 처리 결과를 관리하는 객체
라는 것을 알 수 있다.
pending
: 비동기 처리가 아직 수행되지 않은 상태로 프로미스가 생성된 직후 기본 상태를 나타낸다.
fulfilled
: 비동기 처리가 성공적으로 수행된 상태로 reslove 함수가 호출됐을 때 변경된 상태이다.
rejected
: 비동기 처리가 실패한 상태로 reject 함수가 호출됐을 때 변경된 상태이다.
프로미스의 비동기 처리 상태가 변화하면 성공, 실패에 따라서 적절한 후속 처리 메서드인 then, catch, finally
를 사용해주어야 한다. 해당 메서드들은 콜백 함수를 인수로 갖고 비동기 처리 상태가 변화했을 때 프로미스의 처리 결과를 인수로 갖는 콜백 함수를 실행하여 후속 처리를 하게 된다.
Promise.prototype.then
: then 메서드는 두 개의 콜백 함수를 인수로 전달받는다.
첫 번째 콜백 함수는 프로미스가 fulfilled 상태가 되면 호출된다. 이때 콜백 함수는 프로미스의 비동기 처리 결과를 인수로 전달받는다.
두 번째 콜백 함수는 프로미스가 rejected 상태가 되면 호출된다. 이때 콜백 함수는 프로미스의 에러를 인수로 전달받는다.
then 메서드는 언제나 프로미스를 반환한다. 콜백 함수가 프로미스가 아닌 값을 반환하면 그 값을 암묵적으로 resolve 또는 reject하여 프로미스를 생성하여 반환한다.
//fulfilled: 첫번째 콜백이 실행된다.
new Promise(((resolve, _) => resolve('fulfilled')))
.then(v => console.log(v), e => console.error(e));
//rejected: 두번째 콜백이 실행된다.
new Promise((_, reject) => reject(new Error('rejected')))
.then(v => console.log(v), e => console.error(e));
Promise.prototype.catch
: catch 메서드는 한 개의 콜백 함수를 인수로 전달받는다. catch 메서드의 콜백 함수는 프로미스가 rejected 상태인 경우만 호출된다. 언제나 프로미스를 반환한다.new Promise((_, reject) => reject(new Error('rejected')))
.catch(e => console.log(e)); //Error: rejected
Promise.prototype.finally
: finally 메서드는 한 개의 콜백 함수를 인수로 전달받는다. finally 메서드의 콜백 함수는 프로미스의 성공, 실패 여부와 상관없이 무조건 한 번 호출된다. 언제나 프로미스를 반환한다.new Promise(() => {})
.finally(() => console.log('finally')); //finally
then 메서드 안에서 두 번째 콜백 함수로 에러를 처리하려고 하면 첫 번째 콜백 함수에서 발생한 에러를 캐치하지 못하는 상황이 발생할 수 있다. 그래서 then 메서드 뒤에 catch 메서드를 연결하여 then 메서드 내부에서 발생한 에러까지 모두 캐치할 수 있도록 하는 것이 좋다.
Promise는 5가지 정적 메서드를 제공한다.
Promise.resolve / Promise.reject
: resolve와 reject 메서드는 이미 존재하는 값을 래핑하여 프로미스를 생성하기 위해 사용한다.const rejectedPromise = Promise.reject(new Error('Error!'));
rejectedPromise.catch(console.log); //Error: Error!
Promise.all
: 여러 개의 비동기 처리를 모두 병렬로 처리할 때 사용한다.
all 메서드는 프로미스를 요소로 갖는 배열 등의 이터러블을 인수로 전달받는다. 그리고 전달받은 모든 프로미스가 fulfilled 상태가 되면 모든 처리 결과를 배열에 저장해 새로운 프로미스를 반환한다. 이때 반환되는 프로미스의 순서는 처리 순서와 상관없이 전달해준 비동기 처리 순서대로 배열에 저장되므로 처리 순서가 보장된다.
Promise.all([requestData1(), requestData2(), requestData3()])
.then(console.log)
.catch(console.error);
Promise.race
: race 메서드는
all 메서드와 동일하게 프로미스를 요소로 갖는 배열 등의 이터러블을 인수로 전달받지만 all 메서드처럼 모든 프로미스가 fulfilled 상태가 되는 것을 기다리는 것이 아니라 가장 먼저 fulfilled 상태가 된 프로미스의 처리 결과를 resolve하는 새로운 프로미스를 반환한다.
rejected 상태일 때는 전달된 프로미스 중 하나라도 rejected 상태가 되면 에러를 reject하는 새로운 프로미스를 반환한다.Promise.race([
new Promise(resolve => setTimeout(() => resolve(1), 3000));
new Promise(resoluve => setTimeout(() => resolve(3), 1000));
])
.then(console.log) //3;
.catch(console.log)
Promise.allSettled
: all과 race와 동일하게 이터러블을 인수로 전달받는데 이렇게 전달된 프로미스가 모두 settled 상태(fulfilled 혹은 rejected)가 되면 처리 결과를 배열로 반환한다. 전달된 인수중 rejected가 됐더라도 각각이 수행된 결과를 받을 수 있다.Promise.allSettled([
new Promise(resolve => setTimeout(() => resolve(1), 2000));
new Promise((_, reject) => setTimeout(() => reject(new Error('Error!'), 1000));
])
.then(console.log);
/*
[
{status: "fulfilled", value: 1},
{status: "rejected", reason: Error: Error! at <anonymout>:3:54}
]
*/
프로미스의 후속 처리 메서드의 콜백 함수는 태스크 큐가 아니라 마이크로태스크 큐에 저장된다.
콜백 함수나 이벤트 핸들러를 일시 저장한다는 점에서 태스크 큐와 동일하지만 마이크로태스크 큐는 태스크큐보다 우선순위가 높다.(우선순위: 콜 스택 > 마이크로태스크 큐 > 태스크 큐)
출처: 모던 자바스크립트 Deep Dive-이웅모