이번에 setInterval을 사용해서 타이머 기능을 구현하고 새롭게 알게된 사실이 있다.
setInterval과 setTimeout이 정확한 시간을 보장하지 않는다는 것!
앞서 작성했듯이 interval과 timeout은 특정 시간이 지난 후 실행되는 타이머 함수이지만 설정한 시간이 100% 지켜지지는 않는다. 1분마다 실행되는 interval을 설정했을 때 조금씩 시간이 밀린다는 것이다.
문제 발생의 원인에 대해서는 자바스크립트의 동작 원리에 대해 알 필요가 있다.
자바스크립트는 싱글 스레드 언어다!
싱글 스레드는 한 번에 하나의 작업만 수행할 수 있다는 뜻.
- 모든 함수 호출은 콜스택(Call Stack)에 저장되고, 순차적으로 실행된다.
- 현재 실행 중인 작업이 끝나기 전에는 다음 작업을 처리할 수 없다.
자바스크립트는 싱글 스레드로 동작하지만, 브라우저 내부 멀티 스레드인 Web API를 사용해 비동기 작업을 처리할 수 있다.
자바스크립트의 비동기 작업
호출된 함수는 콜스택에 저장
비동기 작업의 경우 Wep API에 전달한 뒤 백그라운드 작업이 완료되면 큐(Callback Queue)에 저장
콜스택이 비어있을 때 (진행 중인 작업이 없을 때) 큐에 있는 비동기 작업을 가져와 마무리console.log("Start"); setTimeout(() => { console.log("Inside setTimeout"); }, 2000); console.log("End");
console.log("Start")→ 콜스택에서 실행setTimeout→ Web API로 전달console.log("End")→ 콜스택에서 실행- 2초 후 콜백 큐에 콜백 추가
- 이벤트 루프가 콜스택이 비었는지 확인하고 콜백 실행
따라서 출력 결과는 아래와 같다
Start End Inside setTimeout
심지어는 같은 비동기 작업도 우선순위가 다르기 때문에 interval과 timeout이백그라운드 작업을 마치고 큐에서 대기중이더라도 콜스택에서 작업이 실행중이라면 추가적으로 기다리는 시간이 생겨 오차가 발생하는 것이다.
내가 사용한 방법이기도 한데, setInterval을 사용해 1초 주기를 설정하는 것보다 setTimeout을 재귀적으로 호출하되 Date.now()를 통해 기준 시간을 정하고 남은 오차를 계산하여 동적으로 실행 간격을 조정하는 것이 오차를 최소화할 수 있는 방법이었다.
변경 전 코드
useEffect(() => { if (!isWithinTimeRange || !currentSchedule || !timerState?.is_running) return; const timerInterval = setInterval(() => { const totalElapsed = calculateElapsedTime( timerState.last_start, timerState.accumulated_time, ); setTime(totalElapsed); }, 1000); return () => clearInterval(timerInterval); }, [timerState?.is_running, currentSchedule, isWithinTimeRange]);단순하게 setInterval을 사용하여 1초마다 경과 시간을 계산하는 로직을 만들었다.
이렇게 설정할 경우 콜스택에서 지연돠거나 작업이 길어질 경우 미세한 오차가 발생하고, 타이머 시간이 길어지면 길어질수록 오차가 커지게 된다.
변경 후 코드
// 타이머 실행 중일 때 경과시간 계산하기 useEffect(() => { if (!isWithinTimeRange || !currentSchedule || !timerState?.is_running) return; let timerId: NodeJS.Timeout; const tick = () => { const totalElapsed = calculateElapsedTime( timerState.last_start, timerState.accumulated_time, ); setTime(totalElapsed); const drift = Date.now() % 1000; timerId = setTimeout(tick, 1000 - drift); }; tick(); return () => clearTimeout(timerId); }, [timerState?.is_running, currentSchedule, isWithinTimeRange]);tick 이라는 타이머 함수를 만들어 경과 시간은 동일하게 계산한다.
const drift = Date.now() % 1000;를 통해 현재 시간의 밀리초를 구한 뒤
timerId = setTimeout(tick, 1000 - drift);에서 다음 1초까지 남은 시간을 계산해 정확히 1초 뒤 실행될 수 있도록 한다.
Date.now = 12:34:56.789라면,drift = 123456789 % 1000 = 789
1000 - drift는 즉1000 - 789
따라서 남은 시간은 211ms 가 되고 211ms 후 다음 1초가 지나가는 시점에서 경과 시간을 다시 계산하게 된다.
이벤트 루프의 개념에 대해 도움이 되는 블로그
이벤트 루프 구조와 원리
setTimeout과 setInterval은 정확한 시간을 보장하지 않는다!
싱글 스레드 언어