최근, 토스 SLASH 24에서 발표된 N개의 탭, 단 하나의 웹소켓: SharedWorker을 보고 실시간으로 받는 데이터를 모든 탭에서 공유되어야 하는 점이 우리 서비스의 니즈와 맞아 Shared Worker를 도입했습니다.
자바스크립트는 단일 쓰레드
로 하나의 쓰레드에서 작업이 이루어집니다. 단일 쓰레드의 자바스크립트는 동시에 여러 일을 처리할 수 없는 단점이 있습니다.
Web Worker는 워커 쓰레드
를 추가하여 복잡하고 오래걸리는 연산을 처리하고 메인 쓰레드
에서는 다른 일을 처리할 수 있게 도와줍니다.
Web Worker에는 Worker(a.k.a Dedicated Worekr)와 Shared Worker가 있으며 둘은 비슷하면서도 다른 worker입니다.
Shared Worker를 도입하기 위해서는 크게 두 개의 파일이 필요합니다.
하나는 Shared Worker의 실행이 정의된 worker.ts
(파일명은 자유롭게 작명해도 됩니다.)
또 다른 하나는 worker를 사용하기 위한 파일 useWorker.tsx
(마찬가지로 파일명은 자유롭게 작명해도 됩니다.)
구체적인 파일 구조는 다음과 같습니다.
.
├── public
├── src
│ ├── sharedWorker.ts
│ └── contexts
│ └── SharedWorker.tsx
Worker가 처리해야할 내용을 해당 파일에 정의합니다.
이 파일에서는 복잡한 데이터 구조를 변환하거나 복잡한 연산을 하는 등 메인 쓰레드에서 담당하기 어려운 일을 작성합니다.
아래의 예시를 통해 좀 더 쉽게 이해할 수 있습니다.
// SharedWorkerGlobalScope를 할당해주기 위한 타입 전환
const _self: SharedWorkerGlobalScope = self as any
// 브라우저의 탭들을 보관
const ports = []
// Shard Worker의 연결시 이벤트 등록
_self.addEventListener('connect', (event: MessageEvent) => {
const port = event.ports[0]
ports.push(port)
// message 수신시 동작 정의
port.addEventListener('message', (e) => {
const data = e.data
// 데이터 전송
ports.forEach((port) => {
port.postMessage("Message from Shared Worker")
})
})
// message 수신시 에러가 발생할 경우 에러 로깅
port.addEventListener('messageerror', (e) => {
console.error(e.data)
})
})
Shared Worker를 context로 생성하여 특정 Component에서 Shared Worker로부터 받은 데이터를 사용할 수 있게끔 다음과 같이 작성했습니다.
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from "react";
interface SharedWorkerProps {
result: string | null; // worker에서 받는 데이터의 타입 정의
postMessage: (message: string) => void;
}
const worker = new SharedWorker(new URL("@/sharedWorker", import.meta.url), {
type: "module",
});
const SharedWorkerContext = createContext<SharedWorkerProps | undefined>(undefined);
export const SharedWorkerProvider = ({ children }: { children: ReactNode }) => {
const [result, setResult] = useState<string | null>(null);
useEffect(() => {
// 현재 탭을 worker에 등록
worker.port.start();
// 메세지 수신시 동작 정의
worker.port.onmessage = (e: MessageEvent<string>) => {
setResult(e.data); // "Message from Shared Worker"
};
// 메세지 에러시 동작 정의
worker.port.onmessageerror = (e) => {
console.error(e);
};
return () => {
// 현재 탭을 닫습니다.
worker.port.close();
};
}, []);
// wokrer에 전송할 메시지 정의
const postMessage = useCallback((message: string) => {
worker.port.postMessage(message);
}, []);
return <SharedWorkerContext.Provider value={{ result, postMessage }}>{children}</SharedWorkerContext.Provider>;
};
export const useSharedWorkerContext = () => {
const context = useContext(SharedWorkerContext);
if (!context) {
throw new Error("useSharedWorkerContext should be used within SharedWorkerContextProvider");
}
return context;
};
위의 로직들만 보면 모든 동작들이 아무 문제없이 원활히 동작할 것 같지만, 브라우저는 사용자가 탭을 닫아도 할당된 port의 메모리를 해제하지 않습니다. 이에 메모리 누수가 발생할 수 있습니다.
이를 해결하기 위해 WeakRef
를 사용하여 메시지 포트가 가비지 콜렉트 되었는지 확인할 수 있어 효율적으로 자원을 이용할 수 있게 됩니다.
WeakRef를 활용하여 수정된 코드는 다음과 같습니다.
// Shared Worker.ts
export class BrowserPort {
private readonly weakRef: WeakRef<MessagePort>;
constructor(port: MessagePort) {
this.weakRef = new WeakRef(port);
port.start();
}
isAlive(): boolean {
return !!this.weakRef.deref();
}
postMessage(message: unknown): void {
this.weakRef.deref()?.postMessage(message);
}
addEventListener(event: string, handler: (event: Event) => void): void {
this.weakRef.deref()?.addEventListener(event, handler);
}
removeEventListener(event: string, handler: (event: Event) => void): void {
this.weakRef.deref()?.removeEventListener(event, handler);
}
close(): void {
this.weakRef.deref()?.close();
}
}
const ports = []
// Shard Worker의 연결시 이벤트 등록
_self.addEventListener('connect', (event: MessageEvent) => {
const port = event.ports[0]
const BP = new Browser Port(port)
ports.push(BP)
// message 수신시 동작 정의
BP.addEventListener('message', (e) => {
const data = e.data
// 데이터 전송
ports.forEach((port) => {
port.postMessage("Message from Shared Worker")
})
})
// message 수신시 에러가 발생할 경우 에러 로깅
BP.addEventListener('messageerror', (e) => {
console.error(e.data)
})
})
worker를 디버깅할 땐 chrome://inspect/#workers
에 접속하여 디버깅을 할 수 있습니다
참고
Web Workers in 8 Minutes
[번역] 브라우저 탭과 창 사이 WebSocket 커넥션 공유하기