2024.04.16 TIL - 최종프로젝트 22일차 (supabase realtime 실시간 채팅 - 로직 구상, db설계, 1:1 채팅방 구현)

Innes·2024년 4월 16일
1

TIL(Today I Learned)

목록 보기
117/127
post-thumbnail

시행착오

코드

  useEffect(() => {
    supabase
      .channel("messages")
      .on(
        "postgres_changes",
        { event: "INSERT", schema: "public", table: "messages" },
        (payload) => {
          console.log("New message:", payload.new);
        },
      )
      .subscribe();
  }, []);
  • 작성한 메시지가 이렇게 바로 콘솔로 들어온다.

  • 활용 시도

    • payload 넘겨주는 콜백 함수에서 콘솔로그가 아니라, 메시지 리스트를 무효화하는 로직을 넣어봤다.

    • 근데, 이렇게 하면 그냥 useQuery랑 invalidate하는거랑 똑같은거 아닌가...? realtime을 쓰는 의미가 있는건지...?

      // 컴포넌트
      
      useEffect(() => {
        supabase
          .channel("messages")
          .on(
            "postgres_changes",
            { event: "INSERT", schema: "public", table: "messages" },
            (payload) => {
              queryClient.invalidateQueries({
                queryKey: ["messagesList"],
              });
            },
          )
          .subscribe();
      }, []);
      
      const {
        data: messagesList,
        isLoading,
        isError,
      } = useQuery({
        queryKey: ["messagesList"],
        queryFn: getMessages,
      });
      
      if (isLoading) {
        <div>Loading</div>;
      }
      if (isError || messagesList === undefined) {
        <div>Error</div>;
      }
      
      // 메시지 보내기 핸들러
      const handleSendMessage = async (content: string) => {
        await sendMessage({ user_uid: loggedInUserUid, action_id, content });
        setMessage(""); // 메시지를 전송한 후에 입력 필드의 값을 비움
      };
      // api
      
      import { supabase } from "@/utils/supabase/client";
      
      export const sendMessage = async ({
      user_uid,
      action_id,
      content,
      }: {
      user_uid: string;
      action_id: string;
      content: string;
      }) => {
      const { error } = await supabase.from("messages").insert({
        user_uid,
        action_id,
        content,
      });
      
      if (error) {
        console.log("error", error.message);
      }
      };
      
      export const getMessages = async () => {
      const { data, error } = await supabase.from("messages").select("*, users(*)");
      
      if (error) {
        console.log("error", error.message);
      }
      return data;
      };
      

채팅방 현재 인원 표시, 채팅방 들어오고 나가기 표시 등 구체적인 예시들
https://www.sitepen.com/blog/building-a-serverless-chat-application-with-supabase


🏹 트러블슈팅

  • 문제 : 채팅 한번 insert했는데 똑같은게 한번에 2개씩 업로드 되는 이슈...

  • 원인 : setMessage를 비우기 전에 먼저 await로 insert message를 해버리니까 작성한 메시지가 있는 상태에서 여러번 업로드하는게 가능한 로직이라 생긴 이슈.

  • 해결 : message가 없는 경우엔 실행 안되게 만들기 + 채팅 등록 함수 실행시 초기에 먼저 setMessage를 빈값으로 지워줬다.

  // 메시지 보내기 핸들러
  const handleSendMessage = async () => {
    if (message === "") return;
    setMessage(""); // 메시지를 전송한 후에 입력 필드의 값을 비움

    await sendMessage({
      user_uid: loggedInUserUid,
      action_id,
      content: message,
    });
  };

  • 문제 : 불러온 리스트 데이터가 정렬이 안되어있어 채팅 등록하면 아무곳에나 붙는 이슈

  • 원인 : api에서 data return할때 정렬 안된 채로 return 하기 때문

  • 해결 : supabase의 order메서드를 사용하여 데이터를 미리 정렬 후 return 해주자

export const getMessages = async () => {
  const { data, error } = await supabase
    .from("messages")
    .select("*, users(*)")
    .order("created_at", { ascending: true });

  if (error) {
    console.log("error", error.message);
  }
  return data;
};

  • 문제 : 한글로 작성하면 작성한 채팅 + 마지막 글자 해서 총 2개가 업로드되는 기이한 현상

  • 원인 : 영어는 각 알파벳의 나열이지만, 한글은 자음 + 모음 (+받침)이라서 생긴 이슈였다.

  • 해결 : netiveEvent, isComposing(브라우저 기본 내장 메서드)를 사용하여 ⭐️마지막 글자도 앞의 글자에 포함하라고 하자.
    (마지막 글자도 한번에 같이 업로드하라는 의미)

 <Input
    className="w-80 mb-5 mt-10"
    value={message}
    onChange={(e) => setMessage(e.target.value)}
    onKeyDown={(e) => {
              if (e.key === "Enter" && !e.nativeEvent.isComposing) {
                e.preventDefault();
                handleSendMessage();
    }
    }}
 />

  • 문제 : error Could not embed because more than one relationship was found for 'chat_messages' and 'users'

