๐Ÿ”Ž์‹ค์‹œ๊ฐ„ ๊ฒฝ๋งค ์‹œ์Šคํ…œ ๋งŒ๋“ค๊ธฐ

์„œ๊ฐ€ํฌยท2021๋…„ 11์›” 29์ผ
1

Node.js

๋ชฉ๋ก ๋ณด๊ธฐ
12/15
post-thumbnail

ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ๊ฐ–์ถ”๊ธฐ

1. NodeAuction ํ”„๋กœ์ ํŠธ

node-auction ํด๋”๋ฅผ ๋งŒ๋“  ํ›„ ๊ทธ ์•ˆ์— package.json ์ž‘์„ฑ

  • npm i๋กœ ํ•„์š”ํ•œ ํŒจํ‚ค์ง€ ์„ค์น˜
  • ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋Š” MySQL
  • ์‹œํ€„๋ผ์ด์ฆˆ ์„ค์น˜ ๋ฐ ๊ธฐ๋ณธ ๋””๋ ‰ํ„ฐ๋ฆฌ ๋งŒ๋“ฆ

npm i sequelize sequelize-cli mysql2
npx sequelize init

๐Ÿ”ปpackage.json

{
  "name": "node-auction",
  "version": "0.0.1",
  "description": "๋…ธ๋“œ ๊ฒฝ๋งค ์‹œ์Šคํ…œ",
  "main": "app.js",
  "scripts": {
    "start": "nodemon app"
  },
  "author": "seokahi",
  "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"
  }
}

2. ๋ชจ๋ธ ์ž‘์„ฑํ•˜๊ธฐ

models/user.js, models/good.js, models/auctions.js ์ž‘์„ฑ

  • user.js: ์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ, ๋‹‰๋„ค์ž„, ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ž๊ธˆ(money)
  • good.js: ์ƒํ’ˆ์˜ ์ด๋ฆ„๊ณผ ์‚ฌ์ง„, ์‹œ์ž‘ ๊ฐ€๊ฒฉ
  • auction.js: ์ž…์ฐฐ๊ฐ€(bid)์™€ msg(์ž…์ฐฐ ์‹œ ์ „๋‹ฌํ•  ๋ฉ”์‹œ์ง€)
  • config/config.json์— MySQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • ์ž‘์„ฑ

๐Ÿ”ปconfig/config.json

{
  "development": {
    "username": "root",
    "password": "nodejsbook",
    "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"
  }
}

3. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ƒ์„ฑํ•˜๊ธฐ

npx sequelize db:create๋กœ nodeauction ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ƒ์„ฑ

  • npx๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๊ธ€๋กœ๋ฒŒ ์„ค์น˜๋ฅผ ์•ˆ ํ•ด๋„ ๋จ

    npx sequelize db:create

4. DB ๊ด€๊ณ„ ์„ค์ •ํ•˜๊ธฐ

models/index.js ์ˆ˜์ •

  • ํ•œ ์‚ฌ์šฉ์ž๊ฐ€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ๋“ฑ๋ก ๊ฐ€๋Šฅ(user-good, as: owner)
  • ํ•œ ์‚ฌ์šฉ์ž๊ฐ€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ๋‚™์ฐฐ ๊ฐ€๋Šฅ(user-good, as: sold)
  • ํ•œ ์‚ฌ์šฉ์ž๊ฐ€ ์—ฌ๋Ÿฌ ๋ฒˆ ๊ฒฝ๋งค ์ž…์ฐฐ ๊ฐ€๋Šฅ(user-auction)
  • ํ•œ ์ƒํ’ˆ์— ๋Œ€ํ•ด ์—ฌ๋Ÿฌ ๋ฒˆ ๊ฒฝ๋งค ์ž…์ฐฐ ๊ฐ€๋Šฅ(good-auction)
  • as๋กœ ์„ค์ •ํ•œ ๊ฒƒ์€ OwnerId, SoldId๋กœ ์ƒํ’ˆ ๋ชจ๋ธ์— ์ปฌ๋Ÿผ์ด ์ถ”๊ฐ€๋จ

๐Ÿ”ป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;

5. passport ์„ธํŒ…ํ•˜๊ธฐ

passport์™€ passport-local, bcrypt ์„ค์น˜

