Carryduo | TypeORM을 이용한 DB(MySQL) 관리(typescript & javascript)

유현준·2022년 11월 9일
0

추신(javascript 버전)

  • 프로젝트를 진행하면서, 서비스 관련 프로젝트는 nestJS(typescript), 데이터 분석 관련 프로젝트는 javascript를 이용해 작업했다.
  • 그 과정에서 typeORM을 typescript 버전, javascript 버전 모두를 경험할 수 있었다. 해당 포스트에서 두 언어 간 typeORM의 미묘한 문법 차이를 최대한 담아보려고 한다.

1. 개요

  • TypeORM은 node.js 환경에서 가장 대중적으로 활용되는 ORM이다.

    ORM이란 Object Relational Mapping을 의미한다. 객체와 관계형 데이터베이스의 데이터를 연결하는 것으로, ORM을 통해 프로그래밍 과정에서 RDBMS를 더욱 편리하게 조작, 관리할 수 있다.

  • nestJS는 이에, 공식문서 상에서도 RDBMS를 사용할 시, TypeORM을 활용할 것을 권장하고 있다. 이에, nestJS에 기반한 Carryduo 프로젝트를 진행하면서도 TypeORM을 이용하였다.

2. DB 연결 및 테이블 생성

1) DB 연결

typescript version

  • nest 환경에서 typeORM을 활용하여 DB를 연결하는 것은 다음과 같이 연결할 DB를 명세하고, 이를 app module에 주입해주는 방식을 통해 수행할 수 있다.
  • env를 활용하여 DB 정보를 주입하려고 한다면, 다음처럼 configService를 주입할 수도 있다.
const typeOrmModuleOptions = {
  useFactory: async (
    configService: ConfigService,
  ): Promise<TypeOrmModuleOptions> => ({
    namingStrategy: new SnakeNamingStrategy(),
    type: 'mysql',
    host: configService.get('DB_HOST'), // process.env.DB_HOST
    port: configService.get('DB_PORT'),
    username: configService.get('DB_USERNAME'),
    password: configService.get('DB_PASSWORD'),
    database: configService.get('DB_NAME'),
    entities: [생성한 entity 주입],
    synchronize: false, // sync 여부 (sync 시, 소스코드 상 DB 구조 관련 내용이 RDBMS에 반영된다)
    autoLoadEntities: true,
    logging: false, // logging 여부 (logging 시, IDE에서 TypeORM이 번역한 SQL문을 로그로 확인할 수 있다.)
    keepConnectionAlive: true,
    timezone: 'local',
    charset: 'utf8mb4',
  }),
  inject: [ConfigService],
};

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync(typeOrmModuleOptions),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

javascript version

  • javascript 상에서는 DB 정보를 입력하고, 서버 실행 시, initialize()라는 메소드를 실행시켜 DB를 연결할 수 있다.
orm.js
require("dotenv").config()
const typeorm = require("typeorm")

const dataSource = new typeorm.DataSource({
    type: "mysql",
    host: process.env.DB_HOST,
    port: process.env.DB_PORT,
    username: process.env.USER_NAME,
    password: process.env.PASSWORD,
    database: process.env.DATABASE,
    synchronize: false,
    logging: false,
    entities: [
        require("./entity/simulation.data"),
    ],
    migrations: ["migrations/*.js"],
    migrationsDir: ["migrations"],
    migrationsTableName: "migration",
})

// const puuidController = require("./data/puuId/puuId.controller")

module.exports = {
    async connect() {
        await dataSource
            .initialize()
            .then(function () {
                console.log("분석용 연결 완료")
            })
            .catch(function (error) {
                console.log("Error: ", error)
            })
    },
    async close() {
        await dataSource.destroy().then(() => {
            console.log("분석용 연결 해제")
        })
    },
    dataSource,
}
app.js

require("dotenv").config()
const db = require("./orm")

//데이터베이스 연결
db.connect()

2) entity 생성

