오늘은 대시보드 상세에서 카드의 상세 모달을 작업해 봤습니다!
우선 전반적인 코드를 먼저 보여드리고 작업하면서 있었던 문제들과 해결한 방법에 대해서 설명해 볼게요!
(컴포넌트 구조보다 기능적인 부분을 보고 싶으시다면 아래 링크를 눌러주세요↓)
DetailCardModal.tsx (할 일 카드 모달)
기능이 많아 컴포넌트는 아직 분리하지 못했습니다... import 부분은 생략했으니 이해해 주세요😭
빠른 시일 내로 리팩토링 할 예정이니 코드 블록이 불편하시다면 아래 Github PR을 확인해주세요!!
interface DetailCardModalProps {
title: string;
cardId: number;
columnTitle: string;
closeModal: () => void;
setColumnData: React.Dispatch<React.SetStateAction<GetCardsResponse>>;
}
function DetailCardModal({
title,
cardId,
columnTitle,
closeModal,
setColumnData,
}: DetailCardModalProps) {
const {
user: { id },
} = useSelector((state: RootState) => state.userInfo); // 카드의 작성자 확인을 위해 Redux에서 유저 id를 가져옴
const [card, setCard] = useState<Card | null>(null);
const {
commentsResponse,
addComment,
loadMoreComments,
removeComment,
updateComment,
isSubmitting,
} = useComments(cardId, null); //
const [newComment, setNewComment] = useState('');
// 카드 삭제 함수
const handleCardDelete = async () => {
try {
await deleteCard(cardId);
alert('카드가 삭제되었습니다.');
setColumnData((prev) => ({
...prev,
cards: prev.cards.filter((columnCard) => columnCard.id !== cardId), // 삭제된 카드 제외
}));
} catch (error) {
console.error('카드 삭제 오류:', error);
}
};
const handleMenuClick = async (value: string) => {
closeModal();
// 수정하기 모달은 완성 전이라 임시로 alert 처리했습니다.
if (value === 'edit') alert('수정하기 모달 오픈');
else if (value === 'delete') {
await handleCardDelete();
}
};
const fetchData = async () => {
try {
const cardDetail = await getCardDetail({ cardId });
setCard(cardDetail);
loadMoreComments();
} catch (error) {
console.error('데이터 요청 실패:', error);
}
};
const handleObserver = useCallback(
async ([entry]) => {
if (entry.isIntersecting && commentsResponse?.cursorId) {
loadMoreComments(commentsResponse.cursorId);
}
},
[commentsResponse?.cursorId, loadMoreComments],
);
const endPoint = useIntersectionObserver(handleObserver);
useEffect(() => {
fetchData();
}, [cardId]);
if (!card || !commentsResponse) return null;
return (
<div className={styles.container}>
<h2 className={styles.title}>{title}</h2>
<div className={styles['btn-section']}>
{id === card.assignee.id && (
<Dropdown
menus={[
{ label: '수정하기', value: 'edit' },
{ label: '삭제하기', value: 'delete' },
]}
onMenuClick={handleMenuClick}
>
<KebabIcon className={styles['icon-kebab']} />
</Dropdown>
)}
<button
type="button"
className={styles['btn-close']}
onClick={closeModal}
>
<CloseIcon className={styles['icon-close']} />
</button>
</div>
<div className={styles['author-section']}>
<div>
<div className={styles['author-title']}>담당자</div>
<UserProfile
type="todo-detail"
profileImageUrl={card.assignee.profileImageUrl}
nickname={card.assignee.nickname}
/>
</div>
<div>
<div className={styles['author-title']}>마감일</div>
<span className={styles['author-content']}>
{formatDate(card.dueDate, true)}
</span>
</div>
</div>
<div className={styles['content-section']}>
<div className={styles['chip-section']}>
<div className={styles.status}>
<Chip chipType="status">{columnTitle}</Chip>
</div>
<span className={styles.bar} />
<div className={styles.tags}>
{card.tags.map((tag) => (
<Chip key={`${cardId}_tag_${tag}`} chipType="tag">
{tag}
</Chip>
))}
</div>
</div>
<p className={styles.description}>{card.description}</p>
<CardImage
image={card.imageUrl}
name={`${card.title} 이미지`}
className={styles['card-image']}
/>
<div className={styles['comment-input-section']}>
<label htmlFor="comment" className={styles['comment-label']}>
댓글
</label>
<textarea
className={styles['comment-input']}
name="comment"
id="comment"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="댓글 작성하기"
/>
<button
type="button"
className={styles['btn-add-comment']}
onClick={() => {
addComment(newComment, card.columnId, card.dashboardId);
setNewComment('');
}}
disabled={isSubmitting || !newComment.trim()}
>
입력
</button>
</div>
<div className="comment-section">
{commentsResponse.comments.map(
({
id: commentId,
author: { id: authorId, nickname, profileImageUrl },
createdAt,
content,
}) => (
<Comment
key={`comment_${commentId}`}
commentId={commentId}
profileImageUrl={profileImageUrl}
authorId={authorId}
nickname={nickname}
createdAt={createdAt}
content={content}
removeComment={removeComment}
updateComment={updateComment}
/>
),
)}
</div>
{commentsResponse.cursorId && (
<div ref={endPoint} className={styles['end-point']} />
)}
</div>
</div>
);
}
export default DetailCardModal;
Comment.tsx (댓글)
import UserProfile from '@/components/common/userprofile/UserProfile';
import styles from '@/components/dashboard/comment/Comment.module.css';
import { RootState } from '@/redux/store';
import formatDate from '@/utils/formatDate';
import { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
interface CommentProps {
commentId: number;
authorId: number;
nickname: string;
profileImageUrl: string | null;
createdAt: string;
content: string;
removeComment: (commentId: number) => void;
updateComment: (commentId: number, newContent: string) => void;
}
function Comment({
commentId,
authorId,
nickname,
profileImageUrl,
createdAt,
content,
removeComment,
updateComment,
}: CommentProps) {
const {
user: { id }, // 댓글의 작성자 확인을 위해 Redux에서 유저 id를 가져옴
} = useSelector((state: RootState) => state.userInfo);
const [isEditing, setIsEditing] = useState(false);
const [editedContent, setEditedContent] = useState(content);
const handleSave = () => {
if (editedContent.trim() !== content) {
updateComment(commentId, editedContent);
}
setIsEditing(false);
};
const handleCancel = () => {
setEditedContent(content); // 수정 취소 시 원래 내용으로 되돌리기
setIsEditing(false);
};
const handleDelete = useCallback(() => {
removeComment(commentId);
}, [removeComment, commentId]);
return (
<div className={styles.comment}>
<UserProfile
type="todo-detail"
profileImageUrl={profileImageUrl}
onlyImg
nickname={nickname}
/>
<div className={styles.wrap}>
<div className={styles['title-section']}>
<span className={styles.nickname}>{nickname}</span>
<span className={styles.date}>{formatDate(createdAt, true)}</span>
</div>
{isEditing ? (
<div className={styles['content-edit']}>
<textarea
className={styles['content-textarea']}
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
/>
<button
type="button"
className={styles['btn-cancel']}
onClick={handleCancel}
>
취소
</button>
<button
type="button"
className={styles['btn-save']}
onClick={handleSave}
>
저장
</button>
</div>
) : (
<div className={styles.content}>{content}</div>
)}
{!isEditing && id === authorId && (
<div>
<button
type="button"
className={styles.edit}
onClick={() => setIsEditing(true)}
>
수정
</button>
<button
type="button"
className={styles.delete}
onClick={handleDelete}
>
삭제
</button>
</div>
)}
</div>
</div>
);
}
export default Comment;
useComment.ts (댓글 관련 커스텀 훅 - 추가, 삭제, 수정)
import { useState } from 'react';
import { Comment as CommentType, GetCommentsResponse } from '@/type/comment';
import postComment from '@/lib/dashboard/postComment';
import getComments from '@/lib/dashboard/getComments';
import deleteComment from '@/lib/dashboard/deleteComment';
import putComment from '@/lib/dashboard/putComment';
const useComments = (
cardId: number, // 댓글 관련 카드 ID
initialComments: GetCommentsResponse | null, // 초기 댓글 데이터
) => {
const [commentsResponse, setCommentsResponse] =
useState<GetCommentsResponse | null>(initialComments);
// 댓글 작업(추가, 수정, 삭제) 중 상태를 확인하기 위한 플래그
const [isSubmitting, setIsSubmitting] = useState(false);
// 댓글 추가
const addComment = async (
content: string,
columnId: number,
dashboardId: number,
) => {
if (!content.trim()) return; // 공백이면 종료
setIsSubmitting(true); // 작업 상태 true로 변경
try {
// 댓글 추가 API 호출
const addedComment: CommentType = await postComment({
content,
cardId,
columnId,
dashboardId,
});
// 새로운 댓글 state 가장 앞에 추가
setCommentsResponse((prev) => ({
...prev,
comments: [addedComment, ...(prev?.comments || [])],
}));
} catch (error) {
console.error('댓글 추가 실패:', error);
} finally {
setIsSubmitting(false);
}
};
// 댓글 불러오기(무한 스크롤에서 사용)
const loadMoreComments = async (cursorId?: number) => {
try {
// 댓글 요청 API 호출
const newCommentsResponse = await getComments({ cardId, cursorId });
// 댓글 state 가장 앞에 서버에서 불러온 댓글 추가
setCommentsResponse((prev) => ({
...newCommentsResponse,
comments: [...(prev?.comments || []), ...newCommentsResponse.comments],
}));
} catch (error) {
console.error('댓글을 불러오는데 실패했습니다:', error);
}
};
// 댓글 삭제
const removeComment = async (commentId: number) => {
try {
// 댓글 삭제 API 호출
await deleteComment(commentId);
// Array.filter 메서드를 통해 해당 댓글 삭제
setCommentsResponse((prev) => ({
...prev,
comments:
prev?.comments.filter((comment) => comment.id !== commentId) || [],
}));
} catch (error) {
console.error('댓글 삭제 실패:', error);
}
};
// 댓글 수정
const updateComment = async (commentId: number, newContent: string) => {
try {
// 댓글 수정 API 호출
const updatedComment = await putComment({
commentId,
content: newContent,
});
// 댓글의 content를 수정한 댓글로 갱신
setCommentsResponse((prev) =>
prev
? {
...prev,
comments: prev.comments.map((comment) =>
comment.id === commentId
? { ...comment, content: updatedComment.content }
: comment,
),
}
: null,
);
} catch (error) {
console.error('댓글 수정 실패:', error);
}
};
return {
commentsResponse,
addComment,
loadMoreComments,
removeComment,
updateComment,
isSubmitting,
};
};
export default useComments;
댓글 기능에 전반적으로 기존 state를 유지하면서 추가하거나 삭제한 데이터를 처리하는 로직이 있습니다.
setCommentsResponse((prev) => ({
...prev,
comments: [addedComment, ...(prev?.comments || [])],
}));
위 로직을 왜 추가했는지 궁금하실 수 있을 것 같아서 적어보겠습니다!!
문제 상황
- 초기 작업 시 위 로직 없이 API만 호출하고 종료함.
- 네트워크 탭을 확인해 보니 request가 정상적으로 완료됨.
- 새로고침을 하기 전까지 화면에 데이터가 업데이트되지 않음😱
생각해 본 해결 방안
- 댓글 추가 및 삭제 후 request가 정상적으로 완료되면 댓글 불러오기 API를 호출해 화면의 데이터를 갱신한다.
- 댓글 추가 및 삭제 후 프론트에서 데이터를 직접 수정해 화면의 데이터를 갱신한다.
많은 고민 끝에 저는 2번을 선택했습니다!
무한 스크롤로 데이터를 요청하다 보니 데이터를 처음부터 다시 불러오는 방법이 사용자에게 불쾌한 경험일 수 있다고 생각했기 때문입니다💡💡
검색해 보니 이런 방법을 낙관적 업데이트(Optimistic Update)라고 하네요.
낙관적 업데이트: 서버에서 응답을 받을 때까지 기다리지 않고 사용자에게 빠른 피드백을 제공해, 사용자 경험을 개선
적용 화면
댓글 추가
댓글 삭제
댓글 수정 (취소)
댓글 수정 (저장)
카드 삭제
작업 후 멘토님께 코드 리뷰를 받았습니다. 내용을 정리해 보자면 아래와 같습니다.
- 무한 스크롤과 사용자 경험을 고려해 낙관적 업데이트를 적용한 점에서 👍👍
- 낙관적 업데이트도 좋지만 API 요청 후 request가 완료되면 서버에서 데이터를 다시 호출하는 게 좋다.
- 서버에 데이터를 다시 호출하는 동작은 크게 무리가 되지 않는다.
- 데이터 추가, 삭제, 업데이트 같은 기능을 수행하는 동안 다른 사람에 의해 서버의 데이터가 바뀔 수 있다.
프론트에서 낙관적 업데이트를 통해 데이터를 변경하면 서버와 싱크가 맞지 않아 순수하지 못한 데이터일 수 있다.
- 무한 스크롤을 고려하면 낙관적 업데이트로 처리하는 것도 좋은 것 같다.
오늘도 프로젝트를 진행하면서 낙관적 업데이트라는 새로운 지식을 얻어가네요😄
멘토님께서 해주신 피드백을 바탕으로 데이터를 다시 호출하는 방법도 고려해 봐야겠네요!
'프로젝트 > Next+TypeScript' 카테고리의 다른 글
[Taskify] Tag 이슈 수정 (4) | 2024.12.23 |
---|---|
[Taskify] 이미지 확장자 제한 추가 (5) | 2024.12.21 |
[Taskify] 무한스크롤 - 해결 (4) | 2024.12.19 |
[Taskify] 대시보드 상세 - 무한 스크롤 (4) | 2024.12.19 |
[Taskify] 대시보드 상세 페이지 (4) | 2024.12.17 |