Showing posts with label websocket. Show all posts
Showing posts with label websocket. Show all posts

Wednesday, July 5, 2023

효율적인 실시간 웹 애플리케이션 구축: SharedWorker와 WebSocket 연결 공유

현대의 웹 애플리케이션은 사용자에게 즉각적인 피드백과 동적인 경험을 제공하기 위해 실시간 통신 기술에 크게 의존하고 있습니다. 채팅, 협업 도구, 실시간 대시보드, 온라인 게임 등 다양한 서비스가 서버와 클라이언트 간의 끊김 없는 데이터 교환을 기반으로 구축됩니다. 이러한 요구에 부응하기 위해 등장한 WebSocket은 웹의 실시간 통신 패러다임을 혁신적으로 바꾸었습니다.

하지만 기술의 발전은 새로운 도전 과제를 제시합니다. 사용자들이 여러 브라우저 탭이나 창을 동시에 열어두고 애플리케이션을 사용하는 것이 보편화되면서, 각 탭이 독립적으로 서버와 WebSocket 연결을 맺는 전통적인 방식은 심각한 자원 낭비와 성능 저하 문제로 이어질 수 있습니다. 서버는 수많은 중복 연결을 관리해야 하고, 클라이언트 역시 불필요한 네트워크 및 메모리 자원을 소모하게 됩니다. 이 글에서는 이러한 문제를 해결하고, 더 확장 가능하며 효율적인 실시간 웹 애플리케이션을 구축하기 위한 핵심 전략으로서 SharedWorker를 활용한 WebSocket 연결 공유 기법을 심도 있게 탐구합니다.

1. WebSocket의 본질과 다중 탭 환경의 도전 과제

WebSocket 연결 공유의 필요성을 이해하기 위해서는 먼저 WebSocket 프로토콜의 작동 방식과 그것이 다중 탭 환경에서 마주하는 본질적인 한계를 명확히 인지해야 합니다.

WebSocket 프로토콜의 이해

WebSocket은 클라이언트와 서버 간의 단일 TCP 연결을 통해 전이중(full-duplex) 통신을 가능하게 하는 컴퓨터 통신 프로토콜입니다. 그 탄생 배경에는 기존 HTTP 프로토콜의 한계가 있었습니다. HTTP는 본질적으로 요청-응답(request-response) 모델을 따르기 때문에, 서버가 클라이언트에 먼저 데이터를 보낼 수 있는 방법이 없었습니다. 이를 극복하기 위해 Polling, Long-polling, Server-Sent Events(SSE)와 같은 기술들이 사용되었지만, 이는 불필요한 네트워크 트래픽을 유발하거나 단방향 통신만 지원하는 등의 제약을 가졌습니다.

WebSocket은 이러한 문제를 해결하기 위해 다음과 같은 특징을 가집니다.

  • 연결 수립 과정 (Handshake): WebSocket 연결은 표준 HTTP/1.1 요청으로 시작됩니다. 클라이언트는 서버에 Upgrade: websocketConnection: Upgrade 헤더를 포함한 특별한 HTTP 요청을 보냅니다. 서버가 이를 수락하면 HTTP 101 Switching Protocols 응답을 반환하며, 이후 동일한 TCP 연결은 HTTP 프로토콜에서 WebSocket 프로토콜로 전환됩니다.
  • 지속적인 양방향 연결: 일단 연결이 수립되면, 클라이언트와 서버는 양방향으로 언제든지 데이터를 주고받을 수 있는 지속적인 파이프라인을 확보하게 됩니다. 이는 HTTP 요청-응답의 오버헤드 없이 매우 낮은 지연 시간으로 메시지를 교환할 수 있게 해줍니다.
  • 데이터 프레임: WebSocket을 통해 전송되는 데이터는 '프레임(frame)'이라는 작은 단위로 분할됩니다. 각 프레임에는 데이터의 종류(텍스트, 바이너리), 길이 등의 메타 정보가 포함되어 있어 효율적인 파싱과 처리가 가능합니다. 또한 주기적으로 Ping/Pong 프레임을 교환하여 연결 상태를 확인하고 유휴 상태의 연결이 끊어지는 것을 방지합니다.
  • 보안: ws:// 프로토콜은 암호화되지 않은 통신을, wss:// 프로토콜은 TLS(SSL) 암호화를 통해 보안이 강화된 통신을 지원합니다. 현대 웹 환경에서는 wss:// 사용이 강력히 권장됩니다.

다중 탭 환경에서의 비효율성

애플리케이션이 단일 탭에서만 실행된다면 WebSocket은 완벽에 가까운 실시간 통신 솔루션입니다. 그러나 사용자가 동일한 웹 애플리케이션을 여러 탭에서 열었을 때 문제가 발생합니다. 각 브라우저 탭은 독립적인 실행 컨텍스트(JavaScript 환경)를 가지므로, 별다른 조치가 없다면 각 탭은 서버를 향해 개별적인 WebSocket 연결을 생성하고 유지하게 됩니다.

이러한 '탭당 1연결' 모델은 다음과 같은 심각한 비효율을 초래합니다.

  • 서버 자원 고갈: 서버는 클라이언트당 하나의 연결이 아닌, 클라이언트가 연 탭의 수만큼 연결을 유지해야 합니다. 이는 서버의 메모리, CPU 사용량을 급격히 증가시키며, 운영체제나 웹 서버가 허용하는 최대 동시 연결 수를 빠르게 소진시킬 수 있습니다.
  • 네트워크 대역폭 낭비: 만약 서버가 모든 클라이언트에게 동일한 데이터를 브로드캐스트해야 하는 경우(예: 주식 시세, 공지사항), 동일한 데이터가 여러 개의 중복 연결을 통해 동일한 사용자에게 반복적으로 전송됩니다. 이는 불필요한 네트워크 트래픽을 유발합니다.
  • 클라이언트 성능 저하: 클라이언트 측 브라우저 역시 여러 개의 WebSocket 연결을 유지하고, 각 연결에서 들어오는 데이터를 처리하기 위해 더 많은 메모리와 CPU 자원을 사용하게 됩니다.
  • 상태 동기화의 복잡성: 여러 탭이 각기 다른 연결을 통해 서버와 통신하면, 탭 간의 애플리케이션 상태를 일관되게 유지하기가 매우 어려워집니다.

이러한 문제들을 해결하기 위해, 여러 탭이 단 하나의 WebSocket 연결을 공유하는 아키텍처가 필요하며, 바로 이 지점에서 SharedWorker가 핵심적인 역할을 수행합니다.

2. 문제 해결의 열쇠, SharedWorker의 이해

SharedWorker는 일반적인 Web Worker와 유사하지만, 여러 브라우징 컨텍스트(탭, 창, iframe 등)에서 공유될 수 있다는 결정적인 차이점을 가집니다. 이를 통해 여러 탭이 중앙 집중화된 백그라운드 스레드와 통신하며 상태와 리소스를 공유할 수 있습니다.

Web Worker의 기본 개념

SharedWorker를 이해하기에 앞서, Web Worker의 기본 개념을 짚고 넘어갈 필요가 있습니다. Web Worker는 메인 브라우저 UI 스레드와는 별개의 백그라운드 스레드에서 스크립트를 실행할 수 있도록 하는 기술입니다. 이를 통해 복잡하고 시간이 오래 걸리는 연산을 메인 스레드에서 분리하여 UI의 반응성을 유지할 수 있습니다. Web Worker는 크게 두 종류로 나뉩니다.

  • Dedicated Worker: 가장 일반적인 형태의 워커로, 자신을 생성한 스크립트(페이지)에 의해서만 접근 가능합니다. 즉, 하나의 탭에 종속적입니다.
  • SharedWorker: 동일한 출처(origin)를 가진 여러 브라우징 컨텍스트에서 공유될 수 있는 워커입니다. 이것이 바로 우리가 WebSocket 연결을 공유하는 데 사용할 핵심 기술입니다.

