-
https://www.youtube.com/watch?v=-xXASlyU0Ck&t=2007s
를 보며 실습하는글
---
지금까지 만든건 기능이 작동하긴 하지만 실시간이 아니다. optimistic update로 개인이 보기엔 실시간인 것처럼 보이지만, 채팅앱은 실시간으로 정보를 주고 받아야 한다. 이제 실시간으로 만든다.
사실 내가 이 프로젝트를 실습하게 된 가장 결정적인 이유다.
https://supabase.com/docs/reference/javascript/subscribe
여기로 들어가보면 channel subscribe에 관한 부분이 있다.
websocket을 사용할 때도, 어떤 room에 들어가고, 그 room안에서 broad cast 하는 식으로 메세지를 보내고 했던 것 같은데, 여기서도 메서드들이 잘 구현되어 있는 것같다.
우선은 insert에 관한 변화를 탐지하고 반영해줄거기 때문에 Listen to inserts를 클릭하고, 관련 코드를 복사한다.
supabase .channel('room1') .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'countries' }, payload => { console.log('Change received!', payload) }) .subscribe()components/listMessages.tsx로 이동해서 useEffect를 넣어준다음 코드를 넣고, 우리꺼에 맞게 살짝 바꿔준다.
"use client"; import { useMessage } from "@/lib/store/messages"; import React, { useEffect } from "react"; import Message from "./Message"; import { DeleteAlert, EditAlert } from "./MessageActions"; import { createClient } from "@/utils/supabase/client"; const ListMessages = () => { const messages = useMessage((state) => state.messages); // zustand에 전역으로 저장된 state를 불러옴. const supabase = createClient(); useEffect(() => { const channel = supabase .channel("chat-room") // room id를 넣어준다. 여기서는 채팅방이 하나만 있으므로 chat-room으로 전부 통일시킨거다. .on( "postgres_changes", { event: "INSERT", schema: "public", table: "messages" }, (payload) => { console.log("Change received!", payload); } ) .subscribe(); return () => { channel.unsubscribe(); }; }, []); return ( <div className="flex-1 flex flex-col p-5 h-full overflow-y-auto"> <div className="flex-1"></div> <div className="space-y-7"> {messages.map((value, idx) => { return <Message key={idx} message={value} />; })} </div> <DeleteAlert /> <EditAlert /> </div> ); }; export default ListMessages;
이제 메세지를 보내고 콘솔이 잘 찍히나 살펴본다.
서버를 껐다가 다시 켜고 페이지도 다시 새로고침해야 반영될 수도 있다.
잘 반영되는 걸 볼 수 있다.
이제 이걸 화면에 표시되게 하자.
여기서 문제가 하나 있는데, 유저 메세지를 표시해주려면 유저의 정보를 받아와야한다. (display_name, avatar_url등)
그런데 payload로 받아온 new를 보면, user_id만 있을 뿐 foreign key로 연결된 다른 정보는 없다.
그래서 혹시 한번에 외래키연결까지 되는게 없나 하고 찾아봤는데
https://github.com/orgs/supabase/discussions/5958
Realtime will only return the row of the table that changed. If you need other foreign tables you will need to do a select with foreign tables included based on the id in the payload, or fetch the foreign table data from the key. This would be done in the payload handler.
이런게 있었다. 리얼타임은 해당 테이블의 데이터만 가지고 온단다. 외래키 테이블의 정보가 필요하다면, 받아온 페이로드를 가지고 한번 더 데이터 패치가 필요하다고 한다. 왠지 좀 더 느려지고 비효율적인 것 같긴 하지만, 일단 계속 진행해본다. 나중에 더 좋은 방법을 찾으면 다시 글을 써야겠다.
받아온 페이로드로 유저정보를 패치해서 받아오도록 useEffect 안을 고쳐줬다.
useEffect(() => { const channel = supabase .channel("chat-room") // room id를 넣어준다. 여기서는 채팅방이 하나만 있으므로 chat-room으로 전부 통일시킨거다. .on( "postgres_changes", { event: "INSERT", schema: "public", table: "messages" }, async (payload) => { // console.log("Change received!", payload); // 받아온 페이로드로 user 정보를 가져오기 const { error, data } = await supabase .from("users") .select("*") .eq("id", payload.new.user_id) .single(); if (error) { toast.error(error.message); } else { const newMessage = { ...payload.new, users: data, }; addMessage(newMessage as IMessage); } } ) .subscribe(); return () => { channel.unsubscribe(); }; }, []);
이제 실시간으로 상대편도 메세지를 받을 수 있다.
그런데 보낸쪽에서는 메세지가 두개 표시되는 걸 볼 수 있는데 이는 optimistic update때문이다.
useEffect에 messages가 변경되었을 때만 상태값을 변경하도록 넣어준다.
const ListMessages = () => { // const { messages, addMessage, optimisticIds } = useMessage((state) => state); // zustand에 전역으로 저장된 state를 불러옴. // addMessage는 예전에 만든 메세지를 추가하는 메서드 const { messages, addMessage } = useMessage((state) => state); // zustand에 전역으로 저장된 state를 불러옴. // addMessage는 예전에 만든 메세지를 추가하는 메서드 const supabase = createClient(); useEffect(() => { console.log("messages : ", messages); const channel = supabase .channel("chat-room") // room id를 넣어준다. 여기서는 채팅방이 하나만 있으므로 chat-room으로 전부 통일시킨거다. .on( "postgres_changes", { event: "INSERT", schema: "public", table: "messages" }, async (payload) => { // console.log("optimisticIds : ", optimisticIds); console.log("Change received!", payload); // 메세지 보낸쪽에서는 왜 이거 콘솔도 안찍히지? // 받아온 페이로드로 user 정보를 가져오기 // if (optimisticIds.includes(payload.new.id)) { // return; // } const { error, data } = await supabase .from("users") .select("*") .eq("id", payload.new.user_id) .single(); if (error) { toast.error(error.message); } else { const newMessage = { ...payload.new, users: data, }; addMessage(newMessage as IMessage); } } ) .subscribe(); return () => { channel.unsubscribe(); }; }, [messages]); // 여기
이러면 messages가 변경사항이 있을 때만, useEffect가 실행되서 렌더링 해주게 된다.
이때 글을 쓴쪽은 optimistic update를 할때 addMessage를 통해 미리 messages를 업데이트 해주기 때문에 리렌더링이 일어나지 않고, 수신자는 messages가 업데이트 되기 때문에 정보를 받아와서 새롭게 렌더링 해주게 된다.
이 때, 만들어진 id를 봤을 때
메세지를 보낸쪽(왼쪽)은 optimistic update로 생성된 id이고, 메세지를 받는쪽(오른쪽)은 실제 db에서 만들어진 id를 받기 때문에, 둘의 아이디가 다른 것을 볼 수 있다.
useEffect에서 이거까진 신경쓰지 않나보다. 배열 크기가 달라졌나 정도만 신경쓰는 듯.
동영상에서는 Optimistic Id를 스토어에 저장하고, payload에 담겨온 아이디가 optimistic Id 배열에 없을 때, addMessage를 하는 방식으로 해줬는데, 내 생각에 어차피 supabase에서 실제로 만들어진 ID와 uuid 라이브러리로 임의로 만든 아이디는 다를 수 밖에 없고(현재까지 만든거에서는 id를 supabase에서 만들기 때문에) useEffect에서 배열의 크기가 달라진걸 감시해서 거르고 있기 때문에,
optimisticId배열로 거를거 까지 아예 가지를 않는다.
이게 좀 더 의미가 있게 하려면 메세지를 만드는 부분에서부터 ID를 만들어서 넘겨줘야 한다. 그리고 이렇게 하면, supabase와 같은 아이디로 맞춰지기 때문에 optimistic update로 수정이나 삭제를 할 때도 좋기 때문에 이렇게 해줘보겠다.
먼저 메세지를 만드는 부분에가서 좀 고쳐줄거다.
components/ChatInput.tsx
"use client"; import React 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"; export const ChatInput = () => { const supabase = createClient(); const user = useUser((state) => state.user); // user 정보를 불러온다. const addMessage = useMessage((state) => state.addMessage); // addMessage function을 불러온다. // 메세지 전송 펑션 const handleSendMessage = async (text: string) => { // 빈 메세지가 오지 못하도록 처리 if (!text.trim().length) { toast.error("Message cant not be empty"); return; } // optimistic update를 해줄 message // const newMessage = { // id: uuidv4(), // text, // user_id: user?.id, // is_edit: false, // created_at: new Date().toISOString(), // users: { // id: user?.id, // avatar_url: user?.user_metadata.avatar_url, // created_at: new Date().toISOString(), // display_name: user?.user_metadata.user_name, // }, // }; const newMessage = { id: uuidv4(), text, // user_id: user?.id, is_edit: false, // created_at: new Date().toISOString(), }; // const { data, error, status } = await supabase // .from("messages") // .insert({ text }); // 위 주석달린것처럼 기존에는 supabase에 text만 인서트해줬지만, 이제 newMessage객체를 만들어서 id포함해서 insert 해준다. const { data, error, status } = await supabase .from("messages") .insert(newMessage); // 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, }, }; addMessage(newMessageForOpt as IMessage); // 불러온 addMessage 펑션 사용하기 if (error) { toast.error(error.message); console.log(error); } }; return ( <div className="p-5"> <Input placeholder="send message" onKeyDown={(e) => { if (e.key === "Enter") { // enter 키를 누르면 메세지가 전송되도록 handleSendMessage(e.currentTarget.value); e.currentTarget.value = ""; // 메세지를 전송하고 나서 칸을 비워준다. } }} /> </div> ); };
위 코드를 보면, 기존에는 supabase 에 insert해줄때 text만 인서트 해줘서 supabase에서 uuid를 자동으로 생성해서 Insert 하게 해줬지만, 이제는 newMessage객체를 만들어서 id를 포함한 메세지를 supabase에 인서트 해주고 있다. 이렇게 함으로써 supabase에서는 여기서 만들어진 id로 인서트를 하게 되고, 아이디가 동기화된다.
그리고 user_id와 created_at은 주석처리한걸 볼 수 있는데,
이는 전에 insert policy를 만들 때
이렇게 해줬기 때문에, 겹치면 에러가 나면서 인서트가 되지 않는다.
이제 데이터를 넣고 확인해보면
이렇게 같은 아이디 인걸 볼 수 있다.
여전히 소켓을 통해 받아온 payload가 optimistic id 리스트에 있느냐 검사하는거는 useEffect의 messages 상태변화에 막혀 별 의미가 없긴 하지만, 위보다는 나아졌다. 그리고 위에서 말했던 것처럼 optimistic update로 수정이나 삭제를 할 때도 좋다.
그럼 별 의미는 없다고 했지만, 혹시모르기도 하니 optimisticID리스트를 만들어서 걸러주는 걸 만들어보도록 하자.
lib/store/message.ts
import { create } from "zustand"; export type IMessage = { created_at: string; id: string; is_deleted: string | null; is_edit: boolean; text: string; user_id: string; users: { avatar_url: string; created_at: string; deleted_at: string | null; display_name: string; id: string; updated_at: string | null; } | null; }; interface MessageState { messages: IMessage[]; addMessage: (message: IMessage) => void; actionMessage: IMessage | undefined; setActionMessage: (message: IMessage | undefined) => void; optimisticDeleteMessage: (messageId: string) => void; optimisticUpdateMessage: (message: IMessage) => void; optimisticIds: string[]; setOptimisticIds: (id: string) => void; } export const useMessage = create<MessageState>()((set) => ({ messages: [], addMessage: (newMessages) => set((state) => ({ messages: [...state.messages, newMessages], })), // 기존 state에 담겨있던 message들 + 이번에 작성한 메세지 actionMessage: undefined, setActionMessage: (message) => set(() => ({ actionMessage: message })), optimisticDeleteMessage: (messageId) => set((state) => { return { messages: state.messages.filter((message) => message.id != messageId), }; }), optimisticUpdateMessage: (updateMessage) => set((state) => { return { messages: state.messages.filter((message) => { if (message.id == updateMessage.id) { message.text = updateMessage.text; message.is_edit = updateMessage.is_edit; } return message; }), }; }), optimisticIds: [], setOptimisticIds: (id: string) => set((state) => ({ optimisticIds: [...state.optimisticIds, id] })), }));
optimisticIds라는 스트링 배열 속성을 추가했고, setOptimisticIds라는 메서드를 만들어 optimisticIds를 추가해주도록했다.
이제 components/ChatInput으로 이동해 addMessage와 함께 setOptimisticIds도 실행해주도록 한다.
"use client"; import React 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"; export const ChatInput = () => { const supabase = createClient(); 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; } // optimistic update를 해줄 message // const newMessage = { // id: uuidv4(), // text, // user_id: user?.id, // is_edit: false, // created_at: new Date().toISOString(), // users: { // id: user?.id, // avatar_url: user?.user_metadata.avatar_url, // created_at: new Date().toISOString(), // display_name: user?.user_metadata.user_name, // }, // }; const newMessage = { id: uuidv4(), text, // user_id: user?.id, is_edit: false, // created_at: new Date().toISOString(), }; // const { data, error, status } = await supabase // .from("messages") // .insert({ text }); const { data, error, status } = await supabase .from("messages") .insert(newMessage); // 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, }, }; console.log("newMessage.id : ", newMessage.id); addMessage(newMessageForOpt as IMessage); // 불러온 addMessage 펑션 사용하기 setOptimisticIDs(newMessageForOpt.id); // 추가!!! if (error) { toast.error(error.message); console.log(error); } }; return ( <div className="p-5"> <Input placeholder="send message" onKeyDown={(e) => { if (e.key === "Enter") { // enter 키를 누르면 메세지가 전송되도록 handleSendMessage(e.currentTarget.value); e.currentTarget.value = ""; // 메세지를 전송하고 나서 칸을 비워준다. } }} /> </div> ); };
추가!!! 라고 한 부분이다.
그리고 components/ListMessages의 useEffect 부분에 살짝 추가해준다.
"use client"; import { IMessage, useMessage } from "@/lib/store/messages"; import React, { useEffect } from "react"; import Message from "./Message"; import { DeleteAlert, EditAlert } from "./MessageActions"; import { createClient } from "@/utils/supabase/client"; import { toast } from "sonner"; const ListMessages = () => { // const { messages, addMessage, optimisticIds } = useMessage((state) => state); // zustand에 전역으로 저장된 state를 불러옴. // addMessage는 예전에 만든 메세지를 추가하는 메서드 const { messages, addMessage, optimisticIds } = useMessage((state) => state); // zustand에 전역으로 저장된 state를 불러옴. // addMessage는 예전에 만든 메세지를 추가하는 메서드 const supabase = createClient(); useEffect(() => { console.log("messages : ", messages); const channel = supabase .channel("chat-room") // room id를 넣어준다. 여기서는 채팅방이 하나만 있으므로 chat-room으로 전부 통일시킨거다. .on( "postgres_changes", { event: "INSERT", schema: "public", table: "messages" }, async (payload) => { console.log("Change received!", payload); // optimisticIds에 아이디가 겹치면 실행되지 않도록 리턴해주기 if (optimisticIds.includes(payload.new.id)) { return; } // 받아온 페이로드로 user 정보를 가져오기 const { error, data } = await supabase .from("users") .select("*") .eq("id", payload.new.user_id) .single(); if (error) { toast.error(error.message); } else { const newMessage = { ...payload.new, users: data, }; addMessage(newMessage as IMessage); } } ) .subscribe(); return () => { channel.unsubscribe(); }; }, [messages]); return ( <div className="flex-1 flex flex-col p-5 h-full overflow-y-auto"> <div className="flex-1"></div> <div className="space-y-7"> {messages.map((value, idx) => { return <Message key={idx} message={value} />; })} </div> <DeleteAlert /> <EditAlert /> </div> ); }; export default ListMessages;
이제 혹시라도 useEffect가 이상하게 작동하여 실행되더라도 optimisticIds 배열에 있는id와 비교하여 리렌더링 하지 않을것이다.
if (optimisticIds.includes(payload.new.id)) {
return;
}요부분이다.
이제 새로운 글을 썼을 때 포커싱이 되도록, 알맞게 스크롤이 내려갈 수 있도록 하겠다.
이게 무슨말이냐면 현재 글을 쓸 때, 글을 입력하고 새로운 글이 추가되도 그대로 위에 있고, 스크롤을 내려야 새로운 글을 볼 수 있는데 자동으로 스크롤이 내려가도록 하겠다.
components/ListMessages.tsx
"use client"; import { IMessage, useMessage } from "@/lib/store/messages"; import React, { useEffect, useRef } from "react"; import Message from "./Message"; import { DeleteAlert, EditAlert } from "./MessageActions"; import { createClient } from "@/utils/supabase/client"; import { toast } from "sonner"; const ListMessages = () => { // const { messages, addMessage, optimisticIds } = useMessage((state) => state); // zustand에 전역으로 저장된 state를 불러옴. // addMessage는 예전에 만든 메세지를 추가하는 메서드 const { messages, addMessage, optimisticIds } = useMessage((state) => state); // zustand에 전역으로 저장된 state를 불러옴. // addMessage는 예전에 만든 메세지를 추가하는 메서드 const scrollRef = useRef() as React.MutableRefObject<HTMLDivElement>; // ref 지정 const supabase = createClient(); useEffect(() => { console.log("messages : ", messages); const channel = supabase .channel("chat-room") // room id를 넣어준다. 여기서는 채팅방이 하나만 있으므로 chat-room으로 전부 통일시킨거다. .on( "postgres_changes", { event: "INSERT", schema: "public", table: "messages" }, async (payload) => { console.log("Change received!", payload); if (optimisticIds.includes(payload.new.id)) { return; } // 받아온 페이로드로 user 정보를 가져오기 const { error, data } = await supabase .from("users") .select("*") .eq("id", payload.new.user_id) .single(); if (error) { toast.error(error.message); } else { const newMessage = { ...payload.new, users: data, }; addMessage(newMessage as IMessage); } } ) .subscribe(); return () => { channel.unsubscribe(); }; }, [messages]); // 새로운 useEffect useEffect(() => { const scrollContainer = scrollRef.current; if (scrollContainer) { scrollContainer.scrollTop = scrollContainer.scrollHeight; } }, [messages]); return ( // 위에서 ref 만들어준거 지정해줌 <div className="flex-1 flex flex-col p-5 h-full overflow-y-auto" ref={scrollRef} > <div className="flex-1"></div> <div className="space-y-7"> {messages.map((value, idx) => { return <Message key={idx} message={value} />; })} </div> <DeleteAlert /> <EditAlert /> </div> ); }; export default ListMessages;
ref를 하나 선언해주고, div랑 묶어준다음 새로운 useEffect를 만들고, 메세지가 갱신될때마다 엮인 scrollContainer의 위가 scrollHeight가 되도록 했다. 이제, 새로운 글이 추가될 때마다 잘 내려간다.
다음으로 수정과 삭제됬을 때도 실시간 처리를 해주자.
Insert 했을때와 마찬가지로
https://supabase.com/docs/reference/javascript/subscribe
여기 들어가서
Listen to Update와 Listen to Delete 에서 복사해서 달아주면 된다. 어떤식이냐면
supabase .channel('room1') .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'countries' }, handleRecordInserted) .on('postgres_changes', { event: 'DELETE', schema: 'public', table: 'countries' }, handleRecordDeleted) .subscribe()
이런식으로. 이것도 Listen to multiple events를 눌러보면 나온다.
그리고 Delete를 하고나서 받아오는 Payload를 보면
이렇게 삭제된 id를 받아오는 걸 볼 수 있다. 이제 이걸 화면에서 지워주면 된다.
components/ListMessages.tsx
const { messages, addMessage, optimisticIds, optimisticDeleteMessage } = useMessage((state) => state); .on( "postgres_changes", { event: "DELETE", schema: "public", table: "messages" }, (payload) => { console.log("Change received!", payload); optimisticDeleteMessage(payload.old.id); } )
state에서 optimisticDeleteMessage를 가져오고, 삭제되었을 때 payload로 담겨온 아이디를 optimisticDeleteMessage에 넣어주면 된다. 이러면, 메세지를 받는쪽에서도 optimistic update로 바로 메세지가 지워진다. (메세지를 지운쪽에서는 지울때 이미 optimistic delete가 되었고)
또 위에서 서버와 클라이언트의 아이디를 맞춰줬기 때문에( 클라이언트에서 uuid로 아이디를 만들어서 서버로 인서트하기 때문에), 생성하고 바로 지워도 둘다 바로바로 반영된다!
업데이트도 딜리트랑 똑같이 해주면 된다.
const { messages, addMessage, optimisticIds, optimisticDeleteMessage, optimisticUpdateMessage, } = useMessage((state) => state); .on( "postgres_changes", { event: "UPDATE", schema: "public", table: "messages" }, (payload) => { console.log("Change received!", payload); optimisticUpdateMessage(payload.new as IMessage); } )
바로바로 업데이트가 잘 되는걸 볼 수 있다!
github : https://github.com/Wunhyeon/Next-Supabase-Chat/tree/11.RealTime
-- 참고할만한 글
zustand 바로반영 문제
'사이드프로젝트' 카테고리의 다른 글
한글 두번씩 입력되는 버그해결 (2) 2024.07.24 12. Arrow down & notification (4) 2024.07.24 10. 메세지 수정 (3) 2024.07.23 9. Message Delete (7) 2024.07.23 8.Message Menu (1) 2024.07.22