안녕하세요

댓글에 답글(대댓글) 구현하기 본문

Next13 블로그 프로젝트

댓글에 답글(대댓글) 구현하기

sakuraop 2023. 10. 30. 23:32

답글 구현 결과 GIF

Depth controlled GIF


목차

1. 기존의 一자 댓글 형태

2. 댓글에 답글이 달리는 목표 형태

3. 어떻게 구현할까?
4-1. 목표대로 구현하기 위해 조건을 정리해보면
4-2. (Option) 페이지네이션 적용하려면 클라이언트 컴포넌트로 구현

5. 구현 방법 설명
6. 실제 사용한 코드 (주석 등등은 간소화 됨)

7. Depth를 활용해서 계단처럼 만들려면


1. 기존의 一 자 댓글 형태

기존에는 댓글을 저장할 때 parentPostId를 comment에 함께 저장했습니다.

postId에 해당하는 댓글을 요청하여 시간순서로 댓글을 나열하기만 합니다.

[ 
 {
    _id: new ObjectId("653fca0cc5e5048246973a3c"),
    parentId: 3, // 게시물 ID
    postTitle: '', // 게시물 제목
    nickname: 'young', // 댓글 작성자 닉네임
    comment: '자식A-2', // 댓글 내용
    date: 2023-10-30T15:21:48.719Z, // 댓글 작성 시간
    isLoggedIn: true, // 로그인 댓글 여부
  },
]

어느 댓글 아래에 답글이 나열되어야 하지만 정렬을 시키기 위한 정보가 부족합니다.

따라서 다음과 같은 설계를 할 수 있습니다.

 

2. 댓글에 답글이 달리는 목표 형태

부모댓글의 ID를 자식댓글이 가지도록 parentCommentId 속성과

@young 와 같이 어느 댓글을 참조하고 있는지 나타내기 위해서 replyToNickName 속성을 가지도록 합니다.

[
  {
    _id: new ObjectId("653fca12c5e5048246973a3d"),
    parentId: 3, // 게시물 ID
    postTitle: '', // 게시물 제목
    nickname: 'young', // 댓글 작성자 닉네임
    comment: '자식A-2', // 댓글 내용
    date: 2023-10-30T15:21:48.719Z, // 댓글 작성 시간
    isLoggedIn: true, // 로그인 댓글 여부
    parentCommentId: '653fc952c5e5048246973a37', // 부모 댓글 ID
    replyToNickname: 'young', // 부모 댓글 닉네임
  }
]

 

 

3. 어떻게 구현할까?

그리고 이 parentCommentId를 이용하여 [부모-자식] 형태로 묶어준 뒤에 정렬하면 됩니다.

 

답글 작성 시간은 반드시 부모 댓글보다 늦게 이루어지지만,

자식 댓글의 작성 시간에는 순서가 없습니다.

 

부모A -> 부모B -> 자식A-1 -> 자식A-2 -> 자식B-2

작성 순서대로 댓글을 그대로 나열해보면 다음과 같습니다.

부모A
부모B
자식A-1
자식A-2
자식B-2

 

원하는 형태는 다음과 같이 부모A를 가지는 자식A 들을 모아주는 단계가 필요합니다.

부모A
	자식A-1
	자식A-2
부모B
	자식B-2

자식끼리도 마찬가지로 순서를 반드시 유지하기 때문에 시간은 신경쓰지 않아도 됩니다.

 

A덩어리와 B덩어리를 만들고, 덩어리들을 나열하는게 목표가 되는거죠.


4-1. 목표대로 구현하기 위해 조건을 정리해보면

댓글이 작성되고 있는 조건을 살펴보면 다음과 같습니다.

 

1. 부모 댓글끼리는 작성된 순서를 유지한다.

2. 자식 댓글은 부모 댓글이 존재한 뒤에 작성된다.

3. 자식 댓글끼리는 작성된 순서를 유지한다.

 

[부모A ,자식A-1, 자식A-2], [부모B, 자식B-1, 자식B-2] 와 같은 덩어리를 만든다면

