실시간 경매 시스템 만들기

실시간 경매 시스템 만들기

프로젝트 구조 갖추기

NodeAuction 프로젝트

node-auction 폴더를 만든 후 그 안에 package.json 작성

package.json

npm i로 필요한 패키지 설치

데이터베이스는 MySQL

시퀄라이즈 설치 및 기본 디렉터리 만듦

모델 작성하기

models/user.js, models/good.js, models/auctions.js 작성

소스 코드 : https://github.com/ZeroCho/nodejs-book/tree/master/ch13/13.1/node-auction/models

user.js: 사용자 이메일, 닉네임, 비밀번호와 자금(money)

good.js: 상품의 이름과 사진, 시작 가격

auction.js: 입찰가(bid)와 msg(입찰 시 전달할 메시지)

config/config.json에 MySQL 데이터베이스 설정 작성

데이터베이스 생성하기

npx sequelize db:create 로 nodeauction 데이터베이스 생성

npx를 사용하면 글로벌 설치를 안 해도 됨

DB 관계 설정하기

models/index.js 수정

// models/index.js const Sequelize = require('sequelize'); const User = require('./user'); const Good = require('./good'); const Auction = require('./auction'); const env = process.env.NODE_ENV || 'development'; const config = require('../config/config')[env]; const db = {}; const sequelize = new Sequelize( config.database, config.username, config.password, config, ); db.sequelize = sequelize; db.User = User; db.Good = Good; db.Auction = Auction; User.init(sequelize); Good.init(sequelize); Auction.init(sequelize); User.associate(db); Good.associate(db); Auction.associate(db); module.exports = db;

한 사용자가 여러 상품을 등록 가능(user-good, as: owner)

한 사용자가 여러 상품을 낙찰 가능(user-good, as: sold)

한 사용자가 여러 번 경매 입찰 가능(user-auction)

한 상품에 대해 여러 번 경매 입찰 가능(good-auction)

as로 설정한 것은 OwnerId, SoldId로 상품 모델에 컬럼이 추가됨

passport 세팅하기

passport 와 passport-local, bcrypt 설치

passport/localStrategy.js, passport./index.js 작성(9장과 거의 동일)

카카오 로그인은 하지 않음

로그인을 위한 미들웨어인 routes/auth.js, routes/middlewares.js도 작성

.env와 app.js 작성하기

// 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/index'); const authRouter = require('./routes/auth'); const { sequelize } = require('./models'); const passportConfig = require('./passport'); const app = express(); passportConfig(); 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); }); const sessionMiddleware = session({ resave: false, saveUninitialized: false, secret: process.env.COOKIE_SECRET, cookie: { httpOnly: true, secure: false, }, }); 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 })); app.use(cookieParser(process.env.COOKIE_SECRET)); app.use(sessionMiddleware); app.use(passport.initialize()); 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'); }); app.listen(app.get('port'), () => { console.log(app.get('port'), '번 포트에서 대기중'); });

views 파일 작성하기

views 폴더에 layout.html, main.html, join.html, good.html 작성

소스 코드는 https://github.com/ZeroCho/nodejs-book/tree/master/ch13/13.1/node-auction/views

layout.html: 전체 화면의 레이아웃(로그인 폼)

main.html : 메인 화면을 담당(경매 목록이 있음)

join.html: 회원가입 폼

good.html: 상품을 업로드하는 화면(이미지 업로드 폼)

public/main.css도 추가

routes/index.js

routes/index.js 작성

// routes/index.js const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { Good, Auction, User } = require('../models'); const { isLoggedIn, isNotLoggedIn } = require('./middlewares'); const router = express.Router(); router.use((req, res, next) => { res.locals.user = req.user; next(); }); router.get('/', async (req, res, next) => { try { const goods = await Good.findAll({ where: { SoldId: null } }); res.render('main', { title: 'NodeAuction', goods, }); } catch (error) { console.error(error); next(error); } }); router.get('/join', isNotLoggedIn, (req, res) => { res.render('join', { title: '회원가입 - NodeAuction', }); }); router.get('/good', isLoggedIn, (req, res) => { res.render('good', { title: '상품 등록 - NodeAuction' }); }); try { fs.readdirSync('uploads'); } catch (error) { 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: 5 * 1024 * 1024 }, }); router.post('/good', isLoggedIn, upload.single('img'), async (req, res, next) => { try { const { name, price } = req.body; await Good.create({ OwnerId: req.user.id, name, img: req.file.filename, price, }); res.redirect('/'); } catch (error) { console.error(error); next(error); } }); module.exports = router;

GET /는 메인 페이지(경매 리스트) 렌더링

GET /join은 회원가입 페이지

GET /good은 상품 등록 페이지

POST /good 상품 등록 라우터

서버 실행하기

localhost:8018 에 접속

회원가입 후 로그인하고 상품 등록해보기

서버센트 이벤트 사용하기

경매는 시간이 생명

모든 사람이 같은 시간에 경매가 종료되어야 함

모든 사람에게 같은 시간이 표시되어야 함

클라이언트 시간은 믿을 수 없음(조작 가능)

따라서 서버 시간을 주기적으로 클라이언트로 내려보내줌

이 때 서버에서 클라이언트로 단방향 통신을 하기 때문에 서버센트 이벤트(Server Sent Events, SSE)가 적합

