
WebSocket을 활용하여 간단한 채팅서버를 구현해보자.
HTTP의 요청-응답 방식으로 실시간 통신을 구현하려면 굉장히 골치가 아파진다.
클라이언트에서 서버에 요청하지 않는 이상 서버는 응답을 줄 수 없기에, HTTP로 실시간 통신을 구현하려면 몇분 몇초 간격으로 지속적인 요청을 해야만 한다.
극단적으로 0.1초 마다 요청을 한다고 해도 완벽한 의미의 실시간 통신은 아닐 뿐더러, 불필요한 요청에 서버 비용만 증가될 뿐이다.
웹소켓(WebSocket)은 클라이언트와 서버 간의 양방향 통신을 실시간으로 가능하게 하는 프로토콜이다.
TCP를 기반으로 HandShake 과정을 거쳐 신뢰성 있는 연결을 보장하며, 패킷 형태의 데이터를 실시간으로 전송해준다.
모노레포 환경이기에 -w / —workspace를 붙였다.
npm install express ws -w=api
웹소켓을 사용할 수 있는 모듈은 ws와 socket.io가 있다. 비슷한 역할을 하지만 다른 개념이라고 한다.
ws:// 또는 wss://) 사용const server = http.createServer(app);
app은 미들웨어, 라우팅 등 HTTP 요청 처리를 담당하는 Express 애플리케이션 객체이다.const ws = new socket.Server({ server });
ws.on('connection', socket => {
socket 객체를 통해 통신할 수 있다.socket.on('message', message => {
message에서 클라이언트가 보낸 메시지를 확인할 수 있다.// src/server.ts
import express from 'express'; // HTTP 요청을 처리하기 위한 Node.js 웹 프레임워크
import http from 'http'; // Node.js의 기본 HTTP 서버 모듈
import socket from 'ws'; // WebSocket 통신을 구현하기 위한 라이브러리.
const app = express();
app.get('/', (req, res) => {
res.send('Hello, Node.js Backend!');
});
const server = http.createServer(app);
const ws = new socket.Server({ server });
ws.on('connection', socket => {
socket.on('message', message => {
console.log(`Received message => ${message}`);
socket.send(`Received message => ${message}`);
});
socket.on('close', () => {
console.log('Client disconnected');
});
});
const port = 3001;
server.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});
브라우저에서 WebSocket을 기본적으로 지원하기에, 백엔드처럼 패키지를 따로 설치할 필요는 없다.
// utils/useWebSocket.ts
import { useEffect, useRef, useState } from 'react';
const useWebSocket = (url: string) => {
const [messages, setMessages] = useState<string[]>([]); // 수신 메시지 저장
const [isConnected, setIsConnected] = useState(false); // 연결 상태
const socketRef = useRef<WebSocket | null>(null);
useEffect(() => {
const socket = new WebSocket(url);
socketRef.current = socket;
// 연결이 열렸을 때
socket.onopen = () => {
console.log('WebSocket connected');
setIsConnected(true);
};
// 메시지를 수신했을 때
socket.onmessage = event => {
setMessages(prev => [...prev, event.data]);
};
// 연결이 닫혔을 때
socket.onclose = () => {
console.log('WebSocket disconnected');
setIsConnected(false);
};
// 에러 처리
socket.onerror = error => {
console.error('WebSocket error:', error);
};
// 컴포넌트가 언마운트되거나 url이 변경될 때 소켓 닫기
return () => {
socket.close();
};
}, [url]);
const sendMessage = (message: string) => {
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.send(message);
} else {
console.error('WebSocket is not connected.');
}
};
return { messages, isConnected, sendMessage };
};
export default useWebSocket;
// test/page.tsx
'use client';
import { useWebSocket } from 'app/utils';
const Home = () => {
const { messages, isConnected, sendMessage } = useWebSocket('ws://localhost:3001');
const handleSendMessage = () => {
sendMessage('Hello from Next.js!');
};
return (
<div>
<h1>WebSocket Example</h1>
<p>Connection Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
<button onClick={handleSendMessage} disabled={!isConnected}>
Send Message
</button>
<div>
<h2>Messages:</h2>
<ul>
{messages.map((msg, index) => (
<li key={index}>{msg}</li>
))}
</ul>
</div>
</div>
);
};
export default Home;
웹소켓으로 데이터를 보낼 때는 문자열(string)이나 바이너리 데이터 형태로만 보내야 한다. 그렇기에 객체 같은 이외의 타입의 데이터를 보내고 싶다면
JSON.stringify(),JSON.parse()를 활용해서 데이터를 변경시켜야 한다.
기본적으로 ChatRoom[] 타입에 해당하는 chatRooms 변수에서 채팅방들을 관리해줄 것이다.
ws.on('message')messageHandlerMessageType 양식에 해당하는 메세지를 전달하게끔 하였다.action에 따라 'create' | 'join' | 'message' | 'leave' | 'delete' 에 해당하는 동작을 처리해주었다.action 과 함께 변경된 ChatRoom을 전송해주었다.// websocket.ts
import http from 'http';
import socket from 'ws';
interface ChatRoom {
roomId: string;
clients: socket[]; // 방에 참여한 WebSocket 클라이언트 목록
users: string[]; // 방에 참여한 사용자 목록
}
interface MessageType {
action: actionType;
roomId: string;
nickname: string;
content: string;
}
type actionType = 'create' | 'join' | 'message' | 'leave' | 'delete';
let chatRooms: ChatRoom[] = [];
const messageHandler = (message: string, socket: socket) => {
const parsedMessage: MessageType = JSON.parse(message);
const { action, roomId, nickname, content } = parsedMessage;
switch (action) {
case 'create': {
const roomExists = chatRooms.some(room => room.roomId === roomId);
if (roomExists) {
socket.send(JSON.stringify({ action: 'error', message: 'Room already exists' }));
break;
}
chatRooms.push({ roomId, clients: [socket], users: [nickname] });
socket.send(JSON.stringify({ action: 'create', chatRooms }));
break;
}
case 'join': {
const joinRoom = chatRooms.find(room => room.roomId === roomId);
if (joinRoom) {
const userExists = joinRoom.users.some(user => user === nickname);
if (userExists) {
socket.send(JSON.stringify({ action: 'error', message: 'User already exists' }));
break;
}
joinRoom.clients.push(socket);
joinRoom.users.push(nickname);
socket.send(JSON.stringify({ action: 'join', chatRooms }));
joinRoom.clients.forEach(client => {
client.send(JSON.stringify({ action: 'join', chatRooms }));
});
} else {
socket.send(JSON.stringify({ action: 'error', message: 'Room not found' }));
}
break;
}
case 'message': {
const messageRoom = chatRooms.find(room => room.roomId === roomId);
if (messageRoom) {
messageRoom.clients.forEach(client => {
client.send(JSON.stringify({ action: 'message', nickname, content }));
});
} else {
socket.send(JSON.stringify({ action: 'error', message: 'Room not found' }));
}
break;
}
case 'leave': {
const leaveRoom = chatRooms.find(room => room.roomId === roomId);
if (leaveRoom) {
leaveRoom.clients = leaveRoom.clients.filter(client => client !== socket);
leaveRoom.users = leaveRoom.users.filter(user => user !== nickname);
if (leaveRoom.clients.length === 0) {
chatRooms = chatRooms.filter(room => room.roomId !== roomId);
}
socket.send(JSON.stringify({ action: 'leave', chatRooms }));
leaveRoom.clients.forEach(client => {
client.send(JSON.stringify({ action: 'leave', chatRooms }));
});
} else {
socket.send(JSON.stringify({ action: 'error', message: 'Room not found' }));
}
break;
}
case 'delete': {
const deleteRoom = chatRooms.find(room => room.roomId === roomId);
if (deleteRoom) {
const tempRooms = chatRooms.filter(room => room.roomId !== roomId);
deleteRoom.clients.forEach(client => {
client.send(JSON.stringify({ action: 'delete', chatRooms: tempRooms }));
});
chatRooms = tempRooms;
} else {
socket.send(JSON.stringify({ action: 'error', message: 'Room not found' }));
}
break;
}
}
};
const setWebSocket = (server: http.Server) => {
const ws = new socket.Server({ server });
ws.on('connection', socket => {
socket.send(JSON.stringify({ action: 'connect', chatRooms }));
socket.on('message', (message: string) => messageHandler(message, socket));
socket.on('close', () => {
chatRooms.forEach(room => {
room.clients = room.clients.filter(client => client !== socket);
});
});
});
return ws;
};
export default setWebSocket;
프론트 단에서 웹소켓을 사용할 때, 백엔드에서의 이벤트 등록과 유사한 방식을 사용한다.
웹소켓 객체에서 제공하는 이벤트 핸들러를 사용하여 서버로부터 수신된 데이터를 처리하거나 상태 변화를 감지하는 방식을 사용하는데, onopen, onmessage, onclose, onerror 등의 이벤트가 이에 해당한다.
createRoom, joinRoom, leaveRoom, deleteRoom, sendMessage 등의 함수에서 각 함수에 대응하는 요청을 연결된 웹소켓에 보내게 된다.onmessage 이벤트 시 보낼 때와 같은 MessageType 형태로 데이터가 오기에 요청에 대한 결과를 확인할 수 있다.chatRooms state를 응답으로 온 최신 데이터로 변경해준다.// useWebSocket.ts
import { useEffect, useRef, useState } from 'react';
interface MessageType {
action: actionType;
roomId: string;
nickname?: string;
content?: string;
}
interface ChatRoomType {
roomId: string;
users: string[]; // 방에 참여한 사용자 목록
}
type actionType = 'create' | 'join' | 'message' | 'leave' | 'delete';
const useWebSocket = () => {
const [input, setInput] = useState<string>(''); // 수신 메시지 저장
const [chatRooms, setChatRooms] = useState<ChatRoomType[]>([]); // 현재 방
const [isConnected, setIsConnected] = useState(false); // 연결 상태
const socketRef = useRef<WebSocket | null>(null);
const url = 'ws://localhost:8080';
useEffect(() => {
const socket = new WebSocket(url);
socketRef.current = socket;
// 연결이 열렸을 때
socket.onopen = () => {
console.log('WebSocket connected');
setIsConnected(true);
};
// 메시지를 수신했을 때
socket.onmessage = event => {
const data = JSON.parse(event.data);
console.log('Received:', data);
const action = data.action;
switch (action) {
case 'connect': {
break;
}
case 'create': {
const serverRoom = data.chatRooms;
setChatRooms([...serverRoom]);
console.log(`Room created with ID: ${data.roomId}`);
break;
}
case 'join': {
const serverRoom = data.chatRooms;
setChatRooms([...serverRoom]);
console.log(`Joined room with ID: ${data.roomId}`);
break;
}
case 'message': {
console.log(`Message from ${data.nickname}: ${data.content}`);
break;
}
case 'leave': {
const serverRoom = data.chatRooms;
setChatRooms([...serverRoom]);
console.log('Left the room');
break;
}
case 'delete': {
const serverRoom = data.chatRooms;
setChatRooms([...serverRoom]);
console.log('delete room');
break;
}
case 'error': {
console.log(`Error: ${data.message}`);
break;
}
}
};
// 연결이 닫혔을 때
socket.onclose = () => {
console.log('WebSocket disconnected');
setIsConnected(false);
};
// 에러 처리
socket.onerror = error => {
console.error('WebSocket error:', error);
};
// 컴포넌트가 언마운트되거나 url이 변경될 때 소켓 닫기
return () => {
socket.close();
};
}, [url]);
const createRoom = (roomId: string, nickname: string) => {
const socket = socketRef.current;
if (!socket) return;
const message: MessageType = {
action: 'create',
roomId,
nickname,
};
socket.send(JSON.stringify(message));
};
// 채팅방 참가
const joinRoom = (roomId: string, nickname: string) => {
const socket = socketRef.current;
if (!socket) return;
const message: MessageType = {
action: 'join',
roomId,
nickname,
};
socket.send(JSON.stringify(message));
};
// 채팅방 나가기
const leaveRoom = (roomId: string, nickname: string) => {
const socket = socketRef.current;
if (!socket) return;
const message: MessageType = {
action: 'leave',
roomId,
nickname,
};
socket.send(JSON.stringify(message));
};
const deleteRoom = (roomId: string) => {
const socket = socketRef.current;
if (!socket) return;
const message: MessageType = {
action: 'delete',
roomId,
};
socket.send(JSON.stringify(message));
};
// 메시지 전송
const sendMessage = (roomId: string, nickname: string) => {
const socket = socketRef.current;
if (!socket) return;
const message: MessageType = {
action: 'message',
roomId,
nickname,
content: input,
};
socket.send(JSON.stringify(message));
};
return { input, setInput, chatRooms, isConnected, createRoom, joinRoom, leaveRoom, deleteRoom, sendMessage };
};
export default useWebSocket;