2 분 소요

Web Worker스크립트 연산을 웹 어플리케이션의 메인 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술입니다.
따라서 Web Worker를 사용하면 브라우저 탭이 비활성화 되어도 영향을 받지 않고 멀티 스레딩처럼 동작합니다.
또한 무거운 작업을 분리된 스레드에서 처리하면 메인 스레드(보통 UI 스레드)가 멈추거나 느려지지 않고 동작할 수 있습니다.

반영 PR: https://github.com/woowacourse-teams/2024-ddangkong/pull/406

📘 브라우저에서 탭 전환 시 setInterval 실행 간격에 오차가 발생하는 이유

브라우저가 비활성 탭에서의 리소스 사용을 줄이기 위해 타이머의 실행 빈도를 조절한다.

✅ 비활성 탭의 타임아웃

백그라운드 탭으로 인한 부하와 그로 인한 배터리 소모를 줄이기 위해, 브라우저는 비활성 탭에서의 지연 시간에 최소 값을 강제한다.

Chrome의 경우 비활성 탭에 대해 최소 1초의 지연 시간을 강제하여 타이머 오차가 발생하게 된다.

✅ Chrome 타이머 정책

JS 타이머에 대한 Chrome 정책에서 최소 제한, 제한, 집중적인 제한을 소개하고 있다.

우리 서비스의 경우 타이머 체인이 5번 이상이며, 5분 이상의 타이머가 없으므로 제한 에 속한다.

따라서 브라우저는 타이머를 초당 한 번씩 확인하고, 제한 시간이 유사한 타이머는 함께 일괄 처리된다.

🚨 Worker를 사용할 때 주의할 점

Web Worker는 정말 필요할 때만 사용해야 한다.

메모리는 유한하기 때문에, 간단한 로직이라면 싱글 스레드로도 충분한데 굳이 브라우저의 백그라운드 스레드를 사용하는 건 위험하다.

멀티 스레드를 사용하게 되면 컨텍스트 스위칭이 자주 발생하면서 오버헤드 비용이 발생할 수 있고 성능 문제가 발생할 수 있다.

하지만 내 목표는 탭이 전환되어 비활성 탭이 되더라도 타이머를 일정하게 동작시켜 사용자 경험을 개선하는 것이므로 적절하다고 판단하였다.

😎 적용 결과

❌ worker 적용 전 탭 전환 시 타이머 ✅ worker 적용 후 탭 전환 시 타이머
image image

💻 Worker 타이머 예시 코드

Worker는 main thread 의 window와는 별도의 WorkerGlobalScope 를 갖기 때문에 worker 내에서 window 메서드나 DOM 조작이 불가능하다.

main thread와 worker thread가 서로 데이터를 주고 받기 위해선 Message System을 사용해야 한다. (postMessage, onmessage)

1. main thread 에서 worker.onmessage 에 worker 의 메시지를 전달받기 위한 이벤트 핸들러를 등록한다.
2. worker thread 에서 작업 처리후 postMessage로 데이터를 main thread의 worker.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;

📘 reference

댓글남기기