SharedWorker의 라이프사이클과 통신 방식

SharedWorker는 그 라이프사이클이 독특합니다. 동일한 출처에서 특정 스크립트 URL을 사용하는 SharedWorker는 브라우저 내에서 단 하나의 인스턴스만 생성됩니다. 첫 번째 탭이 SharedWorker를 요청하면 새로운 워커 인스턴스가 생성되고, 이후 다른 탭들이 동일한 워커를 요청하면 기존에 실행 중인 인스턴스에 연결됩니다. 모든 연결된 탭이 닫히거나 연결을 해제해야만 SharedWorker 인스턴스는 종료됩니다.

메인 스크립트(탭)와 SharedWorker 간의 통신은 MessagePort 객체를 통해 이루어집니다.

  1. 클라이언트(탭) 측: new SharedWorker('worker.js')를 호출하여 워커 인스턴스에 대한 핸들을 얻습니다. 실제 통신은 sharedWorker.port 속성을 통해 이루어집니다. port.postMessage()로 메시지를 보내고, port.onmessage 이벤트 리스너로 메시지를 받습니다. 통신을 시작하려면 반드시 port.start()를 명시적으로 호출해야 합니다.
  2. SharedWorker 측: 워커 스크립트 내에서는 connect 이벤트가 발생할 때마다 새로운 클라이언트(탭)가 연결되었음을 알 수 있습니다. connect 이벤트 객체의 ports 배열에 새로운 MessagePort가 담겨 전달됩니다. 워커는 이 포트를 저장하고, 이를 통해 해당 탭과 개별적으로 통신할 수 있습니다.

이러한 구조 덕분에 SharedWorker는 '중앙 허브' 역할을 완벽하게 수행할 수 있습니다. 모든 탭은 이 허브에 연결되고, 허브는 단 하나의 WebSocket 연결을 생성하고 관리하며, 서버로부터 받은 메시지를 모든 연결된 탭에 브로드캐스트하거나, 특정 탭에서 받은 메시지를 서버로 전송하는 중재자 역할을 담당하게 됩니다.

3. SharedWorker 기반 WebSocket 연결 관리 아키텍처 설계

이제 이론을 바탕으로 견고하고 확장 가능한 연결 관리 아키텍처를 설계해 보겠습니다. 이 아키텍처는 SharedWorker를 중앙 컨트롤 타워로 사용하여 WebSocket 연결의 전체 생명주기를 관리하고, 여러 클라이언트 탭과의 통신을 효율적으로 처리하는 것을 목표로 합니다.

핵심 구성 요소

  1. SharedWorker (websocket-worker.js):
    • WebSocket 인스턴스 관리: 단 하나의 WebSocket 객체를 생성하고, 연결 상태(CONNECTING, OPEN, CLOSING, CLOSED)를 추적합니다.
    • 클라이언트 포트 관리: 연결된 모든 탭의 MessagePort 객체를 배열이나 Set에 저장하여 관리합니다.
    • 메시지 브로드캐스팅: WebSocket 서버로부터 메시지를 수신하면, 저장된 모든 클라이언트 포트로 해당 메시지를 전달(브로드캐스트)합니다.
    • 메시지 중계: 특정 탭으로부터 메시지를 받으면, 이를 WebSocket을 통해 서버로 전송합니다.
    • 연결 생명주기 관리: 첫 번째 탭이 연결될 때 WebSocket 연결을 시도하고, 마지막 탭의 연결이 끊어지면 WebSocket 연결을 종료하는 로직을 구현합니다. (메모리 누수 방지 및 자원 효율화)
    • 자동 재연결 로직: 네트워크 문제 등으로 WebSocket 연결이 예기치 않게 끊어졌을 경우, 지수 백오프(exponential backoff)와 같은 전략을 사용하여 자동으로 재연결을 시도합니다.
  2. 클라이언트 스크립트 (main.js):
    • SharedWorker 생성 및 연결: 애플리케이션 초기화 시 SharedWorker에 연결합니다.
    • 이벤트 리스너 등록: SharedWorker로부터 오는 메시지(서버 데이터, 연결 상태 변경 등)를 수신하여 UI를 업데이트하거나 관련 로직을 처리하는 이벤트 리스너를 설정합니다.
    • 메시지 전송 인터페이스: 사용자의 입력이나 애플리케이션 이벤트에 따라 SharedWorker를 통해 서버로 메시지를 보내는 함수를 구현합니다.
    • 연결 종료 처리: 사용자가 탭을 닫거나 페이지를 이탈할 때, SharedWorker와의 연결을 깔끔하게 정리하는 로직을 포함합니다. (beforeunload 이벤트 활용)

데이터 흐름

데이터의 흐름은 다음과 같이 요약할 수 있습니다.

서버 → 클라이언트:

  1. WebSocket 서버가 메시지를 보냅니다.
  2. SharedWorker의 WebSocket 인스턴스가 onmessage 이벤트를 통해 메시지를 수신합니다.
  3. SharedWorker는 관리하고 있는 모든 클라이언트 포트(연결된 모든 탭)를 순회하며 port.postMessage()를 호출하여 메시지를 브로드캐스트합니다.
  4. 각 탭의 클라이언트 스크립트는 worker.port.onmessage 이벤트를 통해 메시지를 수신하고, 화면에 내용을 표시하는 등 후속 작업을 수행합니다.

클라이언트 → 서버:

  1. 특정 탭(예: 사용자가 채팅 메시지를 입력한 탭)에서 서버로 데이터를 보내야 하는 이벤트가 발생합니다.
  2. 해당 탭의 클라이언트 스크립트가 worker.port.postMessage()를 호출하여 SharedWorker에게 메시지를 전달합니다.
  3. SharedWorker는 onmessage 이벤트를 통해 메시지를 수신하고, 이를 WebSocket 인스턴스의 send() 메소드를 통해 서버로 전송합니다.
  4. WebSocket 서버가 메시지를 수신하여 처리합니다.

이 아키텍처를 통해 모든 탭은 마치 자신이 직접 WebSocket에 연결된 것처럼 동작하지만, 실제로는 배후에서 SharedWorker가 모든 복잡한 작업을 처리하며 단일 연결을 효율적으로 공유하게 됩니다.

4. 단계별 구현: 코드 분석

이제 위에서 설계한 아키텍처를 실제 코드로 구현해 보겠습니다. 코드는 크게 SharedWorker 스크립트와 클라이언트 스크립트로 나뉩니다.

4.1. SharedWorker 스크립트 (websocket-worker.js)

이 스크립트는 WebSocket 연결을 중앙에서 관리하는 핵심 로직을 담고 있습니다. 클라이언트 연결 관리, 메시지 라우팅, 연결 상태 유지 및 재연결 시도 등을 담당합니다.


// websocket-worker.js

const WEBSOCKET_URL = 'wss://your-websocket-endpoint.com/socket';
let socket = null;
const ports = new Set(); // 연결된 클라이언트(탭) 포트들을 관리
let connectionState = 'CLOSED'; // 연결 상태: CLOSED, CONNECTING, OPEN

const connect = () => {
  // 이미 연결 중이거나 연결된 상태면 중복 실행 방지
  if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
    return;
  }

  console.log('[Worker] WebSocket 연결 시도...');
  connectionState = 'CONNECTING';
  broadcast({ type: 'status', message: 'Connecting...' });

  socket = new WebSocket(WEBSOCKET_URL);

  socket.onopen = () => {
    console.log('[Worker] WebSocket 연결 성공!');
    connectionState = 'OPEN';
    broadcast({ type: 'status', message: 'Connected' });
  };

  socket.onmessage = (event) => {
    console.log('[Worker] 서버로부터 메시지 수신:', event.data);
    // 모든 연결된 탭에 메시지 브로드캐스트
    broadcast({ type: 'message', data: event.data });
  };

  socket.onclose = (event) => {
    console.warn(`[Worker] WebSocket 연결 종료. 코드: ${event.code}, 이유: ${event.reason}`);
    connectionState = 'CLOSED';
    socket = null;
    broadcast({ type: 'status', message: 'Disconnected. Reconnecting...' });
    
    // 연결이 비정상적으로 종료되었을 경우 재연결 시도 (예: 1초 후)
    if (!event.wasClean) {
        setTimeout(connect, 1000);
    }
  };

  socket.onerror = (error) => {
    console.error('[Worker] WebSocket 에러 발생:', error);
    broadcast({ type: 'error', message: 'An error occurred with the WebSocket connection.' });
    // onerror는 보통 onclose를 동반하므로, onclose에서 재연결 로직을 처리
  };
};