돌아버릴뻔..

  • 시도

    • 테이블에서 외래키 연결되어있는거 하나씩 삭제해가면서 디버깅...
    • users테이블과의 문제라서 select() 에서 users 지워봤더니 오류 안 남
  • 해결 : users테이블에서 *로 다 가져오지 말고, 필요한 것만 가져오는걸로 수정했더니 해결됐음

  • 기존 코드

// 메시지 리스트 가져오기
export const getMessages = async () => {
  const { data, error } = await supabase
    .from("chat_messages")
  // users 테이블에서 전부 다 가져오지 말자
    .select("*, users(*)")
    .order("created_at", { ascending: true });

  if (error) {
    console.log("error", error.message);
  }
  return data;
};
  • 해결 후
// 메시지 리스트 가져오기
export const getMessages = async () => {
  const { data, error } = await supabase
    .from("chat_messages")
    .select("*, users(display_name, profile_img)")
    .order("created_at", { ascending: true });

  if (error) {
    console.log("error", error.message);
  }
  return data;
};


시행착오 2

  • 테이블 수정! : channel명을 채팅방마다 유니크하게 만들고싶은데, messages 테이블은 있는데 room_id를 messages테이블 안에 column으로 넣어야하나...?
    (나중에 또 수정될건데... 흑흑)

  • 기존 : messages 테이블에 id, created_at, user_uid, action_id, content 만 있었음

  • 변경 : chat_rooms 테이블 추가, chat_messages column 수정

    • chat_messages : id, created_at, sender_uid, action_id, room_id
    • chat_rooms : id, created_at, owner_uid, participant_uid, action_id

    -> 효율적인 관리, 단체 체팅방도 구현할 수 있게 됨


(아마도) 최종 로직

db 설계 - 참가자 테이블 분리

🧩 chat_messages : 메시지 테이블

  • id(PK), created_at, sender_uid, room_id, content

🧩 chat_rooms_info : 각 채팅방의 정보

  • id(PK), created_at, action_id, owner_uid, room_type, recruit_number

🧩 chat_participants : 채팅방 참가자 테이블

  • id(PK), created_at, room_id, participant_uid, participant_type

'1:1 문의하기' 채팅방

1. 이미 있는 방인지 먼저 확인하기

  • 참가자 테이블 접근 -> 로그인 유저 uid로 내가 참여중인 방의 room_id 리스트 뽑기

  • 채팅방 테이블 접근 -> room_id리스트 중 room_id 일치 + room_type이 '개인'인 것 + action_id 일치하는것 뽑기
    (room_id 일치하는 것만 가져오면 혹시 단체방일수도 있음 - 내가 액션에 참여신청 해놨어서 단체방에도 들어가있지만, 1:1 문의 하고싶을수도 있기 때문)

    ****** 내가 action장 인 경우도 체크해야되네 ㅠㅠ
    내가 action장 인 경우에도 room_id를 받아서 채널명으로 넣어줘야 채팅하지
    -> 다시 생각해보니 필요 없음. Why?? 액션장도 채팅방 참가자니까 '참가자 테이블'에 존재할 것이기 때문!
    -> But, 참가자 테이블에 'participant_type' column 추가
    (이 참가자가 방장인지, 일반 참가자인지 구분하는 column을 추가함)

1) 이미 방이 있으면 -> room_id 반환
2) 방 없으면 -> chat_rooms_info테이블, chat_participants 테이블에 insert하기 -> room_id 반환

2. 반환받은 room_id를 1:1채팅 모달에 넘겨주기

-> channel명을 room_id로 설정하기


'단체' 채팅방 - action에 참여하는 인원만 입장

개인 액션 등록할 때 단체방도 같이 insert하도록 로직 수정하기!
(type '단체'도 넣어놓기)

1. chat_rooms_info에서 room_id 가져오기

1) 채팅방 테이블 접근
-> type이 '단체'이고, action_id가 일치하는 행의 id 찾기
(액션 id 별 단체 채팅방은 하나뿐이기 때문)
-> return (이게 채팅방 room_id)
-> 모달에 전달 (useRef 사용)

useState는 페이지 렌더링을 유발해서 남용하면 안좋다기에 useRef로 만들었는데, useRef도 결국 거기서 거기라고 해서 흠...

2) 참가자 테이블 접근
-> 일치하는 room_id에 로그인 유저가 참가자 테이블에 이미 있나 확인
-> ❌ room_id, 로그인 유저 uid insert (⭐️ 수정 필요)

⭐️ 수정 : 참여하고 있지 않음 && 채팅인원 < 모집인원
이 경우에 insert하도록 수정!
(두번째 조건도 추가해야함)

