📝

async await

1. async await 사용 이유

1.1 정의

async/await 은 자바스크립트의 비동기 처리 패턴 중 기존의 비동기식 처리인 콜백 함수, Promise의 단점을 보완하여 만들어진 가장 최신 문법이다. 아래의 표는 Promise와 async/await을 간단하게 비교한 것이다.
Promise
async/await
then 지옥이 발생하는가
O
X
코드 가독성이 좋은가
X
O
모든 에러를 처리할수 있는가
X
O
후속처리 메서드가 없어도 되는가
X
O
Promise를 사용하여 비동기 처리를 하다 보면 프로미스 체이닝으로 인해 then 지옥이 발생하는데 이는 코드 가독성을 많이 떨어트린다. 반면에 async/await은 프로미스와 달리 후속 처리 메서드 없이 비동기를 동기처럼 사용할 수 있다. 이처럼 async/await은 then을 사용하지 않아도 되고, 비동기 함수를 만들 필요도 없으며, 코드를 nesting* 하지 않기 때문에 코드 가독성이 좋다는 장점이 있다.
에러를 잡아내는 try catch 문법을 사용하는 데에도 차이점이 있다. 프로미스는 프로미스 객체내부에서 발생한 에러를 프로미스 처리 메서드 catch로 처리하기 때문에, 프로미스 객체 내부에 발생한 에러를 프로미스 외부에서 잡아내지 못한다. 하지만 async/await은 모든 동기와 비동기 에러를 try catch를 통해 처리할 수 있어 에러 파악이 보다 용이하다는 장점이 있다.
 
*nesting : 코드내에 코드를 작성하는것
 

2. async

2.1 async function 사용 방법

2.1.1 함수 선언식

async 키워드는 함수 앞에 작성한다. 사용법은 놀랍게도 이게 끝이다. 아래의 두 함수는 모두 같은 내용인데, 이는 async가 결괏값을 프로미스로 반환한다는 것을 묵시적으로 보여 준다. 함수 앞에 async를 붙이는 순간 해당 함수는 프로미스 외의 다른 값을 리턴하더라도 그 값을 그대로 반환하지 않고 프로미스로 감싸 반환한다. 즉 async 사용으로 함수의 반환 타입이 promise로 변환된다는 것이다.
async function planet() { return "🌎"; } // .then()으로 바로 호출 가능 planet().then(console.log);
function planet() { return Promise.resolve("🌎"); }; planet().then(console.log);
notion imagenotion image
 

2.1.2 함수 표현식

async 함수는 함수 선언식뿐만 아닌 함수 표현식으로도 사용할 수 있다.
let planet = async function () { return "🪐"; }; planet().then(console.log);
 
ES2015부터 화살표 함수 형태로도 사용할 수 있게 되었다.
let planet = async () => { return "🪐"; }; planet().then(console.log);
notion imagenotion image
 

3. await

3.1 await 사용 방법 및 특징

await을 사용하는 방법은 간단하다. await을 비동기 처리 코드(프로미스)앞에 작성하면 된다. 이는 비동기 처리 메서드가 프로미스 객체를 반환해야 await이 의도한 대로 동작하기 때문이다. 일반적으로 await의 대상이 되는 비동기 처리 코드는 Axios와 같이 프로미스를 반환하는 API 호출 함수이다. ES 2022 이전에 await을 사용하기 위해선 async 함수 안에서 사용했으며, async 함수가 아닌 일반 함수 안에서 사용할 경우 SyntaxError가 발생했다. 하지만 개정 이후 top level await이 되면서 더 이상 async 함수를 쓰지 않아도 await을 사용할 수 있게 되었다.
사용 방법에 따라 프로미스 앞에 await 키워드를 붙이면 해당 프로미스가 settled 될 때까지 대기하다가 settled 상태가 된 이후 프로미스가 resolve한 결과를 반환한다. 즉 비동기 코드 앞에 await을 두어 비동기 처리를 기다리게 만들고, 앞선 코드가 실행된 후에 차례로 실행하여 비동기 처리 순서를 보장함으로써 비동기를 동기적으로 처리할 수 있게 된다.
await은 프로미스 체이닝으로 발생하는 then 지옥을 없애 코드의 가독성을 높일 수 있다는 장점 뿐만 아니라 await 키워드로 선언된 코드가 실행되는 동안 다른 작업을 중단시키지 않아 효율적으로 자원을 사용할 수 있다는 장점을 가진다.
 

3.2 promise.all 병렬 처리

await 키워드는 앞선 비동기 처리를 기다린 후 실행하기 때문에 아래의 햄버거세트 함수는 종료되는 데 약 9초의 시간이 걸린다. 하지만 여기서 햄버거세트 함수가 실행하는 비동기 처리는 앞선 비동기 처리의 결과를 가지고 다음 비동기 처리를 수행하는 것이 아니다. 비동기 처리 순서를 보장하지 않아도 되는, 개별적으로 수행하는 비동기 처리이기 때문에 앞선 비동기 처리를 기다린 후 실행하는 것보다 여러 개의 비동기 처리를 모두 병렬 처리 하는 것이 효율적이다. 이때 프로미스 챕터에서 다룬 promise.all을 활용하여 병렬처리할 수 있다.
 
