댓글 및 토론
규칙
- 스레드형 댓글: 댓글 테이블의
parent_id 외래 키 사용 — null이면 최상위 댓글, 부모를 참조하면 답글
- 낙관적 UI: 댓글을 즉시 UI에 추가하고, 서버 응답으로 확인하며, 오류 시 롤백
- @멘션: 댓글 본문에서
@username을 파싱 — @ 키 입력 시 사용자 조회 드롭다운 트리거, 멘션된 사용자에게 알림
- 반응: 별도
reactions 테이블(commentId, userId, emoji) — (commentId, userId, emoji)에 유니크 제약 조건
- 중재:
status 필드(published, flagged, removed) — 플래그된 콘텐츠를 위한 중재 대기열, 금칙어 목록을 통한 자동 플래그
- 소프트 딜리트:
deletedAt 타임스탬프 설정 — 답글이 있는 댓글은 "삭제됨" 플레이스홀더 표시, 고아가 된 삭제된 댓글은 완전히 숨김
- 커서 페이지네이션:
cursor(commentId) + limit — createdAt 기준 정렬, 스크롤 시 오래된 댓글 로드
- 수정 기록: 수정 내역을 별도 테이블 또는 JSON 배열에 저장 — 마지막 수정 타임스탬프와 함께 "수정됨" 표시기 표시
패턴
const comments = await db.query.comments.findMany({
where: and(eq(comments.postId, postId), isNull(comments.parentId)),
with: {
author: { columns: { id: true, name: true, avatar: true } },
replies: {
with: { author: { columns: { id: true, name: true, avatar: true } } },
orderBy: [asc(comments.createdAt)],
},
reactions: true,
},
orderBy: [desc(comments.createdAt)],
limit: 20,
});
const addComment = useMutation({
mutationFn: (body: string) => api.post(`/posts/${postId}/comments`, { body }),
onMutate: async (body) => {
await queryClient.cancelQueries({ queryKey: ["comments", postId] });
const previous = queryClient.getQueryData(["comments", postId]);
queryClient.setQueryData(["comments", postId], (old: Comment[]) => [
{ id: `temp-${Date.now()}`, body, author: currentUser, createdAt: new Date() },
...old,
]);
return { previous };
},
onError: (_err, _body, context) => {
queryClient.setQueryData(["comments", postId], context?.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["comments", postId] }),
});
피해야 할 것
- 무제한 중첩 깊이 — 2~3레벨로 제한하고, 더 깊은 답글은 평탄화
- 모든 댓글을 한 번에 로드 — 활발한 토론에서는 특히 커서 페이지네이션 사용
- 클라이언트 측 멘션 파싱만 수행 — 알림을 위해 서버 측에서 멘션을 검증하고 추출
- 답글이 있는 댓글을 하드 삭제 — 스레드를 고아로 만들므로 소프트 딜리트 후 플레이스홀더 표시
- 유니크 제약 조건이 없는 반응 — 동일 사용자가 중복 반응을 추가할 수 있음