React 타이머 만들기

이메일 인증이나 비밀번호 찾기 같은 기능에서 사용할 타이머를 만들었다. 특정 시간 내에 동작해야 하는 기능을 구현하기 위해 타이머 제어 코드가 필요했고, 이를 정리해둔다.

useTimer 훅 만들기 전체 코드

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

export default function App() {
  const { isRunning, minute, second, start, stop, reset, restart, setTimer } =
    useTimer({ initialTime: 120 }); // 초기 2분 (120초) 설정

  const [customTime, setCustomTime] = useState(120); // 사용자 지정 시간

  return (
    <div className="App">
      <h1>타이머</h1>

      {/* 기본 타이머 표시 */}
      <h2>
        {String(minute).padStart(2, "0")}:{String(second).padStart(2, "0")}
      </h2>

      {/* 타이머 컨트롤 버튼 */}
      <button onClick={start} disabled={isRunning}>
        시작
      </button>
      <button onClick={stop} disabled={!isRunning}>
        정지
      </button>
      <button onClick={reset}>리셋</button>
      <button onClick={restart}>재시작</button>

      {/* 사용자 지정 시간 입력 */}
      <div>
        <input
          type="number"
          value={customTime}
          onChange={(e) => setCustomTime(Number(e.target.value))}
        />
        <button
          onClick={() => {
            setTimer(customTime);
          }}
        >
          시간 설정
        </button>
      </div>
    </div>
  );
}

function useTimer({ initialTime }) {
  const initTime = useRef(initialTime); // 초기 타이머 값 저장
  const targetTime = useRef(0); // 타이머 종료 목표 시간 저장
  const stopTime = useRef(0); // 타이머 정지 시 남은 시간 저장
  const requestId = useRef(null); // `requestAnimationFrame` ID 저장

  const [remainingTime, setRemainingTime] = useState(initialTime);
  const [isRunning, setIsRunning] = useState(false);

  /**
   * 타이머 업데이트 함수
   * - 목표 시간과 현재 시간을 비교하여 남은 시간을 계산
   * - 시간이 0 이하가 되면 타이머를 자동으로 정지
   */
  function updateTimer() {
    const newTime = Math.max((targetTime.current - Date.now()) / 1000, 0);
    setRemainingTime(newTime);

    if (newTime <= 0) {
      stop();
    } else {
      requestId.current = requestAnimationFrame(updateTimer); // 다음 프레임에서 실행
    }
  }

  /**
   * 타이머 시작
   * - 최초 실행 시 `initTime` 기준으로 목표 시간 설정
   * - 정지 후 재시작할 경우 `stopTime`을 기준으로 목표 시간 설정
   */
  function start() {
    if (targetTime.current === 0) {
      targetTime.current = Date.now() + initTime.current * 1000;
    } else {
      targetTime.current = Date.now() + stopTime.current * 1000;
    }

    setIsRunning(true);
    requestId.current = requestAnimationFrame(updateTimer);
  }

  /**
   * 타이머 정지
   * - 현재 남은 시간을 저장하고 `requestAnimationFrame`을 취소하여 중지
   */
  function stop() {
    stopTime.current = Math.max((targetTime.current - Date.now()) / 1000, 0);
    setIsRunning(false);

    if (requestId.current) {
      cancelAnimationFrame(requestId.current);
      requestId.current = null;
    }
  }

  /**
   * 타이머 초기화
   * - 초기 시간으로 리셋하고 실행 중이면 정지
   */
  function reset() {
    stop();
    targetTime.current = 0;
    stopTime.current = 0;
    setRemainingTime(initTime.current);
  }

  /* 타이머 재시작 (초기 시간으로 다시 시작) */
  function restart() {
    reset();
    start();
  }

  /**
   * 새로운 시간 설정
   * - 새로운 초기 시간으로 변경하고 리셋
   */
  function setTimer(newTime) {
    initTime.current = newTime;
    setRemainingTime(newTime);
    targetTime.current = 0; // 목표 시간 초기화
    stopTime.current = 0; // 정지된 시간 초기화
  }

  /**
   * - 언마운트 시 `requestAnimationFrame`을 정리하여 메모리 누수 방지
   */
  useEffect(() => {
    return () => {
      if (requestId.current) cancelAnimationFrame(requestId.current);
    };
  }, []);

  return {
    isRunning,
    minute: Math.floor(remainingTime / 60),
    second: Math.floor(remainingTime % 60),
    start,
    stop,
    reset,
    restart,
    setTimer,
  };
}

사실 처음에는 RequestAnimationFrame이 아니라 setInterval을 통해서 일정 시간마다 업데이트를 했었는데, 0초에서 interval 시간 만큼 늦을때가 있어서 방식을 변경한 것이다.

Leave a Comment