npm i passport passport-local bcrypt

  • passport/localStrategy.js, passport./index.js ์ž‘์„ฑ(9์žฅ๊ณผ ๊ฑฐ์˜ ๋™์ผ)
  • ์นด์นด์˜ค ๋กœ๊ทธ์ธ์€ ํ•˜์ง€ ์•Š์Œ
  • ๋กœ๊ทธ์ธ์„ ์œ„ํ•œ ๋ฏธ๋“ค์›จ์–ด์ธ routes/auth.js, routes/middlewares.js๋„ ์ž‘์„ฑ

6. .env์™€ app.js ์ž‘์„ฑํ•˜๊ธฐ

.env์™€ app.js ์ž‘์„ฑ

๐Ÿ”ป.env

COOKIE_SECRET=auction

7. views ํŒŒ์ผ ์ž‘์„ฑํ•˜๊ธฐ

views ํด๋”์— layout.html, main.html, join.html, good.html ์ž‘์„ฑ

  • layout.html: ์ „์ฒด ํ™”๋ฉด์˜ ๋ ˆ์ด์•„์›ƒ(๋กœ๊ทธ์ธ ํผ)
  • main.html : ๋ฉ”์ธ ํ™”๋ฉด์„ ๋‹ด๋‹น(๊ฒฝ๋งค ๋ชฉ๋ก์ด ์žˆ์Œ)
  • join.html: ํšŒ์›๊ฐ€์ž… ํผ
  • good.html: ์ƒํ’ˆ์„ ์—…๋กœ๋“œํ•˜๋Š” ํ™”๋ฉด(์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ํผ)
  • public/main.css๋„ ์ถ”๊ฐ€

8. routes/index.js

routes/index.js ์ž‘์„ฑ

  • GET /๋Š” ๋ฉ”์ธ ํŽ˜์ด์ง€(๊ฒฝ๋งค ๋ฆฌ์ŠคํŠธ) ๋ Œ๋”๋ง
  • GET /join์€ ํšŒ์›๊ฐ€์ž… ํŽ˜์ด์ง€
  • GET /good์€ ์ƒํ’ˆ ๋“ฑ๋ก ํŽ˜์ด์ง€
  • POST /good ์ƒํ’ˆ ๋“ฑ๋ก ๋ผ์šฐํ„ฐ

๐Ÿ”ป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;

9. ์„œ๋ฒ„ ์‹คํ–‰ํ•˜๊ธฐ

localhost:8018์— ์ ‘์†

  • ํšŒ์›๊ฐ€์ž… ํ›„ ๋กœ๊ทธ์ธํ•˜๊ณ  ์ƒํ’ˆ ๋“ฑ๋กํ•ด๋ณด๊ธฐ

์„œ๋ฒ„์„ผํŠธ ์ด๋ฒคํŠธ ์‚ฌ์šฉํ•˜๊ธฐ

1. ์„œ๋ฒ„์„ผํŠธ ์ด๋ฒคํŠธ ์‚ฌ์šฉ

๊ฒฝ๋งค๋Š” ์‹œ๊ฐ„์ด ์ƒ๋ช…

  • ๋ชจ๋“  ์‚ฌ๋žŒ์ด ๊ฐ™์€ ์‹œ๊ฐ„์— ๊ฒฝ๋งค๊ฐ€ ์ข…๋ฃŒ๋˜์–ด์•ผ ํ•จ
  • ๋ชจ๋“  ์‚ฌ๋žŒ์—๊ฒŒ ๊ฐ™์€ ์‹œ๊ฐ„์ด ํ‘œ์‹œ๋˜์–ด์•ผ ํ•จ
  • ํด๋ผ์ด์–ธํŠธ ์‹œ๊ฐ„์€ ๋ฏฟ์„ ์ˆ˜ ์—†์Œ(์กฐ์ž‘ ๊ฐ€๋Šฅ)
  • ๋”ฐ๋ผ์„œ ์„œ๋ฒ„ ์‹œ๊ฐ„์„ ์ฃผ๊ธฐ์ ์œผ๋กœ ํด๋ผ์ด์–ธํŠธ๋กœ ๋‚ด๋ ค๋ณด๋‚ด์คŒ
  • ์ด ๋•Œ ์„œ๋ฒ„์—์„œ ํด๋ผ์ด์–ธํŠธ๋กœ ๋‹จ๋ฐฉํ–ฅ ํ†ต์‹ ์„ ํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์„œ๋ฒ„์„ผํŠธ ์ด๋ฒคํŠธ(Server Sent Events, SSE)๊ฐ€ ์ ํ•ฉ
  • ์›น ์†Œ์ผ“์€ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ž…์ฐฐํ•  ๋•Œ ์‚ฌ์šฉ

