N개의 탭, 단 하나의 웹소켓: SharedWorker

구현의 기초가 된 학습 자료


풍물 커뮤니티 프로젝트에서 실시간 채팅 기능을 구현할 때, 처음에는 각 페이지마다 같은 주소에 대한 웹소켓 요청을 따로 보내고 구독 요청을 하는 방식으로 개발했다. 하지만 토스 SLASH 24 컨퍼런스에서 “N개의 탭, 단 하나의 웹소켓: SharedWorker” 세션을 보면서새로운 사실을 발견했다. Service Worker를 통해서 하나의 웹소켓에 대해서 다중으로 연결할 수 있다는 것이었다.

더욱 흥미로운 것은 Shared Worker를 지원하는 브라우저에서는 여러 탭 간에도 웹소켓 연결을 공유할 수 있다는 사실이었다. 이전에는 각 탭마다 별도의 웹소켓 연결을 생성하는 것이 당연하다고 생각했는데, 실제로는 중앙화된 웹소켓 관리가 가능하다는 것을 알게 되었다. 이 개념을 직접 적용해보기로 했다.


핵심적인 부분은 아래와 같았다.

구현 방법: SharedWorker 기반 웹소켓 관리 시스템

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 모두 지원하도록 설계했다.