본문 바로가기

Development/Web

Nest JS #2 - 로컬 메모리를 사용한 DB 로직 구현(Model, DTO, Pipe)

들어가며

이전 과정에 이어서 이번에는 데이터베이스 로직을 구현하는 내용을 정리하였다.

데이터베이스 로직 구현

데이터베이스와 관련한 로직을 처리하는 부분은 Service에서 담당한다. 지금부터 BoardsService를 구현해볼텐데, 바로 데이터베이스와 연결하면 헷갈릴 수 있으니, 로컬 메모리를 사용하여 처리하는 방식으로 구현해보겠다. 먼저, 데이터를 저장할 boards 변수를 배열로 초기화한다.

@Injectable()
export class BoardsService {
  private boards = [];
}

이때, private 접근 제한자를 사용한 이유는 BoardsController에서 private 접근 제한자를 사용한 이유와 동일하다. 즉, 다른 컴포넌트에서 BoardsService에 접근하였을 때 boards 배열에 담긴 값을 직접 수정하지 못하도록 하기 위함이다.

데이터 조회

다른 컴포넌트에서 boards 배열에 담긴 데이터를 받을 수 있도록 아래와 같이 getAllBoards 메소드를 선언해주었다.

@Injectable()
export class BoardsService {
  private boards = [];

  getAllBoards() {
    return this.boards;
  }
}

이어서 클라이언트로의 GET 요청을 통해 전달받은 end-point(/boards)에 따라 BoardsController에서 처리할 수 있도록 아래와 같이 메소드를 선언하였다(v0.0.5).

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

  @Get()
  getAllBoards() {
    return this.boardsService.getAllBoards();
  }
}

Nest를 실행(npm run start:dev)하고, localhost:3000/boards에 접속하면 현재 BoardsService의 boarders 배열에 저장된 데이터를 확인할 수 있다.

BoardModel 생성

게시물을 나타내기 위해서는 어떠한 데이터가 필요한지 정의해주기 위해서 게시물의 Model을 생성하도록 한다. 이를테면, 게시물 데이터에는 해당 게시물의 식별값(ID), 제목, 내용 등이 포함될 수 있다. 먼저, boards Module 디렉토리에 board.model.ts파일을 생성한다. Model은 Class 또는 Interface를 통해 생성할 수 있으며, Interface로 생성하는 경우에는 변수의 Type만을 체크하고, Class로 생성하는 경우에는 변수의 Type 체크와 인스턴스를 생성할 수 있다. 먼저, Interface로 나타내면 다음과 같다.

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

이중에서 살펴볼 것은 status인데, 이는 공개/비공개 여부를 설정하는 데이터로 두 가지 값만 입력받을 수 있다. 따라서, 두 가지 값만 입력받을 수 있도록 아래와 같이 enum(enumerator)을 사용하여 BoardStatus를 정의하고, 이를 Interface에서 Type으로 사용할 수 있도록 한다(v0.0.6).

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

BoardModel 타입 적용

위에서 생성한 BoardModel의 Type을 Service의 변수와 메소드에 배열 형태로 정의한다.

@Injectable()
export class BoardsService {
  private boards: Board[] = [];

  getAllBoards(): Board[] {
    return this.boards;
  }
}

그리고 Service의 getAllBoard 메소드로부터 전달받은 값은 Controller의 getAllBoard 메소드를 통해 클라이언트에게 응답으로 전달해주므로 Controller에도 BoardModel의 Type을 배열로 정의한다(v0.0.7).

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

이와 같이 Type을 정의하는 것은 선택사항이나, Type을 정의해주면 해당 Type과 일치하지 않는 코드를 사용하는 경우 에러를 발생시킴으로써 의도치 않은 오류를 방지할 수 있을 뿐더러 코드를 읽는 사람으로 하여금 코드를 이해하는데 도움을 준다.

데이터 생성

