자바스크립트 공부 일지 14

Node.js 숙련 1주차 - Sequelize 관계에 따른 테이블 생성 및 모델 매핑

시작

이번에는 Sequelize를 이용해 회원가입, 로그인, 게시판, 댓글 API를 구현을 위한 관계에 따른 테이블 생성 및 모델 매핑을 해보도록 하자.

테이블 구조도

[그림 테이블 구조도]

사용자(Users)는 1개의 사용자 정보(UserInfo)를 가지고 있어요.

  • 그렇기 때문에 Users 테이블과 UserInfo 테이블은 1:1 관계를 가지고 있어요!

사용자(Users)는 여러개의 게시글(Posts)을 등록할 수 있어요.

  • 그렇기 때문에 Users 테이블과 Posts 테이블을 1:N 관계를 가지고 있어요!

사용자(Users)는 여러개의 댓글(Comments)을 작성할 수 있어요.

  • 그렇기 때문에 Users 테이블과 Comments 테이블은 1:N 관계를 가지고 있어요!

라이브러리 설치

bash
# 라이브러리를 설치합니다.
> npm install express sequelize mysql2 cookie-parser jsonwebtoken

# sequelize-cli, nodemon 라이브러리를 DevDependency로 설치합니다.
> npm install -D sequelize-cli nodemon

# 설치한 sequelize를 초기화 하여, sequelize를 사용할 수 있는 구조를 생성합니다.
> npx sequelize init

migration 파일 생성

테이블 구조도에 따른 migration파일을 생성해 봅시다.

bash
# Users 모델
npx sequelize model:generate --name Users --attributes email:string,password:string
# UserInfos 모델
npx sequelize model:generate --name UserInfos --attributes UserId:integer,name:string,age:integer,gender:string,profileImage:string
# Posts 모델
npx sequelize model:generate --name Posts --attributes UserId:integer,title:string,content:string
# Comments 모델
npx sequelize model:generate --name Comments --attributes UserId:integer,PostId:integer,comment:string

이후 폴더 구조는 아래와 같이 변경/추가됩니다.

text
내 프로젝트 폴더 이름
├── config
│   └── config.json
├── migrations
│   ├── 20230119050219-create-posts.js
│   ├── 20230119050219-create-user-infos.js
│   ├── 20230119050219-create-users.js
│   └── 20230119050220-create-comments.js
├── models
│   ├── comments.js
│   ├── index.js
│   ├── posts.js
│   ├── userinfos.js
│   └── users.js
├── package-lock.json
├── package.json
└── seeders

SQL 구문과 비교하면 다음과 같은 모습입니다.

sql
CREATE TABLE Users
(
    userId    int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
    email     varchar(255) UNIQUE NOT NULL,
    password  varchar(255)        NOT NULL,
    createdAt datetime            NOT NULL DEFAULT NOW(),
    updatedAt datetime            NOT NULL DEFAULT NOW()
);


CREATE TABLE UserInfos
(
    userInfoId   int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
    UserId       int(11) UNIQUE NOT NULL, -- 1:1 관계 이므로 UNIQUE 조건을 삽입합니다.
    name         varchar(255) NOT NULL,
    age          int(11) NOT NULL,
    gender       varchar(255) NOT NULL,
    profileImage varchar(255) NULL,
    createdAt    datetime     NOT NULL DEFAULT NOW(),
    updatedAt    datetime     NOT NULL DEFAULT NOW()
);

ALTER TABLE UserInfos
    ADD CONSTRAINT FK_UserInfos_Users
        FOREIGN KEY (UserId) REFERENCES Users (userId) ON DELETE CASCADE;


CREATE TABLE Posts
(
    postId    int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
    UserId    int(11) NOT NULL,
    title     varchar(255) NOT NULL,
    content   varchar(255) NOT NULL,
    createdAt datetime     NOT NULL DEFAULT NOW(),
    updatedAt datetime     NOT NULL DEFAULT NOW()
);

ALTER TABLE Posts
    ADD CONSTRAINT FK_Posts_Users
        FOREIGN KEY (UserId) REFERENCES Users (userId) ON DELETE CASCADE;


