DB와 TypeORM

장현욱(Artlogy)·2022년 11월 10일
0

Nest.js

목록 보기
6/18
post-thumbnail

데이터베이스 준비

로컬 PC에 DB를 설치해도 되지만, 다른 프로젝트와 개발 환경을 분리하기 위해서 도커를 사용하겠다.
만약 도커를 모른다면 도커 게시물을 참고 바란다.

MariaDB 설정

$ docker run --detach --name some-mariadb --env MARIADB_USER=artlogy --env MARIADB_PASSWORD=1213 --env MARIADB_ROOT_PASSWORD=1234 -p 3306:3306/tcp mariadb:latest

DB이름 some-mariadb 유저이름 artlogy 비밀번호 1213 Root비밀번호는 1234설정하였다.

Terminal에서 $ docker ps 혹은 docker desktop을 통해 잘 실행되고 있는지 확인해본다.

DB Client Tool Connect

많은 상용 툴이 있겠지만 여기에선 Microsoft Store에서 공식적으로
다운 받을 수 있는 DBeaver 툴을 사용 할 것이다.

Dbeaver를 다운 받았다면 실행 시킨 후 연결 시켜보자.

TypeORM으로 DB 연결

💡 ORM이란?
ORM(Object-Relational Mapping)은 데이터베이스의 관계를 객체로 바꾸어 개발자가 OOP로 데이터베이스를 쉽게 다룰 수 있도록 해 주는 도구이다. SQL문을 그대로 코드에 기술하는 Row-Query방식에서 세부 쿼리문을 추상화 하는 것으로 발전하였다. 개발자는 ORM에서 제공하는 인터페이스를 통해 일반적인 라이브러리를 호출하듯이 DB에 데이터를 업데이트하고 조회 할 수 있다.

Nest & MariaDB & TypeORM

# npm
npm i -s @nestjs/typeorm typeorm mysql2
# yarn
$ yarn add typeorm @nestjs/typeorm mysql2

dev.env

DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASS=1234
DB_DATABASE=nest
DB_SYNCHRONIZE=1

config/database.config.ts

export const DATABASE_CONFIG: MysqlConnectionOptions = {
  type: 'mariadb',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT),
  username: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: process.env.DB_DATABASE,
  synchronize: Boolean(process.env.DB_SYNCHRONIZE),
  entities: [...AccountEntitys],
};

나는 환경설정은 따로 빼두는 편이라 이렇게 해두었다. (ormconfig.json을 사용하는 방법도 있다)
특히 synchronize를 true로 하면 서버가 실행 될 때마다 항상 DB가 초기화 되기 때문에 주의해서 사용해야한다.

app/app.module.ts

import { DATABASE_CONFIG } from '@config/database.config';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AccountModule } from './account/account.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: process.env.NODE_ENV === 'prod' ? '.prod.env' : '.dev.env',
    }),
    TypeOrmModule.forRoot(DATABASE_CONFIG),
    AccountModule,
  ],
  providers: [ConfigService],
  exports: [],
})
export class AppModule {}

TypeOrmModuleOptions

export declare type TypeOrmModuleOptions = {
    retryAttempts?: number;
    retryDelay?: number;
    toRetry?: (err: any) => boolean;
    autoLoadEntities?: boolean;
    keepConnectionAlive?: boolean;
    verboseRetryLog?: boolean;
} & Partial<ConnectionOptions>;
  • retryAttempts: 연결시 재시도 회수. 기본값은 10입니다.
  • retryDelay: 재시도 간의 지연 시간. 단위는 ms이고 기본값은 3000입니다.
  • toRetry: 에러가 났을 때 연결을 시도할 지 판단하는 함수. 콜백으로 받은 인자 err 를 이용하여 연결여부를 판단하는 함수를 구현하면 됩니다.
  • autoLoadEntities: 엔티티를 자동 로드할 지 여부.
  • keepConnectionAlive: 애플리케이션 종료 후 연결을 유지할 지 여부.
  • verboseRetryLog: 연결을 재시도 할 때 verbose 에러메시지를 보여줄 지 여부. 로깅에서 verbose 메시지는 상세 메시지를 의미합니다.

Test용 Entity만들기

#npm
npm i -s uuid 
npm i -D @types/uuid

#yarn
yarn add uuid @types/uuid

uuid를 쓸꺼니깐 다운 받아두자.
Account/entities/account.entity

import { baseEntityUUID } from '@config/base.entity.config';
import { Column, Entity } from 'typeorm';

@Entity('account')
export class Account extends baseEntityUUID {
  @Column({ length: 24, comment: '유저 이름', nullable: false })
  name: string;

  @Column({ length: 128, comment: '이메일', nullable: false })
  email: string;

  @Column({ length: 48, comment: '패스워드', nullable: false })
  password: string;

  @Column({ length: 128, comment: '리플래시 토큰', nullable: true })
  refresh_token: string;
}

상속 받는 baseEntityUUID는 내가 만든 것이다.
자주 쓰이는 entity의 column은 따로 만들어서 상속받으면 편하다.

트렌젝션 적용

트렌젝션은 요청을 처리하는 과정에서 데이터베이스에 변경이 일어나는 요청을 독립적으로 분리하고
에러가 발생했을 경우 이전 상태로 되돌리기 위해 쓰이는 기능이다.

Typeorm에서 트렌젝션을 사용하는 방법은 총 3가지이다.

  • QueryRunner를 이용하여 단일 DB 커넥션 상태를 생성하고 관리하기
  • transaction객체를 생성하여 이용하기
  • @Transaction, @TransactionManager, @TracsactionRepository 데코레이터 사용하기

