setTimeout(fn, 0)은 왜 즉시 실행되지 않는가
"0초 뒤에 실행"은 거짓말에 가깝다
setTimeout(fn, 0) 을 쓰는 코드를 실무에서 종종 봅니다. "지금 당장 실행하고 싶은데 콜 스택이 정리된 다음에 하고 싶어서"라는 의도로 씁니다. 틀린 용법은 아니지만, 이름이 주는 인상과 실제 동작 사이에는 간극이 있습니다. 0 은 "즉시"가 아니라 "가능한 한 빨리, 하지만 최소한 한 번은 큐를 거쳐서"에 가깝습니다.
직접 확인해 보겠습니다.
console.log("1: 동기 코드 시작");
setTimeout(() => console.log("2: 0ms 타이머"), 0);
console.log("3: 동기 코드 끝");
// 출력 순서: 1, 3, 2setTimeout 은 지연 시간이 얼마든 상관없이 콜백을 즉시 실행하지 않습니다. 반드시 태스크 큐에 넣고, 현재 실행 중인 동기 코드가 콜 스택에서 완전히 내려간 뒤에야 이벤트 루프가 그걸 꺼내옵니다. 그래서 "3"이 "2"보다 먼저 찍힙니다. 이게 태스크 큐의 첫 번째 규칙입니다. 아무리 지연 시간이 짧아도 최소 한 틱은 기다린다.
중첩된 타이머는 4ms로 강제 클램핑된다
여기서부터는 스펙(HTML Living Standard)이 정한, 다소 의외인 규칙입니다. setTimeout 을 재귀적으로 5번 이상 중첩해서 호출하면, 브라우저는 요청한 지연 시간과 무관하게 최소 4ms 로 강제 클램핑합니다.
let count = 0;
function nestedTimer() {
const start = performance.now();
setTimeout(() => {
console.log(`${count++}번째 호출, 실제 간격: ${(performance.now() - start).toFixed(2)}ms`);
if (count < 8) nestedTimer();
}, 0);
}
nestedTimer();
// 처음 몇 번은 1ms 미만, 5번째부터는 4ms 근처로 수렴한다이 규칙은 오래된 웹 페이지들이 setTimeout(fn, 0) 을 남발해 CPU 를 통째로 잡아먹던 시절의 흔적입니다. 지금은 대부분의 코드에서 체감하기 어렵지만, 애니메이션 루프를 setTimeout 으로 직접 구현하려 한다면 이 4ms 하한 때문에 프레임이 미묘하게 밀립니다(그래서 애니메이션에는 requestAnimationFrame 을 씁니다 — 6장에서 다룹니다).
setInterval 은 시간을 보장하지 않는다
setInterval(fn, 1000) 을 쓰면 1초마다 정확히 실행될 거라 기대하기 쉽지만, 사실은 "이전 콜백이 끝나고 콜 스택이 비어 있어야" 다음 콜백이 큐에서 실행됩니다. 만약 fn 자체가 1.5초 걸리는 무거운 작업이라면, setInterval 은 콜백이 끝나자마자 다음 콜백을 또 실행합니다 — 간격을 지키는 게 아니라 밀린 걸 최대한 빨리 처리하려고만 합니다. 반대로 콜백이 끝났는데 이미 다음 실행 시점이 지나 있으면 그 틱은 건너뛰고 다음 틱을 기다립니다(브라우저 구현체마다 조금씩 다릅니다).
정확한 간격이 중요한 코드라면 setInterval 대신 매번 새로 예약하는 패턴을 씁니다.
function tick() {
doWork();
setTimeout(tick, 1000); // 이전 tick 이 끝난 시점부터 1000ms 후로 재예약
}
setTimeout(tick, 1000);setInterval 은 "콜백이 순간적으로 끝난다"는 전제하에서만 간격을 지킵니다. 콜백 실행 시간이 간격보다 길어지는 순간 밀리기 시작하고, 밀린 호출들이 큐에 쌓였다가 한꺼번에 터지는 경우도 봤습니다. 저는 실무에서 setInterval 을 거의 안 씁니다. 재귀 setTimeout 이 코드 한 줄 더 길지만 동작을 예측하기가 훨씬 쉽습니다.