npm i sse socket.io

2. ์„œ๋ฒ„์— ์„œ๋ฒ„์„ผํŠธ ์ด๋ฒคํŠธ ์—ฐ๊ฒฐ

app.js์— SSE(sse.js ์ž‘์„ฑ ํ›„) ์—ฐ๊ฒฐ

  • sse.on(โ€˜connectionโ€™)์€ ์„œ๋ฒ„์™€ ์—ฐ๊ฒฐ๋˜์—ˆ์„ ๋•Œ ํ˜ธ์ถœ๋˜๋Š” ์ด๋ฒคํŠธ
  • client.send๋กœ ํด๋ผ์ด์–ธํŠธ์— ๋ฐ์ดํ„ฐ ์ „์†ก ๊ฐ€๋Šฅ(์ฑ…์—์„œ๋Š” ์„œ๋ฒ„ ์‹œ๊ฐ ์ „์†ก)

๐Ÿ”ป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 sse = require('./sse');
const webSocket = require('./socket');

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

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);
  });
};

3. ์›น ์†Œ์ผ“ ์ฝ”๋“œ ์ž‘์„ฑํ•˜๊ธฐ

socket.js ์ž‘์„ฑํ•˜๊ธฐ

  • ๊ฒฝ๋งค ๋ฐฉ์ด ์žˆ๊ธฐ ๋•Œ๋ฌธ์— 11์žฅ์—์„œ ๋ฐฉ์— ๋“ค์–ด๊ฐ€๋Š” ์ฝ”๋“œ ์žฌ์‚ฌ์šฉ
  • referer์—์„œ ๋ฐฉ ์•„์ด๋””๋ฅผ ์ถ”์ถœํ•ด์„œ socket.join

๐Ÿ”ป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);
    });
  });
};

4. EventSource polyfill

SSE๋Š” EventSource๋ผ๋Š” ๊ฐ์ฒด๋กœ ์‚ฌ์šฉ

  • IE์—์„œ๋Š” EventSource๊ฐ€ ์ง€์›๋˜์ง€ ์•Š์Œ
  • EventSource polyfill์„ ๋„ฃ์–ด์คŒ(์ฒซ ๋ฒˆ์งธ ์Šคํฌ๋ฆฝํŠธ)
  • new EventSource(โ€˜/sseโ€™)๋กœ ์„œ๋ฒ„์™€ ์—ฐ๊ฒฐ
  • es.onmessage๋กœ ์„œ๋ฒ„์—์„œ ๋‚ด๋ ค์˜ค๋Š” ๋ฐ์ดํ„ฐ ๋ฐ›์Œ(e.data์— ๋“ค์–ด์žˆ์Œ)
  • ์•„๋žซ๋ถ€๋ถ„์€ ์„œ๋ฒ„ ์‹œ๊ฐ„๊ณผ ๊ฒฝ๋งค ์ข…๋ฃŒ ์‹œ๊ฐ„์„ ๊ณ„์‚ฐํ•ด ์นด์šดํŠธ๋‹ค์šด์„ ํ•˜๋Š”

5. EventSource ํ™•์ธํ•ด๋ณด๊ธฐ

๊ฐœ๋ฐœ์ž ๋„๊ตฌ Network ํƒญ์„ ํ™•์ธ

  • GET /sse๊ฐ€ ์„œ๋ฒ„์„ผํŠธ ์ด๋ฒคํŠธ ์ ‘์†ํ•œ ์š”์ฒญ(type์ด eventsource)
  • GET /sse ํด๋ฆญ ํ›„ EventStream ํƒญ์„ ๋ณด๋ฉด ๋งค ์ดˆ๋งˆ๋‹ค ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ํƒ€์ž„์Šคํƒฌํ”„ ๋ฐ์ดํ„ฐ๊ฐ€ ์˜ค๋Š” ๊ฒƒ์„ ํ™•์ธ ๊ฐ€๋Šฅ

