React에서 Shared Worker 사용하기

이종경·2024년 10월 26일
1

최근, 토스 SLASH 24에서 발표된 N개의 탭, 단 하나의 웹소켓: SharedWorker을 보고 실시간으로 받는 데이터를 모든 탭에서 공유되어야 하는 점이 우리 서비스의 니즈와 맞아 Shared Worker를 도입했습니다.

Web Worker

자바스크립트는 단일 쓰레드로 하나의 쓰레드에서 작업이 이루어집니다. 단일 쓰레드의 자바스크립트는 동시에 여러 일을 처리할 수 없는 단점이 있습니다.

Web Worker는 워커 쓰레드를 추가하여 복잡하고 오래걸리는 연산을 처리하고 메인 쓰레드에서는 다른 일을 처리할 수 있게 도와줍니다.

Web Worker에는 Worker(a.k.a Dedicated Worekr)와 Shared Worker가 있으며 둘은 비슷하면서도 다른 worker입니다.

  • Dedicated Worker : 별도의 탭마다 워커 쓰레드를 생성합니다.
  • Shared Worker : 모든 탭이 이용할 수 있는 워커 쓰레드를 생성합니다.
    worker 구분

Shared Worker 도입

Shared Worker를 도입하기 위해서는 크게 두 개의 파일이 필요합니다.
하나는 Shared Worker의 실행이 정의된 worker.ts(파일명은 자유롭게 작명해도 됩니다.)
또 다른 하나는 worker를 사용하기 위한 파일 useWorker.tsx(마찬가지로 파일명은 자유롭게 작명해도 됩니다.)

구체적인 파일 구조는 다음과 같습니다.

.
├── public
├── src
│    ├── sharedWorker.ts
│    └── contexts
│          └── SharedWorker.tsx

Worker의 실행 정의

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)
  })
})

Worker를 사용하기 위한 context

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;
};

WeakRef 도입을 통한 메모리 누수 방지

위의 로직들만 보면 모든 동작들이 아무 문제없이 원활히 동작할 것 같지만, 브라우저는 사용자가 탭을 닫아도 할당된 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 디버깅

worker를 디버깅할 땐 chrome://inspect/#workers 에 접속하여 디버깅을 할 수 있습니다

참고
Web Workers in 8 Minutes
[번역] 브라우저 탭과 창 사이 WebSocket 커넥션 공유하기

profile
작은 성취들이 모여 큰 결과를 만든다고 믿으며, 꾸준함을 바탕으로 개발 역량을 키워가고 있습니다

0개의 댓글