blegram (5) - 무한 스크롤링 ( react-query )
포스트
취소

blegram (5) - 무한 스크롤링 ( react-query )

해당 프로젝트는 Next.js + TypeScript를 기반으로 만드는 인스타그램 클론 개인 프로젝트입니다.

react-queryInterectionObserver API를 이용한 게시글 무한 스크롤링에 대한 포스트입니다.

타입과 코드들을 각각 모두 파일을 분리해서 작성했기 때문에 일부분만 가져온 간소화된 코드입니다.

📃 송/수신 데이터 타입

무한 스크롤링을 적용하는 게시글의 송/수신 데이터 타입입니다.

  • take: 가져올 게시글 개수
  • lastIdx: 가져온 마지막 게시글의 식별자 ( 기본 값: -1 )

  • /src/types/api/posts.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type Post = {
  idx: number
  contents: string
  photos: string
  createdAt: Date
  updatedAt: Date
  userIdx: number
}

interface PostWithData extends Post {
  user: SimpleUser;
  comments: Comment[];
  postLiker: { postLiker: SimpleUser }[];
  _count: {
    comments: number;
    postLiker: number;
  };
}

type ApiResponse<T = unknown> = { message: string } & T;

/** 2023/04/08 - 게시글들 가져오기 요청 송신 타입 - by 1-blue */
export interface ApiFetchPostsRequest {
  take: number;
  lastIdx: number;
}
/** 2023/04/08 - 게시글들 가져오기 요청 수신 타입 - by 1-blue */
export interface ApiFetchPostsResponse extends ApiResponse {
  posts?: PostWithData[];
}
/** 2023/04/08 - 게시글들 가져오기 요청 핸들러 - by 1-blue */
export interface ApiFetchPostsHandler {
  (body: ApiFetchPostsRequest): Promise<ApiFetchPostsResponse>;
}

📤 FE

0️⃣ useInfiniteQuery

TODO: react-query에 대한 게시글 작성 후 링크 추가

react-queryuseInfiniteQuery()를 이용해서 무한 스크롤링 구현했습니다.
( useInfiniteQuery()의 구체적인 사용법에 대한 내용은 생략하겠습니다. )

  • take: 한 번에 받아올 게시글의 개수
  • lastIdx: 가장 최근에 가져온 마지막 게시글의 식별자 ( 기본 값: -1 )

  • /src/hooks/query/usePosts.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { useInfiniteQuery } from "react-query";

// api
import { apiServicePosts } from "@src/apis";

// key
import { queryKeys } from ".";

// type
import type { ApiFetchPostsResponse } from "@src/types/api";
interface Props {
  take: number;
  targetIdx?: number;
}

/** 2023/04/08 - 게시글들을 얻는 훅 - by 1-blue ( 2023/04/10 ) */
const usePosts = ({ take, targetIdx = -1 }: Props) => {
  const { data, fetchNextPage, hasNextPage } =
    useInfiniteQuery<ApiFetchPostsResponse>(
      queryKeys.post,
      ({ pageParam = targetIdx }) => apiServicePosts.apiFetchPosts({ take, lastIdx: pageParam }),
      {
        getNextPageParam: (lastPage, allPage) =>
          lastPage.posts?.length === take ? lastPage.posts[lastPage.posts.length - 1].idx : null,
      }
    );

  return { data, fetchNextPage, hasNextPage };
};

export default usePosts;

1️⃣ 렌더링

기본적으로 -1을 값으로 줘서 가장 최신 데이터부터 가져옵니다.
하지만 query string이 있다면 해당 게시글부터 불러오도록 만들었습니다.
( 원래는 양방향 스크롤을 구현하려고 했지만, 게시글을 특정 식별자 뒤에서부터 불러오는 방법을 못 찾아서 일단 넘어가고 이후에 여유가 있을 때 수정하겠습니다.. 🥲 )

