-
채팅 기능 구현을 위한 web socket과 sse 방식 POCconcept/React, Redux, RN 2023. 11. 6. 15:29
HTTP 프로토콜의 주요 특징은 비연결성 이다.
위 특징으로 인해 Server가 전송하고 싶어도 해당 Client와 지속적으로 연결이 되어있지 않기 때문에 보낼 수 없는 상황이 발생하게 된다.
이를 해결하는 방식으로 Polling, Long Polling, Web Socket, SSE 총 네 가지가 있다.
Web socket
RFC 6455 명세서에 정의된 프로토콜.
서버와 브라우저 간 연결을 유지한 상태로 데이터 교환이 가능하다. 전송은 커넥션 중단과 추가 HTTP 요청 없이 양방향으로 이루어진다.
Web socket을 구현하는 방법
- Web socket → 기본으로 HTML5에서 제공하는 기능
- socket.io → 웹 소켓 서버를 쉽게 구현할 수 있게 도와주는 모듈이며 서버는 node.js
- sock.js → 웹 소켓 서버를 쉽게 구현할 수 있게 도와주는 모듈이며 서버는 spring framework
간단한 Web socket 구현 예제
- 자바스크립트에서 Web socket 연결
- 위 socket 변수에서 사용할 수 있는 이벤트
- open – 커넥션이 제대로 만들어졌을 때 발생함. 이 때 **send** 함수를 이용해 서버로 데이터를 전송할 수 있다.
- message – 데이터를 수신하였을 때 발생함
- error – 에러가 생겼을 때 발생함
- close – 커넥션이 종료되었을 때 발생함
이렇게 서버 쪽 코드가 동작하면서 open → message → close 순의 이벤트를 볼 수 있다.let socket = new WebSocket('ws://localhost:8002'); socket.onopen = function(e) { alert("[open] 커넥션이 만들어졌습니다."); alert("데이터를 서버에 전송해봅시다."); socket.send("My name is Bora"); // -> 여기서 send 요청을 보냄. }; socket.onmessage = function(event) { alert(`[message] 서버로부터 전송받은 데이터: ${event.data}`); }; socket.onclose = function(event) { if (event.wasClean) { alert(`[close] 커넥션이 정상적으로 종료되었습니다(code=${event.code} reason=${event.reason})`); } else { // 예시: 프로세스가 죽거나 네트워크에 장애가 있는 경우 // event.code가 1006이 됩니다. alert('[close] 커넥션이 죽었습니다.'); } }; socket.onerror = function(error) { alert(`[error]`); };
- 위 socket 변수에서 사용할 수 있는 이벤트
- const baseUrl = 'ws://localhost:8002'; const socket = new WebSocket(`${baseUrl}/chat/rooms/${roomIndex}`);
sock.js 를 이용해 채팅 구현한 예제 코드 뜯어보기
connect(roomIndex) { this.socket = new WebSocket(`${baseUrl}/chat/rooms/${roomIndex}`); this.currentRoomIndex = roomIndex; this.connected = true; this.publish(); } disconnect() { this.sendMessage({ status: "DISCONNECTED" }); this.socket.close(); this.connected = false; this.currentRoomIndex = 0; this.messageLogs = []; this.publish(); } changeInput(value) { this.messageToSend = value; this.publish(); } sendMessage({ status }) { if (status === "CONNECTED") { this.messageToSend = `사용자 ${this.userIndex} 님이 채팅방 ${this.currentRoomIndex}에 입장했습니다.`; } if (status === "DISCONNECTED") { this.messageToSend = `사용자 ${this.userIndex} 님이 채팅방 ${this.currentRoomIndex}에서 나갔습니다.`; } if (status === "SEND") { this.messageToSend = `사용자 ${this.userIndex}: ${this.messageToSend}`; } messageService.sendMessage({ socket: this.socket, messageToSend: this.messageToSend, }); this.messageToSend = ""; this.publish(); }
위와 비슷한 방식으로 connect, disconnect, changeInput, sendMessage 함수를 만들어 구현
참고 자료
WebSocket을 이용한 실시간 채팅 구현하기 2: 클라이언트
SSE
SSE는 웹소켓과 달리, Client가 Server로부터 데이터만 받을 수 있는 방식.
SSE는 별도의 프로토콜을 사용하지 않고 HTTP 프로토콜만으로 사용할 수 있기 때문에 구현이 용이.
접속에 문제가 있으면 자동으로 재연결 시도하지만, 클라이언트가 close해도 서버에서 감지하기 어렵다.
IE를 제외한 모든 브라우저에서 지원하고 있다.
SSE 통신 과정
1. [Client] SSE Subscribe 요청
클라이언트측에서 우선 서버의 이벤트 구독을 위한 요청 전송 → EventSourse
이벤트의 미디어 타입은 text/event-stream이 표준
2. [Server] Subscription에 대한 응답
Response의 미디어 타입은 text/event-stream .
이때 Transfer-Encoding 헤더의 값을 chunked로 설정 (서버는 동적으로 생성된 컨텐츠를 스트리밍 하기 때문에 본문의 크기를 미리 알 수 없기 때문)
3. [Server] 이벤트 전달
클라이언트에서 subscribe를 하면, 서버는 해당 클라이언트에게 비동기적으로 데이터를 전송 가능(데이터는 utf-8로 인코딩된 텍스트 데이터만 가능)
서로 다른 이벤트는 \n\n 로 구분되며, 각각의 이벤트는 한 개 이상의 name:value로 구성
클라이언트에서 서버를 구독하기 위해 활용할 수 있는 인터페이스 EventSourse
- EventSourse 객체
- 서버에 대해서 영구적인 연결을 open 한다.
- error , message , open 이벤트를 가짐.
- error - 연결이 실패하면 발생, 연결 종료 시점에도 발생
- message - 데이터를 수신하면 발생
- open - 연결이 열렸을 때 발생
예시 데모 영상
- 1:1 채팅
- n:n 채팅
예시 클라이언트 코드
"use client"; import React, { useState, useEffect } from "react"; import axios from "axios"; const Chatting = () => { const [user, setUser] = useState(""); const [text, setText] = useState(""); const [messages, setMessages] = useState([]); // 클라이언트에서 서버로 데이터를 보내주는 함수들 const userHandler = (e) => { setUser(e.target.value); }; const textHandler = (e) => { setText(e.target.value); }; const sendTextHandler = () => { const message = { user: user, text: text, }; axios.post("<http://localhost:5500/chat/send>", message); }; // 서버에서 클라이언트로 데이터를 받아오는 코드들 useEffect(() => { const eventSource = new EventSource("<http://localhost:5500/chat>"); // EventSourse 객체를 활용해 서버를 구독 eventSource.onmessage = (event) => { setMessages(JSON.parse(event.data)); }; return () => { eventSource.close(); }; }, []); return ( <div> 유저 <input onChange={userHandler}></input> 메세지 <input onChange={textHandler}></input> <button onClick={sendTextHandler}>전송</button> <br /> 채팅 <div> {messages.map((message) => { return ( <div> <span>{message.user} : </span> <span>{message.text}</span> </div> ); })} </div> </div> ); }; export default Chatting;
예시 서버 코드
const express = require("express"); const app = express(); const bodyParser = require("body-parser"); const cors = require("cors"); app.use(cors()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); const messages = []; // messages 를 담는 전체 배열 app.post("/chat/send", (req, res) => { messages.push(req.body); // messages 라는 전체 배열에 메시지들을 push return res.send("ok"); }); app.get("/chat", (req, res) => { res.writeHead(200, { "Content-Type": "text/event-stream", // SSE 구현을 위한 Header 작성 "Cache-Control": "no-cache", Connection: "keep-alive", }); // Simulate a real-time data source setInterval(() => { res.write(`data: ${JSON.stringify(messages)}\\n\\n`); }, 2000); // 2초에 한번씩 response 보내주기 }); app.listen(5500, () => { console.log("SSE server listening on port 5500"); });
참고 자료
Building A Chat Application Using SvelteJS and SSE
Local Server와 연결해서 채팅 서비스 구현
서버에서 전달 주신 SSE 방식 채팅 구현 방법
- 하나의 SSE 연결(eventSource 구독)로 사용자의 모든 채팅방 메세지를 구독
- graphQL을 통해서 채팅방 데이터 받아오기, 채팅 메시지 전송 등의 데이터를 클라 → 서버로 전달
- 서버에서 변화가 있을 때 마다 eventSource 이벤트를 클라이언트로 전송
- 이벤트 종류
- 처음 SSE 연결을 맺을 때: sseType: "CONNECTED"
- 채팅 이벤트가 생겼을 때: sseType: "CHAT_MESSAGE_PUBLISHED"
- 채팅 메시지 생성: eventType: "CHAT"
- 채팅방 입장: eventType: "JOIN"
- 채팅방 퇴장: eventType: "LEAVE"
- 이벤트 종류
구현하면서 고민되었던 부분
- eventSource를 각 컴포넌트마다 따로 구독해줘야 하는 것이 코드상으로도 깔끔하지 않고 불편하게 느껴졌음.
- 채팅방리스트 컴포넌트와 채팅방 컴포넌트가 있을 때
- 채팅방리스트 컴포넌트에서 eventSource를 구독하고 채팅방 컴포넌트로 넘어갈 경우 eventSource로부터 이벤트를 받아올 수 없음. 다른 컴포넌트라서..
demo
https://youtube.com/shorts/X9R3yWYsYnc
Graphql 쿼리문
// 채팅방 리스트 받아오기 const GET_MY_CHAT_CHANNELS = gql` query MyChatChannels { myChatChannels { id ctype unReadCnt name createdAt } } `; // 채팅 이벤트 데이터 받아오기 const GET_CHAT_CHANNEL_EVENTS = gql` query ChatChannelEvents($channelId: ID!) { chatChannelEvents(channelId: $channelId) { contents { id eventType createdAt ... on ChatChannelChatEvent { id eventType createdAt message sender { id memberName profileImage } } } } } `; // 채팅 메시지 보내기 const SEND_MESSAGE = gql` mutation SendChatMessage($channelId: ID!, $message: String!) { sendChatMessage(channelId: $channelId, message: $message) { id eventType createdAt } } `;
채팅방 리스트 컴포넌트
const ChatChannelList = () => { const [isNewMessage, setIsNewMessage] = useState(null); const { loading, error, data: myChatChannels, } = useQuery(GET_MY_CHAT_CHANNELS); useEffect(() => { // eventSource 구독 const eventSource = new EventSource( "<http://localhost:8080/v1/chats/subscriptions/channels?memberId=1>" ); // 서버에서 이벤트 발생 시 onmessage 메소드로 전달됨 eventSource.onmessage = (event) => { console.log(JSON.parse(event.data)); if (JSON.parse(event.data).sseType === "CHAT_MESSAGE_PUBLISHED") { setIsNewMessage(JSON.parse(event.data)); } }; return () => { // eventSource 구독 끊기 eventSource.close(); }; }, []); return ( <> <h1>채팅방 목록</h1> <div> {myChatChannels?.myChatChannels.map((channel) => { return ( <Link href={`/chat-channel/${channel.id}`}> <ChatChannel key={channel.id}> {isNewMessage?.ssePayload.chatChannelId === channel.id ? ( <ChatChannelAlarm /> ) : null} {channel.name} </ChatChannel> </Link> ); })} </div> </> ); }; export default ChatChannelList;
채팅방 컴포넌트
const ChatChannel = () => { const params = useParams(); const [channelId, setChannelId] = useState(params.channelId); // state const [message, setMessage] = useState(""); // UseQuery const { loading, error, data: chatChannelEvents, refetch, } = useQuery(GET_CHAT_CHANNEL_EVENTS, { variables: { channelId }, }); // UseMutation const [sendMessage, { data: sendChatMessage }] = useMutation(SEND_MESSAGE, { variables: { channelId, message: message, }, }); // function const messageHandler = (e) => { setMessage(e.target.value); }; const sendMessageHandler = () => { sendMessage(); }; const onKeyDownHandler = (e) => { if (e.key === "Enter") { console.log("enter"); sendMessage(); } }; useEffect(() => { // eventSource 구독 const eventSource = new EventSource( "<http://localhost:8080/v1/chats/subscriptions/channels?memberId=1>" ); // 서버에서 이벤트 발생 시 onmessage 메소드로 전달됨 eventSource.onmessage = (event) => { console.log(JSON.parse(event.data)); if (JSON.parse(event.data).sseType === "CHAT_MESSAGE_PUBLISHED") { refetch(); } }; return () => { // eventSource 구독 끊기 eventSource.close(); }; }, []); return ( <> <h1>채팅방</h1> {chatChannelEvents?.chatChannelEvents.contents .slice() .reverse() .map((chatMessage) => { return ( <Message key={chatMessage.id}> {chatMessage.sender.memberName}: {chatMessage.message} </Message> ); })} <MessageInputWrapper> <MessageInput placeholder="메시지를 입력하세요." onChange={messageHandler} onKeyDown={onKeyDownHandler} ></MessageInput> <SendMessageButton onClick={sendMessageHandler}> 보내기 </SendMessageButton> </MessageInputWrapper> </> ); }; export default ChatChannel;
위 코드는 일부 코드만 발췌한 것으로 전체 코드는 아래 링크 된 github repository 에서 확인할 수 있습니다.
예시 코드 링크
'concept > React, Redux, RN' 카테고리의 다른 글
모던 리액트 딥 다이브 (0) 2024.03.03 [Udemy] 클린코드 리액트 - 소개, State (0) 2024.02.04 이미지 최적화를 위한 방법들과 Next/Image (0) 2023.11.05 React에서 DOM에 직접 접근보다 useRef 쓰는 이유 (0) 2022.09.09 redux-persist로 로그인 유지 구현 (0) 2022.09.09