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

Leo Bang·2021년 11월 16일
1
post-thumbnail


아주아주아주 게으르게 진행하고 있는 프로젝트가 있다.

workout 루틴을 기록하고 친구들과 공유할 수 있도록 하는 mobile application인데, 어쩌다 보니 동기부여가 팍 죽어버린.. 아쉬운 녀석이다.

이게 원래는 동아리원들끼리 같이하는 팀 프로젝트지만,, 다들 FE BE 환경에 익숙치 않아 그 부분에 대한 공부를 선행해야하는 상황이라 지금은 그냥 나 혼자 FE 부분만 깨작깨작 만들어보는 중이다.
(FE는 react native - expo, BE는 node - express - mysql - sequelize로 개발할 예정이었음.)

사실 혼자서 하더라도 빡세게 끝내버렸으면 지금쯤 FE는 다 끝냈고 BE 부분도 얼추 진행해 놓았을 텐데, 나의 게으름이 끝이 없었고 아직도 코드는 프론트 단에서 어물쩍거리고 있다 ㅜㅜ


Trouble

대충 이런식의 milli-second 단위로 표시되는 타이머를 만들고자 했다.

용도는 세트 간 휴식시간 체크를 위해서.

시간 추가 버튼을 통해서 10초나 30초 추가할 수 있고, 피그마에는 빼먹었지만 시작 버튼과 초기화 버튼도 들어간다.

npm에 타이머 기능을 구현한 패키지도 있었지만 업데이트 한지 3년이나 지났기도 했고, 이 정도는 React와 약간의 vanilla JS로 금방 만들겠다 싶어서 직접 구현하기로 한다.

Idea

맨 처음 생각해낸 방식은

  1. useState로 남은 시간을 저장하는 timer state를 관리한다.

  2. 시작 버튼의 onPress 이벤트로 setInterval() 을 지정한다.

    • setInterval()의 첫번째 parameter는 timer 의 값을 0.01씩 줄여주는 함수
    • 두번째 parameter는 10 (10 milli-second마다 0.01초 씩 줄여주도록)
  3. 일시정지 버튼을 누르거나, timer 의 값이 0 이하로 떨어질 경우 clearInterval() 을 실행하여 돌아가고있는 interval을 정지.

대충 방식은 맞았지만 clearInterval()이 말썽이었다.

// ...
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);
	
    let timerInterval;
  
    // Hooks
    useEffect(() => {        
        minuteCalculator();   
        if (timer <= 0) {
            setToggleTimer(false);
            setIsTimerRunning(false);
        }
        console.log(timer);
    }, [timer])

    useEffect(() => {
        if (toggleTimer) {
            timerInterval = setInterval(timeDecrement, 10);
            setIntervalId(timerInterval);
            setIsTimerRunning(true);
        } else if (!toggleTimer || timer < 0) {
            clearInterval(timerInterval);
        } 
    }, [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)
    }

    const minuteCalculator = () => {
        let tempMinute = parseInt(timer / 60).toString();
        let tempSecond = timer % 60
        let tempSecondre = Math.round((tempSecond / 1)).toString();
        let tempMilliSecond = Math.round(((tempSecond % 1) * 100)).toString()

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

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

    const timeDecrement = () => {
        setTimer((prev) => prev - 0.01);
    }

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

구글링으로 setInterval() 이용법을 확인했을 때, 미리 setInterval()을 담을 변수를 선언한 후 특정 조건을 만족했을 때 clearInterval()의 parameter로 그 변수를 건네주면 interval이 중단되어야 했다.

아마 react에서 변수를 관리할 때의 문제가 그 이유인 것 같다.
console.log로 확인해보았을 때, timerInterval에는 해당 setInterval() 메소드의 id 값이 number type이 저장되어있었다.
물론 이는 useEffect Hook 안의 console에서만 확인할 수 있었고, Hook 바깥에서 console.log로 timerInterval을 출력하면 여전히 undefined 상태였다.

이 것도 scope 문제라 부르는게 맞는지 모르겠지만, 아무튼 이 react에서의 scope 문제는 useState로 id state를 관리해줌으로서 해결할 수 있엇다.


Solution

useStateintervalId state를 관리해줌으로서 scope 문제를 해결해준다.

setInterval() 메소드를 할당한 변수를 intervalId 에 업데이트 시켜주고, 특정 조건 달성 시 clearInterval()의 parameter로 intervalId를 건네주면 정상적으로 interval이 중단된다.

// ...
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 timerInterval;
  
    // Hooks
    useEffect(() => {        
        minuteCalculator();   
        if (timer <= 0) {
            setToggleTimer(false);
            setIsTimerRunning(false);
        }
        console.log(timer);
    }, [timer])

    useEffect(() => {
        if (toggleTimer) {
            const timerInterval = setInterval(timeDecrement, 10);
            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)
    }

    const minuteCalculator = () => {
        let tempMinute = parseInt(timer / 60).toString();
        let tempSecond = timer % 60
        let tempSecondre = Math.round((tempSecond / 1)).toString();
        let tempMilliSecond = Math.round(((tempSecond % 1) * 100)).toString()

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

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

    const timeDecrement = () => {
        setTimer((prev) => prev - 0.01);
    }

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

Here comes a new challenger...

setInterval()을 1초 (1000 ms) 단위로 실행시켰을 때는 아무 문제 없이 실행이 되었으나 10 ms 단위로 설정하자 자꾸 시간이 조금씩 밀리는 일이 생기더라.

JavaScript의 event loop가 Single Thread이기 때문에, setInterval()과 같은 내장된 timer 함수들의 event들이 항상 제 시간에 맞춰서 발생할 수는 없다.

10ms 로 interval을 설정했다면 최소한 10ms에 한 번은 이벤트가 발생하는거지, event loop의 대기열에 따라 그 interval이 10ms가 될 수도 있고 20ms가 될 수도 있는 것이다.

따라서 setInterval() 으로 시간을 직접 세주는 것은 오차가 불가피한 방식이므로 다른 방법을 모색해야 했다.

setInterval()을 통해 시간을 직접 세기보다는 해당 interval 텀으로 Date.now()를 통해 현재 system time을 가져와서 시간의 차이를 구하는게 정확한 방식이라고 한다.

Date.now()로 system time을 가져오는 일이 조금 밀릴 수는 있지만, 여전히 가져오는 건 정확한 system time 이기 때문이다.

일단 근데 timer 문제로 좀 시달렸기 때문에 Date.now()를 이용해 개선하는 건 다음 시간에 해보려고 한다...



References

딱히 없고, 문제 해결을 도와준 준호형에게 감사링

profile
me, myself and code

1개의 댓글

comment-user-thumbnail
2021년 11월 28일

게을러서 미안해

답글 달기

관련 채용 정보