tw.heo Github

setTimeout 이 오차를 유발하는 근본적인 원인

이벤트 루프와 브라우저 제약(4ms, 스로틀링) 등 setTimeout 시간 오차의 5가지 핵심 원인을 분석합니다.

Nov 23, 2025

#Javascript

enflo 에서 타이머 기능을 구현하면서 setTimeout 을 사용했었다. 하지만 오차로 인해 정확한 타이머를 만들 수 없었다.

따라서 이번 글에선 다양한 오차 원인을 탐구해보았다.

setTimeout 의 정확한 의미

setTimeout(() => {
console.log("1s");
}, 1000);

일정 시간이 지난 후 콜백을 실행한다. 첫번째 인자로 실행할 콜백, 두 번째 인자로 지연시간(ms)를 설정한다.

이때 동작 방식을 정확히 이해해야 한다. 위 코드는 ‘1000ms 뒤에 콜백함수를 실행한다’ 가 아니라, ‘1000ms 뒤에 Callback Queue 에 콜백함수를 등록한다’ 의 의미를 갖고 있다.

그렇다면 Callback Queue는 무엇이며 어떻게 동작하는지 알아보자.

Callback Queue 와 Event Loop

Callback Queue 란 브라우저 환경에서 비동기 작업의 콜백함수가 대기하는 공간이다.

Event Loop
  • Call Stack: 동기 작업들을 순차적으로 처리하는 스택
  • Web APIs: 브라우저에서 제공되는 API 의 모음으로 멀티스레딩을 지원
  • Callback Queue: 비동기 작업의 콜백을 보관하는 큐로 Microtask Queue 와 Macrotask Queue 로 구분
  • Event Loop: Call Stack 와 Callback Queue 를 polling 하며, 작업들을 우선순위에 맞게 스케줄링

동작방식을 살펴보자.

동기 작업들은 Call Stack 에서 바로 처리된다. 여기서 중간에 비동기 작업(setTimeout)이 들어오면 Web APIs 에서 1000ms 를 기다린다. 이후 Callback Queue 에 setTimeout 의 콜백함수를 넣는다. 이때 Event Loop 가 다음과 같이 동작한다.

  • Call Stack 에서 처리 중인 작업들이 있는지 확인
  • 만약 작업이 있다면 해당 작업을 먼저 처리
  • Call Stack 이 비어있다면 Callback Queue 에서 대기 중인 콜백함수를 Call Stack 으로 옮긴 후 실행

그렇다면 왜 비동기 작업을 이러한 방식으로 처리하는가? JavaScript 는 single thread 언어이다. 네트워크 요청, 파일 입출력 등 무거운 작업들까지 동기적으로 처리한다면 해당 작업을 처리할 때까지 아무것도 할 수 없는 blocking 이 발생한다.

이 문제를 해결하기 위해 브라우저는 Web APIs를 통해 멀티 스레드 처리를 지원하고, 이벤트 루프로 실행 순서를 관리한다.

그렇다면 우리는 비동기 처리 함수들의 병목이 어디서 발생하는지 알 수 있게 됐다. 바로 동기 함수가 모두 처리될 때까지 기다리는 것이 문제다. 실제로도 그런지 확인해보자.

const start = Date.now();
setTimeout(() => {
console.log(`경과시간: ${Date.now() - start}`);
}, 100);
// Call Stack 을 오랫동안 점유하는 동기 작업
let i = 0;
while (i < 1000000000) i++;
result1

while이 실행되는 동안 Call Stack 이 비워지지 않아 콜백 함수가 제때 실행되지 못한다. 그 결과, 설정한 100ms보다 훨씬 늦게 로그가 출력되는 것을 볼 수 있다.

Macrotask Queue

Call Stack 뿐만 아니라 Macrotask QueueMicrotask Queue의 관계에서도 병목이 발생한다.

Callback Queue 는 두 가지로 구분된다.

  • Microtask Queue
  • Macrotask Queue(Task Queue)

둘 다 비동기 작업의 콜백을 담지만, Microtask Queue 가 작업 처리의 우선순위를 갖는다는 차이가 있다.

즉, Event Loop 의 우선순위는 Call Stack > Microtask Queue > Macrotask Queue 이다.

중요한 사실은 setTimeout 의 콜백이 Macrotask Queue 에 들어간다는 것이다. Event Loop 는 Microtask Queue 가 완전히 비워질 때까지 Macrotask Queue 의 작업을 Call Stack 으로 옮기지 않는다. 따라서 Microtask Queue 의 작업량이 많으면 실행이 지연된다.

참고로 Callback Queue 를 Web APIs 관점에서 분리하자면 아래와 같다.

  • Microtask Queue: promise.then, process.nextTick, MutationObserver
  • Macrotask Queue: setTimeout, setInterval, I/O, addEventListener

이전 예제코드에서 Microtask Queue 를 더 바쁘게 만들어보자. 위 기준에 따르면 promise.then이 우선순위가 높으므로 이를 이용한다.