시간 순서는 전혀 고려할 요소가 아니게 됩니다.

 

 

4-2. (Option) 페이지네이션 적용하려면 클라이언트 컴포넌트로 구현

댓글을 서버에 요청할 때 페이지네이션을 적용하게 되면 다음과 같은 문제가 발생합니다.

4. 서버에서 페이지네이션을 적용하면 부모가 없는 자식 댓글이 존재한다.

 

한 페이지에 3개씩 댓글을 보여준다고 가정하면 어떤 문제가 발생하는지 살펴봅시다.

시간순서대로 불러온 댓글을 페이지 내에서 정렬하면,

Page 1.
부모A
자식A-1
자식A-2

Page 2.
자식A-3
부모B
자식A-4

 

Page 2. 에서는 요청한 댓글 목록에 부모 댓글이 존재하지 않는 자식 댓글 ( 자식A-3, 자식A-4 ) 가 존재하게 됩니다.

[부모,자식] 형태의 덩어리로 묶어줄 수 없게 되는 것이죠

따라서 이 방법으로 댓글 페이지네이션을 구현하기 위해서는 서버 클라이언트에 댓글 페이지를 요청하는 것이 아니라,

모든 댓글을 요청한 뒤에, 클라이언트 컴포넌트에서 페이지네이션을 구현하는 것이 맞습니다.

댓글이 많은 형태를 유지하기 위해서는 다음과 같이 replies 배열에 자식 댓글을 담아야 합니다.

[
  {
  	//...
    parentCommentId: '653fc952c5e5048246973a37', // 부모 댓글 ID
    replyToNickname: 'young', // 부모 댓글 닉네임
    replies: [ 자식A-1, 자식A-2 ]
  }
]

하지만 위와 같이 replies 로 구현하게 되면 데이터 결속이 강해져 확장/유지/보수의 난이도가 올라갑니다.

개인 블로그와 같이 댓글이 많이 달려봐야 100개 안팎인 상황을 고려한다면,

replies가 아닌 부모자식 레벨을 지정해주고 정렬알고리즘을 만드는 것이 유지보수면에서 좋다고 생각합니다.

 

TMI

하지만 유튜브 같은 경우엔 하나의 댓글에 답글이 500개씩도 달리기 때문에

replies 방식으로 구현하는게 유지보수를 더 쉽게 하는 방법이라고 봅니다. (결론은 목적에 따라서 선택하면 될 듯)

https://developers.google.com/youtube/v3/docs/commentThreads?hl=ko#resource

{
  "kind": "youtube#commentThread",
  "etag": etag,
  "id": string,
  "snippet": {
    "channelId": string,
    "videoId": string,
    "topLevelComment": comments Resource,
    "canReply": boolean,
    "totalReplyCount": unsigned integer,
    "isPublic": boolean
  },
  "replies": {
    "comments": [
      comments Resource
    ]
  }
}

5. 구현 방법 설명

export const sortCommentList = (comments: CommentInterface[]) => {
  const commentOrderMap = new Map();

  for (const comment of comments) {
    if (comment.depth === 0) {
      commentOrderMap.set(comment._id, [comment]); 
    } else {
      const parentArray = commentOrderMap.get(comment.parentCommentId);
      parentArray.push(comment) 
    }
  }

  const sortedComments = Array.from(commentOrderMap.values()).flat();
  return sortedComments;
};

 

[부모A,자식A], [부모B,자식B] 처럼 A, B 덩어리의 순서를 지키기 위해서 Map 객체를 이용합니다.

