
JuGa 프로젝트를 진행하면서 팀이 소켓 연결 관리 문제에 직면했다.
주식 데이터를 받아오기 위해 한국 투자 증권 API에서 데이터를 가져오는데 한국 투자 증권 API에 한세션당 최대 41개의 구독이 가능하다. 즉, 동시에 최대 41개의 연결만 가능하다는 것이다.
따라서 이 문제를 해결하기 위해 백엔드 팀원들이 redis pub/sub 구조를 이용해 아래와 같은 시스템을 구축하여
아래와 같이 만들어 3개의 서버에서 각각 41개의 구독을 하여 41 * 3개의 연결을 가능하도록 만들었다.
팀에서 소켓 연결 효율화를 위한 방법을 고민하니 나도 클라이언트 사이드에서 할 수 있는 소켓 연결 효율화 방법을 고민하게 되었다.
따라서 조사하던 중 토스증권에서 진행한 Shared Worker를 이용한 소켓 연결 효율화 방법을 봤다.
해당 문서와 영상에서 인사이트를 얻었지만, 외부 라이브러리 없이 주식 차트 직접 구현이라는 업무와 그 외 담당하고 있는 태스크로 인해 우선 순위에서 밀려 진행하지 못했다.
외부 라이브러리 없이 직접 구현했던 차트 GIF
최종 프로젝트 완료 후 다른 팀의 프로젝트를 받아서 3주 동안 직접 문제를 정의하고 개선할 수 있는 리펙토링 프로젝트 참가 기회가 생겼다.
개인적으로 처음부터 끝까지 내가 직접 참여한 프로젝트가 아닌 '처음 보는', '이미 완성 된' 프로젝트를 담당하는 경험을 해보고 싶었고 이런 경험을 할 수 있는 기회가 흔치 않다고 판단하여 참가하게 되었다.
팀원들과 함께 회의를 통해 선택한 프로젝트는 실시간 드로잉 게임 방해꾼은 못말려였다.
방해꾼은 못말려 메인 이미지참고 : 위의 이미지를 클릭했을 때 나오는 레포지토리는 해당 프로젝트의 오리지널 링크로 내가 기여한게 한개도 없는 링크이다.
해당 프로젝트에 대한 작업은 리펙토링용 레포지토리를 따로 만들어서 진행했다.
Shared Worker를 학습하기 전에 먼저 해당 프로젝트에서 소켓 연결을 어떻게 관리하는지부터 확인했다.
코드를 확인한 이후, 처음에 생각했던 것보다 복잡한 구조에 당황했다.
기본적으로는 Zustand를 이용해 전역으로 소켓을 관리하는데 생각보다 많은 파일과 복잡한 구조를 가지고 있었다.
따라서 프로젝트를 진행했던 분들이 작성했던 wiki 문서를 확인했고 관련 문서를 찾게 되었다.
wiki에 있던 관련 문서 링크
위키 문서에 있던 소켓 아키텍처 구조 사진
해당 문서를 확인한 결과 궁금했던 모든 궁금증이 해결됐다.
문서를 토대로 확인한 결과
추가적으로 느낀점
단순히 코드를 통해 이해하는 것보다 문서를 통해 개발했던 담당자의 개발 의도를 알 수 있었다.프로젝트 문서화 중요성에 대해 그 동안 많이 들었지만, 해당 경험을 통해 문서화의 중요성을 확실하게 체감할 수 있었다.
싱글 쓰레드로 작동하는 JavaScript이지만, 이벤트 루프를 통해 알 수 있듯 JavaScript를 실행하는 환경은 싱글 쓰레드가 아니다. 따라서 웹 워커와 같은 Web API를 이용해 병렬로 JavaScript 코드를 실행할 수 있다.
우리는 Web Worker를 사용하면 복잡한 계산이나 데이터 처리와 같은 시간이 오래 걸리는 작업을 메인 스레드와 분리하여 실행함으로써 웹 페이지의 응답성과 성능을 크게 향상시킬 수 있다.
Web Worker는 위 그림처럼 Message를 주고받는 방식으로 데이터를 처리한다.
이 구조는 단순해 보이지만, 실제로 사용할 때는 몇 가지 주의해야 할 점들이 있다.
먼저 이 글에서 우리는 웹소켓(socket.io) 관리를 위한 Shared Worker 사용글이기 때문에 이와 관련된 고민점을 먼저 이야기 해보려 한다.
참고 : 위에서 말한 Message를 주고 받는 방식은 MessagePort라는 웹 API를 의미한다.
먼저 socket.io를 사용할 때 클라이언트에서 웹소켓을 사용하는 방식은 대략 아래와 같다.
// client 부분 코드
import { io } from 'socket.io-client';
export const socket = io(import.meta.env.VITE_SOCKET_URL);
const handleTradeHistory = () => {
// 생략
};
const dataArray = [];
// 이벤트 등록
socket.on(`trade-history/${id}`, handleTradeHistory);
// 이벤트 전송
socket.emit(`trade-history/${id}`, dataArray);
// 이벤트 해제
socket.off(`trade-history/${id}`, handleTradeHistory);
이 코드에서 말하고 싶은 부분은 socket.on("이벤트명", 핸들러)처럼
이벤트명을 기준으로 커스텀 이벤트를 등록하고,
해당 이벤트가 들어오면 지정한 핸들러 함수가 실행된다는 점이다.
이제 같은 기능을 Shared Worker를 통해 구현한 코드를 보자:
// worker 연결
const socketWorker = new SharedWorker('shared-worker.js');
const port = socketWorker.port;
// worker 시작
port.start();
const handleTradeHistory = () => {
// 생략
};
const dataArray = [];
// 이벤트 수신 처리
port.onmessage = function(e) {
if (e.data.type === 'event' && e.data.eventName === `trade-history/${id}`) {
handleTradeHistory();
}
};
// 이벤트 전송
port.postMessage({
type: 'emit',
eventName: `trade-history/${id}`,
data: { dataArray: dataArray }
});
// 종료
port.close();
두 코드 모두 동일한 목적을 가지고 있지만, 방식에는 큰 차이가 있다.
Shared Worker에서는 모든 통신이 postMessage() / onmessage()를 통해 이뤄지기 때문에
이벤트명을 직접 체크하고 필터링하는 등의 추가적인 로직이 필요하다.
즉, socket.io처럼 커스텀 이벤트를 기준으로 실행시키기 위해서는
직접 type과 eventName을 데이터에 포함시키고, 조건문으로 분기 처리를 해줘야 한다.
앞서 말했던 부분은 우리가 Shared Worker를 이용해 웹소켓 관리를 할 때의 주의 사항이고
그외에 Web Worker를 사용하면 주의해야할 점들이 있다.
메모리 누수 주의
const socketWorker = new SharedWorker('shared-worker.js');
이 코드가 반복 실행되면 워커가 계속 생성되기만 하고 종료되지 않아 메모리 누수가 발생할 수 있다.
워커는 의도적으로 잘 관리하고, 필요할 땐 port.close()로 명확히 닫아줘야 한다.
메인 쓰레드와 완전 격리됨
Web Worker는 메인 쓰레드와 메모리를 공유하지 않기 때문에
DOM 접근, window, localStorage 같은 브라우저 API를 사용할 수 없다.
메시지 기반 통신의 번거로움
모든 데이터는 메시지 형태로 전달되고, 수신자 입장에서 어떤 타입의 데이터인지 직접 파악해야 한다.
그만큼 구조적인 설계와 타입 체크가 중요하다.
디버깅의 어려움
Worker는 별도의 실행 컨텍스트에서 동작하므로 일반적인 콘솔 로깅이나 디버깅이 어렵다.
Chrome DevTools에서 전용 워커 디버깅 도구를 사용해야 효과적으로 디버깅할 수 있다.
간단한 작업에는 오버엔지니어링이 될 수 있음
설정과 통신 오버헤드를 고려할 때, 단순하고 가벼운 작업에는 Worker를 사용하지 않는 것이 좋다.
복잡한 구조와 추가적인 코드 작성이 필요하므로, 성능 개선이 확실하지 않은 경우에는
기존 방식으로 구현하는 것이 개발 효율성과 유지보수 측면에서 더 효과적일 수 있다.
Vite를 이용해 React 프로젝트를 생성하면 아래와 같은 간단한 카운팅 프로그램이 나온다.
여기서 봐야할 점은 카운팅되는 숫자가 각각의 탭에서 서로 다르게 작용한다는 점이다.
해당 카운트의 경우 useState()를 통해 관리되고 있기 때문에 아주 당연한 결과이다.
그럼 여기서 내가 하고 싶은 것을 이야기하면 다음과 같다.
목표
그럼 이제 목표를 위해 하나씩 만들어보자.
Shared Worker 파일
// src/workers/counter-worker.js
let count = 0;
const ports = new Set();
self.onconnect = function(e) {
const port = e.ports[0];
ports.add(port);
port.postMessage({ type: 'init', value: count });
port.onmessage = function(e) {
if (e.data === 'increment') {
count++;
broadcastAll({ type: 'count', value: count });
broadcastAll({ type: 'broadcast', value: count });
}
}
port.start();
port.onmessageerror = () => ports.delete(port);
}
function broadcastAll(message) {
ports.forEach(port => {
port.postMessage(message);
});
}
위의 코드를 하나씩 설명하면 다음과 같다.
ports
self
port
self.onconnect
port.postMessage()
port.onmessage
port.start()
port.onmessageerror
이제 메인 JavaScript 코드를 보자.
App.jsx
function App() {
const [count, setCount] = useState(0);
const workerRef = useRef(null);
useEffect(() => {
workerRef.current = new SharedWorker(
new URL('./workers/counter-worker.js', import.meta.url),
{ type: 'module' }
);
const messageHandler = (e) => {
const { type, value } = e.data;
switch(type) {
case 'init':
case 'count':
setCount(value);
break;
case 'broadcast':
console.log(`Broadcast received in tab ${document.title}:`, value);
break;
}
};
workerRef.current.port.onmessage = messageHandler;
workerRef.current.port.start();
return () => {
if (workerRef.current) {
workerRef.current.port.close();
}
};
}, []);
const handleIncrement = () => {
workerRef.current?.port.postMessage('increment');
};
return (
// 초기 생성시 코드와 동일
)
}
export default App
위의 코드를 하나씩 설명하면 다음과 같다.
workerRef.current = new SharedWorker()useRef를 이용해 SharedWorker 저장.useEffect를 이용해 한번만 실행되도록 함.messageHandler()onmessage()를 통해 들어온 데이터를 분류하여 알맞는 로직 수행.workerRef.current.port.start()handleIncrement()
이제 실행시켜서 여러개의 탭에서 확인해보면 성공적으로 N개의 탭에서 카운트를 공유하는 것을 볼 수 있다.
프로젝트에 적용시키기 위해 사전에 분석했던 아키텍처를 기반으로 수정이 필요했다.
다시 한번 목표를 확인하면 다음과 같다.
before
Zustand를 이용해 전역으로 소켓을 두고 사용
after
Shared Worker에서 소켓 연결을 공유하여 사용
목표를 다시 한번 확인하니 아래 소켓 아키텍처 구조에서 우리가 수정해야하는 부분이 명확하게 보인다.
바로 소켓 연결 상태와 소켓 연결을 관리하는 계층인 Socket Store 계층이다.
위키 문서에 있던 소켓 아키텍처 구조 사진
그럼 이제 기존 Socket Store 코드에서 하던 역할을 확인해보자.
아주아주 굵직하게 보면 해당 파일은 아래 구조로 되어있다.
전체 코드는 굳이 이곳에 올리지 않겠다.
전체 코드는 레포지토리 참고
import { create } from 'zustand';
import type { Socket } from 'socket.io-client';
import {
handleSocketError,
SocketNamespace,
SocketAuth,
NAMESPACE_AUTH_REQUIRED,
socketCreators,
} from '@/stores/socket/socket.config';
interface SocketState {
sockets: Record<SocketNamespace, Socket | null>;
connected: Record<SocketNamespace, boolean>;
actions: {
connect: (namespace: SocketNamespace, auth?: SocketAuth) => void;
disconnect: (namespace: SocketNamespace) => void;
disconnectAll: () => void;
};
}
export const useSocketStore = create<SocketState>((set, get) => ({
sockets: {
[SocketNamespace.GAME]: null,
[SocketNamespace.DRAWING]: null,
[SocketNamespace.CHAT]: null,
},
connected: {
[SocketNamespace.GAME]: false,
[SocketNamespace.DRAWING]: false,
[SocketNamespace.CHAT]: false,
},
actions: {
// 생략
}.
}));
위의 코드 구조를 보면 다음과 같은 내용으로 구성되어 있다는 것을 알 수 있다.
socket.config 사용.그럼 이제 해당 내용을 참고하여 Shared Worker 파일을 만들어보자.
일단 Shared Worker를 이용하게 되면 아래와 같은 구조로 통신을 하게 될 것이다.
해당 구조를 생각하며 하나씩 이제 살펴보자.
class SocketManager {
private sockets: Map<SocketNamespace, Socket>;
private ports: Set<MessagePort>;
private connected: Record<SocketNamespace, boolean>;
constructor() {
this.sockets = new Map();
this.ports = new Set();
this.connected = {
game: false,
drawing: false,
chat: false,
};
}
/*
메서드 추가
ex : connect, discoeenct, disconnectAll, emit
*/
}
클래스에 메서드를 선언하기 전에 전체 구조를 살펴보면 다음과 같다.
기존 Socket Store에서 사용되는 연결 상태를 알기 위한 connected 상태와 소켓을 저장하기 위한 sockets를 두었고
Shared Worker에 연결된 port들을 저장하기 위해 ports를 만들었다.
이제 몇몇 메서드들을 살펴보자.
일부 메서드 코드
private broadcast(message: any) {
this.ports.forEach((port) => {
port.postMessage(message);
});
}
private updateConnectionState(namespace: SocketNamespace, isConnected: boolean) {
this.connected[namespace] = isConnected;
this.broadcast({
type: 'connection_update',
namespace,
connected: isConnected,
});
}
private setupSocketEvents(socket: Socket, namespace: SocketNamespace) {
socket.on('connect', () => {
this.updateConnectionState(namespace, true);
});
socket.on('disconnect', () => {
this.updateConnectionState(namespace, false);
});
socket.on('error', (error: SocketError) => {
this.broadcast({
type: 'socket_error',
namespace,
error,
});
});
socket.onAny((eventName, ...args) => {
console.log('namespace:', namespace, 'event:', eventName, 'args:', args);
this.broadcast({
type: 'socket_event',
namespace,
event: eventName,
args,
});
});
}
broadcast : Shared Worker와 연결된 모든 포트들에 메시지를 전송하는 메서드.socket.on() 등) 해당 이벤트를 전달할 때도 사용.updateConnectionState : 연결 상태를 업데이트하는 메서드.setupSocketEvents : 소켓 연결과 동시에 기본적인 이벤트 리스너 등록을 바로 진행하는 메서드.onAny()를 이용해 이벤트 발생시 바로 브로드캐스팅.기본적으로 Message로 전송되는 데이터의 구조는 아래와 같은 구조를 따르며
type이 'socket_event' 일 경우 event 를 이용해 커스텀 이벤트를 분류.
{
type: 'socket_event',
namespace,
event: eventName,
args,
}
전체 메서드를 이곳에서 설명하는 것은 불필요하다고 생각하여 위의 메서드만 설명했고
만약 다른 메서드가 궁금하면 여기 링크를 참고하면 될 것이다.
Shared Worker 파일 동작을 위한 로직
const manager = new SocketManager();
addEventListener('connect', (e: Event) => {
const connectEvent = e as MessageEvent;
const port = connectEvent.ports[0];
manager.addPort(port);
port.onmessage = (e: MessageEvent) => {
const { type, payload } = e.data;
console.log('shared worker에서 받은 값', type, payload);
switch (type) {
case 'connect':
manager.connect(payload.namespace, payload.auth);
break;
case 'disconnect':
manager.disconnect(payload.namespace);
break;
case 'disconnect_all':
manager.disconnectAll();
break;
case 'emit':
manager.emit(payload.namespace, payload.event, ...payload.args);
break;
default:
console.warn('Unknown message type:', type);
}
};
port.start();
port.onmessageerror = () => manager.removePort(port);
});
이제 해당 로직을 보면 조금 햇갈릴 수도 있다.
SocketManager 클래스 내부 메서드로 있는 connect()
그리고 지금 위의 코드에 있는addEventListener('connect', () =>{})
각각 어떤 부분인지 명확하게 설명하면
addEventListener('connect', () =>{})의 경우
Main Thread에서 new SharedWorker()를 이용해 선언하면 실행이 된다.
즉, 아래 그림에서 빨간 동그라미로 표시한 부분에 해당된다.

