웹 워커로 멈추지 않는 시계 만들기
웹 워커를 사용해 타이머 기능을 구현하는 과정
예제
Problem - 투자 가능 시간을 보여주는 타이머가 필요하다.
신입 시절 이커머스 앱을 만들 때 이 주제에 대해 스터디했던 적이 있다.
여기 프로젝트에서도 마찬가지로 타이머 기능을 사용해야 했기 때문에 다시 한 번 정리를 해본다.
GOAL: 메인 쓰레드에 방해받지 않는 시계 만들기.
setInterval이나 RAF을 사용해서 타이머 기능을 만들 수도 있지만 몇 가지 제한사항이 있다.
- 탭이 unfocused된 상황이라면, 쓰레드 자체가 멈추기 때문에 타이머의 실행이 멈춘다.
- 여러 로직들이 실행되어 메인 쓰레드를 점유하는 상황이라면 역시 타이머는 멈추고 해당 로직이 끝난 뒤 업데이트 된다.
따라서 메인 쓰레드를 점유하는 각종 비동기 로직과 타이머 계산 및 시간 렌더링 로직이 분리되어야 할 필요가 있다.
Implementation - 웹 워커와 오프스크린 캔버스를 사용해서 타이머 구현하기
여기서는 web worker와 offscreencanvas를 사용했다.
웹 워커(Web worker)는 스크립트 연산을 웹 어플리케이션의 주 실행 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술입니다. 웹 워커를 통해 무거운 작업을 분리된 스레드에서 처리하면 주 스레드(보통 UI 스레드)가 멈추거나 느려지지 않고 동작할 수 있습니다.
OffscreenCanvas인터페이스는 화면 밖에서 렌더링할 수 있는 캔버스를 제공하고 DOM과 Canvas API를 분리하여<canvas>요소가 DOM에 완전히 의존하지 않도록 합니다. 렌더링 작업은 worker 맥락 내에서 실행할 수도 있어서 별도의 스레드에서 일부 작업을 실행하고 메인 스레드에서 무거운 작업을 피할 수 있습니다.
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는 렌더링 로직을 분리하여 메인 쓰레드가 멈추지 않는 시계를 구현할 수 있었다.