저장을 습관화

Nest.js 연습 - 게시판 만들기 2 본문

공부/node.js

Nest.js 연습 - 게시판 만들기 2

ctrs 2023. 8. 5. 00:18

0. 기능

1) 게시물 작성

작성자 이름

게시물 비밀번호 - 게시물 수정 및 삭제용

게시물 제목

게시물 내용

 

2) 게시물 목록 조회

페이지네이션 - 생략

게시물 번호

게시물 제목

작성자 이름

작성일자

 

3) 게시물 상세 조회

작섲아 이름

게시물 제목

게시물 내용

 

4) 게시물 수정

게시물 비밀번호 - 일치 여부 확인 후 수정할 수 있도록

게시물 제목

게시물 내용

 

5) 게시물 삭제

게시물 비밀번호 일치 여부 확인 후 삭제할 수 있도록

 

 

 

1. lodash 패키지 설치

JavaScript에서 배열과 같은 데이터의 구조를 간편하게 함수형으로 다룰 수 있게 해주는 라이브러리이다.

[참조]

https://inpa.tistory.com/entry/LODASH-%F0%9F%93%9A-Lodash-vs-ES6-%EC%84%A4%EC%B9%98-%EC%9B%90%EB%A6%AC

 

📚 Lodash 소개 & ES6 자바스크립트와 비교

Lodash 라이브러리 Lodash(로대쉬)는 JavaScript의 인기있는 라이브러리 중 하나로 제이쿼리, 리액트와 같이 전세계적으로 가장 많이 사용되는 라이브러리이다. Jquery가 자바스크립트 DOM을 간편하게 다

inpa.tistory.com

 

$ cd .. 
// package.json 파일이 존재하는 프로젝트의 루트 디렉토리로 이동하자

$ npm i lodash

 

tsconfig.json 속성 추가

{
  "compilerOptions": {
    ...(생략)...,
    "esModuleInterop": true
  }
}

ES6 모듈 사양을 준수하여 CommonJS 모듈을 가져올 수 있게 한다.

 

 

 

2. 컨트롤러 코드 작성

/src/board/board.controller.ts

import { Controller, Get, Post, Put, Delete } from '@nestjs/common';
import { BoardService } from './board.service';

@Controller('board')
export class BoardController {
  // 서비스 주입
  constructor(private readonly boardService: BoardService) {}

  // 게시물 목록을 가져오는 API
  @Get('/articles')
  getArticles() {
    return this.boardService.getArticles();
  }

  // 게시물 상세보기 -> 게시물 ID로 확인
  @Get('/articles/:id')
  getArticleById() {
    return this.boardService.getArticleById(id);
  }

  // 게시물 작성
  @Post('/articles')
  createArticle() {
    // 게시물의 내용은 어떻게 넣을 것인가?
    return this.boardService.createArticle();
  }

  // 게시물 수정
  @Put('/articles/:id')
  updateArticle() {
    // 게시물의 내용은 어떻게 넣을 것인가?
    return this.boardService.updateArticle(id);
  }

  // 게시물 삭제
  @Delete('/articles/:id')
  deleteArticle() {
    return this.boardService.deleteArticle(id);
  }
}

 

Express 사용할때는 클라이언트와 데이터를 주고 받을때 req, res 문법을 사용했지만

Nest.js를 사용하여 데이터를 입력할때는 DTO를 사용한다.

 

DTO (Data Transfer Object)는 데이터를 전송하기 위해 작성된 객체이다.

Nest.js에서 모든 데이터는 DTO를 통해 운반된다.

 

 

 

3. DTO를 사용하기 위한 패키지 설치

// 설치전 루트 디렉토리인지 확인

$ npm install class-validator class-transformer

class-validator는 입력값 유효성 검사를 위한 다양한 기능을 제공하고,

class-transformer는 객체를 클래스로, 클래스를 객체로 변환한다.

 

 

 

4. 게시글 생성, 수정, 삭제 API 작성

- /src/board/create-article.dto.ts

import { IsNumber, IsString } from 'class-validator';

export class CreateArticleDto {
  @IsString()
  readonly title: string;

  @IsString()
  readonly content: string;

  @IsNumber()
  readonly password: number;
}

 

- /src/board/update-article.dto.ts

import { IsNumber, IsString } from 'class-validator';

export class UpdateArticleDto {
  @IsString()
  readonly title: string;

  @IsString()
  readonly content: string;

  @IsNumber()
  readonly password: number;
}

 

- /src/board/delete-article.dto.ts

import { IsNumber } from 'class-validator';

export class DeleteArticleDto {
  @IsNumber()
  readonly password: number;
}

 

@IsString(), @IsNumber()는 Nest.js가 아닌 class-validator가 제공하는 데코레이터이다.

위 코드에서 클라이언트로부터 전달받는 title, content를 DTO 객체의 변수로 선언했고

title, content 둘 중 하나라도 String 타입이 아닐 경우 400에러를 리턴한다.

 

 

 

5. @nestjs/mapped-types 패키지의 PartialType, PickType

 

