티스토리 뷰

728x90
SMALL

서버센트 이벤트 사용하기

npm i sse socket.io

 

sse.js

const SSE = require('sse');

module.exports = (server) => {
  const sse = new SSE(server);
  sse.on('connection', (client) => {
    // 서버센트 이벤트 연결
    setInterval(() => {
      client.send(Date.now().toString());
    }, 1000);
  });
};

 

app.js

const sse = require('./sse');
const webSocket = require('./socket');

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

 

main.html

<script src="https://unpkg.com/event-source-polyfill/src/eventsource.min.js"></script>
<script>
  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);
      }
    });
  };
</script>

 

네트워크 탭

 

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 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 %}

 

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.findOne({
        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;

스케줄링 구현하기

카운트다운이 끝나면 더 이상 경매를 진행할 수는 없지만, 아직 낙찰자가 정해지지 않았다. 경매 종료를 24시간 후로 정했으므로 경매가 생성되고 24시간이 지난 후에 낙찰자를 정하는 시스템을 구현해야 한다.

npm i node-schedule

 

routes/index.js

const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const schedule = require('node-schedule');

const { Good, Auction, User } = require('../models');
const { isLoggedIn, isNotLoggedIn } = require('./middlewares');

...

router.post(
  '/good',
  isLoggedIn,
  upload.single('img'),
  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); // 하루 뒤
      schedule.scheduleJob(end, async () => {
        const success = await Auction.findOne({
          where: { GoodId: good.id },
          order: [['bid', 'DESC']],
        });
        await Good.update(
          { SoldId: success.UserId },
          { where: { id: good.id } }
        );
        await User.update(
          {
            money: sequelize.literal(`money - ${success.bid}`),
          },
          {
            where: { id: success.UserId },
          }
        );
      });
      res.redirect('/');
    } catch (error) {
      console.error(error);
      next(error);
    }
  }
);

module.exports = router;

 

schedule 객체의 scheduleJob 메서드로 일정을 예약할 수 있다. 첫번째 인수로 실행될 시각, 두번째 인수로 해당 시각이 되었을 때 수행할 콜백 함수를 넣는다. 경매 모델에서 가장 높은 가격으로 입찰한 사람을 찾아 상품 모델의 낙찰자 아이디에 넣어주도록 정의했다. 또한, 낙찰자의 보유 자산을 낙찰 금액만큼 뺀다. { 컬럼: sequelize.literal(컬럼 - 숫자) }가 시퀄라이즈에서 해당 컬럼의 숫자를 줄이는 방법이다.

 

서버가 꺼지거나 오류가 발생했을 때 대비

checkAuction.js

const { Op } = require('Sequelize');

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

module.exports = async () => {
  try {
    const yesterday = new Date();
    yesterday.getDate.setDate(yesterDay.getDate() - 1);
    const targets = await Good.findAll({
      where: {
        SoldId: null,
        createdAt: { [Op.lte]: yesterday },
      },
    });
    targets.forEach(async (target) => {
      const success = await Auction.findOne({
        where: { GoodId: target.id },
        order: [['bid', 'DESC']],
      });
      await Good.update(
        { SoldId: success.UserId },
        { where: { id: target.id } }
      );
      await User.update(
        {
          money: sequelize.literal(`money-${success.bid}`),
        },
        {
          where: { id: success.UserId },
        }
      );
    });
  } catch (error) {
    console.error(error);
  }
};

 

스스로 해보기

상품 등록자는 참여할 수 업섹 만들기(라우터에서 검사)

경매 시간을 자유롭게 조정할 수 있도록 만드기(상품 등록 시 생성할 수 있게 화면과 DB 수정)

노드 서버가 꺼졌다 다시 켜졌을 때 스케줄러 다시 생성하기(checkAuction에서 DB 조회 후 스케줄러 설정)

아무도 입찰하지 않ㄴ아 낙찰자가 없을 때를 대비한 처리 로직 구현하기(checkAuction과 스케줄러 수정)

 

 

728x90
LIST
댓글
공지사항