node-auction ํด๋๋ฅผ ๋ง๋ ํ ๊ทธ ์์ package.json ์์ฑ
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"
}
}
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"
}
}
npx sequelize db:create๋ก nodeauction ๋ฐ์ดํฐ๋ฒ ์ด์ค ์์ฑ
npx sequelize db:create
models/index.js ์์
๐ป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;
passport์ passport-local, bcrypt ์ค์น
npm i passport passport-local bcrypt
.env์ app.js ์์ฑ
๐ป.env
COOKIE_SECRET=auction
views ํด๋์ layout.html, main.html, join.html, good.html ์์ฑ
- layout.html: ์ ์ฒด ํ๋ฉด์ ๋ ์ด์์(๋ก๊ทธ์ธ ํผ)
- main.html : ๋ฉ์ธ ํ๋ฉด์ ๋ด๋น(๊ฒฝ๋งค ๋ชฉ๋ก์ด ์์)
- join.html: ํ์๊ฐ์ ํผ
- good.html: ์ํ์ ์ ๋ก๋ํ๋ ํ๋ฉด(์ด๋ฏธ์ง ์ ๋ก๋ ํผ)
- public/main.css๋ ์ถ๊ฐ
routes/index.js ์์ฑ
๐ป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;
localhost:8018์ ์ ์
๊ฒฝ๋งค๋ ์๊ฐ์ด ์๋ช
npm i sse socket.io
app.js์ SSE(sse.js ์์ฑ ํ) ์ฐ๊ฒฐ
๐ป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);
});
};
socket.js ์์ฑํ๊ธฐ
๐ป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);
});
});
};
SSE๋ EventSource๋ผ๋ ๊ฐ์ฒด๋ก ์ฌ์ฉ
๊ฐ๋ฐ์ ๋๊ตฌ Network ํญ์ ํ์ธ
auction.html์ ์๋ฒ ์๊ฐ๊ณผ ์ค์๊ฐ ์ ์ฐฐ ๊ธฐ๋ฅ ์ถ๊ฐ
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
POST /good/:id/bid
์๋ฒ ์ฐ๊ฒฐ ํ ๊ฒฝ๋งค ์์
๊ฒฝ๋งค๊ฐ ์์ฑ๋ ์ง 24์๊ฐ ํ์ ๋์ฐฐ์๋ฅผ ์ ํจ
npm i node-schedule
routes/index.js์ ์ถ๊ฐ
๐ป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;
์๋ฒ๊ฐ ์์๋ ๋ ๊ฒฝ๋งค ํ 24์๊ฐ์ด ์ง๋ฌ์ง๋ง ๋์ฐฐ์๊ฐ ์๋ ๊ฒฝ๋งค๋ฅผ ์ฐพ์ ๋์ฐฐ์ ์ง์
๐ป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);
}
};
24์๊ฐ์ ๊ธฐ๋ค๋ฆฌ๋ฉด ๋์ฐฐ๋จ
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 <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>
node-schedule๋ก ๋ฑ๋กํ ์ค์ผ์ค์ ๋ ธ๋ ์๋ฒ๊ฐ ์ข ๋ฃ๋ ๋ ๊ฐ์ด ์ข ๋ฃ๋จ
๐์ถ์ฒ๐
Node.js ๊ต๊ณผ์ - ๊ธฐ๋ณธ๋ถํฐ ํ๋ก์ ํธ ์ค์ต๊น์ง
https://www.inflearn.com/course/%EB%85%B8%EB%93%9C-%EA%B5%90%EA%B3%BC%EC%84%9C/dashboard