상세 컨텐츠

본문 제목

[Node.js] 13장 실시간 경매 시스템 만들기

21-22/21-22 Node.js

by Kimpeep 2022. 1. 17. 13:00

본문

728x90

13.1 프로젝트 구조 갖추기

  • 프로젝트 이름: NodeAuction
// node-auction/package.json
{
  "name": "node-auction",
  "version": "0.0.1",
  "description": "노드 경매 시스템",
  "main": "app.js",
  "scripts": {
    "start": "nodemon app"
  },
  "author": "Zero Cho",
  "license": "ISC",
  "dependencies": {
    "bcrypt": "^3.0.7",
    "cookie-parser": "^1.4.4",
    "dotenv": "^8.2.0",
    "express": "^4.17.1",
    "express-session": "^1.17.0",
    "morgan": "^1.9.1",
    "multer": "^1.4.2",
    "mysql2": "^2.1.0",
    "nunjucks": "^3.2.0",
    "passport": "^0.4.1",
    "passport-local": "^1.0.0",
    "sequelize": "^5.21.3",
    "sequelize-cli": "^5.5.1"
  },
  "devDependencies": {
    "nodemon": "^2.0.2"
  }
}

패키지 설치

$ npm i

 

  • 데이터베이스: MySQL
  • 시퀄라이즈 설치와 기본 디렉터리 만들기
$ npm i sequelize sequelize-cli mysql2
$ npx sequelize init

 

  • 모델 생성
// models/user.js
const Sequelize = require('sequelize');

module.exports = class User extends Sequelize.Model {
  static init(sequelize) {
    return super.init({
      email: {
        type: Sequelize.STRING(40),
        allowNull: false,
        unique: true,
      },
      nick: {
        type: Sequelize.STRING(15),
        allowNull: false,
      },
      password: {
        type: Sequelize.STRING(100),
        allowNull: true,
      },
      money: {
        type: Sequelize.INTEGER,
        allowNull: false,
        defaultValue: 0,
      },
    }, {
      sequelize,
      timestamps: true,
      paranoid: true,
      modelName: 'User',
      tableName: 'users',
      charset: 'utf8',
      collate: 'utf8_general_ci',
    });
  }

  static associate(db) {
    db.User.hasMany(db.Auction);
  }
};
// models/good.js
const Sequelize = require('sequelize');

module.exports = class Good extends Sequelize.Model {
  static init(sequelize) {
    return super.init({
      name: {
        type: Sequelize.STRING(40),
        allowNull: false,
      },
      img: {
        type: Sequelize.STRING(200),
        allowNull: true,
      },
      price: {
        type: Sequelize.INTEGER,
        allowNull: false,
        defaultValue: 0,
      },
    }, {
      sequelize,
      timestamps: true,
      paranoid: true,
      modelName: 'Good',
      tableName: 'goods',
      charset: 'utf8',
      collate: 'utf8_general_ci',
    });
  }

  static associate(db) {
    db.Good.belongsTo(db.User, { as: 'Owner' });
    db.Good.belongsTo(db.User, { as: 'Sold' });
    db.Good.hasMany(db.Auction);
  }
};
// models/auction.js
const Sequelize = require('sequelize');

module.exports = class Auction extends Sequelize.Model {
  static init(sequelize) {
    return super.init({
      bid: {
        type: Sequelize.INTEGER,
        allowNull: false,
        defaultValue: 0,
      },
      msg: {
        type: Sequelize.STRING(100),
        allowNull: true,
      },
    }, {
      sequelize,
      timestamps: true,
      paranoid: true,
      modelName: 'Auction',
      tableName: 'auctions',
      charset: 'utf8',
      collate: 'utf8_general_ci',
    });
  }

  static associate(db) {
    db.Auction.belongsTo(db.User);
    db.Auction.belongsTo(db.Good);
  }
};

 

  • 모델을 데이버테이스 및 서버와 연결