그리고 addEventListener('connect', () =>{}) 내부 switch/case문에서 사용되는
manager.connect()의 경우
Shared Worker에서 Server와 연결하는 로직이다.
즉, 아래 부분에 해당한다.

이제 우리는 앞서 봤던 Socket Store 대신 Socket Manager라는 파일을 만들어 해당 역할을 대신하도록 만들었다.
그런데 여기서 아까 말했던 문제점이 등장한다.
이제 Socket Manager을 이용해 3개(Chat, Game, Draw)의 Domain Store에서 사용해야 하는데
Socket Manager를 Shared Worker를 이용해 사용하면 해당 데이터는 Message를 통해서 전달되기 때문에
Domain Store에서 사용하기 전에 필터링을 해주는 작업이 필요하다.
따라서 해당 작업을 할 계층(Domain Manager)을 추가하여 최종적으로 다음 이미지와 같이 완성할 것이다.
이제 Shared Worker를 구현했으니, 이를 실제 비즈니스 로직과 연결하는 중간 계층인 Domain Manager를 구현하려 한다.
각 도메인(Chat, Game, Drawing)별로 Manager를 만들어야 하는데, 여기서는 Chat Domain Manager를 예시로 진행.
전체 메서드를 이곳에서 설명하는 것은 불필요하다고 생각하여 일부 메서드만 설명 예정.
만약 다른 메서드가 궁금하다면 여기 링크를 참고하면 될 것이다.
ChatManager 코드
import { SocketNamespace } from '@/stores/socket/socket.config';
interface ChatAuth {
roomId: string;
playerId: string;
}
class ChatWorkerManager {
private static instance: ChatWorkerManager;
private worker: SharedWorker | null = null;
private messageHandlers: Set<(e: MessageEvent) => void> = new Set();
private connected: boolean = false;
private auth: ChatAuth | null = null;
private constructor() {
try {
this.worker = new SharedWorker(new URL('./socketWorker.ts', import.meta.url), {
type: 'module',
name: 'socket-worker',
});
this.messageHandlers = new Set();
this.setupWorkerListeners(); // 밑에서 설명 예정
this.worker.port.start();
} catch (error) {
console.error('Error initializing ChatWorkerManager:', error);
}
}
public static getInstance(): ChatWorkerManager {
if (!ChatWorkerManager.instance) {
ChatWorkerManager.instance = new ChatWorkerManager();
}
return ChatWorkerManager.instance;
}
}
export const chatWorkerManager = ChatWorkerManager.getInstance();
export const connectChat = (auth: ChatAuth) => chatWorkerManager.connect(auth);
export const sendChatMessage = (message: string) => chatWorkerManager.sendChatMessage(message);
export const addMessageHandler = (handler: (e: MessageEvent) => void) => chatWorkerManager.addMessageHandler(handler);
export const removeMessageHandler = (handler: (e: MessageEvent) => void) =>
chatWorkerManager.removeMessageHandler(handler);
export const disconnectChat = () => chatWorkerManager.disconnect();
위 코드의 구조를 살펴보면
싱글톤 패턴 적용: 해당 클래스를 매번 생성할 경우 new SharedWorker()를 통해 계속 생성되어 메모리 누수가 발생할 위험이 있기 때문에 싱글톤 패턴을 적용했다.
상태 관리:
connected 변수로 연결 상태를 관리auth 객체를 통해 Chat 소켓 연결에 필요한 roomId, playerId 관리messageHandlers Set 객체를 통해 등록된 이벤트 핸들러 관리간편한 함수 제공: 복잡한 Worker 통신 로직을 숨기고, 외부에서 사용하기 쉬운 함수들(connectChat, sendChatMessage 등)을 제공.
이제 onmessage를 통해 들어오는 데이터를 필터링하는 작업을 진행해야 한다.
setupWorkerListeners 메서드
private setupWorkerListeners() {
if (!this.worker) return;
this.worker.port.onmessage = (e) => {
const { type, namespace, connected, event, args } = e.data;
// CHAT 네임스페이스 이벤트만 처리
if (type === 'connection_update' || type === 'socket_event' || type === 'socket_error') {
if (namespace !== SocketNamespace.CHAT) return;
}
switch (type) {
case 'init':
this.connected = e.data.connected[SocketNamespace.CHAT] || false;
break;
case 'connection_update':
this.connected = connected;
break;
case 'socket_event':
if (event === 'messageReceived') {
console.log('Chat message received:', args[0]);
}
break;
case 'socket_error':
console.error('Chat socket error:', e.data.error);
break;
}
// 등록한 모든 메시지 핸들러 실행
this.messageHandlers.forEach((handler) => handler(e));
};
}
네임스페이스 필터링: Chat Manager는 CHAT 네임스페이스의 이벤트만 처리. 다른 네임스페이스(GAME, DRAWING)의 이벤트는 무시.
이벤트 타입별 필터링:
init: 초기 연결 상태 설정connection_update: 연결 상태 변경 처리socket_event: 실제 소켓 이벤트 처리 (예: 메시지 수신)socket_error: 오류 처리등록된 핸들러 실행: 등록된 모든 핸들러를 실행시킨다.
Worker로 메시지를 보내는 핵심 메서드 두 가지를 만들어 보자
// 연결 로직
public connect(auth: ChatAuth) {
if (!this.worker) {
console.error('Worker not initialized');
return;
}
this.auth = auth;
this.worker.port.postMessage({
type: 'connect',
payload: {
namespace: SocketNamespace.CHAT,
auth: {
roomId: auth.roomId,
playerId: auth.playerId,
},
},
});
}
// 메시지 전송 로직
public sendChatMessage(message: string) {
if (!this.worker) {
console.error('Worker not initialized');
return;
}
if (!this.connected || !this.auth) {
console.warn('Chat is not connected or auth is missing');
return;
}
this.worker.port.postMessage({
type: 'emit',
payload: {
namespace: SocketNamespace.CHAT,
event: 'sendMessage',
args: [{ message: message.trim() }],
},
});
}
이 메서드들을 보면 Shared Worker로 보내는 메시지 구조의 일관성을 볼 수 있다.
모든 메시지는 type, payload 구조를 가지고 이를 통해 Shared Worker 내부에서 필터링 작업을 하여 처리하게 된다.
이제 소켓 하나씩 해당 과정을 통해 Shared Worker로 개선해 나가면 될 것이다.
위에 적힌 것과 같은 과정을 통해 Game Manager를 만들었는데 한가지 문제가 발생했다.
에러 관련 로그의 내용은 connect 전에 joinRoom을 하여 에러가 발생한다는 것이었다.
그럼 해당 에러가 발생하는 원인을 분석하기 위해 문제 상황에 대한 코드를 살펴보자.
Game관련 Custom Hook 코드 일부
// 연결 + 재연결 시도
useEffect(() => {
// roomId가 없으면 연결하지 않음
if (!roomId) return;
// 소켓 연결
socketActions.connect(SocketNamespace.GAME);
// 현재 방의 연결 정보 처리
const savedPlayerId = playerIdStorageUtils.getPlayerId(roomId);
// console.log(savedPlayerId, roomId);
if (savedPlayerId) {
gameSocketHandlers.reconnect({ playerId: savedPlayerId, roomId }).catch((error) => {
// 재연결 실패 시 계정 삭제
console.error('Reconnection failed:', error);
playerIdStorageUtils.removePlayerId(roomId);
});
}
// savedPlayerId가 없다면 새로운 접속 시도
else {
playerIdStorageUtils.removeAllPlayerIds();
gameSocketHandlers.joinRoom({ roomId }).catch(console.error);
}
// 연결 해제 시 현재 방의 playerId만 제거
return () => {
socketActions.disconnect(SocketNamespace.GAME);
playerIdStorageUtils.removePlayerId(roomId);
};
}, [roomId]);
해당 로직이 실행되면 다음과 같은 로직이 실행된다.
1. socketActions.connect(SocketNamespace.GAME)을 통해 연결 시도
2. joinRoom 이벤트를 서버에 전송하면 서버에서 클라이언트로 roomId, playerId가 전송됨
3. 제공 받은 roomId와 playerId를 저장하여 이후 통신에 사용
4. 만약 playerIdStorageUtils.getPlayerId(roomId)에 저장된 값이 없다면 joinRoom을 통해 roomId, playerId을 다시 받음
4. 만약 playerIdStorageUtils.getPlayerId(roomId)에 저장된 값이 있다면 roomId, playerId을 이용해 reconnect
여기서 주의해서 봐야할 부분은 1번 과정과 2번 과정이다.
두 과정은 useEffect를 통해 메인 쓰레드에서 연속으로 실행되기 때문에 Shared Worker를 사용할 경우
Worker에서 연결을 하기 전에 joinRoom을 실행하여 에러가 발생하는 것이다.
따라서 해당 문제를 해결하기 위해 joinRoom이 Shared Worker가 연결을 완료한 이후 실행되도록 하기 위해
setTimeout을 통해 약간의 딜레이를 주어 해결하려 했다.
setTimeout(() => {
gameSocketHandlers.joinRoom({ roomId }).catch(console.error);
}, 100);
이렇게 코드를 작성하니 에러 코드는 다 사라졌지만, StrictMode 에 의해 코드가 2번씩 실행되며 치명적인 문제가 발생했다.
문제 상황에 대해 간략하게 적으면 다음과 같다.
joinRoom(setTimeout) 대기.joinRoom(setTimeout) 대기.joinRoom(setTimeout) 실행.joinRoom(setTimeout) 실행.이렇게 마지막에 joinRoom이 2번 실행되어 플레이어가 2명씩 추가 되는 문제였다.
따라서 더 근본적인 해결책이 필요했다.
해당 문제는 결국 Shared Worker가 소켓 연결을 완료하기 전에 이벤트를 전송하려고 해서 발생한 문제이다.
따라서 Game Manager에 소켓 연결 이전에 들어온 이벤트를 Queue에 저장하고 있다가 연결 후 한번에 처리하는 로직을 추가했다.
type WaitingQueueType = {
event: string;
args: any[];
};
// 클래스 내부 선언
private waitingQueue: WaitingQueueType[] = [];
따라서 위와 같이 새로운 배열을 만들어 Game Manager 클래스 내부에 추가했고
connect 관련 메서드와 emit 관련 메서드에 this.waitingQueue를 사용하는 로직을 추가했다.
setupWorkerListeners 메서드 내부 수정
switch (type) {
// 생략
case 'connection_update':
console.log('Game connection status:', connected);
this.connected = connected;
if (this.connected && this.waitingQueue.length) {
this.waitingQueue.forEach((v) => {
this.emitEvent(v.event, ...v.args);
});
this.waitingQueue = [];
}
break;
// 생략
}
위에 보이는 것과 같이 연결이 되면 this.waitingQueue를 확인하고 처리하는 로직을 추가하고
emitEvent 메서드 수정
if (!this.connected) {
console.warn('Game socket is not connected');
this.waitingQueue.push({ event, args });
return;
}
emitEvent 메서드에는 다음과 같이 만약 연결 전에 emit 이벤트를 요청하면 this.waitingQueue에 순서대로 저장하는 로직을 추가했다.
이렇게 setTimeout을 통해 임시로 처리했던 문제를 waiting Queue를 추가하여 해결할 수 있었다.
3주간의 리팩토링 프로젝트에서 Shared Worker 외에도 다른 개발자의 코드를 이해하는 과정 또한 큰 도전이었다.
프로젝트의 복잡한 소켓 아키텍처와 헤드리스 패턴으로 작성된 프로젝트를 처음 접했을 때는 매우 이해하기 힘들었다. 하지만 프로젝트의 오리지널? 멤버분들이 남긴 wiki 문서를 통해 설계 의도를 이해할 수 있었다.
이 경험은 문서화의 중요성을 깨닫게 해주었다.
이전부터 문서화가 매우 중요하다는 말은 많이 들었지만 이렇게 직접 경험하니 체감되는 느낌이 달랐다.
코드 내에 있는 많은 주석과 코드보다 관련 문서는 확실한 안내서가 되었다.
이번 리팩토링의 내가 담당했던 주요 목표는 Shared Worker를 도입해 소켓 연결을 효율화하는 것이었다.
3주간의 시간이 있었지만 기존 코드에 대한 이해와 Shared Worker에 대한 학습, 설계까지 완벽하게 진행하기에는 시간이 너무 부족했다.
3개의 웹소켓 연결을 점진적 개선을 통해 하나씩 분리하여 Game, Chat 소켓은 Shared Worker를 적용시킬 수 있었다.
하지만 결국 Draw 소켓은 시간 내에 완료하지 못했다.
비록 계획했던 모든 것을 완료하지는 못했지만, 타인의 코드 이해와 클라이언트 사이드에서의 소켓 연결 최적화 방법에 대해 학습할 수 있었던 소중한 경험이었다.