NodeJS Express Typescript로 Sequelize환경구축

임재현·2021년 2월 27일
12

laggardProject

목록 보기
2/3

우선 가장 먼저 주님 감사드립니다!

이번 프로젝트를 진행하는데 있어서 ORM으로 Sequelize를 쓰기로 했다. Sequelize는 사용해봤지만, 타입스크립트를 적용해 사용해 보는 것은 이번이 처음이다.

참고한 자료

위 참고자료들 중 가장 도움이 되었던 것은 공식사이트 문서이다. 공식자료를 잘 읽어보도록 하자.

목표 : 최대한 기존 JS에서 사용하던 Sequelize와 비슷하게 사용할 수 있도록 하자.

개인적으로 기존에 JS로 Sequelize를 사용할 때는 Sequelize-CLI를 사용해 간편학게 파일구조, 기본틀을 만들고(npx sequelize-cli init을 통해), 간단하게 디비를 만든다거나 (npx sequelize-cli db:create)간단하게 마이그레이션(npx sequelize-cli db:migrate)을 할 수 있었다. 하지만 시퀄라이즈가 원래 자바스크립트 기반이기 때문에 위의 명령어들을 입력하면 자바스크립트 파일들이 생성되었다. 그래서 직접 환경을 구축해 보기로 하였다. 목표는 다른 팀원들도 JS Sequelize에 익숙하기 때문에 기존에 사용하던 방식과 최대한 비슷하게 만들어 작업하는데 큰 차질이 없도록 하기이다. 여기서 미리 말해둘 것은 seed(테스트 데이터 등을 대량으로 넣고 빼고 할 수 있게)는 만들지 않았다.

!Note
Sequelize는 런타임 속성 할당에 크게 의존하므로 TypeScript는 즉시 사용할 수 없습니다. 모델을 실행 가능하게 만들려면 상당한 양의 수동 유형 선언이 필요합니다.

As Sequelize heavily relies on runtime property assignments, TypeScript won't be very useful out of the box. A decent amount of manual type declarations are needed to make models workable.

-시퀄라이즈 공식 사이트

1. 기본 설정

먼저 npm install로 express, cors, dotenv등의 자신의 서버에서 사용할 기본 express모듈들을 설치해준다. 타입스크립트를 적용해주기 위해 devDepndencies에 npm install --save-dev로 @types/express, @types/cors등도 설치해준다. 이부분은 자신의 서버 설정에 따른다.
나는 npm install --save express cors dotenv typescript를 해줬고
npm install --save-dev @types/express @types/cors @types/node nodemon ts-node등을 먼저 설치해 주었다. ts-node는 타입스크립트 실행을 위한 모듈이다.
그리고 난 후, sequelize를 사용하기 위한 모듈들을 설치해준다.
npm install --save mysql2 reflect-metadata sequelize sequelize-cli
npm install --save-dev @types/validator를 설치해 주었다.
v5이후부터, 시퀄라이즈는 타입스크립트 정의를 제공한다. 단 타입스크립트 3.1이상 버전부터 적용된다. 따라서, 별도의 @types/sequelize이런거는 설치 안해줘도 된다.

2. 기본 파일 구조 설정

sequelize-cli명령어로 npx sequelize-cli init 을 하게되면 config, models, migration,seeders디렉토리가 생성된다. 그리고 config디렉토리안에는 config.js파일이 생기고, 그 안에 DB접속 정보 등을 담게 된다. models디렉토리 안에는 index.js파일이생기고 이 파일안에서 DB와 연결되는 Sequelize객체를 만들어 사용하게 된다.

본 프로젝트에서는 seeders는 빼고 만들도록 하겠다.
먼저 config 디렉토리를 만들어 준다. 그리고 config.ts파일을 만들어 주고 다음과 같이 입력해주었다.

import * as dotenv from 'dotenv';
dotenv.config();

export const config = {
    development : {
        username : process.env.DB_USERNAME || 'root',
        password : process.env.DB_PASSWORD,
        database : process.env.DB_DBNAME || 'typescript_test',
        host : process.env.DB_HOST || 'localhost',
        port : process.env.DB_PORT || 3306,
        dialect : "mysql"
    }
}