const broadcast = (message) => {
  const serializedMessage = JSON.stringify(message);
  for (const port of ports) {
    try {
      port.postMessage(serializedMessage);
    } catch (e) {
      console.error('[Worker] 포트로 메시지 전송 실패:', e);
    }
  }
};

// 새로운 클라이언트(탭)가 연결될 때마다 실행
self.onconnect = (e) => {
  const port = e.ports[0];
  ports.add(port);
  console.log(`[Worker] 새로운 클라이언트 연결. 현재 연결 수: ${ports.size}`);

  // 이 포트로부터 메시지를 받았을 때의 처리
  port.onmessage = (event) => {
    const message = event.data;
    console.log('[Worker] 클라이언트로부터 메시지 수신:', message);
    
    // WebSocket이 연결된 상태일 때만 서버로 메시지 전송
    if (socket && socket.readyState === WebSocket.OPEN) {
      socket.send(JSON.stringify(message));
    } else {
      console.warn('[Worker] WebSocket이 연결되지 않아 메시지를 전송할 수 없습니다.');
      port.postMessage(JSON.stringify({ type: 'error', message: 'Cannot send message, WebSocket is not connected.' }));
    }
  };

  // 포트가 닫힐 때(탭이 닫힐 때) 처리
  port.addEventListener('close', () => {
    ports.delete(port);
    console.log(`[Worker] 클라이언트 연결 해제. 현재 연결 수: ${ports.size}`);
    
    // 연결된 클라이언트가 하나도 없으면 WebSocket 연결 종료
    if (ports.size === 0 && socket && socket.readyState === WebSocket.OPEN) {
      console.log('[Worker] 모든 클라이언트가 떠나서 WebSocket 연결을 종료합니다.');
      socket.close(1000, 'All clients disconnected'); // 1000은 정상 종료 코드
      socket = null;
    }
  }, { once: true }); // 이벤트 리스너가 한 번만 실행되도록 설정

  // 새로운 클라이언트가 연결되었고, WebSocket이 아직 연결되지 않았다면 연결 시작
  if (ports.size > 0 && (!socket || socket.readyState === WebSocket.CLOSED)) {
    connect();
  }

  port.start(); // 포트와의 통신 시작
};

4.2. 클라이언트 스크립트 (main.js)

이 스크립트는 각 탭에서 실행되며, SharedWorker와 통신하여 UI를 업데이트하고 사용자 입력을 처리합니다.


// main.js

// 브라우저가 SharedWorker를 지원하는지 확인
if (window.SharedWorker) {
  const worker = new SharedWorker('websocket-worker.js', { name: 'my-websocket-worker' });

  const messageLog = document.getElementById('message-log');
  const messageInput = document.getElementById('message-input');
  const sendButton = document.getElementById('send-button');
  const statusDiv = document.getElementById('status');

  // SharedWorker로부터 메시지를 수신했을 때
  worker.port.onmessage = (event) => {
    try {
      const message = JSON.parse(event.data);
      console.log('[Client] 워커로부터 메시지 수신:', message);

      switch (message.type) {
        case 'status':
          statusDiv.textContent = `Status: ${message.message}`;
          break;
        case 'message':
          const logEntry = document.createElement('div');
          logEntry.textContent = `Server: ${message.data}`;
          messageLog.appendChild(logEntry);
          break;
        case 'error':
          const errorEntry = document.createElement('div');
          errorEntry.textContent = `Error: ${message.message}`;
          errorEntry.style.color = 'red';
          messageLog.appendChild(errorEntry);
          break;
      }
    } catch (e) {
      console.error('[Client] 워커로부터 받은 메시지 파싱 오류:', e);
    }
  };
  
  // SharedWorker에서 발생한 처리되지 않은 에러를 잡기 위해
  worker.onerror = (error) => {
    console.error('[Client] SharedWorker 에러 발생:', error);
    statusDiv.textContent = 'Status: Worker Error';
  };

  // 메시지 전송 버튼 클릭 이벤트
  sendButton.addEventListener('click', () => {
    const messageText = messageInput.value;
    if (messageText) {
      const message = {
        type: 'chat',
        payload: messageText,
      };
      
      // 워커에게 메시지 전송
      worker.port.postMessage(message);
      
      const logEntry = document.createElement('div');
      logEntry.textContent = `You: ${messageText}`;
      logEntry.style.fontStyle = 'italic';
      messageLog.appendChild(logEntry);
      
      messageInput.value = '';
    }
  });

  // 통신 포트 시작
  worker.port.start();

  console.log('[Client] SharedWorker 연결 설정 완료.');

} else {
  console.error('이 브라우저는 SharedWorker를 지원하지 않습니다.');
  document.body.innerHTML = '<h1>SharedWorker is not supported in this browser.</h1>';
}

위 코드를 실제 애플리케이션에 적용하면, 여러 탭을 열어도 서버와의 WebSocket 연결은 단 하나만 유지되며, 한 탭에서 보낸 메시지가 서버를 거쳐 다른 모든 탭에 실시간으로 반영되는 것을 확인할 수 있습니다. 이는 자원 사용량을 획기적으로 줄이고 애플리케이션의 확장성과 성능을 크게 향상시킵니다.

5. 심화 논의: 주의사항과 대안 전략

SharedWorker를 이용한 WebSocket 연결 공유는 매우 강력한 패턴이지만, 실제 프로덕션 환경에 적용하기 전에 몇 가지 중요한 사항들을 고려해야 합니다.

주요 주의사항

  1. 브라우저 호환성: SharedWorker의 가장 큰 제약은 브라우저 지원 범위입니다. 2023년 기준, 대부분의 최신 브라우저(Chrome, Firefox, Edge)는 SharedWorker를 지원하지만, Safari(데스크톱 및 iOS 모두)에서는 지원하지 않습니다. 따라서 애플리케이션의 주요 타겟 사용자가 Safari를 많이 사용한다면 이 아키텍처를 채택하기 어렵거나, 지원하지 않는 브라우저를 위한 대체(fallback) 메커니즘을 별도로 구현해야 합니다. 예를 들어, SharedWorker를 사용할 수 없는 환경에서는 각 탭이 개별 WebSocket 연결을 맺도록 하는 전통적인 방식으로 동작하게 할 수 있습니다. if (window.SharedWorker) { ... } else { ... } 와 같은 분기 처리가 필요합니다.
  2. 디버깅의 어려움: SharedWorker는 백그라운드에서 동작하기 때문에 일반적인 페이지 스크립트처럼 디버깅하기가 까다롭습니다. Chrome 개발자 도구의 경우, chrome://inspect/#workers 페이지를 통해 현재 실행 중인 워커 목록을 확인하고, 디버거를 연결하여 코드를 단계별로 실행하거나 콘솔 로그를 확인할 수 있습니다. Firefox 역시 비슷한 디버깅 도구를 제공합니다. 개발 초기 단계부터 이러한 디버깅 방법을 숙지하는 것이 중요합니다.
  3. 상태 동기화 문제: 새로운 탭이 열리면 SharedWorker에 연결되지만, 그 시점까지 다른 탭들이 서버와 주고받았던 애플리케이션의 현재 상태(예: 채팅방의 최근 메시지 목록)를 알지 못합니다. 이를 해결하기 위해 SharedWorker가 최신 상태의 일부를 캐싱하고 있다가, 새로운 클라이언트가 연결되면 즉시 해당 상태를 전송해주는 로직을 구현할 수 있습니다. 또는, 새로운 탭이 연결되었을 때 SharedWorker가 서버에 '초기 상태 요청' 메시지를 보내고, 서버가 해당 탭에만 필요한 데이터를 보내주도록 설계할 수도 있습니다.
  4. 보안 및 동일 출처 정책(Same-Origin Policy): SharedWorker는 동일한 출처(프로토콜, 호스트, 포트가 모두 같음)를 가진 문서들 사이에서만 공유될 수 있습니다. 이는 보안상 중요한 제약 조건이며, 다른 도메인의 페이지와 워커를 공유할 수는 없습니다.

