JIHYEONJEONG
React

웹 워커로 멈추지 않는 시계 만들기

웹 워커를 사용해 타이머 기능을 구현하는 과정

예제

Problem - 투자 가능 시간을 보여주는 타이머가 필요하다.

신입 시절 이커머스 앱을 만들 때 이 주제에 대해 스터디했던 적이 있다.

여기 프로젝트에서도 마찬가지로 타이머 기능을 사용해야 했기 때문에 다시 한 번 정리를 해본다.

GOAL: 메인 쓰레드에 방해받지 않는 시계 만들기.

setInterval이나 RAF을 사용해서 타이머 기능을 만들 수도 있지만 몇 가지 제한사항이 있다.

  1. 탭이 unfocused된 상황이라면, 쓰레드 자체가 멈추기 때문에 타이머의 실행이 멈춘다.
  2. 여러 로직들이 실행되어 메인 쓰레드를 점유하는 상황이라면 역시 타이머는 멈추고 해당 로직이 끝난 뒤 업데이트 된다.

따라서 메인 쓰레드를 점유하는 각종 비동기 로직과 타이머 계산 및 시간 렌더링 로직이 분리되어야 할 필요가 있다.

Implementation - 웹 워커와 오프스크린 캔버스를 사용해서 타이머 구현하기

여기서는 web worker와 offscreencanvas를 사용했다.

웹 워커(Web worker)는 스크립트 연산을 웹 어플리케이션의 주 실행 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술입니다. 웹 워커를 통해 무거운 작업을 분리된 스레드에서 처리하면 주 스레드(보통 UI 스레드)가 멈추거나 느려지지 않고 동작할 수 있습니다.

OffscreenCanvas 인터페이스는 화면 밖에서 렌더링할 수 있는 캔버스를 제공하고 DOM과 Canvas API를 분리하여 <canvas> 요소가 DOM에 완전히 의존하지 않도록 합니다. 렌더링 작업은 worker 맥락 내에서 실행할 수도 있어서 별도의 스레드에서 일부 작업을 실행하고 메인 스레드에서 무거운 작업을 피할 수 있습니다.

https://developer.mozilla.org/ko/docs/Web/API_

https://developer.mozilla.org/ko/docs/Web/API/OffscreenCanvas

실제 시간 - react state 따라서 타이머를 업데이트하는 로직 - web worker 타이머를 렌더링 - OffscreenCanvas 워커를 핸들링하는 훅 - useWorker 의 구조로 적용했다.

// /worker/clock.ts

let offscreenCanvas;
let offscreenContext;
const MILLISECONDS_IN_SECOND = 1000;

const styleClock = () => {
  offscreenContext.scale(global.devicePixelRatio, global.devicePixelRatio);
  ... // 텍스트 위치 및 스타일
  
};

const drawClockText = (dateTime) => {
  if (!offscreenContext) {
    offscreenContext = offscreenCanvas.getContext('2d');
    styleClock();
  }
  offscreenContext.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height);
  offscreenContext.fillText(dateTime.time, textPosition.x, textPosition.y);
};

// 실제 시간 데이터 메인 쓰레드로 전송
const sendTick = () => {
  const now = new Date(); // TODO: NTP(Network Time Protocol) 지원 확장 포인트
  const dateTime = formatLocaleDateTime(now);

  globalThis.postMessage(dateTime);
  if (offscreenCanvas) drawClockText(dateTime);
};


globalThis.addEventListener('message', ({ data }) => {
  ...

  if (data.canvas instanceof OffscreenCanvas) {
    offscreenCanvas = data.canvas;
   ...
  }

  sendTick();
  globalThis.setTimeout(() => {
    sendTick();
    globalThis.setInterval(sendTick, MILLISECONDS_IN_SECOND);
  }, MILLISECONDS_IN_SECOND - new Date().getMilliseconds()); 

}, { passive: true });
///hooks/useWorker.ts

const useWorker = <T>(
  workerInit?: (info?: string) => Worker,
  onMessage?: (message: MessageEvent<T>) => void,
  workerInfo?: string
): React.MutableRefObject<Worker | undefined> => {

  const worker = useRef<Worker>();

  useEffect(() => {
    if (workerInit && !worker.current) {
      worker.current = workerInit(workerInfo);

      if (onMessage) {
        worker.current.addEventListener('message', onMessage, {
          passive: true,
        });
      }

      worker.current.postMessage('init');

    }

    return () => {
      worker.current?.terminate();
      worker.current = undefined;
    };
  }, [onMessage, workerInfo, workerInit]);

  

  return worker;

};

  

export default useWorker;
// _components/Clock.tsx

const Clock: FC = () => {
  const [now, setNow] = useState<LocaleTimeDate>(Object.create(null));
  const offScreenClockCanvas = useRef<OffscreenCanvas>();

  const clockWorkerInit = useCallback(() => 
    new Worker(new URL('./clock.worker', import.meta.url), { name: `Clock` }), 
  []);

  const updateTime = useCallback(({ data, target: clockWorker }: MessageEvent) => {
    if (data === 'source') {
      clockWorker.postMessage(DEFAULT_CLOCK_SOURCE);
    } else {
      setNow((currentNow) => 
        !offScreenClockCanvas.current || currentNow.date !== data.date ? data : currentNow
      );
    }
  }, []);

  const currentWorker = useWorker(clockWorkerInit, updateTime);

  const clockCallbackRef = useCallback((clockContainer: HTMLDivElement | null) => {
    if (!offScreenClockCanvas.current && currentWorker.current && clockContainer) {

      offScreenClockCanvas.current = createOffscreenCanvas(
        clockContainer,
        window.devicePixelRatio,
        clockSize,
      );
      currentWorker.current.postMessage(
        {
          canvas: offScreenClockCanvas.current,
          devicePixelRatio: window.devicePixelRatio,
        },
        [offScreenClockCanvas.current], 
      );
    }
  }, [currentWorker, now]);

  return (
    <StyledClock 
      ref={supportsOffscreenCanvas ? clockCallbackRef : undefined}
      title={now.date}
    >
      {/* OffscreenCanvas가 mount 되었을 경우 react 레이어에서 리렌더하지 않음 */}
      {supportsOffscreenCanvas ? undefined : now.time}
    </StyledClock>
  );
};

Summary

자바스크립트는 싱글 쓰레드 언어이다. 비동기 로직이 중첩되어서 실행되는 많은 경우 콜 스택과 이벤트 루프의 동기화 문제로 인해 UI가 멈추거나 혹은 Race condition 문제가 발생할 수 있다. 여기서는 Web worker는 이러한 복잡한 비동기 로직의 컨텍스트를 메인 쓰레드와 분리하고, OffscreenCanvas는 렌더링 로직을 분리하여 메인 쓰레드가 멈추지 않는 시계를 구현할 수 있었다.

Refs

웹 워커 MDN 오프스크린 캔버스 MDN 시계 구현 예제

On this page