안녕하세요, 코린이의 코딩 학습기 채니 입니다.
[Node.js 교과서]의 책을 참고하여 포스팅한 개인 공부 내용입니다.
익스프레스로 SNS 서비스 만들기
프로젝트 구조 갖추기
// 시퀄라이즈 설치
$ yarn add sequelize mysql2 sequelize-cli
$ npx sequelize init
// 패키지 및 타입스크립트 설치
$ yarn add express cookie-parser express-session morgan multer dotenv nunjucks
$ yarn add -D nodemon
package.json
{
"name": "nodebird",
"version": "0.0.1",
"description": "익스프레스로 만드는 SNS 서비스",
"main": "app.js",
"scripts": {
"test": "nodemon app"
},
"author": "chany",
"license": "MIT",
"dependencies": {
"cookie-parser": "^1.4.6",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-session": "^1.17.3",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^2.3.3",
"nunjucks": "^3.2.3",
"sequelize": "^6.28.0",
"sequelize-cli": "^6.5.2",
"typescript": "^4.9.4"
},
"devDependencies": {
"nodemon": "^2.0.20"
}
}
폴더 및 파일 구성
템플릿 파일을 넣을 views, 라우터를 넣을 routes, 정적 파일을 넣을 public, passport 패키지를 위한 passport 폴더 생성
app.js, .env 파일 생성
app.ts (익스프레스 서버 코드)
import express, { NextFunction, Request, Response } from "express";
import cookieParser from "cookie-parser";
import morgan from "morgan";
import path from "path";
import session from "express-session";
import nunjucks from "nunjucks";
import dotenv from "dotenv";
import pageRouter from "./routes/page";
dotenv.config();
const app = express();
app.set("port", process.env.PORT || 8081);
app.set("view engine", "html");
nunjucks.configure("views", {
express: app,
watch: true,
});
// 패키지
app.use(morgan("dev"));
app.use(express.static(path.join(__dirname, "public"))); // 정적파일
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(
session({
resave: false,
saveUninitialized: false,
secret: process.env.COOKIE_SECRET!,
cookie: {
httpOnly: true,
secure: false,
},
})
);
app.use("/", pageRouter);
interface Error {
status?: number;
message?: string;
}
app.use((req: Request, res: Response, next: NextFunction) => {
const error: Error = new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
error.status = 404;
next(error);
});
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
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")}에서 대기 중~`);
});
.env
COOKIE_SECRET=cookiesecret
routes/page.ts
import express, { NextFunction, Request, Response } from "express";
import { renderProfile, renderJoin, renderMain } from "../controllers/page";
const router = express.Router();
router.use((req: Request, res: Response, next: NextFunction) => {
res.locals.user = null;
res.locals.followerCount = 0;
res.locals.followingCount = 0;
res.locals.followingIdList = [];
next();
});
router.get("/profile", renderProfile);
router.get("/join", renderJoin);
router.get("/", renderMain);
export default router;
router.use로 라우터용 미들웨어를 만들어서 템블릿 엔진에서 사용할 user, followerCount, followingCount, followingIdList 변수를 res.locals로 설정하였습니다. (템플릿 엔진에서 공통으로 사용하므로)
라우터 미들웨어를 controller에서 불러와 사용하고 있는 것을 확인할 수 있습니다.
이처럼, 라우터 마지막에 위치해 클라이언트에 응답을 보내는 미들웨어를 컨트롤러라고 합니다.
controllers/page.ts
import { NextFunction, Request, Response } from "express";
export const renderProfile = (req: Request, res: Response) => {
res.render("profile", { title: "내 정보 - NodeBird" });
};
export const renderJoin = (req: Request, res: Response) => {
res.render("join", { title: "회원 가입 - NodeBird" });
};
export const renderMain = (req: Request, res: Response, next: NextFunction) => {
const twits = [];
res.render("main", {
title: "NodeBird",
twits,
});
};
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="half">
<div>팔로잉</div>
<div class="count following-count">{{followingCount}}</div>
</div>
<div class="half">
<div>팔로워</div>
<div class="count follower-count">{{followerCount}}</div>
</div>
<input id="my-id" type="hidden" value="{{user.id}}" />
<a id="my-profile" href="/profile" class="btn">내 프로필</a>
<a id="logout" href="/auth/logout" class="btn">로그아웃</a>
{% else %}
<form id="login-form" action="/auth/login" method="post">
<div class="input-group">
<label for="email">이메일</label>
<input id="email" type="email" name="email" required autofocus />
</div>
<div class="input-group">
<label for="password">비밀번호</label>
<input id="password" type="password" name="password" required />
</div>
<a id="join" href="/join" class="btn">회원가입</a>
<button id="login" type="submit" class="btn">로그인</button>
<a id="kakao" href="/auth/kakao" class="btn">카카오톡</a>
</form>
{% endif %}
</div>
<footer>
Made by
<a href="https://www.zerocho.com" target="_blank">ZeroCho</a>
</footer>
</div>
{% block content %} {% endblock %}
</div>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
window.onload = () => {
if (new URL(location.href).searchParams.get("loginError")) {
alert(new URL(location.href).searchParams.get("loginError"));
}
};
</script>
{% block script %} {% endblock %}
</body>
</html>
views/main.html
{% extends 'layout.html' %} {% block content %}
<div class="timeline">
{% if user %}
<div>
<form
id="twit-form"
action="/post"
method="post"
enctype="multipart/form-data"
>
<div class="input-group">
<textarea id="twit" name="content" maxlength="140"></textarea>
</div>
<div class="img-preview">
<img
id="img-preview"
src=""
style="display: none"
width="250"
alt="미리보기"
/>
<input id="img-url" type="hidden" name="url" />
</div>
<div>
<label id="img-label" for="img">사진 업로드</label>
<input id="img" type="file" accept="image/*" />
<button id="twit-btn" type="submit" class="btn">짹짹</button>
</div>
</form>
</div>
{% endif %}
<div class="twits">
<form id="hashtag-form" action="/hashtag">
<input type="text" name="hashtag" placeholder="태그 검색" />
<button class="btn">검색</button>
</form>
{% for twit in twits %}
<div class="twit">
<input type="hidden" value="{{twit.User.id}}" class="twit-user-id" />
<input type="hidden" value="{{twit.id}}" class="twit-id" />
<div class="twit-author">{{twit.User.nick}}</div>
{% if not followingIdList.includes(twit.User.id) and twit.User.id !==
user.id %}
<button class="twit-follow">팔로우하기</button>
{% endif %}
<div class="twit-content">{{twit.content}}</div>
{% if twit.img %}
<div class="twit-img"><img src="{{twit.img}}" alt="섬네일" /></div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endblock %} {% block script %}
<script>
if (document.getElementById("img")) {
document.getElementById("img").addEventListener("change", function (e) {
const formData = new FormData();
console.log(this, this.files);
formData.append("img", this.files[0]);
axios
.post("/post/img", formData)
.then((res) => {
document.getElementById("img-url").value = res.data.url;
document.getElementById("img-preview").src = res.data.url;
document.getElementById("img-preview").style.display = "inline";
})
.catch((err) => {
console.error(err);
});
});
}
document.querySelectorAll(".twit-follow").forEach(function (tag) {
tag.addEventListener("click", function () {
const myId = document.querySelector("#my-id");
if (myId) {
const userId = tag.parentNode.querySelector(".twit-user-id").value;
if (userId !== myId.value) {
if (confirm("팔로잉하시겠습니까?")) {
axios
.post(`/user/${userId}/follow`)
.then(() => {
location.reload();
})
.catch((err) => {
console.error(err);
});
}
}
}
});
});
</script>
{% endblock %}
views/profile.html
{% extends 'layout.html' %} {% block content %}
<div class="timeline">
<div class="followings half">
<h2>팔로잉 목록</h2>
{% if user.Followings %} {% for following in user.Followings %}
<div>{{following.nick}}</div>
{% endfor %} {% endif %}
</div>
<div class="followers half">
<h2>팔로워 목록</h2>
{% if user.Followers %} {% for follower in user.Followers %}
<div>{{follower.nick}}</div>
{% endfor %} {% endif %}
</div>
</div>
{% endblock %}
views/join.html
{% extends 'layout.html' %} {% block content %}
<div class="timeline">
<form id="join-form" action="/auth/join" method="post">
<div class="input-group">
<label for="join-email">이메일</label>
<input id="join-email" type="email" name="email" />
</div>
<div class="input-group">
<label for="join-nick">닉네임</label>
<input id="join-nick" type="text" name="nick" />
</div>
<div class="input-group">
<label for="join-password">비밀번호</label>
<input id="join-password" type="password" name="password" />
</div>
<button id="join-btn" type="submit" class="btn">회원가입</button>
</form>
</div>
{% endblock %} {% block script %}
<script>
window.onload = () => {
if (new URL(location.href).searchParams.get("error")) {
alert("이미 존재하는 이메일입니다.");
}
};
</script>
{% endblock %}
views/error.html
{% extends 'layout.html' %} {% block content %}
<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>
{% 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[type='text'], input[type='email'], input[type='password'], 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%; }
.half { float: left; width: 50%; margin: 10px 0; }
#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: lightcoral;
}
.user-name { font-weight: bold; font-size: 18px; }
.count { font-weight: bold; color: crimson; font-size: 18px; }
.timeline {
margin-top: 10px;
width: 100%;
display: inline-block;
border-radius: 4px;
vertical-align: top;
}
@media screen and (min-width: 800px) { .timeline { width: 500px; } }
#twit-form {
border-bottom: 1px solid silver;
padding: 10px;
background: lightcoral;
overflow: hidden;
}
#img-preview { max-width: 100%; }
#img-label {
float: left;
cursor: pointer;
border-radius: 4px;
border: 1px solid crimson;
padding: 0 10px;
color: white;
font-size: 12px;
height: 37px;
line-height: 37px;
}
#img { display: none; }
#twit { width: 100%; min-height: 72px; }
#twit-btn {
float: right;
color: white;
background: crimson;
border: none;
}
.twit {
border: 1px solid silver;
border-radius: 4px;
padding: 10px;
position: relative;
margin-bottom: 10px;
}
.twit-author { display: inline-block; font-weight: bold; margin-right: 10px; }
.twit-follow {
padding: 1px 5px;
background: #fff;
border: 1px solid silver;
border-radius: 5px;
color: crimson;
font-size: 12px;
cursor: pointer;
}
.twit-img { text-align: center; }
.twit-img img { max-width: 75%; }
.error-message { color: red; font-weight: bold; }
#search-form { text-align: right; }
#join-form { padding: 10px; text-align: center; }
#hashtag-form { text-align: right; }
footer { text-align: center; }
서버 접속하면 아래와 같이 페이지 렌더링!
데이터베이스 세팅하기
테이블은 사용자/게시글/해시태그 총 3개의 테이블을 만들겠습니다.
config/config.ts
import * as dotenv from "dotenv";
dotenv.config();
// 객체 config에 대한 타입 정의
type Config = {
username: string;
password: string;
database: string;
host: string;
[key: string]: string;
};
interface IConfigGroup {
development: Config;
test: Config;
production: Config;
}
const config: IConfigGroup = {
development: {
username: "root",
password: process.env.DB_PASSWORD!,
database: "ts-nodeBird",
host: "127.0.0.1",
dialect: "mysql",
},
test: {
username: "root",
password: process.env.DB_PASSWORD!,
database: "ts-nodeBird",
host: "127.0.0.1",
dialect: "mysql",
},
production: {
username: "root",
password: process.env.DB_PASSWORD!,
database: "ts-nodeBird",
host: "127.0.0.1",
dialect: "mysql",
},
};
export default config;
models/sequelize.ts
import { Sequelize } from "sequelize";
import config from "../config/config";
const env =
(process.env.NODE_ENV as "production" | "test" | "development") ||
"development";
const { database, username, password } = config[env];
const sequelize = new Sequelize(database, username, password, config[env]);
export { sequelize };
export default sequelize;
models/index.ts
// import함과 동시에 export
export * from "./sequelize";
모델 생성
models/index.ts
import User, { associate as associateUser } from "./user";
import Post, { associate as associatePost } from "./post";
import Hashtag, { associate as associateHashtag } from "./hashtag";
const db = {
User,
Post,
Hashtag,
};
export type dbType = typeof db;
// 모델 관계 설정
associateUser(db);
associatePost(db);
associateHashtag(db);
models/user.ts
import { Sequelize, Model, DataTypes } from "sequelize";
import sequelize from "./sequelize";
import { dbType } from ".";
// union types
type PROVIDER = "local" | "kakao";
// user 모델 명시
interface UserAttributes {
email?: string;
nick: string;
password: string;
provider?: PROVIDER;
snsId: string;
}
export class User extends Model<UserAttributes> {
public readonly id!: number;
public email!: string;
public nick!: string;
public password!: string;
public provider!: PROVIDER;
public snsId!: string;
public readonly createAt!: Date;
public readonly updateAt!: Date;
}
// 모델 생성
User.init(
{
email: {
type: DataTypes.STRING(40),
allowNull: true,
unique: true,
},
nick: {
type: DataTypes.STRING(15),
allowNull: false,
},
password: {
type: DataTypes.STRING(100),
allowNull: true,
},
provider: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "local",
},
snsId: {
type: DataTypes.STRING(30),
allowNull: true,
},
},
{
sequelize,
timestamps: true,
underscored: false,
modelName: "User",
tableName: "users",
paranoid: true,
charset: "utf8",
collate: "utf8_general_ci",
}
);
export const associate = (db: dbType) => {
db.User.hasMany(db.Post);
db.User.belongsToMany(db.User, {
foreignKey: "followingId",
as: "Followers",
through: "Follow",
});
db.User.belongsToMany(db.User, {
foreignKey: "follwerId",
as: "Followers",
through: "Follow",
});
};
export default User;
이 때 PROVIDER Type을 생성하였는데, enum 대신 type으로 정의하였습니다.
typescript에서는 enum 사용을 지양하던데, 관련 내용은 아래 포스팅을 참고!
https://yceffort.kr/2022/03/typescript-use-union-types-instead-enum
관계 설정을 해주었는데, 먼저 User와 Post는 1:N 관계를 가지고 있으므로, hasMany를 사용하였습니다.
추후 팔로잉 기능을 추가해야 하는데, 이때는 User와 User 간의 N:M 관계를 가지고 있습니다.
같은 테이블 간의 N:M 관계에서는 모델 이름과 컬럼 이름을 따로 설정해야 하고, through 옵션을 이용해 생성할 모델 이름을 Follow로 정의하였습니다.
models/post.ts
import { DataTypes, Model, Sequelize } from "sequelize";
import sequelize from "./sequelize";
import { dbType } from ".";
// post 모델 명시
interface PostAttributes {
content?: string;
img: string;
}
export class Post extends Model<PostAttributes> {
public readonly id!: number;
public content!: string;
public img!: string;
public readonly createAt!: Date;
public readonly updateAt!: Date;
}
// 모델 생성
Post.init(
{
content: {
type: DataTypes.STRING(140),
allowNull: false,
},
img: {
type: DataTypes.STRING(200),
allowNull: false,
},
},
{
sequelize,
timestamps: true,
underscored: false,
modelName: "Post",
tableName: "posts",
paranoid: false,
charset: "utf8",
collate: "utf8mb4_general_ci",
}
);
export const associate = (db: dbType) => {
db.Post.belongsTo(db.User);
db.Post.belongsToMany(db.Hashtag, { through: "PostHashtag" });
};
export default Post;
User 모델과 Post 모델은 1(User):N(Post) 관계이므로 belongsTo를 사용하였습니다.
시퀄라이즈는 Post 모델에 User모델의 id를 가리키는 UserId 컬럼을 추가합니다.
또한, Post와 Hashtag는 N:M 관계를 가지고 있으므로, 마찬가지로 belongsToMany를 사용하였습니다.
through 옵션에 의하여 PostHashtag 브릿지모델이 생기고, postId고 hashtagId라는 foreignKey도 추가됩니다.
models/hashtag.ts
import { Sequelize, Model, DataTypes } from "sequelize";
import sequelize from "./sequelize";
import { dbType } from ".";
// Hashtag 모델 명시
interface HashtagAttributes {
title?: string;
}
export class Hashtag extends Model<HashtagAttributes> {
public readonly id!: number;
public title!: string;
public readonly createAt!: Date;
public readonly updateAt!: Date;
}
// 모델 생성
Hashtag.init(
{
title: {
type: DataTypes.STRING(15),
allowNull: false,
unique: true,
},
},
{
sequelize,
timestamps: true,
underscored: false,
modelName: "Hashtag",
tableName: "hashtags",
paranoid: false,
charset: "utf8",
collate: "utf8mb4_general_ui",
}
);
export const associate = (db: dbType) => {
db.Hashtag.belongsToMany(db.Post, { through: "PostHashtag" });
};
export default Hashtag;
마찬가지로, Hashtag와 Post는 N:M 관계이므로 위처럼 설정해주었습니다.
$ npx sequelie db:create
해당 명령어를 이용하여 데이터베이스를 생성할 수 있습니다.
config.json 파일을 읽어 데이터 베이스를 생성해주는 기능으로, operatorAliases 속성은 제거합니다.
모델과 서버 연결
app.ts
...
const app = express();
app.set("port", process.env.PORT || 8081);
app.set("view engine", "html");
nunjucks.configure("views", {
express: app,
watch: true,
});
// 서버 연결
sequelize
.sync({ force: false })
.then(() => {
console.log("데이터베이스 연결 성공");
})
.catch((error) => {
console.error(error);
});
...
@콘솔출력값
$ yarn start
yarn run v1.22.19
$ nodemon app
[nodemon] 2.0.20
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node app.js`
8081에서 대기 중~
Executing (default): SELECT 1+1 AS result
데이터베이스 연결 성공
시퀄라이즈는 테이블 생성 쿼리문에 IF NOT EXISTS를 넣어주므로, 테이블이 없을 때 테이블을 자동으로 생성합니다.
'JavaScript > Node.js' 카테고리의 다른 글
Node) 익스프레스로 SNS 서비스 만들기 - multer 패키지로 이미지 업로드 구현하기 (0) | 2023.01.02 |
---|---|
Node) 익스프레스로 SNS 서비스 만들기 - Passport 모듈로 로그인 구현하기 (0) | 2023.01.02 |
Node) sequelize 사용하기 - typescript, express, 쿼리 알아보기 (0) | 2022.12.30 |
Node) MySQL - 워크벤치 설치하기 (0) | 2022.12.29 |
Node) 익스프레스로 웹 서버 만들기 (1) | 2022.12.29 |