guksulog
포스트 목록
#React#타이머

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

2026년 6월 22일 6분 읽기

화면에 "경과 시간" 타이머를 붙일 일이 있었어요. setInterval로 1초마다 숫자를 1씩 올리면 끝날 줄 알았는데, 실제로 돌려보니 시간이 조금씩 밀리더라고요.

이번 글에서는 가장 흔한 setInterval + prev + 1 방식 타이머가 왜 시간이 밀리는지(drift) 살펴보고, Date.now()를 기준점으로 삼아 실제 경과 시간을 읽는 방식으로 어떻게 해결했는지, 그리고 그 방식에도 남아있는 한계를 다뤄요.

  1. 순진한 타이머가 시간을 흘리는 이유
  2. 실제 시각을 기준으로 drift를 보정하는 법
  3. React에서 빠지기 쉬운 함정 (effect 의존성)
  4. 일시정지·재개·재진입 복원까지
  5. 이 방식으로도 못 막는 것들 (한계점)

1. 순진한 타이머의 함정

처음 떠올리는 구현은 보통 이래요.

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

useEffect(() => {
  const id = setInterval(() => {
    setElapsed((prev) => prev + 1); // 1초마다 1씩 증가
  }, 1000);
  return () => clearInterval(id);
}, []);

읽기엔 자연스럽지만, 여기엔 숨은 가정이 하나 있어요.

"setInterval은 정확히 1000ms마다 콜백을 불러줄 것이다."

그런데 이 가정은 틀려요. setInterval의 간격은 최소 보장일 뿐, 정확한 보장이 아니에요. 브라우저의 이벤트 루프가 다른 일(렌더링, 무거운 동기 작업, GC 등)로 바쁘면 콜백은 뒤로 밀려요.

핵심 문제는 이 방식이 콜백이 불린 횟수로 시간을 센다는 거예요. 콜백이 한 번 늦거나 누락되면, 그만큼 elapsed도 실제 시간보다 적게 쌓여요. 그리고 이 오차는 사라지지 않고 누적돼요.

대표적으로 시간이 밀리는 상황들이에요.

상황무슨 일이 일어나나
메인 스레드가 바쁨콜백 fire가 지연되어 1초보다 늦게 불림
백그라운드 탭브라우저가 타이머를 throttle (보통 1초 이상으로 강제)
노트북 절전/슬립그동안 콜백이 아예 안 불림

10분쯤 돌려보면 실제 시계보다 몇 초씩 모자라요. "타이머가 느려진다"는 체감이 여기서 나와요.

2. 시간은 세는 게 아니라 "읽는" 것

해결의 핵심은 관점을 바꾸는 거예요.

콜백이 몇 번 불렸는지 세지 말고, 시작한 순간으로부터 지금 실제로 얼마가 지났는지 읽자.

setInterval은 "이제 화면을 갱신할 때야"라는 신호로만 쓰고, 실제 경과 시간은 매번 Date.now()로 직접 계산하는 거예요. 벽에 걸린 시계를 쳐다보듯 매번 현재 시각을 읽는다고 해서, 영어로는 이렇게 시스템이 보여주는 실제 시각을 wall-clock time이라고 불러요.

ts
const startedAt = Date.now(); // 시작 시각을 한 번만 기록

function tick() {
  // prev + 1 이 아니라, 시작점부터 지금까지의 실제 경과를 매번 재계산
  setElapsed(Math.floor((Date.now() - startedAt) / 1000));
}

setInterval(tick, 1000);

이러면 콜백이 한 번 늦게 불려도, 그 다음 tick에서 Date.now()가 실제 시각을 반영하니 오차가 즉시 따라잡혀요. 콜백이 5초간 누락됐다가 한 번 불려도 elapsed는 곧장 5만큼 점프해서 실제 시간과 맞아요. 오차가 누적되지 않는 구조예요.

실제 프로젝트의 훅은 이렇게 생겼어요.