게시글 생성과 수정 API를 작성한 것은 좋은데,

create-article.dto.ts와 update-article.dto.ts 파일의 내용이 사실상 간다.

이를 조금 더 깔끔하게 표현해보자

 

게시글의 생성과 수정에는 모두 title, content, password가 필요한데,

PartialType을 상속받아 "updateArticleDto는 CreateArticleDto 클래스의 부분집합이다." 라고 선언한다면

코드 복사 + 붙여넣기를 하지 않아도 동일한 효력을 가질 수 있다.

 

부분집합은 해당 필드가 전부 포함되어도 성립되고, 특정 필드가 생략되어도 성립된다.

 

$ npm install @nestjs/mapped-types

 

만약 class-validator 의존성 이슈로 설치에 실패한다면 아래 방법을 이용하자.

$ npm uninstall class-validator
$ npm i @nestjs/mapped-types
$ npm i class-validator

의존성 이슈가 발생하는 패키지는 삭제 후, 제일 마지막으로 설치하도록 하자.

 

- /src/board/update-article.dto.ts 수정

import { PartialType } from '@nestjs/mapped-types';
import { CreateArticleDto } from './create-article.dto';

export class UpdateArticleDto extends PartialType(CreateArticleDto) {}

 

이 방법은 DeleteArticleDto에도 적용할 수 있다.

다만 게시물을 삭제하는데 title, content를 굳이 받아올 필요는 없으니

PickType을 상속받아 "나는 CreateArticleDto 클래스에서 특정 필드만 필요해"라고 선언하자.

 

- /src/board/delete-article.dto.ts 수정

import { PickType } from '@nestjs/mapped-types';
import { CreateArticleDto } from './create-article.dto';

export class DeleteArticleDto extends PickType(CreateArticleDto, [
  'password',
] as const) {}

 

 

6. main.ts 파일 수정

import { ValidationPipe } from '@nestjs/common'; // 이 문장 추가
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({ transform: true })); // 이 문장 추가
  await app.listen(3000);
}
bootstrap();

app.useGlobalPipes(new ValidationPipe()); 이 문장을 통해

validation을 사용, 클라이언트로부터 전달 받은 데이터가 유효하지 않다면 400 에러를 리턴한다.

 

안에 들어가는 { transform: true } 는 기존에 설치했던 class-transformer의 옵션이다.

이에 대한 설명은 9-3. 게시글 상세 조회 부분에서 이어진다.

 

 

 

7. 컨트롤러 /src/board/board.controller.ts 내용 수정

import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
} from '@nestjs/common';
import { BoardService } from './board.service';
import { CreateArticleDto } from './create-article.dto';
import { UpdateArticleDto } from './update-article.dto';
import { DeleteArticleDto } from './delete-article.dto';

@Controller('board')
export class BoardController {
  // 서비스 주입
  constructor(private readonly boardService: BoardService) {}

  // 게시물 목록을 가져오는 API
  @Get('/articles')
  getArticles() {
    return this.boardService.getArticles();
  }

  // 게시물 상세보기 -> 게시물 ID로 확인
  @Get('/articles/:id')
  getArticleById(@Param('id') articleId: number) {
    return this.boardService.getArticleById(articleId);
  }

  // 게시물 작성
  @Post('/articles')
  createArticle(@Body() data: CreateArticleDto) {
    return this.boardService.createArticle(
      data.title,
      data.content,
      data.password,
    );
  }

  // 게시물 수정
  @Put('/articles/:id')
  updateArticle(
    @Param('id') articleId: number,
    @Body() data: UpdateArticleDto,
  ) {
    return this.boardService.updateArticle(
      articleId,
      data.title,
      data.content,
      data.password,
    );
  }

  // 게시물 삭제
  @Delete('/articles/:id')
  deleteArticle(
    @Param('id') articleId: number,
    @Body() data: DeleteArticleDto,
  ) {
    return this.boardService.deleteArticle(articleId, data.password);
  }
}

 

@Param, @Body 데코레이터가 추가되었다.

Express에서 사용하던 파라미터와 바디와 같다.

 

게시물 수정 부분에서 ('/article/:id') 에서 :id는 파라미터를 뜻하므로

이를 가져오기 위해서 @Param 데코레이터에 가지고 올 파라미터 이름을 넘긴다.

 

바디를 가져오기 위해서는 @Body 데코레이터를 사용한다.

req.body로 전달되는 데이터를 UpdateArticleDto 객체의 data라는 변수로 받겠다는 의미이다.

 

이때 데이터 타입이 잘못되었다면 validationPipe로 인해 400에러가 리턴된다.

 

 

 

8. 서비스 코드 작성

- /src/board/board.service.ts

