팀 최종 프로젝트[BOOKER] 채팅파트 분석하기

규갓 God Gyu·2024년 7월 16일
0

프로젝트

목록 보기
60/81

이미 2월에 종료했던 프로젝트지만.. 나의 이력서의 이력을 채우기 위해,
가장 성능개선에 많은 도움이 되었던 스파르타 코드 즉 300줄이 넘는 코드를 줄여나가는 과정에서 필수였던 채팅파트를 분석하는 날이 나에게 오고야 말았다....

그치만 내가 아닌 다른 개발자의 초기 고민부터 해결과정을 온전히 체감할 수 있는 소중한 기회라 생각하자..!

1차 코드

import { useMutation, useQueryClient } from 'react-query';
import { supabase } from './supabase.api';

import { useRecoilState } from 'recoil';
import { ChatId } from '../atom/product.atom';
import { ChatData } from '../components/qna/ChatModal';

// 공통으로 뺐어요(물론 .env를 쓰는게 더 바람직해요)

// 챗방 생성 또는 가져오기 로직을 커스텀 훅으로 분리
export function useCreateOrGetChat() {
  const queryClient = useQueryClient();
  const [chatId, setChatId] = useRecoilState(ChatId);

  const createOrGetChat = async ({
    userId,
    otherUserId,
    productId,
  }: {
    userId: string;
    otherUserId: string;
    productId: number;
  }) => {
    console.log('챗확인1', userId);
    console.log('챗확인2', otherUserId);
    console.log('챗확인3', productId);
    // userId와 productId에 대한 챗방 존재 여부 확인
    const { data: chatUser } = await supabase
      .from('chats_users')
      .select('chat_id')
      .eq('user_id', userId)
      .eq('others_id', otherUserId)
      .eq('item_id', productId)
      .maybeSingle();

    // // otherUserId와 productId에 대한 챗방 존재 여부 확인
    // const { data: chatOther } = await supabase
    //     .from('chats_users')
    //     .select('chat_id')
    //     .eq('others_id', otherUserId)
    //     .eq('item_id', productId)
    //     .maybeSingle();

    //     console.log('챗방확인1',chatUser)
    //     console.log('챗방확인2',chatOther)
    // // 두 결과가 같은 chat_id를 가지고 있는지 확인
    if (chatUser && chatUser.chat_id) {
      setChatId(chatUser.chat_id);
      console.log('4챗방확인4');
    } else {
      // 기존 챗방이 없으므로 새 챗방 생성
      const { data: newChatData, error: newChatError } = (await supabase
        .from('chats')
        .insert({}) //id: ChatId
        .select() //데이터가져오기.
        .single()) as { data: ChatData; error: any }; // 타입 단언 사용
      // 챗방을 생성하고 바로 그 데이터를 반환하는 것을 가정

      if (newChatError) {
        console.error('새 챗방 생성 중 오류 발생:', newChatError);
        return;
      }

      // 새 챗방에 두 사용자를 chats_users에 추가
      if (newChatData) {
        console.log('1', newChatData.id);

        const { error } = await supabase.from('chats_users').insert([
          { chat_id: newChatData.id, user_id: userId, others_id: otherUserId, item_id: productId },
          { chat_id: newChatData.id, others_id: userId, user_id: otherUserId,  item_id: productId },
        ]);

        if (error) {
          console.error('새 챗방에 사용자 추가 중 오류 발생:', error);
          return;
        }

        // ChatId를 새 챗방의 id로 설정
        setChatId(newChatData.id);
      }
    }
  };

  return useMutation(createOrGetChat, {
    onSuccess: () => {
      // 챗방 관련 쿼리를 무효화하거나 업데이트
      queryClient.invalidateQueries('chats');
    },
  });
}

// 메시지 전송 로직을 커스텀 훅으로 분리
export function useSendMessage() {
  const queryClient = useQueryClient();

  const sendDirectMessage = async ({
    content,
    authorId,
    chatId,
  }: {
    content: string;
    authorId: string;
    chatId: string;
  }) => {
    console.log('1', content);
    console.log('1', authorId);
    console.log('1', chatId);

    const { error } = await supabase
      .from('messages')
      .insert([{ content: content, author_id: authorId, chat_id: chatId }]);

    if (error) {
      throw new Error('메시지 삽입 중 오류가 발생했습니다');
    }
  };
  console.log('메시지 성공');
  return useMutation(sendDirectMessage, {
    onSuccess: () => {
      // 메시지 전송 성공 시 취할 행동, 예: 쿼리 무효화나 업데이트
      queryClient.invalidateQueries('messages');
    },
  });
}

// // 컴포넌트에서 사용 예
//   function ChatComponent({ userId, otherUserId, productId }) {
//     const { mutate: createOrGetChat } = useCreateOrGetChat();

//     const handleCreateChat = () => {
//       createOrGetChat({ userId, otherUserId, productId });
//     };

//     // ...
//   }

// 메시지 전송 핸들러
//   const KeyPresshandler = async (event: React.KeyboardEvent<HTMLInputElement>) => {
//     if (event.key === 'Enter' && inputValue.trim()) {
//       sendMessage({ content: inputValue, authorId: otherLoginPersonal, chatId });
//       setInputValue('');
//     }
//   };

//   // ... 나머지 컴포넌트 코드
// };

export {};

// const addTodo = async (newTodo) => {
//   await axios.post(`${SERVER_URI}/todos`, newTodo);
// };

// 쿼리 invalidation 사용 예시

// import { addTodo } from "../../../api/todos";
// import { QueryClient, useMutation } from "react-query";
// ...

// function Input() {
// ...
//     const queryClient = new QueryClient();

//     const mutation = useMutation(addTodo, {
//       onSuccess: () => {
//         // Invalidate and refresh
//         // 이렇게 하면, todos라는 이름으로 만들었던 query를
//         // invalidate 할 수 있어요.
//         queryClient.invalidateQueries("todos");
//       },
//   });

// Mutation.mutate()

// 쿼리 컴포넌트 사용 예시

// const { isLoading, isError, data } = useQuery("todos", getTodos);

// if (isLoading) {
//   return <p>로딩중입니다....!</p>;
// }

// if (isError) {
//   return <p>오류가 발생하였습니다...!</p>;

자 이 어마 무시한 200줄 가량의 코드를 분석해보자...!!

일단 해당 코드는 초기 설계에선 recoil과 react query 그리고 supabase의 realtime을 이용해서 구현하려고 시도했었다.. 물론 결과는 실패!
그 원인을 한번 찾아보자..

import { useMutation, useQueryClient } from 'react-query';
import { supabase } from './supabase.api';

import { useRecoilState } from 'recoil';
import { ChatId } from '../atom/product.atom';
import { ChatData } from '../components/qna/ChatModal';
  • 일단 react-query에서 useMataion과 useQueryClient를 활용해서 서버와 비동기 작업, 캐시를 관리하려 했었음
  • supabase를 db로 활용하려 했음
  • recoil의 useRecoilState로 해당 컴포넌트에서 어떤 데이터 값을 recoil 상태관리하려 시도했음
  • recoil안에서는 ChatId와 ChatData가 recoil atom과 컴포넌트에서 사용되는 타입