data.pages에 배열형태로 게시글들의 페이지 데이터가 들어가있습니다.
그리고 page.posts에 배열형태로 게시글들의 데이터가 들어있기 때문에 두 번의 Array.prototype.map()을 이용해서 모든 게시글을 렌더링하도록 했습니다.

<InfiniteScrollContainer>에 대해서는 바로 다음에 자세하게 설명하겠습니다.
간단하게 설명하자면 페이지의 최하단에 도착하면 새로운 데이터를 패칭하는 HOC입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { useSearchParams } from "next/navigation";

// util
import { splitPhotoURL } from "@src/utils";

// hook
import usePosts from "@src/hooks/query/usePosts";

// component
import InfiniteScrollContainer from "@src/components/common/InfiniteScrollContainer";
import PostHeader from "@src/components/Post/PostHeader";
import PostPhotos from "@src/components/Post/PostPhotos";
import PostFooter from "@src/components/Post/PostFooter";

// style
import StyledPost from "./style";

/** 2023/04/09 - 게시글 컴포넌트 - by 1-blue */
const Post = () => {
  /** FIXME: 양방향 스크롤링으로 변경하기 */
  const searchParams = useSearchParams();
  const postIdx = searchParams?.get("postIdx");

  /** 2023/04/10 - 무한 스크롤링을 적용한 게시글들의 데이터 - by 1-blue */
  const { data, hasNextPage, fetchNextPage } = usePosts({
    take: 10,
    lastIdx: postIdx ? +postIdx : undefined,
  });

  return (
    <InfiniteScrollContainer hasMore={hasNextPage} fetchMore={fetchNextPage}>
      <StyledPost>
        {data?.pages.map((page) =>
          page.posts?.map((post) => (
            <li key={post.idx}>
              <PostHeader user={post.user} postIdx={post.idx} />
              <PostPhotos photos={splitPhotoURL(post.photos)} />
              <PostFooter contents={post.contents} />
            </li>
          ))
        )}
      </StyledPost>
    </InfiniteScrollContainer>
  );
};

export default Post;

2️⃣ IntersectionObserver API를 활용한 HOC

IntersectionObserver API에 대한 자세한 내용은 해당 링크를 참고해주세요!

IntersectionObserver API를 이용해서 요소를 감시하고 해당 요소가 뷰포트에 들어오면 props로 받은 게시글들 패치 함수(fetchMore())를 실행하는 구조로 동작합니다.
하지만 언젠가는 데이터가 없을 것이기 때문에 hasMore을 이용해서 더 패치할 수 있는지 유효성 검사를 합니다.

따라서 해당 HOC를 감싸주기만 하면 최하단으로 스크롤을 내렸을 시점에 다시 게시글들을 패치하게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import { useCallback, useEffect, useRef } from "react";

// type
import type { PropsWithChildren } from "react";
interface Props {
  hasMore?: boolean;
  fetchMore: () => void;
}

/** 2023/04/11 - "IntersectionObserver"의 옵션들 - by 1-blue */
const options: IntersectionObserverInit = {
  threshold: 0.1,
};

/** 2023/04/11 - 무한 스크롤링 적용 ( using "IntersectionObserver" ) - by 1-blue */
const InfiniteScrollContainer: React.FC<PropsWithChildren<Props>> = ({
  hasMore,
  fetchMore,
  children,
}) => {
  /** 2023/04/11 - 감시할 요소의 ref - by 1-blue */
  const observerRef = useRef<HTMLDivElement>(null);

  /** 2023/04/11 - 상단으로 뷰포트 내에 감시하는 태그가 들어왔다면 패치 - by 1-blue */
  const onScroll: IntersectionObserverCallback = useCallback(
    (entries) => {
      if (!entries[0].isIntersecting) return;

      fetchMore();
    },
    [fetchMore]
  );
  /** 2023/04/11 - 상단 observer 등록 ( 해당 태그가 뷰포트에 들어오면 게시글 추가 패치 실행 ) - by 1-blue */
  useEffect(() => {
    if (!observerRef.current) return;

    // 콜백함수와 옵션값 지정
    let observer = new IntersectionObserver(onScroll, options);

    // 특정 요소 감시 시작
    observer.observe(observerRef.current);

    // 더 가져올 게시글이 존재하지 않는다면 패치 중지
    if (!hasMore) observer.unobserve(observerRef.current);

    // 감시 종료
    return () => observer.disconnect();
  }, [observerRef, onScroll, hasMore]);

  return (
    <>
      {children}
      <div ref={observerRef} />
    </>
  );
};

