React로 타이머 구현하기 Part.2

Leo Bang·2021년 12월 3일
0
post-thumbnail

Trouble

종전에 setInterval()의 id값을 저장하지 못해 이미 실행된 interval을 죽이지 못했던 문제는 React의 상태관리 Hook인 useState로 해결했다.

하지만 문제가 남아있었으니 그것은 ...

setInterval 타이머 메소드로 실행할 함수가 꼭 지정한 ms마다 실행되지 않는다는 것이다.

// setInterval Example
const testFunc = (param1, param2) => {
  // 10ms 이상 걸리는 함수
};

const intervalId = setInterval(testFunc(param1, param2), 10);

와 같이 testFunc() 함수를 10ms 마다 실행하는 setInterval을 선언했다고 하자.
testFunc() 함수가 delay가 거의 없는 함수라면 상관없지만, 만약 10ms 이상의 시간이 걸리는 함수라면??

당연히 다음 실행될 interval은 그만큼 벌어지게 된다.

따라서 setInterval 타이머로 지난 시간을 n씩 증가시켜주는 방식으로 카운트다운을 구현한다면 정확도는 기대할 수 없겠다. (물론 interval을 충분히 길게 잡아주면 문제없다. 여기서는 1000ms - 1초 씩만 잡아줘도 오차가 크지 않다.)



Solution

항상 정확한 시스템 시각을 반환하는 Date.now() 메서드를 이용하면 된다.

setInterval() 타이머가 처리하는 작업이 오래걸려 함수 간 interval이 밀릴 수는 있지만, 매 함수 실행 시 올바른 시스템 시각을 반환하므로 시각의 정확도 문제는 없다.

setInterval()이 밀려서 생기는 문제는 흘러간 시간의 state를 업데이트하는 순간순간이 일정하지 않을 수 있다는 점 정도? 어차피 이 부분은 사람이 체감하기 어려울 정도의 짧은 시간이니 상관없겠다.

내가 참고한 예제에서는 카운트다운 타이머가 아니라, 증가하는 타이머를 구현했기 때문에 다음과 같은 로직으로 타이머를 계산했다.

  1. 타이머를 시작한 시각을 저장한다.(startTime)
  2. useState 훅을 통해 현재 시각 (Date.now())에서 시작한 시각 (startTime)을 빼준다.
  3. 2를 통해 저장한 state가 시작한 시각부터 현재까지 흘러간 시간이므로 이를 화면에 표시한다.

Idea

나는 증가하는 타이머를 원한게 아니라, 특정 시간부터 줄어드는 Count Down 타이머를 원했으므로 다음과 같은 로직으로 변형했다.

  1. useState 훅을 이용해서 남은 시간을 저장한다. (timer - setTimer())
  2. useRef 훅을 이용해서 타이머를 처음 실행시킬 때 남은 시각을 저장한다. (leftTimeRef)
  3. 타이머를 시작한 시각을 저장한다. (startTime)
  4. 현재 시각 (Date.now())에서 시작한 시각 (startTime)을 뺀 값을 저장한다. (timePassed)
  5. setTimer()를 통해 남은시간 (leftTimerRef.current)에서 5번을 통해 구한 지난 시간 (timePassed)을 뺀 값을 업데이트 시켜준다.
  6. timer에 업데이트되는 값들을 화면에 표시한다.
// ...
const StartWorkout = () => {
    // States
    const [timer, setTimer] = useState(0);
    const [minute, setMinute] = useState("");
    const [second, setSecond] = useState("");
    const [milliSecond, setMilliSecond] = useState("");
    const [toggleTimer, setToggleTimer] = useState(false);
    const [toggleBtnName, setToggleBtnName] = useState("시작");
    const [isTimerRunning, setIsTimerRunning] = useState(false);
    const [intervalId, setIntervalId] = useState(0)
    let startTime = 0;
  
    // Refs
    const leftTimeRef = useRef(0);

    // Hooks
    useEffect(() => {        
        minuteCalculator();   
        console.log("left time: ", timer);
        if (timer <= 0) {
            setToggleTimer(false);
            setIsTimerRunning(false);
        }
    }, [timer])

    useEffect(() => {
        if (toggleTimer) {
            startTime = Date.now();
            const timerInterval = setInterval(timeDecrement, 1000);
            leftTimeRef.current = timer;
            setIntervalId(timerInterval);
            setIsTimerRunning(true);
        } else if (!toggleTimer || timer < 0) {
            clearInterval(intervalId);
        } 
    }, [toggleTimer])

    useEffect(() => {
        if (!isTimerRunning) {
            setToggleBtnName('시작');
        } else if (isTimerRunning && !toggleTimer) {
            setToggleBtnName('다시시작');
        } else if (isTimerRunning && toggleTimer) {
            setToggleBtnName('일시정지');
        }
    }, [isTimerRunning, toggleTimer])


    // Event Handlers
    const addTime = (time) => {
        setTimer((prev) => prev + time)
        leftTimeRef.current += time;
    }

    const minuteCalculator = () => {
        let toSecond = parseInt(timer / 1000);
        let tempMinute = parseInt(toSecond / 60).toString();
        let tempSecond = parseInt(toSecond % 60).toString();
        let tempMilliSecond = parseInt((timer % 1000) / 10).toString();

        setMinute(tempMinute);
        setSecond(tempSecond);
        setMilliSecond(tempMilliSecond);
    }

    const toggleTimerFunc = () => {
        if (toggleTimer) {
            setToggleTimer(false)
        } else if (!toggleTimer && timer > 0){
            setToggleTimer(true)
        }
    }

    const timeDecrement = () => {
        const timePassed = Date.now() - startTime;
        setTimer(leftTimeRef.current - timePassed);
    }

    const clearTime = () => {
        setTimer(0);
    }
return (
  //...


Here comes another challenger ...

또,, 또 문제가 생겼다.

CountDown 타이머를 처음 실행하고 시작, 일시정지 기능만 건들고 다른 건 건들지 않는다면 무난하게 원하는 바를 잘 보여준다.

하지만 타이머 실행 도중 (setInterval이 돌아가는 상태)에 시간을 추가하려고 하면 타이머가 말을 듣지 않는다 ... !

타이머가 실행되지 않는 상태 (setInterval이 돌아가지 않는 상태)에서는 시간이 정상적으로 추가되었지만, 실행되는 상태에서는 아무리 시간을 추가해보아도 그냥 원래 남은시간 그대로를 표시해버린다.

interval을 늘려서 console.log를 통해 디버깅해보니, 시간을 추가하는 버튼 이벤트를 발생시키면, interval 중간에는 정상적으로 남은 시간이 추가되지만 다음 interval이 돌아와서 시간을 감소시킬 때 추가된 남은 시간에서 감소시키는게 아니라 원래 시작했을 때의 남은 시간에서 감소시켜버렸다.

그러니까 setInterval로 실행할 timeDecrement 함수가 실행되는 사이에는 정상적으로 남은 시각이 추가되었지만, 다음 timeDecrement 가 실행될 때면 남은 시각이 추가되지 않은 상태로 계산되었다...

로직의 문제라기보다는 setInterval과 React Hook 사이의 문제인 듯하다.
구글링을 해보니 공통된 문제점을 겪는 사람들이 보였고, 그 중 하나는 이를 해결하기 위한 개선된 Custom Hook도 만들었더라.

Part 3에서는 이 Custom Hook을 이용해서 Count Down 타이머를 완성시키고, 이를 컴포넌트화하는 과정까지 다루겠다 ~ ~ ~ !

Reference

profile
me, myself and code

0개의 댓글

관련 채용 정보