본문 바로가기

BackEnd/Node

[노드교과서] 섹션 12. 13장 실시간 경매 시스템 만들기(서버센트이벤트, 스케줄링)

13.1 프로젝트 구조 갖추기

 

13.2 서버센트 이벤트 사용하기

 ⇒ npm install

$ npm i sse socket.io

 13.2.1-1. Server-Sent Events (SSE) 서버센트 이벤트란?

 ⇒ SSE는 서버의 데이터를 클라이언트로 스트리밍하는 기술이다.  웹 표준이며 단방향 통신만을 지원한다. 

    또한 IE를 제외한 모든 브라우저에서 지원된다.

    IE를 통한 지원을 하려면 ployfill을 통해 지원이 가능하다.

  - 기존 서버에서 변경된 데이터를 불러오기 위해선 새로고침이나 ajax 폴링, 외부 플러그인을 이용해야 했다.

  - 이외에 websocket을 사용할 수 있지만 별도의 서버와 프로토콜로 통신되기 때문에 비용이 많이 든다.

  - SSE는 기존 HTTP 웹 서버에서 HTTP API만으로 동작되므로 비용도 적고 개발이 쉽다.

 

13.2.1-2 webServer와 Server-Sent-Event(SSE)의 특징

 - 클라이언트는 서버가 생성한 Stream을 스트리밍한다.(단방향)

 - HTTP protocal을 사용한다.  HTTP/2를 통한 multiplexing도 사용이 가능하다.

 - 연결이 끊어지면 EventSource 이벤트가 발생시켜 자동으로 다시 연결을 시도한다.

 - 웹 표준 기술로 IE를 제외한 브라우저 대부분을 지원한다.(IE도 Pollyfill로 사용은 가능하다)

 

13.3.1-3 websocket과 Server-Sent-Event의 차이점

 ⇒ 가장 큰 차이점은 websocket은 양방향 데이터를 주고받지만 SSE는 서버에서 클라이언트로 단방향만을 지원한다.

  Socket Server-Sent-Event
지원 브라우저 대부분 브라우저에서 지원 모던 브라우저 지원(IE는 polyfills가능)
통신 방향 양방향 단방향(서버-클라이언트)
리얼타임
데이터 형태 Binary, UTF-8 UTF-8
자동 재접속 아니오 예(3초마다 재시도)
최대 동시 접속 수 연결 한도는 없지만 서버 셋업에 따라 다르다. HTTP : 브라우저 당 6개
HTTP2 : 100개가 기본
프로토콜 websocket HTTP
에너지 소모량 작음
Firewall친화적 비친화적 친화적

 

 13.2.2 서버에 서버센트 이벤트 연결

  ⇒ app.js에 SSE 작성 후 연결 

   

// app.js
...
const passportConfig = require('./passport');
const sse = require('./sse');
const webSocket = require('./socket');

const app = express();
...
const server = app.listen(app.get('port'), () => {
	console.log(app.get('port'), '번 포트에서 대기 중');
});

webSocket(server, app);
sse(server);

 

// sse.js

const SSE = require('sse');

module.exports = (server) => {
	const sse = new SSE(server);
    
    // 서버와 연결되었을 때 호출되는 이벤트
    sse.on('connection', (client) => {
    	setInterval(() => {
        	// client.send로 클라이언트 데이터 전송가능
        	client.send(Date.now().toString());
        }, 1000);
    });
}

 

 13.2.3. 웹 소켓 코드 작성하기

// socket.js

module.exports = (server, app) => {
	const io = SocketIO(server, { apth: '/socket.io'});
    
    // express에 io이 사용가능하게끔 설정
    app.set('io',io);
    
    io.on('connection', (socket) => {
		const req = socket.request;
        const { headers : { referer } } = req;
        const roomId = new URL(referer).pathname.split('/').at(-1);
        socket.join(roomId);
        socket.on('disconnect'), () => {
        	socket.leave(roomId);
        });
    });
};

 

 13.2.4 EventSource polyfill

  ⇒ SSE는 클라이언트에서 EventSource라는 객체로 사용된다.

   - new EvnetSource)('/sse')로 서버와 연결한다.

   - es.conmessage로 서버에서 내려오는 데이터를 받을 수 있다.(e.data에 들어있다)

// views/main.html

<script>
	// sse.js
    const es = new EventSource('/sse');
    es.onmessage = function(e) => {
    	console.log(e.data);
    };
