Vue 실습

[Vue.js] 재귀를 이용한 댓글 구현

코딩질문자 2025. 3. 7. 13:38
728x90

전체 코드 개요

  • CommentItem.vue
    • 단일 댓글(또는 대댓글)을 렌더링합니다.
    • 자신의 자식 댓글이 있을 경우 재귀적으로 자기 자신을 호출하여 트리 구조를 만듭니다.
    • 활성화된 답글 또는 수정 대상에 따라 인라인 폼 슬롯(답글 폼, 수정 폼)을 표시합니다.
    • 버튼 클릭 시 reply, edit, delete 이벤트를 상위 컴포넌트로 전달합니다.
  • CommentSection.vue
    • 전체 댓글 목록을 관리하며, 댓글 작성, 대댓글 작성, 삭제, 수정 기능을 처리합니다.
    • 상태 변수(comments, replyTarget, editTarget, form, editForm)를 관리하고, API 호출 함수를 통해 서버와 데이터를 동기화합니다.
    • 대댓글 추가 시, addReplyToList() 함수를 호출하여 댓글 트리 내에서 올바른 부모 댓글의 children 배열에 새 대댓글을 추가합니다.
  •  

CommentItem.vue

<template>
  <div class="comment-item">
    <!-- 댓글 내용 영역 -->
    <div class="comment-content">
      <div class="comment-header">
        <!-- 작성자 이름과 작성 시간 표시 -->
        <strong>{{ comment.createUser.name }}</strong>
        <span class="comment-date">{{ formatDate(comment.createTime) }}</span>
      </div>
      <div class="comment-body">
        <!-- 댓글 내용: 배열이면 여러 줄, 단일 문자열이면 한 줄 -->
        <div v-if="Array.isArray(comment.content)">
          <p v-for="(line, index) in comment.content" :key="index" v-html="line"></p>
        </div>
        <div v-else>
          <p v-html="comment.content"></p>
        </div>
      </div>
      <!-- 댓글 액션 버튼 -->
      <div class="comment-actions">
        <!-- 최상위 댓글(깊이 0)일 때 답글 버튼 표시 -->
        <span v-if="comment.depth === 0" @click="replyToComment(comment)">답글</span>
        <span @click="setEditTarget(comment)">수정</span>
        <!-- 삭제 버튼은 자식이 없거나 깊이가 1일 때 표시 -->
        <span v-if="!comment.children?.length || comment.depth === 1" class="delete" @click="deleteToComment(comment.id)">삭제</span>
      </div>
    </div>
    <!-- 인라인 답글 폼 슬롯 (해당 댓글이 활성화된 답글 대상일 때) -->
    <template v-if="activeReplyId === comment.id">
      <slot name="replyForm"></slot>
    </template>
    <!-- 인라인 수정 폼 슬롯 (해당 댓글이 활성화된 수정 대상일 때) -->
    <template v-if="activeEditId && String(activeEditId) === String(comment.id)">
      <slot name="editForm"></slot>
    </template>
    <!-- 재귀적으로 자식 댓글 렌더링 -->
    <div class="comment-children" v-if="comment.children && comment.children.length">
      <CommentItem
        v-for="child in comment.children"
        :key="child.id"
        :comment="child"
        :active-reply-id="activeReplyId"
        :active-edit-id="activeEditId"
        @reply="replyToComment"
        @edit="setEditTarget"
        @delete="deleteToComment"
      >
        <template v-if="$slots.replyForm && activeReplyId === child.id" #replyForm>
          <slot name="replyForm"></slot>
        </template>
        <template v-if="$slots.editForm && activeEditId === child.id" #editForm>
          <slot name="editForm"></slot>
        </template>
      </CommentItem>
    </div>
  </div>
</template>

<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import { dateConverter } from '@/utils/common/converter';

// 인터페이스 정의
interface User {
  id: string;
  name: string;
  phone: string;
  email: string;
  imagePath: string;
}