햄버거세트 주문은 정상적으로 이루어져 약 9초 뒤에 나오지만, 철수가 햄버거를 다 만들어야 영희가 감자튀김을 만들고, 영희가 감자튀김을 다 만들어야 길동이가 콜라를 준비하는 것은 비효율적이다. 주문이 들어옴과 동시에 철수, 영희, 길동이 자신이 맡은 업무를 실행하는 것이 보다 효율적일 것이다.
async function 햄버거세트(){ const 철수 = await new Promise(resolve => setTimeout(() => resolve('햄버거🍔'),4000)); const 영희 = await new Promise(resolve => setTimeout(() => resolve('감자튀김🍟'),3000)); const 길동 = await new Promise(resolve => setTimeout(() => resolve('콜라🍷'),2000)); console.log([철수,영희,길동]); } 햄버거세트(); // (3) ['햄버거🍔', '감자튀김🍟', '콜라🍷'] // 앞선 비동기 처리를 기다린 후 처리하기 때문에 약 9초 뒤 나옴
notion imagenotion image
아래의 코드는 promise.all 메서드를 사용하여 여러 개의 비동기 처리를 병렬 처리 한 것이다. 앞서 실행한 햄버거세트 함수와 같은 결괏값을 반환하지만 비동기 처리를 병렬 처리 하여 약 4초후 햄버거 세트가 나오는 것을 확인할 수 있다. 따라서 개별적으로 수행되는 비동기 처리의 경우 promise.all을 사용하여 병렬 처리 하는 것이 좋다.
async function 햄버거세트(){ const res = await Promise.all([ new Promise(resolve => setTimeout(() => resolve('햄버거🍔'),4000)), new Promise(resolve => setTimeout(() => resolve('감자튀김🍟'),3000)), new Promise(resolve => setTimeout(() => resolve('콜라🍷'),2000)), ]); console.log(res); } 햄버거세트(); // (3) ['햄버거🍔', '감자튀김🍟', '콜라🍷'] // 여러 개의 비동기 처리를 병렬 처리 하기 때문에 약 4초 뒤 나옴
notion imagenotion image
비동기 처리의 결과를 가지고 다음 비동기를 처리한다는 것은 아래와 같이 두 번째, 세 번째 비동기 처리를 수행하기 위해선 이전에 실행된 비동기 처리 결과가 필요하다는 것을 의미한다. 따라서 비동기 처리 순서가 보장되어야 한다면 await 키워드를 사용하여 순차적으로 처리해야 한다.
async function 순차적으로(n){ const A = await new Promise(resolve => setTimeout(()=> resolve(n+10),2000)); const B = await new Promise(resolve => setTimeout(()=> resolve(A+20),4000)); const C = await new Promise(resolve => setTimeout(()=> resolve(B+30),3000)); console.log([A,B,C]); } 순차적으로(10); // (3) [20, 40, 70] // 약 9초뒤 나옴
 

4. 예제

4.1 then 지옥을 탈출해 보자

바로 위의 Promise.all 예제에서 다룬 내용처럼 이전 작업 단계에서 반환된 값을 다음 작업 단계로 넘겨 사용해야 하는 경우에 프로미스를 체이닝 형식으로 사용하면 안 될까? 확인해 보자.
아래의 함수는 설정한 ms의 시간이 지나면 resolve를 호출하는 Promise 반환 함수이다. getPants(), getShirts(), getShoes() 함수 모두 1초 동안 기다린 후 각각의 리턴 값을 반환한다.
function timer(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } async function getPants() { await timer(1000); return '👖'; } async function getShirts() { await timer(1000); return '👚'; } async function getShoes() { await timer(1000); return '👟'; }
 
위의 함수들을 이용해 한 번에 외출 준비를 할 수 있도록 도와주는 함수를 작성해 보았다. 동작 순서는 다음과 같다.
  1. getShirts()를 호출해 shirts를 입는다.
  1. getPants()를 호출해 pants를 입는다.
  1. getShoes()를 호출해 shoes를 신는다.
notion imagenotion image
각 함수들이 1초의 지연 시간을 갖기 때문에 콘솔에는 3초 후에 결괏값이 찍히게 된다. 이러한 체이닝 방식으로 then을 사용해 연속적으로 프로미스를 반환하면 개미 지옥처럼 끝없이 안으로 파고드는 콜백 지옥 형태를 띄게 된다. 프로미스도 과도하게 중첩하여 사용한다면 콜백 지옥과 비슷한 문제점이 발생한다는 것을 알 수 있다.
function readyToGo() { return getShirts().then(shirts => { return getPants().then(pants => { return getShoes().then(shoes => `${shirts} + ${pants} + ${shoes}` ) }) }) } readyToGo().then(console.log)
 
그러나 이 현상을 해결할 수 있는 것 역시 asyncawait이다. 결괏값은 동일하고 가독성은 훨씬 더 좋아진 코드를 마주할 수 있게 되었다.
async function readyToGo() { const shirts = await getShirts(); const pants = await getPants(); const shoes = await getShoes(); return `${shirts} + ${pants} + ${shoes}`; } readyToGo().then(console.log)
 

