프로미스 이전의 비동기 작업
단점 1: 콜백 지옥
기존의 자바스크립트에서는 비동기 작업을 처리하기 위해서 콜백 함수를 사용했다.
하지만 콜백 함수를 사용해서 비동기 작업을 처리하는 경우 비동기 작업의 순서를 보장하기 위해 중첩된 콜백 함수를 사용하게되고 이로 인해 코드의 들여 쓰기가 깊어져 가독성이 떨어지는 효과가 나타난다. ( 모양이 아도겐 같다 )
그런데 콜백 지옥이 발생하는 근본적인 원인은 무엇일까?
function foo() {
setTimeout(() => {
return 1
}, 0);
}
const result = foo();
console.log(result); // undefined
위의 코드를 보자.
비동기 함수란 함수 내부에 비동기로 동작하는 코드를 포함한 함수를 말한다. foo 함수 내부에는 setTimeout 타이머 함수가 존재하기 때문에 비동기 함수이다. 이 때 foo 함수를 호출한 경우에 result에는 비동기 함수인 setTimeout의 리턴 값인 1이 반환될까? 답은 그렇지 않다이다.
비동기 함수가 호출되면 함수 내부에서 비동기로 동작하는 코드의 완료를 기다려주지 않고 즉시 종료된다. (이해가 되지 않는다면 이벤트 루프 포스팅을 보는 것을 추천한다.) 따라서 비동기 처리 결과를 외부로 반환하거나 상위 스코프의 변수에 할당하는 것은 불가능하다. 결과적으로 위의 코드에서는 리턴 값을 명시해주지 않았기 때문에 result에는 undefined가 반환된다.
그렇다면 비동기 처리 결과는 어디서 받을 수 있을까? 이에 대한 답은 비동기 함수 내부라고 할 수 있다. 따라서 비동기 처리 결과에 따른 후속 처리를 진행하기 위해 콜백 함수를 사용하게되는것이다. 위의 코드에서 비동기 함수인 setTimeout의 리턴 값을 출력하기 위해서는 다음과 같이 작성할 수 있다.
function foo(callback) {
setTimeout(() => {
callback(1);
});
}
foo((a) => {
console.log(a) // 1
})
위의 경우에는 간단하지만 만약 비동기 작업의 순서를 보장하기 위해 반복적인 콜백 함수를 사용한다면 콜백 지옥이 만들어진다.
다음 예시는 네트워크 요청을 통해 서버로 부터 받은 id값을 통해 또 다른 네트워크 요청을 하고, 그 결과로 받은 post 값을 토대로 또 네트워크 요청을 하는 경우이다. 점점 들여쓰기가 깊어지면서 위에서 본 아도겐 그림과 비슷해져 가고있다.
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.statusText);
}
}
}
get("www.xxx.xxx", ({ id }) => {
get(`www.xxx.xxx/${id}`, ({ post }) => {
get(`www.xxx.xxx/${post}`, (res) => {
console.log(res);
});
});
});
단점 2: 에러 처리 한계
콜백 지옥에 이은 콜백 패턴의 두 번째 문제점은 에러 처리가 힘들다는 것이다. try catch문에서 에러 처리를 구현하게 되는데, 다음과 같이 비동기 함수인 setTimeout이 실행된다고 가정했을 때 setTimeout의 콜백 함수에서 throw한 에러는 catch 블록에서 잡히지 않는다.
try {
setTimeout(() => {
throw new Error('Error')
}, 0)
} catch(err) {
console.log(err)
}
throw한 에러는 호출자 방향으로 전파하게된다. 쉽게 말해서 콜 스택에 가장 위에 쌓인 실행 컨텍스트에서 부터 아래로 전파된다는 말이다. 이 때 위에서 언급했듯이 비동기 함수가 호출되면 함수 내부에서 비동기로 동작하는 코드의 완료를 기다려주지 않고 즉시 종료된다. 따라서 해당 비동기 함수의 실행 컨텍스트 또한 콜 스택에서 제거가 되기 때문에 에러가 throw 되는 시점에는 이미 콜 스택이 비어있는 경우이며 에러를 전파할 호출자가 존재하지 않게된다.
Promise 생성
위에서 마주한 두 가지 단점을 토대로 콜백 지옥과 에러 처리의 한계를 극복하기 위해 ES6에서 Promise가 도입되었다. Promise는 빌트인 객체이며 Promise의 이름에서 알 수 있듯이 "시간이 걸리는 비동기 작업을 기다리는 동안 다른 동기적인 작업을 먼저 진행해 내가 꼭 결과를 전달해줄게" 라는 뜻을 가지고 있다고 생각하면 조금 더 쉽게 이해할 수 있을 것이다. 자 그럼 이제 Promise를 생성해보자.
const promise1 = new Promise((resolve, reject) => {
// Promise 콜백 함수 내부에서 비동기 작업 수행
if(/* 비동기 처리 성공 */) {
resolve('성공');
} else { /* 비동기 처리 실패 */
reject('실패');
}
});
Promise 생성자 함수는 비동기 작업을 수행 할 콜백 함수를 전달받고, 해당 콜백 함수는 resolve와 reject를 인자로 전달 받는다.
비동기 처리가 성공하는 경우 비동기 처리 결과를 resolve 함수의 인수로 전달하면서 호출하고, 비동기 처리가 실패하는 경우 에러를 reject 함수의 인수로 전달하면서 호출한다.
Promise 상태와 상태에 따른 후속 처리 메서드
Promise는 비동기 처리의 진행상황을 나타내는 세 가지 상태 정보를 가진다.
프로미스 상태 정보 | 의미 | 상태 변경 조건 |
pending | 비동기 처리가 아직 수행되지 않은 상태 | 프로미스가 생성된 직후 기본 상태 |
fulfilled | 비동기 처리가 수행된 상태 (성공) | resolve 함수 호출 |
rejected | 비동기 처리가 수행된 상태 (실패) | reject 함수 호출 |
Promise는 생성된 직후에는 pending 상태를 갖고
비동기 처리가 성공하면 resolve 함수를 호출해 Promise의 상태가 fulfilled로 변경되고,
비동기 처리가 실패하면 reject 함수를 호출해 Promise의 상태가 rejected로 변경된다.
Pending 상태
const promise1 = new Promise((resolve, reject) => {});
console.log(promise1);
위와 같이 Promise를 호출하고 확인해보면 Promise는 pending 상태가 된다. 이 때 콜백 함수의 인자인 resolve, reject 함수에 접근할 수 있다.
Fulfilled 상태
const promise1 = new Promise(resolve => {
resolve(1);
});
console.log(promise1);
Promise의 상태가 fulfilled인 경우 pending 상태와 다르게 [[PromiseResult]] 슬롯을 보면 비동기 처리 결과의 정보가 나타나있는 것을 확인할 수 있다.
이 때 비동기 처리 결과의 값을 받기 위해서는 후속 처리 메서드인 then을 사용한다. 후속 처리 메서드인 then이 전달받는 콜백 함수는 Promise가 fulfilled 상태가 되면 호출된다. 해당 콜백 함수의 인수로는 비동기 처리 결과를 인수로 전달받는다.
const promise1 = new Promise((resolve) => {
resolve(1);
});
promise1
.then((res) => console.log(res)); // 1
따라서 위의 실행 결과를 보면 다음과 같은 순서로 작동한다.
- 비동기 처리가 성공
- resolve 함수가 호출
- Promise의 상태가 fulfilled로 변경
- Promise의 상태가 fulfilled로 변경 됨에 따라 후속처리 메서드인 then이 실행
- then의 콜백 함수는 인수로 비동기 처리 결과를 받음
한 가지 더 중요한 점은 then 메서드는 항상 프로미스를 반환한다.
Rejected 상태
const promise1 = new Promise((_, reject) => {
reject("Error");
});
console.log(promise1);
위의 Promise 객체를 살펴보면 Promise의 상태가 rejected 상태이고 [[PromiseResult]] 슬롯에 비동기 처리 결과의 정보가 위와 같이 나타나있는 것을 볼 수 있다.
이 때 비동기 처리 결과의 값을 받기 위해서는 후속 처리 메서드인 catch를 사용한다. 후속 처리 메서드 catch가 전달받는 콜백 함수는 Promise가 rejected 상태가 되면 호출되고 콜백 함수의 인수로는 비동기 처리 결과를 전달받는다.
const promise1 = new Promise((_, reject) => {
reject("Error");
});
promise1
.catch((err) => console.log(err)); // Error
따라서 위의 실행 결과를 보면 다음과 같은 순서로 작동한다.
- 비동기 처리가 실패
- reject 함수가 호출
- Promise의 상태가 rejected로 변경
- Promise의 상태가 rejected로 변경 됨에 따라 후속처리 메서드인 catch가 실행
- catch의 콜백 함수는 인수로 비동기 처리 결과를 받음
추가적으로 Promise의 후속 처리 메서드로는 finally가 존재한다. finally 메서드는 한 개의 콜백 함수를 인수로 전달받고, finally 메서드의 콜백 함수는 프로미스의 성공이나 실패와 상관없이 무조건 한 번 호출된다. 따라서 Promise의 상태와 상관없이 공통적으로 처리해야 할 내용이 있는 경우에 사용한다.
Promise 체이닝
위에서 언급했던 콜백 지옥의 예시는 반복된 콜백 함수의 사용으로 좋지 않은 가독성을 보여준다. Promise는 후속 처리 메서드가 존재하고, then, catch, finally의 후속 처리 메서드들은 항상 Promise를 반환하기 때문에 연속적인 후속 처리 메서드를 호출할 수 있고 이 것을 체인과 같이 연결되어있다고 하여 Promise 체이닝이라고 한다.
위의 콜백 지옥 코드를 가지고 Promise 체이닝을 통해 콜백 지옥을 해결해보면 다음과 같다.
const get = (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(xhr.statusText);
}
}
})
}
get('www.xxx.xxx')
.then(({id}) => get(`www.xxx.xxx/${id}`))
.then(({post}) => get(`www.xxx.xxx/${post}`))
.then((res) => console.log(res))
.catch((err) => console.log(err))
결과적으로 Promise는 Promise 체이닝을 통해 비동기 처리 결과를 전달받아 후속처리를 하기 때문에 콜백 패턴에서 발생하던 콜백 헬이 발생하지 않는다.
Promise 정적 메서드
Promise는 몇가지 유용한 정적 메서드를 가진다. Promise.resolve, Promise.reject 메서드는 이미 존재하는 값을 래핑하여 Promise로 생성하기 위해 사용하고 이외의 몇가지 유용한 Promise 정적 메서드를 소개해보겠다.
Promise.all
Promise.all 메서드는 Promise가 담겨있는 배열과 같이 순회 가능한 이터러블을 인수로 전달받고 전달받은 모든 Promise를 병렬로 처리하여 모두 fulfilled 상태가 되면 모든 처리 결과를 배열에 저장해 새로운 프로미스를 반환한다. 따라서 서로 의존적이지 않고 개별적으로 수행되는 비동기 처리를 하는 경우 사용할 수 있다. 예제를 살펴보자.
const promise1 = () =>
new Promise((resolve) => setTimeout(() => resolve(1), 1000));
const promise2 = () =>
new Promise((resolve) => setTimeout(() => resolve(2), 3000));
const promise3 = () =>
new Promise((resolve) => setTimeout(() => resolve(3), 5000));
Promise.all([promise1(), promise2(), promise3()]).then((res) =>
console.log(res) // [1, 2, 3]
);
Promise.all 메서드는 인수로 전달받은 배열의 Promise가 모두 fulfilled 상태가 되면 종료하기 때문에, 가장 늦게 fulfilled 상태가 되는 Promise의 처리 시간보다 조금 더 소요된다. 따라서 위의 예제에서는 5초 보다 조금 더 소요된다. 다른 말로 모든 Promise의 비동기 처리 완료를 기다리기 때문에 Promise의 처리 결과를 차례대로 배열에 저장하게되고 처리 순서가 보장된다. 만약 Promise가 하나라도 rejected 상태가 되면 나머지 Promise의 상태가 fulfilled 되기를 기다리지 않고 에러를 reject하는 Promise를 즉시 반환한다.
Promise.race
Promise.race 메서드는 Promise가 담겨있는 배열과 같이 순회 가능한 이터러블을 인수로 전달받고 Promise.all과 다르게 모든 Promise가 fulfilled 상태가될 때까지 기다리지 않고 가장 먼저 fulfilled 상태가 된 프로미스의 처리 결과를 resolve하는 새로운 Promise를 반환한다. 예제를 살펴보자.
const promise1 = () =>
new Promise((resolve) => setTimeout(() => resolve(1), 1000));
const promise2 = () =>
new Promise((resolve) => setTimeout(() => resolve(2), 3000));
const promise3 = () =>
new Promise((resolve) => setTimeout(() => resolve(3), 5000));
Promise.race([promise1(), promise2(), promise3()]).then((res) =>
console.log(res) // 1
);
Promise.race 메서드는 Promise.all 메서드와 동일하게 전달된 Promise가 하나라도 rejected 상태가 되면 에러를 reject하는 Promise를 즉시 반환한다.
이렇게 Promise는 비동기 작업의 순서를 보장하기 위해 Promise 체이닝을 통해 콜백 지옥을 해결할 수 있고, 콜백 마다 반복적인 에러 처리 코드를 작성하는 대신 catch 라는 후속 처리 메서드를 사용하여 간편하게 에러를 처리할 수 있다.
하지만 이런 Promise도 단점이 존재하는데 then과 catch와 같은 후속처리 메서드라는 Promise 인터페이스에 코드가 종속되어 있어 코드의 가독성이 떨어질 수 있다. 따라서 Promise 이후에 등장한 async/await는 어떤 장점을 가지고 있고 어떻게 사용하는지 다음 포스팅에서 다뤄보도록 하겠다.
'개발 지식 정리 > Javascript' 카테고리의 다른 글
async/await (0) | 2023.09.14 |
---|---|
requestAnimationFrame (0) | 2023.08.30 |
이벤트 루프 (0) | 2023.07.21 |
타이머 (0) | 2023.07.18 |
DOM (0) | 2023.07.14 |