interface Comment {
  id: string;
  content: string;
  parentId: string | null;
  serviceRequestId: string;
  depth: number;
  children: Comment[];
  createTime: string;
  updateTime: string;
  createUser: User;
  isDeleted: boolean;
}

// props로 comment 데이터와 활성화된 답글/수정 대상 id를 받음
const props = defineProps<{
  comment: Comment;
  activeReplyId: string | null;
  activeEditId: string | null;
}>();

// 이벤트를 부모에 전달하기 위한 emits 정의
const emits = defineEmits<{
  (e: 'reply', comment: Comment): void;
  (e: 'edit', comment: Comment): void;
  (e: 'delete', id: string): void;
}>();

// 답글 버튼 클릭 시 이벤트 전달
const replyToComment = (comment: Comment) => {
  emits('reply', comment);
};

// 수정 버튼 클릭 시 이벤트 전달
const setEditTarget = (comment: Comment) => {
  emits('edit', comment);
};

// 삭제 버튼 클릭 시 이벤트 전달
const deleteToComment = (id: string) => {
  emits('delete', id);
};

// 날짜 포맷팅 함수
const formatDate = (dateStr: string) => {
  return dateConverter(dateStr, 'YYYY-MM-DD HH:mm:ss');
};
</script>

<style scoped>
.comment-item {
  margin-bottom: 1em;
  border-left: 2px solid #ccc;
  padding-left: 1em;
}
.comment-header {
  display: flex;
  align-items: center;
}
.comment-date {
  margin-left: auto;
  font-size: 0.8em;
  color: #888;
}
.comment-actions {
  margin-top: 0.5em;
}
.comment-actions span {
  background: none;
  border: none;
  color: #0879ff;
  cursor: pointer;
  margin-right: 4px;
}
.comment-children {
  margin-top: 1em;
  padding-left: 1em;
}
</style>

이 컴포넌트는 단일 댓글을 표시합니다.
각 댓글은 자신의 자식 댓글을 포함할 수 있고, 활성화된 답글 또는 수정 상태에 따라 인라인 폼 슬롯을 표시합니다.

 

 

CommentSection.vue

<template>
  <div class="comment-section">
    <!-- 제목 영역 -->
    <a-space direction="vertical" :size="4">
      <a-typography-title class="comment-title" :level="3">
        히스토리
      </a-typography-title>
    </a-space>
    
    <!-- 댓글 목록: CommentItem 컴포넌트를 반복 렌더링 -->
    <div class="comment-list">
      <CommentItem
        v-for="comment in comments"
        :key="comment.id"
        :comment="comment"
        :active-reply-id="replyTarget ? replyTarget.id : null"
        :active-edit-id="editTarget ? editTarget.id : null"
        @reply="setReplyTarget"
        @edit="setEditTarget"
        @delete="deleteTarget"
      >
        <!-- 답글 폼 슬롯 (해당 댓글이 답글 대상일 때) -->
        <template v-if="replyTarget && replyTarget.id === comment.id" #replyForm>
          <div class="inline-reply-form">
            <h3 class="comment-title">대댓글 작성</h3>
            <form @submit.prevent="submitReply">
              <a-textarea
                v-model:value="form.content"
                placeholder="대댓글 내용을 입력하세요"
                required
              ></a-textarea>
              <a-button htmlType="submit" class="custom-btn" :loading="isLoading">
                대댓글 등록
              </a-button>
              <a-button class="custom-btn" type="button" @click="cancelReply" :loading="isLoading">
                취소
              </a-button>
            </form>
          </div>
        </template>
        <!-- 수정 폼 슬롯 (해당 댓글이 수정 대상일 때) -->
        <template v-if="editTarget && (editTarget.id === comment.id || editTarget.parentId === comment.id)" #editForm>
          <div class="inline-edit-form">
            <h3 class="comment-title">댓글 수정</h3>
            <form @submit.prevent="submitEdit">
              <a-textarea
                v-model:value="editForm.content"
                placeholder="수정할 내용을 입력하세요"
                required
              ></a-textarea>
              <a-button htmlType="submit" class="custom-btn" :loading="isLoading">
                수정 저장
              </a-button>
              <a-button class="custom-btn" type="button" @click="cancelEdit" :loading="isLoading">
                취소
              </a-button>
            </form>
          </div>
        </template>
      </CommentItem>
    </div>

    <!-- 일반 댓글 작성 폼 (답글/수정 대상이 아닐 때) -->
    <div class="comment-form" v-if="!replyTarget && !editTarget">
      <h3 class="comment-title">댓글 작성</h3>
      <form @submit.prevent="submitComment">
        <a-textarea
          v-model:value="form.content"
          placeholder="댓글 내용을 입력하세요"
          required
        ></a-textarea>
        <a-button htmlType="submit" class="custom-btn" :loading="isLoading">
          댓글 등록
        </a-button>
      </form>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import CommentItem from './CommentItem.vue';
