[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;
댓글남기기