async/await는 결국 Promise다 — 문법 설탕 걷어내기
async 함수는 항상 Promise를 반환한다
async 키워드가 붙은 함수는 내부에서 무엇을 return 하든 상관없이 항상 Promise 를 반환합니다. 값을 리턴하면 그 값으로 즉시 resolve 된 Promise 가, 에러를 던지면 그 에러로 reject 된 Promise 가 됩니다.
async function getAnswer() {
return 42;
}
getAnswer(); // Promise { 42 } — 42가 아니라 Promise 객체
getAnswer().then(v => console.log(v)); // 42이걸 처음 보면 당연해 보이지만, 실무에서 async 함수를 .then 없이 그냥 호출하고 반환값을 동기적으로 쓰려는 실수는 놀랄 만큼 자주 나옵니다. async 가 붙는 순간 그 함수의 반환값은 무조건 Promise 로 감싸진다는 규칙에는 예외가 없습니다.
await는 콜 스택을 비우고 나머지를 마이크로태스크로 미룬다
await 가 하는 일을 한 문장으로 요약하면 이렇습니다. Promise 가 처리될 때까지 이 함수의 나머지 부분을 마이크로태스크로 미루고, 그동안 콜 스택은 비워서 다른 코드가 실행되게 한다. "블로킹"이라는 단어와는 정반대입니다. await 는 스레드를 멈추는 게 아니라, 함수 실행을 그 지점에서 잘라서 콜백으로 만드는 것에 가깝습니다.
async/await 이전에 우리는 같은 걸 .then 체인으로 직접 썼습니다. 아래 두 코드는 동작이 동일합니다.
// async/await 버전
async function fetchUser(id) {
console.log("요청 시작");
const res = await fetch(`/api/users/${id}`);
console.log("응답 도착");
const user = await res.json();
console.log("파싱 완료");
return user;
}// 완전히 동일하게 동작하는 .then 체인 버전
function fetchUser(id) {
console.log("요청 시작");
return fetch(`/api/users/${id}`)
.then(res => {
console.log("응답 도착");
return res.json();
})
.then(user => {
console.log("파싱 완료");
return user;
});
}await res.json() 은 사실상 .then(res => res.json()) 을 문법적으로 다르게 쓴 것뿐입니다. 엔진 내부적으로는 await 를 만날 때마다 함수를 그 지점에서 끊어 이어지는 부분을 콜백으로 만들고, 그 콜백을 Promise 의 .then 에 등록합니다. async/await 는 새로운 실행 모델이 아니라 이미 있던 Promise 체이닝을 순차적인 코드처럼 읽히게 해주는 문법입니다.
await는 값이 이미 준비돼 있어도 한 틱을 쉰다
여기서 자주 놓치는 부분이 있습니다. await 뒤에 오는 값이 Promise 가 아니라 이미 계산된 일반 값이어도, await 는 무조건 최소 한 번의 마이크로태스크를 거칩니다.
async function demo() {
console.log("1");
await 1; // Promise 가 아닌데도 한 틱 쉰다
console.log("3");
}
demo();
console.log("2");
// 출력 순서: 1, 2, 3await 1 은 기다릴 게 없어 보이지만, 스펙상 await 는 피연산자를 Promise.resolve() 로 감싸고 그 결과를 기다리는 것과 동일하게 취급됩니다. 그래서 "1" 다음 줄인 "3"이 동기 코드 "2"보다 먼저 찍히지 않습니다. await 를 만나는 순간 함수는 무조건 콜 스택에서 내려오고, 나머지는 최소 한 번은 큐를 거쳐 돌아옵니다.
참고로 이 "한 틱"이라는 숫자도 역사가 있습니다. ECMAScript 초기 명세에서는 await 가 내부적으로 Promise 체인을 여러 겹 거치도록 정의되어 있어서 실제로는 세 번의 마이크로태스크 틱이 걸렸습니다. 2019년 TC39 가 "Optimize await" 제안(V8 팀 주도)을 통과시키면서 지금은 대부분의 최신 엔진에서 한 틱으로 줄었습니다. 오래된 자료나 책에서 await 관련 타이밍 다이어그램을 보고 있다면, 그게 이 변경 이전 내용인지 확인해 보는 게 좋습니다 — 지금 브라우저·Node.js 동작과 미묘하게 다를 수 있습니다.
try/catch가 reject를 잡아낸다
.then/.catch 체인에서 .catch 가 하던 일을, await 코드에서는 평범한 try/catch 가 대신합니다.
async function safeFetch(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error("요청 실패:", err.message);
return null;
}
}reject 된 Promise 를 await 하면 그 자리에서 예외가 throw 된 것처럼 동작합니다. 동기 코드의 에러 처리 감각을 그대로 비동기 코드에 가져올 수 있다는 게 async/await 가 .then 체인보다 실질적으로 나은 지점입니다. 반면 Promise.all 처럼 여러 비동기 작업을 병렬로 돌려야 하는 상황에서는 await 를 순서대로 나열하면 오히려 손해를 봅니다. 이 함정은 8장에서 다룹니다.