// config/config.json
{
  "development": {
    "username": "root",
    "password": "nodejsbook",	// 각자의 mySQL 비밀번호
    "database": "nodeauction",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "test": {
    "username": "root",
    "password": null,
    "database": "database_test",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "production": {
    "username": "root",
    "password": null,
    "database": "database_production",
    "host": "127.0.0.1",
    "dialect": "mysql"
  }
}

 

  • 데이터 베이스 생성
$ npx sequelize db:create

npx sequelize db:create 실행화면

 

// 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;

 

  • 로그인을 위한 패스포트 설정
$ npm i passport passport-local bcrypt
// passport/localStrategy.js
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');

const User = require('../models/user');

module.exports = () => {
  passport.use(new LocalStrategy({
    usernameField: 'email',
    passwordField: 'password',
  }, async (email, password, done) => {
    try {
      const exUser = await User.findOne({ where: { email } });
      if (exUser) {
        const result = await bcrypt.compare(password, exUser.password);
        if (result) {
          done(null, exUser);
        } else {
          done(null, false, { message: '비밀번호가 일치하지 않습니다.' });
        }
      } else {
        done(null, false, { message: '가입되지 않은 회원입니다.' });
      }
    } catch (error) {
      console.error(error);
      done(error);
    }
  }));
};
// passport/index.js
const passport = require('passport');

const local = require('./localStrategy');
const User = require('../models/user');

module.exports = () => {
  passport.serializeUser((user, done) => {
    done(null, user.id);
  });

  passport.deserializeUser((id, done) => {
    User.findOne({ where: { id } })
      .then(user => done(null, user))
      .catch(err => done(err));
  });

  local();
};

 

  • 로그인을 위한 라우터와 미들웨어 추가
// routes/auth.js
const express = require('express');
const passport = require('passport');
const bcrypt = require('bcrypt');

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

const router = express.Router();
// 회원가입 라우터
router.post('/join', isNotLoggedIn, async (req, res, next) => {
  const { email, nick, password, money } = req.body;
  try {
    const exUser = await User.findOne({ where: { email } });
    if (exUser) {
      return res.redirect('/join?joinError=이미 가입된 이메일입니다.');
    }
    const hash = await bcrypt.hash(password, 12);
    await User.create({
      email,
      nick,
      password: hash,
      money,
    });
    return res.redirect('/');
  } catch (error) {
    console.error(error);
    return next(error);
  }
});
// 로그인 라우터
router.post('/login', isNotLoggedIn, (req, res, next) => {
  passport.authenticate('local', (authError, user, info) => {
    if (authError) {
      console.error(authError);
      return next(authError);
    }
    if (!user) {
      return res.redirect(`/?loginError=${info.message}`);
    }
    return req.login(user, (loginError) => {
      if (loginError) {
        console.error(loginError);
        return next(loginError);
      }
      return res.redirect('/');
    });
  })(req, res, next);
});
// 로그아웃 라우터
router.get('/logout', isLoggedIn, (req, res) => {
  req.logout();
  req.session.destroy();
  res.redirect('/');
});

module.exports = router;
// routes/middlewares.js
exports.isLoggedIn = (req, res, next) => {
  if (req.isAuthenticated()) {
    next();
  } else {
    res.redirect('/?loginError=로그인이 필요합니다.');
  }
};

exports.isNotLoggedIn = (req, res, next) => {
  if (!req.isAuthenticated()) {
    next();
  } else {
    res.redirect('/');
  }
};

 

  • 시퀄라이즈와 패스포트 서버에 연결
//.env
COOKIE_SECRET=auction
//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/error.html-->
{% extends 'layout.html' %}

{% block content %}
  <h1>{{message}}</h1>
  <h2>{{error.status}}</h2>
  <pre>{{error.stack}}</pre>
{% endblock %}
<!-- views/layout.html-->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>{{title}}</title>
    <meta name="viewport" content="width=device-width, user-scalable=no">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <link rel="stylesheet" href="/main.css">
  </head>
  <body>
    <div class="container">
      <div class="profile-wrap">
        <div class="profile">
          {% if user and user.id %}
            <div class="user-name">안녕하세요 {{user.nick}}님</div>
            <div class="user-money">보유 자산: {{user.money}}원</div>
            <input type="hidden" id="my-id" value="user.id">
            <a href="/auth/logout" id="logout" class="btn">로그아웃</a>
            <a href="/good" id="register" class="btn">상품 등록</a>
          {% else %}
            <form action="/auth/login" id="login-form" method="post">
              <div class="input-group">
                <label for="email">이메일</label>
                <input type="email" id="email" name="email" required autofocus>
              </div>
              <div class="input-group">
                <label for="password">비밀번호</label>
                <input type="password" id="password" name="password" required>
              </div>
              <a href="/join" id="join" class="btn">회원가입</a>
              <button id="login" class="btn" type="submit">로그인</button>
            </form>
          {% endif %}
        </div>
        <footer>
          Made by&nbsp;<a href="https://www.zerocho.com" target="_blank">ZeroCho</a>
        </footer>
        {% block good %}
        {% endblock %}
      </div>
      {% block content %}
      {% endblock %}
    </div>
    <script>
      window.onload = () => {
        if (new URL(location.href).searchParams.get('loginError')) {
          alert(new URL(location.href).searchParams.get('loginError'));
        }
      };
    </script>
  </body>
</html>

 

  • 경매가 진행되는 방들을 보이는 메인 화면 구성
<!-- views/main.html-->
{% extends 'layout.html' %}

{% block content %}
  <div class="timeline">
    <h2>경매 진행 목록</h2>
    <table id="good-list">
      <tr>
        <th>상품명</th>
        <th>이미지</th>
        <th>시작 가격</th>
        <th>종료 시간</th>
        <th>입장</th>
      </tr>
      {% for good in goods %}
        <tr>
          <td>{{good.name}}</td>
          <td>
            <img src="/img/{{good.img}}">
          </td>
          <td>{{good.price}}</td>
          <td class="time" data-start="{{good.createdAt}}">00:00:00</td>
          <td>
            <a href="/good/{{good.id}}" class="enter btn">입장</a>
          </td>
        </tr>
      {% endfor %}
    </table>
  </div>
{% endblock %}

 

  • 회원가입 화면 구성
<!-- views/join.html-->
{% extends 'layout.html' %}

{% block content %}
  <div class="timeline">
    <form action="/auth/join" id="join-form" method="post">
      <div class="input-group">
        <label for="join-email">이메일</label>
        <input type="email" id="join-email" name="email">
      </div>
      <div class="input-group">
        <label for="join-nick">닉네임</label>
        <input type="text" id="join-nick" name="nick">
      </div>
      <div class="input-group">
        <label for="join-password">비밀번호</label>
        <input type="password" id="join-password" name="password">
      </div>
      <div class="input-group">
        <label for="join-money">보유자산</label>
        <input type="number" id="join-money" name="money">
      </div>
      <button id="join-btn" class="btn" type="submit">회원가입</button>
    </form>
  </div>
  <script>
    window.onload = () => {
      if (new URL(location.href).searchParams.get('joinError')) {
        alert(new URL(location.href).searchParams.get('joinError'));
      }
    };
  </script>
{% endblock %}

 

  • 상품 업로드 페이지 구성
<!-- views/good.html-->
{% extends 'layout.html' %}

{% block content %}
  <div class="timeline">
    <form action="/good" id="good-form" method="post" enctype="multipart/form-data">
      <div class="input-group">
        <label for="good-name">상품명</label>
        <input type="text" id="good-name" name="name" required autofocus>
      </div>
      <div class="input-group">
        <label for="good-photo">상품 사진</label>
        <input type="file" id="good-photo" name="img" required>
      </div>
      <div class="input-group">
        <label for="good-price">시작 가격</label>
        <input type="number" id="good-price" name="price" required>
      </div>
      <button id="join-btn" class="btn" type="submit">상품 등록</button>
    </form>
  </div>
{% endblock %}

 

css 파일은 저자의 github에서 찾을 수 있습니다.
https://github.com/ZeroCho/nodejs-book

 

  • 라우터
// 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;	// 모든 pug 템플릿에 사용자 정보를 집어넣음(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;

 

  • 서버 실행
$ npm start
  • http://localhost:8010 접속

http://localhost:8010 접속화면
회원가입 화면
로그인 후 화면

 


13.2 서버센트 이벤트 사용하기

  • 서버 시간 가져오기: 서버센트 이벤트
  • 경매 참여나 입찰에 모두에게 금액을 알리기: 웹 소켓

 

  • SSE 패키지와 Socket.IO 패키지 설치
$ npm i sse socket.io@2

 

  • 서버와 모듈 연결
// 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(Date.now().toString());
    }, 1000);
  });
};
// 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);
    });
  });
};

 

 

  • 서버센트 이벤트의 문제점: IE나 엣지 브라우저에서 사용할 수 없음
  • 해결법 -> 클라이언트 측에 EventSource 구현
