본문 바로가기
웹 개발

Web Socket을 사용하여 간단한 채팅 앱 구현하기

by xosoy 2025. 3. 12.

Web Socket이란?

브라우저와 서버 사이의 전이중(양방향, Full Duplex) 통신 프로토콜 

 

실시간 통신을 위해 Polling 방식을 사용한다면?

  • 일정한 간격으로 서버에 요청을 받는 방식
  • 서버는 클라이언트가 요청할 때마다 데이터를 보낼 수 있다 (단방향 통신)
    • 데이터가 변경되더라도 클라이언트가 요청을 보내지 않으면, 바뀐 데이터를 알 수 없다
  • 클라이언트는 실시간 데이터를 위해 지속적으로 요청을 보내야 한다
    • 불필요한 네트워크 트래픽이 발생하고, 응답이 필요하지 않은 경우에도(데이터가 바뀌지 않았더라도) 요청을 보내서 서버에 부하 증가

이외에도 HTTP 기반의 Long Polling, Streaming, Server-Sent Events(SSE) 등의 방식이 있다.

 

기존 HTTP 기반 실시간 통신 방식의 한계를 극복하기 위해, HTML5에서 처음 Web Socket이 등장했다. 

 

Web Socket의 통신

Web Socket은 TCP 기반의 통신 프로토콜이다.

 

클라이언트와 서버 간의 HandShake

1. 클라이언트가 서버에 연결 요청

GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
  • Upgrade: websocket 헤더를 포함한 요청 → 기존의 HTTP 연결을 Web Socket으로 업그레이드

2. 서버의 연결 승인 응답

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
  • 101 Switching Protocols Web Socket 업그레이드 허용

Web Socket의 통신의 특징

  • TCP 연결이 유지된 상태에서 데이터 송수신
  • 프레임(Frame) 단위의 데이터 교환

 

React에서 Web Socket을 사용해 채팅 기능 구현

서버 구현 (Express)

express를 사용해 간단히 서버를 구현해보았다!

 

1. 우선 채팅방과 메시지를 저장할 수 있도록, sqlite3 라이브러리를 사용해 간단하게 데이터베이스를 만들어주었다.

import sqlite3 from "sqlite3";

const Sqlite3 = sqlite3.verbose();

const db = new Sqlite3.Database("ChatRooms", (error) => {
  if (error) {
    console.error("데이터베이스 연결 실패:", error);
  } else {
    console.log("데이터 베이스 연결 성공");
  }
});