대안 기술과의 비교

탭 간 통신을 구현하는 다른 기술도 존재하며, 각 기술의 장단점을 이해하고 상황에 맞는 최적의 솔루션을 선택하는 것이 중요합니다.

  • BroadcastChannel API: BroadcastChannel은 동일 출처의 모든 브라우징 컨텍스트에 메시지를 브로드캐스팅하는 간단한 API를 제공합니다. SharedWorker보다 사용법이 훨씬 간단하지만, 중앙 집중적인 로직이나 상태 관리가 불가능합니다. 모든 탭이 평등한 관계에서 서로에게 메시지를 보낼 뿐입니다. 따라서 각 탭이 여전히 개별 WebSocket 연결을 맺고, 서버로부터 받은 메시지를 BroadcastChannel을 통해 다른 탭에 전파하는 방식으로 사용할 수는 있지만, '단일 연결 공유'라는 근본적인 목표는 달성할 수 없습니다.
  • LocalStorage와 storage 이벤트: 한 탭에서 LocalStorage의 값을 변경하면, 동일 출처의 다른 탭들에서 storage 이벤트가 발생합니다. 이를 이용해 탭 간에 메시지를 주고받는 것처럼 흉내 낼 수 있습니다. 하지만 이는 본래의 용도와는 다른 편법적인 사용이며, 복잡한 데이터 구조를 전달하기 어렵고 성능상 비효율적일 수 있습니다.

결론적으로, 중앙에서 단일 리소스(WebSocket 연결)를 관리하고, 상태를 유지하며, 복잡한 로직을 수행해야 하는 요구사항이 있다면 SharedWorker가 가장 적합하고 강력한 솔루션입니다.

결론: 더 나은 실시간 웹을 향하여

우리는 다중 탭 환경에서 발생하는 실시간 통신의 비효율성 문제를 정의하고, SharedWorker라는 강력한 웹 기술을 통해 이를 해결하는 아키텍처를 설계하고 구현하는 전 과정을 살펴보았습니다. SharedWorker를 활용하여 여러 탭이 단 하나의 WebSocket 연결을 공유하도록 함으로써, 우리는 서버와 클라이언트 양측의 자원 사용을 최적화하고, 네트워크 트래픽을 줄이며, 애플리케이션의 전반적인 성능과 확장성을 크게 향상시킬 수 있습니다.

물론 브라우저 호환성과 같은 현실적인 제약도 존재하지만, 이 아키텍처가 제시하는 원칙과 패턴은 복잡한 현대 웹 애플리케이션을 구축하는 개발자에게 중요한 통찰을 제공합니다. 사용자의 경험이 점점 더 중요해지는 오늘날, 보이지 않는 곳에서의 효율성과 최적화는 눈에 보이는 화려한 기능만큼이나 중요합니다. SharedWorker와 WebSocket의 현명한 조합은, 더 빠르고, 더 안정적이며, 더 효율적인 실시간 웹의 미래를 만들어가는 핵심 기술 중 하나가 될 것입니다.

Optimizing Web Applications with a Shared WebSocket Connection

The modern web is defined by its dynamism and interactivity. Applications that deliver information in real-time—from collaborative document editors and live financial tickers to multiplayer games and instant messaging platforms—have become the standard. The foundational technology powering this immediacy is the WebSocket protocol. Unlike the request-response paradigm of HTTP, WebSocket provides a persistent, full-duplex communication channel between a client and a server, allowing data to be pushed in either direction at any time with minimal overhead. This capability is transformative for creating responsive and engaging user experiences.

However, this power comes with a responsibility to manage resources efficiently. A common and often overlooked issue arises in modern browsing habits: users frequently open multiple tabs or windows for the same web application. In a naive implementation, each of these browser contexts (tabs, windows) would establish its own independent WebSocket connection to the server. While this works functionally, it introduces significant inefficiencies that can degrade performance for both the client and the server. Each connection consumes memory, CPU cycles, and a valuable server-side socket. For an application with thousands of users, each with several tabs open, this can lead to millions of redundant connections, straining server infrastructure and increasing operational costs. This article explores a more sophisticated and resource-conscious architecture: sharing a single WebSocket connection across all browser tabs for a given user, using the capabilities of a SharedWorker.

The Hidden Cost of Redundant Connections

Before diving into the solution, it's crucial to fully appreciate the problem. Why is opening a new WebSocket for every tab so detrimental? The consequences can be broken down into server-side and client-side impacts.

Server-Side Strain

  • Connection Limits: Every operating system and web server has a finite limit on the number of concurrent open sockets it can handle. Each WebSocket connection consumes one of these slots. A single user opening five tabs might seem innocuous, but scale this to 10,000 concurrent users, and the server is suddenly burdened with 50,000 connections instead of a more manageable 10,000. This can lead to connection refusals for new users and requires more robust, and therefore more expensive, server hardware or load-balancing infrastructure.
  • Memory Overhead: Each connection is not free. The server must allocate memory buffers for each socket to handle incoming and outgoing data. Furthermore, application-level logic often involves maintaining session state, user data, and subscription information for each connection. Multiplying this per-connection memory footprint by the number of redundant tabs leads to a significant increase in the server's RAM usage.
  • CPU Consumption: Managing thousands of connections involves CPU overhead for I/O operations, processing heartbeats (pings/pongs) to keep connections alive, and handling message framing/unframing. While a single connection is lightweight, the cumulative effect of many can tax the server's processing power, leading to higher latency for all connected clients.

Client-Side Inefficiency

  • Increased Resource Usage: Just as on the server, each WebSocket connection on the client consumes memory and CPU cycles within the browser. This can lead to a sluggish user experience, especially on lower-powered devices like older laptops or mobile phones.
  • Battery Drain: For mobile users, every network activity consumes battery life. Maintaining multiple active network connections and processing duplicate broadcast messages across several tabs will drain a device's battery much faster than managing a single, shared connection.
  • Data Inconsistency and Redundancy: If the server broadcasts the same message to all connections for a particular user, the client machine ends up receiving and processing the same data multiple times across different tabs. This is not only a waste of bandwidth but can also lead to subtle synchronization issues, where one tab might display an update a few milliseconds before another, creating a jarring experience.

Clearly, architecting a solution that consolidates these connections into a single, shared pipeline is not just a micro-optimization; it is a critical step towards building a scalable, efficient, and robust real-time application.

SharedWorker: The Central Hub for Cross-Tab Communication

The key to solving this problem lies within a powerful, yet often underutilized, feature of the modern web platform: the SharedWorker. To understand a SharedWorker, it's helpful to first understand its more common sibling, the dedicated Web Worker.

A dedicated `Worker` is a JavaScript script that runs in a background thread, separate from the main UI thread. This allows you to offload computationally intensive tasks without freezing the user interface. However, a dedicated worker is tied to the specific script context that created it. If you close the tab, the worker is terminated. If you open a new tab, it gets its own, completely separate worker.