export function useCreateOrGetChat() {
  const queryClient = useQueryClient();
  const [chatId, setChatId] = useRecoilState(ChatId);

  const createOrGetChat = async ({ userId, otherUserId, productId }) => {
    console.log('챗확인1', userId);
    console.log('챗확인2', otherUserId);
    console.log('챗확인3', productId);
    // userId와 productId에 대한 챗방 존재 여부 확인
    const { data: chatUser } = await supabase
      .from('chats_users')
      .select('chat_id')
      .eq('user_id', userId)
      .eq('others_id', otherUserId)
      .eq('item_id', productId)
      .maybeSingle();
  • createOrGetChat함수는 userId, otherUserId, productId에 대해 db인 supabase에서 채팅방을 찾거나 생성하기 위한 함수, 여기서 필요한 정보는 본인 id, 상대방 id, 챗방 id로 생각하면 될 것 같다.
    if (chatUser && chatUser.chat_id) {
      setChatId(chatUser.chat_id);
      console.log('4챗방확인4');
    } else {
      // 기존 챗방이 없으므로 새 챗방 생성
      const { data: newChatData, error: newChatError } = (await supabase
        .from('chats')
        .insert({})
        .select()
        .single()) as { data: ChatData; error: any }; // 타입 단언 사용

      if (newChatError) {
        console.error('새 챗방 생성 중 오류 발생:', newChatError);
        return;
      }
  • 채팅할 유저나 유저의 챗방 아이디가 있다면 기존 챗방을, 없다면 새로운 채팅방을 supabase를 통해 생성 후, select()로 생성된 데이터를 가져오는 방식 채택
      if (newChatData) {
        console.log('1', newChatData.id);

        const { error } = await supabase.from('chats_users').insert([
          { chat_id: newChatData.id, user_id: userId, others_id: otherUserId, item_id: productId },
          { chat_id: newChatData.id, others_id: userId, user_id: otherUserId,  item_id: productId },
        ]);

        if (error) {
          console.error('새 챗방에 사용자 추가 중 오류 발생:', error);
          return;
        }

        // ChatId를 새 챗방의 id로 설정
        setChatId(newChatData.id);
      }
    }
  };
  • 새 채팅방이 성공적으로 생성되면 supabase의 chats_users 테이블에 두 사용자에 대한 레코드를 추가한다.
  • 오류에 대한 처리도 콘솔로 알려준다
  • 기존챗방 or 생성한챗방에 대해 실제로 구현하기 위해 새 챗방의 아이디를 chatId로 상태로 설정
  return useMutation(createOrGetChat, {
    onSuccess: () => {
      queryClient.invalidateQueries('chats');
    },
  });
}
  • 비동기 처리이기 때문에, useMutation을 사용해 createOrGetChat함수를 감싸고, 성공 시 chats관련 쿼리를 무효화해 최신 상태 유지 시키기
export function useSendMessage() {
  const queryClient = useQueryClient();

  const sendDirectMessage = async ({ content, authorId, chatId }) => {
    console.log('1', content);
    console.log('1', authorId);
    console.log('1', chatId);

    const { error } = await supabase
      .from('messages')
      .insert([{ content: content, author_id: authorId, chat_id: chatId }]);

    if (error) {
      throw new Error('메시지 삽입 중 오류가 발생했습니다');
    }
  };
  console.log('메시지 성공');
  return useMutation(sendDirectMessage, {
    onSuccess: () => {
      queryClient.invalidateQueries('messages');
    },
  });
}
  • 같은 컴포넌트 내에서 quertClient를 또 선언한걸 보아 컴포넌트 분리 없이 한 컴포넌트에 몰아서 함수 선언하는 모습(Bad)

  • 비동기 함수 sendDirectMessage는 내용, 작성자 ID, 채팅방 ID 데이터를 이용해서 메세지를 supabase안에 넣어주는 모습, 에러 처리 good
  • useMutation을 사용해 sendDirectMessage함수를 감싸고, 성공 시 messages관련 쿼리를 무효화하여 최신 상태로 유지, 즉 메세지 최신화를 해놓겠다는 의미로 해석됨
//주석된 수많은 코드들
  • 위의 코드들을 기반으로 실제로 호출하기 위한 코드들을 구현해보았으나 호출이 되지 않음

