다중 탭에서 하나의 소켓을 공유할 수 있을까

Why?

현재 채팅 기능의 로직은 이러하다

  1. 채팅 컴포넌트 마운트
  2. 서버 소켓과 connection을 위한 새로운 소켓 생성
  3. userId, sessionId를 알고 있는 상태에서 join_room 시도
  4. 서버에서 userId를 토대로 검증 후 채팅 가능 여부 전송

처음 채팅 기능을 설계할 때도 고민했던 지점은 매번 마운트&언마운트 시에 소켓을 생성해 connect하고 disconnect를 반복하는 부분이었다.

userId는 고유하지만 사용자가 여러탭으로 입장하게 된다면 하나의 userId에 대한 여러 socket이 연결된다는 문제가 있었고, 이로인해 불필요한 소켓 연결이 늘어나 서버의 리소스 낭비를 불러일으킬 수 있다는 결론이 나왔다.

https://www.youtube.com/watch?v=SVt1-Opp3Wo

그러던 중 토스에서도 같은 고민을 했었으며 해당 영상의 인사이트를 통해 하나의 userId에 대한 하나의 소켓으로 개선해보기로 결정하였다.

How?

브라우저 탭들이 하나의 상태를 공유할 수 있도록하는 외부의 무언가가 필요했다

그리고 Web Worker API는 그 역할을 해 줄 수 있었다!

Shared Worker Thread

0image.png

SharedWorkerThread에 소켓 넣고 공유해보기

https://velog.io/@typo/sharing-websocket-connections-betwwen-tabs-and-windows

기존 레퍼런스들은 리액트 + 웹팩과 WS 모듈을 사용한다는 점에서 우리 프로젝트와 다른 부분들이 있었다.

클라이언트와 sharedWorker를 연결하는 과정은 쉽게 될 것이라 생각했지만.. 생각보다 많은 문제가 발생했다.

worker.ts 스크립트

워커의 내부 동작을 작성한 스크립트이다. ts의 경우 를 작성해주어야 워커관련 타입과 메서드를 인식해준다.

typescript
/// <reference lib="webworker" />

import { io } from "socket.io-client";

// 소켓 연결
const socket = io("http://localhost:8080"); // 서버 URL
const ports: MessagePort[] = [];

self.onconnect = (e: MessageEvent) => {
  const port = e.ports[0];
  ports.push(port);

  // 클라이언트에서 오는 메시지를 소켓 서버로 전달
  port.onmessage = (messageEvent) => {
    const { message } = messageEvent.data;
    console.log("Received message in SharedWorker:", message);

    // 서버로 메시지 전송
    socket.emit("send_normal_chat", { msg: message });
  };

  // 소켓에서 오는 메시지를 모든 탭에 전달
  socket.on("message", (msg: string) => {
    ports.forEach((p) => p.postMessage({ message: msg }));
  });
};

export const test = 0;


Shared Worker 생성

https://ko.vite.dev/guide/features#web-workers

1차 시도: 여러 레퍼런스에서 진행하는 생성자를 통한 워커 생성을 진행하였다.

typescript
import { useEffect, useState } from "react";