위에서 정의한 BoardModel을 사용하여 게시물 데이터를 생성하는 기능을 구현해보겠다. 게시물에 관한 로직을 처리하는 Service에서 로직을 처리한 후 Controller에서 Service를 불러오는 형태로 구현하였다.

먼저, Service에 createBoard 메소드를 통해 해당 게시물 데이터를 생성하는 부분을 구현하면 다음과 같다.

createBoard(title: string, description: string) {
  const board: Board = {
    title,
    description,
    status: BoardStatus.PUBLIC,
  };

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

이와 같이 작성하면 아래와 같은 오류 문구를 확인할 수 있는데, 이는 BoardModel의 id를 정의해주지 않아서 발생하는 오류이다.

const board: Board
'id' 속성이 '{ title: string; description: string; status: BoardStatus.PUBLIC; }' 형식에 없지만 'Board' 형식에서 필수입니다.

uuid 모듈 사용

게시물의 id는 고유한 식별값이며, 데이터베이스를 사용할 때에는 알아서 Unique한 값으로 저장한다. 그러나, 현재는 데이터베이스를 사용하지 않고 로컬 메모리를 사용하여 구현할 것이기 때문에 임의로 식별 값을 생성하기 위해 uuid 모듈을 사용하였다.

$ npm i -s uuid

설치한 uuid를 다음과 같이 import하고 그 다음 코드와 같이 board에 id를 추가하도록 한다(v0.0.8).

import { v1 as uuid } from 'uuid';
createBoard(title: string, description: string) {
  const board: Board = {
    id: uuid(),
    title,
    description,
    status: BoardStatus.PUBLIC,
  };

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

Controller 메소드 구현

이어서 Service에서 구현한 메소드를 Controller에서 사용할 수 있도록 구현해보겠다.

먼저, express에서는 클라이언트의 요청에 담긴 데이터를 확인하는 코드는 다음과 같다.

app.post('/', (req, res) => {
    board = { ...req.body };
});

Nest에서는 @Body 데코레이터를 사용하여 클라이언트의 요청 데이터를 가져올 수 있으며, 데이터를 하나씩 가져오고자 할 때에는 @Body('title')과 같이 사용하면 된다(v0.0.9).

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

서버를 실행(npm run start:dev)하고 PostMan에서 localhost:3000/boards로 POST 요청을 하면, 다음과 같이 성공적으로 데이터가 생성된 것을 확인할 수 있다.

DTO

위에서 구현한 BoardModel는 4개의 field(id, title, description, status)로 구성되어 있으며, 클라이언트가 요청 시 Controller를 통해 titledescription을 전달받고, 이를 Service에서 처리한다는 사실을 알고 있다. 그렇다면, 현재 BoardModel에 새로운 property를 추가하거나, 제거하는 경우 Controller와 Service 코드 또한 수정해야 한다는 것을 알 수 있다. 만약, BoardModel의 property가 많아지는 경우 Controlller와 Service의 코드를 각각 수정해주어야 하는데, 이 과정에서 발생한 실수로 인해 오류가 발생할 수 있다. 또한, 유지보수 관점에서도 효율적이지 못하다는 문제가 있는데, 이를 해결하기 위해 DTO를 사용한다.

DTO는 Data Transfer Object로 계층 간 데이터 교환을 위한 객체이다. 이는 데이터베이스의 데이터를 Service나 Controller 등으로 보낼 때 사용하는 객체를 말하며, 데이터가 네트워크를 통해 전송되는 방법을 정의하는 객체이다. DTO는 Interface나 Class를 통해 정의될 수 있는데, Nest JS에서는 Class를 통해 정의하는 것을 권장하고 있다. DTO를 사용하면 데이터 유효성을 확인하는데 효율적이며, 비교적 안정적인 코드로 만들어주고, TypeScript의 Type으로도 사용된다.

CreateBoardDto 생성

먼저, boards Module 디렉토리에 dto 디렉토리를 생성하고, 해당 디렉토리 안에 create-board.dto.ts 파일을 생성한다. DTO는 앞서 언급하였듯이 Class와 Interface로 나타낼 수 있는데, Nest JS의 권장사항과 더불어 RunTime에서 작동되어 Pipe와 같은 기능을 이용할 때 더 유용한 Class를 사용하여 다음과 같이 DTO를 생성하였다(v0.1.0). Pipe에 대한 내용은 뒤에서 다루도록 하겠다.

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

CreateBoardDto 적용

이제 위에서 생성한 DTO를 Service와 Controller에 각각 적용하겠다. 먼저, Service에 해당 DTO를 다음과 같이 적용할 수 있다.

createBoard(createBoardDto: CreateBoardDto) {
  const { title, description } = createBoardDto;
  const board: Board = {
    id: uuid(),
    title,
    description,
    status: BoardStatus.PUBLIC,
  };

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

마찬가지로 Controller에 DTO를 적용하면 다음과 같다(v0.1.1).

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

Param을 사용한 데이터 처리

이번에는 Nest JS에서 parameter를 어떻게 처리하는지에 대해서 다루어보겠다. Nest에서는 @Param() 데코레이터를 사용하여 URL에 담긴 parameter를 가져올 수 있으며, parameter가 한 개 일때와 여러 개일 때는 각각 아래 코드와 같다.

findOne(@Param('id') id: string) {}
findOne(@Param() params: string[]) {}

특정 게시물 조회 기능 구현

특정 게시물의 데이터를 요청하는 기능을 구현해보겠다. 먼저, Service에 getBoardById 메소드를 구현하면 다음과 같다.

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

이어서 Controller에서 Service의 getBoardById 메소드를 사용한 결과를 클라이언트에게 전달하는 기능을 구현하면 다음과 같다(v0.1.2).

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

특정 게시물 삭제 기능 구현

특정 게시물을 삭제하는 기능은 다음과 같은 코드로 구현할 수 있다(v0.1.3).

deleteBoardById(id: string): void {
  this.boards = this.boards.filter((board) => board.id !== id);
}
@Delete('/:id')
deleteBoardById(@Param('id') id: string): void {
  this.boardsService.deleteBoardById(id);
}

특정 게시물의 공개여부 상태 변경 기능 구현

특정 게시물의 공개여부 상태를 변경하는 기능은 다음과 같은 코드로 구현할 수 있다(v0.1.4).

updateBoardStatusById(id: string, status: BoardStatus): Board {
  const board = this.getBoardById(id);
  board.status = status;
  return board;
}
@Patch('/:id/status')
updateBoardStatus(
  @Param('id') id: string,
  @Body('status') status: BoardStatus,
): Board {
  return this.boardsService.updateBoardStatusById(id, status);
}

Pipe

위에서 구현한 기능은 모두 id를 기준으로 데이터를 처리한다는 공통점이 있다. 만약, 클라이언트가 parameter로 전달해준 id가 데이터베이스에는 존재하지 않는 경우에는 별도의 예외처리가 필요한데, 이는 Pipe를 통해 구현할 수 있다. Pipe는 @injection() 데코레이터 주석이 달린 Class이며, 데이터 변환(Data transformation)과 유효성 검사(Data vaildation)를 위해 사용된다. 여기서 데이터는 아래 그림과 같이 Controller의 Route Handler로 전달되는 인수를 의미한다.

Nest JS에서는 메소드가 호출되기 전에 삽입된 Pipe를 통해 메소드로 향하는 인수를 받아 DT또는 DV를 처리하며, 그 결과에 따라 에러를 발생시키거나 정상적으로 메소드를 실행할 수 있다. 이를 한 마디로 요약하자면, express의 middleware와 비슷한 개념이라고 할 수 있겠다.

Nest JS에는 기본적으로 6개의 PIpe가 존재하며, 이들을 나열하면 VaildationPipe, ParseInePipe, ParseBoolPipe, ParseArrayPipe, ParseUUIDPipe, DefaultValuePipe으로, 이름을 보면 어떠한 일을 처리하는지 짐작할 수 있다.

Data Transformation

위에서 구현한 기능 중에서 클라이언트가 특정 게시물의 공개여부 상태를 변경하는 요청을 보낸 경우 요청 Body에 포함된 status의 Type은 BoardStatus가 아닌 string일 것이다.

{ "status": "PUBLIC" }

그러므로 이를 Controller에게 전달 후 Service에서 로직을 처리하도록 놔두면 Type Error가 발생하게 된다. 따라서, 클라이언트로부터 받은 요청 데이터를 Controller에게 넘겨주기 전에 status의 Type을 string에서 BoardStatus로 변환해주어야 하며, 이는 Pipe를 통해 수행된다.

const status = "PUBLIC" ? BoardStatus.PUBLIC : BoardStatus.PRIVATE

Data Vaildation

회원가입 기능을 구현한 상태라고 가정하고 한 가지 경우를 생각해보자. 사용자의 Model에는 아이디와 비밀번호를 비롯한 여러 property가 있을 수 있으며, 그 중 비밀번호는 반드시 8자리 이상으로 저장되어야 하는 상황이다. 만약, 클라이언트로부터 전달받은 데이터 중에 비밀번호의 길이가 4인 경우에는 회원가입 로직이 실행되서는 안 된다(물론, 이 과정은 클라이언트 측에서 서버로 요청을 보낼 때, 정규표현식 등을 사용한 유효성 검사를 수행하긴 하나, 임의로 요청 Body의 데이터를 변경한 상황이라고 가정한다.) 이와 같은 경우에는 요청 Body의 데이터를 Controller에게 넘겨주지 않고, Pipe에서 예외가 발생하도록 로직을 구성하여야 한다.

Pipe의 적용 범위

Pipe가 적용되는 범위는 크게 3가지로 구분되며, 이를 좁은 순으로 나열하면 Parameter-level Pipes, Handler-level Pipes, Global-level Pipes으로 나타낼 수 있다.

Parameter-level Pipes

Parameter-lever은 Controller의 Route Handler로 전달되는 parameter 단위로 적용되는 Pipe의 적용 범위를 의미한다. 예를 들어, BoardsController의 createBoard 메소드로 전달받은 title, description 중에서 title에만 Pipe를 적용시키려는 경우 다음과 같은 코드로 나타낼 수 있다.

@Post()
createBoard(
  @Body('title', ParameterLevelPipe) title: string,
  @Body('description') description: string
): Board {}

Handler-level Pipes

Handler-level은 Controller의 Route Handler로 전달되는 모든 parameter에 적용되는 Pipe의 적용 범위를 의미하며, 이 경우 @UsePipes() 데코레이터를 통해 Pipe를 적용할 수 있다. 예를 들어, BoardController의 createBoard 메소드로 전달받은 모든 parameter에 Pipe를 적용시키려는 경우 다음과 같은 코드로 나타낼 수 있다.

@Post()
@UsePipes(HandlerLevelPipe)
createBoard(
  @Body('title') title: string,
  @Body('description') description: string
): Board {}

Global Pipes

Global은 애플리케이션 전체에 적용되는 Pipe의 적용 범위를 의미한다. 이는 클라이언트로부터 전달받은 모든 요청에 Pipe를 적용시킬 수 있으며, main.ts파일에서 useGlobalPipes 메소드에 인자로 넘겨주어 애플리케이션 전체 범위에 적용시킬 수 있다.

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

Pipe를 이용한 유효성 검사 구현

이번에는 Pipe를 적용시켜 게시물 생성 시 유효성 검사를 수행하는 기능을 구현해보도록 하겠다. 먼저, 앞서 언급하였듯이 PIpe의 역할에 해당하는 DT(Data Transformation)와 DV(Data Vaildation)를 수행할 수 있도록 필요한 모듈을 설치한다(class-transformer, class-validator).

$ npm i -s class-transformer class-validator

서버를 실행(npm run start:dev)하고 PostMan을 통해 아무런 정보를 입력하지 않고 새로운 게시물을 생성하기 위한 요청(@Post('/boards'))을 보내면 아무런 문제 없이 작동하는 것을 확인할 수 있다. 이제 데이터가 존재할 때에만(IsNotEmpty) 해당 게시물 정보를 저장할 수 있도록 Pipe를 이용해서 수정해주도록 하겠다. 이전에 생성하였던 DTO 디렉토리에서 create-board.dto.ts 파일에 다음과 같은 코드를 추가하여 title과 description이 빈 문자열인 경우 예외를 발생시키도록 Pipe를 적용할 수 있다.

import { IsNotEmpty } from 'class-validator';

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

  @IsNotEmpty()
  description: string;
}

이어서 Controller에 @UserPipes() 데코레이터에 내장 Pipe 중 유효성 검사에 해당하는 ValidationPipe를 인수로 넘겨주어 Handler-level Pipe를 생성하도록 하였다(v0.1.5).

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

이와 같이 코드를 작성하면, createBoard 메소드로 전달된 parameter를 DTO에 적용한 Pipe(IsNotEmpty)에 해당하는 유효성 검사를 수행하고, 그 결과에 따라 예외 발생 또는 데이터베이스 로직을 처리한다.

특정 게시물 조회 및 삭제 시 예외 처리

서버를 실행시킨 후 PostMan을 통해 게시물의 id를 기준으로 데이터를 조회하거나 삭제하는 기능을 테스트 할 필요가 있다. 지금은 id 값을 입력하지 않거나, 임의의 id 값을 넘겨주는 경우 아무런 문제 없이 작동하는 것을 확인할 수 있다. 이와 같은 경우 클라이언트의 입장에서는 해당 요청에 대한 작업이 정상적으로 처리되었는지 명확히 알 수 있는 방법이 없기 때문에 해당 게시물의 id가 없는 경우 예외를 발생시키도록 수정해주어야 한다. 이를 위해 Service의 getBoardById 메소드를 아래와 같이 수정한다.

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

다시 PostMan을 통해 존재하지 않는 id에 해당하는 게시물을 조회하는 요청을 보내면, 다음과 같은 오류 결과를 확인할 수 있다.

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

만약, 오류 메시지의 내용을 변경하고 싶은 경우 다음과 같이 NotFoundException에 메시지를 입력하도록 한다.

getBoardById(id: string): Board {
  const board = this.boards.find((board) => board.id === id);
  if (!board) throw new NotFoundException('존재하지 않는 게시물입니다.');
  return board;
}

그리고 다시 오류를 발생시켜보면 다음과 같은 결과가 나타난 것을 확인할 수 있다.

{
    "statusCode": 404,
    "error":"Not Found",
    "message": "존재하지 않는 게시물입니다."
}

존재하지 않는 id에 해당하는 게시물을 삭제하는 요청에 대한 예외 처리를 구현할 때에는 위에서 구현한 메소드를 호출하면 되므로 매우 간단하게 구현할 수 있다(v0.1.6).

deleteBoardById(id: string): void {
  const found = this.getBoardById(id);
  this.boards = this.boards.filter((board) => board.id !== found.id);
}

Custom Pipe

지금까지는 Nest JS에서 이미 구성해놓은 build-in Pipe를 사용한 예외 처리를 구현하였다. 이번에는 직접 Pipe를 생성해서 사용할 수 있는 Custum Pipe에 대해서 다루어보겠다. 앞서 언급한 상황 중 status의 Type 변환을 위한 Custom Pipe는 다음과 같다.

export class BoardStatusValidationPipe implements PipeTransform {
    transform{value: any, metadata: ArgumentMetadata} {
        return value;
    }
}

위의 코드에서 상속받은 PipeTransForm은 모든 파이프에서 반드시 존재해야 하는 Interface이다. 그리고 모든 Pipe는 PipeTransForm Interface와 함께 transform 메소드를 필요로 하는데, 이는 Nest JS에서 인자(arguments)를 처리하기 위해 사용된다. transform 메소드는 처리가 완료된 인자의 값(value)과 인자의 메타데이터(metadata)를 포함한 객체를 필요로 한다(이들이 각각 무엇을 의미하는지는 뒤에서 console.log로 확인해보도록 하겠다). 이어서 transform 메소드에서 return된 값은 Route Handler로 전달되는데, 만약 예외가 발생하는 경우 Route Handler로 전달되지 않고 바로 클라이언트에게 발생한 예외의 내용이 전달된다.

PipeTransform

먼저, BoardsModule 디렉토리에 pipes 디렉토리를 생성하고, 해당 디렉토리 안에 board-status-validation.pipe.ts파일을 생성한 후 아래의 코드를 입력한다.

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;
  }
}

