Web Worker 알아보기
Overview
어제 자기 전 유튜브로 Toss Slash24의 N개의 탭, 단 하나의 웹소켓: SharedWorker 세션을 들었습니다. Web Worker API는 이전에 한번 흝어본 기억이 있어서 알고 있었지만 해당 API를 실제로 사용해본 경험도 없었고 어떤 상황에서 사용하면 좋을지에 대해서 깊게 고민해보지는 못했습니다.
토스 증권에서 WTS를 출시하면서 유저 한명이 여러개의 탭을 띄우고 웹소켓을 통해서 서버와 실시간 데이터 통신을 하는데, 기존의 구조로는 각 탭마다 Web Socket을 가지고 있어서 한 명의 유저가 서버에 여러개의 웹 소켓을 연결해 서버 리소스를 낭비하고 서버에 부하를 줄 수 있는 상황이였습니다.
토스증권의 프론트엔드 박건영 개발자님은 이 상황을 서버측에서 해결할 수 있지만 프론트엔드에서 해결하기 위해 방법을 강구하던 중 Web Worker에서 탭끼리 Worker를 공유할 수 있는 Shared Worker을 사용해 웹소켓을 여러 탭에서 공유하여 유저당 하나의 웹 소켓을 매칭할 수 있도록 문제를 해결했습니다.
탭끼리 공유가 가능한 Shared Worker위에 Web Socket을 얹어서 문제를 해결하다니 자기 전 머리가 띵한 느낌을 받았습니다. 해당 세션은 꼭 한번 보시길 추천합니다!
토스ㅣSLASH 24 - N개의 탭, 단 하나의 웹소켓: SharedWorker
오늘 포스팅에서는 Web Worker에 대해서 간단히 알아보고 React, TypeScript, Vite 애플리케이션에서 Web Worker을 구현하는 예제를 살펴보도록 하겠습니다.
해당 포스팅은 Shared Worker에 대한 포스팅은 아닙니다. Dedicated Web Worker을 살펴보고 예제 코드 구현을 통해서 웹 워커를 구현하는 방법을 살펴볼 예정입니다.
Web Worker
웹 워커란 브라우저에서 제공하는 웹 API입니다. 웹 애플리케이션에서 메인 스레드로 감당하기 무거운 연산의 경우 웹 워커를 활용해 백그라운드 스레드로 연산을 처리해 메인 스레드에 결과를 응답할 수 있습니다.
메인 스레드에서는 연산을 별도의 백그라운드 스레드에 위임하기 때문에 UI Blocking 없이 사용자에게 좋은 경험을 선사할 수 있습니다.
종류
Web Worker은 Dedicated Worker 과 Shared Worker로 나뉘어집니다. Dedicated Worker은 전용 웹 워커로써 백그라운드 스레드에서 연산 처리를 위해서 사용하는 워커 입니다.
반대로 Shared Worker은 공유가 가능한 웹 워커로 특정 연산의 처리의 목적보다는 백그라운드 스레드를 여러 브라우저 탭에서 공유하기 위해서 사용합니다.
Shared Worker을 공유하기 위해서는 두 가지를 충족시켜야 합니다. 같은 Origin, 같은 JavaScript 파일이여야 공유가 가능합니다. 이번 포스팅은 Shared Worker에 대한 포스팅보다는 Web Worker에 대한 내용을 살펴보는 포스팅임으로 다른 포스팅에서 Shared Worker을 자세히 살펴보겠습니다.
Web Worker 주의할 점
웹 워커를 사용하기에 앞서 주의해야 할 점이 몇 가지 있습니다.
1. 브라우저 지원 범위
먼저 브라우저 지원 범위를 살펴봐야합니다. 최신 환경의 브라우저에서는 대부분 웹 워커 기능을 제공하고 있지만 구형 버전의 브라우저나 특정 몇몇의 브라우저는 웹 워커를 기능을 제공하고 있지 않습니다.
특히 Shared Worker은 지원하지 않는 특정 브라우저가 있으므로 개발 중인 서비스의 지원 대상을 면밀하게 검토한 후 적용해야 합니다.
2. 웹 워커에서 사용할 수 없는 API
웹 워커는 별도의 스레드를 가집니다. 메인 스레드의 window는 GlobalScope 입니다. 반면에 워커 스레드는 별도의 WorkerGlobalScope를 가지고 있습니다. 스코프가 다르기 때문에 메인 스레드에서 작동하는 기능을 워커 스레드에서 사용할 수 없습니다. 예를 들면 메인 스레드의 Window 객체에 접근할 수 없고, DOM을 조작 할 수 없습니다.
Fetch, Web Socket, SSE, Canvas API 등의 API는 워커 스레드에서도 사용할 수 있습니다.
웹 워커에서 사용 가능한 API 목록
3. 메세지 기반의 통신
웹 워커는 메세지를 사용해 애플리케이션과 통신합니다. 메세지 기반의 통신은 이벤트 처리에 있어서 번거로울 수 있습니다.
실습
웹 워커를 실습해보도록 하겠습니다. 개발 환경은 Vite, React, TypeScript 입니다.
우리가 개발할 앱은 간단히 웹 워커에 숫자를 전송하고 랜덤 숫자를 곱해 메세지를 전달받아서 화면에 디스플레이 합니다.
필요한 컴포넌트와 웹 워커 스크립트는 다음과 같습니다.
1. 웹 워커 객체를 생성하고 메세지를 보낼 컴포넌트
컴포넌트에서는 웹 워커에 메세지를 전송(postMessage)합니다. 그리고 웹 워커로부터 메세지를 수신합니다. (onmessage)
2. 메세지를 받아서 백그라운드 스레드에서 연산을 처리할 웹 워커 스크립트
웹 워커 역시 메세지를 컴포넌트로부터 수신합니다. (onmessage) 연산을 수행한 후 데이터를 메세지로 컴포넌트에 전송합니다. (postMessage)
위 과정을 도식화 하면 아래의 그림이 나옵니다.
이번 실습에서는 웹 워커를 사용하기 위한 비즈니스 로직을 추상화해 커스텀 훅으로 만들어 컴포넌트에 제공해볼 예정입니다.
실습 디렉토리 구조
실습 디렉토리 구조는 다음과 같습니다.
소스 디레고리 하위에 workers 디렉토리가 위치하고 해당 디렉토리에 워커 스크립트 파일을 위치합니다.
hooks 하위에 useWebWorker 라는 커스텀 훅을 통해 웹 워커를 사용하기 위한 비즈니스 로직을 추상화 합니다.
├── src
│ ├── App.tsx
│ ├── components
│ │ └── WorkerExample.tsx
│ ├── hooks
│ │ └── useWebWorker.ts
│ ├── main.tsx
│ ├── vite-env.d.ts
│ └── workers
│ └── worker-example.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
웹 워커 스크립트
우리의 웹 워커는 메세지(기반 숫자)를 수신해 2초 후에 랜덤 숫자를 곱 연산해 메세지를 전송합니다.
아래 스크립트의 self는 웹 워커의 컨텍스트 입니다. 웹 워커 컨텍스트의 onmessage 프로퍼티에 이벤트 핸들링 함수를 작성해줍니다.
self.onmessage = (event) => {
let result;
const { data } = event;
setTimeout(() => {
if (data) {
result = data * Math.random();
}
result = Math.random();
postMessage(result);
}, 2000);
};
useWebWorker
웹 워커를 사용하기 위한 비즈니스 로직을 추상화한 커스텀 훅 입니다. 해당 비즈니스 로직은 단순한 연산을 위한 코드며 만약 복잡한 연산을 처리할 웹 워커를 만든다면 다양한 Web Worker에 전달하기 위한 인자가 존재하거나 로직이 복잡해질 수 있습니다. 그럼 코드를 살펴보도록 하겠습니다.
먼저 props로 url 객체를 받습니다. 해당 url은 웹 워커 스크립트의 경로입니다.
worker는 useRef를 통해 비제어 변수로 담아줍니다. 컴포넌트가 마운트 된 후 worker 객체를 만들어 worker에 바인딩 합니다.
컴포넌트 역시 워커로부터 메세지를 수신해야 하기 때문에 onmessage 핸들러를 작성합니다. 그리고 메세지를 전송할 postMessage 함수도 작성합니다.
컴포넌트가 언마운트 됐을 경우 메모리 누수를 방지하기 위해 사용중인 웹 워커 자원을 반납 해야합니다. useEffect의 cleanup 함수를 활용해 자원을 terminate 합니다.
마지막으로 loading 상태를 더했습니다. 웹 워커는 별도의 백그라운드 스레드를 활용해 UI를 Non Blocking 합니다. 이때 사용자에게 데이터를 처리하고 있다는 것을 보여주기 위해 loading 상태를 더했습니다.
import { useCallback, useEffect, useRef, useState } from "react";
type Props = {
url: string;
};
type Result<T> = {
message: T | null;
loading: boolean;
sendMessage: (message?: T) => void;
};
function useWebWorker<T>({ url }: Props): Result<T> {
const worker = useRef<Worker | null>(null);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<T | null>(null);
const sendMessage = useCallback((message?: T) => {
if (worker.current) {
setLoading(true);
worker.current.postMessage(message);
}
}, []);
useEffect(() => {
worker.current = new Worker(new URL(url, import.meta.url));
worker.current.onmessage = (event: MessageEvent<T>) => {
setLoading(false);
setMessage(event.data);
};
return () => {
if (worker.current) {
worker.current.terminate();
}
};
}, [url]);
return {
message,
loading,
sendMessage,
};
}
export default useWebWorker;
컴포넌트
import useWebWorker from "../hooks/useWebWorker";
const workerPath = "../workers/worker-example.ts";
const WorkerExample = () => {
const { message, loading, sendMessage } = useWebWorker<number>({
url: workerPath,
});
return (
<div>
<h1>랜덤 숫자 생성</h1>
<button onClick={() => sendMessage(2)}>생성하기</button>
{loading ? <p>로딩중..</p> : <p>생성한 숫자: {message}</p>}
</div>
);
};
export default WorkerExample;
제작한 useWebWorker 훅을 사용하는 컴포넌트 입니다. 파라미터의 프로피티인 url로 사용할 웹 워커 스크립트의 상대 경로를 제공합니다. 버튼을 클릭하면 로딩중이라는 ui가 나오고, 메세지를 수신한 후 수신한 숫자를 확인할 수 있습니다.
실습 코드를 확인하고 싶으시다면 아래의 레포지토리를 참고해주시면 감사하겠습니다!
레포지토리 확인하기
마치며
웹 워커를 잘 활용한다면 사용자에게 더 좋은 경험을 제공할 수 있습니다. 운영중인 서비스에서 무거운 연산으로 인한 UI Blocking이 발생한다면 한번 쯤 고려해보면 좋을 것 같습니다.
또한 토스증권의 케이스처럼 Web Worker와 다양한 API의 조합을 통해 서버 자원의 낭비를 막을 수 있고, 웹 워커를 공유함으로써 서버에 보내는 요청의 횟수도 최적화 할 수 있지 않을까 하는 생각도 들게 됐습니다.
기회가 된다면 추후 포스팅에서는 Shared Worker에 대해서도 공부하고 살펴보도록 하겠습니다.
읽어봐주셔서 감사합니다!