비동기 콜백 함수와 Promise의 차이점, Promise 이해하기
비동기 처리
일반 함수의 비동기 처리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const get = (url) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', url)
xhr.send()
xhr.onload = () => {
if (xhr.status === 200) {
console.log(JSON.parse(xhr.response)) // {userId: 1, id: 1, title: ...}
return JSON.parse(xhr.response)
} else {
console.error(`${xhr.status} ${xhr.statusText}`)
}
}
}
const response = get('https://jsonplaceholder.typicode.com/posts/1')
console.log(response) // undefined
위 예제의 get 함수는 비동기 함수이다.
get 함수를 호출하여 response 값을 콘솔에 찍으면 undefined가 할당된다.
xhr.onload
핸들러는 load 이벤트가 발생하면 일단 테스크 큐에 저장되어 대기하다가, 콜 스택이 비면 이벤트 루프에 의해 콜 스택으로 푸시되어 실행된다.
따라서 xhr.onload
이벤트 핸들러가 실행되는 시점은 console.log(response)
가 이미 종료된 이후다.
- 이처럼 비동기 함수는 비동기 처리 결과를 외부에 반환할 수 없고, 상위 스코프의 변수에 할당할 수도 없다.
- 비동기 함수의 처리 결과에 대한 후속 처리는 비동기 함수 내부에서 콜백 함수를 통해 수행한다.
콜백 함수를 통한 비동기 처리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const get = (url, successCallback, failureCallback) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', url)
xhr.send()
xhr.onload = () => {
if (xhr.status === 200) {
successCallback(JSON.parse(xhr.response))
} else {
failureCallback(`${xhr.status} ${xhr.statusText}`)
}
}
}
const response = get(
'https://jsonplaceholder.typicode.com/posts/1',
console.log,
console.error
)
콜백 함수의 문제점
콜백 지옥(callback hell)
문제는 비동기 처리 결과를 가지고 이후에 또다시 비동기 함수를 호출해야 한다면 콜백 함수가 중첩되어 콜백 지옥이 생긴다는 것이다.
1
2
3
4
5
6
7
8
9
get('/step1', (a) => {
get(`/step2/${a}`, (b) => {
get(`/step3/${b}`, (c) => {
get(`/step4/${c}`, (d) => {
console.log(d)
})
})
})
})
콜백 지옥은 코드를 복잡하게 만들고 가독성을 떨어뜨린다.
실제로 이렇게 코드 짠 사람을 보면 때리고 싶어질 것 같다.
에러 처리의 한계
지옥은 둘째치고 가장 심각한 것은 콜백 패턴의 비동기 처리는 에러 처리가 어렵다는 것이다.
1
2
3
4
5
6
7
8
try {
setTimeout(() => {
throw new Error('Error!')
}, 1000)
} catch (e) {
// 에러를 캐치하지 못한다.
console.error('캐치한 에러', e)
}
위 함수에서 에러는 catch 코드 블록에서 캐치되지 않는다.
앞에서 살펴본 비동기 처리와 마찬가지로 콜 스택이 전부 비워진 후에 setTimeout 핸들러가 실행되기 때문이다.
프로미스
Promise 생성자 함수는 resolve와 reject 함수를 인수로 받는 콜백 함수 내부에서 비동기를 처리한다.
1
2
3
4
5
6
7
8
const promise = new Promise((resolve, reject) => {
// Promise 함수의 콜백 함수 내부에서 비동기 처리를 수행한다.
if(/* 비동기 처리 성공 */) {
resolve('result');
} else {/* 비동기 처리 실패 */
reject('failure reason');
}
})
프로미스는 현재 비동기 처리에 대한 상태 정보를 갖는다.
비동기 처리에 성공하면 resolve 함수를 호출해 프로미스를 fulfilled 상태로 만들고, 실패하면 reject 함수를 호출해 프로미스를 rejected 상태로 만든다.
fulfilled 또는 rejected 상태를 settled 상태라고 한다.
상태 정보 | 의미 | 상태 변경 조건 |
---|---|---|
pending | 비동기 처리가 아직 수행되지 않은 상태 | 프로미스가 생성된 직후 기본 상태 |
fulfilled | 비동기 처리가 수행된 상태(성공) | resolve 함수 호출 |
rejected | 비동기 처리가 수행된 상태(실패) | reject 함수 호출 |
비동기 처리가 성공하면 프로미스는 pending 상태에서 fulfilled 상태로 변하고 비동기 처리 결과를 값으로 갖는다.
비동기 처리가 실패하면 프로미스는 pending 상태에서 rejected 상태로 변하고 비동기 처리 결과인 Error 객체를 값으로 갖는다.
즉, 프로미스는 비동기 처리 상태와 처리 결과를 관리하는 객체다.
프로미스의 후속 처리 메서드
프로미스의 모든 후속 처리는 프로미스를 반환하며, 비동기로 동작한다.
then()
then 메서드는 프로미스 처리 상태에 따라 비동기 처리 결과와 에러를 인수로 전달받는다.
하나는 Promise가 이행했을 때, 다른 하나는 거부했을 때를 위한 콜백 함수이다.
1
2
3
4
5
6
7
8
9
10
11
// fulfilled
new Promise((resolve) => resolve('fulfilled')).then(
(v) => console.log(v),
(e) => console.error(e)
) // fulfilled
// rejected
new Promise((_, reject) => reject(new Error('rejected'))).then(
(v) => console.log(v),
(e) => console.error(e)
) // Error: rejected
단, then 메서드의 두 번째 콜백 함수는 then 내부에서 발생한 에러는 캐치하지 못하고 코드가 복잡해져서 가독성이 좋지 않다.
catch()
catch 메서드의 콜백함수는 프로미스가 rejected 상태인 경우만 호출된다.
1
2
3
4
// rejected
new Promise((_, reject) => reject(new Error('rejected'))).catch((e) =>
console.log(e)
) // Error: rejected
catch 메서드를 사용하면 비동기 처리에서 발생한 에러(rejected 상태)뿐만 아니라 then 메서드 내부에서 발생한 에러까지도 모두 캐치할 수 있다.
finally()
finally 메서드의 콜백 함수는 프로미스의 처리 상태와 상관없이 무조건 한 번 호출된다.
finally 메서드도 then/catch 메서드와 마찬가지로 프로미스를 반환한다.
1
new Promise(() => {}).finally(() => console.log('finally')) // finally
프로미스 체이닝
후속 처리 메서드는 언제나 프로미스를 반환하므로 연속적으로 호출할 수 있다.
이를 프로미스 체이닝이라고 한다.
1
2
3
4
5
6
const url = 'https://jsonplaceholder.typicode.com'
promiseGet(`${url}/posts/1`)
.then(({ userId }) => promiseGet(`${url}/users/${userId}`))
.then((userInfo) => console.log(userInfo))
.catch((err) => console.error(err))
후속 처리 메서드 | 콜백 함수의 인수 | 반환값 |
---|---|---|
then | promiseGet 함수가 반환한 프로미스가 resolve한 값 (id가 1인 post) | 콜백 함수가 반환한 프로미스 |
then | 첫 번째 then 메서드가 반환한 프로미스가 resolve한 값 (post의 userId로 취득한 user 정보) | 콜백 함수가 반환한 값(undefined)을 resolve한 프로미스 |
catch | promiseGet 함수 또는 앞선 후속 처리 메서드가 반환한 프로미스가 reject한 값 | 콜백 함수가 반환한 값(undefined)을 resolve한 프로미스 |
이처럼 후속 처리 메서드는 콜백 함수가 반환한 프로미스를 반환한다.
만약 콜백 함수가 프로미스가 아닌 값을 반환하더라도 그 값을 암묵적으로 resolve 또는 reject하여 프로미스를 생성해 반환한다.
프로미스는 프로미스 체이닝을 통해 후속 처리를 하므로 콜백 지옥이 발생하지 않는다.
다만 프로미스도 콜백 패턴을 사용하므로 콜백 함수를 사용하지 않는 것은 아니다.
콜백 패턴은 가독성이 좋지 않다. 이 문제는 ES8에서 도입된 async/await를 통해 하결할 수 있다.
async/await를 사용하면 프로미스의 후속 처리 메서드 없이 마치 동기 처리처럼 프로미스가 처리 결과를 반환하도록 구현할 수 있다.