Callback / Promise / Async & Await
1. Callback
코드는 기본적으로 순차적으로 실행된다. 그런데웹 서버로 보낸 요청에 대한 응답을 처리하는 코드를 작성해야할 경우 서버로 부터 응답이 언제 올지 알 수 없어 코드를 작성할 때 어려움이 생긴다. 이러한 상황에서 사용할 수 있는 것이 콜백함수이다. 콜백함수란 어떤 함수의 인자로 전달되는 또다른 함수이다. 콜백함수를 사용하면 위와 종료 타이밍을 맞추기 어려운 함수가 실행 된 다음에 콜백 함수가 실행되도록 실행 순서를 조정할 수 있다.
const getDataFromFile = function (filePath, callback) {
fs.readFile(filePath, 'utf-8', (err, data) => {
if (err) { callback(err, null); }
else { callback(null, data); }
});
};
getDataFromFile('README.md', (err, data) => console.log(data));
console.log('readFile 결과'); //-> readFile이 끝나지 않았을 때 실행될 가능성이 있음 -> 오류
readFile함수는 비동기적으로 파일을 읽는 메소드이다. 즉, readFile 다음에 또다른 코드 A가 있다면 readFile이 끝나지 않은 상태에서 A가 실행될 수 있다는 것이다. 그런데 A에서 반드시 readFile을 한 결과를 사용해야 할 경우 readFile이 아직 파일 읽기 도중이라면 파일을 읽은 데이터가 없으므로 오류가 발생할 수 있다. 이러한 상황을 해결하기 위한 것이 콜백함수이다.
readFile 함수의 3번째 파라미터는 콜백함수이다. 위 코드에서 readFile의 비동기적 파일 읽기가 끝나면 콜백함수를 실행한다. 즉, 콜백함수는 readFile이 끝나기를 기다렸다가 readFile이 끝나면 호출되므로 무사히 readFile의 결과를 출력할 수 있는 것이다.
2. Promise
콜백함수를 통해 비동기적 작업의 결과물을 처리할 수 있었다. 그런데 비동기 함수수가 콜백함수A를 부르고, A가 A의 결과 값을 처리하기 위해 콜백함수 B를 부르고 B의 결과를 처리하기 위해 콜백함수 C를 부르고... 가 반복되어 콜백함수가 매우 많이 중첩되는 경우가 발생한다. 이러한 경우 코드의 가독성이 매우 떨어지며 유지보수도 어려워진다. 이러한 경우 Promise를 사용하여 이 현상을 완화할 수 있다.
const fs = require('fs');
const p1 = new Promise((resolve, reject) => {
fs.readFile('README.md', 'utf-8', (err, data) => { //비동기 작업
if (err) { reject(err); } //작업이 실패하면 reject 호출
else { resolve(data); } //작업이 성공하면 resolve 호출
});
})
//1. then만 사용
p1.then((data) => { console.log(data); }, //resolve: 파일 읽기 내용 출력
(err) => { console.log(err) } ); //reject: 파일 읽기 에러 출력
//2. then-catch 사용
p1.then((data) => { console.log(data); }) //resolve: 파일 읽기 내용 출력
.catch((err) => { console.log(err) }); //reject: 파일 읽기 에러 출력
프로미스란 복잡한 비동기 처리를 패턴화한 인터페이스이다. Promise객체는 비동기 작업 관련 처리 로직을 담고 있으며 이 객체에 비동기 작업 결과에 따른 콜백함수. 즉, 성공 시 실행될 작업(함수)와 실패 시 해야할 작업(함수)를 등록하여 사용한다.
1) Promise Constructor
위와 같이 프로미스는 생성자에 비동기 처리가 포함된 함수를 인자로 받는다. 이 때 이 함수의 파라미터는 비동기 작업이 성공시 실행될 함수 resolve, 비동기 작업이 실패시 실행될 함수 reject이다.
2) then(), catch()
프로미스 객체는 then()이라는 메소드를 통해 비동기 작업의 콜백 함수인 resolve와 reject 함수를 등록할 수 있다. then의 첫 번째 파라미터는 resolve, 두 번째 파라미터는 reject가 된다. 만약 resolve와 reject를 분리하여 등록하고 싶거나 then()으로 등록한 resolve에서 발생한 오류도 함께 처리하고 싶다면 then과 catch를 함께 사용한다. then에서는 resolve함수만 등록하고 catch에서는 reject함수만 등록하는 것이다. catch는 then(undefined, reject)와 동일한 의미이기 때문이다.
3) Promise Chaining
프로미스 객체의 then() 메소드는 새로운 프로미스 객체를 리턴한다. 따라서 then()을 호출한 결과에 다시 then()메소드를 연결할 수 있다. 또한 catch를 가장 마지막에 작성함으로써 then()메소드로 연결된 작업 모두의 에러처리를 한 번에 해결할 수 있다. 위 그림은 프로미스 체인을 나타낸 것이다. then을 이용하여 여러개의 작업(함수)을 순차적으로 실행할 수 있다. 또한 처리 과정 도중에 오류가 발생하면 오류가 발생한 시점과 상관없이 마지막 catch에서 처리된다.
const p2 = new Promise((resolve) => {
setTimeout(resolve("promise2"), 2000);
});
const p3 = p2.then((data) => (data + "then1"))
const p4 = p3.then((data) => (data + "then2"))
.then((data) => (data + "then3"))
그렇다면 then()을 통해 리턴된 새로운 프로미스 객체와 원래 프로미스 객체는 어떤 차이가 있을까? 위 코드는 프로미스 객체인 p2와, p2를 기반으로 then을 통해 리턴된 새로운 프로미스 객체인 p3, p3를 기반으로 then을 통해 리턴된 새로운 프로미스 객체인 p4이다. 세 프로미스 객체의 내부를 비교해보자.
프로미스 객체는 [[PromiseState]]와 [[PromiseResult]]라는 내부 프라퍼티를 갖고있다. p2, p3, p4의 [[PromiseResult]]에 주목해보자. p2의 값은 프로미스 객체 생성 시 resolve 콜백 함수의 인자로 쓰인 값이며, p3의 값은 p2의 [[PromiseResult]] 값에 "then1"을 더한 값이다. 즉, 프로미스 객체의 [[PromiseResult]]값은 프로미스 객체 생성 시엔 resolve 콜백 함수의 인자로 쓰인 값이며, then()을 통해 리턴된 새로운 프로미스 객체의 [[PromiseResult]] 값은 then()을 통해 등록된 resolve 콜백 함수의 리턴값인 것이다.
p4의 생성과정은 위와 같다. 원본인 P2의 [[PromiseResult]]가 then()으로 등록된 resolve 함수의 인자로 입력되고, then()은 resolve 함수의 리턴 값을 [[PromiseResult]]로 갖는 프로미스 객체(P3)를 리턴한다. 새로운 프로미스 객체에 다시 then()으로 새로운 resolve 함수를 등록하면, 해당 객체의 [[PromiseResult]] 가 두 번째 then()으로 등록된 새로운 resolve 함수의 인자로 입력되고, then()은 resolve 함수의 리턴 값을 [[PromiseResult]]로 갖는 프로미스 객체를 리턴한다. 리턴된 프로미스 객체의 [[PromiseResult]]가 다시 마지막 then()으로 등록된 resolve 함수의 인자로 입력되고, 마지막 then()은 resolve 함수의 리턴값을 [[PromiseResult]]로 갖는 프로미스 객체(P4)를 리턴하게 된다. 이렇게 then으로 결과값을 연결하여 처리할 수 있다.
4) State
프로미스의 또다른 내부 프라퍼티는 [[PromiseState]] 이다. [[PromiseState]]는 프로미스 객체의 상태를 나타나는 프라퍼티로 Pending(unresolved), Fulfilled(has-resolution), Rejected(has-rejection) 3가지 상태가 존재한다. 프로미스 객체가 생성된 초기에는 [[PromiseState]]의 값은 Pending 상태이다. 그 후 비동기작업이 성공하여 resolve가 호출할 경우 Fullfiled, 실패하여 reject가 호출할 경우에는 Rejected 상태로 변경된다.
5) then
const p2 = new Promise((resolve) => {
console.log("111"); //1
setTimeout(() => {
resolve("p2");
console.log("222"); //2
}, 0);
})
p2.then((data2) => {
console.log("333"); //3
})
console.log("444"); //4
then에 대해 더 자세히 알아보자. 위 코드에서 콘솔에 "111", "222", "333", "444" 중 어떤 문자열이 먼저 출력될까? 또한 1,2,3에서 프로미스 객체 p2의 State는 어떤 값을 가리키고 있을까? 답은 "111" -> "444" -> "222" -> "333" 순으로 출력된다. 그 이유는 프로미스 객체는 생성과 동시에 실행되며, resolve가 비동기적으로 호출되기 때문이다.
프로미스 객체는 new 키워드로 생성과 동시에 인자로 들어온 함수를 실행한다. 따라서 가장 먼저 "start"가 출력된다.
resolve()는 비동기적으로 처리되므로 코드 실행 도중 resolve()를 만났다고 해서 바로 함수 스택에 추가되지 않는다. 동기적으로 수행되는 나머지 코드가 다 실행되어 함수 스택이 빈 상태가 되어야 비로소 then으로 등록된 콜백함수가 함수 스택에 추가되는 것이다. 따라서 2번 코드("p2")가 3번 코드("333")보다 먼저 실행되는 것이다. 또한 setTImeout()의 첫 번째 인자도 콜백함수이므로 4번 코드("444")가 가장 먼저 실행된다.
(이 부분이 이해가 안간다면 아래 링크 글의 영상을 보고오자)
2021/02/03 - [내가읽은글] - 어쨌든 이벤트 루프는 무엇입니까?
결론적으로 then()과 catch()는 프로미스 객체의 상태가 변화할 때 한 번만 호출될 콜백 함수를 등록하는 메소드라 이해할 수 있다. then()으로 등록된 resolve()와 reject()는 프로미스 객체의 상태 변화라는 이벤트의 콜백함수이므로, 상태가 변화되었을 때를 기다린 후 작업 큐에 추가된다. 작업 큐에 추가된 함수들은 바로 함수 스택에 추가되는 것이 아니라, 동기적 코드들이 모두 수행되어 함수 스택이 빈 상태일 때 함수 스택에 추가된다.
6) Promise.all()
const p1 = new Promise((resolve) => {
setTimeout(resolve("p1"), 1000);
});
const p2 = new Promise((resolve) => {
setTimeout(resolve("p2"), 2000);
});
Promise.all([p1, p2]).then((valueArr) => {console.log(valueArr);}) //["p1", "p2"]
Promise.all()은 프로미스 객체들을 배열로 받아 모든 프로미스 객체들의 상태가 Fullfiled가 되었을 때 then()으로 등록한 한수를 호출한다. Promise.all()의 인자는 프로미스 객체의 배열이며, 리턴 값은 배열 내 프로미스 객체들의 result 값의 배열을 result로 갖는 프로미스 객체이다. 따라서 Promise.all()의 결과인 프로미스 객체에 then()으로 resolve() 등록할 때 resolve의 인자로 배열이 넘어온다는 점을 고려해야 한다.
프로미스는 콜백 헬을 해결하는 역할이라 알려져 있지만 실제로는 프로미스도 중첩이 가능하므로 또다른 프로미스 헬을 발생시킬 가능성이 있다. 사실 프로미스의 목적은 콜백 헬의 해결이라기 보다는 통일된 형태로 데이터를 체이닝할 수 있어 비동기 처리를 쉽게 다루는 것에 있다.
추가적으로 Promise.all()은 각 프로미스 객체들의 비동기 작업을 병렬로 수행하므로 전체 처리 속도를 단축할 수 있는 장점이 있다.
3. Async & Await
async function getNewsAndWeatherAsync() {
const newsData = await fetch(newsURL)
.then((response) => response.json())
const weatherData = await fetch(weatherURL) //fetch도 promise를 리턴한다.
.then((response2) => response2.json())
let obj = { news: newsData, weather: weatherData };
return obj; //obj를 [[PromiseResult]] 로 갖는 프로미스 객체를 리턴한다.
}
Async & Await는 프로미스 객체를 좀 더 편하게 사용할 수 있는 문법이다. 위와 같이 await를 사용할 곳을 둘러싼 함수에 async 키워드를 쓰고, 프로미스 객체 앞에 await를 쓰면 프로미스 객체의 result를 추출할 수 있다.
위 코드에서 getNewsAndWeatherAsync() 함수가 객체를 리턴하는 것처럼 보이지만 실제로는 프로미스 객체를 리턴한다. async 키워드와 함께 사용되어있기 때문이다. async 키워드가 사용된 함수는 프라미스를 반환하며 프라미스가 아닌 값을 반환하는 경우 반환 값을 result로 갖는 프로미스를 반환한다. (위 코드에서는 await가 promise를 리턴함을 보여주기 위해 await와 then을 혼용해서 사용하였지만, await를 썼다면 await만 사용하는 것을 추천한다.)
www.hanbit.co.kr/store/books/look.php?p_code=E5027975256