실시간 경매 시스템 - 서버센트 이벤트 사용하기(3)
이제 경매를 진행하는 페이지를 만들어보겠습니다. 이 페이지는 서버센트 이벤트와 웹 소켓 모두에 연결합니다.
views/auction.html
{% extends 'layout.html' %}
{% block good %}
<h2>{{good.name}}</h2>
<div>등록자: {{good.Owner.nick}}</div>
<div>시작가: {{good.price}}원</div>
<strong id="time" data-start="{{good.createdAt}}"></strong>
<img id="good-img" src="/img/{{good.img}}">
{% endblock %}
{% block content %}
<div class="timeline">
<div id="bid">
{% for bid in auction %}
<div>
<span>{{bid.User.nick}}님: </span>
<strong>{{bid.bid}}원에 입찰하셨습니다.</strong>
{% if bid.msg %}
<span>({{bid.msg}})</span>
{% endif %}
</div>
{% endfor %}
</div>
<form id="bid-form">
<input type="number" name="bid" placeholder="입찰가" required min="{{good.price}}">
<input type="msg" name="msg" placeholder="메시지(선택사항)" maxlength="100">
<button class="btn" type="submit">입찰</button>
</form>
</div>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://unpkg.com/event-source-polyfill/src/eventsource.min.js"></script>
<script src="/socket.io/socket.io.js"></script>
<script>
document.querySelector('#bid-form').addEventListener('submit', (e) => {
e.preventDefault();
const xhr = new XMLHttpRequest();
const errorMessage = document.querySelector('.error-message');
axios.post('/good/{{good.id}}/bid', { // 입찰 진행
bid: e.target.bid.value,
msg: e.target.msg.value,
})
.catch((err) => {
console.error(err);
alert(err.response.data);
})
.finally(() => {
e.target.bid.value = '';
e.target.msg.value = '';
errorMessage.textContent = '';
});
});
const es = new EventSource("/sse");
const time = document.querySelector('#time');
es.onmessage = (e) => {
const end = new Date(time.dataset.start); // 경매 시작 시간
const server = new Date(parseInt(e.data, 10));
end.setDate(end.getDate() + 1); // 경매 종료 시간
if (server >= end) { // 경매가 종료되었으면
return time.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 time.textContent = hours + ':' + minutes + ':' + seconds;
}
};
const socket = io.connect('http://localhost:8010', {
path: '/socket.io'
});
socket.on('bid', (data) => { // 누군가가 입찰했을 때
const div = document.createElement('div');
let span = document.createElement('span');
span.textContent = data.nick + '님: ';
const strong = document.createElement('strong');
strong.textContent = data.bid + '원에 입찰하셨습니다.';
div.appendChild(span);
div.appendChild(strong);
if (data.msg) {
span = document.createElement('span');
span.textContent = `(${data.msg})`;
div.appendChild(span);
}
document.querySelector('#bid').appendChild(div);
});
</script>
<script>
window.onload = () => {
if (new URL(location.href).searchParams.get('auctionError')) {
alert(new URL(location.href).searchParams.get('auctionError'));
}
};
</script>
{% endblock %}
먼저 axios, EventSource 폴리필과 Socket.IO 클라이언트 스크립트를 넣었습니다. 네 번째 스크립트 태그는 입찰 시 POST /good/:id/bid로 요청을 보내는 것, 서버센트 이벤트 데이터로 서버 시간을 받아 카운트다운하는 것, 다른 사람이 입찰했을 때 Socket.IO로 입찰 정보를 렌더링하는 것으로 이루어져 있습니다.
이제 라우터에 GET /good/:id와 POST /good/:id/bid를 추가합니다.
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);
}
});
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 라우터는 해당 상품과 기존 입찰 정보들을 불러온 뒤 렌더링합니다. 상품(Coa) 모 델에 사용자(User) 모델을 include할 때 as 속성을 사용한 것에 주의하세요. Cood 모델과 User 모델은 현재 일대다 관계가 두 번 연결(Ower, SoLd)되어 있으므로 이런 경우에는 어떤 관계를 include 할지 as 속성으로 밝혀야 합니다.
POST /good/:id/bid는 클라이언트로부터 받은 입찰 정보를 저장합니다. 만약 시작 가격보다 낮게 입찰했거나, 경매 종료 시간이 지났거나, 이전 입찰가보다 낮은 입찰가가 들어왔다면 반려합니다.
정상적인 입찰가가 들어왔다면 저장한 후 해당 경매방의 모든 사람에게 입찰자, 입찰 가격, 입찰 메시지 등을 웹 소켓으로 전달합니다. Good findone 메서드의 ordec 속성을 눈여겨보길 바랍니다.
include 될 모델의 컬럼을 정렬하는 방법입니다. Auction 모델의 bid를 내림차순으로 정렬하고 있습니다.
'프로그래밍 언어 > NODE JS' 카테고리의 다른 글
| 스케줄링 구현하기(2) (0) | 2026.03.07 |
|---|---|
| 스케줄링 구현하기 (0) | 2026.03.04 |
| 실시간 경매 시스템 - 서버센트 이벤트 사용하기(2) (0) | 2026.02.26 |
| 실시간 경매 시스템 만들기 - 서버센트 이벤트 사용하기(1) (0) | 2026.02.23 |
| 실시간 경매 시스템 만들기 - 프로젝트 구조 갖추기(3) (0) | 2026.02.20 |