const App = () => {
  const [worker, setWorker] = useState<SharedWorker | null>(null);
  const [message, setMessage] = useState<string>("");

  // App.tsx에서 worker.ts를 동적으로 import
  useEffect(() => {
    const worker = new SharedWorker(new URL("./worker.ts", import.meta.url));

    // worker가 준비되면 메시지 전송
    worker.port.onmessage = (event) => {
      console.log("Received message from worker:", event.data.message);
      setMessage(event.data.message);
    };

    worker.port.start();

    // 초기 메시지 전송
    worker.port.postMessage({ message: "Hello from React!" });

    setWorker(worker);

    return () => {
      worker.port.close();
    };
  }, []);


하지만 계속해서 socket 생성도 되지 않고 port와 연결이 되지 않는 모습이 보였고, 브라우저의 소스코드 파일을 확인해본 결과 worker 스크립트가 올라가지 않는 문제를 발견

이는 vite의 트리쉐이킹 문제로 import로 가져오지 않은 스크립트 파일이었으므로 빌드 과정에서 올라가지 못했고 스크립트가 없는 SharedWorker가 생성되게 된것이다.

⇒ 멘토님: 잘못됐다. 브라우저에 어떻게 올라가느냐를 알아보자


❗️해당 문제의 원인과 해결

처음에는 단순히 트리쉐이킹으로 import 문이 없어서 소스에 파일이 들어오지 못했나? 라는 뇌피셜을 마구마구 적어두었다… 하지만 이는 전혀 무관하다!

트리 셰이킹은 번들링 단계에서 불필요한 코드(사용되지 않는 모듈)를 제거하는 최적화 과정으로, 워커 파일의 실행 방식이나 브라우저에서의 동작과는 직접적인 관계가 없다.

typescript
const worker = new SharedWorker(new URL('/src/utils/chatWorker.ts', import.meta.url), { type: 'module' });

1. SharedWorker의 작동 방식

  • SharedWorker는 JavaScript 파일을 워커 스레드로 실행합니다. 브라우저는 워커를 실행할 때 해당 파일을 가져와 실행하지만, 워커가 모듈로 작성되었는지 여부를 알아야 적절히 처리할 수 있습니다.
  • type: 'module'을 지정하면 브라우저는 해당 워커 파일을 ES 모듈로 처리합니다.
  • ES 모듈로 처리하면 다음이 가능합니다:
    1. 파일 내부에서 import/export를 사용할 수 있음.
    2. 파일 스코프가 독립적임.

2. type: 'module'을 붙여야 하나?

Vite는 기본적으로 ES 모듈을 기반으로 동작하며, 브라우저에서 모듈 방식으로 스크립트를 처리하도록 번들링합니다.

브라우저가 파일을 로드하는 과정:

  • 브라우저는 SharedWorker의 첫 번째 인자로 받은 파일 경로를 네트워크 요청으로 받아옵니다.
  • 요청한 파일이 일반 스크립트(type: "classic")로 처리되면, 파일 내용이 전역 스코프에서 실행됩니다.
  • 요청한 파일이 모듈 스크립트(type: "module")로 처리되면, 브라우저는 이를 ESM으로 실행하며, 모듈 사양에 맞는 환경에서 처리합니다.

따라서, SharedWorker 파일이 모듈로 작성되었다면 type: 'module'을 지정해야 브라우저가 이를 올바르게 처리할 수 있습니다.


3. Vite의 번들링 과정과 type: 'module의 관계

Vite의 동작 방식:

  1. 파일 변환:
    • Vite는 TypeScript 파일(.ts)을 JavaScript 파일(.js)로 변환합니다.
    • ES6 이상의 모듈 사양에 따라 번들링합니다.
    • import.meta.url은 Vite가 처리하여 브라우저에서 파일 경로를 알 수 있도록 합니다.
  2. 파일 제공:
    • 개발 서버 모드에서 Vite는 파일을 번들링하지 않고 온디맨드로 제공합니다.
    • 브라우저가 new URL('/src/utils/chatWorker.ts', import.meta.url)을 요청하면 Vite는 해당 파일을 동적으로 처리하고 반환합니다.
    • 프로덕션 빌드에서는 Vite가 워커 파일을 별도 번들로 분리하여 제공하며, 브라우저가 이 파일을 적절히 로드하도록 경로를 설정합니다.
  3. 모듈 지원:
    • Vite는 워커 파일을 기본적으로 ES 모듈로 처리합니다.
    • 워커를 type: 'module'로 지정하지 않으면 브라우저는 이를 일반 스크립트로 처리하려고 하며, 이 경우 오류가 발생할 수 있습니다.

4. type: 'module'이 없을 때 발생하는 문제

문제:

  • 브라우저가 워커 파일을 일반 스크립트로 실행하려고 시도.
  • Vite가 번들링한 결과 파일은 ESM 사양에 따라 작성되므로 일반 스크립트로 실행할 수 없음.
  • 예를 들어, Vite는 워커 파일에서 다음과 같은 코드를 생성할 수 있습니다:
javascript
	javascript
	코드 복사
	import { someFunction } from './someModule.js';
	someFunction();
	
	
	
  • 브라우저는 일반 스크립트에서 import를 지원하지 않으므로 에러 발생:
javascript
	javascript
	코드 복사
	SyntaxError: Unexpected token 'import'
	
	
	

해결:

  • type: 'module'을 명시하면 브라우저는 해당 파일을 모듈로 처리하고, Vite가 제공하는 파일을 정상적으로 실행.

5. Vite가 제공하는 URL 동작 방식

new URL('/src/utils/chatWorker.ts', import.meta.url)은 다음 단계를 거칩니다:

  1. Vite는 import.meta.url을 통해 현재 모듈의 URL을 계산.
  2. /src/utils/chatWorker.ts 경로를 현재 모듈의 URL을 기준으로 해석.
  3. 워커 파일을 http://localhost:3000/src/utils/chatWorker.ts처럼 브라우저에서 접근 가능한 URL로 변환.
  4. 브라우저가 해당 URL로 요청을 보냄.
  5. Vite 개발 서버가 해당 파일을 동적으로 처리하여 반환.

6. 결론

  • type: 'module'은 브라우저에게 워커 파일이 ES 모듈임을 알려주는 역할.
  • Vite는 파일을 번들링하거나 제공할 때 ES 모듈 형식으로 처리하므로, 이를 명시적으로 지정해야 브라우저가 올바르게 로드.
  • Vite와 브라우저의 모듈 동작을 결합한 결과, type: 'module'을 지정하지 않으면 import/export 관련 에러가 발생.

2차 시도

마지막으로 vite 공식문서에 적혀있던 두번째 import 방식을 사용해보았다.

쿼리 접미사 ?worker 또는 ?sharedworker를 이용해 스크립트 파일을 가져올 수 있다

해당 방식을 통해 스크립트 파일은 트리쉐이킹이 되지 않고 성공적으로 워커를 생성할 수 있었다 ㅜㅜ

typescript
import { useEffect, useState } from "react";
import MyWorker from "./worker?sharedworker";

const App = () => {
  const [worker, setWorker] = useState<SharedWorker | null>(null);
  const [message, setMessage] = useState<string>("");

  useEffect(() => {
    const worker = new MyWorker();

    console.log(worker);
    // worker가 준비되면 메시지 전송
    worker.port.onmessage = (event) => {
      console.log("Received message from worker:", event.data.message);
      setMessage(event.data.message);
    };

    worker.port.start();

    // 초기 메시지 전송
    worker.port.postMessage({ message: "Hello from React!" });

    setWorker(worker);

    return () => {
      worker.port.close();
    };
  }, []);

  const handleClick = () => {
    if (worker) {
      // 버튼 클릭 시 메시지 전송
      worker.port.postMessage({ message: "Hello from React on button click!" });
    }
  };

  return (
    <div>
      <h1>React with Shared Worker and Socket.io</h1>
      <p>Received from worker: {message}</p>
      <button onClick={handleClick}>Send Message to Worker</button>
    </div>
  );
};

export default App;


1image.png

위는 클라이언트 탭을 2개 띄웠을때의 서버 로그이다.

처음 탭이 열렸을 때 shared worker는 공유될 소켓 하나를 생성한다 (id: un19~)

그리고 탭일 열렸을 때 클라이언트에서 “Hello from React”를 shared worker에게 postMessage를 하고 worker는 받은 메세지를 자신의 socket을 통해 서버로 전송한다.

다른 탭을 열렸을 때는 이미 소켓이 있기 때문에 새로 생성이 이뤄지지 않고 이미 존재하는 소켓으로 또다시 서버에 메세지를 보낸 걸 확인할 수 있다.

해당 과정을 좀더 확실하게 보고자 port가 연결될 때마다 카운팅을 하고 로그를 찍어보았다.

2image.png

새로운 탭을 열거나 새로고침을 할 때마다 새로운 소켓이 생성되는 것이 아닌 하나의 소켓을 여러 포트(탭)에서 공유하고 있는 것을 확인할 수 있다.

만약 포트(탭)을 사용하지 않게된다면 포트에 대한 공간을 제거하는 방식은 가비지 콜렉터를 이용한다고 한다. 후에 알아보자..