[Next.js] React Query, Prisma와 함께하는 무한 스크롤 (Infinite Scroll)

문성운·2022년 11월 26일
1
post-thumbnail

React Query (useInfiniteQuery)와 Prisma (Cursor-based pagination)로 구현해보는 무한 스크롤

React QueryuseInfiniteQueryPrismaCursor-based pagination을 사용하여 무한 스크롤에 필요한 모든 것을 구현해 보겠습니다! 이번 구현에 사용한 데이터는 아래 첨부한 이미지와 같으며, 전체 코드는 이 곳에서 확인하실 수 있습니다!

효율적인 정보 전달을 위해 React QueryPrisma의 기본적인 사용법은 모두 숙지 되었다는 가정하에, 무한 스크롤 구현에 필요한 기능들만 간단하게 다루겠습니다.

출발~ 😙😙😙

Pagenation

당연히~ 가장 먼저 서버를 구현해야 합니다. 전체 데이터 중 원하는 개수씩 순차적으로 끊어서 보내줄 수 있도록 prisma query를 작성해 보겠습니다!

Pagenation을 어떻게 구현해야 할까?

처음에는 단순하게 개수를 기준으로 전체 데이터를 끊으려고 했습니다. 예를 들어, 다음과 같이 (유저1, 유저2, 유저3, 유저~~, 유저10) 총 10개의 데이터가 있다고 가정해 보겠습니다. 한 번에 5개씩 데이터를 보여주고 싶다면, 처음에는 1~5번 데이터를 불러오고 그 다음에는 6~10번 데이터를 불러오면 될 것입니다. 이 방법은 아래와 같은 코드로 구현할 수 있습니다.

const userList = await prisma.user.findMany({
  skip: 5 * page,
  take: 5,
})

15개의 데이터를 건너뛰고 16번째 데이터부터 20번째 데이터까지 불러오고 싶으면 skip에 15를, take에 5를 넣어주면 됩니다. 간단하죠? Prisma의 공식문서에서는 해당 기능을 아래와 같은 이미지로 설명하고 있습니다.

간단하게 구현할 수 있는 방법이지만, 약간의 문제가 있습니다. 만약, 최근에 가입된 유저 순으로 정렬하여 데이터를 불러오다가 새로운 유저가 회원가입하면 어떻게 될까요? 아래 메모장의 설명처럼 유저5가 두 번 불러와 질 것입니다.

따라서 개수를 기준으로 정하는 것이 아닌, 현재 페이지의 마지막 데이터를 기준으로 다음 페이지의 데이터를 불러와야 합니다. 위의 예시에서는 유저5를 기준으로 다음 페이지의 데이터를 불러와야겠죠? PrismaCursor-based-pagination으로 이러한 방법을 간단하게 구현할 수 있습니다.

const userList = await prisma.user.findMany({
  skip: 1,
  take: 5,
  cursor: {
    id: targetId
  }
})

위 코드의 예시처럼 cursor 객체에 조건을 설정하여 해당 조건에 맞는 데이터를 기준으로 정할 수 있습니다. 도입부에 첨부된 User Table 이미지 기억나시나요? User Table은 id, nickname, password의 필드로 구성되어 있습니다. 이 중 고유한 값인 id 필드가 지정한 값(targetId)인 데이터가 기준이 됩니다. nickname 필드 또한 고유한 값이라면, id 대신 nickname을 사용할 수도 있습니다.

근데 skip은 왜 또 필요할까요? 마찬가지로 Prisma의 공식문서를 확인해 보겠습니다.

바로 cursor를 포함하여 take개를 가져오기 때문입니다. 저희는 현재 페이지의 마지막 데이터를 cursor로 정하기로 했으니, skip: 1이 필요한 것입니다.

이제 정말 구현해볼까요?!

/* /pages/api/userList.ts */

const TAKE_COUNT = 5;

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<IUserResponse>
) {
  const { id } = req.query;
  const isFirstPage = !id;

  const pageCondition = {
    skip: 1,
    cursor: {
      id: id as string,
    },
  };

  const userList = await client.user.findMany({
    /*
    where: { },
    orderBy: { }
    */
    take: TAKE_COUNT,
    ...(!isFirstPage && pageCondition),
  });

  const length = userList.length;
  res.status(200).json({ userList: 0 < length ? userList : undefined });
}

현재 페이지 마지막 데이터의 idquery parameter로 받아옵니다. 만약, 첫 페이지의 데이터를 불러오는 경우라면 당연히 id가 존재하지 않겠죠? 따라서 isFirstPagetrue일 경우 skipcursor를 설정하지 않습니다.