6. ํด๋ผ์ด์–ธํŠธ์— ์›น์†Œ์ผ“, SSE ์—ฐ๊ฒฐํ•˜๊ธฐ

auction.html์— ์„œ๋ฒ„ ์‹œ๊ฐ„๊ณผ ์‹ค์‹œ๊ฐ„ ์ž…์ฐฐ ๊ธฐ๋Šฅ ์ถ”๊ฐ€

  • ์„œ๋ฒ„ ์‹œ๊ฐ„์„ ๋ฐ›์•„์™€์„œ ์นด์šดํŠธ๋‹ค์šดํ•˜๋Š” ๋ถ€๋ถ„์€ ์ด์ „๊ณผ ๋™์ผ
  • ์„ธ ๋ฒˆ์งธ ์Šคํฌ๋ฆฝํŠธ ํƒœ๊ทธ๋Š” ์ž…์ฐฐ ์‹œ POST /good/:id/bid๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๊ฒƒ
  • ๋‹ค๋ฅธ ์‚ฌ๋žŒ์ด ์ž…์ฐฐํ–ˆ์„ ๋•Œ Socket.IO๋กœ ์ž…์ฐฐ ์ •๋ณด๋ฅผ ๋ Œ๋”๋งํ•จ

7. ์ƒํ’ˆ์ •๋ณด, ์ž…์ฐฐ ๋ผ์šฐํ„ฐ ์ž‘์„ฑํ•˜๊ธฐ

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;

8. ์ƒํ’ˆ์ •๋ณด, ์ž…์ฐฐ ๋ผ์šฐํ„ฐ ์ž‘์„ฑํ•˜๊ธฐ

GET /good/:id

  • ํ•ด๋‹น ์ƒํ’ˆ๊ณผ ๊ธฐ์กด ์ž…์ฐฐ ์ •๋ณด๋“ค์„ ๋ถˆ๋Ÿฌ์˜จ ๋’ค ๋ Œ๋”๋ง

* ์ƒํ’ˆ ๋ชจ๋ธ์— ์‚ฌ์šฉ์ž ๋ชจ๋ธ์„ includeํ•  ๋•Œ as ์†์„ฑ ์‚ฌ์šฉํ•จ(owner๊ณผ sold ์ค‘ ์–ด๋–ค ๊ด€๊ณ„๋ฅผ ์‚ฌ์šฉํ• ์ง€ ๋ฐํ˜€์ฃผ๋Š” ๊ฒƒ)

POST /good/:id/bid

  • ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ ๋ฐ›์€ ์ž…์ฐฐ ์ •๋ณด ์ €์žฅ
  • ์‹œ์ž‘ ๊ฐ€๊ฒฉ๋ณด๋‹ค ๋‚ฎ๊ฒŒ ์ž…์ฐฐํ–ˆ๊ฑฐ๋‚˜, ๊ฒฝ๋งค ์ข…๋ฃŒ ์‹œ๊ฐ„์ด ์ง€๋‚ฌ๊ฑฐ๋‚˜, ์ด์ „ ์ž…์ฐฐ๊ฐ€๋ณด๋‹ค ๋‚ฎ์€ ์ž…์ฐฐ๊ฐ€๊ฐ€ ๋“ค์–ด์™”๋‹ค๋ฉด ๋ฐ˜๋ ค
  • ์ •์ƒ ์ž…์ฐฐ๊ฐ€๊ฐ€ ๋“ค์–ด ์™”๋‹ค๋ฉด ์ €์žฅ ํ›„ ํ•ด๋‹น ๊ฒฝ๋งค๋ฐฉ์˜ ๋ชจ๋“  ์‚ฌ๋žŒ์—๊ฒŒ ์ž…์ฐฐ์ž, ์ž…์ฐฐ ๊ฐ€๊ฒฉ, ์ž…์ฐฐ ๋ฉ”์‹œ์ง€ ๋“ฑ์„ ์›น ์†Œ์ผ“์œผ๋กœ ์ „๋‹ฌ
  • Good.find ๋ฉ”์„œ๋“œ์˜ order ์†์„ฑ์€ include๋  ๋ชจ๋ธ์˜ ์ปฌ๋Ÿผ์„ ์ •๋ ฌํ•˜๋Š” ๋ฐฉ๋ฒ•(Auction ๋ชจ๋ธ์˜ bid๋ฅผ ๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ ์ •๋ ฌ)

