[React 6] 기본 CRUD 복습 (댓글 기능)

김헤일리·2023년 2월 3일
0

React

목록 보기
10/10

그 동안 항해를 진행하면서 게시글과 댓글 CRUD는 많이 구현했었기 때문에 큰 문제없이 구현할 수 있다고 생각했었다.
기존 프로젝트들과 같이 RTK를 이용해서 CRUD를 구현했었지만, 댓글 조회 시 무한 스크롤을 적용하기 위해 리덕스를 사용하지 않고 구현하는 방식으로 변경하게 되었다.
그리고 무한 스크롤을 구현하기 위해 기존 API 구조도 게시글 상세페이지와 댓글 관련을 분리하는 쪽으로 변경했다.

다만... 코드를 수정하니 갑자기 단 한개도 기능이 동작하지 않게되었는데, 여러가지 실수를 방지하기 위해 다시한번 CRUD를 정리하고자 한다.

연관된 포스팅: WIL #11


🔎 댓글 작성 - CREATE Method

  • RTK를 이용해서 댓글 작성 기능을 구현했을 땐 문제없이 실행되던 기능이, 수정된 이후 동작하지 않게되었다.
  • 수정된 코드를 확인해보니, 댓글이 추가되어야 하는 부분에 들어가면 안되는 데이터가 들어가있었다.

문제의 코드

async function postComment() {
// 1. 댓글 작성 버튼 클릭 시 실행되는 함수
  const data = { comment, id };
  // 2. 서버에 보낼 데이터는 comment의 내용과, 해당 comment가 달리는 게시글의 id 값이다.
  // 2-1. 댓글은 "1번 게시글의 댓글" 로 추가된다.
  // 2-2. 여기서 comment는 상위 컴포넌트에서 useState로 관리하는 댓글 작성 input에 연결된 state다.
  
  if (comment === '') {
  // 3. 만약 코멘트의 내용이 비어있다면,
    useToast('댓글 내용이 없닭!', 'warning');
    // 3-1. customHook으로 구현한 토스트 메세지가 생성된다.
     return;
  }
  
  await instance.post(`/posts/${id}/comments`, data).then((res) => {
  // 4. 내용이 비어있지 않다면 post method로 해당 api 주소로 data를 전달한다.
  // 4-1. 그 다음 서버로부터 응답(res)을 받는다.
    const { comment, id } = res.data;
    // 4-2. 이때 res에서 data 부분을 comment와 id에 각각 비구조화 할당한다.
    setComment(data.comment);
    // 4-3. data의 comment에 해당하는 내용을 setComment에 넣어서 comment state를 변경시킨다.
    comments.push(data.comment);
    // 4-4. 그리고 코멘트들이 모여있는 배열엔 data의 comment에 해당하는 내용을 push해서 추가한다.
  });
  setComment('');
  // 5. 그리고 comment state를 빈값으로 만들어서 input 필드를 초기화한다.
}
  • 4-2 형식으로 코드가 작성된 이유는 작성하신 분이 res.data에 담긴 값이 comment의 내용과 해당 댓글의 id값이 들어있을거라 생각하셨기 때문인 것 같다.
  • 실제로 res를 콘솔에 찍어서 확인해보니, res.data엔 그냥 코멘트의 내용 자체만 들어있었다. (문자열)

수정된 코드

