Socket을 활용한 멀티 채팅 방 구현 ( feat. SSE vs Socket )

5tr1ker·2023년 9월 4일
2

Server

목록 보기
7/10
post-thumbnail

SSE vs Socket

채팅 방 구현에 앞서 먼저 SSE 와 Socket에 차이에 대해 설명하려고 합니다.
먼저 SSE ( Server-Send Events ) 는 단 방향 이벤트로 서버에서 받는 push Event에 사용되며 Socket 보다 가볍다는 특징을 갖고 있습니다.
단방향이기 때문에 서버 -> 클라이언트로만 통신이 가능하며 그의 반대인 클라이언트 -> 서버로의 통신은 불가합니다.

Socket은 양방향 통신으로 서버 <-> 클라이언트간의 통신이 가능하며 지속적인 TCP 라인을 통해 데이터를 실시간으로 주고받습니다. 연결지향 통신으로 채팅 , 게임 , 주식 차트 등에 사용되며 주기적인 HTTP 요청을 하는 Polling ( Client Pull ) 과는 다르게 연결을 유지합니다.

이때 SSE 의 특징은 다음과 같습니다.

  • WebSocket과 달리 별도의 프로토콜을 사용하지 않고 HTTP 프로토콜만 사용하여 가볍습니다.
  • 접속 오류가 발생 시 자동으로 재연결을 합니다.
  • 최대 동시 접속 수는 HTTP/2 기준 100개 입니다.
  • IE 를 제외한 브라우저에서 지원됩니다.
  • 클라이언트에서 페이지를 닫을 시 감지하기 힘듭니다.

서버 구현 ( Spring Boot )

HandlerChatConfig.java

@Configuration
@RequiredArgsConstructor
@EnableWebSocket
public class HandlerChatConfig implements WebSocketConfigurer {

    private final WebSocketHandler webSocketHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketHandler , "/chat").setAllowedOrigins("*");
    }

}

WebSocketConfigure 를 상속받는 클래스로 WebSocket을 사용하기 위한 기반이 됩니다.
addHandler를 통해 소켓을 연결할 URL를 설정할 수 있으며 , 접속을 허용할 Origin을 지정해 주면 됩니다.

WebSocketHandler.java

@Component
@RequiredArgsConstructor
public class WebSocketHandler extends TextWebSocketHandler {

    private Map<Long , List<WebSocketSession>> sessionList = new HashMap<>();
    private final MeetingRepository meetingRepository;
    private final ChatRepository chatRepository;

    @Override
    @Transactional
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        ObjectMapper objectMapper = new ObjectMapper();
        ChatRequest chatMessage = objectMapper.readValue(message.getPayload(), ChatRequest.class);
        long meetingId = chatMessage.getMeetingId();

        if(chatMessage.getMessageType() == MessageType.ENTER) {
            joinChatBySession(meetingId , session);

        } else if(chatMessage.getMessageType() == MessageType.SEND) {
            sendChatToSameRootId(meetingId , objectMapper , chatMessage);
        }
    }

    private void joinChatBySession(long meetingId , WebSocketSession session) {
        if(!sessionList.containsKey(meetingId)) {
            sessionList.put(meetingId , new ArrayList<>());
        }
        List<WebSocketSession> sessions = sessionList.get(meetingId);
        if(!sessions.contains(session)) {
            sessions.add(session);
        }
    }

    private void sendChatToSameRootId(long meetingId , ObjectMapper objectMapper , ChatRequest chatMessage) throws IOException {
        List<WebSocketSession> sessions = sessionList.get(meetingId);

        for(WebSocketSession webSocketSession : sessions) {
            ChatResponse chatResponse = createChatResponse(chatMessage);
            saveChatData(meetingId , chatResponse);

            String result = objectMapper.writeValueAsString(chatResponse);
            webSocketSession.sendMessage(new TextMessage(result));
        }
    }

    private void saveChatData(long meetingId , ChatResponse chatResponse) {
        Meeting meeting = meetingRepository.findMeetingAndChatById(meetingId)
                .orElseThrow(() -> new MeetingException("모임 정보를 찾을 수 없습니다."));

        Chat chat = Chat.createChat(chatResponse , meeting);
        meeting.getChats().add(chat);
        chatRepository.save(chat);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        for(Long meetingId : sessionList.keySet()) {
            List<WebSocketSession> webSocketSessions = sessionList.get(meetingId);

            for(int i = 0; i < webSocketSessions.size(); i++) {
                WebSocketSession socket = webSocketSessions.get(i);

                if(socket.getId().equals(session.getId())) {
                    webSocketSessions.remove(i);
                    break;
                }
            }
        }
    }
}

