kream 메인페이지를 작업해보자

·2023년 10월 3일
12

프로젝트

목록 보기
2/3
post-thumbnail

⚒️ 사용기술

React-Query / useInfiniteQuery / react-masonry-css

👟Kream Style Main 사이트의 특징

구현 하기에 앞서 Kream Style 사이트의 특징을 크게 두가지로 잡아보았습니다.

  1. 무한스크롤
  2. mansory 레이아웃

🧮 무한스크롤

무한스크롤을 위한 백엔드 API 제작

Next.js 로 페이지네이션을 구현해 무한 스크롤을 할 수 있도록 간단한 API를 제작했습니다.

클라이언트에서

    const res = await axios.get(`/api/posts?page=${page}&limit=${limit}`);

page와 limit을 query 로 넣어주면 원하는 숫자만큼 데이터 갯수가 호출 됩니다.

무한스크롤 기능 구현

처음 바닐라로 Kream 프로젝트를 구현을 했을 때 scroll 이벤트를 사용해 dom의 offset 값을 전부 계산해 내가 현재 보고있는 뷰포트에서 끝에 닿았을때 다음 페이지를 불러오는 형식으로 구현을 했습니다. 하지만 제가 왜 이 방식을 선택하지 않고 다른 방식을 선택해 구현을 하기로 결정했을까요 ?

Scroll Event

scroll 값을 계산하는 것과 같은 기하학적인 속성 값을 읽는 로직은 가장 최신 값을 가져와야 하기 때문에 리플로우 계산을 위해 메인 스레드가 블락 되므로 성능에 치명적인 영향을 줄 수 있습니다.

Intersection Observer API

이러한 문제를 해결하기 위해 제가 추가적으로 사용한 Intersection Observer API비동기적으로 실행되기 때문에 메인 스레드에 영향을 주지 않으면서 변경 사항을 관찰할 수 있습니다.

useInfiniteQuery

useInfiniteQuery는 페이징을 구현할 때도 굳이 state를 사용하지 않고 손쉽게 구현할 수 있도록 도와줍니다. 저는 페이지 데이터의 상태 관리로 인한 잦은 렌더링 방지와 데이터 캐싱에 대한 이점을 위해 React-Query 에서 제공하는 useInfiniteQuery 라이브러리를 사용하기로 결정했습니다.

🔧 라이브러리가 모든걸 다 해주나요 ?

"아닙니다." 라이브러리는 기능 구현을 조금이나마 더 편리하게 해줄 수 있는 도구일 뿐이지 모든 것을 다 해주지 않습니다. 때로는 내 프로젝트, 내 코드에 적용하는게 더 어려울 때도 있습니다.

✔️ 구현

1. 데이터 패칭 hooks

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 레이아웃 파트에서 이 부분을 이어서 언급하도록 하겠습니다.

2. intesrsection hooks

ref 정의

const observerElem = useRef(null);

handleObserver

  const handleObserver = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      const [target] = entries;
      if (target.isIntersecting) {
        callback(); //fetchNext 함수 
      }
    },
    [callback]
  );

IntersectionObserver 생성자를 만들어 인자에 넣어줄 함수를 정의했습니다. 타겟 관찰자가 뷰포트에 들어왔을때 원하는 callback 함수를 실행시킵니다. 저는 fetchNext 를 넘겨 실행시켜 주었습니다.

✨ 전체적인 과정은

스크롤 끝에 닿는다. => 관찰자가 뷰포트에 들어왔다. => 새롭게 데이터 패칭하는 함수를 실행시킨다.

입니다.

useEffect

  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 폴더에 파일로 빼주었습니다.

🎨 StyleMain 시각화

  
    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 의 조합으로 무한 스크롤 구현이 완료 되었습니다!

🧮 mansory 레이아웃

Kream 사이트의 Style 부분 레이아웃을 보면 일반적인 정렬 방법이 아닌 mansory 레이아웃 형태로 정렬 된 것을 볼 수 있습니다.

저는 처음에 해당 사이트를 봤을 때 mansory 레이아웃 이라는 용어를 몰라서 처음부터 끝까지 Kream 사이트 측에서 정렬을 구현했다고 생각했습니다. 알고보니 "react-masonry-css"/ "react-masonry-component" 같은 라이브러리가 따로 존재하더라구요.

제가 사용한 라이브러리는 react-masonry-css 입니다.

StylePostWrap

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>
  );
};

Issue

  <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 이 적용 되는 것이라고 추측했습니다. 구글에 정보가 많이 없어서 직접 디버깅을 한 후에 스스로 결론을 내린 부분이라 혹시나 더 아는 정보가 있다면 댓글 달아주시면 감사하겠습니다 !

이렇게 메인 페이지의 큰 기능들을 마무리 했습니다.

레퍼런스

무한스크롤
https://junvelee.tistory.com/132

https://jforj.tistory.com/246

https://velog.io/@elrion018/%EC%8B%A4%EB%AC%B4%EC%97%90%EC%84%9C-%EB%8A%90%EB%82%80-%EC%A0%90%EC%9D%84-%EA%B3%81%EB%93%A4%EC%9D%B8-Intersection-Observer-API-%EC%A0%95%EB%A6%AC

레이아웃
https://wit.nts-corp.com/2022/10/26/6595

profile
My Island

0개의 댓글