async function postComment() {
  const data = { comment, id };

  if (comment === '') {
    useToast('댓글 내용이 없닭!', 'warning');
    return;
  }

  await instance
    .post(`/posts/${id}/comments`, data)
    // 1. 위의 코드와 동일하게 필요한 내용이 담겨있는 데이터를 post method로 해당 api 주소에 전달한다.
    .then((res) => {
    // 1-1. console에서 res를 확인하니 res.data엔 그냥 코멘트의 내용 자체가 문자열로 들어있었다.
      comments.unshift(res.data);
      // 1-2. 코멘트가 넣어져있는 배열에 코멘트 내용을 추가한다.
    })
    .catch((error) => {
    // 2. 에러 핸들링이 없었기 때문에 추가했다.
      if (error.status === 403) {
      // 2-1. 로그인이 만료되어 403 에러가 생긴다면,
        useToast('로그인이 되어있지 않닭!', 'error');
        // 2-2. 안내 문구를 토스트 메세지로 보여주고,
        navigate(`/login`);
        // 2-3. 로그인 페이지로 이동시켰다. (해당 부분은 refresh token이 생겨서 쓰이진 않을 것 같다.)
      } else {
      // 2-4. 그 외에 어떠한 이유로 에러가 발생한다면,
        useToast('오류가 발생했닭!.', 'error');
        // 2-5. 에러가 생겼다는 안내문구를 추가한다.
      }
    });
  setComment('');
}
  • 동일하게 push()를 사용했을 때, 가장 최근 댓글이 맨 아래에 보이는 이슈가 있었다. 새로고침할 경우, 최근 댓글의 경우 맨 위에 보이는 방식이 적용됐기 때문에, 사용자가 댓글을 추가한 순간에도 최신 댓글이 위에 보여야했다.
  • push()가 아니라 unshift()를 사용해서 배열에 추가될 때 배열의 끝이 아닌 배열의 앞에 새로운 내용이 추가되도록 변경하였다.


🔎 댓글 수정 - PUT Method

  • 댓글 수정도 댓글 작성과 마찬가지로 수정 시 기능이 동작하지 않았다.
  • 댓글 수정의 경우, 수정된 내용은 content, setContent라는 상수에 useState를 활용했다.
    • 사실상 댓글을 보여주는 부분에서 사용하는 state의 이름은 content다.
  • 수정 버튼 클릭 시 댓글의 상태값을 변경해서 수정 중인지 아닌지를 구분하였고, 마찬가지로 useState를 사용했다.

문제의 코드

function editComment() {
// 1. 댓글의 상태 (수정, 일반)를 변경하는 함수
// 1-1. 해당 함수는 [수정 버튼]을 클릭하면 실행된다.
  if (commentType === 'display') {
  // 1-2. 만약 commentType이 'display'라면, 
    setCommentType('edit');
    // 1-3. 댓글의 상태를 'edit'으로 변경한다.
    // 1-4. 댓글의 상태가 'edit'일 땐 input 필드가 생성되고 버튼 이름은 [수정 완료]가 된다. (조건부 렌더링 이용)
  } else if (commentType === 'edit') {
  // 1-5. 만약 댓글의 상태가 'edit'이었다면, 
    setCommentType('display');
    // 1-6. 댓글의 상태를 다시 'display'로 변경한다.
    // 1-7. 댓글의 상태가 'display'일 땐, 인풋 필드가 아니라 작성된 댓글의 내용이 보이고 [수정하기]라는 버튼이 표시된다.
  }
}

async function onClickEditComment() {
// 2. 댓글 타입이 edit일 때 생기는 [수정 완료] 버튼을 클릭하게되면 댓글 수정을 실행하는 함수
  await instance
    .put(`/posts/comments/${commentId}`, { comment: content })
  	// 2-1. 수정할 댓글의 id값이 포함된 api주소로 comment라는 key값으로 content의 내용을 보낸다.
    .then((res) => {
    // 2-2. 서버로부터 받은 응답 (res)
      const { comment } = res.data;
      // 2-3. 비구조화 할당을 사용해서 res.data의 내용을 comment라는 상수에 할당하고,
      comments.push(comment);
      // 2-4. 댓글을 모아두는 comments라는 배열에 comment를 추가한다.
    });
  setCommentType('display');
  // 2-5. 댓글 타입을 다시 'display'로 변경한다.
}
  • 이번의 문제점은 어떤 state로 수정된 댓글을 관리하는지 모르고 코드를 작성하셔서 생긴 문제였던 것 같다.
  • 수정된 댓글의 내용은 comments라는 배열에 추가하는 것이 아니라, 기존의 코멘트 내용을 새로 변경된 내용으로 덮어 씌우듯이 변경해야했다.

