[WebSocket] React + express 스트리밍 챗봇 만들어보기

최근 실시간 기능에 대한 이해가 필요해져서 WebSocket을 학습하며,
React와 Express를 사용해 스트리밍 방식의 간단한 챗봇 예제를 만들어봤다.

 

WebSocket

- 클라이언트와 서버가 한 번 연결되면, 양방향으로 지속적인 데이터 교환이 가능한 프로토콜

- HTTP는 요청/응답 구조의 단방향 통신이며, 매번 연결이 필요함

 

스트리밍 전송

- 데이터를 한번에 보내지 않고 여러 조각으로 나누어 순차적으로 전송하는 방식

- 사용자가 일부 데이터를 받아 즉시 처리할 수 있도록 활용할 수 있음

ex. 유튜브에서 영상 파일 전체를 받지 않아도 곧바로 재생을 시작할 수 있음, chatGPT같은 챗봇 서비스에서 한문장씩 응답 출력 등

 

 

목표

- 사용자가 버튼으로 질문 전송

- 서버는 전송된 질문에 맞는 답변을 스트리밍 방식으로 전송

- 클라이언트에서는 답변을 실시간으로 표시

 

서버

- express + ws로 구현

- 스트리밍은 setTimeout으로 답변 배열을 순차적으로 전송하는 방식으로 구현

 

import express from "express";
import http from "http";
import { WebSocketServer } from "ws";
import cors from "cors";

const app = express();
app.use(cors());

const server = http.createServer(app);
const wss = new WebSocketServer({ server });

const responses = {
  "안녕?": ["안녕!", "오늘도 좋은 하루 보내 :)"],
  "디저트 추천해줘": [
    "투썸플레이스에 떠먹는 망고생이 새로 나왔다는데",
    "오늘은 이걸 먹어보는 거 어때?",
  ],
};

wss.on("connection", (ws) => {
  console.log("클라이언트 연결됨");

  ws.on("message", (message) => {
    const question = message.toString();
    const answerList = responses[question] || [
      "죄송해요. 아직 답변을 준비 중이에요.",
    ];

    let i = 0;
    const interval = setInterval(() => {
      if (i < answerList.length) {
        ws.send(answerList[i]);
        i++;
      } else {
        clearInterval(interval);
      }
    }, 1000);
  });

  ws.on("close", () => {
    console.log("클라이언트 연결 종료");
  });
});

const PORT = 3001;
server.listen(PORT, () => {
  console.log(`WebSocket 서버가 http://localhost:${PORT} 에서 실행 중`);
});

 

 

클라이언트

소켓 연결

- useEffect로 마운트시 WebSocket 연결 / 언마운트시 연결 종료

- 새 메세지가 수신되면 화면에 렌더링될 수 있도록 state로 수신 메세지를 관리

 

// 소켓 연결, 종료 + 상태관리
import { useEffect, useState } from "react";
import "./App.css";
import ButtonPanel from "./components/ButtonPanel";
import ChatDisplayArea from "./components/ChatDisplayArea";

function App() {
  const [socket, setSocket] = useState(null);
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const ws = new WebSocket("ws://localhost:3001");

    ws.onopen = () => {
      console.log("웹소켓 연결 완료");
      setSocket(ws); // 연결이 완료되면 socket 상태 설정
    };

    ws.onclose = () => {
      console.log("웹소켓 연결 종료");
      setSocket(null); // 연결이 종료되면 socket 상태를 null로
    };

    ws.onmessage = (event) => {
      console.log("새 메시지 수신:", event.data);
      setMessages((prevMessages) => [
        ...prevMessages,
        { type: "chatbot", message: event.data },
      ]);
    };

    return () => {
      console.log("웹소켓 연결 클린업 실행");
      ws.close();
    };
  }, []);

  return (
    <div className="flex flex-col justify-center items-center w-full h-[100dvh]">
      <ChatDisplayArea messages={messages} />
      {socket ? (
        <ButtonPanel socket={socket} setMessages={setMessages} />
      ) : (
        <div>연결 중...</div>
      )}
    </div>
  );
}

export default App;

 

채팅 표시 컴포넌트

- 수신한 메세지 표시

- 메세지가 컴포넌트 높이를 넘어섰을 때 가장 최신 메세지가 보일 수 있도록 scrollIntoView 활용

- 서버에서 수신한 메세지, 사용자가 선택한 질문 구분해서 flex 박스 내부 정렬 바뀔 수 있도록 구현

// 수신 메세지 표시
import { useEffect, useRef } from "react";

export default function ChatDisplayArea({ messages }) {
  const messageEndRef = useRef(null);
  const scrollToBottom = () => {
    messageEndRef.current?.scrollIntoView({ behavior: "smooth" });
  };

  useEffect(() => {
    scrollToBottom();
  }, [messages]);

  return (
    <div className="flex flex-col gap-[10px] w-[500px] h-[300px] p-[10px] bg-red-100 overflow-y-auto">
      {messages.map((message) => (
        <p
          key={`${message}-${crypto.randomUUID()}`}
          className={`${
            message.type === "user" ? "self-end" : "self-start"
          } bg-white rounded-[10px] w-fit px-[10px] py-[5px]`}
        >
          {message.message}
        </p>
      ))}
      <div ref={messageEndRef} />
    </div>
  );
}

 

질문 전송 버튼

- 연결된 socket에 send 메서드를 이용해 질문 전송

- 채팅 표시에 활용할 수 있도록 message 관리 state를 업데이트하며 type: 'user' 함께 전송

// 질문 전송

export default function QuestionButton({ question, socket, setMessages }) {
  const handleClick = () => {
    if (socket?.readyState === 1) {
      console.log("메시지 전송:", question);
      socket.send(question);
      setMessages((prevMessages) => [
        ...prevMessages,
        { type: "user", message: question },
      ]);
    } else {
      console.log("소켓이 연결되지 않음");
    }
  };

  return (
    <button
      onClick={handleClick}
      className="bg-amber-200 rounded-[10px] px-[5px] py-[2px] cursor-pointer"
    >
      {question}
    </button>
  );
}

 

완성~~