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에서 돌아가게 된다.
- <스타게이트>와 같이 시간 보정이 들어가는 대신, 탭이 백그라운드로 넘어갔을때는 시간이 크게 튈 것이다.
- 시간이 1000ms 이하로 줄어들지 않고 늘어나기만 함
- 탭이 백그라운드로 넘어가도 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
그렇다면, 왜 이렇게 동작하는 것일까? 추측해보기로는
- React와 Next.js의 코드를 까보면 그 답을 알 수 있지 않을까?
- 그리고 브라우저 환경(Chrome, Edge 등 V8 사용 브라우저 한정)에서의 보정 기능을 찾아 코드에 추가하면 문제를 해결 할 수 있지 않을까?
setInterval In React
- react의 경우에는 함수 정의문으로 이동해보니 Local의 typeScript로 이동했다.
- 코딩애플은 보정이 들어가서 시간 오차가 늘어나지 않게끔 조절해준다고 말하고 있고, 실제로도 그렇게 동작한다.
- 정확히 어떻게 구현 되어있는지와 보정 코드를 확인하기 위해 더 찾아봤다.
- 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
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 오차
해결책
- 브라우저의 보정 코드를 찾아 next.js에 넣어주자
- ❌ 코드도 못찾았고, 그리고 찾았더라도 C++로 구현되어 있다면?
- delay 값으로 1000ms을 하면 10ms 가량씩 초과되는 모습을 보인다. 그럼 오차를 감안해 delay값을 조정해주자.
- 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번)
반드시 지정한 시간 이상이 지난 다음 함수를 실행시키는 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://nextjs.org/docs/pages/building-your-application/rendering/client-side-rendering
'Project > 모익' 카테고리의 다른 글
로그인 여부에 따른 접근 제한 처리하기 (0) | 2024.01.29 |
---|---|
유저 인증 상태 관리하기 (0) | 2024.01.08 |
Next.js 13 App Routing PWA 적용하기 / Next.js (0) | 2023.11.30 |
Atomic 패턴과 react-hook-form으로 재사용성을 고려한 로그인&회원가입 컴포넌트 만들기 (0) | 2023.11.30 |