반응형

자바스크립트 코드는 위에서 아래로 실행되는 것처럼 보이지만, 브라우저 안에서는 조금 더 복잡한 순서로 움직입니다. 특히 Promise.then, setTimeout, requestAnimationFrame을 함께 쓰면 “왜 이 코드가 먼저 실행되지?”라는 상황을 자주 만나게 됩니다.

핵심은 브라우저가 작업을 크게 task, microtask, 렌더링 단계로 나누어 처리한다는 점입니다. 이 차이를 이해하면 클릭 후 UI가 늦게 바뀌는 이유, setTimeout(0)이 바로 실행되지 않는 이유, requestAnimationFrame을 언제 써야 하는지 훨씬 명확해집니다.

한 줄로 정리하기

task는 브라우저가 처리하는 큰 작업 단위입니다. microtask는 현재 task가 끝난 직후 먼저 비워지는 우선 대기열입니다. requestAnimationFrame은 다음 화면 그리기 직전에 실행되는 시각적 예약 지점입니다.

즉 실행 순서를 단순히 “코드가 적힌 순서”로만 보면 헷갈립니다. 브라우저는 하나의 task를 처리한 뒤 microtask를 비우고, 그 다음 화면을 그릴 기회를 확인합니다.

task란 무엇인가

task는 이벤트 루프가 한 번에 하나씩 꺼내 실행하는 큰 작업입니다. 대표적으로 클릭, 입력 같은 사용자 이벤트, setTimeout, setInterval, 네트워크 이벤트 콜백 등이 task로 들어갑니다.

예를 들어 버튼 클릭 핸들러는 하나의 task로 실행됩니다. 클릭 핸들러 안의 동기 코드는 먼저 끝까지 실행되고, 그 안에서 예약된 다른 콜백들은 각각의 규칙에 따라 나중에 실행됩니다.

microtask는 언제 실행될까

microtask는 현재 task가 끝난 직후 실행됩니다. 대표적인 예시는 Promise.then, queueMicrotask, MutationObserver입니다.

중요한 점은 microtask가 렌더링보다 먼저 비워진다는 것입니다. 그래서 Promise.then이 setTimeout(fn, 0)보다 먼저 실행되는 것처럼 보입니다.

정확히 말하면 Promise.then이 무조건 “가장 빠른 비동기”라서가 아닙니다. 현재 task가 끝난 뒤, 다음 task로 넘어가기 전에 microtask queue가 먼저 처리되기 때문입니다.

setTimeout(0)이 바로 실행되지 않는 이유

setTimeout(fn, 0)은 0ms 뒤에 즉시 실행된다는 뜻이 아닙니다. 최소 지연 시간이 지난 뒤 새로운 task로 들어간다는 뜻에 가깝습니다.

따라서 현재 실행 중인 task가 끝나야 하고, 그 뒤에 쌓인 microtask도 모두 처리되어야 합니다. 메인 스레드가 바쁘다면 setTimeout(0)도 얼마든지 뒤로 밀릴 수 있습니다.

requestAnimationFrame은 어디에 들어갈까

requestAnimationFrame, 줄여서 rAF는 task도 microtask도 아닙니다. 브라우저가 다음 프레임을 그리기 직전에 콜백을 실행합니다.

그래서 rAF는 DOM 변경이나 애니메이션처럼 화면 반영 타이밍이 중요한 작업에 잘 맞습니다. 상태 계산은 task나 microtask에서 끝내고, 실제 시각적 반영은 rAF에서 처리하는 식으로 나누면 렌더링 흐름을 예측하기 쉬워집니다.

예제로 실행 순서 확인하기

<button id="btn">Run</button>
<pre id="log"></pre>

<script>
  const log = (msg) => {
    document.querySelector('#log').textContent += msg + '\n';
  };

  document.querySelector('#btn').addEventListener('click', () => {
    log('task: click handler start');

    queueMicrotask(() => log('microtask: queueMicrotask'));
    Promise.resolve().then(() => log('microtask: promise'));

    requestAnimationFrame(() => log('rAF: before repaint'));
    setTimeout(() => log('task: setTimeout(0)'), 0);

    log('task: click handler end');
  });
</script>

버튼을 클릭하면 동기 코드가 먼저 실행됩니다. 그 다음 현재 task가 끝났으므로 microtask가 실행됩니다. 이후 브라우저는 렌더링 타이밍을 확인하고, 다음 paint 직전에 rAF 콜백을 실행합니다. setTimeout(0)은 새로운 task로 들어가기 때문에 그 뒤에 실행될 수 있습니다.

click task 시작
동기 코드 실행
click task 종료
microtask queue 비우기
requestAnimationFrame 실행
렌더링
다음 task에서 setTimeout 실행

브라우저 상태나 프레임 타이밍에 따라 세부 순서는 관찰 환경에서 조금 달라 보일 수 있지만, 핵심 기준은 같습니다. microtask는 현재 task 직후 렌더링보다 먼저 처리되고, rAF는 다음 paint 직전에 실행됩니다.

실무에서 중요한 포인트

입력 반응이 느리거나 클릭 직후 UI가 늦게 바뀌는 문제는 긴 task 때문에 생기는 경우가 많습니다. 하나의 클릭 핸들러 안에서 무거운 계산을 오래 수행하면 브라우저는 그동안 화면을 갱신하지 못합니다.

microtask도 조심해야 합니다. Promise.then 안에서 다시 Promise.then을 만들거나, queueMicrotask를 반복해서 쌓으면 렌더링 기회가 계속 밀릴 수 있습니다. 코드가 짧아 보여도 microtask queue를 오래 붙잡으면 사용자는 화면이 멈춘 것처럼 느낄 수 있습니다.

  • 계산과 상태 정리는 task 또는 microtask에서 처리
  • 화면에 맞춰 반영해야 하는 DOM 작업은 requestAnimationFrame 사용
  • 다음 이벤트 루프로 넘겨도 되는 작업은 setTimeout 등으로 분리
  • 무거운 작업은 한 번에 처리하지 말고 잘게 나누기

직접 해볼 실습

첫 번째로 Promise.then 안에 다시 Promise.then을 넣어보세요. microtask가 연쇄적으로 실행될 때 로그 순서가 어떻게 바뀌는지 확인할 수 있습니다.

두 번째로 setTimeout(0)과 requestAnimationFrame의 위치를 바꿔보세요. 코드 위치를 바꿔도 이벤트 루프 규칙 때문에 실제 실행 순서가 단순히 위아래 순서대로만 움직이지 않는다는 점을 확인할 수 있습니다.

세 번째로 클릭 한 번에 여러 DOM 상태를 바꾸는 예제를 만들어보세요. 계산은 먼저 끝내고 최종 DOM 반영만 rAF로 보내면, 렌더링 타이밍을 더 안정적으로 관리할 수 있습니다.

마무리

이벤트 루프를 이해할 때 중요한 질문은 “무엇이 먼저 실행될까?”에서 끝나지 않습니다. 더 중요한 질문은 “언제 화면이 실제로 갱신될까?”입니다.

task, microtask, requestAnimationFrame의 차이를 알고 있으면 비동기 코드의 실행 순서뿐 아니라 렌더링 지연 문제까지 더 정확하게 판단할 수 있습니다. 특히 UI 성능을 다룰 때는 microtask를 무리하게 쌓지 않고, 시각적 반영은 rAF에 맞추는 습관이 도움이 됩니다.

반응형