
지난 포스트에서는 로그아웃 기능 및 채팅방 목록을 받아와서 선택한 채팅방을 여는 기능까지 구현했다.
지난 내용을 적고 보니 마지막이라 생각하고 달려서 그런가 이번 포스트엔 내용이 많을 것 같은 느낌이다.
이번(마지막!) 포스트에서는 각 채팅방의 내용 리스트 렌더링, 실시간 채팅 및 스크롤링, 유저 검색 및 채팅방 생성 기능 구현에 대한 내용을 작성하려고 한다.
사실상 채팅 프로그램의 핵심 기능은 모두 구현이 되어 아마 여기서 과제를 완료하고 제출할 것 같다.
바로 들어가 보자!
실제로 구현은 가장 마지막에 했지만 로직 흐름 상 먼저 적는게 좋을 것 같아서 먼저 적는다.
채팅방을 생성하려면 전제 조건이 붙는데, 바로 다른 유저의 존재를 알아야 한다는 것이다.
그래서 유저 검색 기능부터 구현해봤다.
const INITIAL_SEARCH_STATE = {
value: '',
userList: [],
};
const getSearchResult = async (pb, id, query) => {
const data = await pb.collection('users').getFullList({
filter: `id != "${id}" && username ~ "${query}"`,
fields: 'id, username',
});
return data;
};
const makeChatRoom = async (pb, currentUserId, selectUserId, resultOpen) => {
const data = await pb.collection('chats').getFullList({
filter: `users~"${currentUserId}" && users~"${selectUserId}"`
})
if(data.length > 0) {
resultOpen(data[0].id);
return;
}
const chatCreateData = {
users: [currentUserId, selectUserId]
}
pb.collection('chats').create(JSON.stringify(chatCreateData))
.then((data) => resultOpen(data.id));
}
function SearchUser({ currentUser, closeHandler, resultOpen }) {
const pb = usePb();
const [user, searchUser] = useState(INITIAL_SEARCH_STATE);
pb.autoCancellation(false);
const handleSearchInput = (e) => {
searchUser({
...user,
value: e.target.value,
});
};
const handleStartChat = (currentUserId, selectUserId, closeHandler, resultOpen) => {
return () => {
makeChatRoom(pb, currentUserId, selectUserId, resultOpen)
closeHandler();
}
};
useEffect(() => {
getSearchResult(pb, currentUser, user.value).then((data) => {
searchUser({
...user,
userList: data,
});
});
}, [user.value]);
return (
<section className="absolute left-0 top-0 w-full h-[720px] backdrop-blur-sm bg-white/20">
<h4 className="sr-only">유저 검색</h4>
<Button
styleClass="w-6 absolute top-6 right-6"
aria-label="검색창 닫기"
title="검색창 닫기"
onClick={closeHandler}
>
<Close />
</Button>
<div className="w-full absolute top-24 flex flex-col items-center gap-6">
<span className="text-xl ">검색할 유저 ID를 입력하세요.</span>
<input
className="outline-none w-80 h-8 bg-sky-100 text-center border-b-2 border-solid border-zinc-700"
type="search"
defaultValue={user.value}
onChange={debounce(handleSearchInput)}
/>
<ul className="flex flex-col gap-3 h-[500px] overflow-y-scroll scrollbar-hide">
{user.userList.length > 0 &&
user.userList.map((item) => {
return (
<SearchUserCard
key={item.id}
userInfo={item}
handleStartChat={handleStartChat(currentUser, item.id, closeHandler, resultOpen)}
/>
);
})}
</ul>
</div>
</section>
);
}
정신없이 짰는데 길다. 엄청 길다!!!
그래도 내용 자체는 간단하다. 수업시간 때 배운 debounce 기법을 이용해 search 태그의 defaultValue를 조정하며 타이핑이 끝난 이후 유저 정보를 보여주도록 구현했다.
보여진 유저를 클릭하면, makeChatRoom 메소드가 이미 생성되어있는 채팅방의 경우에는 채팅방을 열어주고(data.length>0 일 경우), 없다면 채팅방을 생성 후 열어주도록 구현했다.
이렇게 생성하고나면 포켓베이스의 구독 상태에 따라 상대방 화면에도 자동으로 채팅방이 생성되어 보여진다.