import { UserInfoStore } from 'hostMaestro/store/HostUserStore';
import {
  getHistoryItems,
  postHistoryItems,
  deleteHistoryItem,
  putHistoryItems,
} from '@/axios/service/service-requet-management-api/serviceRequest';

const userInfo = UserInfoStore().getUserInfo;

interface User {
  id: string;
  name: string;
  phone?: string;
  email?: string;
  imagePath?: string;
}

interface Comment {
  id: string;
  content: string | string[];
  parentId: string | null;
  serviceRequestId: string;
  depth: number;
  children: Comment[];
  createTime?: string;
  updateTime: string;
  createUser: User;
  isDeleted: boolean;
}

const props = defineProps<{ id: string }>();

// 댓글 목록과 활성화 대상 상태
const comments = ref<Comment[]>([]);
const replyTarget = ref<Comment | null>(null);
const editTarget = ref<Comment | null>(null);

// 댓글 작성 및 수정 폼 상태
const form = reactive({ content: '' });
const editForm = reactive({ content: '' });
const isLoading = ref(false);

/* -----------------------------------------------
   시나리오 1: 댓글 작성 (Top-level Comment Creation)
----------------------------------------------- */
// 사용자가 일반 댓글 작성 폼에서 내용을 입력하고 "댓글 등록"을 누르면 호출
const submitComment = async () => {
  // payload 구성: 일반 댓글은 parentId가 null
  const payload = {
    content: form.content,
    parentId: replyTarget.value ? replyTarget.value.id : null,
    createUserId: userInfo.id,
    serviceRequestId: props.id,
  };

  try {
    isLoading.value = true;
    const res = await postHistoryItems(payload);
    if (res && res.data.data) {
      // 새 댓글 객체 생성 (depth 0인 경우)
      const newCommentFromApi: Comment = {
        id: res.data.data.id,
        content: form.content,
        parentId: null, // 일반 댓글
        serviceRequestId: props.id,
        depth: 0,
        children: [],
        createTime: new Date().toISOString(),
        updateTime: new Date().toISOString(),
        createUser: {
          id: userInfo.id,
          name: userInfo.user,
          phone: '',
          email: '',
          imagePath: '',
        },
        isDeleted: false,
      };

      // comments 배열에 새 댓글 추가
      comments.value.push(newCommentFromApi);
    }
  } catch (e) {
    console.log(e);
  } finally {
    // 폼 초기화 및 상태 업데이트
    form.content = '';
    replyTarget.value = null;
    isLoading.value = false;
  }
};

/* -----------------------------------------------
   시나리오 2: 대댓글 작성 (Reply Creation)
----------------------------------------------- */
// 대댓글 작성은 기본적으로 submitComment()를 사용하지만, replyTarget가 설정되어 있음
const submitReply = async () => {
  if (!replyTarget.value) return;
  // 여기서 payload의 parentId가 replyTarget.value.id가 되어 대댓글이 됨
  await submitComment();
};

