오늘은 Promise와 async/await를 알아보자.
예전에 React를 공부하며 느꼈던 것이, Promise에 대한 이해 없이는 절대 프론트엔드를 할 수가 없다. 물론 다른 지식도 중요하지만, 서버에 데이터를 요청하는 작업에서 Promise를 모른다는 것이 있을 수가 없는 일이다. 근데 나는 그러고 있었다...
Promise는 비동기 코드를 컨트롤하기 위해 만들어 졌다고 할 수 있다.
Promise가 등장하기 이전에는 JS에서 비동기 코드를 만들기 위해 콜백 함수를 사용했었다.
step1(function (value1) {
step2(function (value2) {
step3(function (value3) {
step4(function (value4) {
step5(function (value5) {
step6(function (value6) {
// Do something with value6
});
});
});
});
});
});
step1이 실행된 뒤에 step2가 실행되고, step3이 실행되고...
서버에 데이터를 요청하고 요청에 실패했을 때의 에러 처리 작업까지 하면 코드는 더 엉망진창이 된다.
이것이 Promise가 등장하기 이전의 비동기 코드 컨트롤이다.
그럼 Promise가 등장하고 난 후는 어떠한가?
new Promise(function(resolve) {
resolve(value1);
}).then(function(value2) {
return value2;
}).then(function(value3) {
return value3;
}).then ...
이런 느낌의 코드가 된다. 속이 울렁거리는 콜백 피라미드로 부터 벗어난 것이다.
Promise를 사용하면 개발자는 콜백 지옥없이도 비동기적인 작동을 쉽게 구현할 수 있으며 그로 인해 코드 생산성도 높아지고 더 질 좋은 코드를 짤 수 있게 된다.
이것이 Promise를 사용하는 이유다.
Promise를 처음 봤을 때는 꽤나 혼란스러웠다. new연산자가 붙었으니 생성자인데, 생성자 함수가 콜백 함수를 인자로 받고 있다. 또 콜백 함수는 resolve와 reject같은 걸 인자로 받고 있고, 뭔가 반환하고 있지도 않고... 몬가... 몬가 어려웠다.
Promise 생성자에 대해서 MDN은 이렇게 설명하고 있다.
new Promise(executor)
매개변수
executor :
resolve 및 reject 인수를 전달할 실행 함수. 실행 함수는 프로미스 구현에 의해 resolve와 reject 함수를 받아 즉시 실행됩니다(실행 함수는 Promise 생성자가 생성한 객체를 반환하기도 전에 호출됩니다). resolve 및 reject 함수는 호출할 때 각각 프로미스를 이행하거나 거부합니다. 실행 함수는 보통 어떤 비동기 작업을 시작한 후 모든 작업을 끝내면 resolve를 호출해 프로미스를 이행하고, 오류가 발생한 경우 reject를 호출해 거부합니다. 실행 함수에서 오류를 던지면 프로미스는 거부됩니다. 실행 함수의 반환값은 무시됩니다.
Promise 생성자의 콜백 함수는 excutor라는 이름이고, Promise가 인스턴스를 반환하기 전에 실행된다. 또한 함수 내에서 resolve를 호출하면 Promise가 이행되고, reject를 호출하면 Promise가 거부된다.
그러니까 executor 내부에서 서버에 데이터를 요청하고, 요청에 성공하면 resolve를, 실패하면 reject를 호출하면 되는 듯 하다.
그리고 Promise를 사용할 때에 빠질 수가 없는 메서드가 있으니, 바로 Promise.prototype.then() 메서드이다.
then 메서드는 인자로 콜백 함수 2개를 받는다.
p.then(onFulfilled, onRejected);
p.then(function(value) {
// 이행
}, function(reason) {
// 거부
});
onFulfilled 함수는 해당 Promise객체가 이행되면 실행되고, onRejected는 거부되면 실행된다. onFulfilled의 인자값은 해당 Promise객체의 resolve함수의 인자가 되고, onRejected의 인자값은 reject함수의 인자가 된다.
하지만 오류 처리를 위해서 then의 onRejected를 사용하기보다 throw/catch문을 사용하길 권장하고 있다. 이유는 Promise의 resolve가 호출되어도 then메서드 내부에서 오류가 나는 경우, then의 onRejected에서는 감지하지 못 하기 때문이다.
그래서 Promise생성자를 통해 데이터를 호출하면 아래와 같은 느낌이다.
const promise1 = new Promise((resolve, reject) => {
const res = $.get('user/?id=1');
if(res.status === '200') {
resolve(res);
} else(res.status === '404') {
reject(`error: ${res.status}`);
}
})
promise1.then((res) => {
//res를 가지고 뭔가 작업을 함
}).catch((err) => {
//err를 가지고 뭔가 작업을 함
})
함수에 Promise 기능을 추가하려면 함수가 Promise를 반환하면 된다.
function promiseFunction(url) {
return new Promise((resolve, reject) => {
const res = $.get(url);
if(res.status === '200') {
resolve(res);
} else(res.status === '404') {
reject(`error: ${res.status}`);
}
})
}
promiseFunction('user/?id=1').then((res) => {
//res를 가지고 뭔가 작업을 함
}).catch((err) => {
//err를 가지고 뭔가 작업을 함
})
아! 콜백을 보고 있다가 Promise를 보고 있으니 너무나 편안하다.
또한 then 메서드에서 그냥 반환한 값은 Promise.resolve()로 반환한 것과 같기 때문에 체이닝이 가능해진다.
function promiseFunc(string) {
return new Promise((resolve) => {
resolve(string);
})
}
promiseFunc("strawberry")
.then((data) => {
return data+'1';
})
.then((data) => {
return data+'2';
})
then 메서드로 체이닝이 가능하다. 반환된 값은 이행된 상태(fulfilled)의 Promise이다.
그리고 then 메서드 내부에서 발생한 오류 또한 catch 메서드로 잡아낼 수 있다.
function promiseFunc(string) {
return new Promise((resolve) => {
resolve(string);
})
}
promiseFunc("strawberry")
.then((data) => {
throw new Error("im error"); //error가 throw되면 바로 catch문으로 이동한다.
return data+'1';
})
.then((data) => { //error가 throw된 then과 catch사이의 then은 실행되지 않는다.
console.log(data);
return data+'2';
})
.catch((error) => {
return error;
})
그리고 나는 궁금해졌다. error를 반환받았는데 왜 PromiseState는 fulfilled인가? rejected가 되야하는 게 아닌가?
아무튼 catch가 반환하는 Promise도 이행된(fulfilled) 상태이기 때문에, catch문 뒤에 then을 붙여도 잘 작동한다.
function promiseFunc(string) {
return new Promise((resolve) => {
resolve(string);
})
}
promiseFunc("strawberry")
.then((data) => {
throw new Error("im error"); //error가 throw되면 바로 catch문으로 이동한다.
return data;
})
.catch((error) => {
console.log(error);
})
.then((data) => { //catch문 이후의 then은 잘 실행된다.
console.log('잘 실행됩니다.');
console.log(`data: ${data}`);
})
그리고 생긴 의문은 맨 마지막에 출력된 Promise이다. 확인해보니 then 메서드는 메서드 내부에서 아무것도 return 하지 않아도 Promise를 반환한다. 하지만 result는 undefined인 상태이다.
지금까지 Promise에 대해 알아보았다. 이 얼마나 간편하고 매력적인가.
그런데 개발자들은 Promise에서도 불편함을 느꼈는지, 더 사용자 친화적인 비동기 컨트롤 도구를 만들어낸다. 바로 async/await이다.
async/await를 사용한 코드를 보면 알 수 있다.
async function getUser() {
const res = await getSomething('domain.com/users/2');
if (res.state === 200) {
console.log(res);
} else if (res.state === 404) {
console.log(res.state);
}
}
이것은 getSomething이라는 Promise 반환 함수가 인자로 받은 url에 데이터를 요청하고 응답할 때 까지 기다린 뒤 요청이 성공했다면 응답을 출력하고 실패했다면 응답의 HTTP state를 출력하는 함수이다.
단지 async/await 두 단어만으로 이 비동기적인 과정을 더할 나위 없이 동기적인 코드로 나타내고 있다.
이것이 async/await를 사용하는 이유이다.
async가 붙은 function 내부에서 await는 Promise가 fulfill 되거나 reject 될 때 까지 async function을 일시정지하고, fulfill되면 그 값을 반환하고 async함수를 일시정지된 부분부터 시작한다.
reject되었다면 그 값을 throw한다.
즉 Promise와 then을 대체할 수 있다는 것이다.
async/await는 사실 Promise에 기반하여 만들어 졌다. 이는 async/await가 Promise를 더 개발자 친화적으로 사용하기 위해 만들어진 도구라는 의미이다.
async는 function 앞에 위치해서 해당 함수를 async function으로 바꾼다.
async function은 항상 Promise를 반환하고, 함수 내에서 Promise가 아닌 값을 반환하면 Promise.resolve()로 반환값을 감싸서 호출한다.
async function a() {
return 1;
}
a().then((data) => {
console.log(data); //1
})
then 메소드가 async가 return한 1을 무사히 받아서 출력하는 모습이다.
await는 async function 내부에서만 사용 가능하다. 만일 async function이 아닌 곳에서 await를 사용하면 syntax error를 일으킨다.
await는 Promise 앞에 붙어서 Promise가 값을 반환할 때 까지 기다린다.
async function a() {
const promise1 = new Promise((resolve) => {
setTimeout(() => {
resolve("blueberry");
}, 1000);
})
const res = await promise1;
console.log(res);
return res;
}
a().then((data) => {
console.log(data);
})
setTimeout을 통해 Promise.resolve의 반환을 비동기적으로 만들었으나 undefined를 출력하지 않고 blueberry를 잘 출력하고 있다. await가 이것을 가능하게 만들고 있다.
이 코드에서 내가 궁금한 것은, res를 return했는데 Promise의 result가 undefined이다.
await를 썼으니 blueberry가 Promise.resolve()로 감싸져서 호출되는 게 아닌가? 싶어서 then 메소드를 사용했는데 then 메소드는 blueberry를 잘 받아서 출력했다.
이에 대해선 나중에 알아봐야겠다.
또한 await가 붙은 Promise가 reject를 호출하면 async function은 error를 throw한다.
async function a() {
try {
await Promise.reject(new Error('error'));
} catch(error) {
console.log(error);
}
}
a();
await한 Promise가 reject를 호출하자 catch문이 error를 잡아내는 모습이다.
오늘은 JS의 비동기 작동을 컨트롤하는 콜백 함수, Promise, async/await에 대해 순서대로 알아보았다. 이전에 react 교재를 보며 라이브러리를 사용할 때, Promise에 대해 전혀 몰랐기 때문에 많이 헤매었다.
이 글을 쓰고 난 뒤에 다시 같은 부분을 본다면 많이 다른 시야를 가지고 볼 수 있을 것이라 생각한다. react에 대해 배우게 될 날이 머지 않았고 기대가 된다.
다음은 이터레이터와 제네레이터에 대해서 알아보려 한다. Redux를 사용할 때에 이터레이터에 대한 개념이 나왔고, 이터레이터는 Promise와 밀접한 관련이 있다는 것 까지는 알고 있다.
어서 React를 배워서 그럴싸한 프로젝트를 진행하고 싶다.
그렇기 때문에 더더욱 기초를 다질 필요가 있다. 그렇게 믿는다.