LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLAB Coreteam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

자바스크립트 퀴즈북 리마인드 Day 8: 이벤트 루프와 Promise 타이밍

2026년 7월 3일·12분 읽기
예상 읽기 시간: 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이 정확히 언제 실행되는가? 이 질문이 이벤트 루프의 역할입니다.

먼저 맞혀보기

js
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 런타임은 실행 순서를 단순한 시간표가 아니라 스택이 비는 시점과 큐의 우선순위로 정합니다.

이벤트 루프와 마이크로태스크 실행 순서

최소 모델: 스택이 먼저, 그다음 microtask

리뷰할 때는 이 정도만 기억해도 대부분의 순서를 설명할 수 있습니다.

지금 실행 중인 동기 코드는 call stack에서 끝까지 돈다. 스택이 비면 microtask queue를 비운다. microtask 안에서 새 microtask가 생기면 그것도 같은 턴에 이어서 돈다. 그 뒤 렌더 기회가 오고, 다음 task가 하나 실행된다. 다음 task가 끝나면 다시 microtask를 비운다.

그래서 Promise.then, queueMicrotask, await 뒤쪽 코드는 보통 setTimeout보다 먼저 보입니다.

js
setTimeout(() => console.log("task"), 0);
queueMicrotask(() => console.log("microtask"));
console.log("sync");

// sync
// microtask
// task

여기서 “보통”이라고 쓴 이유도 중요합니다. 브라우저와 Node의 세부 큐는 완전히 같지 않고, requestAnimationFrame, I/O, rendering step, Node의 process.nextTick 같은 세부 규칙까지 들어가면 표 하나로 끝나지 않습니다. 하지만 프론트엔드 코드 리뷰에서 먼저 필요한 것은 전체 스펙 암기가 아니라 현재 코드가 어느 경계로 실행을 미뤘는지를 설명하는 힘입니다.

js
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를 썼으니 이 줄이 먼저 끝났을 것” 같은 애매한 리뷰가 됩니다.

async/await도 Promise 타이밍이다

await가 있으면 함수가 잠깐 멈추는 것처럼 보입니다. 하지만 “스레드가 멈춘다”보다 “나머지 부분이 Promise continuation으로 예약된다”에 가깝게 보는 편이 안전합니다.

js
async function run() {
  console.log("1");
  await null;
  console.log("2");
}

run();
console.log("3");

// 1
// 3
// 2

await null은 즉시 끝나는 값처럼 보여도, console.log("2")는 현재 동기 흐름 뒤로 밀립니다. UI 코드에서 await 뒤에 상태를 쓰는 부분은 “이미 다른 이벤트나 렌더가 끼어들 수 있었나?”까지 같이 봐야 합니다.

js
let selectedId = "a";

async function loadDetail() {
  const id = selectedId;
  const detail = await fetchDetail(id);

  renderDetail(detail);
}

selectedId = "b";

이 코드는 타입만 보면 평범합니다. 하지만 리뷰에서는 두 가지 의도를 나눠야 합니다.

요청을 시작한 시점의 id에 대한 결과를 보여주는가? 화면의 최신 selectedId와 맞는 결과만 보여줘야 하는가?

두 번째라면 await 뒤에 최신성 검사를 해야 합니다.

js
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이 끝난 뒤 실행하고 싶다. 둘째, 브라우저가 실제로 화면을 그린 뒤 실행하고 싶다. 둘은 같지 않습니다.

js
setOpen(true);

setTimeout(() => {
  measurePanel();
}, 0);

이 코드는 “다음 task에서 측정한다”는 뜻에 가깝습니다. 하지만 프레임워크의 commit, 브라우저 렌더, layout 계산이 정확히 어느 시점에 끝나는지는 별도의 문제입니다. 어떤 경우에는 requestAnimationFrame이 더 의도를 잘 드러냅니다.

js
setOpen(true);

requestAnimationFrame(() => {
  measurePanel();
});

그렇다고 requestAnimationFrame이 항상 정답이라는 뜻도 아닙니다. 필요한 것이 “현재 동기 처리 이후”인지, “렌더 직전/직후”인지, “다음 사용자 입력 전에”인지, “네트워크 요청 완료 후”인지 먼저 말해야 합니다. 이벤트 루프 리뷰는 API 이름 맞히기가 아니라 필요한 시간 경계를 정확히 고르는 일입니다.

microtask가 길면 렌더도 밀린다

Promise가 timer보다 먼저 실행된다는 사실만 외우면 절반입니다. 더 중요한 리뷰 포인트는 microtask queue를 비우는 동안 렌더 기회가 늦어질 수 있다는 점입니다.

js
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 위치가 어긋나면 에러가 생각과 다르게 흘러갑니다.

