프로그래밍 언어/NODE JS

실시간 경매 시스템 - 서버센트 이벤트 사용하기(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를 내림차순으로 정렬하고 있습니다.