[Express + React] 댓글 - 대댓글 기능을 구현하면서

subbni·2024년 10월 12일

대충 Figma로 UI는 이 정도로 잡고 시작했다.

실제 구현

실제 구현은 이렇게 되었다

좋아요 기능은 구현 전이라서 댓글 뷰만 구현했다
헤더는 아직 구현을 제대로 하지 않아 흐린 눈 부탁 .. 😶

요구사항 설계

  1. 하나의 게시글에 N개의 댓글을 달 수 있다.

  2. 1개의 부모 댓글에 N개의 대댓글을 달 수 있다.

  3. 어떤 하나의 댓글에 달린 대댓글들은 모두 같은 깊이를 갖는다.

  • 부모 댓글에 단 답글이나 대댓글에 단 답글이나 모두 같은 깊이를 가진다.
  1. 답글을 다는 경우, 원본 댓글(댓글 혹은 대댓글)을 작성한 사용자가 '언급' 된다.

구현하기 전 ...

대댓글을 어떻게 보여줄 것인가?

댓글을 전부 보여줄 수 없으므로 페이지네이션을 적용해야 한다.

한 페이지에 보여주는 댓글의 수에 대댓글을 포함 / 대댓글을 미포함 할 것인가?

길게 생각할 필요 없이 대댓글을 포함하는 것은 거의 불가능에 가깝다는 결론이 나왔고 (방법이 있다면 말씀해주십사..)

대댓글을 미포함한 부모 댓글의 개수로 페이지의 limit을 정했다.

대댓글을 애초에 처음부터 모두 보여줄 것인가? 혹은 접은 채로 보여준 후 사용자 요청 시 불러와 보여줄 것인가?

이건 사용자 요청 시 대댓글을 불러와 보여주는 쪽을 선택해서 구현하였다.

사실, 만일 Springboot로 백엔드를 구현 중이었다면 애초에 처음부터 보여주는 쪽을 선택했을 지도 모른다.
Java + JPA로 구조화가 쉽게 잘 되는 상황이었다면 애초에 CommentListDto에 RecommentDto를 리스트로 넣어서 보냈을 것 같기도 하다.

그런데 나는 지금 JS로 백엔드를 구현 중이고 ... JPA를 사용할 때와는 달리 SQL문을 내가 직접 짜서 '성능'을 고려하여야 하고 .. 타입 검증도 안 되고 .. 구조화도 힘들고 ...

그니까 처음부터 보여주려면 일단 부모 댓글을 전부 불러오고, 그 부모 댓글에 대해 대댓글을 전부 불러와야 한다. 근데 또 작성자 정보를 가져와야하니까 각 대댓글에 대해 작성자 정보를 가져오기 위해 member table을 조인해야 하고, 부모 댓글의 작성자 정보도 필요하니까 또 member table을 따로 조인해야 하고 ...........

이렇듯 SQL문과 데이터 처리 로직이 너무 복잡해지는 매직을 겪고는 아예 댓글 조회와 대댓글 조회 로직을 분리해버리는 쪽을 선택하게 되었다 ㅎㅎ

구현하면서 ...

대댓글이 달린 댓글의 삭제 처리

처음 구현 당시, 댓글 삭제는 로직이라할 것 없이 댓글 작성자가 삭제 요청 시, DB에서 바로 삭제 되도록 구현하였다.

그런데 이제 이렇게 구현하고 나서 테스트를 하는 과정에서
'대댓글이 달린 댓글을 작성자가 삭제할 경우'를 고려해야 함을 깨닫게 됐다.

댓글 조회의 로직은 다음과 같았다.

  1. 부모 댓글 N개를 보여준다.
  2. 사용자가 답글을 펼칠 경우, 해당 댓글을 parent_id로 갖는 댓글들을 가져와 답글로 보여준다.

따라서 만일 부모 댓글이 삭제 되면, 그에 달린 대댓글들은 DB에는 존재하지만 사용자에게는 보여질 수 없다. 불필요하게 DB가 낭비되는 것이다.

가능한 댓글 삭제 로직을 생각해보니 다음과 같았다.

  1. 대댓글이 달린 답글은 삭제할 수 없도록 한다.
  2. 부모 댓글이 삭제될 때 대댓글도 전부 삭제한다.
  3. 대댓글이 없는 댓글만 DB에서 바로 삭제하며, 대댓글이 존재하는 댓글은 삭제하지 않되 삭제 되었음을 마킹한다.

일단 1번은 말도 안 되고
2번과 3번 중에서 선택을 해야 했는데, 확인을 해보니 유튜브의 경우 2번을 블로그의 경우 3번의 방법을 선택하고 있는 듯 했다.