typescript

  • typescript는 객체와 데코레이터 패턴을 활용하여 entity를 생성한다.
  • Carryduo 서비스 프로젝트에서는 모든 entity에 공통으로 선언되는 column을 commonEntity를 선언하고 기능별 entity는 commonEntity를 상속받아 선언하였다.
commonEntity
import {
  CreateDateColumn,
  DeleteDateColumn,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { IsString, IsUUID } from 'class-validator';
import { Exclude } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';

export class CommonEntity {
  @ApiProperty()
  @IsUUID()
  @IsString()
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @ApiProperty()
  @CreateDateColumn({
    type: 'timestamp' /* timestamp with time zone */,
  })
  createdAt: Date;

  @ApiProperty()
  @UpdateDateColumn({ type: 'timestamp' })
  updatedAt: Date;

  @ApiProperty()
  @Exclude()
  @DeleteDateColumn({ type: 'timestamp' })
  deletedAt?: Date | null;
}
------------------------------
userEntity
import { OmitType } from '@nestjs/swagger';
import { IsString, IsUUID } from 'class-validator';
import {
  Column,
  Entity,
  JoinColumn,
  ManyToOne,
  OneToMany,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity({
  name: 'USER',
})
export class UserEntity extends OmitType(CommonEntity, ['id']) {
  @IsUUID()
  @IsString()
  @PrimaryGeneratedColumn('uuid')
  userId: string;

  @Column({ type: 'varchar', nullable: false })
  nickname: string;

  @Column({ type: 'varchar', nullable: true })
  bio: string;
}

javascript

  • 데코레이터를 이용하지 않는 환경을 기준으로, javascript에서는 다음과 같이 entity를 생성할 수 있다.
  • 데코레이터를 활용하지 않아, 데이터가 생성된 시간을 삽입해야하는 createdAt, updatedAt의 경우, () => {return NOW()}라는 구조로 SQL문을 직접적으로 적용해주었다.
var EntitySchema = require("typeorm").EntitySchema

module.exports = new EntitySchema({
    name: "combination", 
    tableName: "combination",
    columns: {
        id: {
            type: 'varchar',
            primary: true,
            generated: 'uuid',
        },
        createdAt: {
            type: 'timestamp',
            require: true,
            default: () => { return `NOW()` }
        },
        updatedAt: {
            type: 'timestamp',
            require: true,
            default: () => { return `NOW()` }
        },
        mainChampName: {
            type: "varchar",
            require: true
        },
        subChampName: {
            type: "varchar",
            require: true
        },
        sampleNum: {
            type: 'int',
            require: true,
            default: 0
        },
    },
})

3. 관계 설정

typescript

  • userEntity와 commentEntity를 1대다 관계로 연결한다고 했을 때, 다음과 같이 작성하면 된다.
  • 요는 한 테이블에는 OneToMany, 다른 테이블에는 ManyToOne으로 관계를 선언해주는 것이다.
  • entity에서 다른 테이블과 관계설정 시 특히 유의해야할 점은 eager/lazy loading 설정 유무이다.
    | eager, lazy loading을 설정하지 않을 경우, typeORM 기본 메소드로 select 실행 시, 관계 설정된 테이블의 데이터가 조회되지 않는 것으로 경험되었다.
    | eager loading: A,B 테이블이 관계 설정되어있다고 했을 때, A테이블 조회 시, 무조건적으로 B테이블의 데이터도 모두 조회
    | lazy loading: A,B 테이블이 관계 설정되어있다고 했을 때, B테이블을 조회하려면 A테이블 조회 이후, B테이블과 연결된 foreignKey로 한번 더 조회해야만 조회 가능.
userEntity

import { OmitType } from '@nestjs/swagger';
import { IsString, IsUUID } from 'class-validator';
import {
  Column,
  Entity,
  JoinColumn,
  ManyToOne,
  OneToMany,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity({
  name: 'USER',
})
export class UserEntity extends OmitType(CommonEntity, ['id']) {
  @IsUUID()
  @IsString()
  @PrimaryGeneratedColumn('uuid')
  userId: string;

  @Column({ type: 'varchar', nullable: false })
  nickname: string;

  @Column({ type: 'varchar', nullable: true })
  bio: string;

  @OneToMany(
    () => CommentEntity,
    (commentEntity: CommentEntity) => commentEntity.userId,
    {
      cascade: true, // cascade 설정, 설정 시 user 데이터의 변동 사항이 commentEntity의 연결된 데이터에도 적용된다.
    },
  )
  comment: CommentEntity;
}

--------------------
commentEntity
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { UserEntity } from 'src/user/entities/user.entity';

@Entity({
  name: 'COMMENT',
})
export class CommentEntity extends CommonEntity {
  @Column({ type: 'varchar', nullable: false })
  category: string;

  @Column({ type: 'varchar', nullable: false })
  content: string;

  @Column({ type: 'int', nullable: false, default: 0 })
  reportNum: number;

  @ManyToOne(() => UserEntity, (user: UserEntity) => user.userId, {
    onDelete: 'CASCADE',
    onUpdate: 'CASCADE',
    eager: true, // eager loading 설정, 설정 시에 comment table select를 하면 연결된 user table 데이터가 모두 조회된다.
  })
  @JoinColumn([
    {
      name: 'userId', // commentEntity의 foreinKey
      referencedColumnName: 'userId', // 부모 entity인 userEntity에서 참조할 column
    },
  ])
  userId: UserEntity;
}

javascript

  • champEntity와 combinationStatEntity를 1대다 관계로 연결한다고 했을 때, 다음과 같이 작성할 수 있다. (typescript에서 관계설정을 하는 두 entity에 작성해주는 내용과 동일한 구조이다.)
  • champEntity의 champId가 combinationStatEntity의 mainChampId, subChampId와 연결하는 것이다.
  • 부모가 되는 champEntity에서는 combinationStatEntity와 1대다 관계라는 것만 명시한다.
  • 자식이 되는 combinationStatEntity에 부모인 champEntity에서 column과 해당 column을 combinationStatEntity에서 어떤 column으로 지칭할 것인지를 명시한다.

champEntity
var EntitySchema = require("typeorm").EntitySchema

module.exports = new EntitySchema({
    name: "CHAMP", // Will use table name `category` as default behaviour.
    tableName: "CHAMP", // Optional: Provide `tableName` property to override the default behaviour for table name.
    columns: {
        champId: {
            type: "varchar",
            primary: true,
        },
    },
    relations: {
        combination: {
            target: "COMBINATION_STAT",
            type: "one-to-many",
            cascade: true,
            eager: true,
        },
    }
}
)


combinationStatEntity

var EntitySchema = require("typeorm").EntitySchema

module.exports = new EntitySchema({
    name: "COMBINATION_STAT", // Will use table name `category` as default behaviour.
    tableName: "COMBINATION_STAT", // Optional: Provide `tableName` property to override the default behaviour for table name.
    columns: {
        id: {
            type: "varchar",
            primary: true,
            generated: "uuid",
        },
        created_at: {
            type: "timestamp",
            require: true,
            default: () => {
                return `NOW()`
            },
        },
        updated_at: {
            type: "timestamp",
            require: true,
            default: () => {
                return `NOW()`
            },
        },
        deleted_at: {
            type: "timestamp",
            require: false,
            default: null,
        },
        mainChampId: {
            type: "varchar",
            require: true,
        },
        subChampId: {
            type: "varchar",
            require: true,
        },
        version: {
            type: 'varchar',
            require: true
        }
    },
    relations: {
        mainChampId: {
            target: 'CHAMP',
            type: 'many-to-one',
            joinColumn: {
                name: 'mainChampId', // combinatioNStatEntity의 foreignKey column
                referencedColumnName: 'champId' // 부모 entity인 champEntity에서 참조할 column
            },
        },
        subChampId: {
            target: 'CHAMP',
            type: 'many-to-one',
            joinColumn: {
                name: 'subChampId',
                referencedColumnName: 'champId'
            },
        }
    }
})

4. 기본적인 CRUD

  • typeORM에서 CRUD는 typeORM에서 이를 위해 제공하는 기본 메소드를 이용하는 방식과 createQueryBuilder를 이용하는 방식이 있다.
  • 구체적인 문법들은 아래 참고자료의 typeORM 공식문서를 확인하면 될 듯하고, 해당 포스트에서는 기본적인 CRUD 문법만 소개하겠다.
  • CRUD 문법은 typescript, javascript 간 차이는 없다.
  • 필자는 createQueryBuilder를 활용했는데, 그 이유는 기본 메소드보다 더 적은 종류의 메소드로 더 구체적이고 꼼꼼하게 SQL을 다룰 수 있다고 느꼈기 때문이다.
    | 기본 메소드는 내가 의도한 쿼리를 작성하기 위해 추가적인 메소드나 property를 찾아야하는 반면, createQueryBuilder는 동일한 메소드 안에서 좀 더 직관적으로 SQL을 다룰 수 있다고 느꼈다.

1) 기본 메소드를 이용한 CRUD

**SELECT**
userRepository.find({
    select: {
        firstName: true,
        lastName: true,
    },
  where: {
    firstName: 'example'
  }
})
= select firstName, lastName from user where firstName = 'example'

userRepository.find({
    select: {
        firstName: true,
        lastName: true,
    },
  where: {
    firstName: 'example'
  }
})
= select firstName, lastName from user where firstName = 'example'


**관계가 설정된 TABLE까지 SELECT**
userRepository.find({
    relations: {
        profile: true,
        photos: true,
        videos: true,
    },
})

= SELECT * FROM "user"
LEFT JOIN "profile" ON "profile"."id" = "user"."profileId"
LEFT JOIN "photos" ON "photos"."id" = "user"."photoId"
LEFT JOIN "videos" ON "videos"."id" = "user"."videoId"

** 이 때, profile, photos, video가 각 entity에서 loading option이 eager/lazy 여부에 따라서 조회되는 데이터는 달라진다.
ex1. profile이 eager일 경우 => 별도로 profileEntity와 그 안에서 select할 column을 지정하지 않더라도
userEntity 데이터 조회 (userRepository.find()) profile의 모든 데이터가 조회됨.
ex2. profile이 lazy인 경우 => profileEntity를 userEntity 조회 시 함께 조회하려면, 추가적으로 profileEntity를 요청해야함.
ex2. 코드 예시

const companies: Company = await this.companyRepository.findOne(
  companyId
);
const employees: Employee[] = await companies.employee;
return companies;

=> company와 employee가 lazy loading으로 설정된 경우, company와 관계 설정된 employee의 데이터를 조회하려면,
  위처럼 company를 조회한 이후, company에 있는 employee 관련 foreignkey를 한번 더 조회해야한다.


**CREATE**
await userRepository.insert({
    firstName: "Timber",
    lastName: "Timber",
})
    
**UPDATE**
await userRepository.update(1, { firstName: "Rizzrak" })
// executes UPDATE user SET firstName = Rizzrak WHERE id = 1

**DELETE**
await repository.delete([1, 2, 3]) // delete by primary id
await repository.delete({ firstName: "Timber" }) // delete by specific condition

2) createQueryBuilder를 이용한 CRUD

  • createQueryBuilder를 이용한 CRUD의 장점은 기본 메소드에 비해 적은 양의 메소드를 이용해서 구체적이고 꼼꼼하게 SQL 문을 작성하는 데 있다고 느꼈다.
  • createQuerytBuilder를 이용하면, 관계 설정된 table을 조회할 시에 eager, lazy loading 여부를 entity에서 설정하지 않아도 조회할 수 있다. select 상황에 따라서, 기본 메소드와 createQueryBuilder를 적절히 활용하면 더 효율적인 작업이 가능할 것이라 생각한다.
**SELECT**
await this.commentsRepository
      .createQueryBuilder('comment') // 'comment' alias로 commentEntity를 사용할 것임을 선언
      .leftJoinAndSelect('comment.userId', 'user') // foreignKey 이용하여 관계설정된 table 'user' alias로 연결
      .leftJoinAndSelect('comment.champId', 'champ')
      .leftJoinAndSelect('comment.summonerName', 'summoner')
      .select([  // 조회할 column 선택
        'comment.content',
        'comment.id',
        'user.userId AS userId',
        'user.profileImg',
        'user.nickname',
        'champ.id',
        'comment.reportNum',
        'comment.createdAt',
        'comment.category',
        'comment.summonerName',
        'summoner.summonerName',
      ])
      .where(option) // 조건 명시
      .orderBy({ // 데이터 정렬 명시
        'comment.createdAt': 'DESC',
      })
      .getMany(); // 1개 조회시 getOne()
  }

