⚒️ 사용기술
React-Query / useInfiniteQuery / react-masonry-css
구현 하기에 앞서 Kream Style 사이트의 특징을 크게 두가지로 잡아보았습니다.
- 무한스크롤
- mansory 레이아웃
Next.js 로 페이지네이션을 구현해 무한 스크롤을 할 수 있도록 간단한 API를 제작했습니다.
클라이언트에서
const res = await axios.get(`/api/posts?page=${page}&limit=${limit}`);
page와 limit을 query 로 넣어주면 원하는 숫자만큼 데이터 갯수가 호출 됩니다.
처음 바닐라로 Kream 프로젝트를 구현을 했을 때 scroll 이벤트를 사용해 dom의 offset 값을 전부 계산해 내가 현재 보고있는 뷰포트에서 끝에 닿았을때 다음 페이지를 불러오는 형식으로 구현을 했습니다. 하지만 제가 왜 이 방식을 선택하지 않고 다른 방식을 선택해 구현을 하기로 결정했을까요 ?
scroll 값을 계산하는 것과 같은 기하학적인 속성 값을 읽는 로직은 가장 최신 값을 가져와야 하기 때문에 리플로우 계산을 위해 메인 스레드가 블락 되므로 성능에 치명적인 영향을 줄 수 있습니다.
이러한 문제를 해결하기 위해 제가 추가적으로 사용한 Intersection Observer API 는 비동기적으로 실행되기 때문에 메인 스레드에 영향을 주지 않으면서 변경 사항을 관찰할 수 있습니다.
useInfiniteQuery는 페이징을 구현할 때도 굳이 state를 사용하지 않고 손쉽게 구현할 수 있도록 도와줍니다. 저는 페이지 데이터의 상태 관리로 인한 잦은 렌더링 방지와 데이터 캐싱에 대한 이점을 위해 React-Query 에서 제공하는 useInfiniteQuery 라이브러리를 사용하기로 결정했습니다.
"아닙니다." 라이브러리는 기능 구현을 조금이나마 더 편리하게 해줄 수 있는 도구일 뿐이지 모든 것을 다 해주지 않습니다. 때로는 내 프로젝트, 내 코드에 적용하는게 더 어려울 때도 있습니다.
useInfiniteQuery 를 사용해 만든 대략적인 로직입니다.
//fetch API 함수
async function getPosts(page: number) {
try {
const res = await axios.get(`endPoint?page=${page}&limit=${limit}`);
return res.data;
} catch (err) {
throw err;
}
}
function useGetPosts() {
return useInfiniteQuery(
["getPosts"],
({ pageParam = 1 }) => getPosts(pageParam), //API 함수
{
getNextPageParam: (lastPage) => {
const page = Number(lastPage.page);
return lastPage.result.length !== 0 ? page + 1 : null;
//getNextPageParam 함수의 return 값은 pageParam에 들어간다.
},
select: (data) => ({
pages: data?.pages.flatMap((page) => page.result),
pageParams: data.pageParams,
}),
}
);
}
저는 select Option 을 통해 데이터 전처리를 했습니다. 무한 스크롤 기능을 구현 할 때 스크롤 끝에 닿으면 페이지를 추가적으로 불러오는 로직에서 pages가 배열로 담겼기 때문에 map을 두번 돌려줘야 하는 번거로움이 있었습니다.
예시
🥺 select 로 전처리를 하지 않았을 때
page:[
{page : 1, result :Array(10)},
{page : 2, result :Array(10)} //.......
]
이처럼 useInfiniteQuery를 이용해 호출되는 데이터들은 page별로 배열의 요소에 담기게 됩니다. 이 때문에 전처리를 하지 않게 된다면 map 을 이중으로 돌려야 하는 문제가 있습니다.
(성능을 떠나 가독성에 매우 좋지 않다고 생각합니다.)
🥳 select 로 전처리 후
이렇게 전처리를 한다면 post data를 한 번에 돌려 줄 수 있게 됩니다!
사실 select option 을 추가해 데이터를 클라이언트 측에서 바꾸게 된 것에는 많은 이슈와 이유가 있었습니다. mansory 레이아웃 파트에서 이 부분을 이어서 언급하도록 하겠습니다.
const observerElem = useRef(null);
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [target] = entries;
if (target.isIntersecting) {
callback(); //fetchNext 함수
}
},
[callback]
);
IntersectionObserver 생성자를 만들어 인자에 넣어줄 함수를 정의했습니다. 타겟 관찰자가 뷰포트에 들어왔을때 원하는 callback 함수를 실행시킵니다. 저는 fetchNext 를 넘겨 실행시켜 주었습니다.
✨ 전체적인 과정은
스크롤 끝에 닿는다. => 관찰자가 뷰포트에 들어왔다. => 새롭게 데이터 패칭하는 함수를 실행시킨다.
입니다.
useEffect(() => {
const element = observerElem.current;
const options = {
root: null,
rootMargin: "0px",
threshold: 1,
};
const observer = new IntersectionObserver(handleObserver, options);
if (element) observer.observe(element);
return () => {
if (element) observer.unobserve(element);
};
}, [handleObserver]);
이렇게 두 가지 기능을 분리해 따로 hooks 폴더에 파일로 빼주었습니다.
const {
data,
fetchNextPage,
isFetchingNextPage,
isError,
isSuccess,
hasNextPage,
} = useGetPosts(); //fetch 함수 불러오기
const observerElem = useIntersectionObserver(fetchNextPage);
// useIntersectionObserver 함수 리턴
// data fetch & intersection 로직을 분리한 후 import 해
// main Page 에서 return 값 만 사용
<> {isSuccess && <StyleList styledatas={data.pages} />} </>
<div ref={observerElem}>
{isFetchingNextPage && hasNextPage ? "Loading..." : "No search left"}
</div>
//리턴 받은 observerElem 맨 밑 div에 ref로 달아주기
제가 작성한 로직이 무조건 옳다고 생각하지 않습니다.
저는 Component 는 최대한 View 기능만을 하는 것을 지향하는 편입니다. 복잡한 로직이 포함되어 있거나 다른 기능을 포함한 로직이 있다면 기능 별로 hook 이나 함수로 분류하기 위해 많은 고민을 합니다.
이렇게 useInfiniteQuery 와 Intersection Observer API 의 조합으로 무한 스크롤 구현이 완료 되었습니다!
Kream 사이트의 Style 부분 레이아웃을 보면 일반적인 정렬 방법이 아닌 mansory 레이아웃 형태로 정렬 된 것을 볼 수 있습니다.
저는 처음에 해당 사이트를 봤을 때 mansory 레이아웃 이라는 용어를 몰라서 처음부터 끝까지 Kream 사이트 측에서 정렬을 구현했다고 생각했습니다. 알고보니 "react-masonry-css"/ "react-masonry-component" 같은 라이브러리가 따로 존재하더라구요.
제가 사용한 라이브러리는 react-masonry-css 입니다.
import Masonry from "react-masonry-css";
const StylePostWrap = ({ children }: { children: ReactNode }) => {
return (
<>
<Masonry
style={{ display: "flex" }}
breakpointCols={{
default: 4,
1100: 3,
700: 2,
500: 1,
}}
className="list"
columnClassName="column"
>
{children}
</Masonry>
</>
);
};
export default StylePostWrap;
먼저, StylePostWrap 이라는 parent 틀을 생성해 children 을 받아 어디서든 사용 할 수 있게 만들어 주었습니다.
const StyleList = ({ styledatas }: { styledatas: PostDataType[] }) => {
return (
<StylePostWrap>
{styledatas?.map((styledatas: PostDataType) => (
<StylePost
styledatas={styledatas}
key={`style-data-${styledatas.postId}`}
/>
))}
</StylePostWrap>
);
};
<StylePostWrap>
{isSuccess && <StyleList styledatas={data.pages} />}
</StylePostWrap>
{data &&
data.pages.map((data) =>
<StyleList styledatas={data.data.result} />)
}
첫 번째 예시처럼 반복문 밖에서 parent 를 적용해도 원하는대로 style 이 적용되지 않았습니다.
또한 두 번째 예시처럼 map 을 두번 돌려도 style 이 제대로 인식이 안되는 이슈가 존재했습니다. 처음 제가 작성했던 로직은 data.pages 로 받아온 배열들을 map으로 돌린 후 그 안의 result 데이터를 또 한 번 반복하는 로직이었는데 제가 select 로 데이터를 미리 전처리 했던 이유도 여기서 비롯되었습니다. 🥺
아마 라이브러리 내부 기능 구현 특성상 Style 을 적용할 컴포넌트 리스트 로직 바로 위에 Masonry 를 적용해야 제대로 Style 이 적용 되는 것이라고 추측했습니다. 구글에 정보가 많이 없어서 직접 디버깅을 한 후에 스스로 결론을 내린 부분이라 혹시나 더 아는 정보가 있다면 댓글 달아주시면 감사하겠습니다 !
이렇게 메인 페이지의 큰 기능들을 마무리 했습니다.