해당 클래스는 소켓이 실제로 사용되는 클래스로 방에 입장하거나 , 채팅을 보냈을 때 같은 세션에 등록되어 있는 사용자에게 똑같이 메세지를 보내는 로직이 구현되어 있습니다.

Map<Long , List<WebSocketSession>> sessionList 와 같이 세션을 분리한 이유는 채팅방은 하나만 있는 것이 아닌 여러개의 방이 있기 때문입니다.

  • handlerTextMessage
    소켓에 메세지가 전달되었을 때 실행되는 메서드로 readValue() 메서드를 통해 전송된 메세지를 자바 파일로 변환합니다. 이때 ChatRequest.class 는 개발자가 만든 파일입니다.

  • joinChatBySession
    처음 세션에 등록되었을 때 실행되는 메서드로 만약 처음 세션에 접속한 사람이면 세션을 만들고 , 그게 아니라면 세션에 사용자 정보를 넣습니다.

  • sendChatToSmaeRoodId
    데이터 전송이 되었을 때 같은 방에 접속한 사람들에게 데이터를 전송하는 로직입니다.

  • saveChatData
    필수는 아닌데 이전 채팅에 대한 정보를 저장하기 위한 메서드로 채팅이 전송될 때 마다 DataBase에 데이터가 저장됩니다.

  • afterConnectionClosed
    연결이 끊어졌을 때 실행되는 메서드로 연결이 끊겼을 경우 세션을 제거합니다.

etc

지금 까지 주 구현체만 작성했는데 관련된 구현체 ( Service , Controller , Entity )도 보고 싶다면 GitHub 링크 를 참고해주세요.

클라이언트 구현 ( React.js + javascript )

const [chatRoom, setChatRoom] = useState([]); // 방 번호
  const [meetingId, setMeetingId] = useState(0); // 채팅방이 속한 채팅 방 번호
  const [toogle, setToogle] = useState(true);
  const [chatData, setChatData] = useState([]); // 전송된 데이터 목록
  const [userInfo, setUserInfo] = useState({ userKey: 0, userId: "", profileImage: "" });

  // Chatting 관련 끝
  const webSocketUrl = `ws://localhost:8080/chat`;
  let ws = useRef(null);

  const [socketConnected, setSocketConnected] = useState(false);

  // Chatting 관련 종료

  const [inputData, setInputData] = useState(""); // 입력 데이터
  const onChangeInputData = (e) => {
    setInputData(e.target.value);
  }
  
  // 채팅을 전송했을 때 실행되는 로직으로 만약 소캣이 열려있다면 메세지를 전송합니다.
  const onPushEnter = (e) => {
    if (e.key == 'Enter') {
      if(inputData == "") {
        alert("메세지를 입력해 주세요.");
      }
      if (socketConnected) {
        ws.current.send(
          JSON.stringify({
            sender: userInfo.userId,
            message: inputData,
            meetingId: meetingId,
            senderImage: userInfo.profileImage,
            messageType: "SEND"
          })
        );
        setInputData("");
      }
    }
  }

  useEffect(() => {
    if (toogle == false) {
      // Socket 연결을 시도하는 코드이며 , 각각의 상황에 따라 이벤트 리스너를 등록하고 있습니다.
      if (!ws.current) {
        ws.current = new WebSocket(webSocketUrl);
        ws.current.onopen = () => {
          console.log("connected to " + webSocketUrl);
          setSocketConnected(true);
        };
        ws.current.onclose = (error) => {
          console.log("disconnect from " + webSocketUrl);
          console.log(error);
        };
        ws.current.onerror = (error) => {
          console.log("connection error " + webSocketUrl);
          console.log(error);
        };
        ws.current.onmessage = (evt) => {
          const data = JSON.parse(evt.data);
          setChatData((prevItems) => [...prevItems, data]);
        };
      }

      // 브라우저가 닫히거나 연결이 끊어졌을 경우 소켓을 종료하는 구문이 실행됩니다.
      return () => {
        console.log("clean up");
        ws.current.close();
      };
    }
  }, [toogle])

