무한 대댓글 구현 (Prisma)

JM·2024년 12월 19일
0

이번에 토이프로젝트로 게시판을 구현하면서 가장 난관이 무한 대댓글을 구현하는 것이었다.

구글링해보니 이미 많은 분들이 이미 고민하고 정리한 글들을 찾을 수 있었고, 그것들을 참고해서 구현한 내용을 정리해본다.

참고로 먼저 쓴 댓글이 상단에, 최신 댓글이 아래로 가는 방식이다. (최신 댓글이 상단으로 오게 하려면 대댓글 저장하는 부분의 로직을 수정해야한다. 로직이 조금 더 복잡해진다)

DB 모델 설계

Prisma Model

model Comment {
  id        Int     @id @default(autoincrement())
  content   String  @db.Text
  post      Post    @relation(fields: [postId], references: [id])
  postId    Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 대댓글 구현을 위한 필드
  parentComment   Comment?  @relation("childComments", fields: [parentCommentId], references: [id]) // 상위 댓글
  parentCommentId Int?
  childComments   Comment[] @relation("childComments")
  group           Int       @default(1) // 댓글 그룹 (최상위 댓글 순서)
  order           Int       @default(0) // 최상위 댓글 그룹 기준으로, 모든 최하위 댓글까지의 정렬 순서
  depth           Int       @default(0) // 댓글 깊이 (0일시 최상위 댓글)
}

아주 간단하다. (간단한 예제를 위해 userId 레퍼런스 필드는 삭제하였다)

group은 말그대로 최상위 댓글 순서이다. 첫번째 댓글은 1, 두번째 댓글부터 2,3,4 로 등록된다.

order은 해당 댓글 group에 속하는 모든 대댓글에 대한 순서이다.

depth는 해당 댓글의 깊이이다. 최상단 댓글은 0, 최상단 댓글이 달린 대댓글은 1이 되겠다.

이렇게 만든 계층구조를 Visualize 하면 이렇게 된다.

만약 레코드가 많아 댓글을 정렬해서 가져오는데 오래 걸린다면 아래와 같이 인덱스를 걸어주는 게 좋을 것 같다.
@@index([group, order])

댓글 로드

이 부분은 아주 간단하다.

await prisma.comment.findMany({
  where: { postId: post.id },
  orderBy: [{ group: "asc" }, { order: "asc" }],
});

group (최상단 댓글) 을 기준으로 먼저 정렬을 해준다. 그 후, 하위 대댓글들 정렬값을 담고 있는 order 필드를 기준으로 정렬을 해준다.

댓글 저장

/**
 * 댓글을 DB에 저장합니다.
 * @param {number} postId 댓글을 다는 게시글 ID
 * @param {string} content 댓글 내용
 * @param {number | null} parentCommentId 대댓글일 경우 상부 댓글 ID, 최상부 코멘트일 경우 null
*/
async function createComment(postId: number, content: string, parentCommentId: number | null): Promise<void> {
  const post = await prisma.post.findUnique({ where: { id: postId } });
  if (!post) return;

  if (parentCommentId === null) {
    /*** 최상단 댓글 작성 로직 ***/
    
    // group 계산
    const { _max } = await prisma.comment.aggregate({
      where: { postId: post.id },
      _max: { group: true },
    });
    const maxGroupInThisPost = _max.group ?? 0;

    // DB 저장
    await prisma.comment.create({
      data: {
        content,
        post: { connect: { id: post.id } },
        group: maxGroupInThisPost + 1,
      },
    });
  } else {
    /*** 대댓글 댓글 작성 로직 ***/
    
    // 상부 댓글
    const parentComment = await prisma.comment.findUnique({
      select: { group: true, order: true, depth: true },
      where: { postId: post.id, id: parentCommentId },
    });
    if (!parentComment) return;

    // order 계산을 위해 상부 댓글의 자식 댓글이 몇개인지 구해옴.
    const parentCommentChildrenCount = await prisma.comment.count({
      where: { postId: post.id, parentCommentId },
    });

    // 생성할 대댓글 order 값 계산. 상부 댓글의 order에 자식 댓글의 개수를 더한 값에 바로 다음 위치에 생성하므로 +1 해줌.
    const order = parentComment.order + parentCommentChildrenCount + 1;

    // 정합성을 위해 트랜잭션 이용해서 저장
    await prisma.$transaction([
      // 생성할 위치보다 order 값이 큰 대댓글들의 order 값을 1씩 올려줌.
      prisma.comment.updateMany({
        where: {
          postId: post.id,
          group: parentComment.group,
          order: { gte: order },
        },
        data: { order: { increment: 1 } },
      }),
      
      // 대댓글 저장
      prisma.comment.create({
        data: {
          content,
          post: { connect: { id: post.id } },
          parentComment: { connect: { id: parentCommentId } },
          group: parentComment.group, // 상부 댓글 그룹
          depth: parentComment.depth + 1, // 상부댓글에 대한 대댓글이므로 상부댓글 depth에 +1
          order,
        },
      }),
    ]);
  }
}

Validation이나 인증 관련은 제외하고 순수 대댓글 로직만 작성하였다.

주석을 Verbose하게 달아두어서 추가 설명은 생략해도 될 것 같다.

프론트엔드에서 댓글 마크업시에 depth에 따라서, 좌측 마진을 comment.depth * 32 이런식으로 주면 계층 표현이 된다.

이렇게 간단한 구현이 마무리 되었다. 대댓글 삭제시에는 order 값을 수정해야하는데 댓글 삭제 로직은 직접 실습해보시기 바랍니다 :)

profile
No one is perfect, but I'm still striving for perfection

0개의 댓글

관련 채용 정보