내배캠 95일차

·2023년 2월 16일
0

내일배움캠프

목록 보기
104/142
post-thumbnail

TypeORM

TypeORM : Typescript ORM

sequelize 같은 ORM

이전에는 실제 DB와 연결하지 않고 임의로 boards라는 변수를 배열 [] 로 만들었음!

이제 실제 DB와 연결 하기 위해서 NestJS에서 DB연동할떄 사용하는 TypeORM을 배울 것!

TypeORM 설치

npm i typeorm@0.3.0
npm i @nestjs/typeorm mysql

윈도우 사용자는 0.3.0 버전이 아니면 오류가 나는 이슈가 있어서 0.3.0 버전을 깔아주었다.

app.module.ts 1차

특정 모듈을 사용하기 위해서는 imports 속성에 추가를 해서 해당 모듈을 사용할 것이라는 것을 명시합니다!
여기서 TypeModule.forRoot({..})을 추가해줌!
forRoot 함수로 설정된 내용들은 모든 모듈에 적용이 되기 때문에 forRoot 함수로 설정을 한 것입니다.

@Module({
  imports: [
    TypeOrmModule.forRoot({
      // 데이터베이스 설정에 관련된 내용을 여기다 적는 것
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'id',
      password: 'pw',
      database: 'NestJS-board',
      entities: [],
      synchronize: true, // 개발 버전에서는 스키마의 용이한 수정을 위해 true
    }),
    BoardModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})

app.module.ts 2차

TypeORM 모듈에서 데이터베이스 환경 설정하는 부분을 다른 파일에 있는게 미관상으로든 보안상으로든 좋지않을까?

config/typeorm.config.service.ts

import { Injectable } from '@nestjs/common';
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm';

@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
  createTypeOrmOptions(): TypeOrmModuleOptions {
    return {
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'id',
      password: 'pw',
      database: 'NestJS-board',
      entities: [],
      synchronize: true,
    };
  }
}

app.module.ts

@Module({
  imports: [
    TypeOrmModule.forRootAsync({ // forRoot -> forRootAsync로 바뀌었어요!
      useClass: TypeOrmConfigService, // DB 관련 설정이 있는 서비스 파일을 불러와요!
    }),
    BoardModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})

TypeOrmModule.forRoot라는 함수가 사라지고 TypeOrmModule.forRootAsync 함수를 대신 사용함.
왜냐하면, Nest.js 웹 어플리케이션이 부트스트랩될 때 데이터베이스 연결의 동적 구성을 허용해야 하는데 이것은 TypeOrmModule.forRootAsync라는 함수를 써야 가능하기 때문입니다!

즉, 비동기 작업(여기서는 데이터베이스와 연결 설정)이 완료될 때까지 응용 프로그램 시작을 지연해야하기 때문에 TypeOrmModule.forRootAsync 를 사용하는 것입니다. 파일이나 환경 변수에서 구성을 읽어야 하거나 런타임에 구성을 결정해야 하는 경우에 유용하게 쓸 수 있어요!

app.module.ts 3차

이제 코드에 민감한 정보를 남기는 것을 방지하고 정보가 변경이 되는 경우에도 코드를 수정할 필요가 없게끔 하기위해서 express 에서 사용한 것 처럼 .env를 사용할 것!

Nest.js에서 같은 역할을 하는게 바로, @nestjs/config 입니다.

npm i @nestjs/config 설치하기!

app.module.ts

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }), // 일단 이것은 무조건 가장 위에서!
    TypeOrmModule.forRootAsync({ useClass: TypeOrmConfigService }),
    BoardModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})

ConfigModule.forRoot({ isGlobal: true }) 코드가 추가되었습니다. 이 코드는 TypeORMModule이 TypeOrmConfigService를 통하여 초기 세팅이 되기전에 .env 파일 값들을 읽어오기 위해서 제일 위에 선언을 합니다.

config/typeorm.config.service.ts

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm';

@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
  constructor(private readonly configService: ConfigService) {}
  createTypeOrmOptions(): TypeOrmModuleOptions {
    return {
      type: 'mysql',
      host: this.configService.get<string>('DATABASE_HOST'),
      port: this.configService.get<number>('DATABASE_PORT'),
      username: this.configService.get<string>('DATABASE_USERNAME'),
      password: this.configService.get<string>('DATABASE_PASSWORD'),
      database: this.configService.get<string>('DATABASE_NAME'),
      entities: [],
      synchronize: this.configService.get<boolean>('DATABASE_SYNCHRONIZE'),
    };
  }
}

