나의 발자취

[Node.js] ORM sequelizer, Postgres 사용해서 MVC 패턴 적용해보기 본문

Backend

[Node.js] ORM sequelizer, Postgres 사용해서 MVC 패턴 적용해보기

달모드 2024. 10. 17. 16:39

지난 포스팅

2024.10.17 - [Backend] - [Node.js] 시퀄라이즈 게시판 첨부파일 기능 구현하기

 

[Node.js] 시퀄라이즈 게시판 첨부파일 기능 구현하기

지난 포스팅2024.10.16 - [Backend] - [Node.js] sequelize-CLI 기존 테이블 필드 추가, 외래키 association 적용 [Node.js] sequelize-CLI 기존 테이블 필드 추가, 외래키 association 적용이전 포스팅에 이어서 바로 적는

wildguess.tistory.com

 

관련 포스팅 - 이전에 했던 내용이 일부 반복된다.

 

 

 

MVCS 패턴

MVCS 패턴은 애플리케이션을 구성하는 네 가지 주요 컴포넌트로 이루어져 있다: Models, Services, Controllers, Views.

  • Models - 데이터 구조와 상태를 정의하며, 데이터의 유효성 검사와 비즈니스 규칙을 적용 (= 데이터 담는것)
  • Services - 비즈니스 로직을 처리하고, Models와 Controllers 사이에서 데이터 처리를 담당
  • Controllers - 사용자 요청을 처리하고, 적절한 Service를 호출하여 응답을 생성
  • Views - 사용자 인터페이스를 담당하며, 데이터를 시각적으로 표시

* DAO: Data Access Object - (모델을 이용해) 데이터를 데이터베이스에서 갖고오는 역할을 한다. 데이터베이스와의 상호작용을 추상화하여 CRUD 작업을 수행한다.

 


프로젝트 시작: 패키지 설치

새 디렉토리 만들고, 터미널에 아래처럼 입력

npm init -y

npm i express nodemon pg sequelize sequelize-cli

 


Postgres 접속

그다음 Postgres 접속해서 1. DB 생성, 2. User생성, 3. user 권한을 설정해줄 것이다.

 

터미널에 psql postgres 입력

\l (역슬래시 + L) 을 하여 현재 생성된 데이터베이스 확인

 

 

이제부터 명령어를 입력할건데, 쿼리문이므로 반드시 뒤에다가 세미콜론을 붙여주어야한다. 

 

1. DB 생성

create database ch10;

을 입력해서 CREATE DATABASE 가 나오면 성공

 

2. 유저 생성

이제 이름은 admin 인 유저의 비번을 admin1234를 사용해서 생성해준다.

create user admin with encrypted password 'admin1234';

을 입력해서 CREATE ROLE가 나오면 성공

 

3. 유저 권한 설정

이 유저가 어디에만 접근할 수 있게끔 권한을 주면 된다.

grant all privileges on database ch10 to admin;

을 입력해서 GRANT가 나오면 성공

 

\q를 해서 빠져나온다.


Sequelize-cli 초기화

생성된

npx sequelize-cli init

 

이후 config.json에 가서 아래와 같이 "development" 안의 항목을 방금 생성한 내용대로 수정해준다.

 

 


디렉토리(폴더) 생성

나중에 해도 되긴 한데, 최상위에 controllers, dao, routes, services 디렉토리(폴더)를 각각 만든다.

 


CLI를 활용하여 모델 생성

CLI를 활용해서 모델을 생성해줄거다!

npx sequelize-cli model:generate --name User --attributes name:string,email:string,password:string,address:string

 

그러면 각각 migrations, models 폴더에 파일이 생성된 것이 보인다.

 

npx sequelize-cli model:generate --name Post --attributes title:string,content:string,userId:integer

 

최종적으로 이렇게 생겼다.

 


Sequelize ORM에서 데이터베이스 모델 정의

models: 

migrations: 

 

migrations 안의 파일들 먼저 작업해줄것이다.

1. 20241017033336-create-post.js

에 아래와 같이 속성을 설정해준다. 

 

대문자 구분 주의!! Users (O)

 

<참고>
npx sequelize-cli db:migrate:status 
를 사용하면 현재 마이그레이션 상태를 볼 수 있습니다. 이 명령어는 실행된 마이그레이션과 실행되지 않은 마이그레이션 목록을 보여준다.

 