3) 나 포함 채팅인원 파악(참여자 테이블에서의 length 활용)
-> room_id로 채팅방인원 파악(length)
-> 외래키 연결된 individual_green_action 테이블에서 모집인원 파악
-> 모집인원 다 찼으면(채팅방인원 === 모집인원) '모집 마감' 처리
(individual_green_action 테이블에서 is_recruiting을 false로)

2. 채팅방 나가기 로직

1) 채팅방 인원 파악(참여자 테이블에서 length)
-> 채팅방 인원 === 모집인원인 경우 (현재 모집마감 상태임)

-> 내가 나가면 '모집중'으로 바꿔야됨
(채팅방 인원 !== 모집인원 인 경우 이미 is_recruiting이 false일 것이기 때문에 굳이 접근할 필요가 없음)

-> action_id들고 individaul_green_action 테이블 접근

-> is_recruiting을 true로 바꿔주기

2) 참가자 테이블에서 로그인유저 uid의 행 삭제


소감

오늘은 오전 오후 내내 supabase realtime에 대해 구글링, gpt, 유튜브 계속 검색하고 읽어보고 무슨 말인지 이해하려고 노력하고...


처음보는 단어들, 로직들 무슨 내용인지 파악하려고 엄청 시간 많이 쓰고 머리도 많이 아팠다.ㅠㅠㅋㅋ 이렇게 난생 처음 만나는 기능들, 처음 보는 로직들을 볼 때마다 멘탈이 흔들린다.
심지어 supabase realtime을 리액트 혹은 next.js에서 구현한 정보들이 생각보다 별로 없었다. 내 구글링 실력 부족인지 모르겠으나...


검색해서 내용들이 많이 나오면 차라리 읽어보면서 이해해볼 수 있을텐데, 한글 정보들은 내가 딱 원하는 정보는 거의 없었고 realtime을 쓰긴 했는데 알림기능이라든지, 채팅을 구현하긴 했는데 flutter 기반이라든지.... 아님 채팅이고 next인데 firebase realtime이었다든지.....
뭔가 내가 딱 원하는 내용들이 별로 없어서 realtime에 대한 코드 및 개념 자체를 이해하기가 너무 어려웠다.ㅜㅜㅠ
공식문서보고 코드를 그대로 가져와봐도 바로 에러나버리고...^^


개념 자체가 이해가 안되니까 뭐 남들 코드를 가져다가 복붙해서 이해해보는 것도 영 안되겠고.... 오전 오후는 진짜 멘탈 많이 흔들렸다. 거의 울기 직전으로 아득바득 한 것 같다.ㅠㅠㅋㅋ


근데 엄청 찾아보고 하면서 그래도 broadcast, channel, subscribe, stream 등 realtime과 관련된 용어들에 조금씩 익숙해짐을 느꼈다. (어떤 기능인지 정확히 파악하지 못했더라도 막연한 느낌까지는 받을 수 있었다.)


그리고 엄청 여러 사이트들(한글, 영어 전부), 영상, gpt코드 등등 보면서 점점 코드들이 뭔가 비슷비슷한 것 같다는 생각이 들었다. 그러던 찰나 진짜 운 좋게도 어떤 영문 블로그를 보고 코드를 가져와서 테스트해보니까 console에 payload.new가 찍히는걸 볼 수 있었다!!!!! 이때 확 희망이 생기기도 하면서, 엥 이게 왜 됐지? 뭐지? 이런 생각도 들고...ㅋㅋㅋ
그러면서 조금씩 조금씩 하나씩 로직을 추가해보고, 중간에 생긴 트러블슈팅들 혼자서 도저히 해결 안되는 것들 튜터님들께 질문도 해가면서 1:1 채팅을 얼추 완성시킬 수 있었다.


진짜 개념 자체가 이해가 안되고 검색해도 제대로 뭐가 나오지도 않으니까 울기 직전으로 엄청 스트레스 많이 받았는데 엥...? 이게 됐네...? ㅎㄷㄷ
튜터님께서 역시 해낼줄 알았다고 쉬웠죠?? 하루만에 하셨네 이러시는데 하하... 그..그러게요 하긴 했네요 난 정말 어려웠는데 이게 되긴 됐으니 너무 어려웠다고 말하는것도 안먹히고(?) ㅠ ㅋㅋㅋ 나 진짜 어려웠는데...


심지어 아직도 모르겠는건, channel을 구독하는데 왜 realtime schema에서 channel 테이블에 아무것도 안보임...?
interface에서 channel이 존재한다했나 운영한다했나 무튼 그래서 테이블에서는 안보이는거라는데 잘 모르겠다 그냥 구현해낸게 대견하고 신기하다ㅠ ㅋㅋㅋ


기능 구현을 마치면 realtime, subscribe, channel의 개념에 대해 좀 더 깊게 고민하고 공부 및 내 언어로 정리해보는 시간을 가지면 아주 유익할 것 같다.

profile
꾸준히 성장하는 우상향 개발자

0개의 댓글