티스토리 뷰


 

나는 이번 여행의 이유 프로젝트에서 게시물 CRUD를 구현했는데, 초기에는 게시물에 댓글 기능이 없었다.

그런데 추후에 프론트엔드 측이 댓글과 답글 기능이 추가되면 좋겠다는 요청을 했고, 

나도 정말 구현해보고 싶었던 기능이라 흔쾌히 수락했다! 

이번 게시물은 내가 댓글, 답글 기능을 구현한 로직부터 마주친 다양한 오류들, 그리고 해결 방법을 기록해보고자 한다.

 

 

여행의 이유, 각자가 가진 여행의 이유를 찾고, 둘러보고, 탐구해보자!

각자가 가진 여행의 이유를 찾고, 둘러보고, 탐구해보는 서비스

www.here-you.com

 

GitHub - Here-You/here-you-backend: 여행의 이유: Here You - backend server

여행의 이유: Here You - backend server. Contribute to Here-You/here-you-backend development by creating an account on GitHub.

github.com


1. 댓글, 답글 구현 로직

우선 내가 처음에 댓글, 답글 구현할 때 '이 둘을 어떻게 연관시킬까?'가 가장 큰 고민이었다.

댓글 답글을 각각 따로따로 테이블을 생성해서 저장해야 할지, 한 테이블 안에 둔다면 어떻게 구분할 수 있을지 고민했다.

오랜 고민 끝에 내가 생각해 낸 방법은 하나의 테이블 안에 저장하되 'parentID'라는 속성을 사용하는 것이다!

 

만약 내가 오리지널 댓글이라면 parentID는 자기 자신이다. 

그리고 그 댓글에 답글이 달린다면 해당 답글의 parentID는 오리지널 댓글의 기본키를 참조하는 것이다.

ERD를 직접 확인해 보자면 다음과 같이 구성돼 있다. 

signature_comment_entity를 확인해 보면 parentCommentId라는 속성을 확인할 수 있고, 해당 속성은 자신의 테이블의 기본키를 참조하는 것(노란 선)을 확인할 수 있다. 

parentCommentId 대신 간단하게 parentId라고 간단히 할걸 그랬다 지금 보니 아쉽다. 참고로 시그니처는 우리 서비스에서 게시물을 의미한다.

 

자 그럼 로직을 완성했으니 엔티티로 구현을 해보자. 위의 signature_comment_entity를 Nest.JS 엔티티로 가져오면 다음과 같다.

// signature.comment.entity.ts

import {
  BaseEntity,
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  JoinColumn,
  ManyToOne,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { SignatureEntity } from './signature.entity';
import { UserEntity } from 'src/user/user.entity';

@Entity()
export class SignatureCommentEntity extends BaseEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @ManyToOne(() => SignatureEntity, (signature) => signature.comments)
  @JoinColumn()
  signature: SignatureEntity;				// 게시물 

  @ManyToOne(() => UserEntity, (user) => user.signatureComments)
  @JoinColumn()
  user: UserEntity;					// 작성자

  @OneToMany(
    () => SignatureCommentEntity,
    (childComment) => childComment.parentComment,
  )
  @JoinColumn()
  childComments: SignatureCommentEntity[];		// 답글(자식 댓글)

  @ManyToOne(
    () => SignatureCommentEntity,
    (parentComment) => parentComment.childComments,
  )
  @JoinColumn()
  parentComment: SignatureCommentEntity;		// 부모 댓글

  @Column()
  content: string;

  @CreateDateColumn()
  created: Date;

  @UpdateDateColumn()
  updated: Date;

  @DeleteDateColumn()
  deleted: Date;
}

댓글 테이블은 여러 테이블과 종속 관계를 가지고 있어서 복잡해 보일 수 있는데 사실 단순하다.

 

(1) signature: 당연히 댓글은 게시물에 달리니까 게시물을 참조하고 있다. 하나의 게시물에는 여러 개의 댓글이 달리니까 @ManyToOne

