-
19. Message WebSocket Broadcast 방식으로 바꿔주기 - 1사이드프로젝트 2024. 7. 30. 14:16
다음으로 기존에 디비가 바뀐걸 감지하고, 메세지를 표시해주는 방식을 바꿔주기로 하겠다.
이 걸 왜 바꿔주냐면, 디비에 접근하는데는 아무래도 시간이 좀 걸리기 때문에 메세지를 빠르게 입력하면 모든 내용을 표시해주는 게 아니라 몇개가 씹히는 현상이 있다.
이걸 웹소켓 Broadcast방식으로 바꿔줘보자.
https://supabase.com/docs/guides/realtime/broadcast
먼저 메세지를 보내는 부분부터 바꿔주자.
먼저 보내는 부분부터.
위 공식문서에 보면, 메세지를 보내는 방식은 두가지가 있다.
첫번째는 채널을 구독하고 보내는 방식, 두번째는 Rest call로 보내는 방식.(맨 아래로 내리면 있다.)
여기서는 두번째 방식인 REST call로 보내는 방식으로 하겠다.
이유는 현재 components/ChatInput.tsx라는 곳에서 메세지 보내는 역할을 하고 있고,
components/ListMessages.tsx라는 곳에서 메세지들을 표시해주는 역할을 하고 있다.
첫번째 방식 - Sending broadcast messages
// Join a room/topic. Can be anything except for 'realtime'. const channelB = supabase.channel('room-1') channelB.subscribe((status) => { // Wait for successful connection if (status !== 'SUBSCRIBED') { return null } // Send a message once the client is subscribed channelB.send({ type: 'broadcast', event: 'test', payload: { message: 'hello, world' }, }) })
두번째 방식 - Send messages using REST calls
const channel = supabase.channel('test-channel') // No need to subscribe to channel channel .send({ type: 'broadcast', event: 'test', payload: { message: 'Hi' }, }) .then((resp) => console.log(resp)) // Remember to clean up the channel supabase.removeChannel(channel)
보면 알겠지만, 첫번째 방식에서는 구독을 하고, 동기적으로 받아온 status가 ok인지 확인하고, 메세지를 보내는 방식으로 되어있고,
두번째 방식은 그냥 바로 메세지를 보내고 결과를 동기적으로 받아오고, 받아온 결과를 표시해주고 있다.
이걸 메세지 보내는 걸 어떻게 구현하냐를 생각했을 때, 뭐 결과는 같겠지만 두번째 방식이 훨씬 편하고 코드도 깔끔하다.
먼저 첫번째 방식으로 구현해보면(subscribe하는 방식)
// components/ChatInput.tsx "use client"; import React, { useEffect, useState } from "react"; import { Input } from "./ui/input"; import { createClient } from "@/utils/supabase/client"; import { toast } from "sonner"; import { v4 as uuidv4 } from "uuid"; // uuid import import { useUser } from "@/lib/store/user"; // user 정보를 불러온다. import { IMessage, useMessage } from "@/lib/store/messages"; import { RealtimeChannel } from "@supabase/supabase-js"; export const ChatInput = () => { const supabase = createClient(); const [channel, setChannel] = useState<RealtimeChannel>(); const user = useUser((state) => state.user); // user 정보를 불러온다. const { addMessage, optimisticIds } = useMessage((state) => state); // addMessage function을 불러온다. const setOptimisticIDs = useMessage((state) => state.setOptimisticIds); // 메세지 전송 펑션 const handleSendMessage = async (text: string) => { const newMessage = { id: uuidv4(), text, is_edit: false, }; // optimistic update에 사용될 newMessage객체 const newMessageForOpt = { ...newMessage, users: { id: user?.id, avatar_url: user?.user_metadata.avatar_url, created_at: new Date().toISOString(), display_name: user?.user_metadata.user_name, }, created_at: new Date().toISOString(), }; addMessage(newMessageForOpt as IMessage); // 불러온 addMessage 펑션 사용하기 setOptimisticIDs(newMessageForOpt.id); if (!channel) { console.log("!channel"); return null; } channel.send({ type: "broadcast", event: "test", payload: { message: newMessageForOpt }, }); }; useEffect(() => { const chan = supabase.channel("chat-room"); chan.subscribe(); setChannel(chan); return () => { if (channel) { channel.unsubscribe(); setChannel(undefined); } console.log("remove Channel"); }; }, []); return ( <div className="p-5"> <Input placeholder="send message" onKeyDown={(e) => { if (e.key === "Enter" && e.nativeEvent.isComposing === false) { // enter 키를 누르면 메세지가 전송되도록. // e.nativeEvent.isComposing === false - 한글 두번 입력현상 방지 handleSendMessage(e.currentTarget.value); e.currentTarget.value = ""; // 메세지를 전송하고 나서 칸을 비워준다. } }} /> </div> ); };
이렇게 해줄 수 있다.
useState로 채널 상태를 관리하고, useEffect안에서 처음 채널을 만들고 useState에 set해준다.
그리고 handleSendMessage안에서 state에 담긴 채널을 불러다가 send메세지를 사용하는 식이다.
이게 공식홈페이지에 쓰인거랑 조금 다르게 보일 수 있는데, 공식홈페이지에서는 리액트나 넥스트제이에스가 아니라 그냥 자바스크립트에서 쓰는 방식을 보여주고 있다. 그리고, 채널을 구독하고, 콜백함수를 넣어줘서 그 안에서 메세지 보내는 걸 처리하고 있는데,
이렇게 하면 내가 원하는 메세지를 보내려면 어떻게해야할까 고민하며 다양한 시도를 해봤다.
handleSendMessage안에서 메세지를 보낼때마다 channel.subscribe를 해보기도 하는등의 시도를 해봤지만, status가 전부 closed로 나왔다. 아마도 channel을 여러번 불러서가 아닐까 싶다. cmd+클릭을 통해 node_module에 들어가서 확인해봐도
tried to subscribe multiple times. 'subscribe' can only be called a single time per channel instance라며 throw 하는 부분이 있고, 콘솔에서도 저 문구를 본 것 같다.
그래서 useEffect로 처음 시작될 때 채널이 SUBSCRIBE상태가 되면, 그 상태를 state에 저장하고 사용하는 방식이 작동하는 것 같다.
나도 이 방식을 내가 생각해낸 건 아니고, https://www.sitepen.com/blog/building-a-serverless-chat-application-with-supabase
를 보며 도움을 받았다.
이걸 보기전까지 나는 useState는 생각 못하고, 그냥 변수로 채널을 구독하거나, 함수를 호출할때마다 구독을 시키는 등으로 시도했었다.
어쨌든 위 글의 도움으로 간신히 첫번째 방식으로 성공했다.
이제 두번째 방식인데, 두번째 방식이 훨씬 쉽고 간단하고 코드도 깔끔하다. 이건 공식문서만 보고도 할 수 있다.
// components/ChatInput.tsx "use client"; import React, { useEffect, useState } from "react"; import { Input } from "./ui/input"; import { createClient } from "@/utils/supabase/client"; import { toast } from "sonner"; import { v4 as uuidv4 } from "uuid"; // uuid import import { useUser } from "@/lib/store/user"; // user 정보를 불러온다. import { IMessage, useMessage } from "@/lib/store/messages"; import { RealtimeChannel } from "@supabase/supabase-js"; export const ChatInput = () => { const supabase = createClient(); const channel = supabase.channel("chat-room"); // channel *********** const user = useUser((state) => state.user); // user 정보를 불러온다. const { addMessage, optimisticIds } = useMessage((state) => state); // addMessage function을 불러온다. const setOptimisticIDs = useMessage((state) => state.setOptimisticIds); // 메세지 전송 펑션 const handleSendMessage = async (text: string) => { // 빈 메세지가 오지 못하도록 처리 if (!text.trim().length) { toast.error("Message cant not be empty"); return; } const newMessage = { id: uuidv4(), text, is_edit: false, }; // optimistic update에 사용될 newMessage객체 const newMessageForOpt = { ...newMessage, users: { id: user?.id, avatar_url: user?.user_metadata.avatar_url, created_at: new Date().toISOString(), display_name: user?.user_metadata.user_name, }, created_at: new Date().toISOString(), }; addMessage(newMessageForOpt as IMessage); // 불러온 addMessage 펑션 사용하기 setOptimisticIDs(newMessageForOpt.id); // send *************** const res = await channel.send({ type: "broadcast", event: "test", payload: { message: newMessageForOpt }, }); // await을 안붙여도 된다.(비동기) 나는 결과가 어떻게 나오나 보고싶어서 await을 붙였었다. 지금은 뗐다 const { data, error, status } = await supabase // DB에 저장하는 부분. .from("messages") .insert(newMessage); if (error) { toast.error(error.message); console.log(error); } }; useEffect(() => { console.log("useEffect"); return () => { console.log("remove Channel"); supabase.removeChannel(channel); }; }, []); return ( <div className="p-5"> <Input placeholder="send message" onKeyDown={(e) => { if (e.key === "Enter" && e.nativeEvent.isComposing === false) { // enter 키를 누르면 메세지가 전송되도록. // e.nativeEvent.isComposing === false - 한글 두번 입력현상 방지 handleSendMessage(e.currentTarget.value); e.currentTarget.value = ""; // 메세지를 전송하고 나서 칸을 비워준다. } }} /> </div> ); };
위 코드에서 주석으로 *********을 달아놓은 부분정도만 보면 된다. 아, 추가로 useEffect의 클린업 부분까지.
subscribe를 하지 않고 메세지를 보낼 수 있다. 아마도 처음에는 웹소켓 연결이 안되어있는데, 이 걸 실행하고 나면 소켓을 연결시키는 게 아닐까 한다. 공식문서에서도 보면
You can also send a Broadcast message by making an HTTP request to Realtime servers. This is useful when you want to send messages from your server or client without having to first establish a WebSocket connection.
라고 나와있다.
본인에게 맞는 방식을 사용하면 될 거 같다. 나는 두번째 방식을 사용하기로 했다.
그리고 이제 메세지를 받는 쪽.
// components/ListMessages.tsx "use client"; import { IMessage, useMessage } from "@/lib/store/messages"; import React, { useEffect, useRef, useState } from "react"; import Message from "./Message"; import { DeleteAlert, EditAlert } from "./MessageActions"; import { createClient } from "@/utils/supabase/client"; import { toast } from "sonner"; import { ArrowDown } from "lucide-react"; import LoadMoreMessages from "./LoadMoreMessages"; const ListMessages = () => { // const { messages, addMessage, optimisticIds } = useMessage((state) => state); // zustand에 전역으로 저장된 state를 불러옴. // addMessage는 예전에 만든 메세지를 추가하는 메서드 const { messages, addMessage, optimisticIds, optimisticDeleteMessage, optimisticUpdateMessage, } = useMessage((state) => state); // zustand에 전역으로 저장된 state를 불러옴. // addMessage는 예전에 만든 메세지를 추가하는 메서드 const scrollRef = useRef() as React.MutableRefObject<HTMLDivElement>; const [userScrolled, setUserScrolled] = useState(false); // scroll관리를 위한 state const [notification, setNotification] = useState(0); // 새로운 메세지가 왔을때 notification을 위한 state const supabase = createClient(); useEffect(() => { console.log("messages : ", messages); // ******************** 여기 const channel = supabase .channel("chat-room") // room id를 넣어준다. 여기서는 채팅방이 하나만 있으므로 chat-room으로 전부 통일시킨거다. .on("broadcast", { event: "test" }, (payload) => { console.log("payload : ", payload); if (optimisticIds.includes(payload.payload.message.id)) { return; } addMessage(payload.payload.message); }) .subscribe(); return () => { channel.unsubscribe(); }; }, [messages]); useEffect(() => { const scrollContainer = scrollRef.current; if (scrollContainer && !userScrolled) { scrollContainer.scrollTop = scrollContainer.scrollHeight; } }, [messages]); const handleOnScroll = () => { const scrollContainer = scrollRef.current; if (scrollContainer) { // scroll 높이를 계산해 밑에있으면 false, 위로 뜨면 true const isScroll = scrollContainer.scrollTop < scrollContainer.scrollHeight - scrollContainer.clientHeight - 10; setUserScrolled(isScroll); if (!isScroll) { setNotification(0); } } }; // 스크롤을 내려주는 함수 const scrollDown = () => { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }; return ( <> <div className="flex-1 flex flex-col p-5 h-full overflow-y-auto" ref={scrollRef} onScroll={handleOnScroll} > <div className="flex-1 pb-5"> <LoadMoreMessages /> </div> <div className="space-y-7"> {messages.map((value, idx) => { return <Message key={idx} message={value} />; })} </div> {/* 아래로 가는 화살표. 아래로 내려갔을때만 표시되도록. */} {userScrolled && ( <div className="absolute bottom-20 w-full"> {notification ? ( <div className="w-36 mx-auto bg-indigo-500 p-1 rounded-md cursor-pointer hover:scale-110 transition-all" onClick={scrollDown} > <h1>New {notification} Messages</h1> </div> ) : ( <div className="w-10 h-10 bg-blue-500 rounded-full flex justify-center items-center mx-auto border cursor-pointer hover:scale-110 transition-all" onClick={scrollDown} > <ArrowDown /> </div> )} </div> )} <DeleteAlert /> <EditAlert /> </div> </> ); }; export default ListMessages;
useEffect 안쪽을 보면 된다. 전에는 DB의 변화를 감지해서 메세지를 넣어줬었는데, 이제 broadcast로 받아온 정보를 바로 addMessage를 통해 optimistic update를 해주고 있다. 디비에 넣고, 그걸 감지하는 과정이 없이 소켓통신으로만 하기 때문에, 속도가 훨씬 빠르고 빠르게 입력했을 때 가끔 씹히는 현상도 없어졌다.
또, 디비에서 정보를 받아와 업데이트 할 때는 유저의 id만 담겨있었기에, 이름이랑 사진등을 표시해주려고 디비에 다시 셀렉트요청을 보내 받아오는 방식이었는데, 이번에 고친방식은 payload에 유저정보까지 담아서 보내주기 때문에 디비에서 셀렉트 요청을 또 보낼 필요가 없기 때문에 요청도 줄고 속도도 더 빨라졌다.
이제 메세지를 빠르게 연속으로 보내도 상대편에서 누락되는 거 없이 다 잘 나오는 걸 볼 수있다.
메세지 수정과 삭제부분도 고쳐줘야 한다. 또, 현재 메세지를 보낼때마다 인서트해주고 있는데, 이를 고칠지 말지 고민중. 다음글에서 계속...
++ useEffect 밖에서 만든 채널을 useEffect안에서 쓰고, subscribe를 하면 에러가 발생한다. 무한으로 불러버리는 것 같다. dependency Array에 채널을 넣어줘도 그러는 걸 보니, 구독을 하며 실시간으로 뭐가 바뀌는데 그걸 useEffect에 넣어버리니 계속 렌더링해버리는 것 같다. (useEffect안에서 만든 채널을 사용하는 건 괜찮다.)
supabase real time 등으로 자료를 검색해보면 죄다 Postgres Changes (디비 변화를 감지해 업데이트 하는방식) 의 글 만 주로 있어서 조금 애먹었다. 그래도 supabase가 워낙 편리하고, 공식문서가 잘되어있고, 어렵긴했지만 관련 문서도 찾을 수 있어서 좋았다.
'사이드프로젝트' 카테고리의 다른 글
supabase google 연결 (1) 2024.08.30 20. Message Websocket Broadcast방식으로 바꿔주기 2 (0) 2024.07.30 18. Invalid Date 버그처리 (0) 2024.07.29 17. 앞으로 개선사항 (0) 2024.07.29 16. 배포 (0) 2024.07.26