CREATE TABLE Comments
(
    commentId int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
    UserId    int(11) NOT NULL,
    PostId    int(11) NOT NULL,
    comment   varchar(255) NOT NULL,
    createdAt datetime     NOT NULL DEFAULT NOW(),
    updatedAt datetime     NOT NULL DEFAULT NOW()
);

ALTER TABLE Comments
    ADD CONSTRAINT FK_Comments_Posts
        FOREIGN KEY (PostId) REFERENCES Posts (postId) ON DELETE CASCADE;

ALTER TABLE Comments
    ADD CONSTRAINT FK_Comments_Users
        FOREIGN KEY (UserId) REFERENCES Users (userId) ON DELETE CASCADE;

현재 migration파일의 경우 외래키가 정의되지 않았습니다. 코드를 수정해 외래키를 참조하도록 변경해봅시다.

js
// 날짜-create-users.js
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Users', {
      userId: {
        allowNull: false, // NOT NULL
        autoIncrement: true, // AUTO_INCREMENT
        primaryKey: true, // Primary Key (기본키)
        type: Sequelize.INTEGER
      },
      email: {
        allowNull: false, // NOT NULL
        type: Sequelize.STRING,
        unique: true
      },
      password: {
        allowNull: false, // NOT NULL
        type: Sequelize.STRING
      },
      createdAt: {
        allowNull: false, // NOT NULL
        type: Sequelize.DATE,
        defaultValue: Sequelize.fn("now")
      },
      updatedAt: {
        allowNull: false, // NOT NULL
        type: Sequelize.DATE,
        defaultValue: Sequelize.fn("now")
      }
    });
  },
  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable('Users');
  }
};

이제 Users 의 userId를 참조하는 Posts를 봅시다.

js
// 날짜-create-posts.js
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Posts', {
      postId: {
        allowNull: false, // NOT NULL
        autoIncrement: true, // AUTO_INCREMENT
        primaryKey: true, // Primary Key (기본키)
        type: Sequelize.INTEGER
      },
      UserId: {
        allowNull: false, // NOT NULL
        type: Sequelize.INTEGER,
        references: {
          model: 'Users', // Users 모델을 참조합니다.
          key: 'userId', // Users 모델의 userId를 참조합니다.
        },
        onDelete: 'CASCADE', // 만약 Users 모델의 userId가 삭제되면, Posts 모델의 데이터가 삭제됩니다.
      },
      title: {
        allowNull: false, // NOT NULL
        type: Sequelize.STRING,
      },
      content: {
        allowNull: false, // NOT NULL
        type: Sequelize.STRING
      },
      createdAt: {
        allowNull: false, // NOT NULL
        type: Sequelize.DATE,
        defaultValue: Sequelize.fn("now")
      },
      updatedAt: {
        allowNull: false, // NOT NULL
        type: Sequelize.DATE,
        defaultValue: Sequelize.fn("now")
      }
    });
  },
  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable('Posts');
  }
};

onDelete 속성으로 UserId 외래키가 본 테이블에서 삭제된 경우 이 Posts데이터도 삭제하게 됩니다.

이어서 N의 역할인 comments를 봅시다. comments는 어떤 사용자가 어떤 게시글에 댓글을 남겼는지에 대한 정보가 필요하므로, UserId, PostId가 참조키로 있어야합니다.

js
// 날짜-create-comments.js
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Comments', {
      commentId: {
        allowNull: false, // NOT NULL
        autoIncrement: true, // AUTO_INCREMENT
        primaryKey: true, // Primary Key (기본키)
        type: Sequelize.INTEGER
      },
      UserId: {
        allowNull: false, // NOT NULL
        type: Sequelize.INTEGER,
        references: {
          model: 'Users', // Users 모델을 참조합니다.
          key: 'userId', // Users 모델의 userId를 참조합니다.
        },
        onDelete: 'CASCADE', // 만약 Users 모델의 userId가 삭제되면, Comments 모델의 데이터가 삭제됩니다.
      },
      PostId: {
        allowNull: false, // NOT NULL
        type: Sequelize.INTEGER,
        references: {
          model: 'Posts', // Posts 모델을 참조합니다.
          key: 'postId', // Posts 모델의 postId를 참조합니다.
        },
        onDelete: 'CASCADE', // 만약 Posts 모델의 postId가 삭제되면, Comments 모델의 데이터가 삭제됩니다.
      },
      comment: {
        allowNull: false, // NOT NULL
        type: Sequelize.STRING
      },
      createdAt: {
        allowNull: false, // NOT NULL
        type: Sequelize.DATE,
        defaultValue: Sequelize.fn("now")
      },
      updatedAt: {
        allowNull: false, // NOT NULL
        type: Sequelize.DATE,
        defaultValue: Sequelize.fn("now")
      }
    });
  },
  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable('Comments');
  }
};