dotenv를 이용해 .env파일에 유저네임, 비밀번호등을 설정해 주었다. 단, 나중에 이것들을 쓸 때 타입스크립트에서는 변수의 타입등을 명확하게 설정해 주어야 하는데, 새로운 Sequelize객체를 만들 때 정확한 타입을 요구하기 때문에 (만약에 .env에 설정한 process.env.DB_USERNAME를 읽을 수 있다면 타입이 스트링이지만 없거나 dotenv를 읽지 못한다면 변수 타입이 undefined가 된다.) 위처럼 설정해 주었다.

다음으로, model디렉토리를 만들어 주고 index.ts파일을 만들어 준다. model디렉토리에는 앞으로 만들 모델파일들을 넣을 것이다. 그리고 index.ts에는 DB와 연결할 Sequelize객체를 만들어 준다. 다음과 같이 입력해준다.

import {Sequelize} from 'sequelize';
import {config} from '../config/config'

// export const sequelize = new Sequelize('typescript_test', 'root','Jaehyeon2!',{
//     host : 'localhost',
//     dialect : 'mysql',
// })

export const sequelize = new Sequelize(
    config.development.database,
    config.development.username,
    config.development.password,
    {
        host: config.development.host,
        dialect: 'mysql'
    }
)

sequelize객체를 만들어 주었다!

migration은 우선 디렉토리만 만들어 두기만 한다.
지금까지의 구조

-config
  L config.ts
-model
  L index.ts
-migration
index.ts

2. 연결확인

위에서 설정한 시퀄라이즈 객체가 DB와 잘 연결되었는지 확인해보자.
index.ts에 작성해주도록 한다.

/**
 * Required External Modules
 */
import * as dotenv from "dotenv";
import express,{Request, Response, NextFunction} from "express";
import cors from "cors";
import { userRouter } from "./routes/userRouter";
import { songRouter } from "./routes/songRouter";
import { scoreRouter } from "./routes/scoreRouter";
import { sequelize } from "./model";	//방금 만든 sequelize객체를 import해준다.(index.ts에 만들었으므로 폴더명만 입력하면 먼저 자동으로 index.ts를 찾아 그 안에 있는거 import)

dotenv.config();
/**
 * App Variables
 */
const PORT:number = parseInt(process.env.PORT as string, 10) || 5000;
const HOST:string = process.env.HOST || 'localhost';
const app = express();

/**
 *  App Configuration   //middleware
 */
app.use(cors());
app.use(express.json());
app.use((req:Request,res:Response,next:NextFunction) => {
    console.log(`Request Occur! ${req.method}, ${req.url}`);
    next();
})
// 라우터 설정
// app.use('/')
app.use('/users',userRouter);
app.use('/songs',songRouter);
app.use('/score',scoreRouter);

/**
 * Server Activation
 */
app.listen(PORT,HOST,async () => {
    console.log(`Server Listening on ${HOST}:${PORT}`);

    // //sequelize-db 연결 테스트
     await sequelize.authenticate()
     .then(async () => {
         console.log("connection success");
     })
     .catch((e) => {
         console.log('TT : ', e);
     })
})

앱을 실행시켰을 때 시퀄라이즈와 디비가 제대로 연결이 되어있다면 connection success메세지가 뜰 것이다.

3.모델 설정

모델을 만들어 준다.
시퀄라이즈 연결을 위해 방금 위에서 만든 시퀄라이즈 객체를 임포트해와야 한다.
유저모델을 만들어 주겠다.

import {
    Sequelize, 
    DataTypes, 
    Model, 
    Optional,
    HasManyGetAssociationsMixin,
    HasManyAddAssociationMixin,
    HasManyHasAssociationMixin,
    HasManyCountAssociationsMixin,
    HasManyCreateAssociationMixin,
    Association
} from 'sequelize';
import {sequelize} from './index';		//방금 만들어주었던 sequelize객체 임포트
import { Scores } from './scores';		