/* -----------------------------------------------
   시나리오 3: 삭제 (Comment Deletion)
----------------------------------------------- */
// 삭제 시, 댓글 ID를 받아 API 호출 후 comments 트리에서 삭제
function deleteCommentFromList(list: Comment[], commentId: string) {
  for (let i = 0; i < list.length; i++) {
    if (list[i].id === commentId) {
      list.splice(i, 1);
      return true;
    }
    if (list[i].children && list[i].children.length > 0) {
      if (deleteCommentFromList(list[i].children, commentId)) return true;
    }
  }
  return false;
}

const deleteTarget = async (commentId: string) => {
  try {
    await deleteHistoryItem(commentId);
    deleteCommentFromList(comments.value, commentId);
  } catch (error) {
    console.log(error);
  }
};

/* -----------------------------------------------
   시나리오 4: 수정 (Comment Editing)
----------------------------------------------- */
// 수정 시, 사용자가 "수정" 버튼 클릭 시 setEditTarget()를 호출하여 대상 댓글을 활성화하고, editForm에 기존 내용 채워넣음
const submitEdit = async () => {
  if (!editTarget.value) return;
  try {
    isLoading.value = true;
    const res = await putHistoryItems(editTarget.value.id, { content: editForm.content });
    if (res && res.data.data) {
      // 재귀적으로 댓글 트리에서 해당 댓글을 찾아 업데이트
      const updateComment = (list: Comment[]) => {
        list.forEach((comment) => {
          if (comment.id === editTarget.value?.id) {
            comment.content = editForm.content;
            comment.updateTime = new Date().toISOString();
          }
          if (comment.children?.length) {
            updateComment(comment.children);
          }
        });
      };
      updateComment(comments.value);
    }
  } catch (e) {
    console.error(e);
  } finally {
    // 상태 초기화
    editTarget.value = null;
    editForm.content = '';
    isLoading.value = false;
  }
};

/* -----------------------------------------------
   활성화 대상 설정 및 취소
----------------------------------------------- */
const setReplyTarget = (comment: Comment) => {
  replyTarget.value = comment;
  editTarget.value = null;
};

const setEditTarget = (comment: Comment) => {
  editTarget.value = comment;
  replyTarget.value = null;
  editForm.content = typeof comment.content === 'string' ? comment.content : '';
};

const cancelReply = () => {
  replyTarget.value = null;
};

const cancelEdit = () => {
  editTarget.value = null;
  editForm.content = '';
};

/* -----------------------------------------------
   댓글 데이터 불러오기 (초기 로드)
----------------------------------------------- */
async function getCommentsHandler(id: string) {
  const res = await getHistoryItems(id);
  if (res) {
    const data = res.data.data;
    data.forEach((item) => {
      let newComment: Comment;
      if (item.createUser.id === 'SYSTEM') {
        newComment = {
          id: item.id,
          content: convertContentData(item.content), // 시스템 댓글은 내용 변환 후 배열로 저장
          parentId: item.parent,
          serviceRequestId: id,
          depth: item.depth,
          children: [],
          createTime: item.createTime,
          updateTime: item.updateTime,
          createUser: {
            id: item.userId || item.id,
            name: '시스템',
            phone: '',
            email: '',
            imagePath: '',
          },
          isDeleted: item.deleted,
        };
      } else {
        newComment = {
          id: item.id,
          content: item.content,
          parentId: item.parent,
          serviceRequestId: id,
          depth: item.depth,
          children: item.children || [],
          createTime: item.createTime,
          updateTime: item.updateTime,
          createUser: {
            id: item.userId || item.id,
            name: item.createUser.name,
            phone: '',
            email: '',
            imagePath: '',
          },
          isDeleted: item.deleted,
        };
      }
      comments.value.push(newComment);
    });
  }
}

onMounted(async () => {
  await getCommentsHandler(props.id);
});
</script>

