최근 실시간 기능에 대한 이해가 필요해져서 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>
);
}
완성~~