// These are all the attributes in the User model
interface UsersAttributes {
    // id: number | null;
    email : string,
    password : string | null,
    nickname : string,
    age : number,
    sex : boolean
}


export class Users extends Model<UsersAttributes>{
    public readonly id! : number;   //굳이 안넣어줘도 될 것 같지만 공식문서에 있으니깐 일단 넣어줌.
    public email! : string;
    public password! : string;
    public nickname! : string;
    public age! : number;
    public sex! : boolean;

    // timestamps!
    public readonly createdAt!: Date;   //굳이 안넣어줘도 될 것 같지만 공식문서에 있으니깐 일단 넣어줌.
    public readonly updatedAt!: Date;   //굳이 안넣어줘도 될 것 같지만 공식문서에 있으니깐 일단 넣어줌.

//여기는 안넣어줘도 일단 오류는 나지 않는다. 더 알아보고 나중에 업데이트 하겠다. 혹시 모르니깐-----
    // // Since TS cannot determine model association at compile time
    // // we have to declare them here purely virtually
    // // these will not exist until `Model.init` was called.
    public getScores!: HasManyGetAssociationsMixin<Scores>; // Note the null assertions!
    public addScores!: HasManyAddAssociationMixin<Scores, number>;
    public hasScores!: HasManyHasAssociationMixin<Scores, number>;
    public countScores!: HasManyCountAssociationsMixin;
    public createScores!: HasManyCreateAssociationMixin<Scores>;

    // // You can also pre-declare possible inclusions, these will only be populated if you
    // // actively include a relation.
    // public readonly projects?: Project[]; // Note this is optional since it's only populated when explicitly requested in code

    public static associations: {
        userHasManyScores: Association<Users, Scores>;
    };
}
//----------------------------
Users.init(
    {
        email : {
            type : DataTypes.STRING(45),
            allowNull: false
        },
        password : {
            type : DataTypes.STRING(45),
            allowNull : true
        },
        nickname : {
            type : DataTypes.STRING(45),
            allowNull : false
        },
        age : {
            type : DataTypes.INTEGER,
            allowNull : false
        },
        sex : {
            type : DataTypes.BOOLEAN,
            allowNull : false
        }
    },
    {
        modelName : 'Users',
        tableName : 'Users',
        sequelize,
        freezeTableName : true,
        timestamps : true,
        updatedAt : 'updateTimestamp'
    }
)

Users.hasMany(Scores, {
    sourceKey : "id",
    foreignKey : "user_id",
    as : 'userHasManyScores'
});

이제 모델이 만들어졌다.

4.마이그레이션

이제 디비에 테이블을 자동으로 생성하는 마이그레이션을 해주겠다.
원래는 JS에서 sequelize-cli를 해줬을 때 처럼 up, down으로 do, undo형식으로 만들 예정이었다. 그런데 queryInterface.createTable방식으로 테이블을 만들 시 유저의 속성을 정의하는 (ex.allowNull : false등)부분을 ModelAttributes로 정의해 넘겨주는 방식으로 만들었는데, 이부분이 자바스크립트에서 객체를 넘겨줄 때 처럼[Object object]이렇게 넘어가 오류가 발생하였다. 이 부분은 나중에 방법을 찾는다면 업데이트 하도록 하겠다.

그래서 찾은 다음 방법은 Sequelize.sync()라는 메서드를 이용하는 것이다. 이 메서드는 모델에 정의한 테이블을 데이터베이스에 똑같이 정의하도록 해준다.
migration데이터베이스에 create-table이라는 디렉토리를 만들어 주고 그 밑에 1.create-table-users.ts 라는 파일을 만들어 주겠다. 그리고 다음과 같이 입력해줬다.

import {Users} from '../../model/users';	//방금 만들어준 user model

console.log("======Create User Table======");

const create_table_users = async() => {
    await Users.sync({force : true})
    .then(() => {
        console.log("✅Success Create User Table");
    })
    .catch((err) => {
        console.log("❗️Error in Create User Table : ", err);
    })
}

create_table_users();