<style scoped>
.comment-section {
  background-color: #f7f7f7;
  padding: 1em;
  border-radius: 4px;
}
.comment-list {
  margin-bottom: 2em;
}
.custom-btn {
  margin-top: 4px;
  margin-right: 4px;
}
.comment-title {
  margin-bottom: 2px;
}
.inline-reply-form,
.inline-edit-form {
  background-color: #fff;
  padding: 8px;
  border: 1px solid #ddd;
  margin-top: 8px;
}
</style>

 

이 컴포넌트는 전체 댓글 목록을 관리하고, 각 시나리오(댓글 작성, 대댓글 작성, 삭제, 수정)를 처리하는 핵심 로직을 포함합니다.

 

 


시나리오별 상세 흐름 요약

1. 댓글 작성 시나리오

  • 사용자 동작:
    • 사용자가 일반 댓글 작성 폼에 텍스트 입력 후 "댓글 등록" 버튼 클릭.
  • 코드 흐름:
    1. submitComment() 호출 →
    2. payload 구성 (content, parentId null, 사용자 및 요청 ID 포함) →
    3. postHistoryItems(payload) API 호출 →
    4. API 성공 시 새 댓글 객체 생성 (depth 0, children 빈 배열) →
    5. comments.value.push(newCommentFromApi)로 목록에 추가 →
    6. 폼 초기화 및 상태 리셋.

 

2. 대댓글 작성 시나리오 (Reply Creation)

  • 사용자 동작:
    • 사용자가 특정 댓글의 "답글" 버튼 클릭.
  • 코드 흐름:
    1. CommentItem에서 replyToComment(comment) 호출 → 이벤트를 통해 상위에서 setReplyTarget(comment) 실행 →
    2. 해당 댓글의 ID가 replyTarget으로 설정되며, 인라인 답글 폼 슬롯 표시됨.
    3. 사용자가 인라인 폼에 대댓글 내용 입력 후 "대댓글 등록" 클릭 →
    4. submitReply() 호출 (내부에서 submitComment() 실행) →
    5. payload의 parentId가 replyTarget.id로 설정되어 대댓글 생성 (depth = 부모 depth + 1) →
    6. 새 대댓글은 addReplyToList()를 통해 부모 댓글의 children 배열에 추가 →
    7. 폼 초기화 및 replyTarget 리셋.

 

여기서 addReplyToList가 중요한데 좀 더 자세하게 설명하겠습니다.

  1. 기본적으로 CommentItem.vue 에서 선택된 id 즉 replyTarget의 id 값을 가지고 댓글을 작성할 곳을 정하는 로직입니다.
  2. 부모쪽에서 CommentSection.vue 에서든 CommentItem.vue 에서든 결국에는 comment를 emit 해서 보내는 주는식으로 설계 되어있습니다.
  3.  
 
<CommentItem
  v-for="comment in comments"
  :key="comment.id"
  :comment="comment"
  :active-reply-id="replyTarget ? replyTarget.id : null"
  :active-edit-id="editTarget ? editTarget.id : null"
  @reply="setReplyTarget"
  @edit="setEditTarget"
  @delete="deleteTarget"
>
....

const setReplyTarget = (comment: Comment) => {
  replyTarget.value = comment;
  editTarget.value = null;
};

 

<CommentItem
  v-for="child in comment.children"
  :key="child.id"
  :comment="child"
  :active-reply-id="activeReplyId"
  :active-edit-id="activeEditId"
  @reply="replyToComment"
  @edit="setEditTarget"
  @delete="deleteToComment"
>
....

const replyToComment = (comment: Comment) => {
  emits('reply', comment);
};

 

무작위의 대댓글을 클릭하는 순간 해당 comment를 emit 해서 올라 오는 형식으로 구현되어 있습니다.

그렇기 때문에 위와 같이 replyToComment을 이용해 자식에서 올라온 comment를 부모의 함수 setReplyTarget 을 이용해 해당하는 comment를 캐치 하게 됩니다.

