우리 서비스는 유저가 여러 방송에 참여해 채팅을 할 수 있고, 질문, 공지, 유저 차단 등 다양한 기능을 제공했다.
기존 소켓 통신의 로직은 다음과 같다.
useEffect(() => {
const eventMap = {
[CHATTING_SOCKET_RECEIVE_EVENT.NORMAL]:
(newMessage) => {...},
[CHATTING_SOCKET_RECEIVE_EVENT.QUESTION]:
(questionMessage) => {...}, ...
}
const newSocket = createSocket(SOCKET_URL, eventMap, initCallback);
return () => {
if (newSocket) {
newSocket.disconnect();
}
};
}, [id]);
하지만 위와 같은 소켓 통신을 진행할 경우, 다음과 같은 문제가 발생했다.
connect
와 disconnect
가 반복된다.이로 인해 동일한 userId임에도 여러 번 소켓 연결을 시도하게 되어 서버에 과부하를 일으킬 수 있다.
문제점 1을 개선하기 위한 간단한 해결책으로 전역상태를 떠올릴 수 있었다.
🚨 위 그림에서 disconnect는 채팅 컴포넌트 언마운트 시점이 아닌 유저가 브라우저를 닫거나 탭을 종료할 때 (ex.
beforeunload
) 발생하는 것으로 수정해야 한다...
이 방식은 서버와 연결된 소켓을 전역에서 하나만 유지하는 방식으로, 채팅방(방송)을 이동할 때마다 소켓을 새로 연결하고 끊는 과정을 피할 수 있다. 즉, 사용자가 방송을 변경할 때마다 새로운 연결을 생성하는 대신, 기존에 연결된 소켓을 재사용하여 서버와의 연결을 유지하도록 하는 것이다.
전역 상태에서 소켓을 관리하면, 방송이 변경되더라도 연결된 소켓이 그대로 유지되며, 1번의 불필요한 connect와 disconnect가 반복되지 않게 된다.
하지만 전역 상태로 소켓을 관리하더라도 여전히 두 번째 문제점인 여러 탭에서 다중 소켓 연결이 발생하는 문제는 해결되지 않았다.
localStorage 등을 활용해 탭 간에 리소스 공유할 수 있었지만, 문자열 리소스만 가능할 뿐 소켓 객체 자체를 저장할 수 없다는 한계가 있었다.
그렇게 브라우저 탭들이 하나의 상태를 공유할 수 있도록 하는 외부의 무언가를 찾던 와중, 약 한 달 전 진행된 토스의 컨퍼런스를 통해 Shared Worker라는 해결책을 찾을 수 있었다!
토스ㅣSLASH 24 - N개의 탭, 단 하나의 웹소켓: SharedWorker
Shared Worker는 여러 브라우저 탭이나 창에서 동시에 접근할 수 있는 Web Worker의 일종이다.
일반적인 Web Worker는 단일 스레드에서 비동기적으로 작업을 처리하지만, Shared Worker는 여러 탭, 창 또는 iframe에서 공유될 수 있는 하나의 워커를 생성할 수 있어, 여러 클라이언트가 같은 작업을 병렬로 처리하면서 리소스를 절약할 수 있다.
위와 같이 Shared Worker 내에서 웹 소켓 객체를 생성하고 서버와 커넥션을 맺게 되면, 해당 커넥션은 모든 브라우저 탭과 창에서 공유된다.
참고로 Shared Worker Thread의 공유 기준은 다음과 같다
1. 브라우징 컨텍스트들은 모두 정확히 같은 오리진을 공유해야한다.
ex)https://liboo.kr
는https://liboo.kr/host
와 동일한 오리진으로 공유가 가능하지만https://liboo.blog
와는 공유가 불가능 하다.
2. 불러온 JavaScript 파일이 동일해야한다.
Shared Worker에서는 MessageChannel을 활용하여 메인 스레드(탭)와 통신한다. 각 스레드는 MessagePort 객체를 하나씩 가지며, 이 MessagePort를 통해 서로 메시지를 송수신할 수 있는 것이다!
Shared Worker 내부에서는 각 메인스레드에 대한 포트들을 배열로 저장하고 메시지가 발생할 때마다 필요한 포트에 송신을 해주면 된다.
위의 설계를 마친 후 바로 프로젝트에 적용하려고 했으나, 관련된 레퍼런스가 부족했다. 또한 기존의 레퍼런스들은 주로 React와 Webpack 환경에서 WebSocket 모듈을 사용한 예시들로, Vite와 Socket.io를 사용하는 우리 프로젝트 환경과는 차이가 있었기 때문에 우선적으로 데모를 만들어 설계를 검증하고자 했다.
참고로 Vite 공식문서의 Web Worker 관련 파트를 참고해 진행하였다.
우선 Shared Worker를 도입하기 위해선 Shared Worker의 실행 동작이 정의된 스크립트 파일이 필요하다.
스크립트 파일의 위치는 다음과 같다. (변경 가능)
📦src
┣ 📂utils
┃ ┗ 📜worker.ts
┗ 📜App.tsx
TypeScript에서는 Web Worker와 관련된 타입(self, onconnect, MessagePort 등)을 사용하려면 /// <reference lib="webworker" />
라는 Triple-Slash Directive를 추가해야 한다.
/// <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 { msg } = messageEvent.data;
// 서버로 메시지 전송
socket.emit("send_normal_chat", { msg });
};
// 소켓에서 오는 메시지를 모든 탭에 전달
socket.on("message", (msg: string) => {
ports.forEach((p) => p.postMessage({ msg }));
});
};
Vite 공식문서에 따르면 Shared Worker를 생성&접근하는 방식은 2가지이다.
const App = () => {
const [worker, setWorker] = useState<SharedWorker | null>(null);
const [message, setMessage] = useState<string>("");
useEffect(() => {
const worker = new SharedWorker(new URL("/src/utils/worker.ts", import.meta.url), { type: 'module' });
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();
};
}, []);
...
}
스크립트 파일을 가져와 워커를 생성하면서 가장 어려움을 겪었던 부분은 파일 경로 설정이었다 🥲
프로젝트에서 Webpack이나 Vite와 같은 번들러를 사용하면, TypeScript 파일(.ts
)은 JavaScript 파일(.js
)로 변환된다. 번들링 과정에서 파일은 최적화되고, 브라우저에서 실행 가능한 형태로 출력된다.
번들링 후 파일은 번들러의 설정에 따라 경로와 이름이 변경된다. 예를 들어:
src/utils/worker.ts
dist/utils/worker.js
(일반적인 경우) dist/utils/worker.[hash].js
(해시가 추가된 경우) 따라서 아래처럼 TypeScript 파일을 직접 참조하려고 하면 문제가 발생하기 때문에 번들링된 환경에서는 번들러가 생성한 최종 파일 경로를 정확히 지정해야 한다.
new SharedWorker("/src/utils/worker.ts"); // .ts 파일 인식 불가
번들링 환경에서도 안전하게 워커 파일을 참조하려면 import.meta.url
과 URL
생성자를 활용해 동적으로 경로를 계산할 수 있다.
const worker = new SharedWorker(new URL("/src/utils/worker.ts", import.meta.url), { type: "module" });
import.meta.url
: 현재 모듈의 URL을 제공한다. new URL()
: 상대 경로를 기준으로 절대 경로를 생성한다. { type: 'module' } 옵션은 SharedWorker가 ES 모듈로 작성되었음을 브라우저에 명시한다. 기본적으로 Vite는 ES Module을 기본 출력 형식으로 사용하기 때문에, 워커가 번들된 결과물을 올바르게 읽기 위해 이 설정이 필요하다!
import 문에서 ?sharedworker
접미사를 붙여 가져올 수 있다.
모듈의 export default 로는 워커의 생성자가 들어가게 된다.
import SharedWorker from '@utils/worker?sharedworker';
const App = () => {
const [worker, setWorker] = useState<SharedWorker | null>(null);
useEffect(() => {
const worker = new SharedWorker();
...
}, []);
...
}
위는 클라이언트 탭을 2개 띄웠을 때의 서버 로그이다.
처음 탭이 열리면 Shared Worker가 공유될 소켓을 하나 생성한다 (id: un19~).
그 후, 첫 번째 탭에서 클라이언트가 “Hello from React”라는 메시지를 Shared Worker에게 postMessage로 보내면, worker는 해당 메시지를 받은 후 자신이 가진 소켓을 통해 서버로 메시지를 전송한다.
두 번째 탭을 열 때는 이미 소켓이 생성되어 있기 때문에 새로운 소켓이 생성되지 않고, 기존 소켓으로 서버에 메시지를 다시 전송하는 것을 확인할 수 있다.
이 과정을 더 명확히 확인하기 위해, 포트가 연결될 때마다 카운팅을 하고 해당 로그를 찍었다.
새로운 탭을 열거나 새로고침을 할 때마다 새로운 소켓이 생성되는 것이 아닌 하나의 소켓을 여러 포트(탭)에서 공유하고 있는 것을 확인할 수 있다!!
데모로 검증을 끝낸 후 실제 프로젝트에 Shared Worker를 도입할 수 있었다.
다양한 채팅 이벤트를 처리하는 chatWorker 스크립트와 워커를 사용할 수 있는 useChatRoom 훅으로 관리해주었다.
자세한 코드는 레포지토리와 관련 PR에서 확인할 수 있다 😊
과정 | 사진 |
---|---|
스레드 간 연결 | |
클라이언트 ➡️ 서버 | |
서버 ➡️ 클라이언트 |
결과적으로 Shared Worker를 통해 채팅 소켓 통신에서의 서버 부담을 클라이언트 측면에서 성공적으로 개선해 볼 수 있었다.
이미 잘 돌아가던 채팅 기능이었기에 Shared Worker 도입을 고민했지만, 이를 통해 서버 리소스를 효과적으로 절약할 수 있었고, 문제 해결 과정에서 많은 배움을 얻을 수 있었다.
앞으로는 Shared Worker 외에도 다양한 Web Worker를 어떤 부분에서 활용할 수 있을지 계속해서 탐색할 예정이다!