Stack
ReactJS
, Mui
, NodeJS
, Pm2
Preview
앱을 사용하다보면 PUSH 알림을 자주 받게됩니다. 금융앱에서도 이체, 주식주문 등과 같은 이벤트가 발생 시 고객에게 PUSH 알림을 주곤합니다.
PUSH 서버는 메시지를 받아 FCM(Firebase Cloud Messaging)
으로 전송하기 위한 Bridge
성격의 서버로 구성했습니다. 자세한 내용은 아래 링크를 참고해 주세요.
https://sieon-dev.tistory.com/69
이제 이 푸시 서버를 직접적으로 다루지 않고 관리할 수 있는 Admin
웹 사이트를 개발한 방법을 소개할까 합니다.
Methodology
Reactjs
로 개발했고 컴포넌트는 Mui 라이브러리를 사용해서 간편하게 개발했습니다.
네모난 박스 하나는 한 개의 pm2 프로세스 상태를 보여주며 토글스위치로 pm2 프로세스를 내리고 올릴 수 있습니다.
//React
<FormControlLabel label="Process ON" control={
<Switch onChange={(e)=>{handleSwitch(e,item,idx)}} checked={checked?.[idx]} disabled={item.name==="WebServer"}></Switch>}>
</FormControlLabel>
//node router
router.post('/api/stopProcessById' , async(req,res)=>{
res.header("Access-Control-Allow-Origin", "*");
console.log("start pid : " + req.body.pid);
res.send(JSON.stringify(await pm2.stopProcessById(req.body.pid)));
})
router.post('/api/startProcessById', async(req,res)=>{
res.header("Access-Control-Allow-Origin", "*");
res.send(JSON.stringify(await pm2.startProcessById(req.body.pid)));
})
const startProcessById = async(pid)=>{
return new Promise((resolve,reject)=>{
pm2.connect((err)=>{
if(err) resolve(0);
pm2.restart(pid,{}, (err,data)=>{
if(err) resolve(0);
resolve(1);
});
});
pm2.disconnect(()=>{})
});
}
module.exports.startProcessById = startProcessById;
const stopProcessById = async(pid) =>{
return new Promise((resolve,reject)=>{
pm2.connect((err)=>{
if(err) resolve(0);
pm2.stop(pid, (err,data)=>{
if(err) return;
resolve(1);
});
});
pm2.disconnect(()=>{})
});
}
module.exports.stopProcessById = stopProcessById;
리액트에서 토글 change 이벤트가 발생하면 startProcessById
api를 호출하면서 Change Event
가 발생한 컴포넌트의 pId 값을 보내 해당 pId를 가진 pm2 프로세스를 시작합니다.
현재 각 프로세스는 FEP의 각 포트에 연결되어 있고 푸시서버가 장애가 날 수 있기 때문에 이중화를 통해 언제든 각기 다른 포트에 연결할 수 있어야 합니다.
Pm2 instance 중 웹 서버는 종료되면 안 되기 때문에 WebServer 가 아닌 active 된 instance를 가져오는 코드는 아래와 같습니다.
const getInstancesWithOutWeb = async () => {
var pm2List = await getInstances();
return pm2List.filter(item => item.name != 'WebServer');
}
또한 푸시가 전송됨을 한 instance가 감지해 다른 instance들과 웹서버 instance에 시그널을 보내기 위한 코드는 아래와 같습니다.
const sendDataToInstance = async (data) =>{
let inst = await getWebInstance(); //이 코드에선 웹 instance에만 전송하기 위함.
pm2.connect((err)=>{
if(err) return;
pm2.sendDataToProcessId(inst.pm_id,{
type : 'message received signal',
data : data,
topic : 'message received'
},(err,res)=>{
});
});
pm2.disconnect();
}
Log Table
푸쉬가 전송되기 시작하면 Redis DB에 적재하고 FCM으로 전송 결과를 update 하는 구조로 개발했습니다.
서버가 이중화되어있기 때문에 Redis 역시 Master-Slave 구조로 이중화해놓았고,
서버가 다운되면 날아갈 수 있기 때문에 AOF 방식으로 명령이 실행될 때마다 백업이 되도록 했습니다.
Redis에 데이터를 쌓는 방식은 다음과 같습니다.
1. 데이터를 받으면 pushTable이라는 키를 값을 가진 Object의 Value(type array)에 push 합니다.
let indexOfObj = await this.client.rPush('pushTable', JSON.stringify({데이터}));
indexofObj에는 방금 insert 된 데이터의 index of array 가 return 됩니다. (ex. arr = [1,2,3] 일 때 5가 rPush 되면 4가 return 됩니다.)
이때 받은 indexOfObj를 데이터의 unique 한 값을 키로 한 객체에 value로 저장합니다.
let bool = await this.client.hSet('keyIndex', uniqueKey, indexOfObj-1);
이렇게 한 이유는 insert 한 데이터를 update 할 때 index 값으로 바로 찾기 위함입니다.
//update
let index = parseInt(await this.client.hGet('keyIndex', data.uniqueKey));
let data_ = JSON.parse((await this.client.lRange('pushTable', 0, -1))[index]);
if(isSuccess) {
data_['fcm_send_yn'] = 'Y';
data_['fcm_send_time'] = nowTime;
data_['result'] = response;
}
else{
data_['fcm_send_yn'] = 'N';
data_['fcm_send_time'] = nowTime;
data_['result'] = response;
}
let bool = await this.client.lSet('pushTable', index, JSON.stringify(data_));
이제 저장된 데이터를 읽어와야겠죠!
하지만 데이터가 너무 많으면 테이블 UI에 버벅거림이 발생하기 때문에 pagination을 구현해 주었습니다.
selectPushTable = async (page, searchBy, keyword, isDesc, callback)=>{
let total = (await this.client.lRange('pushTable',0, -1)) || [];
if(isDesc) total = total.reverse(); //내림차순인 경우
//검색 키워드와 카테고리에 따라 filtering
if(keyword) total = total.filter((item)=> JSON.parse(item)[searchBy].includes(keyword));
let length_ = total.length;
let data_ = page ? total.slice((page-1)*100, page*100) : total; //100건 씩 paging
callback(true, {data_, length_});
}
이렇게 받아온 데이터는 React에서 Mui 컴포넌트 중 Table을 사용하여 보여줍니다.
//React
const [info, setInfo] = useState([]);
const [dataLoad, setDataLoad] = useState(false);
const [totalLength, setTotalLength] = useState(0);
useEffect(() =>{
const getMsgTBL = async()=>{
let result = await fetchServer('getMsgTBL', 'POST', {page,searchBy, keyword,isDesc})
if(result.data_){
let foo = result.data_.map((item)=>JSON.parse(item));
setTotalLength(result.length_);
setInfo(foo);
setDataLoad(true);
}
}
getMsgTBL();
},[page, keyword,dbFlused,isDesc, searchBy]);
import {Typography, Paper, Box, Button, TableContainer, Table, TableHead, TableRow, TableCell, TableBody, IconButton, TextField,ToggleButtonGroup, ToggleButton, Select, MenuItem} from "@mui/material";
return (<Box sx={{width: '100%'}}>
<Typography variant="h5">Push 전송 내역</Typography>
<Box sx={{display:"flex", justifyContent:"end", mb:2, gap: 1}}>
<Select value={searchBy} label="search by" size="small" onChange={handleSearchByChange}>{columns.map((item)=>{return <MenuItem key={item} value={item}>{item}</MenuItem>})}</Select>
<TextField variant='outlined' size='small' label={"search by " + searchBy} onChange={handleKeyword}></TextField>
<Button onClick={flushDB} variant="contained">Flush Database<Delete ></Delete></Button>
</Box>
{dataLoad && <Paper sx={{ mb :2}}>
<TableContainer sx={{maxHeight: 800}}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell sx={{alignItems:"center"}} style={{minWidth:170}}>sndn_rsev_hour<IconButton onClick={onOrderHandle}>{isDesc ? <KeyboardDoubleArrowDown />:<KeyboardDoubleArrowUp/>}</IconButton></TableCell>
<TableCell>csno </TableCell>
<TableCell>uuid_id </TableCell>
<TableCell>device_id </TableCell>
<TableCell>msg_title </TableCell>
<TableCell>msg_contents</TableCell>
<TableCell>D_servicename </TableCell>
<TableCell>push_grp_id </TableCell>
<TableCell>cust_telno </TableCell>
<TableCell>fcm_send_yn</TableCell>
<TableCell>fcm_send_time </TableCell>
<TableCell>result </TableCell>
</TableRow>
</TableHead>
<TableBody>
{info.map((item,idx)=>{
return <TableRow key={idx} sx={{'&:last-child td, &:last-child th' : {border:0}}}>
{Object.keys(item).map((column,idx)=>{
if(columns.includes(column)) return <TableCell key={column} align="left" component="th">{column==="result" ? JSON.stringify(item[column]) : item[column]}</TableCell>
})}
</TableRow>
})}
</TableBody>
</Table>
</TableContainer>
<Box sx={{display:"flex"}}>
<Typography variant="subtitle1" sx={{m:2, color:"gray"}}>Rows Per Page : {rowsPerPage}</Typography>
<Typography variant="subtitle1" sx={{m:2, color:"gray"}}>{page} of {parseInt(totalLength/rowsPerPage)+1}</Typography>
<IconButton onClick={()=>{handlePage("before")}}><NavigateBefore></NavigateBefore></IconButton>
<IconButton onClick={()=>{handlePage("next")}}><NavigateNext></NavigateNext></IconButton>
<Typography variant="subtitle1" sx={{m:2, color:"gray"}}>total {totalLength}</Typography>
</Box>
</Paper>}
</Box>)
Reference
'개발 프로젝트' 카테고리의 다른 글
Chrome Extension - Html Tag wrapper (0) | 2023.10.27 |
---|---|
아파트 매물 알리미 - NestJs / ReactJs (0) | 2023.10.14 |
NodeJS Chat Service (0) | 2023.04.21 |
[SwitfUI] 깨워줘요 앱 개발 (0) | 2023.03.07 |
파이썬으로 업무 프로그램 개발하기 (0) | 2022.11.11 |