(*TypeORM은 종속관계를 쓸 때 기본키로 안 잇고 엔티티 자체를 참조하도록 쓴다! )

(2) user: 게시물 작성자이다. 작성자 한 명이 여러 개의 댓글을 작성할 수 있으므로 @ManyToOne

(3) parentComment: 부모 댓글이다. 이것도 하나의 부모 댓글 하위에 여러 개의 답글이 달릴 수 있으니 @ManyToOne

(4) childComments: 자기 자신을 참조하니까 참조당하는 입장에서의 속성도 작성해줘야 한다. 참조를 받는 테이블 입장에서는 @OneToMany니까 s를 붙이고 엔티티 Array 타입으로 작성해 줬다.

 

자 그럼 모든 준비는 끝났으니 실제로 댓글과 답글의 CRUD를 구현해 보자.

구현 로직에 집중하기 위해 모든 매서드의 컨트롤러 코드는 생략하고 서비스단만 확인하겠다.


2. CREATE: 댓글 답글 생성하기

 생략된 부분에서 주요 내용은 POST 메서드로 댓답글 생성 요청이 들어오면 1) 내용이 담겨있는 DTO, 2) signtureID(필수)와 parentID(선택)를 담은 파라미터, 그리고 3) 댓글 작성자를 토큰으로 받고 이것을 서비스단으로 보내준다. ( 컨트롤러 전체 코드는 깃허브에서 확인할 수 있다)

 

그럼 서비스단에서는 댓답글을 생성할 건데 생성할 때는 아래와 같은 로직으로 구성된다. 

[1] 우선 받아온 작성자와 시그니처(게시글)가 유효한지 확인한다 -> 유효하지 않다면 404 오류를 던진다.

[2] 둘 다 유효하다면 parentID 변수에 값이 담겨 있는지를 확인해서 댓글인지 답글인지 체크한다.

[3] 만약 parentID가 null이면 오리지널 댓글을 생성한다. 즉 parentID는 자기 자신으로 지정해 준다.

[3] parentID에 값이 담겨왔다면 답글을 생성한다. 즉 parentID에 내 기본키가 아닌 다른 댓글(부모)의 기본키가 들어간다. 

// signature.comment.service.ts
async createSignatureComment(
  // 댓글, 답글 생성하기
  createCommentDto: CreateCommentDto,
  userId: number,
  signatureId: number,
  parentCommentId?: number,
) {
  const comment = new SignatureCommentEntity();

  const user = await UserEntity.findOneOrFail({ where: { id: userId } });
  const signature = await SignatureEntity.findOneOrFail({
    where: { id: signatureId },
    relations: { user: true },
  });

  if (!user || !signature) {
    throw new NotFoundException('404 Not Found');
  } else {
    comment.user = user;
    comment.signature = signature;
    comment.content = createCommentDto.content;

    // parentCommentId가 존재할 경우 -> 답글 / 존재하지 않을 경우 -> 댓글
    if (parentCommentId) {
      // 대댓글: parentId는 파라미터로 받은 parentCommentId로 설정

      const parentComment = await SignatureCommentEntity.findOneOrFail({
        where: { id: parentCommentId },
        relations: { user: true },
      });

      if (!parentComment) throw new NotFoundException('404 Not Found');
      else {
        comment.parentComment = parentComment;
        await comment.save();
      }
    } else {
      // 댓글: parentId는 본인으로 설정
      const savedComment = await comment.save();
      savedComment.parentComment = savedComment;
      await savedComment.save();
    }

    return comment.id;
  }
}

3. READ: 댓글 답글 조회하기 ⭐️

무한스크롤이 더해지면서 정말 헷갈리고 어려웠던 부분이다. 우선 기본적인 무한스크롤 구현 방법은 이전 포스트에 자세히 작성해 두었다. 

 

[Nest.JS] 무한스크롤을 구현하는 방법 | Cursor Based Pagination 이해하기 | Cursor vs Offset