db.serialize(() => {
  db.run(`
    CREATE TABLE IF NOT EXISTS rooms (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      name TEXT NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);

  db.run(`
    CREATE TABLE IF NOT EXISTS messages (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      room_id INTEGER NOT NULL,
      username TEXT NOT NULL,
      message TEXT NOT NULL,
      sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (room_id) REFERENCES rooms(id)
    )  
  `);
});

export default db;

 

2. 채팅방 생성, 채팅방 내 메시지 조회 등 관련 api를 작성해준다.

import express from "express";
import { createServer } from "http";
import cors from "cors";
import db from "./database";

const app = express();

app.use(express.json());
app.use(cors());

const server = createServer(app);

server.listen(8080);

// 모든 채팅방
app.get('/api/rooms', (req, res) => {
  db.all("SELECT * FROM rooms", (err, rows) => {
    if (err) {
      return res.status(500).json({ error: err.message });
    }
    res.json(rows);
  });
});

// 채팅방 생성
app.post('/api/create-room', (req, res) => {
  const { roomName } = req.body;

  db.run("INSERT INTO rooms (name) VALUES (?)", [roomName], function(err) {
    if (err) {
      return res.status(500).json({ error: err.message });
    }
    res.json({ id: this.lastID });
  });
});

// 채팅방 정보
app.get('/api/room/:room_id', (req, res) => {
  const { room_id } = req.params;
  db.get(
    "SELECT * FROM rooms WHERE id = ?", 
    [Number(room_id)], 
    (err, row) => {
      if (err) {
        return res.status(500).json({ error: err.message });
      }
      res.json(row);
    }
  );
});

//채팅방의 메시지 
app.get('/api/room/:room_id/messages', (req, res) => {
  const { room_id } = req.params;
  db.all(
    "SELECT * FROM messages WHERE room_id = ? ORDER BY sent_at ASC", 
    [Number(room_id)], 
    (err, rows) => {
      if (err) {
        return res.status(500).json({ error: err.message });
      }
      res.json(rows);
    }
  );
});

 

3. 이제 웹소켓을 사용해보자!

서버에서는 ws 라이브러리를 설치해서 웹 소켓을 사용할 수 있다. 

import WebSocket from "ws";

const server = createServer(app);
const wss = new WebSocket.Server({ server });

wss.on("connection", (ws, req) => {
  ws.on("message", (data) => {
    console.log("New Message Received:", data.toString());
  });

  ws.on("close", () => {
    console.log("disconnected");
  });
});

 

 

채팅방에 메시지를 보내면, 해당 방에 있는 사람들(클라이언트)에게만 메시지가 가야한다. 그래서 Map<방 번호, 해당 방에 들어가 있는 클라이언트 웹소켓 Set>의 맵 형태로 관리하기로 했다. 

  • 클라이언트
    • 자신이 진입한 채팅방의 번호를 search parameter에 포함해 웹 소켓 연결
    • new WebSocket(`ws://localhost:8080?roomId=${roomId}`
  • 서버
    • request의 url에서 search parameter에서 roomId 분리
    • 맵에 웹소켓 추가
import WebSocket from "ws";

const wss = new WebSocket.Server({ server });

const rooms = new Map<number, Set<WebSocket>>();

wss.on("connection", (ws, req) => {
  // request url에서 방 번호 분리
  const searchParameters = new URLSearchParams(req.url?.split("?")[1]);
  let roomIdParam = searchParameters.get("roomId");

  if (roomIdParam === null) {
    ws.close(4000, "roomId is required");
    return;
  }
  
  const roomId = Number(roomIdParam);
  if (rooms.has(roomId)) {
    rooms.get(roomId)?.add(ws);
  } else {
    rooms.set(roomId, new Set([ws]));
  }
});

 

 

그럼 이제 메시지가 들어오면, 맵을 조회해 해당 채팅방에 속한 웹소켓들에 새로운 메시지가 들어왔음을 알린다.

wss.on("connection", (ws, req) => {
  ....

  // 메시지 이벤트 발생 시
  ws.on("message", (data) => {
    console.log("New Message Received:", data.toString());

    const { roomId, userName, message } = JSON.parse(data.toString());
    // DB에 메시지 저장
    db.run(
      "INSERT INTO messages (room_id, username, message) VALUES (?, ?, ?)", 
      [roomId, userName, message], 
      function(err) {
        // 에러 처리
      }
    );

    const clients = rooms.get(roomId);
    if (!clients) {
      return;
    }
	
    // 각 방에 연결된 웹소켓들(클라이언트)에 메시지 보내기
    for (const client of clients) {
      if (client === ws || client.readyState !== WebSocket.OPEN) {
        continue;
      }
      client.send(data);
    }
  });
});

 

클라이언트가 연결을 끊으면, 맵에서 해당 클라이언트 웹소켓을 제거한다.

ws.on("close", () => {
    console.log("disconnected");

    rooms.get(roomId)?.delete(ws);
    if (rooms.get(roomId)?.size === 0) {
      rooms.delete(roomId);
    }
});

 

클라이언트 (React)

브라우저는 기본적으로 Web Socket을 지원하기 때문에, 따로 라이브러리를 설치하지 않아도 된다.

 

우선 채팅방에 진입하면, 해당 채팅방 내 이전 메시지를 가져와야 한다. 그리고 채팅 메시지가 전송될 때마다 메시지가 업데이트되어야 한다. 이러한 동작을 커스텀 훅으로 구현하기로 했다. 

 

UseChatRoom 훅

각 채팅방은 하나의 ChatManager 인스턴스를 생성하여 사용한다. useRef를 사용해 렌더링되더라도 새로 만들어지지 않도록 한다. 

const useChatRoom = (props: useChatRoomProps) => {
  const { roomId } = props;

  // 채팅방 내 메시지들
  const [messages, setMessages] = useState<SimplifiedMessage[]>([]);
    
  const chatManager = useRef<ChatManager | null>(null);

  useEffect(() => {
    // Chat Manager 인스턴스 생성 -> 웹 소켓 연결
    chatManager.current = new ChatManager(roomId);
    // 웹소켓을 통해 들어온 메시지를 추가
    chatManager.current.setOnMessageEventHandler((message) => {
      setMessages(prev => [...prev, message]);
    });

    // 기존의 메시지 세팅
    RoomManager.getRoomMessage(roomId)
      .then(messages => {
        setMessages(messages.map(msg => simplifyMessage(msg)));
      });

    // 언마운트될 때 웹소켓 연결 끊기
    return () => {
      chatManager.current?.leaveRoom();
      chatManager.current = null;
      setMessages([]);
    }
  }, [roomId]);
};

 

ChatManager 클래스

ChatManager는 다음과 같이 구현했다. 

인스턴스가 생성될 때, 웹소켓 객체를 생성하여 연결한다. 이때 search parameter에 room id를 같이 넣어준다. (이유는 위 서버 부분에 설명되어 있다.)

import { SimplifiedMessage } from "../types";

class ChatManager {
  private roomId: number;
  private socket: WebSocket;

  constructor(roomId: number) {
    this.roomId = roomId;
    this.socket = new WebSocket(`ws://localhost:8080?roomId=${roomId}`);

    this.socket.onopen = () => console.log(`Room ${roomId}: socket opened`);
    this.socket.onmessage = (ev) => console.log(`Room ${roomId}: ${ev.data}`);
    this.socket.onclose = () => console.log(`Room ${roomId}: socket closed`);
  }

  public sendMessage(userName: string, message: string) {
    if (this.socket.readyState === WebSocket.OPEN) {
      const data: SimplifiedMessage = {
        roomId: this.roomId,
        userName,
        message
      };
      this.socket.send(JSON.stringify(data));
    } else {
      throw new Error("websocket is not open");
    }
  }

  public leaveRoom() {
    this.socket.close();
  }

  // 메시지가 들어오면 실행할 함수 등록
  public setOnMessageEventHandler(eventHandler: (message: SimplifiedMessage) => void) {
    this.socket.onmessage = async (ev) => {
      const blob = ev.data as Blob;
      const text = await blob.text();
      const msg = JSON.parse(text);
      console.log(`Room ${this.roomId}:`, msg);
      eventHandler(msg);
    }
  }
}

export default ChatManager;

 

Room 페이지

import { useParams } from "react-router";
import { Box, Button, Input, Snackbar } from "@mui/material";
import { useLayoutEffect, useRef } from "react";

import useChatRoom from "../hooks/useChatRoom";
import { useUserContext } from "../context/UserContext";

const Room = () => {
  const { roomId } = useParams();

  if (roomId === undefined) {
    throw new Error("유효하지 않는 방");
  }

  const { user } = useUserContext();

  const inputRef = useRef<HTMLInputElement | null>(null);

  const { 
    room,
    messages,
    sendMessage,
    errorMsg
  } = useChatRoom({ roomId: Number(roomId) });

  // 메시지가 추가되면 하단으로 스크롤
  useLayoutEffect(() => {
    window.scrollTo({
      top: document.body.scrollHeight,
      behavior: "smooth"
    });
  }, [messages]);

  // 메시지 전송 버튼 클릭
  const onClickSendButton = () => {
    if (!inputRef.current || inputRef.current.value === "") {
      return;
    }
    const message = inputRef.current.value;
    sendMessage(message);
  }

  return (
    <Box component="main">
      <Box 
        position="fixed" 
        width="100%" 
        display="flex"
        alignItems="center"
        justifyContent="space-between"
        style={{
          backgroundColor: "white"
        }}
      >
        <Button href="/rooms" style={{
          height: "fit-content",
          flexShrink: 0
        }}>
          돌아가기
        </Button>

        <h1 style={{
          textAlign: 'center',
          position: "absolute",
          left: "50%",
          transform: "translateX(-50%)",
          width: "70%",
          whiteSpace: "nowrap",
          textOverflow: "ellipsis",
          overflow: "hidden"
        }}>{room?.name}</h1>
      </Box>

      <Box 
        marginBottom="2rem"
        paddingTop="5rem"
        paddingBottom="4rem"
        style={{
          display: 'flex',
          flexDirection: 'column',
          gap: '1rem',
        }}
      >
        {messages.map((message, idx) => {
          const isMyMsg = message.userName === user?.userName;

          return (
            <Box key={`msg-${idx}`} style={{
              display: 'flex',
              flexDirection: 'column',
              alignItems: isMyMsg ? 'flex-end' : 'flex-start'
            }}>
              <p style={{
                fontSize: '0.8rem',
                margin: 0
              }}>{message.userName}</p>
              <p style={{
                backgroundColor: '#e2e2e2',
                borderRadius: '1rem',
                padding: '1rem',
              }}>{message.message}</p>
            </Box>
          );
        })}
      </Box>

      <Box 
        width="100%"
        boxSizing="border-box"
        display="flex"
        gap="1rem"
        position="fixed"
        bottom="0"
        padding="1rem"
        style={{
          backgroundColor: "white"
        }}
      >
        <Input inputRef={inputRef} style={{
          flexGrow: "1"
        }} />
        <Button onClick={onClickSendButton} variant="contained">전송</Button>
      </Box>

      <Snackbar open={errorMsg !== null} message={errorMsg} autoHideDuration={5000} />
    </Box>
  );
};

export default Room;

 

결과

 

 

https://github.com/sykim0181/websocket-chat

 

GitHub - sykim0181/websocket-chat: 웹소켓을 사용한 간단한 채팅 구현

웹소켓을 사용한 간단한 채팅 구현. Contribute to sykim0181/websocket-chat development by creating an account on GitHub.

github.com