9. ๊ฒฝ๋งค ์ง„ํ–‰ํ•ด๋ณด๊ธฐ

์„œ๋ฒ„ ์—ฐ๊ฒฐ ํ›„ ๊ฒฝ๋งค ์‹œ์ž‘

  • ๋ธŒ๋ผ์šฐ์ €๋ฅผ ๋‘ ๊ฐœ ๋„์›Œ ๊ฐ์ž ๋‹ค๋ฅธ ์•„์ด๋””๋กœ ๋กœ๊ทธ์ธํ•˜๋ฉด ๋‘ ๊ฐœ์˜ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋™์‹œ ์ ‘์†ํ•œ ํšจ๊ณผ๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ์Œ

์Šค์ผ€์ค„๋ง ๊ตฌํ˜„ํ•˜๊ธฐ

1. ์Šค์ผ€์ค„๋Ÿฌ ์„ค์น˜ํ•˜๊ธฐ

๊ฒฝ๋งค๊ฐ€ ์ƒ์„ฑ๋œ ์ง€ 24์‹œ๊ฐ„ ํ›„์— ๋‚™์ฐฐ์ž๋ฅผ ์ •ํ•จ

  • 24์‹œ๊ฐ„ ํ›„์— ๋‚™์ฐฐ์ž๋ฅผ ์ •ํ•˜๋Š” ์‹œ์Šคํ…œ ๊ตฌํ˜„ํ•ด์•ผ ํ•จ
  • node-schedule ๋ชจ๋“ˆ ์‚ฌ์šฉ

npm i node-schedule

2. ์Šค์ผ€์ค„๋ง์šฉ ๋ผ์šฐํ„ฐ ์ถ”๊ฐ€ํ•˜๊ธฐ

routes/index.js์— ์ถ”๊ฐ€

  • schedule ๋ชจ๋“ˆ์„ ๋ถˆ๋Ÿฌ์˜ด
  • scheduleJob ๋ฉ”์„œ๋“œ๋กœ ์ผ์ • ์˜ˆ์•ฝ
  • ์ฒซ ๋ฒˆ์งธ ์ธ์ˆ˜๋กœ ์‹คํ–‰๋  ์‹œ๊ฐ์„ ๋„ฃ๊ณ , ๋‘ ๋ฒˆ์งธ ์ธ์ˆ˜๋กœ ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋ฅผ ๋„ฃ์Œ
  • ๊ฐ€์žฅ ๋†’์€ ์ž…์ฐฐ์„ ํ•œ ์‚ฌ๋žŒ์„ ์ฐพ์•„ ์ƒํ’ˆ ๋ชจ๋ธ์˜ ๋‚™์ฐฐ์ž ์•„์ด๋””์— ๋„ฃ์–ด์คŒ
  • ๋™์‹œ์—, ๋‚™์ฐฐ์ž์˜ ๋ณด์œ  ์ž์‚ฐ์„ ๋‚™์ฐฐ ๊ธˆ์•ก๋งŒํผ ์ œ์™ธ(sequelize.literal(์ปฌ๋Ÿผ โ€“ ์ˆซ์ž)๋กœ ์ˆซ์ž ์ค„์ž„)
  • ๋‹จ์ : ๋…ธ๋“œ ๊ธฐ๋ฐ˜์œผ๋กœ ์Šค์ผ€์ฅด๋ง์ด ๋˜๋ฏ€๋กœ, ๋…ธ๋“œ๊ฐ€ ์ข…๋ฃŒ๋˜๋ฉด ์Šค์ผ€์ค„ ์˜ˆ์•ฝ๋„ ๊ฐ™์ด ์ข…๋ฃŒ๋จ
    +* ์„œ๋ฒ„๊ฐ€ ์–ด๋–ค ์—๋Ÿฌ๋กœ ์ข…๋ฃŒ๋  ์ง€ ์˜ˆ์ธกํ•˜๊ธฐ ์–ด๋ ค์šฐ๋ฏ€๋กœ ๋ณด์™„ํ•˜๊ธฐ ์œ„ํ•œ ๋ฐฉ๋ฒ•์ด ํ•„์š”ํ•จ