무한 스크롤이란? 무한 스크롤(Infinite Scroll)은 웹사이트나 앱에서 사용되는 스크롤링 기술로, 사용자가 웹페이지를 스크롤 하면 새로운 콘텐츠가 자동으로 동적으로 로드되는 방식이다. 기존의

yuejeong.tistory.com

 

1차로 구현했을 때는 정말 단순하게 생각해서 예외 상황을 전혀 생각하지 못했다. 구현 완료 후 포스트맨에서는 잘 작동하니 디벨롭 브랜치에 마지했다. 그런데 막상 프론트와 코드를 합치고 UI 화면에서 다양한 상황으로  테스트해 보니 댓글 여럿이 누락되는 버그가 발생했다. 혹시 나와 같이 무한스크롤로 댓글과 답글을 구현한다면 조회할 때 나와 같은 실수를 하지 않기를 바란다!!

 

우선 내가 1차로 구현했던 방법은 아래와 같다. find문에만 집중해 보자.

 

🔹 댓글 답글 조회 1차 시도 -> 댓글 누락 발생

    const [comments, total] = await SignatureCommentEntity.findAndCount({
      take: cursorPageOptionsDto.take,
      where: {
        signature: { id: signatureId },
        parentComment: { id: MoreThan(cursorPageOptionsDto.cursorId) },
      },
      relations: {
        user: { profileImage: true },
        parentComment: true,
        signature:{ user: true, }
      },
      order: {
        parentComment: { id: "ASC" as any,},
        created: 'ASC'
      },
    });

각 옵션을 설명하자면 

[1] take: 한 번에 가져오는 댓글 수

[2] where: find의 조건절이다. signature_comment_entity 테이블에서 현재 조회하는 게시물에 달린 댓글들을 가져오고, 이때 조건은 parentComment가 무한 스크롤 curesorID보다 큰 값을 가져온다. 

 

여기서 내가 간과한 점이 있다. 자 만약 signature_comment 엔티티에 아래와 같은 인스턴스가 들어있다고 가정해 보자. 

id parent_id 설명
1 1 첫 번째 부모 댓글
2 1 첫 번째 답글
3 1 두 번째 답글
4 1 세 번째 답글
5 1 네 번째 답글
6 6 두 번째 부모 댓글

-> 빨간색 하이라이트 부분만 가져오고 나머지는 누락되는 상황 발생

 

만약 take가 3인데 parent_id가  1인 댓글은 4개 이상이라면 어떻게 될까?

 

현재 cursor_id가 0이라면 parent_id가 1인 댓글들이 출력될 차례다. 그런데 take가 3이므로 parent_id에서 created 오름차순 나열 시 상위 세 개에 있는 댓글들만 가져오게 된다. 그리고 무한 스크롤 로직에 의해 cursorID는 1 증가해 2로 변하므로 출력되지 못한 나머지 댓글들(4,5)은 누락된다. 

 

그러니까 핵심은 무한 스크롤 구현시 cursorID는 무조건 테이블에서 유일한 값을 가지는 기본키 id로 설정해야 한다는 것이다.

나는 comment 엔티티에서 여러 데이터가 중복 값을 가질 수 있는 parentID를 cursorID로 설정했기 때문에 일부 댓글이 누락되는 오류가 발생했다. 

 

그래서 댓글 조회 로직을 아예 갈아엎고 다음과 같이 수정했다.

[1] 댓글을 가져올 때 무한스크롤로 가져올 때 cursorID는 ID순으로 가져오되 parentID가 자기 자신인 오리지널 부모 댓글들만 가져온다.

[2] 그리고 각 부모 댓글의 답글들을 가져와서 함께 넘겨준다.

 

