웹 서비스 채팅 도입기

웹 서비스 채팅 도입기

생성일
2023년 05월 31일
태그
Next.js
WebSocket

Background

현재 중고나라 서비스는 앱에만 채팅 기능이 도입되어있다. 회사에서는 웹의 활성화를 위해 앱 기능과의 sync를 맞추기 위해 노력중이다.
웹에는 로그인, 회원가입 기능이 없고 상품을 검색하고, 상세를 확인하는 것이 전부이다. 팀 내부적으로 기능을 어떤 순서로 도입하는 것이 좋겠는가에 대한 여러가지 회의를 한 결과, 상품 등록보다 ‘웹에서도 상품을 구매할 수 있도록 하는 것이 최우선’ 이라는 결론이 도출되었다.
상품을 구매하는 과정은 앱 서비스에도 웹뷰로 되어있기 때문에 웹 서비스에서 웹뷰를 보여주면 그만이지만, 채팅 기능이 없어서 사실상 웹 서비스 내에서 상품 구매를 원활하게 하는 것이 힘들다고 생각이 들었다.
따라서, 웹에도 채팅 기능을 구현하고, 상품 구매 관련 웹뷰를 웹에서 제공하여 사용자가 웹에서도 상품을 구매할 수 있도록 하기로 결정하였다.

WebSocket vs Polling

 
  • WebSocket.readyState
  • WebSocket.onEvent
  • REST vs Socket
  • requestTime (epochTime)
  • custom hook (useRef)
  • react component mount 개념 다잡기
    • unmount
    • react-portal - useEffect cleanup이 정상적으로 작동하지 않는 이슈
  • Issue
    • re-rendering 될 때 websocket server closed → useRef 사용
    • unmounted 될 때 websocket server closed → cleanup 로직 삭제
    • react-portal useEffect cleanup → useUI로 해결

적용해보기

적용하기로 한 방식은 WebSocket 을 활용하는 방식이며, socket을 사용하는 커스텀 훅을 만들어보았다.
커스텀 훅을 작성하면서 계속 마주했던 문제가 리렌더링이 될 때 새로운 웹소켓 인스턴스를 만든다는 점이였기 때문에 웹소켓 인스턴스에 직접 접근하는 것이 아닌 useRef 를 활용하여 커스텀 훅을 작성하였다.
// src/api/chat/socket/use-chat-socket.tsx import { useEffect, useRef, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { loginStateSelector } from '@contexts/login'; interface SocketResponseProps { action?: string; requestTime?: number; data?: object; } export default function useChatSocketV2() { // 로그인 상태를 recoil에서 불러온다. const isLogin = useRecoilValue(loginStateSelector); const wsRef = useRef<WebSocket | null>(null); const [waitingToReconnect, setWaitingToReconnect] = useState<boolean | null>( null, ); const [messages, setMessages] = useState<SocketResponseProps[]>([]); const [isOpen, setIsOpen] = useState(false); const addMessage = (message: string) => { setMessages((prev) => [...prev, JSON.parse(message)]); }; useEffect(() => { // 비로그인 상태이거나, 웹소켓 인스턴스가 없으면 useEffect를 종료시킨다. if (!isLogin || !!wsRef.current) { return; } if (waitingToReconnect) { return; } // 웹소켓 인스턴스가 없을 때 1회만 웹소켓 인스턴스를 생성한다. if (!wsRef.current) { wsRef.current = new WebSocket( `${process.env.NEXT_PUBLIC_CHAT_API}?token=${localStorage.getItem( 'jn_access_token', )}`, ); const ws = wsRef.current; ws.onerror = (error) => { console.error(error); }; ws.onopen = () => { setIsOpen(true); console.log('ws opened'); ws.send(JSON.stringify({ action: 'ping' })); }; ws.onclose = () => { if (wsRef.current) { console.log('ws closed by server'); } else { console.log('ws closed by app component unmount'); return; } setIsOpen(false); console.log('ws closed'); setWaitingToReconnect(true); setTimeout(() => setWaitingToReconnect(null), 5000); if (isLogin) { setWaitingToReconnect(null); } }; ws.onmessage = (message) => { console.log('received message', JSON.parse(message.data)); if (JSON.parse(message.data).action === 'pong') { localStorage.setItem( 'last_message_at', JSON.parse(message.data).requestTime, ); } addMessage(message.data); }; return () => { console.log('cleanup'); wsRef.current = null; setMessages([]); ws.close(); }; } }, [isLogin, waitingToReconnect]); const value = { wsRef, messages, }; // console.log(value); return { wsRef, messages, isOpen }; }
 
