그 동안 항해를 진행하면서 게시글과 댓글 CRUD는 많이 구현했었기 때문에 큰 문제없이 구현할 수 있다고 생각했었다.
기존 프로젝트들과 같이 RTK를 이용해서 CRUD를 구현했었지만, 댓글 조회 시 무한 스크롤을 적용하기 위해 리덕스를 사용하지 않고 구현하는 방식으로 변경하게 되었다.
그리고 무한 스크롤을 구현하기 위해 기존 API 구조도 게시글 상세페이지와 댓글 관련을 분리하는 쪽으로 변경했다.
다만... 코드를 수정하니 갑자기 단 한개도 기능이 동작하지 않게되었는데, 여러가지 실수를 방지하기 위해 다시한번 CRUD를 정리하고자 한다.
연관된 포스팅: WIL #11
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 필드를 초기화한다.
}
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()
를 사용해서 배열에 추가될 때 배열의 끝이 아닌 배열의 앞에 새로운 내용이 추가되도록 변경하였다.content, setContent
라는 상수에 useState
를 활용했다.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'로 변경한다.
}
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');
}
unshift()
같은 배열에 사용하는 메소드를 사용했지만, 댓글 수정은 댓글 배열을 건드릴 필요 없이 해당하는 댓글의 내용만 바꾸면 됐기 때문에 state만 변경하는 방식으로 코드를 수정했다.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를 추가한것 같다.
});
}
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()
를 사용해서 간단하게 원하는 조건에 부합하는 코멘트만 보이게끔 수정할 수 있었다.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. 타겟 요소
// 생략
)}
offSetTop
)을 매번 새로 계산해서 출력하는 reflow 단계가 추가로 생긴다고한다.다시한번 기본 CRUD를 돌아볼 수 있어서 좋은 시간이었다.
이 방법이 당연히 전부는 아니겠지만, 비효율적인 코드여도 이렇게 하나하나 쌓아가면서 나만의 데이터베이스가 구축된다고 생각하니까 너무너무 신이난다. 🥳 특히 이번 프로젝트에서 페이지네이션과 무한스크롤도 구현해볼 수 있어서 좋은 경험이었다. 물론 무한 스크롤은 내가 하지 않았지만..!
새로운 것을 시도하는게 실전 프로젝트의 묘미인것 같다. 항해가 끝나도 다른 동료들과 사이드 프로젝트를 진행해야지!
출처: