2025년 2월 3일

김동환·2025년 2월 3일
0

📌 오늘의 학습 (TIL) - NestJS 댓글 기능 구현

🚀 NestJS에서 댓글 CRUD 기능 구현

오늘은 NestJS를 활용하여 댓글(Comment) CRUD 기능을 구현하면서 CommentsServiceCommentsController를 리팩토링하고 보완하는 작업을 진행했다.


1. 댓글 서비스 (comments.service.ts)

🔹 댓글 생성 (createComment)

  • 댓글이 빈 문자열인지 확인 (_.isEmpty(content.trim()))
  • 50자 이상이면 예외 처리 (CommentLengthExceededException)
  • 댓글을 생성 후 데이터베이스에 저장

🔹 댓글 조회

  • getCommentByCardId(cardId): 특정 카드 ID에 해당하는 모든 댓글 조회
  • getCommentById(id): 특정 ID의 댓글 조회 (없으면 CommentNotFoundException 발생)

🔹 댓글 수정 (updateComment)

  • 수정 시에도 빈 댓글 또는 50자 초과 여부 확인
  • verifyComment(id, userId) 메서드를 사용하여 작성자 권한 확인 후 업데이트

🔹 댓글 삭제 (deleteComment)

  • 삭제 전 작성자 권한 확인
  • 삭제 후 { id, message: '삭제되었습니다.' } 응답 반환

🔹 댓글 권한 검증 (verifyComment)

  • 댓글이 존재하지 않거나 작성자가 다르면 CommentPermissionException 발생

2. 댓글 컨트롤러 (comments.controller.ts)

🔹 댓글 생성 (POST /comments/:cardId)

  • JwtAuthGuard를 적용하여 로그인한 사용자만 댓글을 작성할 수 있도록 설정
  • req.user에서 user.id를 가져와 댓글을 생성

🔹 댓글 목록 조회 (GET /comments/:cardId)

  • 특정 카드에 대한 모든 댓글을 조회하여 반환

🔹 댓글 상세 조회 (GET /comments/:id/detail)

  • 특정 ID의 댓글을 조회

🔹 댓글 수정 (PATCH /comments/:id)

  • JwtAuthGuard를 적용하여 인증된 사용자만 수정 가능
  • updateCommentDto.content를 이용하여 댓글 내용 업데이트

🔹 댓글 삭제 (DELETE /comments/:id)

  • JwtAuthGuard를 적용하여 인증된 사용자만 삭제 가능
  • 작성자 검증 후 댓글 삭제

이번 리팩토링에서 보완한 점

  1. 비어있는 댓글 예외 처리 강화 (_.isEmpty(content.trim()))
  2. 댓글 길이 제한 추가 (50자 초과 시 CommentLengthExceededException 발생)
  3. 작성자 권한 검증 메서드(verifyComment) 분리하여 중복 코드 제거
  4. JwtAuthGuard 적용으로 인증된 사용자만 댓글을 수정/삭제 가능하도록 설정

📝 배운 점 & 개선할 점

  • verifyComment 같은 검증 로직을 따로 분리하면 중복을 줄이고 가독성을 높일 수 있다.
  • @UseGuards(JwtAuthGuard)를 사용하면 인증된 사용자만 API를 사용할 수 있도록 제한할 수 있다.

comments.controller.ts

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Patch,
  Post,
  UseGuards,
  Request,
} from '@nestjs/common';
import { CommentsService } from './comments.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';

@Controller('comments')
export class CommentsController {
  constructor(private readonly commentsService: CommentsService) {}

  @UseGuards(JwtAuthGuard)
  @Post(':cardId')
  async createComment(
    @Request() req,
    @Param('cardId') cardId: number,
    @Body() createCommentDto: CreateCommentDto,
  ) {
    const user = req.user; // JwtAuthGuard에서 설정된 user 정보
    const comment = await this.commentsService.createComment(
      cardId,
      user.id,
      createCommentDto.content,
    );
    return { data: comment };
  }

  @Get(':cardId')
  async findAllComment(@Param('cardId') cardId: number) {
    const comments = await this.commentsService.getCommentByCardId(cardId);
    return { data: comments };
  }

  @Get(':id/detail')
  async findOneComment(@Param('id') id: number) {
    return await this.commentsService.getCommentById(+id);
  }

  @UseGuards(JwtAuthGuard)
  @Patch(':id')
  async updateComment(
    @Request() req,
    @Param('id') id: number,
    @Body() updateCommentDto: UpdateCommentDto,
  ) {
    const user = req.user;
    const comment = await this.commentsService.updateComment(
      +id,
      user.id,
      updateCommentDto.content,
    );
    return { data: comment };
  }

  @UseGuards(JwtAuthGuard)
  @Delete(':id')
  async deleteComment(@Request() req, @Param('id') id: number) {
    const user = req.user;
    const comment = await this.commentsService.deleteComment(+id, user.id);
    return { data: comment };
  }
}

comments.service.ts

import { Repository } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import _ from 'lodash';

import { Comment } from './entities/comment.entity';
import {
  CommentNotFoundException,
  CommentPermissionException,
  EmptyCommentException,
  CommentLengthExceededException,
} from 'src/common/exceptions/comment.exception';

@Injectable()
export class CommentsService {
  constructor(
    @InjectRepository(Comment)
    private commentRepository: Repository<Comment>,
  ) {}

  async createComment(cardId: number, userId: number, content: string) {
    if (_.isEmpty(content.trim())) {
      throw new EmptyCommentException();
    }

    if (content.length > 50) {
      throw new CommentLengthExceededException();
    }

    const newComment = this.commentRepository.create({
      cardId,
      userId,
      content,
    });

    return await this.commentRepository.save(newComment);
  }

  async getCommentByCardId(cardId: number) {
    return await this.commentRepository.findBy({
      cardId: cardId,
    });
  }

  async getCommentById(id: number) {
    const comment = await this.commentRepository.findOneBy({ id });

    if (_.isNil(comment)) {
      throw new CommentNotFoundException();
    }

    return comment;
  }

  async updateComment(id: number, userId: number, content: string) {
    if (_.isEmpty(content.trim())) {
      throw new EmptyCommentException();
    }

    if (content.length > 50) {
      throw new CommentLengthExceededException();
    }

    await this.verifyComment(id, userId);
    await this.commentRepository.update({ id }, { content });

    return await this.commentRepository.findOneBy({ id });
  }

  async deleteComment(id: number, userId: number) {
    await this.verifyComment(id, userId);
    await this.commentRepository.delete({ id });

    return { id, message: '삭제되었습니다.' };
  }

  private async verifyComment(id: number, userId: number) {
    const comment = await this.commentRepository.findOneBy({ id });

    if (_.isNil(comment) || comment.userId !== userId) {
      throw new CommentPermissionException();
    }
  }
}
profile
Node.js 7기

0개의 댓글

관련 채용 정보