export const sortCommentList = (comments: CommentInterface[]) => {
  const commentOrderMap = new Map(); // 덩어리를 모을 Map 객체

 

그리고 GET으로 요청한 댓글 목록을 for문으로 순회합니다.

if문부터 살펴보겠습니다.

  for (const comment of comments) {
    if (comment.parentCommentId === null) { // 부모 댓글이 없다면
      commentOrderMap.set(comment._id, [comment]); // Map 객체에 { "부모A의ID":[부모A] } 추가
    }
    // else...
  }

comment.parentCommentId가 null이라는 것은 부모댓글이라는 것이기 때문에

자식 댓글이 참조할 부모 댓글을 Key로 만들고,

덩어리를 만들기 위한 배열에 부모A 자신을 추가하여 Value에 할당합니다.

 

다음으로 else 문을 살펴보겠습니다.

if문의 조건이 "부모댓글이라면" 이기 때문에, else 문에는 "자식댓글"이 오게 됩니다.

  for (const comment of comments) {
    // if... 
    } else { // 부모 댓글이 아니라면 자식 댓글
      const parentArray = commentOrderMap.get(comment.parentCommentId); // 부모 댓글 찾기
      parentArray.push(comment) // 부모덩어리에 추가
    }
  }

Map의 Key 중에서 자식 댓글의 parentCommentId가 있는지 확인합니다.

parentArray가 확인 된다면 부모 댓글이 존재하는 것이므로 해당 배열에 추가합니다.

 

Map 객체는 순서를 지키기 때문에 먼저 작성된(Map객체에 먼저 삽입된) 덩어리 또한 반드시 순서를 유지합니다. 

부모A
	자식A-1
	자식A-2
부모B
	자식B-2

 

마지막으로 Map객체를 배열로 변환한 뒤에 flat() 메서드로 배열을 평탄화시켜주면 됩니다.

  const sortedComments = Array.from(commentOrderMap.values()).flat(); // 평탄화
  return sortedComments;

결과 다시 확인


6. 실제 사용한 코드 (주석 등등은 간소화 됨)

댓글 목록 컴포넌트

// CommentList.tsx

const CommentList = ({
  const { commentList, setCommentList } = useCommentList(postId, newUpdate);
  return (
    <ul className={styles.commentList}>
      {commentList &&
        commentList.map((commentItem: CommentInterface) => {
          const { comment, date, isLoggedIn, nickname, author, _id, depth, replyToNickname, parentCommentId } = commentItem;
          return (
            <li
              key={commentId}
              className={`${styles.commentItem}`}
              style={{ paddingLeft: `${0.5 + (depth || 0) * 4}rem` }}
            >
              //... 댓글 아이템
            </li>
          );
        })}
    </ul>
  );
};

export default CommentList;

 

댓글 목록 데이터 요청 hook

// useCommentList.ts

const useCommentList = (postId: string, newUpdate: boolean = false) => {
  const [commentList, setCommentList] = useState<CommentInterface[]>([]); 

  useEffect(() => {
    (async () => {
      const comments: CommentInterface[] | false = await getCommentsDataApi(postId);
      if (comments) {
        const sortedCommentList = sortCommentList(comments);
        setCommentList(sortedCommentList);
      }
    })();
  }, [postId, newUpdate]);

  return { commentList, setCommentList };
};

export default useCommentList;

 

댓글 정렬 함수

// sortCommentList.ts

export const sortCommentList = (comments: CommentInterface[]) => {
  const commentOrderMap = new Map();

  for (const comment of comments) {
    if (comment.depth === 0) {
      commentOrderMap.set(comment._id, [comment]);
    } else {
      const parentArray = commentOrderMap.get(comment.parentCommentId);
      parentArray.push(comment); 
    }
  }

  const sortedComments = Array.from(commentOrderMap.values()).flat();
  return sortedComments;
};

7. Depth를 활용해서 계단처럼 만들려면

부모 댓글이 depth 0을 가지고, 

댓글을 DB에 저장할 때 다음과 같이 자식 댓글이 1, 2, 3, ... 을 가지도록 depth 속성에 +1을 해주는 방식으로 구현하면,

  // DB에 저장할 데이터
  const saveData: CommentInterface = {
    // ...
    depth: parentCommentId ? (depth || 0) + 1 : 0,
  };

다음 이미지와 같이 댓글이 계층을 가질 수 있게 됩니다.