현재 개발 중인 어플리케이션은 블로그의 성질을 띄고 있으므로 3번의 방법을 따라가기로 결정했다.

변경된 댓글 삭제 로직 in Service

	static async deleteComment(commentId) {
		const comment = await CommentRepository.findByCommentId(commentId);
		if (!comment) {
			throw new CustomError(CommentErrorMessage.COMMENT_NOT_FOUND);
		}

		const { parent_id, article_id, recomment_count } = comment;

		if (parent_id) {
			CommentRepository.updateRecommentCount(comment.parent_id, -1);
		}

		await ArticleService.updateCommentCount(article_id, -1);

		const result =
			recomment_count > 0
				? await CommentRepository.updateDeleted(commentId)
				: await CommentRepository.delete(commentId);

		return { data: result };
	}
  1. 삭제하는 댓글이 '대댓글' 인 경우, 부모 댓글의 recomment_count를 -1 처리
  2. 삭제하는 댓글이 달린 article의 comment_count를 -1 처리
  3. 삭제하는 댓글에 '대댓글'이 존재하는 경우, deleted 필드를 true 처리
  4. 삭제하는 댓글에 '대댓글'이 존재하지 않는 경우, DB에서 완전 삭제 처리

변경된 댓글 갯수 조회 로직 in Repository

	static async getCountByArticleId(article_id) {
		const result = await pool.query(
			'SELECT count(*) FROM comment WHERE article_id = $1 AND deleted = false',
			[article_id],
		);
		return result.rows[0];
	}
  • 특정 article_id를 가진 댓글 중 deleted = false 인 것들만 조회

트랜잭션 처리의 필요성

article에 comment_count, comment에 recomment_count 필드를 넣어 유지하다보니 트랜잭션의 필요성이 드러났다 . . .

위의 댓글 삭제 로직에서 한 부분에서 에러가 나도 rollback이 되지 않아 무결성이 유지되지 않는 문제를 직접 겪었기 때문이다.

Springboot+JPA를 사용할 때는 @transaction 어노테이션을 사용해서 간편하게 트랜잭션 처리를 할 수 있었다.
Express에는 MySQL의 경우 모듈의 트랜잭션 관련 함수를 사용할 수 있지만 PostgreSQL의 경우에는 직접 SQL문에 BEGIN;COMMIT; 명령을 지정해야하는 것 같다.
(곧 트랜잭션을 도입할 예정이므로 그 때 확실히 알아봐야겠다.)

대댓글을 여러 개 펼칠 경우?


위처럼 하나의 답글을 펼친 상황에서, 다른 답글을 펼치면

이렇게 .. 원래 펼쳐져 있던 답글은 다시 접혀지는 상황이 되었다.
여러 개의 답글을 펼친 상태로 유지할 수 있으려면 어떻게 해야할까?

컴포넌트 구조

redux 상태 관리

현재 recomment를 불러올 때, parentId를 전달하면 해당 parentId를 가진 대댓글들을 리스트로 받아오고 있다.
프론트에서는 이렇게 받아온 recomment 리스트들을 받아올 때마다 차곡차곡 저장해놓아야 한다.

따라서 기존의 state에서의 recomments : []recomments : {} 의 객체로 변경하였고, key를 parentId로, value를 해당 parentId를 갖는 대댓글 리스트로 설정하였다.

const initialState = {
	comments: null,
	recomments: {},
	deletedComment: null,
	addedComment: null,
	error: null,
};

const comments = handleActions(
	{
      
...중략
      
		[READ_RECOMMENTS_SUCCESS]: (state, { payload: { parentId, data } }) => ({
			...state,
			recomments: {
				...state.recomments,
				[parentId]: [...data],
			},
		}),

      
... 중략
      
	},
	initialState,
);

export default comments;

대댓글 요청에 대한 응답으로 리스트 형태의 data와 parentId를 받아오면 기존의 recomments는 유지하되, 해당 parentId를 키로 대댓글 리스트를 추가한다.

CommentsList

expandedCommentId 리스트를 상태로 관리해준다.
이 안에 답글을 펼친 부모 댓글의 Id들을 넣어 유지한다.

const [expandedCommentId, setExpandedCommentId] = useState([]);

// 답글 펼치기 & 접기 버튼을 누르면 실행하는 함수
const onRecommentShowBtnClick = (commentId) => {
		if (expandedCommentId.length > 0 && expandedCommentId.includes(commentId)) {
			setExpandedCommentId(expandedCommentId.filter((id) => id !== commentId));
		} else {
			setExpandedCommentId([...expandedCommentId, commentId]);
			onRecommentShow(commentId);
		}
	};

