React로 SockJS, STOMP를 사용하여 채팅기능을 구현해보자.
SockJS란 웹 애플리케이션과 웹 서버 간에 실시간 양방향 통신을 가능하게 해주는 JavaScript라이브러리다.
SockJS는 웹 소켓(WebSocket)을 사용할 수 있는 경우에는 웹 소켓을 사용하여 통신하지만, 웹 소켓을 지원하지 않는 경우에는 다른 대안 수단으로 통신하도록 해주는 유용한 라이브러이다.
STOMP(Streaming Text Oriented Messaging Protocol)은 메시징 시스템과 클라이언트 간에 상호 작용하기 위해 사용되는 간단하고 가볍운 텍스트 중심의 프로토콜이다.
가볍고 간단한 프로토콜
: STOMP는 텍스트 기반으로 이해하기 쉬운 프로토콜로 HTTP와 유사한 구문을 사용하며, 프로토콜 자체가 가볍고 단순하다.
다양한 클라이언트 및 서버 지원
: STOMP는 다양한 프로그래밍 언어와 프레임워크에서 지원된다. Java, Python, JavaScript, Ruby, 등 다양한 언어에서 STOMP 클라이언트 및 서버를 구현할 수 있다.
발행-구독 및 요청-응답 모델
: STOMP는 발행-구독
모델을 통해 메시지의 발행과 구독을 지원하며, 요청-응답 모델을 통해 메시지의 요청과 응답을 처리할 수 있다.
헤더와 프레임
: STOMP는 메시지에 대한 헤더와 본문 프레임으로 구성되는데 헤더는 메시지의 속성을 나타내고, 프레임은 메시지의 실제 내용을 포함합니다.
STOMP는 대표적으로 웹 소켓(WebSocket)과 함께 사용되며, 웹 애플리케이션에서 실시간 통신을 구현하는 데 널리 사용된다. STOMP 클라이언트
를 사용하여 메시지를 발행
하고 구독
하며, STOMP 서버
는 이러한 메시지를 처리하고 전달한다.
STOMP 프로토콜을 사용하기 위해 클라이언트 라이브러리 @stomp/stompjs를 사용하는데,
이 패키지에서 CompatClient는 호환성
을 가진 STOMP 클라이언트 객체를 나타낸다.
CompatClient는 SockJS와 STOMP.js의 호환성을 지원하여 SockJS 클라이언트와 STOMP 프로토콜을 함께 사용할 수 있도록 도와준다.
CompatClient 객체는 STOMP 라이브러리를 사용하여 소켓 연결을 수행하고 STOMP 프로토콜을 통해 메시지를 주고받는 역할을 한다.
CompatClient를 client 변수로 useRef를 사용하여 초기화하면 CompatClient 객체가 생성되고 소켓 연결을 수행할 때 해당 객체를 참조하도록 할 수 있다. 이렇게 CompatClient 객체로 초기화한 client 변수는 다른 함수나 이벤트 핸들러에서 CompatClient 객체에 접근할 수 있게 된다.
예를 들어, client.current를 통해 CompatClient 객체에 접근할 수 있고, 해당 객체를 사용하여 소켓 연결 설정이나 메시지 발행 등을 처리할 수 있다. useRef를 사용했기에 리액트 컴포넌트의 렌더링이 다시 발생해도 client 변수가 유지되므로, CompatClient 객체에 접근할 수 있는 참조를 유지할 수 있다.
- 여기서 사용자는 제품ID로 서버에 방 생성 요청을 보내고
- 서버는 요청을 받으면 제품 ID, 사용자 ID, 판매자 ID로 Room을 만들어 준다.
export const postNewChatRoom = async (
token: string | null,
productId: string,
) => {
try {
const res = await axiosInstanceWithBearer.post(
`/chat/room/create`,
{
productId: productId,
},
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
return res.data.data.roomId;
} catch (error) {
console.error('방 생성 에러:', error);
}
};
// Example Response
{
"success": true,
"status": "OK",
"code": 20000,
"message": "요청이 완료되었습니다.",
"data": {
"roomId": 2,
"productId": 2,
"sellerId": 2,
"buyerId": 1
}
}
// 채팅방 생성 요청 페이지 (상품 상세) 전체 코드
interface DetailTapBarProps {
price: number | null;
curProductsId: string | undefined;
}
interface Room {
roomId: string;
productId: string;
sellerId: number;
buyerId: number;
}
const DetailTapBar = ({ price, curProductsId }: DetailTapBarProps) => {
const navigate = useNavigate();
const accessToken = localStorage.getItem(ACCESS_TOKEN);
const handleChatClick = async () => {
if (curProductsId) {
try {
// 방 생성 전에 있는 존재하는 방 리스트 확인하기
const roomsList = await getRoomsList(accessToken);
// 현재 제품 ID와 일치하는 방이 있는지 확인
const matchedRoom = roomsList.find(
(room: Room) => String(room.productId) === curProductsId,
);
if (matchedRoom) {
// 일치하는 방으로 이동
enterChatRoom(matchedRoom.roomId);
} else {
// 일치하는 방이 없으면 새로운 방 생성
await createChatRoom(curProductsId);
}
} catch (error) {
console.error('방 생성 에러:', error);
}
}
};
// 방이 없다면 curProductsId와 accessToken으로 방 생성
const createChatRoom = async (curProductsId: string) => {
try {
await postNewChatRoom(accessToken, curProductsId);
// 생성된 방으로 이동
enterChatRoom(curProductsId);
} catch (error) {
console.error('방 생성 에러:', error);
}
};
// 방으로 이동
const enterChatRoom = (roomId: string) => {
sessionStorage.setItem('curRoomId', roomId);
if (curProductsId) sessionStorage.setItem('curProductsId', curProductsId);
navigate(`${CHATROOM}/${roomId}`);
};
return (
<>
<S.DetailTapBarContainer>
<S.Menu>
<div>
<S.Left>
<Icon name="heart" width="27" height="28" />
{price !== null && <S.Price>{formatNumber(price)}원</S.Price>}
</S.Left>
</div>
<S.Right>
<Button active={!!curProductsId} onClick={handleChatClick}>
채팅하기
</Button>
</S.Right>
</S.Menu>
</S.DetailTapBarContainer>
</>
);
};
export const getChatDetails = async (
token: string | null,
curRoomId: string | undefined,
) => {
try {
const res = await axiosInstanceWithBearer.get(
`/chat/room/history/${curRoomId}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
return res.data.data.messageHistory;
} catch (error) {
console.error('채팅 내역 조회 에러', error);
}
};
// Stomp의 CompatClient 객체를 참조하는 객체 (리렌더링에도 유지를 위해 useRef 사용)
// Stomp라이브러리와 소켓 연결을 수행하는 cliet객체에 접근할 수 있게 해준다.
const client = useRef<CompatClient | null>(null);
// 소켓 연결
const connectHandler = () => {
// SockJS 클라이언트 객체를 생성하고, 웹 소켓을 연결한다.
// ws-stomp는 서버의 Endpoint 경로로, 웹 소켓 통신을 위한 특정 경로를 의미한다.
const socket = new SockJS(`${BASE_URL}/ws-stomp`);
// SockJS 클라이언트 객체 socket를 STOMP 프로토콜로 오버랩하여 client.current에 할당
client.current = Stomp.over(socket);
// 클라이언트 객체를 서버와 연결
client.current.connect(
{
Authorization: 'Bearer ' + accessToken,
'Content-Type': 'application/json',
},
() => {
// 연결 성공 시 해당 방을 구독하면 서버로부터 새로운 매시지를 수신 한다.
client.current?.subscribe(
`/sub/chat/room/${curRoomId}`,
(message) => {
// 기존 대화 내역에 새로운 메시지 추가
setChatHistory((prevHistory) => {
return prevHistory
? [...prevHistory, JSON.parse(message.body)]
: null;
});
},
{
Authorization: 'Bearer ' + accessToken,
'Content-Type': 'application/json',
},
);
},
);
};
useEffect(() => {
connectHandler();
}, [accessToken, curRoomId]);
const sendHandler = (inputValue: string) => {
// client.current가 존재하고 연결되었다면 메시지 전송
if (client.current && client.current.connected) {
client.current.send(
'/pub/chat/message',
{
Authorization: 'Bearer ' + accessToken,
'Content-Type': 'application/json',
},
// JSON 형식으로 전송한다
JSON.stringify({
type: 'TALK',
roomId: curRoomId,
message: inputValue,
}),
);
}
즉, client 객체가 메시지를 전송하면 서버는 그것을 받아서 처리(방 DB에 저장) 후 client 객체에게 다시 보내주고 client 객체는 전달 받은 메시지를 대화 내역에 추가하면서 실시간으로 메시지를 주고 받을 수 있다.
import { useNavigate } from 'react-router-dom';
import { useEffect, useRef, useState } from 'react';
import SockJS from 'sockjs-client';
import { Stomp, CompatClient } from '@stomp/stompjs';
import ChatRoomContents from '../../components/ChatRoomContents';
import ChatRoomItem from '../../components/ChatRoomItem';
import ChatInputBar from '../../components/ChatInputBar';
import NavBarTitle from '../../components/NavBarTitle';
import { BASE_URL } from '../../constants/api';
import { ACCESS_TOKEN } from '../../constants/login';
import useAsync from '../../hooks/useAsync';
import { getSeller } from '../../api/member';
import { getChatDetails } from '../../api/chat';
interface ChatHistoryProps {
type: string;
sender: string;
message: string;
}
const ChatRoom = () => {
const navigate = useNavigate();
const accessToken = localStorage.getItem(ACCESS_TOKEN);
// Stomp의 CompatClient 객체를 참조하는 객체 (리렌더링에도 유지를 위해 useRef 사용)
// Stomp라이브러리와 소켓 연결을 수행하는 cliet객체에 접근할 수 있게 해준다.
const client = useRef<CompatClient | null>(null);
const curRoomId = sessionStorage.getItem('curRoomId') || undefined;
const curProductsId = sessionStorage.getItem('curProductsId') || undefined;
// TODO : 판매자 번호 (추후 닉네임으로 받기)
const sellerData = useAsync(() => getSeller(accessToken, curRoomId));
const sellerId = sellerData?.data?.data.sellerId;
const [chatHistory, setChatHistory] = useState<ChatHistoryProps[] | null>(
null,
);
const [inputValue, setInputValue] = useState('');
// 채팅 내역 조회하고 불러오기
const checkChatDetails = async () => {
try {
const chatDetails = await getChatDetails(accessToken, curRoomId);
setChatHistory(chatDetails);
} catch (error) {
console.error('채팅 내역 불러오기 에러:', error);
}
};
useEffect(() => {
checkChatDetails();
}, [accessToken, curRoomId]);
// 소켓 연결
const connectHandler = () => {
// SockJS 클라이언트 객체를 생성하고, 웹 소켓을 연결한다.
// ws-stomp는 서버의 Endpoint 경로로, 웹 소켓 통신을 위한 특정 경로를 의미한다.
const socket = new SockJS(`${BASE_URL}/ws-stomp`);
// SockJS 클라이언트 객체 socket를 STOMP 프로토콜로 오버랩하여 client.current에 할당
client.current = Stomp.over(socket);
// 클라이언트 객체를 서버와 연결
client.current.connect(
{
Authorization: 'Bearer ' + accessToken,
'Content-Type': 'application/json',
},
() => {
// 연결 성공 시 해당 방을 구독하면 서버로부터 새로운 매시지를 수신 한다.
client.current?.subscribe(
`/sub/chat/room/${curRoomId}`,
(message) => {
// 기존 대화 내역에 새로운 메시지 추가
setChatHistory((prevHistory) => {
return prevHistory
? [...prevHistory, JSON.parse(message.body)]
: null;
});
},
{
Authorization: 'Bearer ' + accessToken,
'Content-Type': 'application/json',
},
);
},
);
};
useEffect(() => {
connectHandler();
}, [accessToken, curRoomId]);
// 소켓을 통해 메시지를 전송
const sendHandler = (inputValue: string) => {
// client.current가 존재하고 연결되었다면 메시지 전송
if (client.current && client.current.connected) {
client.current.send(
'/pub/chat/message',
{
Authorization: 'Bearer ' + accessToken,
'Content-Type': 'application/json',
},
// JSON 형식으로 전송한다
JSON.stringify({
type: 'TALK',
roomId: curRoomId,
message: inputValue,
}),
);
}
};
const handleBackIconClick = () => {
sessionStorage.clear();
navigate(-1);
};
useEffect(() => {
sendHandler(inputValue);
}, [inputValue]);
return (
<>
<NavBarTitle
type="high"
backIcon
prevTitle="뒤로"
centerTitle={sellerId}
moreIcon
preTitleClick={handleBackIconClick}
/>
<ChatRoomItem curProductsId={curProductsId} />
<ChatRoomContents chatHistory={chatHistory} />
<ChatInputBar onChange={setInputValue} />
</>
);
};
export default ChatRoom;
유익한 글 잘 봤습니다, 감사합니다.