EZSET-전자출결-02-출석기능구현하기

Seok·2020년 12월 6일
0

EZSET

목록 보기
2/7
post-thumbnail

EZSET 프로젝트를 진행하면서 small-j와 함께 출석 파트를 담당했다. 출석 파트를 개발하는 과정을 기록해보려한다.


0. 전자출결 요구사항

전자출결 기능을 구현하기 앞서 필요한 사항은

  • 출결페이지에 있다가 출석이 시작되면 실시간으로 출석창으로 바뀜

  • 종료시에도 실시간으로 종료상태로 바뀜

  • 출석 시작 후 종료를 누르지 않아도 일정시간 후 자동 종료

  • 출석코드를 입력하지 않는다면 자동으로 결석 처리

  • 이미 출석을 한 사용자는 출석코드 입력창을 띄우지 않음

정도가 있다.


1. 실시간 상태 알림을 위한 Socket.io

요구사항중 현재 출석이 진행중인지, 종료된 상태인지를 실시간으로 사용자들에게 알려주기 위해 Socket.io 모듈을 사용했다.

Socket.io에 대한 자세한 설명은 추후에 작성하고 출결 기능중 socket의 흐름은 아래와 같다.

  1. Socket 서버는 출석이 상태변경을 계속 기다리며 클라이언트가 출결 페이지 접속 시 attendance room에 넣는다.

  2. 관리자가 출석을 시작한 경우 Socket 서버에게 상태변경을 알리고 서버는 출석이 종료될 때까지 attendace room에 접속중인 클라이언트들에게 일정 시간 간격으로 계속 해서 현재 상태를 전달한다.

  3. 이미 출석이 시작된 상태에서 사용자가 attendance room에 입장시 2번에서 socket 서버는 계속해서 현재 상태를 알리고 있으므로 바로 출석 코드를 입력할 수 있는 창을 볼 수 있게한다.

  4. 출석 시작과 동시에 타이머를 작동시켜 3분이 지나게되면 자동으로 출석상태를 종료로 업데이트 하고 클라이언트들에게 전달한다.

1-1. Socket.io 메소드

//connect event
    io.on('connection', function(socket) {
        ...
    })

클라이언트가 소켓서버에 접속 시 클라이언트의 정보를 담은 socket객체를 전달 받는다.

io 객체는 연결된 전체 클라이언트의 정보를 담고있는 객체이다.

 socket.on('disconnect', () => {
            console.log('[socket.io] ' + socket.id + 'user disconnected')
        })

클라이언트에서 연결이 끊기거나 페이지를 벗어났을때 disconnect 이벤트가 발생한다.

메시지를 수신하기 위해서는 on 메소드를 사용한다.

    socket.on('eventName', function(data) {
        ...
    })

eventName은 클라이언트가 메시지 송신 시 지정한다.

function은 이벤트 핸들러로 함수의 인자로 클라이언트가 전송한 메시지가 전달 된다.

메시지를 발신하기 위한 메소드

    //접속한 모든 클라이언트들에게 메시지를 전송
    io.emit('eventName',msg);

    //특정 Room 에 접속한 클라이언트들에게 메시지를 전송한다.
    io.to('attendance').emit('eventName',msg);
    
    //메시지를 전송한 클라이언트에게만 메시지를 전송
    socket.emit('eventName',msg);

    //메시지를 전송한 클라이언트만 제외하고 모든 클라이언트에게 메시지 전송
    socket.broadcast.emit('eventName',msg);

Room 에 접속하기

    //클라이언트를 roomName 이라는 Room 에 입장시킨다.
    socket.join('roomName')

더많은 메소드들이 있지만 내가 사용한 메소드들만 정리했다.

1-2. 서버 코드(socket.js)

export const io = undefined