ts
export const useElapsedTimer = ({
  isRunning,
  initialSeconds = 0,
}: {
  isRunning: boolean;
  initialSeconds?: number;
}): number => {
  const ONE_SECOND = 1_000;
  const [elapsed, setElapsed] = useState(initialSeconds);

  useEffect(() => {
    if (!isRunning) return;

    // 기준점: "지금"에서 이미 흐른 만큼을 뺀 가상의 시작 시각
    const startedAt = Date.now() - elapsed * ONE_SECOND;

    function tick() {
      setElapsed(Math.floor((Date.now() - startedAt) / ONE_SECOND));
    }

    const id = setInterval(tick, ONE_SECOND);
    return () => clearInterval(id);
  }, [isRunning]);

  return elapsed;
};

startedAt을 그냥 Date.now()가 아니라 Date.now() - elapsed * 1000으로 잡은 게 포인트인데, 이건 4번에서 다시 설명할게요.

3. React에서의 진짜 함정 — effect 의존성

위 코드에서 가장 눈에 띄는 건 이 줄이에요.

ts
}, [isRunning]); // elapsed가 deps에 없다!

elapsed를 effect 안에서 읽는데 의존성 배열엔 없어요. ESLint(react-hooks/exhaustive-deps)가 경고하는, 일부러 누른 부분이에요. 왜 일부러 뺐을까요?

만약 규칙대로 elapsed를 deps에 넣으면 이렇게 돼요.

  1. ticksetElapsedelapsed를 바꿈
  2. elapsed가 바뀌었으니 effect가 재실행
  3. cleanup으로 기존 setIntervalclearInterval되고, setInterval이 다시 걸림
  4. 매 1초마다 이 과정이 반복

문제는 3번에서 타이머가 매번 처음부터 다시 시작한다는 거예요. clearset 사이의 미세한 시간이 매초 버려지면서, 오히려 drift를 막으려던 코드가 drift를 만드는 역설이 생겨요.

그래서 의존성은 [isRunning]만 둬요. effect는 시작/정지될 때만 재구성되고, 그동안 setInterval은 끊김 없이 한 번만 걸려 있어요. elapsed는 effect가 시작되는 그 순간의 closure 값으로 딱 한 번 읽혀서 startedAt 기준점을 잡는 데만 쓰여요. 이후 시간 계산은 전부 Date.now()에 맡기니, 오래된 closure 값을 들고 있어도 문제가 안 돼요.

정리하면, "매 tick마다 신선한 값이 필요하다"는 직관이 여기선 틀려요. 우리가 effect 안에서 진짜 필요한 건 elapsed의 최신값이 아니라 기준 시각 하나뿐이거든요.

4. 일시정지, 재개, 그리고 재진입 복원

startedAt = Date.now() - elapsed * 1000 이 한 줄이 일시정지/재개와 복원을 한꺼번에 해결해줘요.

어떤 동작 때문에 타이머를 잠깐 멈춰야 하고(isRunning = false), 다시 돌아오면 멈췄던 그 값에서 이어서 가야 하는 경우를 생각해볼게요.

  • isRunningfalse가 되면 cleanup이 돌아 setInterval이 해제되고, elapsed는 마지막 값 그대로 멈춰요.
  • 다시 true가 되면 effect가 재실행되는데, 이때 elapsed는 이미 멈췄던 값(예: 12)이에요.
  • startedAt = Date.now() - 12 * 1000으로 잡으면, "12초 전에 시작한 것처럼" 가상의 시작점이 만들어져요. 그래서 다음 tick은 13, 14로 자연스럽게 이어져요.

이 트릭은 재진입 복원에도 그대로 쓰여요. 사용자가 보던 중 브라우저를 닫았다 다시 들어오는 경우를 대비해, 경과 시간을 주기적으로 localStorage에 백업해두면 돼요. 그리고 다시 들어왔을 때 저장된 값을 initialSeconds로 넘기는 거죠.

