본문 바로가기

Backend/NestJS

[NestJS] TypeORM, Entity, Repository 구조(NestJS PostgresSQL 적용)

728x90
드디어 DB 연동을 해봤다.
NestJS에서 DB 연동에 필요한 개념과 전체적 구조에 대한 기록이다.

개요

NestJS에서 DB를 연동하는데 내가 이용한 개념은 

TypeORM, Entity, Repository이다.

 

TypeORM은 쉽게 TS를 사용해 DB에 접근하고, DB를 다룰 수 있게 해주는 역할,

Entity는 DB 테이블로 변환되는 클래스 즉 테이블의 타입을 지정해 주는 역할,

Repository는 프로바이더에서 비즈니스 로직을 처리할 때 DB 관련 로직을 처리하는 역할이다.

 

각 개념에 대한 설명과 NestJS에서 어떻게 구현되는지에 대해 다루려고 한다.

TypeORM

ORM(Object Relational Mapping)
ORM은 객체와 관계형 DB의 데이터를 자동으로 변형 및 연결하는 작업을 담당한다.
ORM을 이용하게 되면 객체와 DB의 변형을 유연하게 다룰 수 있다.

TypeORM은 node.js 에서 실행되고, TS로 작성된 ORM 라이브러리이다.

NestJS 공식 문서에서도 다룰 만큼 가장 안정적이고 편리한 ORM 라이브러리라고 볼 수 있다.

 

TypeORM의 기능과 장점은 아래와 같다.

  • 모델을 기반으로 DB 테이블 체계를 자동으로 생성
  • DB의 개체에 대해 CRUD를 쉽게 구현할 수 있다.
  • 테이블 간의 매핑을 만든다.
  • 간단한 CLI 명령을 제공한다.
  • 다른 모듈과 쉽게 통합된다.
  • 간단한 코딩으로 ORM 프레임워크를 사용하기 쉽다.

Nest에서 typeORM을 사용하기 위해서는 두 개의 모듈을 설치해야 한다.

$npm i typeorm

postgresSQL을 사용할 것이기 때문에 pg도 설치해 줬다.

$npm i pg

 

이제 typeorm.config.ts 파일을 만들어보자.

import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const typeORMConfig: TypeOrmModuleOptions = {
  type: ''
  host: ''
  port: 
  username: '',
  password: '',
  database: '',
  entities: [__dirname + '/../**/*.entity.{js,ts}'],
  synchronize: true,
};
  • database : 연결할 DB의 이름을 작성한다.
  • entities : entity를 이용해 DB 테이블을 생성해 주기 때문에 entity 파일이 어디 있는지 설정해 준다.
    • /**/*. entity. {js, ts}는 entity.ts 확장자를 가진 모든 파일을 가리키는 것이다.
  • synchronize : true 값을 주면 애플리케이션을 다시 실행할 때 entity 안에서 수정된 칼럼의 길이, 타입 변경값등을 해당 테이블을 드랍한 후 다시 생성해 준다.
    • production에서는 true로 하면 안 된다. 데이터가 모두 삭제될 수도 있기 때문이다.
파일 경로
** : 부분 경로를 찾는다.
* : 0개 이상의 문자를 찾는다.
ex) Images/**/*. jpg : Images 디렉터리 및 하위 디렉터리의 모든 jpg 확장자를 가진 파일을 찾는다.

 

이제 적용한 config를 루트 모듈에 적용시켜 주면 된다.

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { BoardsModule } from './boards/boards.module';
import { typeORMConfig } from './config/typeorm.config';