** CREATE **
  await this.commentsRepository 
      .createQueryBuilder()
      .insert()
      .into(CommentEntity) // 데이터 주입할 테이블 명시
      .values(value) // 주입할 데이터
      .execute(); // 쿼리 종료 선언
** UPDATE **
  await commentsRepository 
      .createQueryBuilder()
      .update(CommentEntity) // 데이터 변경할 테이블 명시
      .set({ reportNum: data.reportNum + 1 }) // 변경할 데이터
      .where('id = :id', { id }) // 데이터 변경하는 조건
      .execute(); // 쿼리 종료 선언
      
** DELETE **
  await commentsRepository
      .createQueryBuilder()
      .delete()
      .from(CommentEntity) // 데이터 삭제할 테이블 명시
      .where('id = :id', { id: data.id }) // 삭제할 조건 1
      .andWhere('userId = :userId', { userId }) // 삭제할 조건 2 (orWhere) 
      .execute(); // 쿼리 종료 선언

5. 트랜잭션

  • 트랜잭션이란 데이터베이스의 상태를 변화시키기 위해 수행하는 작업의 단위를 의미한다.
    | 요컨대, 쿼리문 하나하나가 독립적인 트랜잭션이라 할 수 있다.
    | 서비스 로직에 따라, 간혹 복수의 쿼리문을 하나의 트랜잭션으로 묶어서 활용해야하는 상황이 있는데, 이 때 복수의 쿼리를 하나의 트랜잭션으로 묶는 방법을 typeORM에서는 제공하고 있다.
  • typeORM에서 트랜젝션을 적용하는 방법으로는 대표적으로 transcationEntityManager, queryRunner를 이용하는 방법이 있다.
  • 프로젝트에서는 개인적인 취항(?)으로 queryRunner가 더 직관적으로 트랜잭션을 작성할 수 있어서 이용했다.
  • 아래 예시는 다른 블로그에서 참고한 예시이다.

