본문 바로가기
JavaScript/Node.js

Node) 익스프레스로 SNS 서비스 만들기 - 프로젝트 구조 갖추기, 모델 생성, 데이터베이스 연결

by 박채니 2023. 1. 2.
안녕하세요, 코린이의 코딩 학습기 채니 입니다.
[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&nbsp;
          <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

 

Home

yceffort

yceffort.kr

관계 설정을 해주었는데, 먼저 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를 넣어주므로, 테이블이 없을 때 테이블을 자동으로 생성합니다.