Nest.js 블로그 단계별: 답글 달기
Wenhao Wang
Dev Intern · Leapcell

이전 글[https://leapcell.io/blog/nestjs-blog-step-by-step-comment-system]에서는 블로그에 댓글 기능을 추가하여 독자와 작성자 간의 초기 상호 작용을 가능하게 했습니다. 그러나 모든 댓글이 선형적으로 표시되어 토론이 활발해질 때 대화를 따라가기 어려웠습니다. 더욱 상호 작용적인 커뮤니티를 구축하기 위해 이 튜토리얼에서는 댓글 답글 기능을 구현하도록 댓글 시스템을 업그레이드할 것입니다. 이 기능은 "중첩 댓글" 또는 "스레드 댓글"이라고도 합니다. 다음 목표를 달성할 것입니다: - 사용자가 기존 댓글에 답글을 달 수 있도록 합니다. - 중첩(또는 들여쓰기) 형식으로 페이지에 답글 계층 구조를 명확하게 표시합니다. - 간단한 클라이언트 측 JavaScript로 사용자 경험을 향상시킵니다. ## 1단계: 데이터 모델 업데이트 답글 기능을 구현하려면 댓글 간에 부모-자식 관계를 설정해야 합니다. 답글은 본질적으로 댓글이지만 "부모 댓글"을 가집니다. 이를 위해 Comment
엔티티에 자체 참조 관계를 추가합니다. ### 1. 데이터베이스 테이블 수정 먼저 comment
테이블의 구조를 수정하여 부모 댓글의 ID를 가리키는 parentId
필드를 추가해야 합니다. PostgreSQL 데이터베이스에서 다음 ALTER TABLE
문을 실행합니다. sql ALTER TABLE "comment" ADD COLUMN "parentId" UUID REFERENCES "comment"("id") ON DELETE CASCADE;
- parentId
열은 최상위 댓글에는 부모가 없으므로 선택 사항입니다(NULL 허용). - REFERENCES "comment"("id")
는 parentId
를 동일한 테이블의 id
열에 연결하는 외래 키를 생성합니다. ### 2. 댓글 엔티티 업데이트 이제 src/comments/comment.entity.ts
파일을 열고 이 계층적 관계를 코드에 반영하기 위해 parent
및 replies
속성을 추가합니다. typescript // src/comments/comment.entity.ts import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, ManyToOne, OneToMany } from 'typeorm'; import { User } from '../users/user.entity'; import { Post } from '../posts/post.entity'; @Entity() export class Comment { @PrimaryGeneratedColumn('uuid') id: string; @Column('text') content: string; @CreateDateColumn() createdAt: Date; @ManyToOne(() => User, user => user.comments) user: User; @ManyToOne(() => Post, post => post.comments) post: Post; // --- 새 필드 --- @ManyToOne(() => Comment, comment => comment.replies, { nullable: true }) parent: Comment; // 부모 댓글 @OneToMany(() => Comment, comment => comment.parent) replies: Comment[]; // 자식 댓글(답글) 목록 }
## 2단계: 댓글 서비스 조정 서비스 계층을 조정하여 새 댓글을 만들 때 부모 댓글을 연결하고 쿼리할 때 댓글 목록을 트리 구조로 구성해야 합니다. src/comments/comments.service.ts
를 열고 다음 변경 사항을 적용합니다. typescript // src/comments/comments.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Comment } from './comment.entity'; import { Post } from '../posts/post.entity'; import { User } from '../users/user.entity'; @Injectable() export class CommentsService { constructor( @InjectRepository(Comment) private commentsRepository: Repository<Comment>, ) {} // findByPostId 메서드 수정 async findByPostId(postId: string): Promise<Comment[]> { const comments = await this.commentsRepository.find({ where: { post: { id: postId } }, relations: ['user', 'parent'], // 사용자 및 부모 동시 로드 order: { createdAt: 'ASC', }, }); return this.structureComments(comments); } // 평면 목록을 트리 구조로 변환하는 새 비공개 메서드 추가 private structureComments(comments: Comment[]): Comment[] { const commentMap = new Map<string, Comment>(); comments.forEach(comment => { comment.replies = []; // replies 배열 초기화 commentMap.set(comment.id, comment); }); const rootComments: Comment[] = []; comments.forEach(comment => { if (comment.parent) { const parentComment = commentMap.get(comment.parent.id); if (parentComment) { parentComment.replies.push(comment); } } else { rootComments.push(comment); } }); return rootComments; } // optional parentId를 허용하도록 create 메서드 수정 async create( content: string, user: User, post: Post, parentId?: string, ): Promise<Comment> { const newComment = this.commentsRepository.create({ content, user, post, parent: parentId ? { id: parentId } as Comment : null, }); return this.commentsRepository.save(newComment); } }
논리 설명: 1. findByPostId
는 이제 게시물에 대한 모든 댓글(최상위 댓글 및 모든 답글 포함)을 가져옵니다. 2. 새로운 structureComments
메서드는 이 로직의 핵심입니다. 먼저 모든 댓글을 빠른 조회를 위해 Map
에 넣습니다. 그런 다음 모든 댓글을 반복합니다. 댓글에 parent
가 있으면 부모의 replies
배열에 푸시되고, 그렇지 않으면 최상위 댓글입니다. 3. create
메서드에는 이제 선택적 parentId
매개변수가 있습니다. 이 ID가 제공되면 새로 생성된 댓글이 해당 부모 댓글과 연결됩니다. ## 3단계: 컨트롤러 업데이트 컨트롤러는 요청 본문에서 선택적 parentId
를 수신하고 서비스로 전달해야 합니다. 이 변경은 매우 간단합니다. src/comments/comments.controller.ts
: typescript // src/comments/comments.controller.ts import { Controller, Post, Body, Param, Req, Res, UseGuards } from '@nestjs/common'; // ... imports @Controller('posts/:postId/comments') export class CommentsController { constructor(private readonly commentsService: CommentsService) {} @UseGuards(AuthenticatedGuard) @Post() async create( @Param('postId') postId: string, @Body('content') content: string, @Body('parentId') parentId: string, // <-- parentId 수신 @Req() req: Request, @Res() res: Response, ) { const user = req.user as any; // parentId를 서비스로 전달 await this.commentsService.create(content, user, { id: postId } as any, parentId); res.redirect(`/posts/${postId}`); } }
## 4단계: 프론트엔드 뷰 업그레이드 가장 흥미로운 부분입니다. post.ejs
템플릿을 업데이트하여 댓글과 해당 답글을 재귀적으로 렌더링해야 합니다. 또한 답글 양식을 동적으로 표시하기 위한 JavaScript를 추가해야 합니다. ### 1. 재사용 가능한 댓글 템플릿 만들기 재귀 렌더링의 가장 좋은 방법은 재사용 가능한 "부분" 템플릿을 만드는 것입니다. views
디렉터리에서 _comment.ejs
라는 새 파일을 만듭니다. ```html <% comments.forEach(comment => { %> <div class=