수정된 코드

async function onClickEditComment() {
  await instance
    .put(`/posts/comments/${commentId}`, { comment: content })
  	// 1. 동일한 방식으로 api 요청을 보낸다.
    .then((res) => {
      const { comment } = res.data;
      // 1-1. 서버로부터 받은 응답 데이터를 비구조화 할당을 사용해서 comment라는 상수에 넣는다.
      // 1-2. comment라는 상수의 내용은 변경된 댓글의 내용 (문자열)과 같다.
      setContent(comment);
      // 1-3. comment의 내용을 setContent의 인수로 넣어서 content의 상태를 변경했다.
    })
    .catch((error) => {
    // 1-4. 에러 핸들링 부분이 없길래 추가하였다.
      useToast(`${error.response.data.statusMsg}`, 'error');
      // 1-5. 에러가 발생하면 토스트 메세지로 상황에 맞는 에러 메세지를 출력한다.
    });
  setCommentType('display');
}
  • update의 경우, 어떤식으로 댓글이 표시되는지 잘 몰라서 생긴 문제였고, 댓글을 표시하는 state를 올바르게 업데이트 했더니 잘 동작했다.
  • 댓글 작성은 댓글 배열에 새로운 댓글을 추가하기 때문에 unshift() 같은 배열에 사용하는 메소드를 사용했지만, 댓글 수정은 댓글 배열을 건드릴 필요 없이 해당하는 댓글의 내용만 바꾸면 됐기 때문에 state만 변경하는 방식으로 코드를 수정했다.


🔎 댓글 삭제 - DELETE Method

  • 댓글 삭제의 경우, 댓글이 삭제되었기에 사실 상 응답으로 가공해서 활용할 수 있는 데이터가 도착하지 않는다.
  • 특정 댓글이 삭제되어 댓글 배열을 수정하셔야 한다고 생각하셨던 것 같은데, 배열 수정의 방식에 문제가 있었던 것 같다.

문제의 코드

async function onClickDeleteComment() {
// 1. 댓글 삭제버튼 클릭 시 실행되는 함수
  await instance.delete(`/posts/comments/${commentId}`).then((res) => {
  // 1-1. 특정 댓글의 id값을 포함한 주소로 삭제 요청을 보낸 다음 응답을 받는다.
    const { comment } = res.data;
    // 1-2. 응답 중 data를 꺼내서 comment라는 상수에 비구조 할당을 한다.
    setComments((prev) => [...prev, ...comment]);
    // 1-3. 댓글 배열을 변경할 때, 이전의 값을 보존하기 위해 ...prev로 전개 연산자를 사용했다.
    // 1-4. 삭제된 댓글을 제외한 새로운 댓글 배열이 만들어졌을 것이므로 전개 연산자를 사용해서 comment를 추가한것 같다.
  });
}
  • 사실 상 댓글은 삭제되었기 때문에, 콘솔에 응답을 찍어보니 그냥 http statusMsg인 "삭제가 완료되었습니다!"와 같은 메세지만 담겨있었다.
  • 댓글을 삭제할 때 정확히 어떤 일이 발생하는지 상기하며 코드를 수정했다.

수정된 코드

async function onClickDeleteComment() {
  await instance
    .delete(`/posts/comments/${commentId}`)
  	// 1. 동일한 api 주소를 사용해서 삭제 요청을 서버로 보냈다.
    .then(() => {
    // 1-1. 서버의 응답 데이터를 사용하지 않기 때문에 아무런 인수가 없다.
      setComments(
      // 1-2. 코멘트 배열을 수정하기 위해 setComments를 사용했다.
        comments.filter((comment) => {
        // 1-3. 다만 setComments 안에 들어갈 내용은, 기존 comments 배열이다.
          return comment.id !== commentId;
          // 1-4. 삭제된 코멘트의 아이디값을 지칭하는 commentID와 id값이 같지 않은 코멘트만 리턴되도록 한다.
        }),
      );
    })
    .catch((error) => {
      useToast(`${error.response.data.statusMsg}`, 'error');
    });
}
  • filter()를 사용해서 간단하게 원하는 조건에 부합하는 코멘트만 보이게끔 수정할 수 있었다.


