이미 2월에 종료했던 프로젝트지만.. 나의 이력서의 이력을 채우기 위해,
가장 성능개선에 많은 도움이 되었던 스파르타 코드 즉 300줄이 넘는 코드를 줄여나가는 과정에서 필수였던 채팅파트를 분석하는 날이 나에게 오고야 말았다....
그치만 내가 아닌 다른 개발자의 초기 고민부터 해결과정을 온전히 체감할 수 있는 소중한 기회라 생각하자..!
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';
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();
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;
}
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 }) => {
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');
},
});
}
//주석된 수많은 코드들
그리고 실제로 챗을 보여줄 컴포넌트에서는
// 각 채팅방에 대해 사용자의 닉네임과 마지막 메시지를 가져오기
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(); // 채팅방에 속한 '받는 사람'은 한 명만 있다고 가정
선언한 컴포넌트를 불러오는 코드가 아닌것도 문제지만, 받는 사람과 보내는 사람이 구분이 제대로 되고있지 않음
이러한 부분들 때문에 3주동안 제대로 코드 구현이 안되었다 생각된다..... maybe..?
그래서 다른 긴 코드인 recoil, query 실제 채팅 구현시킬 컴포넌트는 이미 이 부분부터 문제가 있으므로 skip!!
하지만 여기서 마냥 코드만의 문제점만 있던건 아니였는게..!
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줄이 훌쩍 넘어버린 모습 ㅎㅎ..
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);
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 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 InputChanger = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(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>
));
};
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>
));
};
const toggleChatModal = () => {
setChatBtnOpen((prevState) => !prevState);
setNewMessagesCount(0);
};
{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>
)}
결국 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차 코드 수정으로 해결하였지만..!!
즉, 이미 300줄이 넘는 코드를 한 컴포넌트에 몰아 넣으면서 유지보수, 가독성, 버그 발생 가능성, 확장성 등에 대한 문제가 발생함
코드가 크게 차이가 나지 않으므로 차이나는 부분만 언급
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();
}, []);
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('');
};
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>
));
};
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';
const [newMessagesCount, setNewMessagesCount] = useRecoilState(newMessagesCountState);
const [ChatBtnOpen, setChatBtnOpen] = useRecoilState(isChatModalOpenState);
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,
),
);
}
}
};
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>
));
};
const toggleChatModal = () => {
setChatBtnOpen((prevState) => !prevState);
setNewMessagesCount(0);
};
<St.TalkButton
src="/images/customerchatting/bookerchattingicon.png"
alt="bookerchattingicon"
onClick={() => {
setIsOpen(!isOpen);
setIsSwitch(!isSwitch);
toggleChatModal();
}}
/>
<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>
자.... 4번째 수정인데... 아직도 코드를 분리하지 않고 막 넣어놓다보니 코드의 줄은 벌써 500줄이 넘어갔다.... 이걸 다 적기엔 너무 길어서 수정된 부분만 최대한 찾아보겠다
const [unreadCounts, setUnreadCounts] = useRecoilState(UnreadCounts);
// 채팅 몸체에 스크롤 이벤트 리스너를 추가
useEffect(() => {
const chatBody = chatBodyRef.current;
if (chatBody) {
chatBody.addEventListener('scroll', handleScroll);
// 컴포넌트 언마운트 시 이벤트 리스너 제거
return () => {
chatBody.removeEventListener('scroll', handleScroll);
};
}
}, []);
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'); // 한국어 로케일 설정
// 총 안읽은 메시지 계산
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>
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>
</>
);
})}
</>
);
};
그래도 어느정도 윤곽이 잡혀가는 챗 관련 추가 기능 및 디테일이 느껴지지만 여전히 커다란 벽으로 느껴지는건
500줄이 넘어가는 코드....
리펙토링하기도 전에 계속해서 수정해야할 곳이 생기고 있음
const [isAtBottom, setIsAtBottom] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const chatBodyRef = useRef<HTMLDivElement>(null);
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' });
}
};
useEffect(() => {
if (isChatModalOpen && isAtBottom) {
setTimeout(scrollToBottom, 0);
}
}, [messages, isChatModalOpen, isAtBottom]);
useEffect(() => {
const chatBody = chatBodyRef.current;
if (chatBody) {
chatBody.addEventListener('scroll', handleScroll);
return () => {
chatBody.removeEventListener('scroll', handleScroll);
};
}
}, []);
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} />
</>
);
};
이 부분이 가장 성능 개선에 도움이 되었는데 코드로 일일히 다 적으면서 표현을 할 수 없는게 아쉽다..
그래도 굵직굵직하게 표현하자면
1. 같은 모달창 안에 있다는 이유로 한 컴포넌트에 있던 고객센터 채팅문의 분리하기
2. 채팅 관련 데이터 recoil로 관리하기
- 선택한 중고거래 제품ID
- 로그인된 사용자의 ID
- 현재 활성화된 채팅의 ID
- 메인 채팅 모달의 열림 상태를 관리하는 상태
- 상대방 사용자의 추가정보(닉네임, 프로필 이미지 등) 저장하는 상태
- 선택된 제품의 상세 정보를 저장하는 상태
- 전송된 채팅 메시지들을 관리하는 상태
- 사용자의 모든 채팅 방 목록을 관리하는 상태
- 처음 채팅 모달이 열린 상태인지 관리하는 상태
- 전역 모달의 열림/닫힘 상태를 관리하는 상태
- 새로운 메시지의 개수를 관리하는 상태
- 메시지 개수를 세어야 하는지 결정하는 상태
- 현재 로그인한 사용자의 ID를 관리하는 상태
- 선택된 사용자의 이미지를 관리하는 상태
- 각 채팅방의 읽지 않은 메시지 개수를 관리하는 상태
- 메시지가 업데이트 될 때 발생하는 이벤트를 관리하는 상태
이렇게 해서 총 500줄이 넘어가는 컴포넌트 코드 길이를 200줄대까지 줄일 수 있었다...
중고거래 물품을 중심으로 두 유저간에 실시간 채팅이 가능하도록 하는 기능 개발
- 두 유저간의 채팅 기능
- 중고거래 제품을 사이에 둔 채팅 기능
- 실시간 알람기능 및 모달 창 구현 / 채팅목록 / 스크롤 및 최신 메시지에 포커싱
- 코드 리펙토링하여 컴포넌트 분리
- TDD과정 없이 바로 recoil및 react-query로 상태관리를 하려함
- 최초 두 유저간의 채팅에서 중고거래 물품을 사이에 둔 유저간의 채팅으로 기획이 바뀌면서 데이터가 좀 섞임
- 한 컴포넌트에 모든 코드를 다 넣어서 queryClient, 메시지전송, 입력처리 등의 이벤트 핸들러가 여러번 정의되어 중복되는 코드들이 많아짐
- 실시간 채팅 구현을 위해 websoket에 대한 이해도 없이 supabase를 db로 사용한단 이유로 supabase의 realtime으로 바로 구현하려함
- 여기서 realtime은??
클라이언트 측에서 realtime 구독을 설정하면 채팅 메시지가 추가될 때마다 실시간으로 클라이언트에 푸쉬되도록 구성할 수 있음- 조건부 렌더링과 여러 상태를 기반으로 UI렌더링을 하고 있는데, 너무 복잡해서 코드 흐름 읽기 어려움
- 이미 컴포넌트끼리 의존성이 너무 높아져서 분리하기도 쉽지 않음
- 상태관리로 너무 많은 데이터를 관리하려 시도함
- 상태관리 단순화
- 컴포넌트 분리
- 커스텀 훅 사용 - 조건부 렌더링 및 상태 관리
- 명확한 데이터 흐름 - 양방향 되지 않도록
- 유닛 테스트 작성 - 가작 작은 단위의 코드부터 의도한 대로 작동하는지 검증하면서 구현하기
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>
));
};
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>
));
};
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 내려받아 스타일링 가능!
<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>
// 총 안읽은 메시지 계산
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>
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();
};
}, []);