실시간 경매 시스템 만들기 - 프로젝트 구조 갖추기(3)
경매 시스템은 회원가입, 로그인, 경매 상품 등록, 방 참여, 경매 진행으로 이루어져 있습니다. 회원가입, 로그인, 경매 상품 등록 페이지와 라우터를 만들어보겠습니다.
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 <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>
그리고 메인 화면을 담당하는 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 %}
회원가입 화면을 담당하는 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 %}
상품을 업로드하는 페이지인 good.html 파일을 작성합니다. form에서 이미지 업로드(#good-photo)도 해야 하므로 form 태그의 enctype을 multipart/form-data로 두어 폼 데이터를 사용하도록 설정했습니다.
{% 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 %}
public/main.css
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
.btn {
display: inline-block;
padding: 0 5px;
text-decoration: none;
cursor: pointer;
border-radius: 4px;
background: white;
border: 1px solid silver;
color: crimson;
height: 37px;
line-height: 37px;
vertical-align: top;
font-size: 12px;
}
input,
textarea {
border-radius: 4px;
height: 37px;
padding: 10px;
border: 1px solid silver;
}
.container {
width: 100%;
height: 100%;
}
@media screen and (min-width: 800px) {
.container {
width: 800px;
margin: 0 auto;
}
}
.input-group {
margin-bottom: 15px;
}
.input-group label {
width: 25%;
display: inline-block;
}
.input-group input {
width: 70%;
}
#join {
float: right;
}
.profile-wrap {
width: 100%;
display: inline-block;
vertical-align: top;
margin: 10px 0;
}
@media screen and (min-width: 800px) {
.profile-wrap {
width: 290px;
margin-bottom: 0;
}
}
.profile {
text-align: left;
padding: 10px;
margin-right: 10px;
border-radius: 4px;
border: 1px solid silver;
background: yellow;
}
.user-name,
.user-money {
font-weight: bold;
font-size: 18px;
margin-bottom: 10px;
}
.timeline {
margin-top: 10px;
width: 100%;
display: inline-block;
border-radius: 4px;
vertical-align: top;
}
@media screen and (min-width: 800px) {
.timeline {
width: 500px;
}
}
#good-list,
#good-list th,
#good-list td {
border: 1px solid black;
border-collapse: collapse;
}
#good-list img {
max-height: 100px;
vertical-align: top;
}
#good-img {
width: 280px;
display: block;
}
.error-message {
color: red;
font-weight: bold;
}
#join-form,
#good-form {
padding: 10px;
text-align: center;
}
footer {
text-align: center;
}
마지막으로 라우터를 만듭니다.
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,
});
res.redirect("/");
} catch (error) {
console.error(error);
next(error);
}
},
);
module.exports = router;
router.use에서 res.locals.user = req.user;로 모든 png 템플릿에 사용자 정보를 변수로 집어 넣었습니다. 이렇게 하면 res.render 메서드에 user: req.user를 하지 않아도 되므로 중복을 제거할 수 있습니다.
라우터는 GET /, GET /join, GET /good, POST /good으로 이루어져 있습니다. GET /는 메인 화면을 렌더링합니다. 렌더링할 때 경매가 진행 중인 상품 목록도 같이 불러옵니다. SoldId가 낙찰자의 아이디어이므로 낙찰자가 null이면 경매가 진행 중인 것입니다.
GET /join과 GET /good은 각각 회원가입 화면과 상품 등록 화면을 렌더링합니다. POST /good 라우터는 업로드한 상품을 처리합니다. 상품 이미지 업로드 기능이 있어 multer 미들웨어가 붙었습니다.
이제 npm start 명령어로 서버를 실행한 후 http://localhost:8010에 접속하면 됩니다. 회원가입 후 로그인한 후 상품을 등록해봅시다.

'프로그래밍 언어 > NODE JS' 카테고리의 다른 글
| 실시간 경매 시스템 - 서버센트 이벤트 사용하기(2) (0) | 2026.02.26 |
|---|---|
| 실시간 경매 시스템 만들기 - 서버센트 이벤트 사용하기(1) (0) | 2026.02.23 |
| 실시간 경매 시스템 만들기 - 프로젝트 구조 갖추기(2) (0) | 2026.02.16 |
| 실시간 경매 시스템 만들기 - 프로젝트 구조 갖추기(1) (0) | 2026.02.13 |
| 프로젝트 마무리하기 (0) | 2026.02.10 |