Project/[SAFU] 1st Project

3. [Server] Sequelize DB 세팅(Oct 17~18, 2020 회고)

HJChung 2020. 11. 5. 17:02

API 와 마찬가지로 DB schema역시 Client 파트와 Server 파트의 커뮤니케이션 및 합의가 많이 이루어지는 부분이다. 
프로젝트를 진행하기 이전에는 DB에서 MVC 디자인 패턴으로 데이터를 가져오는 것은 Server 파트라고 생각했던 탓인지

client와 server 파트를 오가며 코드 리뷰하는데 이 부분이 은근 많은 걸림돌이 되었다. 

그리고 프로젝트가 2/3 정도 마무리 될 쯤에 회원탈퇴에 대한 DB 처리 이슈가 큰 논의사항이 되었고, 그래서 많은 마이그레이션이 이루어졌는데, 만약 이러한 상황이 파트 불문하고 커뮤니케이션이 바로바로 이루어지지 않았다면 DB가 꼬여버리는 현상이 발생하게 된다. (ex. '같은 시점에서 저도 pull 받아왔는데 저는 왜 DB에서 데이터를 받아올 때 null 에러가 나는 거죠??!' 라는 슬랙을 보낼 수도..ㅠ)

 

그래서 아에 날을 잡고 Server 파트 중 HJ님께서 어떤 식으로 세팅해 주셨는지를 리뷰하고, sequelize에서는 외래키를 어떻게 선언할 수 있는지를 공부해보았다. 

※ 많은 부분을 HJ님이 정리해주신 블로그 글을 참고하였고 reference에서 확인할 수 있다.

 

1. Sequelize로 DB model 세팅

① sequelize 설치 

//터미널
npm install sequelize --save-dev

② migration을 위한 cli설치

//터미널
npm install sequelize-cli --save-dev

③ 프로젝트 bootstrapping

//터미널
npx sequlize-cli init

④ mysql을 열어서 데이터베이스 생성 

이제 이 프로젝트에서는 safu 데이터베이스를 사용할 것이다. 

//터미널
mysql -u root -p
mysql> create database safu;

2. sequuelize의 config설정

위의 단계까지 마무리하면 config/config.js 파일이 생성 된 것을 알 수 있다. 아래와 같은 형식일 것이다.
이제 여기를 내가 사용할 DB 환경에 맞게 변경하여서 sequelize가 DB를 사용한는데 필요한 정보를 알려 줄 수 있다. 

