브라우저는 언제 화면을 그리는가
렌더링은 매크로태스크와 매크로태스크 사이에 낀다
지금까지의 그림에 한 단계를 더 끼워 넣어야 합니다. 브라우저는 마이크로태스크 큐를 다 비운 뒤, 다음 매크로태스크를 꺼내기 전에 "지금 화면을 다시 그려야 하나?"를 판단합니다. 스타일이나 레이아웃에 영향을 주는 변경이 쌓여 있고 다음 리프레시 시점(대략 초당 60번, 16.6ms 간격)이 됐다면 이 타이밍에 페인트가 끼어듭니다. Node.js 에는 화면이 없으니 이 단계 자체가 존재하지 않는다는 것도 짚어 둘 만합니다 — 이 챕터는 브라우저 환경에 한정된 이야기입니다.
중요한 건 렌더링이 "매 마이크로태스크마다" 일어나는 게 아니라는 점입니다. 마이크로태스크 큐가 완전히 빌 때까지는 절대 끼어들지 않습니다. 즉 Promise.then 을 수백 번 체이닝해도 그 사이사이에 화면이 깜빡이며 다시 그려지는 일은 없습니다 — 전부 처리된 다음, 딱 한 번의 렌더링 기회가 옵니다.
requestAnimationFrame이 setTimeout보다 나은 이유
애니메이션을 setTimeout 으로 직접 구현하면 두 가지 문제가 생깁니다. 하나는 3장에서 본 4ms 클램핑, 다른 하나는 브라우저의 실제 리프레시 주기와 타이머 주기가 어긋난다는 점입니다. requestAnimationFrame(callback) 은 이 문제를 피해 갑니다 — "다음 리페인트 직전"이라는, 렌더링 파이프라인에 맞춰진 정확한 시점에 콜백을 실행해 주기 때문입니다.
function animate(timestamp) {
const progress = Math.min((timestamp - startTime) / 1000, 1);
box.style.transform = `translateX(${progress * 300}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
let startTime;
requestAnimationFrame((t) => {
startTime = t;
requestAnimationFrame(animate);
});setTimeout(fn, 16) 으로 똑같이 흉내 낼 수 있을 것 같지만, 탭이 백그라운드로 가면 setTimeout 은 계속 (느리게라도) 돌아가며 배터리를 갉아먹는 반면 requestAnimationFrame 은 브라우저가 알아서 실행을 멈춥니다. 보이지 않는 애니메이션을 계산할 이유가 없으니까요.
레이아웃 스래싱 — 읽기와 쓰기를 섞으면 벌어지는 일
렌더링 타이밍과 관련해서 실무에서 가장 자주 만나는 성능 문제는 강제 동기 레이아웃(forced synchronous layout), 흔히 "레이아웃 스래싱"이라 부르는 현상입니다.
// 나쁜 예: 읽기와 쓰기가 루프 안에서 번갈아 일어난다
const boxes = document.querySelectorAll(".box");
boxes.forEach((box) => {
const height = box.offsetHeight; // 읽기 → 레이아웃 계산 강제
box.style.height = `${height * 2}px`; // 쓰기 → 다음 읽기에서 또 레이아웃 강제
});offsetHeight 를 읽는 순간, 브라우저는 원래 다음 프레임에 몰아서 하려던 레이아웃 계산을 그 자리에서 즉시 실행합니다. 스타일을 아직 반영 안 한 상태로 읽으면 값이 틀릴 수 있기 때문입니다. 문제는 바로 다음 줄에서 스타일을 또 바꾸고, 다음 반복에서 또 읽는다는 겁니다. 요소가 100개면 레이아웃 계산이 100번 강제로 일어납니다. 원래는 한 프레임에 한 번이면 될 일이었습니다.
// 나은 예: 읽기를 전부 끝낸 다음 쓰기를 전부 한다
const boxes = document.querySelectorAll(".box");
const heights = Array.from(boxes, (box) => box.offsetHeight); // 읽기만 모아서 처리
boxes.forEach((box, i) => {
box.style.height = `${heights[i] * 2}px`; // 쓰기만 모아서 처리
});읽기 단계와 쓰기 단계를 분리하면 레이아웃 계산은 딱 한 번으로 줄어듭니다. 이벤트 루프나 태스크 큐와 직접적인 관계는 없어 보이지만, "언제 렌더링이 강제되는가"를 모르면 이런 코드가 왜 느린지 설명할 수 없습니다. 저는 이 문제를 프로파일러의 Recalculate Style 항목이 프레임마다 빽빽하게 찍혀 있는 걸 보고서야 알아챈 적이 있는데, 코드만 봐서는 딱히 이상해 보이지 않는 게 이 버그의 가장 골치 아픈 부분입니다.