이번에 토이프로젝트로 게시판을 구현하면서 가장 난관이 무한 대댓글을 구현하는 것이었다.
구글링해보니 이미 많은 분들이 이미 고민하고 정리한 글들을 찾을 수 있었고, 그것들을 참고해서 구현한 내용을 정리해본다.
참고로 먼저 쓴 댓글이 상단에, 최신 댓글이 아래로 가는 방식이다. (최신 댓글이 상단으로 오게 하려면 대댓글 저장하는 부분의 로직을 수정해야한다. 로직이 조금 더 복잡해진다)
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 값을 수정해야하는데 댓글 삭제 로직은 직접 실습해보시기 바랍니다 :)