{
  "development": {
    "username": "root",
    "password": null,
    "database": "database_development",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "test": {
    "username": "root",
    "password": null,
    "database": "database_test",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "production": {
    "username": "root",
    "password": null,
    "database": "database_production",
    "host": "127.0.0.1",
    "dialect": "mysql"
  }
}

그러나 이러한 정보는 여기에 직접 입력하면 보안상 매우 위험하다. 그래서 환경 변수 설정 파일에 정보를 넣고, 이 정보를 불러와서 사용할 수 있도록 하는 dotenv 모듈을 사용하고자 하였다. 또한 사용한 환경 변수 설정 파일 .env는 gitignore에 추가하여 실수로라도 git에 올리는 것을 막았다. 

① dotenv 모듈 설치

//터미널
npm install dotenv --save-dev

② 환경 변수 설정 파일 .env 생성 및 정보 입력

//.env
MYSQL_USERNAME = // 내 mysql 접속 이름
MYSQL_PASSWORD = // 내 mysql 접속 비밀번호
MYSQL_DATABASE = // 내 mysql 사용할 DB명
MYSQL_HOST = //사용할 host 

③ 이를 사용해서 config.js 작성

.env파일은 dotenv 모듈을 불러와서 사용할 수 있다.

require('dotenv').config();
const env = process.env;

const development = {
  username: env.MYSQL_USERNAME,
  password: env.MYSQL_PASSWORD,
  database: env.MYSQL_DATABASE,
  host: env.MYSQL_HOST,
  dialect: "mysql",
};

const production = {
  username: //이건 배포환경에 맞게 작성해주면 된다. 
  password: //이건 배포환경에 맞게 작성해주면 된다. 
  database: //이건 배포환경에 맞게 작성해주면 된다. 
  host: //이건 배포환경에 맞게 작성해주면 된다. 
  dialect: "mysql",
};

const test = {
  username: env.MYSQL_USERNAME,
  password: env.MYSQL_PASSWORD,
  database: env.MYSQL_DATABASE_TEST,
  host: env.MYSQL_HOST,
  dialect: "mysql",
};

module.exports = { development, production, test };

 3. model/index.js 파일 수정

이 파일은 Server에서 DB를 실행 했을 때 어떤 경로의 파일을 불러와서 실행할 것인지. 즉 sequelize가 어떤 경로에 있는 config파일의 정보를 읽으면 되는지 지정해주는 것이다. 그래서 const config = reequire()의 괄호 자리에 앞서 우리가 만들어준 config.js 파일의 경로를 입입력해준다.

const env = process.env.NODE_ENV || 'development'; //이 부분을 개인 개발 용으로 할때는 'development'로 바꿔주세요
// 변경 전) const config = require(__dirname + '/../config/config.json')[env];
const config = require(__dirname + '/../config/config.js')[env];

4. sequelize 모델 생성

SAFU의 DB schema

우리 프로젝트의 DB schema는 이렇다.

  • reviews의 각 리뷰 데이터 : users의 각 사용자 데이터의 관계 = N: 1
    • 한 명의 사용자는 여러개의 리뷰를 작성할 수 있는(hasMany) 반면 한 리뷰는 한 명의 사용자가 작성(belongsTo)한 것이다. 
    • (주의! 후에 abusing을 다루었던 날의 회고도 작성할 것이지만 한 명의 사용자가 한 부트캠프에 대해서는 여러개의 리뷰를 작성하지 못하도록 조건 검사를 통해 걸러주도록 했는데 이는 DB단에서 한 것이 아니라 조건문으로 걸러주도록 했다.)
  • reviews의 각 리뷰 데이터 : bootcamp_lists의 각 부트캠프 데이터의 관계 = N:1
    • 한 부트캠프에는 여러개의 리뷰들이 있을 수 있지만(hasMany) 한 리뷰는 하나의 부트캠프에 대한 리뷰(belongsTo)이다. 
  • users의 각 사용자 데이터 :bootcamp_lists의 각 부트캠프 데이터의 관계 = N: M
    • 한 부트캠프에는 여러 사용자가 있을(belongsToMany) 수 있고, 한 명의 사용자도 여러개의 부트캠프를 이용(belongsToMany)할 수 있다. 
  • 그래서 이 N:M 관계를 이어주기 위한 users_bootcamp 테이블이 있는 것이다 .

위의 그림을 보면 알겠지만 외래키로 각 테이블이 연결되어 있다. 그래서 sequelize에서는 외래키를 어떻게 선언할 수 있는지에 초점을 맞추어서 어떻게 모델을 작성하였는지 리뷰해보도록 하겠다. 

 

두 모델간의 연결 관계는 associaste(model){}안에 작성해준다. 

 

1) belongsTo

reviews.hasOne(users) 라고 하면 reviews가 source 모델이 되고, users가 target 모델이 된다. belongsTo는 데이터 간 1:1 관계일 때 외래키가 source모델에 존재한는 연결 관계이다. 

즉, reviews에 있는 users_id 데이터가 외래키가 되고 이는 useremail이라고 정의된 users 모델의 id 데이터와 1:1 관계를 가진다. 는 것을

model/reviews.js의 associate에 이렇게 정의해 줄 수 있다. 

reviews.belongsTo(models.users, {
        foreignKey: 'users_id',
        as: 'useremail', //as로 정의된 이름이 target모델(여기선 users 모델)의 이름으로 사용된다. 
        targetKey: 'id',
        onUpdate: 'cascade',
        onDelete: 'set null',
      });

reviews에 있는 bootcamp_id 데이터가 외래키가 되고 이는 bootcampname이라고 정의된 bootcamp_list 모델의 id 데이터와 1:1 관계를 가진다.는 것도

마찬가지로model/reviews.js의 associate에 이렇게 정의해 줄 수 있다. 

reviews.belongsTo(models.bootcamp_list, {
        foreignKey: 'bootcamp_id',
        as: 'bootcampname',//as로 정의된 이름이 target모델(여기선 bootcamp_list 모델)의 이름으로 사용된다.
        targetKey: 'id',
        onUpdate: 'cascade',
        onDelete: 'set null',
      });

2) hasMany

bootcamp_list.hasMany(reviews) 라고 하면 bootcamp_list가 source 모델이 되고, reviews가 target 모델이 된다. hasMany는 데이터 간 1:N 관계일 때 단일 source 데이터를 여러 target모델에 연결한다. 

