Nest.js

효율적이고 확장 가능한 Node.js 서버 측 애플리케이션을 구축하기 위한 프레임워크이다. progressive javascript를 사용하며, typescript로 구축되고 완벽하게 지원하지만 개발자는 순수 javascript로 코딩할 수 있으며, OOP(객체 지향 프로그래밍), FP(함수형 프로그래밍), FRP(기능적 반응형 프로그래밍)의 요소를 결합합니다.

내부적으로 Nest는 Express(기본값)와 같은 강력한 HTTP 서버 프레임워크를 사용하며 선택적으로 Fastify도 사용하도록 구성할 수 있다!

Nest는 이러한 일반적인 Node.js 프레임워크(Express/Fastify)보다 높은 수준의 추상화를 제공할 뿐만 아니라 개발자에게 직접 API를 노출한다. 따라서 개발자는 기본 플랫폼에서 사용할 수 있는 수 많은 타사 모듈을 자유롭게 사용할 수 있다.

 

 

처음 nest.js로 프로젝트를 생성하면 많은 기본 파일들이 생성된다. 아직 무엇인지 정확하게 모르기때문에 정리해보자.

프로젝트를 생성하면 만들어지는 기본 파일

eslintrc.js

개발자들이 특정한 규칙을 가지고 코드를 깔끔하게 짤 수 있게 도와주는 라이브러리로 typescript를 쓰는 가이드라인을 제시, 문법에 오류가 나면 알려주는 역할등을 한다.

 

nest-cli.json

nest 프로젝트를 위해 특정한 설정을 할 수 있는 Json 파일

 

tsconfig.json

어떻게 typescript를 컴파일할지 설정

 

tsconfig.build.json

tsconfig.json의 연장선상 파일이며, build를 할 때 필요한 설정들 "excludes"에서는 빌드할 때 필요없는 파일들 명시

 

package.json

build: 운영 환경을 위한 빌드

format: 린트에러가 났을지 수정

start: 앱 시작

 

src 폴더 (대부분의 비즈니스 로직이 들어가는 곳)

main.ts - 앱을 생성하고 실행 (앱의 시작점.)

app.module.ts - 앱 모듈을 정의?

 

 

 

실행 방법

npm run start:dev

main.ts를 보면 3000번 port로 listen하고 있는 코드가 보인다. 테스트하기 위해 localhost:3000를 탐색해보면 아래와 같이 받음을 알 수 있다.

그렇다면, Hello Wolrd!는 어디에 적혀있는 걸까? main.ts 파일의 코드를 보면 ./app.module을 import하여 create한 것을 볼 수 있다. app.moduel을 가보면, @Module 어노테이션에 ./app.contorller와 ./app.service를 추가한 것을 확인할 수 있다. 이제 app.controller.ts파일을 보면, @Get()아래 getHello()라는 함수가 보인다. appService의 getHello()를 가져와서 반환해주는데 app.service.ts 파일을 보면 'Hello World!' 를 return 하고있는 것을 확인할 수 있다. 위와같은 방식으로 Hello World!를 찾아서 반환해준다.

 

 

불필요한 파일 제거

기본 파일에서 필요없는 파일을 제거하자.

  • src/app.controller.spec.ts
  • src/app.controller.ts
  • src/app.service.ts
  • test directory

위의 4개 파일을 삭제했다. root module아래로 여러 모듈을 추가하여 프로젝트를 돌리기 위함이다.

 

 

Create Board Module

각 기능별로 Module화 시키기위해 Module을 만들어야 한다. nest.js는 명령어로 생성할 수 있는데, 아래처럼 사용하면 된다.

$ nest g module boards

명령어를 치면 app.module.ts에 boardmodule이 자동으로 Import 되어있고 boardmodule 또한 생성된걸 확인할 수 있다.

 

 

컨트롤러는 @Controller 데코레이터로 클래스를 데코레이션하여 정의된다. 데코레이터는 인자를 Controller에 의해서 처리되는 "경로"를 받는다.

 

Handler?

핸들러는 @Get, @Post, @Delete 등과 같은 데코레이터로 장식된 컨트롤러 클래스 내의 단순한 메서드이다.

 

Create Board Controller 

$ nest g controller boards --no-spec

module을 생성할 때와 같이 import 같은 경우 명령어를 통해 자동으로 코드를 완성해준다.

@Controller('boards')
export class BoardsController {
    
}

@Controller('boards')는 이 클래스가 '/boards' 경로에 대한 요청을 처리함을 나타낸다.

 

Create Board Service

$ nest g service boards --no-spec

보통 service 로직은 데이터 베이스와 관련된 로직을 처리한다.

