반응형

목차

  1. 프로젝트 구조
  2. 도메인 모델 설계
  3. API 엔드포인트 구현
  4. 유효성 검증
  5. 에러 처리
  6. 테스트

프로젝트 구조

src/boards/
├── constants/
│   └── board.constants.ts   # 상수 정의
├── dto/
│   └── create-board.dto.ts  # DTO 클래스
├── pipes/
│   └── board-status-validation.pipe.ts # 커스텀 파이프
├── board.model.ts           # 도메인 모델
├── boards.controller.ts     # 컨트롤러
├── boards.service.ts        # 서비스
└── boards.module.ts         # 모듈

도메인 모델 설계

게시글 모델 정의

// board.model.ts
export interface Board {
  id: string;
  title: string;
  description: string;
  status: BoardStatus;
}

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

DTO 설계

// create-board.dto.ts
import { IsNotEmpty } from 'class-validator';

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

  @IsNotEmpty()
  description: string;
}

API 엔드포인트 구현

컨트롤러 구현

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

  // 게시글 목록 조회
  @Get()
  getAllBoards(): Board[] {
    return this.boardsService.getAllBoards();
  }

  // 게시글 생성
  @Post()
  @HttpCode(HttpStatus.CREATED)
  @UsePipes(ValidationPipe)
  createBoard(@Body() createBoardDto: CreateBoardDto): Board {
    return this.boardsService.createBoard(createBoardDto);
  }

  // 게시글 상세 조회
  @Get(':id')
  getBoardById(
    @Param('id', new ParseUUIDPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE })) id: string
  ): Board {
    return this.boardsService.getBoardById(id);
  }

  // 게시글 상태 업데이트
  @Patch(':id/status')
  updateBoardStatus(
    @Param('id', ParseUUIDPipe) id: string,
    @Body('status', BoardStatusValidationPipe) status: BoardStatus,
  ): Board {
    return this.boardsService.updateBoardStatus(id, status);
  }

  // 게시글 삭제
  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  deleteBoard(@Param('id', ParseUUIDPipe) id: string): void {
    this.boardsService.deleteBoard(id);
  }
}

서비스 로직 구현

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

  // 전체 게시글 조회
  getAllBoards(): Board[] {
    return [...this.boards];
  }

  // 게시글 생성
  createBoard(createboardDto: CreateBoardDto): Board {
    const board: Board = {
      id: randomUUID(),
      ...createboardDto,
      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(BOARD_MESSAGES.NOT_FOUND(id));
    }
    return { ...found };
  }

  // 게시글 상태 업데이트
  updateBoardStatus(id: string, status: BoardStatus): Board {
    const boardIndex = this.boards.findIndex(board => board.id === id);
    if (boardIndex === -1) {
      throw new NotFoundException(BOARD_MESSAGES.NOT_FOUND(id));
    }

    this.boards[boardIndex] = {
      ...this.boards[boardIndex],
      status
    };

    return { ...this.boards[boardIndex] };
  }

  // 게시글 삭제
  deleteBoard(id: string): void {
    const boardIndex = this.findBoardIndex(id);
    this.boards.splice(boardIndex, 1);
  }
}

유효성 검증

커스텀 파이프를 통한 상태값 검증

export class BoardStatusValidationPipe implements PipeTransform<string, BoardStatus> {
  private readonly statusOptions = Object.values(BoardStatus);

  transform(value: string): BoardStatus {
    const upperValue = value?.toUpperCase();

    if (!this.isStatusValid(upperValue)) {
      throw new BadRequestException(BOARD_MESSAGES.INVALID_STATUS(value));
    }

    return upperValue as BoardStatus;
  }

  private isStatusValid(status: string): boolean {
    return this.statusOptions.includes(status as BoardStatus);
  }
}

에러 처리

메시지 상수화

export const BOARD_MESSAGES = {
  NOT_FOUND: (id: string) => `해당 id의 게시글이 없습니다. id: ${id}`,
  INVALID_STATUS: (status: string) => `올바르지 않은 게시글 상태입니다. status=${status}`,
} as const;

구현된 기능들

  1. 게시글 목록 조회 (GET /boards)
    • 전체 게시글 목록 반환
    • 불변성을 위해 복사본 반환
  2. 게시글 생성 (POST /boards)
    • UUID를 통한 고유 ID 생성
    • DTO를 통한 입력값 검증
    • 생성 시 기본 상태는 PUBLIC
  3. 게시글 상세 조회 (GET /boards/:id)
    • UUID 형식 검증
    • 존재하지 않는 게시글 처리
  4. 게시글 상태 변경 (PATCH /boards/:id/status)
    • 커스텀 파이프를 통한 상태값 검증
    • 유효하지 않은 상태값 처리
  5. 게시글 삭제 (DELETE /boards/:id)
    • 204 No Content 응답
    • 존재하지 않는 게시글 처리

핵심 구현 포인트

  1. 불변성 유지
    • 객체의 복사본을 반환하여 의도치 않은 수정 방지
      return [...this.boards];
      return { ...found };
  2. 유효성 검증
    • DTO 데코레이터를 통한 입력값 검증
    • 커스텀 파이프를 통한 상태값 검증
    • UUID 형식 검증
  3. 에러 처리
    • 메시지 상수화로 일관성 있는 에러 메시지 관리
    • 적절한 HTTP 상태 코드 사용
  4. 코드 구조화
    • 책임에 따른 명확한 계층 분리
    • 재사용 가능한 컴포넌트 설계

마치며

이 예제는 NestJS의 핵심 기능들을 활용하여 실제 프로덕션에서 사용할 수 있는 게시판 API를 구현하는 방법을 보여줍니다. 특히 다음과 같은 NestJS의 장점들을 잘 활용했습니다:

  • 데코레이터를 통한 선언적 프로그래밍
  • 파이프를 통한 검증 로직 분리
  • 의존성 주입을 통한 느슨한 결합
  • TypeScript의 타입 시스템 활용

개선 가능한 부분

  1. 데이터베이스 연동 (TypeORM/Prisma)
  2. 인증/인가 구현
  3. 페이지네이션 구현
  4. 검색 기능 추가
  5. 테스트 코드 보강

참고자료

https://kth990303.tistory.com/411

반응형