본문 바로가기
Project/모익

런타임을 고려해 타이머 구현하기

by 이의찬 2024. 1. 29.

0.TL;DR

  • Next.js에서 setInterval을 동작시키니, 오차가 누적되는 현상을 발견
  • Next.js가 node.js 환경을 전제로 구현된 프레임워크라는 점을 원인으로 추측
  • 기본적인 오차 누적 문제는 delay값을 이전 오차값에 따라 적절하게 변동해서 넘겨주도록 구현해 해결
  • 비활성화 문제는 탭 비활성화 시간 기록 -> 재활성화 시, 기록한 시간을 기반으로 남은 시간 재계산

1. Why?

나는 핀테크 프로젝트 <모익>에서 회원관리 및 보안 처리를 담당했는데, 그 중에서 아래의 타이머 부분에 setInterval을 사용했다. <스타게이트>에서 setInterval을 사용한 글을 보고 왔다면 알겠지만, Js의 setInterval은 정확하게 동작하지 않는 문제가 있기 때문에 이를 고쳐보고자 한다.

 


2. 뭐가 문젠데?

우선 <모익>은 <스타게이트>와는 다르게 Next.js를 사용해서 프로덕트를 만들었다. 타이머 부분의 코드는 아래와 같다.

export default function Timer() {
  const [remainingTime, setRemainingTime] = useState<number>(180);

  useEffect(() => {
    let timer: NodeJS.Timeout;

    if (remainingTime > 0) {
      timer = setInterval(() => {
        setRemainingTime((prevTime) => {
          if (prevTime <= 1) {
            clearInterval(timer);
            return 0;
          }
          return prevTime - 1;
        });
      }, 1000);
    }
    return () => {
      if (timer) {
        clearInterval(timer);
      }
    };
  }, [remainingTime]);

  // return JSX... 
}

지금 보니까 왜 컴포넌트안에 그대로 타이머를 박아둔거지...? 일단은 타이머 문제점 파악부터 해보자. 내 예상은

  • useEffect 내부에서 setInterval을 사용했으니, 그럼 client side에서 돌아가게 된다.
  • <스타게이트>와 같이 시간 보정이 들어가는 대신, 탭이 백그라운드로 넘어갔을때는 시간이 크게 튈 것이다.

띠용?

  1. 시간이 1000ms 이하로 줄어들지 않고 늘어나기만 함
  2. 탭이 백그라운드로 넘어가도 2000ms 가량까지만 늘어남

useEffect를 사용했으니, 브라우저에서 Web api로 계산할거라고 예상했다. 실제로 Next.js 공식 홈페이지에도 useEffect를 사용하는 경우 CSR이 이뤄진다고 작성되어 있다. 그러면 도대체 왜 시간 보정이 안되는걸까? 그 실마리를 setInterval의 return 타입에서 찾을 수 있었다.

NodeJs.Timeout

NodeJS.Timeout은 Node.js 환경에서 setTimeout이나 setInterval 함수가 반환하는 타이머 객체의 타입이다. 이 타입을 사용하는 것으로 봤을 때, node.js의 event loop에서 setInterval이 사용되는 것이 분명해보인다.


3. React Vs Next.js

그렇다면, 왜 이렇게 동작하는 것일까? 추측해보기로는

  1. React와 Next.js의 코드를 까보면 그 답을 알 수 있지 않을까?
  2. 그리고 브라우저 환경(Chrome, Edge 등 V8 사용 브라우저 한정)에서의 보정 기능을 찾아 코드에 추가하면 문제를 해결 할 수 있지 않을까?

setInterval In React

  • react의 경우에는 함수 정의문으로 이동해보니 Local의 typeScript로 이동했다.
  • 코딩애플은 보정이 들어가서 시간 오차가 늘어나지 않게끔 조절해준다고 말하고 있고, 실제로도 그렇게 동작한다.
  • 정확히 어떻게 구현 되어있는지와 보정 코드를 확인하기 위해 더 찾아봤다.
    • 같은 파일 안 : 없음, 그냥 선언만 있음
    • V8 엔진 : BoyerMoorePositionInfo라는 클래스(아마 보이어무어 알고리즘에 사용되는 구조체인듯...?) 내부에 setInterval 코드가 있긴 한데, 내가 찾는건 아닌거같음..
    • Chrome : 없음...
  • https://stackoverflow.com/questions/35824722/implementing-settimeout-and-setinterval-in-pure-javascript
  • stackoverflow에서는 node에 구현된게 전부라고 한다.