사실 여기서 굳이 펑션에 안담아줘도 되고 await을 안써줘도 된다. 나는 그냥 써줬다.
이제 터미널에 ./node_modules/.bin/ts-node ./src/migration/create-table/1.create-table-users.ts명령을 실행하고 ✅Success Create User Table메세지가 나온다면 디비에 테이블이 만들어져 있을 것이다!

하나님 감사합니다!

5. 명령어로 실행할 수 있게 만들기

이제 자동으로 마이그레이션 명령어들을 실행할 수 있게 만들겠다. npm script에 명령어들을 추가해서 자동으로 마이그레이션 될 수 있게 만들겠다.

5-1.디비 만드는 명령어

그런데 먼저, 지금까지는 테이블이 하나만 있어서 지우고 다시 만드는데 별 제약이 없지만 테이블 개수가 늘어서 서로간에 외래키등으로 연결되어 있다면 테이블을 지우고 다시만들고 이러는게 번거로워진다. 그래서 일단은 테이블 수정사항이 생기면 디비를 드랍하는 방식으로 하기로 하겠다. 그래서 우선은 npx sequelize-cli db:create처럼 디비를 새로 만드는 기능을 만들어보겠다.
migration디렉토리에 create-db.ts파일을 추가해주고 다음과 같이 입력해주었다.

import { QueryInterface, Sequelize, Options } from "sequelize";
import * as dotenv from 'dotenv';
dotenv.config();

class options implements Options{
   dialect!: 'mysql';
   username!: string;
   password!: string;
}        

const createDBOptions = new options();
createDBOptions.username = process.env.DB_USERNAME || 'root';
createDBOptions.password = process.env.DB_PASSWORD || 'your password';
createDBOptions.dialect = 'mysql';

let db_name = process.env.DB_DBNAME || 'new DataBase';

const dbCreateSequelize = new Sequelize(createDBOptions);

console.log(`======Create DataBase : ${db_name}======`);

dbCreateSequelize.getQueryInterface().createDatabase(db_name)
.then(() => {
   console.log("✅db create success!");
})
.catch((e) => {
   console.log("❗️error in create db : ", e);
})

Sequelize생성자에 데이터베이스 이름을 받지 않고 옵션만 받는 것도 있기 때문에 옵션을 설정해(디비종류, 유저이름, 비밀번호만 설정해서) 새로운 시퀄라이즈 객체를 만들어준다. 만들어진 객체에서 queryInterface를 get하고, 그걸 통해서 새로운 데이터 베이스를 만들어준다. 새로운 데이터 베이스의 이름은 .env의 DB_NAME을 통해 설정해 주었다.
이제 터미널에 ./node_modules/.bin/ts-node ./src/migration/create-db.ts명령어를 실행해 준다.
✅db create success!메세지가 나왔다면 성공적으로 디비가 만들어졌을 것이다.
이제 package.json의 scripts에 명령어를 등록해주도록 하겠다. 나는 "create_db"라는 이름으로 명령어를 등록해줬다. scripts의 안에"create_db" : "ts-node ./src/migration/create-db.ts"이렇게 명령어를 등록해줬다. 그리고 터미널에서 npm run create_db명령어를 입력해주면 ✅db create success!메세지가 나오면서 디비가 만들어 진 것을 확인할 수 있다.

5-2.여러 모델 한번에 마이그레이션하기

위에서 user 모델을 마이그레이션 해줬었다. 하지만 보통 모델이 하나만 있는 경우는 없다. 그래서 npx sequelize-cli db:migrate명령어처럼 여러 모델들을 한번에 마이그레이션 해주는 기능을 만들었다. 먼저 model 디렉토리에 필요한 모델들을 만들어준다. 그 후에, 4번에서 1.create-table-users.ts라는 마이그레이션 파일을 만들어 줬던 것처럼 마이그레이션 파일들을 만들어준다. 그런데 여기서 주의해야 될 점이 테이블을 만드는 순서를 정해줘야 한다는 것이다. 테이블들 간의 관계 때문이다. 예를들어 유저 테이블과 songs테이블의 아이디를 외래키로 가지고 있는 users_and_songs라는 테이블이 있는데 유저테이블과 songs테이블이 없는데 먼저 users_and_songs라는 테이블을 만들려고 하면 오류가 발생하기 때문이다. 따라서 순서를 정해서 파일들을 만들어 준다.