import { Injectable } from '@nestjs/common';

@Injectable()
export class BoardsService {}

Service에는 @Injectable 데코레이터가 있다. 대강 알아보니 DI system에 등록되는 데코레이터라고 한다. next.js는 이것을 이용해서 다른 컴포넌트에서 이 서비스를 사용할 수 있게 만들어준다. Service 로직은 Controller를 통해 들어오기 때문에 Controller에 Service를 Import하도록 코드를 추가해준다.

@Controller("boards")
export class BoardsController {
  constructor(private boardsService: BoardsService) {}
  
}

constructor를 통해 DI에 등록되어있는 BoardsService를 가져온다. 여기서 readonly를 추가하기도 하는데, 의존성 주입을 통해 주입된 서비스가 변경되지 않도록 하기 위해 사용한다고 한다. 이를 통해 주입된 의존성이 변경되지 않음을 보장할 수 있다. 나도 readonly를 추가해주었다.

@Controller("boards")
export class BoardsController {
  constructor(private readonly boardsService: BoardsService) {}
  
}

 

 

Create Board Model

모델을 정의할 때, class 혹은 interface로 생성하면 된다. class는 변수의 타입을 체크하고 인스턴스를 생성할 수 있다. interface는 변수의 타입만 체크할 수 있다.

 

나는 일단 변수의 타입만 체크하기 위해 interface로 생성했다.

export interface Board {
  id: string;
  title: string;
  description: string;
  status: BoardStatus;
}

export enum BoardStatus {
  PUBLIC = "PUBLIC",
  PRIVATE = "PRIVATE",
}

 

 

Create Board(Service)

  createBoard(title: string, description: string): Board {
    const board: Board = {
      id: uuid(), // 유니크한 값으로 게시판에 줄 수 있음
      title,
      description,
      status: BoardStatus.PUBLIC,
    };

    this.boards.push(board);
    return board;
  }

일단, local에만 저장하여 테스트할 것이기 때문에 UUID 라이브러리를 사용하여 겹치지않게끔 값을 생성해주었다. UUID를 사용하려면 아래 명령어를 통해 사용할 수 있다.

npm install uuid --save

status의 경우 기본을 PUBLIC으로 하여 게시글을 생성해주었다.

 

Create Board(Controller)

Board를 생성하기 위해서는 request Body를 가져와야 한다. nest.js의 경우 @Body 데코레이터를 통해 request Body를 가져올 수 있다.

  @Post()
  createBoard(
    @Body("title") title: string,
    @Body("description") description: string
  ): Board {
    return this.boardsService.createBoard(title, description);
  }

postman으로 테스트

Postman으로 정상적으로 post되는 모습을 확인할 수 있다.

 

Create DTO

DTO는 계층간 데이터 교환을 위한 객체이다. DTO를 사용하는 이유는 데이터 유효성을 체크하기 위함으로 알고 있다. DTO 역시 interface 혹은 class로 생성할 수 있지만, nestjs에서는 class로 만들 것을 권장한다고 한다.

export class CreateBoardDto {
  title: string;
  description: string;
}

 

 

Create get board by id

Service

  getBoardById(id: string): Board {
    return this.boards.find((board) => board.id === id);
  }

Controller

  @Get("/:id")
  getBoardById(@Param("id") id: string): Board {
    return this.boardsService.getBoardById(id);
  }

 

 

Create delete board by id

Service

  deleteBoardById(id: string): void {
    // filter 이후 로직은 id가 같은 것만 남기고 다른 것은 지우는 로직
    this.boards = this.boards.filter((board) => board.id !== id);
  }

Controller

  @Delete("/:id")
  deleteBoardById(@Param("id") id: string): void {
    this.boardsService.deleteBoardById(id);
  }

 

Create update board status

Service

  updateBoardStatus(id: string, status: BoardStatus): Board {
    const board = this.getBoardById(id);
    board.status = status;
    return board;
  }

Controller

  @Patch("/:id/status")
  updateBoardStatus(
    @Param("id") id: string,
    @Body("Status") status: BoardStatus
  ) {
    return this.boardsService.updateBoardStatus(id, status);
  }

 

 

파이프를 이용한 유효성 체크

  @Post()
  createBoard(@Body() CreateBoardDto: CreateBoardDto): Board {
    return this.boardsService.createBoard(CreateBoardDto);
  }

현재 게시판 서비스를 만들고 있는데, 게시판의 제목과 내용에 ""인 공백을 입력해도 post가 되는 문제가 있다. 파이프를 이용해서 게시물을 생성할 때 유효성을 체크하도록 구현해보려고 한다.

 

