본문 바로가기

개발 프로젝트

Push 서버 관리자 사이트 개발

728x90
 

Stack

ReactJS, Mui, NodeJS, Pm2

Preview

앱을 사용하다보면 PUSH 알림을 자주 받게됩니다. 금융앱에서도 이체, 주식주문 등과 같은 이벤트가 발생 시 고객에게 PUSH 알림을 주곤합니다.

 

PUSH 서버는 메시지를 받아 FCM(Firebase Cloud Messaging)으로 전송하기 위한 Bridge 성격의 서버로 구성했습니다. 자세한 내용은 아래 링크를 참고해 주세요.

https://sieon-dev.tistory.com/69

 

[Node.js] PM2를 사용해 PUSH서버 구축하기

이번 포스팅에서는 증권사 내부 서버에서 고객의 MTS(Mobile Trading System)에 푸시 알림이 도착하기 까지의 과정을 코드와 함께 기록해두려 합니다. * 보안으로 인해 중요 정보는 생략하고 넘어가겠

sieon-dev.tistory.com

 

이제 이 푸시 서버를 직접적으로 다루지 않고 관리할 수 있는 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

https://mui.com/

 

MUI: The React component library you always wanted

MUI provides a simple, customizable, and accessible library of React components. Follow your own design system, or start with Material Design.

mui.com