그리고 실제로 챗을 보여줄 컴포넌트에서는

        // 각 채팅방에 대해 사용자의 닉네임과 마지막 메시지를 가져오기
        const updatedChatRooms = await Promise.all(
          chatRoomsData.map(async (chatRoom) => {
            // 'chats_users' 테이블에서 채팅방에 대한 '받는 사람'의 user_id 가져오기
            const { data: chatUser, error: chatUserError } = await supabase
              .from('chats_users')
              .select('user_id,others_id,item_id') //others_id=>user_id로 바꿈 상점에서 a->b  받는 사람 b // 메인에서 b->a 받는사람 a 근데 b가 로직이 꼬여서 받는사람이자 보내는사람이 되버림.
              .eq('chat_id', chatRoom.id)
              .single(); // 채팅방에 속한 '받는 사람'은 한 명만 있다고 가정

선언한 컴포넌트를 불러오는 코드가 아닌것도 문제지만, 받는 사람과 보내는 사람이 구분이 제대로 되고있지 않음

문제점

  1. 기초 기능 구현이 되지도 않았는데 벌써부터 상태관리를 하겠다고 recoil과 react-query를 사용해서 리펙토링 과정을 스킵하고 너무 성급하게 코드를 구현하였다.
  2. 충분히 컴포넌트를 분리하고 진행할 수 있음에도, 한 컴포넌트에 몰아서 구현을 하다보니 코드가 200줄이 넘어갔다.
  3. websoket에 대한 이해도 없이 supabase의 realtime으로 구현할 수 있다는 말만 듣고 너무 사전 조사 없이 바로 코드부터 구현하였다.

이러한 부분들 때문에 3주동안 제대로 코드 구현이 안되었다 생각된다..... maybe..?

그래서 다른 긴 코드인 recoil, query 실제 채팅 구현시킬 컴포넌트는 이미 이 부분부터 문제가 있으므로 skip!!

하지만 여기서 마냥 코드만의 문제점만 있던건 아니였는게..!

또 다른 문제점

  1. 지금 구현하는 코드는 유저들끼리 커뮤니티를 이용하면서 채팅도 할 수 있도록 구현하는 것을 원했었다
  2. 그러나 기획 단계를 벗어나서 실제로 주요 기능을 개발하며 회의를 진행하다보니 유저끼리 커뮤니티 댓글 대댓글 정도로 소통하면 되지 굳이 채팅까지 구현하기엔 시간도 모자르고 쓸모없다 판단 + 중고거래 자체를 1:1채팅으로 구현해보자 라는 의견으로 중간에 내용이 바뀜!!
  3. 중고거래는 중고품목을 사이에 둔 두 유저간의 채팅이다보니, 제품ID까지 찾아야하는 로직으로 바뀌어서 전반적인 채팅구현 로직자체가 전체적으로 수정될 부분이 많이 생겼다..!!

2차 수정 코드

import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import { useSendMessage } from '../../api/chatApi';
import { supabase } from '../../api/supabase.api';
import {
  ChatId,
  chatRoomsState,
  globalModalSwitch,
  isChatModalOpenState,
  newMessagesCountState,
  otherPerson,
  person,
  productState,
  sendMessages,
} from '../../atom/product.atom';

import { useAuth } from '../../contexts/auth.context';
import AdminChat from './AdminChat';
import ChatLog from './ChatLog';
import * as St from './ChatStyle';

export type MessageType = {
  id: number;
  content: string;
  author_id: string;
  chat_id: string;
  item_id: number;
  others_id: string;
  users?: UserType; // 사용자 닉네임을 포함할 수 있는 옵셔널 프로퍼티
  created_at: number;
};

export type UserType = {
  id: string;
  email: string;
  lastMessage?: string; // lastMessage 속성 추가 (옵셔널로 처리)
  nickname: string;
};
export type ChatData = {
  id: string;
};

const Chat = () => {
  // 문쨩
  const [isOpen, setIsOpen] = useRecoilState(globalModalSwitch);
  //모달창을 열고 닫는 state
  const [isSwitch, setIsSwitch] = useState<boolean>(false);
  const [isAsk, setIsAsk] = useState<boolean>(false);
  //메세지 저장 state
  const [askMessage, setAskMessage] = useState<string>('');
  const [inputValue, setInputValue] = useState('');
  const [isChatModalOpen, setIsChatModalOpen] = useState(false);
  const [LoginPersonal, setLoginPersonal] = useRecoilState(person);
  const [otherLoginPersonal, setOtherLoginPersonal] = useRecoilState(otherPerson);
  const [messages, setMessages] = useRecoilState(sendMessages);
  const [chatId, setChatId] = useRecoilState(ChatId);
  const { mutate: sendDirectMessage } = useSendMessage();

  const [productId, setProductId] = useRecoilState(productState);
  const [chatRooms, setChatRooms] = useRecoilState(chatRoomsState);
  const [loginUser, setLoginUser] = useState('');
  const [newMessagesCount, setNewMessagesCount] = useRecoilState(newMessagesCountState);
  const [ChatBtnOpen, setChatBtnOpen] = useRecoilState(isChatModalOpenState);

  //로그인 유저 가져오기
  useEffect(() => {
    async function fetchLoggedInUser() {
      try {
        const {
          data: { user },
        } = await supabase.auth.getUser();

        if (user?.id) {
          setLoginUser(user.id);
        } else {
          setLoginUser('');
        }
      } catch (error) {
        console.error('Error fetching logged in user:', error);
      }
    }

    fetchLoggedInUser();
  }, []);

  //입력값 가져오기
  const InputChanger = (event: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(event.target.value);
  };

  // DM 클릭 핸들러
  const DmClickhandler = async (item_id: number, chat_id: string) => {
    const {
      data: { user },
    } = await supabase.auth.getUser();
    // console.log('item_id',item_id)
    // console.log('chat_id',chat_id)
    if (user && user.email) {
      if (user) {
        setChatId(chat_id);
        setLoginPersonal(user.id);
        // setOtherLoginPersonal(otherUserId);
        setProductId(item_id);

        // clearNewMessageFlag(chat_id);

        setIsChatModalOpen(true);

        setChatRooms((prevChatRooms) =>
          prevChatRooms.map((chatRoom) =>
            chatRoom.chat_id === chat_id ? { ...chatRoom, hasNewMessage: false } : chatRoom,
          ),
        );
      }
    }
  };

  //모달 창 뜨고 메시지 보내는 핸들러들
  // 메시지 전송 핸들러
  const KeyPresshandler = async (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === 'Enter' && inputValue.trim()) {
      event.preventDefault(); // 폼 제출 방지
      sendDirectMessage({
        content: inputValue,
        author_id: LoginPersonal,
        chat_id: chatId,
        item_id: productId,
        // others_id: otherLoginPersonal,
      });
      setInputValue('');
    }
  };

  //dm메시지 전송
  const sendDmMessage = async () => {
    if (!inputValue.trim()) return; // 메시지가 비어있지 않은지 확인


    sendDirectMessage({
      content: inputValue,
      author_id: LoginPersonal,
      chat_id: chatId,
      item_id: productId,
      // others_id: otherLoginPersonal,
    });

    setInputValue('');
  };

  //메시지 보여주는 것
  const renderMessages = () => {
    return messages
      .filter((message: MessageType) => message.chat_id === chatId)
      .filter((message: MessageType) => message.chat_id === chatId)
      .map((message: MessageType) => (
        <div key={message.id}>
          {message.author_id !== LoginPersonal && (
            <St.NicknameLabel>
              {message.users?.nickname} 
              {/* {message.created_at} */}
            </St.NicknameLabel>
          )}
          <St.MessageComponent isOutgoing={message.author_id === LoginPersonal}>{message.content}</St.MessageComponent>
        </div>
      ));
  };

  
  const auth = useAuth();
  const onChangeMessageHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    setAskMessage(e.target.value);
  };
  //메세지보내는 함수
  const sendMessage = async () => {
    if (!auth.session) return;
    if (!askMessage.trim()) return; // 메시지가 비어있지 않은지 확인
    await supabase.from('qna').insert({
      room_id: auth.session.user.id,
      sender_id: auth.session.user.id,
      content: askMessage,
      message_type: 'question',
    });
    setAskMessage(''); // 메시지 전송 후 입력 필드 초기화
  };
  const onKeyDownHandler = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') {
      e.preventDefault(); // 폼 제출 방지
      sendMessage();
    }
  };
  if (!auth.session) return null;

  const prevHandler = () => {
    setIsAsk(false);
  };

  console.log('chatRooms', chatRooms);
  const renderChatRoomsList = () => {
    console.log('renderChatRoomsList', chatRooms);
    return chatRooms
      .filter((chatRoom) => chatRoom.user_id === loginUser)
      .map((chatRoom) => (
        <St.UserItem key={chatRoom.chat_id}>
          <St.UserEmail>
            {chatRoom.sendNickname}
            {chatRoom.hasNewMessage ? (
              <>
                {console.log('chatRoom:', chatRoom.chat_id)}
                <St.NotificationBadge>New!</St.NotificationBadge>
              </>
            ) : null}
          </St.UserEmail>
          
          <St.UserLastMessage>{chatRoom.lastMessage || 'No messages yet.'}</St.UserLastMessage>
          <St.DMButton onClick={() => DmClickhandler(chatRoom.item_id, chatRoom.chat_id)}>Open Chat</St.DMButton>
          <>{console.log('아직챗룸리스트')}</>
        </St.UserItem>
      ));
  };

  const toggleChatModal = () => {
    setChatBtnOpen((prevState) => !prevState);
    setNewMessagesCount(0);
  };

  // console.log('ChatModalOpen',ChatBtnOpen)
  // console.log('newMessagesCount',newMessagesCount)
  //모달 여는 것 자체는 문제 없다.
  return (
    <>
    <>{console.log('제일 상단 여기도 렌더링 되나?')}</>
      {auth.session.profile.isAdmin ? (
        isOpen && <AdminChat />
      ) : (
        <St.Container>
          {isChatModalOpen && (
            <>
              {console.log('모달 중고거래창 렌더링됩니다.')} {/* 여기에 콘솔 로그를 추가했습니다 */}
              <St.ChatModalWrapper>
                {/* 채팅 모달 내용 */}
                <St.ChatModalHeader>
                  <button onClick={() => setIsChatModalOpen(false)}>닫기</button>
                  <div>채팅</div>
                  <div>구매확정</div>
                </St.ChatModalHeader>
                <St.ChatModalBody>{renderMessages()}</St.ChatModalBody>
                <St.ChatModalFooter>
                  <St.InputField
                    value={inputValue}
                    onChange={InputChanger}
                    onKeyDown={KeyPresshandler}
                    placeholder="메세지를 입력해주세요"
                  />
                  <St.SendButton onClick={sendDmMessage}>전송</St.SendButton>
                </St.ChatModalFooter>
              </St.ChatModalWrapper>
            </>
          )}
          {/* 채팅 UI가 모달 UI 위에 올라가지 않도록 조건부 렌더링을 적용합니다. */}
          {isSwitch && !isChatModalOpen && (
            <>
              {console.log('모달 문의하기 렌더링됩니다.')} {/* 여기에 콘솔 로그를 추가했습니다 */}
              <St.ChatWrapper>
                {isAsk ? (
                  <St.LogoWrapper>
                    <St.PrevBtn onClick={prevHandler}>
                      <img src="/images/chat/prev.png" alt="Prev" width={30} height={30} />
                    </St.PrevBtn>
                    <St.ChatHeader></St.ChatHeader>
                  </St.LogoWrapper>
                ) : (
                  <St.ChatHeader></St.ChatHeader>
                )}
                <St.ChatTopBox>
                  <St.MainMessage>
                    안녕하세요 !
                    <br />
                    책에 대한 모든 것을 담는 북커입니다 ⸜๑•⌔•๑ ⸝ <br />
                    궁금한 점이 있으신가요?{' '}
                    <St.AskButtonWrapper>
                      <St.AskButton
                        style={isAsk ? { display: 'none' } : { display: 'block' }}
                        onClick={() => setIsAsk(true)}>
                        문의하기
                      </St.AskButton>
                    </St.AskButtonWrapper>
                    <St.Contour />
                  </St.MainMessage>
                </St.ChatTopBox>

                {isAsk ? (
                  <>
                    <ChatLog />
                    <St.ChatInputWrapper>
                      <St.Input
                        placeholder="메세지를 입력해주세요"
                        value={askMessage}
                        onChange={onChangeMessageHandler}
                        onKeyDown={onKeyDownHandler}
                      />
                    </St.ChatInputWrapper>
                  </>
                ) : (
                  <>
                    {/* Chats 컴포넌트의 UI 추가 */}
                    <div>{renderChatRoomsList()}</div>
                  </>
                )}
              </St.ChatWrapper>
            </>
          )}
        </St.Container>
      )}
      {/* // Chat 컴포넌트 내부에서 채팅 아이콘과 카운터를 렌더링하는 부분 */}
      {/* {console.log('모달 입구 렌더링됩니다.')} 여기에 콘솔 로그를 추가했습니다 */}
      <St.TalkButtonWrapper>
        <div>
          {/* 채팅 모달이 열리지 않았을 때만 새 메시지 수를 표시합니다. */}

          {!ChatBtnOpen && newMessagesCount > 0 && <span>{newMessagesCount}</span>}
          <St.TalkButton
            src="/images/customerchatting/bookerchattingicon.png"
            alt="bookerchattingicon"
            onClick={() => {
              setIsOpen(!isOpen);
              setIsSwitch(!isSwitch);
              toggleChatModal();
            }}
          />
        </div>
      </St.TalkButtonWrapper>
    </>
  );
};
export default Chat;

