Next.js에서의 Web Worker
웹 애플리케이션이 점점 복잡해짐에 따라, 메인 스레드에서 모든 작업을 처리하는 방식은 사용자 경험을 해칠 수 있다. 특히, 복잡한 계산 작업이나 I/O 작업이 길어질 경우 브라우저가 멈춘 것처럼 보이는 현상이 발생할 수 있다. 이를 해결하기 위한 방법 중 하나가 바로 Web Worker를 사용하는 것이다. Worker는 브라우저에서 제공하는 API로, 메인 스레드와는 별도의 스레드에서 JavaScript 코드를 실행할 수 있게 해준다. 이를 통해 계산이나 데이터 처리 등의 무거운 작업을 백그라운드에서 수행하게 하고, 메인 스레드의 반응성을 유지할 수 있다.
Dedicated Worker
Dedicated Worker는 Web Worker의 한 종류로, 하나의워커 인스턴스가 오직 하나의 메인 스레드와만 통신할 수 있도록 설계되어 있다. 이는 메인 스레드와 워커 간의 관계가 1:1이라는 점에서 이후 살펴볼 Shared Worker와 구분된다. Dedicated Worker는 상대적으로 구현이 간단하며, 복잡한 동기화 문제 없이 메인 스레드로부터 독립된 작업을 수행하기에 적합하다. 워커 내부에서는 DOM에 직접 접근할 수 없지만, postMessage 메서드를 통해 메인 스레드와 데이터를 주고받을 수 있으며, 이 메시지 통신은 비동기적으로 처리된다. 일반적으로 이미지 처리, 데이터 파싱, 복잡한 수학 계산 등 사용자 인터페이스의 흐름을 방해하지 않고 수행해야 하는 연산을 전담할 때 유용하게 활용된다.
아래 코드는 이미지 파일을 받아 HTMLCanvasElement에 렌더링한 뒤, Web Worker를 통해 이미지 데이터를 처리하고, 처리된 결과를 다시 캔버스에 출력하는 커스텀 훅 useImageProcessor를 정의한 것이다. 이 훅은 React 컴포넌트 내에서 사용될 수 있으며, 이미지 처리 작업을 메인 스레드가 아닌 Web Worker에서 수행함으로써 UI의 반응성을 유지할 수 있다는 장점을 가진다.
useImageProcessor 훅은 내부적으로 canvasRef, workerRef, loading 상태를 관리하며, 사용자가 파일을 업로드하면 해당 이미지를 캔버스에 그린 후 ImageData 객체를 워커에게 전달한다. 워커는 이 데이터를 처리한 후 postMessage를 통해 메인 스레드로 결과를 반환하고, 메인 스레드는 이를 다시 캔버스에 출력한다. 이 모든 과정은 비동기적으로 이루어지며, 처리 중에는 loading 상태가 true로 설정되어 로딩 UI 등의 피드백을 줄 수 있게 되어 있다.
interface ProcessImageOptions {
workerFactory: () => Worker; // Worker 인스턴스 대신 Worker 생성 함수
}
export function useImageProcessor({ workerFactory }: ProcessImageOptions) {
const [loading, setLoading] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
// workerFactory가 제공되면 Worker 생성
workerRef.current = workerFactory();
// 컴포넌트 언마운트 시 Worker 정리
return () => {
workerRef.current?.terminate();
workerRef.current = null;
};
}, [workerFactory]);
const handleFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
setLoading(true);
workerRef.current?.postMessage(imageData);
const onMessage = (e: MessageEvent<ImageData>) => {
ctx.putImageData(e.data, 0, 0);
setLoading(false);
workerRef.current?.removeEventListener('message', onMessage);
};
workerRef.current?.addEventListener('message', onMessage);
};
};
return { handleFile, loading, canvasRef };
}
Next.js에서 Web Worker를 안전하게 사용하려면 두 가지 조건을 충족해야 한다. 첫째, 워커는 컴포넌트의 생명 주기와 무관하게 생성된 이후, 명시적으로 종료될 때를 제외하고는 가비지 컬렉션의 대상이 되어서는 안 된다. 둘째, 워커의 생성과 평가가 반드시 브라우저 환경에서만 이루어져야 한다. 이는 Web Worker가 Node.js의 worker_threads와는 다르기 때문이다. 이 두 조건을 만족시키는 방법은 다양하지만, 나는 컴포넌트 외부에 워커 생성 함수를 정의하고, useEffect 내부에서 이를 호출하는 방식으로 구현하고 있다.
const grayScaleWorkerFactory = () => {
return new Worker(
new URL('../worker/grayScale.worker.ts', import.meta.url),
{ type: 'module' }
);
}
const negativeWorkerFactory = () => {
return new Worker(
new URL('../worker/negative.worker.ts', import.meta.url),
{ type: 'module' }
);
}
이렇게 정의된 워커 팩토리를 워커 타입에 따라 조건부로 넘겨주게 되면, 특정 워커 구현에 의존하지 않고도 동일한 훅 인터페이스를 통해 다양한 이미지 처리 로직을 유연하게 적용할 수 있게 된다. 이는 코드의 재사용성과 유지 보수성을 높여줄 뿐 아니라, 새로운 워커 타입이 추가되더라도 기존 훅을 수정하지 않고 팩토리만 확장하면 되므로 확장성 면에서도 매우 유리하다. 또한 워커 생성 책임을 외부로 분리함으로써 브라우저 환경에서만 워커가 평가된다는 보장을 더욱 명확하게 할 수 있어, Next.js와 같이 클라이언트와 서버가 혼합된 실행 환경에서도 안정적으로 워커를 사용할 수 있는 구조를 갖출 수 있다.
export default function Page() {
const [workerType, setWorkerType] = useState<'grayScale' | 'negative'>('grayScale');
const { handleFile, loading, canvasRef } = useImageProcessor({
workerFactory: workerType === 'grayScale' ? grayScaleWorkerFactory : negativeWorkerFactory
});
return (
<div style={{ padding: 20 }}>
<WorkerSelector onChange={() => {
setWorkerType(prev => prev === 'grayScale' ? 'negative' : 'grayScale');
canvasRef.current!.getContext('2d')?.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
}} workerType={workerType} />
<h2>🖼️ 이미지 {workerType === 'grayScale' ? '그레이스케일' : '네거티브'} 처리 (Web Worker)</h2>
<input type="file" accept="image/*" onChange={handleFile} />
{loading && <p>이미지 처리 중...</p>}
<canvas ref={canvasRef} style={{ marginTop: 20, maxWidth: '100%' }} />
</div>
);
}
이처럼 Web Worker를 활용하면 이미지 필터링, 색상 보정, 엣지 디텍션 등 복잡한 연산이 포함된 이미지 처리 작업을 백그라운드에서 실행할 수 있어, 사용자 경험을 해치지 않고 고성능 처리를 구현할 수 있다. 이 훅은 확장성과 재사용성 면에서도 유용하며, 다양한 이미지 처리 작업에 쉽게 적용될 수 있는 구조를 갖추고 있다.
Shared Worker
Shared Worker는 Web Worker의 또 다른 형태로, 여러 개의 메인 스레드 ─ 예를 들어 같은 오리진 내의 여러 탭, 윈도우, iframe 등 ─ 에서 하나의 워커 인스턴스를 공유할 수 있도록 설계되어 있다. 이 구조는 워커와 메인 스레드 간의 관계가 1:N이라는 점에서 Dedicated Worker와 구분되며, 공통된 리소스를 다수의 컨텍스트에서 활용해야 하는 상황에 적합하다. Shared Worker는 onconnect 이벤트를 통해 각 연결된 클라이언트의 MessagePort를 받아 통신을 처리하며, 내부적으로는 연결된 포트들 간의 메시지를 브로드캐스트하는 등의 작업이 가능하다. 주로 실시간 협업 도구나 여러 탭 간의 상태 동기화, 공통 캐시 처리 등에서 활용되며, 워커 인스턴스가 하나만 유지되기 때문에 메모리 절약 및 일관성 있는 데이터 관리가 필요한 상황에서 큰 장점을 가진다.
사파리에서는 어디서 보는 지 모르겠는데, 크롬에서는 chrome://inspect/#workers 에서 Shared Worker의 로그를 확인할 수 있다.
import { io, Socket } from 'socket.io-client';
// 연결된 모든 클라이언트 포트를 저장하는 Set (WeakRef 사용)
const connectedPorts = new Set<WeakRef<MessagePort>>();
let socket: Socket | null = null;
const SOCKET_IO_SERVER_URL = 'http://localhost:3000/ws'; // 실제 Socket.IO 서버 주소로 변경하세요.
/**
* 모든 연결된 클라이언트에게 메시지를 브로드캐스트합니다.
* @param message 브로드캐스트할 메시지
*/
function broadcastToClients(message: any) {
const portsToRemove: WeakRef<MessagePort>[] = [];
connectedPorts.forEach(weakPortRef => {
const port = weakPortRef.deref();
if (port) {
try {
port.postMessage(message);
} catch (e) {
console.error('SharedWorker: 클라이언트로 브로드캐스트 중 오류 발생, 해당 포트 제거 시도:', e);
portsToRemove.push(weakPortRef); // 오류 발생 시 제거 목록에 추가
}
} else {
// 포트가 가비지 컬렉션됨 (더 이상 유효하지 않음)
portsToRemove.push(weakPortRef);
}
});
// 유효하지 않거나 오류가 발생한 포트 정리
portsToRemove.forEach(ref => connectedPorts.delete(ref));
}
/**
* 현재 소켓의 상태를 문자열로 반환합니다.
* @param currentSocket 확인할 Socket.IO 소켓 객체
* @returns 'connected', 'connecting', 'disconnected' 중 하나
*/
function getSocketStatusString(currentSocket: Socket | null): string {
if (!currentSocket) {
return 'disconnected';
}
if (currentSocket.connected) {
return 'connected';
}
// currentSocket.io는 Manager 객체이며, readyState를 가집니다.
if (currentSocket.io && currentSocket.io._readyState === 'opening') {
return 'connecting';
}
return 'disconnected';
}
/**
* Socket.IO 서버에 연결하고 이벤트 핸들러를 설정합니다.
*/
function initializeSocketIO() {
// 이미 연결 중이거나 연결된 상태이면 새로 연결하지 않음
if (socket) {
const currentStatus = getSocketStatusString(socket);
if (currentStatus === 'connected' || currentStatus === 'connecting') {
console.log('SharedWorker: Socket.IO가 이미 연결되었거나 연결 중입니다.');
broadcastToClients({ type: 'socket_status', status: currentStatus, id: socket.id });
return;
}
}
console.log(`SharedWorker: Socket.IO (${SOCKET_IO_SERVER_URL}) 연결 시도 중...`);
// 워커 환경에서는 WebSocket 전송만 명시적으로 사용하는 것이 안정적일 수 있습니다.
socket = io(SOCKET_IO_SERVER_URL, {
transports: ['websocket'],
reconnectionAttempts: 5, // 재연결 시도 횟수
});
socket.on('connect', () => {
console.log('SharedWorker: Socket.IO 서버에 성공적으로 연결되었습니다. ID:', socket?.id);
broadcastToClients({ type: 'socket_status', status: 'connected', id: socket?.id });
});
socket.on('disconnect', (reason) => {
console.log('SharedWorker: Socket.IO 서버와 연결이 끊어졌습니다. 이유:', reason);
socket = null; // 소켓 참조 제거
broadcastToClients({ type: 'socket_status', status: 'disconnected', reason });
// 모든 클라이언트 연결이 끊어졌고, 워커가 더 이상 필요 없다면 self.close()를 고려할 수 있으나,
// SharedWorker의 특성상 다른 탭이 다시 연결할 수 있으므로 신중해야 합니다.
});
socket.on('connect_error', (error) => {
console.error('SharedWorker: Socket.IO 연결 오류:', error);
broadcastToClients({ type: 'socket_status', status: 'error', error: error.message });
// socket = null; // 필요에 따라 연결 실패 시 소켓 참조를 제거할 수 있습니다.
});
// --- 서버로부터 오는 사용자 정의 이벤트를 여기에 등록 ---
// 예시: 서버가 'chat_message' 이벤트를 보내는 경우
socket.on('chat_message', (data) => {
console.log('SharedWorker: Socket.IO 서버로부터 "chat_message" 수신:', data);
broadcastToClients({ type: 'socket_event', eventName: 'chat_message', data });
});
// 예시: 서버가 'user_joined' 이벤트를 보내는 경우
socket.on('user_joined', (data) => {
console.log('SharedWorker: Socket.IO 서버로부터 "user_joined" 수신:', data);
broadcastToClients({ type: 'socket_event', eventName: 'user_joined', data });
});
// 필요한 다른 서버 이벤트들을 여기에 추가합니다.
// socket.on('another_server_event', (data) => { ... });
socket.emit('userConnect', { userId: 'worker_user', message: 'SharedWorker가 연결되었습니다.' });
}
// 새 클라이언트가 연결될 때마다 호출됨
self.addEventListener('connect', (event: MessageEvent) => {
const port = event.ports[0];
if (!port) {
return;
}
const weakPortRef = new WeakRef(port);
connectedPorts.add(weakPortRef);
console.log('SharedWorker: 새 클라이언트 연결됨. 현재 연결 수:', connectedPorts.size);
// 첫 번째 클라이언트 연결 시 또는 Socket.IO 연결이 끊어진 경우 연결 시도
if (!socket || !socket.connected) {
initializeSocketIO();
} else {
// 이미 Socket.IO 연결이 있는 경우, 새 클라이언트에게 현재 상태 알림
port.postMessage({ type: 'socket_status', status: getSocketStatusString(socket), id: socket.id });
}
// 클라이언트로부터 메시지를 수신했을 때
port.onmessage = (e: MessageEvent) => {
const message = e.data;
console.log('SharedWorker: 클라이언트로부터 메시지 수신:', message);
if (!socket && message.command !== 'get_socket_status') {
console.warn('SharedWorker: Socket.IO가 연결되어 있지 않아 메시지를 처리할 수 없습니다.');
port.postMessage({ type: 'error', message: 'Socket.IO가 연결되어 있지 않습니다. 먼저 연결을 확인하세요.' });
return;
}
switch (message.command) {
case 'emit_socket_event':
if (socket && socket.connected) {
try {
socket.emit(message.eventName, message.data);
console.log(`SharedWorker: Socket.IO 이벤트 "${message.eventName}" 전송:`, message.data);
} catch (emitError: any) {
console.error('SharedWorker: Socket.IO 이벤트 전송 오류:', emitError);
port.postMessage({ type: 'error', message: `Socket.IO 이벤트 "${message.eventName}" 전송에 실패했습니다: ${emitError.message}` });
}
} else {
const currentStatus = getSocketStatusString(socket);
port.postMessage({ type: 'error', message: `Socket.IO가 연결되어 있지 않아 이벤트를 전송할 수 없습니다. (현재 상태: ${currentStatus})` });
}
break;
case 'get_socket_status':
const status = getSocketStatusString(socket);
port.postMessage({ type: 'socket_status', status: status, id: socket?.id });
break;
case 'client_disconnecting': // 클라이언트가 명시적으로 연결 해제를 알릴 때
console.log('SharedWorker: 클라이언트로부터 연결 해제 요청 받음');
connectedPorts.delete(weakPortRef);
port.close(); // 해당 MessagePort 닫기
console.log('SharedWorker: 클라이언트 포트 제거됨. 현재 연결 수:', connectedPorts.size);
// 모든 클라이언트가 연결을 끊으면 Socket.IO 연결도 끊을 수 있습니다.
if (connectedPorts.size === 0 && socket && socket.connected) {
console.log('SharedWorker: 모든 클라이언트가 연결 해제되어 Socket.IO 연결을 닫습니다.');
socket.disconnect();
socket = null;
}
break;
// 다른 커맨드 처리 로직 추가 가능
default:
console.warn('SharedWorker: 알 수 없는 커맨드:', message.command);
port.postMessage({ type: 'error', message: `알 수 없는 커맨드: ${message.command}` });
break;
}
};
// 포트에서 오류 발생 시 (메시지 직렬화 실패 등)
port.onmessageerror = (error) => {
console.error('SharedWorker: 포트 메시지 오류:', error);
// 필요시 해당 클라이언트에게 오류 알림
};
// 클라이언트에게 SharedWorker 연결 성공 메시지 전송
port.postMessage({ type: 'worker_connected', message: 'SharedWorker에 성공적으로 연결되었습니다.' });
});
// 워커 전역 스코프에서 발생하는 처리되지 않은 오류 핸들러
self.onerror = function(error) {
console.error('SharedWorker: 처리되지 않은 전역 오류:', error);
broadcastToClients({ type: 'worker_error', message: 'SharedWorker 내부에서 예기치 않은 오류가 발생했습니다.' });
};
// SharedWorker 스크립트가 로드되었음을 알림
console.log('SharedSocketIO Worker 스크립트 로드됨 및 초기화 시작.');
// 초기 Socket.IO 연결은 첫 번째 클라이언트가 연결될 때 시도됩니다.
import { useState, useEffect, useRef, useCallback } from 'react';
interface SharedWorkerMessageEventData {
type: string;
message?: string;
status?: string;
id?: string;
reason?: string;
error?: string;
eventName?: string;
data?: any;
}
interface SocketStatus {
status: 'initializing' | 'connected' | 'disconnected' | 'connecting' | 'error' | 'unknown';
id?: string | null;
reason?: string | null;
error?: string | null;
}
interface SocketEvent {
eventName: string;
data: any;
}
interface UseSharedSocketIOReturn {
/** SharedWorker가 성공적으로 연결되었는지 여부 */
isWorkerConnected: boolean;
/** 현재 Socket.IO 연결 상태 객체 */
socketStatus: SocketStatus;
/** 가장 최근에 수신된 Socket.IO 서버 이벤트 */
lastSocketEvent: SocketEvent | null;
/** SharedWorker 자체에서 발생한 오류 메시지 */
workerErrorMessage: string | null;
/** Worker를 통해 Socket.IO 서버로 이벤트를 전송하는 함수 */
sendSocketEvent: (eventName: string, data: any) => void;
/** Worker에게 현재 Socket.IO 연결 상태를 요청하는 함수 */
requestSocketStatus: () => void;
}
interface UseSharedSocketIOProps {
workerFactory?: () => SharedWorker;
}
/**
* SharedWorker를 사용하여 Socket.IO 통신을 관리하는 React Hook.
* @param workerUrl SharedWorker 스크립트 파일의 URL.
* @param workerOptions SharedWorker 생성자 옵션 (예: { type: 'module' }).
*/
export function useSharedSocketIO({
workerFactory,
}: UseSharedSocketIOProps): UseSharedSocketIOReturn {
const workerRef = useRef<SharedWorker | null>(null);
// MessagePort는 workerRef.current.port를 통해 접근하므로 별도 ref나 state 불필요
const [isWorkerConnected, setIsWorkerConnected] = useState<boolean>(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: any) => {
if (workerRef.current?.port) {
try {
workerRef.current.port.postMessage(message);
} catch (e) {
console.error('Hook: Failed to post message to SharedWorker:', e);
setWorkerErrorMessage(`Failed to send message to worker: ${e instanceof Error ? e.message : String(e)}`);
}
} else {
console.warn('Hook: SharedWorker port not available to post message:', message);
}
}, []);
const sendSocketEvent = useCallback(
(eventName: string, data: any) => {
if (!eventName) {
console.error('Hook: Event name is required to send a socket event.');
setWorkerErrorMessage('Event name is required.');
return;
}
console.log(`Hook: Attempting to send Socket.IO event "${eventName}" with data:`, data);
postMessageToWorker({ command: 'emit_socket_event', eventName, data });
},
[postMessageToWorker]
);
const requestSocketStatus = useCallback(() => {
postMessageToWorker({ command: 'get_socket_status' });
}, [postMessageToWorker]);
useEffect(() => {
if (typeof SharedWorker === 'undefined') {
console.error('Hook: SharedWorker is not supported in this browser.');
setWorkerErrorMessage('SharedWorker is not supported in this browser.');
setSocketStatus({ status: 'error', error: 'SharedWorker not supported' });
setIsWorkerConnected(false);
return;
}
// workerUrl이 변경되지 않는 한, 또는 이미 인스턴스가 있는 경우 중복 생성 방지
if (!workerRef.current) {
try {
workerRef.current = workerFactory ? workerFactory() : new SharedWorker(
new URL('../component/shared.ts', import.meta.url), { type: 'module' },
);
} catch (e) {
console.error('Hook: Failed to create SharedWorker:', e);
setWorkerErrorMessage(`Failed to create SharedWorker: ${e instanceof Error ? e.message : String(e)}`);
setSocketStatus({ status: 'error', error: 'Failed to create SharedWorker' });
setIsWorkerConnected(false);
return;
}
}
const port = workerRef.current.port;
const handleMessage = (event: MessageEvent<SharedWorkerMessageEventData>) => {
const data = event.data;
console.log(`Hook: Message from SharedWorker:`, data);
switch (data.type) {
case 'worker_connected':
setIsWorkerConnected(true);
setWorkerErrorMessage(null);
console.log(`Hook: SharedWorker connected - ${data.message}`);
// 워커 연결 성공 후 Socket.IO 상태 자동 요청
requestSocketStatus();
break;
case 'socket_status':
setSocketStatus({
status: data.status as SocketStatus['status'] || 'unknown',
id: data.id,
reason: data.reason,
error: data.error,
});
console.log(`Hook: Socket.IO status update - Status: ${data.status}, ID: ${data.id}, Reason: ${data.reason}, Error: ${data.error}`);
break;
case 'socket_event':
if (data.eventName) {
setLastSocketEvent({ eventName: data.eventName, data: data.data });
console.log(`Hook: Received socket_event "${data.eventName}" with data:`, data.data);
}
break;
case 'error': // Worker 내부 로직에서 발생한 특정 오류
setWorkerErrorMessage(data.message || 'An unknown error occurred in SharedWorker.');
console.error(`Hook: Error message from SharedWorker - ${data.message}`);
break;
case 'worker_error': // Worker 전역 스코프에서 발생한 처리되지 않은 오류
setWorkerErrorMessage(data.message || 'An unhandled global error occurred in SharedWorker.');
setSocketStatus(prev => ({ ...prev, status: 'error', error: data.message || 'Worker global error' }));
console.error(`Hook: Global error from SharedWorker - ${data.message}`);
break;
default:
console.warn(`Hook: Received unknown message type from SharedWorker: ${data.type}`);
}
};
const handleMessageError = (event: MessageEvent) => {
console.error('Hook: Message error from SharedWorker:', event);
setWorkerErrorMessage('A message error occurred with the SharedWorker.');
};
port.addEventListener('message', handleMessage as EventListener);
port.addEventListener('messageerror', handleMessageError as EventListener);
port.start();
// 페이지 이탈 전에 워커에게 알림
const handleBeforeUnload = () => {
console.log('Hook: beforeunload - notifying worker about client disconnecting.');
postMessageToWorker({ command: 'client_disconnecting' });
};
window.addEventListener('beforeunload', handleBeforeUnload);
// 초기 상태 요청 (이미 워커가 연결되어 있을 수 있으므로)
if (isWorkerConnected) { // worker_connected 메시지를 이미 받았다면
requestSocketStatus();
}
return () => {
console.log('Hook: Cleaning up SharedWorker listeners.');
port.removeEventListener('message', handleMessage as EventListener);
port.removeEventListener('messageerror', handleMessageError as EventListener);
window.removeEventListener('beforeunload', handleBeforeUnload);
// SharedWorker 자체나 포트를 여기서 닫지 않습니다.
// SharedWorker는 브라우저 레벨에서 공유되며, 모든 클라이언트가 연결을 끊으면
// 워커 내부 로직(예: connectedPorts.size === 0)에 따라 스스로 종료하거나 연결을 해제할 수 있습니다.
// 이 훅의 인스턴스가 언마운트된다고 해서 다른 탭의 연결까지 끊어서는 안 됩니다.
};
}, [postMessageToWorker, requestSocketStatus, isWorkerConnected]); // isWorkerConnected 추가하여 연결 후 상태 요청
return {
isWorkerConnected,
socketStatus,
lastSocketEvent,
workerErrorMessage,
sendSocketEvent,
requestSocketStatus,
};
}
import { useSharedSocketIO } from '@/component/useSharedSocketIO';
import React, { useEffect } from 'react';
export default function MyComponent() {
const {
isWorkerConnected,
socketStatus,
lastSocketEvent,
workerErrorMessage,
sendSocketEvent,
requestSocketStatus, // 필요시 수동으로 상태 요청
} = useSharedSocketIO({});
useEffect(() => {
// isWorkerConnected 상태가 변경될 때 로그
console.log('Worker Connection Status:', isWorkerConnected);
}, [isWorkerConnected]);
useEffect(() => {
// socketStatus 상태가 변경될 때 로그
console.log('Socket.IO Status:', socketStatus);
// 예: UI에 연결 상태 표시
const statusElement = document.getElementById('socket-display-status');
if (statusElement) {
statusElement.textContent = `Socket.IO: ${socketStatus.status} (ID: ${socketStatus.id || 'N/A'})`;
}
}, [socketStatus]);
useEffect(() => {
// lastSocketEvent가 변경될 때 (새로운 서버 이벤트 수신 시)
if (lastSocketEvent) {
console.log(`Received event "${lastSocketEvent.eventName}":`, lastSocketEvent.data);
// 여기서 특정 이벤트에 따른 UI 업데이트 또는 로직 처리
if (lastSocketEvent.eventName === 'chat_message') {
// 예: 채팅 메시지 목록에 추가
}
}
}, [lastSocketEvent]);
useEffect(() => {
if (workerErrorMessage) {
console.error('Worker Error:', workerErrorMessage);
// 예: 사용자에게 오류 알림
}
}, [workerErrorMessage]);
const handleSendMessage = () => {
sendSocketEvent('client_message', { text: '안녕하세요! React 클라이언트에서 보냅니다.' });
};
return (
<div>
<h1>SharedWorker Socket.IO Hook Demo</h1>
<p>Worker Connected: {isWorkerConnected ? 'Yes' : 'No'}</p>
<p id="socket-display-status">Socket.IO: {socketStatus.status} (ID: {socketStatus.id || 'N/A'})</p>
{socketStatus.error && <p style={{ color: 'red' }}>Socket Error: {socketStatus.error}</p>}
{socketStatus.reason && <p>Socket Disconnect Reason: {socketStatus.reason}</p>}
{workerErrorMessage && <p style={{ color: 'red' }}>Worker Error: {workerErrorMessage}</p>}
<button onClick={handleSendMessage} disabled={socketStatus.status !== 'connected'}>
서버로 메시지 보내기
</button>
<button onClick={requestSocketStatus}>
Socket.IO 상태 새로고침
</button>
<h2>Last Received Event:</h2>
{lastSocketEvent ? (
<pre>{JSON.stringify(lastSocketEvent, null, 2)}</pre>
) : (
<p>No events received yet.</p>
)}
</div>
);
}
이 기나긴 코드는 https://www.youtube.com/watch?v=SVt1-Opp3Wo 영상에서 영감을 받아 만들었다. Shared Worker를 활용함으로써 하나의 클라이언트에서 여러 개의 탭을 열더라도 단 하나의 소켓 연결만 유지할 수 있도록 설계했다. 이를 통해 불필요한 중복 연결을 방지하고, 자원 사용을 최소화하며, 여러 탭 간의 효율적인 데이터 공유와 통신을 가능하게 했다.
실제로 11개의 탭을 동시에 열어도 백엔드에는 단 한 번의 소켓 연결만 생성되는 것을 확인할 수 있다.
Web Worker vs event loop
이렇게만 놓고 보면 Web Worker와 이벤트 루프를 통한 비동기 함수가 크게 다르지 않은 것처럼 느껴질 수 있다. 그러나 이 두 기술의 목적과 동작 방식은 근본적으로 다르다. 자바스크립트는 기본적으로 단일 스레드 환경에서 실행되기 때문에, 비동기 함수는 실제로 별도의 스레드에서 실행되는 것이 아니라, Web API나 Node API에 작업을 위임하고 완료된 작업을 이벤트 루프를 통해 다시 메인 스레드에서 처리하는 방식으로 작동한다.
이 방식은 네트워크 요청이나 타이머처럼 I/O 대기가 필요한 작업에 적합하지만, 무거운 계산과 같은 CPU 중심 작업에는 효과적이지 않다. 계산이 길어질 경우, 비동기 함수라 하더라도 해당 작업이 메인 스레드에서 실행되기 때문에 여전히 사용자 인터페이스가 멈추는 현상이 발생할 수 있다. 반면, Web Worker는 메인 스레드와 완전히 분리된 별도의 스레드에서 실행되며, 무거운 연산 작업을 백그라운드에서 병렬로 처리할 수 있다. 메시지 기반 통신을 통해 메인 스레드와 데이터를 주고받기 때문에 직접적인 변수 공유는 불가능하지만, 그만큼 UI의 반응성을 완전히 유지할 수 있다는 점에서 복잡한 연산을 포함하는 애플리케이션에서는 큰 이점을 제공한다.
블로그의 정보
Ayden's journal
Beard Weard Ayden