일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 |
- radixui
- sharedworker
- 프론트엔드
- server component
- MicroFrontEnd
- 회고
- JavaScript
- Web
- 이것저것
- virtaullist
- provider 패턴
- CustomHook
- CRA
- MFA
- 리액트
- 아키텍처
- 오블완
- Webworker
- 합성 컴포넌트
- 웹워커
- context.api
- 리팩토링
- TypeScript
- vite
- 에세이
- frontend
- react
- 클린코드
- 자바스크립트
- 티스토리챌린지
- Today
- Total
Lighthouse of FE biginner
SharedWorker에 WebSocket 얹어보기 본문
Overview
저번 글에서 Toss Slash24 세션 중 N개의 탭, 단 하나의 소켓이라는 세션을 공유하며 Web Worker에 대해서 살펴봤습니다.
Web Worker 알아보기
N개의 탭, 단 하나의 웹소켓: SharedWorker
이번 글에서는 Slash24 세션에서 소개한 SharedWorker을 살펴보고 세션에서 공유해주신 솔루션(SharedWorker에 WebSocket을 얹는)을 예제와 함께 알아보도록 하겠습니다.
Socket Server
본격적으로 SharedWorker을 살펴보기에 앞서 서버 코드를 공유합니다.
해당 예제는 Node.js 환경에서 간단한 소켓 서버를 구현하고 해당 소켓 서버와 통신을 하고 있습니다.
Interval을 통해서 8초마다 한번 씩 클라이언트에 메세지를 전송하고, 클라이언트에서 메세지를 받으면 그대로 클라이언트에 재전송 하고 있는 예제입니다.
const nameList = [
"테슬라",
"알파벳",
"삼성전자",
"LG화학",
"엔비디아",
"네이버",
"카카오",
"토스",
];
const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 8080 });
wss.on("connection", (ws) => {
console.log("Client connected. Connected Clients: ", wss.clients.size);
const intervalHandler = () => {
const randomIndex = Math.floor(Math.random() * 7);
const alarm = {
id: Date.now(),
name: nameList[randomIndex],
price: Math.floor(Math.random() * 100000),
updatedAt: new Date().toDateString(),
};
console.log("ws send", JSON.stringify({ type: "DATA", data: alarm }));
ws.send(
JSON.stringify({
type: "DATA",
data: alarm,
})
);
};
const intervalId = setInterval(intervalHandler, 8000);
ws.on("message", (message) => {
console.log(`Received: ${message}`);
let serverMessage = "";
if (message === "PING") {
serverMessage = JSON.stringify({
type: "MESSAGE",
data: "PONG",
});
} else {
serverMessage = JSON.stringify({
type: "MESSAGE",
data: `Server: Received your message - ${message}`,
});
}
ws.send(serverMessage);
});
// 연결이 닫혔을 때
ws.on("close", () => {
console.log("Client disconnected");
clearInterval(intervalId);
});
});
console.log("WebSocket server running on ws://localhost:8080");
WebWorker
앞선 글에서는 WebWorker에는 공유하지 않는 별도의 백그라운드 스레드를 가지는 DedicatedWorker과 같은 도메인의 worker 스크립트에 한해서 백그라운드 스레드를 공유하는 SharedWorker가 있다고 했습니다.
SharedWorker
SharedWorker란 Message Port를 통해서 메인 스레드와 통신하는 백그라운드 스레드 입니다.
SharedWorker에서 메세지 포트는 브라우저 여러 탭이 존재할 수 있으므로 배열을 통해서 관리를 해줄 수 있습니다.
탭 간 스레드를 공유하기 때문에 같은 세션인 브라우저에서 동일한 데이터를 바라보아야 하는 상황에서 유용하게 사용할 수 있습니다.
주의해야 할 점은, SharedWorker을 지원하지 않는 브라우저와 버전이 존재하는 것 입니다. 그렇기 때문에 기능을 구현하기 전 반드시 지원 대상을 면밀하게 검토하시고 해당 API를 사용하시면 좋을 것 같습니다.
DedicatedWorker with WebSocket
SharedWorker을 구현하기 앞서 먼저 DedicatedWorker에 WebSocket을 얹어보도록 하겠습니다. WebWorker을 사용하기 위한 코드는 이전에 공유드린 코드의 내용과 같습니다. 대신 Worker 위에 WebSocket을 얹기 때문에 워커의 스크립트를 새로 작성합니다.
Worker 스크립트
먼저 socket 객체를 생성합니다.
이전 스크립트와 동일하게 워커가 메세지를 받았을 때 이벤트 핸들링 처리를 합니다. message를 전달 받았을 때 그대로 열린 소켓에 메세지를 전달합니다.
하단에 socket에서 메세지를 전달받았을 경우 핸들러 함수를 작성합니다. 웹 소켓 서버에서 받은 메세지를 그대로 클라이언트에 전달할 예정입니다.
const socket: WebSocket = new WebSocket("ws://localhost:8080");
self.onmessage = (event: MessageEvent<string>) => {
const { data: message } = event;
if (message === "CLOSE_SOCKET") {
socket.close();
}
if (socket.readyState === socket.CONNECTING) {
postMessage(
JSON.stringify({ type: "MESSAGE", data: "Socket is connecting..." })
);
return;
}
if (socket) {
socket.send(message);
}
};
socket.onmessage = (event: MessageEvent<string>) => {
const { data: serverMessage } = event;
console.log("worker from socket: ", serverMessage);
postMessage(serverMessage);
};
socket.onopen = () => {
socket.send("PING");
};
socket.onerror = () => {
postMessage(JSON.stringify({ type: "MESSAGE", data: "소켓 에러입니다." }));
};
화면단 코드
화면단에서는 알림을 받아서 데이터를 화면에 노출하고 있습니다. 주의해야 할 점은 메세지 기반으로 데이터를 주고 받기 때문에 객체 형태의 문자열은 자바스크립트 객체로 파싱을 해야 합니다.
알림 객체를 전달받아서 state에 배열로 담습니다. 그리고 화면에 알림 리스트를 랜더링 합니다.
import { useCallback, useEffect, useMemo, useState } from "react";
import useWebWorker from "../hooks/useWebWorker";
import AlarmComponent from "./Alarm";
import styles from "./worker.module.css";
import { Alarm } from "../types/alarm";
import { SocketData } from "../types/socket-data";
import { isEmpty } from "../utils/util";
const socketWorkerPath = "../workers/socket-worker.ts";
const WorkerExample = () => {
const [alarmList, setAlarmList] = useState<Alarm[]>([]);
const { message, loading, sendMessage } = useWebWorker<SocketData>({
url: socketWorkerPath,
});
const serverMessage: string | null = useMemo(() => {
let serverMsg = null;
if (message?.type === "MESSAGE") {
serverMsg = (message?.data as string) ?? null;
}
return serverMsg;
}, [message]);
const handleClick = useCallback(() => {
sendMessage(`알림 요청: ${Date.now()}`);
}, [sendMessage]);
useEffect(() => {
if (message?.type === "DATA") {
const alarm = message.data as Alarm;
setAlarmList((prev) => [...prev, alarm]);
}
}, [message]);
return (
<div className={styles.container}>
<h1 className={styles.title}>Dedicated Worker</h1>
<div className={styles.serverMessageWrapper}>
<h3>서버메세지</h3>
<p>{serverMessage ?? "서버에서 보낸 메세지가 존재하지 않습니다."}</p>
<button onClick={handleClick}>알람 보내기</button>
</div>
<div className={styles.alarmWrapper}>
{loading ? (
<p>로딩중..</p>
) : isEmpty(alarmList) ? (
<p>알람이 존재하지 않습니다.</p>
) : (
alarmList.map((alarm) => <AlarmComponent key={alarm.id} {...alarm} />)
)}
</div>
</div>
);
};
export default WorkerExample;
구현한 내용을 도식화하면 다음과 같은 도면이 나옵니다. 탭마다의 WebWorker를 가지고 있고 각 워커마다 소켓에 연결해 여러 커넥션이 맺어집니다.
당연히 커넥션이 다르기 때문에 소켓에서 전달받는 데이터도 다른 데이터를 받게 됩니다.
해당 예제에서는 세션을 고려하지 않았습니다.
실제 서버에서 맺어진 커넥션의 갯수를 살펴봅시다!
커넥션이 연결되면 서버에서 연결된 커넥션의 갯수를 콘솔에 로깅하도록 구현했습니다. 브라우저의 탭이 늘어날수록 커넥션의 갯수도 늘어나는 것을 확인할 수 있습니다.
SharedWorker with WebSocket
그렇다면 이번엔 SharedWorker을 구현하고 WebSocket을 얹어보도록 하겠습니다.
클라이언트에서 SharedWorker을 사용하기 위한 로직은 Worker을 위한 로직과 동일합니다. 하지만 SharedWorker이라는 클래스를 사용해 인스턴스를 만들고, 해당 객체의 port 프로퍼티를 통해 메서드를 사용해야 하는 점이 다릅니다.
useSharedWorker
import { useCallback, useEffect, useRef, useState } from "react";
type Props<T> = {
url: string;
initialData?: T;
};
type Result<T> = {
message: T | null;
loading: boolean;
sendMessage: (message?: string) => void;
};
function useSharedWorker<T>({ url, initialData }: Props<T>): Result<T> {
const worker = useRef<SharedWorker | null>(null);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<T | null>(initialData ?? null);
const sendMessage = useCallback((message?: string) => {
if (worker.current) {
setLoading(true);
worker.current.port.postMessage(message);
}
}, []);
useEffect(() => {
worker.current = new SharedWorker(new URL(url, import.meta.url));
worker.current.port.onmessage = (event: MessageEvent<string>) => {
let messageData = null;
const { data } = event;
try {
messageData = JSON.parse(data);
} catch (error) {
messageData = data;
}
setLoading(false);
setMessage(messageData);
};
if (worker.current) {
worker.current.port.start();
}
return () => {
if (worker.current) {
worker.current.port.close();
}
};
}, [url]);
return {
message,
loading,
sendMessage,
};
}
export default useSharedWorker;
만약 SharedWorker 클래스를 사용할 수 없다면 TypeScript를 사용해 SharedWorker을 사용하기 위해서는 tsconfig의 옵션을 수정할 필요가 있습니다.
1. tsconfig 파일의 lib에 ESNext를 추가합니다.
"lib": ["ESNext", ...],
2. TypeScript를 사용해 SharedWorker을 구현하기 위해서는 타입 라이브러리를 추가해야 합니다.
pnpm add @types/sharedworker -D
위 과정을 통해 컴파일 에러가 발생하지 않는다면 SharedWorker을 구현할 준비가 끝났습니다.
SharedWorker 스크립트
let connections: WeakRef<MessagePort>[] = [];
const socket: WebSocket = new WebSocket("ws://localhost:8080");
self.onconnect = (event) => {
const port = event.ports[0];
const weakPort = new WeakRef<MessagePort>(port);
connections.push(weakPort);
if (!weakPort.deref()) {
return;
}
port.onmessage = (event: MessageEvent<string>) => {
const { data: message } = event;
if (message === "CLOSE_SOCKET") {
socket.close();
}
if (socket.readyState === socket.CONNECTING) {
postMessage(
JSON.stringify({ type: "MESSAGE", data: "Socket is connecting..." })
);
return;
}
if (socket) {
socket.send(message);
}
};
};
socket.onmessage = (event: MessageEvent<string>) => {
const { data: serverMessage } = event;
console.log("worker from socket: ", serverMessage);
connections.forEach((connection) => {
const port = connection.deref();
if (port) {
port.postMessage(serverMessage);
}
});
};
socket.onopen = () => {
socket.send("PING");
};
socket.onerror = () => {
postMessage(JSON.stringify({ type: "MESSAGE", data: "소켓 에러입니다." }));
};
SharedWorker은 메세지 포트를 통해 메인 스레드와 통신합니다. 그렇기 때문에 connect를 맺고나서 연결된 포트를 커넥션 배열에 추가해야 합니다.
발표된 세션에서는 연결된 커넥션에 의한 메모리 누수를 방지하기 위해 포트 객체를 WeafRef로 감싸 커넥션이 참조되지 않을 경우, 얕은 참조로 가비지 컬렉트 되도록 구현했습니다. 해당 예제는 세션의 예제를 코드로 구현해보는 과정이기에 동일하게 WeafRef로 포트를 감싸 메세지 포트를 관리하도록 합니다.
그 외의 로직은 DedicatedWorker와 동일합니다. 한 가지 다른 점은 socket으로부터 메세지를 받았을 경우 연결된 포트 전체에 메세지를 전송해야 하는 점 입니다.
socket.onmessage = (event: MessageEvent<string>) => {
const { data: serverMessage } = event;
console.log("worker from socket: ", serverMessage);
connections.forEach((connection) => {
const port = connection.deref();
if (port) {
port.postMessage(serverMessage);
}
});
};
위 과정을 도식화 하면 아래와 같은 도면이 나옵니다. SharedWorker는 동일한 도메인, 워커일 경우 스레드를 공유한다고 했습니다. 그렇기 때문에 동일한 워커에서 하나의 커넥션으로 웹소켓 서버와 커넥션을 맺습니다.
세션을 한번 고려해 시뮬레이션 해보도록 하겠습니다. 만약 동일한 유저가 여러 탭을 띄어놓고 서버와 커넥션을 맺을 경우 DedicatedWorker을 사용한다면 여러개의 커넥션을 맺게됩니다. 반면에 SharedWorker을 사용할 경우 하나의 커넥션을 사용해 프론트엔드 단에서 서버 리소스의 낭비를 방지할 수 있습니다.
실제로 서버 로그를 살펴보도록 합시다. 여러 브라우저 탭이 열리지만 서버와 연결된 커넥션은 단 한 개라는 것을 확인할 수 있습니다.
추가 내용
예제를 작성하고 리팩토링 하던 중 worker 스크립트가 빌드에 포함이 안되는 문제가 발생했습니다.
해당 문제를 발견하고 트러블슈팅 하는 과정은 아래 포스팅을 참고해주시면 감사하겠습니다.
[트러블슈팅] WebWorker 빌드 시 worker 파일이 포함이 안되는 문제
마무리하며
Slash24 세션의 예제를 코드로 구현해보며 SharedWorker에 대해서 살펴보고 WebSocket을 얹어보는 시간을 가졌습니다.
위 과정을 실제 프로젝트에서 구현한다면 더욱 복잡한 코드를 가지겠지만, 간단하게 구현해보는 과정에서 프론트엔드 단에서 서버 리소스 낭비를 방지할 수 있구나 라는 것을 느꼈습니다.
또한 JavaScript는 가비지 컬렉션에 의해서 메모리 누수를 자동으로 방지한다고 생각을 했었는데, WeafRef를 통해서 한번 더 세심하게 애플리케이션의 메모리를 관리하는 방법을 보고 많은 생각이 들었습니다.
앞으로더 IT 세션에 많은 관심을 가지고 추후에 또 좋은 세션의 경우 코드로 직접 구현해보며 발표 내용을 더욱 깊게 이해해보도록 하겠습니다.
구현하신 코드가 궁금하시다면 아래의 저장소를 살펴봐주세요!
저장소 바로가기
읽어봐주셔서 감사합니다.
'[WEB] 프론트엔드' 카테고리의 다른 글
[React] 합성(Composition) 컴포넌트 (2) | 2025.01.01 |
---|---|
[트러블슈팅] WebWorker 빌드 시 worker 파일이 포함이 안되는 문제 (2) | 2024.10.19 |
Web Worker 알아보기 (4) | 2024.10.13 |
MFA (Micro Frontend Architecture) (3) | 2024.10.03 |
[React] 상태(State)에 대하여 (4) | 2024.09.19 |