자 코드 수정을 위해 이것 저것 노력하시다보니 코드 자체가 300줄이 훌쩍 넘어버린 모습 ㅎㅎ..

주요 내용

  • 상태 관리를 위해 react-query를 제거한 recoil만 사용하려 노력한 모습
  • supabase를 통한 인증 및 데이터 가져오기 위한 시도
  • 메시지 전송 및 입력 처리
  • 채팅방 및 메시지 렌더링
  • 모달 창 열고 닫기(모달창으로 구현 시도)
const [isOpen, setIsOpen] = useRecoilState(globalModalSwitch);
const [LoginPersonal, setLoginPersonal] = useRecoilState(person);
const [otherLoginPersonal, setOtherLoginPersonal] = useRecoilState(otherPerson);
const [messages, setMessages] = useRecoilState(sendMessages);
const [chatId, setChatId] = useRecoilState(ChatId);
const [productId, setProductId] = useRecoilState(productState);
const [chatRooms, setChatRooms] = useRecoilState(chatRoomsState);
const [newMessagesCount, setNewMessagesCount] = useRecoilState(newMessagesCountState);
const [ChatBtnOpen, setChatBtnOpen] = useRecoilState(isChatModalOpenState);
  • 1차 코드 구현 없이 query는 뺏지만 recoil로는 오히려 더 많은 데이터 값을 전역 상태 관리하려 노력한 모습
useEffect(() => {
  async function fetchLoggedInUser() {
    try {
      const {
        data: { user },
      } = await supabase.auth.getUser();

      if (user?.id) {
        setLoginUser(user.id);
      } else {
        setLoginUser('');
      }
    } catch (error) {
      console.error('Error fetching logged in user:', error);
    }
  }

  fetchLoggedInUser();
}, []);
  • 로그인을 해야만 채팅을 이용할 수 있기 때문에, 로그인된 유저 정보를 supabase 인증을 통해 컴포넌트가 마운트 될때 가지고 오려함
const KeyPresshandler = async (event: React.KeyboardEvent<HTMLInputElement>) => {
  if (event.key === 'Enter' && inputValue.trim()) {
    event.preventDefault();
    sendDirectMessage({
      content: inputValue,
      author_id: LoginPersonal,
      chat_id: chatId,
      item_id: productId,
    });
    setInputValue('');
  }
};
  • 입력 필드에서 Enter키를 눌러도 메시지가 전송될 수 있도록 하는 handler 함수
  • 입력된 메세지를 sendDirectMessage를 통해 서버에 전송한 후, 입력 필드 비워주기
const InputChanger = (event: React.ChangeEvent<HTMLInputElement>) => {
  setInputValue(event.target.value);
};
  • 입력 필드의 값이 변경될 때 호출되며, 입력 값을 상태에 저장시키기 todolist에서 많이 써봤던 event.target.value!!
const renderChatRoomsList = () => {
  return chatRooms
    .filter((chatRoom) => chatRoom.user_id === loginUser)
    .map((chatRoom) => (
      <St.UserItem key={chatRoom.chat_id}>
        <St.UserEmail>
          {chatRoom.sendNickname}
          {chatRoom.hasNewMessage ? <St.NotificationBadge>New!</St.NotificationBadge> : null}
        </St.UserEmail>
        <St.UserLastMessage>{chatRoom.lastMessage || 'No messages yet.'}</St.UserLastMessage>
        <St.DMButton onClick={() => DmClickhandler(chatRoom.item_id, chatRoom.chat_id)}>Open Chat</St.DMButton>
      </St.UserItem>
    ));
};
  • chatRooms 배열 순회하면서 로그인한 유저와 관련된 채팅방 필터링하고, 각 채팅방 렌더링
  • 채팅방에 새 메세지가 있으면 NotificationBadge 표시
  • DMButton클릭하면 해당 채팅방 열도록 DMClickhandler 호출
const renderMessages = () => {
  return messages
    .filter((message: MessageType) => message.chat_id === chatId)
    .map((message: MessageType) => (
      <div key={message.id}>
        {message.author_id !== LoginPersonal && (
          <St.NicknameLabel>{message.users?.nickname}</St.NicknameLabel>
        )}
        <St.MessageComponent isOutgoing={message.author_id === LoginPersonal}>{message.content}</St.MessageComponent>
      </div>
    ));
};
  • 현재 chatId에 해당하는 메시지들을 필터링하여 렌더링
  • 메세지의 작성자가 로그인한 유저가 아니면 닉네임 표시시키기
const toggleChatModal = () => {
  setChatBtnOpen((prevState) => !prevState);
  setNewMessagesCount(0);
};
  • ChatBtnOpen상태를 토글하고, 새로운 메시지 수 초기화
{isChatModalOpen && (
  <St.ChatModalWrapper>
    <St.ChatModalHeader>
      <button onClick={() => setIsChatModalOpen(false)}>닫기</button>
      <div>채팅</div>
      <div>구매확정</div>
    </St.ChatModalHeader>
    <St.ChatModalBody>{renderMessages()}</St.ChatModalBody>
    <St.ChatModalFooter>
      <St.InputField
        value={inputValue}
        onChange={InputChanger}
        onKeyDown={KeyPresshandler}
        placeholder="메세지를 입력해주세요"
      />
      <St.SendButton onClick={sendDmMessage}>전송</St.SendButton>
    </St.ChatModalFooter>
  </St.ChatModalWrapper>
)}
  • isChastModalOpen이 true면 채팅모달 렌더링
  • 헤더, 바디, 푸터로 이루어져있고, 메시지 입력 및 전송 기능 포함

결국 1차 코드에서 보이던 문제점인 id데이터를 잘 체크해서 보내는 사람 받는 사람을 구분지어서 보여주는 에러를 해결하던 중 챗 발신자 고정 로직 오류, 챗리스트에서 본인 채팅이 아닌 타유저 것들도 보이는 문제도 나타났지만 아래 코드들로 인해 해결하게 되었음

const renderMessages = () => {
  return messages
    .filter((message: MessageType) => message.chat_id === chatId)
    .map((message: MessageType) => (
      <St.MessageComponent key={message.id} isOutgoing={message.author_id === LoginPersonal}>
        {message.content}
      </St.MessageComponent>
    ));
};

