Promise 핸들러가 항상 비동기로 실행되는 이유인 마이크로태스크 큐를 짚고, 이를 편하게 다루는 async/await 문법과 실행 순서 종합 문제로 시리즈를 마무리합니다.
지금까지 콜백에서 시작해 Promise의 체이닝, 에러 핸들링, Promise API와 Promisification까지 살펴봤습니다. 시리즈의 마지막인 이번 편에서는 Promise 핸들러가 항상 비동기로 실행되는 이유를 짚어보고, 이를 훨씬 편하게 다룰 수 있게 해주는 async/await 문법, 그리고 지금까지 배운 내용을 종합하는 실행 순서 문제로 마무리합니다.
Promise 핸들러(.then/catch/finally)는 항상 비동기적으로 실행됩니다. Promise가 즉시 이행되더라도 .then/catch/finally 아래에 있는 코드들이 이 핸들러보다 먼저 실행됩니다.
let promise = Promise.resolve();
promise.then(() => alert("Promise 성공!"));
alert("코드 종료");
// 결과
// alert("코드 종료");
// alert("Promise 성공!");
이를 위해 ECMA에서는 PromiseJobs라는 내부 큐를 명시합니다(V8 엔진에서 부르는 이름인 Microtask Queue가 더 자주 쓰입니다). 먼저 들어온 작업을 먼저 실행하며(FIFO), 실행할 것이 아무것도 남아있지 않을 때만 큐에 있는 작업이 실행되기 시작합니다. 즉 어떤 Promise가 준비되었을 때 이 Promise의 .then/catch/finally 핸들러는 큐에 들어가고 아직 실행되지 않다가, 현재 코드에서 자유로운 상태가 되었을 때 자바스크립트 엔진이 큐에서 작업을 꺼내 실행합니다.
Promise 핸들러는 항상 이 내부 큐(Promise jobs이자 Microtask Queue)를 통과합니다. 여러 개의 .then/catch/finally로 만든 체인의 경우도 각 핸들러는 현재 코드가 완료되고, 큐에 있던 이전 핸들러의 실행이 끝났을 때 비동기적으로 실행됩니다.
Promise.resolve()
.then(() => alert("Promise Fulfilled"))
.then(() => alert("finish")); // 이렇게 비동기 동작을 차례대로 진행할 수 있습니다.
앞선 편에서 처리되지 못한 거부(unhandled rejection)와 unhandledrejection 이벤트를 다뤘습니다. 그런데 만약 .catch를 나중에, 타이머로 지연시켜 붙이면 어떻게 될까요.
let promise = Promise.reject(new Error("Promise 실패!"));
setTimeout(() => promise.catch((err) => console.log("잡았다!")), 1000);
// Error: Promise 실패!
window.addEventListener("unhandledrejection", (event) =>
console.log(event.reason),
);
실행 결과를 보면 unhandledrejection 핸들러가 먼저 실행되고, 그다음 catch의 소비 함수가 실행되는 순서인 것을 확인할 수 있습니다. 이는 다음 과정 때문입니다.
window.addEventListener까지)가 끝나면 Microtask Queue를 확인하는데, rejected Promise가 처리되지 않은 채 남아있으므로 unhandledrejection이 발동해 console.log(event.reason)이 실행됩니다.promise.catch(...) 호출로 () => console.log('잡았다!')가 Microtask Queue로 이동합니다.잡았다!가 출력됩니다.Promise를 편하게 사용하기 위해 async, await 문법이 탄생했습니다.
async function f() {
return 1;
}
function 앞에 async를 붙이면 해당 함수는 항상 Promise를 반환합니다. Promise가 아닌 값을 반환하더라도 fulfilled 상태의 Promise로 값을 감싸서 반환하므로, return 1과 return Promise.resolve(1)은 async function 안에서는 같은 기능을 합니다.
await(async function 안에서만 동작)는 Promise가 처리될 때까지 기다립니다. await 아래에 있는 동일 블록의 코드들은 .then으로 넘기는 것과 기능적으로 같게 Microtask Queue로 이동합니다.
async function f() {
let promise = new Promise((resolve, reject) => {
setTimeout(() => resolve("완료!"), 1000);
});
let result = await promise; // Promise가 이행될 때까지 기다림 (*)
alert(result); // "완료!"
}
f();
Promise가 처리될 때까지 기다리는 동안 엔진은 다른 일(다른 스크립트 실행, 이벤트 처리)을 처리할 수 있기 때문에 CPU 리소스가 낭비되지 않습니다.
즉 await는
promise.then보다 좀 더 세련되게 Promise의 result 값을 얻을 수 있게 해주는 문법입니다.
이전 편의 fetch 체이닝 예제였던 showAvatar를 async/await 문법으로 정리하면 다음과 같습니다.
async function showAvatar(url) {
const loadJsonResponse = await fetch(url);
const { name } = await loadJsonResponse.json();
const githubUserResponse = await fetch(
`https://api.github.com/users/${name}`,
);
const githubUser = await githubUserResponse.json();
// 아바타 보여주기
let img = document.createElement("img");
img.src = githubUser.avatar_url;
img.className = "promise-avatar-example";
document.body.append(img);
// 3초 대기 (await가 실행되면 아래 코드들은 Microtask Queue로 간다고 생각하면 됩니다. .then과 동작이 같습니다.)
await new Promise((resolve, reject) => setTimeout(resolve, 3000));
img.remove();
return githubUser;
}
showAvatar();
await는 최상위 레벨 코드에서 동작하지 않으므로, 이를 위해 IIFE(Immediately Invoked Function Expression)를 사용할 수 있습니다.
(async () => {
let response = await fetch(`/article/promise-chaining/user.json`);
let user = await response.json();
// ...
})();
await도 앞선 편에서 다룬 thenable 객체를 받을 수 있고, 클래스 메서드에도 async를 붙여 Promise를 반환하게 만들 수 있습니다.
await promise는 Promise 객체의 result에 저장된 값을 반환합니다. 반면 Promise가 거부되면 마치 throw문을 작성한 것처럼 에러를 던집니다.
// 아래 두 코드의 기능은 같습니다.
async function f() {
await Promise.reject(new Error("에러 발생!"));
}
async function f() {
throw new Error("에러 발생!");
}
await가 던진 에러는 throw가 던진 에러를 잡을 때처럼 try...catch로 잡을 수 있습니다.
async function f() {
try {
let response = await fetch("http://유효하지-않은-주소");
} catch (err) {
alert(err); // TypeError: failed to fetch
}
}
f();
에러가 발생하면 catch 블록으로 제어 흐름이 넘어갑니다. 원래 async function에 리턴값이 없으면 fulfilled Promise에 undefined가 담겨서 반환되지만,
async function에서 await한 Promise가 reject된다면 rejected된 Promise에 에러가 담겨서 반환됩니다. 이때도 .catch를 이어서 사용해 해당 Error를 핸들링할 수 있습니다.
.catch로 핸들링되지 않은 에러는 전역 에러로 처리되며, 이는unhandledrejection이벤트의 핸들러로 처리할 수 있습니다.
.then을 await가,.catch를 try...catch가 대신해주기 때문에 동일한 기능을 훨씬 편하게 쓸 수 있습니다. 다만 await는 최상위 레벨 코드에서는 사용할 수 없어서(async가 없는 최상위 레벨 코드).then/.catch를 써야 하는 경우가 생깁니다. async/await는 Promise.all과도 함께 쓸 수 있고, 에러 핸들링 역시 try...catch로 처리할 수 있습니다.
// Promise 처리 결과가 담긴 배열을 기다립니다.
let results = await Promise.all([
fetch(url1),
fetch(url2),
// ...
]);
비동기 관련 로직을 완벽하게 이해하기 위해 만들어 본 문제입니다. 각 큐(Microtask/Macrotask)와 Web API의 동작을 생각하면서 결과값이 무엇이고 왜 그런지 따라가 보겠습니다.
export const testFn = async () => {
console.log(1); // 바로 실행
setTimeout(() => {
console.log(2); // Web API로 넘겨져 500ms 후 Macrotask Queue로
}, 500);
console.log(3); // 바로 실행
new Promise(async (resolve) => {
// executor는 Promise 생성과 함께 바로 실행됩니다.
console.log(4, "전");
await setTimeout(() => {
console.log(4); // Web API에서 5000ms가 지난 후 Macrotask Queue로 전달됩니다.
}, 5000);
// await 아래 코드는 Microtask Queue로
console.log(4, "다음");
resolve(4);
});
console.log(5); // 바로 실행
const t = await new Promise((resolve) => {
// executor는 바로 실행되고, await 아래 코드는 Microtask Queue로 이동
console.log(6);
resolve("a");
});
console.log(t);
console.log(7);
};
진행 단계별로 무슨 일이 일어나는지 순서대로 풀어보겠습니다.
1단계. testFn 실행 (첫 번째 Macrotask)
console.log(1), console.log(3)이 순서대로 바로 출력됩니다.setTimeout(..., 500)과 setTimeout(..., 5000)은 Web API로 넘겨져 각각 타이머가 시작됩니다.new Promise(async (resolve) => {...})의 executor가 즉시 실행되어 console.log(4, "전")이 출력됩니다. setTimeout 자체는 타이머 ID를 반환하므로 await setTimeout(...)은 의미 있는 대기 없이 지나가고, 그 아래 console.log(4, "다음")과 resolve(4)는 Microtask Queue로 들어갑니다.console.log(5)가 출력됩니다.const t = await new Promise((resolve) => {...})의 executor가 즉시 실행되어 console.log(6)이 출력되고 resolve("a")가 호출됩니다. 이후의 console.log(t), console.log(7)은 Microtask Queue로 들어갑니다.1, 3, 4 "전", 5, 62단계. testFn 관련 Microtask 전부 실행
4 "다음", a, 7이 출력됩니다.3단계. 500ms 경과, 세 번째 Macrotask 실행
() => console.log(2)가 Macrotask Queue로 이동해 실행되고, 2가 출력됩니다. Microtask Queue가 비어있는 것을 확인하고 다음으로 넘어갑니다.4단계. 5000ms 경과, 네 번째 Macrotask 실행
() => console.log(4)가 Macrotask Queue로 이동해 실행되고, 4가 출력됩니다.최종 출력 순서
| 순서 | 출력 | 발생 위치 |
|---|---|---|
| 1 | 1, 3, 4 "전", 5, 6 | 1단계, testFn 동기 실행 구간 |
| 2 | 4 "다음", a, 7 | 2단계, Microtask Queue 소진 |
| 3 | 2 | 3단계, 500ms 경과 후 Macrotask |
| 4 | 4 | 4단계, 5000ms 경과 후 Macrotask |
Macrotask, Microtask, requestAnimationFrame, 브라우저 렌더링 순서로 렌더링이 진행되기 때문에, 위 코드 실행 중간에 이벤트가 발생해서 Macrotask가 하나 생기면 그 Macrotask에 관련된 브라우저 렌더링이 일어나고 나서 위 코드로 복귀하게 될 수 있습니다.
기존에는 비동기 코드가 Microtask Queue와 이벤트 루프를 활용해서 돌아간다는 정도만 알고 있었다면, 이번 시리즈를 통해 브라우저 렌더링 흐름과 함께 이해하면서 비동기 방식이 왜 필요한지를 더 정확히 알 수 있었습니다.
기본기를 탄탄하게 다지자는 생각으로 콜백부터 시작해 Promise, Promise API, async/await까지 촘촘하게 훑어봤고, 그 과정에서 파편화되어 있던 지식을 한데 모을 수 있었습니다. 이 기술이 왜 필요한지에 집중하다 보니 이전보다 좀 더 다양한 시각으로 비동기 코드를 볼 수 있게 되었습니다.
Promise.all/allSettled/race 같은 정적 메서드, AbortController로 요청을 취소하는 방법, 그리고 콜백을 Promise로 바꾸는 Promisification을 정리합니다.
then을 이어 붙여 비동기 작업을 순서대로 처리하는 Promise 체이닝과, fetch 예제, 그리고 체인에서 에러를 다루는 방법을 정리합니다.
콜백 기반 비동기 처리의 한계인 콜백 지옥을 짚어보고, 이를 해결하기 위해 등장한 Promise의 기본 구조와 소비 함수를 정리합니다.
메타 태그를 다 넣었는데도 검색 결과는 파란 링크 한 줄뿐이었습니다. 검색엔진이 페이지를 '읽는' 것과 '이해하는' 것은 다른 문제였고, 그 사이를 메우는 게 구조화된 데이터였습니다.
전역 Suspense로 감싸면 경고는 사라지지만, 페이지의 정적 마크업까지 placeholder로 대체됩니다. Next.js 소스코드를 따라가 BailoutToCSRError가 CLIENT_RENDERED 경계로 어떻게 흐르는지 살펴봅니다.
hooks 기반 이벤트 드리븐 위젯을 만들고, 그 과정에서 왜 Electron을 선택했는지, hook과 기록 파일이라는 두 신호를 어떻게 교차 검증했는지 정리했습니다.
문서에는 "queued"라고만 적혀 있습니다. 호출 단위인지 컴포넌트 단위인지 탭 전체인지 알 수 없어서, 30개 요청을 세 가지 방식으로 직접 재봤습니다.
참조형 데이터는 내용이 같아도 주소가 다르면 다른 값으로 취급됩니다. 이 특성이 useEffect 의존성 배열과 React.memo에서 왜 문제가 되는지, useCallback과 useMemo가 무엇을 해결하는지, 그리고 왜 모든 곳에 쓰면 안 되는지 정리합니다.
인앱 브라우저에서 기능이 깨지는 문제와 대용량 목록 렌더링 성능 문제, 두 가지를 실제로 부딪히고 풀어낸 기록입니다.