<!-- views/main.html-->
{% extends 'layout.html' %}

{% block content %}
  <div class="timeline">
    <h2>경매 진행 목록</h2>
    <table id="good-list">
      <tr>
        <th>상품명</th>
        <th>이미지</th>
        <th>시작 가격</th>
        <th>종료 시간</th>
        <th>입장</th>
      </tr>
      {% for good in goods %}
        <tr>
          <td>{{good.name}}</td>
          <td>
            <img src="/img/{{good.img}}">
          </td>
          <td>{{good.price}}</td>
          <td class="time" data-start="{{good.createdAt}}">00:00:00</td>
          <td>
            <a href="/good/{{good.id}}" class="enter btn">입장</a>
          </td>
        </tr>
      {% endfor %}
    </table>
  </div>
  <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>
{% endblock %}

카운트다운 화면

 

Network 탭
EventStream 탭

 

  • 경매 진행 화면 구성
<!-- view/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
...
// 해당 상품과 기존 입찰정보들을 불러온 뒤 렌더링
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;

 

  • 경매 진행

경매 진행 화면

 


13.3 스케줄링 구현하기

경매 시작 24시간 후 낙찰자를 정하는 시스템 구현: node-schedule 패키지 사용

$ 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');

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;
    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 () => { // scheduleJob: 일정 예약
      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);
  }
});

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;

 

 

  • node-schedule의 문제점: 노드가 종료되면 스케줄 예약도 같이 종료됨
  • 경매가 끝나도 낙찰자가 없는 경매에 따로 낙찰자를 지정해야함