이 코드를 다시 살펴보면?

  • message.author_id === LoginPersonal 조건을 통해 메시지의 작성자가 로그인한 사용자와 동일한지 확인
  • St.MessageComponent컴포넌트의 isOutgoint prop을 설정하여 보내는 메시지와 받는 메시지 스타일링
    결국 이 코드로 보내는 사람과 받는 사람을 구분할 수 있었음!!!
const renderChatRoomsList = () => {
  return chatRooms
    .filter(chatRoom => chatRoom.user_id === loginUser)
    .map(chatRoom => (
      <St.UserItem key={chatRoom.chat_id}>
        <St.UserEmail>{chatRoom.sendNickname}</St.UserEmail> 
        <St.UserLastMessage>{chatRoom.lastMessage || 'No messages yet.'}</St.UserLastMessage>
        <St.DMButton onClick={() => DmClickhandler(chatRoom.item_id, chatRoom.chat_id)}> 
          Open Chat
        </St.DMButton>
      </St.UserItem>
    ));
};

그리고 위에서 나왔던 이 코드에선??

  • chatRoom.user_id === loginUser 조건을 사용하여 현재 로그인한 사용자가 참여한 채팅방만 필터링
  • 이렇게 필터링한 채팅방만 map으로 렌더링 시키므로, 본인의 채팅방만 리스트에 표시

많은 부분은 2차 코드 수정으로 해결하였지만..!!

문제점

  1. 300줄이 넘는 코드로 어찌 어찌 구현은 성공하였어도, 유지보수 불가한 스파게티 코드
  2. 상태 관리로 recoil를 채택하였는데, 너무 많은 상태를 관리하여 복잡
  3. 조건부 렌더링과 여러 상태를 기반으로 UI렌더링을 하고 있는데, 복잡하게 얽혀있어서 코드 흐름 읽기 어려움
  4. 메시지 전송, 입력 처리 등의 이벤트 핸들러가 여러 번 정의되어 있어 중복 코드가 많음
  5. 이미 컴포넌트들끼리 의존성이 너무 높아져서 어떻게 컴포넌트 분리해야할지 감이 안옴

즉, 이미 300줄이 넘는 코드를 한 컴포넌트에 몰아 넣으면서 유지보수, 가독성, 버그 발생 가능성, 확장성 등에 대한 문제가 발생함

개선방법

  1. 상태관리 단순화
  2. 컴포넌트 분리
  3. 커스텀 훅 사용
  4. 조건부 렌더링 단순화
  5. 명확한 데이터 흐름
  6. 유닛 테스트 작성

3차 수정 코드 + 알람 기능 추가

3차 수정 코드

코드가 크게 차이가 나지 않으므로 차이나는 부분만 언급
1. Recoil 상태 추가 - 알람 기능을 위해
2. 상태 관리 추가 - useRecoilState로 알람 기능 구현
3. 사용자 정보 가져오기

useEffect(() => {
  async function fetchLoggedInUser() {
    try {
      const {
        data: { user },
      } = await supabase.auth.getUser();

      if (user?.id) {
        setLoginUser(user.id);
      } else {
        setLoginUser('');
      }
    } catch (error) {
      console.error('Error fetching logged in user:', error);
    }
  }

  fetchLoggedInUser();
}, []);
  • supabase.auth.getUser()를 사용하여 사용자 정보 가져올 때 useEffect내 함수가 fetchLoggedInUser로 정의하여 호출
  1. 메시지 전송 핸들러
const KeyPresshandler = async (event: React.KeyboardEvent<HTMLInputElement>) => {
  if (event.key === 'Enter' && inputValue.trim()) {
    event.preventDefault();
    sendDirectMessage({
      content: inputValue,
      author_id: LoginPersonal,
      chat_id: chatId,
      item_id: productId,
    });
    setInputValue('');
  }
};

const sendDmMessage = async () => {
  if (!inputValue.trim()) return;
  sendDirectMessage({
    content: inputValue,
    author_id: LoginPersonal,
    chat_id: chatId,
    item_id: productId,
  });
  setInputValue('');
};
  • sendDirectMessage 함수로 메시지 전송
  1. 메시지 렌더링
const renderMessages = () => {
  return messages
    .filter((message: MessageType) => message.chat_id === chatId)
    .map((message: MessageType) => (
      <div key={message.id}>
        {message.author_id !== LoginPersonal && (
          <St.NicknameLabel>
            {message.users?.nickname}
          </St.NicknameLabel>
        )}
        <St.MessageComponent isOutgoing={message.author_id === LoginPersonal}>{message.content}</St.MessageComponent>
      </div>
    ));
};
  • message.users?.nickname을 통해 사용자 닉네임 표시하는 코드 추가
  1. 문의하기 관련 코드
const [isAsk, setIsAsk] = useState<boolean>(false);
const [askMessage, setAskMessage] = useState<string>('');
const auth = useAuth();

const onChangeMessageHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
  setAskMessage(e.target.value);
};

const sendMessage = async () => {
  if (!auth.session) return;
  if (!askMessage.trim()) return;
  await supabase.from('qna').insert({
    room_id: auth.session.user.id,
    sender_id: auth.session.user.id,
    content: askMessage,
    message_type: 'question',
  });
  setAskMessage('');
};

const onKeyDownHandler = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (e.key === 'Enter') {
    e.preventDefault();
    sendMessage();
  }
};

고객센터도 채팅으로 운영하기로 했는데 결과적으로는 여기가 아주 큰 문제였다 다른 팀원이 채팅기능 구현 성공했다고 그 코드를 합쳐버린 것이다. 물론 코드를 이해하고 사용했다면 같은 데이터를 사용하지 않으면서 문제가 없었겠지만 그대로 복붙해서 사용해버리니 고객센터 채팅과 짬뽕이 된 결과가 된 것이다..

알람 기능

import {
  ...
  isChatModalOpenState,
  newMessagesCountState,
  ...
} from '../../atom/product.atom';
  • 일단 recoil에 isChatModalOpenState와 newMessagesCountState 상태를 새로 추가
const [newMessagesCount, setNewMessagesCount] = useRecoilState(newMessagesCountState);
const [ChatBtnOpen, setChatBtnOpen] = useRecoilState(isChatModalOpenState);
  • 로컬 상태엔 newMessagesCount와 ChatBtnOpen 상태를 추가하여 새로운 메시지 수와 채팅 모달의 상태 관리
const DmClickhandler = async (item_id: number, chat_id: string) => {
  ...
  if (user && user.email) {
    if (user) {
      ...
      setIsChatModalOpen(true);

      setChatRooms((prevChatRooms) =>
        prevChatRooms.map((chatRoom) =>
          chatRoom.chat_id === chat_id ? { ...chatRoom, hasNewMessage: false } : chatRoom,
        ),
      );
    }
  }
};
  • DM클릭 시, 해당 채팅방의 hasNewMessage플래그를 false로 변경하여 새로운 메시지 알람을 지워줌
const renderChatRoomsList = () => {
  ...
  return chatRooms
    .filter((chatRoom) => chatRoom.user_id === loginUser)
    .map((chatRoom) => (
      <St.UserItem key={chatRoom.chat_id}>
        <St.UserEmail>
          {chatRoom.sendNickname}
          {chatRoom.hasNewMessage ? (
            <>
              <St.NotificationBadge>New!</St.NotificationBadge>
            </>
          ) : null}
        </St.UserEmail>
        ...
      </St.UserItem>
    ));
};
  • 채팅방 리스트를 렌더링할 때, 새로운 메시지가 있는 방에 New! 알림 배지를 추가
const toggleChatModal = () => {
  setChatBtnOpen((prevState) => !prevState);
  setNewMessagesCount(0);
};
  • 채팅 모달을 여닫을 때 새로운 메시지 수를 0으로 초기화
<St.TalkButton
  src="/images/customerchatting/bookerchattingicon.png"
  alt="bookerchattingicon"
  onClick={() => {
    setIsOpen(!isOpen);
    setIsSwitch(!isSwitch);
    toggleChatModal();
  }}