웹 소켓은 실시간으로 입찰할 때 사용

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

sse.js. 작성

// sse.js const SocketIO = require('socket.io'); module.exports = (server, app) => { const io = SocketIO(server, { path: '/socket.io' }); app.set('io', io); io.on('connection', (socket) => { // 웹 소켓 연결 시 const req = socket.request; const { headers: { referer } } = req; const roomId = referer.split('/')[referer.split('/').length - 1]; socket.join(roomId); socket.on('disconnect', () => { socket.leave(roomId); }); }); };

sse.on(‘connection’)은 서버와 연결되었을 때 호출되는 이벤트

client.send로 클라이언트에 데이터 전송 가능(책에서는 서버 시각 전송)

웹 소켓 코드 작성하기

socket.js 작성하기

// socket.js const SocketIO = require('socket.io'); module.exports = (server, app) => { const io = SocketIO(server, { path: '/socket.io' }); app.set('io', io); io.on('connection', (socket) => { // 웹 소켓 연결 시 const req = socket.request; const { headers: { referer } } = req; const roomId = referer.split('/')[referer.split('/').length - 1]; socket.join(roomId); socket.on('disconnect', () => { socket.leave(roomId); }); }); };

경매 방이 있기 때문에 11장에서 방에 들어가는 코드 재사용

referer에서 방 아이디를 추출해서 socket.join

app.js 에서 socket과 sse연결

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

EventSource polyfill

SSE 는 EventSource 라는 객체로 사용

main.html에 script 추가

// main.html . . . const es = new EventSource('/sse'); es.onmessage = function (e) { document.querySelectorAll('.time').forEach((td) => { const end = new Date(td.dataset.start); // 경매 시작 시간 const server = new Date(parseInt(e.data, 10)); end.setDate(end.getDate() + 1); // 경매 종료 시간 if (server >= end) { // 경매가 종료되었으면 return td.textContent = '00:00:00'; } else { const t = end - server; // 경매 종료까지 남은 시간 const seconds = ('0' + Math.floor((t / 1000) % 60)).slice(-2); const minutes = ('0' + Math.floor((t / 1000 / 60) % 60)).slice(-2); const hours = ('0' + Math.floor((t / (1000 * 60 * 60)) % 24)).slice(-2); return td.textContent = hours + ':' + minutes + ':' + seconds ; } }); }; {% endblock %}

IE에서는 EventSource가 지원되지 않음

EventSource polyfill을 넣어줌(첫 번째 스크립트)

new EventSource(‘/sse’)로 서버와 연결

es.onmessage로 서버에서 내려오는 데이터 받음(e.data에 들어있음)

아랫부분은 서버 시간과 경매 종료 시간을 계산해 카운트다운을 하는 코드 . 24 시간 카운트다운됨

EventSource 확인해보기

개발자 도구 Network 탭을 확인

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

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

클라이언트에 웹소켓 , SSE 연결하기

auction.html 에 서버 시간과 실시간 입찰 기능 추가

소스 코드는 https://github.com/ZeroCho/nodejs-book/blob/master/ch13/13.2/node-auction/views/auction.html

서버 시간을 받아와서 카운트다운하는 부분은 이전과 동일

세 번째 스크립트 태그는 입찰 시 POST /good/:id/bid 로 요청을 보내는 것

다른 사람이 입찰했을 때 Socket.IO 로 입찰 정보를 렌더링함

상품정보, 입찰 라우터 작성하기

GET /good/:id 와 POST /good/:id/bid

// routes/index.js . . . router.get('/good/:id', isLoggedIn, async (req, res, next) => { 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); } }); router.post('/good/:id/bid', isLoggedIn, async (req, res, next) => { try { const { bid, msg } = req.body; const good = await Good.findOne({ where: { id: req.params.id }, include: { model: Auction }, order: [[{ model: Auction }, 'bid', 'DESC']], }); 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] && 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); return next(error); } }); module.exports = router;

상품정보, 입찰 라우터 작성하기

GET /good/:id

해당 상품과 기존 입찰 정보들을 불러온 뒤 렌더링

상품 모델에 사용자 모델을 include할 때 as 속성 사용함(owner과 sold 중 어떤 관계를 사용할지 밝혀주는 것)

POST /good/:id/bid

클라이언트로부터 받은 입찰 정보 저장

시작 가격보다 낮게 입찰했거나, 경매 종료 시간이 지났거나, 이전 입찰가보다 낮은 입찰가가 들어왔다면 반려

정상 입찰가가 들어 왔다면 저장 후 해당 경매방의 모든 사람에게 입찰자, 입찰 가격, 입찰 메시지 등을 웹 소켓으로 전달

Good.find 메서드의 order 속성은 include될 모델의 컬럼을 정렬하는 방법(Auction 모델의 bid를 내림차순으로 정렬)

경매 진행해보기

서버 연결 후 경매 시작

브라우저를 두 개 띄워 각자 다른 아이디로 로그인하면 두 개의 클라이언트가 동시 접속한 효과를 얻을 수 있음

https://www.inflearn.com/course/%EB%85%B8%EB%93%9C-%EA%B5%90%EA%B3%BC%EC%84%9C/dashboard/

본글의 모든 내용은 위 강의를 토대로 작성됩니다.

from http://jhg3410.tistory.com/34 by ccl(A) rewrite - 2021-11-29 19:27:03