페이지를 쭉쭉 넘겨 cursor가 전체 데이터의 마지막 데이터가 됐을 경우, 더 이상 불러올 데이터가 없기 때문에 findMany 함수는 빈 배열을 return합니다. 그렇기 때문에 userListlength를 확인하여 0 < length일 때만 userListreturn하고 그렇지 않을 경우 undefinedreturn합니다.

살짝 싱숭생숭 하신가요?

현재 페이지 마지막 데이터의 id를 어떻게 넘겨주는지? 왜 굳이 undefinedreturn하는지? 이런 부분들은 다음 차례인 React QueryuseInfiniteQuery 구현 방법을 확인해 보시면 명확해지실 겁니다!

useInfiniteQuery

const {
  data,
  hasNextPage,
  fetchNextPage,
} = useInfiniteQuery({
  queryKey,
  queryFn: ({ pageParam = "" }) => fetchPage(pageParam),
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
})

위의 예시 코드가 구현에 필요한 전부입니다! useInfiniteQuery는 굉장히 많은 기능들을 제공하지만, 무한 스크롤 구현을 위한 최소한의 기능들만 하나씩 살펴보겠습니다.

  1. getNextPageParam : pageParamhasNextPage의 값을 결정하는 함수이며, 매개변수로 lastPageallPages를 넘겨 받을 수 있습니다. lastPage공식문서에서 '마지막 페이지'라는 뜻으로 사용되는데요, 저희는 지금까지 '현재 페이지'라는 단어를 사용했기 때문에 편의상 '현재 페이지'라고 부르겠습니다! getNextPageParam단일 변수 혹은 undefinedreturn 해야 합니다. 이렇게 return된 값은 queryFnpageParamhasNextPage의 값으로 사용됩니다.

  2. data : 서버로부터 응답받은 결과이며, 우리가 원하는 TData[]data.pages에 담겨 있습니다.

  3. hasNextPage : 다음에 더 불러올 페이지가 있는지 확인할 수 있는 값입니다. 이 값은 getNextPageParam에 의해 결정됩니다. getNextPageParam단일 변수return할 경우 true, undefinedreturn할 경우 false가 됩니다.

  4. queryFn: ({ pageParam = "" }) : getNextPageParamreturn한 값이 pageParam의 값으로 사용됩니다. 최초 요청 시 정의한 기본 값이 사용됩니다. (예시에서는 "")

  5. fetchNextPage : 다음 페이지를 요청할 때 호출하는 함수입니다.

  6. 전체적인 흐름은 다음과 같습니다.

    1. hasNextPage = !!getNextPageParam()
    2. hasNextPagetrue이면 fetchNextPage()
    3. pageParam = getNextPageParam()
    4. fetchPage(pageParam)
    5. render(data.pages)

구현된 코드를 살펴보겠습니다!

/* /queries/user.ts */

export function useFetchUserList() {
  const queryResult = useInfiniteQuery<IUserResponse>(
    ["user"],
    ({ pageParam = "" }) => get(`?id=${pageParam}`),
    {
      getNextPageParam: ({ userList }) =>
        userList ? userList[userList.length - 1].id : undefined,
    }
  );

  return queryResult;
}

const service = axios.create({
  baseURL: "/api/userList",
});
function get(queryString: string) {
  return service.get(queryString).then((response) => response.data);
}

기능 단위로 쪼개고 서버 코드와 함께 살펴보겠습니다.

/* /queries/user.ts */

({ pageParam = "" }) => get(`?id=${pageParam}`)

/* /pages/api/userList.ts */

const { id } = req.query;
const isFirstPage = !id;

pageParamquery parameter를 통해 서버로 전송합니다. 최초 요청 시 기본 값이 ""이기 때문에 isFirstPagetrue가 됩니다.

/* /queries/user.ts */

getNextPageParam: ({ userList }) =>
  userList ? userList[userList.length - 1].id : undefined;

/* /pages/api/userList.ts */

const pageCondition = {
  skip: 1,
  cursor: {
    id: id as string,
  },
};

getNextPageParam에서 현재 페이지의 userList가 존재한다면? userList의 마지막 데이터의 idreturn하여 queryFnpageParam으로 사용합니다. 이렇게 서버로 전달된 idcursor로 사용되는 것입니다.

현재 페이지의 userList가 존재하지 않는 경우는 어떤 경우일까요?

/* /pages/api/userList.ts */