A `SharedWorker`, on the other hand, is designed for exactly our use case. A single SharedWorker instance is created for a given origin (domain) and is shared across all browser contexts (tabs, windows, iframes) from that same origin. The lifecycle is simple yet powerful:

  1. The first tab from a specific origin that instantiates the SharedWorker causes the browser to download, parse, and execute the worker script in a new background thread.
  2. Subsequent tabs from the same origin that try to instantiate the same worker script do not create a new instance. Instead, they simply establish a new communication channel to the existing worker instance.
  3. The SharedWorker instance remains alive as long as at least one browser context is connected to it. When the very last tab connected to the worker is closed, the worker is terminated.

Communication between the main application tabs and the SharedWorker happens through a `MessagePort` object. When a new tab connects, the worker's `onconnect` event handler fires, providing a unique `MessagePort` for that specific tab. The worker can then use this port to send and receive messages exclusively with that tab, while the tab uses its corresponding port to communicate with the worker. This architecture positions the SharedWorker as a perfect central proxy or a message broker for our WebSocket connection.

Architectural Design: A WebSocket Proxy in a SharedWorker

By leveraging a SharedWorker, we can design a clean and effective architecture. The SharedWorker will be solely responsible for establishing and maintaining the single WebSocket connection with the server. All client tabs will communicate with the SharedWorker, not directly with the WebSocket server.

The data flow looks like this:

  1. Initialization: The first tab opens and creates the SharedWorker. The worker's `onconnect` handler runs, sees it's the first connection, and initiates the WebSocket connection to the server.
  2. New Tab Connection: A second tab is opened. It connects to the existing SharedWorker. The worker's `onconnect` handler runs again, registers the new tab's `MessagePort` for communication, and informs the new tab of the current WebSocket connection status (e.g., "connected").
  3. Client-to-Server Message: A user in one of the tabs performs an action that needs to be sent to the server (e.g., sending a chat message). The tab's JavaScript sends a message to the SharedWorker via its `MessagePort`. The SharedWorker receives this message and forwards it through the single, shared WebSocket connection.
  4. Server-to-Client Message: The WebSocket server pushes a message (e.g., a new stock price, a message from another user). The SharedWorker is the sole recipient of this message. Upon receiving it, the worker iterates through its list of all connected tab ports and broadcasts the message to every single one of them.
  5. Tab Closure: A user closes one of the tabs. The connection port associated with that tab is closed. The SharedWorker should handle this to remove the port from its list of active clients. If this was the last tab, the worker itself will be terminated by the browser, which will also close the WebSocket connection.

This model elegantly solves our initial problems. The server only ever sees one connection per user session, regardless of the tab count. The client avoids redundant network traffic and processing, and all tabs are guaranteed to be in sync as they receive data from a single source of truth—the SharedWorker.

A Practical Implementation: Building the Shared Connection

Let's translate this architecture into functional code. We will need two main files: the worker script (`shared-socket-worker.js`) and the client-side script that interacts with it (`main.js`). We'll also define a simple, structured message protocol using JSON to make communication clear and extensible.

Step 1: The Shared Worker Script (`shared-socket-worker.js`)

This is the heart of our solution. This script will manage the WebSocket connection and the list of connected client tabs.


// shared-socket-worker.js

// Use a Set for efficient addition and removal of ports.
const connectedPorts = new Set();
let socket;
const WEBSOCKET_URL = 'wss://your-websocket-url.com/socket';

/**
 * Creates and configures the WebSocket connection.
 * This function is called only when the first client connects.
 */
function initializeSocket() {
    // Avoid creating a new socket if one already exists or is connecting.
    if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
        return;
    }

    socket = new WebSocket(WEBSOCKET_URL);

    socket.addEventListener('open', () => {
        // Notify all connected tabs that the connection is now open.
        broadcast({ type: 'WS_OPEN' });
    });

    socket.addEventListener('message', (event) => {
        // A message is received from the server. Broadcast it to all tabs.
        try {
            const messageData = JSON.parse(event.data);
            broadcast({ type: 'WS_MESSAGE', payload: messageData });
        } catch (error) {
            // If data is not JSON, send it as is.
            broadcast({ type: 'WS_MESSAGE', payload: event.data });
        }
    });

    socket.addEventListener('close', (event) => {
        // Notify tabs about the closure.
        broadcast({ type: 'WS_CLOSE', payload: { code: event.code, reason: event.reason } });
        socket = null; // Clear the socket reference.
    });

    socket.addEventListener('error', (event) => {
        // Notify tabs about an error.
        console.error('WebSocket error observed in SharedWorker:', event);
        broadcast({ type: 'WS_ERROR', payload: 'An error occurred with the WebSocket connection.' });
    });
}

/**
 * Broadcasts a message to all connected client ports.
 * @param {object} message - The message object to send.
 */
function broadcast(message) {
    const serializedMessage = JSON.stringify(message);
    for (const port of connectedPorts) {
        port.postMessage(serializedMessage);
    }
}

/**
 * The main entry point for the SharedWorker.
 * This event fires every time a new tab/client connects to this worker.
 */
self.onconnect = (event) => {
    const port = event.ports[0];
    connectedPorts.add(port);
    console.log(`New connection. Total clients: ${connectedPorts.size}`);

    // When a port receives a message, it's from a client tab.
    port.onmessage = (event) => {
        const message = event.data;

        // Ensure the socket exists and is open before trying to send.
        if (socket && socket.readyState === WebSocket.OPEN) {
            // We expect the client to send data that is ready for the server.
            socket.send(JSON.stringify(message.payload));
        } else {
            // Inform the client that the message could not be sent.
            port.postMessage(JSON.stringify({
                type: 'ERROR',
                payload: 'WebSocket is not connected. Message not sent.'
            }));
        }
    };

    // The 'start()' method is essential for the port to begin receiving messages.
    port.start();

    // If this is the first client connecting, initialize the WebSocket.
    if (connectedPorts.size === 1) {
        initializeSocket();
    } else if (socket && socket.readyState === WebSocket.OPEN) {
        // If the socket is already open, immediately notify the new tab.
        port.postMessage(JSON.stringify({ type: 'WS_OPEN' }));
    }

    // A robust implementation should handle when a tab is closed.
    // However, the 'onconnect' event doesn't provide a direct 'close' event for the port.
    // Client-side code must notify the worker before unload. Alternatively,
    // a more complex ping/pong mechanism between worker and tabs could detect dead ports.
    // For simplicity, we'll rely on the worker terminating when all tabs are gone.
};

Step 2: The Client-Side Wrapper (`SharedSocketClient.js`)

To make using this system easy and clean in our main application code, we can create a wrapper class that mimics the standard WebSocket API. This abstracts away the complexities of communicating with the SharedWorker.


// SharedSocketClient.js

export class SharedSocketClient {
    constructor(workerUrl) {
        if (!window.SharedWorker) {
            // Fallback for browsers that don't support SharedWorker (e.g., Safari).
            // This implementation would create a normal WebSocket per tab.
            console.warn("SharedWorker not supported. Falling back to standard WebSocket.");
            // For brevity, the fallback is not implemented here.
            throw new Error("SharedWorker not supported in this browser.");
        }

        this.worker = new SharedWorker(workerUrl);
        this.port = this.worker.port;

        // Custom event listeners
        this.listeners = {
            'open': [],
            'message': [],
            'error': [],
            'close': []
        };

        this.port.onmessage = this._handleMessage.bind(this);
        this.port.start();
    }

    _handleMessage(event) {
        try {
            const message = JSON.parse(event.data);
            switch (message.type) {
                case 'WS_OPEN':
                    this._dispatchEvent('open');
                    break;
                case 'WS_MESSAGE':
                    this._dispatchEvent('message', message.payload);
                    break;
                case 'WS_CLOSE':
                    this._dispatchEvent('close', message.payload);
                    break;
                case 'WS_ERROR':
                case 'ERROR':
                    this._dispatchEvent('error', message.payload);
                    break;
            }
        } catch (err) {
            console.error('Failed to parse message from SharedWorker:', event.data, err);
        }
    }