해당 커스텀훅을 사용하면서 만난 2번째 이슈는, 컴포넌트가 unmount될 때 websocket server가 닫히고, mount될 때 다시 열린다는 점이었다. 이는 커스텀훅에 있는 cleanup 로직 때문이었다.
여기서 두 가지 선택방법이 있었다.
  1. cleanup 로직을 삭제하고 WebSocket Server Close 관리를 서버에만 맡기는 방법
      • 서버에 의해서만 WebSocket 연결이 관리되기 때문에 다시 연결하는 과정이 적다.
      • 컴포넌트가 mount, unmount 될 때에도 끊김이 없어 실시간 데이터 확보가 가능하다.
  1. cleanup 로직을 추가하여 WebSocket Server Close 관리를 클라이언트에서도 하는 방법
      • 컴포넌트가 unmount되면 WebSocket 연결이 끊기기 때문에 불필요한 리소스가 소요되지 않는다.
      • 컴포넌트가 mount될 때 새로 연결이 되기 때문에 fresh한 연결을 보장받을 수 있다.
 
모든 페이지에 공통적으로 있는 header 에서 이미 WebSocket 연결이 관리되기 때문에 첫 번재 방법을 사용하기로 결정했고, 커스텀훅에 cleanup 로직을 삭제하였다.
// 최종 import { useEffect, useRef, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { loginStateSelector } from '@contexts/login'; interface SocketResponseProps { action?: string; requestTime?: number; data?: object; } export default function useChatSocketV2() { const isLogin = useRecoilValue(loginStateSelector); const wsRef = useRef<WebSocket | null>(null); const [waitingToReconnect, setWaitingToReconnect] = useState<boolean | null>( null, ); const [messages, setMessages] = useState<SocketResponseProps[]>([]); const [isOpen, setIsOpen] = useState(false); const addMessage = (message: string) => { setMessages((prev) => [...prev, JSON.parse(message)]); }; const closeWebSocket = () => { wsRef.current?.close(); wsRef.current = null; setMessages([]); }; useEffect(() => { if (!isLogin) { wsRef.current?.close(); return; } if (waitingToReconnect) { return; } if (!wsRef.current) { wsRef.current = new WebSocket( `${process.env.NEXT_PUBLIC_CHAT_API}?token=${localStorage.getItem( 'jn_access_token', )}`, ); const ws = wsRef.current; ws.onerror = (error) => { console.error(error); }; ws.onopen = () => { setIsOpen(true); console.log('ws opened'); ws.send(JSON.stringify({ action: 'ping' })); }; ws.onclose = () => { if (wsRef.current) { // wsRef가 null이 아닌데 onclose 이벤트 시 서버에서 소켓 연결 해제 console.log('ws closed by server'); } else { // wsRef가 null이면 클라이언트가 의도적으로 소켓 연결 해제 console.log('ws closed by client'); return; } setIsOpen(false); console.log('ws closed'); setWaitingToReconnect(true); setTimeout(() => setWaitingToReconnect(null), 5000); if (isLogin) { setWaitingToReconnect(null); } }; ws.onmessage = (message) => { console.log('received message', JSON.parse(message.data)); if (JSON.parse(message.data).action === 'pong') { localStorage.setItem( 'last_message_at', JSON.parse(message.data).requestTime, ); } addMessage(message.data); }; } }, [isLogin, waitingToReconnect]); return { wsRef, messages, isOpen, closeWebSocket }; }

References

댓글

guest