각각 파일 하나씩 실행하려면 4번에서 했던 것 처럼 ./node_modules/.bin/ts-node ./src/migration/create-table/1.create-table-users.ts 이렇게 해주면 된다. 물론 제약조건에 걸리지 않을 때. 그런데 나는 이 파일들을 한번에 마이그레이션 해주고 싶었다.
그런데 package.json에 있는 scripts명령어로는 반복문을 돌리면서 저 파일들을 실행해 줄 수 없다. 그래서 저 파일들을 순서대로 실행해줄 방법이 필요했다. 그래서 검색 끝에 Node.js에 있는 Child processes를 찾았다. child process는 새로운 프로세스를 생성하여 실행하는 것이다. node js에서는 child_process라는 방법이 있다. 여러가지 방법이 있겠지만 나는 exec와 execFile메서드에 주목했다.
migration디렉토리 밑에 migration-all-table.ts라는 파일을 만들어 줬다. 그리고 다음과 같이 입력해 주었다.

import * as fs from 'fs';
import * as path from 'path'
import {exec, execFile} from 'child_process';


console.log("migration-all-table");

    //**************** */ exec로 실행
    //버전 1(상대경로)
    exec('./node_modules/.bin/ts-node "./src/migration/create-db.ts"', (error, stdout, stderr) => {
         if(error){
             console.log(`exec drror : ${error}`);
             return;
         }
         if(stdout) console.log(`${stdout}`);
         if(stderr) console.log(`err : ${stderr}`);
     });
    //버전 2(절대경로)
     exec(`./node_modules/.bin/ts-node "${__dirname}/create-db.ts"`, (error, stdout, stderr) => {
             if(error){
                 console.log(`exec drror : ${error}`);
                 return;
             }
             if(stdout) console.log(`${stdout}`);
             if(stderr) console.log(`err : ${stderr}`);
         });
    //******************* */

    ///******* */ execFile로 실행
    //버전 1 (상대경로)
     execFile('./node_modules/.bin/ts-node',['./src/migration/create-db.ts'], (error, stdout, stderr) => {
         if(error){
             console.log(`exec error : ${error}`);
             return;
         }
         if(stdout) console.log(`${stdout}`);
         if(stderr) console.log(`err : ${stderr}`);
     })

    //버전 2 (절대경로)
     execFile('./node_modules/.bin/ts-node',[`${__dirname}/create-db.ts`], (error, stdout, stderr) => {
         if(error){
             console.log(`exec error : ${error}`);
             return;
        }
        if(stdout) console.log(`${stdout}`);
         if(stderr) console.log(`err : ${stderr}`);
     })
    ///******************** */

위의 명령어들 모두 방금 만들어 줬던 create-db파일을 잘 실행시키고 있음을 알 수 있다.
그럼 이제 migration.create-table디렉토리에 있는 파일들을 전부 순차적으로 실행시켜주면 된다.
먼저 fs.readdir()을 통해 파일들이 잘 들어갔나 확인해준다.

import * as fs from 'fs';
import * as path from 'path'
import {exec, execFile} from 'child_process';

console.log("migration-all-table");


console.log(`
    --------------------------------------
    +++Laggard Project Migration Start+++
    --------------------------------------
`);


