코드 리뷰를 진행하던 중, 기존 구현된 타이머에서 인터벌 시점이 지연되는 문제가 있다는 피드백을 받았다.

문제를 해결하기 위해 두 가지 방법을 생각해보았다.

아래 코드는 예시를 위해 작성된 코드다.


AS-IS. 시간을 직접 계산, setInterval로 1초마다 갱신

'use client';

import { useEffect, useState } from 'react';
import { atomWithStorage } from 'jotai/utils';
import { useAtom } from 'jotai';

const startTimeAtom = atomWithStorage<number>('inboundProcessStartTime', Date.now());

export default function Timer() {
  const [startTime, setStartTime] = useAtom(startTimeAtom);
  const [elapsedTime, setElapsedTime] = useState(() => Math.floor((Date.now() - startTime) / 1000));

  useEffect(() => {
    const updateTimer = () => {
      setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
    };

    updateTimer(); // 최초 실행
    const intervalId = setInterval(updateTimer, 1000);

    return () => clearInterval(intervalId);
  }, [startTime]);

  const resetTimer = () => {
    const newStartTime = Date.now();
    setStartTime(newStartTime);
    setElapsedTime(0);
  };

  const formatTime = (seconds: number) => {
    const hours = Math.floor(seconds / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);
    const remainingSeconds = seconds % 60;
    return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
  };

  return (
    <div>
      <p>경과 시간: {formatTime(elapsedTime)}</p>
      <button onClick={resetTimer}>타이머 초기화</button>
    </div>
  );
}
  • `inboundProcessStartTime` 변경 시 항상 현재 시간을 기준으로 경과 시간을 계산하므로, 인터벌 시작/취소로 인한 시간 지연 문제가 없다.
    • 현재 시간을 기준으로 연산을 하지만 setInterval로 화면 렌더링이 되며 시간 지연 문제가 발생한다.

TO-BE. requestAnimationFrame을 통해 유연한 화면 갱신

setInterval을 사용하는 대신, requestAnimationFrame을 활용해 갱신 주기가 고정된 1초보다 더 유연하게 동작하여 즉시 시간을 업데이트하도록 구현한다.

"use client";

import { useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { useEffect, useState } from "react";

const startTimeAtom = atomWithStorage<number>(
  "inboundProcessStartTime",
  Date.now()
);

export default function Timer() {
  const [startTime, setStartTime] = useAtom(startTimeAtom);
  const [elapsedTime, setElapsedTime] = useState(() =>
    Math.floor((Date.now() - startTime) / 1000)
  );

  useEffect(() => {
    let animationFrameId: number;

    const updateTimer = () => {
      setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
      animationFrameId = requestAnimationFrame(updateTimer);
    };

    updateTimer();

    return () => cancelAnimationFrame(animationFrameId);
  }, [startTime]);

  const resetTimer = () => {
    const newStartTime = Date.now();
    setStartTime(newStartTime);
    setElapsedTime(0);
  };

  const formatTime = (seconds: number) => {
    const hours = Math.floor(seconds / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);
    const remainingSeconds = seconds % 60;
    return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(
      2,
      "0"
    )}:${String(remainingSeconds).padStart(2, "0")}`;
  };

  return (
    <div>
      <p>경과 시간: {formatTime(elapsedTime)}</p>
      <button onClick={resetTimer}>타이머 초기화</button>
    </div>
  );
}
  • inboundProcessStartTime 변경 즉시 화면에 반영된다.
  • requestAnimationFrame 을 활용하여 갱신 주기가 고정된 1초보다 더 유연하게 동작하므로 사용자 경험이 자연스럽다.
    • requestAnimationFrame:
      • requestAnimationFrame은 브라우저에서 화면 갱신 주기에 맞춰서 콜백함수가 호출되도록 예약한다. 즉, updateTimer 함수는 화면을 다시 그릴 때마다 호출된다.
      • updateTimer는 setElapsedTime을 사용해 elapsedTime을 계속 갱신하고, requestAnimationFrame(updateTimer)로 자신을 다시 호출한다. 이로 인해 타이머가 계속 갱신된다.
    • updateTimer 함수는 requestAnimationFrame을 호출하여 자신을 계속 실행하게 만든다. 그래서 한 번 updateTimer가 실행되면, 그 다음에는 다시 requestAnimationFrame(updateTimer)가 호출되면서 계속해서 실행된다.
    • 브라우저가 화면을 새로 그릴 때마다 updateTimer가 호출되므로, 타이머는 화면 갱신 주기마다 실행된다.
 

Window: requestAnimationFrame() method - Web API | MDN

window.requestAnimationFrame() 메서드는 브라우저에게 수행하기를 원하는 애니메이션을 알리고 다음 리페인트 바로 전에 브라우저가 애니메이션을 업데이트할 지정된 함수를 호출하도록 요청한다.

developer.mozilla.org


참고 자료