const start = Date.now();
setTimeout(() => {
console.log(`경과시간: ${Date.now() - start}`);
}, 100);
let i = 0;
while (i < 1000000000) i++;
Promise.resolve().then(() => {
let i = 0;
while (i < 1000000000) i++;
});
result2

Microtask Queue 가 바빠지면서 이전 결과 처리시간(947ms)보다 늘어나는 것을 볼 수 있다.

브라우저 정책

단순히 Call Stack, Microtask Queue 가 바빠지면서 생기는 병목 외에, 브라우저 정책으로 인해 병목이 발생하는 경우도 있다.

중첩 타이머의 최소 지연 시간(4ms)

0ms 로 설정하더라도, 재귀적으로 반복 호출하면 브라우저는 강제로 시간을 늘린다. 이는 과도한 타이머 호출로 인해 CPU 가 독점되어 브라우저 성능 전체가 저하되는 것을 막기 위함이다.

HTML Living Standard 명세는 다음과 같이 규정한다.

If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.

즉, 타이머가 5회 이상 중첩되면 최소 지연 시간이 4ms로 고정된다.

let count = 0;
let start = Date.now();
function run() {
console.log(`호출 횟수: ${count++}, 시간: ${Date.now() - start}ms`);
if (count < 10) {
setTimeout(run, 0);
}
}
run();
result3

다만, 결과를 보면 스펙상의 5번째가 아닌 7번째 호출부터 지연이 발생하는 것을 확인할 수 있다.

이는 브라우저 스펙상 ‘중첩 레벨이 5를 초과(>5)할 때’ 제약이 걸리는데, 이 조건 체크는 현재 실행 중인 콜백 내부에서 다음 타이머를 예약할 때 수행되기 때문이다.

즉, 6번째 호출(Level 6) 시점에서 비로소 ‘5 초과’ 조건이 성립하여 그 다음 순서인 7번째 호출부터 4ms 지연이 강제된다.

비활성 탭에서 스로틀링

비활성된 탭에선 1000ms 이하의 타이머들을 1000ms 로 강제 clamping 한다. 즉, 사용자가 다른 탭을 보고 있다면 최소 1000ms 는 기다려야 타이머가 실행된다.

이전에 사용했던 코드에서 500ms 지연으로 변경한 뒤 탭을 비활성화 해보자.

let count = 0;
let start = Date.now();
function run() {
console.log(`Call: ${count++}, Time: ${Date.now() - start}ms`);
if (count < 20) {
setTimeout(run, 500);
}
}
run();
result4

코드를 실행한 뒤 Call1 시점에 다른 탭으로 이동한 결과다. 비활성 탭이 된 시점부터 1000ms 만큼 늘어난 것을 확인할 수 있다.

추가적으로 Chrome 버전 88 이후부턴 배터리 절약을 위해 Intensive Wake Up Throttling 라는 정책이 도입됐다. 이 정책은 탭이 비활성 상태로 5분 이상 지난 경우, 타이머가 1초가 아니라 1분(60,000ms)에 한 번만 실행되도록 제한된다. 만약 타이머를 띄워놓고 다른 탭에서 5분이 지나면 갱신이 1분이나 늦어질 수 있다는 것이다.

물론 항상 그런 것은 아니고 오디오/비디오가 재생중일 때나 web socket 등 실시간 통신중일 땐 위 두 제한이 걸리지 않는다.

최대 지연 시간 오버플로우(32-bit signed integer)

이건 브라우저 정책이라기 보단 기술적 한계에 가까운데, 설정할 수 있는 최대 시간은 231 - 1 (= 2,147,483,647ms) 이다.

이는 브라우저에서 딜레이 시간을 32-bit signed integer 로 처리하기 때문이다. 따라서 이 값을 초과하는 숫자를 넣으면 정수 오버플로우가 발생하여 타이머가 즉시 실행된다.

setTimeout(() => console.log("실행"), 2147483648);

결론

지금까지 시간 오차가 발생하는 다양한 원인을 알아보았다.

단순히 시간을 지연시키는 함수라고 생각했던 이면에는 Event Loop 의 상호작용이 있었다. 특히 싱글 스레드 환경에서 비동기 작업을 효율적으로 처리하기 위한 브라우저의 설계가, 타이머의 정확도에는 걸림돌이 될 수 있다는 것을 알았다.

우리가 살펴본 오차의 원인은 다음과 같다.

  • 이벤트 루프 특성: Call Stack이 비워져야 실행되므로 동기 작업에 영향을 받음
  • 우선순위 밀림: Microtask Queue 작업이 많으면 실행이 지연됨
  • 중첩 제한: 5회 이상 중첩 시 4ms의 최소 지연 시간 강제
  • 백그라운드 제한: 비활성 탭에서는 1초(또는 1분)로 실행 주기 제한
  • 기술적 한계: 32비트 정수 오버플로우

결론적으로 정밀한 타이밍이 필요한 기능을 구현할 때는 setTimeout에만 의존해서는 안된다. 정확도를 높이기 위해선 requestAnimationFrame, Web WorkerDate 객체를 이용하여 시간 차를 보정해야 한다.