이제 변경사항을 반영해준다.

npx sequelize-cli db:migrate

 

그리고 테이블이 잘 마이그레이션되었는지 postgre sql에 접속해서 확인해준다.

 

내가 생성했던 테이블(Posts, Users) 말고도 낯선 스키마가 있는 것을 볼 수 있다.

(스키마데이터베이스의 구조를 정의하는 영역을 나타낸다. PostgreSQL에서는 여러 스키마를 사용할 수 있으며, 스키마는 테이블, 뷰, 시퀀스 등의 이름을 그룹화하는 데 사용된다.)

 

Name 필드

    • Posts: 게시글 정보를 저장하는 테이블
    • Posts_id_seq: Posts 테이블의 기본 키 또는 특정 필드의 값을 자동으로 증가시키기 위한 시퀀스
    • SequelizeMeta: Sequelize ORM이 마이그레이션 상태를 추적하기 위해 사용하는 테이블
    • Users: 사용자 정보를 저장하는 테이블
    • Users_id_seq: Users 테이블의 기본 키를 자동 증가시키기 위한 시퀀스

Type 필드

객체의 종류를 나타낸다. 일반적으로 table 또는 sequence 이다.

  • table: 데이터가 저장되는 구조
  • sequence: 자동 증가하는 값을 생성하기 위한 객체로, 보통 기본 키와 함께 사용된다.

 


\d "DB name"
을 입력하면 테이블을 볼 수 있다.

 

models > post.js에 가서, associate()부분을 채워준다.

 

 

seed 생성

npx sequelize-cli seed:generate --name demo-user

npx sequelize-cli seed:generate --name demo-post

 

하고, seeder 폴더의 20241017050728-demo-user.js 파일에 가서 up()에다가 샘플 데이터를 넣어준다. 나는 유저 3명을 샘플로 넣었다.

"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.bulkInsert("Users", [
      {
        name: "Blossom",
        email: "blossom@gmail.com",
        password: "admin1234",
        address: "Seoul, Korea",
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        name: "Bubble",
        email: "bubble@gmail.com",
        password: "admin1234",
        address: "Paris, France",
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        name: "Buttercup",
        email: "buttercup@gmail.com",
        password: "admin1234",
        address: "SF, US",
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ]);
  },
};

 

 

마찬가지로 20241017050749-demo-post.js 에 가서 샘플을 생성해줄건데, 유저 각 한명당 3개의 게시글을 넣어서 총 9개의 데이터 샘플을 만들었다.

"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.bulkInsert("Posts", [
      {
        title: "Test Title 1 (user 1)",
        content: "Test Content 1",
        userId: 1,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        title: "Test Title 2 (user 1)",
        content: "Test Content 2",
        userId: 1,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        title: "Test Title 2 (user 1)",
        content: "Test Content 2",
        userId: 1,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        title: "Test Title 1 (user 2)",
        content: "Test Content 1",
        userId: 2,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        title: "Test Title 2 (user 2)",
        content: "Test Content 2",
        userId: 2,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        title: "Test Title 2 (user 2)",
        content: "Test Content 2",
        userId: 2,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        title: "Test Title 1 (user 3)",
        content: "Test Content 1",
        userId: 3,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        title: "Test Title 2 (user 3)",
        content: "Test Content 2",
        userId: 3,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        title: "Test Title 2 (user 3)",
        content: "Test Content 2",
        userId: 3,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ]);
  },
};

 

 

seed 적용

하기 전에

user.js에 가서 .hasMany()를 채워준다.

 

이 부분을 이와 같이 적용을 해주는 이유는 데이터베이스의 관계를 ORM에서 명시적으로 정의하기 위해서다. .hasMany()는 한 모델이 다른 모델과의 관계를 명확히 정의한다. 예를 들어, User 모델이 여러 개의 Post를 가질 수 있는 경우, User Post에 대해 "하나가 여러 개"의 관계를 가진다.

사실 해당 부분을 적용시켜주지 않아도 시드 데이터는 적용이 될 것이긴 한데, 그렇게 되면 몇 가지 취약점이 존재하게 된다.

 

1. 무결성 문제

  • 관계 없는 데이터: 모델 간의 관계가 명시되지 않으면, 시드 데이터를 삽입할 때 외래 키 제약 조건이 제대로 작동하지 않을 수 있다. 예를 들어, userId가 존재하지 않는 사용자 ID로 포스트를 생성하려고 하면 오류가 발생할 수 있다.

