해당 프로젝트는
Next.js
+TypeScript
를 기반으로 만드는 인스타그램 클론 개인 프로젝트입니다.
react-query
와InterectionObserver 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-query
의 useInfiniteQuery()
를 이용해서 무한 스크롤링 구현했습니다.
( 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️⃣ 게시글들 가져오기 엔드포인트
자세한 부분은
prisma
의pagination
를 참고해주세요!
prisma
의 take
, 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.query
의 useInfiniteQuery()
와 prisma
를 이용해서 양방향 스크롤을 구현하려고 했습니다.
하지만 useInfiniteQuery()
을 이용한 양방향을 구현할 때 위쪽으로 패치인지 아래쪽으로 패치인지 파악하기 힘들었고, 파악했다고 하더라도 prisma
의 -take
로는 어떻게 구현해야할지 몰라서 계속 시도하다가 포기했습니다.
만약 데이터가 1 ~ 10까지 있고 5를 cursor
로 시작한다면 take: -3
을 가져오도록 한다면 2, 3, 4
를 가져올거라고 생각했지만, 8, 7, 6
으로 역순으로 가져오게 되어서 뭐가 문제인지 어떻게 사용하는 것이 맞는지 혼동이 와서 핵심적인 기능이 아니기 때문에 일단은 넘어갔습니다.
주요 기능들을 먼저 구현하고 나서 추가적으로 구현하도록 하겠습니다.