자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이.
ESM live binding, default/named export, circular dependency, dynamic import, CJS와 ESM 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.
예상 읽기 시간: 18분
오늘의 질문: “setTimeout(..., 0)은 정말 바로 실행될까?”이 글은 기존 Day 8을 보강한 개정판입니다. 처음 글은 Promise와 timer의 순서를 설명하는 데는 충분했지만, 자바스크립트 퀴즈북에서 이어지는 “비동기 코드를 코드 리뷰에서 어떻게 읽을 것인가”까지는 조금 얇았습니다. 이번 보강에서 붙잡을 범위는 이렇습니다.
call stack이 비기 전에는 어떤 callback도 끼어들지 못한다.
Promise.then, queueMicrotask, await 뒤쪽은 task보다 먼저 이어질 수 있다.
microtask가 길어지면 렌더 기회와 다음 입력 이벤트가 밀릴 수 있다.
async 함수 호출은 반환된 Promise를 기다리는 쪽까지 같이 봐야 한다.
setTimeout(fn, 0)은 “렌더 후”, “상태 반영 후”, “네트워크 완료 후” 같은 의미를 정확히 보장하지 않는다.
Day 6이 “함수가 어느 바인딩을 기억하는가”를 묻고, Day 7이 “그 바인딩이 오래된 값이 될 수 있는가”를 묻는다면, Day 8은 한 걸음 더 들어갑니다. 그 오래된 callback이 정확히 언제 실행되는가? 이 질문이 이벤트 루프의 역할입니다.
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve()
.then(() => {
console.log("C");
})
.then(() => {
console.log("D");
});
console.log("E");출력은 A E C D B입니다. setTimeout(..., 0)이 “0초 뒤 즉시 실행”이라는 뜻으로 보이면 B를 너무 앞에 놓기 쉽습니다. 실제로는 현재 동기 코드가 끝나고, 마이크로태스크 큐가 먼저 비워진 뒤, 다음 태스크에서 timeout 콜백이 실행됩니다.
비동기 코드를 “시간이 짧은 순서대로 실행된다”고 생각하면 Promise와 timer가 섞인 버그를 잘못 읽습니다. 브라우저와 Node 런타임은 실행 순서를 단순한 시간표가 아니라 스택이 비는 시점과 큐의 우선순위로 정합니다.
리뷰할 때는 이 정도만 기억해도 대부분의 순서를 설명할 수 있습니다.
지금 실행 중인 동기 코드는 call stack에서 끝까지 돈다. 스택이 비면 microtask queue를 비운다. microtask 안에서 새 microtask가 생기면 그것도 같은 턴에 이어서 돈다. 그 뒤 렌더 기회가 오고, 다음 task가 하나 실행된다. 다음 task가 끝나면 다시 microtask를 비운다.
그래서 Promise.then, queueMicrotask, await 뒤쪽 코드는 보통 setTimeout보다 먼저 보입니다.
setTimeout(() => console.log("task"), 0);
queueMicrotask(() => console.log("microtask"));
console.log("sync");
// sync
// microtask
// task여기서 “보통”이라고 쓴 이유도 중요합니다. 브라우저와 Node의 세부 큐는 완전히 같지 않고, requestAnimationFrame, I/O, rendering step, Node의 process.nextTick 같은 세부 규칙까지 들어가면 표 하나로 끝나지 않습니다. 하지만 프론트엔드 코드 리뷰에서 먼저 필요한 것은 전체 스펙 암기가 아니라 현재 코드가 어느 경계로 실행을 미뤘는지를 설명하는 힘입니다.
function handleClick() {
console.log("handler start");
Promise.resolve().then(() => {
console.log("microtask inside handler");
});
console.log("handler end");
}이 핸들러가 한 번 실행되면 handler start, handler end가 먼저 찍히고, 그 뒤 microtask가 이어집니다. 핸들러 중간에 Promise callback이 갑자기 끼어드는 것이 아닙니다. 이 구분을 놓치면 “Promise를 썼으니 이 줄이 먼저 끝났을 것” 같은 애매한 리뷰가 됩니다.
await가 있으면 함수가 잠깐 멈추는 것처럼 보입니다. 하지만 “스레드가 멈춘다”보다 “나머지 부분이 Promise continuation으로 예약된다”에 가깝게 보는 편이 안전합니다.
async function run() {
console.log("1");
await null;
console.log("2");
}
run();
console.log("3");
// 1
// 3
// 2await null은 즉시 끝나는 값처럼 보여도, console.log("2")는 현재 동기 흐름 뒤로 밀립니다. UI 코드에서 await 뒤에 상태를 쓰는 부분은 “이미 다른 이벤트나 렌더가 끼어들 수 있었나?”까지 같이 봐야 합니다.
let selectedId = "a";
async function loadDetail() {
const id = selectedId;
const detail = await fetchDetail(id);
renderDetail(detail);
}
selectedId = "b";이 코드는 타입만 보면 평범합니다. 하지만 리뷰에서는 두 가지 의도를 나눠야 합니다.
요청을 시작한 시점의 id에 대한 결과를 보여주는가?
화면의 최신 selectedId와 맞는 결과만 보여줘야 하는가?
두 번째라면 await 뒤에 최신성 검사를 해야 합니다.
async function loadDetail() {
const id = selectedId;
const detail = await fetchDetail(id);
if (selectedId !== id) return;
renderDetail(detail);
}실제 앱에서는 AbortController, request id, query library의 stale result 처리 같은 도구를 씁니다. 핵심은 도구 이름이 아니라, await가 값의 최신성을 보장하지 않는다는 점입니다.
| 코드 냄새 | 왜 위험한가 | 리뷰 질문 |
|---|---|---|
setTimeout(fn, 0)으로 순서 보정 | 다음 task로 미룰 뿐, 안정적인 상태 동기화가 아니다 | 왜 다음 턴이어야 하는가? |
| Promise chain 안에서 DOM/state 대량 갱신 | microtask가 길면 렌더 기회가 늦어진다 | 한 번에 너무 많은 일을 밀어 넣지 않았는가? |
await 뒤에 오래된 값 사용 | 그 사이 다른 작업이 끝났을 수 있다 | 취소/최신성 검사를 했는가? |
| 이벤트 핸들러 안에서 microtask 재귀 | 렌더나 다음 이벤트가 밀릴 수 있다 | 루프가 큐를 독점하지 않는가? |
이벤트 루프는 “외울 순서”가 아니라 리뷰할 때 버그의 시간 축을 그리는 도구입니다. 출력 순서 문제, 로딩 상태 깜빡임, 클릭 직후 DOM 측정, 오래된 async 결과 덮어쓰기 모두 같은 질문으로 시작할 수 있습니다.
이 콜백은 어느 큐에 들어가고, 그 전에 어떤 큐가 먼저 비워질까?
setTimeout(fn, 0)을 붙이는 코드는 대개 둘 중 하나입니다.
첫째, 지금 call stack이 끝난 뒤 실행하고 싶다. 둘째, 브라우저가 실제로 화면을 그린 뒤 실행하고 싶다. 둘은 같지 않습니다.
setOpen(true);
setTimeout(() => {
measurePanel();
}, 0);이 코드는 “다음 task에서 측정한다”는 뜻에 가깝습니다. 하지만 프레임워크의 commit, 브라우저 렌더, layout 계산이 정확히 어느 시점에 끝나는지는 별도의 문제입니다. 어떤 경우에는 requestAnimationFrame이 더 의도를 잘 드러냅니다.
setOpen(true);
requestAnimationFrame(() => {
measurePanel();
});그렇다고 requestAnimationFrame이 항상 정답이라는 뜻도 아닙니다. 필요한 것이 “현재 동기 처리 이후”인지, “렌더 직전/직후”인지, “다음 사용자 입력 전에”인지, “네트워크 요청 완료 후”인지 먼저 말해야 합니다. 이벤트 루프 리뷰는 API 이름 맞히기가 아니라 필요한 시간 경계를 정확히 고르는 일입니다.
Promise가 timer보다 먼저 실행된다는 사실만 외우면 절반입니다. 더 중요한 리뷰 포인트는 microtask queue를 비우는 동안 렌더 기회가 늦어질 수 있다는 점입니다.
button.addEventListener("click", () => {
setLoading(true);
Promise.resolve().then(() => {
heavyWork();
setLoading(false);
});
});이 코드는 loading을 켰지만, 바로 이어지는 microtask에서 무거운 일을 해버리면 브라우저가 중간에 loading UI를 그릴 기회를 얻지 못할 수 있습니다. “Promise를 썼으니 비동기라 괜찮다”가 아닙니다. microtask는 같은 턴 안에서 매우 빨리 이어질 수 있고, 길어지면 다음 렌더와 다음 이벤트를 밀어냅니다.
무거운 일을 정말 뒤로 미뤄야 한다면 task 경계, worker, chunking, scheduler를 고려해야 합니다. 어떤 선택이 맞는지는 UI가 먼저 그려져야 하는지, 입력 응답성이 중요한지, 작업을 취소할 수 있어야 하는지에 따라 달라집니다.
async/await에서 try/catch 위치가 어긋나면 에러가 생각과 다르게 흘러갑니다.
async function save() {
try {
startSave();
} catch (error) {
showError(error);
}
}
async function startSave() {
await fetch("/api/save");
throw new Error("failed after await");
}startSave()를 await하지 않으면, try/catch는 그 Promise rejection을 잡지 못합니다.
async function save() {
try {
await startSave();
} catch (error) {
showError(error);
}
}리뷰에서는 async 함수를 호출하는 쪽이 반환된 Promise를 기다리는지, 의도적으로 fire-and-forget인지 구분해야 합니다. 의도적으로 기다리지 않는다면 에러 처리와 취소 경계가 따로 있어야 합니다.
void startSave().catch((error) => {
reportError(error);
});이런 코드는 적어도 “기다리지 않겠다”와 “그래도 에러는 처리하겠다”를 드러냅니다. 반대로 아무 표시 없이 startSave();만 남으면 리뷰어는 실수인지 의도인지 알기 어렵습니다.
프론트엔드에서 이벤트 루프는 DOM 측정 버그와도 연결됩니다.
setState({ open: true });
const rect = panel.getBoundingClientRect();상태 갱신 직후 DOM이 바로 원하는 모양으로 반영됐다고 가정하면 안 됩니다. 사용하는 프레임워크의 렌더 commit 타이밍, microtask, layout/reflow, requestAnimationFrame의 위치를 같이 봐야 합니다. DOM이 그려진 뒤 측정해야 한다면 requestAnimationFrame이나 framework가 제공하는 effect 경계를 쓰는 편이 더 분명할 수 있습니다.
AI는 순서가 안 맞는 버그를 보면 setTimeout(fn, 0)을 쉽게 붙입니다. 이건 원인을 설명하지 않습니다. 그저 다음 task로 넘길 뿐입니다. 다음 task로 넘기는 것이 맞는 경우도 있지만, 대부분은 “렌더 후 실행”, “현재 call stack 이후 실행”, “Promise 완료 후 실행”, “다음 user interaction 전에 실행” 중 무엇이 필요한지 더 구체적으로 써야 합니다.
또 하나는 async callback을 forEach에 넣는 실수입니다.
items.forEach(async (item) => {
await save(item);
});
showDone();showDone()은 저장이 끝나기 전에 실행됩니다. 순차 처리가 필요하면 for...of와 await, 병렬 처리가 필요하면 Promise.all을 의도적으로 써야 합니다.
for (const item of items) {
await save(item);
}
showDone();await Promise.all(items.map((item) => save(item)));
showDone();두 코드는 실행 시간이 다르고 실패 방식도 다릅니다. 순차 저장은 앞 항목 실패 시 뒤 항목을 시작하지 않을 수 있고, 병렬 저장은 이미 여러 요청이 동시에 나갑니다. “비동기를 기다렸다”에서 끝내지 말고, 순차성·병렬성·취소·부분 실패 정책까지 같이 봐야 합니다.
Promise callback이 task보다 먼저 도는 사실에 기대고 있는가?
microtask 안의 작업이 너무 길어 렌더와 다음 입력을 밀지 않는가?
await 뒤에 쓰는 값이 요청 시작 시점의 값인지, 화면의 최신 값인지 분명한가?
setTimeout(fn, 0)이 필요한 시간 경계를 설명하지 못한 채 붙어 있지 않은가?
async 함수를 호출한 쪽이 Promise를 기다리거나, 의도적으로 기다리지 않는다는 표시와 에러 처리를 갖고 있는가?
forEach(async ...)가 완료 순서와 실패 정책을 숨기고 있지 않은가?
Day 8의 결론은 “Promise가 timer보다 먼저다”가 아닙니다. 그건 시작점일 뿐입니다. 실제 리뷰에서는 callback이 만들어진 위치(Day 6), 오래될 수 있는 값(Day 7), 그리고 실행될 큐와 시간 경계(Day 8)를 한 번에 봐야 합니다.
Day 7 closure 글과 이어집니다. 콜백이 오래된 값을 읽는 문제는 “어디서 만들어졌는가”와 “언제 실행되는가”가 함께 만든 결과입니다.
Day 13 module 글과도 이어집니다. dynamic import는 Promise 타이밍을 만들고, lazy boundary에서 로딩 UI와 에러 흐름을 다시 봐야 합니다.
Effective TypeScript Day 13의 테스트/런타임 경계와도 연결됩니다. 타입은 async 순서와 렌더 타이밍을 대신 검증하지 않습니다.
Effective TypeScript Day 5의 narrowing도 같이 읽을 만합니다. 타입이 좁혀진 값이 await 뒤에도 런타임에서 같은 의미를 갖는지는 별도 질문입니다.
'console.log("A");\nsetTimeout(() => console.log("B"), 0);\nPromise.resolve().then(() => console.log("C")).then(() => console.log("D"));\nconsole.log("E");'Post Q&A
자바스크립트 퀴즈북 리마인드 Day 8: 이벤트 루프와 Promise 타이밍 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
DOM 이벤트의 capture/bubble, delegation, default action, React synthetic event 경계를 프론트엔드 코드 리뷰 관점에서 정리합니다.