node.js의 경우에는 구현체를 github에서 쉽게 찾을 수 있었다.

/**
 * Schedules repeated execution of `callback`
 * every `repeat` milliseconds.
 * @param {Function} callback
 * @param {number} [repeat]
 * @param {any} [arg1]
 * @param {any} [arg2]
 * @param {any} [arg3]
 * @returns {Timeout}
 */
function setInterval(callback, repeat, arg1, arg2, arg3) {
  validateFunction(callback, 'callback');

  let i, args;
  switch (arguments.length) {
    // fast cases
    case 1:
    case 2:
      break;
    case 3:
      args = [arg1];
      break;
    case 4:
      args = [arg1, arg2];
      break;
    default:
      args = [arg1, arg2, arg3];
      for (i = 5; i < arguments.length; i++) {
        // Extend array dynamically, makes .apply run much faster in v6.0.0
        args[i - 2] = arguments[i];
      }
      break;
  }

  const timeout = new Timeout(callback, repeat, args, true, true);
  insert(timeout, timeout._idleTimeout);

  return timeout;
}

setInterval In Next.js

@types/node 패키지에 선언된 setInterval

Next.js의 경우에는 Local 대신 node_modules의 @types/node 패키지로 이동했다. 그렇다면, NodeJs.Timeout 타입을 Next.js의 setInterval이 강제하는 이유는 아마도

  • 애초에 Next.js는 서버인 node.js를 사용하는 것을 전제로 구현된 SSR 프레임워크다.
  • 고로 각 함수에 대한 선언도 node.js환경에서 돌아간다는 것을 기반으로 선언되어 있다.
  • => 런타임 환경이 브라우저인지, node인지는 실행될 때 아는거니까 일단 node에서 돌아간다고 생각할게~

가 아닐까?

혹시 Chrome에서 어떻게 setInterval 보정이 이뤄지는지 아시는 분이 있다면 알려주시면 감사드리겠습니다!

4. How?

해결 방법

이제 추측을 기반으로 어떻게 해결할지를 생각해보자. 우선 문제는 다음과 같다.

  • setInterval 오차
  • 탭 비활성화 오차

setInterval 오차

해결책

  1. 브라우저의 보정 코드를 찾아 next.js에 넣어주자 
    • ❌ 코드도 못찾았고, 그리고 찾았더라도 C++로 구현되어 있다면?
  2. delay 값으로 1000ms을 하면 10ms 가량씩 초과되는 모습을 보인다. 그럼 오차를 감안해 delay값을 조정해주자.
  3. delay를 측정한 다음, 초과/미달값을 가감해서 다음 delay로 넘겨주자

2번

오차를 감안해 delay값을 990ms로 하고 시간을 측정해보았다.

  const [remainingTime, setRemainingTime] = useState<number>(180);
  const [sumTime, setSumTime] = useState<number>(0);

  useEffect(() => {
    let from = Date.now();
    let timer: NodeJS.Timeout;
    if (remainingTime > 0) {
      timer = setInterval(() => {
        const to = Date.now();
        setSumTime(sumTime + to - from - 1000);
        console.log(sumTime);
        console.log(to - from);
        from = to;
        setRemainingTime((prevTime) => {
          if (prevTime <= 1) {
            clearInterval(timer);
            return 0;
          }
          return prevTime - 1;
        });
      }, 990);
    }
    return () => {
      if (timer) {
        clearInterval(timer);
      }
    };
  }, [remainingTime]);

직접 돌려서 오차를 측정해보니 대략 0.25초 정도의 오차가 생긴다. 티켓팅도 아니고 3분짜리 타이머의 결과물이니 큰 문제는 없을 듯 하긴 하지만,,, 조금 더 개선할 수 있다면 좋겠다.

3번

