글 목록

setInterval로 만든 타이머는 왜 시간이 밀릴까

2026년 6월 22일 5분 읽기
React타이머

화면에 경과 시간 타이머를 붙일 일이 있었습니다. setInterval로 1초마다 숫자를 올리면 될 줄 알았는데, 다른 작업을 하거나 탭을 바꿨다 돌아오면 실제 시계와 차이가 났습니다.

처음에는 호출 간격을 더 짧게 하면 정확해질 것 같았습니다. 하지만 이 방식은 얼마나 자주 호출하느냐보다 무엇을 시간의 기준으로 삼느냐가 문제였습니다. 콜백 횟수, 실제 경과 시간, 사용자가 화면에서 보는 숫자를 나누어 살펴보겠습니다.

1초 뒤 실행과 1초가 지났다는 판단은 다르다

처음 구현은 다음과 같았습니다.

tsx
const [elapsed, setElapsed] = useState(0);

useEffect(() => {
  const id = setInterval(() => setElapsed((prev) => prev + 1), 1000);
  return () => clearInterval(id);
}, []);

이 코드는 실제로 콜백이 실행된 횟수를 셉니다. 브라우저가 바쁘거나 백그라운드 실행을 제한하면 예정 시점에 콜백이 실행되지 않을 수 있습니다. 1000은 정확한 실행 시각을 보장하는 예약이 아닙니다.

다음 그림은 원리를 설명하기 위한 가상 실행 기록입니다. 시작 후 1초와 2초에 콜백이 실행되고, 다음 실행은 5초까지 지연됐다고 가정합니다.

1초, 2초, 5초에 실행된 콜백에서 횟수 방식은 3초, 시각 차이 방식은 5초가 되는 비교

세 번째 콜백에서 횟수 방식은 3을 보여줍니다. 같은 순간 현재 시각 - 시작 시각을 계산하면 5초입니다. 이 차이가 핵심입니다. 모든 지연이 일정하게 누적되는 것은 아니지만, 호출 횟수를 시간으로 해석하는 한 실행이 지연·제한되는 상황에서 실제 시간과 어긋날 수 있습니다.

타이머는 화면을 깨우고, 시계는 시간을 계산한다

해결은 setInterval의 책임을 줄이는 것입니다. 타이머는 화면을 다시 계산할 기회만 만들고, 값은 기준 시각에서 구합니다.

ts
const startedAt = Date.now();

function elapsedSeconds(now: number) {
  return Math.floor((now - startedAt) / 1000);
}

const id = setInterval(() => {
  console.log(elapsedSeconds(Date.now()));
}, 1000);

// 사용이 끝나면 clearInterval(id)

콜백이 늦게 오면 그 순간의 시각으로 다시 계산합니다. 누락된 콜백 수를 추측하거나 반복해서 1을 더할 필요가 없습니다. 다만 콜백이 실행되지 않는 동안 화면은 그대로입니다. 값의 정확성과 화면 갱신 빈도는 서로 다른 요구사항입니다.

이를 확인하는 데 10분을 기다릴 필요는 없습니다. 계산 함수에 시작 시각보다 1,000ms, 2,000ms, 5,000ms 뒤의 값을 넣었을 때 각각 1, 2, 5가 나오는지 보면 됩니다. 실제 백그라운드 제한은 브라우저별 통합 확인으로 따로 검증합니다.

일시정지를 넣으면 정수 초만 저장해서는 부족하다

처음에는 표시한 elapsed를 가지고 Date.now() - elapsed * 1000으로 시작점을 복원했습니다. 정수 초를 이어 붙이기에는 간단하지만, 12.8초에 정지한 경우 0.8초를 잃습니다. 정지와 재개를 반복하면 이 손실도 쌓입니다.

더 명확한 모델은 두 값을 두는 것입니다. 이미 확정한 누적 밀리초와 현재 실행 구간의 시작 시각입니다. 실행 중에는 둘의 합으로 경과 시간을 계산하고, 정지할 때 이번 구간을 누적 밀리초에 합칩니다. 정수로 버림하는 것은 화면에 표시할 때만 합니다.

text
실행 중 경과 = 확정한 누적 시간 + (현재 시각 − 이번 시작 시각)
정지 시 누적 = 확정한 누적 시간 + 이번 실행 구간
재개 시 시작 = 재개한 현재 시각

이 방식은 정지한 시간을 포함하지 않는 타이머입니다. 휴식 중에도 흘러야 하는 마감 카운트다운이라면 종료 시각 하나를 저장하고 종료 시각 - 현재 시각을 계산하는 모델이 더 맞습니다. 같은 타이머 UI라도 시간의 의미부터 정해야 합니다.