status는 게시물의 공개여부 수정 요청 시 필요한 데이터이므로 Controller의 updateBoardStatus 메소드에 Pipe를 적용시킨다.

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

BoardStatusValidationPipe의 value와 metadata가 각각 무엇을 의미하는지 확인하기 위해 PostMan을 통해 다음과 같이 2개의 요청을 시도해보았다.

POST http://localhost:3000/boards
content-type: application/json
{
    "title": "테스트 입니다.",
    "description": "테스트의 내용입니다.",
}
PATCH http://localhost:3000/boards/0aa62910-aa50-11ec-ac11-4bbf39811cac/status
content-type: application/json
{
    "status": "공개"    
}

두 번째 요청이 처리된 결과 BoardStatusValidationPipe의 value와 metadata가 다음과 같이 출력된 것을 확인할 수 있었다.

value 공개
metadata { metatype: [Function: String], type: 'body', data; 'status'}

즉, 위에서 언급한 '처리가 완료된 인자의 값(value)'은 클라이언트로부터 전달받은 status를 의미하며, '인자의 메타데이터(metadata)를 포함한 객체'는 value의 metadata 정보가 담긴 객체인 것을 알 수 있다.

Custom Pipe 구현

이전에 BoardModel을 생성할 때 status에는 'PUBLIC'과 'PRIVATE' 두 개의 값만 포함될 수 있도록 하였는데, 위에서는 '공개'라는 문자열을 전달해주었고, 그 결과 해당 게시물의 status가 '공개'로 변경된 것을 확인하였다. 따라서, 'PUBLIC', 'PRIVATE' 이외에 다른 값을 전달받은 경우 예외를 발생시키도록 Pipe를 아래와 같이 수정하였다(v0.1.7).

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

  transform(value: any) {
    value = value.toUpperCase();

    if (!this.isStatusValid(value)) {
      throw new BadRequestException(
        `'${value}'는 게시물의 공개여부로 설정할 수 없습니다.`,
      );
    }

    return value;
  }

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

그리고 다시 위의 공개여부 상태 변경 요청을 다시 보내면 아래와 같은 오류 정보가 반환된 것을 확인할 수 있다.

{
    "statusCode": 400,
    "error":"Bad Request",
    "message": "'공개'는 게시물의 공개여부로 설정할 수 없습니다."
}

마치며

여기까지 Nest JS로 데이터베이스 로직 처리 부분에 대해서 정리해보았다. 다음 글은 로컬 메모리로 사용하던 데이터베이스 방식을 PostgresSQL로 바꾸고, TypeORM, Repository을 다뤄보면서 추가적인 데이터베이스 로직 처리에 대해서 정리해보겠다.

GitHub

참고자료