필요한 모듈

  • class-validator
  • class-transformer

위 두개의 모듈이 필요하다. 해당 모듈은 명령어를 통해 가져올 수 있다. 아래 명령어를 터미널에서 실행해주자.

npm install class-validator class-transformer --save

document 페이지는 링크에 있다. 문서를 읽어보면 @IsNotEmpty()라는 데코레이터를 사용하면 원하는 기능이 구현될 것 같다. 

 

import { IsNotEmpty } from "class-validator";

export class CreateBoardDto {
  @IsNotEmpty()
  title: string;

  @IsNotEmpty()
  description: string;
}

파라미터에 받아오는 Dto에 위와같이 @IsNotEmpty()를 추가해주었다.

 

  @Post()
  @UsePipes(ValidationPipe)
  createBoard(@Body() CreateBoardDto: CreateBoardDto): Board {
    return this.boardsService.createBoard(CreateBoardDto);
  }

controller에도 @UsePipes 데코레이터를 추가하여 Handler-level pipe를 추가해주었다. ValidationPipe는 built-in pipe로 유효성을 체크하겠다는 의미이다.

 

그렇다면, 테스트를 해보자.

입력없이 request

{ "message": [ "title should not be empty", "description should not be empty" ], "error": "Bad Request", "statusCode": 400}

성공적으로 핸들러에서 에러를 처리하는 모습을 볼 수 있다.

 

 

특정 게시물을 찾을 때 없는 경우 결과 값 처리

현재 특정 게시물을 ID로 가져올 때 없는 아이디의 게시물을 가져오려고 한다면 결과값으로 아무 내용없이 돌아온다. 

  getBoardById(id: string): Board {
    const found = this.boards.find((board) => board.id === id);
    if (!found) throw new NotFoundException();
    return found;
  }

이를 해결하기 위해서는 service로직에서 erorr를 return 해주면 된다. Board객체를 return한다고 되어있지만, error를 return해도 별다른 문제가 발생하지 않는 듯 하다.

http://localhost:3001/boards/1

위 요청을 보내보니

{ "message": "Not Found", "statusCode": 404}

위처럼 명확한 error code와 message가 오는 모습이다. 만약, 해당 message가 명확하지 않다고 생각하여 추가 문구를 적고 싶다면 아래와 같이 수정해주면 된다.

  getBoardById(id: string): Board {
    const found = this.boards.find((board) => board.id === id);
    if (!found) throw new NotFoundException(`Can't find Board with id ${id}`);
    return found;
  }
{ "message": "Can't find Board with id 1", "error": "Not Found", "statusCode": 404}

 

 

없는 게시물을 지우려할 때 결과 값 처리

  deleteBoardById(id: string): void {
    // filter 이후 로직은 id가 같은 것만 남기고 다른 것은 지우는 로직
    const found = this.getBoardById(id);
    this.boards = this.boards.filter((board) => board.id !== found.id);
  }

이전에 작성한 getBoardById() 함수를 통해 에러를 처리해주면 된다. (재사용)

 

 

커스텀 파이프를 이용한 유효성 체크

구현 방법

PipeTransform interface를 새롭게 만들 커스텀 파이프에 구현해줘야 한다. PipeTransform interface는 모든 파이프에서 구현해줘야 하는 인터페이스이다. 해당 Interface는 transform() 메소드를 필요로 하는데, nest.js가 인자를 처리하기 위해서 사용된다.

 

transform 메소드

  • value
  • metadata

위 두개의 인자를 가진다. 해당 메소드에서 return된 값은 route 핸들러로 전해지고, 예외가 발생한다면 클라이언트에 바로 전달된다.

 

실제 사용

import { ArgumentMetadata, PipeTransform } from "@nestjs/common";

export class BoardStatusValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    console.log("value", value);
    console.log("metadata", metadata);
     
    return value;
  }
}

위와 같이 PipeTransform class를 만들고

  @Patch("/:id/status")
  updateBoardStatus(
    @Param("id") id: string,
    @Body("status", BoardStatusValidationPipe) status: BoardStatus
  ) {
    return this.boardsService.updateBoardStatus(id, status);
  }

위와 같이 사용하고 싶은 곳에 넣어주면 된다. 

직접 돌려보면, Pipe 코드를 읽어 log가 출력되는 것을 확인할 수 있다. 그렇다면, 현재 enum에 없는 값으로 update해도 작동하고 있는데 pipe를 통해 막아보자.

 

export class BoardStatusValidationPipe implements PipeTransform {
  readonly StatusOptions = [BoardStatus.PRIVATE, BoardStatus.PUBLIC];