    _dispatchEvent(eventName, data) {
        if (this.listeners[eventName]) {
            this.listeners[eventName].forEach(callback => callback(data));
        }
    }

    send(data) {
        // Send data in the structured format the worker expects.
        this.port.postMessage({
            type: 'SEND_MESSAGE',
            payload: data
        });
    }

    addEventListener(eventName, callback) {
        if (this.listeners[eventName]) {
            this.listeners[eventName].push(callback);
        }
    }

    removeEventListener(eventName, callback) {
        if (this.listeners[eventName]) {
            this.listeners[eventName] = this.listeners[eventName].filter(cb => cb !== callback);
        }
    }

    // You can add a close() method to explicitly disconnect a tab's port,
    // although browser closing handles termination automatically.
    close() {
        this.port.close();
    }
}

Step 3: Using the Client in the Main Application (`main.js`)

Now, using our shared WebSocket connection is as simple as using the standard WebSocket API, thanks to our wrapper.


// main.js
import { SharedSocketClient } from './SharedSocketClient.js';

document.addEventListener('DOMContentLoaded', () => {
    try {
        const sharedSocket = new SharedSocketClient('shared-socket-worker.js');

        sharedSocket.addEventListener('open', () => {
            console.log('Shared WebSocket connection is open.');
            // Now you can send messages.
            sharedSocket.send({ action: 'subscribe', channel: 'news' });
        });

        sharedSocket.addEventListener('message', (payload) => {
            console.log('Message received from server via worker:', payload);
            // Update the UI with the new data.
            const display = document.getElementById('messages');
            const item = document.createElement('li');
            item.textContent = JSON.stringify(payload);
            display.appendChild(item);
        });

        sharedSocket.addEventListener('close', (event) => {
            console.log(`Shared WebSocket connection closed: Code=${event.code}, Reason=${event.reason}`);
        });

        sharedSocket.addEventListener('error', (error) => {
            console.error('An error occurred:', error);
        });

        // Example: sending a message on button click
        document.getElementById('sendButton').addEventListener('click', () => {
            const input = document.getElementById('messageInput');
            if (input.value) {
                sharedSocket.send({ text: input.value, timestamp: new Date().toISOString() });
                input.value = '';
            }
        });

    } catch (error) {
        // This will catch the error thrown if SharedWorker is not supported.
        console.error(error.message);
        // Here you would initialize your fallback WebSocket implementation.
    }
});

Advanced Considerations and Best Practices

While the above implementation provides a solid foundation, a production-grade system requires attention to several edge cases and optimizations.

Browser Compatibility and Fallbacks

The most significant limitation of `SharedWorker` is its lack of universal browser support. As of late 2023, Safari (both desktop and iOS) does not support it. Therefore, any application using this pattern must include a fallback mechanism. A simple approach is to check for the existence of `window.SharedWorker`. If it's undefined, the application should fall back to instantiating a standard `WebSocket` object for that tab. The wrapper class we designed is the perfect place to implement this conditional logic, presenting a consistent API to the rest of the application regardless of the underlying mechanism.

Debugging SharedWorkers

Debugging a script running in a separate, hidden thread can be tricky. Fortunately, modern browsers provide tools for this. In Chrome, you can navigate to `chrome://inspect/#workers` to see a list of running shared workers, inspect their console logs, and even set breakpoints in the worker script. Firefox provides similar capabilities in its Developer Tools under the "Workers" section. Familiarizing yourself with these tools is essential for development.

Graceful Disconnection and Connection Management

The SharedWorker lives as long as one tab is connected. In our simple example, the worker has no way of knowing a tab has closed until the browser terminates the entire worker process after the last tab is gone. A more robust implementation might involve the client-side code sending a "disconnecting" message to the worker in a `beforeunload` event handler. This would allow the worker to clean up the port from its `connectedPorts` set immediately. This also enables more advanced logic, such as closing the WebSocket connection if no users are "active" for a certain period, even if a tab is left open in the background.

State Synchronization for New Tabs

When a new tab connects to an already-active worker, it might have missed important initial state information. For instance, in a chat application, the user might already be logged in and subscribed to certain channels. The worker should be designed to send the current state (e.g., `__{ state: 'CONNECTED', subscriptions: ['news', 'sports'] }__`) to a newly connected port immediately, ensuring the new tab's UI can render correctly without waiting for the next broadcast message.

Conclusion: A Scalable Architecture for the Real-Time Web

By moving WebSocket connection management from individual tabs into a centralized SharedWorker, we fundamentally change the resource profile of a real-time web application. This architectural pattern transforms a potential performance bottleneck into a streamlined and efficient communication channel. The benefits are clear and compelling: a drastic reduction in server load, lower memory and CPU consumption on the client, extended battery life for mobile users, and a more consistent user experience across all open tabs.

While this approach introduces a layer of abstraction and requires careful handling of browser compatibility and edge cases, the investment in a more sophisticated client-side architecture pays significant dividends in scalability and performance. For any modern web application that relies on WebSockets and expects users to engage through multiple browser tabs, sharing a single connection via a SharedWorker is not just an optimization—it is a strategic choice for building a robust, efficient, and future-proof platform.

SharedWorkerによるWebSocket接続の最適化と実践

現代のWebアプリケーションにおいて、リアルタイム通信は不可欠な要素となっています。チャットアプリケーション、ライブ通知、共同編集ツール、金融データのストリーミングなど、その用途は多岐にわたります。このリアルタイム性を実現するための主要技術がWebSocketです。しかし、複数のブラウザータブやウィンドウで同じアプリケーションを開いた場合、それぞれのタブが独立してWebSocket接続を確立すると、クライアントとサーバーの両方に多大なリソース負荷をかけるという課題が生じます。この記事では、この問題を解決するための強力なソリューションとしてSharedWorkerに焦点を当て、その仕組みから実践的な実装、高度な考慮事項までを詳細に解説します。

第1章: WebSocket通信の基礎と課題

SharedWorkerを用いた最適化を理解する前に、まずWebSocketそのものと、それがなぜリソース消費の課題を抱えるのかを深く理解する必要があります。

1.1. HTTPの限界とWebSocketの登場

伝統的なWebの通信プロトコルであるHTTPは、クライアントがリクエストを送信し、サーバーがレスポンスを返すという「リクエスト-レスポンス」モデルに基づいています。このモデルは静的なコンテンツの取得には非常に効率的ですが、サーバー側から自発的にデータを送信する必要があるリアルタイムアプリケーションには不向きでした。

この問題を回避するため、以下のような技術が考案されました。

  • ポーリング (Polling): クライアントが一定間隔でサーバーに「新しいデータはありますか?」と問い合わせ続ける方式。シンプルですが、データがない場合でもリクエストが発生するため、無駄な通信が多く、遅延も大きくなります。
  • ロングポーリング (Long Polling): クライアントからのリクエストに対し、サーバーは新しいデータが発生するまでレスポンスを保留します。データが発生した時点でレスポンスを返し、クライアントは即座に次のリクエストを送信します。ポーリングよりは効率的ですが、依然としてHTTPリクエストのオーバーヘッドが伴い、タイムアウト処理なども複雑になります。

これらの手法はHTTPの制約内での場当たり的な解決策であり、本質的な双方向通信には不十分でした。そこで2011年にIETFによってRFC 6455として標準化されたのがWebSocketプロトコルです。

1.2. WebSocketの仕組み

WebSocketは、単一のTCP接続上でクライアントとサーバー間の全二重(full-duplex)通信を可能にするプロトコルです。これは、クライアントとサーバーがいつでも互いにメッセージを送信できることを意味します。

ハンドシェイクプロセス

WebSocket接続は、まずHTTP/1.1互換のハンドシェイクから始まります。クライアントは、以下のような特別なヘッダーを含むHTTPリクエストをサーバーに送信します。


GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
  • Upgrade: websocket: プロトコルをWebSocketに切り替えたいという意思表示です。
  • Connection: Upgrade: 接続方法のアップグレードを要求します。
  • Sec-WebSocket-Key: サーバーが正しくWebSocketリクエストを解釈したことをクライアントが確認するためのランダムなキーです。

サーバーがWebSocketをサポートしている場合、ステータスコード101 Switching Protocolsとともに、Sec-WebSocket-Keyを元に生成したSec-WebSocket-Acceptヘッダーを含むレスポンスを返します。このハンドシェイクが成功すると、既存のTCP接続はHTTPプロトコルからWebSocketプロトコルへと「アップグレード」され、以降はこの接続上で高速な双方向通信が行われます。

1.3. 複数タブが引き起こすリソース枯渇問題

WebSocketは非常に効率的ですが、その利便性ゆえに新たな問題が浮上しました。ユーザーが同じWebアプリケーションを複数のタブで開いた場合を想像してみてください。例えば、GmailやSlackのようなアプリケーションです。デフォルトの実装では、各タブがそれぞれ独立したWebSocket接続をサーバーと確立します。

仮に一人のユーザーが5つのタブを開いていると、そのユーザーだけで5つのWebSocket接続が作成されます。これが1,000人のユーザーにスケールすると、サーバーは5,000もの同時接続を処理しなければなりません。

この状況は、以下のような深刻な問題を引き起こします。

  • サーバーリソースの圧迫: WebSocket接続はステートフルであり、サーバーは各接続を維持するためにメモリやファイルディスクリプタといったリソースを消費します。接続数が増えれば増えるほど、サーバーの負荷は増大し、最終的には新規接続を受け付けられなくなったり、パフォーマンスが著しく低下したりする可能性があります。
  • クライアントリソースの消費: 接続を管理するのはサーバーだけではありません。クライアント(ブラウザ)側でも、接続ごとにメモリやCPUリソースが消費されます。特にモバイルデバイスでは、バッテリー消費の増加にも繋がります。
  • APIレートリミット: サービスによっては、IPアドレスごとやユーザーアカウントごとにAPIの呼び出し回数や同時接続数に制限を設けている場合があります。複数のタブで無駄に接続を増やすことは、これらの制限に抵触するリスクを高めます。

この「1タブ1接続」モデルは、明らかに非効率です。理想的なのは、同一オリジン(同一ドメイン)のすべてのタブで単一のWebSocket接続を共有し、リソース消費を最小限に抑えることです。この課題を解決する鍵となるのが、次章で解説するSharedWorkerです。

第2章: SharedWorkerによる接続の集中管理

Web Workerは、メインスレッド(UIスレッド)とは別のバックグラウンドスレッドでJavaScriptを実行するための仕組みです。これにより、重い処理を行ってもUIが固まるのを防ぐことができます。Web Workerにはいくつかの種類がありますが、タブ間のリソース共有という文脈で特に重要なのがSharedWorkerです。

2.1. Web Workerの種類

  • Dedicated Worker: 最も一般的なワーカーです。生成されたスクリプト(タブ)専用のワーカーであり、他のタブと共有することはできません。
  • Service Worker: Webアプリケーション、ブラウザ、そして(もし利用可能なら)ネットワークの間に介在するイベント駆動型のワーカーです。プッシュ通知やバックグラウンド同期、リソースのキャッシング(オフライン対応)などに用いられます。オリジン全体で一つだけ実行されますが、ライフサイクルが複雑で、WebSocket接続のような永続的な接続の管理には必ずしも最適ではありません。
  • SharedWorker: 本稿の主役です。SharedWorkerは、同一オリジンから読み込まれた複数のブラウジングコンテキスト(タブ、ウィンドウ、iframeなど)で共有できるという最大の特徴を持ちます。これにより、複数のタブで共有したいリソースや状態を一元管理するのに理想的な環境を提供します。

2.2. SharedWorkerのライフサイクルと通信モデル

SharedWorkerのライフサイクルは独特です。SharedWorkerへの最初の接続が試みられたときに生成され、そのワーカーに接続している最後のタブが閉じるまで生存し続けます。

メインスレッド(各タブ)とSharedWorkerとの通信は、`MessagePort`オブジェクトを介して行われます。 1. タブが `new SharedWorker('worker.js')` を実行すると、ブラウザは指定されたオリジンで`worker.js`のインスタンスが既に存在するか確認します。 2. 存在しない場合は、新しいSharedWorkerを生成し、そのグローバルスコープで `worker.js` を実行します。 3. 存在する場合は、既存のインスタンスに接続します。 4. SharedWorker側では、新しいタブが接続するたびに `connect` イベントが発火します。このイベントオブジェクトには、接続してきたタブと通信するための`port`(`MessagePort`のインスタンス)が含まれています。 5. タブとワーカーは、それぞれの`port`オブジェクトの `postMessage()` メソッドと `onmessage` イベントハンドラを使って双方向にメッセージをやり取りします。

この仕組みを利用すれば、SharedWorkerを「WebSocket接続マネージャー」として機能させることができます。WebSocketの接続、切断、メッセージの送受信といった全てのロジックをSharedWorker内に集約し、各タブはSharedWorkerとのみ通信すればよくなります。これにより、サーバーとの間に確立されるWebSocket接続は常に一つだけになります。

第3章: 実践的実装: WebSocket接続共有マネージャーの構築

それでは、実際にSharedWorkerを使ってWebSocket接続を共有するコードを構築していきましょう。ここでは、接続管理、メッセージのブロードキャスト、エラーハンドリングなど、より実践的な側面を考慮した実装を示します。

3.1. プロジェクトの構成

プロジェクトは、以下の3つのファイルで構成されるとします。

  • index.html: アプリケーションのUIを持つHTMLファイル。
  • main.js: index.htmlから読み込まれるクライアントサイドのJavaScript。SharedWorkerとの通信を担当します。
  • shared-worker.js: SharedWorker本体のコード。WebSocket接続の管理ロジックをここに実装します。

3.2. Step 1: SharedWorkerの実装 (shared-worker.js)

SharedWorkerは、このアーキテクチャの心臓部です。接続のライフサイクル管理、全タブへのメッセージブロードキャスト、接続状態の管理などを担当します。


// shared-worker.js

// 接続されているすべてのタブのポートを管理する配列
const ports = [];
let socket = null;
const WEBSOCKET_URL = 'wss://your-websocket-url'; // 実際のURLに置き換えてください

// 新しいタブが接続してきたときの処理
self.onconnect = (event) => {
    const port = event.ports[0];
    ports.push(port);

    // WebSocket接続がまだ確立されていない場合、最初の接続時に確立する
    if (!socket || socket.readyState === WebSocket.CLOSED) {
        connectWebSocket();
    }

    // 接続が既に確立されている場合は、現在の状態を新しいタブに通知する
    if (socket && socket.readyState === WebSocket.OPEN) {
        port.postMessage({ type: 'status', message: 'WebSocket connection already established.' });
    }

    // タブからメッセージを受信したときの処理
    port.onmessage = (e) => {
        const message = e.data;

        // タブからのメッセージをWebSocketサーバーに送信する
        if (socket && socket.readyState === WebSocket.OPEN) {
            socket.send(JSON.stringify(message));
        } else {
            // 接続が確立されていない場合はエラーメッセージを返す
            port.postMessage({ type: 'error', message: 'WebSocket is not connected.' });
        }
    };
    
    // タブが切断されたとき(タブが閉じられたときなど)にポートリストから削除する
    // ただし、明示的な切断メッセージがないと検知は難しい。
    // そのため、クライアント側からの 'unload' イベント通知が有効。
    // ここではシンプルに、メッセージングを開始するために port.start() を呼び出す。
    port.start();
};