🔎 댓글 조회 - GET Method + 무한 스크롤 구현하기

  • 댓글의 경우, 스크롤을 내려서 확인하는 방식이 자연스럽다고 생각했고, 작은 영역에서 효과적으로 보일 수 있는 방법이라고 생각했다.
  • 페이지네이션을 쓸까 했지만, 사용자의 클릭 수를 줄이는 것이 더 자연스러울 것이라고 판단되었다.
  • 서버에서 특정 개수의 댓글을 잘라서 내려주면, 사용자가 페이지의 끝 (Target이라고 지정했다)에 다다랐을 때 다시 한번 api 요청을 보내서 다음 페이지의 댓글을 가져온다.
const [comments, setComments] = useState([]);
// 1. 해당 게시글에 달린 모든 comment가 담겨있는 배열을 state로 관리하였다.
const [totalPage, setTotalPage] = useState(0);
// 1-1. 서버에서 잘라서 보내는 댓글이 총 몇개의 페이지로 구성되어있는지 확인하는 용도 
const [isLoading, setIsLoading] = useState(false);
// 1-2. 무한 스크롤 구현 시, 페이지의 끝에 다다르면 새로 api 요청이 가서 댓글을 추가적으로 불러온다.
// 1-3. 이때 불러오는 동안 약간의 지연이 생기는데, 로딩이 완료되었는지 확인하는 용도로 boolean state를 추가했다.
const [commentPage, setCommentPage] = useState(0); 
// 1-4. 스크롤이 닿았을 때 새롭게 데이터 페이지를 바꿀 state
// 1-5. Target에 스크롤이 닿으면 isLoading이 true로 바뀌고, 다음 페이지가 불러와졌을 때 commentPage는 +1이 된다.
const pageEnd = useRef(); 
// 1-6. 페이지의 마지막 요소(infinite scroll의 탐색 타겟)

const loadMore = () => {
// 2. 댓글을 더 불러올 때 실행될 함수
  if (commentPage < totalPage) {
  // 2-1. 만약 현재 댓글 페이지가 댓글 전체 페이지의 값보다 작을 경우,
    setCommentPage((commentPage) => commentPage + 1);
    // 2-2. commentPage의 값을 1 증기시킨다.
    // 2-3. 이때 setState 함수로 변경하는 내용이 비동기가 아니라 동기적으로 바로 처리할 수 있게 설정한다.
  }
  if (commentPage === totalPage - 1) {
  // 2-4. 만약 현재 댓글 페이지가 전체 페이지 - 1의 값과 같으면,
    setIsLoading(false);
    // 2-5. 더 이상 댓글을 로딩하지 않는다.
  }
};

const getComment = async () => {
// 3. 게시글 상세 페이지에 들어왔을 때 해당 게시글의 댓글을 불러오는 함수.
  await instance
    .get(`/posts/${id}/comments/all?page=${commentPage}&size=5`)
  	// 3-1. get방식으로 api 요청 시, 댓글이 한번에 5개씩만 오도록 설정한다. (size = 5)
    .then((res) => {
    // 3-2. api 요청으로 받아온 응답을 가공한다.
      const { totalPage, commentResponseDtoList } = res.data;
      // 3-3. 응답 데이터에서 totalPage와 코멘트의 정보를 담고있는 commentResponseDtoList를 활용한다.
      setComments((prev) => [...prev, ...commentResponseDtoList]);
      // 3-4. comments 배열에 이전 코멘트의 값을 보존하는 형식으로 새로 받아온 코멘트의 정보를 추가한다.
      // 3-5. 여기서 prev는 updater의 인자인 state이고,  state는 항상 최신값으로 보장된다. 
      // 3-6. 비동기로 동작하는 setState를 prev라는 인자를 사용해서 동기적인 연산을 처리할 수 있다.
      setTotalPage(totalPage);
      // 3-7. 서버로부터 받아온 totalPage의 값을 totalPage에 할당한다.
      setIsLoading(true);
      // 3-8. 로딩중 상태를 true로 변경해서 무한 스크롤이 가능하게 만든다.
    })
    .catch((error) => {
      if (error.response.status === 403) {
        useToast(`${error.response.data.message}`, 'error');
      } else {
        useToast('에러가 발생했닭! 다시 시도해야한닭!', 'error');
      }
    });
};