/>
  • 채팅 아이콘 클릭할 때 'toddleChatModal' 함수를 호출하여 채팅 모달을 토글하고 새로운 메시지 카운트 초기화
<St.TalkButtonWrapper>
  <div>
    {!ChatBtnOpen && newMessagesCount > 0 && <span>{newMessagesCount}</span>}
    <St.TalkButton
      src="/images/customerchatting/bookerchattingicon.png"
      alt="bookerchattingicon"
      onClick={() => {
        setIsOpen(!isOpen);
        setIsSwitch(!isSwitch);
        toggleChatModal();
      }}
    />
  </div>
</St.TalkButtonWrapper>
  • 채팅 모달이 열리지 않았을 때만 새로운 메시지 수 표시

문제점

  1. 모달이 열려있는 상태에서는 알람 카운팅이 되지 않음
  2. 모달이 닫혀있을 때만 알람 카운팅이 되고 있음
  3. 닉네임 옆 new!로 표현한 알람이 모달만 열면 다 사라짐

4차 코드 수정

자.... 4번째 수정인데... 아직도 코드를 분리하지 않고 막 넣어놓다보니 코드의 줄은 벌써 500줄이 넘어갔다.... 이걸 다 적기엔 너무 길어서 수정된 부분만 최대한 찾아보겠다

const [unreadCounts, setUnreadCounts] = useRecoilState(UnreadCounts);
  • 읽지 않은 알람기능 관련 데이터 상태 관리 위해 unreadCounts 선언
// 채팅 몸체에 스크롤 이벤트 리스너를 추가
useEffect(() => {
  const chatBody = chatBodyRef.current;
  if (chatBody) {
    chatBody.addEventListener('scroll', handleScroll);

    // 컴포넌트 언마운트 시 이벤트 리스너 제거
    return () => {
      chatBody.removeEventListener('scroll', handleScroll);
    };
  }
}, []);

  • 채팅창 scroll 기능을 위해 코드 구현
  • 이전 코드는 스크롤 이벤트 리스너를 추가하고 제거하는 로직 중복, 지금은 useEffect를 사용하여 하나의 효과로 통합
  • chatBodyRef를 통해 참조된 DOM 요소에 스크롤 이벤트 리스너 추가
  • 컴포넌트 언마운트 시 이벤트 리스너를 제거하여 메모리 누수 방지
import dayjs from 'dayjs';
import 'dayjs/locale/ko'; // 한국어 로케일 가져오기
import relativeTime from 'dayjs/plugin/relativeTime.js';
import { useNavigate } from 'react-router';
import { useAuth } from '../../contexts/auth.context';
import AdminChat from './AdminChat';
import ChatLog from './ChatLog';
import * as St from './ChatModal.styled';
dayjs.extend(relativeTime); // relativeTime 플러그인 활성화
dayjs.locale('ko'); // 한국어 로케일 설정
  • day.js라이브러리를 활용하여 가볍고 성능 뛰어나게 날짜 및 시간 조작

그리고 3차 기능 수정에서 문제가 있었던 부분에 대해서 수정된 코드를 살펴보면

// 총 안읽은 메시지 계산
const totalUnreadCount = chatRooms
  .filter((chatRoom) => chatRoom.user_id === LoginPersonal)
  .reduce((total, chatRoom) => {
    const unreadInfo = unreadCounts.find((uc) => uc.chat_id === chatRoom.chat_id);
    return total + (unreadInfo ? unreadInfo.unread_count : 0);
  }, 0);

// 채팅 버튼
<St.TalkButtonWrapper>
  <St.BookerChattingIcon
    onClick={() => {
      setIsOpen(!isOpen);
      setIsSwitch(!isSwitch);
      toggleChatModal();
    }}
  />
  {!ChatBtnOpen && totalUnreadCount > 0 && (
    <St.NotificationBadge>{totalUnreadCount}</St.NotificationBadge>
  )}
</St.TalkButtonWrapper>
  • totalUnreadCount로 읽지 않은 메시지 총 개수를 계산하여 채팅 버튼 옆에 배지로 구현화 함
const renderChatRoomsList = () => {
  return chatRooms
    .filter((chatRoom) => chatRoom.user_id === LoginPersonal)
    .map((chatRoom) => {
      const unreadInfo = unreadCounts.find((uc) => uc.chat_id === chatRoom.chat_id);
      const lastMessageTimeAgo = chatRoom.created_at ? getTimeDifference(chatRoom.created_at) : 'No messages yet.';
      return (
        <St.UserItem key={chatRoom.chat_id} onClick={() => DmClickhandler(chatRoom.item_id, chatRoom.chat_id, chatRoom.author_id)}>
          <St.UserImage src={chatRoom.user_img || '기본 이미지 경로'} alt="user image" />
          <St.UserInfo>
            <St.UserNickname>
              {chatRoom.sendNickname}
              {unreadInfo && unreadInfo.unread_count > 0 && (
                <St.NotificationBadge>{unreadInfo.unread_count}</St.NotificationBadge>
              )}
            </St.UserNickname>
            <St.UserLastMessage>
              {chatRoom.lastMessage || 'No messages yet.'} {lastMessageTimeAgo}
            </St.UserLastMessage>
          </St.UserInfo>
          <St.ProductImage src={chatRoom.product_img || '기본 물품 이미지 경로'} alt="product image" />
        </St.UserItem>
      );
    });
};
  • 사용자가 속한 채팅방 목록으로 렌더링
  • 채팅방 안에는 사용자 이미지, 닉네임, 가장 최근 메시지, 메시지 시간, 상품 이미지까지 구현
// 채팅 헤더를 렌더링하는 함수
const renderChatHeader = () => {
  const navigateToProductPage = () => {
    const productId = productDetails?.id;
    if (productId) {
      navigate(`/product/${productId}`);
    }
  };

  return (
    <St.ChatModalHeader>
      <St.UserInfoSection>
        <St.CloseButton onClick={() => setIsChatModalOpen(false)}>←</St.CloseButton>
        <St.UserImage src={otherUserDetails?.user_img} alt="user" />
        <St.UserNickname>{otherUserDetails?.nickname}</St.UserNickname>
      </St.UserInfoSection>
      <St.ProductInfoSection>
        <St.ProductImage onClick={navigateToProductPage} src={productDetails?.image} alt="product" />
        <div>
          <St.ProductTitle>제목: {productDetails?.title}</St.ProductTitle>
          <St.ProductPrice>가격: {productDetails?.price}</St.ProductPrice>
        </div>
      </St.ProductInfoSection>
    </St.ChatModalHeader>
  );
};

// 채팅 메시지 렌더링
const renderMessages = () => {
  let lastDate: dayjs.Dayjs | null = null;

  return (
    <>
      {messages
        .filter((message: MessageType) => message.chat_id === chatId)
        .sort((a: MessageType, b: MessageType) => a.id - b.id)
        .map((message: MessageType) => {
          const currentDate = dayjs(message.created_at);
          const formattedTime = currentDate.format('hh:mm A');
          const formattedDate = currentDate.format('YYYY-MM-DD dddd');
          let dateLabel = null;

          if (lastDate === null || !currentDate.isSame(lastDate, 'day')) {
            dateLabel = <St.DateLabel>{formattedDate}</St.DateLabel>;
            lastDate = currentDate;
          }

          return (
            <>
              {dateLabel}
              {message.author_id !== LoginPersonal && <St.NicknameLabel>{message.users?.nickname}</St.NicknameLabel>}
              <St.MessageComponent key={message.id} isOutgoing={message.author_id === LoginPersonal}>
                {message.content} {formattedTime}
              </St.MessageComponent>
            </>
          );
        })}
    </>
  );
};
  • renderChatHeader 함수는 챗 창의 상단 헤더 렌더링, 상대방 이미지, 닉네임, 판매 물품 정보 표시
  • renderMessages 함수는 메시지를 날짜별로 그룹화하고 메시지의 작성자의 닉네임 메시지 시간 포함 하여 표시

