[Worker] Web Worker를 활용하여 탭 전환 시 발생하는 타이머 오차 개선하기
반영 PR: https://github.com/woowacourse-teams/2024-ddangkong/pull/406
📘 브라우저에서 탭 전환 시 setInterval 실행 간격에 오차가 발생하는 이유
브라우저가
비활성 탭에서의 리소스 사용을 줄이기 위해
타이머의 실행 빈도를 조절한다.
✅ 비활성 탭의 타임아웃
백그라운드 탭으로 인한 부하와 그로 인한 배터리 소모를 줄이기 위해, 브라우저는 비활성 탭에서의 지연 시간에 최소 값을 강제한다.
Chrome의 경우 비활성 탭에 대해 최소 1초
의 지연 시간을 강제하여 타이머 오차가 발생하게 된다.
✅ Chrome 타이머 정책
JS 타이머에 대한 Chrome 정책에서 최소 제한, 제한, 집중적인 제한을 소개하고 있다.
우리 서비스의 경우 타이머 체인이 5번 이상이며, 5분 이상의 타이머가 없으므로 제한
에 속한다.
따라서 브라우저는 타이머를 초당 한 번씩 확인
하고, 제한 시간이 유사한 타이머는 함께 일괄 처리된다.
🚨 Worker를 사용할 때 주의할 점
Web Worker는 정말 필요할 때만 사용해야 한다.
메모리는 유한하기 때문에, 간단한 로직이라면 싱글 스레드로도 충분한데 굳이 브라우저의 백그라운드 스레드를 사용하는 건 위험하다.
멀티 스레드를 사용하게 되면 컨텍스트 스위칭이 자주 발생하면서 오버헤드 비용이 발생할 수 있고 성능 문제가 발생할 수 있다.
😎 적용 결과
❌ worker 적용 전 탭 전환 시 타이머 | ✅ worker 적용 후 탭 전환 시 타이머 |
---|---|
💻 Worker 타이머 예시 코드
Worker는 main thread 의 window와는 별도의 WorkerGlobalScope
를 갖기 때문에 worker 내에서 window 메서드나 DOM 조작이 불가능하다.
main thread와 worker thread가 서로 데이터를 주고 받기 위해선 Message System을 사용해야 한다. (postMessage
, onmessage
)
TimerWorker.ts
let intervalId: NodeJS.Timeout;
self.onmessage = function (e) {
const { type, delay } = e.data;
if (type === "start") {
const startTime = Date.now();
intervalId = setInterval(() => {
const elapsedTime = Date.now() - startTime;
self.postMessage({ elapsedTime });
}, delay);
} else if (type === "stop") {
clearInterval(intervalId);
}
};
useTimer.ts
const useTimer = ({
timeLimit,
isSelectedOption,
isVoted,
vote,
}: UseTimerProps) => {
const [leftRoundTime, setLeftRoundTime] = useState(timeLimit);
const workerRef = useRef<Worker | null>(null);
const isVoteTimeout = leftRoundTime <= 0;
const isAlmostFinished = leftRoundTime <= ALMOST_FINISH_SECOND;
useEffect(() => {
const timerWorker = new Worker(
new URL("./timerWorker.ts", import.meta.url)
);
workerRef.current = timerWorker;
timerWorker.postMessage({ type: "start", delay: POLLING_DELAY });
timerWorker.onmessage = () => {
setLeftRoundTime((prev) => prev - 1);
};
// 타이머가 끝나기 전에 투표가 완료될 경우 clean-up
return () => {
timerWorker.postMessage({ type: "stop" });
timerWorker.terminate();
};
}, []);
useEffect(() => {
if (isVoteTimeout) {
if (isSelectedOption && !isVoted) {
vote();
}
workerRef.current?.postMessage({ type: "stop" });
workerRef.current?.terminate();
}
}, [isVoteTimeout, isSelectedOption, isVoted, vote]);
return { leftRoundTime, isAlmostFinished };
};
export default useTimer;
댓글남기기