마지막 남은 user-infos에는 UserId만 참조하면 될것같군요. 단 1:1관계이므로 UserId에 속성으로 UNIQUE의 값을 true로 설정합니다.

ERD를 작성해두고 이와 같은 작업을 진행하면 순조로울것입니다. 반드시 설계 완료 후 작업하도록 합시다.

테이블 생성하기

마이그레이션 과정이 끝났으니 테이블을 생성해봅시다. config/config.json에 연동할 DB 설정을 완료합시다.

json
  "development": {
    "username": "루트 계정",
    "password": "루트 비번",
    "database": "sequelize-relation",
    "host": "엔드포인트 주소",
    "dialect": "mysql"
  },

이후 다음과 같이 명령어를 입력합니다.

bash
# config/config.json에 설정된 DB를 생성합니다. 
> npx sequelize db:create

# 해당 프로젝트에 Migrations에 정의된 Posts 테이블을 MySQL에 생성합니다.
> npx sequelize db:migrate

만일 그대로 npx sequelize db:migrate를 입력하게 되면 참조키 테이블이 존재하지 않아 에러가 발생합니다. 이는 테이블 생성 순서를 지정해줘야하기 때문입니다. 기본으로 이름순으로 생성하죠 변경해봅시다.

log
ERROR: Failed to open the referenced table 'Posts'

일단 이름을 수정하여

text
users.js
user-infos.js
posts.js
comments.js

순으로 정렬되도록 한 후 다시 명령어를 입력해봅시다.

Sequelize Model 구현하기

Model의 경우에도 관계에 따른 참조키 등 정의해야합니다. 관계에 따라 한번 작성해봅시다.

Sequelize model 1:1 관계

  • “사용자(Users)는 1개의 사용자 정보(UserInfo)를 가지고 있어요.” 에서 사용자와 사용자 정보 모델의 경우 1:1 관계를 가지고 있습니다.
    해당 모델을 비교하여 Sequelize model에서 어떤 방법으로 관계를 설정하는지 확인해봅시다.
js
  // models/users.js

  // 1. Users 모델에서
  this.hasOne(models.UserInfos, { // 2. UserInfos 모델에게 1:1 관계 설정을 합니다.
    sourceKey: 'userId', // 3. Users 모델의 userId 컬럼을
    foreignKey: 'UserId', // 4. UserInfos 모델의 UserId 컬럼과 연결합니다.
  });
js
  // models/userinfos.js

  // 1. UserInfos 모델에서
  this.belongsTo(models.Users, { // 2. Users 모델에게 1:1 관계 설정을 합니다.
    targetKey: 'userId', // 3. Users 모델의 userId 컬럼을
    foreignKey: 'UserId', // 4. UserInfos 모델의 UserId 컬럼과 연결합니다.
  });

사용자(Users) 모델은 사용자 정보(UserInfos) 모델과 1:1 관계를 가지고 있습니다.

1:1 관계를 설정할 때는 hasOne, belongsTo 2가지의 메서드를 사용합니다. 만약 model 간의 관계를 설정한 경우 foreignKey에 해당하는 참조하는 컬럼이 belongsTo 메서드를 사용하는 모델에 생성되게 됩니다.
지금같은 경우는 userinfos이죠.

  • hasOne: 메서드를 사용하는 model참조하는 컬럼이 생성되지 않습니다.
  • belongsTo 메서드를 사용하는 model참조하는 컬럼이 생성됩니다. (UserId 컬럼)

