프로젝트/Next+TypeScript

[Taskify] 할 일 카드 모달 컴포넌트 (feat. optimistic update)

dev-hpk 2024. 12. 20. 14:25

할 일 카드 모달

오늘은 대시보드 상세에서 카드의 상세 모달을 작업해 봤습니다!

우선 전반적인 코드를 먼저 보여드리고 작업하면서 있었던 문제들과 해결한 방법에 대해서 설명해 볼게요!

(컴포넌트 구조보다 기능적인 부분을 보고 싶으시다면 아래 링크를 눌러주세요↓)

기능 코드

 

DetailCardModal.tsx (할 일 카드 모달)

기능이 많아 컴포넌트는 아직 분리하지 못했습니다... import 부분은 생략했으니 이해해 주세요😭

빠른 시일 내로 리팩토링 할 예정이니 코드 블록이 불편하시다면 아래 Github PR을 확인해주세요!!

 

#92 모달 할 일 카드 by hpk5802 · Pull Request #98 · codeit-sprint-part3-6team/project

이슈 번호 close #92 변경 사항 요약 공통 dropdown 추가 댓글 무한 스크롤 추가 카드 삭제 기능 추가 댓글 관련 기능 추가 (댓글 추가, 삭제, 수정) 카드, 댓글 관리 작성자 확인 추가 테스트 결과 카드

github.com

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가 정상적으로 완료됨.
  • 새로고침을 하기 전까지 화면에 데이터가 업데이트되지 않음😱

생각해 본 해결 방안

  1. 댓글 추가 및 삭제 후 request가 정상적으로 완료되면 댓글 불러오기 API를 호출해 화면의 데이터를 갱신한다.
  2. 댓글 추가 및 삭제 후 프론트에서 데이터를 직접 수정해 화면의 데이터를 갱신한다.

 

많은 고민 끝에 저는 2번을 선택했습니다!

무한 스크롤로 데이터를 요청하다 보니 데이터를 처음부터 다시 불러오는 방법이 사용자에게 불쾌한 경험일 수 있다고 생각했기 때문입니다💡💡


검색해 보니 이런 방법을 낙관적 업데이트(Optimistic Update)라고 하네요.

낙관적 업데이트: 서버에서 응답을 받을 때까지 기다리지 않고 사용자에게 빠른 피드백을 제공해,  사용자 경험을 개선

 

적용 화면

댓글 추가

댓글 추가

댓글 삭제

댓글 삭제

댓글 수정 (취소)

댓글 수정 (취소)

댓글 수정 (저장)

댓글 수정 (저장)

 

카드 삭제

카드 삭제

 

작업 후 멘토님께 코드 리뷰를 받았습니다. 내용을 정리해 보자면 아래와 같습니다.

  • 무한 스크롤과 사용자 경험을 고려해 낙관적 업데이트를 적용한 점에서 👍👍
  • 낙관적 업데이트도 좋지만 API 요청 후 request가 완료되면 서버에서 데이터를 다시 호출하는 게 좋다.
    • 서버에 데이터를 다시 호출하는 동작은 크게 무리가 되지 않는다.
    • 데이터 추가, 삭제, 업데이트 같은 기능을 수행하는 동안 다른 사람에 의해 서버의 데이터가 바뀔 수 있다.
      프론트에서 낙관적 업데이트를 통해 데이터를 변경하면 서버와 싱크가 맞지 않아 순수하지 못한 데이터일 수 있다.
  • 무한 스크롤을 고려하면 낙관적 업데이트로 처리하는 것도 좋은 것 같다.

오늘도 프로젝트를 진행하면서 낙관적 업데이트라는 새로운 지식을 얻어가네요😄

멘토님께서 해주신 피드백을 바탕으로 데이터를 다시 호출하는 방법도 고려해 봐야겠네요!

 

 

[Taskify] 무한스크롤 - 해결

무한 스크롤 관련된 문제로 라이브러리를 사용해야 하나 고민이 많았습니다. 우선 문제 상황과 지금까지 시도한 방법들을 간단하게 소개해보겠습니다.문제 상황PC로 확인했을 때는 잘 동작하던

dev-hpk.tistory.com

 

 

[Taskify] 대시보드 상세 - 무한 스크롤

라이브러리 없이 무한 스크롤 구현하기!!!어떤 방식으로 구현할까 고민하다가 Intersection Observer API라는 좋은 기능을 찾았습니다. Intersection Observer API는 상위 요소 또는 최상위 문서의 viewport와 대

dev-hpk.tistory.com