ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 채팅 기능 구현을 위한 web socket과 sse 방식 POC
    concept/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 – 커넥션이 종료되었을 때 발생함
      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]`);
      };
      
      이렇게 서버 쪽 코드가 동작하면서 open → message → close 순의 이벤트를 볼 수 있다.
    • 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

    EventSource - Web APIs | MDN

    • EventSourse 객체
      • 서버에 대해서 영구적인 연결을 open 한다.
      • error , message , open 이벤트를 가짐.
        • error - 연결이 실패하면 발생, 연결 종료 시점에도 발생
        • message - 데이터를 수신하면 발생
        • open - 연결이 열렸을 때 발생

    예시 데모 영상

    • 1:1 채팅

    https://youtu.be/YX_7xu12aGw

    • n:n 채팅

    https://youtu.be/-K4Ez3YqVEo

    예시 클라이언트 코드

    "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

    [React] Realtime으로 데이터 주고받기

    Local Server와 연결해서 채팅 서비스 구현

    서버에서 전달 주신 SSE 방식 채팅 구현 방법

    1. 하나의 SSE 연결(eventSource 구독)로 사용자의 모든 채팅방 메세지를 구독
    2. graphQL을 통해서 채팅방 데이터 받아오기, 채팅 메시지 전송 등의 데이터를 클라 → 서버로 전달
    3. 서버에서 변화가 있을 때 마다 eventSource 이벤트를 클라이언트로 전송
      1. 이벤트 종류
        1. 처음 SSE 연결을 맺을 때: sseType: "CONNECTED"
        2. 채팅 이벤트가 생겼을 때: sseType: "CHAT_MESSAGE_PUBLISHED"
          1. 채팅 메시지 생성: eventType: "CHAT"
          2. 채팅방 입장: eventType: "JOIN"
          3. 채팅방 퇴장: 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 에서 확인할 수 있습니다.

    예시 코드 링크

    https://github.com/oyeon-kwon/sse

    댓글

Designed by Tistory.