4.2 실용 예제

자바스크립트에서는 뒤에서 다루게 될 fetch() 함수를 이용해 비동기로 리소스를 요청할 수 있다. 응답받은 데이터를 이용하려면 비동기 처리가 모두 끝난 후에 다른 작업을 진행할 수 있도록 해야 한다. 이런 흐름을 만들기 위해 asyncawait을 이용해 API를 호출하고 응답받아 보려고 한다.
아래의 함수는 서버에 데이터를 요청해 유저들의 정보를 받아 오고 있다. 요청한 5번 사용자의 정보에는 주소, 회사, 이메일, 아이디 등의 여러 정보가 들어 있는 것을 확인할 수 있다.
function fetchUser() { fetch("https://jsonplaceholder.typicode.com/users/5") // 5번 사용자의 정보 요청 .then((res) => res.json()) .then((json) => console.log(json)); } fetchUser();
notion imagenotion image
이렇게 받아 온 5번 사용자의 정보들 중에서도 전화번호를 따로 추출해 확인하고자 한다. 우리는 이제 await이 사용된 부분에서는 비동기 작업이 완료될 때까지 기다려 준다는 사실을 알고 있다. await을 사용하지 않는다면 데이터를 받기도 전에 바로 뒤의 동작인 if문이 실행되며 아무런 결괏값도 얻을 수 없게 된다.
notion imagenotion image
이러한 상황을 방지하기 위해서 await을 사용한다. 순서를 보장해 준 후 getUserNumber()를 호출하면 5번 사용자의 전화번호가 잘 출력되는 것을 확인할 수 있다.
function fetchUser() { return fetch('https://jsonplaceholder.typicode.com/users/5') .then((response) => response.json()) } async function getUserNumber() { const user = await fetchUser(); if (user.id === 5) { console.log(user.phone); } } getUserNumber(); // (254)954-1289 출력
notion imagenotion image
 

5. 에러 처리

기존의 자바스크립트는 비동기 처리를 하기 위해 콜백 함수를 사용한다. 이런 콜백 함수를 연속해서 사용한 콜백 패턴은 콜백 지옥이 발생한다는 단점이 있다. 또한 에러 처리에도 어려움이 있는데 아래의 예시를 통해 확인해 보겠다.

5.1 try catch

try { setTimeout(() => { throw new Error('에러!'); }, 2000); } catch (err) { console.error(err); }
notion imagenotion image
2초 뒤 에러를 던지고, 그 에러를 출력하는 예제이다. try 블록 내의 setTimeout 함수에서 에러를 던졌지만, Uncaught Error가 발생하며 catch 블록 내에서 에러가 캐치되지 않은 것을 확인할 수 있다. 에러는 호출자 방향으로 전파되기 때문에 호출자가 있어야 에러를 처리할 수 있는데, 현재 setTimeout 함수의 콜백함수를 호출한 것이 setTimeout 함수가 아니기 때문에 호출자가 존재하지 않고, 이로 인해 에러를 처리할 수 없는 것이다. 아래 그림을 통해 자세히 알아보겠다.
 
notion imagenotion image
  1. setTimeout 함수가 호출되어 콜스택에 들어간다.
  1. setTimeout 함수가 타이머 함수를 호출해 콜백 함수 호출을 스케줄링한다.
 
notion imagenotion image
  1. 호출 스케줄링을 한 setTimeout 함수는 콜 스택에서 제거(pop)된다.
  1. 2초가 지나면 타이머가 종료되고 콜백 함수가 태스크 큐로 들어가서 대기한다.
 
notion imagenotion image
  1. 콜스택이 비어있으니 이벤트 루프에 의해 콜 스택으로 들어가서 실행된다.
  1. 에러가 실행되지만 호출자가 존재하지 않아 에러를 전파할 수 없다.
 
이처럼 setTimeout 함수는 2초가 지난 후 콜백 함수가 호출되도록 예약만 해놓고 종료된다. setTimeout 함수가 종료된 후 콜백 함수가 호출되는 것이다. 이 콜백 함수는 콜 스택에 혼자 있어 에러를 전파할 호출자가 존재하지 않기 때문에 try catch문으로 에러를 처리할 수 없다. (콜 스택이나 이벤트 루프 등에 대한 자세한 내용은 비동기 처리 챕터를 참고하길 바란다.)
 
반면 async await문에서는 try catch을 사용한 에러 처리가 가능하다. 호출자가 명확하게 존재하기 때문에 콜 스택에 실행 컨텍스트들이 쌓이고, 그 상태에서 에러가 발생하게 된다면 아래 방향으로 전파되고 catch로 발생된 에러를 잡을 수 있다. 또한 async await에서의 에러 처리는 동기 비동기 상관없이 일관적인 처리를 할 수 있는 것도 장점이다.
const example = async() => { try { const response = await axios.post(url, { "user": { "email": email, "password": password }, }); } catch (err) { console.error(err); } };