자바스크립트는 비동기 처리를 위한 하나의 패턴으로 콜백 함수를 사용한다. 전통적인 콜백 패턴은 콜백 지옥로 인해 가독성이 나쁘고 비동기 처리 중 발생한 에러 처리가 곤란해 여러 비동기 처리를 한번에 처리하는데 한계가 있다.
ES6에서는 비동기 처리를 위한 새로운 패턴인 프로미스(Promise)를 도입했다. 프로미스는 전통적인 콜백 패턴의 단점을 보완하고 비동기 처리 시점을 명확하게 표현할 수 있다.
비동기 처리를 위한 콜백 패턴의 단점
1. 콜백 지옥 (Callback Hell)
🤔 콜백 지옥이 발생하는 이유?
결과 먼저:
비동기 함수는 비동기 처리 결과를 외부에 반환할 수 없고, 상위 스코프의 변수에 할당할 수 없다. 따라서 비동기 함수의 처리 결과에 대한 후속 처리는 비동기 함수 내부에서 수행해야 한다. 이러한 후속 처리를 위해서 비동기 함수에 콜백 함수를 전달하게 되고, 후속 처리가 많아질수록 콜백 지옥이 발생하게 된다.
▪️ 비동기 처리 결과를 반환하는 경우를 살펴보자.
다음의 코드는 get 함수를 호출하면 서버의 응답 결과를 반환하도록 하려했지만 결과는 그렇지 않다.
const get = url => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
xhr.onload = () => {
if (xhr.status === 200) {
return JSON.parse(xhr.response);
}
console.error(`${xhr.status} ${xhr.statusText}`);
};
};
const response = get('https://jsonplaceholder.typicode.com/posts/1');
console.log(response); // undefined
- get 함수가 호출되면 XMLHttpRequest 객체 생성, HTTP 요청 초기화, HTTP 요청 전송이 차례로 이루어진다.
- xhr.onload 이벤트 핸들러 프로퍼티에 이벤트 핸들러를 바인딩하고 get 함수가 종료되면서 undefined를 반환한다.(명시적인 반환문이 없으므로)
이때, 함수의 반환값은 함수를 명시적으로 호출을 해줘야 캐치할 수 있다. 따라서 onload 이벤트 핸들러에서 반환된 서버의 응답은 캐치할 수 없다.
▪️ 비동기 처리 결과를 상위 스코프의 변수에 할당하는 경우를 살펴보자.
let todos;
const get = url => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
xhr.onload = () => {
if (xhr.status === 200) {
todos = JSON.parse(xhr.response);
} else {
console.error(`${xhr.status} ${xhr.statusText}`);
}
};
};
get('https://jsonplaceholder.typicode.com/posts/1');
console.log(todos); // undefined --- 1️⃣
console.log(todos); // {userId: 1, id: 1, title: 'occaecati..', body: 'quia suscipit\nsuscipit recusandae..'}
xhr.onload 이벤트 핸들러는 load 이벤트가 발생하면 일단 태스크 큐에 저장되어 대기하다가, 콜 스택이 비면 이벤트 루프에 의해 콜 스택으로 푸시되어 실행된다. 따라서 1️⃣번 시점에 todos를 출력하면 undefined이다.
따라서 비동기 함수를 사용하기 위해 비동기 처리 결과에 대한 후속 처리를 수행하는 콜백함수를 비동기 함수에 전달하는 것이 일반적이다.
const get = (url, successCb, failureCb) => {
const xhr = new XMLHttpRequest();
xhr.open('Get', url);
xhr.send();
xhr.onload = () => {
if (xhr.status === 200) {
successCb(JSON.parse(xhr.response));
} else {
failureCb(xhr.status);
}
};
};
get('https://jsonplaceholder.typicode.com/posts/1', console.log, console.error);
비동기 처리 결과에 대한 후속 처리를 수행하는 비동기 함수가 비동기 처리 결과를 가지고 또다시 비동기 함수를 호출해야 한다면 콜백 함수 호출이 계속 중첩되어 가독성이 나빠지고 복잡해진다. 즉, 콜백 지옥 현상이 나타난다.
get('/step1', (a) => {
get(`/step2/${a}`, (b) => {
get(`/step3/${b}`, (c) => {
get(`/step4/${c}`, (d) => {
console.log(d);
});
});
});
});
2. 에러 처리의 한계
비동기 처리를 위한 콜백 패턴의 가장 큰 문제점은 에러 처리가 곤란한 것이다.
try {
setTimeout(() => {throw new Error('Error!');}, 1000);
} catch {
console.error('캐치한 에러', e);
}
타이머가 만료되면 태스크 큐에 콜백함수가 푸시된다. 콜 스택이 비었을 때 콜백함수 실행 컨텍스트를 생성하여 이벤트 루프에 의해 콜 스택으로 푸시되어 실행된다. 따라서 catch 블록에서는 콜백함수가 발생시킨 에러가 캐치되지 않는다.
+) 이는 콜백 함수 내에 try/catch문을 넣어주면 해결될 것이다.
setTimeout(() => {
try {
throw new Error("error!");
} catch(e) {
console.error('캐치한 에러', e);
}
}, 1000)
프로미스의 생성
- Promise 생성자 함수를 new 연산자와 함께 호출하면 프로미스(= Promise 객체)를 생성한다.
- 프로미스는 ECMAScript 사양에 정의된 표준 빌트인 객체다.
const promise = new Promise((resolve, reject) => {
// Promise 함수의 인수로 전달받은 콜백함수 내부에서 비동기 처리를 수행한다.
if (/* 비동기 처리 성공 */) {
resolve('result');
} else { /* 비동기 처리 실패 */
reject('failure reason');
}
});
- Promise 생성자 함수가 인수로 전달받은 콜백 함수 내부에서 비동기 처리를 수행한다.
- 이때 비동기 처리가 성공하면 콜백 함수의 인수로 전달받은 resolve 함수를 호출하고, 비동기 처리가 실패하면 reject 함수를 호출한다.
- 비동기 처리 진행 상태 정보를 갖는 프로미스 객체가 생성된다.
프로미스 상태 정보 | 의미 | 상태 변경 조건 |
pending | 비동기 처리가 아직 수행되지 않은 상태 | 프로미스가 생성된 직후 기본 상태 |
fulfilled | 비동기 처리가 수행된 상태(성공) | resolve 함수 호출 |
rejected | 비동기 처리가 수행된 상태(실패) | reject 함수 호출 |
생성된 직후의 프로미스는 기본적으로 pending 상태다. 이후 비동기 처리가 수행된 결과에 따라 프로미스의 상태가 변경된다.
- 비동기 처리 성공 ➡️ resolve 함수를 호출해 프로미스를 fullfilled 상태로 변경한다.
- 비동기 처리 실패 ➡️ reject 함수를 호출해 프로미스를 rejected 상태로 변경한다.
- settled 상태: fulfilled 또는 rejected 상태, settled 상태가 되면 다른 상태로 변화할 수 없다.
const fulfilled = new Promise(resolve => resolve(1));
const rejected = new Promise((_, rejected) => rejected(new Error('에러 발생')));
// 개발자 도구에서 각각의 프로미스를 확인해보자.
프로미스의 내부 슬롯인 [[PromiseStatus]]를 통해 비동기 처리 상태 정보와 [[PromiseValue]]를 통해 비동기 처리 결과를 확인할 수 있다. 즉, 프로미스는 비동기 처리 상태와 처리 결과를 관리하는 객체다.
프로미스의 후속 처리 메서드
프로미스의 비동기 처리 상태가 변화하면 이에 따른 후속 처리를 해야 한다.
ex:
프로미스가 fulfilled 상태가 되면 ➡️ 프로미스의 처리 결과를 가지고 후속 처리
프로미스가 rejected 상태가 되면 ➡️ 프로미스의 처리 결과(에러)를 가지고 후속 처리
이러한 후속처리를 위해 후속 처리 메서드를 사용한다.
- 프로미스의 비동기 처리 상태가 변화하면 후속 처리 메서드에 인수로 전달한 콜백 함수가 상태에 따라 선택적으로 호출된다.
- 후속 처리 메서드의 콜백 함수 인수에는 프로미스 처리 결과가 전달된다.
1. Promise.prototype.then
- 두 개의 콜백 함수를 인수로 전달받을 수 있다.
- 첫 번째 콜백 함수는 프로미스가 fulfilled 상태(resolve 함수가 호출된 상태)가 되면 호출한다. 이때, 콜백 함수는 프로미스의 비동기 처리 결과를 인수로 전달받는다.
- 두 번째 콜백 함수는 프로미스가 rejected 상태(reject 함수가 호출된 상태)가 되면 호출된다. 이때, 콜백 함수는 프로미스의 에러를 인수로 전달받는다.
new Promise(resolve => resolve('fulfilled'))
.then(v => console.log(v), e => console.error(e)); // fulfilled
new Promise((_, reject) => reject(new Error('rejected')))
.then(v => console.log(v), e => console.error(e)); // Error: rejected
then 메서드는 항상 프로미스를 반환한다.
- then 메서드의 콜백 함수가 프로미스를 반환하면 그대로 그 프로미스를 반환한다.
- 콜백 함수가 프로미스가 아닌 값을 반환하면 그 값을 암묵적으로 resolve 또는 reject하여 프로미스를 생성해 반환한다.
2. Promise.prototype.catch
- 한 개의 콜백 함수를 인수로 전달받는다.
- catch 메서드의 콜백 함수는 프로미스가 rejected 상태인 경우만 호출된다.
- then(undefined, onRejected)와 동일하게 동작한다.
- 프로미스를 반환한다.
3. Promise.prototype.finally
- 한개의 콜백 함수를 인수로 전달받는다.
- finally 메서드의 콜백 함수는 프로미스의 성공(fulfilled) 또는 실패(rejected)와 상관없이 무조건 한번 호출된다. 따라서, 프로미스의 상태와 상관없이 공통으로 처리해야 할 내용이 있을 때 유용하다.
- 프로미스를 반환한다.
후속 처리 메서드 try, catch, finally를 사용해 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.typicode.com/posts/1")
.then((res) => console.log(res))
.catch((err) => console.error(err))
.finally(() => console.log("Bye"));
프로미스의 에러 처리
- then 메서드의 두번째 콜백 함수로 에러를 처리할 수 있지만 프로미스 체이닝을 이용해 catch 메서드로 에러 처리하는 것을 권장한다.
- then/ catch/ finally 메서드는 언제나 프로미스를 반환하므로 메서드 체이닝이 가능하다.
- catch 메서드를 맨 마지막에 호출하면 비동기 처리에서 발생한 에러(rejected)뿐만 아니라 then 메서드 내부에서 발생한 에러까지 모두 캐치 가능하다.
- 가독성 또한 좋다.
promiseGet("https://jsonplaceholder.typicode.com/todos/1")
.then((res) => console.xxx(res))
.catch((err) => console.error('Error!', err));
프로미스의 정적 메서드
1. Promise.resolve/ Promise.reject
이미 존재하는 값을 래핑해 프로미스를 생성하기 위해 사용한다.
const resolved = Promise.resolve([1, 2, 3]);
// const resolved = new Promise(resolve => resolve([1, 2, 3]);
resolved.then(console.log); // [1, 2, 3]
const rejected = Promise.reject(new Error('Error!'));
// const rejected = new Promise((_, reject) => reject(new Error('Error!'));
rejected.catch(console.log); // Error: Error!
2. Promise.all
여러 개의 비동기 처리를 모두 병렬 처리할 때 사용한다.
- 프로미스를 요소로 갖는 배열 등의 이터러블을 인수로 전달받는다.
- 전달받은 모든 프로미스가 모두 fulfilled 상태가 되면 모든 처리 결과를 배열에 저장해 새로운 프로미스를 반환한다.
- 이때, 첫 번째 프로미스가 3초 후에 처리되어 가장 늦게 프로미스를 반환하여도 배열에서 처리 순서가 보장된다.
const requestData1 = () => new Promise((resolve) => setTimeout(() => resolve(1), 3000));
const requestData2 = () => new Promise((resolve) => setTimeout(() => resolve(2), 2000));
const requestData3 = () => new Promise((resolve) => setTimeout(() => resolve(3), 1000));
// 세 비동기 처리는 서로 의존하지 않고 개별적으로 수행되므로 다음과 같이 순차적으로 처리할 필요가 없다.
/*
const res = [];
requestData1()
.then((data) => {
res.push(data);
return requestData2();
})
.then((data) => {
res.push(data);
return requestData3();
})
.then((data) => {
res.push(data);
console.log(res);
})
.catch(console.error);
*/
Promise.all([requestData1(), requestData2(), requestData3()])
.then(console.log)
// .then((res) => console.log(res))
.catch(console.error);
- 인수로 전달받은 배열의 프로미스 중 하나라도 rejected 상태가 되면 즉시 종료한다.
Promise.all([
new Promise((_, reject) => setTimeout(() => reject(new Error("Error 1")), 3000)),
new Promise((_, reject) => setTimeout(() => reject(new Error("Error 2")), 2000)),
new Promise((_, reject) => setTimeout(() => reject(new Error("Error 3")), 1000)),
])
.then(console.log)
.catch(console.log); // Error: Error 3
- 인수로 전달받은 이터러블의 요소가 프로미스가 아닌 경우 Promise.resolve 메서드를 통해 프로미스로 래핑한다.
Promise.all([
1, // -> Promise.resolve(1)
2, // -> Promise.resolve(2)
3, // -> Promise.resolve(3)
])
.then(console.log)
.catch(console.log);
3. Promise.race
가장 먼저 fulfilled 상태가 된 프로미스의 처리 결과를 resolve하는 새로운 프로미스를 반환한다.
- 프로미스를 요소로 갖는 배열 등의 이터러블을 인수로 전달받는다.
- 인수로 전달받은 배열의 프로미스 중 하나라도 rejected 상태가 되면 즉시 종료한다.
Promise.race([
new Promise((resolve) => setTimeout(() => resolve(1), 3000)),
new Promise((resolve) => setTimeout(() => resolve(2), 2000)),
new Promise((resolve) => setTimeout(() => resolve(3), 1000)),
])
.then(console.log) // 3
.catch(console.log);
4. Promise.allSettled
프로미스를 요소로 갖는 배열 등의 이터러블을 인수로 전달받아 전달받은 프로미스가 모두 settled 상태(fulfilled, 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 <anonymous>:3:54}
]
*/
마이크로태스크 큐
마이크로태스크 큐에는 프로미스의 후속 처리 메서드의 콜백 함수가 일시 저장된다.
- 마이크로태스크 큐는 태스크 큐와는 별도의 큐이다.
- 태스크 큐에는 그외의 비동기 함수의 콜백 함수나 이벤트 핸들러가 일시 저장된다.
- 마이크로태스크 큐가 태스크 큐보다 우선순위가 높다. 즉, 이벤트 루프는 콜 스택이 비면 먼저 마이크로태스크 큐에서 대기하고 있는 함수를 가져와 실행한다. 이후 마이크로태크스 큐가 비면 태스크 큐에서 대기하고 있는 함수를 가져와 실행한다.
fetch
HTTP 요청 전송 기능을 제공하는 클라이언트 사이드 Web API이다.
- XMLHttpRequest 객체보다 사용법이 간단하고 프로미스를 지원하기 때문에 비동기 처리를 위한 콜백 패턴의 단점에서 자유롭다.
- IE에서 사용 불가하다.
- fetch(url [, options]) : HTTP 요청을 전송할 URL과 HTTP 요청 메서드, HTTP 요청 헤더, 페이로드 등을 설정한 객체를 전달한다.
fetch 함수는 HTTP 응답을 나타내는 Response 객체를 래핑한 Promise 객체를 반환한다.
fetch("https://jsonplaceholder.typicode.com/todos/1")
.then((response) => response.json())
.then((json) => console.log(json));
// {userId: 1, id: 1, title: 'delectus aut autem', completed: false}
❗️fetch 함수 사용 시에는 에러 처리에 주의해야 한다.
fetch 함수가 반환하는 프로미스는 기본적으로 404 Not Found나 500 Internal Server Error와 같은 HTTP 에러가 발생해도 에러를 reject하지 않고 불리언 타입의 ok 상태를 false로 설정한 reponse 객체를 resolve한다. 네트워크 장애(ex. 오프라인)나 CORS 에러에 의해 요청이 완료되지 못한 경우에만 프로미스를 reject한다.
(Response 객체의 프로퍼티 ok는 응답 성공 여부를 의미하는 boolean 값을 갖는다.)
// 각 fetch 함수가 반환한 프로미스 객체의 response 객체의 ok 프로퍼티를 확인해보자.
fetch("https://jsonplaceholder.typicode.com/todos/1");
fetch("https://jsonplaceholder.typicode.com/xxx/1");
// 404 에러 발생 -> 에러를 reject 하지 않고 Reponse 객체를 resolve 한다.
fetch("https://jsonplaceholder.typicode.com/xxx/1")
.then(() => console.log("ok")) // ok
.catch(() => console.log("error"));
fetch 함수를 사용하면 Resonponse 객체의 ok 상태를 확인해 명시적으로 에러를 처리해줘야 한다.
fetch("https://jsonplaceholder.typicode.com/xxx/1")
.then((response) => {
if (!response.ok) throw new Error(response.status);
return response.json();
})
.then((todo) => console.log(todo))
.catch((err) => console.error(err));
✅ axios - 프로미스 기반 HTTP 클라이언트 라이브러리
▪️ 모든 HTTP 에러를 reject하는 프로미스를 반환한다.
▪️ 모든 에러를 catch에서 처리할 수 있어 편리하다.
fetch 함수를 통한 HTTP 요청하기
const request = {
get(url) {
return fetch(url);
},
post(url, payload) {
return fetch(url, {
method: "POST",
headers: {"content-Type": "application/json"},
body: JSON.stringify(payload),
});
},
patch(url, payload) {
return fetch(url, {
method: "PATCH",
headers: {"content-Type": "application/json"},
body: JSON.stringify(payload),
});
},
delete(url) {
return fetch(url, {method: "DELETE"});
},
};
const url = "https://jsonplaceholder.typicode.com/posts";
// 1. GET 요청
request
.get(`${url}/1`)
.then((res) => {
if (!res.ok) throw new Error(res.status);
return res.json();
})
.then((todos) => console.log(todos))
.catch((err) => console.error(err));
// 2. POST 요청
request
.post(`${url}`, {
userId: 10,
id: 100,
title: "JavaScript",
body: "learn JavaScript",
})
.then((response) => {
if (!response.ok) throw new Error(response.status);
return response.json();
})
.then((todo) => console.log(todo))
.catch((err) => console.error(err));
// 3. PATCH 요청
request
.patch(`${url}/1`, {
title: "learn fetch",
})
.then((response) => {
if (!response.ok) throw new Error(response.status);
return response.json();
})
.then((todo) => console.log(todo))
.catch((err) => console.error(err));
// 4. DELETE 요청
request
.delete(`${url}/1`)
.then((response) => {
if (!response.ok) throw new Error(response.status);
return response.json();
})
.then((todo) => console.log(todo))
.catch((err) => console.error(err));
'JavaScript > 모던 자바스크립트 Deep Dive' 카테고리의 다른 글
32. Ajax (0) | 2022.11.01 |
---|---|
31. 비동기 프로그래밍 (0) | 2022.10.31 |
30. 타이머 (0) | 2022.10.31 |
29. 이벤트 (0) | 2022.10.26 |
19. 클로저 (0) | 2022.10.12 |