import {
  Injectable,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common';
import _ from 'lodash';

@Injectable()
export class BoardService {
  // 데이터베이스를 사용하지 않아 일단은 배열로 구현을 하였다.
  // 보통은 TypeORM 모듈을 이용하여 리포지토리를 의존한다.
  private articles = [];

  // 게시글 비밀번호를 저장하기 위한 Map 객체
  private articlePasswords = new Map();

  // 게시물 목록 조회
  getArticles() {
    return this.articles;
  }

  // 게시글 상세 조회
  getArticleById(id: number) {
    return this.articles.find((article) => {
      return article.id === id;
    });
  }

  // 게시글 작성
  createArticle(title: string, content: string, password: number) {
    const articleId = this.articles.length + 1;
    this.articles.push({ id: articleId, title, content });
    this.articlePasswords.set(articleId, password);
    return articleId;
  }

  // 게시글 수정
  updateArticle(id: number, title: string, content: string, password: number) {
    if (this.articlePasswords.get(id) !== password) {
      throw new UnauthorizedException(
        `Article password is not correct. id: ${id}`,
      );
    }

    const article = this.getArticleById(id);
    if (_.isNil(article)) {
      throw new NotFoundException(`Article not found. id: ${id}`);
    }

    article.title = title;
    article.content = content;
  }

  // 게시글 삭제
  deleteArticle(id: number, password: number) {
    if (this.articlePasswords.get(id) !== password) {
      throw new UnauthorizedException(
        `Article password is not correct. id: ${id}`,
      );
    }

    this.articles = this.articles.filter((article) => article.id !== id);
  }
}

 

이번 예시에서는 DB를 사용하지 않기 때문에

article의 정보를 메모리에 저장/수정/삭제 하고, 수정/삭제 시에는 게시물 비밀번호를 체크하도록 하였다.

 

Nest.js에서는 별도의 return을 통한 에러처리를 할 필요 없이

UnauthorizedException, NotFoundException라는 이미 정의된 예외사항을 throw하면 된다.

 

 

9. 작동 테스트

웹 서버 실행

$ npm start

> ctrs-nest@0.0.1 start
> nest start

[Nest] 36668  - 2023. 08. 05. 오전 12:01:25     LOG [NestFactory] Starting Nest application...
[Nest] 36668  - 2023. 08. 05. 오전 12:01:25     LOG [InstanceLoader] AppModule dependencies initialized +15ms
[Nest] 36668  - 2023. 08. 05. 오전 12:01:25     LOG [InstanceLoader] BoardModule dependencies initialized +2ms
[Nest] 36668  - 2023. 08. 05. 오전 12:01:25     LOG [RoutesResolver] AppController {/}: +28ms
[Nest] 36668  - 2023. 08. 05. 오전 12:01:25     LOG [RouterExplorer] Mapped {/, GET} route +2ms
[Nest] 36668  - 2023. 08. 05. 오전 12:01:25     LOG [RoutesResolver] BoardController {/board}: +1ms
[Nest] 36668  - 2023. 08. 05. 오전 12:01:25     LOG [RouterExplorer] Mapped {/board/articles, GET} route +0ms
[Nest] 36668  - 2023. 08. 05. 오전 12:01:25     LOG [RouterExplorer] Mapped {/board/articles/:id, GET} route +1ms
[Nest] 36668  - 2023. 08. 05. 오전 12:01:25     LOG [RouterExplorer] Mapped {/board/articles, POST} route +1ms
[Nest] 36668  - 2023. 08. 05. 오전 12:01:25     LOG [RouterExplorer] Mapped {/board/articles/:id, PUT} route +0ms
[Nest] 36668  - 2023. 08. 05. 오전 12:01:25     LOG [RouterExplorer] Mapped {/board/articles/:id, DELETE} route +1ms
[Nest] 36668  - 2023. 08. 05. 오전 12:01:25     LOG [NestApplication] Nest application successfully started +3ms

 

현재 VSC와 thunder client의 버전 이슈로 

URL을 0.0.0.0:3000 으로 적었다.

 

원래라면 localhost:3000 이나, 127.0.0.1:3000 이 맞다.

 

9-1. 게시글 작성

예외 사항) body에 빈 JSON을 보냈을때

400에러를 리턴하며 title, content, password에 있는 문제를 말해준다.

 

express에서 if문을 사용했을때 한번에 하나의 예외사항만을 리턴받던 것에 비하면 훨씬 편하다.

 

 

9-2. 게시글 전체 조회

 

9-3. 게시글 상세 조회

 

컨트롤러의 게시글 조회 코드를 다시 보면

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

파라미터 id를 number 타입 articleId로 받겠다고 적었지만,

실제로 articleId의 타입은 string으로 저장된다.

 

URI에 들어가는 파라미터는 항상 string으로 표현되기 때문이다.

 

articleId에 타입 변화를 주기 위해 main.ts 에서

app.useGlobalPipes(new ValidationPipe({ transform: true })); 옵션을 줬었다.

 

만약 main.ts의 내용이 아래와 같이 class-transformer 옵션이 없다면

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

게시글을 조회할 수 없게 된다.

 

 

9-4. 게시글 수정

예외 사항) 패스워드를 틀렸을때

 

패스워드를 정상적으로 입력했을때

 

수정됨 확인

 

 

9-5. 게시글 삭제

예외 사항) 패스워드를 틀렸을때

 

패스워드를 정상적으로 입력했을 때

 

게시글 삭제됨 확인