typeorm.config.service.ts도 .env 파일의 환경변수를 읽어오는 버전으로 코드를 변경해주면 끝!

엔티티 & 리포지토리 생성

이전에는 서비스 코드에서 게시글 정보들을 메모리에 담고 있었는데, 이것을 이제 데이터베이스로 넘겨봅시다. 그러기 위해서 우선 1) 엔티티와 2) 리포지토리를 생성해야 됩니다! 아쉽게도 이것은 커맨드로 지원되지 않고 있어서 저희가 수동으로 해야됨....!!

엔티티 생성

board/article.entity.ts

import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from "typeorm";

@Entity({ schema: "board", name: "articles" })
export class Article {
  @PrimaryGeneratedColumn({ type: "int", name: "id" })
  id: number;

  @Column("varchar", { length: 10 })
  author: string;

  @Column("varchar", { length: 50 })
  title: string;

  @Column("varchar", { length: 1000 })
  content: string;

  @Column("varchar", { select: false })
  password: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt: Date | null;
}

기존에는 제목, 내용, 비밀번호만 넘겼던 것 기억나시죠? 이번에는 작성자, 작성일, 수정일, 삭제일이라는 컬럼을 새롭게 추가하였습니다.

@Entity 어노테이션은 해당 클래스가 어떤 테이블에 매핑이 되는지를 나타내는 어노테이션이에요! PK에 해당하는 컬럼에는 @PrimaryGeneratedColumn 어노테이션을 씁니다! 나머지 컬럼에는 @Column 이나 날짜에 해당되는 컬럼은 @DateColumn 타입의 어노테이션을 사용해주시면 됩니다. 그러면 자동으로 date 타입으로 기록이 됩니다.

여기서

@DeleteDateColumn은 레코드가 삭제가 되는데 삭제된 날짜가 자동으로 기록이 된다뇨? 이 어노테이션을 사용하면 해당 엔티티가 삭제되는 순간 실제로 삭제(Hard Delete)되는 것이 아니라 논리적으로 삭제(Soft Delete)가 되는 것입니다! 이렇게 되면, 레코드는 데이터베이스에 기록된 상태에서 전체 게시물을 가지고 올 때 deletedAt ≠ NULL인 게시물만 가지고 와도 삭제된 것과 같은 효과가 날 수 있습니다!

레포지토리 생성

엔티티를 생성했으니 이제 리포지토리도 생성해야겠죠? 리포지토리는 서비스가 엔티티 객체를 다루기 위해 꼭 필요한 친구입니다. 여기에는 일반 리포지토리가 있고 커스텀 리포지토리가 있습니다. 일반 리포지토리 명세는 TypeORM의 Repository 문서를 참고(꼭 정독하세요!)하시면 됩니다. 일반 리포지토리로 데이터베이스 연산이 부족하면 일반 리포지토리를 상속한 커스텀 리포지토리를 작성하면 됩니다. 일단은, 일반 리포지토리를 만들어 볼 것!

일반 리포지토리 사용은 매우 쉽습니다. 서비스의 생성자에 사용할 리포지토리를 아래와 같이 주입하면 됩니다!

board/board.service.ts

constructor(
    @InjectRepository(Article) private articleRepository: Repository<Article>
  ) {}

@InjectRepository 어노테이션을 사용하여 생성자 매개변수를 통해 주입하도록 하겠습니다. 하지만, 이전에 리포지토리를 주입하기 위해서는 해당 서비스를 관장하는 모듈에서 Article 엔티티를 임포트한다고 명시해주어야 합니다.

board/board.module.ts

import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Article } from "./article.entity";
import { BoardController } from "./board.controller";
import { BoardService } from "./board.service";

@Module({
  // 매우 중요: 서비스에서 사용할 리포지토리 사용을 imports에 명시!
  imports: [TypeOrmModule.forFeature([Article])],
  controllers: [BoardController],
  providers: [BoardService],
})
export class BoardModule {}