즉, reviews에 있는 bootcamp_id 데이터가 외래키가 되고 이는 한 부트캠프에는 여러개의 리뷰들이 있을 수 있다(hasMany)는 것을 hasMany를 사용해서 정의해 줄 수 있다.  

이렇게 하면 reviews 모델에 bootcamp_id라는 column이 추가된다. 

model/bootcamp.js의 assocaitea에

 bootcamp_list.hasMany(models.reviews, {
        foreignKey: 'bootcamp_id',
        sourceKey: 'id',
        onUpdate: 'cascade',
        onDelete: 'set null',
      });

한 명의 사용자는 여러개의 리뷰를 작성할 수 있는(hasMany) 경우도 마찬가지 방식으로

model/users.js 의 associate 부분에 정의하면 된다. 

 users.hasMany(models.reviews, {
        foreignKey: 'users_id',
        sourceKey: 'id',
        onUpdate: 'cascade',
        onDelete: 'set null',
      });

3) belongsToMany

다대다 괸계는 각 모델이 아니라 through에 정의된 새로 생성된 모델에 특성이 추가된다.

즉, 한 부트캠프에는 여러 사용자가 있을(belongsToMany) 수 있다는 것은

model/bootcamplist.js의 associate에

bootcamp_list.belongsToMany(models.users, {
        through: 'users_bootcamp',
        targetKey: 'id',
        foreignKey: 'bootcamp_id',
        onUpdate: 'cascade',
        onDelete: 'cascade',
      });

한 명의 사용자도 여러개의 부트캠프를 이용(belongsToMany)할 수 있다. 는 것은

model/users.js의 associate에

 users.belongsToMany(models.bootcamp_list, {
        through: 'users_bootcamp',
        targetKey: 'id',
        foreignKey: 'users_id',
        onUpdate: 'cascade',
        onDelete: 'cascade',
      });

 

이렇게 모델 정의가 완성된 model/reviews.js & model/users.js & model/bootcamplist.js 는 <더보기> 를 클릭하면 볼 수 있다. 

더보기

① model/reviews.js

'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class reviews extends Model {
    static associate(models) {
      // define association here : 두 모델의 연결 관계를 정의
      reviews.belongsTo(models.users, {
        foreignKey: 'users_id',
        as: 'useremail', //as로 정의된 이름이 target모델(여기선 users 모델)의 이름으로 사용된다. 
        targetKey: 'id',
        onUpdate: 'cascade',
        onDelete: 'set null',
      });
      reviews.belongsTo(models.bootcamp_list, {
        foreignKey: 'bootcamp_id',
        as: 'bootcampname',//as로 정의된 이름이 target모델(여기선 bootcamp_list 모델)의 이름으로 사용된다.
        targetKey: 'id',
        onUpdate: 'cascade',
        onDelete: 'set null',
      });
    }
  }
  reviews.init(
    { //팀 내에서 정한 reviews 테이블의 column들을 type에 맞게 선언해준다. 
      users_id: DataTypes.INTEGER,
      bootcamp_id: DataTypes.INTEGER,
      githublink: DataTypes.STRING,
      price: DataTypes.STRING,
      level: DataTypes.STRING,
      recommend: DataTypes.STRING,
      curriculum: DataTypes.STRING,
      comment: DataTypes.STRING,
      active: DataTypes.BOOLEAN,
    },
    {
      sequelize, //데이터베이스 connection
      modelName: 'reviews', // 모델명
    },
  );
  return reviews;
};

② model/bootcamplist.js

'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class bootcamp_list extends Model {
    static associate(models) {
      // define association here : 두 모델의 연결 관계를 정의
      bootcamp_list.belongsToMany(models.users, {
        through: 'users_bootcamp',
        targetKey: 'id',
        foreignKey: 'bootcamp_id',
        onUpdate: 'cascade',
        onDelete: 'cascade',
      });
      bootcamp_list.hasMany(models.reviews, {
        foreignKey: 'bootcamp_id',
        sourceKey: 'id',
        onUpdate: 'cascade',
        onDelete: 'set null',
      });
    }
  }
  bootcamp_list.init(
    { //팀 내에서 정한 reviews 테이블의 column들을 type에 맞게 선언해준다. 
      name: DataTypes.STRING,
    },
    {
      sequelize,
      modelName: 'bootcamp_list',// 모델명 모델명을 단수(bootcamp_list)로 해주어도 mysql 테이블로는 복수형(bootcamp_lists)으로 만들어진다. 주의!
    },
  );
  return bootcamp_list;
};