그래도 어느정도 윤곽이 잡혀가는 챗 관련 추가 기능 및 디테일이 느껴지지만 여전히 커다란 벽으로 느껴지는건
500줄이 넘어가는 코드....
리펙토링하기도 전에 계속해서 수정해야할 곳이 생기고 있음

5차 코드 수정

const [isAtBottom, setIsAtBottom] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const chatBodyRef = useRef<HTMLDivElement>(null);
  • isAtBottom은 현재 스크롤이 최하단에 있는지 추적하기 위한 상태
  • messagesEndRef는 메시지 목록의 끝을 참조
  • chatBodyRef는 채팅 목록 컨테이너를 참조
const handleScroll = () => {
  const current = chatBodyRef.current;
  if (current) {
    const isAtBottom = current.scrollHeight - current.scrollTop === current.clientHeight;
    setIsAtBottom(isAtBottom);
  }
};

const scrollToBottom = () => {
  if (messagesEndRef.current) {
    messagesEndRef.current.scrollIntoView({ behavior: 'auto' });
  }
};
  • handleScroll 함수는 스크롤 위치를 추적하고 isAtBottom 상태 업데이트
useEffect(() => {
  if (isChatModalOpen && isAtBottom) {
    setTimeout(scrollToBottom, 0);
  }
}, [messages, isChatModalOpen, isAtBottom]);
  • isChatModalOpen 또는 messages state 변경될 때, scrollToBottom을 호출하여 채팅이 열리거나 메시지 추가될 때 자동으로 스크롤
  • DOM 업데이트 후 비동기적으로 실행(setTimeout)
useEffect(() => {
  const chatBody = chatBodyRef.current;
  if (chatBody) {
    chatBody.addEventListener('scroll', handleScroll);
    return () => {
      chatBody.removeEventListener('scroll', handleScroll);
    };
  }
}, []);
  • chatBodyRef 에 스크롤 이벤트 리스너를 추가하여 스크롤 위치를 추적
const renderMessages = () => {
  let lastDate: dayjs.Dayjs | null = null;

  return (
    <>
      {messages
        .filter((message: MessageType) => message.chat_id === chatId)
        .sort((a: MessageType, b: MessageType) => a.id - b.id)
        .map((message: MessageType) => {
          const currentDate = dayjs(message.created_at);
          const formattedTime = currentDate.format('hh:mm A');
          const formattedDate = currentDate.format('YYYY-MM-DD dddd');
          let dateLabel = null;

          if (lastDate === null || !currentDate.isSame(lastDate, 'day')) {
            dateLabel = <St.DateLabel>{formattedDate}</St.DateLabel>;
            lastDate = currentDate;
          }

          return (
            <>
              {dateLabel}
              {message.author_id !== LoginPersonal && <St.NicknameLabel>{message.users?.nickname}</St.NicknameLabel>}
              <St.MessageComponent key={message.id} isOutgoing={message.author_id === LoginPersonal}>
                {message.content} {formattedTime}
              </St.MessageComponent>
            </>
          );
        })}
      <div ref={messagesEndRef} />
    </>
  );
};
  • renderMessages 함수 내에 메시지 목록 렌더링하고 끝에 messagesEndRef를 추가하여 스크롤 참조 설정

즉, 채팅 모달 열거나 메시지 추가될때 자동으로 스크롤하여 최신 메시지에 포커스 맞춰지도록 설정함

최종 코드 수정

이 부분이 가장 성능 개선에 도움이 되었는데 코드로 일일히 다 적으면서 표현을 할 수 없는게 아쉽다..
그래도 굵직굵직하게 표현하자면
1. 같은 모달창 안에 있다는 이유로 한 컴포넌트에 있던 고객센터 채팅문의 분리하기
2. 채팅 관련 데이터 recoil로 관리하기

  • 선택한 중고거래 제품ID
  • 로그인된 사용자의 ID
  • 현재 활성화된 채팅의 ID
  • 메인 채팅 모달의 열림 상태를 관리하는 상태
  • 상대방 사용자의 추가정보(닉네임, 프로필 이미지 등) 저장하는 상태
  • 선택된 제품의 상세 정보를 저장하는 상태
  • 전송된 채팅 메시지들을 관리하는 상태
  • 사용자의 모든 채팅 방 목록을 관리하는 상태
  • 처음 채팅 모달이 열린 상태인지 관리하는 상태
  • 전역 모달의 열림/닫힘 상태를 관리하는 상태
  • 새로운 메시지의 개수를 관리하는 상태
  • 메시지 개수를 세어야 하는지 결정하는 상태
  • 현재 로그인한 사용자의 ID를 관리하는 상태
  • 선택된 사용자의 이미지를 관리하는 상태
  • 각 채팅방의 읽지 않은 메시지 개수를 관리하는 상태
  • 메시지가 업데이트 될 때 발생하는 이벤트를 관리하는 상태
  1. 채팅메시지 / 채팅방목록 컴포넌트 분리
  2. 유지보수를 위해 chatuser / chatadmin / adminchatid / adminchatbody / adminchatInput 등 각 파트별로 구분지어서 컴포넌트 분리

이렇게 해서 총 500줄이 넘어가는 컴포넌트 코드 길이를 200줄대까지 줄일 수 있었다...

recoil덕분에 여러 컴포넌트 간 공유되는 복잡한 상태를 효과적으로 관리 및 효율적인 상태 업데이트를 할 수 있었고, 한 파일 안에 상태가 다 담겨있으므로 유지보수와 디버깅에 강점이, 그리고 프로젝트가 성장하고 변화할 때 더 많은 상태와 컴포넌트를 관리하는데 유연성을 제공할 수 있었다.

그리고 총 500줄 그리고 전체적으로 채팅 관련 모든 코드에 대해서 이렇게 분리하여 관리해 줌으로써 중복되는 코드를 매우 줄이면서 유지 보수하기 좋게 만들어서 코드 리펙토링 당시에 Lighthouse가 70점대에서 90점대로 올라올 수 있었는데 가장 큰 기여를 한 파트가 chat쪽이라 확신한다....!

총 정리

구현 목적

중고거래 물품을 중심으로 두 유저간에 실시간 채팅이 가능하도록 하는 기능 개발

구현 순서

  1. 두 유저간의 채팅 기능
  2. 중고거래 제품을 사이에 둔 채팅 기능
  3. 실시간 알람기능 및 모달 창 구현 / 채팅목록 / 스크롤 및 최신 메시지에 포커싱
  4. 코드 리펙토링하여 컴포넌트 분리

500줄이 넘는 스파게티 코드가 된 최초 문제점

  1. TDD과정 없이 바로 recoil및 react-query로 상태관리를 하려함
  2. 최초 두 유저간의 채팅에서 중고거래 물품을 사이에 둔 유저간의 채팅으로 기획이 바뀌면서 데이터가 좀 섞임
  3. 한 컴포넌트에 모든 코드를 다 넣어서 queryClient, 메시지전송, 입력처리 등의 이벤트 핸들러가 여러번 정의되어 중복되는 코드들이 많아짐
  4. 실시간 채팅 구현을 위해 websoket에 대한 이해도 없이 supabase를 db로 사용한단 이유로 supabase의 realtime으로 바로 구현하려함
    • 여기서 realtime은??
      클라이언트 측에서 realtime 구독을 설정하면 채팅 메시지가 추가될 때마다 실시간으로 클라이언트에 푸쉬되도록 구성할 수 있음
  5. 조건부 렌더링과 여러 상태를 기반으로 UI렌더링을 하고 있는데, 너무 복잡해서 코드 흐름 읽기 어려움
  6. 이미 컴포넌트끼리 의존성이 너무 높아져서 분리하기도 쉽지 않음
  7. 상태관리로 너무 많은 데이터를 관리하려 시도함

