3. SharedWorker로 멀티탭 소켓을 하나로 묶기
작성일:2025.05.26|조회수:1
같은 서비스를 10개의 탭으로 열었을 때 서버에 소켓 연결도 10개가 생긴다면, 문제는 실시간 기능 자체가 아니라 브라우저 컨텍스트마다 연결을 새로 만드는 구조에 있을 수 있다. 채팅, 알림, 협업 상태처럼 페이지가 열려 있는 동안 유지되는 연결은 탭 수만큼 늘어날 때 서버와 클라이언트 양쪽에 부담을 만든다. SharedWorker는 같은 origin의 여러 탭과 iframe이 하나의 Worker에 연결될 수 있게 하므로, 이 문제를 브라우저 안에서 풀 수 있는 선택지가 된다.
Dedicated Worker가 한 화면의 무거운 계산을 분리하는 도구라면, SharedWorker는 여러 브라우징 컨텍스트가 공유하는 백그라운드 실행 지점에 가깝다. 원문의 Socket.IO 예제는 이 차이를 잘 보여준다. 각 탭이 직접 소켓을 만들지 않고 SharedWorker 안에 하나의 소켓을 유지한 뒤, 각 탭은 MessagePort를 통해 상태와 이벤트를 주고받는다.
SharedWorker는 포트를 통해 연결된다
SharedWorker를 생성하면 메인 스레드는 Worker 인스턴스 자체와 직접 메시지를 주고받는 대신 worker.port를 사용한다. Worker 내부에서는 onconnect 이벤트가 호출되고, 이 이벤트에 포함된 MessagePort를 통해 각 클라이언트와 통신한다. Dedicated Worker의 postMessage가 1:1 관계처럼 느껴진다면, SharedWorker는 여러 포트를 받아 관리하는 구조다.
이 구조는 같은 origin이라는 조건을 갖는다. 프로토콜, 호스트, 포트가 맞지 않으면 같은 SharedWorker를 공유할 수 없다. 또한 브라우저 지원 수준과 사생활 보호 모드의 동작 차이를 확인해야 한다. 그래서 SharedWorker는 도입 전에 feature detection과 fallback 전략을 함께 설계해야 한다.
아래 코드는 클라이언트 쪽에서 SharedWorker를 만들고 포트를 시작하는 최소 형태다. addEventListener로 메시지를 받을 때는 port.start()를 호출해 포트 연결을 명시적으로 열어둔다.
const sharedWorker = new SharedWorker(
new URL('./socket.worker.ts', import.meta.url),
{ type: 'module', name: 'shared-socket' },
);
sharedWorker.port.addEventListener('message', (event) => {
console.log('message from shared worker', event.data);
});
sharedWorker.port.start();
sharedWorker.port.postMessage({ command: 'get_socket_status' });이 코드도 Dedicated Worker와 마찬가지로 new URL(..., import.meta.url) 패턴을 사용한다. Next.js/Turbopack 환경에서는 Worker 파일이 별도 entrypoint로 다뤄져야 하므로, SharedWorker에서도 정적인 URL 형태를 유지하는 것이 중요하다. 번들러 지원은 버전과 설정의 영향을 받을 수 있으므로, 빌드와 배포 환경에서 실제 Worker 파일이 어떤 URL로 제공되는지 확인해야 한다.
소켓은 하나만 만들고 이벤트는 브로드캐스트한다
각 탭이 Socket.IO 클라이언트를 직접 만들면 서버는 탭 수만큼 연결을 본다. 사용자가 같은 앱을 여러 탭에 열어두는 습관이 있다면 연결 수는 사용자의 수보다 훨씬 커질 수 있다. 인증 토큰 갱신, 재연결, 서버 이벤트 처리도 탭마다 반복된다. SharedWorker 안에 소켓을 하나만 만들면 이 반복을 줄이고, 탭들은 Worker가 중계하는 상태만 받는다.
Worker 내부에서는 연결된 포트를 저장하고, 소켓 상태가 바뀔 때 모든 포트에 메시지를 보낸다. 원문은 WeakRef<MessagePort>로 포트 참조를 관리했는데, 이 방식은 끊어진 포트를 정리할 여지를 준다. 다만 WeakRef만으로 모든 종료 상황을 통제할 수 있다고 보면 안 된다. 탭이 정상적으로 닫힐 때 보내는 client_disconnecting 메시지와, 메시지 전송 실패 시 포트를 제거하는 방어 로직을 함께 둬야 한다.
아래 Worker 코드는 포트를 등록하고 전체 클라이언트에 브로드캐스트하는 구조를 보여준다. 실제 Socket.IO 연결 코드는 생략하고, 포트 생명주기와 메시지 전달의 모양에 집중한다.
const connectedPorts = new Set<MessagePort>();
function broadcastToClients(message: unknown) {
for (const port of connectedPorts) {
try {
port.postMessage(message);
} catch {
connectedPorts.delete(port);
}
}
}
self.addEventListener('connect', (event: Event) => {
const connectEvent = event as MessageEvent;
const port = connectEvent.ports[0];
if (!port) {
return;
}
connectedPorts.add(port);
port.start();
port.postMessage({ type: 'worker_connected' });
port.addEventListener('message', (messageEvent) => {
if (messageEvent.data?.command === 'client_disconnecting') {
connectedPorts.delete(port);
port.close();
return;
}
if (messageEvent.data?.command === 'get_socket_status') {
port.postMessage({ type: 'socket_status', status: 'connected' });
}
});
});브로드캐스트 함수의 catch 블록은 실패한 포트를 제거하기 위해 존재한다. 여기서 오류를 조용히 삼키는 구조로 끝내면 운영 중 문제를 추적하기 어려우므로, 실제 코드에서는 로그나 진단 메시지를 남기는 편이 낫다. 포트 관리는 SharedWorker 패턴의 핵심이다. 연결된 탭이 몇 개인지, 어떤 탭이 종료되었는지, 어떤 메시지가 실패했는지 알 수 있어야 소켓 하나로 묶는 이점이 유지된다.
React 훅은 통신 계층을 화면에서 분리한다
SharedWorker를 사용하는 컴포넌트가 매번 포트 이벤트를 직접 다루면 화면 코드가 통신 프로토콜에 묶인다. 그래서 원문의 useSharedSocketIO처럼 Worker 연결 상태, 소켓 상태, 마지막 서버 이벤트, 오류 메시지, 전송 함수를 하나의 훅으로 감싸는 구조가 좋다. 훅은 Worker와 포트의 생명주기를 관리하고, 컴포넌트는 현재 상태를 읽고 사용자 액션을 함수로 전달한다.
이 훅에서도 브라우저 경계는 중요하다. SharedWorker가 없는 환경에서는 오류 상태를 만들거나 fallback 경로로 넘어가야 한다. 또한 페이지가 닫히기 전에 Worker에 client_disconnecting을 보내면 정상 종료 경로에서 포트를 정리할 수 있다. 비정상 종료까지 완전히 제어할 수는 없으므로 Worker 내부 방어 로직도 함께 남겨야 한다.
아래 훅은 SharedWorker 포트를 React 상태와 연결하는 축약된 형태다. 실제 서비스에서는 이벤트 이름과 payload 타입을 더 좁히고, 인증 토큰 갱신이나 재연결 상태를 별도 메시지로 모델링하는 편이 좋다.
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
type SocketStatus = 'initializing' | 'connected' | 'disconnected' | 'error';
export function useSharedSocket() {
const workerRef = useRef<SharedWorker | null>(null);
const [workerConnected, setWorkerConnected] = useState(false);
const [socketStatus, setSocketStatus] = useState<SocketStatus>('initializing');
const [lastEvent, setLastEvent] = useState<unknown>(null);
const sendSocketEvent = useCallback((eventName: string, data: unknown) => {
workerRef.current?.port.postMessage({
command: 'emit_socket_event',
eventName,
data,
});
}, []);
useEffect(() => {
if (typeof SharedWorker === 'undefined') {
setSocketStatus('error');
return;
}
const worker = new SharedWorker(
new URL('./socket.worker.ts', import.meta.url),
{ type: 'module', name: 'shared-socket' },
);
workerRef.current = worker;
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'worker_connected') {
setWorkerConnected(true);
}
if (event.data?.type === 'socket_status') {
setSocketStatus(event.data.status);
}
if (event.data?.type === 'socket_event') {
setLastEvent(event.data);
}
};
worker.port.addEventListener('message', handleMessage);
worker.port.start();
worker.port.postMessage({ command: 'get_socket_status' });
const handleBeforeUnload = () => {
worker.port.postMessage({ command: 'client_disconnecting' });
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
worker.port.removeEventListener('message', handleMessage);
window.removeEventListener('beforeunload', handleBeforeUnload);
worker.port.postMessage({ command: 'client_disconnecting' });
workerRef.current = null;
};
}, []);
return { workerConnected, socketStatus, lastEvent, sendSocketEvent };
}이 훅에서 컴포넌트가 알아야 하는 것은 Worker가 연결됐는지, 소켓 상태가 무엇인지, 서버 이벤트가 도착했는지 정도다. 포트 시작, 메시지 구독, 종료 알림은 훅 내부에 숨겨져 있다. 이런 분리가 있어야 SharedWorker를 쓰는 화면이 늘어나도 통신 규칙을 한곳에서 바꿀 수 있다.
지원하지 않는 환경을 먼저 정한다
SharedWorker는 Worker API 중에서도 환경 차이를 더 신경 써야 하는 편이다. 같은 origin 조건, 브라우저 지원, 사생활 보호 모드, 모바일 브라우저 차이, 번들러의 SharedWorker 처리 여부가 모두 영향을 줄 수 있다. 따라서 “지원하지 않으면 어떻게 할 것인가”를 구현 말미가 아니라 설계 초기에 정해야 한다.
fallback은 서비스 성격에 따라 달라진다. 실시간 연결 수가 조금 늘어도 괜찮다면 탭별 Socket.IO 연결로 되돌아갈 수 있다. 탭 간 상태 동기화가 중요하지만 소켓 공유가 필수는 아니라면 BroadcastChannel을 보조 수단으로 둘 수 있다. 연결 수 제한이 엄격한 서비스라면 SharedWorker 미지원 환경에서 일부 실시간 기능을 제한하고 사용자에게 상태를 보여주는 선택도 가능하다.
Next.js 배포 환경에서는 CDN과 assetPrefix도 확인해야 한다. Worker 스크립트가 페이지와 다른 origin에서 로드되면 브라우저가 보안 오류를 낼 수 있다. Turbopack 쪽에서도 Worker asset prefix를 다루는 변경이 이어지고 있으므로, 로컬 개발에서 동작했다는 사실만으로 배포 환경까지 확정하면 안 된다. 실제 배포 URL에서 Worker 파일이 같은 origin으로 제공되는지 확인해야 한다.
디버깅은 연결 수와 메시지 흐름을 같이 본다
SharedWorker 디버깅은 일반 컴포넌트 디버깅과 다르다. Chrome에서는 chrome://inspect/#workers 같은 경로에서 Worker 로그를 확인할 수 있고, DevTools의 source map 설정이 맞아야 Worker 파일의 원본 위치를 따라가기 수월하다. Next.js/Turbopack 문제를 의심할 때는 Worker entrypoint가 실제 파일로 생성되는지, module worker 옵션이 유지되는지, 배포된 URL이 같은 origin인지 확인해야 한다.
운영 체크리스트는 기능 성공 여부보다 더 넓어야 한다. 탭을 1개에서 10개로 늘렸을 때 서버 연결 수가 그대로인지, 탭을 닫았을 때 포트가 정리되는지, 소켓 재연결 중 각 탭이 같은 상태를 받는지, 인증 만료 후 Worker와 탭의 상태가 엇갈리지 않는지 봐야 한다. 메시지 직렬화에 실패하는 payload가 있는지도 확인해야 한다.
SharedWorker를 적용하면 여러 탭을 하나의 실행 단위로 묶을 수 있다. 하지만 그 대가로 브라우저 지원, 포트 생명주기, 번들러 출력, 배포 origin을 함께 관리해야 한다. Dedicated Worker가 한 화면의 계산을 밖으로 빼는 기술이었다면, SharedWorker는 브라우저 안의 여러 화면이 같은 백그라운드 자원을 공유하도록 만드는 기술이다. 여기까지 오면 Worker는 성능 최적화 API가 아니라 클라이언트 아키텍처의 한 축으로 다뤄야 한다.
전체 코드 흐름
SharedWorker는 조각 코드만 보면 오히려 더 헷갈린다. 포트는 어디서 시작하는지, Socket.IO 연결은 언제 만들어지는지, 탭이 닫힐 때 누가 연결 수를 줄이는지, React hook은 어떤 메시지만 화면에 노출하는지 한 번에 보여야 구조가 잡힌다. 그래서 이 예제는 세 파일로 나누어 보는 편이 낫다.
첫 번째 파일은 SharedWorker 본체다. 이 파일은 연결된 MessagePort 목록과 Socket.IO 인스턴스를 갖고, 서버 이벤트를 각 탭으로 브로드캐스트한다. 실제 서비스에서는 서버 URL과 인증 payload를 환경에 맞게 조정해야 하지만, 구조의 핵심은 포트 관리와 소켓 생명주기를 한곳에 모으는 데 있다.
// socket.worker.ts
import { io, Socket } from 'socket.io-client';
type SocketStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
type ClientCommand =
| { command: 'emit_socket_event'; eventName: string; data: unknown }
| { command: 'get_socket_status' }
| { command: 'client_disconnecting' };
type WorkerToClientMessage =
| { type: 'worker_connected'; message: string }
| { type: 'socket_status'; status: SocketStatus; id?: string; reason?: string; error?: string }
| { type: 'socket_event'; eventName: string; data: unknown }
| { type: 'worker_error'; message: string }
| { type: 'error'; message: string };
const SOCKET_IO_SERVER_URL = 'http://localhost:3000/ws';
const connectedPorts = new Set<MessagePort>();
let socket: Socket | null = null;
let socketStatus: SocketStatus = 'disconnected';
function postToClient(port: MessagePort, message: WorkerToClientMessage) {
port.postMessage(message);
}
function broadcastToClients(message: WorkerToClientMessage) {
for (const port of connectedPorts) {
try {
postToClient(port, message);
} catch (error) {
console.error('SharedWorker: 클라이언트 메시지 전송 실패', error);
connectedPorts.delete(port);
}
}
}
function getSocketStatus(currentSocket: Socket | null): SocketStatus {
if (!currentSocket) {
return 'disconnected';
}
if (currentSocket.connected) {
return 'connected';
}
return socketStatus;
}
function initializeSocketIO() {
const currentStatus = getSocketStatus(socket);
if (currentStatus === 'connected' || currentStatus === 'connecting') {
broadcastToClients({
type: 'socket_status',
status: currentStatus,
id: socket?.id,
});
return;
}
socket = io(SOCKET_IO_SERVER_URL, {
transports: ['websocket'],
reconnectionAttempts: 5,
});
socketStatus = 'connecting';
socket.on('connect', () => {
socketStatus = 'connected';
broadcastToClients({
type: 'socket_status',
status: 'connected',
id: socket?.id,
});
});
socket.on('disconnect', (reason) => {
socketStatus = 'disconnected';
broadcastToClients({
type: 'socket_status',
status: 'disconnected',
reason,
});
socket = null;
});
socket.on('connect_error', (error) => {
socketStatus = 'error';
broadcastToClients({
type: 'socket_status',
status: 'error',
error: error.message,
});
});
socket.on('chat_message', (data: unknown) => {
broadcastToClients({
type: 'socket_event',
eventName: 'chat_message',
data,
});
});
socket.on('user_joined', (data: unknown) => {
broadcastToClients({
type: 'socket_event',
eventName: 'user_joined',
data,
});
});
}
function handleClientMessage(port: MessagePort, message: ClientCommand) {
if (message.command === 'client_disconnecting') {
connectedPorts.delete(port);
port.close();
if (connectedPorts.size === 0 && socket?.connected) {
socket.disconnect();
socket = null;
socketStatus = 'disconnected';
}
return;
}
if (message.command === 'get_socket_status') {
postToClient(port, {
type: 'socket_status',
status: getSocketStatus(socket),
id: socket?.id,
});
return;
}
if (message.command === 'emit_socket_event') {
if (!socket?.connected) {
postToClient(port, {
type: 'error',
message: 'Socket.IO가 연결되어 있지 않습니다.',
});
return;
}
socket.emit(message.eventName, message.data);
}
}
self.addEventListener('connect', (event) => {
const port = event.ports[0];
if (!port) {
return;
}
connectedPorts.add(port);
port.start();
initializeSocketIO();
port.addEventListener('message', (messageEvent: MessageEvent<ClientCommand>) => {
handleClientMessage(port, messageEvent.data);
});
port.addEventListener('messageerror', () => {
postToClient(port, {
type: 'error',
message: 'SharedWorker 메시지를 해석하지 못했습니다.',
});
});
postToClient(port, {
type: 'worker_connected',
message: 'SharedWorker에 연결되었습니다.',
});
});
self.addEventListener('error', () => {
broadcastToClients({
type: 'worker_error',
message: 'SharedWorker 내부에서 처리되지 않은 오류가 발생했습니다.',
});
});두 번째 파일은 React hook이다. 이 hook은 SharedWorker의 메시지 프로토콜을 화면에서 숨기고, 컴포넌트가 사용할 상태와 함수만 반환한다. 화면이 MessagePort를 직접 만지지 않게 만드는 것이 핵심이다.
// useSharedSocketIO.ts
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
type SocketStatusValue = 'initializing' | 'connected' | 'connecting' | 'disconnected' | 'error';
interface SocketStatus {
status: SocketStatusValue;
id?: string;
reason?: string;
error?: string;
}
interface SocketEvent {
eventName: string;
data: unknown;
}
type SharedWorkerMessage =
| { type: 'worker_connected'; message: string }
| { type: 'socket_status'; status: SocketStatusValue; id?: string; reason?: string; error?: string }
| { type: 'socket_event'; eventName: string; data: unknown }
| { type: 'worker_error'; message: string }
| { type: 'error'; message: string };
interface UseSharedSocketIOReturn {
isWorkerConnected: boolean;
socketStatus: SocketStatus;
lastSocketEvent: SocketEvent | null;
workerErrorMessage: string | null;
sendSocketEvent: (eventName: string, data: unknown) => void;
requestSocketStatus: () => void;
}
export function useSharedSocketIO(): UseSharedSocketIOReturn {
const workerRef = useRef<SharedWorker | null>(null);
const [isWorkerConnected, setIsWorkerConnected] = useState(false);
const [socketStatus, setSocketStatus] = useState<SocketStatus>({ status: 'initializing' });
const [lastSocketEvent, setLastSocketEvent] = useState<SocketEvent | null>(null);
const [workerErrorMessage, setWorkerErrorMessage] = useState<string | null>(null);
const postMessageToWorker = useCallback((message: unknown) => {
const port = workerRef.current?.port;
if (!port) {
setWorkerErrorMessage('SharedWorker port가 아직 준비되지 않았습니다.');
return;
}
port.postMessage(message);
}, []);
const sendSocketEvent = useCallback(
(eventName: string, data: unknown) => {
if (!eventName) {
setWorkerErrorMessage('Socket.IO 이벤트 이름이 필요합니다.');
return;
}
postMessageToWorker({ command: 'emit_socket_event', eventName, data });
},
[postMessageToWorker],
);
const requestSocketStatus = useCallback(() => {
postMessageToWorker({ command: 'get_socket_status' });
}, [postMessageToWorker]);
useEffect(() => {
if (typeof SharedWorker === 'undefined') {
setSocketStatus({ status: 'error', error: 'SharedWorker를 지원하지 않는 브라우저입니다.' });
setWorkerErrorMessage('SharedWorker를 지원하지 않는 브라우저입니다.');
return;
}
const worker = new SharedWorker(
new URL('./socket.worker.ts', import.meta.url),
{ type: 'module', name: 'shared-socket' },
);
workerRef.current = worker;
const handleMessage = (event: MessageEvent<SharedWorkerMessage>) => {
const message = event.data;
if (message.type === 'worker_connected') {
setIsWorkerConnected(true);
setWorkerErrorMessage(null);
requestSocketStatus();
return;
}
if (message.type === 'socket_status') {
setSocketStatus({
status: message.status,
id: message.id,
reason: message.reason,
error: message.error,
});
return;
}
if (message.type === 'socket_event') {
setLastSocketEvent({ eventName: message.eventName, data: message.data });
return;
}
if (message.type === 'error' || message.type === 'worker_error') {
setWorkerErrorMessage(message.message);
setSocketStatus((previous) => ({ ...previous, status: 'error', error: message.message }));
}
};
const handleMessageError = () => {
setWorkerErrorMessage('SharedWorker에서 받은 메시지를 해석하지 못했습니다.');
};
worker.port.addEventListener('message', handleMessage);
worker.port.addEventListener('messageerror', handleMessageError);
worker.port.start();
const handleBeforeUnload = () => {
worker.port.postMessage({ command: 'client_disconnecting' });
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
worker.port.removeEventListener('message', handleMessage);
worker.port.removeEventListener('messageerror', handleMessageError);
window.removeEventListener('beforeunload', handleBeforeUnload);
worker.port.postMessage({ command: 'client_disconnecting' });
workerRef.current = null;
};
}, [requestSocketStatus]);
return {
isWorkerConnected,
socketStatus,
lastSocketEvent,
workerErrorMessage,
sendSocketEvent,
requestSocketStatus,
};
}마지막 파일은 화면에서 hook을 쓰는 컴포넌트다. 이 컴포넌트는 Worker가 SharedWorker인지, 포트가 어떻게 열리는지, 소켓이 어디서 생성되는지 알 필요가 없다. 연결 상태를 보여주고, 필요한 이벤트를 보낼 수 있으면 된다.
// SharedSocketDemo.tsx
'use client';
import { useEffect } from 'react';
import { useSharedSocketIO } from './useSharedSocketIO';
export function SharedSocketDemo() {
const {
isWorkerConnected,
socketStatus,
lastSocketEvent,
workerErrorMessage,
sendSocketEvent,
requestSocketStatus,
} = useSharedSocketIO();
useEffect(() => {
if (lastSocketEvent?.eventName === 'chat_message') {
console.log('새 채팅 메시지', lastSocketEvent.data);
}
}, [lastSocketEvent]);
return (
<section>
<h2>SharedWorker Socket.IO Demo</h2>
<p>Worker: {isWorkerConnected ? 'connected' : 'disconnected'}</p>
<p>Socket.IO: {socketStatus.status}</p>
{socketStatus.id && <p>Socket ID: {socketStatus.id}</p>}
{socketStatus.reason && <p>Disconnect reason: {socketStatus.reason}</p>}
{workerErrorMessage && <p role="alert">{workerErrorMessage}</p>}
<button
type="button"
onClick={() => sendSocketEvent('client_message', { text: '안녕하세요.' })}
disabled={socketStatus.status !== 'connected'}
>
서버로 메시지 보내기
</button>
<button type="button" onClick={requestSocketStatus}>
Socket.IO 상태 새로고침
</button>
<pre>{JSON.stringify(lastSocketEvent, null, 2)}</pre>
</section>
);
}이 세 파일을 같이 보면 SharedWorker의 역할이 더 분명해진다. Worker 파일은 소켓과 포트를 관리하고, hook은 메시지 프로토콜을 React 상태로 번역하며, 컴포넌트는 상태를 보여주고 사용자 액션만 전달한다. 탭이 늘어나도 이 세 경계가 유지되면 서버 연결 수와 UI 상태가 서로 다른 방향으로 흩어지는 일을 줄일 수 있다.
댓글
댓글을 불러오는 중...