🔹 댓글 답글 조회 2차 시도 -> 댓글 누락 해결

      
      // 댓글만 가져오기
      const [comments, total] = await SignatureCommentEntity.findAndCount({
        take: cursorPageOptionsDto.take,
        where: {
          id: MoreThan(cursorPageOptionsDto.cursorId),
          signature: { id: signatureId },
          parentComment: { id: Raw("SignatureCommentEntity.id") },      // 부모 댓글이 자기 자신인 댓글들만 가져오기
        },
        relations: {
          user: { profileImage: true },
          parentComment: true,
          signature:{ user: true, }
        },
        order: {
          parentComment: { id: "ASC" as any,},
          created: 'ASC'
        },
      });
      
      
      // 찾아온 각 댓글들의 답글 가져오기
      const result: GetSignatureCommentDto[] = [];

      for(const comment of comments){
        console.log(comment);
        result.push(await this.createSignatureCommentDto(comment,userId));

        const childrenComments = await SignatureCommentEntity.find({ // 답글 찾아오기
          where: {
            parentComment: { id: comment.id },
            id: Not(Raw("SignatureCommentEntity.parentComment.id"))
          },
          relations: {
            user: { profileImage: true },
            parentComment: true,
            signature:{ user: true, }
          },
          order: {
            created: 'ASC'
          },
        });

 

다음과 같이 수정하면서 댓글 누락 오류는 해결했다!

덧붙여서 댓글 답글 조회 시 보내는 데이터가 꽤 많고 복잡한데 하나씩 소개해보겠다.

 

📑 댓글 답글 조회 API 명세서

 

시그니처 댓글/ 답글 불러오기 (무한 스크롤) | Notion

api/v1/signautre/:signatureId/comment?take=${take}&cursorId=${cursorId}

sally626.notion.site

 

우선 댓글 데이터를 보낼 때 담는 정보들은 다음과 같다. 

// get-signature-comment.dto.ts

import { GetCommentWriterDto } from './get-comment-writer.dto';

export class GetSignatureCommentDto {
  _id: number;
  parentId: number;
  content: string;
  writer: GetCommentWriterDto;
  date: Date; // 생성 | 수정일
  is_edited: boolean; // 댓글 수정 여부
  can_delete: boolean; // 로그인한 사용자의 댓글 삭제 권한 여부: 시그니처 작성자면 true
}
// get-comment-writer.dto.ts

export class GetCommentWriterDto {
  _id: number;
  name: string;
  image: string; // 프로필 이미지
  is_writer: boolean; // 로그인 유저의 수정 삭제 가능 여부
}

 

여기서 is_edited, can_delete, is_writer 이렇게 세 가지 속성에 주목해 보자

 

🔹 is_edited: 댓글 수정 여부

해당 데이터는 댓글 생성 시간(created_at)과 수정 시간(updated_at)을 비교해서 2초 이하면 수정 안 함 false, 그 이상이면 수정함 true를 담는다.

    // 댓글 수정 여부 구하기
    const createdTime = comment.created.getTime();
    const updatedTime = comment.updated.getTime();

    if (Math.abs(createdTime - updatedTime) <= 2000) {
      // 두 시간 차가 2초 이하면 수정 안함
      getCommentDto.is_edited = false;
    } else {
      getCommentDto.is_edited = true;
    }

왜 단순히 created와 updated 일치 여부를 비교하지 않았냐면, 처음 댓글을 생성할 때 생성 시가 created_at, updated_at 두 속성에 담기는데 서버 지연 시간 때문인지 어떤 이유로 애초부터 두 개의 속성에 차이가 발생하는 경우가 있기 때문이다.

그러므로 단순히 일치하는지만 비교한다면 댓글을 생성하기만 했는데 수정했다고 뜰 수 있는 것이다.

그래서 최대 오차를 고려해 2초로 잡았다.


🔹 can_delete: 현재 로그인한 사용자의 해당 댓글 삭제 가능 여부

    // 로그인한 사용자가 시그니처 작성하면 can_delete = true
    let can_delete = false;
    if (comment.signature.user) {
      // 시그니처 작성자가 존재할 경우
      if (comment.signature.user.id == userId) {
        // 로그인한 사용자가 시그니처 작성자일 경우 댓글 삭제 가능
        can_delete = true;
      }
    }
    getCommentDto.can_delete = can_delete;

기획에 따라 달라질 수 있는데 우리는 게시물 작성자가 자신의 글에 달린 댓글을 삭제할 수 있도록 구현하고 싶었다.

그래서 게시물 작성자와 현재 로그인한 사용자가 일치하면 모든 댓글들의 can_delete에 true가 담긴다.

 

 

그래서 이렇게 실제 서비스를 보면 내가 작성한 글에 달린 모든 댓글에 휴지통 아이콘이 보인다.

( 다만 수정하는 것은 안된다 )

 

그럼 만약 현재 로그인한 사용자가 게시글 작성자는 아니지만 댓글 작성자는 맞으면 can_delete 값은 어떻게 되는 것인가?

-> false이다. 대신 can_delete보다 더 우선순위에 있는 속성이 있어서 그 속성이 true면 can_delete가 false여도 삭제할 수 있다.

그 속성이 바로 is_writer이다. 


🔹 is_writer: 현재 로그인한 사용자가 댓글 작성자인지 여부

    // 로그인한 사용자가 댓글 작성자인지 확인
    if (userId == comment.user.id) writerProfile.is_writer = true;
    else writerProfile.is_writer = false;

자 이번에는 로그인한 사용자가 댓글 작성자인지 여부를 보내준다. is_writer가 true면 해당 댓글에 대해 수정, 삭제가 가능해진다. 이 속성이 위에 can_delete보다 우선순위에 있도록 프런트엔드 개발자와 합의를 했기 때문에 can_delete값과 상관없이 내가 댓글 작성자면 true를 보내준다. 

 

실제로 서비스에 적용한 모습은 다음과 같다.

 

(댓글 답글의 복잡한 로직을 함께 고민하고 화면을 멋있게 구현해 준 프론트엔드 용민님께 다시 한번 무한 감사를 드립니다..)


 

4. UPDATE: 댓글 답글 수정하기

사실 댓글 수정 구현부에서 실질적으로 댓글 수정에 작용하는 코드는 맨 아래에 세 개의 줄이다. 나머지는 데이터가 유효한지, 혹은 수정하려는 사용자의 권한이 있는지 확인하는 코드다. 사실 프론트 측에서 현재 로그인한 유저가 작성한 댓글일 때만 '수정'아이콘이 뜨도록 안전장치를 해두었지만 혹시 게시글이 갑자기 삭제되거나 뜻하지 않은 오류가 발생할 수 있으니 총 3단계의 2차 안전장치를 만들어뒀다.

[1] 게시글이 유효한지 확인
[2] 수정하려는 댓글이 유효한지 확인
[3] 현재 수정을 요청한 유저(현재 로그인한 유저)와 댓글 작성자가 일치하는지 확인
  async patchSignatureComment(
    // 댓글 수정하기
    userId: number,
    signatureId: number,
    commentId: number,
    patchedComment: CreateCommentDto,
  ) {
  
    // 시그니처 유효한지 확인
    const signature = await SignatureEntity.findOne({
      where: { id: signatureId },
      relations: { user: true },
    });
    if (!signature) throw new NotFoundException('존재하지 않는 시그니처입니다');

    // 댓글 데이터 유효한지 확인
    const comment = await SignatureCommentEntity.findOne({
      where: { id: commentId },
      relations: { user: true },
    });
    if (!comment) throw new NotFoundException('존재하지 않는 댓글입니다');


    let forbiddenUser = true;
    // 댓글 작성자가 로그인한 사용자 본인이 맞는지 확인

    if (comment.user.id) {
      // 댓글 작성자가 존재한다면 댓글 작성자와 로그인한 사용자가 일치하는지 확인
      if (comment.user.id == userId) forbiddenUser = false;
    }

    if (forbiddenUser)
      throw new ForbiddenException('댓글 수정 권한이 없습니다');


    // 댓글 수정하기
    comment.content = patchedComment.content;
    await comment.save();
    return comment.id;
  }

 

5. DELETE: 댓글 답글 삭제하기

댓글 삭제도 위에 댓글 수정처럼 2차 안전장치를 구현해 뒀다. 다만, 댓글 수정과 다른 점은 댓글 삭제는 댓글 작성자뿐만 아니라 게시글 작성자도 할 수 있다는 점이다.

[1] 게시글이 유효한지 확인
[2] 수정하려는 댓글이 유효한지 확인
[3] 현재 수정을 요청한 유저(현재 로그인한 유저)와 댓글 작성자 혹은 게시글 작성자가 일치하는지 확인

 

안전장치를 모두 통과하면 삭제 코드를 만날 수 있다. 이때 댓글 삭제는 parentID가 본인인 오리지널 부모 댓글이냐 아니면 parentID가 다른 댓글의 ID, 즉 답글이냐에 따라서 삭제 방법이 다르다.

만약 후자라면 본인만 삭제하면 되니 간단하다. 

하지만 전자라면 본인에게 딸린 자식 댓글을 모두 삭제해야 한다. 그러므로 TypeORM find문을 사용해서 답글을(자식 댓글) 모두 가져와서 배열에 담은 후, 배열에 있는 모든 답글들을 softRemove 해준다. 그다음엔 마지막으로 본인 삭제.

( 사실 이건 기획의 방향에 따라 다를 수 있다. 댓글을 삭제할 때 아래에 달린 답글을 모두 삭제할 것인지, 아니면 댓글 삭제랑 상관없이 나머지 답글을 남겨둘 것이냐 이 두 개를 고민했는데 우리는 모두 삭제하는 방향으로 정했다 )

  async deleteSignatureComment(
    userId: number,
    signatureId: number,
    commentId: number,
  ) {
    try {
    
      // [1] 시그니처 유효한지 확인
      const signature = await SignatureEntity.findOne({
        where: { id: signatureId },
        relations: { user: true },
      });
      if (!signature)
        throw new NotFoundException('존재하지 않는 시그니처입니다');


      // [2] 댓글 데이터 유효한지 확인
      const comment = await SignatureCommentEntity.findOne({
        where: { id: commentId },
        relations: ['user', 'parentComment', 'signature'],
      });
      if (!comment) throw new NotFoundException('존재하지 않는 댓글입니다');


      let forbiddenUser = true;
      // [3] 댓글 작성자가 로그인한 사용자 본인 혹은 시그니처 작성자가 맞는지 확인
      
      if (signature.user) {
        // 시그니처 작성자가 존재한다면 시그니처 작성자와 로그인한 사용자가 일치하는지 확인
        if (signature.user.id == userId) forbiddenUser = false;
      }
      if (comment.user.id) {
        // 댓글 작성자가 존재한다면 댓글 작성자와 로그인한 사용자가 일치하는지 확인
        if (comment.user.id == userId) forbiddenUser = false;
      }

      if (forbiddenUser)
        throw new ForbiddenException('댓글 삭제 권한이 없습니다');


      // 해당 댓글이 부모 댓글인 경우 자식 댓글 모두 삭제
      if (commentId == comment.parentComment.id) {
        // 자식 댓글 모두 찾아오기
        const replyComments: SignatureCommentEntity[] =
          await SignatureCommentEntity.find({
            where: { parentComment: { id: commentId } },
          });

        // 자식 댓글 모두 삭제
        for (const reply of replyComments) {
          await reply.softRemove();
        }

        // 자식 모두 삭제했으면 부모 댓글 삭제
        await comment.softRemove();
      } else {
        // 자식 댓글 없는 경우 본인만 삭제
        await comment.softRemove();
      }

      return commentId;
      
    } catch (error) {
      console.log(error);
      throw error;
    }
  }

 

그럼 댓글 답글에 대한 CRUD 기본적인 로직은 모두 구현할 수 있게 된다.

여러 가지 오류를 해결하는 과정에서 프론트엔드와 적극적으로 협업하고 다양한 방법을 생각해 보며 크게 성장할 수 있었다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함