개선 방법

  1. 상태관리 단순화
  2. 컴포넌트 분리
  3. 커스텀 훅 사용 - 조건부 렌더링 및 상태 관리
  4. 명확한 데이터 흐름 - 양방향 되지 않도록
  5. 유닛 테스트 작성 - 가작 작은 단위의 코드부터 의도한 대로 작동하는지 검증하면서 구현하기

트러블 슈팅

  1. 본인 채팅 아니여도 모든 채팅 목록이 다 나타남
const renderChatRoomsList = () => {
  return chatRooms
    .filter(chatRoom => chatRoom.user_id === loginUser)
    .map(chatRoom => (
      <St.UserItem key={chatRoom.chat_id}>
        <St.UserEmail>{chatRoom.sendNickname}</St.UserEmail> 
        <St.UserLastMessage>{chatRoom.lastMessage || 'No messages yet.'}</St.UserLastMessage>
        <St.DMButton onClick={() => DmClickhandler(chatRoom.item_id, chatRoom.chat_id)}> 
          Open Chat
        </St.DMButton>
      </St.UserItem>
    ));
};
  • chatRoom.user_id === loginUser 조건을 사용하여 현재 로그인한 사용자가 참여한 채팅방만 필터링
    => 이렇게 필터링한 채팅방만 map으로 렌더링 시키므로, 본인의 채팅방만 리스트에 표시
  1. 보내는 사람과 받는 사람을 구분 지을 수 없음
const renderMessages = () => {
  return messages
    .filter((message: MessageType) => message.chat_id === chatId)
    .map((message: MessageType) => (
      <St.MessageComponent key={message.id} isOutgoing={message.author_id === LoginPersonal}>
        {message.content}
      </St.MessageComponent>
    ));
};
  • message.author_id === LoginPersonal 조건을 통해 메시지의 작성자가 로그인한 사용자와 동일한지 확인
    isOutgoing은 보통 실시간 채팅 애플리케이션에서 사용자의 메시지인지 아닌지 구분하기위해 사용되는 속성
    => 본인과 상대방 채팅을 구분짓게 해줌
  • St.MessageComponent컴포넌트의 isOutgoing prop을 설정하여 보내는 메시지와 받는 메시지 스타일링
    => 보내는 사람과 받는 사람 구분할 수 있음
export const MessageComponent = styled.div<MessageComponentProps>`
  display: flex;
  flex-direction: column;
  word-wrap: break-word;
  background-color: ${(props) => (props.$isoutgoing ? '#FCA311' : '#14213D')}; /* 배경 색상을 변경합니다. */
  color: ${(props) => (props.$isoutgoing ? '#fff' : '#fff')}; /* 텍스트 색상을 변경합니다. */
  align-self: ${(props) => (props.$isoutgoing ? 'flex-end' : 'flex-start')};
  /* 추가: 메시지 버블 안에 텍스트가 중앙에 오도록 만듭니다. */
  align-items: ${(props) => (props.$isoutgoing ? 'flex-end' : 'flex-start')};
  font-weight: bold;
  text-align: ${(props) => (props.$isoutgoing ? 'right' : 'left')};
  text-align: right;
  padding: 0.6rem;
  margin: 0.3rem;
  border-radius: 1rem;
  max-width: 10rem;
  width: auto;
  height: auto;

styled-components의 장점이 여기서 나옴!! props 내려받아 스타일링 가능!

  1. 중복 선언이 많은 코드
  • 코드 리펙토링 시 여러번 선언한 함수는 따로 컴포넌트 분리를 통해 유지보수까지 챙김
  1. 모달창을 닫고 있을땐 알람이 잘 뜨고 카운팅이 잘 되나, 열려있는 상태에선 알람 카운팅이 전부 사라지고, 새 메시지가 와도 카운딩이 안됨(채팅 목록 중 어떤 채팅에 대한 알람인지에 대한 세부적인 알람 설정이 안되어있음)
<St.TalkButtonWrapper>
  <div>
    {!ChatBtnOpen && newMessagesCount > 0 && <span>{newMessagesCount}</span>}
    <St.TalkButton
      src="/images/customerchatting/bookerchattingicon.png"
      alt="bookerchattingicon"
      onClick={() => {
        setIsOpen(!isOpen);
        setIsSwitch(!isSwitch);
        toggleChatModal();
      }}
    />
  </div>
</St.TalkButtonWrapper>
  • !ChatBtnOpen이 false기 때문에 최초엔 모달창이 닫혀있을때만 새로운 메시지 수 표시하던 단순한 조건이였음
  • newMessagesCount가 0보다 클 때까지의 두 조건이 참이면 {newMessagesCount} 이 부분이 렌더링되게 되어있음
// 총 안읽은 메시지 계산
const totalUnreadCount = chatRooms
  .filter((chatRoom) => chatRoom.user_id === LoginPersonal)
  .reduce((total, chatRoom) => {
    const unreadInfo = unreadCounts.find((uc) => uc.chat_id === chatRoom.chat_id);
    return total + (unreadInfo ? unreadInfo.unread_count : 0);
  }, 0);

// 채팅 버튼
<St.TalkButtonWrapper>
  <St.BookerChattingIcon
    onClick={() => {
      setIsOpen(!isOpen);
      setIsSwitch(!isSwitch);
      toggleChatModal();
    }}
  />
  {!ChatBtnOpen && totalUnreadCount > 0 && (
    <St.NotificationBadge>{totalUnreadCount}</St.NotificationBadge>
  )}
</St.TalkButtonWrapper>
  • totalUnreadCount로 읽지 않은 메시지 총 개수를 계산하여 채팅 버튼 옆에 배지로 구현화함
    => 모달이 열려있든 닫혀있든 상관없이 메시지 총 개수를 나타나게 함
    => 채팅 목록이 여러개 있을 수 있으므로 이렇게 접근했어야 함
    (모달을 연다고 모든 채팅방이 다 열리는게 아님)
  1. 4번과 이어지는 내용인데 최초에 모달창이 열려있는 상태에서 채팅을 보내면 알람이 바로 사라지면서 리렌더링이 되면서 무한 네트워크 요청이 되고 있는 상황

  • 모달창이 열려있는 상태에서 New! 라는 알람이 떴지만

  • 이렇게 바로 알람이 사라지는 리렌더링이 발생했었음
useEffect(() => {
  // 메시지 생길때마다, 덧씌워주기
  const handleNewMessage = (payload: MessagePayload) => {
    setChatRooms((prevChatRooms) =>
      prevChatRooms.map((chatRoom) => {
        if (chatRoom.chat_id === payload.new.chat_id) {
          return {
            ...chatRoom,
            lastMessage: payload.new.content,
            created_at: payload.new.created_at,
          };
        } else {
          return chatRoom;
        }
      }),
    );
  };

  // 새 메시지 생성시 감지할 채널 구독
  const changes = supabase
    .channel('schema-db-changes')
    .on(
      'postgres_changes',
      {
        event: 'INSERT',
        schema: 'public',
        table: 'messages',
      },
      async (payload) => {
        setNewMessagesCount((prev) => prev + 1);
        handleNewMessage(payload as MessagePayload);
      },
    )
    .subscribe();

  // 채팅방 변경사항을 감지할 채널 구독
  const chatChannel = supabase
    .channel('chat-channel')
    .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'chats' }, (payload) => {})
    .subscribe();

  return () => {
    changes.unsubscribe();
    chatChannel?.unsubscribe();
  };
}, []);
  • useState로 상태관리하던 부분을 useEffect안에 넣어 dependency로 []를 넣어줌으로써 컴포넌트가 마운트될 때 한 번만 실행되고, 이후엔 상태 변경에 따라 무한 네트워크 요청이 발생하지 않도록 변경
  • handleNewMessage 함수 내에서 상태를 업데이트하여 새로운 메시지가 들어올 때만 채팅방 상태 변경되도록 처리
profile
웹 개발자 되고 시포용

0개의 댓글