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 모델 생성
우리 프로젝트의 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
velog.io/@cadenzah/sequelize-document-4
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
'Project > [SAFU] 1st Project' 카테고리의 다른 글
6. [Client & Server] Findid, Findpw 구현 (Oct 24, 2020 ~ Oct 25, 2020 회고) (0) | 2020.11.05 |
---|---|
5. [Client & Server] Login, Logout 구현 (Oct 21, 2020 ~ Oct 25, 2020 회고) (0) | 2020.11.05 |
4. [Client & Server] Signup 구현 (Oct 17, 2020 ~ Oct 20, 2020 회고) (0) | 2020.11.05 |
2. [Basic] 프로젝트 준비 - 프로젝트 협업을 위한 Gitflow (0) | 2020.10.18 |
1. [Basic] 프로젝트 Intro, 첫 번째 미팅 (0) | 2020.10.18 |