이번 토이프로젝트로 MBTI 유형을 선택해서 채팅을 할 수 있는 서비스를 구현했다.
서버는 Java, Spring을 사용했고
클라이언트는 React, Typescript를 사용했다.
처음에 버전이 안 맞아서 조금 헤매었었다.
"dependencies": {
"@stomp/stompjs": "^7.0.0",
"react": "^18.2.0",
"sockjs-client": "^1.6.1",
"typescript": "^4.9.5",
},
보통 라이브러리를 사용하지 않고, 할 수 있다면 구현하는 방식을 해왔는데
웹소켓 라이브러리를 사용하지 않고 단체방, 채팅을 기다리는 방.. 등등 다양한 기능을 구현하는데 어려움이 너무 많을 것 같아서
Stomp 라이브러리를 사용했다.
전체적인 플로우는 다음과 같다.
MBTI선택 -> 대기방으로 연결 -> 매칭 -> 매칭방으로 연결 -> 채팅 -> 연결종료
구현하는 데 다양한 방식이 있겠지만, 내가 이 라이브러리를 이해한 바로 내 프로젝트 정책에 맞게 만들면
1. 웹소켓 연결
2. MBTI를 선택하면 대기방을 구독시킨다.
`/sub/chat/wait/${userId}`
3. 대기방의 메시지를 받기 위해 퍼블리쉬시킨다.
'/pub/chat/wait'
4. 매칭이 되었다는 메시지가 도착하면 새로운 채팅방을 구독한다.
`/sub/chat/match/${id}`
5. 마찬가지로 채팅방의 메시지를 받기 위해 퍼블리쉬시킨다.
`/sub/chat/match/${id}`
6. 채팅한다.
이 플로우를 이해했다면 Stomp로 채팅구현하는데 아무런 문제가 없다.
좀 더 자세히 코드를 살펴보자
연결
const connect = () => {
client.current = new StompJs.Client({
brokerURL: 'ws://api.projectsassy.net:8080/ws',
onConnect: () => {
console.log('success');
subscribe();
publishOnWait();
},
});
client.current.activate();
};
연결 끊기
const disconnect = () => {
client.current.deactivate();
console.log('채팅이 종료되었습니다.');
setIsMatch(false);
};
처음 MBTI 선택 시 구독 ( 매칭되기 전 )
const subscribe = () => {
client.current.subscribe(`/sub/chat/wait/${userId}`, (body) => {
const watingRoomBody = JSON.parse(body.body) as WatingRoomBody;
const { type, roomId: newRoomId } = watingRoomBody;
if (type === 'open') {
console.log('채팅 웨이팅 시작');
}
if (type === 'match') {
console.log('매칭이 되었습니다!');
subscribeAfterGetRoomId(newRoomId);
setRoomId(newRoomId);
setIsMatch(true);
}
});
};
대기 방 메시지를 받기 위한 퍼블리쉬
const publishOnWait = () => {
if (!client.current.connected) return;
client.current.publish({
destination: '/pub/chat/wait',
body: JSON.stringify({
type: 'open',
userId,
selectMbti: `${selectedMbti}`,
}),
});
};
이후 매칭 후 새로운 채팅 방 구독
const subscribeAfterGetRoomId = (id: number) => {
client.current.subscribe(`/sub/chat/match/${id}`, onMessageReceived);
};
채팅 방의 메세지를 받기 위한 퍼블리쉬
const publishAfterGetRoomId = (event: React.FormEvent<HTMLFormElement>, content: string) => {
event.preventDefault();
if (!client.current.connected) return;
client.current.publish({
destination: `/pub/chat/match/${roomId}`,
body: JSON.stringify({
type: 'match',
roomId,
sendUserId: userId,
content,
}),
});
setChat('');
};
받은 메세지 관리하는 함수
const onMessageReceived = (message: StompJs.Message) => {
const messageBody = JSON.parse(message.body) as MessageBody;
const { type, sendUserId, content, time, nickname } = messageBody;
const isMine = sendUserId === userId;
const newChat = {
id: generateId(),
content,
isMine,
time,
nickname,
};
console.log(newChat);
setChatList((prevChatList) => [...prevChatList, newChat]);
console.log(chatList);
if (type === 'close') {
console.log('closed');
}
};
다음은 전체 코드이다
import { useRef, useState, useEffect, useContext } from 'react';
import { useParams } from 'react-router-dom';
import * as StompJs from '@stomp/stompjs';
import styles from './style.module.scss';
import { MbtiContext } from '../../Context/MbtiContext';
import styled from 'styled-components';
import ChatMessageForm from '../../components/styled-components/ChatMessageForm';
import UserInfo from '../../components/styled-components/UserInfo';
interface Chat {
id: number;
content: string;
isMine: boolean;
time: string;
nickname: string;
}
interface WatingRoomBody {
type: string;
roomId: number;
sendUserId?: string;
content?: string;
}
interface MessageBody {
type: string;
sendUserId: string;
content: string;
time: string;
nickname: string;
}
interface StyledMessageProps {
isMine: boolean;
}
const ChatPage: React.FC = () => {
const { userId } = useParams<{ userId: string }>();
const { selectedMbti } = useContext(MbtiContext);
const client = useRef<any>({});
const [chatList, setChatList] = useState<Chat[]>([]);
const [chat, setChat] = useState<string>('');
const [roomId, setRoomId] = useState<number>();
const userNickname = localStorage.getItem('nickname');
const [isMatch, setIsMatch] = useState<boolean>(false);
// 고유한 ID를 발급하는 함수
const generateId = (() => {
let id = 0;
return () => {
id += 1;
return id;
};
})();
const disconnect = () => {
client.current.deactivate();
console.log('채팅이 종료되었습니다.');
setIsMatch(false);
};
const onMessageReceived = (message: StompJs.Message) => {
const messageBody = JSON.parse(message.body) as MessageBody;
const { type, sendUserId, content, time, nickname } = messageBody;
const isMine = sendUserId === userId;
const newChat = {
id: generateId(),
content,
isMine,
time,
nickname,
};
console.log(newChat);
setChatList((prevChatList) => [...prevChatList, newChat]);
console.log(chatList);
if (type === 'close') {
console.log('closed');
}
};
const subscribeAfterGetRoomId = (id: number) => {
client.current.subscribe(`/sub/chat/match/${id}`, onMessageReceived);
};
const publishAfterGetRoomId = (event: React.FormEvent<HTMLFormElement>, content: string) => {
event.preventDefault();
if (!client.current.connected) return;
client.current.publish({
destination: `/pub/chat/match/${roomId}`,
body: JSON.stringify({
type: 'match',
roomId,
sendUserId: userId,
content,
}),
});
setChat('');
};
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setChat(event.target.value);
};
// 최초 렌더링시 실행
useEffect(() => {
const subscribe = () => {
client.current.subscribe(`/sub/chat/wait/${userId}`, (body) => {
const watingRoomBody = JSON.parse(body.body) as WatingRoomBody;
const { type, roomId: newRoomId } = watingRoomBody;
if (type === 'open') {
console.log('채팅 웨이팅 시작');
}
if (type === 'match') {
console.log('매칭이 되었습니다!');
subscribeAfterGetRoomId(newRoomId);
setRoomId(newRoomId);
setIsMatch(true);
}
});
};
const publishOnWait = () => {
if (!client.current.connected) return;
client.current.publish({
destination: '/pub/chat/wait',
body: JSON.stringify({
type: 'open',
userId,
selectMbti: `${selectedMbti}`,
}),
});
};
const connect = () => {
client.current = new StompJs.Client({
brokerURL: 'ws://api.projectsassy.net:8080/ws',
onConnect: () => {
console.log('success');
subscribe();
publishOnWait();
},
});
client.current.activate();
};
connect();
return () => disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
return (
<div className={styles.ChatPage}>
<div className={styles.ChatPage__form}>
<div className={styles.ChatPage__form__participants}>
<UserInfo user={userNickname} />
{isMatch ? (
<UserInfo user={userNickname} />
) : (
<span>{selectedMbti}와의 채팅을 기다리는 중...</span>
)}
</div>
<div className={styles.ChatPage__form__chatform}>
{/* 채팅 시작시 알림 */}
<div className={styles.ChatPage__form__chatlist}>
{chatList.map((chatt: Chat) => (
<StyledMessage key={chatt.id} isMine={chatt.isMine}>
<span>{chatt.content}</span>
<span>{chatt.time}</span>
</StyledMessage>
))}
</div>
{/* 채팅 종료시 알림 */}
<ChatMessageForm
publishAfterGetRoomId={publishAfterGetRoomId}
handleChange={handleChange}
chat={chat}
/>
</div>
</div>
</div>
);
};
const StyledMessage = styled.div<StyledMessageProps>`
display: flex;
flex-direction: ${(props) => (props.isMine ? 'row-reverse' : 'row')};
margin: 20px;
background-color: #f2f2f2;
align-items: end;
> span {
:nth-child(1) {
border: none;
border-radius: 5px;
padding: 5px 8px;
background-color: ${(props) =>
props.isMine ? 'var(--chat-background-you)' : 'var(--chat-background-me)'};
}
:nth-child(2) {
font-size: 10px;
color: #949494;
border: none;
border-radius: 5px;
padding: 5px 8px;
background-color: #f2f2f2;
}
}
`;
export default ChatPage;
확실히 라이브러리를 잘 이해하고 코드를 짜면 생각보다 어렵지 않게 짤 수 있는 거 같다.
Stomp를 처음 써보신다면 꼭 전체적인 플로우를 이해하고 코드 작성하는 게 아주 도움이 될 거다.,
리팩토링 하기 전이라 추상화도 레벨이 맞지 않고 하나의 함수가 한 가지 일을 하지도 않은 엉망인 코드이지만, 내가 까먹기 전에 얼른 블로깅을 해야겠다 싶어서 올린다..!
다음은 결과 화면
'토이프로젝트 > ProjectSassy' 카테고리의 다른 글
지수 백오프(Exponential Backoff) 알아보기 (1) | 2023.07.28 |
---|---|
리액트에서 캐러셀 구현 (0) | 2023.05.07 |
인프런에서의 첫 스터디..! (3) | 2023.05.05 |