특정 모듈 서비스에서 사용하고 싶은 리포지토리가 있다면 @Module 데코레이터imports 속성에 반드시 넣어주셔야 DI가 원활하게 됩니다! 앞으로도 계속 동일한 패턴이니 중요함!!

데이터베이스 기반의 서비스 코드 작성

import {
  Injectable,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import _ from 'lodash';
import { Repository } from 'typeorm';
import { Article } from './article.entity';

@Injectable()
export class BoardService {
  constructor(
    @InjectRepository(Article) private articleRepository: Repository<Article>,
  ) {}

  async getArticles() {
    return await this.articleRepository.find({
      where: { deletedAt: null },
      select: ['author', 'title', 'createdAt'],
    });
  }

  async getArticleById(id: number) {
    return await this.articleRepository.findOne({
      where: { id, deletedAt: null },
      select: ['author', 'title', 'content', 'createdAt', 'updatedAt'],
    });
  }

  createArticle(title: string, content: string, password: number) {
    this.articleRepository.insert({
      // 일단, 편의를 위해 author는 잠시 test로 고정합니다.
      author: 'test',
      title,
      content,
      // 마찬가지로, password도 잠시 숫자를 문자열로 바꾸겠습니다.
      // 나중에 암호화된 비밀번호를 저장하도록 하겠습니다.
      password: password.toString(),
    });
  }

  async updateArticle(
    id: number,
    title: string,
    content: string,
    password: number,
  ) {
    await this.checkPassword(id, password);
    this.articleRepository.update(id, { title, content });
  }

  async deleteArticle(id: number, password: number) {
    await this.checkPassword(id, password);
    this.articleRepository.softDelete(id); // soft delete를 시켜주는 것이 핵심!
  }

  private async checkPassword(id: number, password: number) {
    const article = await this.articleRepository.findOne({
      where: { id, deletedAt: null },
      select: ['password'],
    });
    if (_.isNil(article)) {
      throw new NotFoundException(`Article not found. id: ${id}`);
    }

    if (article.password !== password.toString()) {
      throw new UnauthorizedException(
        `Article password is not correct. id: ${id}`,
      );
    }
  }
}

다른 함수와 다르게 createArticle 함수는 async-await를 쓰지 않았습니다. 물론 리포지토리의 insert 함수도 Promise를 리턴하지만 이것을 async-await를 쓰지 않는 이유는 어련히 게시물이 알아서 작성이 잘 되겠지~ 라고 생각하고 다른 API 함수를 호출해도 무방하기 때문입니다.

Promise를 리턴한다고 해서 무조건 async-await를 쓰는 것은 아님을 알아주셨으면 좋겠어요! 1) 동기적으로 리턴값을 받아와야 할 때2) 해당 함수 리턴 값이 뒤에 나오는 코드 실행에 영향을 주는 경우라면 async-await를 꼭 써야하지만 그렇지 않은 경우엔 생략해도 무방합니다! async-await가 필요없는 코드에서도 계속 남발을 하면 코드의 성능이 조금씩 하락할 수 있다는 것을 명심해야합니다!!

컨트롤러 코드 수정

BoardService를 데이터베이스 기반의 코드로 변경하면서 async-await 함수로 변경된 함수들이 생겼습니다. 따라서, 컨트롤러도 빠르게 그에 맞게 수정을 하겠습니다.

@Controller("board")
export class BoardController {
  constructor(private readonly boardService: BoardService) {}

  @Get("/articles")
  async getArticles() {
    return await this.boardService.getArticles();
  }

  @Get("/articles/:id")
  async getArticleById(@Param("id") articleId: number) {
    return await this.boardService.getArticleById(articleId);
  }

  @Post("/articles")
  createArticle(@Body() data: CreateArticleDto) {
    return this.boardService.createArticle(
      data.title,
      data.content,
      data.password
    );
  }

  @Put("/articles/:id")
  async updateArticle(
    @Param("id") articleId: number,
    @Body() data: UpdateArticleDto
  ) {
    return await this.boardService.updateArticle(
      articleId,
      data.title,
      data.content,
      data.password
    );
  }

  @Delete("/articles/:id")
  async deleteArticle(
    @Param("id") articleId: number,
    @Body() data: DeleteArticleDto
  ) {
    return await this.boardService.deleteArticle(articleId, data.password);
  }
}
profile
개발자 꿈나무

0개의 댓글