</script>

 

 13.2.5 EvnetSource 확인하기

  ⇒ 개발자 도구 Network 탭을 확인

   - GET /sse가 서버센트 이벤트 접속한 요청(type이 eventsource)

   - GET /sse 클릭 후 EventStream 탭을 보면 매 초마다 서버로부터 타임스탬프 데이터가 오는 것을 확인가능.

 

13.3 스케줄링 구현하기

 13.3.1 스케줄러 npm 설치하기

  ⇒ Scheduler는 특정 시간마다 일을 반복 수행하도록 해주는 함수를 의미한다.

$ npm i node-schedule
*    *    *    *    *    *
┬    ┬    ┬    ┬    ┬    ┬
│    │    │    │    │    │
│    │    │    │    │    └ day of week (0 - 7) (0 or 7 is Sun)
│    │    │    │    └───── month (1 - 12)
│    │    │    └────────── day of month (1 - 31)
│    │    └─────────────── hour (0 - 23)
│    └──────────────────── minute (0 - 59)
└───────────────────────── second (0 - 59, OPTIONAL)

const schedule = require('node-schedule');

const job = schedule.scheduleJob('42 * * * *', function(){
  console.log('The answer to life, the universe, and everything!');
});

 

 13.3.5 Sequelize transaction 적용하기

  ⇒ 전부 다 성공해야 DB에 저장된다.  하나라도 실패 시 롤백된다.

   - 시퀄라이즈에서 transaction, commit, rollback을 제공한다.

   - 스케줄러는 서버가 종료될 때 같이 종료되므로 운영체제의 스케줄러를 사용하는 것이 좋다.

   - 윈도우 : schtasks, 맥/리눅스 : cron

   - 노드에서는 이 두 명령어를 child_process를 통해 호출할 수 있다.

// 시퀄라이즈 트랜잭션 설정.
const t = await sequelize.transaction(); // 트랜잭션 생성
try {
    const success = await Auction.findOne({
       where : { GoodId : good.id},
       order : [['bid','DESC']],
       transaction : t,   // 트랜잭션 설정
    });
    if(!success) return;
    await good.setSold(success.UserId, { transaction : t});  // 트랜잭션 설정
    await User.update({
        money : sequelize.literal(`money - ${success.bid}`),
    },
    {
        where : { id : success.UserId},
        transaction : t,  // 트랜잭션 설정
    });
    await t.commit();
} catch(error) {
    await t.rollback();
}

 

13. 4 프로젝트 마무리하기

// app.js

const express = require('express');
const path = require('path');
const morgan = require('morgan');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const passport = require('passport');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');

dotenv.config();
const indexRouter = require('./routes');
const authRouter = require('./routes/auth');
const { sequelize } = require('./models');
const passportconfig = require('./passport');
const sse = require('./sse');
const webSocket = require('./socket');
const checkAuction = require('./checkAuction');


const app = express();
passportconfig(); // 패스포트 설정
checkAuction();

app.set('port', process.env.PORT || 8010);
app.set('view engine', 'html');
nunjucks.configure('views', {
  express : app,
  watch : true,
});

sequelize.sync({ force : false})
.then (() => {
    console.log('데이터베이스 연결 성공');
})
.catch((err) => {
    console.error(err);
})

app.use(morgan('dev'));
app.use(express.static(path.join(__dirname,'public')));
app.use('/img', express.static(path.join(__dirname,'uploads')));
app.use(express.json());

app.use(express.urlencoded({ extended : false }));
// 3] connect.sid 세션 쿠키를 읽고 세션 객체를 찾아서 req.session으로 만듦
// {connect.sid : xxxxx}
app.use(cookieParser(process.env.COOKIE_SECRET));

app.use(session({
    resave : false,
    saveUninitialized : false,
    secret : process.env.COOKIE_SECRET,
    cookie : {
        httpOnly : true,
        secure : false,
    },
}))
// passport를 연결하며 로그인에 필요한 것들은 생성해준다.
// 연결할 때 req.user, req.login, req.isAuthenticate, req.logout이 생성된다.
app.use(passport.initialize());

// 7. express-session에 설정한 대로 브라우저에 connect.sid 세션 쿠키를 전송한다.
// passrpot의 쿠키 로그인을 도와주는 역할을 한다.
// connect.sid라는 이름으로 세션 쿠키가 브라우저로 전송된다.
app.use(passport.session());

app.use('/', indexRouter);
app.use('/auth', authRouter);

app.use((req, res, next) => {
    const error = new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
    error.status = 404;
    next(error);
});