export default InfiniteScrollContainer;

📥 BE

0️⃣ 게시글들 가져오기 엔드포인트

자세한 부분은 prismapagination를 참고해주세요!

prismatake, skip, cursor를 이용해서 게시글들을 가져옵니다.
( cursor를 이용해서 특정 식별자를 기반으로 데이터를 불러올 수 있습니다. )

나머지 include 부분은 프론트에서 필요한 데이터를 내려줘야하기 때문에 데이터를 추가해주는 부분이라 해당 로직의 흐름에는 영향을 끼치지 않습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// prisma
import { prisma } from "@src/prisma";

// lib
import withAuthMiddleware from "@src/lib/middleware";

// type
import type { NextApiHandler } from "next";
import type { ApiFetchPostsResponse } from "@src/types/api";

/** 2023/04/08 - 게시글들 관련 엔드포인트 - by 1-blue */
const handler: NextApiHandler<ApiFetchPostsResponse> = async (req, res) => {
  try {
    // 모든 게시글들 가져오기 요청
    if (req.method === "GET") {
      const take = +(req.query.take as string);
      const lastIdx = +(req.query.lastIdx as string);

      const posts = await prisma.post.findMany({
        where: {},
        take,
        skip: lastIdx === -1 ? 0 : 1,
        ...(lastIdx !== -1 && { cursor: { idx: lastIdx } }),
        orderBy: { createdAt: "desc" },
        include: {
          user: {
            select: {
              idx: true,
              avatar: true,
              nickname: true,
            },
          },
          comments: {},
          postLiker: {
            select: {
              postLiker: {
                select: {
                  idx: true,
                  avatar: true,
                  nickname: true,
                },
              },
            },
          },
          _count: {
            select: {
              comments: true,
              postLiker: true,
            },
          },
        },
      });

      return res.status(200).json({
        message: `최신 게시글 ${posts.length}개를 가져왔습니다.`,
        posts,
      });
    }
  } catch (error) {
    console.error("/api/user error >> ", error);

    return res.status(500).json({
      message: "서버측 문제입니다.\n잠시후에 다시 시도해주세요!",
    });
  }
};

export default withAuthMiddleware({
  methods: ["GET"],
  handler,
  isAuth: false,
});

😢 실패한 이야기

react.queryuseInfiniteQuery()prisma를 이용해서 양방향 스크롤을 구현하려고 했습니다.
하지만 useInfiniteQuery()을 이용한 양방향을 구현할 때 위쪽으로 패치인지 아래쪽으로 패치인지 파악하기 힘들었고, 파악했다고 하더라도 prisma-take로는 어떻게 구현해야할지 몰라서 계속 시도하다가 포기했습니다.

만약 데이터가 1 ~ 10까지 있고 5를 cursor로 시작한다면 take: -3을 가져오도록 한다면 2, 3, 4를 가져올거라고 생각했지만, 8, 7, 6으로 역순으로 가져오게 되어서 뭐가 문제인지 어떻게 사용하는 것이 맞는지 혼동이 와서 핵심적인 기능이 아니기 때문에 일단은 넘어갔습니다.
주요 기능들을 먼저 구현하고 나서 추가적으로 구현하도록 하겠습니다.

📮 레퍼런스

  1. prisma - pagination

  2. 1-blue - IntersectionObserver API

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.