딜레이 시간을 보장하는 setTimeout의 특성 때문인지, 오차가 계속해서 누적되는 현상이 보여진다. 9초만에 0.044초가 누적되었으니, 180초라면 단순계산시 0.9초 가까이 오차가 생긴다. 아이디어는 좋았으나, 결과는 문제가 있어보인다.

결론(2번 + 3번)

출처 :&nbsp;https://ko.javascript.info/settimeout-setinterval

 

반드시 지정한 시간 이상이 지난 다음 함수를 실행시키는 setTimeout의 특성을 고려해, interval 값을 0.99초로 지정해주었다. 그 결과 180초를 계산하였을때, 0.06초 가량의 오차만 생기게 할 수 있었다.

탭 비활성화 오차

탭 비활성화 시간 기록 -> 재활성화 시, 기록한 시간을 기반으로 남은 시간 재계산

const useTimer = (initialTime: number, interval: number) => {
  const [remainingTime, setRemainingTime] = useState<number>(initialTime);
  const [inactiveTime, setInactiveTime] = useState<number>(0);
  const [stopTime, setStopTime] = useState<number>(0);

  useEffect(() => {
    let from = Date.now();
    let timer: NodeJS.Timeout;

    const handleVisibilityChange = () => {
      const to = Date.now();
      if (document.visibilityState === 'hidden') {
        setInactiveTime(to);
        setStopTime(remainingTime);
      } else {
        const inactiveDuration = inactiveTime
          ? Math.floor(stopTime - (to - inactiveTime) / 1000)
          : 0;
        setRemainingTime(inactiveDuration);
        setInactiveTime(0);
      }
    };
    document.addEventListener('visibilitychange', handleVisibilityChange);

    const calculateRemaining = () => {
      if (remainingTime <= 1) {
        setRemainingTime(0);
        return;
      }
      const to = Date.now();
      const diff = to - from;
      const delay = interval - (diff - interval);

      from = to;
      setRemainingTime((prevStepTime) => prevStepTime - 1);
      timer = setTimeout(calculateRemaining, delay);
    };
    timer = setTimeout(calculateRemaining, interval);

    return () => {
      clearTimeout(timer);
      document.removeEventListener('visibilitychange', handleVisibilityChange);
    };
  }, [remainingTime]);
  return remainingTime;
};

export default useTimer;
import useTimer from '@/hooks/useTimer';

const formatTime = (seconds: number): string => {
  const minutes = Math.floor(seconds / 60)
    .toString()
    .padStart(2, '0');
  const remainingSeconds = (seconds % 60).toString().padStart(2, '0');
  return `${minutes}:${remainingSeconds}`;
};
export default function Timer() {
  const remainingTime = useTimer(179, 992);

  return (
    <div className="w-16 h-8 px-2 text-center border-2 border-Tertiary rounded-[10px] text-red-500 my-1">
      <p>{`${formatTime(remainingTime)}`}</p>
    </div>
  );
}

6. Future Work

  • Next.js에서는 왜 client side에서 실행되는 경우에도 NodeJS.Timeout 타입을 강제하는지 찾아보기
  • 시간 보정 / 탭 비활성화 오차 모두 좀 더 정확하고 효율적인 방법을 생각해보기

7. 래퍼런스

https://medium.com/pixo-co/%EC%9B%B9%EC%97%90%EC%84%9C-%EC%A0%95%ED%99%95%ED%95%9C-%ED%83%80%EC%9D%B4%EB%A8%B8%EB%A5%BC-%EB%A7%8C%EB%93%9C%EB%8A%94-%EB%B0%A9%EB%B2%95%EC%9D%80-f134e3766301

 

웹에서 정확한 타이머를 만드는 방법은?

How to make accurate timer in Web

medium.com

https://nextjs.org/docs/pages/building-your-application/rendering/client-side-rendering

 

Rendering: Client-side Rendering (CSR) | Next.js

Learn how to implement client-side rendering in the Pages Router.

nextjs.org

https://stackoverflow.com/questions/35824722/implementing-settimeout-and-setinterval-in-pure-javascript

 

Implementing setTimeout() and setInterval() in pure JavaScript

I have some JavaScript benchmark code that is supported to be running on browser. But I would like to run it on console mode of JavaScript engine such as 'd8' in V8 for testing purpose. I have wri...

stackoverflow.com