export async function initSocket(app, SOCKET_PORT) {
    const server = require('http').createServer(app)
    const io = require('socket.io')(server)

    //현재 출석 상태를 저장
    var curState = {
        flag: false,
    }

    //클라이언트 접속 이벤트 핸들러
    io.on('connection', function(socket) {
        socket.on('join', function(data) {
            //클라이언트에게 접속할 Room 이름을 전달받고 클라이언트를 해당 Room에 넣음
            socket.join(data.roomName)
        })

        // 클라이언트 disconnect 이벤트 핸들러
        socket.on('disconnect', () => {
            console.log('[socket.io] ' + socket.id + 'user disconnected')
        })

        //attendance 이벤트 핸들러
        //출석상태가 관리자에 의해 변경된 경우 클라이언트에게 바뀐 상태를 전달함
        socket.on('attendance', function(data) {
            curState.flag = data.flag
            var rtnMessage = {
                flag: data.flag,
            }
            // 상태를 변경시킨 관리자를 제외하고 접속한 모든 사용자에게 바뀐상태를 전달
            socket.broadcast.to('attendance').emit('attendance', rtnMessage)
        })
        //출석이 진행중일때 접속하는 클라이언트를 위한 start이벤트 핸들러
        // setInterval로 0.5초 마다 attendance room에 있는 클라이언트들에게 상태를 전달함
        //setTimeout 으로 출석 시작 후 3분이 지나면 자동으로 종료되게 함
        socket.on('start', function(data) {
            var emitFlag = setInterval(function() {
            io.to('attendance').emit('attendance', curState)
                if (curState.flag == false) clearInterval(emitFlag)
            }, 500)

            setTimeout(function() {
                if (curState.flag == true) {
                    var endMsg = {
                        flag: false,
                    }
                    curState.flag = false
                    io.to('attendance').emit('attendance', endMsg)
                }
            }, 300000)
        })
    })

    //start socket.io server
    server.listen(SOCKET_PORT, function() {
        console.log(`[socket io] server listening on port ${SOCKET_PORT}`) // eslint-disable-line no-console
    })
}

2. API

출석체크 페이지에서 필요한 API는 5 개 였다.

  1. 출석 시작시 랜덤코드 생성 처리

  2. 사용자들의 출석 요청 처리

  3. 출석 수행 여부 처리

  4. 관리자일 경우 랜덤 코드 유지

  5. 종료 처리

2-1. API 작성

2-1-1. 출석 시작시 랜덤코드 생성 처리

출석은 관리자가 시작시 화면에 3자리 숫자가 나오고 사용자들은 그 숫자를 자신의 화면에 입력하는 방식이다.

그 랜덤숫자를 DB에 저장하는 것까진 필요없다고 생각해서 서버에서 가지고 있는 형태로 설계했다.

먼저 출석이 시작 되면 DB의 두개의 Collection에 모두 접근하여 출석을 시작한 관리자와 출석대상이 아닌 사용자를 제외하고 모두를 결석된 상태로 업데이트 시킨다.

Document가 중복으로 삽입될 일이 없도록 먼저 오늘 날짜를 기준으로 검색 후 Document가 존재하지 않는다면 삽입을 진행하도록 했다.

4번 API사용을 위해 출석체크를 시작한 관리자의 ID도 저장해 둔다.

var ranNum = random(100, 999)
var startUser = ''
router.post(
    '/startAttendance',
    [perm('attendance').can('start')],
    asyncRoute(async function(req, res) {
        var Date = moment().format('YYYYMMDD')
        //출석 권한이 있는 사용자만 불러옴
        const userList = await User.find({
            attable: true,
        }).select('username')

        var attendanceDay = new AttendanceDay()
        attendanceDay.day = Date

        //중복 체크
        const cnt = await AttendanceDay.find()
            .where('day')
            .equals(Date)
            .count()

        if (cnt == 0) {
            for (var k in userList) {
                var cursor_Day = await AttendanceDay.findOne()
                    .where('day')
                    .equals(Date)
                var state = 'absence'
                if (req.user.username == userList[k].username)
                    state = 'attendance'
                if (!cursor_Day) {
                    var attendanceDay = new AttendanceDay()
                    attendanceDay.day = Date
                    attendanceDay.addStatus(userList[k].username, state)
                } else {
                    cursor_Day.addStatus(userList[k].username, state)
                }
                //create db - AttendanceUser
                var cursor_User = await AttendanceUser.findOne()
                    .where('name')
                    .equals(userList[k].username)
                state = 'absence'
                if (req.user.username == userList[k].username)
                    state = 'attendance'
                if (!cursor_User) {
                    var attendanceUser = new AttendanceUser()
                    attendanceUser.name = userList[k].username
                    attendanceUser.addStatus(Date, state)
                } else {
                    cursor_User.addStatus(Date, state)
                }
            }
        }
        //Generate Attendance Code and return
        try {
            ranNum = await random(100, 999)
            startUser = req.user.username
            res.json({ code: ranNum })
        } catch (err) {
            res.status(501).json()
        }
    })
)

2-1-2. 사용자들의 출석 요청 처리

사용자가 입력한 숫자를 body에 넣어 받고, 그 숫자와 서버에서 가지고있는 숫자와 맞는지 비교 후, 일치한다면 1번 API에서 결석 상태로 삽입했던 Document를 찾아 출석 상태로 업데이트 시킨다.

클라이언트에서 출석이 정상적으로 처리되었는지 확인을 위해 result를 0과 1로 구분해서 반환시킨다.

상태 업데이트도 2개의 Collection 모두 진행한다.