그리고 이렇게 expendedCommentId에 commentId가 포함되어 있는 친구들만 RecommensList를 보여주면 된다.

	{expandedCommentId.length > 0 && expandedCommentId.includes(comment.comment_id) ? (
								<RecommentsList props={생략} />) : null}

CommentsList 전체 코드

import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import CommentItem from './CommentItem';
import RecommentsList from './RecommentsList';
const CommentsListBlock = styled.div`
	/* border: 1px solid black; */
	font-size: 0.9rem;
`;

const CommentWrapper = styled.div`
	display: flex;
	flex-direction: column;
	justify-content: start;
	align-items: center;
	/* padding: 0.6rem 0; */
`;

const CommentsList = ({
	comments,
	recomments,
	onCommentSubmit,
	onRecommentShow,
	currentUserId,
	onCommentDelete,
	onCommentModify,
}) => {
	const [replyingCommentId, setReplyingCommentId] = useState(null);
	// TODO : expandedCommentIds Set으로 만들기
	const [expandedCommentId, setExpandedCommentId] = useState([]);
	const [editingCommentId, setEditingCommentId] = useState(null);

	const onRecommentWriteBtnClick = (commentId) => {
		setReplyingCommentId(commentId);
	};

	const onRecommentShowBtnClick = (commentId) => {
		if (expandedCommentId.length > 0 && expandedCommentId.includes(commentId)) {
			setExpandedCommentId(expandedCommentId.filter((id) => id !== commentId));
		} else {
			setExpandedCommentId([...expandedCommentId, commentId]);
			onRecommentShow(commentId);
		}
	};

	const onRecommentCancelClick = () => {
		setReplyingCommentId(null);
	};

	const onCommentModifyClick = (commentId) => {
		setEditingCommentId(commentId);
	};

	const onCommentModifyCancelClick = () => {
		setEditingCommentId(null);
	};

	const onCommentModifyConfirmClick = (form) => {
		setEditingCommentId(null);
		onCommentModify(form);
	};

	useEffect(() => {
		setReplyingCommentId(null);
	}, [comments, recomments]);

	return (
		<CommentsListBlock>
			{comments &&
				comments.data.map((comment) => {
					return (
						<CommentWrapper
							className={`comment${comment.comment_id}-item`}
							key={`comment${comment.comment_id}`}
						>
							<CommentItem
								comment={comment}
								expandedCommentId={expandedCommentId}
								replyingCommentId={replyingCommentId}
								onCommentSubmit={onCommentSubmit}
								onRecommentShowBtnClick={onRecommentShowBtnClick}
								onRecommentWriteBtnClick={onRecommentWriteBtnClick}
								onRecommentCancleClick={onRecommentCancelClick}
								onCommentModifyClick={onCommentModifyClick}
								onCommentModifyCancelClick={onCommentModifyCancelClick}
								onCommentDelete={onCommentDelete}
								onCommentModifyConfirmClick={onCommentModifyConfirmClick}
								isAuthor={currentUserId === comment.member_id}
								isEditing={editingCommentId === comment.comment_id}
								isExpanded={expandedCommentId.includes(comment.comment_id)}
							/>
							{expandedCommentId.length > 0 && expandedCommentId.includes(comment.comment_id) ? (
								<RecommentsList
									comment={comment}
									recomments={recomments}
									expandedCommentId={expandedCommentId}
									replyingCommentId={replyingCommentId}
									editingCommentId={editingCommentId}
									onCommentSubmit={onCommentSubmit}
									onRecommentWriteBtnClick={onRecommentWriteBtnClick}
									onRecommentShowBtnClick={onRecommentShowBtnClick}
									onRecommentCancleClick={onRecommentCancelClick}
									onCommentModifyClick={onCommentModifyClick}
									onCommentModifyCancelClick={onCommentModifyCancelClick}
									onCommentDelete={onCommentDelete}
									onCommentModifyConfirmClick={onCommentModifyConfirmClick}
									currentUserId={currentUserId}
									isAuthor={currentUserId === comment.member_id}
									isEditing={editingCommentId === comment.comment_id}
								/>
							) : null}
						</CommentWrapper>
					);
				})}
		</CommentsListBlock>
	);
};

export default CommentsList;

지금은 expandedCommentId를 List로 만들어서, includes 메서드를 사용하고 있는데, Set으로 만들어서 해당 id가 있는지를 확인하는 것이 성능이 더 빠를 것이므로 리팩토링 시 적용할 생각이다.


to be continued ...

profile
개발콩나물

0개의 댓글