async: 비동기 작업을 만드는 손쉬운 방법
async 키워드는 함수를 선언할 때 붙여줄 수 있습니다. async 키워드가 붙은 함수를 async 함수로, async 가 없는 함수는 일반 함수라고 부르도록 하겠습니다. 의미를 생각해본다면 async 함수는 비동기 작업 그 자체를 뜻한다는 말일 것 같은데, 실제로 우리가 어떻게 사용해볼 수 있을까요?
async 함수는 Promise 와 굉장히 밀접한 연관을 가지고 있는데, 기존에 작성하던 executor 로부터 몇 가지 규칙만 적용한다면 new Promise(…) 를 리턴하는 함수를 async 함수로 손쉽게 변환할 수 있습니다.
- 함수에 async 키위드를 붙입니다.
- new Promise... 부분을 없애고 executor 본문 내용만 남깁니다.
- resolve(value); 부분을 return value; 로 변경합니다.
- reject(new Error(…)); 부분을 throw new Error(…); 로 수정합니다.
자 그럼 기다릴 것 없이 바로 직전에 작성했던 startAsync 함수를 async 함수로 바꾸어봅시다.
// 기존
// function startAsync(age) {
// return new Promise((resolve, reject) => {
// if (age > 20) resolve(`${age} success`);
// else reject(new Error("Something went wrong"));
// });
// }
async function startAsync(age) {
if (age > 20) return `${age} success`;
else throw new Error(`${age} is not over 20`);
}
setTimeout(() => {
const promise1 = startAsync(25);
promise1
.then((value) => {
console.log(value);
})
.catch((error) => {
console.error(error);
});
const promise2 = startAsync(15);
promise2
.then((value) => {
console.log(value);
})
.catch((error) => {
console.error(error);
});
}, 1000);
25 success
Error: 15 is not over 20
at startAsync (/home/taehoon/Desktop/playground-nodejs/index.js:11:14)
at Timeout._onTimeout (/home/taehoon/Desktop/playground-nodejs/index.js:23:20)
at listOnTimeout (internal/timers.js:554:17)
at processTimers (internal/timers.js:497:7)
놀랍게도 완전히 똑같이 동작합니다. 세부적으로야 동작이 미묘하게 다를 수는 있겠지만 실제로 사용하는 입장에서 차이점을 느낄 수 없어요! 그래서 우리는 다음 진리를 얻게 되었습니다.
- async 함수의 리턴 값은 무조건 Promise 입니다 !
이 단순한 문장에 의해서 우리는 async 함수를 일반 함수처럼 사용할 수 없다는 걸 절실히 깨닫게 되었습니다. (혹은 앞으로 무수히 깨닫게 될 것입니다….) 분명히 위 함수에서는 우리가 문자열을 리턴했는데, promise1과 promise2 는 문자열이 아닙니다! 이게 무슨 일입니까!! 네… 우리는 무조건 async 함수를 실행시킨 뒤 then 과 catch 를 활용하여 흐름을 제어해야 합니다. 정말 익숙하지 않습니다. 비동기 작업을 하려면 지금까지 써왔던 코딩의 느낌을 모두 바꿔야 할까요? 아니오, 그렇지 않습니다. async 함수 안에서는 await 를 쓸 수 있습니다.
또 한가지 사소하게 다른 점은, 에러 메시지가 조금 줄어들었습니다. 기존에는 6줄이었는데, 지금은 4줄이 되었습니다.
await: Promise 가 끝날 때까지 기다리거라.
await 는 왠지 wait 이 있으니까 기다리라는 뜻 같습니다. 맞습니다. await 는 Promise 가 fulfilled 가 되든지 rejected 가 되든지 아무튼 간에 끝날 때까지 기다리는 함수입니다. 쓰임새는 그렇구요, await 은 또 쓸 수 있는 제약 조건이 있습니다. 바로 async 함수에서만 사용할 수 있습니다. 왜 await 은 async 함수 내에서만 쓸 수 있는가는 조금 뒤에 알아보고, 일단은 어떻게 사용할 수 있는지 코드를 확인해봅시다.
function setTimeoutPromise(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(), ms);
});
}
async function startAsync(age) {
if (age > 20) return `${age} success`;
else throw new Error(`${age} is not over 20`);
}
async function startAsyncJobs() {
await setTimeoutPromise(1000);
const promise1 = startAsync(25);
try {
const value = await promise1;
console.log(value);
} catch (e) {
console.error(e);
}
const promise2 = startAsync(15);
try {
const value = await promise2;
console.log(value);
} catch (e) {
console.error(e);
}
}
startAsyncJobs();
결과는 아래와 같습니다.
25 success
Error: 15 is not over 20
at startAsync (/home/taehoon/Desktop/playground-nodejs/index.js:17:14)
at startAsyncJobs (/home/taehoon/Desktop/playground-nodejs/index.js:29:20)
본격적으로 await 가 어떤 일을 하는지 알아보기 전에 자잘한 변경사항부터 짚고 넘어갑시다.
- setTimeout 을 Promise 버전으로 하여 setTimeoutPromise 함수를 새로 만들었습니다. 이 함수는 setTimeout 함수를 활용하여 지정된 ms 만큼 기다린 후 resolve 를 호출합니다. 이렇게 만든 Promise 의 then 으로 다음 동작을 정의할 수 있습니다. then 동작은 resolve 함수가 호출되면 실행된다고 했었지요? 자연스럽게 ms 만큼 기다린 후 다음 동작으로 넘어갑니다.
- startAsyncJobs 함수를 새로 만들었습니다. 이 함수 내에서 await 을 사용하기 위해 async 함수로 정의내린 후, 코드의 마지막 부분에서 호출함으로써 비동기 작업을 시작했습니다. 기존의 then 과 catch 하던 작업들은 모두 이 함수 내에 있습니다.
이젠 await 의 특성을 봅시다.
- 문법적으로 await [[Promise 객체]] 이렇게 사용합니다.
- await 은 Promise 가 완료될 때까지 기다립니다. 그러므로 setTimeoutPromise 의 executor 에서 resolve 함수가 호출될 때까지 기다립니다. 그 시간동안 startAsyncJobs 의 진행은 멈춰있습니다.
- await 은 Promise 가 resolve 한 값을 내놓습니다. async 함수 내부에서는 리턴하는 값을 resolve 한 값으로 간주하므로, `${age} success` 가 value로 들어온다는 점을 알 수 있습니다.
- 해당 Promise 에서 reject 가 발생한다면 예외가 발생합니다. 이 예외 처리를 하기 위해 try-catch 구문을 사용했습니다. reject 로 넘긴 에러(async 함수 내에서는 throw 한 에러)는 catch 절로 넘어갑니다. 이로써 본래 해왔던 에러 처리 하듯이 진행할 수 있습니다.
await 은 then 과 catch 의 동작을 모두 자기 나름대로 처리합니다. 그래서 async 함수 내에서 then, catch 메소드의 존재를 잊게 할 수 있습니다. 즉 콜백 함수를 넘기고 흐름을 제어하던 때가 엊그제 같은데… 라며 과거 회상을 할 수 있다는 뜻입니다!
왜 await 은 async 함수에서만 쓸 수 있을까요?
비동기로 시작한 작업의 특징은, 그로부터 파생된 모든 작업 또한 비동기 작업으로 간주할 수 있습니다. 어느 항구 마을에서 커다란 고기잡이 배를 바다로 떠나보낸다고 가정합시다. 커다란 고기잡이 배는 비동기 작업의 시작입니다. 동이 틀 무렵 고기잡이 배는 떠났고, 그 고기잡이 배는 나름 열심히 일할 겁니다. 고기잡이 배에서 다른 소형 배를 다시 내보내든 그물을 준비하는 작업을 하든 큰 배를 떠나보낸 항구 입장에서는 신경쓸 일이 없습니다. 배 안에서 일어나는 게 비동기 작업이든 동기 작업이든, 항구 입장에서는 모두 비동기 작업입니다.
동기 환경에서 비동기 작업을 마냥 기다리는 게 의미가 없는 이유는, 그럴 바에야 그냥 동기 코드를 사용하면 되기 때문입니다! 항구는 배를 떠나보낸 뒤 어제 잡아왔던 물고기를 포장하거나 새로운 거래처를 뚫는 등 다른 작업들을 수행할 수 있습니다. 그냥 아무것도 안하고 떠나보낸 고기잡이 배를 기다리는 건 비동기 작업의 의의를 없애는 것입니다.
반면 비동기 환경에서 비동기 작업의 결과를 기다리겠다는 것은 다소 의미가 있습니다. 예를 들어 비밀 요원의 정보를 받아오는 비동기 작업인 fetchData 함수가 있는데, 이 함수의 결과로 나오는 생일을 알아야 생일 파티를 제때에 해줄 수 있는 것처럼, 마냥 기다리는 게 정답일 때도 있습니다. 생일을 알기 전까지는 생일 파티 준비를 해서는 안 됩니다. 그 때 await 을 사용하는 것입니다.
하여튼 비동기는 동작 특성상 실제 작업과 그 작업의 후속조치를 따로 분리시킬 수 밖에 없는데, (그래서 then, catch 등을 썼는데) async 와 await을 쓰면 하나의 흐름 속에서 코딩할 수 있게 해줍니다! 실제 작업이 끝난 다음 그 후속조치를 수행한다. 가 아니라, 실제 작업이 끝나는 걸 기다린 다음 다음 코드를 수행한다의 느낌으로, 코딩할 수 있는 것이죠. 기다리는 게 뭐죠? 동기 코드를 쓸 때 마냥 기다렸죠? 그걸 할 수 있다는 것입니다. async와 await은 우리가 예전에 동기 코드를 작성했던 익숙한 경험 속에서 비동기 작업들을 코딩할 수 있게 해줍니다. 당장 then과 catch를 사용한 코드와 async, await 까지 활용한 코드를 비교해보면 체감이 확 될 것입니다.
어쨌든 await 을 쓰고 싶단 말이에요!
Promise 를 반환하는 (혹은 async) 함수를 사용하면서 await 까지 쓰고 싶은데, await 을 쓸 수 없는 상황이라면 어떻게 하면 되냐구요? 그렇다면 내가 그 함수를 어떤 흐름 속에서 사용하려고 하는지를 파악한 후 적절한 전략을 취하면 됩니다. 이 전략은 상황마다 너무 다르기 때문에, 그냥 여러가지로 검색을 해보라는 답 밖에 드릴 수 없을 것 같습니다. 예를 들면 Express 에서 요청 핸들러는 무조건 일반 함수로 만들어야 하되 “성공”을 알리는 방법, 그리고 “실패”를 알리는 방법 또한 정해져 있는데, 이를 이용해 async 함수를 집어넣어볼 수도 있습니다.