  transform(value: any, metadata: ArgumentMetadata) {
    value = value.toUpperCase();

    if (!this.isStatusValid(value))
      throw new BadRequestException(`${value} isn't in the status`);

    return value;
  }

  private isStatusValid(status: any): boolean {
    const index = this.StatusOptions.indexOf(status);
    return index !== -1;
  }
}

코드를 위와같이 변경해주고 테스트해보자.

성공 실패

정상적으로 예외처리하는 것을 확인할 수 있다.

 

 

PostgresSQL 적용

https://www.postgresql.org/ftp/pgadmin/pgadmin4/v8.9/macos/

https://postgresapp.com/?downloads.html

다운로드가 되어있지 않았다면 위 링크를 통해 다운받아 주면 된다.

데이터베이스는 위와같이 만들어주었다.

 

 

TypeORM

node.js에서 실행되고 TypeScript로 작성된 객체 관계형 매퍼 라이브러리

TypeORM은 MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, Oracle, SAP Hana 및 WebSQL 같은 여러 데이터베이스를 지원한다.

 

 

ORM

객체와 RDB의 데이터를 자동으로 변형 및 연결하는 작업이다.

ORM을 이용한 개발은 객체와 데이터베이스의 변형에 유연하게 사용할 수 있다.

 

TypeORM vs Pure Javascript

typeORM