③ model/users.js

'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class users extends Model {
    static associate(models) {
      // define association here : 두 모델의 연결 관계를 정의
      users.belongsToMany(models.bootcamp_list, {
        through: 'users_bootcamp',
        targetKey: 'id',
        foreignKey: 'users_id',
        onUpdate: 'cascade',
        onDelete: 'cascade',
      });
      users.hasMany(models.reviews, {
        foreignKey: 'users_id',
        sourceKey: 'id',
        onUpdate: 'cascade',
        onDelete: 'set null',
      });
    }
  }
  users.init(
    {//팀 내에서 정한 reviews 테이블의 column들을 type에 맞게 선언해준다. 
      email: DataTypes.STRING,
      password: DataTypes.STRING,
      githubId: DataTypes.STRING,
      kind_login: DataTypes.STRING,
    },
    {
      sequelize,
      modelName: 'users',// 모델명 
    },
  );
  return users;
};

 

5. Migration 진행

모델을 직접 작성해주었으므로 마이그레이션을 해야 DB에 반영이 된다. 

① migration skeleton 생성

//터미널
npx sequelize-cli migration:generate --name [마이그레이션 명 지정] migration-skeleton

이후 생성된 migrates 디렉토리의 파일에 migration 코드 작성

sequelize.org/master/manual/migrations.html 여기에 나와있는 방식대로 참고하여서 자신이 원하는 동작을 작성하면 된다. 

 

위에서 작성한 모델 정의에 맞게 테이블을 생성하는 migration 코드는 <더보기> 를 클릭하면 볼 수 있다.

더보기

1. create reviews table

'use strict';

module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface
      .createTable('reviews', {
        id: {
          allowNull: false,
          autoIncrement: true,
          primaryKey: true,
          type: Sequelize.INTEGER,
        },
        githublink: {
          type: Sequelize.STRING,
        },
        price: {
          type: Sequelize.STRING,
        },
        level: {
          type: Sequelize.STRING,
        },
        recommend: {
          type: Sequelize.STRING,
        },
        curriculum: {
          type: Sequelize.STRING,
        },
        comment: {
          type: Sequelize.STRING,
        },
        active: {
          type: Sequelize.BOOLEAN,
        },
        createdAt: {
          allowNull: false,
          type: Sequelize.DATE,
        },
        updatedAt: {
          allowNull: false,
          type: Sequelize.DATE,
        },
      })
      .then(function () {
        queryInterface.addColumn('reviews', 'users_id', {
          type: Sequelize.INTEGER,
          allowNull: true,
          onDelete: 'SET NULL',
          references: { model: 'users', key: 'id' },
        });
      })
      .then(function () {
        queryInterface.addColumn('reviews', 'bootcamp_id', {
          type: Sequelize.INTEGER,
          allowNull: true,
          onDelete: 'SET NULL',
          references: { model: 'bootcamp_lists', key: 'id' },
        });
      });
  },

  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('reviews');
  },
};

2. create bootcamplist table

'use strict';

module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface
      .createTable('bootcamp_lists', {
        id: {
          allowNull: false,
          autoIncrement: true,
          primaryKey: true,
          type: Sequelize.INTEGER,
        },
        name: {
          type: Sequelize.STRING,
        },
        createdAt: {
          allowNull: false,
          type: Sequelize.DATE,
        },
        updatedAt: {
          allowNull: false,
          type: Sequelize.DATE,
        },
      })
      .then(function () {
        queryInterface.addColumn('users_bootcamp', 'bootcamp_id', {
          type: Sequelize.INTEGER,
          allowNull: true,
          onDelete: 'CASCADE',
          references: { model: 'bootcamp_lists', key: 'id' },
        });
      });
  },

  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('bootcamp_lists');
  },
};

3. create users table

'use strict';

module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface
      .createTable('users', {
        id: {
          allowNull: false,
          autoIncrement: true,
          primaryKey: true,
          type: Sequelize.INTEGER,
        },
        email: {
          type: Sequelize.STRING,
        },
        password: {
          type: Sequelize.STRING,
        },
        githubId: {
          type: Sequelize.STRING,
        },
        kind_login: {
          type: Sequelize.STRING,
        },
        createdAt: {
          allowNull: false,
          type: Sequelize.DATE,
        },
        updatedAt: {
          allowNull: false,
          type: Sequelize.DATE,
        },
      })
      .then(function () {
        queryInterface.addColumn('users_bootcamp', 'users_id', {
          type: Sequelize.INTEGER,
          allowNull: true,
          onDelete: 'CASCADE',
          references: { model: 'users', key: 'id' },
        });
      });
  },

  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('users');
  },
};