router.post(
    '/attendanceWrite',
    [perm('attendance').can('att')],
    asyncRoute(async function(req, res) {
        if (ranNum != req.body.code) {
            res.json({
                message: 'wrongCode!',
                result: 0,
            })
        }
        var Date = moment().format('YYYYMMDD')
        var Name = req.user.username

        await AttendanceDay.findOneAndUpdate(
            {
                day: Date,
                'status.name': Name,
            },
            { 'status.$.state': 'attendance' },
            function(err, doc) {}
        )

        await AttendanceUser.findOneAndUpdate(
            {
                name: Name,
                'status.date': Date,
            },
            { 'status.$.state': 'attendance' },
            function(err, doc) {}
        )
        res.json({ result: 1 })

2-1-3. 출석 수행 여부 처리

오늘 날짜와 사용자의 ID를 가지고 출석 상태로 존재하는 정보가 있는지 찾는다.

출석정보가 존재한다면 1을 존재하지 않는다면 0을 반환한다.

router.get(
    '/attendanceCheck',
    [perm('attendance').can('att')],
    asyncRoute(async function(req, res) {
        var Date = moment().format('YYYYMMDD')
        var Name = req.user.username
        try {
            const cursor = await AttendanceDay.find({
                day: Date,
                status: { $elemMatch: { name: Name, state: 'attendance' } },
            })
            if (cursor != '') {
                res.json(1)
            } else {
                res.json(0)
            }
        } catch (err) {
            res.status(501).json(err)
        }
    })
)

2-1-4. 관리자일 경우 랜덤 코드 유지

관리자가 새로고침이나 페이지를 벗어났다가 다시 돌아왔을 경우 진행중인 출석정보는 유지 되어야한다. 그래서 출석을 시작한 즉 랜덤숫자를 생성한 아이디를 저장해 뒀다가 다시 출석체크 페이지로 돌아가게 된다면 현재 생성되어있는 숫자를 반환하게 한다.

router.get(
    '/attendanceCheckAdmin',
    [perm('attendance').can('att')],
    asyncRoute(async function(req, res) {
        if (startUser == req.user.username) res.json(ranNum)
        else res.json(0)
    })
)

2-1-5. 종료 처리

출석이 종료될 때 관리자가 생성했던 랜덤숫자와 저장된 ID를 초기화 시킨다.

router.post(
    '/attendanceCheckEnd',
    [perm('attendance').can('att')],
    asyncRoute(async function(req, res) {
        startUser = ''
        ranNum = -1
        res.end()
    })
)

3. 클라이언트와 연결

Created hook

먼저 Created훅에서 socket 연결을 해주고, attendance 이벤트 핸들러를 만들어준다.

그리고 사용자가 출석을 했는지 확인해주고, 관리자인경우 출석이 진행중에 들어온다면 랜덤코드를 다시 받는다.

async created() {
        await this.$socket.emit('join', {
            roomName: 'attendance',
        })
        await this.$socket.on('attendance', data => {
            this.flag = data.flag
        })
        try {
            const res = await axios.get('attendance/attendanceCheck')
            this.code = parseInt(res.data)
        } catch (err) {
            console.log(err)
        }
        const res = await axios.get('attendance/attendanceCheckAdmin')
        if (res.data != 0) this.output_attendance_code = parseInt(res.data)
    },

methods hook

  • 관리자인 경우 출석 시작(권한 처리는 서버와 클라이언트 모두 처리해줬다.)
    async startAttendance() {
        try {
           const res_code = await axios.post('attendance/startAttendance')
            this.output_attendance_code = res_code.data.code                this.code = 1
        } catch (err) {
            console.log(err)
        }
        this.$socket.emit('attendance', {
            flag: true,
        })
        this.$socket.emit('start', {
            flag: true,
        })
        this.flag = true
    },
  • 사용자의 출석 요청

사용자가 입력한 숫자와 함께 출석요청을 보내고 정상적으로 처리되었다면 홈화면으로 이동하게 한다.

 async attendanceCheck() {
        try {
            const res = await axios.post('attendance/attendanceWrite', {
                code: this.input_attendance_code,
                state: 'attendance',
            })
            if (res.data.result) {
                this.snackbar_c = true
                setTimeout(() => {
                    this.$router.push('/')
                }, 2000)
            } else this.snackbar_e = true
        } catch (err) {
            console.log(err)
        }
    },
  • 출석 종료

출석상태 변경을 attendance이벤트로 알리고 해당 날짜의 일별 출결현황 페이지로 push한다.

그리고 5번 API로 저장되어있는 코드와 관리자ID를 초기화 시킨다.

async endAttendance() {
            this.$socket.emit('attendance', {
                flag: false,
            })
            this.flag = false
            this.input_attendance_code = ''
            await axios.post('attendance/attendanceCheckEnd')
            this.$router.push(
                `/AttendanceManageDay/${moment().format('YYYYMMDD')}`
            )
        },

3-1. 클라이언트 코드(attendance.vue)

<template>
    <v-container>
        <v-form>
            <v-card
                class="mx-auto"
                max-width="500"
                max-height="500"
                v-if="flag == true && code == 0"
            >
                <v-card-title>
                    <v-text-field
                        v-model="input_attendance_code"
                    ></v-text-field>
                </v-card-title>

                <v-card-actions>
                    <v-btn color="purple" text @click="attendanceCheck"
                        >출석하기</v-btn
                    >
                </v-card-actions>
            </v-card>
            <v-card
                class="mx-auto"
                max-width="500"
                max-height="500"
                v-if="flag && this.$perm('attendance').can('start')"
            >
                <v-card-text>
                    <div class="d-flex justify-center">
                        <span class="display-3">{{
                            output_attendance_code
                        }}</span>
                    </div>
                    <div class="d-flex justify-center">
                        <v-btn
                            color="purple"
                            text
                            v-if="flag"
                            @click="endAttendance"
                            large
                            >종료</v-btn
                        >
                    </div>
                </v-card-text>
            </v-card>

            <v-card
                class="mx-auto"
                max-width="500"
                max-height="500"
                text
                v-if="!flag && this.$perm('attendance').can('start')"
            >
                <v-card-actions>
                    <v-btn color="purple" text @click="startAttendance" large
                        >시작</v-btn
                    >
                </v-card-actions>
            </v-card>
            <div>
                <v-alert
                    type="warning"
                    v-if="
                        !this.$perm('attendance').can('start') && flag == false
                    "
                >
                    출석중이 아닙니다.
                </v-alert>
                <v-alert
                    type="success"
                    v-if="
                        !this.$perm('attendance').can('start') &&
                            flag == true &&
                            code == 1
                    "
                >
                    이미 출석하셨습니다!
                </v-alert>
            </div>
        </v-form>

        <v-snackbar v-model="snackbar_c" color="success">
            출석되었습니다.
            <v-btn dark text @click="close">Close</v-btn>
        </v-snackbar>
        <v-snackbar v-model="snackbar_e" color="error">
            번호가 일치하지 않습니다.
            <v-btn dark text @click="closeSnack">Close</v-btn>
        </v-snackbar>
    </v-container>
</template>
<script>
import moment from 'moment'
import axios from 'axios'
export default {
    name: 'attendance',
    async created() {
        await this.$socket.emit('join', {
            roomName: 'attendance',
        })
        await this.$socket.on('attendance', data => {
            this.flag = data.flag
        })
        try {
            const res = await axios.get('attendance/attendanceCheck')
            this.code = parseInt(res.data)
        } catch (err) {
            console.log(err)
        }
        const res = await axios.get('attendance/attendanceCheckAdmin')
        if (res.data != 0) this.output_attendance_code = parseInt(res.data)
    },

    data() {
        return {
            socket_id: '',
            input_attendance_code: '',
            output_attendance_code: '',
            flag: false,
            snackbar_c: false,
            snackbar_e: false,
            attendanceCard: true,
            code: 0,
        }
    },
    methods: {
        async startAttendance() {
            try {
                const res_code = await axios.post('attendance/startAttendance')
                this.output_attendance_code = res_code.data.code
                this.code = 1
            } catch (err) {
                console.log(err)
            }
            this.$socket.emit('attendance', {
                flag: true,
            })
            this.$socket.emit('start', {
                flag: true,
            })
            this.flag = true
        },
        async endAttendance() {
            this.$socket.emit('attendance', {
                flag: false,
            })
            this.flag = false
            this.input_attendance_code = ''
            await axios.post('attendance/attendanceCheckEnd')
            this.$router.push(
                `/AttendanceManageDay/${moment().format('YYYYMMDD')}`
            )
        },
        async attendanceCheck() {
            try {
                const res = await axios.post('attendance/attendanceWrite', {
                    code: this.input_attendance_code,
                    state: 'attendance',
                })
                if (res.data.result) {
                    this.snackbar_c = true
                    setTimeout(() => {
                        this.$router.push('/')
                    }, 2000)
                } else this.snackbar_e = true
            } catch (err) {
                console.log(err)
            }
        },
        close() {
            this.input_attendance_code = ''
            this.$router.push('/')
        },
        closeSnack() {
            this.snackbar_e = false
            this.input_attendance_code = ''
        },
    },
}
</script>

프로젝트의 전체 코드는 EZSET github 에서 확인하실 수 있습니다.

profile
🦉🦉🦉🦉🦉

0개의 댓글