ts
const initialSeconds = loadSavedElapsed() ?? 0; // 저장된 값 복원
const elapsed = useElapsedTimer({
  isRunning,
  initialSeconds, // 0이 아니라 이어서 시작
});

initialSeconds로 시작값을 넘기면, startedAt이 그만큼 과거로 당겨지면서 저장된 시점부터 매끄럽게 이어져요. "세션을 넘어선 일시정지/재개"인 셈이라, 같은 메커니즘으로 처리돼요.

마지막으로 표시 단에서는 음수를 한 번 더 방어해요. 계산 도중 Date.now() 차이가 일시적으로 음수가 될 수 있거든요.

ts
export const formatTime = ({ seconds }: { seconds: number }): string => {
  if (seconds < 0) return "00:00"; // 음수 방어
  const total = Math.floor(seconds);
  const minutes = Math.floor(total / 60);
  const remainder = total % 60;
  return `${minutes.toString().padStart(2, "0")}:${remainder
    .toString()
    .padStart(2, "0")}`;
};

5. 이 방식으로도 못 막는 것들 (한계점)

이 방식이 만능은 아니에요. Date.now()에 기댄 대가로 생기는 한계가 분명히 있어요.

(1) Date.now()는 단조 증가(monotonic)가 아니에요

Date.now()는 **시스템 시계(wall clock)**를 읽어요. 그래서 시계 자체가 바뀌면 타이머도 같이 튀어요.

  • 사용자가 OS 시간을 수동으로 바꾸거나
  • NTP 동기화로 시계가 갑자기 앞/뒤로 점프하거나
  • 서머타임 전환이 끼면

elapsed가 갑자기 점프하거나, 심하면 뒤로 가요. (그래서 4번의 음수 방어가 필요했어요.)

이런 시계 점프에 영향받지 않으려면 단조 증가가 보장되는 performance.now()를 써야 해요. 다만 performance.now()페이지 로드 기준 상대 시간이라, 브라우저를 닫았다 켜는 재진입 복원에는 그대로 쓸 수 없어요. localStorage에 저장하려면 결국 Date.now() 같은 실제 시각 기준 값이 필요하거든요. "재진입 복원"이 더 중요한 요구사항이라면 Date.now()를 택하고, 시계 점프는 흔치 않은 엣지 케이스로 보고 음수 방어 정도로 타협하는 선택을 할 수 있어요.

(2) 백그라운드 탭에서 "표시"는 여전히 멈춰요

drift 보정은 복귀 시점에 따라잡는 거예요. 백그라운드 탭이나 슬립 동안엔 콜백이 throttle되거나 아예 안 불려서, 그 시간 동안 화면의 숫자는 멈춰 보여요. 탭으로 돌아오는 순간 한 번에 정확한 값으로 점프하긴 하지만, "백그라운드에서도 실시간으로 흐르는 것처럼 보이게" 만들지는 못해요.

(3) 정밀도는 1초, 갱신 주기는 결국 interval에 묶여요

값 자체는 Date.now() 기반이라 정확하지만, 화면이 갱신되는 시점은 여전히 setInterval(1000)에 의존해요. 그래서 밀리초 단위 정밀 표시나 부드러운 애니메이션이 필요하면 이 구조론 부족하고, requestAnimationFrame 같은 다른 도구가 필요해요.

마치며

타이머는 "1초마다 1 더하기"처럼 단순해 보이지만, 실제로는 시간을 세느냐 읽느냐의 문제예요.

  • setInterval갱신 신호로만 쓰고, 경과 시간은 Date.now()매번 다시 읽는다.
  • React에선 effect 의존성을 [isRunning]으로 좁혀, 타이머가 매 tick마다 재생성되지 않게 한다.
  • startedAt = Date.now() - elapsed * 1000 하나로 일시정지·재개·재진입 복원을 모두 처리한다.
  • 단, Date.now()는 monotonic이 아니고 백그라운드 표시는 멈춘다는 한계를 안고 간다.