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) {
}
};
'BackEnd > Node' 카테고리의 다른 글
[노드교과서] 섹션 14. 15장 AWS에 배포해보기 (2) | 2024.02.02 |
---|---|
[노드교과서] 섹션 13. 14장 CLI 프로그램 만들기 (2) | 2024.01.31 |
[노드교과서] 섹션 11. 12장 실시간 GIF 채팅방 만들기(웹소켓, Socket.IO) (0) | 2024.01.28 |
[노드교과서] 섹션 10. 11장 테스트 해보기(단위, 통합, 부하) (0) | 2024.01.25 |
섹션 9. 10장 API 서버 만들기(JWT, CORS) (0) | 2024.01.17 |