// 소켓의 값이 변경됐을 경우 ( 열렸을 때 ) 서버에 접속 메세지를 전송합니다.
  useEffect(() => {
    if (socketConnected) {
      ws.current.send(
        JSON.stringify({
          sender: userInfo.userId,
          message: "",
          meetingId: meetingId,
          senderImage: "",
          messageType: "ENTER"
        })
      );
    }
  }, [socketConnected]);

	// 채팅 방에 대한 정보를 가져오는 API , 소켓과는 무관합니다.
  useEffect(async () => {
    await axios({
      method: "GET",
      mode: "cors",
      url: `/chat/participants`
    }).then((response) => {
      setChatRoom(response.data);
    }).catch((err) => {
      console.log(err.response.data)
    });

    await axios({ // 유저의 개인 정보
      method: "GET",
      mode: "cors",
      url: `/users`
    }).then((response) => {
      setUserInfo({ userKey: response.data.data.userKey, userId: response.data.data.id, profileImage: response.data.data.profileImage });
    }).catch((err) => {
      console.log(err.response.data)
    });
  }, []);

	// 이전 채팅에 대한 데이터를 가져옵니다.
  const getChatData = async (roomId, meetingId) => {
    setMeetingId(meetingId);
    setToogle(false);
    await axios({ // 데이터 가져오기
      method: "GET",
      mode: "cors",
      url: `/chat/${meetingId}`
    }).then((response) => {
      setChatData(response.data);
    }).catch((err) => {
      console.log(err.response.data)
    });
  }

  // 채팅 방에 대한 데이터를 화면에 출력합니다.
  const ChatRoomContent = ({ room }) => {
    return (
      room.map(data => (
        <div className="chatContent" key={data.chatId} onClick={() => getChatData(data.chatId, data.meetingId)}>
          <img className="ellipse-chat" alt="image" src={data.meetingImage} />
          <div className="text-wrapper-chat">{data.meetingTitle}</div>
          <div className="text-wrapper-chat-last">마지막 대화 : {data.lastChat}</div>
        </div>
      ))
    )
  }

  // 채팅 데이터에 대한 데이터를 화면에 출력합니다.
  const ChatDataContent = ({ data }) => {
    return (
      data.map(detail => (
        <div className="overlap-group-chatData" key={detail.chatId == 0 ? Math.floor(Math.random() * 10_000_000_001) : detail.chatId}>
          <img
            className="mask-group-chatData"
            alt="Mask group"
            src={detail.senderImage}
          />
          <div className="text-wrapper-7-chatData">{detail.sender}</div>
          <span className="rectangle-chatData">
            {detail.message}
          </span>
          <div className="text-wrapper-4-chatData">{detail.sendTime}</div>
        </div>
      ))
    )
  }

참고

참고 블로그 1 : https://dalili.tistory.com/125
참고 블로그 2 : https://velog.io/@bagt/Spring%EC%97%90%EC%84%9C-%EC%9B%B9-%EC%86%8C%EC%BC%93WebSocket-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0#handletextmessage---%EC%96%91%EB%B0%A9%ED%96%A5-%ED%86%B5%EC%8B%A0-%EB%A1%9C%EC%A7%81
참고 블로그 3 : https://lts0606.tistory.com/569
참고 블로그 4 : https://jcon.tistory.com/186

profile
https://github.com/5tr1ker

0개의 댓글