React Next.js WebSocket 실시간 통신 구현 가이드
React와 Next.js 환경에서 WebSocket을 활용하여 실시간 통신 기능을 구현하는 방법을 상세히 안내합니다. TypeScript 기반의 클라이언트 코드 예제와 커스텀 훅을 통해 효율적인 실시간 애플리케이션 개발 전략을 제시합니다.
React Next.js WebSocket 실시간 통신 구현 가이드
현대 웹 애플리케이션에서 실시간 통신은 사용자 경험을 혁신하는 핵심 요소입니다. 채팅, 알림, 실시간 데이터 업데이트 등 다양한 기능 구현에 필수적인 WebSocket은 HTTP의 한계를 넘어선 양방향 통신 프로토콜로 자리 잡았습니다. 이 글에서는 React 및 Next.js 환경에서 TypeScript를 활용하여 WebSocket 기반의 실시간 통신 기능을 효과적으로 구현하는 방법에 대해 심층적으로 다루고자 합니다.
WebSocket이란 무엇인가요?
WebSocket은 웹 브라우저와 서버 간에 전이중(full-duplex) 통신 채널을 제공하는 고급 기술 프로토콜입니다. 기존 HTTP 통신이 요청-응답(request-response) 모델로 동작하여 서버 푸시(server push)에 제한적이었던 반면, WebSocket은 한 번 연결을 수립하면 클라이언트와 서버가 독립적으로 언제든지 데이터를 주고받을 수 있는 영구적인 연결을 유지합니다.
이러한 특성 덕분에 WebSocket은 다음과 같은 장점을 가집니다.
- 양방향 통신: 클라이언트와 서버 모두 서로에게 메시지를 보낼 수 있습니다.
- 지속적인 연결: 한 번 연결되면 핸드셰이크 과정 없이 데이터를 전송하여 오버헤드가 적습니다.
- 낮은 지연 시간: 불필요한 HTTP 헤더 교환 없이 데이터 프레임만 전송하여 실시간성이 뛰어납니다.
이러한 장점들은 실시간 채팅, 온라인 게임, 주식 시세, 협업 도구 등 즉각적인 데이터 동기화가 필요한 애플리케이션 개발에 WebSocket을 이상적인 선택으로 만듭니다.
WebSocket 서버 설정 (간단한 Node.js 예시)
프론트엔드 개발에 초점을 맞추지만, WebSocket 통신을 테스트하려면 서버가 필요합니다. 여기서는 ws 라이브러리를 사용하여 간단한 Node.js WebSocket 서버를 구축하는 예시를 보여드리겠습니다. 이 서버는 클라이언트로부터 메시지를 받으면 모든 연결된 클라이언트에게 다시 메시지를 브로드캐스트하는 역할을 합니다.
먼저, 프로젝트를 초기화하고 ws 라이브러리를 설치합니다.
mkdir websocket-server
cd websocket-server
npm init -y
npm install ws
다음으로, server.js 파일을 생성하고 아래 코드를 작성합니다.
// server.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', ws => {
console.log('Client connected');
ws.on('message', message => {
console.log(`Received message: ${message}`);
// 모든 연결된 클라이언트에게 메시지 브로드캐스트
wss.clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(`Broadcast: ${message}`);
}
});
// 메시지를 보낸 클라이언트에게 응답
ws.send(`You said: ${message}`);
});
ws.on('close', () => {
console.log('Client disconnected');
});
ws.on('error', error => {
console.error('WebSocket error:', error);
});
});
console.log('WebSocket server started on port 8080');
서버를 실행합니다.
node server.js
이제 localhost:8080에서 WebSocket 서버가 클라이언트 연결을 기다리게 됩니다.
React 애플리케이션에서 WebSocket 클라이언트 구현
React 애플리케이션에서 WebSocket 클라이언트를 구현하는 가장 기본적인 방법은 브라우저의 내장 WebSocket API를 사용하는 것입니다. useEffect 훅을 활용하여 컴포넌트 마운트 시 WebSocket 연결을 수립하고, 언마운트 시 연결을 정리하는 패턴을 따릅니다.
다음은 간단한 메시지 송수신 컴포넌트 예시입니다.
// src/components/SimpleWebSocketClient.tsx
import React, { useEffect, useState } from 'react';
const SimpleWebSocketClient: React.FC = () => {
const [messages, setMessages] = useState<string[]>([]);
const [inputValue, setInputValue] = useState<string>('');
const [ws, setWs] = useState<WebSocket | null>(null);
useEffect(() => {
// WebSocket 연결
const websocket = new WebSocket('ws://localhost:8080');
websocket.onopen = () => {
console.log('WebSocket Connected');
setMessages(prev => [...prev, 'Connected to WebSocket server!']);
};
websocket.onmessage = (event) => {
console.log('Message from server:', event.data);
setMessages(prev => [...prev, `Server: ${event.data}`]);
};
websocket.onerror = (error) => {
console.error('WebSocket Error:', error);
setMessages(prev => [...prev, `Error: ${error.message}`]);
};
websocket.onclose = () => {
console.log('WebSocket Disconnected');
setMessages(prev => [...prev, 'Disconnected from WebSocket server!']);
};
setWs(websocket);
// 컴포넌트 언마운트 시 WebSocket 연결 해제
return () => {
if (websocket.readyState === WebSocket.OPEN) {
websocket.close();
}
};
}, []); // 빈 배열은 컴포넌트 마운트/언마운트 시 한 번만 실행
const sendMessage = () => {
if (ws && ws.readyState === WebSocket.OPEN && inputValue.trim()) {
ws.send(inputValue);
setMessages(prev => [...prev, `You: ${inputValue}`]);
setInputValue('');
}
};
return (
<div>
<h1>Simple WebSocket Client</h1>
<div>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={(e) => { if (e.key === 'Enter') sendMessage(); }}
placeholder="메시지를 입력하세요"
/>
<button onClick={sendMessage} disabled={!ws || ws.readyState !== WebSocket.OPEN}>
Send
</button>
</div>
<div style={{ marginTop: '20px', border: '1px solid #ccc', padding: '10px', height: '300px', overflowY: 'scroll' }}>
{messages.map((msg, index) => (
<p key={index}>{msg}</p>
))}
</div>
</div>
);
};
export default SimpleWebSocketClient;
이 컴포넌트는 WebSocket 연결 상태를 관리하고, 서버로부터 메시지를 받아 화면에 표시하며, 사용자 입력 메시지를 서버로 전송하는 기본적인 기능을 수행합니다.
Custom Hook을 활용한 WebSocket 관리
여러 컴포넌트에서 WebSocket 기능을 사용해야 하거나, 더 복잡한 로직을 처리해야 할 경우, 위와 같은 useEffect 로직을 매번 반복하는 것은 비효율적입니다. 이때 Custom Hook을 사용하면 WebSocket 로직을 추상화하고 재사용성을 높일 수 있습니다.
useWebSocket이라는 Custom Hook을 만들어 보겠습니다. 이 훅은 연결 상태, 메시지 목록, 메시지 전송 함수 등을 반환하여 컴포넌트에서 쉽게 사용할 수 있도록 합니다.
// src/hooks/useWebSocket.ts
import { useEffect, useRef, useState, useCallback } from 'react';
interface UseWebSocketOptions {
onOpen?: (event: Event) => void;
onMessage?: (event: MessageEvent) => void;
onClose?: (event: CloseEvent) => void;
onError?: (event: Event) => void;
shouldReconnect?: boolean;
reconnectInterval?: number;
}
const useWebSocket = (url: string, options?: UseWebSocketOptions) => {
const {
onOpen,
onMessage,
onClose,
onError,
shouldReconnect = true,
reconnectInterval = 3000, // 3초 후 재연결 시도
} = options || {};
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<MessageEvent | null>(null);
const webSocketRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const connect = useCallback(() => {
if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) {
return; // 이미 연결되어 있으면 다시 연결하지 않음
}
const ws = new WebSocket(url);
webSocketRef.current = ws;
ws.onopen = (event) => {
console.log('WebSocket Connected');
setIsConnected(true);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
onOpen?.(event);
};
ws.onmessage = (event) => {
setLastMessage(event);
onMessage?.(event);
};
ws.onclose = (event) => {
console.log('WebSocket Disconnected');
setIsConnected(false);
onClose?.(event);
if (shouldReconnect) {
reconnectTimeoutRef.current = setTimeout(connect, reconnectInterval);
}
};
ws.onerror = (event) => {
console.error('WebSocket Error:', event);
onError?.(event);
if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.CONNECTING) {
// 연결 시도 중 에러 발생 시 즉시 재연결 시도 (onclose에서 재연결 로직이 실행될 수 있음)
webSocketRef.current.close();
}
};
}, [url, onOpen, onMessage, onClose, onError, shouldReconnect, reconnectInterval]);
useEffect(() => {
connect();
return () => {
if (webSocketRef.current) {
webSocketRef.current.close();
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, [connect]);
const sendMessage = useCallback((message: string | object) => {
if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) {
const data = typeof message === 'object' ? JSON.stringify(message) : message;
webSocketRef.current.send(data);
} else {
console.warn('WebSocket is not connected. Message not sent.');
}
}, []);
return { isConnected, lastMessage, sendMessage, ws: webSocketRef.current };
};
export default useWebSocket;이제 이 useWebSocket 훅을 React 컴포넌트에서 다음과 같이 사용할 수 있습니다.
// src/components/ChatRoom.tsx
import React, { useState, useEffect } from 'react';
import useWebSocket from '../hooks/useWebSocket';
interface ChatMessage {
id: string;
sender: string;
message: string;
timestamp: string;
}
const ChatRoom: React.FC = () => {
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [inputMessage, setInputMessage] = useState<string>('');
const userName = 'User' + Math.floor(Math.random() * 100); // 임시 사용자 이름
const { isConnected, lastMessage, sendMessage } = useWebSocket('ws://localhost:8080', {
onMessage: (event) => {
// 서버에서 받은 메시지 처리
try {
const data = JSON.parse(event.data as string);
// 서버에서 브로드캐스트하는 메시지 형식에 따라 파싱
// 예: "Broadcast: 메시지 내용" 또는 "You said: 메시지 내용"
// 여기서는 간단히 문자열로 처리하고, 실제 앱에서는 JSON 객체 형태로 주고받는 것이 일반적입니다.
let parsedMessage: ChatMessage;
if (typeof data === 'object' && data.sender && data.message) {
parsedMessage = data;
} else {
// 서버로부터 받은 일반 문자열 메시지 처리
parsedMessage = {
id: Date.now().toString(),
sender: 'Server',
message: event.data as string,
timestamp: new Date().toLocaleTimeString(),
};
}
setChatMessages(prev => [...prev, parsedMessage]);
} catch (e) {
// JSON 파싱 실패 시 일반 문자열로 처리
setChatMessages(prev => [...prev, {
id: Date.now().toString(),
sender: 'Server',
message: event.data as string,
timestamp: new Date().toLocaleTimeString(),
}]);
}
},
shouldReconnect: true,
reconnectInterval: 5000,
});
const handleSendMessage = () => {
if (inputMessage.trim() && isConnected) {
const messageToSend: ChatMessage = {
id: Date.now().toString(),
sender: userName,
message: inputMessage,
timestamp: new Date().toLocaleTimeString(),
};
sendMessage(JSON.stringify(messageToSend)); // 서버로 JSON 문자열 전송
setChatMessages(prev => [...prev, messageToSend]); // 자신의 메시지도 화면에 추가
setInputMessage('');
}
};
useEffect(() => {
// lastMessage가 변경될 때마다 특정 로직 수행 가능
if (lastMessage) {
console.log('Last message updated:', lastMessage.data);
}
}, [lastMessage]);
return (
<div style={{ maxWidth: '600px', margin: '20px auto', border: '1px solid #ddd', borderRadius: '8px', padding: '20px' }}>
<h2>WebSocket Chat Room</h2>
<p>Status: {isConnected ? <span style={{ color: 'green' }}>Connected</span> : <span style={{ color: 'red' }}>Disconnected</span>}</p>
<div style={{ height: '400px', overflowY: 'auto', border: '1px solid #eee', padding: '10px', marginBottom: '15px', backgroundColor: '#f9f9f9' }}>
{chatMessages.map((msg) => (
<div key={msg.id} style={{ marginBottom: '8px', padding: '5px', borderRadius: '5px', backgroundColor: msg.sender === userName ? '#e0f7fa' : '#ffffff', textAlign: msg.sender === userName ? 'right' : 'left' }}>
<strong>{msg.sender === userName ? 'You' : msg.sender}</strong> ({msg.timestamp}): {msg.message}
</div>
))}
</div>
<div style={{ display: 'flex' }}>
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={(e) => { if (e.key === 'Enter') handleSendMessage(); }}
placeholder="메시지를 입력하세요..."
style={{ flexGrow: 1, padding: '10px', border: '1px solid #ccc', borderRadius: '4px', marginRight: '10px' }}
disabled={!isConnected}
/>
<button
onClick={handleSendMessage}
disabled={!isConnected || !inputMessage.trim()}
style={{ padding: '10px 15px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
>
Send
</button>
</div>
</div>
);
};
export default ChatRoom;
useWebSocket 훅은 연결 관리, 재연결 로직, 메시지 처리 콜백 등을 모두 캡슐화하여 컴포넌트의 가독성과 유지보수성을 크게 향상시킵니다.
Next.js 환경에서의 고려사항
Next.js는 서버 사이드 렌더링(SSR), 정적 사이트 생성(SSG) 등 다양한 렌더링 방식을 지원합니다. WebSocket은 기본적으로 클라이언트(브라우저) 환경에서 동작하는 API이므로, Next.js 애플리케이션에서 WebSocket을 사용할 때는 몇 가지 사항을 고려해야 합니다.
- 클라이언트 사이드 전용: WebSocket API는 브라우저의
window객체에 의존합니다. 따라서 SSR 환경에서 코드가 실행될 때WebSocket is not defined와 같은 오류가 발생할 수 있습니다. 이를 방지하려면useEffect훅 내부에서 WebSocket 연결을 수립하거나, 동적 임포트(next/dynamic)를 사용하여 클라이언트에서만 컴포넌트를 렌더링하도록 해야 합니다. 위에서 제시한useWebSocket훅 예시는useEffect내에서 연결을 시도하므로 이러한 문제를 자연스럽게 해결합니다.
- 데이터 페칭과의 조화: Next.js의
getServerSideProps나getStaticProps와 같은 데이터 페칭 함수는 빌드 타임 또는 요청 시에 서버에서 실행됩니다. 이 함수들 내에서 직접 WebSocket 연결을 시도하는 것은 적절하지 않습니다. 초기 데이터는 Next.js의 데이터 페칭 방식으로 가져오고, 그 이후의 실시간 업데이트는 클라이언트 측에서 WebSocket을 통해 처리하는 것이 일반적인 패턴입니다.
- API Routes 활용: Next.js의 API Routes를 사용하여 자체 WebSocket 서버를 구축할 수도 있습니다.
ws라이브러리나socket.io와 같은 라이브러리를 API Route 내에서 활용하여 Node.js 서버처럼 WebSocket 연결을 처리할 수 있습니다. 이는 Next.js 애플리케이션 하나로 프론트엔드와 백엔드 WebSocket 기능을 모두 관리하고 싶을 때 유용합니다.
// pages/api/websocket.ts (예시)
import { Server } from 'ws';
import { NextApiRequest, NextApiResponse } from 'next';
// Next.js API Routes는 요청-응답 모델이므로,
// 직접 WebSocket 서버를 시작하려면 약간의 트릭이 필요합니다.
// 보통 custom server.js를 사용하거나,
// http 서버 인스턴스에 WebSocket 서버를 attach하는 방식을 사용합니다.
// 여기서는 개념적인 예시만 제공하며, 실제 구현은 더 복잡할 수 있습니다.
let wss: Server | null = null;
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (!wss) {
// Next.js 개발 서버의 http-server에 WebSocket 서버를 attach
// 이 코드는 실제 Next.js API Routes에서 직접 동작하기 어렵습니다.
// Next.js와 WebSocket 서버를 함께 실행하려면 보통 별도의 서버 파일을 사용합니다.
// 또는 Next.js custom server.js를 통해 HTTP 서버 인스턴스에 ws를 붙입니다.
//
// 예시:
// const server = require('http').createServer();
// wss = new Server({ server });
// server.listen(8080); // Next.js와 다른 포트 사용
// 이 예시에서는 Next.js API Route 내에서 WebSocket 서버를 직접 시작하는 것은 권장되지 않음
// 일반적으로 Next.js 앱은 프론트엔드 역할만 하고, WebSocket 서버는 별도의 Node.js 서버로 둡니다.
res.status(200).json({ message: 'WebSocket server typically runs on a separate process.' });
return;
}
// WebSocket 클라이언트가 HTTP 요청을 보내는 것이 아니므로,
// 이 API Route는 WebSocket 연결에 직접적으로 사용되지 않습니다.
// 대신, 클라이언트가 ws://localhost:8080과 같은 주소로 직접 연결합니다.
res.status(200).json({ name: 'WebSocket API Route' });
}
이처럼 Next.js API Routes는 직접적인 WebSocket 서버 구현보다는 RESTful API를 제공하는 데 더 적합합니다. 별도의 Node.js 서버를 운영하거나, Next.js의 Custom Server 기능을 활용하여 HTTP 서버 인스턴스에 WebSocket 서버를 붙이는 것이 일반적인 방법입니다.
마무리
WebSocket을 활용한 실시간 통신 구현은 사용자에게 동적이고 반응적인 웹 경험을 제공하는 데 필수적인 기술입니다. React 및 Next.js 환경에서 WebSocket API를 직접 사용하거나, useWebSocket과 같은 Custom Hook을 통해 로직을 추상화함으로써 효율적이고 재사용 가능한 실시간 애플리케이션을 개발할 수 있습니다. 특히 TypeScript를 통해 타입 안정성을 확보하면 대규모 프로젝트에서도 견고한 코드를 유지할 수 있습니다. 이 가이드가 여러분의 실시간 웹 애플리케이션 개발에 유용한 통찰과 실질적인 도움을 제공했기를 바랍니다.
관련 게시글
Vite Build Tool: Fast Frontend Development Guide
Vite는 현대적인 프론트엔드 개발을 위한 빠르고 효율적인 빌드 도구입니다. 이 가이드에서는 Vite의 핵심 기능, React 및 TypeScript 프로젝트 설정, 플러그인 활용법, 그리고 빌드 최적화 전략까지 완벽하게 다룹니다.
React Server Components (RSC) 심층 가이드: Next.js와 함께하는 Full-stack React
React Server Components (RSC)의 개념, 등장 배경, 동작 원리, 그리고 Next.js 13+ App Router에서의 활용법을 심층적으로 다룹니다. 클라이언트/서버 컴포넌트 분리 전략과 실전 코드 예제를 통해 RSC의 강력한 이점을 이해하고 웹 애플리케이션 성능을 최적화하는 방법을 알아봅니다.
Next.js Middleware: 강력한 요청 처리 활용법
Next.js Middleware를 활용하여 사용자 인증, 국제화, A/B 테스트 등 다양한 요청 처리 로직을 효율적으로 구현하는 방법을 심층적으로 알아봅니다. 실전 코드 예제를 통해 Next.js 애플리케이션의 프론트엔드 기능을 강화하세요.