app.use((err, req, res , next) => {
    res.locals.message = err.message;
    res.locals.error = process.env.NODE_ENV !== 'production' ? err : {};
    res.status(err.status || 500);
    res.render('error');
});

const server = app.listen(app.get('port'), () => {
    console.log(app.get('port'), '번 포트에서 대기중');
});

webSocket(server, app);
sse(server);

 

// socket.js

const SocketIO = require('socket.io');
module.exports = (server, app) => {
    const io = SocketIO(server, { path : '/socket.io'});
    app.set('io', io);
    // io -> 네임스페이스 -> 방 아이디
    io.on('connection', (socket) => {  // 웹 소켓 연결 시
        const req = socket.request;
        const { headers : { referer } } = req;
        const roomId = new URL(referer).pathname.split('/').at(-1);
        socket.join(roomId);           // 경매방ID
        socket.on('disconnect', () => {
            socket.leave(roomId);
        })
    })
}

 

// sse.js

const SSE = require('sse');

module.exports = (server) => {
    const sse = new SSE(server);

    // 현재 서버시간을 클라이언트로 보내준다.
    sse.on('connection', (client) =>{
        setInterval(() => {
            // string으로 보내줘야 한다.
            client.send(Date.now().toString());
        }, 1000)
    })
}

 

// checkauction.js
// 서버가 재시작할때 스케줄러에 따른 처리를 갱신한다.

const { scheduleJob } = require('node-schedule');
const { Op } = require('sequelize');
const { Good, Auction, User, sequelize} = require('./models');
const schedule = require("node-schedule");

module.exports = async () => {
    console.log('checkAuction');
    try {
        const yesterday = new Date();
        yesterday.setDate(yesterday.getDate() - 1);
        const targets = await Good.findAll({
            where : {
                SoldId : null,
                createdAt : { [Op.lte] : yesterday},
            },
        });

        targets.forEach(async (good) => {
            // 시퀄라이즈 트랜잭션 설정
            const t = await sequelize.transaction();
            try {
                const success = await Auction.findOne({
                   where : { GoodId : good.id},
                   order : [['bid','DESC']],
                   transaction : t,
                });
                if(!success) return;
                await good.setSold(success.UserId, { transaction : t});
                await User.update({
                    money : sequelize.literal(`money - ${success.bid}`),
                },
                {
                    where : { id : success.UserId},
                    transaction : t,
                });
                await t.commit();
            } catch(error) {
                await t.rollback();
            }
        });

        const ongoing = await Good.findAll({
            where : {
                SoldId : null,
                createdAt : { [Op.gte] : yesterday},
            },
        });
        ongoing.forEach((good) => {
            const end = new Date(good.createdAt);
            end.setDate(end.getDate() + 1); // 생성일 24시간 뒤가 낙찰시간
            const job = schedule.scheduleJob(end, async () => {
                const success = await Auction.findOne({
                    where: {goodId: good.id},
                    order: [['bid', 'DESC']],
                });
                await good.setSold(success.UserId);
                await User.update({
                    money: sequelize.literal(`money - ${success.bid}`),
                }, {
                    where: {id: success.UserId},
                })
            });
            job.on('error', (err) => {
                console.error('스케줄링 에러', err);
            });
            job.on('success', () => {
                console.log('스케줄링 성공');
            });
        });
    } catch(error) {
        console.error(error);
    }
}

 

// routes/index.js

const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

const { isLoggedIn, isNotLoggedIn} = require('../middlewares');
const { renderMain, renderJoin, renderGood, createGood, renderAuction, bid, renderList } = require('../controllers/index');
const router = express.Router();

router.use((req, res, next) => {
    // 5] 조회된 정보를 req.user에 저장
    // 6] 라우터에서 req.user 객체를 사용할 수 있다.
    res.locals.user = req.user;
    next();
});

router.get('/join', isNotLoggedIn, renderJoin);
router.get('/', renderMain);
router.get('/good', isLoggedIn, renderGood);

try {
    fs.readdirSync('uploads');
} catch {
    console.error('uploads 폴더가 없어 uploads 폴더를 생성합니다.');
    fs.mkdirSync('uploads');
}

const upload = multer({
    storage : multer.diskStorage({
        destination(req, file, cb) {
            cb(null, 'uploads/')
        },
        filename(req, file, cb) {
            const ext = path.extname(file.originalname);
            cb(null, path.basename(file.originalname, ext) + new Date().valueOf() + ext);
        },
    }),
    limits : { fileSize : 10 * 1024 * 1024},
});

router.post('/good', isLoggedIn, upload.single('img'), createGood);