1) transcationEntityManager

import { getManager, getConnection } from 'typeorm'
async deleteUserAndBoards(userId: number) {
  await getManager().transaction(async (transactionalEntityManager) => {
    await this.boardRepository.deleteBoardsByUserId(transactionalEntityManager, userId)
    await this.userRepository.deleteUserByUserId(transactionalEntityManager, userId)
  }).catch((err) => {
    throw err
  })
}

2) queryRunner

import { getManager, getConnection } from 'typeorm'

async deleteUserAndBoards(userId: number) {
    const queryRunner = await getConnection().createQueryRunner()
    await queryRunner.startTransaction()

    try {
        await this.boardRepository.deleteBoardsByUserId(queryRunner.manager, userId)
        await this.userRepository.deleteUserByUserId(queryRunner.manager, userId)

        await queryRunner.commitTransaction();
    } catch(err) {
        await queryRunner.rollbackTransaction();
    } finally {
        await queryRunner.release();
    }
}

6. 마이그레이션

  • 데이터베이스에서 언급되는 마이그레이션이란 데이터를 한 위치에서 다른 위치로, 한 형식에서 다른 형식으로 또는 한 애플리케이션에서 다른 애플리케이션으로 이동하는 프로세스 즉, 데이터베이스의 형상을 안전하게 변경/이동하는 작업을 의미한다.
  • 마이그레이션은 실제 운영중인 서비스에서 데이터베이스에 구조적인 변경 사항이 생겼을 때, 혹은 다른 RDBMS를 이용하게 되었을 때 수행할 수 있을 것이다.
  • typeORM에서는 이와 같은 마이그레이션 작업은 다음과 같은 명령어들을 통해 수행할 수 있다.

