일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- react-query
- cookie
- styled-components
- textarea autosize
- 투포인터
- 그리디
- 블로그만들기
- nextjs
- Algorithm
- JavaScript
- nestjs
- 큐
- 해쉬
- js알고리즘
- 알고리즘
- react
- typscript
- tailwindcss
- aws lightsail
- NextAuth
- TypeScript
- next.js
- never타입
- 슬라이딩윈도우
- 스택
- isNaN
- 빅오
- 정렬
- 라이프사이클
- 버블정렬
- Today
- Total
far
[React + websocket] Sock.js와 Stomp.js로 간단한 채팅 구현하기 본문
프로젝트중 websocket을 도입해보자는 말이 나와서 Sock.js와 Stomp.js를 사용해 실시간 채팅을 구현해봤다.
Sock.js
Sock.js는 클라이언트와 서버간의 연결을 유지하며 양방향 통신을 가능하게 해주는 websocket과 비슷한 기능의 Javascript 라이브러리다.
프로젝트에서 Sock.js를 선택한 이유는 두가지가 있다.
첫째로, Sock.js는 크로스 브라우징을 지원하는 API이며, websocket 프로토콜을 지원하지 않는 브라우저의 경우 자동으로 Streaming, Long-Polling 같은 HTTP 기반의 다른 기술로 전환해 연결해준다.
둘째로, SpringBoot를 사용해 웹소켓을 구현할 경우 Sock.js를 사용하는게 일반적이라는 글을 읽기도 했고, Stomp.js를 사용할 때 SpringBoot에서 spring-websocket 모듈을 통해 Stomp를 제공하기 때문에 사용하기 쉽다고 들었기 때문이다.
Stomp.js
Stomp.js는 STOMP (Simple Text Oriented Messaging Protocol) 프로토콜을 사용하여 웹 브라우저나 JavaScript 환경에서 메시징 클라이언트를 작성하기 위한 Javascript 라이브러리다.
websocket 프로토콜은 Text나 Binary 두 유형의 메세지 타입은 정의하지만 메세지의 내용에 대해서는 정의하지 않는다. 그래서 Stomp를 사용해 Binary, Text가 아닌 규격을 갖춘 메시지를 보낼 수 있도록 해준다.
STOMP 프레임의 구성은 다음과 같다.
COMMAND
header1:value1
header2:value2
body
- COMMAND는 STOMP 프레임의 명령을 나타낸다. CONNECT, SUBSCRIBE, SEND, UNSUBSCRIBE, DISCONNECT 등.
- header: 헤더는 키-값 쌍의 형태로 구성되며, content-length, content-type, destination 등 프레임에 대한 추가 정보를 제공한다.
- body: 전송하려는 본문 데이터를 나타낸다.
Install
npm i sockjs-client @stomp/stompjs
채팅 구현
import { over } from 'stompjs';
import SockJS from 'sockjs-client';
const [privateChats, setPrivateChats] = useState(new Map());
const [publicChats, setPublicChats] = useState([]);
const [tab, setTab] = useState("CHATROOM");
const [userData, setUserData] = useState({
username: cookies.user?.username, // 나의 경우 유저 정보에 쿠키가 들어있기 때문
receivername: '',
connected: false,
message: ''
});
const [connectError, setConnectError] = useState(false);
const [chatError, setChatError] = useState(false);
const inputFocus = React.useRef();
const chatFocus = React.useRef();
const prChatFocus = React.useRef();
const scrollRef = useRef();
const scrollRefPr = useRef();
let stompClient = null;
const ChatRoom = () => {
const connect = useCallback(() => {
let Sock = new SockJS('연결할 url주소');
stompClient = over(Sock);
stompClient.connect({}, onConnected, onError);
}, [])
}
stomp 프로토콜 위에서 sockJS가 작동되도록 클라이언트 생성 후 connect로 연결해주고, onConnected, onError함수를 넣어준다. 이제 onConnected와 onError 함수를 만들어준다.
const onError = useCallback((err) => {
console.log(err);
}, [])
onError 함수
const onConnected = useCallback(() => {
setUserData({ ...userData, "connected": true });
stompClient.subscribe('/chatroom/public', onMessageReceived);
stompClient.subscribe('/user/' + userData.username + '/private', onPrivateMessage);
userJoin();
}, [userData])
// join send
const userJoin = useCallback(() => {
let chatMessage = {
senderName: userData.username,
status: "JOIN"
};
stompClient.send("/app/message", {}, JSON.stringify(chatMessage));
}, [userData.username])
// public 수신
const onMessageReceived = (payload) => {
let payloadData = JSON.parse(payload.body);
switch (payloadData.status) {
case "JOIN":
if (!privateChats.get(payloadData.senderName)) {
privateChats.set(payloadData.senderName, []);
setPrivateChats(new Map(privateChats));
}
break;
case "MESSAGE":
publicChats.push(payloadData);
setPublicChats([...publicChats]);
break;
}
}
// private 수신
const onPrivateMessage = (payload) => {
let payloadData = JSON.parse(payload.body);
if (privateChats.get(payloadData.senderName)) {
privateChats.get(payloadData.senderName).push(payloadData);
setPrivateChats(new Map(privateChats));
} else {
let list = [];
list.push(payloadData);
privateChats.set(payloadData.senderName, list);
setPrivateChats(new Map(privateChats));
}
}
onConnected 함수에는 subscribe로 구독을 만들어준다.
구독에 대해 설명을 하자면, subscribe()의 첫번째 인자는 구독할 url을 의미하고, 두번째 인자는 구독 후 실행할 콜백함수(onMessageReceive와 onPrivateMessage)로 상대에게 메세지를 수신 받을 때마다 실행된다. 좀 더 자세히 말하자면, 유저들이 채팅방에 입장할 때 public과 private 채팅방(위의 경로)을 구독(subscribe)을 하게 되는데, 이 때 메시지 브로커가 클라이언트의 구독 정보를 자체적으로 메모리에 유지해준다. 그래서 채팅방에서 전송되는 메세지를 수신할 수 있다.
이 코드의 경우 join시 누가 들어왔는지 알아야 하기 때문에 이름과 status를 입력받는다. 그리고 Join시 채팅창 옆에 접속자들의 Private챗을 띄워준다. 이제 senderName+메세지를 수신받을 수 있다.
// public 메세지 전송
const sendValue = () => {
if (userData.message === '') {
chatFocus.current.focus()
setChatError(true)
return
}
if (stompClient) {
let chatMessage = {
senderName: userData.username,
message: userData.message,
status: "MESSAGE"
};
stompClient.send("/app/message", {}, JSON.stringify(chatMessage));
setUserData({ ...userData, "message": "" });
chatFocus.current.focus()
setChatError(false)
}
chatFocus.current.focus()
setChatError(false)
}
// private 메세지 전송
const sendPrivateValue = () => {
if (userData.message === '') {
prChatFocus.current.focus()
setChatError(true)
return
}
if (stompClient) {
let chatMessage = {
senderName: userData.username,
receiverName: tab,
message: userData.message,
status: "MESSAGE"
};
if (userData.username !== tab) {
privateChats.get(tab).push(chatMessage);
setPrivateChats(new Map(privateChats));
}
stompClient.send("/app/private-message", {}, JSON.stringify(chatMessage));
setUserData({ ...userData, "message": "" });
prChatFocus.current.focus()
}
prChatFocus.current.focus()
}
그리고 send를 어떤 유저가 메세지를 보내면 메시지 브로커가 구독중인 다른 유저에게 메세지를 전달해준다. status가 Message여야 onMessageReceive에서 case Message로 넘어가기 때문에 값을 변경해준다. 또한, send의 경우 두번째 인자가 프레임 전송시 헤더를 설정하고 세번째 인자가 전송할 body를 보내는 곳이기 때문에 메세지를 세번째 인자에 놓는다.
const handleMessage = useCallback((event) => {
const { value } = event.target;
setUserData({ ...userData, "message": value });
}, [userData])
const handleUsername = useCallback((event) => {
const { value } = event.target;
setUserData({ ...userData, "username": value });
}, [userData.username])
HTML부분에 넣을 핸들러를 만들어준다.
const onKeyPress = useCallback((event) => {
if (event.key === 'Enter') {
sendValue();
}
}, [userData.message])
const onKeyPressPr = useCallback((event) => {
if (event.key === 'Enter') {
sendPrivateValue();
}
}, [userData.message])
Enter를 눌러서 채팅을 칠 수 있게 해준다.
useEffect(() => {
if(tab === 'CHATROOM' && scrollRef.current !== undefined && scrollRef) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [scrollRef, publicChats])
useEffect(() => {
if(tab !== 'CHATROOM' && scrollRefPr.current !== undefined && scrollRefPr) {
scrollRefPr.current.scrollTop = scrollRefPr.current.scrollHeight;
}
}, [scrollRefPr, privateChats])
새로운 채팅이 올라오면 스크롤이 맨 아래로 내려와야 하기 때문에 해당 작업을 해준다.
// 접속
const registerUser = useCallback(() => {
if (userData.username === '' || userData.username.match(/\s/g)) {
chatFocus.current.focus()
setConnectError(true)
return
}
connect();
setConnectError(false)
}, [userData.username])
// 접속 엔터
const registerUserEnter = useCallback((e: React.KeyboardEvent<HTMLElement>) => {
if (e.key === 'Enter') {
registerUser()
}
}, [userData.username])
채팅방에 접속할 수 있도록 connect를 만들어준다.
나머지는 취향에 따라 jsx파일의 HTML부분에 배치를 해주면 된다.
<ul ref={scrollRef} className="h-4/5 p-6 mb-5 overflow-y-scroll bg-white border border-gray-200 rounded-lg shadow-md dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700">
{publicChats.map((chat, index) => (
<li className={`message border border-gray-200 rounded-lg
${chat.senderName === userData.username && "justify-end"}`} key={index}>
{chat.senderName !== userData.username && <div className="avatar">{chat.senderName}</div>}
<div className="message-data flex items-center">{chat.message}</div>
{chat.senderName === userData.username &&
<div className="flex items-center px-3 py-2 rounded-lg bg-green-100">{chat.senderName}</div>}
</li>
))}
</ul>
<div className="send-message">
<input type="text"
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="채팅을 입력해주세요."
value={userData.message}
onChange={handleMessage}
ref={chatFocus}
onKeyDown={onKeyPress} />
<button
type="button"
onClick={sendValue}
className="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600">
<svg
aria-hidden="true"
className="w-6 h-6 rotate-90"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"></path>
</svg>
<span className="sr-only">Send message</span>
</button>
</div>
public 부분과 input부분의 예시.
참고: https://velog.io/@akskflwn/StompJs%EC%99%80-SockJs
https://github.com/JayaramachandranAugustin/ChatApplication
'React > 기록' 카테고리의 다른 글
[React] input에서 한 글자 입력 후 포커싱이 풀리는 현상 (0) | 2024.07.10 |
---|---|
[React + Typescript] Modal창 만들기 (+ 스크롤 고정) (0) | 2023.04.18 |
[React] Checkbox와 API연결하기 (첫 클릭시 Boolean 전환 안되는 현상 해결) (0) | 2023.04.15 |
[React + Typescript] Intersection Observer로 간단한 스크롤 애니메이션 만들기 (텍스트 가로 이동, Fade In/Out) (0) | 2023.03.30 |
[React] Key에 대하여 (Map의 key props) (0) | 2023.03.15 |