// GET /good/:id
router.get('/good/:id', isLoggedIn, renderAuction);

// POST /good/:id/bid
router.post('/good/:id/bid', isLoggedIn, bid);

router.get('/list', isLoggedIn, renderList);

module.exports = router;

 

// controllers/index.js

const { Op } = require('sequelize');
const { Good, Auction, User } = require('../models');
const schedule = require('node-schedule');

exports.renderMain = async (req, res, next) => {
    try {
        const yesterday = new Date();
        yesterday.setDate(yesterday.getDate() - 1); // 어제 시간
        const goods = await Good.findAll({
            where: { SoldId: null, createdAt: { [Op.gte]: yesterday } },
        });
        res.render('main', {
            title: 'NodeAuction',
            goods,
        });
    } catch (error) {
        console.error(error);
        next(error);
    }
};

exports.renderJoin = (req, res) => {
    res.render('join', {
        title: '회원가입 - NodeAuction',
    });
};

exports.renderGood = (req, res) => {
    res.render('good', { title: '상품 등록 - NodeAuction' });
};

exports.createGood = async (req, res, next) => {
    try {
        const { name, price } = req.body;
        const good = await Good.create({
            OwnerId: req.user.id,
            name,
            img: req.file.filename,
            price,
        });
        const end = new Date();
        end.setDate(end.getDate() + 1) // 하루 뒤
        const job = schedule.scheduleJob(end, async () => {
            const success = await Auction.findOne({
                where: {goodId: good.id},
                order: [['bid', 'DESC']],
            });
            await good.setSold(success.UserId);
            await User.update({
                money: sequelize.literal(`money - ${success.bid}`),
            }, {
                where: {id: success.UserId},
            })
        });
        job.on('error', console.error);
        job.on('success', () => {
            console.log(`${good.id} 스케줄링 성공`);
        })
        res.redirect('/');
    } catch (error) {
        console.error(error);
        next(error);
    }
};

exports.renderAuction = async (req, res, next) => {
    // Promise.all은 두 쿼리가 동시에 수행되며 쿼리 수행능력에 있어 좀더 효율적이다.
    try {
        const [good, auction] = await Promise.all([
            Good.findOne({
                where : {id : req.params.id},
                include : {
                    model : User,
                    as : 'Owner',
                }
            }),
            Auction.findAll({
                where : { GoodId : req.params.id},
                include : { model : User},
                order : [['bid', 'ASC']],
            }),
        ]);
        res.render('auction', {
            title : `${good.name} - NodeAuction`,
            good,
            auction,
        });
    } catch (error) {
        console.error(error);
        next(error);
    }
};

exports.bid = async (req, res, next) => {
    try{
        const { bid, msg } = req.body;
        const good = await Good.findOne({
            where : { id : req.params.id },
            include: { model: Auction},
            // include된 결과를 order by 처리할 땐 {model : Auction}를 필이 추가해야 한다.
            order : [[{model : Auction},'bid', 'DESC']],
        });

        const user = await User.findOne({
            where : { email : req.user.email},
        })


        if(!good) {
            return res.status(404).send('해당 상품은 존재하지 않습니다.');
        }
        if(user.money < bid) {
            return res.status(403).send('자신이 가진 돈보다 높게 입찰할 수 없습니다.');
        }
        if(good.price >= bid) {
            return res.status(403).send('시작 가격보다 높게 입찰하셔야 합니다.');
        }
        if(new Date(good.createdAt).valueOf() + (24 * 60 * 60 * 1000) < new Date()) {
            return res.status(403).send('경매가 이미 종료 되었습니다.');
        }
        if(good.Auctions[0]?.bid >= bid) {
            return res.status(403).send('이전 입찰가보다 높아야 합니다.');
        }
        const result = await Auction.create({
            bid, msg,
            UserId : req.user.id,
            GoodId : req.params.id,
        });

        // 서버에서 클라이언트로 입찰한 내역을 전송한다.
        req.app.get('io').to(req.params.id).emit('bid', {
            bid : result.bid,
            msg : result.msg,
            nick : req.user.nick,
        });
        return res.send('ok');
    } catch(error) {
        console.error(error);
        next();
    }
};

exports.renderList = async (req, res, next) => {
  try {
      const goods = await Good.findAll({
          where : { SoldId : req.user.id},
          include : {model : Auction},
          order : [[{model : Auction}, 'bid', 'DESC']],
      });
      res.render('list', {title : '낙찰 목록 - NodeAuction', goods});
  } catch(error) {

  }
};