1) 마이그레이션 파일 경로 주입

const dataSource = new typeorm.DataSource({
    type: "mysql",
    host: process.env.DB_HOST,
    port: process.env.DB_PORT,
    username: process.env.USER_NAME,
    password: process.env.PASSWORD,
    database: process.env.DATABASE,
    synchronize: false,
    logging: false,
    entities: [
        require("./entity/simulation.data"),
    ],
    migrations: ["migrations/*.js"], // 마이그레이션 파일 경로
    migrationsDir: ["migrations"], // 마이그레이션 폴더명
    migrationsTableName: "migration", // 실제 DB에 생성될 migration 테이블 이름
})

2) 마이그레이션 파일 생성/실행

1) 마이그레이션 파일 생성
typeorm migration:create 경로 (경로에 TS 파일로 마이그레이션 파일 생성)
typeorm migration:create 경로 -o (경로에 JS 파일로 마이그레이션 파일 생성) 

2) 마이그레이션 코드 작성
- 다음과 같은 예시처럼 작성될 수 있다.

import { MigrationInterface, QueryRunner } from 'typeorm';

export class UserTableCreate1615829427764 implements MigrationInterface {

  
  up은 migration commit을 위한 코드, down은 migration rollback을 위한 코드를 작성하면 된다.
  
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`CREATE TABLE "user" (
            id INT(11) PRIMARY KEY NOT NULL AUTOINCREMENT,
            email VARCHAR(20) NOT NULL UNIQUE,
            password VARCHAR(200) NOT NULL,
            name VARCHAR NOT NULL,
            birthday VARCHAR NOT NULL,
            phone VARCHAR NOT NULL,
            createAt Date NOT NULL DEFAULT CURRENT_TIMESTAMP() ,
            updatedAt Date NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP(),
        )COLLATE='utf8_bin'`);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP TABLE "user"`);
  }
}


3) 마이그레이션  up 실행
typerom migration:run -d 데이터베이스 경로
typeorm-ts-node-commonjs migration:run -d 데이터베이스 경로 (JS 환경에서)
=> run 실행 시 실제 DB의 migration table에 생성한 마이그레이션 코드를 데이터로 삽입한다.
=> pending 상태에 있는 migration을 실행하여, 마이그레이션 코드에 따라 데이터베이스 변경을 실행한다.

4) 마이그레이션 down 실행
typeorm migration:revert -d 데이터베이스 경로
typeorm-ts-node-commonjs migration:revert -d 데이터베이스 경로 (JS 환경에서)
=> 이미 실행된 마이그레이션을 다시 rollback시킨다.
=> 여러 마이그레이션을 rollback할 경우, 해당 명령어를 여러번 실행시켜야 한다.

c.f) typeorm migration:generate
- 소스코드 상에서 entity 모델과 실제 DB의구조를 비교하여, 실제 DB에서 업데이트 되어야 하는 차이점을 자동적으로 마이그레이션 코드로
작성해주는 명령어이다.
- 기존 컬럼의 데이터 타입 변경과 같이 기존 컬럼을 변경하는 사항의 경우, 기존 컬럼의 데이터를 DROP한 뒤에 재생성하는 특징이 있다.
- 이에, 안전한 migration을 위해, 실제 운영중인 DB에서는 권장되지 않는 옵션이라 할 수 있겠다.

참고자료

profile
차가운에스프레소의 개발블로그입니다. (22.03. ~ 22.12.)

0개의 댓글