const boards = Board.find{{title:'hello', status:'PUBLIC'};

pure javascript

db.query('SELECT * FROM boards WHERE title = "hello" AND status = "PUBLIC", (err, result)=>
{
	if(err) {
    	throw new Error('Error')
    }
    boards = result.rows;
}

 

TypeORM 특징과 이점

  • 모델을 기반으로 데이터베이스 테이블 체계를 자동으로 생성
  • 데이터베이스에서 개체를 쉽게 삽입, 업데이트 및 삭제할 수 있다.
  • 테이블 간의 매핑(일대일, 일대 다 및 다대 다)을 만든다.
  • 간단한 CLI 명령을 제공
  • TypeORM은 간단한 코딩으로 ORM 프레임워크를 사용하기 쉽다.
  • TypeORM은 다른 모듈과 쉽게 통합된다.

 

TypeORM 애플리케이션에서 이용

필요 모듈

  • @nestjs/typeorm
    • nest.js에서 TypeORM을 사용하기 위해 연동시켜주는 모듈
  • typeorm
    • TypeORM 모듈
  • pg
    • Postgres 모듈
npm install pg typeorm @nestjs/typeorm --save

 

 

TypeORM 애플리케이션 연결

typeORM 설정파일 생성

import { TypeOrmModuleOptions } from "@nestjs/typeorm";

export const typeORMConfig : TypeOrmModuleOptions = {
    type: "postgres",
    host: "localhost",
    port: 5432,
    username: "postgres",
    password: "postgres",
    database: "board-app",
    entities: [__dirname + '/../**/*.entity.{js,ts}'],
    synchronize: true
  }

app.module.ts인 root module에서 import

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

 

게시물을 위한 엔티티 생성

DB를 생성하기 위해서는 CREATE TABLE ...의 쿼리를 통해 생성해주어야 한다. 하지만, TypeORM을 사용할 때는 데이터베이스 테이블로 변환되는 Class이기 때문에 쿼리문을 작성하지 않고, 클래스 안에서 컬럼들을 정의만 해주면 된다.
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { BoardStatus } from "./boards.model";

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

  @Column()
  title: string;

  @Column()
  description: string;

  @Column()
  status: BoardStatus;
}
  • @Entity
    • 해당 클래스가 엔티티임을 나타내는 데 사용. CREATE TABLE '클래스 이름' 부분이다.
  • @PrimaryGeneratedColumn()
    • id 열이 클래스 엔티티의 기본 키 열임을 나타내는 데 사용
  • @Column
    • 엔티티의 title 및 description과 같은 다른 열을 나타내는 데 사용

자동적으로 mapping되는 table이 생성이 된다.

생성된 모습

 

 

Repository 생성

board.repository.ts 파일 생성 

import { DataSource, Repository } from "typeorm";
import { Board } from "./board.entity";

export class BoardRepository extends Repository<Board> {
  constructor(dataSource: DataSource) {
    super(Board, dataSource.manager);
  }
}

생성한 Repository를 다른 곳에서 사용할 수 있게 하기 위해 (Injectable) board.moduel에서 import 시킴

@Module({
  imports: [TypeOrmModule.forFeature([Board])],
  controllers: [BoardsController],
  providers: [
    BoardService,
    BoardRepository,
  ]
})
export class BoardsModule {}

 

 

CURD local -> DB

일단, Controller와 Service 부분은 Board라는 객체로 저장했기 때문에 해당 코드들은 주석처리 해줍니다.

 

Board interface 삭제

export enum BoardStatus {
  PUBLIC = "PUBLIC",
  PRIVATE = "PRIVATE",
}

BoardStatus만 냄겨두고 board-status.enum로 이름을 바꿔줌


Service에 Repository 넣어주기 (Repository Injection)

@Injectable()
export class BoardsService {
  
  // Inject Repository to Service
  constructor (
    @InjectRepository(BoardRepository)
    private boardsRepository: BoardRepository,
  ) {}
}

- @InjectRepository를 이용해서 해당 서비스에서 BoardRepository를 이용한다고 boardRepository 변수에 넣어줌

 

Service에 getBoardById 메소드 생성

  async getBoardById(id: number): Promise<Board> {
    const found = await this.boardRepository.findOneBy({id});

    if(!found) {
      throw new NotFoundException(`Can't find Board with id ${id}`);
    }

    return found;
  }

- typeOrm에서 제공하는 findOne 메소드 사용

- async await를 이용해서 데이터베이스 작어비 끝난 후 결과값을 받을 수 있도록 함

 

게시글 생성

BoardService

  async createBoard(createBoardDto: CreateBoardDto): Promise<Board> {
    const {title, description} = createBoardDto;

    const board = this.boardRepository.create({
      title,
      description,
      status: BoardStatus.PUBLIC
    })

    await this.boardRepository.save(board);
    return board;
  }

BoardController

  @Post()
  @UsePipes(ValidationPipe)
  createBoard(@Body() createBoardDto: CreateBoardDto): Promise<Board> {
    return this.boardsService.createBoard(createBoardDto);
  }

postman으로 테스트해보면 postgresql에 잘 생성되는 모습을 볼 수 있다.

 

service에 있는 db관련된 코드를 repository로 이동

@Injectable()
export class BoardRepository extends Repository<Board> {
  constructor(dataSource: DataSource) {
    super(Board, dataSource.createEntityManager());
  }

  async createBoard(createBoardDto: CreateBoardDto): Promise<Board> {
    const {title, description} = createBoardDto;

    const board = this.create({
      title,
      description,
      status: BoardStatus.PUBLIC
    })

    await this.save(board);
    return board;
  }
}

Service 수정

  createBoard(createBoardDto: CreateBoardDto): Promise<Board> {
    return this.boardRepository.createBoard(createBoardDto);
  }

 

typeORM에 remove? delete? 같은거 아닌가??

remove

무조건 존재하는 아이템을 remove 메소드를 이용해서 지워야 한다. 그렇지 않으면 에러가 발생한다. (404 error)

delete

아이템이 존재하면 지우고, 존재하지 않으면 아무런 영향이 없다.

그렇다면, delete로 지워보자.

Service 코드추가

  async deleteBoard(id: number): Promise<void> {
    const result = await this.boardRepository.delete(id);
    
    if(result.affected === 0) {
      throw new NotFoundException(`Can't find Board with id ${id}`);
    }
  }

Controller 코드 추가

  @Delete('/:id')
  deleteBoard(@Param('id', ParseIntPipe) id: number): Promise<void> {
    return this.boardsService.deleteBoard(id);
  }

Postman으로 Delete Method를 통해 요청을 보내면 정상적으로 지워지는 걸 확인할 수 있다.

 

Update 추가

Service 코드 추가

  async updateBoardStatus(id: number, status: BoardStatus): Promise<Board> {
    const board = await this.getBoardById(id);

    board.status = status;
    await this.boardRepository.save(board);

    return board;
  }

Controller 코드 추가

  @Patch("/:id/status")
  updateBoardStatus(
    @Param("id", ParseIntPipe) id: number,
    @Body("status", BoardStatusValidationPipe) status: BoardStatus
  ): Promise<Board> {
    return this.boardsService.updateBoardStatus(id, status);
  }

 

모든 게시물 가져오기

Service 코드 추가

  async getAllBoards(): Promise<Board[]> {
    return this.boardRepository.find();
  }

Controller 코드 추가

  @Get()
  getAllBoard(): Promise<Board[]> {
    return this.boardsService.getAllBoards();
  }

 

 

 

 

 

 

 

 

 

 

 

 

Reference

 

'Javascript' 카테고리의 다른 글

유저와 게시물 관계  (0) 2024.07.21
로그인 기능 구현  (0) 2024.07.21
Nest.js Pipes  (0) 2024.06.17
[Nest.js] Module  (0) 2024.06.11
ES6  (0) 2024.06.05

+ Recent posts