N개의 탭, 단 하나의 웹소켓: SharedWorker
구현의 기초가 된 학습 자료
풍물 커뮤니티 프로젝트에서 실시간 채팅 기능을 구현할 때, 처음에는 각 페이지마다 같은 주소에 대한 웹소켓 요청을 따로 보내고 구독 요청을 하는 방식으로 개발했다. 하지만 토스 SLASH 24 컨퍼런스에서 “N개의 탭, 단 하나의 웹소켓: SharedWorker” 세션을 보면서새로운 사실을 발견했다. Service Worker를 통해서 하나의 웹소켓에 대해서 다중으로 연결할 수 있다는 것이었다.
더욱 흥미로운 것은 Shared Worker를 지원하는 브라우저에서는 여러 탭 간에도 웹소켓 연결을 공유할 수 있다는 사실이었다. 이전에는 각 탭마다 별도의 웹소켓 연결을 생성하는 것이 당연하다고 생각했는데, 실제로는 중앙화된 웹소켓 관리가 가능하다는 것을 알게 되었다. 이 개념을 직접 적용해보기로 했다.
핵심적인 부분은 아래와 같았다.
1. SharedSocketManager 싱글톤 패턴 구현
먼저 SharedWorker를 활용한 웹소켓 관리 시스템의 전체 아키텍처를 설계했다. SharedWorker는 여러 탭에서 공유할 수 있는 백그라운드 스크립트로, 중앙에서 웹소켓 연결을 관리하고 각 탭과 메시지를 주고받는 역할을 담당한다. 브라우저 호환성을 고려하여 SharedWorker를 지원하지 않는 환경에서는 DedicatedWorker로 fallback하도록 구현했다.
// SharedSocketManager.ts - 싱글톤 패턴으로 구현
export class SharedSocketManager {
private static instance: SharedSocketManager;
private worker: SharedWorker | Worker | null = null;
private port: MessagePort | null = null;
private clientId: string;
private subscriptions = new Map<string, (data: any) => void>();
private isConnected = false;
private isSharedWorkerSupported: boolean;
private constructor() {
this.clientId = Date.now() + Math.random().toString(36);
this.isSharedWorkerSupported = typeof SharedWorker !== 'undefined';
}
static getInstance(): SharedSocketManager {
if (!SharedSocketManager.instance) {
SharedSocketManager.instance = new SharedSocketManager();
}
return SharedSocketManager.instance;
}
async connect(config: SocketConfig): Promise<void> {
if (this.worker) {
return;
}
try {
if (this.isSharedWorkerSupported) {
// SharedWorker 사용 (최적화)
this.worker = new SharedWorker('/socket-worker.js');
this.port = (this.worker as SharedWorker).port;
console.log('SharedWorker 모드로 연결');
} else {
// DedicatedWorker 사용 (폴백)
this.worker = new Worker('/socket-worker.js');
this.port = this.worker as any;
console.log('DedicatedWorker 모드로 폴백');
}
if (!this.port) {
throw new Error('Worker port initialization failed');
}
this.port.addEventListener('message', (event) => {
const { type, data, error } = event.data;
switch (type) {
case 'CONNECTED':
this.isConnected = true;
console.log('✅ Worker: WebSocket 연결 완료');
break;
case 'MESSAGE':
const { topic, message } = data;
const callback = this.subscriptions.get(topic);
if (callback) {
callback(message);
}
break;
case 'ERROR':
console.error('❌ Worker: 에러 발생', error);
this.isConnected = false;
break;
}
});
if (this.isSharedWorkerSupported && this.port) {
(this.port as MessagePort).start();
}
// 웹소켓 연결 요청
this.port.postMessage({
type: 'CONNECT',
data: config
});
} catch (error) {
console.error('Worker 연결 실패:', error);
throw error;
}
}
subscribe(topic: string, callback: (data: any) => void): void {
this.subscriptions.set(topic, callback);
if (this.port && this.isConnected) {
this.port.postMessage({
type: 'SUBSCRIBE',
data: { topic }
});
}
}
getConnectionStatus(): boolean {
return this.isConnected;
}
getWorkerType(): 'shared' | 'dedicated' {
return this.isSharedWorkerSupported ? 'shared' : 'dedicated';
}
}
export const sharedSocketManager = SharedSocketManager.getInstance();
2. SharedWorker 내부 웹소켓 관리
SharedWorker 내부에서는 STOMP 프로토콜을 사용하여 웹소켓 연결을 관리하고, 여러 클라이언트의 연결을 Map으로 관리하여 효율적으로 처리한다. GitHub 이슈 #337에서 제시한 해결책을 적용하여 SharedWorker 환경에서 StompJS를 올바르게 사용할 수 있도록 구현했다. SharedWorker와 DedicatedWorker 모두 지원하도록 설계했다.