2. 쿼리 및 데이터 조작의 복잡성

  • 복잡한 쿼리: 관계가 정의되지 않으면 관련된 데이터를 쿼리할 때 조인(join) 등의 작업을 수동으로 수행해야 할 수 있습니다. 이는 코드가 복잡해지고 가독성이 떨어질 수 있다

3. 코드의 일관성 부족

  • 비즈니스 로직 혼란: 비즈니스 로직에서 두 모델 간의 관계를 명확히 이해하기 어려워지므로, 개발자 간의 의사소통이 복잡해질 수 있습니다.

 

npx sequelize-cli db:seed:all

 

 

seed 데이터 확인

 

그리고 psql에 접속을 해서, select * from "Users"; 그리고 select * from "Posts"; 로 시드 데이터가 잘 들어갔는지 확인을 해준다.

(주의: ORM 문법 특성상 테이블명 앞뒤로 ""를 해주어야지만 인식된다. 그리고 대소문자 구분을 해주어야 한다. 안하면 아래와 같이 에러가 난다.)

 

 

정상적으로 하면 보면 아래와 같이 데이터가 확인된다.

 


DAO 모델 생성 후 외부 export

Data Access Object라는 이름 그대로, 데이터 모델에 접근할 수 있는 로직을 작성해줄것이다.

dao > postDao.js 파일을 생성해준다.

 

const models = require("../models");
// models/index.js의 db 객체가 models에 할당

const createPost = async (data) => {
  // 글쓰기
  return await models.Post.create(data);
};

const findPostById = async (id) => {
  // 게시글 가져오기
  return await models.Post.findByPk(id, {
    include: { model: models.User },
  });
};

const findAllPost = async () => {
  // 목록 조회
  return await models.Post.findAll({
    include: {
      model: models.User,
    },
  });
};

const updatePost = async (id, data) => {
  // 게시글 수정
  return await models.Post.update(data, {
    where: { id },
  });
};

const deletePost = async (id) => {
  // 게시글 삭제
  return await models.Post.destroy({
    where: { id },
  });
};

 

그리고 최하단에 아래와 같이 모듈 export를 해주면, 외부 파일에서도 이 메서드들을 사용할 수 있다.

module.exports = {
    createPost,
    findAllPost,
    findPostById,
    updatePost,
    deletePost,
};

유닛테스트 해보기

npm i jest

설치 후 package.json에서 아래의 항목으로 수정을 해준다.

 

dao 폴더 안에 postDao.test.js 라는 파일을 생성한다.

이렇게 하면 모든 디렉토리에 "test"가 들어간 모든 부분을 검사하게 된다.

 

내용 작성

const postDao = require("./postDao");

describe("Test DAO", () => {
  test("should", async () => {
    const data = {
      title: "unit test dao",
      content: "unit test dao content",
      userId: 4,
    };
    const result = await postDao.createPost(data);
    expect(result.title).toBe(data.title);
  });
});

 

 

그리고 터미널에서 npm run test을 쳐본다.

 

에러 발생

 

그건 바로 위에서 userId: 4로 해서 그렇다. 존재하는 userId로 해야한다.

unit test 파일 내의 userId: 1 로 바꿔주면, 아래와 같이 테스트 통과가 확인된다!

 


 

services

services > postService.js 를 생성해서 아래와 같이 기본적인 몸체를 작성한 후 메서드 안을 다 채운다.

post.db랑 비슷한데? ..라고 할 수 있는데, 지금은 간단해보여도 이 postService 파일은 Post.db에 있는 데이터를 가져와서 비즈니스 로직을 처리하는 부분을 수행하는 것이다.

const postDao = require("../dao/postDao");

const createPost = async (data) => {
  return await postDao.createPost(data);
};

const findPostById = async (id) => {
  return await postDao.findPostById(id);
};

const findAllPost = async () => {
  return await postDao.findAllPost();
};

const updatePost = async (id, data) => {
  return await postDao.updatePost(id, data);
};

const deletePost = async (id) => {
  return await postDao.deletePost(id);
};

module.exports = {
  createPost,
  findPostById,
  findAllPost,
  updatePost,
  deletePost,
};

 

controllers

controllers > postController.js 라고 새 파일 생성 후 아래와 같이 내용을 작성한다.

 

 