@Module({
  imports: [TypeOrmModule.forRoot(typeORMConfig), BoardsModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

앞서 말했듯 NestJS에서 typeorm을 사용할 수 있게 연결해 주는 역할을 @nestjs/typeorm이 하고 있다.

 

TypeOrmModule은 앞서 설정한 config에 따라 동적으로 작동하는 모듈이기 때문에

forRoot를 사용해 인자로 typeORMConfig를 전달하고, 동적 모듈을 반환한다.

static forRoot(options?: TypeOrmModuleOptions): DynamicModule;

 

forRoot로 DataSource의 구성 속성을 지정하면,

모듈을 import 할 필요 없이 TypeORM DataSource 및 EntityManager 객체를 전체 프로젝트에 주입할 수 있다.

Data Source
Data Source는 쉽게 데이터가 저장되고, 활용되는 DB를 말한다.
forRoot는 DB에 대한 config를 지정하는 메소드이다.

Entity

Entity는 DB의 테이블 구성에 대해 설명한다.
해당 테이블의 칼럼의 이름과 타입을 지정할 수 있다.

entity파일을 만들어보자.

import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { BoardStatus } from './board-status.enum';

@Entity()
export class Board extends BaseEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column()
  description: string;

  @Column()
  status: BoardStatus;
}
  • @Entity : Entity 데커레이터는 아래 클래스가 entity임을 나타내 역할을 한다.
  • BaseEntity : entity 클래스는 BaseEntity 클래스를 상속해야 한다.
    • BaseEntity 클래스를 상속하면 ActiveRecord Pattern을 사용할 수 있다고 한다.
    • ActiveRecord Pattern은 query 메소드들이 각 개체에 저장되어 있어 메소드를 통해 데이터 변형이 가능하다는 것이다.
    • 실제로 BaseEntity를 보면, 데이터 관련 메소드들이 있는 것을 볼 수 있다.
  • PrimaryGeneratedColumn : 해당 테이블의 기본 키를 지정한다.
  • Column : 일반 키를 지정한다.

 

Repository

Repository는 entity객체와 함께 작동하며 개체 찾기, 삽입, 업데이트, 삭제 등을 처리한다.

service 파일에서 비즈니스 로직을 처리할 때 DB작업은 Repository가 담당한다.(Repository Pattern)

 

Repository 파일을 생성해 보자.

간단한 createBoard만 구현되어 있는 코드이다.

import { CustomRepository } from 'src/typeorm-ex.decorator';
import { Repository } from 'typeorm';
import { BoardStatus } from './board-status.enum';
import { Board } from './board.entity';
import { CreateBoardDTO } from './dto/create-board-dto';

@CustomRepository(Board)
export class BoardRepository extends Repository<Board> {
  async createBoard(createBoardDTO: CreateBoardDTO): Promise<Board> {
    const { title, description } = createBoardDTO;
    const newBoard = this.create({
      title,
      description,
      status: BoardStatus.PUBLIC,
    });
    await this.save(newBoard);
    return newBoard;
  }
}
  • CustomRepository : 원래는 EntityRepository라는 데커레이터가 있어 쉽게 Repository를 생성할 수 있었지만 Nest 업데이트로 해당 데커레이터가 삭제되어 Custom 데커레이터를 만들었다.
  • Repository : Repository 클래스를 상속해야 한다.
    • Repository 클래스에는 target entity를 제너릭으로 전달해야 한다.
    • Repository 클래스는 target entity에 대한 쿼리 메소드를 제공한다.

Transaction

트랜잭션은 DB 관리 시스템 내에서 DB에 대해 수행되는 작업단위를 상징하고,
변경이 일어나는 요청을 독립적으로 분리하여 에러가 발생했을 경우 이전 상태로 되돌리게 하기 위해
DB에서 제공하는 기능이기도 하다.

TypeORM에서 transaction을 사용하는 방법은 두 가지가 있다.

  1. QueryRunner를 이용해 단일 DB 커넥션 상태를 생성하고 관리
  2. transaction 함수를 직접 사용

NestJS 공식문서에서는 QueryRunner가 transaction을 완전하게 제어할 수 있기 때문에 QueryRunner 사용을 권장한다.

 

QueryRunner

먼저 typeORM의 DataSource 객체를 주입한다.

import { DataSource } from 'typeorm';

@Injectable()
export class UsersService {
  constructor(
      ...
      private dataSource: DataSource,
  ) {}
...
}

DataSource는 DB와의 상호 작용을 도와준다.
DB의 연결 관련 설정을 가지고 있으며 사용하는 RDBMS에 따라 초기 DB 연결 또는 연결 풀을 설정한다.

 

DataSource를 주입했으므로 transaction을 생성할 수 있다.

아래는 유저의 정보를 받아 queryRunner를 이용하여 transaction을 하고 에러가 없을 경우

해당 유저 정보를 저장하는 코드이다.

  private async saveUserUsingQueryRunner(
    name: string,
    email: string,
    password: string,
    signupVerifyToken: string,
  ) {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      const user = new UserEntity();
      user.name = name;
      user.email = email;
      user.password = password;
      user.signupVerifyToken = signupVerifyToken;

      await queryRunner.manager.save(user);

      //throw new InternalServerErrorException();

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

 위에서부터 코드를 보며 이해해 보자.

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

queryRunner는 RDBMS가 연결 풀링을 지원하는 경우 dataSource의 연결풀에서 단일 연결을 사용한다.

유저 한 명의 정보를 받아 DB에 저장하는 작업이 하나의 연결일 것이고,

각각의 연결에 대해서 queryRunner가 해당 작업에 에러가 없는지 검사하고

 

에러가 없으면, 해당 작업을 DB에 커밋하여 영속화하고

에러가 있으면, 에러 롤백을 수행한다.

 

위 코드는 작업을 수행하기 전 queryRunner가 DB와의 연결을 통해 transaction을 시작하는 코드이다.

try {
      const user = new UserEntity();
      user.name = name;
      user.email = email;
      user.password = password;
      user.signupVerifyToken = signupVerifyToken;

      await queryRunner.manager.save(user);

      //throw new InternalServerErrorException();

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

이제 try 문에서 유저의 정보를 저장하는 작업(transaction)을 커밋하여 영속화한다.

만약 DB 작업이 수행됐다면 커밋으로 영속화를 완료한다.

여기서 manager는 해당 queryRunner에서만 사용되는 manager이고, 매니저는 DB에 entity 관련 작업을 담당한다.

 

예외를 전달해서 에러 상황을 가정하면,

rollbackTransaction 메서드를 이용해 직접 롤백을 수행한다.

 

마지막은 finally 구문을 통해 생성한 QueryRunner 객체를 해제한다. 생성한 QueryRunner는 꼭 해제해야 한다.

transaction

dataSource의 transaction 메서드를 이용하는 방법도 있다.

  private async saveUserUsingTransaction(
    name: string,
    email: string,
    password: string,
    signupVerifyToken: string,
  ) {
    this.dataSource.transaction(async (manager) => {
      const user = new UserEntity();
      user.name = name;
      user.email = email;
      user.password = password;
      user.signupVerifyToken = signupVerifyToken;
      await manager.save(user);
    });
  }

위처럼 transaction 메서드를 이용해 EntityManager를 콜백으로 받아서 함수 작성으로 trancaction을 구현할 수도 있다.

728x90