useEffect(() => {
  getComment(commentPage);
  // 4. 게시글 조회할 때 getComment가 실행되고, commentPage가 변경될때마다 계속해서 getComment를 호출한다.
}, [commentPage]);

useEffect(() => {
// 5. 게시글 조회할 때 옵져버가 탐색을 시작할 수 있도록 설정한다.
  if (isLoading) {
  // 5-1. 만약 isLoading값이 true일 경우,
    const observer = new IntersectionObserver(
    // 5-2. Intersection Observer API를 이용한다.
    // 5-3. 옵져버는 타겟 요소와 상위 또는 최상위 document의 viewport 사이 intersection의 변화를 관찰한다.
    // 5-4. 즉, 우리가 지정한 타겟요소가 화면에 노출되었는지 비동기적으로 감지할 수 있는 api이다.
      (entries) => {
      // 5-5. 옵져버가 바라볼 관찰 대상 (entry)를 가져오고,
        if (entries[0].isIntersecting) {
        // 5-6. entry의 속성 중 하나인 isIntersecting을 사용해서 관찰 대상의 교차상태를 확인한다.
        // 5-7. 이때 교차 상태는 boolean으로 관리한다.
          loadMore();
          // 5-8. 만약 교차 상태값이 true일 경우, loadMore 함수를 호출한다.
        }
      },
      { threshold: 1 },
      // 5-9. intersection observer는 첫번째 인자로 callback 함수를, 두번째 인자로 options를 받는다.
      // 5-10. threshold도 options 중 하나다.
      // 5-11. 0.0 ~ 1.0 사이의 숫자를 설정하고, 숫자를 %로 치환하여 해당 비율만큼 타겟 요소가 교차되면 콜백이 실행된다.
    );
    observer.observe(pageEnd.current);
    // 5-12. 옵져버 탐색을 시작한다.
  }
}, [isLoading]);
// 5-13. 해당 useEffect 내의 함수는 isLoading 값이 변경될때마다 실행된다.


return(
  // 생략
  <Target ref={pageEnd} />
  // 6. 타겟 요소
  // 생략
)}
  • 무한스크롤 구현 시 scroll event를 사용할 수도 있다고 하지만, intersection observer를 사용하는 이유는 debounce & throttle 을 사용하지 않아도 되기 때문이라고 한다.
  • 또한 scroll event를 이용할 경우, 요소의 Y축 위치 값(offSetTop)을 매번 새로 계산해서 출력하는 reflow 단계가 추가로 생긴다고한다.
  • 여러모로 intersection observer를 사용하는게 쉽고 편하게 구현된다는 것!


다시한번 기본 CRUD를 돌아볼 수 있어서 좋은 시간이었다.
이 방법이 당연히 전부는 아니겠지만, 비효율적인 코드여도 이렇게 하나하나 쌓아가면서 나만의 데이터베이스가 구축된다고 생각하니까 너무너무 신이난다. 🥳 특히 이번 프로젝트에서 페이지네이션과 무한스크롤도 구현해볼 수 있어서 좋은 경험이었다. 물론 무한 스크롤은 내가 하지 않았지만..!

새로운 것을 시도하는게 실전 프로젝트의 묘미인것 같다. 항해가 끝나도 다른 동료들과 사이드 프로젝트를 진행해야지!

출처:

profile
공부하느라 녹는 중... 밖에 안 나가서 버섯 피는 중... 🍄

0개의 댓글