๐Ÿ”ป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 () => {
      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;

3. ์„œ๋ฒ„ ์‹œ์ž‘ ์‹œ ์ด์ „ ๊ฒฝ๋งค ์ฒดํฌํ•˜๊ธฐ

์„œ๋ฒ„๊ฐ€ ์‹œ์ž‘๋  ๋•Œ ๊ฒฝ๋งค ํ›„ 24์‹œ๊ฐ„์ด ์ง€๋‚ฌ์ง€๋งŒ ๋‚™์ฐฐ์ž๊ฐ€ ์—†๋Š” ๊ฒฝ๋งค๋ฅผ ์ฐพ์•„ ๋‚™์ฐฐ์ž ์ง€์ •

  • checkAuction.js ์ž‘์„ฑ ํ›„ app.js์— ์—ฐ๊ฒฐ

๐Ÿ”ป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);
  }
};

4. ์„œ๋ฒ„ ์‹œ์ž‘ ์‹œ ์ด์ „ ๊ฒฝ๋งค ์ฒดํฌํ•˜๊ธฐ

24์‹œ๊ฐ„์„ ๊ธฐ๋‹ค๋ฆฌ๋ฉด ๋‚™์ฐฐ๋จ

  • ์„œ๋ฒ„๊ฐ€ ๊ณ„์† ์ผœ์ ธ ์žˆ์–ด์•ผ ํ•จ
  • ๋น ๋ฅธ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด ์‹œ๊ฐ„์„ ๋‹จ์ถ•ํ•ด์„œ ํ•ด๋ณด๊ธฐ

ํ”„๋กœ์ ํŠธ ๋งˆ๋ฌด๋ฆฌํ•˜๊ธฐ

1. ๋‚™์ฐฐ ๋‚ด์—ญ ๋ณด๊ธฐ ๊ตฌํ˜„ํ•˜๊ธฐ

GET /list ๋ผ์šฐํ„ฐ ์ž‘์„ฑ ํ›„ views/list.html ์ž‘์„ฑ ๋ฐ views/layout.html ์ˆ˜์ •

๐Ÿ”ป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 () => {
      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;

๐Ÿ”ป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>
  1. ๋‚™์ฐฐ ๋‚ด์—ญ ๋ณด๊ธฐ
    ๋‚™์ฐฐ์ž์˜ ๊ณ„์ •์œผ๋กœ ๋กœ๊ทธ์ธ ํ›„ http://localhost:8010/list์—์„œ ๋‚™์ฐฐ ๋ชฉ๋ก ํ™•์ธ ๊ฐ€๋Šฅ

3. ์šด์˜์ฒด์ œ์˜ ์Šค์ผ€์ค„๋Ÿฌ

node-schedule๋กœ ๋“ฑ๋กํ•œ ์Šค์ผ€์ค„์€ ๋…ธ๋“œ ์„œ๋ฒ„๊ฐ€ ์ข…๋ฃŒ๋  ๋•Œ ๊ฐ™์ด ์ข…๋ฃŒ๋จ

  • ์šด์˜์ฒด์ œ์˜ ์Šค์ผ€์ค„๋Ÿฌ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Œ
  • ์œˆ๋„์—์„œ๋Š” schtasks
  • ๋งฅ๊ณผ ๋ฆฌ๋ˆ…์Šค์—์„œ๋Š” cron ์ถ”์ฒœ
  • ๋…ธ๋“œ์—์„œ๋Š” ์ด ๋‘ ๋ช…๋ น์–ด๋ฅผ child_process๋ฅผ ํ†ตํ•ด ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ์Œ

๐Ÿ˜ƒ์ถœ์ฒ˜๐Ÿ˜ƒ
Node.js ๊ต๊ณผ์„œ - ๊ธฐ๋ณธ๋ถ€ํ„ฐ ํ”„๋กœ์ ํŠธ ์‹ค์Šต๊นŒ์ง€
https://www.inflearn.com/course/%EB%85%B8%EB%93%9C-%EA%B5%90%EA%B3%BC%EC%84%9C/dashboard

0๊ฐœ์˜ ๋Œ“๊ธ€