실무에서 만나는 이벤트 루프 버그
forEach 안의 await는 순서를 기다려주지 않는다
이 책 전체에서 가장 많이 실무 코드 리뷰에서 지적한 패턴을 하나만 꼽으라면 이겁니다.
async function processAll(ids) {
ids.forEach(async (id) => {
const data = await fetchData(id);
await saveToDb(data);
});
console.log("전부 처리 완료"); // 거짓말입니다
}forEach 는 콜백이 Promise 를 반환하든 말든 신경 쓰지 않습니다. 콜백을 순서대로 "호출"만 하고, 각 호출이 끝나기를 기다리지 않은 채 바로 다음 원소로 넘어갑니다. async 콜백 안에서 await 를 만나는 순간 그 콜백은 콜 스택에서 즉시 내려오고, forEach 는 그 사실을 알 방법이 없으니 곧바로 다음 원소를 처리합니다. 결과적으로 "전부 처리 완료" 는 fetchData 요청이 하나도 끝나기 전에 찍힙니다.
고치는 방법은 두 가지고, 어느 쪽을 쓸지는 "순서가 중요한가"에 달려 있습니다.
// 순서대로 하나씩 처리해야 한다면 for...of
async function processAll(ids) {
for (const id of ids) {
const data = await fetchData(id);
await saveToDb(data);
}
console.log("전부 처리 완료"); // 이제 진짜다
}
// 순서 상관없이 동시에 처리해도 된다면 Promise.all
async function processAll(ids) {
await Promise.all(
ids.map(async (id) => {
const data = await fetchData(id);
await saveToDb(data);
})
);
console.log("전부 처리 완료");
}for...of 는 await 를 정직하게 기다려 주는 반복문입니다. Promise.all 은 아예 다 병렬로 던져 놓고 전부 끝나길 기다립니다. forEach 만 이도 저도 아닌 채로 "기다리는 척하지만 안 기다리는" 함정을 만듭니다.
병렬로 해도 되는 걸 실수로 직렬로 만드는 경우
반대 방향의 실수도 흔합니다. for...of 습관이 몸에 배면, 서로 의존 관계가 없는 요청까지 직렬로 묶어버립니다.
// 나쁜 예: user 와 posts 는 서로 무관한데 순서대로 기다린다
async function getProfile(userId) {
const user = await fetchUser(userId); // 300ms
const posts = await fetchPosts(userId); // 300ms
return { user, posts }; // 총 600ms
}
// 좋은 예: 동시에 요청을 보내고 둘 다 끝나길 기다린다
async function getProfile(userId) {
const [user, posts] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
]);
return { user, posts }; // 총 300ms
}fetchPosts 가 fetchUser 의 결과값을 필요로 하지 않는다면 두 요청을 동시에 쏘지 않을 이유가 없습니다. 코드를 위에서 아래로 읽히는 순서대로 쓰다 보면 자연스럽게 직렬로 작성하게 되는데, await 를 볼 때마다 "이 값이 다음 줄에 정말 필요한가?"를 한 번씩 물어보는 습관을 들이는 게 좋습니다.
무한 마이크로태스크로 화면이 멈추는 사고
4장 끝에서 예고했던 사고를 재현해 보겠습니다.
function poll() {
Promise.resolve().then(() => {
checkSomeCondition();
poll(); // 조건이 안 끝나면 계속 자기 자신을 마이크로태스크로 재예약
});
}
poll();"조건을 폴링한다"는 의도로 짠 코드인데, setTimeout 대신 실수로(혹은 "더 빠르게 반응하라"는 생각으로) Promise.resolve().then 을 썼다고 해봅시다. 4장에서 본 것처럼 마이크로태스크 큐는 완전히 빌 때까지 매크로태스크로 넘어가지 않습니다. poll 이 매번 자기 자신을 마이크로태스크 큐 끝에 다시 밀어 넣으니 이 큐는 절대 비지 않습니다. 클릭 이벤트, 타이머, 렌더링 전부 매크로태스크 큐에 쌓인 채 무한정 대기하게 됩니다. 탭은 "멈춘 것"처럼 보이지만 CPU 사용률은 100%를 칩니다 — 콜 스택이 비었다 채워졌다를 미친 듯이 반복하고 있을 뿐이니까요.
같은 의도의 폴링이라면 setTimeout 을 써야 합니다. 매크로태스크 큐를 거치기 때문에 다른 작업들이 끼어들 틈이 생깁니다.
function poll() {
checkSomeCondition();
setTimeout(poll, 0); // 최소한 다른 매크로태스크에게 기회를 준다
}
poll();처리하지 않은 rejection은 조용히 사라지지 않는다
마지막으로 짚을 건 에러 처리입니다. .catch 도 try/catch 도 없이 reject 된 Promise 를 방치하면, Node.js 는 unhandledRejection 이벤트를 발생시키고(최신 버전 기본 설정에서는 프로세스를 종료시킵니다), 브라우저 콘솔에는 Uncaught (in promise) 에러가 찍힙니다. 문제는 이게 에러가 발생한 시점이 아니라, 이벤트 루프가 그 마이크로태스크 처리를 다 끝낸 뒤에야 감지된다는 점입니다.
async function riskyOperation() {
throw new Error("뭔가 잘못됨");
}
riskyOperation(); // catch 도 await 도 없음 — 에러가 콘솔 어딘가에 조용히 찍히고 끝난다이런 코드는 로컬 개발 중에는 콘솔에 빨간 줄 하나로 지나가지만, 프로덕션에서는 로그에 묻혀 아무도 못 보고 지나가는 경우가 많습니다. async 함수를 호출부에서 await 하지 않을 계획이라면(파이어 앤 포겟), 최소한 .catch(err => logger.error(err)) 정도는 붙여서 에러가 조용히 증발하지 않게 만들어야 합니다. 이벤트 루프가 "언제, 어떤 순서로" 코드를 실행하는지 아는 것만큼이나, "실행되지 않은 채 버려진 에러가 어디로 가는지" 아는 것도 실무에서는 똑같이 중요합니다.