function connectWebSocket() {
    // 既に接続中、または接続済みの場合は何もしない
    if (socket && (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN)) {
        return;
    }
    
    socket = new WebSocket(WEBSOCKET_URL);

    socket.onopen = () => {
        console.log('SharedWorker: WebSocket connection opened.');
        // 接続が確立されたことを全タブに通知
        broadcast({ type: 'status', message: 'WebSocket connection opened.' });
    };

    socket.onmessage = (event) => {
        console.log('SharedWorker: Message from server: ', event.data);
        // サーバーから受信したメッセージを全タブにブロードキャスト
        try {
            const parsedData = JSON.parse(event.data);
            broadcast({ type: 'message', data: parsedData });
        } catch (error) {
            broadcast({ type: 'message', data: event.data });
        }
    };

    socket.onclose = (event) => {
        console.log('SharedWorker: WebSocket connection closed.', event);
        socket = null; // 接続をリセット
        // 接続が閉じたことを全タブに通知
        broadcast({ type: 'status', message: 'WebSocket connection closed.' });
    };

    socket.onerror = (error) => {
        console.error('SharedWorker: WebSocket error: ', error);
        // エラーが発生したことを全タブに通知
        broadcast({ type: 'error', message: 'An error occurred with the WebSocket connection.' });
    };
}

// 接続されている全タブにメッセージを送信する関数
function broadcast(message) {
    ports.forEach(port => {
        port.postMessage(message);
    });
}

3.3. Step 2: クライアントサイドの実装 (main.js)

クライアントサイドのスクリプトは、UIの操作とSharedWorkerとの通信に専念します。WebSocketの複雑なロジックは一切含みません。


// main.js

document.addEventListener('DOMContentLoaded', () => {
    const messageInput = document.getElementById('messageInput');
    const sendButton = document.getElementById('sendButton');
    const messagesContainer = document.getElementById('messages');
    const statusDiv = document.getElementById('status');

    // SharedWorkerがブラウザでサポートされているかチェック
    if (window.SharedWorker) {
        const worker = new SharedWorker('shared-worker.js');

        // Workerからメッセージを受信したときの処理
        worker.port.onmessage = (event) => {
            const { type, message, data } = event.data;

            switch (type) {
                case 'status':
                    statusDiv.textContent = `Status: ${message}`;
                    console.log(`Status update: ${message}`);
                    break;
                case 'message':
                    const messageElement = document.createElement('div');
                    messageElement.textContent = `Received: ${JSON.stringify(data)}`;
                    messagesContainer.appendChild(messageElement);
                    break;
                case 'error':
                    statusDiv.textContent = `Error: ${message}`;
                    console.error(`Worker error: ${message}`);
                    break;
            }
        };

        // メッセージングポートを開始
        worker.port.start();

        // 送信ボタンがクリックされたときの処理
        sendButton.addEventListener('click', () => {
            const messageText = messageInput.value;
            if (messageText) {
                // Workerにメッセージを送信
                worker.port.postMessage({ action: 'sendMessage', payload: messageText });
                messageInput.value = '';
            }
        });

    } else {
        // SharedWorkerがサポートされていない場合のフォールバック処理
        statusDiv.textContent = 'Your browser does not support SharedWorker. Each tab will have its own connection.';
        // ここに、SharedWorkerなしでWebSocketを直接利用するコードを記述することもできる
    }
});

この実装により、複数のタブを開いてもWebSocket接続はSharedWorkerによってただ一つだけ確立・維持されます。サーバーからのメッセージはワーカーが一括で受信し、接続されている全てのタブに効率的に配信されます。これにより、当初の課題であったリソース消費の問題が劇的に改善されます。

第4章: 高度な考慮事項とベストプラクティス

基本的な実装はできましたが、堅牢なアプリケーションを構築するためには、さらにいくつかの点を考慮する必要があります。

4.1. 接続のライフサイクル管理とクリーンアップ

SharedWorkerは最後のタブが閉じるまで生存しますが、タブがクラッシュした場合など、切断を正常に検知できないことがあります。これにより、`ports`配列に無効なポートが残り続ける可能性があります。 より堅牢な実装では、クライアント側が `window.addEventListener('beforeunload', ...)` を使用して、タブが閉じる直前にSharedWorkerに切断メッセージを送信することが推奨されます。 SharedWorker側では、この切断メッセージを受け取ったら`ports`配列から該当のポートを削除し、もし`ports`配列が空になったらWebSocket接続を閉じる、というロジックを追加します。


// main.js (追加)
window.addEventListener('beforeunload', () => {
    // タブが閉じることをWorkerに通知
    worker.port.postMessage({ type: 'disconnect' });
});

// shared-worker.js (修正)
// onconnect 内の onmessage
port.onmessage = (e) => {
    if (e.data.type === 'disconnect') {
        // 切断メッセージを受け取ったらポートをリストから削除
        const index = ports.indexOf(port);
        if (index > -1) {
            ports.splice(index, 1);
        }
        // 接続しているタブがなくなったらWebSocketを閉じる
        if (ports.length === 0) {
            if (socket) {
                socket.close();
                socket = null;
            }
        }
    } else {
        // ... 通常のメッセージ処理 ...
    }
};

4.2. 再接続戦略

ネットワークの問題でWebSocket接続が意図せず切断されることはよくあります。このような場合、SharedWorker内で自動再接続ロジックを実装することが重要です。単純な再試行ではなく、「エクスポネンシャルバックオフ(Exponential Backoff)」のような戦略を用いることで、サーバーに過度な負荷をかけることなくスマートに再接続を試みることができます。


// shared-worker.js (socket.onclose の修正)
socket.onclose = (event) => {
    console.log('SharedWorker: WebSocket connection closed.', event);
    socket = null;
    broadcast({ type: 'status', message: 'WebSocket connection closed. Attempting to reconnect...' });
    
    // エクスポネンシャルバックオフで再接続を試みる
    setTimeout(connectWebSocket, 3000); // 簡略化のため、ここでは3秒後に再試行
};

4.3. ブラウザ互換性とフォールバック

SharedWorkerは主要なモダンブラウザ(Chrome, Firefox, Edge)でサポートされていますが、Safari(デスクトップおよびiOS)ではサポートされていません(2023年時点)。そのため、`if (window.SharedWorker)` のような機能検出は必須です。 SharedWorkerが利用できないブラウザ向けには、フォールバック戦略を用意する必要があります。

  • 戦略1: 何もしない: サポートされていないブラウザでは、従来通り各タブが個別のWebSocket接続を持つことを許容する。最もシンプルな方法です。
  • 戦略2: Leader Election: `BroadcastChannel` や `localStorage` を利用して、開いているタブの中から一つを「リーダー」として選出します。リーダータブのみがWebSocket接続を確立し、他のタブは`BroadcastChannel`などを通じてリーダーと通信します。実装は複雑になりますが、接続共有を実現できます。

4.4. デバッグ

SharedWorkerはバックグラウンドで動作するため、デバッグが少し特殊です。 - Chrome: アドレスバーに `chrome://inspect/#workers` と入力すると、現在実行中のワーカーの一覧が表示され、そこから開発者ツールを開いてコンソールログの確認やブレークポイントの設定ができます。 - Firefox: アドレスバーに `about:debugging#/runtime/this-firefox` と入力すると、同様にSharedWorkerのデバッグが可能です。

結論: より効率的でスケーラブルなWebへ

WebSocketは現代のWebにリアルタイム性をもたらす強力な技術ですが、その力を最大限に引き出すためには、リソースの効率的な利用が不可欠です。複数のタブで無駄な接続を増やすことは、アプリケーションのパフォーマンスとスケーラビリティを著しく損ないます。

SharedWorkerを活用することで、WebSocket接続をオリジン全体で一つに集約し、この問題を根本的に解決できます。クライアントとサーバーの両方のリソースを節約し、より安定したユーザーエクスペリエンスを提供することが可能になります。実装にはライフサイクル管理やエラーハンドリング、ブラウザ互換性といった考慮点がありますが、それらを乗り越えることで得られるメリットは計り知れません。リアルタイムWebアプリケーションを開発する際には、このSharedWorkerを用いた接続共有アーキテクチャをぜひ検討してください。