js
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을 잡지 못합니다.

js
async function save() {
  try {
    await startSave();
  } catch (error) {
    showError(error);
  }
}

리뷰에서는 async 함수를 호출하는 쪽이 반환된 Promise를 기다리는지, 의도적으로 fire-and-forget인지 구분해야 합니다. 의도적으로 기다리지 않는다면 에러 처리와 취소 경계가 따로 있어야 합니다.

js
void startSave().catch((error) => {
  reportError(error);
});

이런 코드는 적어도 “기다리지 않겠다”와 “그래도 에러는 처리하겠다”를 드러냅니다. 반대로 아무 표시 없이 startSave();만 남으면 리뷰어는 실수인지 의도인지 알기 어렵습니다.

DOM 측정과 렌더 타이밍

프론트엔드에서 이벤트 루프는 DOM 측정 버그와도 연결됩니다.

js
setState({ open: true });
const rect = panel.getBoundingClientRect();

상태 갱신 직후 DOM이 바로 원하는 모양으로 반영됐다고 가정하면 안 됩니다. 사용하는 프레임워크의 렌더 commit 타이밍, microtask, layout/reflow, requestAnimationFrame의 위치를 같이 봐야 합니다. DOM이 그려진 뒤 측정해야 한다면 requestAnimationFrame이나 framework가 제공하는 effect 경계를 쓰는 편이 더 분명할 수 있습니다.

AI가 만든 코드에서 자주 보이는 실수

AI는 순서가 안 맞는 버그를 보면 setTimeout(fn, 0)을 쉽게 붙입니다. 이건 원인을 설명하지 않습니다. 그저 다음 task로 넘길 뿐입니다. 다음 task로 넘기는 것이 맞는 경우도 있지만, 대부분은 “렌더 후 실행”, “현재 call stack 이후 실행”, “Promise 완료 후 실행”, “다음 user interaction 전에 실행” 중 무엇이 필요한지 더 구체적으로 써야 합니다.

또 하나는 async callback을 forEach에 넣는 실수입니다.

js
items.forEach(async (item) => {
  await save(item);
});
showDone();

showDone()은 저장이 끝나기 전에 실행됩니다. 순차 처리가 필요하면 for...of와 await, 병렬 처리가 필요하면 Promise.all을 의도적으로 써야 합니다.

js
for (const item of items) {
  await save(item);
}

showDone();
js
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 뒤에도 런타임에서 같은 의미를 갖는지는 별도 질문입니다.

Day 8 복습 퀴즈

Quiz1 / 5
Q.첫 예제의 출력 순서로 맞는 것은 무엇일까요?
js
'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 타이밍 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!

0/500

포스트 목록

/study/javascript-quizbook
파일 13개, 폴더 0개
자바스크립트 퀴즈북 리마인드 Day 1: 숫자는 왜 가끔 믿을 수 없을까자바스크립트 퀴즈북 리마인드 Day 2: 같은 값인지 묻는 네 가지 방법자바스크립트 퀴즈북 리마인드 Day 3: + 연산자와 ToPrimitive 흐름자바스크립트 퀴즈북 리마인드 Day 4: 프로퍼티 descriptor와 freeze의 경계자바스크립트 퀴즈북 리마인드 Day 5: 참조, 복사, Map/Set의 메모리 감각자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된 값자바스크립트 퀴즈북 리마인드 Day 8: 이벤트 루프와 Promise 타이밍자바스크립트 퀴즈북 리마인드 Day 9: this는 호출한 쪽이 정한다자바스크립트 퀴즈북 리마인드 Day 10: 함수는 값이고 경계다자바스크립트 퀴즈북 리마인드 Day 11: class를 봐도 프로토타입을 읽는다자바스크립트 퀴즈북 리마인드 Day 12: 이벤트는 target에서 끝나지 않는다자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이 아니라 의존성 그래프다

같은 섹션의 대표 이미지

13 posts · latest first
ESM live binding과 dynamic import, circular dependency 리뷰 포인트를 보여주는 module graph 다이어그램
Study26. 07. 09.

자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이.

ESM live binding, default/named export, circular dependency, dynamic import, CJS와 ESM 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 09.SEOJing
DOM 이벤트가 capture 단계로 내려가고 bubble 단계로 올라가는 경로 다이어그램
Study26. 07. 08.

자바스크립트 퀴즈북 리마인드 Day 12: 이벤트는.

DOM 이벤트의 capture/bubble, delegation, default action, React synthetic event 경계를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 08.SEOJing
인스턴스에서 프로토타입과 Object.prototype으로 이어지는 프로퍼티 조회 경로 다이어그램
Study26. 07. 07.