React에서는 기준점과 표시 상태를 분리했다

기준값을 ref에 두면 화면 상태가 갱신될 때마다 타이머를 다시 만들 필요가 없습니다. 다음은 실행·정지만 다루는 훅입니다. initialSeconds는 최초 마운트의 초기값이며, 외부에서 변경해 리셋하는 API는 아닙니다.

tsx
import { useEffect, useRef, useState } from "react";

export function useElapsedTimer({
  isRunning,
  initialSeconds = 0,
}: {
  isRunning: boolean;
  initialSeconds?: number;
}) {
  const accumulatedMs = useRef(initialSeconds * 1000);
  const [elapsed, setElapsed] = useState(initialSeconds);

  useEffect(() => {
    if (!isRunning) {
      setElapsed(Math.floor(accumulatedMs.current / 1000));
      return;
    }

    const startedAt = Date.now();
    const readMs = () =>
      accumulatedMs.current + Math.max(0, Date.now() - startedAt);
    const tick = () => setElapsed(Math.floor(readMs() / 1000));
    tick();
    const id = window.setInterval(tick, 250);
    const onVisible = () => {
      if (document.visibilityState === "visible") tick();
    };
    document.addEventListener("visibilitychange", onVisible);

    return () => {
      accumulatedMs.current = readMs();
      window.clearInterval(id);
      document.removeEventListener("visibilitychange", onVisible);
    };
  }, [isRunning]);

  return elapsed;
}

elapsed를 effect 안에서 읽지 않으므로 의존성을 숨길 필요가 없습니다. effect는 isRunning이 바뀔 때 실행되고, cleanup에서 밀리초 단위의 구간을 확정합니다. 250ms는 표시 반응성을 위한 예시 간격이지 정확도를 보장하는 숫자는 아닙니다. 탭이 보이게 될 때도 한 번 계산해 다음 interval까지 기다리지 않도록 했습니다.

이 훅의 정지 시점은 React가 effect를 정리하는 시점입니다. 사용자 입력이 발생한 정확한 시각에 정지해야 한다면 pause()에서 기준 시각을 즉시 확정하는 별도의 컨트롤러를 둘 수 있습니다. 리셋·랩 타임까지 필요한 제품이라면 그 동작도 명시적인 상태 전이로 설계하는 편이 낫습니다.

Date.now를 선택하면 시스템 시계의 성질도 따라온다

Date.now()는 실제 시각의 타임스탬프입니다. 사용자가 시스템 시간을 바꾸거나 시계가 보정되면 경과 계산도 바뀔 수 있습니다. 위 Math.max는 음수 구간을 막을 뿐, 앞으로 크게 이동한 시각이나 과거로 움직인 시각을 정확하게 복구하지 못합니다.

다만 시간대나 서머타임이 바뀌는 것 자체가 Unix 타임스탬프를 한 시간 이동시키는 것은 아닙니다. 지역 시각을 표시하는 방식과 경과 시간을 계산하는 기준을 혼동하면 안 됩니다.

한 페이지 안에서 짧은 구간을 재려면 단조 시계인 performance.now()가 적합합니다. 시스템 시각 변경에 영향을 받지 않습니다. 반면 페이지를 새로 열었을 때 이전 페이지의 상대 시각을 그대로 빼서는 안 되고, 절전 중 시간 진행에도 운영체제·브라우저 차이가 있으므로 요구사항에 맞춰 확인해야 합니다. MDN의 두 시계 비교

다시 들어왔을 때 무엇을 복원할지도 정해야 한다

저장한 누적 시간을 초기값으로 넣으면 마지막 저장 시점까지의 활동 시간은 복원할 수 있습니다. 브라우저를 닫은 동안의 시간은 추가되지 않습니다. 종료 직전 기록이 저장되지 않았다면 마지막 저장 이후의 시간도 잃을 수 있습니다.

닫혀 있던 시간도 포함해야 한다면 누적 초만 저장하지 말고 실행 상태와 시작 또는 종료 타임스탬프를 함께 저장해야 합니다. 시험 종료나 결제 만료처럼 권한과 관련된 시간은 브라우저의 값으로 판정하지 않고 서버에서 마감을 검증해야 합니다. 화면의 카운트다운은 안내 역할입니다.

확인할 때는 실행 중 지연, 소수 초에서 정지·재개, 탭 복귀, 재마운트, 시스템 시각 변경을 나눠보면 좋겠습니다. 각각 값이 어떻게 보여야 하는지 정해두면 구현도 그 계약에 맞춰 단순해집니다. 제가 고친 것은 더 정확한 interval이 아니라, 시간을 판단하는 기준이었습니다.