const userList = await client.user.findMany({
  /*
    where: { },
    orderBy: { }
    */
  take: TAKE_COUNT,
  ...(!isFirstPage && pageCondition),
});

const length = userList.length;
res.status(200).json({ userList: 0 < length ? userList : undefined });

서버 코드를 설명할 때 더 이상 불러올 데이터가 없을 경우 undefined를 응답한다고 했습니다. 현재 페이지의 userList가 존재하지 않는 경우는 더 이상 불러올 데이터가 없을 때이므로 getNextPageParamundefinedreturn하여 hasNextPagefalse가 되는 것입니다.

이제 데이터를 불러오기만 하면 됩니다!

/* /pages/index.tsx */

export default function Home() {
  const { data, hasNextPage, fetchNextPage } = useFetchUserList();

  const handleClick = () => {
    if (hasNextPage) {
      fetchNextPage();
    }
  };

  const renderUserList = () => {
    if (data && data.pages) {
      const userList = data.pages.reduce<IUser[]>((prev, { userList }) => {
        if (userList) prev.push(...userList);
        return prev;
      }, []);

      return userList.map(({ id, nickname }) => (
        <User key={id} id={id} nickname={nickname} />
      ));
    }
  };

  return (
    <Wrapper>
      {renderUserList()}
      {hasNextPage && <Button onClick={handleClick}>Next Page</Button>}
    </Wrapper>
  );
}

data.pages[[], [], [], []]와 같은 형식으로 되어있습니다. 이를 1차원 배열로 풀기 위해 renderUserList 함수에서 reduce를 사용했습니다. 우선 데이터를 page 단위로 잘 불러오나 확인하기 위해 버튼을 두었고 이를 누를 때마다 다음 페이지 데이터를 불러오도록 하였습니다.

잘 되나 볼까요?

Intersection Observer API

이제 마지막입니다! 버튼을 눌렀을 때가 아닌 스크롤을 바닥까지 내렸을 때 다음 페이지를 불러와야 합니다. 이러한 기능을 구현하기 위해 바닥에 div 태그를 두고 이 태그가 뷰포트 내에 감지됐을 때 fetchNextPage 함수를 호출할 것입니다.

Intersection Observer API를 사용하면 특정 요소와 상위 요소의 뷰포트가 교차하는 것을 감지할 수 있습니다. 바로 구현된 코드를 확인해 보겠습니다.

/* /components/Observer.tsx */

export default function Observer({ handleIntersection }: IProps) {
  const target = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]: IntersectionObserverEntry[]) => {
        if (entry.isIntersecting) {
          handleIntersection();
        }
      },
      { threshold: 1 }
    );

    if (target.current) {
      observer.observe(target.current);
    }

    return () => observer.disconnect();
  }, []);

  return <Wrapper ref={target}>이게 보이면? 다음 데이터를!</Wrapper>;
}

IntersectionObserver 객체를 생성할 때 매개변수로, 감지됐을 때 호출될 콜백 함수를 넣어줄 수 있습니다. 이 콜백 함수는 observer.observe(target.current)로 처음 감지를 시작했을 때도 호출 됩니다. 따라서 원하는 요소가 감지됐을 때만 fetchNextPage를 호출하기 위해 entry.isIntersecting로 감지 여부를 확인하는 과정이 필요합니다.

마지막으로, 이전에 추가해놓은 버튼을 제거하고 Observer 컴포넌트를 적용해 보겠습니다.

/* /pages/index.tsx */

export default function Home() {
  const { data, hasNextPage, fetchNextPage } = useFetchUserList();

  const handleIntersection = () => {
    if (hasNextPage) {
      fetchNextPage();
    }
  };

  const renderUserList = () => {
    if (data && data.pages) {
      const userList = data.pages.reduce<IUser[]>((prev, { userList }) => {
        if (userList) prev.push(...userList);
        return prev;
      }, []);

      return userList.map(({ id, nickname }) => (
        <User key={id} id={id} nickname={nickname} />
      ));
    }
  };

  return (
    <Wrapper>
      {renderUserList()}
      {hasNextPage && <Observer handleIntersection={handleIntersection} />}
    </Wrapper>
  );
}

완성 ~!

Observer 컴포넌트의 높이를 작게 하고 배경색과 동일하게 하면 아주 자연스러운 동작이 가능합니다!

마무리

최대한 간략하게 정리해보고 싶었는데 이상하게 글이 길어졌네요. 잘못된 내용은 지적해주시면 정말 감사하겠습니다.

안녕~ 😙

profile
베르나르 성운성운

0개의 댓글