Node.js에서 WebSocket 활용하여 채팅 서버 만들어보기

sham·2025년 1월 16일

개요

WebSocket을 활용하여 간단한 채팅서버를 구현해보자.

WebSocket이란?

HTTP의 요청-응답 방식으로 실시간 통신을 구현하려면 굉장히 골치가 아파진다.

클라이언트에서 서버에 요청하지 않는 이상 서버는 응답을 줄 수 없기에, HTTP로 실시간 통신을 구현하려면 몇분 몇초 간격으로 지속적인 요청을 해야만 한다.

극단적으로 0.1초 마다 요청을 한다고 해도 완벽한 의미의 실시간 통신은 아닐 뿐더러, 불필요한 요청에 서버 비용만 증가될 뿐이다.

웹소켓(WebSocket)은 클라이언트와 서버 간의 양방향 통신을 실시간으로 가능하게 하는 프로토콜이다.

TCP를 기반으로 HandShake 과정을 거쳐 신뢰성 있는 연결을 보장하며, 패킷 형태의 데이터를 실시간으로 전송해준다.

본문

세팅

모노레포 환경이기에 -w / —workspace를 붙였다.

npm install express ws -w=api

ws(WebSocket), socket.io?

웹소켓과 socket.io

웹소켓을 사용할 수 있는 모듈은 wssocket.io가 있다. 비슷한 역할을 하지만 다른 개념이라고 한다.

  • ws(WebSocket)
    • HTML5 표준 프로토콜
    • HTTP가 아닌 전용 프로토콜(ws:// 또는 wss://) 사용
    • 표준이기 때문에 대부분의 브라우저와 서버 언어에서 네이티브 지원을 제공
    • 단순한 기능에 적합
  • socket.io
    • WebSocket을 기반으로 추가 기능을 제공하는 라이브러리
    • WebSocket이 지원되지 않는 환경에서는 Long Polling 등 다른 기술로 자동 폴백하여 연결을 유지(호환성)
    • 비교적 쉬운 구현
    • 복잡한 실시간 애플리케이션에 유리

소켓 연결하기

백엔드 (Node.js)

const server = http.createServer(app);

  • 인자에 들어간 app은 미들웨어, 라우팅 등 HTTP 요청 처리를 담당하는 Express 애플리케이션 객체이다.
    • express는 HTTP 요청만 처리할 수 있기에, WebSocket 요청도 처리하려면 별도의 처리가 필요하다.
  • Express와 WebSocket 서버를 통합하기 위해 server라는 HTTP 서버를 새로 만들고, 먼저 express 앱을 통합하는 코드이다.

const ws = new socket.Server({ server });

  • socket.Server로 서버를 생성하면서 HTTP 서버 객체인 server를 전달해준다.
  • WebSocket은 HTTP 업그레이드 요청(Upgrade Request)을 통해 연결을 시작한다.
    • WebSocket 연결은 먼저 HTTP 요청으로 시작되고, 이후에 프로토콜이 WebSocket으로 전환되는 방식이다.
    • 그렇기에 WebSocket 서버가 HTTP 서버와 연결해야 HTTP 업그레이드 요청을 감지하고 WebSocket 연결을 처리할 수 있다.

ws.on('connection', socket => {

  • 클라이언트 단에서 새로운 WebSocket이 연결되었을 때 호출될 이벤트 리스너이다.
  • 연결된 클라이언트는 인자인 socket 객체를 통해 통신할 수 있다.

socket.on('message', message => {

  • 클라이언트에서 WebSocket을 통해 메시지를 보냈을 때 호출되는 이벤트 리스너이다.
  • 인자인 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}`);
});

프론트 (Next.js)

브라우저에서 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;

채팅방 CRUD 구현

웹소켓으로 데이터를 보낼 때는 문자열(string)이나 바이너리 데이터 형태로만 보내야 한다. 그렇기에 객체 같은 이외의 타입의 데이터를 보내고 싶다면 JSON.stringify(), JSON.parse() 를 활용해서 데이터를 변경시켜야 한다.

백엔드

기본적으로 ChatRoom[] 타입에 해당하는 chatRooms 변수에서 채팅방들을 관리해줄 것이다.

  • ws.on('message')
    • 소켓이 연결된 클라이언트 단에서 메세지를 보냈을 때 처리를 담당하는 함수를 따로 분리해주었다.
  • messageHandler
    • MessageType 양식에 해당하는 메세지를 전달하게끔 하였다.
    • 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 등의 이벤트가 이에 해당한다.

  • 해당 커스텀 훅에서 Node.js 서버(백엔드)에 웹소켓을 연결한다.
  • createRoom, joinRoom, leaveRoom, deleteRoom, sendMessage 등의 함수에서 각 함수에 대응하는 요청을 연결된 웹소켓에 보내게 된다.
  • onmessage 이벤트 시 보낼 때와 같은 MessageType 형태로 데이터가 오기에 요청에 대한 결과를 확인할 수 있다.
    • 인자로 변경된 chatRooms이 전달되고, 데이터를 추가, 변경, 삭제하는 작업 시 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;

레퍼런스

웹소켓과 socket.io

profile
씨앗 개발자

0개의 댓글