본문 바로가기
Developer/Trouble Shooting

크롬에서 타이머가 자꾸 멈춰요

by 해적거북 2024. 12. 5.
728x90

목차

  1. 느리게 가는 타이머
  2. 무슨 문제일까?
  3. 어떻게 해결할까?
    1. web worker 이용하기
    2. Page Visibility api 이용하기
  4. 다른 브라우저는 어때?
  5. 참고

 


 

느리게 가는 타이머

저는 최근 프로젝트에서 예상치 못한 타이머 문제를 접하게 되었습니다. 처음에는 타이머가 멈춘 것처럼 보였는데, 사실 정확히 말하면 타이머가 느리게 작동하고 있었습니다. 이런 상황은 처음이라 당황스러웠습니다. 코드를 점검해 보았지만, 코드 자체에는 문제가 없었습니다.

 

이 문제를 처음 발견하신 타 부서의 직원분께서 문제를 재현하는 방법을 알려주셨습니다. 사용 중인 브라우저는 크롬이었고, 타이머를 실행한 후 해당 탭을 오랫동안 상호작용하지 않은 채 다른 탭에서 활동을 하다가 약 5분 후에 다시 타이머가 있는 탭으로 돌아왔을 때, 타이머가 5분이 아니라 2-3분밖에 지나지 않았습니다.

 

다행히도 이 문제는 짧은 시간으로 설정된 타이머나 해당 탭에서 상호작용이 있는 경우에는 발생하지 않았습니다. 그리고 서비스 특성상 긴 시간으로 타이머를 설정하는 경우가 드물었습니다. 그렇지만 이러한 오류를 경험한 사용자가 있으므로, 반드시 수정해야 할 필요가 있었습니다.

 

 

무슨 문제일까?

크롬 브라우저는 자원과 배터리를 절약하기 위해 비활성화된 탭에 대해 쓰로틀링을 적용합니다. 이 변화가 이루어지기 전에는 크롬에서 사용되는 전력의 3분의 1이 비활성화된 탭에서 소비되고 있었습니다. 이러한 전력 소비는 배터리 수명에 부정적인 영향을 미치며, 비활성화된 탭이 차지하는 CPU 사용량 때문에 실제로 상호작용하는 부분의 성능을 최대한 발휘하지 못하게 되어 사용자 경험을 저해하는 상황이 발생하기도 했습니다. 관련 내용은 크롬 개발자 블로그에서 확인할 수 있습니다.

https://developer.chrome.com/blog/background_tabs?hl=ko
https://blog.chromium.org/2017/03/reducing-power-consumption-for.html

 

또한, 해당 페이지를 장시간 비활성화 상태로 두면 크롬에서 사용자에게 알림을 제공하기도 합니다. (현재 버전 131)

장시간 비활성화 탭 알림 (크롬)

 

 

어떻게 해결할까?

권장되는 해결 방법은 대표적으로 두 가지가 있습니다.

 

web worker 이용하기

JavaScript API를 사용하면 사이트 디자이너가 웹 페이지의 기본 스레드와는 별도로 백그라운드 스레드에서 JavaScript 코드를 실행할 수 있습니다. 이를 통해 개발자는 웹 페이지 성능에 영향을 주지 않으면서 백그라운드 처리나 다른 프로세스를 수행할 수 있습니다. 스레드 간 통신은 메시지 시스템을 통해 이루어지며, 양측 모두 postMessage() 메서드를 사용해 메시지를 전송하고 onmessage 이벤트 핸들러를 통해 수신합니다.

import { useEffect, useRef, useState } from "react";
import { generateWebWorker, timerWorker } from "./util";

interface TimerArgs {
  initialSeconds: number;
  onTimerEnd?: () => void;
}