4. create users_bootcamp table

'use strict';

module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('users_bootcamp', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER,
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
    });
  },

  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('users_bootcamp');
  },
};

③ Migration 해서 반영시키기

//터미널
npx sequelize-cli db:migrate

6. Seed 반영

① seed 작성

HJ님의 블로그에서 foreign key가 있을 때 seed 작성 방법에 대해 알 수 있었다.

참조하는 데이터 열을 지정해주고, 그 열의 어느 column을 외래키로 넣어줄 것인지를 지정해주는 방식으로 작성해야한다. 

module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.bulkInsert('users', [
      {
        email: 'thdguswn93@naver.com',
        password: '1234',
        githubId: 'hyunju-song',
        active: true,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ]);

    await queryInterface.bulkInsert('bootcamp_lists', [
      {
        name: 'codestates',
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ]);

    const users = await queryInterface.sequelize.query(`SELECT id FROM users;`);
    const bootcamp = await queryInterface.sequelize.query(`SELECT id FROM bootcamp_lists`);
    const usersRows = users[0];
    const bootcampRows = bootcamp[0];

    await queryInterface.bulkInsert('users_bootcamp', [
      {
        users_id: usersRows[0].id,
        bootcamp_id: bootcampRows[0].id,
      },
    ]);

    return await queryInterface.bulkInsert('reviews', [
      {
        users_id: usersRows[0].id,
        bootcamp_id: bootcampRows[0].id,
        githublink: 'https://github.com/codestates/SAFU-server.git',
        price: '비쌈',
        level: '어려움',
        recommend: '추천',
        curriculum: '어려움',
        comment: '자기주도 학습!!!!중심이다',
        active: true,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ]);
  },

  down: async (queryInterface, Sequelize) => {
    await queryInterface.bulkDelete('users', null, {});
    await queryInterface.bulkDelete('bootcamp_lists', null, {});
    await queryInterface.bulkDelete('users_bootcamp', null, {});
    await queryInterface.bulkDelete('reviews', null, {});
  },
};

③ Seed 데이터 반영시키기

sequelize.org/master/manual/migrations.html#running-seeds를 참고해서 다양한 방식으로 할 수 있다. 

그냥 기본적으로 다 하고 싶다면,

//터미널
npx sequelize-cli db:seed:all

 

다음 프로젝트때는 Back(즉, Server와 DB 쪽)을 맡게 될 텐데..

잘 할 수 있었으면 좋겠다. 

 

 

reference

sequelize 공식문서 중 Associations, Migration, Seed 부분

sequelize.org/master/manual/migrations.html#running-seeds

sequelize.org/master/manual/migrations.html

sequelize.org/master/manual/advanced-many-to-many.html

 

Manual | Sequelize

Advanced M:N Associations Make sure you have read the associations guide before reading this guide. Let's start with an example of a Many-to-Many relationship between User and Profile. const User = sequelize.define('user', { username: DataTypes.STRING, poi

sequelize.org

velog.io/@cadenzah/sequelize-document-4

 

Sequelize 공식 Document - (4) Associations (상)

해석과 설명을 곁들인 Sequelize 도큐먼트 정복, 그 4편

velog.io

velog.io/@hyunju-song/sequelize%EB%A1%9C-DB%EC%85%8B%ED%8C%85%ED%95%A0-%EB%95%8C-%ED%99%98%EA%B2%BD%EB%B3%80%EC%88%98-%ED%8C%8C%EC%9D%BC-%EC%84%A4%EC%A0%95-%EB%B0%8F-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

 

sequelize로 DB셋팅할 때, 환경변수 파일 설정 및 사용하기

sequelize 뿐 아니라, 데이터베이스를 관리하거나 기타 시스템을 구축할 때, 패스워드와 같이 유출되어서는 안되는 정보를 다루어야 하는 경우가 있다.

velog.io

velog.io/@hyunju-song/sequelize%EC%97%90%EC%84%9C-foreign-key-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0

 

sequelize에서 foreign key 설정하기

DB구성 시에, 특히 sequelize로 작업할 때, 외래키(foreign key)를 어떻게 설정하는 지에 대해서 정리해 보고자 한다.

velog.io