자바스크립트 퀴즈북 리마인드 Day 11: class를 봐도.

프로토타입 조회, class 문법, 인스턴스 필드와 prototype 메서드 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 07.SEOJing
입력, 순수 계산, 출력, 부수 효과 경계를 나누어 함수 설계를 보여주는 다이어그램
Study26. 07. 06.

자바스크립트 퀴즈북 리마인드 Day 10: 함수는 값이고 경계다.

고차 함수, 순수 함수, 부수 효과, async 함수의 Promise 반환을 코드 리뷰에서 어떻게 읽을지 정리합니다.

26. 07. 06.SEOJing
메서드 호출, 분리된 함수, 이벤트 콜백, bind와 arrow function의 this 차이를 보여주는 다이어그램
Study26. 07. 05.

자바스크립트 퀴즈북 리마인드 Day 9: this는 호출한 쪽이.

this 바인딩을 정의 위치가 아니라 호출 형태, 메서드 분리, arrow function, bind 기준으로 읽는 방법을 코드 리뷰 관점에서 정리합니다.

26. 07. 05.SEOJing
콜 스택이 비워진 뒤 마이크로태스크 큐가 먼저 실행되고 다음 태스크로 넘어가는 이벤트 루프 다이어그램
Study26. 07. 04.

자바스크립트 퀴즈북 리마인드 Day 8: 이벤트 루프와.

콜 스택, 태스크 큐, 마이크로태스크 큐를 기준으로 Promise와 setTimeout 실행 순서를 코드 리뷰 관점에서 정리합니다.

26. 07. 04.SEOJing
클로저가 생성 위치의 바인딩을 기억해 나중 실행에서 오래된 값을 읽을 수 있음을 보여주는 다이어그램
Study26. 06. 29.

자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된.

클로저가 값 복사가 아니라 렉시컬 환경 참조라는 점을 stale closure, 루프 콜백, React 이벤트 리뷰 관점에서 정리합니다.

26. 06. 29.SEOJing
스코프 체인과 호이스팅 관계를 단순화한 다이어그램
Study26. 06. 28.

자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅.

스코프 체인, 렉시컬 환경, var/let/const 호이스팅 차이를 콜백·상태 버그 리뷰 관점에서 다시 정리합니다.

26. 06. 28.SEOJing
JavaScript 객체 참조와 Map, Set, WeakMap의 참조 구조를 비교한 다이어그램
Study26. 06. 27.

자바스크립트 퀴즈북 리마인드 Day 5: 참조, 복사,.

객체 참조와 얕은 복사, Object와 Map/Set의 차이, WeakMap/WeakSet이 필요한 메모리 상황을 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 06. 27.SEOJing
JavaScript 프로퍼티 descriptor가 data descriptor와 accessor descriptor로 갈라지는 구조 다이어그램
Study26. 06. 26.

자바스크립트 퀴즈북 리마인드 Day 4: 프로퍼티.

객체 프로퍼티를 key/value가 아니라 descriptor로 읽는 법, getter/setter와 defineProperty, preventExtensions/seal/freeze의 경계를 코드 리뷰 관점에서 정리합니다.

26. 06. 26.SEOJing
객체가 ToPrimitive를 거쳐 + 연산자의 문자열 연결 또는 숫자 덧셈으로 갈라지는 흐름 다이어그램
Study26. 06. 25.

자바스크립트 퀴즈북 리마인드 Day 3: + 연산자와.

객체가 원시값으로 바뀌는 ToPrimitive 흐름, + 연산자의 문자열 연결과 숫자 덧셈 분기, Symbol.toPrimitive가 코드 리뷰에서 왜 중요한지 정리합니다.

26. 06. 25.SEOJing
==, ===, Object.is, SameValueZero의 차이를 정리한 JavaScript equality 다이어그램
Study26. 06. 24.

자바스크립트 퀴즈북 리마인드 Day 2: 같은 값인지 묻는 네.

==, ===, Object.is, SameValueZero가 각각 어떤 비교 알고리즘을 쓰는지 정리하고, includes와 indexOf, Map/Set 키 비교에서 생기는 프론트엔드 리뷰 포인트를 잡습니다.

26. 06. 24.SEOJing
JavaScript Number, safe integer, NaN, -0, BigInt를 한 장으로 정리한 다이어그램
Study26. 06. 23.

자바스크립트 퀴즈북 리마인드 Day 1: 숫자는 왜 가끔 믿을.

자바스크립트의 Number가 왜 정수처럼 보여도 부동소수점 모델 위에서 움직이는지, NaN과 -0, safe integer, BigInt를 프론트엔드 코드 리뷰 관점에서 다시 정리합니다.

26. 06. 23.SEOJing