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. 댓글 작성 시나리오
- 사용자 동작:
- 사용자가 일반 댓글 작성 폼에 텍스트 입력 후 "댓글 등록" 버튼 클릭.
- 코드 흐름:
- submitComment() 호출 →
- payload 구성 (content, parentId null, 사용자 및 요청 ID 포함) →
- postHistoryItems(payload) API 호출 →
- API 성공 시 새 댓글 객체 생성 (depth 0, children 빈 배열) →
- comments.value.push(newCommentFromApi)로 목록에 추가 →
- 폼 초기화 및 상태 리셋.
2. 대댓글 작성 시나리오 (Reply Creation)
- 사용자 동작:
- 사용자가 특정 댓글의 "답글" 버튼 클릭.
- 코드 흐름:
- CommentItem에서 replyToComment(comment) 호출 → 이벤트를 통해 상위에서 setReplyTarget(comment) 실행 →
- 해당 댓글의 ID가 replyTarget으로 설정되며, 인라인 답글 폼 슬롯 표시됨.
- 사용자가 인라인 폼에 대댓글 내용 입력 후 "대댓글 등록" 클릭 →
- submitReply() 호출 (내부에서 submitComment() 실행) →
- payload의 parentId가 replyTarget.id로 설정되어 대댓글 생성 (depth = 부모 depth + 1) →
- 새 대댓글은 addReplyToList()를 통해 부모 댓글의 children 배열에 추가 →
- 폼 초기화 및 replyTarget 리셋.
여기서 addReplyToList가 중요한데 좀 더 자세하게 설명하겠습니다.
- 기본적으로 CommentItem.vue 에서 선택된 id 즉 replyTarget의 id 값을 가지고 댓글을 작성할 곳을 정하는 로직입니다.
- 부모쪽에서 CommentSection.vue 에서든 CommentItem.vue 에서든 결국에는 comment를 emit 해서 보내는 주는식으로 설계 되어있습니다.
<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의 모든 정보를 가져오게 되는 것 입니다,.
- 리스트 순회: for (const comment of list) 구문은 전체 댓글 배열을 순회하면서 각각의 댓글을 확인합니다.
- 부모 찾기:
if (comment.id === parentId) 조건문은 현재 댓글이 대댓글을 추가할 대상인 부모인지 검사합니다.
→ 이 부분이 바로 "부모 찾기" 로직입니다. - 재귀 호출:
만약 현재 댓글이 부모가 아니라면, if (comment.children && comment.children.length > 0) 조건문을 통해 자식 댓글이 존재하는지 확인한 후,
addReplyToList(comment.children, parentId, newComment)를 재귀적으로 호출하여 자식 댓글 배열에서 부모 댓글을 찾습니다.
→ 이 부분이 "재귀 순회"입니다. - 최종 반환:
전체 리스트를 순회한 후에도 부모 댓글을 찾지 못하면, 함수는 false를 반환합니다.
해서 0번째 해당하는 comment를 올리는 원리를 그대로 이용해서 아래의 두 가지 시나리오도 같은 동작을 합니다.
3. 삭제 시나리오 (Comment Deletion)
- 사용자 동작:
- 사용자가 댓글 또는 대댓글의 "삭제" 버튼 클릭.
- 코드 흐름:
- CommentItem에서 deleteToComment(comment.id) 호출 →
- 상위 컴포넌트에서 deleteTarget(commentId) 호출 →
- deleteHistoryItem(commentId) API 호출 →
- 성공 시, 재귀 함수 deleteCommentFromList()를 통해 comments 트리에서 해당 댓글 제거 →
- 삭제된 댓글이 목록에서 사라짐.
4. 수정 시나리오 (Comment Editing)
- 사용자 동작:
- 사용자가 댓글 또는 대댓글의 "수정" 버튼 클릭.
- 코드 흐름:
- CommentItem에서 setEditTarget(comment) 호출 → 상위 컴포넌트에서 해당 댓글을 editTarget으로 설정하고, editForm.content에 기존 내용 채워짐.
- 활성화된 수정 대상 댓글에서 인라인 수정 폼 슬롯이 표시됨.
- 사용자가 수정 폼에서 내용을 변경 후 "수정 저장" 버튼 클릭 →
- submitEdit() 호출 →
- putHistoryItems(editTarget.id, { content: editForm.content }) API 호출하여 수정 내용 저장 →
- 재귀 함수로 댓글 트리 내 해당 댓글을 찾아 업데이트 →
- 수정 폼 초기화 및 상태 리셋, 화면에 변경 내용 반영됨.
결론
이 댓글 시스템은 재귀적 컴포넌트 구조, 슬롯을 통한 인라인 폼, 이벤트 기반 상태 관리 및 재귀 함수를 활용하여
다음 네 가지 시나리오를 처리합니다:
- 댓글 작성:
일반 댓글 작성 폼에서 입력 후, submitComment()를 통해 API 등록 후 comments 배열에 추가합니다. - 대댓글 작성:
"답글" 버튼 클릭 → setReplyTarget()로 활성화 → 인라인 폼 입력 후 submitReply() 호출 →
payload의 parentId가 replyTarget.id로 설정되고,
addReplyToList() 함수를 통해 부모 댓글의 children 배열에 새 대댓글 추가됩니다. - 삭제:
"삭제" 버튼 클릭 → deleteTarget(comment.id) 호출 → API 호출 후,
재귀 함수 deleteCommentFromList()로 댓글 트리에서 해당 댓글 삭제 → UI 업데이트. - 수정:
"수정" 버튼 클릭 → setEditTarget()로 수정 대상 설정 및 기존 내용 채움 →
인라인 수정 폼에서 입력 후 submitEdit() 호출 → API로 수정 내용 전송 및 댓글 트리 업데이트 → UI 반영.
728x90