이중 데코레이터를 이용하는 방식은 Nest에서 권장하지 않기 때문에 나머지 두가지 방법을 알아볼것이다.

QueryRunner 클래스를 사용하는 방법

QueryRunner를 사용하면 트렌젝션을 완전히 제어할 수 있습니다.

...
import {DataSource, ...} from "typeorm";
        
@Injectable()
export class UsersService{
	constructor(
  		...
     	private datasource: DataSource,	//DataSource 객체를 통해 트렌젝션을 만든다.
  ){}
  ...
}

트렌젝션을 적용 할 서비스에 dataSource객체를 주입하고 트렌젝션을 적용 해 볼 것이다.

Account/account.service.ts

// DataSource 객체를 이용한 트렌젝션 적용
  async saveUserUsingQueryRunner(
    name: string,
    email: string,
    password: string,
  ) {
    const queryRunner = this.datasource.createQueryRunner();

    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      const user = new Account();
      user.uuid = randomUUID();
      user.name = name;
      user.email = email;
      user.password = password;
      await queryRunner.manager.save(user);

      // throw new InternalServerErrorException(); // 일부러 에러를 발생시켜 본다

      await queryRunner.commitTransaction();
    } catch (e) {
      // 에러가 발생하면 롤백
      await queryRunner.rollbackTransaction();
    } finally {
      // 직접 생성한 QueryRunner는 해제시켜 주어야 함
      await queryRunner.release();
    }
  }

예전엔 Connect객체를 썻는데 Connect는 Deprecated로 바뀌었다.
이제 DataSource객체가 Connect를 대신 한다고 생각하면 된다.

transaction 메소드 사용 (강추)

transaction은 주어진 메소드 실행을 트렌잭션으로 래핑하기 때문에 훨씬 편하고 가독성있게 트렌젝션을 적용할 수 있다.

Account/account.service.ts

  // transaction 메소드를 이용한 트렌젝션 적용
  private async saveUserUsingTransaction(name: string, email: string, password: string) {
    await this.datasource.transaction(async manager => {
      const user = new Account();
      user.uuid = randomUUID();
      user.name = name;
      user.email = email;
      user.password = password;
  
      await manager.save(user);
  
      // throw new InternalServerErrorException();
    })
  }

마이그레이션

# npm
npm i -g ts-node
# yarn
yarn add global ts-node

migration CLI명령어로 제어해야 하므로 typescript환경에서 작성 할 수 있게 글로벌 환경에 ts-node 패키지를 다운받아주자.

package.json

"scripts": {
    ...
    "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js"
}

마이그레이션을 CLI로 생성하고 실행할 수 있는 환경이 구성되었다.
다음은 마이그레이션 이력을 관리 할 테이블을 설정해야한다.
ormconfig.json에서 마이그레이션 관련 옵션을 추가하자.

ormconfig.json

{
  //true 할 경우 서버가 새로 구동 될 때마다 테이블이 새로 생성된다.
  "synchronize": false, 
  //마이그레이션을 수행할 파일
  "migrations": ["dist/migrations/*{.ts,.js}"],
  //마이그레이션 파일을 생성할 디렉토리
  "cli": {
    "migrationsDir": "src/migrations"
  },
  //마이그레이션 이력이 기록되는 테이블 이름
  "migrationsTableName": "migrations"
}

마이그레이션 파일을 생성하는 방법은 2가지가 있다.

  • migration:create : 수행할 마이그레이션 내용이 비어있는 파일을 생성한다.
    migration:generate : 현재 소스코드와 migrations 테이블에 기록된 이력을 기반으로 마이그레이션 파일을 자동 생성한다.

먼저 migration:create 명령어를 사용해 보자. -n 옵션은 생성될 파일의 이름과 마이그레이션 이력에 사용된다.

$ npm run typeorm:migration:create -- -n CreateUserTable

생성된 파일이다.

import { MigrationInterface, QueryRunner } from "typeorm";

// migation:create 명령의 -n 옵션으로 설정한 이름 + 파일생성시간(unix) 조합한 이름을 가진 클래스가 생성된다.
export class CreateUserTable1640441100470 implements MigrationInterface {

  //up함수는 `npm run typeorm:migation:run` 명령으로 마이그레이션이 수행 될 때 실행되는 코드를 작성한다.
  public async up(queryRunner: QueryRunner): Promise<void> {
  }
  //down함수는 'npm run typeorm:migration:revert' 명령으로 마이그레이션을 되돌릴 때 실행되는 코드를 작성한다.
  public async down(queryRunner: QueryRunner): Promise<void> {
  }

}

만약 마이그레이션이 실행되거나 되돌리는 코드를 작성하기 귀찮다면, migration:generate명령을 사용하면 된다.

$ npm run typeorm:migration:generate -- -n CreateUserTable

생성된 파일이다.

import { MigrationInterface, QueryRunner } from "typeorm";

export class CreateUserTable1640441100470 implements MigrationInterface {
  name = 'CreateUserTable1640441100470'

  //User Table을 생성하는 SQL문을 실행 하는 코드 npm run typeorm:migation:run
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`CREATE TABLE User (id varchar(255) NOT NULL, name varchar(30) NOT NULL, email varchar(60) NOT NULL, password varchar(30) NOT NULL, signupVerifyToken varchar(60) NOT NULL, PRIMARY KEY (id)) ENGINE=InnoDB`);
  }

  //User Table을 삭제하는 SQL문 npm run typeorm:migration:revert
  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP TABLE \`User\``);
  }
}

0개의 댓글