export default function useTimer({ initialSeconds, onTimerEnd }: TimerArgs) {
  const workerRef = useRef<Worker | null>(null);
  const [seconds, setSeconds] = useState(initialSeconds);
  const [isTimerRunning, setIsTimerRunning] = useState(false);

  useEffect(() => {
    if (window.Worker) {
      workerRef.current = generateWebWorker(timerWorker);
      workerRef.current.onmessage = (event) => {
        const newSeconds = event.data;
        if (newSeconds === 0) {
          stopTimer();
          if (onTimerEnd) {
            onTimerEnd();
          }
        }
        setSeconds(newSeconds);
      };
      setTimer(seconds);
    }

    return () => {
      workerRef.current?.terminate();
    };
  }, []);

  const startTimer = () => {
    setIsTimerRunning(true);
    workerRef.current?.postMessage({ action: "start" });
  };

  const stopTimer = () => {
    setIsTimerRunning(false);
    workerRef.current?.postMessage({ action: "stop" });
  };

  const setTimer = (newSeconds: number) => {
    workerRef.current?.postMessage({ action: "setTime", seconds: newSeconds });
  };

  const resetTimer = (newSeconds = initialSeconds) => {
    stopTimer();
    setTimer(newSeconds);
  };

  return {
    seconds,
    isTimerRunning,
    startTimer,
    resetTimer,
    stopTimer,
  };
}

 

type IntervalId = ReturnType<typeof setInterval>;

interface TimerWorkerThis {
  intervalId: IntervalId | null;
  seconds: number;
  onmessage: (event: MessageEvent) => void;
  setInterval: typeof setInterval;
  clearInterval: (intervalId: IntervalId) => void;
}

export function timerWorker(this: TimerWorkerThis) {
  this.intervalId = null;
  this.seconds = 0;
  this.onmessage = (messageEvent: MessageEvent) => {
    const { action, seconds: newSeconds } = messageEvent.data;

    switch (action) {
      case "start":
        if (this.intervalId) {
          this.clearInterval(this.intervalId);
        }
        this.intervalId = this.setInterval(() => {
          postMessage(this.seconds);
          this.seconds -= 1;
        }, 1000);
        break;
      case "stop":
        if (this.intervalId) {
          this.clearInterval(this.intervalId);
          this.intervalId = null;
        }
        break;
      case "setTime":
        this.seconds = newSeconds;
        break;
      default:
    }
  };
}

export function generateWebWorker(worker: () => void) {
  const code = worker.toString();
  const blob = new Blob([`(${code})()`]);
  return new Worker(URL.createObjectURL(blob));
}

아래의 재현 사진을 보면, 일반 타이머와 Web Worker를 이용한 타이머를 동시에 실행했을 때 어떤 타이머가 정상적으로 동작하는지를 확인할 수 있습니다.

비활성화된 탭의 타이머 비교 (2분까지 비활성화 후 확인)

 

Page Visibility api 이용하기

JavaScript API를 사용하면 웹 페이지의 가시성을 결정할 수 있습니다. 개발자는 페이지의 가시성이 변경될 때마다 트리거되는 visibilitychange 이벤트를 통해 탭이 숨겨지거나 표시되는 시점을 추적할 수 있습니다.하지만 탭이 비활성화될 경우, 백그라운드에서 타이머 기능을 정상적으로 실행하려면 Web Worker를 사용하는 것이 필요합니다. 결국, Web Worker 작업에 코드가 가중된 것으로 보입니다. 또한, 가시성이 변경되자마자 타이머에 변화가 생기는 것이 아니기 때문에, 이 방법은 구현 방식으로 적절하지 않다고 생각되었습니다.

document.addEventListener("visibilitychange", () => {
  if (document.hidden) {
    // web worker에게 백그라운드 timer 시작
  } else {
    // web worker로 진행된 timer와 기존 timer의 동기화
  }
});

 

 

다른 브라우저는 어때?

여러 브라우저에서도 비활성화된 탭의 CPU 점유율과 배터리 관리 문제를 인식하고 이를 해결하기 위해 다양한 방법을 도입했습니다. 예를 들어, Firefox에서는 Tab Hibernation, Edge에서는 Sleeping Tabs라는 기능을 통해 각기 다른 명칭과 방법으로 문제를 해결하고 있습니다.

https://aboutfrontend.blog/tab-throttling-in-browsers/

 

 

참고

 

 

728x90

'Developer > Trouble Shooting' 카테고리의 다른 글

Chrome Extension Trouble Shooting  (0) 2023.01.08

댓글