[Nest.js] 메모리 CRUD와 Pipes
구현할 API 목록
이번 편에서 메모리 기반으로 구현하는 Board CRUD 엔드포인트다.
| Method | URL | 설명 |
|---|---|---|
| GET | /boards | 모든 게시물 가져오기 |
| POST | /boards | 게시물 생성 |
| GET | /boards/:id | 특정 게시물 가져오기 |
| DELETE | /boards/:id | 특정 게시물 삭제 |
| PATCH | /boards/:id/status | 게시물 상태 업데이트 |
Board Model 정의
게시물 데이터의 형태를 정의한다. TypeScript의 인터페이스(interface)로 Board 타입을 만들고, 상태값은 enum으로 제한한다. 나중에 TypeORM을 도입할 때 인터페이스는 Entity 클래스로 교체된다.
// src/boards/board.model.ts
import { BoardStatus } from './board-status.enum';
export interface Board {
id: string;
title: string;
description: string;
status: BoardStatus;
}
// src/boards/board-status.enum.ts
export enum BoardStatus {
PUBLIC = 'PUBLIC',
PRIVATE = 'PRIVATE',
}
인터페이스는 타입 체크에만 사용되고 런타임에는 존재하지 않는다. 지금은 메모리 배열에 저장하는 단순한 객체 형태이므로 인터페이스로 충분하다. TypeORM Entity는 데코레이터와 DB 매핑 정보가 필요해서 클래스로 정의한다.
모든 게시물 가져오기
서비스에 게시물을 저장할 배열을 선언하고, 그 배열을 반환하는 메서드를 만든다. 컨트롤러에서는 @Get()으로 해당 메서드와 연결한다.
// src/boards/boards.service.ts
import { Injectable } from '@nestjs/common';
import { Board } from './board.model';
import { BoardStatus } from './board-status.enum';
@Injectable()
export class BoardsService {
private boards: Board[] = [];
getAllBoards(): Board[] {
return this.boards;
}
}
// src/boards/boards.controller.ts
import { Controller, Get } from '@nestjs/common';
import { BoardsService } from './boards.service';
import { Board } from './board.model';
@Controller('boards')
export class BoardsController {
constructor(private boardsService: BoardsService) {}
@Get()
getAllBoard(): Board[] {
return this.boardsService.getAllBoards();
}
}
게시물 생성
게시물 생성 시 id는 uuid 패키지로 고유 값을 생성한다. 상태 기본값은 PUBLIC으로 지정한다.
먼저 uuid 패키지를 설치한다.
npm install uuid
npm install @types/uuid --save-dev
// src/boards/boards.service.ts (createBoard 추가)
import { v1 as uuid } from 'uuid';
import { CreateBoardDto } from './dto/create-board.dto';
@Injectable()
export class BoardsService {
private boards: Board[] = [];
getAllBoards(): Board[] {
return this.boards;
}
createBoard(createBoardDto: CreateBoardDto): Board {
const { title, description } = createBoardDto;
const board: Board = {
id: uuid(),
title,
description,
status: BoardStatus.PUBLIC,
};
this.boards.push(board);
return board;
}
}
Controller에서 createBoard 연결
@Post()로 POST 요청을 받고, @Body()로 요청 바디 전체를 DTO 객체로 받는다.
// src/boards/boards.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';
@Controller('boards')
export class BoardsController {
constructor(private boardsService: BoardsService) {}
@Get()
getAllBoard(): Board[] {
return this.boardsService.getAllBoards();
}
@Post()
createBoard(@Body() createBoardDto: CreateBoardDto): Board {
return this.boardsService.createBoard(createBoardDto);
}
}
@Body() vs @Body('key')@Body()는 요청 바디 전체를 DTO 객체로 받는다. @Body('title')처럼 특정 키를 지정하면 해당 필드 값만 추출된다. DTO를 쓰면 여러 필드를 한 번에 받고 타입 안전성도 확보할 수 있다.
Data Transfer Object (DTO)
DTO는 계층 간 데이터를 전달할 때 사용하는 객체다. 요청 바디 형태를 타입으로 정의하고, 이후 유효성 검증도 DTO에 데코레이터를 붙여서 처리한다.
createBoard(
@Body('title') title: string,
@Body('description') description: string
): Board {
// 필드 늘어날수록
// 파라미터가 계속 추가된다
}
createBoard(
@Body() createBoardDto: CreateBoardDto
): Board {
// 필드가 늘어나도
// DTO 클래스만 수정한다
}
CreateBoardDto 작성
// src/boards/dto/create-board.dto.ts
import { IsNotEmpty } from 'class-validator';
export class CreateBoardDto {
@IsNotEmpty()
title: string;
@IsNotEmpty()
description: string;
}
@IsNotEmpty()는 빈 문자열을 허용하지 않는 검증 데코레이터다. 이 검증이 실제로 동작하려면 Pipe에서 ValidationPipe를 함께 사용해야 한다. DTO에 데코레이터만 붙이는 것으로는 자동 검증이 되지 않는다.
ID로 특정 게시물 가져오기
URL 경로의 :id 파라미터는 @Param('id')로 추출한다. 이 단계에서 id는 문자열(uuid) 타입이다.
// boards.service.ts
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;
}
// boards.controller.ts
@Get('/:id')
getBoardById(@Param('id') id: string): Board {
return this.boardsService.getBoardById(id);
}
ID로 게시물 삭제
먼저 해당 id의 게시물이 존재하는지 확인한 뒤, filter()로 배열에서 제거한다. 없는 게시물에 대한 삭제 요청은 getBoardById()에서 이미 NotFoundException이 발생한다.
// boards.service.ts
deleteBoard(id: string): void {
const found = this.getBoardById(id);
this.boards = this.boards.filter(
(board) => board.id !== found.id
);
}
// boards.controller.ts
@Delete('/:id')
deleteBoard(@Param('id') id: string): void {
this.boardsService.deleteBoard(id);
}
게시물 상태 업데이트
상태(status) 필드만 변경하므로 전체 교체가 아닌 PATCH를 사용한다. 바디에서 status 필드만 추출하고, 아직 커스텀 파이프를 적용하기 전이므로 일단 BoardStatus 타입을 명시하는 것으로 처리한다.
// boards.service.ts
updateBoardStatus(id: string, status: BoardStatus): Board {
const board = this.getBoardById(id);
board.status = status;
return board;
}
// boards.controller.ts
@Patch('/:id/status')
updateBoardStatus(
@Param('id') id: string,
@Body('status') status: BoardStatus
): Board {
return this.boardsService.updateBoardStatus(id, status);
}
NestJS Pipes
Pipe는 컨트롤러 핸들러가 실행되기 직전에 요청 데이터를 가로채서 변환(Transform)하거나 유효성 검증(Validation)을 수행하는 미들웨어 개념의 클래스다.
NestJS에는 자주 쓰이는 빌트인 파이프들이 있다.
class-validator 데코레이터를 기반으로 DTO 유효성 검증을 수행한다
문자열 파라미터를 정수로 변환한다. 변환 실패 시 400 오류를 반환한다
문자열을 boolean으로 변환한다
배열 파라미터를 파싱한다
문자열이 UUID 형식인지 검증한다
파라미터가 없을 때 기본값을 설정한다
파이프는 세 가지 위치에 적용할 수 있다.
- Handler 레벨 — 특정 핸들러 메서드에
@UsePipes()로 적용 - Parameter 레벨 —
@Param('id', ParseIntPipe)처럼 파라미터 단위로 적용 - Global 레벨 —
main.ts에서app.useGlobalPipes()로 앱 전체에 적용
ValidationPipe로 유효성 검증
class-validator와 class-transformer를 설치하고, ValidationPipe를 적용하면 DTO에 붙인 데코레이터가 실제로 동작한다.
npm install class-validator class-transformer
방법 1. 핸들러 레벨 — 특정 라우트에만 적용한다.
// boards.controller.ts
import { UsePipes, ValidationPipe } from '@nestjs/common';
@Post()
@UsePipes(ValidationPipe)
createBoard(@Body() createBoardDto: CreateBoardDto): Board {
return this.boardsService.createBoard(createBoardDto);
}
방법 2. 글로벌 레벨 — 앱 전체에 적용한다. 매 핸들러마다 붙일 필요가 없어서 편하다.
// src/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());
await app.listen(3000);
}
bootstrap();
이제 title이나 description이 비어 있는 요청을 보내면 NestJS가 자동으로 400 Bad Request와 오류 메시지를 반환한다.
없는 게시물 요청 처리
존재하지 않는 id로 조회를 시도할 때 null을 반환하는 것은 클라이언트에게 불명확한 응답이다. NestJS의 NotFoundException을 던지면 404 Not Found 상태 코드와 메시지를 HTTP 응답으로 자동 변환한다.
// boards.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
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;
}
삭제 핸들러에서도 마찬가지다. deleteBoard()가 내부적으로 getBoardById()를 호출하기 때문에, 없는 id를 삭제하려 하면 자동으로 404가 반환된다.
NotFoundException 외에도 BadRequestException(400), UnauthorizedException(401), ForbiddenException(403), ConflictException(409) 등이 있다. 모두 @nestjs/common에서 가져와서 throw하면 된다.
커스텀 파이프로 상태값 검증
게시물 상태를 변경하는 API에서 클라이언트가 PUBLIC이나 PRIVATE이 아닌 값을 보내면 거부해야 한다. 빌트인 파이프로는 이 로직을 표현하기 어렵기 때문에 커스텀 파이프를 만든다.
커스텀 파이프는 PipeTransform 인터페이스를 구현하는 클래스다. transform() 메서드 안에서 검증 로직을 작성하고, 통과하지 못하면 예외를 던진다.
// src/boards/pipes/board-status-validation.pipe.ts
import { BadRequestException, PipeTransform } from '@nestjs/common';
import { BoardStatus } from '../board-status.enum';
export class BoardStatusValidationPipe implements PipeTransform {
readonly StatusOptions = [
BoardStatus.PRIVATE,
BoardStatus.PUBLIC,
];
transform(value: any) {
value = value.toUpperCase();
if (!this.isStatusValid(value)) {
throw new BadRequestException(
`${value} isn't in the status options`
);
}
return value;
}
private isStatusValid(status: any) {
const index = this.StatusOptions.indexOf(status);
return index !== -1;
}
}
커스텀 파이프를 파라미터 레벨에 적용한다. @Body('status', BoardStatusValidationPipe)처럼 데코레이터 두 번째 인자로 넘기면 된다.
// boards.controller.ts
import { BoardStatusValidationPipe } from './pipes/board-status-validation.pipe';
@Patch('/:id/status')
updateBoardStatus(
@Param('id') id: string,
@Body('status', BoardStatusValidationPipe) status: BoardStatus
): Board {
return this.boardsService.updateBoardStatus(id, status);
}
transform(value)의 value에는 해당 파라미터로 들어온 원래 값이 담긴다. 반환값이 핸들러 파라미터로 전달된다. readonly를 붙인 StatusOptions는 실수로 배열을 수정하는 것을 방지하기 위한 것이다.
메모리 기반 CRUD 최종 전체 코드
이 시점에서의 Service와 Controller 전체 코드다. TypeORM 도입 전 메모리 배열로 작동하는 최종 상태다.
// src/boards/boards.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { v1 as uuid } from 'uuid';
import { Board } from './board.model';
import { BoardStatus } from './board-status.enum';
import { CreateBoardDto } from './dto/create-board.dto';
@Injectable()
export class BoardsService {
private boards: Board[] = [];
getAllBoards(): Board[] {
return this.boards;
}
createBoard(createBoardDto: CreateBoardDto): Board {
const { title, description } = createBoardDto;
const board: Board = {
id: uuid(),
title,
description,
status: BoardStatus.PUBLIC,
};
this.boards.push(board);
return board;
}
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;
}
deleteBoard(id: string): void {
const found = this.getBoardById(id);
this.boards = this.boards.filter(
(board) => board.id !== found.id
);
}
updateBoardStatus(id: string, status: BoardStatus): Board {
const board = this.getBoardById(id);
board.status = status;
return board;
}
}
// src/boards/boards.controller.ts
import {
Body, Controller, Delete, Get,
Param, Patch, Post, UsePipes, ValidationPipe,
} from '@nestjs/common';
import { BoardStatus } from './board-status.enum';
import { Board } from './board.model';
import { BoardsService } from './boards.service';
import { CreateBoardDto } from './dto/create-board.dto';
import { BoardStatusValidationPipe } from './pipes/board-status-validation.pipe';
@Controller('boards')
export class BoardsController {
constructor(private boardsService: BoardsService) {}
@Get()
getAllBoard(): Board[] {
return this.boardsService.getAllBoards();
}
@Post()
@UsePipes(ValidationPipe)
createBoard(@Body() createBoardDto: CreateBoardDto): Board {
return this.boardsService.createBoard(createBoardDto);
}
@Get('/:id')
getBoardById(@Param('id') id: string): Board {
return this.boardsService.getBoardById(id);
}
@Delete('/:id')
deleteBoard(@Param('id') id: string): void {
this.boardsService.deleteBoard(id);
}
@Patch('/:id/status')
updateBoardStatus(
@Param('id') id: string,
@Body('status', BoardStatusValidationPipe) status: BoardStatus
): Board {
return this.boardsService.updateBoardStatus(id, status);
}
}