Sequelize model 1:N 관계

  • “사용자(Users)는 여러개의 게시글(Posts)을 등록할 수 있어요.” 입니다. 즉, 사용자와 게시글 모델의 경우 1:N 관계를 가지고 있는 것을 확인할 수 있습니다.

다음과 같이 hasMany를 추가해줍시다.

js
// models/users.js
  // 1 : N(Posts)
  // 1. Users 모델에서
  this.hasMany(models.Posts, { // 2. Posts 모델에게 1:N 관계 설정을 합니다.
    sourceKey: 'userId', // 3. Users 모델의 userId 컬럼을
    foreignKey: 'UserId', // 4. Posts 모델의 UserId 컬럼과 연결합니다.
  });

이를 참조하는 Posts에서는 당연히 belongsTo를 사용하면 됩니다.

js
// models/posts.js

// 1. Posts 모델에서
      this.belongsTo(models.Users, { // 2. Users 모델에게 N:1 관계 설정을 합니다.
        targetKey: 'userId', // 3. Users 모델의 userId 컬럼을
        foreignKey: 'UserId', // 4. Posts 모델의 UserId 컬럼과 연결합니다.
      });
  • hasMany: 메서드를 사용하는 model참조하는 컬럼이 생성되지 않습니다.
  • belongsTo 메서드를 사용하는 model참조하는 컬럼이 생성됩니다. (UserId 컬럼)

왜 model만으로 테이블을 구성하지않나?

우선 app.js를 다음과 같이 작성합니다.

js
const { sequelize } = require('./models/index.js');

async function main() {
  // model을 이용해 데이터베이스에 테이블을 삭제 후 생성합니다.
  await sequelize.sync({ force: true });
}

main();

추가 작업을 위해 DB를 다시 drop합니다.

bash
# config에 지정한 sequelize-ralation Database 삭제
> npx sequelize db:drop
# config에 지정한 sequelize-ralation Database 재 생성
> npx sequelize db:create
> npx sequelize
> node app.js

하지만 이 방법은 추천하지 않습니다. 다음과 같은 단점이 있기 때문이죠. 1. Sequelize의 model의 설계가 변경될 경우 현재 서비스중인 테이블이 삭제 된 후 생성됩니다.

  • Production 환경에서 서비스 중인 Users 테이블에 새로운 관심사 컬럼을 추가하게 되는 상황이 발생하였다고 가정해보겠습니다. 만약 sequelize.sync의 명령어를 이용해 model로 테이블을 생성하게 되었을 경우 아래와 같은 쿼리가 MySQL에 전송됩니다.
bash
Executing (default): DROP TABLE IF EXISTS `Users`;
Executing (default): CREATE TABLE IF NOT EXISTS `Users` (`userId` INTEGER NOT NULL auto_increment , `email` VARCHAR(255) NOT NULL UNIQUE, `password` VARCHAR(255) NOT NULL, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, PRIMARY KEY (`userId`)) ENGINE=InnoDB;

내부 데이터들도 모두 삭제되겠죠? 운영 환경에선 사용해선 안됩니다.

2. MySQL의 테이블이 Node.js Application에 의존적이 됩니다.

  • sequelize.sync 명령어가 수행되어야만 MySQL의 테이블을 구현할 수 있게 됩니다. 그렇게 될 경우 현재 DB의 상황을 인지하기 힘들어지고, MySQL에 직접 등록한 부가적인 기능들을 이해하기 힘들어집니다.
  • MySQL의 View, Index, Scheduler, Trigger와 같은 기능들은 실제 MySQL에 접근하여 작업하는게 더욱 수월
  • 다양한 문제점으로 인해 sync의 명령어를 이용해 테이블을 생성하는 것은 좋은 방법이 아닙니다. 서버의 안정성과 개발 편의성을 위해서라도 migration을 이용하는 것이 model보다 custom하게 관리할 수 있고, cli로서 테이블을 구현할 수 있어 더욱 좋은 방법이라고 할 수 있습니다.

sync 알아보러가기