리액트와 Node.js의 익스프레스 프레임워크를 활용하여 만들어본 채팅 프로그램 입니다(with 웹소켓)
리액트는 버전 18로 구성되어 있으며 함수형으로 되어 있습니다.
익스프레스 서버는 4.18버전이며 타입스크립트(Typescript) 환경으로 구성 하였습니다.
#1. 익스프레스 서버(Express server)
익스프레스 서버에서는 아래 3가지 역할을 하도록 되어 있습니다.
0) 회원 가입 및 로그인 응답
1) 채팅방 만들기
2) 채팅방 전달하기
타입스크립트를 활용하면 데이터 형식(type)을 지정 할 수 있습니다.
이런 훌륭한 기능을 사용하기 위해 채팅방과 관련된 타입을 먼저 정의하여 보았습니다.
//소캣 객체 타입 입니다.
type soketT = {
ws: WebSocket; //웹소캣 객체 입니다.
_room_id: string; //방 키값 입니다.
};
//방 관련 타입 입니다.
type roomT = {
kor: string; //방이름 입니다.
password: string; //방 미빌번호 입니다.
_id?: string; //만든사람 아이디 입니다.
_room_id: string; //실제 사용될 방키값 입니다.
list?: Array<string>; //방에들어온 유저목록 입니다.
showId?: string; //외부에서 사용되는 실제 아이디 입니다.
join?: string; //방에 들어오는 경우입니다.
send?: string; //메시지를 보내는 경우 입니다.
};
//메시지 타입 입니다.
type message = { _id: string; message: string; _room_id: string };
//마스터 타입 입니다.
type roomAndMsg = roomT & message;
위 타입을 바탕으로 작업을 진행하였습니다.
아래는 회원 가입 및 로그인 응답을 하는 코드 입니다.
//간단하게 구현한 로그인 관련 내용 입니다.
const db = new Map<string, string>(); //데이터 베이스용 map 객체 입니다.
app.all("/data/joinOrLogIn", (req: express.Request, res: express.Response) => {
let { id, password, join } = req.body;
id = id.toString();
if (join) {
//회원가입
if (!db.get(id)) {
db.set(id, password);
res.set(id, password.toString());
res.send({ result: "OK" });
} else {
res.send({ result: "ID IS EXSIST" });
}
} else {
//로그인
if (!db.get(id)) {
res.send({ result: "no member" });
} else if (db.get(id) && db.get(id) != password) {
res.send({ result: "wrong password" });
} else {
res.send({ result: "OK" });
}
}
});
특정 데이터베이스를 사용하지 않기 위해서 간단하게 Map 객체를 활용 하였습니다.
그러므로 익스프레스 서버가 종료가 되면 채팅과 관련된 정보는 존재하지 않습니다.
다음으로 채팅방 목록을 전달하고 채팅방을 만들어주는 기능 입니다.
채팅방이라는 개념은 키와 값이 존재하는 Map형태로 구현하면 사용하기가 쉽습니다.
//채팅방 객체 입니다.
let room: Map<string, roomT> = new Map<string, roomT>();
//채팅방 목록을 전달 합니다
app.all("/data/getRoomList", (req: express.Request, res: express.Response) => {
let arr: Array<any> = [];
room.forEach((value, key) => {
let { kor, password, _room_id } = value;
arr.push({ key, kor, password, _room_id, size: value.list.length });
});
res.send(JSON.stringify(arr)); //Javascript에서 Map 객체는 JSON.stringfy에 아무런 반응을 안합니다..
});
//방을 개설합니다.
app.all("/data/createRoom", (req: express.Request, res: express.Response) => {
let { kor, password, _id }: roomT = req.body; //방이름, 비밀번호, 사용자 아이디를 받습니다
let rommId = getUniqueID();
let array = new Array();
room.set(rommId, {
kor: kor,
password: password,
_room_id: rommId,
list: array,
});
res.send(JSON.stringify({ result: "succ", _room_id: rommId }));
});
타입을 지정하면 데이터의 제한을 바로 적용할 수가 있습니다.
정해지지 않는 데이터가 들어오거나, 없는 값이 오는 것을 방지할 수가 있습니다.
여기서 주의해야되는 점은 Javascript에서의 Map 객체는 JSON.stringfy 함수를 사용하게 되면 아무런 값이 나오지 않는다는 점 입니다.
그러므로 Map객체를 외부에 문자형태로 전달하기 위해서는 배열로 변환하여 문자로 치환하거나, 아니면 Map 객체를 문자로 바꾸어주는 함수를 추가하여야 합니다.
* Map 객체를 문자로 변환하기
https://lts0606.tistory.com/596
#2. 웹소켓 서버(Websocket server)
웹소캣 서버는 ws 라이브러리를 사용하였습니다. * npm install ws
ws라이브러리는 워낙 유명하고 기능이 직관적이라 기본적인 개념과 구조만 잘 만든다면 어렵지 않게 사용할 수 있습니다.
import { WebSocket, WebSocketServer } from "ws";
import { IncomingMessage } from "http";
const wss = new WebSocketServer({ port: 8001 });
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
//최초 접속시 해야할 행동 정의 구간
//데이터 수신 이벤트
ws.on("message", (data : any)=>{});
//연결이 끊긴 경우
ws.on("close", () => {
});
});
큰 틀을 잡아준 다음에 이제 기본 구성을 하여 봅니다.
최초 접속을 하게되면 사용자는 특정 아이디값을 기준으로 해당 소켓(websocket) 정보를 저장해야 합니다.
사용자 아이디와 함께 웹소켓 객체를 저장하는 이유는 보내고 받는 사람을 구분짓기 위해서 필요한 작업이기 때문 입니다.
const sokets = new Map<string, WebSocket>(); //소캣 정보를 담기위한 Map 참조 객체 입니다.
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
//최초 접속시 사용자아이디와 웹소캣 객체를 담습니다.
sokets.set('고유아이디', ws);
//데이터 수신 이벤트
ws.on("message", (data : any)=>{
//Map 객체를 반복문을 통해 메시지를 전달 할 수 있습니다.
sokets.forEach((value: WebSocket, key: any) => {
value.send(`받는아이디 : ${key}, 보내는 메시지 : ${data.toString()}`);
});
});
//연결이 끊긴 경우
ws.on("close", () => {
});
});
위 코드에서 "데이터 수신 이벤트" 에서 다양한 조건을 추가 한 다면 채팅방에 따라 메시지를 받는사람을 구분지을 수가 있습니다.
이렇게 하기 위해서는 기존에 map 객체에 "아이디, 웹소켓" 형태로 저장했던 데이터를 새롭게 지정할 필요가 있습니다.
ws.on("message", (data : any)=>{
sokets.forEach((value: WebSocket, key: any) => {
if(방아이디 == map객체에 저장된 방아이디가 같다면){ //요런 식으로 작업하면 되겠네요!
value.send(`받는아이디 : ${key}, 보내는 메시지 : ${data.toString()}`);
}
});
});
아래 샘플 코드는 아이디를 기준으로 소캣정보를 새로이 저장하는 코드 입니다.
이제 남은 것은 메시지 송수신기능과 소켓이 끊기는 경우 입니다.
메시지 송수신에서는 메시지 타입에 따라 브라우저에서 전달을 해 주어야 하며,
사용자가 방에서 나가는 경우 웹소켓 객체가 담긴 sokects 객체를 정리해 주어야 하는 것 입니다.
type soketT = {
ws: WebSocket; //웹소캣 객체 입니다.
_room_id: string; //방 키값 입니다.
};
//const sokets = new Map<string, WebSocket>(); //소캣 정보를 담기위한 Map 참조 객체 입니다.
const sokets = new Map<string, soketT>(); //soketT 타입으로 바꾸었습니다.
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
//최초 접속시 사용자아이디와 웹소캣 객체를 담습니다.
const id = req.url.id; //요청 url에서 아이디값을 분리하도록 합니다.
sokets.set(id, { ws, _room_id: null }); //최초 들어오면 아이디를 기준으로 값을 저장 합니다.
//데이터 수신 이벤트
ws.on("message", (data : any)=>{
//Map 객체를 반복문을 통해 메시지를 전달 할 수 있습니다.
sokets.forEach((value: WebSocket, key: any) => {
value.send(`받는아이디 : ${key}, 보내는 메시지 : ${data.toString()}`);
});
});
//연결이 끊긴 경우
ws.on("close", () => {
});
});
기존에 정의된 타입으로 조건식만 잘 활용하다면 어렵지 않게 구현할 수 있습니다.
#3. 리엑트(React.js)
리엑트에서는 리엑트 버전이 높아서 그런지 웹소켓 객체를 생성하여 사용하게 되는 경우에 오류가 발생하였었습니다.
이러한 불편함을 해결하고자 "react-use-websocket" 라이브러리를 추가하여 사용하였습니다.
익스프레스 서버와의 응답은 프록시를 통해 진행 하였습니다.
기본적인 상태 관리 및 공유는 useState와 레덕스(redux)를 통하여 진행 하였습니다.
로그인과 관련된 정보는 세션스토리지에 저장하여 브라우저가 새로고침을 하거나 다른 페이지를 이동하더라도 해당 정보는 지속적으로 남아있게 하였습니다.
import { createAction, handleActions } from 'redux-actions';
//#1. 상태 정의
const INSERT_SESSION = 'LOGIN_SESSION'
//#2. 함수 정의
export const UPDATE_SESSION = createAction(INSERT_SESSION, arg => arg)
//#3. 상태(세션 스토리지에 저장하여 새로고침에 대비합니다.)
const DATA_STATUS = {id : ''}
if(sessionStorage.getItem('id')){
DATA_STATUS.id = sessionStorage.getItem('id');
}
//#4. 리듀서
const GET_SESSION = handleActions({
[INSERT_SESSION] : (state, action)=>{
sessionStorage.setItem('id', action.payload._id)
return {
state, id : action.payload._id
}
}
}, DATA_STATUS)
export default GET_SESSION
또 한번 어려웠떤 점이..리엑트 라우터의 버전이 높아짐에 따라(6.3) 기존의 방식들과 조금은 다른 방식으로 개발을 해야되었던 점 입니다.
예를들면, render 라는 속성을 통해서 라우터기능을 만지곤 했었는데..리엑트 라우터 6.3버전에서는 사라져 버렸습니다.
큰 틀은 그대로 유지하고 있기 때 문에 어렵지 않게 작업은 할 수 있었습니다.
왜 SPA 형식의 프레임워크(라이브러리) 들은 버전을 올리면서 하위호환을 안되게 만드는지 항상 궁금합니다..
import {HashRouter, Routes, Route} from 'react-router-dom'
import Main from './component/Main'
import ChattingList from './component/ChattingList'
import NotFound from './component/NotFound'
import Chatting from './component/Chatting'
import { useSelector} from 'react-redux';
//라우팅을 담당하는 App 함수 입니다.
function App(props) {
const logInData = useSelector( state => state.INSERT_SESSION) //로그인 여부, ChattingList와 Chatting 컴포넌트에 대한 접근을 제한합니다.
return (
<HashRouter>
<Routes>
<Route path="/" element={<Main {...props}/>} ></Route>
<Route path="/ChattingList" element={ logInData.id.length > 0 ? <ChattingList {...props}/> : <NotFound {...props}/> } ></Route>
<Route path="/chatting" element={ logInData.id.length > 0 ? <Chatting {...props}/> : <NotFound {...props}/> } ></Route>
<Route path="*" element={<NotFound {...props}/>} ></Route>
</Routes>
</HashRouter>
);
}
export default App;
웹소켓 채팅은 "react-use-websocket" 라이브러리를 사용하였는데 사용법이 무척 쉽고 간결하였습니다.
리엑트 라이브러리답게 use..라는 이름으로 되어 있습니다.
import React, { useState, useEffect } from 'react'
import { useLocation } from "react-router";
import useWebSocket from 'react-use-websocket'; //웹소켓 라이브러리를 사용 합니다.
function Chatting(arg) {
let { state: { value, _id } } = useLocation();
const [socketUrl,] = useState(`ws://localhost:8001/wsocket?id=${_id}`);
const { sendMessage, lastMessage } = useWebSocket(socketUrl); //웹소캣 라이브러리인 useWebSocket 입니다.
const [messageHistory, setMessageHistory] = useState([]); //웹소켓에서 메시지를 받으면 호출되는 상태 입니다.
}
안타까운 것은 useState에 적혀있는 웹소켓 주소가 프록시를 타지 않는다는 점 입니다..ㅠ
어쩔수 없이 하드코딩한 부분이므로 실제 배포시에는 반드시 고려되야 하겠습니다.
lastMessage값은 웹소켓 채팅시 마지막에 들어오는 값 이며,
setMessageHistory 함수를 통하여 기존의 메시지들을 배열형태로 넣어서 항상 관리 할 수 있습니다.
그러므로 채팅 목록을 보여주기 위해서는 messageHistory를 사용하면 되겠습니다.
아래 코드는 실제 타입을 고려하여 작성된 코드 입니다.
import React, { useState, useEffect } from 'react'
import { useLocation } from "react-router";
import useWebSocket from 'react-use-websocket'; //웹소켓 라이브러리를 사용 합니다.
function Chatting(arg) {
let { state: { value, _id } } = useLocation();
const [socketUrl,] = useState(`ws://localhost:8001/wsocket?id=${_id}`);
const { sendMessage, lastMessage } = useWebSocket(socketUrl); //웹소캣 라이브러리인 useWebSocket 입니다.
const [messageHistory, setMessageHistory] = useState([]); //웹소켓에서 메시지를 받으면 호출되는 상태 입니다.
//메시지를 보내기 위한 기능 입니다.
const [message, setMessage] = useState('');
const onMessage = (event) => (setMessage(event.target.value));
const sendMsg = () => {
let msg = { //전송 규격
_room_id: value._room_id,
_id: _id,
send: 'send',
message: message
}
sendMessage(JSON.stringify(msg))
}
//메시지에 대한 변화에 대해서 정의 합니다.
useEffect(() => {
if (lastMessage !== null) {
setMessageHistory((prev) => { //기존 메시지에 데이터를 추가합니다.
let msg = lastMessage ? lastMessage.data : null;
if (msg) {
let object = JSON.parse(msg);
lastMessage._id = object._id;
lastMessage.result = object.result;
lastMessage.message = object.message;
}
return prev.concat(lastMessage)
});
}
}, [lastMessage, setMessageHistory]);
//최초 방에 들어온 경우 실행되는 "나 방에 들어왔어" 기능 입니다.
useEffect(() => {
let join = {
_room_id: value._room_id,
password: value.password,
_id: _id,
join: 'join'
}
sendMessage(JSON.stringify(join))
}, [value, _id, sendMessage])
return (
<div className='container'>
<div className='col-md-12 border m-4'>
chatting
<input type='text' onChange={onMessage} value={message} placeholder='메시지를 입력하세요' className='form-control' />
<button type='button' onClick={sendMsg} className='btn btn-success'> send test</button>
</div>
<div className='col-md-12 border m-4'>
<div>전체 메시지</div>
<ul>
{messageHistory.map(
(message, idx) => {
if (message.result) {
let desc = '가 나갔습니다.'
if (message.result === 'someIn') {
desc = '가 들어왔습니다.'
}
return <div key={idx} className='form-control'>{message._id}{desc}</div>
}
return <div key={idx} className='form-control'>
<strong>{message._id}</strong> : {message.message}
</div>
}
)}
</ul>
</div>
</div>
);
}
export default Chatting;
대규모 트래픽에 대해서 고려가 되지 않았기 때 문에 많은 인원이 사용되는 환경이라면 정보를 저장하는 부분이 고려되어야 할 것 같습니다.
데이터베이스를 사용하지 않으므로 서버가 종료됨과 동시에 채팅내용이 전부 사라지는 것도 고려가 되어야 겠습니다.
* 레디스, 몽고db는 어떨까요?
위 내용에 사용된 최종 소스코드는 아래 제 깃허브에서 받아서 확인하실 수 있습니다.
https://github.com/TaeSeungRyu/ReactWith_TypescriptExpress
이상으로 리엑트 + 익스프레스 + 웹소켓 과 관련된 토이프로젝트 소개를 마치겠습니다.
세부적인 설명은 깃허브의 소스코드에 주석을 전부 입력하였습니다.
궁금한점 또는 틀린부분은 언제든 연락 주세요! 👻
'토이 프로젝트(TOY PROJECT)' 카테고리의 다른 글
일렉트론 랜덤 번호 추출기(윈도우 번호 추첨) (0) | 2022.11.25 |
---|---|
리액트 익스프레스 게시판 (React, Express typescript, file drag and drop) (0) | 2022.06.18 |
댓글