// checkAuction.js
const { Op } = require('Sequelize');

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

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 (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);
  }
};
// app.js
...
dotenv.config();
const indexRouter = require('./routes/index');
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);
...

 

하루 지난 낙찰 화면

 


13.4 프로젝트 마무리하기

  • 낙찰자가 낙찰 내역을 볼 수 있게 변경
// routes/index.js
...
router.get('/list', isLoggedIn, 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) {
    console.error(error);
    next(error);
  }
});

module.exports = router;

 

  • 낙찰 목록을 보여주는 화면
<!-- views/list.html-->
{% extends 'layout.html' %}

{% block content %}
  <div class="timeline">
    <h2>경매 낙찰 목록</h2>
    <table id="good-list">
      <tr>
        <th>상품명</th>
        <th>사진</th>
        <th>낙찰가</th>
      </tr>
      {% for good in goods %}
        <tr>
          <td>{{good.name}}</td>
          <td>
            <img src="/img/{{good.img}}">
          </td>
          <td>{{good.Auctions[0].bid}}</td>
        </tr>
      {% endfor %}
    </table>
  </div>
{% endblock %}

 

  • 낙찰 목록 버튼 추가
<!-- views/layout.html-->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>{{title}}</title>
    <meta name="viewport" content="width=device-width, user-scalable=no">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <link rel="stylesheet" href="/main.css">
  </head>
  <body>
    <div class="container">
      <div class="profile-wrap">
        <div class="profile">
          {% if user and user.id %}
            <div class="user-name">안녕하세요 {{user.nick}}님</div>
            <div class="user-money">보유 자산: {{user.money}}원</div>
            <input type="hidden" id="my-id" value="user.id">
            <a href="/auth/logout" id="logout" class="btn">로그아웃</a>
            <a href="/good" id="register" class="btn">상품 등록</a>
            <a href="/list" id="list" class="btn">낙찰 내역</a>
          {% else %}
            <form action="/auth/login" id="login-form" method="post">
              <div class="input-group">
                <label for="email">이메일</label>
                <input type="email" id="email" name="email" required autofocus>
              </div>
              <div class="input-group">
                <label for="password">비밀번호</label>
                <input type="password" id="password" name="password" required>
              </div>
              <a href="/join" id="join" class="btn">회원가입</a>
              <button id="login" class="btn" type="submit">로그인</button>
            </form>
          {% endif %}
        </div>
        <footer>
          Made by&nbsp;<a href="https://www.zerocho.com" target="_blank">ZeroCho</a>
        </footer>
        {% block good %}
        {% endblock %}
      </div>
      {% block content %}
      {% endblock %}
    </div>
    <script>
      window.onload = () => {
        if (new URL(location.href).searchParams.get('loginError')) {
          alert(new URL(location.href).searchParams.get('loginError'));
        }
      };
    </script>
  </body>
</html>

 

  • 낙찰 목록 확인

경매 낙찰 목록

 

728x90

관련글 더보기