let migrationAllTable = async () => {
    let migrationFiles : string[] = []

    fs.readdir(path.join(__dirname,"/","create-table"),async (err,files) => {
        if(err) console.log("err : ", err);
        if(files) {
            // console.log("files : ", files);
            files.forEach(el=> {
                // console.log(el.substr(el.indexOf('.')+1,12));
                if(el.substr(el.indexOf('.')+1,12) === 'create-table'){
                    migrationFiles.push(el);
                }
            })
           
            console.log(migrationFiles)
    })
}
migrationAllTable()

터미널에 ./node_modules/.bin/ts-node ./src/migration/migration-all-table.ts명령어를 입력하면 콘솔이 찍히는데, 순서가 [1,10,11,2,3,4,...9]이렇게 찍히게 된다. 아스키코드 때문에 그런것이다. 순서대로 나오도록 정렬해준다. 참조 : MDN, [Javascript] 배열 정렬하기 (오름차순, 내림차순, 문자열, 객체)

//! 파일들 이름을 1.어쩌고 2.저쩌고 이런식으로 해주었기 때문에 첫번째 '.'이 나오기 전까지를 숫자로 보면 된다.서브스트링으로 분리해줬다.
migrationFiles.sort((a,b) => {
                return Number(a.substr(0,a.indexOf('.'))) - Number(b.substr(0,b.indexOf('.')))
            });
console.log("migrationFiles : ", migrationFiles);

이제 순서대로 잘 나오게 된다.
이제 forEach를 이용해서 exec로 각각 돌리면 될 것 같지만 forEach를 이용하면 안된다. 순서대로 파일들을 실행해야 하는데 exec가 비동기로 실행되기 때문이다. 참조자료 : Loop와 함께 async, await 사용하기
그래서 for문을 써줘야 하고, exec를 동기적으로 사용해주기 위해 util.promisify()를 사용해 준다. 매우 유용한 함수이다. 사용법을 잘 익혀야 겠다. Node.JS공식 홈페이지에도 나와있다. 최종 코드는 이렇게 된다.

import * as fs from 'fs';
import * as path from 'path'
import {exec, execFile} from 'child_process';
import * as util from 'util';
console.log("migration-all-table");


const asyncExec = util.promisify(exec)      //!!!!!중요!!!

console.log(`
    --------------------------------------
    +++Laggard Project Migration Start+++
    --------------------------------------
`);


let migrationAllTable = async () => {
    let migrationFiles : string[] = []

    fs.readdir(path.join(__dirname,"/","create-table"),async (err,files) => {
        if(err) console.log("err : ", err);
        if(files) {
            files.forEach(el=> {
                // console.log(el.substr(el.indexOf('.')+1,12));
                if(el.substr(el.indexOf('.')+1,12) === 'create-table'){
                    migrationFiles.push(el);
                }
            })
            
            migrationFiles.sort((a,b) => {
                return Number(a.substr(0,a.indexOf('.'))) - Number(b.substr(0,b.indexOf('.')))
            });
            console.log("migrationFiles : ", migrationFiles);
            
            for(let el of migrationFiles){
                console.log("Migration File Name : ", el);
                
                const { stdout, stderr } = await asyncExec(`./node_modules/.bin/ts-node "${__dirname}/create-table/${el}"`)
                if(stdout) console.log(stdout);
                if(stderr) console.error("Std Err : ",stderr);
            }
        }
    })

    
}



migrationAllTable()

이제 터미널에 ./node_modules/.bin/ts-node ./src/migration/migration-all-table.ts 명령어를 입력해보면 !!

via GIPHY

이렇게 순서대로 마이그레이션 되는 것을 볼 수 있다. 마지막으로 package.json에 scripts를 등록해준다. "migration_all" : "ts-node ./src/migration/migration-all-table.ts" 이제 터미널에 npm run migration_all 명령어를 입력하면 자동으로 마이그레이션 되는 것을 볼 수 있다.

❗️주의사항 : .env파일을 루트 디렉토리에 제대로 만들었는지, .env파일안에 변수들이 잘 들어가 있는지, 마이그레이션 하기전에 데이터베이스를 만들었는지

migration:undo:all은 마이그레이션을 한 순서의 역순으로 drop table 되게 만들면 된다.
나는 일단은 그냥 drop table하고 새로 create db 하는 걸로 했다.

이 과정을 하면서 정말로 나 혼자서는 못했다. 주님께서 도와주셔서 할 수 있음을 느꼈다.

주님 정말로 감사합니다!

profile
임재현입니다.

2개의 댓글

comment-user-thumbnail
2021년 4월 6일

아멘...

답글 달기
comment-user-thumbnail
2023년 7월 21일

할렐루야..

답글 달기