메신저의 핵심 기능인 실시간 채팅이다.
이 기능은 포켓베이스의 구독 서비스를 이용해서 비교적 편하게 구현했다.
채팅을 입력하면 데이터에 생성되며 구독 서비스가 이를 감지해 데이터를 업데이트 시켜주는 방식이다.
const CHAT_DATA = {
currentUser: '',
otherUser: '',
messages: [],
};
const getMessages = async (chatRoomId, me, pb) => {
try {
const messageData = await pb.collection('chats').getFullList({ ... });
const [data] = messageData;
const [otherUserId] = data.users.filter((v) => v != me);
const otherUser = await pb.collection('users').getOne(otherUserId);
const currentUser = await pb.collection('users').getOne(me);
return { data, otherUser, currentUser };
} catch (error) {
console.error(error);
}
};
function ChatRoom({ closer, chatRoomId, me, pb }) {
const [chatRoomInfo, updateChatRoomInfo] = useState(CHAT_DATA);
const scrollRef = useRef(null);
const useUpdateMessages = (chatRoomId, me, updateChatRoomInfo, pb) => {
getMessages(chatRoomId, me, pb).then((item) => {
const { data: message, otherUser, currentUser } = item;
const messages = message.expand ? message.expand.messages : '';
updateChatRoomInfo({
messages,
otherUser: otherUser.name,
currentUser: currentUser.username,
});
}).then(() => {
setTimeout(() => {
scrollRef.current?.scrollIntoView(false)
}, 10)
});
};
useEffect(() => {
useUpdateMessages(chatRoomId, me, updateChatRoomInfo, pb);
pb.collection('chats').unsubscribe();
pb.collection('chats').subscribe('*', async () => {
useUpdateMessages(chatRoomId, me, updateChatRoomInfo, pb);
});
}, []);
const closeHandler = () => {
pb.collection('chats').unsubscribe();
closer();
};
return (
<section className="absolute z-10 w-full h-full bg-slate-200">
...
<section className="p-4 w-full h-[500px] overflow-y-scroll scrollbar-hide">
{chatRoomInfo.messages &&
chatRoomInfo.messages.map((item, index, array) => {
return (
<Message
ref={index === array.length - 1 ? scrollRef : null}
key={item.id}
item={item}
currentUser={chatRoomInfo.currentUser}
/>
);
})}
</section>
<ChatForm
currentChat={chatRoomInfo.messages}
currentRoom={chatRoomId}
sender={me}
/>
</section>
);
}
하단의 채팅 폼에서 글을 입력하고 전송하면 데이터를 업데이트하고 채팅을 보여준다.
이렇게 해놓고 나서 생각난 것이 바로 스크롤링인데, 이 기능을 구현하면서 웹 API중 하나인 element.scrollIntoView이다.
해당 element가 있는 구역으로 스크롤을 옮겨주는 기능을 하는데, 채팅 리스트가 업데이트되면 마지막의 채팅 원소를 ref로 지정해 스크롤을 이동시켜주었다.

여기까지 실시간 채팅의 기능을 리액트로 모두 구현해보는 실습을 해봤다.
개발하는 도중 컨텍스트 API의 기능을 어느정도 알게 되어서 바꿔서 해볼까 하다가도, 욕심때문에 기능 구현이 많아지다 보니 바꿀 엄두를 못내어 수업시간에 배운 내용들만 사용해 개발하고 말았다.
그래도 시작하기 전엔 긴 연휴때문에 리액트를 모두 까먹을 것만 같았는데, 다시 뇌를 활성화시키는 시간이 되어 뿌듯한 마음이 느껴진다.
지금 시간이 12일 새벽 1시40분인데.. 내 연휴 다 어디갔니..
배포 사이트: https://sangmessenger.netlify.app/
깃허브: https://github.com/SWLee2973/react-homework-4