컨트롤러는 유저의 요청을 받아서 비즈니스 로직에서 처리한 결과를 다시 유저에게 보내주는 역할을 하는 것이기 때문에, 다른 역할들은 굳이 여기서 안해주는게 좋다.

const postService = require('../services/postService');
// const postDao = require('../dao/postDao'); // 금지
// const models = require('../models'); // 금지

 

 

위 services때와 마찬가지로, 아래에 틀을 잡아놓는다.

다른 점은 async에서 req, res를 인자로 받는다는 점이다. (요청처리를 해주는 기능이기 때문에!)

const postService = require("../services/postService");
// const postDao = require('../dao/postDao'); // 금지
// const models = require('../models'); // 금지

const createPost = async (req, res) => {};
const findPostById = async (req, res) => {};
const findAllPost = async (req, res) => {};
const updatePost = async (req, res) => {};
const deletePost = async (req, res) => {};


module.exports = {
  createPost,
  findPostById,
  findAllPost,
  updatePost,
  deletePost,
};

 

 

그리고 try-catch 구문으로 내용들을 채워준다.

const post = require("../models/post");
const postService = require("../services/postService");

const createPost = async (req, res) => {
  // 게시글 작성
  try {
    // {"title":"a","content","b","userId":2} = req.body
    const post = await postService.createPost(req.body);
    res.status(201).json({ data: post });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
};

const findPostById = async (req, res) => {
  // 게시글 가져오기
  try {
    const post = await postService.findPostById(req.params.id);
    if (post) {
      res.status(200).json({ data: post });
    } else {
      res.status(404).json({ eror: "Post not found" });
    }
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
};

const findAllPost = async (req, res) => {
  // 게시글 목록 전체 반환
  try {
    const posts = await postService.findAllPost();
    res.status(200).json({ data: posts });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
};

const updatePost = async (req, res) => {
  // 게시글 수정
  try {
    // postman으로 보낼 때 {"title":"a","content":"b","userId":2} // http://localhost:3000/posts/1
    const post = await postService.updatePost(req.params.id, req.body);
    if (post) { // 밑에 Json 값을 집어넣지 않으면, 성공 시 1, 실패 시 0으로 결과가 나온다.
        res.status(200).json({data: "successfully added post"});
        
    } else {
      res.status(404).json({ data: "Post not found" });
    }
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
};
const deletePost = async (req, res) => {
  try {
    const result = await postService.deletePost(req.params.id);
    if (result) {
      res.status(200).json({ message: "success" });
    } else {
      res.status(404).json({ message: "Post not found" });
    }
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
};

module.exports = {
  createPost,
  findPostById,
  findAllPost,
  updatePost,
  deletePost,
};

 

 


router

라우터를 따로 관리해주기 위해서, 이제 routes > postRoute.js 파일을 새로 생성하고 아래와 같이 내용을 작성한다.

const express = require("express");
const postController = require("../controllers/postController");

const router = express.Router();

router.post("/", postController.createPost); // = POST /posts
router.get("/", postController.findAllPost); // = GET /posts
router.get("/:id", postController.findPostById); // = GET /posts/1
router.put("/:id", postController.updatePost); // = PUT /posts/1
router.delete("/:id", postController.deletePost); // = DELETE /posts/1


module.exports = router;

 

보면 default 라우터인 /posts는 app.js 에서 따로 설정을 해줄 것이다.

 

 


app.js에 /posts 라우터 추가하기

최상위 디렉토리에 app.js 파일을 새로 생성해준다.

const express = require("express");
const postRoute = require("./routes/postRoute");
const models = require("./models");
const app = express();
const PORT = 3000;

app.use(express.json());
app.use("/posts", postRoute); //

app.listen(PORT, () => {
  models.sequelize
    .sync({ force: false })
    .then(() => {
      console.log("DB 연결 성공");
    })
    .catch((err) => {
      console.error(`DB 연결 실패: ${err}`);
      process.exit();
    });
});

 

 

<참고>

여기서 정의한 이 line 8 덕분에, postRoute에서 /posts 라우터를 디폴트로 사용할 수 있다. 

따라서 서비스를 확장할수록 posts 말고도 users 관련된 라우터, products 관련된 라우터들을 아래와 같이 선언해서 사용할 수 있다.

 


테스트

 

npm run dev로 실행

 

포스트맨에서 실행~

GET 

 

 

POST

 

PUT

Comments