→ 결과 적으로 댓글 생성 클릭을 누르는 순간 해당 하는 comment의 모든 정보를 가져오게 되는 것 입니다,.

  1. 리스트 순회: for (const comment of list) 구문은 전체 댓글 배열을 순회하면서 각각의 댓글을 확인합니다.
  2. 부모 찾기:
    if (comment.id === parentId) 조건문은 현재 댓글이 대댓글을 추가할 대상인 부모인지 검사합니다.
    → 이 부분이 바로 "부모 찾기" 로직입니다.
  3. 재귀 호출:
    만약 현재 댓글이 부모가 아니라면, if (comment.children && comment.children.length > 0) 조건문을 통해 자식 댓글이 존재하는지 확인한 후,
    addReplyToList(comment.children, parentId, newComment)를 재귀적으로 호출하여 자식 댓글 배열에서 부모 댓글을 찾습니다.
    → 이 부분이 "재귀 순회"입니다.
  4. 최종 반환:
    전체 리스트를 순회한 후에도 부모 댓글을 찾지 못하면, 함수는 false를 반환합니다.

 

해서 0번째 해당하는 comment를 올리는 원리를 그대로 이용해서 아래의 두 가지 시나리오도 같은 동작을 합니다.

 

3. 삭제 시나리오 (Comment Deletion)

  • 사용자 동작:
    • 사용자가 댓글 또는 대댓글의 "삭제" 버튼 클릭.
  • 코드 흐름:
    1. CommentItem에서 deleteToComment(comment.id) 호출 →
    2. 상위 컴포넌트에서 deleteTarget(commentId) 호출 →
    3. deleteHistoryItem(commentId) API 호출 →
    4. 성공 시, 재귀 함수 deleteCommentFromList()를 통해 comments 트리에서 해당 댓글 제거 →
    5. 삭제된 댓글이 목록에서 사라짐.

 

4. 수정 시나리오 (Comment Editing)

  • 사용자 동작:
    • 사용자가 댓글 또는 대댓글의 "수정" 버튼 클릭.
  • 코드 흐름:
    1. CommentItem에서 setEditTarget(comment) 호출 → 상위 컴포넌트에서 해당 댓글을 editTarget으로 설정하고, editForm.content에 기존 내용 채워짐.
    2. 활성화된 수정 대상 댓글에서 인라인 수정 폼 슬롯이 표시됨.
    3. 사용자가 수정 폼에서 내용을 변경 후 "수정 저장" 버튼 클릭 →
    4. submitEdit() 호출 →
    5. putHistoryItems(editTarget.id, { content: editForm.content }) API 호출하여 수정 내용 저장 →
    6. 재귀 함수로 댓글 트리 내 해당 댓글을 찾아 업데이트 →
    7. 수정 폼 초기화 및 상태 리셋, 화면에 변경 내용 반영됨.

 


결론

이 댓글 시스템은 재귀적 컴포넌트 구조, 슬롯을 통한 인라인 폼, 이벤트 기반 상태 관리 및 재귀 함수를 활용하여
다음 네 가지 시나리오를 처리합니다:

  1. 댓글 작성:
    일반 댓글 작성 폼에서 입력 후, submitComment()를 통해 API 등록 후 comments 배열에 추가합니다.
  2. 대댓글 작성:
    "답글" 버튼 클릭 → setReplyTarget()로 활성화 → 인라인 폼 입력 후 submitReply() 호출 →
    payload의 parentId가 replyTarget.id로 설정되고,
    addReplyToList() 함수를 통해 부모 댓글의 children 배열에 새 대댓글 추가됩니다.
  3. 삭제:
    "삭제" 버튼 클릭 → deleteTarget(comment.id) 호출 → API 호출 후,
    재귀 함수 deleteCommentFromList()로 댓글 트리에서 해당 댓글 삭제 → UI 업데이트.
  4. 수정:
    "수정" 버튼 클릭 → setEditTarget()로 수정 대상 설정 및 기존 내용 채움 →
    인라인 수정 폼에서 입력 후 submitEdit() 호출 → API로 수정 내용 전송 및 댓글 트리 업데이트 → UI 반영.
728x90