프로미스로 콜백 지옥을 세로로 펴긴 했지만, then을 점점점 이어 붙인 코드는 여전히 눈에 걸린다. 콜백 함수가 줄줄이 달려 있어서, 한눈에 흐름을 읽으려면 머릿속에서 한 번 번역을 거쳐야 한다. async await는 그 프로미스를 마치 평범한 동기 코드처럼 위에서 아래로 읽히게 바꿔주는 문법이다. 나는 이 문법을 처음 쓰고 비동기 코드가 이렇게까지 안 어지러울 수 있다는 데 좀 놀랐다.
중요한 건 이게 프로미스를 대체하는 새 물건이 아니라는 점이다. 속은 여전히 프로미스고, async await는 그 위에 씌운 편한 껍데기일 뿐이다. 그래서 실행 순서도 프로미스 규칙 그대로 돈다. 이번 편은 그 껍데기가 어떻게 생겼는지, await에서 정확히 무슨 일이 벌어지는지, 순차와 병렬이 시간에서 얼마나 갈리는지를 코드를 돌려 출력으로 확인한다.
async 함수는 프로미스를 돌려준다. 함수 앞에 async를 붙이면 그 함수는 무조건 프로미스를 돌려주는 함수가 된다. 안에서 그냥 값을 return해도, 그 값이 이행된 프로미스에 담겨 나온다.
async function getNumber() {
return 42;
}
console.log(getNumber());
getNumber().then((n) => console.log("풀어낸 값:", n));
실행 결과다.
Promise { 42 }
풀어낸 값: 42
42를 return했는데 그대로 찍으면 42가 아니라 Promise { 42 }가 나온다. async 함수가 값을 프로미스로 감싸 돌려줬다는 증거다. 그래서 이 값을 꺼내려면 then을 쓰거나, 곧 볼 await를 써야 한다. 이 사실을 놓치면, async 함수를 부르고 그 반환값을 그냥 쓰다가 프로미스 상자를 값인 줄 알고 다루는 실수를 한다.
await는 값이 나올 때까지 기다린다. await는 프로미스 앞에 붙여, 그게 이행돼 값이 나올 때까지 그 줄에서 기다렸다가 알맹이 값만 꺼내준다. then의 콜백 안으로 들어가지 않고, 마치 동기 함수의 반환값을 변수에 받듯 쓴다. 단, await는 async 함수 안에서만 쓸 수 있다.
const wait = (ms, val) => new Promise((r) => setTimeout(() => r(val), ms));
async function main() {
console.log("1 함수 시작");
const a = await wait(100, "가");
console.log("2 첫 await 뒤:", a);
const b = await wait(100, "나");
console.log("3 둘째 await 뒤:", b);
}
console.log("0 호출 전");
main();
console.log("4 호출 직후");
실행 결과다.
0 호출 전
1 함수 시작
4 호출 직후
2 첫 await 뒤: 가
3 둘째 await 뒤: 나
이 순서가 async await의 핵심이라 천천히 따라가야 한다. main()을 부르면 함수 안으로 들어가 1을 찍는다. 그러다 첫 await를 만나는 순간, 함수는 거기서 멈추고 제어권을 바깥으로 돌려준다. 그래서 함수 밖의 4가 먼저 찍힌다. await가 함수를 통째로 멈추긴 하지만, 그 멈춤이 프로그램 전체를 세우는 게 아니라 이 함수만 잠시 빠져나오게 한다는 게 중요하다. 100밀리초 뒤 프로미스가 이행되면 멈췄던 자리로 돌아와 2를 찍고, 다시 다음 await에서 같은 일을 반복해 3을 찍는다. 겉보기엔 위에서 아래로 순서대로 읽히지만, 속에서는 await마다 함수가 빠져나갔다 돌아오기를 반복하고 있는 셈이다.
await 뒤 코드는 프로미스 콜백과 같은 줄에 선다. 방금 4가 2보다 먼저 나온 이유를 프로미스 관점에서 보면 더 또렷하다. await 아래에 있는 코드는 사실상 then에 넣은 콜백과 똑같다. 그러니 그 뒤 코드는 프로미스 콜백이 서는 우선 처리 대기줄에 들어가고, 지금 도는 동기 코드가 끝난 뒤에야 실행된다. await가 새로운 마법을 부리는 게 아니라, then 콜백을 문법으로 숨겼을 뿐이라는 걸 순서가 그대로 보여준다. 이걸 알면 async 코드의 출력 순서를 프로미스 규칙으로 똑같이 예측할 수 있다.
await를 잘못 늘어놓으면 느려진다. 여기가 실무에서 성능을 크게 좌우하는 지점이다. 서로 상관없는 일 셋을 await로 하나씩 줄 세우면, 앞 일이 끝나야 다음 일이 출발한다. 반면 Promise.all로 한꺼번에 출발시키고 그 결과를 await하면 셋이 나란히 돈다.
const wait = (ms, val) => new Promise((r) => setTimeout(() => r(val), ms));
async function sequential() {
console.time("순차");
const a = await wait(100, "가");
const b = await wait(100, "나");
const c = await wait(100, "다");
console.timeEnd("순차");
}
async function parallel() {
console.time("병렬");
const [a, b, c] = await Promise.all([wait(100, "가"), wait(100, "나"), wait(100, "다")]);
console.timeEnd("병렬");
}
(async () => { await sequential(); await parallel(); })();
실행 결과다.
순차: 327ms
병렬: 103ms
똑같은 일 셋인데 순차는 300 넘게, 병렬은 100 남짓이다. 세 배 차이다. sequential은 가를 다 기다린 뒤에야 나를 출발시키니 100이 세 번 쌓이고, parallel은 셋을 동시에 던져놓고 다 오기를 한 번만 기다린다. 나는 이걸 모르고 목록 화면에서 항목마다 await로 이미지 정보를 하나씩 받다가, 페이지가 굼벵이처럼 뜨는 걸 겪었다. 서로 의존하지 않는 일은 줄 세우지 말고 Promise.all로 묶으라는 게 여기서 얻은 교훈이다. 물론 앞 결과가 있어야 다음을 부를 수 있는 일은 순차가 맞다.
에러는 평범한 try catch로 잡는다. async await가 특히 반가운 건 에러 처리다. 프로미스는 catch 메서드로 잡았는데, async 함수 안에서는 동기 코드와 똑같은 try catch로 잡힌다. await한 프로미스가 거부되면 그 자리에서 예외가 던져진 것처럼 동작하기 때문이다.
const fail = () => new Promise((_, reject) =>
setTimeout(() => reject(new Error("서버 없음")), 50));
async function load() {
try {
const data = await fail();
console.log("성공:", data);
} catch (err) {
console.log("잡음:", err.message);
} finally {
console.log("정리 끝");
}
}
load();
실행 결과다.
잡음: 서버 없음
정리 끝
fail이 거부되자 await 줄에서 예외가 튀어 catch로 넘어갔고, 성공 줄은 아예 실행되지 않았다. 동기든 비동기든 같은 try catch 하나로 통일되니 머릿속이 훨씬 단순해진다. finally도 그대로 동작해, 로딩 표시를 끄는 뒷정리 같은 걸 성공 실패 상관없이 여기 넣는다.
await를 빠뜨리면 프로미스가 그대로 샌다. 내가 가장 많이 저지른 실수가 이거다. await를 깜빡하면 변수에 알맹이 값이 아니라 프로미스 상자가 통째로 담긴다.
async function bug() {
const data = wait(50, "진짜 데이터");
console.log("await 빠뜨림:", data);
}
bug();
실행 결과다.
await 빠뜨림: Promise { <pending> }
진짜 데이터가 나와야 할 자리에 Promise { <pending> }, 즉 아직 안 끝난 프로미스가 찍혔다. 값을 기다리지 않고 상자째 들고 다음 줄로 가버린 거다. 이 상태로 data.name 같은 걸 꺼내려 하면 undefined가 나오고, 화면엔 [object Promise] 같은 괴상한 문자열이 뜬다. 나는 이 문자열을 볼 때마다 아, await를 또 빼먹었구나 하고 반사적으로 안다. async 함수를 부르는 자리엔 await가 붙었는지부터 확인하는 게 버릇이 됐다.
정리하면 async 함수는 언제나 프로미스를 돌려주고, await는 그 프로미스가 이행될 때까지 그 함수만 잠시 멈춰 세웠다가 알맹이 값을 꺼낸다. 겉은 동기 코드처럼 읽히지만 속은 프로미스 그대로라, 실행 순서도 우선 처리 대기줄 규칙을 따른다. 상관없는 일은 Promise.all로 묶어 나란히 돌리고, 에러는 try catch로 잡으며, await를 빠뜨리지만 않으면 된다. 비동기 코드를 위에서 아래로 그냥 읽히게 만든다는 것, 그거 하나가 이 문법이 준 가장 큰 선물이다.