프론트에서 게시글을 보여주는 방법은 다양하다. 아예 전체글을 보여주든가 근본의 페이지네이션을 설정하든가, 요즘 핫한 무한스크롤을 쓰는 것이다.
무한스크롤은 양날의 검이다. 왜냐면 내릴 수록 끝도 없다는 게 사람들에게 호불호를 안겨다줄 수 있고, 특정 컨텐츠를 찾기 힘들 수도 있고(물론 키워드 검색과 주변 장소 검색 등이 있다.), 데이터가 많아지면 많아지는 특성상 성능이 저하될 수 있다.
그러면 나는 왜 무한스크롤을 쓰려고 했을까?
맛피는 인스타 피드처럼 쭉 나오는 메인 글이 가장 핵심적인 기능이자 자주 즐기는 기능이다.
이 기능을 기획하며 중요한 포인트가 있었는데,
이러한 이유로 무한스크롤을 구현하기로 했다!
Axios, TypeScript, React로 무한스크롤 하기! 서버에서 모든 게시글을 불러온다음 프론트에서 뚝 잘라 렌더링하는 방법도 있지만
우리 팀은 서버에서 페이징을 설정하고 그 다음 페이지를 불러오고 싶다면 프론트에서 요청을 보내는 식으로 진행했다.
데이터를 담아줄 상태와 페이지를 설정할 상태를 만들어 줌
const [page, setPage] = useState(1);
const [data, setData] = useState([]);
Axios를 사용하여 API에서 데이터를 로드하는 함수를 생성하고 그에 따라 상태 변수를 업데이트한다. 이 기능은 사용자가 페이지 하단으로 스크롤할 때 트리거된다.
const loadData = async () => {
const response = await axios.get(`api-url?page=${page}`);
setData([...data, ...response.data]);
setPage(page + 1);
}
useEffect를 사용해 사용자가 페이지 하단으로 스크롤한 시점을 감지하고 loadData 함수를 호출한다.
useEffect(() => {
const handleScroll = () => {
const scrollHeight = document.body.scrollHeight;
const innerHeight = window.innerHeight;
const scrollTop = window.scrollY;
if (scrollHeight - innerHeight === scrollTop) {
loadData();
}
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [page]);
로드한 데이터를 가져와 페이지의 콘텐츠를 렌더링한다.
return (
<div>
{data.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
참고로 기본적인 예고, 이거를 본인의 개발 환경에 맞게 변환하여 옮겨야 한다.
useEffect(() => {
const handleScroll = () => {
const scrollHeight = document.body.scrollHeight;
const innerHeight = window.innerHeight;
const scrollTop = window.scrollY;
if (scrollHeight - innerHeight === scrollTop) {
loadData();
}
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [page]);
document.body.scrollHeight
라든지 window.innerHeight
는 뷰포트 전체 윈도우 기준이라 하나의 컴포넌트에서의 scroll을 기준 잡고 싶다면 안된다. 그래서 scroll 이벤트를 따로 작성하여 scroll하고 싶은 기준이 되는 div
에 넣어서 계산해야한다.
이렇게! 도메인 페이지와 같이 보자
// 모듈 중략
// css 중략...
const Domain: React.FC = () => {
// 계정 정보 중략...
const [hasMore, setHasMore] = useState<boolean>(true);
const [postsReload, setPostsReload] = useState<boolean>(false);
const [page, setPage] = useState(1);
const [limit] = useState(24);
const [postData, setPostData] = useState([]);
const { responseData: posts } = useAxios(getPosts, [postsReload], false);
const { axiosData: getPageAxios, responseData: pagePosts } = useAxios(
() => getPagePosts(page, limit),
[page],
false
);
// 이벤트를 타겟해 scroll 관련 이벤트를 잡고, 그 이벤트가 발생하면 페이지 +1, useAxios의 get 이벤트 발생
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
const target = event.target as HTMLDivElement;
const { scrollTop, clientHeight, scrollHeight } = target;
// 시작 조건
if (scrollTop + clientHeight >= scrollHeight && hasMore) {
setPage(page + 1);
loadData();
// 종료 조건
if (pagePosts.length < limit) {
setHasMore(false);
}
}
};
const loadData = () => {
getPageAxios();
setPostData([...postData, ...pagePosts]);
};
const getAllPostsReload = () => {
setPostsReload(!postsReload);
};
return (
<StyledFeed>
<HeaderContainer>
<h1>오늘의 맛 Post</h1>
</HeaderContainer>
<StyledPosts onScroll={handleScroll}>
{posts &&
posts.map((post: IPosts) => (
<PostRead
key={post.id}
post={post}
getAllPostsReload={getAllPostsReload}
/>
))}
{postData &&
postData.map((post: IPosts) => (
<PostRead
key={post.id}
post={post}
getAllPostsReload={getAllPostsReload}
/>
))}
</StyledPosts>
</StyledFeed>
);
};
export default Domain;
export const getPagePosts = async (page: number, limit: number) => {
const response = await axios.get(`${url}/places/posts?page=${page}&size=${limit}`);
return response.data;
};
posts
를 불러와 렌더링setPage(page + 1)
이 됨posts
이후 postData
를 렌더링limit
(한번에 불러오는 데이터 양)이 더 크다면 그 다음 컨텐츠는 없으므로 hasMore
이 false
가 되어 시작 조건에 맞지 않아 더이상 불러오지 않음그런 구조를 갖고 있다.
이거 은근 빡셌다. 그래도 마지막 구현하고 나니 진짜 뿌듯했다. 카카오맵이랑 동급이랄까 시간이 생각보다 오래 걸렸다.
또 div
를 기준 삼아 '관찰되는 즉시' 불러오는 Intersection Observer API, 캐싱 고수 리액트 쿼리 등등으로 구현할 수 있는 거를 알았다.
어떤 원리인지 알았으니 다음 무한스크롤이 있으면 자신있게 구현할 수 있을 거 같다. 무엇이든간에 구현한다면 적어도 1개는 배운다는 걸 알았다 ㅎㅎㅎ
스크롤 이벤트로 구현하는거 어려웠을 텐데 구현하셨다니 정말 대단합니다 !!! 하나 또배워갑니다!